From 1b8bcb855b55d55d59f9bde12ffcd4e00934fbdc Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sat, 14 Nov 2026 01:42:03 -0300 Subject: [PATCH] ci: unit test coverage in the job summary, held to a floor vitest run --coverage writes coverage/ on every task test, and fails below the statements floor in the vitest config, a little under today's number. The Gate job writes the totals to its summary and keeps the HTML as coverage-web for 7 days, even when a test fails. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 10 ++++ .gitignore | 1 + Taskfile.yml | 7 ++- hack/coverage-summary.mjs | 29 ++++++++++ package.json | 3 +- pnpm-lock.yaml | 108 +++++++++++++++++++++++++++++++++++++- vitest.config.mts | 12 +++++ 7 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 hack/coverage-summary.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 153e3e4..bccf18d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,16 @@ jobs: with: go-version-file: go.mod - run: go tool task ci + # The coverage task test wrote, even when a test failed: the summary says so when there is none. + - if: always() + run: go tool task coverage:summary + - if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: coverage-web + path: coverage + retention-days: 7 + if-no-files-found: ignore integration: name: Integration diff --git a/.gitignore b/.gitignore index 5bef1d6..21518de 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist-test/ build/ bin/ .wt/ +coverage/ diff --git a/Taskfile.yml b/Taskfile.yml index 2d5fb05..48790a6 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -33,11 +33,16 @@ tasks: - pnpm typecheck test: - desc: The unit tests - Vitest, against the fake spin in test/fake-spin/, and hack/'s Go. + desc: The unit tests - Vitest, against the fake spin in test/fake-spin/, with coverage held to the floor in vitest.config.mts - and hack/'s Go. cmds: - pnpm test - go test ./hack/... + coverage:summary: + desc: The coverage task test left in coverage/, as the job's summary (hack/coverage-summary.mjs); the HTML is coverage/index.html. + cmds: + - node hack/coverage-summary.mjs + test:cli: desc: The extension's reader against the real spin CLI of the pinned release (test/cli/), pulled and checked by package:spin - minutes, so it is not in task test. cmds: diff --git a/hack/coverage-summary.mjs b/hack/coverage-summary.mjs new file mode 100644 index 0000000..08153a7 --- /dev/null +++ b/hack/coverage-summary.mjs @@ -0,0 +1,29 @@ +// The unit tests' coverage, from what `vitest run --coverage` writes into coverage/, as the job's +// summary (stdout outside a workflow). The HTML beside it is the coverage-web artifact. +import { appendFileSync, existsSync, readFileSync } from "node:fs" + +const file = "coverage/coverage-summary.json" +const write = (text) => { + const summary = process.env.GITHUB_STEP_SUMMARY + if (summary) appendFileSync(summary, `${text}\n`) + else console.log(text) +} + +if (!existsSync(file)) { + write(`${file} was not produced; the tests may have failed before coverage ran.`) +} else { + const { total } = JSON.parse(readFileSync(file, "utf8")) + const rows = ["statements", "branches", "functions", "lines"].map((key) => { + const { covered, total: all, pct } = total[key] + return `| ${key} | ${covered} | ${all} | ${pct}% |` + }) + write( + [ + "## Unit test coverage", + "", + "| Metric | Covered | Total | % |", + "|---|---:|---:|---:|", + ...rows, + ].join("\n"), + ) +} diff --git a/package.json b/package.json index a6ae283..7213f97 100644 --- a/package.json +++ b/package.json @@ -210,7 +210,7 @@ "lint": "biome check .", "lint:fix": "biome check --write .", "typecheck": "tsc --noEmit", - "test": "vitest run", + "test": "vitest run --coverage", "test:cli": "vitest run --config vitest.cli.config.mts", "test:integration": "node esbuild.mjs --tests && node test/integration/run.mjs", "package": "vsce package --no-dependencies --out spinbox.vsix" @@ -219,6 +219,7 @@ "@biomejs/biome": "2.6.3", "@types/node": "22.20.5", "@types/vscode": "1.146.0", + "@vitest/coverage-v8": "5.0.5", "@vscode/test-electron": "3.1.0", "@vscode/vsce": "4.1.0", "esbuild": "0.28.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15c62f7..c22f1ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@types/vscode': specifier: 1.146.0 version: 1.146.0 + '@vitest/coverage-v8': + specifier: 5.0.5 + version: 5.0.5(vitest@5.0.5) '@vscode/test-electron': specifier: 3.1.0 version: 3.1.0 @@ -34,7 +37,7 @@ importers: version: 7.0.2 vitest: specifier: 5.0.5 - version: 5.0.5(@types/node@22.20.5)(vite@8.3.5(@types/node@22.20.5)(esbuild@0.28.2)) + version: 5.0.5(@types/node@22.20.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@22.20.5)(esbuild@0.28.2)) packages: @@ -86,6 +89,27 @@ packages: resolution: {integrity: sha512-52hKQLGpEQzwNufIXG1SPVU+wQ3RIe7Rp2+g+VdAmZojCAZ6yCdGEffzQEQ6RnBZGBEs40nl14rWBmfJpCoHsg==} engines: {node: '>=20'} + '@babel/helper-string-parser@7.29.14': + resolution: {integrity: sha512-CXvKMFvM80ihI7Vox9jb+kQjnyjESQNPM+vB7U0Zz6TWuZA+Z/FL0QPMH300h33qJtIx+REeBhXqWOPW48TcAQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.14': + resolution: {integrity: sha512-VL220vXuyO2z3rNIQwY7ftj9UwpqOt+33sH+sBWkOekbsSR2g7EEMnOx24ZlCH0xOA3DM/33SifVFGbnJD8r/w==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.14': + resolution: {integrity: sha512-UjI/dKmwJwT9pOszJitq2G6EObZPnsZUfdCr92z2TlF3EZK9J3EdfOKUrCzHl6wFthbzwhzl1Soc/UIwxRiq6w==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@biomejs/biome@2.6.3': resolution: {integrity: sha512-va/0xPku7wnfzv+D8Zpdm5uKiGJWiV8neqoEPnRjvNQQwhl4tVXS4fETs4H0CCB2rGeqJ4cu9y4iOZOnOYKAUA==} engines: {node: '>=14.21.3'} @@ -669,6 +693,23 @@ packages: resolution: {integrity: sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==} engines: {node: '>=22.0.0'} + '@vitest/coverage-v8@5.0.5': + resolution: {integrity: sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==} + peerDependencies: + '@vitest/browser': 5.0.5 + vitest: 5.0.5 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/istanbul-lib-coverage@1.0.2': + resolution: {integrity: sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-report@1.0.2': + resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==} + engines: {node: '>=22'} + '@vitest/mocker@5.0.5': resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} peerDependencies: @@ -752,6 +793,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.8: + resolution: {integrity: sha512-lPdpagk13ANXV+HClJEpCZM7kUsl5XcvgqKzZhaj6Ve1ZFICcY2sGhAN1n1vT0tML6EclT7bfVgTxqL57Gkpow==} + azure-devops-node-api@12.5.0: resolution: {integrity: sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==} @@ -984,6 +1028,9 @@ packages: resolution: {integrity: sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==} engines: {node: '>=4'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} @@ -1109,6 +1156,9 @@ packages: magic-string@1.4.5: resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==} + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + marked@18.1.3: resolution: {integrity: sha512-9ChMH8EVVgjgID49nsL6IWxWk5c6GtAsq/xw7U1ggkjkU5AGuv68oLTVlFRtvcUYZtpbNyjyvGo3YDu0fGapiw==} engines: {node: '>= 20'} @@ -1302,6 +1352,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + tslib@2.9.1: resolution: {integrity: sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==} @@ -1531,6 +1585,21 @@ snapshots: '@azure/msal-common': 16.15.0 jsonwebtoken: 9.0.3 + '@babel/helper-string-parser@7.29.14': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.14': + dependencies: + '@babel/types': 7.29.14 + + '@babel/types@7.29.14': + dependencies: + '@babel/helper-string-parser': 7.29.14 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@2.6.3': optionalDependencies: '@biomejs/cli-android-arm64': 2.6.3 @@ -1870,6 +1939,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@5.0.5(vitest@5.0.5)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/istanbul-lib-coverage': 1.0.2 + '@vitest/istanbul-lib-report': 1.0.2 + ast-v8-to-istanbul: 1.0.8 + magicast: 0.5.5 + obug: 3.0.0 + std-env: 4.3.0 + tinyrainbow: 3.2.0 + vitest: 5.0.5(@types/node@22.20.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@22.20.5)(esbuild@0.28.2)) + + '@vitest/istanbul-lib-coverage@1.0.2': {} + + '@vitest/istanbul-lib-report@1.0.2': + dependencies: + '@vitest/istanbul-lib-coverage': 1.0.2 + '@vitest/mocker@5.0.5(vite@8.3.5(@types/node@22.20.5)(esbuild@0.28.2))': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -1967,6 +2054,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.8: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + azure-devops-node-api@12.5.0: dependencies: tunnel: 0.0.6 @@ -2183,6 +2276,8 @@ snapshots: editions: 6.22.0 textextensions: 6.11.0 + js-tokens@10.0.0: {} + jsonc-parser@3.3.1: {} jsonwebtoken@9.0.3: @@ -2296,6 +2391,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.14 + '@babel/types': 7.29.14 + source-map-js: 1.2.2 + marked@18.1.3: {} math-intrinsics@1.1.0: {} @@ -2500,6 +2601,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tinyrainbow@3.2.0: {} + tslib@2.9.1: {} tunnel@0.0.6: {} @@ -2555,7 +2658,7 @@ snapshots: esbuild: 0.28.2 fsevents: 2.3.3 - vitest@5.0.5(@types/node@22.20.5)(vite@8.3.5(@types/node@22.20.5)(esbuild@0.28.2)): + vitest@5.0.5(@types/node@22.20.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@22.20.5)(esbuild@0.28.2)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.5(vite@8.3.5(@types/node@22.20.5)(esbuild@0.28.2)) @@ -2573,6 +2676,7 @@ snapshots: why-is-node-running: 3.2.1 optionalDependencies: '@types/node': 22.20.5 + '@vitest/coverage-v8': 5.0.5(vitest@5.0.5) transitivePeerDependencies: - msw diff --git a/vitest.config.mts b/vitest.config.mts index 8babcc1..e9ce7c0 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -3,5 +3,17 @@ import { defineConfig } from "vitest/config" export default defineConfig({ test: { include: ["test/unit/**/*.test.ts"], + coverage: { + provider: "v8", + // Every file under src/, so one no unit test imports counts as uncovered rather than absent. + include: ["src/**"], + // json-summary is what CI's job summary reads (hack/coverage-summary.mjs); reportOnFailure + // keeps a red suite from leaving it nothing to read. + reporter: ["text", "json-summary", "html"], + reportOnFailure: true, + // A floor below the 87.1% of statements measured on 2026-11-14. Raise it when the number has + // moved up and stayed; never lower it to let a change through. + thresholds: { statements: 85 }, + }, }, })