ADDED - test gate: pre-push hook, copy-paste summary, make targets, mandatory-tests rule, CI test step
This commit is contained in:
Executable
+21
@@ -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."
|
||||||
@@ -29,5 +29,10 @@ jobs:
|
|||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run 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
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ SITE_RUNTIME_ORIGIN Internal origin for middleware to fetch runtime state
|
|||||||
|
|
||||||
## Working rules
|
## 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.
|
- 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.
|
- Make the smallest safe change that solves the task.
|
||||||
- Do not modify unrelated files.
|
- Do not modify unrelated files.
|
||||||
|
|||||||
@@ -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
|
MIGRATION_NAME ?= init
|
||||||
|
|
||||||
@@ -46,6 +46,12 @@ db-migrate:
|
|||||||
db-seed:
|
db-seed:
|
||||||
docker compose exec app npx prisma db seed
|
docker compose exec app npx prisma db seed
|
||||||
|
|
||||||
|
test:
|
||||||
|
@node scripts/test-summary.mjs
|
||||||
|
|
||||||
|
test-watch:
|
||||||
|
npm run test:watch
|
||||||
|
|
||||||
prisma-generate:
|
prisma-generate:
|
||||||
docker compose exec app npx prisma generate
|
docker compose exec app npx prisma generate
|
||||||
|
|
||||||
@@ -74,3 +80,5 @@ help:
|
|||||||
@echo " make prisma-generate Run prisma generate"
|
@echo " make prisma-generate Run prisma generate"
|
||||||
@echo " make prisma-migrate Create/apply dev migration"
|
@echo " make prisma-migrate Create/apply dev migration"
|
||||||
@echo " make health Check app health endpoint via public domain"
|
@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"
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user