diff --git a/options/controllers/token-controller.js b/options/controllers/token-controller.js index f604a4a..3d3d6bf 100644 --- a/options/controllers/token-controller.js +++ b/options/controllers/token-controller.js @@ -6,7 +6,7 @@ const notifications = NotificationManager.getInstance(); export async function clearToken() { if (!confirm('Are you sure you want to clear your GitHub token?')) { - return; + return false; } document.getElementById('githubToken').value = ''; @@ -25,10 +25,12 @@ export async function clearToken() { await clearStoredToken(); notifications.info('GitHub token cleared successfully'); + return true; } -export async function validateToken(token, toastManager) { +export async function validateToken(token, toastManager, options = {}) { const statusEl = document.getElementById('tokenStatus'); + const shouldApplyResult = options.shouldApplyResult ?? (() => true); try { const response = await fetch('https://api.github.com/user', { @@ -37,6 +39,10 @@ export async function validateToken(token, toastManager) { if (response.ok) { const user = await response.json(); + if (!shouldApplyResult()) { + return { isValid: true, user: user.login }; + } + statusEl.textContent = `✓ Valid (${user.login})`; statusEl.className = 'token-status valid'; document.getElementById('clearTokenBtn').style.display = 'block'; @@ -59,7 +65,12 @@ export async function validateToken(token, toastManager) { } toastManager.isManualTokenEntry = false; + return { isValid: true, user: user.login }; } else if (response.status === 401) { + if (!shouldApplyResult()) { + return { isValid: false, reason: 'invalid' }; + } + statusEl.textContent = '✗ Invalid token'; statusEl.className = 'token-status invalid'; document.getElementById('clearTokenBtn').style.display = 'none'; @@ -78,7 +89,12 @@ export async function validateToken(token, toastManager) { notifications.error('Invalid GitHub token. Please check your token and try again.'); toastManager.lastInvalidToken = token; } + return { isValid: false, reason: 'invalid' }; } else { + if (!shouldApplyResult()) { + return { isValid: false, reason: 'http', status: response.status }; + } + statusEl.textContent = `✗ Error (${response.status})`; statusEl.className = 'token-status invalid'; document.getElementById('clearTokenBtn').style.display = 'none'; @@ -97,8 +113,13 @@ export async function validateToken(token, toastManager) { notifications.error(`GitHub API error (${response.status}). Please try again later.`); toastManager.lastApiError = response.status; } + return { isValid: false, reason: 'http', status: response.status }; } } catch (_error) { + if (!shouldApplyResult()) { + return { isValid: false, reason: 'network' }; + } + statusEl.textContent = '✗ Network error'; statusEl.className = 'token-status invalid'; document.getElementById('clearTokenBtn').style.display = 'none'; @@ -112,5 +133,6 @@ export async function validateToken(token, toastManager) { document.getElementById('importReposSection').style.display = 'none'; notifications.error('Network error while validating token. Please check your connection and try again.'); + return { isValid: false, reason: 'network' }; } } diff --git a/options/options.html b/options/options.html index 5f42b28..82de4fd 100644 --- a/options/options.html +++ b/options/options.html @@ -79,7 +79,7 @@

Create a GitHub Token

- → Create a token with recommended permissions or + → Create a token with recommended permissions or manually create one with repo and read:user scopes.

diff --git a/options/options.js b/options/options.js index 89e7112..deece2b 100644 --- a/options/options.js +++ b/options/options.js @@ -1,5 +1,5 @@ import { applyTheme, formatDateVerbose } from '../shared/utils.js'; -import { getToken, setToken, getLocalItems, setLocalItem } from '../shared/storage-helpers.js'; +import { getToken, setToken, clearToken as clearStoredToken, getLocalItems, setLocalItem } from '../shared/storage-helpers.js'; import { createHeaders } from '../shared/github-api.js'; import { STORAGE_CONFIG, VALIDATION_PATTERNS } from '../shared/config.js'; import { validateRepository } from '../shared/repository-validator.js'; @@ -29,6 +29,7 @@ const state = { searchQuery: '', hidePinnedRepos: false }; +let persistedToken = null; if (typeof document !== 'undefined') { document.addEventListener('DOMContentLoaded', async () => { @@ -121,12 +122,41 @@ function handleUrlParameters() { } +function syncTokenUiWithStoredCredential(hasStoredToken) { + const clearTokenBtn = document.getElementById('clearTokenBtn'); + const repoInput = document.getElementById('repoInput'); + const addRepoBtn = document.getElementById('addRepoBtn'); + const repoHelpText = document.getElementById('repoHelpText'); + const importSection = document.getElementById('importReposSection'); + + clearTokenBtn.style.display = hasStoredToken ? 'block' : 'none'; + repoInput.disabled = !hasStoredToken; + repoInput.placeholder = hasStoredToken + ? 'e.g., react, facebook/react, or GitHub URL' + : 'Enter a valid GitHub token to add repositories'; + addRepoBtn.disabled = !hasStoredToken; + repoHelpText.textContent = hasStoredToken + ? 'Add repositories to monitor (npm package, owner/repo, or GitHub URL)' + : 'Add a valid GitHub token above to start adding repositories'; + importSection.classList.toggle('hidden', !hasStoredToken); + importSection.style.display = hasStoredToken ? 'block' : 'none'; +} + +function shouldClearStoredToken(validationResult) { + return !validationResult.isValid && validationResult.reason === 'invalid'; +} + function setupEventListeners() { // Tab navigation setupTabNavigation(); document.getElementById('addRepoBtn').addEventListener('click', addRepo); - document.getElementById('clearTokenBtn').addEventListener('click', clearToken); + document.getElementById('clearTokenBtn').addEventListener('click', async () => { + const tokenCleared = await clearToken(); + if (tokenCleared) { + persistedToken = null; + } + }); // Action button toggles const hidePinnedToggleBtn = document.getElementById('hidePinnedToggleBtn2'); @@ -200,16 +230,19 @@ function setupEventListeners() { } }); - // Validate and auto-save token on input + // Validate and persist token only after the current input has been confirmed valid. let tokenValidationTimeout; - document.getElementById('githubToken').addEventListener('input', (e) => { + let tokenValidationRequestId = 0; + document.getElementById('githubToken').addEventListener('input', async (e) => { clearTimeout(tokenValidationTimeout); const token = e.target.value.trim(); + tokenValidationRequestId++; + const validationId = tokenValidationRequestId; if (!token) { document.getElementById('tokenStatus').textContent = ''; document.getElementById('tokenStatus').className = 'token-status'; - document.getElementById('clearTokenBtn').style.display = 'none'; + syncTokenUiWithStoredCredential(Boolean(persistedToken)); return; } @@ -220,10 +253,26 @@ function setupEventListeners() { toastManager.isManualTokenEntry = true; tokenValidationTimeout = setTimeout(async () => { - await validateToken(token, toastManager); - // Auto-save token after validation - if (token) { + const validationResult = await validateToken(token, toastManager, { + shouldApplyResult: () => + validationId === tokenValidationRequestId && + document.getElementById('githubToken')?.value.trim() === token + }); + + if (validationId !== tokenValidationRequestId) { + return; + } + + if (validationResult.isValid) { await setToken(token); + persistedToken = token; + } else if (shouldClearStoredToken(validationResult)) { + await clearStoredToken(); + persistedToken = null; + } + + if (!validationResult.isValid && persistedToken) { + syncTokenUiWithStoredCredential(true); } }, 500); }); @@ -516,20 +565,17 @@ async function loadSettings() { applyTheme(theme); if (githubToken) { + persistedToken = githubToken; document.getElementById('githubToken').value = githubToken; document.getElementById('clearTokenBtn').style.display = 'block'; // Validate existing token - validateToken(githubToken, toastManager); + const validationResult = await validateToken(githubToken, toastManager); + if (shouldClearStoredToken(validationResult)) { + await clearStoredToken(); + persistedToken = null; + } } else { - // No token - set appropriate placeholder and help text - const repoInput = document.getElementById('repoInput'); - repoInput.disabled = true; - repoInput.placeholder = 'Enter a valid GitHub token to add repositories'; - document.getElementById('addRepoBtn').disabled = true; - document.getElementById('repoHelpText').textContent = 'Add a valid GitHub token above to start adding repositories'; - const importSection = document.getElementById('importReposSection'); - importSection.classList.add('hidden'); - importSection.style.display = 'none'; + syncTokenUiWithStoredCredential(false); } state.watchedRepos = settings.watchedRepos || []; @@ -1148,12 +1194,16 @@ setInterval(async () => { export { state, validateToken, + loadSettings, + setupEventListeners, addRepo, validateRepo, removeRepo, cleanupRepoNotifications, getFilteredRepos, renderRepoList, + shouldClearStoredToken, + syncTokenUiWithStoredCredential, formatNumber, formatDateVerbose as formatDate, // Export verbose formatter for tests exportSettings, diff --git a/popup/views/onboarding-view.js b/popup/views/onboarding-view.js index a0954ca..de190c6 100644 --- a/popup/views/onboarding-view.js +++ b/popup/views/onboarding-view.js @@ -189,7 +189,7 @@ async function renderTokenStep() {

Quick setup:

    -
  1. Create a GitHub token
  2. +
  3. Create a GitHub token
  4. Copy the generated token
  5. Paste it below
diff --git a/tests/options-main.test.js b/tests/options-main.test.js index 31efee7..ca12e6b 100644 --- a/tests/options-main.test.js +++ b/tests/options-main.test.js @@ -1,9 +1,14 @@ -import { jest, describe, test, beforeEach, expect } from '@jest/globals'; +import { TextEncoder, TextDecoder } from 'node:util'; +import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; const { formatNumber, getFilteredRepos, cleanupRepoNotifications, + loadSettings, + setupEventListeners, + shouldClearStoredToken, + syncTokenUiWithStoredCredential, state } = await import('../options/options.js'); @@ -21,9 +26,49 @@ describe('Options Main Functions', () => {
+
+
+ + +
+ + + + + + + + + + + + + + +
+ + +
+ + + + + + + + + + + + +
+
+ + + `; // Reset state for each test @@ -51,7 +96,25 @@ describe('Options Main Functions', () => { }, sync: { get: jest.fn((keys, callback) => { - callback({}); + const result = {}; + if (callback) { + callback(result); + return; + } + return Promise.resolve(result); + }), + set: jest.fn((data, callback) => { + if (callback) callback(); + return Promise.resolve(); + }), + remove: jest.fn((keys, callback) => { + if (callback) callback(); + return Promise.resolve(); + }) + }, + session: { + get: jest.fn((keys, callback) => { + if (callback) callback({}); }), set: jest.fn((data, callback) => { if (callback) callback(); @@ -67,6 +130,42 @@ describe('Options Main Functions', () => { }; global.fetch = jest.fn(); + global.confirm = jest.fn(() => true); + window.matchMedia = jest.fn().mockReturnValue({ + matches: false, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn() + }); + Object.defineProperty(global, 'crypto', { + configurable: true, + value: { + getRandomValues: jest.fn((array) => { + array.fill(1); + return array; + }), + subtle: { + generateKey: jest.fn(async () => ({ mockKey: true })), + importKey: jest.fn(async () => ({ mockKey: true })), + exportKey: jest.fn(async () => new Uint8Array([1, 2, 3, 4]).buffer), + encrypt: jest.fn(async () => new Uint8Array([9, 8, 7]).buffer), + decrypt: jest.fn(async () => new TextEncoder().encode('decrypted-token').buffer) + } + } + }); + Object.defineProperty(global, 'TextEncoder', { + configurable: true, + value: TextEncoder + }); + Object.defineProperty(global, 'TextDecoder', { + configurable: true, + value: TextDecoder + }); + }); + + afterEach(() => { + jest.useRealTimers(); }); describe('formatNumber', () => { @@ -189,6 +288,234 @@ describe('Options Main Functions', () => { }); }); + describe('token persistence helpers', () => { + test('only clears stored tokens for invalid credentials', () => { + expect(shouldClearStoredToken({ isValid: false, reason: 'invalid' })).toBe(true); + expect(shouldClearStoredToken({ isValid: false, reason: 'network' })).toBe(false); + expect(shouldClearStoredToken({ isValid: false, reason: 'http', status: 500 })).toBe(false); + expect(shouldClearStoredToken({ isValid: true, user: 'testuser' })).toBe(false); + }); + + test('restores authenticated UI when a stored token still exists', () => { + const clearBtn = document.getElementById('clearTokenBtn'); + const repoInput = document.getElementById('repoInput'); + const addBtn = document.getElementById('addRepoBtn'); + const helpText = document.getElementById('repoHelpText'); + const importSection = document.getElementById('importReposSection'); + + clearBtn.style.display = 'none'; + repoInput.disabled = true; + addBtn.disabled = true; + helpText.textContent = 'Invalid token. Please check your GitHub token and try again.'; + importSection.classList.add('hidden'); + importSection.style.display = 'none'; + + syncTokenUiWithStoredCredential(true); + + expect(clearBtn.style.display).toBe('block'); + expect(repoInput.disabled).toBe(false); + expect(addBtn.disabled).toBe(false); + expect(helpText.textContent).toContain('Add repositories to monitor'); + expect(importSection.classList.contains('hidden')).toBe(false); + expect(importSection.style.display).toBe('block'); + }); + + test('restores unauthenticated UI when no stored token is available', () => { + const clearBtn = document.getElementById('clearTokenBtn'); + const repoInput = document.getElementById('repoInput'); + const addBtn = document.getElementById('addRepoBtn'); + const helpText = document.getElementById('repoHelpText'); + const importSection = document.getElementById('importReposSection'); + + syncTokenUiWithStoredCredential(false); + + expect(clearBtn.style.display).toBe('none'); + expect(repoInput.disabled).toBe(true); + expect(addBtn.disabled).toBe(true); + expect(helpText.textContent).toContain('Add a valid GitHub token above'); + expect(importSection.classList.contains('hidden')).toBe(true); + expect(importSection.style.display).toBe('none'); + }); + + test('loadSettings preserves stored token on transient validation failures', async () => { + chrome.storage.session.get.mockImplementation((keys, callback) => { + callback({ githubToken: 'persisted-token' }); + }); + chrome.storage.sync.get.mockImplementation((keys, callback) => { + const result = Array.isArray(keys) && keys.includes('snoozedRepos') + ? { snoozedRepos: [] } + : {}; + if (callback) { + callback(result); + return; + } + return Promise.resolve(result); + }); + global.fetch.mockResolvedValue({ + ok: false, + status: 500 + }); + + await loadSettings(); + + expect(document.getElementById('githubToken').value).toBe('persisted-token'); + expect(document.getElementById('clearTokenBtn').style.display).toBe('none'); + expect(document.getElementById('repoInput').disabled).toBe(true); + expect(chrome.storage.local.set).not.toHaveBeenCalledWith( + expect.objectContaining({ encryptedGithubToken: null }), + expect.any(Function) + ); + }); + + test('loadSettings clears stored token when validation reports invalid credentials', async () => { + chrome.storage.session.get.mockImplementation((keys, callback) => { + callback({ githubToken: 'expired-token' }); + }); + chrome.storage.sync.get.mockImplementation((keys, callback) => { + const result = Array.isArray(keys) && keys.includes('snoozedRepos') + ? { snoozedRepos: [] } + : {}; + if (callback) { + callback(result); + return; + } + return Promise.resolve(result); + }); + global.fetch.mockResolvedValue({ + ok: false, + status: 401 + }); + + await loadSettings(); + + expect(chrome.storage.local.set).toHaveBeenCalledWith( + expect.objectContaining({ encryptedGithubToken: null }), + expect.any(Function) + ); + }); + + test('setupEventListeners clears persisted token after the clear action succeeds', async () => { + setupEventListeners(); + + document.getElementById('clearTokenBtn').click(); + await Promise.resolve(); + + expect(chrome.storage.session.remove).toHaveBeenCalledWith(['githubToken'], expect.any(Function)); + expect(chrome.storage.local.set).toHaveBeenCalledWith( + expect.objectContaining({ encryptedGithubToken: null }), + expect.any(Function) + ); + }); + + test('empty token input restores stored-token UI state', async () => { + chrome.storage.session.get.mockImplementation((keys, callback) => { + callback({ githubToken: 'persisted-token' }); + }); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ login: 'persisted-user' }) + }); + + await loadSettings(); + setupEventListeners(); + + const tokenInput = document.getElementById('githubToken'); + tokenInput.value = ''; + tokenInput.dispatchEvent(new Event('input', { bubbles: true })); + + expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); + expect(document.getElementById('repoInput').disabled).toBe(false); + }); + + test('token input persists newly validated replacement tokens', async () => { + jest.useFakeTimers(); + setupEventListeners(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ login: 'new-user' }) + }); + + const tokenInput = document.getElementById('githubToken'); + tokenInput.value = 'new-token'; + tokenInput.dispatchEvent(new Event('input', { bubbles: true })); + + await jest.advanceTimersByTimeAsync(500); + await Promise.resolve(); + + expect(chrome.storage.session.set).toHaveBeenCalledWith( + { githubToken: 'new-token' }, + expect.any(Function) + ); + expect(chrome.storage.local.set).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ encryptedGithubToken: expect.any(Object) }), + expect.any(Function) + ); + }); + + test('token input clears persisted credentials after confirmed invalid replacement', async () => { + jest.useFakeTimers(); + chrome.storage.session.get.mockImplementation((keys, callback) => { + callback({ githubToken: 'persisted-token' }); + }); + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ login: 'persisted-user' }) + }) + .mockResolvedValueOnce({ + ok: false, + status: 401 + }); + + await loadSettings(); + setupEventListeners(); + + const tokenInput = document.getElementById('githubToken'); + tokenInput.value = 'expired-token'; + tokenInput.dispatchEvent(new Event('input', { bubbles: true })); + + await jest.advanceTimersByTimeAsync(500); + await Promise.resolve(); + + expect(chrome.storage.session.remove).toHaveBeenCalledWith(['githubToken'], expect.any(Function)); + expect(chrome.storage.local.set).toHaveBeenCalledWith( + expect.objectContaining({ encryptedGithubToken: null }), + expect.any(Function) + ); + }); + + test('token input keeps repository controls enabled after transient replacement failure', async () => { + jest.useFakeTimers(); + chrome.storage.session.get.mockImplementation((keys, callback) => { + callback({ githubToken: 'persisted-token' }); + }); + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ login: 'persisted-user' }) + }) + .mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + await loadSettings(); + setupEventListeners(); + + const tokenInput = document.getElementById('githubToken'); + tokenInput.value = 'replacement-token'; + tokenInput.dispatchEvent(new Event('input', { bubbles: true })); + + await jest.advanceTimersByTimeAsync(500); + await Promise.resolve(); + + expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); + expect(document.getElementById('repoInput').disabled).toBe(false); + expect(document.getElementById('addRepoBtn').disabled).toBe(false); + }); + }); + describe('cleanupRepoNotifications', () => { test('removes activities for deleted repository', async () => { const activities = [ diff --git a/tests/options-token-controller.test.js b/tests/options-token-controller.test.js index 677fa61..78c6c61 100644 --- a/tests/options-token-controller.test.js +++ b/tests/options-token-controller.test.js @@ -17,6 +17,9 @@ describe('Token Controller', () => { // Chrome mocks are provided by setup.js global.confirm = jest.fn(() => true); global.fetch = jest.fn(); + chrome.storage.local.set.mockImplementation((items, callback) => { + if (callback) callback(); + }); }); test('clearToken does nothing when cancelled', async () => { @@ -34,10 +37,33 @@ describe('Token Controller', () => { }); const toastManager = {}; - await validateToken('test-token', toastManager); + const result = await validateToken('test-token', toastManager); const statusEl = document.getElementById('tokenStatus'); expect(statusEl.textContent).toContain('testuser'); + expect(result).toEqual({ isValid: true, user: 'testuser' }); + }); + + test('validateToken skips UI updates for stale responses', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ login: 'stale-user' }) + }); + + document.getElementById('tokenStatus').textContent = 'Checking...'; + document.getElementById('tokenStatus').className = 'token-status checking'; + document.getElementById('clearTokenBtn').style.display = 'none'; + + const toastManager = { isManualTokenEntry: true }; + const result = await validateToken('stale-token', toastManager, { + shouldApplyResult: () => false + }); + + expect(result).toEqual({ isValid: true, user: 'stale-user' }); + expect(document.getElementById('tokenStatus').textContent).toBe('Checking...'); + expect(document.getElementById('tokenStatus').className).toBe('token-status checking'); + expect(document.getElementById('clearTokenBtn').style.display).toBe('none'); + expect(toastManager.lastValidToken).toBeUndefined(); }); test('validateToken handles invalid token', async () => { @@ -47,13 +73,36 @@ describe('Token Controller', () => { }); const toastManager = {}; - await validateToken('bad-token', toastManager); + const result = await validateToken('bad-token', toastManager); const statusEl = document.getElementById('tokenStatus'); expect(statusEl.textContent).toContain('Invalid'); + expect(result).toEqual({ isValid: false, reason: 'invalid' }); + }); + + test('validateToken skips stale invalid-token UI updates', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 401 + }); + + document.getElementById('tokenStatus').textContent = 'Checking...'; + document.getElementById('tokenStatus').className = 'token-status checking'; + document.getElementById('clearTokenBtn').style.display = 'block'; + + const toastManager = {}; + const result = await validateToken('stale-bad-token', toastManager, { + shouldApplyResult: () => false + }); + + expect(result).toEqual({ isValid: false, reason: 'invalid' }); + expect(document.getElementById('tokenStatus').textContent).toBe('Checking...'); + expect(document.getElementById('tokenStatus').className).toBe('token-status checking'); + expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); + expect(toastManager.lastInvalidToken).toBeUndefined(); }); - test.skip('clearToken clears all fields when confirmed', async () => { + test('clearToken clears all fields when confirmed', async () => { global.confirm.mockReturnValue(true); const tokenInput = document.getElementById('githubToken'); @@ -80,22 +129,64 @@ describe('Token Controller', () => { }); const toastManager = {}; - await validateToken('token', toastManager); + const result = await validateToken('token', toastManager); const statusEl = document.getElementById('tokenStatus'); expect(statusEl.textContent).toContain('Error (500)'); expect(statusEl.className).toContain('invalid'); + expect(result).toEqual({ isValid: false, reason: 'http', status: 500 }); + }); + + test('validateToken skips stale API error UI updates', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 500 + }); + + document.getElementById('tokenStatus').textContent = 'Checking...'; + document.getElementById('tokenStatus').className = 'token-status checking'; + document.getElementById('clearTokenBtn').style.display = 'block'; + + const toastManager = {}; + const result = await validateToken('stale-token', toastManager, { + shouldApplyResult: () => false + }); + + expect(result).toEqual({ isValid: false, reason: 'http', status: 500 }); + expect(document.getElementById('tokenStatus').textContent).toBe('Checking...'); + expect(document.getElementById('tokenStatus').className).toBe('token-status checking'); + expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); + expect(toastManager.lastApiError).toBeUndefined(); }); test('validateToken handles network errors', async () => { global.fetch.mockRejectedValue(new Error('Network error')); const toastManager = {}; - await validateToken('token', toastManager); + const result = await validateToken('token', toastManager); const statusEl = document.getElementById('tokenStatus'); expect(statusEl.textContent).toContain('Network error'); expect(statusEl.className).toContain('invalid'); + expect(result).toEqual({ isValid: false, reason: 'network' }); + }); + + test('validateToken skips stale network-error UI updates', async () => { + global.fetch.mockRejectedValue(new Error('Network error')); + + document.getElementById('tokenStatus').textContent = 'Checking...'; + document.getElementById('tokenStatus').className = 'token-status checking'; + document.getElementById('clearTokenBtn').style.display = 'block'; + + const toastManager = {}; + const result = await validateToken('stale-token', toastManager, { + shouldApplyResult: () => false + }); + + expect(result).toEqual({ isValid: false, reason: 'network' }); + expect(document.getElementById('tokenStatus').textContent).toBe('Checking...'); + expect(document.getElementById('tokenStatus').className).toBe('token-status checking'); + expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); }); test('validateToken shows success toast only on first validation', async () => {