From 6e8c7039301b568f487a191ed1db844d07e70b69 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Sat, 7 Feb 2026 13:51:03 +0800 Subject: [PATCH 1/6] test(coverage): add code coverage tracking and enforce 90% minimum Add @vitest/coverage-v8 with 90% thresholds on statements, branches, functions, and lines. Coverage enforcement runs in both pre-commit hooks and CI, with coverage-final.json uploaded as a GitHub Actions artifact. Added 21 new tests (34 -> 55) to achieve 100% coverage across all metrics for main.ts, service_worker.ts, and utils.ts. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Jared Scott --- .github/workflows/ci.yml | 12 +- .gitignore | 1 + .husky/pre-commit | 2 +- biome.json | 2 +- package.json | 3 + pnpm-lock.yaml | 125 +++++++++++++ src/main.spec.ts | 350 +++++++++++++++++++++++++++++++++++++ src/service_worker.spec.ts | 230 +++++++++++++++++++++++- vitest.config.ts | 12 ++ 9 files changed, 732 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 609a8bf..c570542 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,5 +48,13 @@ jobs: - name: Type Check run: pnpm type:check - - name: Test - run: pnpm test + - name: Test with Coverage + run: pnpm test:coverage + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-node-${{ matrix.node-version }}-${{ github.run_id }} + path: coverage/coverage-final.json + retention-days: 7 diff --git a/.gitignore b/.gitignore index 1ad96e8..cc3b3a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +coverage/ .idea/ .DS_Store diff --git a/.husky/pre-commit b/.husky/pre-commit index ce82c02..72dc841 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -8,4 +8,4 @@ # pnpm verify-version pnpm lint -pnpm test +pnpm test:coverage diff --git a/biome.json b/biome.json index f1fb825..6d7fca4 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,7 @@ { "$schema": "https://biomejs.dev/schemas/2.3.12/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, - "files": { "includes": ["**", "!!**/dist"] }, + "files": { "includes": ["**", "!!**/dist", "!!**/coverage"] }, "formatter": { "enabled": true, "formatWithErrors": false, diff --git a/package.json b/package.json index 5b038f5..c95b458 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "type": "module", "scripts": { "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:coverage:summary": "vitest run --coverage --coverage.reporter=text-summary", "lint": "biome check --diagnostic-level=error", "lint:fix": "biome check --write --diagnostic-level=error", "develop": "webpack --config webpack/webpack.config.cjs --watch", @@ -25,6 +27,7 @@ "@biomejs/biome": "2.3.12", "@testing-library/user-event": "^14.6.1", "@types/chrome": "^0.1.36", + "@vitest/coverage-v8": "^4.0.18", "adm-zip": "^0.5.16", "copy-webpack-plugin": "^13.0.1", "corepack": "^0.34.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 422c53f..66f0c8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@types/chrome': specifier: ^0.1.36 version: 0.1.36 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@types/node@22.13.12)(terser@5.39.0)) adm-zip: specifier: ^0.5.16 version: 0.5.16 @@ -57,14 +60,31 @@ packages: resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.26.10': resolution: {integrity: sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@biomejs/biome@2.3.12': resolution: {integrity: sha512-AR7h4aSlAvXj7TAajW/V12BOw2EiS0AqZWV5dGozf4nlLoUF/ifvD0+YgKSskT0ylA6dY1A8AwgP8kZ6yaCQnA==} engines: {node: '>=14.21.3'} @@ -302,6 +322,9 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@rollup/rollup-android-arm-eabi@4.56.0': resolution: {integrity: sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==} cpu: [arm] @@ -476,6 +499,15 @@ packages: '@types/node@22.13.12': resolution: {integrity: sha512-ixiWrCSRi33uqBMRuICcKECW7rtgY43TbsHDpM2XK7lXispd48opW+0IXrBVxv9NMhaz/Ue9kyj6r3NTVyXm8A==} + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + peerDependencies: + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} @@ -631,6 +663,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.11: + resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + baseline-browser-mapping@2.9.17: resolution: {integrity: sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==} hasBin: true @@ -828,6 +863,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -869,10 +907,25 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -901,6 +954,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -1298,12 +1358,25 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + '@babel/runtime@7.26.10': dependencies: regenerator-runtime: 0.14.1 + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@2.3.12': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.3.12 @@ -1443,6 +1516,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@rollup/rollup-android-arm-eabi@4.56.0': optional: true @@ -1575,6 +1653,20 @@ snapshots: dependencies: undici-types: 6.20.0 + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@22.13.12)(terser@5.39.0))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@22.13.12)(terser@5.39.0) + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -1747,6 +1839,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.11: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + baseline-browser-mapping@2.9.17: {} braces@3.0.3: @@ -1924,6 +2022,8 @@ snapshots: dependencies: function-bind: 1.1.2 + html-escaper@2.0.2: {} + husky@9.1.7: {} import-local@3.2.0: @@ -1953,12 +2053,27 @@ snapshots: isobject@3.0.1: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jest-worker@27.5.1: dependencies: '@types/node': 22.13.12 merge-stream: 2.0.0 supports-color: 8.1.1 + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} json-parse-even-better-errors@2.3.1: {} @@ -1979,6 +2094,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.1 + merge-stream@2.0.0: {} micromatch@4.0.8: diff --git a/src/main.spec.ts b/src/main.spec.ts index b83281d..0f95a35 100644 --- a/src/main.spec.ts +++ b/src/main.spec.ts @@ -672,4 +672,354 @@ describe('main UI bootstrap', () => { const html = readFileSync(new URL('./html/index.html', import.meta.url), 'utf8'); expect(html).toContain('placeholder="Type here... auto-syncs across Chrome."'); }); + + it('shows 0 words when text is empty', async () => { + vi.resetModules(); + const { chrome, document, textAreaEl, numCharsEl, countModeEl, getCountModeHandler } = + setupMainTest({}); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + + await import('./main.js'); + + textAreaEl.value = ''; + countModeEl.value = 'words'; + const handler = getCountModeHandler('change'); + handler?.(); + + expect(numCharsEl.innerText).toBe('0'); + }); + + it('silently returns from updateLastSynced when last-synced element is missing', async () => { + vi.resetModules(); + const { chrome, document, textAreaEl, getHandler } = setupMainTest({}); + const originalGetElementById = document.getElementById; + document.getElementById = vi.fn((id: string) => { + if (id === 'last-synced') { + return null; + } + return originalGetElementById(id); + }); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + + await import('./main.js'); + + textAreaEl.value = 'updated'; + const handler = getHandler('keyup'); + + expect(() => handler?.()).not.toThrow(); + + await Promise.resolve(); + await Promise.resolve(); + }); + + it('focuses textarea when storage.local is falsy', async () => { + vi.resetModules(); + const { chrome, document, textAreaEl } = setupMainTest({ v2: 'hello' }); + chrome.storage.local = undefined as unknown as ChromeLocal; + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + + await import('./main.js'); + + expect(textAreaEl.focus).toHaveBeenCalledTimes(1); + }); + + it('logs warning when cursor storage fails', async () => { + vi.resetModules(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { + // noop for test + }); + const error = new Error('local fail'); + const { chrome, document, getHandler } = setupMainTest( + { v2: 'hello' }, + { + localOverrides: { + set: vi.fn(() => Promise.reject(error)), + }, + }, + ); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + + await import('./main.js'); + + const handler = getHandler('input'); + handler?.(); + + await Promise.resolve(); + await Promise.resolve(); + + expect(warnSpy).toHaveBeenCalledWith(error); + }); + + it('logs warning when immediate save (Ctrl+S) fails', async () => { + vi.resetModules(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { + // noop for test + }); + const error = new Error('sync fail'); + const { chrome, document, textAreaEl, getHandler } = setupMainTest( + {}, + { + syncOverrides: { + set: vi.fn(() => Promise.reject(error)), + }, + }, + ); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + + await import('./main.js'); + + textAreaEl.value = 'updated'; + const preventDefault = vi.fn(); + const handler = getHandler('keydown'); + handler?.({ key: 's', ctrlKey: true, metaKey: false, shiftKey: false, preventDefault }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(warnSpy).toHaveBeenCalledWith(error); + }); + + it('logs warning when clipboard.writeText throws synchronously', async () => { + vi.resetModules(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { + // noop for test + }); + const error = new Error('clipboard fail'); + const writeText = vi.fn(() => { + throw error; + }); + const { chrome, document, textAreaEl, getCopyHandler } = setupMainTest({ v2: 'hello' }); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + + await import('./main.js'); + + textAreaEl.value = 'copied'; + const handler = getCopyHandler('click'); + handler?.(); + + await Promise.resolve(); + await Promise.resolve(); + + expect(warnSpy).toHaveBeenCalledWith(error); + expect(textAreaEl.select).toHaveBeenCalledTimes(1); + }); + + it('logs warning when execCommand throws', async () => { + vi.resetModules(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { + // noop for test + }); + const error = new Error('exec fail'); + const execCommand = vi.fn(() => { + throw error; + }); + const { chrome, document, textAreaEl, getCopyHandler } = setupMainTest({ v2: 'hello' }); + vi.stubGlobal('navigator', {}); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', { + ...document, + execCommand, + }); + + await import('./main.js'); + + textAreaEl.value = 'copied'; + const handler = getCopyHandler('click'); + handler?.(); + + expect(warnSpy).toHaveBeenCalledWith(error); + }); + + it('defaults to bytes mode when countModeEl is null', async () => { + vi.resetModules(); + const { chrome, document, numCharsEl, sync } = setupMainTest({ v2: 'hello' }); + const originalGetElementById = document.getElementById; + document.getElementById = vi.fn((id: string) => { + if (id === 'count-mode') { + return null; + } + return originalGetElementById(id); + }); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + + await import('./main.js'); + + expect(sync.getBytesInUse).toHaveBeenCalled(); + expect(numCharsEl.innerText).toBe('42'); + }); + + it('skips usageMaxEl update when usage-max element is null', async () => { + vi.resetModules(); + const { chrome, document, numCharsEl, countModeEl, getCountModeHandler } = setupMainTest({ + v2: 'hello', + }); + const originalGetElementById = document.getElementById; + document.getElementById = vi.fn((id: string) => { + if (id === 'usage-max') { + return null; + } + return originalGetElementById(id); + }); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + + await import('./main.js'); + + countModeEl.value = 'chars'; + const handler = getCountModeHandler('change'); + handler?.(); + + expect(numCharsEl.innerText).toBe('5'); + + countModeEl.value = 'words'; + handler?.(); + + expect(numCharsEl.innerText).toBe('1'); + + countModeEl.value = 'bytes'; + handler?.(); + }); + + it('restores cursor with missing start and end properties', async () => { + vi.resetModules(); + const { chrome, document, textAreaEl } = setupMainTest( + { v2: 'hello' }, + { + localOverrides: { + get: vi.fn((_: unknown, callback: (items: Record) => void) => { + callback({ + cursor: {}, + }); + }), + }, + }, + ); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + + await import('./main.js'); + + expect(textAreaEl.setSelectionRange).toHaveBeenCalledWith(0, 0); + }); + + it('skips setSelectionRange when cursor data is not present', async () => { + vi.resetModules(); + const { chrome, document, textAreaEl } = setupMainTest( + { v2: 'hello' }, + { + localOverrides: { + get: vi.fn((_: unknown, callback: (items: Record) => void) => { + callback({}); + }), + }, + }, + ); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + + await import('./main.js'); + + expect(textAreaEl.setSelectionRange).not.toHaveBeenCalled(); + expect(textAreaEl.focus).toHaveBeenCalledTimes(1); + }); + + it('handles null selectionStart and selectionEnd in cursor storage', async () => { + vi.resetModules(); + const { chrome, document, textAreaEl, local, getHandler } = setupMainTest({ + v2: 'hello', + }); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + + await import('./main.js'); + + textAreaEl.selectionStart = null as unknown as number; + textAreaEl.selectionEnd = null as unknown as number; + const handler = getHandler('input'); + handler?.(); + + expect(local.set).toHaveBeenCalledWith({ + cursor: { start: 0, end: 0 }, + }); + }); + + it('ignores keydown events without modifier keys', async () => { + vi.resetModules(); + const { chrome, document, getHandler } = setupMainTest({}); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + + await import('./main.js'); + + const preventDefault = vi.fn(); + const handler = getHandler('keydown'); + handler?.({ key: 'c', ctrlKey: false, metaKey: false, shiftKey: true, preventDefault }); + handler?.({ key: 's', ctrlKey: false, metaKey: false, shiftKey: false, preventDefault }); + + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it('copies text on metaKey+Shift+C', async () => { + vi.resetModules(); + const { chrome, document, textAreaEl, getHandler } = setupMainTest({ + v2: 'hello', + }); + const writeText = vi.fn(() => Promise.resolve()); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + + await import('./main.js'); + + const handler = getHandler('keydown'); + const preventDefault = vi.fn(); + textAreaEl.value = 'meta-copy'; + handler?.({ key: 'c', ctrlKey: false, metaKey: true, shiftKey: true, preventDefault }); + + await Promise.resolve(); + + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith('meta-copy'); + }); + + it('saves on metaKey+S', async () => { + vi.resetModules(); + const { chrome, document, textAreaEl, sync, getHandler } = setupMainTest({}); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + + await import('./main.js'); + + textAreaEl.value = 'meta-save'; + const preventDefault = vi.fn(); + const handler = getHandler('keydown'); + handler?.({ key: 's', ctrlKey: false, metaKey: true, shiftKey: false, preventDefault }); + + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(sync.set).toHaveBeenCalledTimes(1); + }); + + it('skips button listeners when elements are null', async () => { + vi.resetModules(); + const { chrome, document } = setupMainTest({}, { includeClearButton: false }); + const originalGetElementById = document.getElementById; + document.getElementById = vi.fn((id: string) => { + if (id === 'copy' || id === 'export' || id === 'count-mode') { + return null; + } + return originalGetElementById(id); + }); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + + await import('./main.js'); + }); }); diff --git a/src/service_worker.spec.ts b/src/service_worker.spec.ts index 6814761..2786b54 100644 --- a/src/service_worker.spec.ts +++ b/src/service_worker.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; describe('service worker action handler', () => { it('opens the extension page when no existing tab is found', async () => { @@ -100,4 +100,232 @@ describe('service worker action handler', () => { expect(chrome.windows.update).toHaveBeenCalledWith(3, { focused: true }); expect(chrome.tabs.create).not.toHaveBeenCalled(); }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('logs error when tabs.update rejects', async () => { + vi.resetModules(); + let clickHandler: (() => void) | null = null; + const addListener = vi.fn((handler: () => void) => { + clickHandler = handler; + }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { + // noop for test + }); + + const chrome = { + action: { + onClicked: { + addListener, + }, + }, + runtime: { + getURL: vi.fn((path: string) => `chrome-extension://test/${path}`), + }, + tabs: { + create: vi.fn(() => Promise.resolve()), + query: vi.fn(() => + Promise.resolve([ + { + id: 12, + windowId: 3, + }, + ]), + ), + update: vi.fn(() => Promise.reject(new Error('tabs.update failed'))), + }, + windows: { + update: vi.fn(() => Promise.resolve()), + }, + }; + + vi.stubGlobal('chrome', chrome); + await import('./service_worker.js'); + + const handler = clickHandler as (() => void) | null; + if (!handler) throw new Error('Expected click handler'); + await handler(); + await Promise.resolve(); + await Promise.resolve(); + + expect(errorSpy).toHaveBeenCalled(); + }); + + it('logs error when windows.update rejects', async () => { + vi.resetModules(); + let clickHandler: (() => void) | null = null; + const addListener = vi.fn((handler: () => void) => { + clickHandler = handler; + }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { + // noop for test + }); + + const chrome = { + action: { + onClicked: { + addListener, + }, + }, + runtime: { + getURL: vi.fn((path: string) => `chrome-extension://test/${path}`), + }, + tabs: { + create: vi.fn(() => Promise.resolve()), + query: vi.fn(() => + Promise.resolve([ + { + id: 12, + windowId: 3, + }, + ]), + ), + update: vi.fn(() => Promise.resolve()), + }, + windows: { + update: vi.fn(() => Promise.reject(new Error('windows.update failed'))), + }, + }; + + vi.stubGlobal('chrome', chrome); + await import('./service_worker.js'); + + const handler = clickHandler as (() => void) | null; + if (!handler) throw new Error('Expected click handler'); + await handler(); + await Promise.resolve(); + await Promise.resolve(); + + expect(errorSpy).toHaveBeenCalled(); + }); + + it('logs error when tabs.create rejects', async () => { + vi.resetModules(); + let clickHandler: (() => void) | null = null; + const addListener = vi.fn((handler: () => void) => { + clickHandler = handler; + }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { + // noop for test + }); + + const chrome = { + action: { + onClicked: { + addListener, + }, + }, + runtime: { + getURL: vi.fn((path: string) => `chrome-extension://test/${path}`), + }, + tabs: { + create: vi.fn(() => Promise.reject(new Error('tabs.create failed'))), + query: vi.fn(() => Promise.resolve([])), + }, + windows: { + update: vi.fn(() => Promise.resolve()), + }, + }; + + vi.stubGlobal('chrome', chrome); + await import('./service_worker.js'); + + const handler = clickHandler as (() => void) | null; + if (!handler) throw new Error('Expected click handler'); + await handler(); + await Promise.resolve(); + await Promise.resolve(); + + expect(errorSpy).toHaveBeenCalled(); + }); + + it('logs error when tabs.query rejects', async () => { + vi.resetModules(); + let clickHandler: (() => void) | null = null; + const addListener = vi.fn((handler: () => void) => { + clickHandler = handler; + }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { + // noop for test + }); + + const chrome = { + action: { + onClicked: { + addListener, + }, + }, + runtime: { + getURL: vi.fn((path: string) => `chrome-extension://test/${path}`), + }, + tabs: { + create: vi.fn(() => Promise.resolve()), + query: vi.fn(() => Promise.reject(new Error('tabs.query failed'))), + update: vi.fn(() => Promise.resolve()), + }, + windows: { + update: vi.fn(() => Promise.resolve()), + }, + }; + + vi.stubGlobal('chrome', chrome); + await import('./service_worker.js'); + + const handler = clickHandler as (() => void) | null; + if (!handler) throw new Error('Expected click handler'); + await handler(); + await Promise.resolve(); + await Promise.resolve(); + + expect(errorSpy).toHaveBeenCalled(); + }); + + it('skips window focus when tab has no windowId', async () => { + vi.resetModules(); + let clickHandler: (() => void) | null = null; + const addListener = vi.fn((handler: () => void) => { + clickHandler = handler; + }); + + const chrome = { + action: { + onClicked: { + addListener, + }, + }, + runtime: { + getURL: vi.fn((path: string) => `chrome-extension://test/${path}`), + }, + tabs: { + create: vi.fn(() => Promise.resolve()), + query: vi.fn(() => + Promise.resolve([ + { + id: 12, + windowId: null, + }, + ]), + ), + update: vi.fn(() => Promise.resolve()), + }, + windows: { + update: vi.fn(() => Promise.resolve()), + }, + }; + + vi.stubGlobal('chrome', chrome); + await import('./service_worker.js'); + + const handler = clickHandler as (() => void) | null; + if (!handler) throw new Error('Expected click handler'); + await handler(); + await Promise.resolve(); + await Promise.resolve(); + + expect(chrome.tabs.update).toHaveBeenCalledWith(12, { active: true }); + expect(chrome.windows.update).not.toHaveBeenCalled(); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 867a93d..7648239 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,5 +5,17 @@ export default defineConfig({ clearMocks: true, environment: 'node', include: ['src/**/*.spec.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/**/*.spec.ts'], + reporter: ['text', 'text-summary', 'json', 'json-summary'], + thresholds: { + statements: 90, + branches: 90, + functions: 90, + lines: 90, + }, + }, }, }); From d0b0d2e66a07f5532f7edc64817aab344ba20e89 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Sat, 7 Feb 2026 14:06:52 +0800 Subject: [PATCH 2/6] ci(coverage): add PR coverage report workflow Add a coverage-report workflow that triggers after CI completes on PRs. It downloads coverage artifacts from both the PR and master branches, compares them, and posts a markdown summary as a PR comment showing overall coverage delta and per-file coverage for changed files. Changes: - ci.yml: trigger on push to master, upload coverage-main artifact - coverage-report.yml: new workflow_run-triggered reporter - scripts/coverage-report.cjs: coverage comparison and report generation - src/coverage-report.spec.ts: 29 tests for the report script Co-Authored-By: Claude Opus 4.6 Signed-off-by: Jared Scott --- .github/workflows/ci.yml | 12 + .github/workflows/coverage-report.yml | 112 +++++++ scripts/coverage-report.cjs | 247 ++++++++++++++++ scripts/coverage_report.py | 225 +++++++++++++++ src/coverage-report.spec.ts | 401 ++++++++++++++++++++++++++ 5 files changed, 997 insertions(+) create mode 100644 .github/workflows/coverage-report.yml create mode 100644 scripts/coverage-report.cjs create mode 100644 scripts/coverage_report.py create mode 100644 src/coverage-report.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c570542..16d6ffd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,9 @@ name: CI on: + push: + branches: + - master pull_request: types: [opened, synchronize, reopened] @@ -58,3 +61,12 @@ jobs: name: coverage-node-${{ matrix.node-version }}-${{ github.run_id }} path: coverage/coverage-final.json retention-days: 7 + + - name: Upload master branch coverage + uses: actions/upload-artifact@v4 + if: github.event_name == 'push' && github.ref == 'refs/heads/master' && matrix.node-version == '22' + with: + name: coverage-main + path: coverage/coverage-final.json + retention-days: 30 + overwrite: true diff --git a/.github/workflows/coverage-report.yml b/.github/workflows/coverage-report.yml new file mode 100644 index 0000000..f95274b --- /dev/null +++ b/.github/workflows/coverage-report.yml @@ -0,0 +1,112 @@ +name: Coverage Report + +on: + workflow_run: + workflows: ["CI"] + types: [completed] + +env: + COVERAGE_THRESHOLD: "1.0" + +jobs: + coverage: + runs-on: ubuntu-latest + if: > + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + permissions: + contents: read + pull-requests: write + actions: read + + steps: + - name: Checkout PR + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Get PR number + id: pr + env: + GH_TOKEN: ${{ github.token }} + run: | + PR_NUMBER=$(gh pr view --repo "${{ github.repository }}" \ + "${{ github.event.workflow_run.head_repository.owner.login }}:${{ github.event.workflow_run.head_branch }}" \ + --json 'number' --jq '.number' 2>/dev/null || echo "") + + if [ -z "$PR_NUMBER" ]; then + PR_NUMBER="${{ github.event.workflow_run.pull_requests[0].number }}" + echo "Using fallback PR number from workflow_run" + fi + + if [ -z "$PR_NUMBER" ]; then + echo "::error::Could not determine PR number" + exit 1 + fi + + echo "number=$PR_NUMBER" >> $GITHUB_OUTPUT + echo "Found PR #$PR_NUMBER" + + # Download base branch coverage (from latest master push) + - name: Download base coverage + uses: dawidd6/action-download-artifact@v6 + continue-on-error: true + with: + workflow: ci.yml + branch: master + name: coverage-main + path: ./base-coverage/ + + # Download PR branch coverage (Node 22 artifact from the triggering run) + - name: Download PR coverage + uses: dawidd6/action-download-artifact@v6 + continue-on-error: true + with: + run_id: ${{ github.event.workflow_run.id }} + name: coverage-node-22-${{ github.event.workflow_run.id }} + path: ./pr-coverage/ + + - name: Ensure coverage files exist + run: | + mkdir -p base-coverage pr-coverage + [ -f base-coverage/coverage-final.json ] || echo '{}' > base-coverage/coverage-final.json + [ -f pr-coverage/coverage-final.json ] || echo '{}' > pr-coverage/coverage-final.json + + - name: Get changed files + id: changed + uses: tj-actions/changed-files@v47 + with: + separator: ',' + + - name: Generate coverage report + id: report + run: | + node scripts/coverage-report.cjs \ + --base base-coverage/coverage-final.json \ + --pr pr-coverage/coverage-final.json \ + --changed-files "${{ steps.changed.outputs.all_changed_files }}" \ + --threshold ${{ env.COVERAGE_THRESHOLD }} \ + --base-path "$(pwd)/" \ + --output coverage-report.md + continue-on-error: true + + - name: Find existing comment + id: find-comment + uses: peter-evans/find-comment@v3 + with: + issue-number: ${{ steps.pr.outputs.number }} + body-includes: "" + + - name: Post PR comment + uses: peter-evans/create-or-update-comment@v4 + with: + issue-number: ${{ steps.pr.outputs.number }} + comment-id: ${{ steps.find-comment.outputs.comment-id }} + body-path: coverage-report.md + edit-mode: replace diff --git a/scripts/coverage-report.cjs b/scripts/coverage-report.cjs new file mode 100644 index 0000000..ae12117 --- /dev/null +++ b/scripts/coverage-report.cjs @@ -0,0 +1,247 @@ +const fs = require('fs'); +const path = require('path'); + +/** + * Parse V8/Istanbul coverage-final.json output. + * Returns object with 'overall' percentage and 'files' dict, or null. + */ +const parseCoverage = (coveragePath, basePath) => { + if (!fs.existsSync(coveragePath)) { + return null; + } + + let data; + try { + data = JSON.parse(fs.readFileSync(coveragePath, 'utf8')); + } catch { + return null; + } + + if (!data || typeof data !== 'object' || Object.keys(data).length === 0) { + return null; + } + + const files = {}; + let totalStatements = 0; + let coveredStatements = 0; + + for (const [filepath, fileData] of Object.entries(data)) { + const statements = fileData.s || {}; + const numStatements = Object.keys(statements).length; + const numCovered = Object.values(statements).filter((v) => v > 0).length; + + let relPath = filepath; + if (basePath) { + const normalizedBase = basePath.replace(/\/+$/, ''); + if (filepath.startsWith(normalizedBase + '/')) { + relPath = filepath.slice(normalizedBase.length + 1); + } + } + + if (numStatements > 0) { + files[relPath] = { + percent: (numCovered / numStatements) * 100, + covered: numCovered, + total: numStatements, + }; + totalStatements += numStatements; + coveredStatements += numCovered; + } + } + + if (totalStatements === 0) { + return null; + } + + return { + overall: (coveredStatements / totalStatements) * 100, + files, + }; +}; + +/** + * Calculate coverage delta and determine pass/fail status. + */ +const calculateDelta = (basePct, prPct, threshold) => { + if (basePct == null) { + return { delta: null, indicator: 'NEW', passed: true }; + } + + const delta = prPct - basePct; + + if (delta >= 0) { + return { delta, indicator: '+', passed: true }; + } + if (delta >= -threshold) { + return { delta, indicator: '~', passed: true }; + } + return { delta, indicator: '!', passed: false }; +}; + +/** + * Format delta for display in markdown table. + */ +const formatDelta = (deltaInfo) => { + if (deltaInfo.delta == null) { + return deltaInfo.indicator; + } + const sign = deltaInfo.delta >= 0 ? '+' : ''; + return `${sign}${deltaInfo.delta.toFixed(1)}% ${deltaInfo.indicator}`; +}; + +/** + * Filter coverage data to only include changed files. + */ +const filterChangedFiles = (coverageFiles, changedFiles) => { + const changedSet = new Set(changedFiles); + const result = {}; + for (const [filepath, data] of Object.entries(coverageFiles)) { + if (changedSet.has(filepath)) { + result[filepath] = data; + } + } + return result; +}; + +/** + * Generate markdown coverage report for PR comment. + */ +const generateReport = (baseData, prData, changedFiles, threshold) => { + if (!prData) { + return [ + '', + '## Coverage Report', + '', + 'Coverage data unavailable.', + '', + 'No coverage artifacts were found for this PR. This can happen when:', + '- The test workflows have not completed yet', + '- The coverage artifacts failed to upload', + '- This is the first run on a new branch', + '', + 'Coverage will be reported once test workflows complete successfully.', + '', + ].join('\n'); + } + + const lines = [ + '', + '## Coverage Report', + '', + '### Summary', + '| Metric | Coverage | Change |', + '|--------|----------|--------|', + ]; + + const prDelta = calculateDelta(baseData ? baseData.overall : null, prData.overall, threshold); + lines.push(`| **Overall** | ${prData.overall.toFixed(1)}% | ${formatDelta(prDelta)} |`); + + // Changed files section + let prChanged = {}; + if (changedFiles.length > 0) { + prChanged = filterChangedFiles(prData.files || {}, changedFiles); + } + + if (Object.keys(prChanged).length > 0) { + lines.push('', '
', 'Changed Files', ''); + lines.push('| File | Coverage | Change |'); + lines.push('|------|----------|--------|'); + + for (const filepath of Object.keys(prChanged).sort()) { + const data = prChanged[filepath]; + let basePct = null; + if (baseData?.files?.[filepath]) { + basePct = baseData.files[filepath].percent; + } + const delta = calculateDelta(basePct, data.percent, threshold); + lines.push(`| \`${filepath}\` | ${data.percent.toFixed(1)}% | ${formatDelta(delta)} |`); + } + + lines.push('', '
'); + } + + lines.push(''); + return lines.join('\n'); +}; + +/** + * Parse CLI arguments. + */ +const parseArgs = (argv) => { + const args = { + base: null, + pr: null, + changedFiles: '', + threshold: 1.0, + output: null, + basePath: '', + }; + for (let i = 2; i < argv.length; i++) { + switch (argv[i]) { + case '--base': + args.base = argv[++i]; + break; + case '--pr': + args.pr = argv[++i]; + break; + case '--changed-files': + args.changedFiles = argv[++i]; + break; + case '--threshold': + args.threshold = parseFloat(argv[++i]); + break; + case '--output': + args.output = argv[++i]; + break; + case '--base-path': + args.basePath = argv[++i]; + break; + } + } + return args; +}; + +// CLI entry point +if (require.main === module) { + const args = parseArgs(process.argv); + + if (!args.pr || !args.output) { + console.error( + 'Usage: coverage-report.cjs --pr --output [--base ] [--changed-files ] [--threshold ] [--base-path ]', + ); + process.exitCode = 1; + } else { + const baseData = args.base ? parseCoverage(path.resolve(args.base), args.basePath) : null; + const prData = parseCoverage(path.resolve(args.pr), args.basePath); + + const changedFiles = args.changedFiles + .split(',') + .map((f) => f.trim()) + .filter(Boolean); + + const report = generateReport(baseData, prData, changedFiles, args.threshold); + + fs.writeFileSync(args.output, report, 'utf8'); + console.log(`Coverage report written to ${args.output}`); + + if (prData && baseData) { + const delta = calculateDelta(baseData.overall, prData.overall, args.threshold); + if (!delta.passed) { + console.error( + `Coverage dropped by ${Math.abs(delta.delta).toFixed(1)}% (threshold: ${args.threshold}%)`, + ); + process.exitCode = 1; + } + } + } +} + +// Export for testing +module.exports = { + parseCoverage, + calculateDelta, + formatDelta, + filterChangedFiles, + generateReport, + parseArgs, +}; diff --git a/scripts/coverage_report.py b/scripts/coverage_report.py new file mode 100644 index 0000000..69a48ad --- /dev/null +++ b/scripts/coverage_report.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +""" +Generate a coverage report for PR comments. + +Compares coverage between base (master) and PR branches, +generates a markdown report for changed files. +""" + +import argparse +import json +import sys +from pathlib import Path + + +def parse_coverage(coverage_file, base_path=""): + """Parse V8/Istanbul coverage-final.json output. + + Returns dict with 'overall' percentage and 'files' dict, or None. + """ + if not coverage_file.exists(): + return None + + try: + with open(coverage_file) as f: + data = json.load(f) + except (json.JSONDecodeError, IOError): + return None + + if not data: + return None + + files = {} + total_statements = 0 + covered_statements = 0 + + for filepath, file_data in data.items(): + statements = file_data.get("s", {}) + num_statements = len(statements) + num_covered = sum(1 for v in statements.values() if v > 0) + + rel_path = filepath + if base_path: + normalized_base = base_path.rstrip("/") + if filepath.startswith(normalized_base + "/"): + rel_path = filepath[len(normalized_base) + 1 :] + + if num_statements > 0: + files[rel_path] = { + "percent": (num_covered / num_statements) * 100, + "covered": num_covered, + "total": num_statements, + } + total_statements += num_statements + covered_statements += num_covered + + if total_statements == 0: + return None + + return { + "overall": (covered_statements / total_statements) * 100, + "files": files, + } + + +def calculate_delta(base_pct, pr_pct, threshold=1.0): + """Calculate coverage delta and determine pass/fail status.""" + if base_pct is None: + return {"delta": None, "indicator": "NEW", "passed": True} + + delta = pr_pct - base_pct + + if delta >= 0: + return {"delta": delta, "indicator": "+", "passed": True} + elif delta >= -threshold: + return {"delta": delta, "indicator": "~", "passed": True} + else: + return {"delta": delta, "indicator": "!", "passed": False} + + +def format_delta(delta_info): + """Format delta for display in markdown table.""" + if delta_info["delta"] is None: + return delta_info["indicator"] + sign = "+" if delta_info["delta"] >= 0 else "" + return f"{sign}{delta_info['delta']:.1f}% {delta_info['indicator']}" + + +def filter_changed_files(coverage_files, changed_files): + """Filter coverage data to only include changed files.""" + changed_set = set(changed_files) + return { + filepath: data + for filepath, data in coverage_files.items() + if filepath in changed_set + } + + +def generate_report(base_data, pr_data, changed_files, threshold=1.0): + """Generate markdown coverage report for PR comment.""" + if not pr_data: + return """ +## Coverage Report + +Coverage data unavailable. + +No coverage artifacts were found for this PR. This can happen when: +- The test workflows have not completed yet +- The coverage artifacts failed to upload +- This is the first run on a new branch + +Coverage will be reported once test workflows complete successfully. +""" + + lines = [ + "", + "## Coverage Report", + "", + "### Summary", + "| Metric | Coverage | Change |", + "|--------|----------|--------|", + ] + + pr_delta = calculate_delta( + base_data["overall"] if base_data else None, + pr_data["overall"], + threshold, + ) + delta_str = format_delta(pr_delta) + lines.append(f"| **Overall** | {pr_data['overall']:.1f}% | {delta_str} |") + + # Changed files section + pr_changed = {} + if changed_files: + pr_changed = filter_changed_files(pr_data.get("files", {}), changed_files) + + if pr_changed: + lines.extend( + [ + "", + "
", + "Changed Files", + "", + "| File | Coverage | Change |", + "|------|----------|--------|", + ] + ) + for filepath, data in sorted(pr_changed.items()): + base_pct = None + if base_data and filepath in base_data.get("files", {}): + base_pct = base_data["files"][filepath]["percent"] + delta = calculate_delta(base_pct, data["percent"], threshold) + delta_str = format_delta(delta) + lines.append(f"| `{filepath}` | {data['percent']:.1f}% | {delta_str} |") + lines.append("") + lines.append("
") + + lines.append("") + return "\n".join(lines) + + +def main(): + """CLI entry point.""" + parser = argparse.ArgumentParser(description="Generate coverage report for PR") + parser.add_argument( + "--base", type=Path, help="Base branch coverage-final.json" + ) + parser.add_argument( + "--pr", type=Path, required=True, help="PR branch coverage-final.json" + ) + parser.add_argument( + "--changed-files", + type=str, + default="", + help="Comma-separated list of changed files", + ) + parser.add_argument( + "--threshold", + type=float, + default=1.0, + help="Max allowed coverage drop percentage", + ) + parser.add_argument( + "--output", type=Path, required=True, help="Output markdown file path" + ) + parser.add_argument( + "--base-path", + type=str, + default="", + help="Base path to strip from file paths", + ) + + args = parser.parse_args() + + base_data = parse_coverage(args.base, args.base_path) if args.base else None + pr_data = parse_coverage(args.pr, args.base_path) + + changed_files = [f.strip() for f in args.changed_files.split(",") if f.strip()] + + report = generate_report( + base_data=base_data, + pr_data=pr_data, + changed_files=changed_files, + threshold=args.threshold, + ) + + args.output.write_text(report) + print(f"Coverage report written to {args.output}") + + passed = True + if pr_data and base_data: + delta = calculate_delta( + base_data["overall"], pr_data["overall"], args.threshold + ) + if not delta["passed"]: + passed = False + print( + f"Coverage dropped by {abs(delta['delta']):.1f}% " + f"(threshold: {args.threshold}%)" + ) + + sys.exit(0 if passed else 1) + + +if __name__ == "__main__": + main() diff --git a/src/coverage-report.spec.ts b/src/coverage-report.spec.ts new file mode 100644 index 0000000..7d396be --- /dev/null +++ b/src/coverage-report.spec.ts @@ -0,0 +1,401 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +const scriptPath = path.resolve('scripts', 'coverage-report.cjs'); + +interface FileData { + percent: number; + covered: number; + total: number; +} + +interface CoverageData { + overall: number; + files: Record; +} + +const esmRequire = createRequire(import.meta.url); +const { + parseCoverage, + calculateDelta, + formatDelta, + filterChangedFiles, + generateReport, + parseArgs, +} = esmRequire(scriptPath) as { + parseCoverage: (coveragePath: string, basePath: string) => CoverageData | null; + calculateDelta: ( + basePct: number | null, + prPct: number, + threshold: number, + ) => { delta: number | null; indicator: string; passed: boolean }; + formatDelta: (deltaInfo: { delta: number | null; indicator: string }) => string; + filterChangedFiles: ( + coverageFiles: Record, + changedFiles: string[], + ) => Record; + generateReport: ( + baseData: CoverageData | null, + prData: CoverageData | null, + changedFiles: string[], + threshold: number, + ) => string; + parseArgs: (argv: string[]) => { + base: string | null; + pr: string | null; + changedFiles: string; + threshold: number; + output: string | null; + basePath: string; + }; +}; + +const tempDirs: string[] = []; + +const createTempDir = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'coverage-report-')); + tempDirs.push(dir); + return dir; +}; + +const writeCoverageFile = (dir: string, name: string, data: Record) => { + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, JSON.stringify(data), 'utf8'); + return filePath; +}; + +const makeCoverageJson = (files: Record>) => { + const result: Record }> = {}; + for (const [filepath, statements] of Object.entries(files)) { + result[filepath] = { s: statements }; + } + return result; +}; + +describe('coverage-report', () => { + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + describe('parseCoverage', () => { + it('returns null for non-existent file', () => { + expect(parseCoverage('/nonexistent/file.json', '')).toBeNull(); + }); + + it('returns null for invalid JSON', () => { + const dir = createTempDir(); + const filePath = path.join(dir, 'bad.json'); + fs.writeFileSync(filePath, 'not json', 'utf8'); + expect(parseCoverage(filePath, '')).toBeNull(); + }); + + it('returns null for empty object', () => { + const dir = createTempDir(); + const filePath = writeCoverageFile(dir, 'empty.json', {}); + expect(parseCoverage(filePath, '')).toBeNull(); + }); + + it('parses coverage-final.json with statement counts', () => { + const dir = createTempDir(); + const data = makeCoverageJson({ + '/project/src/a.ts': { '0': 1, '1': 1, '2': 0, '3': 1 }, + '/project/src/b.ts': { '0': 1, '1': 1 }, + }); + const filePath = writeCoverageFile(dir, 'coverage.json', data); + + const result = parseCoverage(filePath, '/project') as CoverageData; + expect(result).not.toBeNull(); + expect(result.overall).toBeCloseTo(83.33, 1); + expect(result.files['src/a.ts'].percent).toBe(75); + expect(result.files['src/a.ts'].covered).toBe(3); + expect(result.files['src/a.ts'].total).toBe(4); + expect(result.files['src/b.ts'].percent).toBe(100); + }); + + it('strips trailing slashes from basePath', () => { + const dir = createTempDir(); + const data = makeCoverageJson({ + '/project/src/a.ts': { '0': 1 }, + }); + const filePath = writeCoverageFile(dir, 'coverage.json', data); + + const result = parseCoverage(filePath, '/project/') as CoverageData; + expect(result.files['src/a.ts']).toBeDefined(); + }); + + it('returns null when all files have zero statements', () => { + const dir = createTempDir(); + const data = { '/project/src/a.ts': { s: {} } }; + const filePath = writeCoverageFile(dir, 'coverage.json', data); + expect(parseCoverage(filePath, '')).toBeNull(); + }); + }); + + describe('calculateDelta', () => { + it('returns NEW when base is null', () => { + const result = calculateDelta(null, 80, 1.0); + expect(result).toEqual({ delta: null, indicator: 'NEW', passed: true }); + }); + + it('returns positive delta when coverage increased', () => { + const result = calculateDelta(80, 85, 1.0); + expect(result.delta).toBe(5); + expect(result.indicator).toBe('+'); + expect(result.passed).toBe(true); + }); + + it('returns warning when drop is within threshold', () => { + const result = calculateDelta(80, 79.5, 1.0); + expect(result.delta).toBe(-0.5); + expect(result.indicator).toBe('~'); + expect(result.passed).toBe(true); + }); + + it('returns failure when drop exceeds threshold', () => { + const result = calculateDelta(80, 78, 1.0); + expect(result.delta).toBe(-2); + expect(result.indicator).toBe('!'); + expect(result.passed).toBe(false); + }); + + it('returns positive for zero delta', () => { + const result = calculateDelta(80, 80, 1.0); + expect(result.delta).toBe(0); + expect(result.indicator).toBe('+'); + expect(result.passed).toBe(true); + }); + }); + + describe('formatDelta', () => { + it('formats NEW indicator', () => { + expect(formatDelta({ delta: null, indicator: 'NEW' })).toBe('NEW'); + }); + + it('formats positive delta', () => { + expect(formatDelta({ delta: 5.0, indicator: '+' })).toBe('+5.0% +'); + }); + + it('formats negative delta', () => { + expect(formatDelta({ delta: -2.3, indicator: '!' })).toBe('-2.3% !'); + }); + + it('formats zero delta', () => { + expect(formatDelta({ delta: 0, indicator: '+' })).toBe('+0.0% +'); + }); + }); + + describe('filterChangedFiles', () => { + it('returns only files in the changed list', () => { + const coverageFiles = { + 'src/a.ts': { percent: 80, covered: 4, total: 5 }, + 'src/b.ts': { percent: 90, covered: 9, total: 10 }, + 'src/c.ts': { percent: 100, covered: 3, total: 3 }, + }; + const result = filterChangedFiles(coverageFiles, ['src/a.ts', 'src/c.ts']); + expect(Object.keys(result)).toEqual(['src/a.ts', 'src/c.ts']); + }); + + it('returns empty when no files match', () => { + const coverageFiles = { + 'src/a.ts': { percent: 80, covered: 4, total: 5 }, + }; + const result = filterChangedFiles(coverageFiles, ['src/other.ts']); + expect(Object.keys(result)).toHaveLength(0); + }); + }); + + describe('generateReport', () => { + it('returns unavailable message when prData is null', () => { + const report = generateReport(null, null, [], 1.0); + expect(report).toContain(''); + expect(report).toContain('Coverage data unavailable'); + }); + + it('generates summary with NEW indicator when no base', () => { + const prData = { + overall: 85.5, + files: { 'src/a.ts': { percent: 85.5, covered: 17, total: 20 } }, + }; + const report = generateReport(null, prData, [], 1.0); + expect(report).toContain('85.5%'); + expect(report).toContain('NEW'); + expect(report).not.toContain('
'); + }); + + it('generates summary with delta when base exists', () => { + const baseData = { overall: 80, files: {} }; + const prData = { overall: 85, files: {} }; + const report = generateReport(baseData, prData, [], 1.0); + expect(report).toContain('85.0%'); + expect(report).toContain('+5.0%'); + }); + + it('includes changed files section', () => { + const baseData = { + overall: 80, + files: { 'src/a.ts': { percent: 70, covered: 7, total: 10 } }, + }; + const prData = { + overall: 90, + files: { 'src/a.ts': { percent: 90, covered: 9, total: 10 } }, + }; + const report = generateReport(baseData, prData, ['src/a.ts'], 1.0); + expect(report).toContain('
'); + expect(report).toContain('`src/a.ts`'); + expect(report).toContain('90.0%'); + expect(report).toContain('+20.0%'); + }); + + it('shows NEW for changed files not in base', () => { + const prData = { + overall: 100, + files: { 'src/new.ts': { percent: 100, covered: 5, total: 5 } }, + }; + const report = generateReport(null, prData, ['src/new.ts'], 1.0); + expect(report).toContain('`src/new.ts`'); + expect(report).toContain('NEW'); + }); + }); + + describe('parseArgs', () => { + it('parses all arguments', () => { + const argv = [ + 'node', + 'script.cjs', + '--base', + 'base.json', + '--pr', + 'pr.json', + '--changed-files', + 'a.ts,b.ts', + '--threshold', + '2.5', + '--output', + 'out.md', + '--base-path', + '/project', + ]; + const args = parseArgs(argv); + expect(args.base).toBe('base.json'); + expect(args.pr).toBe('pr.json'); + expect(args.changedFiles).toBe('a.ts,b.ts'); + expect(args.threshold).toBe(2.5); + expect(args.output).toBe('out.md'); + expect(args.basePath).toBe('/project'); + }); + + it('has sensible defaults', () => { + const args = parseArgs(['node', 'script.cjs']); + expect(args.base).toBeNull(); + expect(args.pr).toBeNull(); + expect(args.threshold).toBe(1.0); + expect(args.basePath).toBe(''); + }); + }); + + describe('CLI integration', () => { + it('generates a report file from coverage-final.json', () => { + const dir = createTempDir(); + const prCoverage = makeCoverageJson({ + '/project/src/a.ts': { '0': 1, '1': 1, '2': 1 }, + }); + const prPath = writeCoverageFile(dir, 'pr.json', prCoverage); + const outputPath = path.join(dir, 'report.md'); + + execFileSync(process.execPath, [ + scriptPath, + '--pr', + prPath, + '--output', + outputPath, + '--base-path', + '/project', + ]); + + const report = fs.readFileSync(outputPath, 'utf8'); + expect(report).toContain(''); + expect(report).toContain('100.0%'); + }); + + it('generates report with base comparison', () => { + const dir = createTempDir(); + const baseCoverage = makeCoverageJson({ + 'src/a.ts': { '0': 1, '1': 0 }, + }); + const prCoverage = makeCoverageJson({ + 'src/a.ts': { '0': 1, '1': 1 }, + }); + const basePath = writeCoverageFile(dir, 'base.json', baseCoverage); + const prPath = writeCoverageFile(dir, 'pr.json', prCoverage); + const outputPath = path.join(dir, 'report.md'); + + execFileSync(process.execPath, [ + scriptPath, + '--base', + basePath, + '--pr', + prPath, + '--output', + outputPath, + '--changed-files', + 'src/a.ts', + ]); + + const report = fs.readFileSync(outputPath, 'utf8'); + expect(report).toContain('+50.0%'); + expect(report).toContain('`src/a.ts`'); + }); + + it('exits with error when required args are missing', () => { + expect(() => execFileSync(process.execPath, [scriptPath], { stdio: 'pipe' })).toThrow(); + }); + + it('exits with error when coverage drops beyond threshold', () => { + const dir = createTempDir(); + const baseCoverage = makeCoverageJson({ + 'src/a.ts': { '0': 1, '1': 1, '2': 1, '3': 1 }, + }); + const prCoverage = makeCoverageJson({ + 'src/a.ts': { '0': 1, '1': 0, '2': 0, '3': 0 }, + }); + const basePath = writeCoverageFile(dir, 'base.json', baseCoverage); + const prPath = writeCoverageFile(dir, 'pr.json', prCoverage); + const outputPath = path.join(dir, 'report.md'); + + expect(() => + execFileSync( + process.execPath, + [ + scriptPath, + '--base', + basePath, + '--pr', + prPath, + '--output', + outputPath, + '--threshold', + '1.0', + ], + { stdio: 'pipe' }, + ), + ).toThrow(); + }); + + it('handles empty coverage gracefully', () => { + const dir = createTempDir(); + const prPath = writeCoverageFile(dir, 'pr.json', {}); + const outputPath = path.join(dir, 'report.md'); + + execFileSync(process.execPath, [scriptPath, '--pr', prPath, '--output', outputPath]); + + const report = fs.readFileSync(outputPath, 'utf8'); + expect(report).toContain('Coverage data unavailable'); + }); + }); +}); From 2b3c838f267c82f88bfa1e287e930155c9b86115 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Sat, 7 Feb 2026 14:07:58 +0800 Subject: [PATCH 3/6] chore: remove unused Python coverage script Co-Authored-By: Claude Opus 4.6 Signed-off-by: Jared Scott --- scripts/coverage_report.py | 225 ------------------------------------- 1 file changed, 225 deletions(-) delete mode 100644 scripts/coverage_report.py diff --git a/scripts/coverage_report.py b/scripts/coverage_report.py deleted file mode 100644 index 69a48ad..0000000 --- a/scripts/coverage_report.py +++ /dev/null @@ -1,225 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate a coverage report for PR comments. - -Compares coverage between base (master) and PR branches, -generates a markdown report for changed files. -""" - -import argparse -import json -import sys -from pathlib import Path - - -def parse_coverage(coverage_file, base_path=""): - """Parse V8/Istanbul coverage-final.json output. - - Returns dict with 'overall' percentage and 'files' dict, or None. - """ - if not coverage_file.exists(): - return None - - try: - with open(coverage_file) as f: - data = json.load(f) - except (json.JSONDecodeError, IOError): - return None - - if not data: - return None - - files = {} - total_statements = 0 - covered_statements = 0 - - for filepath, file_data in data.items(): - statements = file_data.get("s", {}) - num_statements = len(statements) - num_covered = sum(1 for v in statements.values() if v > 0) - - rel_path = filepath - if base_path: - normalized_base = base_path.rstrip("/") - if filepath.startswith(normalized_base + "/"): - rel_path = filepath[len(normalized_base) + 1 :] - - if num_statements > 0: - files[rel_path] = { - "percent": (num_covered / num_statements) * 100, - "covered": num_covered, - "total": num_statements, - } - total_statements += num_statements - covered_statements += num_covered - - if total_statements == 0: - return None - - return { - "overall": (covered_statements / total_statements) * 100, - "files": files, - } - - -def calculate_delta(base_pct, pr_pct, threshold=1.0): - """Calculate coverage delta and determine pass/fail status.""" - if base_pct is None: - return {"delta": None, "indicator": "NEW", "passed": True} - - delta = pr_pct - base_pct - - if delta >= 0: - return {"delta": delta, "indicator": "+", "passed": True} - elif delta >= -threshold: - return {"delta": delta, "indicator": "~", "passed": True} - else: - return {"delta": delta, "indicator": "!", "passed": False} - - -def format_delta(delta_info): - """Format delta for display in markdown table.""" - if delta_info["delta"] is None: - return delta_info["indicator"] - sign = "+" if delta_info["delta"] >= 0 else "" - return f"{sign}{delta_info['delta']:.1f}% {delta_info['indicator']}" - - -def filter_changed_files(coverage_files, changed_files): - """Filter coverage data to only include changed files.""" - changed_set = set(changed_files) - return { - filepath: data - for filepath, data in coverage_files.items() - if filepath in changed_set - } - - -def generate_report(base_data, pr_data, changed_files, threshold=1.0): - """Generate markdown coverage report for PR comment.""" - if not pr_data: - return """ -## Coverage Report - -Coverage data unavailable. - -No coverage artifacts were found for this PR. This can happen when: -- The test workflows have not completed yet -- The coverage artifacts failed to upload -- This is the first run on a new branch - -Coverage will be reported once test workflows complete successfully. -""" - - lines = [ - "", - "## Coverage Report", - "", - "### Summary", - "| Metric | Coverage | Change |", - "|--------|----------|--------|", - ] - - pr_delta = calculate_delta( - base_data["overall"] if base_data else None, - pr_data["overall"], - threshold, - ) - delta_str = format_delta(pr_delta) - lines.append(f"| **Overall** | {pr_data['overall']:.1f}% | {delta_str} |") - - # Changed files section - pr_changed = {} - if changed_files: - pr_changed = filter_changed_files(pr_data.get("files", {}), changed_files) - - if pr_changed: - lines.extend( - [ - "", - "
", - "Changed Files", - "", - "| File | Coverage | Change |", - "|------|----------|--------|", - ] - ) - for filepath, data in sorted(pr_changed.items()): - base_pct = None - if base_data and filepath in base_data.get("files", {}): - base_pct = base_data["files"][filepath]["percent"] - delta = calculate_delta(base_pct, data["percent"], threshold) - delta_str = format_delta(delta) - lines.append(f"| `{filepath}` | {data['percent']:.1f}% | {delta_str} |") - lines.append("") - lines.append("
") - - lines.append("") - return "\n".join(lines) - - -def main(): - """CLI entry point.""" - parser = argparse.ArgumentParser(description="Generate coverage report for PR") - parser.add_argument( - "--base", type=Path, help="Base branch coverage-final.json" - ) - parser.add_argument( - "--pr", type=Path, required=True, help="PR branch coverage-final.json" - ) - parser.add_argument( - "--changed-files", - type=str, - default="", - help="Comma-separated list of changed files", - ) - parser.add_argument( - "--threshold", - type=float, - default=1.0, - help="Max allowed coverage drop percentage", - ) - parser.add_argument( - "--output", type=Path, required=True, help="Output markdown file path" - ) - parser.add_argument( - "--base-path", - type=str, - default="", - help="Base path to strip from file paths", - ) - - args = parser.parse_args() - - base_data = parse_coverage(args.base, args.base_path) if args.base else None - pr_data = parse_coverage(args.pr, args.base_path) - - changed_files = [f.strip() for f in args.changed_files.split(",") if f.strip()] - - report = generate_report( - base_data=base_data, - pr_data=pr_data, - changed_files=changed_files, - threshold=args.threshold, - ) - - args.output.write_text(report) - print(f"Coverage report written to {args.output}") - - passed = True - if pr_data and base_data: - delta = calculate_delta( - base_data["overall"], pr_data["overall"], args.threshold - ) - if not delta["passed"]: - passed = False - print( - f"Coverage dropped by {abs(delta['delta']):.1f}% " - f"(threshold: {args.threshold}%)" - ) - - sys.exit(0 if passed else 1) - - -if __name__ == "__main__": - main() From 064190b2c8dd2f303049241ecd528017b5306011 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Sat, 7 Feb 2026 14:15:51 +0800 Subject: [PATCH 4/6] fix(ci): address CodeQL security findings in coverage-report workflow - Checkout default branch instead of PR head to prevent untrusted code execution in a privileged workflow_run context - Move attacker-controllable values (head_branch, head_owner) to env variables to prevent shell injection via crafted branch names - Replace tj-actions/changed-files with gh pr diff API call to avoid needing PR code checkout Co-Authored-By: Claude Opus 4.6 Signed-off-by: Jared Scott --- .github/workflows/coverage-report.yml | 31 ++++++++++++++++++--------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/.github/workflows/coverage-report.yml b/.github/workflows/coverage-report.yml index f95274b..48c65d4 100644 --- a/.github/workflows/coverage-report.yml +++ b/.github/workflows/coverage-report.yml @@ -20,28 +20,33 @@ jobs: actions: read steps: - - name: Checkout PR + # Checkout the default branch (trusted code), NOT the PR head. + # This prevents a malicious PR from modifying scripts/coverage-report.cjs + # to exfiltrate secrets via the pull-requests:write permission. + - name: Checkout uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.event.workflow_run.head_sha }} - name: Setup Node uses: actions/setup-node@v4 with: node-version: "22" + # Pass workflow_run context as environment variables to avoid + # shell injection via attacker-controlled branch names. - name: Get PR number id: pr env: GH_TOKEN: ${{ github.token }} + HEAD_OWNER: ${{ github.event.workflow_run.head_repository.owner.login }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + FALLBACK_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} run: | PR_NUMBER=$(gh pr view --repo "${{ github.repository }}" \ - "${{ github.event.workflow_run.head_repository.owner.login }}:${{ github.event.workflow_run.head_branch }}" \ + "${HEAD_OWNER}:${HEAD_BRANCH}" \ --json 'number' --jq '.number' 2>/dev/null || echo "") if [ -z "$PR_NUMBER" ]; then - PR_NUMBER="${{ github.event.workflow_run.pull_requests[0].number }}" + PR_NUMBER="$FALLBACK_PR_NUMBER" echo "Using fallback PR number from workflow_run" fi @@ -78,11 +83,17 @@ jobs: [ -f base-coverage/coverage-final.json ] || echo '{}' > base-coverage/coverage-final.json [ -f pr-coverage/coverage-final.json ] || echo '{}' > pr-coverage/coverage-final.json + # Use the GitHub API to get changed files instead of git-based detection. + # This avoids checking out the PR head (untrusted code). - name: Get changed files id: changed - uses: tj-actions/changed-files@v47 - with: - separator: ',' + env: + GH_TOKEN: ${{ github.token }} + run: | + CHANGED=$(gh pr diff "${{ steps.pr.outputs.number }}" \ + --repo "${{ github.repository }}" \ + --name-only 2>/dev/null | paste -sd ',' - || echo "") + echo "files=$CHANGED" >> $GITHUB_OUTPUT - name: Generate coverage report id: report @@ -90,7 +101,7 @@ jobs: node scripts/coverage-report.cjs \ --base base-coverage/coverage-final.json \ --pr pr-coverage/coverage-final.json \ - --changed-files "${{ steps.changed.outputs.all_changed_files }}" \ + --changed-files "${{ steps.changed.outputs.files }}" \ --threshold ${{ env.COVERAGE_THRESHOLD }} \ --base-path "$(pwd)/" \ --output coverage-report.md From ce829a4205e1fb5c8bd0c1ac4ef77cbe387d6b02 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Sat, 7 Feb 2026 14:20:31 +0800 Subject: [PATCH 5/6] fix(ci): move step outputs to env vars to prevent shell injection Move steps.pr.outputs.number and steps.changed.outputs.files from direct ${{ }} interpolation in run: blocks to env: variables, preventing potential shell injection via attacker-controlled values. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Jared Scott --- .github/workflows/coverage-report.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage-report.yml b/.github/workflows/coverage-report.yml index 48c65d4..5be6f25 100644 --- a/.github/workflows/coverage-report.yml +++ b/.github/workflows/coverage-report.yml @@ -89,19 +89,22 @@ jobs: id: changed env: GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.pr.outputs.number }} run: | - CHANGED=$(gh pr diff "${{ steps.pr.outputs.number }}" \ + CHANGED=$(gh pr diff "$PR_NUMBER" \ --repo "${{ github.repository }}" \ --name-only 2>/dev/null | paste -sd ',' - || echo "") echo "files=$CHANGED" >> $GITHUB_OUTPUT - name: Generate coverage report id: report + env: + CHANGED_FILES: ${{ steps.changed.outputs.files }} run: | node scripts/coverage-report.cjs \ --base base-coverage/coverage-final.json \ --pr pr-coverage/coverage-final.json \ - --changed-files "${{ steps.changed.outputs.files }}" \ + --changed-files "$CHANGED_FILES" \ --threshold ${{ env.COVERAGE_THRESHOLD }} \ --base-path "$(pwd)/" \ --output coverage-report.md From ecad4ab8e4bccc29917e40bf109c7948efbf36ea Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Sat, 7 Feb 2026 15:34:23 +0800 Subject: [PATCH 6/6] feat(ui): add light and dark mode with theme toggle Add system-preference-aware theming with a manual toggle button that persists the user choice to chrome.storage.local. Uses a warm sepia/paper palette for light mode to reduce eye strain. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Jared Scott --- app.zip | Bin 258282 -> 258736 bytes package.json | 2 +- public/css/main.css | 47 ++++++- public/html/index.html | 1 + public/manifest.json | 2 +- src/main.spec.ts | 276 ++++++++++++++++++++++++++++++++++++++++- src/main.ts | 51 ++++++++ 7 files changed, 374 insertions(+), 5 deletions(-) diff --git a/app.zip b/app.zip index 7a8a33d7ab73efe4aa7b32be62f70bb7b99e5397..bdebe4e387d8159f1b844af8cc78d8062343782c 100644 GIT binary patch delta 4113 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 delta 3593 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 UH&^s$Z1@-OMMxZxe7?ZH06>3ucK`qY diff --git a/package.json b/package.json index 97caee2..fe6b5fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "write-wall", - "version": "2.5.0", + "version": "2.6.0", "description": "A simple, sync-able text pad similar to Write Space", "license": "CC-BY-SA-4.0", "private": true, diff --git a/public/css/main.css b/public/css/main.css index 313bd59..d0b27a3 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -28,6 +28,41 @@ --hover-text: #e7f0f5; } +@media (prefers-color-scheme: light) { + :root:not([data-theme]) { + --bg-top: #f0ebe3; + --bg-bottom: #e6e0d6; + --text-main: #2c2416; + --text-muted: #5c4f3a; + --accent: #1a7a94; + --border: #a89a85; + --hover-bg: #d9d1c4; + --hover-text: #1a1408; + } +} + +[data-theme="dark"] { + --bg-top: #0b1116; + --bg-bottom: #05080a; + --text-main: #c9d4dc; + --text-muted: #8fa1ad; + --accent: #2d8ba6; + --border: #3a4650; + --hover-bg: #1a232b; + --hover-text: #e7f0f5; +} + +[data-theme="light"] { + --bg-top: #f0ebe3; + --bg-bottom: #e6e0d6; + --text-main: #2c2416; + --text-muted: #5c4f3a; + --accent: #1a7a94; + --border: #a89a85; + --hover-bg: #d9d1c4; + --hover-text: #1a1408; +} + html, body { position: absolute; @@ -76,7 +111,8 @@ textarea::placeholder { #clear, #copy, -#export { +#export, +#theme-toggle { background: transparent; border: 1px solid var(--border); color: var(--text-muted); @@ -86,6 +122,11 @@ textarea::placeholder { cursor: pointer; } +#theme-toggle { + min-width: calc(5ch + 1rem + 2px); + text-align: center; +} + #count-mode { background: transparent; border: 1px solid var(--border); @@ -93,6 +134,7 @@ textarea::placeholder { font-family: monospace; font-size: 12px; padding: 0.2rem 0.3rem; + color-scheme: light dark; } #last-synced { @@ -101,7 +143,8 @@ textarea::placeholder { #clear:hover, #copy:hover, -#export:hover { +#export:hover, +#theme-toggle:hover { background: var(--hover-bg); color: var(--hover-text); border-color: var(--accent); diff --git a/public/html/index.html b/public/html/index.html index 3cc0e2b..63a0af1 100644 --- a/public/html/index.html +++ b/public/html/index.html @@ -32,6 +32,7 @@ + diff --git a/public/manifest.json b/public/manifest.json index c1a1470..1591961 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,6 +1,6 @@ { "name": "Write Wall", - "version": "2.5.0", + "version": "2.6.0", "manifest_version": 3, "incognito": "split", "description": "A simple, sync-able text pad similar to Write Space", diff --git a/src/main.spec.ts b/src/main.spec.ts index 11d410e..e4887f9 100644 --- a/src/main.spec.ts +++ b/src/main.spec.ts @@ -71,6 +71,13 @@ const setupMainTest = ( clearHandlers.set(event, handler); }), }; + const themeToggleHandlers = new Map void>(); + const themeToggleEl = { + textContent: 'Light', + addEventListener: vi.fn((event: string, handler: () => void) => { + themeToggleHandlers.set(event, handler); + }), + }; const numCharsEl = { innerText: '' }; const usageMaxEl = { hidden: false, innerText: '' }; const lastSyncedEl = { innerText: 'Synced: --' }; @@ -121,6 +128,9 @@ const setupMainTest = ( if (id === 'count-mode') { return countModeEl; } + if (id === 'theme-toggle') { + return themeToggleEl; + } if (id === 'clear') { return includeClearButton ? clearButtonEl : null; } @@ -135,6 +145,22 @@ const setupMainTest = ( } return null; }), + documentElement: { + _dataTheme: null as string | null, + setAttribute: vi.fn(function ( + this: { _dataTheme: string | null }, + _attr: string, + value: string, + ) { + this._dataTheme = value; + }), + getAttribute: vi.fn(function (this: { _dataTheme: string | null }) { + return this._dataTheme; + }), + removeAttribute: vi.fn(function (this: { _dataTheme: string | null }) { + this._dataTheme = null; + }), + }, createElement: vi.fn(() => ({ href: '', download: '', @@ -148,6 +174,7 @@ const setupMainTest = ( getCopyHandler: (event: string) => copyHandlers.get(event) ?? null, getExportHandler: (event: string) => exportHandlers.get(event) ?? null, getClearHandler: (event: string) => clearHandlers.get(event) ?? null, + getThemeToggleHandler: (event: string) => themeToggleHandlers.get(event) ?? null, getHandler: (event: string) => { const list = handlers.get(event); if (!list) { @@ -163,6 +190,7 @@ const setupMainTest = ( countModeEl, local, lastSyncedEl, + themeToggleEl, numCharsEl, sync, textAreaEl, @@ -1012,7 +1040,7 @@ describe('main UI bootstrap', () => { const { chrome, document } = setupMainTest({}, { includeClearButton: false }); const originalGetElementById = document.getElementById; document.getElementById = vi.fn((id: string) => { - if (id === 'copy' || id === 'export' || id === 'count-mode') { + if (id === 'copy' || id === 'export' || id === 'count-mode' || id === 'theme-toggle') { return null; } return originalGetElementById(id); @@ -1022,4 +1050,250 @@ describe('main UI bootstrap', () => { await import('./main.js'); }); + + it('applies stored dark theme on load', async () => { + vi.resetModules(); + const { chrome, document } = setupMainTest( + {}, + { + localOverrides: { + get: vi.fn((_: unknown, callback: (items: Record) => void) => { + callback({ theme: 'dark' }); + }), + }, + }, + ); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: false, addEventListener: vi.fn() })), + ); + + await import('./main.js'); + + expect(document.documentElement.setAttribute).toHaveBeenCalledWith('data-theme', 'dark'); + }); + + it('applies stored light theme on load', async () => { + vi.resetModules(); + const { chrome, document, themeToggleEl } = setupMainTest( + {}, + { + localOverrides: { + get: vi.fn((_: unknown, callback: (items: Record) => void) => { + callback({ theme: 'light' }); + }), + }, + }, + ); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: false, addEventListener: vi.fn() })), + ); + + await import('./main.js'); + + expect(document.documentElement.setAttribute).toHaveBeenCalledWith('data-theme', 'light'); + expect(themeToggleEl.textContent).toBe('Dark'); + }); + + it('uses system preference when no theme stored', async () => { + vi.resetModules(); + const { chrome, document } = setupMainTest({}); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: false, addEventListener: vi.fn() })), + ); + + await import('./main.js'); + + expect(document.documentElement.removeAttribute).toHaveBeenCalledWith('data-theme'); + }); + + it('toggles from dark to light on click', async () => { + vi.resetModules(); + const { chrome, document, themeToggleEl, local, getThemeToggleHandler } = setupMainTest( + {}, + { + localOverrides: { + get: vi.fn((_: unknown, callback: (items: Record) => void) => { + callback({ theme: 'dark' }); + }), + }, + }, + ); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: false, addEventListener: vi.fn() })), + ); + + await import('./main.js'); + + const handler = getThemeToggleHandler('click'); + handler?.(); + + expect(document.documentElement.setAttribute).toHaveBeenCalledWith('data-theme', 'light'); + expect(themeToggleEl.textContent).toBe('Dark'); + expect(local.set).toHaveBeenCalledWith({ theme: 'light' }); + }); + + it('toggles from light to dark on click', async () => { + vi.resetModules(); + const { chrome, document, themeToggleEl, local, getThemeToggleHandler } = setupMainTest( + {}, + { + localOverrides: { + get: vi.fn((_: unknown, callback: (items: Record) => void) => { + callback({ theme: 'light' }); + }), + }, + }, + ); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: false, addEventListener: vi.fn() })), + ); + + await import('./main.js'); + + const handler = getThemeToggleHandler('click'); + handler?.(); + + expect(document.documentElement.setAttribute).toHaveBeenCalledWith('data-theme', 'dark'); + expect(themeToggleEl.textContent).toBe('Light'); + expect(local.set).toHaveBeenCalledWith({ theme: 'dark' }); + }); + + it('logs warning when theme storage fails', async () => { + vi.resetModules(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { + // noop for test + }); + const error = new Error('theme save failed'); + const { chrome, document, getThemeToggleHandler } = setupMainTest( + {}, + { + localOverrides: { + get: vi.fn((_: unknown, callback: (items: Record) => void) => { + callback({ theme: 'dark' }); + }), + set: vi.fn(() => Promise.reject(error)), + }, + }, + ); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: false, addEventListener: vi.fn() })), + ); + + await import('./main.js'); + + const handler = getThemeToggleHandler('click'); + handler?.(); + + await Promise.resolve(); + await Promise.resolve(); + + expect(warnSpy).toHaveBeenCalledWith(error); + }); + + it('updates button text based on system preference when no theme stored', async () => { + vi.resetModules(); + const { chrome, document, themeToggleEl } = setupMainTest({}); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: query === '(prefers-color-scheme: light)', + addEventListener: vi.fn(), + })), + ); + + await import('./main.js'); + + expect(themeToggleEl.textContent).toBe('Dark'); + }); + + it('responds to system preference change when no explicit theme set', async () => { + vi.resetModules(); + let mediaChangeHandler: (() => void) | undefined; + const { chrome, document, themeToggleEl } = setupMainTest({}); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: query === '(prefers-color-scheme: light)', + addEventListener: vi.fn((_event: string, handler: () => void) => { + if (query === '(prefers-color-scheme: dark)') { + mediaChangeHandler = handler; + } + }), + })), + ); + + await import('./main.js'); + + // System says light, so button should say "Dark" + expect(themeToggleEl.textContent).toBe('Dark'); + + // Simulate system switching to dark + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: false, addEventListener: vi.fn() })), + ); + mediaChangeHandler?.(); + + expect(themeToggleEl.textContent).toBe('Light'); + }); + + it('ignores system preference change when explicit theme is set', async () => { + vi.resetModules(); + let mediaChangeHandler: (() => void) | undefined; + const { chrome, document, themeToggleEl } = setupMainTest( + {}, + { + localOverrides: { + get: vi.fn((_: unknown, callback: (items: Record) => void) => { + callback({ theme: 'light' }); + }), + }, + }, + ); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: false, + addEventListener: vi.fn((_event: string, handler: () => void) => { + if (query === '(prefers-color-scheme: dark)') { + mediaChangeHandler = handler; + } + }), + })), + ); + + await import('./main.js'); + + expect(themeToggleEl.textContent).toBe('Dark'); + + // Simulate system preference change — should be ignored since explicit theme is set + mediaChangeHandler?.(); + + // Theme should still be light (button still says "Dark") + expect(document.documentElement.getAttribute()).toBe('light'); + }); }); diff --git a/src/main.ts b/src/main.ts index dd4a3d8..510189a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,7 @@ import { throttle } from './utils.js'; const HOUR_IN_SECONDS = 60 * 60; const FOUR_SECONDS_IN_MIL = 4000; const CURSOR_KEY = 'cursor'; +const THEME_KEY = 'theme'; /* global chrome:readonly */ ((chrome) => { @@ -22,12 +23,32 @@ const CURSOR_KEY = 'cursor'; textAreaEl = document.getElementById('text') as HTMLTextAreaElement, copyButtonEl = document.getElementById('copy'), clearButtonEl = document.getElementById('clear'), + themeToggleEl = document.getElementById('theme-toggle'), exportButtonEl = document.getElementById('export'), countModeEl = document.getElementById('count-mode') as HTMLSelectElement | null, storage = chrome.storage, storageObject: Record = {}; let remoteStoredText = ''; + type Theme = 'light' | 'dark'; + + const getSystemTheme = (): Theme => + globalThis.matchMedia?.('(prefers-color-scheme: light)')?.matches ? 'light' : 'dark'; + + const applyTheme = (theme: Theme) => { + document.documentElement.setAttribute('data-theme', theme); + if (themeToggleEl) { + themeToggleEl.textContent = theme === 'dark' ? 'Light' : 'Dark'; + } + }; + + const removeExplicitTheme = () => { + document.documentElement.removeAttribute('data-theme'); + if (themeToggleEl) { + themeToggleEl.textContent = getSystemTheme() === 'dark' ? 'Light' : 'Dark'; + } + }; + const updateUsage = () => { const numCharEl = document.getElementById('num-chars'); if (!numCharEl) { @@ -76,6 +97,18 @@ const CURSOR_KEY = 'cursor'; // Set the number of bytes in use updateUsage(); + // Load theme preference + if (storage.local) { + storage.local.get(THEME_KEY, (localItems: Record) => { + const stored = localItems[THEME_KEY] as string | undefined; + if (stored === 'light' || stored === 'dark') { + applyTheme(stored); + } else { + removeExplicitTheme(); + } + }); + } + // get or create key to store data storage.sync.get( [LEGACY_STORAGE_KEY, STORAGE_KEY], @@ -232,4 +265,22 @@ const CURSOR_KEY = 'cursor'; throttledStorageUpdate(); }); } + + if (themeToggleEl) { + themeToggleEl.addEventListener('click', () => { + const current = + (document.documentElement.getAttribute('data-theme') as Theme | null) ?? getSystemTheme(); + const next: Theme = current === 'dark' ? 'light' : 'dark'; + applyTheme(next); + storage.local?.set({ [THEME_KEY]: next }).catch((e: unknown) => { + console.warn(e); + }); + }); + } + + globalThis.matchMedia?.('(prefers-color-scheme: dark)')?.addEventListener('change', () => { + if (!document.documentElement.getAttribute('data-theme')) { + removeExplicitTheme(); + } + }); })(chrome);