/** * Shared setup/teardown for the test runners. * * Both the full runner (`run-tests.ts`) and the focused runner * (`run-test-files.ts`) need the same environment before any test can import * the app: the static client assets must be present (the app reads them at * module load, see src/features/assets.ts) and stripe-mock must be running * with STRIPE_MOCK_HOST/PORT exported. This module owns that lifecycle so a * fresh checkout can run either runner without manual preparation. The assets * are only rebuilt when their sources changed, so a repeat run pays nothing * for them. */ import { TEST_NAME_FILTER_ENV } from "#test-utils/test-name-filter.ts"; import { TEST_STATE_DIR_ENV } from "#test-utils/test-state-env.ts"; import { failAfterCleanups, releaseWhenStarted, withCleanup, } from "./cleanup.ts"; import { runCompactDenoTest } from "./compact-test-reporter.ts"; import { COVERAGE_OUTPUT_DIR, removeOldCoverageOutput, } from "./coverage-output.ts"; import { junitPathInArgs, junitReportForRun } from "./junit-report-path.ts"; import { extractNameFilter, rejectReporterWithFilter } from "./name-filter.ts"; import { rethrowUnlessNotFound } from "./not-found.ts"; import { projectRoot } from "./project-root.ts"; import { prepareStaticAssets } from "./static-assets/prepare.ts"; import type { StaticAssetBuild } from "./static-assets/session.ts"; import { startStripeMock, stripeMockEnv } from "./stripe-mock.ts"; import { estimateTapEventCount, hasReporterArg, skipsDeclaredTests, } from "./tap-estimate.ts"; import { JUNIT_PATH } from "./test-durations.ts"; import { withEnvironment } from "./test-environment.ts"; const verboseHarness = Deno.env.get("TICKETS_TEST_HARNESS_VERBOSE") === "1"; const harnessLog = (...args: unknown[]): void => { if (verboseHarness) console.log(...args); }; export interface TestHarnessResources { staticAssets: StaticAssetBuild; } /** Build the deno test CLI args from the standard flags plus caller extras */ const buildDenoTestArgs = ( extraArgs: string[], useCoverage: boolean, reporter?: "tap", junitPath?: string, ): string[] => { const args = [ "test", "--no-check", "--allow-net", "--allow-env", "--allow-read", "--allow-write", "--allow-run", "--allow-sys", "--allow-ffi", "--parallel", // Install shared test state in every isolate before test-module imports: // the fast typed-array equality tester and the full locale catalog used // by direct template tests that do not enter through a production route // loader. "--preload", "./test/test-utils/preload.ts", // Expose `globalThis.gc` so the test DB layer can reclaim libsql's // file-descriptor leak (a fresh client per test + every interactive // transaction leaks an fd that close() never releases — only GC does). Under // high --parallel worker counts these outrun GC and exhaust the process fd // limit ("Too many open files"), which surfaces as flaky, moving failures. // See maybeReclaimLeakedFds in test/test-utils/db.ts. "--v8-flags=--expose-gc", ]; if (reporter) args.push("--reporter", reporter); if (useCoverage) args.push(`--coverage=${COVERAGE_OUTPUT_DIR}`); if (junitPath) args.push("--junit-path", junitPath); args.push(...extraArgs); return args; }; /** The environment a test child runs in: this process's environment, the * stripe mock endpoints, and the `--filter` value the child prunes by. */ const testChildEnv = (nameFilter?: string): Record => ({ ...Deno.env.toObject(), ...stripeMockEnv(), ...(nameFilter === undefined ? {} : { [TEST_NAME_FILTER_ENV]: nameFilter }), }); /** What both run shapes need: the caller's test arguments (already stripped * of `--filter`), the stripped `--filter` value, whether the run feeds the * coverage gate, and an optional JUnit report path. */ type TestRunRequest = { extraArgs: string[]; nameFilter?: string | undefined; useCoverage: boolean; junitPath?: string | undefined; }; /** The focused run: a compact TAP child whose summary reads a JUnit report * and names the cause of a silent exit. The temporary report directory is * removed after the run; a report the caller forwarded keeps their * directory. */ const runFocusedTest = async ( { extraArgs, nameFilter, useCoverage, junitPath }: TestRunRequest, estimateFrom?: string[], ): Promise => { const forwardedJunitPath = junitPathInArgs(extraArgs); const report = await junitReportForRun(forwardedJunitPath ?? junitPath); const runJunitPath = forwardedJunitPath ?? report.path; // A run that selects a subset or stops at the first failure will not // report every declaration, so the count would name tests the run never // meant to report. const estimatedTotal = nameFilter !== undefined || skipsDeclaredTests(extraArgs) ? undefined : await estimateTapEventCount(projectRoot, estimateFrom ?? extraArgs); try { return await runCompactDenoTest( buildDenoTestArgs( extraArgs, useCoverage, "tap", forwardedJunitPath === undefined ? runJunitPath : undefined, ), { cwd: projectRoot, env: testChildEnv(nameFilter), ...(estimatedTotal === undefined ? {} : { estimatedTotal }), junitPath: runJunitPath, ...(nameFilter === undefined ? {} : { nameFilter }), }, ); } finally { if (report.dir !== undefined) { await Deno.remove(report.dir, { recursive: true }).catch( rethrowUnlessNotFound, ); } } }; /** The pass-through run: a caller that chose its own reporter keeps deno's * own output and exit code. The child still prunes by the filter; a run that * matched nothing keeps deno's own silent zero-test exit, since this shape * has no parsed TAP to check. */ const runPlainTest = async ({ extraArgs, nameFilter, useCoverage, junitPath, }: TestRunRequest): Promise => { console.log("Running tests..."); const testCmd = new Deno.Command(Deno.execPath(), { args: buildDenoTestArgs(extraArgs, useCoverage, undefined, junitPath), cwd: projectRoot, env: testChildEnv(nameFilter), stderr: "inherit", stdin: "inherit", stdout: "inherit", }); const result = await testCmd.output(); return result.code; }; /** * Run `deno test` with the standard permission flags. `extraArgs` are appended * verbatim — the full runner passes `["test/"]`, the focused runner passes the * requested files (and any flags such as `--filter`). A `--filter` argument is * stripped from the child's command line and set as `TICKETS_TEST_FILTER` * instead, so the bdd wrappers in the child can prune by leaf name (deno's own * `--filter` matches top-level names only). `junitPath`, when set, makes * `deno test` write a JUnit XML file the caller can parse for per-test * timings. Returns the exit code. */ export const runTests = async ( extraArgs: string[], useCoverage: boolean, junitPath?: string, estimateFrom?: string[], ): Promise => { if (useCoverage) await removeOldCoverageOutput(); const { args, nameFilter } = extractNameFilter(extraArgs); rejectReporterWithFilter(args, nameFilter); if (!hasReporterArg(args)) { return await runFocusedTest( { extraArgs: args, junitPath, nameFilter, useCoverage }, estimateFrom, ); } return await runPlainTest({ extraArgs: args, junitPath, nameFilter, useCoverage, }); }; /** * Build the run-wide test state (golden DB + captured setup ceremony) that * every test isolate seeds itself from instead of rebuilding — see * test/test-utils/test-state.ts. Returns a cleanup function. When the env var * is already set (a nested harness, e.g. a mutation run's child), the existing * state is reused and left in place. The import is dynamic so the harness only * loads the app graph when it actually builds state. */ const setupTestState = async (): Promise<() => Promise> => { if (Deno.env.get(TEST_STATE_DIR_ENV)) return async () => {}; const { writeTestState } = await import("#test-utils/test-state.ts"); const dir = await Deno.makeTempDir({ prefix: "tickets-test-state-" }); try { await writeTestState(dir); } catch (error) { return failAfterCleanups(error, [ () => Deno.remove(dir, { recursive: true }).catch(rethrowUnlessNotFound), ]); } Deno.env.set(TEST_STATE_DIR_ENV, dir); harnessLog("test state prebuilt in", dir); return async () => { Deno.env.delete(TEST_STATE_DIR_ENV); // Only an already-gone dir is expected; anything else (e.g. permissions) // must surface rather than silently leave run state on disk. await Deno.remove(dir, { recursive: true }).catch(rethrowUnlessNotFound); }; }; /** * Run `task` with the full test environment in place: up-to-date static * assets, a running stripe-mock with STRIPE_MOCK_HOST/PORT exported, and the * run-wide prebuilt test state exported as TICKETS_TEST_STATE_DIR. Afterwards * the mock is stopped and the prebuilt state removed, even if `task` throws. * The built assets are left in place: they are gitignored build output, and * keeping them is what lets the next run skip the build (see * static-assets/cache.ts). * * stripe-mock spends most of a second loading its API spec before it accepts a * connection, and it needs nothing else the harness makes. So it is started * first and only waited for at the end: that wait runs while the assets and the * run-wide test state are prepared, which is otherwise dead time on every run. * Nothing in either of those steps talks to Stripe, and the Stripe client reads * STRIPE_MOCK_HOST/PORT afresh every time it is built, so setting them once the * mock is up is still in time for the first test. */ export const withTestHarness = async ( task: (resources: TestHarnessResources) => Promise, ): Promise => { const startingStripeMock = startStripeMock(); const stopStripeMock = releaseWhenStarted(startingStripeMock, (mock) => { harnessLog("Stopping stripe-mock..."); return mock.stop(); }); let staticAssets: StaticAssetBuild | null = null; let cleanupTestState: (() => Promise) | null = null; return withCleanup(async () => { const assets = await prepareStaticAssets({ quiet: true }); staticAssets = assets; cleanupTestState = await setupTestState(); const mockEnv = stripeMockEnv((await startingStripeMock).port); harnessLog("stripe-mock running on port", mockEnv.STRIPE_MOCK_PORT); return withEnvironment( { STRIPE_MOCK_HOST: mockEnv.STRIPE_MOCK_HOST, STRIPE_MOCK_PORT: mockEnv.STRIPE_MOCK_PORT, }, () => task({ staticAssets: assets }), ); }, [ stopStripeMock, async () => { if (cleanupTestState) await cleanupTestState(); }, () => staticAssets?.dispose(), ]); }; /** * Run the whole `test/` suite inside the harness, emitting per-test timings to * the shared JUnit path. Any stale JUnit file is removed first so a killed prior * run can't surface its timings; `deno test --junit-path` rewrites it on a * completed run. * * Test files are grouped into shared isolates (see scripts/test-groups.ts) so * the app module graph is evaluated once per group instead of once per file. * Set TICKETS_TEST_UNGROUPED=1 to run every file in its own isolate — useful * to rule grouping out when chasing a state leak between test files. */ export const runSuiteWithHarness = async ( useCoverage: boolean, nameFilter?: string, ): Promise => { // Only a missing file is expected here; a real removal failure (e.g. // permissions) must surface rather than leave a stale JUnit file behind. await Deno.remove(JUNIT_PATH).catch(rethrowUnlessNotFound); // The coverage gate's workers run concurrently, and each one's memory grows // with the files its group holds. Without an explicit count, cap the // workers so the gate's total memory stays inside the runner's budget; the // group count rises with the file count, so every isolate stays small. if (useCoverage) { const { coverageDenoJobs } = await import("./workers.ts"); const jobs = coverageDenoJobs( Deno.env.get("DENO_JOBS"), navigator.hardwareConcurrency, ); if (jobs !== undefined) Deno.env.set("DENO_JOBS", String(jobs)); } return withTestHarness(async () => { // The filter travels in the arguments and `runTests` — the single place // that strips it and sets the child's env — picks it up there. const filterArgs = nameFilter === undefined ? [] : ["--filter", nameFilter]; let testCode: number; if (Deno.env.get("TICKETS_TEST_UNGROUPED") === "1") { testCode = await runTests( ["test/", ...filterArgs], useCoverage, JUNIT_PATH, ); } else { const { writeTestGroups } = await import("./test-groups.ts"); const groups = await writeTestGroups(projectRoot); try { testCode = await runTests( [...groups.runArgs, ...filterArgs], useCoverage, JUNIT_PATH, groups.testFiles, ); } finally { await groups.cleanup(); } } if (testCode !== 0) return testCode; // A name filter selects direct Deno tests; the acceptance stories are // Cucumber features with no test name to match, so a filtered run skips // them instead of running the whole suite behind a one-test request. if (nameFilter !== undefined) return 0; // Acceptance stories run in this harness process after direct Deno tests. // Their behavior is required, but their execution never fills a gap in the // direct suite's separately-enforced 100% coverage. const { runSpecs } = await import("./specs/run.ts"); return (await runSpecs()).success ? 0 : 1; }); };