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'); });