diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 609a8bf..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] @@ -48,5 +51,22 @@ 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 + + - 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..5be6f25 --- /dev/null +++ b/.github/workflows/coverage-report.yml @@ -0,0 +1,126 @@ +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: + # 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 + + - 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 }}" \ + "${HEAD_OWNER}:${HEAD_BRANCH}" \ + --json 'number' --jq '.number' 2>/dev/null || echo "") + + if [ -z "$PR_NUMBER" ]; then + PR_NUMBER="$FALLBACK_PR_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 + + # 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 + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + run: | + 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 "$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/.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/app.zip b/app.zip index 7a8a33d..bdebe4e 100644 Binary files a/app.zip and b/app.zip differ diff --git a/biome.json b/biome.json index 5e7788d..9359cee 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,7 @@ { "$schema": "https://biomejs.dev/schemas/2.3.14/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 46ac11b..fe6b5fa 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,14 @@ { "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, "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": "vite build --watch", @@ -25,6 +27,7 @@ "@biomejs/biome": "2.3.14", "@testing-library/user-event": "^14.6.1", "@types/chrome": "^0.1.36", + "@vitest/coverage-v8": "^4.0.18", "adm-zip": "^0.5.16", "corepack": "^0.34.6", "globals": "^17.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4d643f..0e7c617 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 @@ -48,14 +51,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.14': resolution: {integrity: sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA==} engines: {node: '>=14.21.3'} @@ -290,6 +310,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] @@ -468,6 +491,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==} @@ -525,6 +557,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.11: + resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -595,11 +630,29 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} hasBin: true + 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'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -610,6 +663,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'} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -652,6 +712,11 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -791,12 +856,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.14': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.3.14 @@ -917,8 +995,7 @@ snapshots: '@jridgewell/trace-mapping': 0.3.25 optional: true - '@jridgewell/resolve-uri@3.1.2': - optional: true + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/set-array@1.2.1': optional: true @@ -937,6 +1014,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 optional: true + '@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 @@ -1058,6 +1140,20 @@ snapshots: undici-types: 6.20.0 optional: true + '@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 @@ -1116,6 +1212,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 + buffer-from@1.1.2: optional: true @@ -1189,8 +1291,25 @@ snapshots: has-flag@4.0.0: {} + html-escaper@2.0.2: {} + husky@9.1.7: {} + 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 + + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} lz-string@1.5.0: {} @@ -1199,6 +1318,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.4 + nanoid@3.3.11: {} npm-check-updates@19.3.2: {} @@ -1258,6 +1387,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.56.0 fsevents: 2.3.3 + semver@7.7.4: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} 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/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/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'); + }); + }); +}); diff --git a/src/main.spec.ts b/src/main.spec.ts index 4ff89bb..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, @@ -672,4 +700,600 @@ describe('main UI bootstrap', () => { const html = readFileSync(new URL('../public/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' || id === 'theme-toggle') { + return null; + } + return originalGetElementById(id); + }); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('document', document); + + 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); 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, + }, + }, }, });