diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..a8e869a --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# +# Pre-push test gate. The deploy server only ever PULLS, so the real place to +# stop broken code is right here — before anything leaves this machine. +# +# The suite needs no external database: integration tests spin up an in-process +# PGlite database per worker (see tests/helpers/integration-setup.ts), so +# `npm test` runs standalone. A real failure blocks the push; the run ends with a +# compact copy-pasteable summary (scripts/test-summary.mjs). +# +# Install once: git config core.hooksPath .githooks +# Emergency skip: git push --no-verify +# +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +echo "▶ pre-push: running the full test suite…" +node scripts/test-summary.mjs + +echo "✓ all tests green — pushing." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a6b02b..20ed568 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,10 @@ jobs: - name: Lint run: npm run lint + - name: Test + # Integration tests use an in-process PGlite database, so no service + # container is needed — the suite runs standalone. + run: npm test + - name: Build run: npm run build diff --git a/CLAUDE.md b/CLAUDE.md index 0c8d43a..ea4bc29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,7 @@ SITE_RUNTIME_ORIGIN Internal origin for middleware to fetch runtime state ## Working rules +- **Tests are mandatory for every logic change, in the SAME change.** New behaviour → new tests covering the intent (positive **and** negative cases), not one happy example. Deliberate change → update the affected tests and say which/why. A test that fails unexpectedly is a real bug → fix the code, not the test. Test the real thing — integration tests use a real (in-process PGlite) database, so do NOT mock our own `lib/`/DB layer; only true external boundaries (the admin session, third-party APIs, SMTP) may be substituted. Keep the suite green (`make test`); the `pre-push` hook (`.githooks/pre-push`) enforces it. Frontend/UI is verified manually; add component tests only for components with real logic. - Before making any change, first explain the plan briefly and list the files that will be touched. - Make the smallest safe change that solves the task. - Do not modify unrelated files. diff --git a/Makefile b/Makefile index 236c4d9..ad646ac 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: start stop restart deploy logs build ps port health clean-orphans app-shell db-shell db-init db-migrate db-seed prisma-generate prisma-migrate help +.PHONY: start stop restart deploy logs build ps port health clean-orphans app-shell db-shell db-init db-migrate db-seed prisma-generate prisma-migrate test test-watch help MIGRATION_NAME ?= init @@ -46,6 +46,12 @@ db-migrate: db-seed: docker compose exec app npx prisma db seed +test: + @node scripts/test-summary.mjs + +test-watch: + npm run test:watch + prisma-generate: docker compose exec app npx prisma generate @@ -74,3 +80,5 @@ help: @echo " make prisma-generate Run prisma generate" @echo " make prisma-migrate Create/apply dev migration" @echo " make health Check app health endpoint via public domain" + @echo " make test Run the whole test suite + print a copy-paste summary" + @echo " make test-watch Run the test suite in watch mode" diff --git a/scripts/test-summary.mjs b/scripts/test-summary.mjs new file mode 100644 index 0000000..d0757ce --- /dev/null +++ b/scripts/test-summary.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * Runs the test suite with Vitest's JSON reporter (plus the normal live output) + * and prints ONE compact, copy-pasteable summary at the end — pass/fail counts + * and every failing test with a one-line reason. Paste the block between the + * ===== markers to hand off the full picture without a wall of logs. + * + * Exits with the suite's own status, so `make test` / the pre-push hook still + * block on failure. This file is the same across all my projects; only RUNS + * differs (a project with several vitest configs lists one entry per config). + */ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// The npm script(s) that together make up "the whole suite". Run through npm (not +// `npx vitest` directly) so nested tooling in a globalSetup — e.g. `npx drizzle-kit +// migrate` — resolves with the right PATH. A project split across several vitest +// configs lists one entry per config. +const RUNS = [{ label: "all", args: ["test"] }]; + +const reporterArgs = (out) => ["--", "--reporter=default", "--reporter=json", `--outputFile.json=${out}`]; + +const projectName = (() => { + try { + return JSON.parse(readFileSync("package.json", "utf8")).name ?? "project"; + } catch { + return "project"; + } +})(); + +const workDir = mkdtempSync(join(tmpdir(), "test-summary-")); +const started = Date.now(); +let status = 0; +const reports = []; + +for (const run of RUNS) { + const out = join(workDir, `${run.label}.json`); + const res = spawnSync("npm", ["run", ...run.args, ...reporterArgs(out)], { + stdio: "inherit", + shell: process.platform === "win32", + }); + if (res.status !== 0) status = res.status ?? 1; + try { + reports.push(JSON.parse(readFileSync(out, "utf8"))); + } catch { + /* a crash before the report was written — status already non-zero */ + } +} + +let total = 0; +let passed = 0; +let failed = 0; +let skipped = 0; +let files = 0; +let filesFailed = 0; +const failures = []; +const cwd = process.cwd(); + +for (const r of reports) { + total += r.numTotalTests ?? 0; + passed += r.numPassedTests ?? 0; + failed += r.numFailedTests ?? 0; + skipped += (r.numPendingTests ?? 0) + (r.numTodoTests ?? 0); + for (const tr of r.testResults ?? []) { + files += 1; + const fileFailed = tr.status === "failed" || (tr.assertionResults ?? []).some((a) => a.status === "failed"); + if (fileFailed) filesFailed += 1; + for (const a of tr.assertionResults ?? []) { + if (a.status !== "failed") continue; + const file = (tr.name ?? "").replace(`${cwd}/`, ""); + const name = a.fullName || [...(a.ancestorTitles ?? []), a.title].filter(Boolean).join(" › "); + const reason = + (a.failureMessages ?? []) + .join("\n") + .split("\n") + .map((l) => l.trim()) + .find((l) => l && !l.startsWith("at ")) ?? ""; + failures.push({ file, name, reason: reason.slice(0, 200) }); + } + } +} + +const elapsed = ((Date.now() - started) / 1000).toFixed(1); +const ok = status === 0 && failed === 0 && reports.length > 0; +const L = "=".repeat(38); + +const lines = []; +lines.push(L); +lines.push(`TEST SUMMARY — ${projectName} (${new Date().toISOString().slice(0, 16).replace("T", " ")})`); +if (reports.length === 0) { + lines.push("❌ CRASH — the test run failed before producing a report (see output above)."); +} else { + lines.push( + `${ok ? "✅ PASS" : "❌ FAIL"} — ${passed}/${total} tests passed` + + (failed ? `, ${failed} failed` : "") + + (skipped ? `, ${skipped} skipped` : "") + + ` · ${files} files (${filesFailed} failed) · ${elapsed}s`, + ); + if (failures.length) { + lines.push(""); + lines.push(`FAILED (${failures.length}):`); + for (const f of failures) { + lines.push(` ✗ ${f.file} › ${f.name}`); + if (f.reason) lines.push(` ${f.reason}`); + } + } +} +lines.push(L); + +console.log(`\n${lines.join("\n")}`); +rmSync(workDir, { recursive: true, force: true }); +process.exit(status);