From 45b5b3468065b01db0bc6911e075ce9abface0a5 Mon Sep 17 00:00:00 2001 From: "nilesh.p" Date: Sat, 10 Oct 2026 13:04:46 +0530 Subject: [PATCH] Add AI-assisted QA test workflow: scenario cards, runner, hooks, API capture A complete loop for defining, generating, running and gating tests: - qa/: scenario-card template + examples (API/UI/binding), the Claude generation prompt, and a QA runbook tying it together - e2e/CLAUDE.md: conventions that keep AI-generated tests consistent - run-tests.ps1: one runner for every layer (backend/api/web/ui/binding), logs per run under .testlogs/, JUnit output, evidence report, and a re-seed guard against stale demo data - .githooks/ + scripts/install-hooks.ps1: pre-commit secret scan and pre-push unit tests (shift-left before CI) - evidence report now captures each API call's request, payload and response (Authorization redacted); JUnit reporter added for CI - scripts/db.mjs: db:check freshness guard Azure DevOps pipeline deferred; the runner already emits JUnit + the evidence report for any CI to publish. Co-Authored-By: Claude Opus 4.8 --- .githooks/pre-commit | 45 ++++ .githooks/pre-push | 20 ++ .gitignore | 2 + TESTING.md | 14 +- e2e/CLAUDE.md | 72 +++++ e2e/fixtures/http-capture.ts | 110 ++++++++ e2e/fixtures/test.ts | 26 ++ e2e/playwright.config.ts | 17 +- e2e/reporters/evidence.ts | 47 +++- e2e/reporters/render-html.ts | 97 ++++++- e2e/reporters/report-model.ts | 15 ++ qa/README.md | 135 ++++++++++ qa/generate-test.prompt.md | 45 ++++ qa/scenario-template.yml | 49 ++++ qa/scenarios/API-005-cross-tenant-booking.yml | 27 ++ qa/scenarios/BOOK-012-no-oversell.yml | 28 ++ qa/scenarios/SRCH-003-sort-by-fare.yml | 25 ++ run-tests.ps1 | 252 ++++++++++++++++++ scripts/db.mjs | 22 ++ scripts/install-hooks.ps1 | 17 ++ 20 files changed, 1054 insertions(+), 11 deletions(-) create mode 100644 .githooks/pre-commit create mode 100644 .githooks/pre-push create mode 100644 e2e/CLAUDE.md create mode 100644 e2e/fixtures/http-capture.ts create mode 100644 qa/README.md create mode 100644 qa/generate-test.prompt.md create mode 100644 qa/scenario-template.yml create mode 100644 qa/scenarios/API-005-cross-tenant-booking.yml create mode 100644 qa/scenarios/BOOK-012-no-oversell.yml create mode 100644 qa/scenarios/SRCH-003-sort-by-fare.yml create mode 100644 run-tests.ps1 create mode 100644 scripts/install-hooks.ps1 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..d42bb7c --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,45 @@ +#!/bin/sh +# Fast, local shift-left gate: block obvious secrets from being committed. +# Runs in seconds, needs no toolchain. Enable with: scripts/install-hooks.ps1 +# +# Bypass in a genuine emergency with: git commit --no-verify (discouraged). + +staged=$(git diff --cached --name-only --diff-filter=ACM) +[ -z "$staged" ] && exit 0 + +fail=0 +report() { echo " BLOCKED: $1"; fail=1; } + +for f in $staged; do + # Skip the hook scripts themselves: they legitimately contain the patterns + # (and literals) they search for, so scanning them would self-block. + case "$f" in + .githooks/*) continue;; + esac + + # Never commit a real env file (only .env.example is allowed). + case "$f" in + *.env|.env) report "$f looks like a secrets file (.env is git-ignored for a reason)";; + esac + + # Scan staged content for credential patterns. + [ -f "$f" ] || continue + content=$(git show ":$f" 2>/dev/null) || continue + + echo "$content" | grep -Eq 'GITEA_TOKEN[[:space:]]*=[[:space:]]*[A-Za-z0-9]' && \ + report "$f contains a GITEA_TOKEN value" + echo "$content" | grep -Eq 'PGPASSWORD[[:space:]]*=[[:space:]]*[^ \"$]' && \ + report "$f hardcodes PGPASSWORD" + echo "$content" | grep -q 'BEGIN .*PRIVATE KEY' && \ + report "$f contains a private key" + # The known dev DB password must never re-enter the tree (see VAPT FINDING-8). + echo "$content" | grep -q 'Did10n@c' && \ + report "$f contains the real dev DB password" +done + +if [ "$fail" -ne 0 ]; then + echo "" + echo "Commit blocked: remove the secret(s) above, or use .env (git-ignored)." + exit 1 +fi +exit 0 diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100644 index 0000000..0e24343 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,20 @@ +#!/bin/sh +# Pre-push gate: run the fast test layers (Go + Angular unit) before code leaves +# the machine. The browser e2e suite is left for CI to keep push quick. +# +# Bypass in a genuine emergency with: git push --no-verify (discouraged). + +echo "pre-push: running unit layers (backend + web) ..." + +# Delegate to the one runner so local and CI behave identically. The ps1 +# refreshes PATH itself, so this works even in a terminal opened before the +# toolchain was installed. +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ./run-tests.ps1 -Layer unit -NoReport +code=$? + +if [ "$code" -ne 0 ]; then + echo "" + echo "pre-push blocked: unit tests failed. Fix them, or push --no-verify to override." + exit 1 +fi +exit 0 diff --git a/.gitignore b/.gitignore index cfdb50f..7abc26c 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ e2e/**/*.pdf # --- app.ps1 runtime (pids, logs) ------------------------------------------- .run/ +.testlogs/ +e2e/results/ diff --git a/TESTING.md b/TESTING.md index c451734..2b7c15f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -290,8 +290,18 @@ invisibility, and the dashboard before and after a sale. Every other test still contributes its end-of-test screenshot automatically (`screenshot: 'on'`), so the report covers the whole run; the toolbar filters to the narrated ones. -A typical full run produces **96 screenshots across 82 scenarios**, about 14 MB. -The directory is git-ignored and CI keeps it as an artifact for 30 days. +**API traffic.** Every scenario also records the request/response of every +`/api/**` call it made — method, path, request headers, payload, status, and +response body — shown as a collapsible "API calls" block under the scenario. +This is automatic (an auto fixture in `fixtures/http-capture.ts`); no test has +to opt in. The **`Authorization` header is redacted** before anything is stored +— it carries the Basic-auth credential, and the report is meant to be shared. +Response bodies are capped (12 KB) so a large flight list cannot bloat the +report. + +A typical full run produces **96 screenshots and ~390 captured API calls across +82 scenarios**, about 15 MB of HTML. The directory is git-ignored and CI keeps +it as an artifact for 30 days. ### Simulated-condition tests diff --git a/e2e/CLAUDE.md b/e2e/CLAUDE.md new file mode 100644 index 0000000..23f04d9 --- /dev/null +++ b/e2e/CLAUDE.md @@ -0,0 +1,72 @@ +# Conventions for generating Playwright tests in this project + +Read this before generating or editing any test under `e2e/`. These rules keep +AI-generated tests consistent, reviewable, and trustworthy. A generated test +that breaks them should be rejected in review. + +## Where things go + +- **Specs** live in `e2e/tests/.spec.ts`, grouped by the areas defined in + `e2e/reporters/report-model.ts` (auth, search, booking, tenant-isolation, + dashboard, simulation, responsive). Add a scenario to the spec whose area it + belongs to; do not create a new spec file per scenario. +- **Page objects** live in `e2e/pages/*.page.ts`. One class per screen, + exposing intent (`search.searchRoute('JFK','LHR')`), never raw selectors in + the test. +- **Fixtures** live in `e2e/fixtures/`. Import `test` and `expect` from + `../fixtures/test`, never from `@playwright/test` directly — the project + fixtures add evidence capture, the console-error guard, and API-traffic + capture automatically. +- **Accounts** come from `e2e/fixtures/accounts.ts`. Never hardcode an email or + password in a spec. + +## Hard rules + +1. **No raw selectors in specs.** If a selector is missing, add a method or + locator to the relevant page object and use that. Prefer role- and + text-based locators; avoid brittle CSS/nth-child chains. +2. **No hardcoded waits.** Never `waitForTimeout`. Wait on state: `await + expect(locator).toBeVisible()`, `waitForURL`, the page object's `settled()`. +3. **Never weaken an assertion to make a test pass.** If the app is wrong, the + test should fail. Report the discrepancy; do not loosen the check. +4. **Never hardcode or print secrets.** Credentials come from `accounts.ts`. + The API-capture fixture already redacts the `Authorization` header — do not + defeat it. +5. **Capture evidence at each `then`.** Use `evidence.step('', { ...data })` + so the report shows a screenshot and the figures the test observed. Use + `evidence.note(...)` for data compared across pages. +6. **Leave the data as you found it.** A test that creates a booking must cancel + it (or the suite's cleanup must). Use `uniqueSurname()` so rows are findable + and removable. +7. **Assert on behaviour the user can observe**, not on implementation details. + `expect(seatsAfter).toBe(seatsBefore - 2)` survives a refactor; asserting an + internal call does not. + +## Shape of a generated test + +```ts +import { expect, test, uniqueSurname } from '../fixtures/test'; +import { SKYLINE_AGENT } from '../fixtures/accounts'; + +test.describe('Booking and cancelling', () => { + test('the last seat cannot be oversold', async ({ signInAs, search, checkout, evidence, page }) => { + // given + await signInAs(SKYLINE_AGENT); + // when ... (drive through page objects) + // then ... + await expect(/* observable state */).toBeTruthy(); + await evidence.step('Seat refused as oversold', { status: 409 }); + }); +}); +``` + +## After generating + +Run it and read the evidence report before committing: + +``` +..\run-tests.ps1 -Layer ui -Grep "" -Open +``` + +The report must show the screenshots and API request/response the scenario card +listed under `evidence:`. If they are missing, the test is not done. diff --git a/e2e/fixtures/http-capture.ts b/e2e/fixtures/http-capture.ts new file mode 100644 index 0000000..b78df97 --- /dev/null +++ b/e2e/fixtures/http-capture.ts @@ -0,0 +1,110 @@ +/** + * Captures the API traffic a test produced, for the evidence report. + * + * A screenshot shows what the user saw; this shows what crossed the wire -- the + * request (method, path, payload) and the response (status, body) for every + * `/api/**` call. Together they make the report a complete record of the + * scenario. + * + * Security: the Authorization header carries the Basic-auth credential (a + * base64 of email:password), so it is redacted before anything is stored. The + * report is meant to be shared; a credential must never ride along in it. + */ +import type { Response } from '@playwright/test'; + +export interface ApiCall { + method: string; + path: string; + status: number; + statusText: string; + durationMs: number; + requestHeaders: Record; + requestBody?: string; + responseBody?: string; + truncated?: boolean; +} + +// Headers that must never appear in a shareable report. +const SENSITIVE = new Set(['authorization', 'cookie', 'set-cookie', 'proxy-authorization']); + +// Response bodies are capped so a large flight list cannot bloat the report. +const MAX_BODY = 12000; + +function redactHeaders(headers: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + out[key] = SENSITIVE.has(key.toLowerCase()) ? '[redacted]' : value; + } + return out; +} + +/** Pretty-prints JSON and caps length, reporting whether it was truncated. */ +function presentBody(raw: string, contentType: string): { body: string; truncated: boolean } { + let text = raw; + if (/json/i.test(contentType)) { + try { + text = JSON.stringify(JSON.parse(raw), null, 2); + } catch { + // Leave non-parseable bodies as-is. + } + } + if (text.length > MAX_BODY) { + return { body: text.slice(0, MAX_BODY) + '\n… (truncated)', truncated: true }; + } + return { body: text, truncated: false }; +} + +/** + * Turns one response into an ApiCall record, or null if it is not an API call. + * The body is read here, while the response is still live, and returned via the + * promise the caller awaits at test teardown. + */ +export function captureApiCall(response: Response): Promise { + const request = response.request(); + + let url: URL; + try { + url = new URL(request.url()); + } catch { + return Promise.resolve(null); + } + if (!url.pathname.startsWith('/api/')) { + return Promise.resolve(null); + } + + return (async () => { + try { + const contentType = response.headers()['content-type'] ?? ''; + let responseBody: string | undefined; + let truncated = false; + + if (/json|text|xml|form/i.test(contentType)) { + const raw = await response.text().catch(() => ''); + if (raw) { + const shown = presentBody(raw, contentType); + responseBody = shown.body; + truncated = shown.truncated; + } + } + + const timing = request.timing(); + const durationMs = timing.responseEnd > 0 ? Math.round(timing.responseEnd) : 0; + + return { + method: request.method(), + path: url.pathname + url.search, + status: response.status(), + statusText: response.statusText(), + durationMs, + requestHeaders: redactHeaders(request.headers()), + requestBody: request.postData() ?? undefined, + responseBody, + truncated: truncated || undefined, + }; + } catch { + // A response that cannot be read (e.g. the page navigated away) is + // skipped rather than failing the test it was only observing. + return null; + } + })(); +} diff --git a/e2e/fixtures/test.ts b/e2e/fixtures/test.ts index 42f5fce..f248da3 100644 --- a/e2e/fixtures/test.ts +++ b/e2e/fixtures/test.ts @@ -2,6 +2,7 @@ import { expect, test as base } from '@playwright/test'; import { Account } from './accounts'; import { Evidence } from './evidence'; +import { ApiCall, captureApiCall } from './http-capture'; import { BookingDetailPage, BookingsPage } from '../pages/bookings.page'; import { CheckoutPage } from '../pages/checkout.page'; import { DashboardPage } from '../pages/dashboard.page'; @@ -26,6 +27,9 @@ interface Fixtures { /** Console and uncaught errors seen during the test; asserted empty at the end. */ consoleErrors: string[]; + + /** Records every /api/** request and response for the run report. */ + apiTraffic: void; } export const test = base.extend({ @@ -75,6 +79,28 @@ export const test = base.extend({ }, { auto: true }, ], + + // Records the request/response of every API call the test made, attached as + // evidence-http for the reporter. Auto, so no test has to opt in. + apiTraffic: [ + async ({ page }, use, testInfo) => { + const pending: Array> = []; + page.on('response', (response) => { + pending.push(captureApiCall(response)); + }); + + await use(); + + const calls = (await Promise.all(pending)).filter((c): c is ApiCall => c !== null); + if (calls.length > 0) { + await testInfo.attach('evidence-http', { + body: Buffer.from(JSON.stringify(calls, null, 2), 'utf8'), + contentType: 'application/json', + }); + } + }, + { auto: true }, + ], }); export { expect } from '@playwright/test'; diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index bd4151a..7310c65 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -22,9 +22,22 @@ export default defineConfig({ // The evidence reporter turns the screenshots and data captured by passing // tests into a readable run report; the html one stays for debugging failures. + // junit.xml is the machine-readable result any CI (Azure DevOps, Gitea) can + // ingest; the evidence reporter is the human record; html is for debugging. reporter: process.env.CI - ? [['github'], ['html', { open: 'never' }], ['./reporters/evidence.ts'], ['list']] - : [['html', { open: 'never' }], ['./reporters/evidence.ts'], ['list']], + ? [ + ['github'], + ['junit', { outputFile: 'results/junit.xml' }], + ['html', { open: 'never' }], + ['./reporters/evidence.ts'], + ['list'], + ] + : [ + ['junit', { outputFile: 'results/junit.xml' }], + ['html', { open: 'never' }], + ['./reporters/evidence.ts'], + ['list'], + ], use: { baseURL: WEB, diff --git a/e2e/reporters/evidence.ts b/e2e/reporters/evidence.ts index a7b34b4..78468d2 100644 --- a/e2e/reporters/evidence.ts +++ b/e2e/reporters/evidence.ts @@ -21,6 +21,7 @@ import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } fr import { DATA_PREFIX, SHOT_PREFIX } from '../fixtures/evidence'; import { renderHtml } from './render-html'; import { + ApiCall, RunMeta, Scenario, Step, @@ -69,6 +70,7 @@ export default class EvidenceReporter implements Reporter { const suite = path.slice(2, -1).join(' › '); const steps = this.collectSteps(test, result); + const apiCalls = this.collectApiCalls(result); this.scenarios.push({ project, @@ -79,10 +81,28 @@ export default class EvidenceReporter implements Reporter { expected: result.status === test.expectedStatus, durationMs: result.duration, steps, + apiCalls, error: result.error?.message?.split('\n').slice(0, 4).join('\n'), }); } + /** Reads the evidence-http attachment the apiTraffic fixture produced. */ + private collectApiCalls(result: TestResult): ApiCall[] { + const att = result.attachments.find((a) => a.name === 'evidence-http'); + if (!att) { + return []; + } + const raw = att.body ? att.body.toString('utf8') : att.path ? safeRead(att.path) : ''; + if (!raw) { + return []; + } + try { + return JSON.parse(raw) as ApiCall[]; + } catch { + return []; + } + } + /** Pairs `evidence:NN:name` screenshots with `evidence-data:NN:name` blobs. */ private collectSteps(test: TestCase, result: TestResult): Step[] { const bySeq = new Map(); @@ -180,6 +200,7 @@ export default class EvidenceReporter implements Reporter { (n, s) => n + s.steps.filter((step) => step.shot).length, 0, ), + apiCalls: this.scenarios.reduce((n, s) => n + s.apiCalls.length, 0), }; mkdirSync(OUT_DIR, { recursive: true }); @@ -206,7 +227,7 @@ export default class EvidenceReporter implements Reporter { 'Evidence report — what passed, with data and screenshots:', ` ${url}`, ` ${summary.passed}/${summary.total} passed · ${summary.screenshots} screenshots · ` + - `${summary.narrated} scenarios with recorded data`, + `${summary.apiCalls} API calls · ${summary.narrated} scenarios with recorded data`, '', ' Open it: npm run report', ' Markdown: ' + pathToFileURL(join(OUT_DIR, 'report.md')).href, @@ -254,7 +275,7 @@ function renderMarkdown(summary: Summary, scenarios: Scenario[]): string { lines.push(`### ${mark} ${heading}`, ''); lines.push(`*${scenario.suite ? scenario.suite + ' · ' : ''}${scenario.project} · ${friendly(scenario.durationMs)}*`, ''); - if (scenario.steps.length === 0) { + if (scenario.steps.length === 0 && scenario.apiCalls.length === 0) { lines.push('_No evidence captured._', ''); continue; } @@ -272,10 +293,32 @@ function renderMarkdown(summary: Summary, scenarios: Scenario[]): string { lines.push(`![${step.name}](${step.shot})`, ''); } } + + if (scenario.apiCalls.length > 0) { + lines.push(`**API calls (${scenario.apiCalls.length})**`, ''); + for (const call of scenario.apiCalls) { + lines.push(`- \`${call.method} ${call.path}\` → **${call.status}** (${call.durationMs} ms)`); + if (call.requestBody) { + lines.push('', ' Request:', '', ' ```json', indent(call.requestBody), ' ```'); + } + if (call.responseBody) { + lines.push('', ' Response:', '', ' ```json', indent(call.responseBody), ' ```'); + } + } + lines.push(''); + } } } return lines.join('\n'); } +/** Indents a block two spaces so it nests under a Markdown list item. */ +function indent(text: string): string { + return text + .split('\n') + .map((line) => ' ' + line) + .join('\n'); +} + diff --git a/e2e/reporters/render-html.ts b/e2e/reporters/render-html.ts index b89ea51..5c0bd13 100644 --- a/e2e/reporters/render-html.ts +++ b/e2e/reporters/render-html.ts @@ -6,6 +6,7 @@ * ("2.3.1 failed"), and a print stylesheet so it can be saved as a PDF. */ import { + ApiCall, Area, RunMeta, Scenario, @@ -108,6 +109,7 @@ ${STYLES}
Scenarios executed
${summary.total}
Duration
${friendly(summary.durationMs)}
Screenshots captured
${summary.screenshots}
+
API calls captured
${summary.apiCalls}
Scenarios with recorded data
${summary.narrated}
@@ -207,10 +209,11 @@ function renderArea(entry: AreaBlock): string { function renderScenario(scenario: Scenario, ref: string): string { const hasFacts = scenario.steps.some((s) => s.facts); - const steps = - scenario.steps.length === 0 - ? '

No screenshot was captured for this scenario.

' - : scenario.steps.map(renderStep).join(''); + const hasAnything = scenario.steps.length > 0 || scenario.apiCalls.length > 0; + const steps = scenario.steps.map(renderStep).join(''); + const emptyNote = hasAnything + ? '' + : '

No evidence was captured for this scenario.

'; return `
@@ -222,6 +225,7 @@ function renderScenario(scenario: Scenario, ref: string): string { ${escapeHtml(scenario.project)} + ${scenario.apiCalls.length} API ${friendly(scenario.durationMs)} ${statusWord(scenario.status)} @@ -229,10 +233,56 @@ function renderScenario(scenario: Scenario, ref: string): string {
${scenario.error ? `
${escapeHtml(scenario.error)}
` : ''} ${steps} + ${emptyNote} + ${renderApiCalls(scenario.apiCalls)}
`; } +/** The API traffic block: collapsed by default so it never dominates. */ +function renderApiCalls(calls: ApiCall[]): string { + if (calls.length === 0) { + return ''; + } + + const rows = calls.map(renderApiCall).join(''); + + return ` +
+ API calls · ${calls.length} request${calls.length === 1 ? '' : 's'} +
${rows}
+
`; +} + +function renderApiCall(call: ApiCall): string { + const cls = call.status >= 500 ? 'c5' : call.status >= 400 ? 'c4' : call.status >= 300 ? 'c3' : 'c2'; + + const headerRows = Object.entries(call.requestHeaders) + .map(([k, v]) => `${escapeHtml(k)}${escapeHtml(v)}`) + .join(''); + + const request = call.requestBody + ? `
Request payload
${escapeHtml(call.requestBody)}
` + : ''; + + const response = call.responseBody + ? `
Response body${call.truncated ? ' (truncated)' : ''}
${escapeHtml(call.responseBody)}
` + : ''; + + return ` +
+
+ ${escapeHtml(call.method)} + ${escapeHtml(call.path)} + ${call.status} ${escapeHtml(call.statusText)} + ${call.durationMs} ms +
+
Request headers${headerRows}
+ ${request} + ${response} +
`; +} + function renderStep(step: Step): string { const facts = step.facts ? `${Object.entries(step.facts) @@ -372,6 +422,41 @@ const STYLES = ` border-radius:6px;font-size:.83rem;white-space:pre-wrap; font-family:ui-monospace,monospace} + /* --- API traffic --- */ + details.api{border-top:1px solid var(--line);margin-top:6px} + details.api > summary{list-style:none;cursor:pointer;padding:12px 0 10px;font-size:.78rem; + font-weight:650;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)} + details.api > summary::-webkit-details-marker{display:none} + details.api > summary::before{content:'\\25B8 ';color:var(--faint)} + details.api[open] > summary::before{content:'\\25BE '} + .api-list{display:flex;flex-direction:column;gap:10px;padding-bottom:6px} + .call{border:1px solid var(--line);border-radius:7px;overflow:hidden;background:#fff} + .call-head{display:flex;align-items:center;gap:10px;padding:8px 11px;background:#f8fafc; + flex-wrap:wrap;font-size:.82rem} + .method{font-weight:700;font-size:.7rem;letter-spacing:.04em;padding:2px 7px;border-radius:4px; + background:#e5e7eb;color:#374151} + .method.m-get{background:#e0edff;color:#0b5cd6} + .method.m-post{background:#e6f4f1;color:#067a6e} + .method.m-patch,.method.m-put{background:#fef3c7;color:#b45309} + .method.m-delete{background:#fdeaee;color:#b3243f} + .call-path{font-family:ui-monospace,Menlo,monospace;font-size:.8rem;color:var(--ink); + word-break:break-all;flex:1;min-width:120px} + .status{font-weight:700;font-size:.76rem;font-variant-numeric:tabular-nums} + .status.c2{color:var(--ok)} .status.c3{color:var(--med)} + .status.c4{color:var(--fail)} .status.c5{color:#fff;background:var(--fail);padding:1px 6px;border-radius:4px} + .call-time{color:var(--faint);font-size:.76rem;font-variant-numeric:tabular-nums} + .part{padding:8px 11px;border-top:1px solid var(--line)} + .part-label{display:block;font-size:.68rem;letter-spacing:.06em;text-transform:uppercase; + color:var(--faint);font-weight:650;margin-bottom:5px} + .call pre{background:#0f172a;color:#e2e8f0;padding:10px 12px;border-radius:6px;overflow-x:auto; + font:12px/1.5 ui-monospace,Menlo,monospace;margin:0;max-height:340px} + details.headers{padding:8px 11px;border-top:1px solid var(--line)} + details.headers > summary{cursor:pointer;font-size:.72rem;color:var(--muted)} + details.headers table{border-collapse:collapse;font-size:.76rem;margin-top:6px} + details.headers td{border:1px solid var(--line);padding:3px 8px; + font-family:ui-monospace,Menlo,monospace;word-break:break-all} + details.headers td:first-child{color:var(--muted);background:#f8fafc} + .toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:20px} .toolbar button{border:1px solid var(--rule);background:#fff;border-radius:6px; padding:6px 13px;font:inherit;font-size:.85rem;cursor:pointer;color:var(--body)} @@ -468,7 +553,9 @@ const SCRIPT = ` // first and put it back the way the reader had it afterwards. let restore = []; window.addEventListener('beforeprint', () => { - restore = [...document.querySelectorAll('.scenario')].map((d) => [d, d.open]); + // Open every details element (scenarios and the API/header blocks inside), + // since a collapsed one prints only its summary line. + restore = [...document.querySelectorAll('details')].map((d) => [d, d.open]); for (const [d] of restore) { d.open = true; } diff --git a/e2e/reporters/report-model.ts b/e2e/reporters/report-model.ts index 1b1b39b..96990f2 100644 --- a/e2e/reporters/report-model.ts +++ b/e2e/reporters/report-model.ts @@ -15,6 +15,19 @@ export interface Step { facts?: Record; } +/** One API request/response pair observed during a scenario. */ +export interface ApiCall { + method: string; + path: string; + status: number; + statusText: string; + durationMs: number; + requestHeaders: Record; + requestBody?: string; + responseBody?: string; + truncated?: boolean; +} + /** One test, as the report talks about it. */ export interface Scenario { project: string; @@ -25,6 +38,7 @@ export interface Scenario { expected: boolean; durationMs: number; steps: Step[]; + apiCalls: ApiCall[]; error?: string; } @@ -45,6 +59,7 @@ export interface Summary { skipped: number; narrated: number; screenshots: number; + apiCalls: number; } // --- areas ----------------------------------------------------------------- diff --git a/qa/README.md b/qa/README.md new file mode 100644 index 0000000..e49cbc4 --- /dev/null +++ b/qa/README.md @@ -0,0 +1,135 @@ +# TravelHub QA workflow — AI-assisted testing + +This is how we define, automate, run and continuously gate tests on TravelHub. +It is the same loop for API, UI, backend and binding scenarios: + +``` +scenario card -> Claude generates the test -> run-tests.ps1 runs + logs + evidence -> gates CI/CD + (you) (AI, you review) (one runner, anywhere) +``` + +The scenario card is the single source of truth: a human reviews it, Claude +generates the test from it, and the evidence report traces each result back to +it. **No card, no test.** + +--- + +## 1. Define a scenario + +Copy [`scenario-template.yml`](scenario-template.yml) to +`qa/scenarios/.yml` and fill it in. Pick the layer — it decides the tool and +where the test lands: + +| Layer | Proves | Lands in | +| ----- | ------ | -------- | +| `backend` | logic & data (tenant scoping, seat accounting) | `backend/**/*_test.go` | +| `api` | HTTP contract: status, schema, errors | Go handler tests / Playwright request | +| `ui` | a user journey in the browser | `e2e/tests/*.spec.ts` | +| `binding` | the UI↔API / API↔DB seam (payload sent + response rendered) | `e2e/tests/*.spec.ts` (with API capture) | + +Worked examples to copy from: +[BOOK-012](scenarios/BOOK-012-no-oversell.yml) (binding), +[API-005](scenarios/API-005-cross-tenant-booking.yml) (api), +[SRCH-003](scenarios/SRCH-003-sort-by-fare.yml) (ui). + +The card's `evidence:` list is a contract: the generated test must capture +exactly those screenshots and API calls, and the report must show them. + +## 2. Automate it with Claude Code + +From the repo root, use the prompt in +[`generate-test.prompt.md`](generate-test.prompt.md) with your card id. Claude +reads the card and the conventions in [`e2e/CLAUDE.md`](../e2e/CLAUDE.md) and +generates the spec using our page objects and fixtures. + +Then **you review** — the diff *and* the evidence: + +- the test uses page objects, not raw selectors; +- every `then` has an assertion on observable state; +- the evidence report shows each screenshot and API request/response the card + listed. + +The report is the proof of work: the AI *claims* it tested the scenario; the +captured screenshots and API traffic *prove* it. Review the evidence, not just +the code. Full reviewer checklist is in the prompt file. + +> Tip: `npx playwright codegen http://localhost:4200` records a rough draft by +> clicking; then ask Claude to refactor it into page-object style. You never +> write selectors by hand. + +## 3. Run it — one ps1, logged, CI-ready + +[`run-tests.ps1`](../run-tests.ps1) runs any layer, logs everything, and emits +machine-readable results. The **same script runs locally, in a git hook, and in +a pipeline**. + +```powershell +.\run-tests.ps1 -Layer all # everything +.\run-tests.ps1 -Layer unit # backend + web, no browser (fast) +.\run-tests.ps1 -Layer ui -Grep "oversell" -Open # one scenario + open the report +.\run-tests.ps1 -Layer binding # UI<->API seam with traffic capture +``` + +What you get per run, under `.testlogs//`: + +- a log per layer (`backend.log`, `ui.log`, …) — full output, tee'd live; +- `junit.xml` — the result format Azure DevOps / Gitea publish; +- `summary.json` — pass/fail per layer; +- the **evidence report** (`e2e/evidence-report/`) — screenshots + the request, + payload and response of every API call, with credentials redacted. + +It also **re-seeds the demo data if it has gone stale** (the seeded flights are +dated relative to now and expire after ~3 weeks), so you never chase a failure +that is really just old data. + +Exit code is 0 only if every layer passed — that is what CI gates on. + +## 4. Run continuously, before CI and CD + +Five cadences, fastest to slowest — the same `run-tests.ps1` drives them all: + +| When | What | Enforced? | +| ---- | ---- | --------- | +| while writing | watch mode (`ng test`, `playwright --ui`) | no | +| **pre-commit** (git hook) | secret scan | local | +| **pre-push** (git hook) | `run-tests.ps1 -Layer unit` | local | +| **pre-merge** (CI) | full suite + evidence artifact | **blocks merge** | +| nightly (scheduled) | full suite | alerts on drift | + +Install the local hooks once after cloning: + +```powershell +.\scripts\install-hooks.ps1 +``` + +- **pre-commit** blocks committed secrets (it is what would have caught the dev + DB password leak). Fast, no toolchain needed. +- **pre-push** runs the unit layers so a logic break never leaves your machine. + +"Before CI" is those two hooks (shift-left). "Before CD" is the pre-merge gate +plus a smoke run against staging before promote. The **nightly** run matters: +the stale-data failure above would surface at 2 a.m. instead of in front of a +person. + +### CI / CD wiring + +`run-tests.ps1` is the contract; the pipeline just calls it and publishes its +outputs (`junit.xml` + the evidence report). **Azure DevOps integration is +planned** — when we add it, the pipeline will run this same script and use +`PublishTestResults@2` on `junit.xml` plus `PublishBuildArtifacts` on the +evidence report. Nothing in steps 1–3 changes. + +--- + +## The 15-minute demo + +Walk one card end to end: + +1. Open [BOOK-012](scenarios/BOOK-012-no-oversell.yml) — the scenario. +2. Prompt Claude Code with [the template](generate-test.prompt.md) → watch it + generate the Playwright spec using the page objects. +3. `.\run-tests.ps1 -Layer binding -Grep "oversell" -Open` → logs stream, the + evidence report opens showing the 409 and both bookings' request/response. +4. (Later) show the same ps1 running in the pipeline, blocking a bad merge. + +That one loop is the whole system. diff --git a/qa/generate-test.prompt.md b/qa/generate-test.prompt.md new file mode 100644 index 0000000..d4bb453 --- /dev/null +++ b/qa/generate-test.prompt.md @@ -0,0 +1,45 @@ +# Prompt template — generate a Playwright test from a scenario card + +Paste this into Claude Code (from the repo root), filling in the card id. +Claude has the repo in context, so it will read the card and the conventions +itself. + +--- + +Generate the automated test for scenario card `qa/scenarios/.yml`. + +Follow `e2e/CLAUDE.md` exactly. In particular: + +- Put the test in the spec file for the card's `area`; add to the existing + `test.describe` block, do not create a new spec. +- Drive the UI only through the page objects in `e2e/pages/`. If a needed action + or locator is missing, add a method to the right page object and use it. +- Import `test`/`expect` from `../fixtures/test`; take accounts from + `e2e/fixtures/accounts.ts`. +- For every clause under the card's `then:` and every item under `evidence:`, + add an `evidence.step(name, { ...observed data })` capturing a screenshot and + the figures the card asks for. +- No raw selectors in the spec, no `waitForTimeout`, no hardcoded secrets. +- If the test creates data (a booking), clean it up at the end. + +When done: + +1. Show me the diff (the spec, and any page-object change). +2. Run it: `.\run-tests.ps1 -Layer -Grep "" -Open`. +3. Confirm the evidence report shows each screenshot and the API + request/response listed in the card's `evidence:` section. + +Do not loosen any assertion to make it pass. If the application behaves +differently from the card, stop and tell me what differs — that is a finding, +not something to code around. + +--- + +## Reviewer checklist (the human reading the diff) + +- [ ] Test lives in the right area spec; uses page objects, no raw selectors. +- [ ] Every `then` has a matching assertion on observable state. +- [ ] Evidence steps cover everything the card listed. +- [ ] No `waitForTimeout`, no hardcoded credentials, no weakened assertions. +- [ ] It actually ran green, and the evidence report proves the scenario. +- [ ] Data created by the test is cleaned up. diff --git a/qa/scenario-template.yml b/qa/scenario-template.yml new file mode 100644 index 0000000..41da327 --- /dev/null +++ b/qa/scenario-template.yml @@ -0,0 +1,49 @@ +# TravelHub test scenario card — TEMPLATE +# +# One card = one scenario. Author this BEFORE any test code exists: it is the +# thing a human reviews, the thing Claude Code generates the test from, and the +# thing the evidence report traces each result back to. +# +# Rule: no card, no test. Copy this file to qa/scenarios/<ID>.yml and fill it in. + +id: AREA-000 # stable id, e.g. BOOK-012, SRCH-003, AUTH-001 +title: One sentence, present tense, describing the behaviour under test + +# Which layer this scenario lives at. Picks the tool and where the test lands: +# backend Go logic/data (repository, middleware, config) -> backend/**/*_test.go +# api HTTP contract (status, schema, errors) -> Go handler tests / Playwright request +# ui a user journey through the browser -> e2e/tests/*.spec.ts +# binding the UI<->API or API<->DB seam (payload + render) -> e2e/tests/*.spec.ts (with API capture) +layer: ui + +area: Booking and cancelling # must match an area in e2e/reporters/report-model.ts +risk: medium # low | medium | high -> drives how hard CI gates it +owner: qa # qa | dev — who maintains this card + +# The world the test assumes before it starts. +preconditions: + - signed in as agent@skyline.test / skyline + - at least one bookable flight on JFK -> LHR + +# Given / When / Then. Keep each a single clear clause. +steps: + given: the agent is on the flight search screen + when: they search JFK -> LHR and select the cheapest flight + then: the checkout screen shows that flight at the same fare + +# What the evidence report must contain to prove this ran. The generated test +# should capture exactly these with evidence.step(...). +evidence: + - screenshot of the checkout screen + - the GET /api/flights request and response + - the fare carried from search into checkout (recorded data) + +# Inputs the test uses. Keep data here, not hardcoded in the spec. +data: + origin: JFK + destination: LHR + passengers: 1 + +# Optional: what must NOT happen (negative assertions). +must_not: + - no booking is created (this scenario stops at checkout) diff --git a/qa/scenarios/API-005-cross-tenant-booking.yml b/qa/scenarios/API-005-cross-tenant-booking.yml new file mode 100644 index 0000000..034fcea --- /dev/null +++ b/qa/scenarios/API-005-cross-tenant-booking.yml @@ -0,0 +1,27 @@ +id: API-005 +title: One agency cannot read another agency's booking by id +layer: api +area: Keeping agencies separate +risk: high +owner: qa + +preconditions: + - a confirmed booking exists under the Globetrotter tenant + - valid credentials for the Skyline tenant + +steps: + given: Skyline holds a valid booking id that belongs to Globetrotter + when: Skyline requests GET /api/bookings/{that-id} + then: the API responds 404 Not Found (not 403), leaking nothing about the record + +evidence: + - the GET /api/bookings/{id} request (X-Tenant skyline) and the 404 response + - the same id fetched successfully as Globetrotter, for contrast (recorded data) + +data: + owning_tenant: globetrotter + requesting_tenant: skyline + +must_not: + - the response body must not contain the other tenant's passenger or contact data + - the status must not be 200 or 403 (404 keeps existence hidden) diff --git a/qa/scenarios/BOOK-012-no-oversell.yml b/qa/scenarios/BOOK-012-no-oversell.yml new file mode 100644 index 0000000..e341639 --- /dev/null +++ b/qa/scenarios/BOOK-012-no-oversell.yml @@ -0,0 +1,28 @@ +id: BOOK-012 +title: The last seat cannot be oversold to two agents at once +layer: binding +area: Booking and cancelling +risk: high +owner: qa + +preconditions: + - signed in as agent@skyline.test / skyline + - a flight with exactly 1 seat remaining + +steps: + given: a flight showing "1 left" + when: two bookings for that flight are confirmed at the same moment + then: one succeeds (201 with a PNR), the other is refused (409), and seats reach 0 + +evidence: + - the POST /api/bookings request and response for BOTH attempts + - the seat count before and after (recorded data) + - screenshot of the 409 "just sold" message on the losing attempt + +data: + route: JFK -> LHR + passengers: 1 + +must_not: + - the flight must never show negative seats + - both bookings must never both succeed diff --git a/qa/scenarios/SRCH-003-sort-by-fare.yml b/qa/scenarios/SRCH-003-sort-by-fare.yml new file mode 100644 index 0000000..e538907 --- /dev/null +++ b/qa/scenarios/SRCH-003-sort-by-fare.yml @@ -0,0 +1,25 @@ +id: SRCH-003 +title: Flight results sort by lowest fare +layer: ui +area: Searching for flights +risk: low +owner: qa + +preconditions: + - signed in as agent@skyline.test / skyline + - more than one flight on JFK -> LHR + +steps: + given: the agent has searched JFK -> LHR + when: they choose "Lowest fare" from the sort control + then: the fares shown are in non-decreasing order down the page + +evidence: + - screenshot of the sorted results + - the ordered list of fares read off the page (recorded data) + - the GET /api/flights?sort=price request and response + +data: + origin: JFK + destination: LHR + sort: price diff --git a/run-tests.ps1 b/run-tests.ps1 new file mode 100644 index 0000000..ac959cf --- /dev/null +++ b/run-tests.ps1 @@ -0,0 +1,252 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Run the TravelHub test suite by layer, log everything, and emit CI-ready + results and an evidence report. The same script runs locally, in a git + hook, and in a pipeline. + +.DESCRIPTION + One runner for every test layer so there is a single source of truth for + "how tests run here". It: + - refreshes PATH from the registry (works in stale terminals) + - guards e2e against stale demo data by re-seeding when inventory is old + - writes a timestamped log per step under .testlogs\<run-id>\ + - emits JUnit XML (e2e) that Azure DevOps / Gitea can publish + - regenerates the evidence report (screenshots + API request/response) + - returns a non-zero exit code if any layer fails, so CI can gate on it + + Layers map to the scenario taxonomy in qa\: + backend Go unit + integration tests (repository, middleware, config) + api Go HTTP-contract tests (handlers, middleware) + web Angular unit tests + ui Playwright browser journeys (UI + binding, with API capture) + binding alias for ui -- the UI<->API seam, proven by captured traffic + unit backend + web (no browser; fast) + all backend + web + ui + +.PARAMETER Layer + Which layer to run. Default: all. + +.PARAMETER Grep + Only run tests whose title matches this (passed to `go test -run` and + `playwright -g`). + +.PARAMETER Reseed + Force a database re-seed before running, instead of only when stale. + +.PARAMETER NoReport + Skip regenerating the evidence report (faster; ui/binding only). + +.PARAMETER Open + Open the evidence report in the browser when the run finishes. + +.EXAMPLE + .\run-tests.ps1 -Layer all +.EXAMPLE + .\run-tests.ps1 -Layer ui -Grep "oversell" -Open +.EXAMPLE + .\run-tests.ps1 -Layer unit # fast pre-commit gate, no browser +#> +[CmdletBinding()] +param( + [ValidateSet('all', 'unit', 'backend', 'api', 'web', 'ui', 'binding')] + [string]$Layer = 'all', + + [string]$Grep, + [switch]$Reseed, + [switch]$NoReport, + [switch]$Open +) + +$ErrorActionPreference = 'Stop' + +# --- configuration --------------------------------------------------------- + +$Root = $PSScriptRoot +$Backend = Join-Path $Root 'backend' +$Frontend = Join-Path $Root 'frontend' +$E2E = Join-Path $Root 'e2e' + +$RunId = Get-Date -Format 'yyyyMMdd-HHmmss' +$LogDir = Join-Path $Root ".testlogs\$RunId" + +# --- console --------------------------------------------------------------- + +function Write-Head($msg) { Write-Host "`n==== $msg ====" -ForegroundColor Cyan } +function Write-Ok($msg) { Write-Host " PASS $msg" -ForegroundColor Green } +function Write-Fail($msg) { Write-Host " FAIL $msg" -ForegroundColor Red } +function Write-Info($msg) { Write-Host " $msg" -ForegroundColor DarkGray } + +# --- environment ----------------------------------------------------------- + +function Sync-Path { + $m = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $u = [Environment]::GetEnvironmentVariable('Path', 'User') + $env:Path = ($m, $u | Where-Object { $_ }) -join ';' +} + +function Resolve-Tool($name, [string[]]$fallbacks) { + $cmd = Get-Command $name -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + foreach ($f in $fallbacks) { if (Test-Path $f) { return $f } } + throw "$name not found on PATH." +} +function Get-Go { Resolve-Tool 'go.exe' @('C:\Program Files\Go\bin\go.exe') } +function Get-Npm { Resolve-Tool 'npm.cmd' @('C:\Program Files\nodejs\npm.cmd') } +function Get-Npx { Resolve-Tool 'npx.cmd' @('C:\Program Files\nodejs\npx.cmd') } +function Get-Node { Resolve-Tool 'node.exe' @('C:\Program Files\nodejs\node.exe') } + +# --- step runner ----------------------------------------------------------- + +$script:Results = @() + +# Run one command, tee its output to a log file, and record pass/fail by exit +# code. Never throws -- a failing step is recorded and the run continues so the +# report covers every layer. +function Invoke-Step { + param( + [string]$Name, + [string]$WorkDir, + [string]$Exe, + [string[]]$CmdArgs, + [string]$LogName + ) + Write-Head $Name + $log = Join-Path $LogDir $LogName + Write-Info "log: $log" + + $sw = [Diagnostics.Stopwatch]::StartNew() + Push-Location $WorkDir + # A native tool writing to stderr (e.g. a webserver startup warning) must + # not be treated as a PowerShell error. Pass/fail is the exit code alone; + # stderr is just logged. Continue preference keeps 2>&1 from throwing. + $prev = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + & $Exe @CmdArgs 2>&1 | Tee-Object -FilePath $log + $code = $LASTEXITCODE + } finally { + $ErrorActionPreference = $prev + Pop-Location + } + $sw.Stop() + + $ok = ($code -eq 0) + $script:Results += [pscustomobject]@{ + Layer = $Name; Exit = $code; Ok = $ok; Seconds = [math]::Round($sw.Elapsed.TotalSeconds, 1); Log = $log + } + if ($ok) { Write-Ok "$Name ($($sw.Elapsed.TotalSeconds.ToString('0.0'))s)" } + else { Write-Fail "$Name (exit $code)" } +} + +# --- preconditions --------------------------------------------------------- + +function Ensure-Inventory { + # Only the browser layers need live, future-dated flight data. + $node = Get-Node + $check = Join-Path $Root 'scripts\db.mjs' + + if ($Reseed) { + Write-Info 'Re-seeding database (-Reseed).' + & $node (Join-Path $Root 'scripts\db.mjs') reset | Out-Null + return + } + + & $node $check check 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Info 'Demo inventory is stale or missing -- re-seeding before e2e.' + & $node (Join-Path $Root 'scripts\db.mjs') reset | Out-Null + } else { + Write-Info 'Demo inventory is fresh.' + } +} + +# --- layers ---------------------------------------------------------------- + +function Run-Backend { + $go = Get-Go + $cmdArgs = @('test', './...') + if ($Grep) { $cmdArgs += @('-run', $Grep) } + Invoke-Step 'backend' $Backend $go $cmdArgs 'backend.log' +} + +function Run-Api { + # The HTTP contract lives in the handler + middleware packages. + $go = Get-Go + $cmdArgs = @('test', './internal/handlers/...', './internal/middleware/...') + if ($Grep) { $cmdArgs += @('-run', $Grep) } + Invoke-Step 'api' $Backend $go $cmdArgs 'api.log' +} + +function Run-Web { + $npx = Get-Npx + Invoke-Step 'web' $Frontend $npx @('ng', 'test', '--no-watch') 'web.log' +} + +function Run-Ui { + param([string]$Label = 'ui') + Ensure-Inventory + $npx = Get-Npx + $cmdArgs = @('playwright', 'test') + if ($Grep) { $cmdArgs += @('-g', $Grep) } + Invoke-Step $Label $E2E $npx $cmdArgs "$Label.log" +} + +# --- evidence + results collection ----------------------------------------- + +function Collect-Artifacts { + # JUnit (for CI test publishing) and the evidence report both live under e2e. + $junit = Join-Path $E2E 'results\junit.xml' + if (Test-Path $junit) { + Copy-Item $junit (Join-Path $LogDir 'junit.xml') -Force + Write-Info "JUnit results -> $(Join-Path $LogDir 'junit.xml')" + } + $evidence = Join-Path $E2E 'evidence-report\index.html' + if (Test-Path $evidence) { + Write-Info "Evidence report -> $evidence" + if ($Open) { Start-Process $evidence } + } +} + +# --- dispatch -------------------------------------------------------------- + +Sync-Path +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null +Write-Host "TravelHub test run $RunId (layer: $Layer)" -ForegroundColor White + +switch ($Layer) { + 'backend' { Run-Backend } + 'api' { Run-Api } + 'web' { Run-Web } + 'ui' { Run-Ui 'ui' } + 'binding' { Run-Ui 'binding' } + 'unit' { Run-Backend; Run-Web } + 'all' { Run-Backend; Run-Web; Run-Ui 'ui' } +} + +if (-not $NoReport) { Collect-Artifacts } + +# --- summary --------------------------------------------------------------- + +Write-Head 'Summary' +$script:Results | Format-Table Layer, @{N='Result';E={ if ($_.Ok) { 'PASS' } else { "FAIL ($($_.Exit))" } }}, Seconds -AutoSize | Out-String | Write-Host + +# Write a small machine-readable summary alongside the logs. +$summary = [pscustomobject]@{ + runId = $RunId + layer = $Layer + when = (Get-Date).ToString('o') + results = $script:Results + passed = @($script:Results | Where-Object Ok).Count + failed = @($script:Results | Where-Object { -not $_.Ok }).Count +} +$summary | ConvertTo-Json -Depth 4 | Out-File (Join-Path $LogDir 'summary.json') -Encoding utf8 +Write-Info "Logs: $LogDir" + +$failed = @($script:Results | Where-Object { -not $_.Ok }).Count +if ($failed -gt 0) { + Write-Fail "$failed layer(s) failed." + exit 1 +} +Write-Ok 'All layers passed.' +exit 0 diff --git a/scripts/db.mjs b/scripts/db.mjs index 14e77c6..77cc3ed 100644 --- a/scripts/db.mjs +++ b/scripts/db.mjs @@ -173,6 +173,28 @@ const commands = { createDatabase(CONFIG.testDatabase); console.log(`✓ ${CONFIG.testDatabase} ready for go test ./...`); }, + + // Freshness guard for the e2e suite. The seed generates flights relative to + // now(), so after ~3 weeks all seeded departures are in the past and the + // browser tests fail on empty search results. Exit non-zero when stale so a + // runner can re-seed before running. + check() { + if (!databaseExists(CONFIG.database)) { + console.error(`✗ ${CONFIG.database} does not exist`); + process.exit(2); + } + const out = run( + ['-tAc', 'SELECT count(*) FROM flights WHERE departure_time > now()'], + { database: CONFIG.database, capture: true }, + ); + const future = Number((out || '').trim()); + if (Number.isFinite(future) && future > 0) { + console.log(`✓ inventory fresh: ${future} future flights`); + process.exit(0); + } + console.error('✗ inventory stale: no future flights — re-seed needed (npm run db:reset)'); + process.exit(1); + }, }; const command = process.argv[2]; diff --git a/scripts/install-hooks.ps1 b/scripts/install-hooks.ps1 new file mode 100644 index 0000000..f205776 --- /dev/null +++ b/scripts/install-hooks.ps1 @@ -0,0 +1,17 @@ +#requires -Version 5.1 +# Point git at the versioned hooks in .githooks so every developer gets the +# same pre-commit / pre-push gates. Run once after cloning. +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +Push-Location $root +try { + git config core.hooksPath .githooks + # Git for Windows runs hooks via its bundled bash; the files are sh scripts. + Write-Host 'Git hooks installed (core.hooksPath -> .githooks):' -ForegroundColor Green + Write-Host ' pre-commit : blocks committed secrets' + Write-Host ' pre-push : runs backend + web unit tests' + Write-Host '' + Write-Host 'Bypass in an emergency with --no-verify (discouraged).' +} finally { + Pop-Location +}