#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run /** * Runs a workspace member's test files as the `deno test` runs their flags * need. * * run-test-batches.ts ROOT [OPTION]... -- FLAGS... * * `readBatchRunnerArguments()` in the test topology reads those arguments * for this runner and for a lane pointed at the same files, so the two agree * on which files the member has and which flags each of them runs under. * The files a `--serial` or `--all-access` option names run apart from the * rest, grouped by `testBatches()` there into one `deno test` for each set * of flags. Every glob those options and `--ignore` give has to match a * test file of the member, so that a renamed file is reported rather than * run under the wrong flags. */ import { memberTestFiles, readBatchRunnerArguments, type TestBatch, testBatches, unmatchedGlobs, } from "./test-topology/deno-task.ts"; /** The flag that names where `deno test` writes its JUnit report. */ const JUNIT_PATH_FLAG = "--junit-path="; /** * Merges JUnit reports that `deno test` wrote into one report holding every * suite of each, in the order given, with the counts and time of the root * element summed. Throws on a report with no `testsuites` element, which is * not one `deno test` writes. */ export function mergeJUnitReports(reports: readonly string[]): string { const totals = { tests: 0, failures: 0, errors: 0, time: 0 }; const suites: string[] = []; for (const report of reports) { const root = /]*)>/.exec(report); const end = report.lastIndexOf(""); if (root === null || end < root.index) { throw new Error("Not a JUnit report: no `testsuites` element."); } for (const key of Object.keys(totals) as (keyof typeof totals)[]) { const value = new RegExp(`\\b${key}="([^"]*)"`).exec(root[1]!)?.[1]; totals[key] += Number(value ?? 0); } suites.push(report.slice(root.index + root[0].length, end)); } return '\n' + `` + suites.join("") + "\n"; } /** * Like `flags`, except that a report path written as two words, * `--junit-path PATH`, is written as the one word `--junit-path=PATH`. */ function joinedReportPath(flags: readonly string[]): string[] { const joined: string[] = []; for (let index = 0; index < flags.length; index++) { const flag = flags[index]!; if (flag === "--junit-path" && index + 1 < flags.length) { joined.push(`${JUNIT_PATH_FLAG}${flags[++index]}`); } else { joined.push(flag); } } return joined; } /** * Runs each batch as a `deno test` in `cwd`, in turn, and returns the exit * code of the first that fails, or zero when none does. * A batch that fails does not stop the ones after it, so that a run * reports every test it was given. * * Every batch is handed the same `--junit-path`, where the flags hold one. * With two batches or more, each writes its report beside that path, and * once the run ends the reports the batches wrote are merged into it and * removed. A batch that stopped before writing one contributes nothing to * the merge, which is what its own report would have contributed. */ export async function runTestBatches( batches: readonly TestBatch[], cwd: string = Deno.cwd(), ): Promise { let junitPath: string | undefined; const reports: string[] = []; let code = 0; for (const [index, batch] of batches.entries()) { const flags = batches.length === 1 ? batch.flags : joinedReportPath(batch.flags).map((flag) => { if (!flag.startsWith(JUNIT_PATH_FLAG)) return flag; junitPath = flag.slice(JUNIT_PATH_FLAG.length); reports.push(`${junitPath}.${index}`); return `${JUNIT_PATH_FLAG}${reports.at(-1)}`; }); const status = await new Deno.Command(Deno.execPath(), { args: ["test", ...flags, ...batch.files], cwd, stdin: "inherit", stdout: "inherit", stderr: "inherit", }).spawn().status; if (!status.success && code === 0) code = status.code; } if (junitPath !== undefined) { const written: string[] = []; for (const report of reports) { try { written.push(await Deno.readTextFile(report)); } catch (error) { if (error instanceof Deno.errors.NotFound) continue; throw error; } await Deno.remove(report); } if (written.length > 0) { await Deno.writeTextFile(junitPath, mergeJUnitReports(written)); } } return code; } /** * Runs every test file of the member at `memberDir` that the runner's * arguments name, and returns the exit code of the first `deno test` that * failed, or zero when none did. * * Throws on arguments it cannot read, on a `--serial`, `--all-access` or * `--ignore` glob that names no test file, and on a member holding no test * file. */ export async function runMemberBatches( args: readonly string[], memberDir: string, ): Promise { const test = readBatchRunnerArguments(args); if (test === undefined) { throw new Error( "Usage: run-test-batches.ts ROOT " + "[--serial=GLOBS] [--all-access=GLOBS] -- TEST_FLAGS...", ); } const unmatched = await unmatchedGlobs(memberDir, test.paths, [ ...test.serial, ...test.allAccess, ...test.ignores, ]); if (unmatched.length > 0) { throw new Error( `No test file matches ${ unmatched.map((glob) => `\`${glob}\``).join(", ") }.`, ); } const files = await memberTestFiles(memberDir, test); if (files.length === 0) throw new Error("No test file to run."); return await runTestBatches(testBatches(test, files), memberDir); } async function main(): Promise { const code = await runMemberBatches(Deno.args, Deno.cwd()); if (code !== 0) Deno.exit(code); } if (import.meta.main) await main();