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 @@
Quick setup:
- - Create a GitHub token
+ - Create a GitHub token
- Copy the generated token
- 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 () => {