scripts/test-harness.ts
Rama main · réplica de lectura del repositorio original.
/**
* 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<string, string> => ({
...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<number> => {
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<number> => {
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<number> => {
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<void>> => {
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 <T>(
task: (resources: TestHarnessResources) => Promise<T>,
): Promise<T> => {
const startingStripeMock = startStripeMock();
const stopStripeMock = releaseWhenStarted(startingStripeMock, (mock) => {
harnessLog("Stopping stripe-mock...");
return mock.stop();
});
let staticAssets: StaticAssetBuild | null = null;
let cleanupTestState: (() => Promise<void>) | 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<number> => {
// 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;
});
};