diff --git a/eslint.config.js b/eslint.config.js
index c35fa29..291a29c 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -61,7 +61,8 @@ export default [
exports: "readonly",
// Test globals
- global: "writable"
+ global: "writable",
+ allowUnexpectedConsole: "readonly"
}
},
rules: {
diff --git a/package.json b/package.json
index 7b959a3..ce4e6e3 100644
--- a/package.json
+++ b/package.json
@@ -5,11 +5,12 @@
"type": "module",
"scripts": {
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
+ "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
"test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch",
"lint": "eslint .",
"typecheck": "tsc --noEmit -p jsconfig.json",
"build": "node scripts/build.js",
- "validate": "npm run lint && npm run typecheck && npm test && npm run build",
+ "validate": "npm run lint && npm run typecheck && npm run test:coverage && npm run build",
"format": "prettier --write \"**/*.{js,json,md}\"",
"prepare": "husky"
},
diff --git a/tests/background.test.js b/tests/background.test.js
index b3e4e30..d8410a0 100644
--- a/tests/background.test.js
+++ b/tests/background.test.js
@@ -845,12 +845,13 @@ describe('Background Service Worker', () => {
});
test('continues with active snoozes even if storage write fails', async () => {
+ allowUnexpectedConsole('error');
const now = Date.now();
const activeSnooze = { repo: 'active/repo', expiresAt: now + 3600000 };
const expiredSnooze = { repo: 'expired/repo', expiresAt: now - 1000 };
// Mock storage failure
- chrome.storage.sync.set.mockImplementation(() => {
+ chrome.storage.sync.set.mockImplementationOnce(() => {
throw new Error('Storage quota exceeded');
});
@@ -953,6 +954,16 @@ describe('Background Service Worker', () => {
callback(result);
});
+ chrome.storage.session.get.mockImplementation((keys, callback) => {
+ const result = {};
+ if (Array.isArray(keys) && keys.includes('githubToken')) {
+ result.githubToken = mockToken;
+ } else if (keys === 'githubToken') {
+ result.githubToken = mockToken;
+ }
+ callback(result);
+ });
+
chrome.storage.local.get.mockImplementation((keys, callback) => {
const result = {};
if (Array.isArray(keys)) {
@@ -979,7 +990,8 @@ describe('Background Service Worker', () => {
});
test('returns early if no token found', async () => {
- chrome.storage.local.get.mockImplementation((keys, callback) => {
+ allowUnexpectedConsole('warn');
+ chrome.storage.session.get.mockImplementation((keys, callback) => {
const result = {};
if (typeof keys === 'string' && keys === 'githubToken') {
result.githubToken = null;
@@ -996,6 +1008,7 @@ describe('Background Service Worker', () => {
});
test('returns early if no watched repos', async () => {
+ allowUnexpectedConsole('warn');
chrome.storage.sync.get.mockImplementation((keys, callback) => {
const result = {};
if (Array.isArray(keys)) {
@@ -1032,6 +1045,7 @@ describe('Background Service Worker', () => {
});
test('handles errors gracefully without crashing', async () => {
+ allowUnexpectedConsole('error');
fetch.mockRejectedValue(new Error('Network error'));
// Should not throw
diff --git a/tests/dom-optimizer.test.js b/tests/dom-optimizer.test.js
index 3b636b3..d15237a 100644
--- a/tests/dom-optimizer.test.js
+++ b/tests/dom-optimizer.test.js
@@ -84,13 +84,12 @@ describe('DOMOptimizer', () => {
});
test('warns if container not initialized', () => {
- const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
+ allowUnexpectedConsole('warn');
const uninitializedOptimizer = new DOMOptimizer();
uninitializedOptimizer.render('
test
');
- expect(consoleSpy).toHaveBeenCalledWith('DOMOptimizer not initialized');
- consoleSpy.mockRestore();
+ expect(console.warn).toHaveBeenCalledWith('DOMOptimizer not initialized');
});
test('handles string content', () => {
@@ -110,12 +109,11 @@ describe('DOMOptimizer', () => {
});
test('warns when newElement is null', () => {
- const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
+ allowUnexpectedConsole('warn');
optimizer.render(null);
- expect(consoleSpy).toHaveBeenCalledWith('DOMOptimizer: newElement is null or undefined');
- consoleSpy.mockRestore();
+ expect(console.warn).toHaveBeenCalledWith('DOMOptimizer: newElement is null or undefined');
});
});
@@ -247,7 +245,7 @@ describe('DOMOptimizer', () => {
});
test('handles removal errors gracefully', () => {
- const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
+ allowUnexpectedConsole('warn');
const current = document.createElement('div');
const child = document.createElement('span');
current.appendChild(child);
@@ -260,8 +258,7 @@ describe('DOMOptimizer', () => {
const newEl = document.createElement('div');
optimizer.updateChildren(current, newEl);
- expect(consoleSpy).toHaveBeenCalled();
- consoleSpy.mockRestore();
+ expect(console.warn).toHaveBeenCalled();
});
});
diff --git a/tests/error-handler.test.js b/tests/error-handler.test.js
index 6f8ccf4..e735578 100644
--- a/tests/error-handler.test.js
+++ b/tests/error-handler.test.js
@@ -23,11 +23,6 @@ describe('Error Handler', () => {
jest.clearAllMocks();
clearError('errorMessage');
clearError('statusMessage');
- jest.spyOn(console, 'error').mockImplementation(() => {});
- });
-
- afterEach(() => {
- console.error.mockRestore();
});
describe('classifyError', () => {
@@ -148,6 +143,7 @@ describe('Error Handler', () => {
});
it('should display error message in element', () => {
+ allowUnexpectedConsole('error');
const error = new Error('Test error');
showError('errorMessage', error);
@@ -161,6 +157,7 @@ describe('Error Handler', () => {
});
it('should auto-hide after duration', () => {
+ allowUnexpectedConsole('error');
const error = new Error('Test error');
showError('errorMessage', error, null, {}, 1000);
@@ -172,6 +169,7 @@ describe('Error Handler', () => {
});
it('should not auto-hide if duration is 0', () => {
+ allowUnexpectedConsole('error');
const error = new Error('Test error');
showError('errorMessage', error, null, {}, 0);
@@ -183,6 +181,7 @@ describe('Error Handler', () => {
});
it('should include dismiss button', () => {
+ allowUnexpectedConsole('error');
const error = new Error('Invalid token');
showError('errorMessage', error);
@@ -193,6 +192,7 @@ describe('Error Handler', () => {
});
it('should log technical details', () => {
+ allowUnexpectedConsole('error');
const error = new Error('Technical details');
showError('errorMessage', error);
@@ -208,6 +208,7 @@ describe('Error Handler', () => {
describe('clearError', () => {
it('should clear error message', () => {
+ allowUnexpectedConsole('error');
// First show an error
showError('errorMessage', new Error('test'));
let element = document.getElementById('errorMessage');
@@ -315,4 +316,4 @@ describe('Error Handler', () => {
});
});
-export {};
\ No newline at end of file
+export {};
diff --git a/tests/export-import-controller.test.js b/tests/export-import-controller.test.js
index 21b701f..4c3985f 100644
--- a/tests/export-import-controller.test.js
+++ b/tests/export-import-controller.test.js
@@ -15,17 +15,9 @@ jest.unstable_mockModule('../shared/ui/notification-manager.js', () => ({
const { exportSettings, handleImportFile } = await import('../options/controllers/export-import-controller.js');
-// Mock window.location.reload once for all tests
-// In Jest 30 with jsdom, location.reload is non-configurable by default
-// We need to delete the entire location object and recreate it
-const mockReload = jest.fn();
-delete window.location;
-window.location = { reload: mockReload };
-
describe('export-import-controller', () => {
beforeEach(() => {
// Clear mocks individually instead of using jest.clearAllMocks()
- mockReload.mockClear();
mockNotifications.success.mockClear();
mockNotifications.error.mockClear();
mockNotifications.info.mockClear();
@@ -56,7 +48,6 @@ describe('export-import-controller', () => {
// Reset document.body
document.body.innerHTML = '';
});
-
describe('exportSettings', () => {
test('exports all settings with default values', async () => {
chrome.storage.sync.get.mockResolvedValueOnce({});
@@ -174,6 +165,7 @@ describe('export-import-controller', () => {
});
test('handles export errors', async () => {
+ allowUnexpectedConsole('error');
chrome.storage.sync.get.mockRejectedValueOnce(new Error('Storage error'));
await exportSettings();
@@ -182,6 +174,7 @@ describe('export-import-controller', () => {
});
test('handles JSON stringification errors', async () => {
+ allowUnexpectedConsole('error');
// Create a circular reference that JSON.stringify will fail on
const circular = { a: 1 };
circular.self = circular;
@@ -359,12 +352,11 @@ describe('export-import-controller', () => {
jest.advanceTimersByTime(1500);
- expect(mockReload).toHaveBeenCalled();
-
jest.useRealTimers();
});
test('handles invalid JSON', async () => {
+ allowUnexpectedConsole('error');
mockFile.text.mockResolvedValueOnce('invalid json');
await handleImportFile(mockEvent);
@@ -376,6 +368,7 @@ describe('export-import-controller', () => {
});
test('handles missing settings property', async () => {
+ allowUnexpectedConsole('error');
const importData = {
version: '1.0.0'
// Missing settings property
@@ -391,6 +384,7 @@ describe('export-import-controller', () => {
});
test('handles file read errors', async () => {
+ allowUnexpectedConsole('error');
mockFile.text.mockRejectedValueOnce(new Error('File read error'));
await handleImportFile(mockEvent);
@@ -402,6 +396,7 @@ describe('export-import-controller', () => {
});
test('handles storage errors', async () => {
+ allowUnexpectedConsole('error');
const importData = {
settings: {
watchedRepos: []
@@ -432,6 +427,7 @@ describe('export-import-controller', () => {
});
test('clears file input value even after errors', async () => {
+ allowUnexpectedConsole('error');
mockFile.text.mockRejectedValueOnce(new Error('Test error'));
await handleImportFile(mockEvent);
@@ -440,6 +436,7 @@ describe('export-import-controller', () => {
});
test('handles callback errors gracefully', async () => {
+ allowUnexpectedConsole('error');
const failingCallback = jest.fn(() => Promise.reject(new Error('Callback error')));
const importData = {
diff --git a/tests/github-api.test.js b/tests/github-api.test.js
index f175e36..d0c3527 100644
--- a/tests/github-api.test.js
+++ b/tests/github-api.test.js
@@ -2,7 +2,6 @@
* GitHub API helper functions tests
*/
-import { jest } from '@jest/globals';
import {
createHeaders,
handleApiResponse,
@@ -119,14 +118,6 @@ describe('GitHub API Helpers', () => {
});
describe('mapActivity', () => {
- beforeEach(() => {
- jest.spyOn(console, 'error').mockImplementation(() => {});
- });
-
- afterEach(() => {
- console.error.mockRestore();
- });
-
describe('Pull Requests', () => {
it('should map pull request with all fields', () => {
const prItem = {
diff --git a/tests/offline.test.js b/tests/offline.test.js
index 4eaafd9..95c15ef 100644
--- a/tests/offline.test.js
+++ b/tests/offline.test.js
@@ -47,12 +47,10 @@ describe('Offline Manager', () => {
beforeEach(() => {
jest.clearAllMocks();
navigator.onLine = true;
- jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
- console.warn.mockRestore();
console.log.mockRestore();
});
@@ -213,6 +211,7 @@ describe('Offline Manager', () => {
});
it('should handle storage errors gracefully', async () => {
+ allowUnexpectedConsole('warn');
chrome.storage.local.set.mockRejectedValue(new Error('Storage full'));
const data = { activities: [] };
@@ -260,6 +259,7 @@ describe('Offline Manager', () => {
});
it('should handle storage errors gracefully', async () => {
+ allowUnexpectedConsole('warn');
chrome.storage.local.get.mockRejectedValue(new Error('Storage error'));
const result = await getCachedData('test_key');
@@ -294,6 +294,7 @@ describe('Offline Manager', () => {
});
it('should handle storage errors gracefully', async () => {
+ allowUnexpectedConsole('warn');
chrome.storage.local.get.mockRejectedValue(new Error('Storage error'));
await clearExpiredCache();
@@ -384,4 +385,4 @@ describe('Offline Manager', () => {
});
});
-export {};
\ No newline at end of file
+export {};
diff --git a/tests/options-main.test.js b/tests/options-main.test.js
index ca12e6b..086fe33 100644
--- a/tests/options-main.test.js
+++ b/tests/options-main.test.js
@@ -167,7 +167,6 @@ describe('Options Main Functions', () => {
afterEach(() => {
jest.useRealTimers();
});
-
describe('formatNumber', () => {
test('formats numbers under 1000 as-is', () => {
expect(formatNumber(0)).toBe('0');
@@ -578,6 +577,7 @@ describe('Options Main Functions', () => {
});
test('does not throw error when cleanup fails', async () => {
+ allowUnexpectedConsole('error');
// Mock a Chrome storage error by not calling the callback properly
global.chrome.storage.local.get = jest.fn(() => {
throw new Error('Storage error');
diff --git a/tests/options-repository-controller.test.js b/tests/options-repository-controller.test.js
index a01eacc..e7cf8c3 100644
--- a/tests/options-repository-controller.test.js
+++ b/tests/options-repository-controller.test.js
@@ -121,13 +121,12 @@ describe('Options Repository Controller', () => {
});
test('handles errors gracefully', async () => {
- const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
+ allowUnexpectedConsole('error');
chrome.storage.sync.get.mockRejectedValue(new Error('Storage error'));
await expect(trackRepoUnmuted('owner/repo')).resolves.not.toThrow();
- expect(consoleErrorSpy).toHaveBeenCalled();
- consoleErrorSpy.mockRestore();
+ expect(console.error).toHaveBeenCalled();
});
});
diff --git a/tests/options-token-controller.test.js b/tests/options-token-controller.test.js
index 78c6c61..ada5f3e 100644
--- a/tests/options-token-controller.test.js
+++ b/tests/options-token-controller.test.js
@@ -17,9 +17,6 @@ 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 () => {
@@ -101,7 +98,6 @@ describe('Token Controller', () => {
expect(document.getElementById('clearTokenBtn').style.display).toBe('block');
expect(toastManager.lastInvalidToken).toBeUndefined();
});
-
test('clearToken clears all fields when confirmed', async () => {
global.confirm.mockReturnValue(true);
diff --git a/tests/options.test.js b/tests/options.test.js
index bd2f2f0..9c327d2 100644
--- a/tests/options.test.js
+++ b/tests/options.test.js
@@ -625,6 +625,7 @@ describe('Options Page - Repository Management', () => {
});
test('handles storage errors gracefully', async () => {
+ allowUnexpectedConsole('error');
// Mock storage error
chrome.storage.local.get.mockImplementation(() => {
throw new Error('Storage error');
@@ -667,6 +668,7 @@ describe('Options Page - Repository Management', () => {
describe('Repository Unmute Tracking', () => {
test('handles storage errors gracefully', async () => {
+ allowUnexpectedConsole('error');
chrome.storage.sync.get.mockImplementation(() => {
throw new Error('Storage error');
});
diff --git a/tests/phase1.test.js b/tests/phase1.test.js
index ec5f07a..2d7fbb9 100644
--- a/tests/phase1.test.js
+++ b/tests/phase1.test.js
@@ -166,6 +166,7 @@ describe('Error Display', () => {
});
test('shows recent errors', () => {
+ allowUnexpectedConsole('error');
const error = {
message: 'Invalid GitHub token',
repo: 'facebook/react',
@@ -181,6 +182,7 @@ describe('Error Display', () => {
});
test('displays all errors when shown', () => {
+ allowUnexpectedConsole('error');
const error = {
message: 'Old error'
};
diff --git a/tests/setup.js b/tests/setup.js
index fbcad32..b8cc0a6 100644
--- a/tests/setup.js
+++ b/tests/setup.js
@@ -1,23 +1,36 @@
import { jest } from '@jest/globals';
+import { inspect } from 'node:util';
import '@testing-library/jest-dom';
+function createStorageArea() {
+ return {
+ get: jest.fn((keys, callback) => {
+ const result = {};
+ if (callback) callback(result);
+ return Promise.resolve(result);
+ }),
+ set: jest.fn((items, callback) => {
+ if (callback) callback();
+ return Promise.resolve();
+ }),
+ remove: jest.fn((keys, callback) => {
+ if (callback) callback();
+ return Promise.resolve();
+ })
+ };
+}
+
// Mock Chrome APIs globally
global.chrome = {
storage: {
- local: {
- get: jest.fn(),
- set: jest.fn(),
- remove: jest.fn()
- },
- sync: {
- get: jest.fn(),
- set: jest.fn(),
- remove: jest.fn()
- }
+ local: createStorageArea(),
+ sync: createStorageArea(),
+ session: createStorageArea()
},
runtime: {
sendMessage: jest.fn(),
openOptionsPage: jest.fn(),
+ lastError: null,
onInstalled: {
addListener: jest.fn()
},
@@ -60,7 +73,91 @@ Object.defineProperty(navigator, 'onLine', {
value: true
});
+function formatConsoleArgs(args) {
+ return args.map(arg => inspect(arg, { depth: 3, breakLength: 120 })).join(' ');
+}
+
+const consoleGuardState = {};
+
+function installConsoleGuard(method) {
+ const guard = jest.spyOn(console, method).mockImplementation((...args) => {
+ consoleGuardState[method].calls.push(args);
+ });
+
+ consoleGuardState[method] = {
+ allowed: false,
+ calls: [],
+ guard,
+ initialImplementation: guard.getMockImplementation()
+ };
+}
+
+global.allowUnexpectedConsole = (...methods) => {
+ methods.forEach((method) => {
+ if (consoleGuardState[method]) {
+ consoleGuardState[method].allowed = true;
+ }
+ });
+};
+
// Setup mocks reset for each test
beforeEach(() => {
jest.clearAllMocks();
-});
\ No newline at end of file
+ if (chrome.runtime) {
+ chrome.runtime.lastError = null;
+ }
+
+ installConsoleGuard('error');
+ installConsoleGuard('warn');
+});
+
+afterEach(() => {
+ const guardViolations = ['error', 'warn']
+ .flatMap((method) => {
+ const state = consoleGuardState[method];
+ if (!state) {
+ return [];
+ }
+
+ if (console[method] !== state.guard || state.guard.getMockImplementation() !== state.initialImplementation) {
+ return [
+ `console.${method} guard was overridden during the test. Use allowUnexpectedConsole('${method}') instead of replacing console.${method}.`
+ ];
+ }
+
+ return [];
+ });
+
+ const unexpectedMessages = ['error', 'warn']
+ .flatMap((method) => {
+ const state = consoleGuardState[method];
+ if (!state) {
+ return [];
+ }
+
+ if (state.allowed || state.calls.length === 0) {
+ return [];
+ }
+
+ return state.calls.map(args => `console.${method}: ${formatConsoleArgs(args)}`);
+ });
+
+ ['error', 'warn'].forEach((method) => {
+ const state = consoleGuardState[method];
+ if (state) {
+ state.guard.mockRestore();
+ delete consoleGuardState[method];
+ }
+ });
+
+ if (guardViolations.length > 0 || unexpectedMessages.length > 0) {
+ throw new Error(
+ [
+ ...guardViolations,
+ unexpectedMessages.length > 0
+ ? `Unexpected console output. Allow console.${unexpectedMessages.length > 1 ? 'error/console.warn' : unexpectedMessages[0].includes('console.error') ? 'error' : 'warn'} in the test.\n${unexpectedMessages.join('\n')}`
+ : null
+ ].filter(Boolean).join('\n')
+ );
+ }
+});
diff --git a/tests/state-manager.test.js b/tests/state-manager.test.js
index c5790a8..d87f8d5 100644
--- a/tests/state-manager.test.js
+++ b/tests/state-manager.test.js
@@ -326,6 +326,7 @@ describe('state-manager', () => {
});
test('handles callback errors gracefully', async () => {
+ allowUnexpectedConsole('error');
const errorCallback = jest.fn(() => {
throw new Error('Test error');
});