From b76c6f4e08da80a290d1baa804127b1cee8d9ac2 Mon Sep 17 00:00:00 2001 From: omartelo Date: Thu, 16 Jul 2026 13:44:31 -0300 Subject: [PATCH] ci: surface test counts + coverage in the pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ci.yml — a per-PR and push-to-main gate that runs gofmt, go vet and both suites; release.yml now runs its test steps through the same shared scripts. Both render pass/fail counts and coverage into the GitHub Actions job summary (.github/scripts/{go,web}-test-summary.sh) and still fail the job on a red test. Frontend coverage is measured over the .ts logic layer via a new @vitest/coverage-v8 devDep; the .tsx DOM/xterm boundary stays out of the denominator (invariant #1). CLAUDE.md documents the local gate — fmt, vet, test, build before every commit or PR. Previously CI ran no tests on PRs at all. --- .github/scripts/go-test-summary.sh | 41 ++++++++++ .github/scripts/web-test-summary.sh | 31 ++++++++ .github/workflows/ci.yml | 61 +++++++++++++++ .github/workflows/release.yml | 12 ++- .gitignore | 1 + CLAUDE.md | 16 ++++ frontend/package.json | 1 + frontend/pnpm-lock.yaml | 112 +++++++++++++++++++++++++++- frontend/vitest.config.ts | 11 +++ 9 files changed, 280 insertions(+), 6 deletions(-) create mode 100755 .github/scripts/go-test-summary.sh create mode 100755 .github/scripts/web-test-summary.sh create mode 100644 .github/workflows/ci.yml diff --git a/.github/scripts/go-test-summary.sh b/.github/scripts/go-test-summary.sh new file mode 100755 index 000000000..a57e1715f --- /dev/null +++ b/.github/scripts/go-test-summary.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Runs the Go suite once, appends pass/fail/skip counts and total statement +# coverage to the GitHub Actions job summary, and exits with the suite's own +# status so a red test still fails the job. Arg $1: optional label (e.g. the OS) +# appended to the heading. Falls back to stdout when run outside Actions. +set -uo pipefail + +label="${1:+ — $1}" +json="$(mktemp)" +cover="$(mktemp)" + +go test -json -coverprofile="$cover" ./... >"$json" +code=$? + +read -r pass fail skip < <(jq -rs ' + map(select(.Test != null and (.Action == "pass" or .Action == "fail" or .Action == "skip"))) + | [ (map(select(.Action == "pass")) | length), + (map(select(.Action == "fail")) | length), + (map(select(.Action == "skip")) | length) ] | @tsv +' "$json") + +total="$(go tool cover -func="$cover" | awk '/^total:/ {print $3}')" + +summary="${GITHUB_STEP_SUMMARY:-/dev/stdout}" +{ + echo "### Backend (Go)${label}" + echo "" + echo "| Passed | Failed | Skipped | Coverage |" + echo "|-------:|-------:|--------:|---------:|" + echo "| ${pass:-0} | ${fail:-0} | ${skip:-0} | ${total:-n/a} |" + echo "" + if [ "${fail:-0}" != "0" ]; then + echo "
Failed tests" + echo "" + jq -rs 'map(select(.Action == "fail" and .Test != null)) | .[] | "- `\(.Package) \(.Test)`"' "$json" | sort -u + echo "" + echo "
" + fi +} >>"$summary" + +exit "$code" diff --git a/.github/scripts/web-test-summary.sh b/.github/scripts/web-test-summary.sh new file mode 100755 index 000000000..f01b2027b --- /dev/null +++ b/.github/scripts/web-test-summary.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Runs the frontend (Vitest) suite with coverage, appends file/pass/fail counts +# and line coverage to the GitHub Actions job summary, and exits with the +# suite's own status. node_modules must already be installed. Falls back to +# stdout when run outside Actions. +set -uo pipefail + +cd "$(dirname "$0")/../../frontend" +out="$(mktemp)" + +pnpm exec vitest run --coverage --reporter=default --reporter=json --outputFile.json="$out" +code=$? + +read -r files passed failed < <(jq -r ' + [ (.testResults | length), .numPassedTests, .numFailedTests ] | @tsv +' "$out") + +cov="$(jq -r '.total.lines.pct' coverage/coverage-summary.json 2>/dev/null || echo n/a)" +[ "$cov" != "n/a" ] && cov="${cov}%" + +summary="${GITHUB_STEP_SUMMARY:-/dev/stdout}" +{ + echo "### Frontend (Vitest)" + echo "" + echo "| Files | Passed | Failed | Coverage (lines) |" + echo "|------:|-------:|-------:|-----------------:|" + echo "| ${files:-0} | ${passed:-0} | ${failed:-0} | ${cov:-n/a} |" + echo "" +} >>"$summary" + +exit "$code" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..279ef6ed8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: ci + +# Fast per-PR gate: format/vet + both suites, with pass/fail counts and +# coverage rendered into the job summary. Windows backend is validated at +# release time (release.yml); a PR runs the linux path only for quick feedback. +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: frontend/package.json + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + # dist must exist before any Go step: the backend go:embeds frontend/dist, + # so it will not even compile without it. build also runs tsc (typecheck). + - name: Frontend install + build + working-directory: frontend + run: | + pnpm install --frozen-lockfile + pnpm build + + - name: gofmt + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "::error::gofmt: these files need formatting:" + echo "$unformatted" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: Frontend tests + run: bash .github/scripts/web-test-summary.sh + + - name: Backend tests + run: bash .github/scripts/go-test-summary.sh linux diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a8fa1a5d..5c38fc2d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,15 +46,19 @@ jobs: # Frontend first: the backend embeds frontend/dist (go:embed), so Go # tests only compile once the frontend has been built. - - name: Frontend install + tests + build + - name: Frontend install + build working-directory: frontend run: | pnpm install --frozen-lockfile - pnpm test pnpm build + # Both suites emit pass/fail counts + coverage into the job summary and + # still fail the job on a red test (shared with ci.yml). + - name: Frontend tests + run: bash .github/scripts/web-test-summary.sh + - name: Backend tests - run: go test ./... + run: bash .github/scripts/go-test-summary.sh linux - name: Build Linux packages run: task package @@ -120,7 +124,7 @@ jobs: run: task package:windows - name: Backend tests (windows) - run: go test ./... + run: bash .github/scripts/go-test-summary.sh windows - name: Assemble Windows assets run: | diff --git a/.gitignore b/.gitignore index dfe51416e..2367951bb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ bin .idea/ frontend/dist frontend/node_modules +frontend/coverage diff --git a/CLAUDE.md b/CLAUDE.md index c8a273384..d94c862e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,22 @@ task package:windows # bin/lich-setup.exe installer (needs Inno Setup 6 — CI/ Frontend in isolation: `cd frontend && pnpm run build` (runs `tsc` + `vite build`). +## Local Gate (before every commit / PR) + +Run the whole check locally before pushing a commit or opening a PR — never lean +on CI to find what a local run catches in seconds: + +- `gofmt -l .` clean (fix with `gofmt -w .`) and `go vet ./...` clean. +- `go test ./...` (backend) and `cd frontend && pnpm test` (frontend) green — + or `task test` for both at once. +- `cd frontend && pnpm build` succeeds (tsc typecheck + vite). + +CI mirrors exactly these: `ci.yml` runs them on every PR and push to `main`, +`release.yml` on a tag. Both render pass/fail counts and coverage into the +Actions job summary (`.github/scripts/*-test-summary.sh`), so the numbers are +visible per run rather than buried in the log. A red test fails the job — the +summary is transparency, never a substitute for the gate. + --- ## Hard Invariants diff --git a/frontend/package.json b/frontend/package.json index e5023c2d0..d9b3cc869 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,6 +43,7 @@ "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^6.0.0", + "@vitest/coverage-v8": "4.1.10", "tailwindcss": "^4.3.2", "typescript": "^5.2.2", "vite": "^8.0.5", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5abac94cb..617f344a2 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: '@vitejs/plugin-react': specifier: ^6.0.0 version: 6.0.3(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)) + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.10) tailwindcss: specifier: ^4.3.2 version: 4.3.2 @@ -107,7 +110,7 @@ importers: version: 8.1.3(@types/node@26.1.0)(jiti@2.7.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)) + version: 4.1.10(@types/node@26.1.0)(@vitest/coverage-v8@4.1.10)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)) packages: @@ -271,6 +274,10 @@ packages: '@types/react': optional: true + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@codemirror/autocomplete@6.20.3': resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} @@ -765,6 +772,15 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -852,6 +868,9 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + atomically@1.7.0: resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} engines: {node: '>=10.12.0'} @@ -1255,6 +1274,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -1267,6 +1290,9 @@ packages: resolution: {integrity: sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==} engines: {node: '>=16.9.0'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -1390,6 +1416,18 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + 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'} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -1397,6 +1435,9 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1537,6 +1578,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1956,6 +2004,10 @@ packages: style-mod@4.1.3: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + systeminformation@5.31.13: resolution: {integrity: sha512-iUJXJoKzm4vtLSeT3nwe2s9QjoJAxHg7wYJ0KaQ54Xy2u9jsTq0ULWQQ0+T72FXjX2XnGqubazNx9lUfng7ELw==} engines: {node: '>=8.0.0'} @@ -2402,6 +2454,8 @@ snapshots: optionalDependencies: '@types/react': 18.3.31 + '@bcoe/v8-coverage@1.0.2': {} + '@codemirror/autocomplete@6.20.3': dependencies: '@codemirror/language': 6.12.4 @@ -3049,6 +3103,20 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.1.3(@types/node@26.1.0)(jiti@2.7.0) + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.3 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@26.1.0)(@vitest/coverage-v8@4.1.10)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)) + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -3132,6 +3200,12 @@ snapshots: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + atomically@1.7.0: {} balanced-match@4.0.4: {} @@ -3522,6 +3596,8 @@ snapshots: graceful-fs@4.2.11: {} + has-flag@4.0.0: {} + has-symbols@1.1.0: {} hasown@2.0.4: @@ -3530,6 +3606,8 @@ snapshots: hono@4.12.28: {} + html-escaper@2.0.2: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -3611,10 +3689,25 @@ snapshots: isexe@3.1.5: {} + 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 + jiti@2.7.0: {} jose@6.2.3: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.3.0: @@ -3720,6 +3813,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + math-intrinsics@1.1.0: {} media-typer@1.1.0: {} @@ -4161,6 +4264,10 @@ snapshots: style-mod@4.1.3: {} + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + systeminformation@5.31.13: {} tailwind-merge@3.6.0: {} @@ -4249,7 +4356,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 - vitest@4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)): + vitest@4.1.10(@types/node@26.1.0)(@vitest/coverage-v8@4.1.10)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)) @@ -4273,6 +4380,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.0 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) transitivePeerDependencies: - msw diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index fd6a639ec..daa419317 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -13,5 +13,16 @@ export default defineConfig({ test: { environment: "node", include: ["src/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text-summary", "json-summary"], + reportsDirectory: "coverage", + // The suite owns the pure logic layer (`.ts`: lib, hooks, stores); the + // `.tsx` components are the DOM/xterm boundary invariant #1 exempts, so + // they stay out of the denominator — including them would report a + // misleadingly low number against a suite that never targeted them. + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/**/*.d.ts"], + }, }, })