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,
+ },
+ },
},
});