diff --git a/.gitignore b/.gitignore index 5f76a6a430..523fb8f580 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ cov_profile/ .deno/ .deno-cache/ .local-data/ +.mutation-runs/ docs-output*/ misc/ ARCHITECTURE.md diff --git a/AGENTS.md b/AGENTS.md index 7bdec3275c..9782a26318 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -475,9 +475,9 @@ logging and table-scoped cache invalidation stay automatic. - `deno task lint:ci` - Strict, read-only lint (`check --error-on-warnings`, no `--write`). Fails on lint warnings (e.g. cognitive complexity) and on any code that *would* be reformatted, without touching the checkout. This is the lint `deno task precommit` runs in **every** environment, so a clean `precommit` locally means the lint step will pass in CI too. Run `deno task lint` to auto-fix before re-running. - `deno task build:edge` - Build for Bunny Edge deployment - `deno task backup` - Dump the database out-of-band to a `.zip`. Uploads to the configured storage zone by default (so it appears on the Backups page and lets the next migration skip its own inline backup); pass `--out ` to write a local file. Runs in a full Deno process, so unlike the in-edge backup it has no per-request subrequest budget and can dump arbitrarily large databases. -- `deno task precommit` - Run all checks (typecheck, lint, tests, changed-file mutation) +- `deno task precommit` - Run all checks (typecheck, lint, tests) - `deno task precommit:mutation` - The precommit mutation gate, runnable on its own: mutation-test every `src/` file this branch changed against every changed `test/` file and demand a 100% kill rate. The changed set is the branch's committed diff against the integration branch (`origin/main`, else a local `main`) via `base...HEAD` — three-dot/merge-base, so it's the branch's full diff vs main and stays bounded to the branch's own commits (precommit runs post-commit on a clean tree, so the index is empty). Because the project requires 100% coverage, a src change lands with its covering test change in the same commit range, so the changed set is its own source→test mapping. Skips cheaply when there is no base ref or no changed `src/` files (and likewise when src changed without any changed test). If a badly stale local `origin/main` balloons the changed set past `STALE_BASE_SOURCE_LIMIT`, it skips with a "run `git fetch origin main`" hint instead of mutating most of the tree. See [Mutation Testing](#mutation-testing). -- `deno task mutation ` - Mutation-test your tests on demand: mutate operators in the source and check your tests catch it (see [Mutation Testing](#mutation-testing)) +- `deno task mutation ` - Mutation-test your tests on demand in an isolated `.mutation-runs//work` copy: mutate operators in the source and check your tests catch it (see [Mutation Testing](#mutation-testing)) ### Running Individual Test Files @@ -651,18 +651,34 @@ bundle for each mutant, so the mutation reaches the built asset the tests load. How it works (and why it is bespoke): it mutates the source file **in place**, runs the mapped tests in a fresh `deno test` subprocess, then restores the -file. In-place mutation is what makes mutations bind through `#…` import-map -aliases. The operator tables and AST walk are vendored from +file. The normal `deno task mutation` command first copies the current checkout +(including dirty source/test edits, excluding `.git`, cache/report folders, +local databases, secrets, and generated assets) to `.mutation-runs//work`; +all in-place writes and per-mutant bundle rebuilds happen inside that copy, not +the live files. Each run leaves `.mutation-runs//run.json` with the child +PID/status, so a stray run is easy to find and stop: + +```bash +deno task mutation --list +deno task mutation --kill # or: all +deno task mutation --clean finished # or: / all +``` + +In-place mutation inside the copied checkout is what makes mutations bind +through `#…` import-map aliases. The operator tables and AST walk are vendored from [Mutasaurus](https://github.com/christoshrousis/mutasaurus) (MIT); its own execution model writes a temp copy but runs the original tests, so every mutant falsely "survives" on an alias-based project — see `scripts/mutation/LICENSE.mutasaurus.md`. As a manual tool it is **targeted** (run `deno task mutation` on the module you are hardening) — running it across -the whole tree would be far too slow. `deno task precommit` does run it -automatically, but **only over the files this branch changed** (its committed -diff against `origin/main`/`main`): the `precommit:mutation` step -mutates each changed `src/` file against the changed `test/` files and demands a -100% kill rate, so the cost stays bounded to what you actually changed. +the whole tree would be far too slow. The standalone +`deno task precommit:mutation` runs it automatically, but **only over the files +this branch changed** (its committed diff against `origin/main`/`main`): the +`precommit:mutation` step mutates each changed `src/` file against the changed +`test/` files and demands a 100% kill rate, so the cost stays bounded to what +you actually changed. Run `deno task precommit:mutation` before merging a +branch that changes `src/` files; the standard `deno task precommit` no longer +runs it (it was too slow for every commit). Known-equivalent survivors recorded in `scripts/mutation/equivalent-mutants.txt` are suppressed, as with a manual run. That file's header warns against recording `=== → ==`/`!== → !=` mutants diff --git a/TODO.md b/TODO.md index 5290156b1e..1c0cbe21e0 100644 --- a/TODO.md +++ b/TODO.md @@ -187,6 +187,14 @@ descriptors — PRs #1478 and others). A weak-assertion audit script also exists **Remaining:** +- **Mutation tests removed from `deno task precommit`.** The + `precommit:mutation` step was too slow for the standard precommit run and was + removed from `scripts/precommit/steps.ts`. The mutation gate still exists as + `deno task precommit:mutation` and `deno task mutation` — run it manually on + changed src/test pairs before merging. Re-wire it into precommit (perhaps + behind a flag or with a tighter changed-set bound) only if the per-commit + mutation cost comes down. + - **Property-based tests (item 5).** `fast-check` is currently used in only one test (`test/lib/fold-tree.test.ts`). Add properties for: slug generation, CSV round-trips (commas / quotes / CRLF), date formatting across timezones, token diff --git a/scripts/mutation.ts b/scripts/mutation.ts index ae11d5c06d..9843066561 100644 --- a/scripts/mutation.ts +++ b/scripts/mutation.ts @@ -2,20 +2,30 @@ /** * In-house mutation tester — "tests for your tests". * - * Mutates binary/logical/assignment operators in the given source file(s), runs the + * Copies the checkout into `.mutation-runs//work`, mutates + * binary/logical/assignment operators in the copied source file(s), runs the * mapped test file(s), and reports which mutants SURVIVED (were not caught by * any assertion). It is the real version of the heuristic in * `test-quality-audit.ts`: instead of guessing which assertions look weak, it * proves which code changes your tests fail to notice. * * The operator tables and AST walk are derived from Mutasaurus (MIT); the - * execution model is our own — see scripts/mutation/LICENSE.mutasaurus.md. + * execution model is our own. The child process still mutates in place inside + * the copied checkout so import-map aliases bind to the mutant — see + * scripts/mutation/LICENSE.mutasaurus.md. * * Usage: deno task mutation [options] */ import { globToRegExp, join, normalize, SEPARATOR } from "@std/path"; -import { runMutationTesting } from "./mutation/runner.ts"; +import { runIsolatedMutationCommand } from "./mutation/isolation.ts"; +import { + MUTATION_RUN_ID_ENV, + MUTATION_RUN_ROOT_ENV, + MUTATION_SNAPSHOT_CHILD_ENV, + MUTATION_WORK_ROOT_ENV, + withMutationRunLock, +} from "./mutation/isolation-state.ts"; const DEFAULT_TIMEOUT = 10_000; @@ -197,6 +207,7 @@ const main = async (): Promise => { Deno.exit(1); } + const { runMutationTesting } = await import("./mutation/runner.ts"); const code = await runMutationTesting({ ...(args.batchJobs === undefined ? {} : { batchJobs: args.batchJobs }), exhaustive: args.exhaustive, @@ -208,4 +219,24 @@ const main = async (): Promise => { Deno.exit(code); }; -main(); +const mutationRunRootFromEnv = (): string | null => { + const id = Deno.env.get(MUTATION_RUN_ID_ENV); + const runRoot = Deno.env.get(MUTATION_RUN_ROOT_ENV); + const workRoot = Deno.env.get(MUTATION_WORK_ROOT_ENV); + return id && runRoot && workRoot ? runRoot : null; +}; + +const runSnapshotChild = async (): Promise => { + const runRoot = mutationRunRootFromEnv(); + return runRoot === null + ? await main() + : await withMutationRunLock(runRoot, main); +}; + +if (import.meta.main) { + if (Deno.env.get(MUTATION_SNAPSHOT_CHILD_ENV) === "1") { + await runSnapshotChild(); + } else { + Deno.exit(await runIsolatedMutationCommand(Deno.args)); + } +} diff --git a/scripts/mutation/isolation-state.ts b/scripts/mutation/isolation-state.ts new file mode 100644 index 0000000000..5669ffd4f5 --- /dev/null +++ b/scripts/mutation/isolation-state.ts @@ -0,0 +1,481 @@ +/** + * Pure-ish state and filesystem helpers for isolated mutation runs. + * + * The process supervisor lives in isolation.ts; this module holds the small + * rules that are cheap to unit-test directly. + */ + +import { + dirname, + isAbsolute, + join, + relative, + resolve, + SEPARATOR, +} from "@std/path"; +import { projectRoot } from "../project-root.ts"; + +export const MUTATION_RUNS_DIR = ".mutation-runs"; +export const MUTATION_WORK_DIR = "work"; +export const MUTATION_RECORD_FILE = "run.json"; +export const MUTATION_RUN_LOCK_FILE = "run.lock"; +export const MUTATION_SNAPSHOT_CHILD_ENV = "TICKETS_MUTATION_SNAPSHOT_CHILD"; +export const MUTATION_RUN_ID_ENV = "TICKETS_MUTATION_RUN_ID"; +export const MUTATION_RUN_ROOT_ENV = "TICKETS_MUTATION_RUN_ROOT"; +export const MUTATION_WORK_ROOT_ENV = "TICKETS_MUTATION_WORK_ROOT"; + +const SKIPPED_TOP_LEVEL_NAMES = new Set([ + ".agents", + ".claude", + ".codex", + ".deno", + ".deno-cache", + ".deno_cache", + ".direnv", + ".do", + ".git", + ".i18n-work", + ".local-data", + ".mutation-runs", + ".pi-worktrees", + "cov", + "cov_profile", + "dist", + "docs-output", + "misc", + "node_modules", + "undefined", + "null", +]); + +const SKIPPED_TOP_LEVEL_PREFIXES = ["coverage", ".jscpd", "docs-output"]; + +const SKIPPED_FILE_NAMES = new Set([ + ".build-tag", + ".db-key", + ".env", + ".test-junit.xml", + "bunny-script.ts", + "bunny-script.ts.map", + "tickets.db", +]); + +export type MutationRunStatus = + | "copying" + | "running" + | "passed" + | "failed" + | "interrupted"; + +export interface MutationRunRecord { + args: string[]; + createdAt: string; + exitCode?: number; + id: string; + pid?: number; + root: string; + status: MutationRunStatus; + updatedAt: string; + workRoot: string; +} + +export type IsolationCommand = + | { kind: "clean"; target: string } + | { kind: "help" } + | { kind: "invalid"; message: string } + | { kind: "kill"; force: boolean; target: string } + | { kind: "list" } + | { args: string[]; kind: "run" }; + +export const ISOLATION_USAGE = `Usage: + deno task mutation [mutation options] + deno task mutation --list + deno task mutation --kill [--force] + deno task mutation --clean + +Mutation runs are copied to .mutation-runs//work first. The normal +in-place mutation engine then runs inside that copy, so live source files are +not touched.`; + +const nowIso = (): string => new Date().toISOString(); + +const pathParts = (path: string): string[] => + path.split(/[\\/]+/).filter((part) => part.length > 0); + +const slashPath = (path: string): string => pathParts(path).join("/"); + +const isDatabaseFile = (name: string): boolean => + name.endsWith(".db") || name.endsWith(".db-shm") || name.endsWith(".db-wal"); + +const isGeneratedStaticAsset = (relativePath: string): boolean => + relativePath.startsWith("src/ui/static/") && + (relativePath.endsWith(".js") || relativePath === "src/ui/static/style.css"); + +export const shouldCopySnapshotPath = (relativePath: string): boolean => { + const parts = pathParts(relativePath); + const top = parts[0]; + const name = parts.at(-1); + if (!top || !name) return true; + if (SKIPPED_TOP_LEVEL_NAMES.has(top)) return false; + if (SKIPPED_TOP_LEVEL_PREFIXES.some((prefix) => top.startsWith(prefix))) { + return false; + } + if (SKIPPED_FILE_NAMES.has(name) || isDatabaseFile(name)) return false; + return !isGeneratedStaticAsset(slashPath(relativePath)); +}; + +const copyDirectory = async ( + fromRoot: string, + toRoot: string, + relativePath = "", +): Promise => { + const fromDir = join(fromRoot, relativePath); + const toDir = join(toRoot, relativePath); + await Deno.mkdir(toDir, { recursive: true }); + + const entries: Deno.DirEntry[] = []; + for await (const entry of Deno.readDir(fromDir)) entries.push(entry); + + for (const entry of entries.sort((left, right) => + left.name.localeCompare(right.name), + )) { + const childPath = relativePath + ? join(relativePath, entry.name) + : entry.name; + if (!shouldCopySnapshotPath(childPath)) continue; + + const from = join(fromRoot, childPath); + const to = join(toRoot, childPath); + if (entry.isDirectory) { + await copyDirectory(fromRoot, toRoot, childPath); + } else { + await Deno.mkdir(dirname(to), { recursive: true }); + await Deno.copyFile(from, to); + } + } +}; + +export const copyMutationSnapshot = async ( + fromRoot: string, + toRoot: string, +): Promise => { + await Deno.mkdir(toRoot, { recursive: true }); + await copyDirectory(fromRoot, toRoot); +}; + +const compactIso = (iso: string): string => + iso + .replaceAll(":", "") + .replaceAll("-", "") + .replace(/\.\d+Z$/, "Z"); + +export const createRunId = ( + date = new Date(), + suffix = crypto.randomUUID().slice(0, 8), +): string => `mutation-${compactIso(date.toISOString())}-${suffix}`; + +export const runsRoot = (root = projectRoot): string => + join(root, MUTATION_RUNS_DIR); + +export const runRoot = (id: string, root = projectRoot): string => + join(runsRoot(root), id); + +export const workRoot = (id: string, root = projectRoot): string => + join(runRoot(id, root), MUTATION_WORK_DIR); + +export const recordPath = (id: string, root = projectRoot): string => + join(runRoot(id, root), MUTATION_RECORD_FILE); + +export const runLockPath = (record: Pick): string => + join(record.root, MUTATION_RUN_LOCK_FILE); + +export const newRunRecord = ( + id: string, + args: string[], + root = projectRoot, + createdAt = nowIso(), +): MutationRunRecord => ({ + args, + createdAt, + id, + root: runRoot(id, root), + status: "copying", + updatedAt: createdAt, + workRoot: workRoot(id, root), +}); + +export const statusForExitCode = (code: number): MutationRunStatus => + code === 0 ? "passed" : code === 130 ? "interrupted" : "failed"; + +export const markRunning = ( + record: MutationRunRecord, + pid: number, + updatedAt = nowIso(), +): MutationRunRecord => ({ + ...record, + pid, + status: "running", + updatedAt, +}); + +export const markFinished = ( + record: MutationRunRecord, + exitCode: number, + updatedAt = nowIso(), +): MutationRunRecord => ({ + ...record, + exitCode, + status: statusForExitCode(exitCode), + updatedAt, +}); + +export const markInterrupted = ( + record: MutationRunRecord, + updatedAt = nowIso(), +): MutationRunRecord => ({ + ...record, + exitCode: 130, + status: "interrupted", + updatedAt, +}); + +export const isTerminalRunStatus = (status: MutationRunStatus): boolean => + status === "passed" || status === "failed" || status === "interrupted"; + +/** + * How long after a run is marked "running" we still treat it as active for + * cleanup, even if the child has not acquired the run lock yet. This covers + * the startup window between `spawn()` and the child taking the lock. After + * it expires, a running record with a live PID but no lock is stale (the PID + * may have been reused by an unrelated process) and can be cleaned. + */ +export const RUN_STARTUP_GRACE_MS = 30_000; + +export const runStartedRecently = ( + record: MutationRunRecord, + now: Date = new Date(), + graceMs: number = RUN_STARTUP_GRACE_MS, +): boolean => + Date.parse(record.updatedAt) > 0 && + now.getTime() - Date.parse(record.updatedAt) < graceMs; + +export const writeRunRecord = async ( + record: MutationRunRecord, +): Promise => { + await Deno.mkdir(dirname(join(record.root, MUTATION_RECORD_FILE)), { + recursive: true, + }); + await Deno.writeTextFile( + join(record.root, MUTATION_RECORD_FILE), + `${JSON.stringify(record, null, 2)}\n`, + ); +}; + +export const readRunRecord = async ( + path: string, +): Promise => { + try { + return JSON.parse(await Deno.readTextFile(path)) as MutationRunRecord; + } catch { + return null; + } +}; + +const recordInCurrentRunDirectory = ( + record: MutationRunRecord, + id: string, + root = projectRoot, +): MutationRunRecord => ({ + ...record, + id, + root: runRoot(id, root), + workRoot: workRoot(id, root), +}); + +export const readRunRecords = async ( + root = projectRoot, +): Promise => { + const records: MutationRunRecord[] = []; + try { + for await (const entry of Deno.readDir(runsRoot(root))) { + if (!entry.isDirectory) continue; + const record = await readRunRecord(recordPath(entry.name, root)); + if (record) { + records.push(recordInCurrentRunDirectory(record, entry.name, root)); + } + } + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } + return records.sort((left, right) => + right.createdAt.localeCompare(left.createdAt), + ); +}; + +const withTrailingSeparator = (path: string): string => + path.endsWith(SEPARATOR) ? path : `${path}${SEPARATOR}`; + +export const rewriteProjectPathArg = ( + root: string, + snapshotRoot: string, + value: string, +): string => { + if (!isAbsolute(value)) return value; + const resolvedRoot = resolve(root); + const resolvedSnapshot = resolve(snapshotRoot); + const resolvedValue = resolve(value); + if (resolvedValue === resolvedRoot) return resolvedSnapshot; + const rootPrefix = withTrailingSeparator(resolvedRoot); + if (!resolvedValue.startsWith(rootPrefix)) return value; + return join(resolvedSnapshot, resolvedValue.slice(rootPrefix.length)); +}; + +export const rewriteMutationArgs = ( + root: string, + snapshotRoot: string, + args: string[], +): string[] => + args.map((arg) => rewriteProjectPathArg(root, snapshotRoot, arg)); + +export const parseIsolationCommand = (args: string[]): IsolationCommand => { + const [first, second, ...rest] = args; + if (!first) { + return { + kind: "invalid", + message: "Mutation source and test globs are required.", + }; + } + if (first === "-h" || first === "--help") return { kind: "help" }; + if (first === "list" || first === "--list") return { kind: "list" }; + if (first === "kill" || first === "--kill") { + return second + ? { force: rest.includes("--force"), kind: "kill", target: second } + : { kind: "invalid", message: "A run id or all is required for --kill." }; + } + if (first === "clean" || first === "--clean") { + return second + ? { kind: "clean", target: second } + : { + kind: "invalid", + message: "A run id, all, or finished is required for --clean.", + }; + } + return { args, kind: "run" }; +}; + +export const visibleStatus = ( + record: MutationRunRecord, + isAlive: boolean, +): MutationRunStatus | "stale" => + record.status === "running" && !isAlive ? "stale" : record.status; + +export const formatRunLine = ( + record: MutationRunRecord, + isAlive: boolean, + root = projectRoot, +): string => { + const status = visibleStatus(record, isAlive); + const pid = record.pid === undefined ? "pid=-" : `pid=${record.pid}`; + const exit = + record.exitCode === undefined ? "exit=-" : `exit=${record.exitCode}`; + const work = relative(root, record.workRoot); + const args = record.args.length === 0 ? "" : ` args=${record.args.join(" ")}`; + return `${record.id} ${status} ${pid} ${exit} work=${work}${args}`; +}; + +export const formatRunList = ( + records: MutationRunRecord[], + liveRunIds: Set, + root = projectRoot, +): string[] => + records.length === 0 + ? ["No isolated mutation runs."] + : records.map((record) => + formatRunLine(record, liveRunIds.has(record.id), root), + ); + +const LOCK_HELD_EXIT_CODE = 124; + +const LOCK_PROBE_SCRIPT = ` +const [path, timeoutText] = Deno.args; +const timeout = setTimeout( + () => Deno.exit(${LOCK_HELD_EXIT_CODE}), + Number(timeoutText), +); +const file = await Deno.open(path, { read: true, write: true }).catch(() => null); +if (file === null) { + clearTimeout(timeout); + Deno.exit(2); +} +try { + await file.lock(true); + await file.unlock(); + clearTimeout(timeout); + file.close(); + Deno.exit(0); +} catch { + clearTimeout(timeout); + file.close(); + Deno.exit(2); +} +`; + +const lockProbeExitCode = async ( + path: string, + timeoutMs: number, +): Promise => { + const { code } = await new Deno.Command(Deno.execPath(), { + args: ["eval", LOCK_PROBE_SCRIPT, "--", path, String(timeoutMs)], + stderr: "null", + stdout: "null", + }).output(); + return code; +}; + +export const runLockIsHeld = async ( + record: Pick, + timeoutMs = 50, +): Promise => { + const path = runLockPath(record); + const file = await Deno.open(path, { + create: true, + read: true, + write: true, + }).catch(() => null); + if (file === null) return false; + file.close(); + return (await lockProbeExitCode(path, timeoutMs)) === LOCK_HELD_EXIT_CODE; +}; + +export const withMutationRunLock = async ( + runRootPath: string, + run: () => Promise, +): Promise => { + await Deno.mkdir(runRootPath, { recursive: true }); + const file = await Deno.open(join(runRootPath, MUTATION_RUN_LOCK_FILE), { + create: true, + read: true, + write: true, + }); + try { + await file.lock(true); + return await run(); + } finally { + await file.unlock(); + file.close(); + } +}; + +export const selectedRuns = ( + records: MutationRunRecord[], + target: string, +): MutationRunRecord[] => { + if (target === "all") return records; + if (target === "finished") { + return records.filter((record) => isTerminalRunStatus(record.status)); + } + const exact = records.filter((record) => record.id === target); + if (exact.length > 0) return exact; + const prefixed = records.filter((record) => record.id.startsWith(target)); + return prefixed.length === 1 ? prefixed : []; +}; diff --git a/scripts/mutation/isolation.ts b/scripts/mutation/isolation.ts new file mode 100644 index 0000000000..71713994d9 --- /dev/null +++ b/scripts/mutation/isolation.ts @@ -0,0 +1,336 @@ +/** + * Side-effecting supervisor for isolated mutation runs. + * + * It copies the checkout, starts the normal mutation script inside that copy, + * and manages list/kill/clean commands for `.mutation-runs/`. + */ + +import { relative } from "@std/path"; +import { processExists, stopProcess, stopProcessNow } from "../process.ts"; +import { projectRoot } from "../project-root.ts"; +import { + copyMutationSnapshot, + createRunId, + formatRunList, + ISOLATION_USAGE, + MUTATION_RUN_ID_ENV, + MUTATION_RUN_ROOT_ENV, + MUTATION_SNAPSHOT_CHILD_ENV, + MUTATION_WORK_ROOT_ENV, + type MutationRunRecord, + markFinished, + markInterrupted, + markRunning, + newRunRecord, + parseIsolationCommand, + readRunRecords, + rewriteMutationArgs, + runLockIsHeld, + runStartedRecently, + selectedRuns, + withMutationRunLock, + writeRunRecord, +} from "./isolation-state.ts"; + +const processBelongsToRun = async ( + record: MutationRunRecord, +): Promise => { + if (record.status !== "running" || record.pid === undefined) return false; + if (!processExists(record.pid)) return false; + return await runLockIsHeld(record); +}; + +const copyingRunStillActive = async ( + record: MutationRunRecord, +): Promise => + record.status === "copying" && (await runLockIsHeld(record)); + +const runningProcessStillExists = async ( + record: MutationRunRecord, +): Promise => + record.status === "running" && + record.pid !== undefined && + processExists(record.pid) && + (runStartedRecently(record) || (await runLockIsHeld(record))); + +const liveRunIdSet = async ( + records: MutationRunRecord[], +): Promise> => { + const live = await Promise.all( + records.map(async (record) => { + if (record.pid === undefined) return null; + return (await processBelongsToRun(record)) ? record.id : null; + }), + ); + return new Set(live.filter((id): id is string => id !== null)); +}; + +type RemoveRunResult = + | { record: MutationRunRecord; removed: true } + | { error: unknown; record: MutationRunRecord; removed: false }; + +const removeRun = async ( + record: MutationRunRecord, +): Promise => { + try { + await Deno.remove(record.root, { recursive: true }); + return { record, removed: true }; + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return { record, removed: true }; + } + return { error, record, removed: false }; + } +}; + +const cleanableRuns = async ( + records: MutationRunRecord[], +): Promise<{ + removable: MutationRunRecord[]; + skipped: MutationRunRecord[]; +}> => { + const statuses = await Promise.all( + records.map(async (record) => ({ + isActive: + (await copyingRunStillActive(record)) || + (await runningProcessStillExists(record)), + record, + })), + ); + return { + removable: statuses + .filter(({ isActive }) => !isActive) + .map(({ record }) => record), + skipped: statuses + .filter(({ isActive }) => isActive) + .map(({ record }) => record), + }; +}; + +const signalRun = async ( + record: MutationRunRecord, + force: boolean, +): Promise => { + if (!(await processBelongsToRun(record)) || record.pid === undefined) { + return false; + } + try { + Deno.kill(record.pid, force ? "SIGKILL" : "SIGTERM"); + return true; + } catch { + return false; + } +}; + +const childEnv = ( + id: string, + runRootPath: string, + snapshotRoot: string, +): Record => ({ + ...Deno.env.toObject(), + [MUTATION_SNAPSHOT_CHILD_ENV]: "1", + [MUTATION_RUN_ID_ENV]: id, + [MUTATION_RUN_ROOT_ENV]: runRootPath, + [MUTATION_WORK_ROOT_ENV]: snapshotRoot, +}); + +const childArgs = ( + root: string, + snapshotRoot: string, + args: string[], +): string[] => [ + "run", + "-A", + "scripts/mutation.ts", + ...rewriteMutationArgs(root, snapshotRoot, args), +]; + +export const runMutationInSnapshot = async ( + args: string[], + root = projectRoot, +): Promise => { + const id = createRunId(); + let record = newRunRecord(id, args, root); + await writeRunRecord(record); + + let child: Deno.ChildProcess | null = null; + let interrupted = false; + const stopChild = (): void => { + if (interrupted) { + if (child) stopProcessNow(child); + Deno.exit(130); + } + interrupted = true; + if (child) { + try { + child.kill(); + } catch { + // It may already have exited. + } + } + }; + const signals: Deno.Signal[] = ["SIGINT", "SIGTERM"]; + for (const signal of signals) { + try { + Deno.addSignalListener(signal, stopChild); + } catch { + // Signal handling is platform-dependent; the child still owns cleanup. + } + } + + let exitCode = 1; + try { + console.log(`Creating isolated mutation run ${id}`); + console.log(`Snapshot: ${relative(root, record.workRoot)}`); + child = await withMutationRunLock(record.root, async () => { + await writeRunRecord(record); + await copyMutationSnapshot(root, record.workRoot); + if (interrupted) { + record = markInterrupted(record); + await writeRunRecord(record); + exitCode = 130; + return null; + } + const spawned = new Deno.Command(Deno.execPath(), { + args: childArgs(root, record.workRoot, args), + cwd: record.workRoot, + env: childEnv(id, record.root, record.workRoot), + stderr: "inherit", + stdin: "inherit", + stdout: "inherit", + }).spawn(); + child = spawned; + record = markRunning(record, spawned.pid); + await writeRunRecord(record); + console.log(`Mutation child pid ${spawned.pid}`); + return spawned; + }); + + if (child !== null) { + const status = await child.status; + record = interrupted + ? markInterrupted(record) + : markFinished(record, status.code); + await writeRunRecord(record); + exitCode = interrupted ? 130 : status.code; + } + } catch (error) { + if (child !== null) await stopProcess(child, 250); + exitCode = interrupted ? 130 : 1; + record = interrupted + ? markInterrupted(record) + : markFinished(record, exitCode); + try { + await writeRunRecord(record); + } catch { + // A failed write should not mask the original error. + } + console.error(error instanceof Error ? error.message : String(error)); + } + for (const signal of signals) { + try { + Deno.removeSignalListener(signal, stopChild); + } catch { + // Matches the add above. + } + } + return exitCode; +}; + +const listRuns = async (root = projectRoot): Promise => { + const records = await readRunRecords(root); + const liveRunIds = await liveRunIdSet(records); + for (const line of formatRunList(records, liveRunIds, root)) { + console.log(line); + } + return 0; +}; + +const killRuns = async ( + target: string, + force: boolean, + root = projectRoot, +): Promise => { + const records = selectedRuns(await readRunRecords(root), target); + if (records.length === 0) { + console.error(`No isolated mutation run matched ${target}.`); + return 1; + } + const signalled = await Promise.all( + records.map(async (record) => + (await signalRun(record, force)) ? record : null, + ), + ); + const killed = signalled.filter( + (record): record is MutationRunRecord => record !== null, + ); + for (const record of killed) console.log(`Signalled ${record.id}.`); + if (killed.length === 0) { + console.error(`No running isolated mutation run matched ${target}.`); + return 1; + } + return 0; +}; + +const cleanRuns = async ( + target: string, + root = projectRoot, +): Promise => { + const records = selectedRuns(await readRunRecords(root), target); + if (records.length === 0) { + console.error(`No isolated mutation run matched ${target}.`); + return 1; + } + const { removable, skipped } = await cleanableRuns(records); + const removeResults = await Promise.all(removable.map(removeRun)); + const removed = removeResults + .filter( + (result): result is Extract => + result.removed, + ) + .map(({ record }) => record); + const failed = removeResults.filter( + (result): result is Extract => + !result.removed, + ); + + for (const record of removed) console.log(`Removed ${record.id}.`); + for (const { error, record } of failed) { + console.error( + `Failed to remove ${record.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + for (const record of skipped) { + console.error(`Skipped active isolated mutation run ${record.id}.`); + } + if (failed.length > 0) return 1; + if (removed.length === 0) { + console.error(`No cleanable isolated mutation run matched ${target}.`); + return 1; + } + return 0; +}; + +export const runIsolatedMutationCommand = async ( + args: string[], + root = projectRoot, +): Promise => { + const command = parseIsolationCommand(args); + if (command.kind === "invalid") { + console.error(command.message); + console.error(ISOLATION_USAGE); + return 1; + } + if (command.kind === "help") { + console.log(ISOLATION_USAGE); + return 0; + } + if (command.kind === "list") return await listRuns(root); + if (command.kind === "kill") { + return await killRuns(command.target, command.force, root); + } + if (command.kind === "clean") return await cleanRuns(command.target, root); + return await runMutationInSnapshot(command.args, root); +}; diff --git a/scripts/precommit-mutation.ts b/scripts/precommit-mutation.ts index 02282455e9..80301bae0d 100644 --- a/scripts/precommit-mutation.ts +++ b/scripts/precommit-mutation.ts @@ -14,32 +14,30 @@ * scripts/precommit.ts). */ -import { join } from "@std/path"; -import { runMutationTesting } from "./mutation/runner.ts"; +import { runMutationInSnapshot } from "./mutation/isolation.ts"; import { runCommand } from "./precommit/merge-warning.ts"; import { runMutationStep } from "./precommit/mutation-step.ts"; -import { projectRoot } from "./project-root.ts"; /** Per-mutant timeout floor; mirrors `deno task mutation`'s default. */ const MUTANT_TIMEOUT_MS = 10_000; -/** git diff yields repo-relative paths; the runner's ignore-list matching needs - * the absolute form so its `rel()` recovers the repo-relative key. */ -const absolute = (paths: string[]): string[] => - paths.map((path) => join(projectRoot, path)); +const flaggedPaths = (flag: "--source" | "--test", paths: string[]): string[] => + paths.flatMap((path) => [flag, path]); + +const mutationArgs = (sources: string[], tests: string[]): string[] => [ + ...flaggedPaths("--source", sources), + ...flaggedPaths("--test", tests), + "--timeout", + String(MUTANT_TIMEOUT_MS), + "--harness", +]; if (import.meta.main) { const code = await runMutationStep({ log: (message) => console.log(message), run: runCommand, runMutation: ({ sources, tests }) => - runMutationTesting({ - exhaustive: false, - sourceFiles: absolute(sources), - testFiles: absolute(tests), - timeout: MUTANT_TIMEOUT_MS, - useHarness: true, - }), + runMutationInSnapshot(mutationArgs(sources, tests)), }); Deno.exit(code); } diff --git a/scripts/precommit/mutation-step.ts b/scripts/precommit/mutation-step.ts index 7660a81c85..494b166ffe 100644 --- a/scripts/precommit/mutation-step.ts +++ b/scripts/precommit/mutation-step.ts @@ -66,8 +66,8 @@ import type { RunCommand } from "./merge-warning.ts"; export const STALE_BASE_SOURCE_LIMIT = 100; /** Prefix on the skip/warning notices that must stay visible even when the gate - * passes. The precommit runner swallows a successful step's stdout, so these - * are re-surfaced via `mutationNoticeSummary` (wired as the step's summary). */ + * passes. The standalone `precommit:mutation` task prints these directly; + * `mutationNoticeSummary` extracts them for callers that swallow stdout. */ export const MUTATION_NOTICE_PREFIX = "⚠ mutation: "; /** The changed paths split into the src files to mutate and tests to run. */ diff --git a/scripts/precommit/steps.ts b/scripts/precommit/steps.ts index 9ed45f8d1b..defbf3ea93 100644 --- a/scripts/precommit/steps.ts +++ b/scripts/precommit/steps.ts @@ -1,5 +1,4 @@ import { readSlowTestsReport } from "../test-durations.ts"; -import { mutationNoticeSummary } from "./mutation-step.ts"; import { filterTestOutput, testProgressFromLine } from "./output.ts"; /** @@ -39,15 +38,5 @@ export const getSteps = (): Step[] => { progress: testProgressFromLine, summary: async () => (await readSlowTestsReport()) || undefined, }, - // Mutation-test the changed src files against the changed test files, - // demanding a 100% kill rate. Runs last: it needs the green baseline the - // test step proves, and most commits change no src files, so it skips - // cheaply. The summary re-surfaces skip notices (stale base, no merge base, - // no changed tests) that the runner would otherwise swallow on a green step. - { - cmd: [deno, "task", "precommit:mutation"], - name: "mutation", - summary: (stdout) => mutationNoticeSummary(stdout), - }, ]; }; diff --git a/scripts/process.ts b/scripts/process.ts new file mode 100644 index 0000000000..decb2d238c --- /dev/null +++ b/scripts/process.ts @@ -0,0 +1,55 @@ +import nodeProcess from "node:process"; + +const beforeTimeout = async ( + status: Promise, + timeoutMs: number, +): Promise => { + let timeout = 0; + const delayed = new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), timeoutMs); + }); + try { + return await Promise.race([status.then(() => true), delayed]); + } finally { + clearTimeout(timeout); + } +}; + +export const processExists = (pid: number): boolean => { + try { + nodeProcess.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +export const stopProcessNow = (process: Deno.ChildProcess): void => { + try { + process.kill("SIGKILL"); + } catch { + // It may already have exited. + } +}; + +export const stopProcess = async ( + process: Deno.ChildProcess, + timeoutMs: number, + afterStop: () => Promise = () => Promise.resolve(), +): Promise => { + process.ref(); + const status = process.status; + try { + try { + process.kill(); + } catch { + // It may already have exited. + } + if (!(await beforeTimeout(status, timeoutMs))) { + stopProcessNow(process); + await status.catch(() => {}); + } + } finally { + await afterStop(); + } +}; diff --git a/scripts/stripe-mock.ts b/scripts/stripe-mock.ts index 1e4d1f6591..b19a8767d1 100644 --- a/scripts/stripe-mock.ts +++ b/scripts/stripe-mock.ts @@ -8,6 +8,7 @@ */ import { join } from "node:path"; +import { stopProcess, stopProcessNow } from "./process.ts"; import { defaultStripeMockPaths, downloadStripeMock, @@ -102,16 +103,6 @@ const raceWithDelay = async ( } }; -const beforeTimeout = ( - status: Promise, - timeoutMs: number, -): Promise => - raceWithDelay( - status.then(() => true), - timeoutMs, - () => false, - ); - const confirmProcessStillRunning = ( processExited: Promise, delayMs: number, @@ -179,37 +170,6 @@ type StartStripeMockOptions = StripeMockInstallOptions & { stopTimeoutMs?: number; }; -const stopManagedProcess = async ( - process: Deno.ChildProcess, - timeoutMs = STOP_TIMEOUT_MS, - closeStderr: () => Promise = () => Promise.resolve(), -): Promise => { - process.ref(); - const status = process.status; - try { - try { - process.kill(); - } catch { - // It may already have exited. - } - const stopped = await beforeTimeout(status, timeoutMs); - if (!stopped) { - process.kill("SIGKILL"); - await status; - } - } finally { - await closeStderr(); - } -}; - -const stopManagedProcessNow = (process: Deno.ChildProcess): void => { - try { - process.kill("SIGKILL"); - } catch { - // It may already have exited. - } -}; - const alreadyRunningStripeMock = (port: number): RunningStripeMock => ({ port, stop: () => Promise.resolve(), @@ -225,8 +185,8 @@ const managedStripeMock = ( process.unref(); return { port, - stop: () => stopManagedProcess(process, stopTimeoutMs, closeStderr), - stopNow: () => stopManagedProcessNow(process), + stop: () => stopProcess(process, stopTimeoutMs, closeStderr), + stopNow: () => stopProcessNow(process), }; }; @@ -320,7 +280,7 @@ const startStripeMockProcess = async ( ); } - await stopManagedProcess( + await stopProcess( spawned.process, options.stopTimeoutMs ?? STOP_TIMEOUT_MS, ); diff --git a/test/lib/stripe-mock/install.test.ts b/test/lib/stripe-mock/install.test.ts index 73d208d202..d5c351d2a5 100644 --- a/test/lib/stripe-mock/install.test.ts +++ b/test/lib/stripe-mock/install.test.ts @@ -62,9 +62,11 @@ const releaseLockAfterWritingBinary = async ( paths: TestStripeMockPaths, releaseLock: () => Promise, ): Promise => { + const pendingBinaryPath = `${paths.binaryPath}.pending`; await wait(30); - await Deno.writeTextFile(paths.binaryPath, "#!/bin/sh\nexit 0\n"); - await makeExecutable(paths.binaryPath); + await Deno.writeTextFile(pendingBinaryPath, "#!/bin/sh\nexit 0\n"); + await makeExecutable(pendingBinaryPath); + await Deno.rename(pendingBinaryPath, paths.binaryPath); await releaseLock(); }; diff --git a/test/lib/stripe-mock/ports.test.ts b/test/lib/stripe-mock/ports.test.ts index 69a1012dcc..237217eff2 100644 --- a/test/lib/stripe-mock/ports.test.ts +++ b/test/lib/stripe-mock/ports.test.ts @@ -180,12 +180,12 @@ describe("startStripeMock ports", () => { await writeTermIgnoringMock(paths); await withUnusedPort(async (port) => { const stripeMock = await startStripeMock({ - confirmDelayMs: 10, + confirmDelayMs: 50, delayMs: 10, maxAttempts: 100, paths, port, - stopTimeoutMs: 20, + stopTimeoutMs: 50, }); expect(stripeMock.port).toBe(port); diff --git a/test/scripts/mutation-isolation-helpers.ts b/test/scripts/mutation-isolation-helpers.ts new file mode 100644 index 0000000000..ad8df7f713 --- /dev/null +++ b/test/scripts/mutation-isolation-helpers.ts @@ -0,0 +1,78 @@ +import { join } from "node:path"; +import { stub } from "@std/testing/mock"; +import { runIsolatedMutationCommand } from "../../scripts/mutation/isolation.ts"; +import { + markFinished, + newRunRecord, + recordPath, + runRoot, +} from "../../scripts/mutation/isolation-state.ts"; + +export const withTempDir = async ( + run: (dir: string) => Promise, +): Promise => { + const dir = await Deno.makeTempDir({ prefix: "mutation-isolation-" }); + try { + await run(dir); + } finally { + await Deno.remove(dir, { recursive: true }).catch(() => {}); + } +}; + +const lineFrom = (values: unknown[]): string => values.map(String).join(" "); + +export const captureConsole = async ( + run: () => Promise, +): Promise<{ errors: string[]; logs: string[]; result: Result }> => { + const logs: string[] = []; + const errors: string[] = []; + using _log = stub(console, "log", (...values: unknown[]) => { + logs.push(lineFrom(values)); + }); + using _error = stub(console, "error", (...values: unknown[]) => { + errors.push(lineFrom(values)); + }); + + return { errors, logs, result: await run() }; +}; + +export const captureMutationCommand = async ( + args: string[], + root: string, +): Promise<{ errors: string[]; logs: string[]; result: number }> => + await captureConsole(() => runIsolatedMutationCommand(args, root)); + +export const runQuietMutationCommand = async ( + args: string[], + root: string, +): Promise => (await captureMutationCommand(args, root)).result; + +export const writeMovedRunRecord = async ( + root: string, +): Promise<{ + id: string; + oldRunRoot: string; + record: ReturnType; +}> => { + const id = "mutation-moved"; + const oldRunRoot = join(root, "old-checkout", ".mutation-runs", id); + const record = markFinished(newRunRecord(id, [], root), 0); + await Deno.mkdir(runRoot(id, root), { recursive: true }); + await Deno.writeTextFile( + recordPath(id, root), + `${JSON.stringify({ + ...record, + root: oldRunRoot, + workRoot: join(oldRunRoot, "work"), + })}\n`, + ); + return { id, oldRunRoot, record }; +}; + +export const writeFakeMutationScript = async ( + root: string, + body: string, +): Promise => { + await Deno.mkdir(join(root, "scripts"), { recursive: true }); + await Deno.writeTextFile(join(root, "scripts", "mutation.ts"), body); +}; diff --git a/test/scripts/mutation-isolation-supervisor.test.ts b/test/scripts/mutation-isolation-supervisor.test.ts new file mode 100644 index 0000000000..2c4a29be8f --- /dev/null +++ b/test/scripts/mutation-isolation-supervisor.test.ts @@ -0,0 +1,553 @@ +import { join } from "node:path"; +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { stub } from "@std/testing/mock"; +import { pathExists } from "#test-utils/files.ts"; +import { runMutationInSnapshot } from "../../scripts/mutation/isolation.ts"; +import { + ISOLATION_USAGE, + type MutationRunRecord, + markFinished, + markRunning, + newRunRecord, + readRunRecords, + withMutationRunLock, + writeRunRecord, +} from "../../scripts/mutation/isolation-state.ts"; +import { + captureConsole, + captureMutationCommand, + runQuietMutationCommand, + withTempDir, + writeFakeMutationScript, +} from "./mutation-isolation-helpers.ts"; + +const wait = async (milliseconds: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, milliseconds)); +}; + +const SNAPSHOT_FAILED = "snapshot failed"; + +const sendFirstSignalImmediately = () => { + let sent = false; + return stub(Deno, "addSignalListener", ((_signal, listener) => { + if (!sent) { + sent = true; + listener(); + } + }) as typeof Deno.addSignalListener); +}; + +const failSnapshotRead = (reason: unknown) => + stub(Deno, "readDir", (() => { + throw reason; + }) as typeof Deno.readDir); + +const failTextFileWrites = (shouldFail: (writeNumber: number) => boolean) => { + const writeTextFile = Deno.writeTextFile; + let writes = 0; + return stub(Deno, "writeTextFile", (( + path: string | URL, + data: string | ReadableStream, + options?: Deno.WriteFileOptions, + ) => { + writes += 1; + if (shouldFail(writes)) throw new Error("record write failed"); + return writeTextFile(path, data, options); + }) as typeof Deno.writeTextFile); +}; + +const failRunningStatusWrite = () => { + const original = Deno.writeTextFile; + return stub(Deno, "writeTextFile", (( + path: string | URL, + data: string | ReadableStream, + options?: Deno.WriteFileOptions, + ) => { + if (typeof data === "string" && data.includes('"running"')) { + throw new Error("record write failed"); + } + return original(path, data, options); + }) as typeof Deno.writeTextFile); +}; + +const capturePlainSnapshotFailure = async ( + root: string, + extraFailure: (() => Disposable) | null = null, +): Promise>> => { + using _readDir = failSnapshotRead(SNAPSHOT_FAILED); + if (extraFailure) { + using _extraFailure = extraFailure(); + return await captureSimpleSnapshotMutation(root); + } + return await captureSimpleSnapshotMutation(root); +}; + +const waitForRunningRecord = async ( + root: string, +): Promise => { + for (let attempt = 0; attempt < 100; attempt += 1) { + const record = (await readRunRecords(root)).find( + (candidate) => candidate.status === "running", + ); + if (record) return record; + await wait(10); + } + throw new Error("Mutation child did not start."); +}; + +const writeRecords = async (records: MutationRunRecord[]): Promise => { + await Promise.all(records.map(writeRunRecord)); +}; + +const readOnlyRunRecord = async (root: string): Promise => { + const records = await readRunRecords(root); + expect(records).toHaveLength(1); + return records[0]!; +}; + +const runSimpleSnapshotMutation = (root: string): Promise => + runMutationInSnapshot(["src/a.ts", "test/a.test.ts"], root); + +const captureSimpleSnapshotMutation = ( + root: string, +): ReturnType => + captureConsole(() => runSimpleSnapshotMutation(root)); + +const withCapturedStopChild = async ( + run: (getStopChild: () => (() => void) | undefined) => Promise, +): Promise => { + let stopChild: (() => void) | undefined; + using _addSignal = stub(Deno, "addSignalListener", ((_signal, listener) => { + stopChild = listener; + }) as typeof Deno.addSignalListener); + using _removeSignal = stub( + Deno, + "removeSignalListener", + (() => {}) as typeof Deno.removeSignalListener, + ); + + await run(() => stopChild); +}; + +type KillCall = { pid: number; signal: Deno.Signal | undefined }; +type DenoCommandShim = { Command: (...args: unknown[]) => unknown }; + +const denoCommand = Deno as unknown as DenoCommandShim; +const childCommand = (child: Deno.ChildProcess) => + function fakeCommand(): { spawn: () => Deno.ChildProcess } { + return { + spawn: () => child, + }; + }; + +const controlledChild = ( + pid: number, + onKill: ( + signal: Deno.Signal | undefined, + finish: (status: Deno.CommandStatus) => void, + ) => void, +) => { + let resolveStatus: (status: Deno.CommandStatus) => void = () => {}; + const status = new Promise((resolve) => { + resolveStatus = resolve; + }); + const child = { + kill: (signal?: Deno.Signal) => onKill(signal, resolveStatus), + pid, + ref: () => {}, + status, + } as unknown as Deno.ChildProcess; + + return { child, finish: resolveStatus }; +}; + +describe("mutation isolation supervisor commands", () => { + test("runs invalid, help, and empty list commands", async () => { + await withTempDir(async (root) => { + const invalid = await captureMutationCommand([], root); + expect(invalid.result).toBe(1); + expect(invalid.errors[0]).toBe( + "Mutation source and test globs are required.", + ); + expect(invalid.errors[1]).toBe(ISOLATION_USAGE); + + const help = await captureMutationCommand(["--help"], root); + expect(help).toEqual({ errors: [], logs: [ISOLATION_USAGE], result: 0 }); + + const list = await captureMutationCommand(["--list"], root); + expect(list).toEqual({ + errors: [], + logs: ["No isolated mutation runs."], + result: 0, + }); + }); + }); + + test("lists running runs only when their pid and lock are live", async () => { + await withTempDir(async (root) => { + const unlocked = markRunning( + newRunRecord("mutation-unlocked", [], root, "2026-07-09T12:04:00.000Z"), + Deno.pid, + ); + const live = markRunning( + newRunRecord( + "mutation-live", + ["src/live.ts"], + root, + "2026-07-09T12:03:00.000Z", + ), + Deno.pid, + ); + const stale = markRunning( + newRunRecord("mutation-stale", [], root, "2026-07-09T12:02:00.000Z"), + 99_999_999, + ); + const copying = newRunRecord( + "mutation-copying", + [], + root, + "2026-07-09T12:01:00.000Z", + ); + await writeRecords([unlocked, live, stale, copying]); + + await withMutationRunLock(live.root, async () => { + const list = await captureMutationCommand(["list"], root); + + expect(list).toEqual({ + errors: [], + logs: [ + `mutation-unlocked stale pid=${Deno.pid} exit=- work=.mutation-runs/mutation-unlocked/work`, + `mutation-live running pid=${Deno.pid} exit=- work=.mutation-runs/mutation-live/work args=src/live.ts`, + "mutation-stale stale pid=99999999 exit=- work=.mutation-runs/mutation-stale/work", + "mutation-copying copying pid=- exit=- work=.mutation-runs/mutation-copying/work", + ], + result: 0, + }); + }); + }); + }); + + test("lists stale pid records without shelling out to kill", async () => { + await withTempDir(async (root) => { + const stale = markRunning( + newRunRecord("mutation-stale", [], root), + 99_999_999, + ); + await writeRunRecord(stale); + + using _command = stub(denoCommand, "Command", function failCommand() { + throw new Error("unexpected external command"); + }); + + const list = await captureMutationCommand(["--list"], root); + + expect(list).toEqual({ + errors: [], + logs: [ + "mutation-stale stale pid=99999999 exit=- work=.mutation-runs/mutation-stale/work", + ], + result: 0, + }); + }); + }); + + test("signals live runs and reports missing or stale targets", async () => { + await withTempDir(async (root) => { + const live = markRunning( + newRunRecord("mutation-live", [], root), + Deno.pid, + ); + const noPid = { + ...newRunRecord("mutation-nopid", [], root), + status: "running" as const, + }; + const passed = markFinished(newRunRecord("mutation-passed", [], root), 0); + await writeRecords([live, noPid, passed]); + + const calls: KillCall[] = []; + using _kill = stub(Deno, "kill", ((pid, signal) => { + calls.push({ pid, signal }); + }) as typeof Deno.kill); + + await withMutationRunLock(live.root, async () => { + expect( + await runQuietMutationCommand(["--kill", "mutation-live"], root), + ).toBe(0); + expect( + await runQuietMutationCommand( + ["kill", "mutation-live", "--force"], + root, + ), + ).toBe(0); + }); + + expect(calls).toEqual([ + { pid: Deno.pid, signal: "SIGTERM" }, + { pid: Deno.pid, signal: "SIGKILL" }, + ]); + expect(await runQuietMutationCommand(["--kill", "missing"], root)).toBe( + 1, + ); + expect( + await runQuietMutationCommand(["--kill", "mutation-passed"], root), + ).toBe(1); + expect( + await runQuietMutationCommand(["--kill", "mutation-nopid"], root), + ).toBe(1); + }); + }); + + test("reports live runs that cannot be signalled", async () => { + await withTempDir(async (root) => { + const live = markRunning( + newRunRecord("mutation-live", [], root), + Deno.pid, + ); + await writeRunRecord(live); + + using _kill = stub(Deno, "kill", (() => { + throw new Error("cannot signal"); + }) as typeof Deno.kill); + + await withMutationRunLock(live.root, async () => { + expect( + await runQuietMutationCommand(["--kill", "mutation-live"], root), + ).toBe(1); + }); + }); + }); + + test("reports missing clean targets", async () => { + await withTempDir(async (root) => { + const clean = await captureMutationCommand(["--clean", "missing"], root); + + expect(clean).toEqual({ + errors: ["No isolated mutation run matched missing."], + logs: [], + result: 1, + }); + }); + }); + + test("runs mutation in a copied snapshot and records the exit code", async () => { + await withTempDir(async (root) => { + await writeFakeMutationScript(root, "Deno.exit(7);\n"); + + const run = await captureMutationCommand( + ["src/a.ts", join(root, "test/a.test.ts")], + root, + ); + const record = await readOnlyRunRecord(root); + + expect(run.result).toBe(7); + expect(run.errors).toEqual([]); + expect(run.logs[0]?.startsWith("Creating isolated mutation run ")).toBe( + true, + ); + expect(record.status).toBe("failed"); + expect(record.exitCode).toBe(7); + expect(record.args).toEqual(["src/a.ts", join(root, "test/a.test.ts")]); + expect(typeof record.pid).toBe("number"); + expect( + await pathExists(join(record.workRoot, "scripts", "mutation.ts")), + ).toBe(true); + }); + }); + + test("marks the run interrupted when a signal arrives before the child starts", async () => { + await withTempDir(async (root) => { + await writeFakeMutationScript(root, "Deno.exit(0);\n"); + + using _addSignal = sendFirstSignalImmediately(); + + const run = await captureSimpleSnapshotMutation(root); + const record = await readOnlyRunRecord(root); + + expect(run.result).toBe(130); + expect(record.status).toBe("interrupted"); + expect(record.exitCode).toBe(130); + }); + }); + + test("records an interrupted run when snapshot copying fails after a signal", async () => { + await withTempDir(async (root) => { + await Deno.mkdir(join(root, "src")); + await Deno.symlink("missing.ts", join(root, "src", "missing.ts")); + + using _addSignal = sendFirstSignalImmediately(); + + const run = await captureConsole(() => + runMutationInSnapshot(["src/missing.ts", "test/missing.test.ts"], root), + ); + const record = await readOnlyRunRecord(root); + + expect(run.result).toBe(130); + expect(run.errors).toHaveLength(1); + expect(record.status).toBe("interrupted"); + expect(record.exitCode).toBe(130); + }); + }); + + test("records a failed run when snapshot copying throws a plain value", async () => { + await withTempDir(async (root) => { + const run = await capturePlainSnapshotFailure(root); + const record = await readOnlyRunRecord(root); + + expect(run).toMatchObject({ errors: [SNAPSHOT_FAILED], result: 1 }); + expect(record.status).toBe("failed"); + expect(record.exitCode).toBe(1); + }); + }); + + test("keeps the original failure when the failed record cannot be rewritten", async () => { + await withTempDir(async (root) => { + let copyFailed = false; + const failSnapshotReadAfterMarkingCopyFailed = (reason: unknown) => + stub(Deno, "readDir", (() => { + copyFailed = true; + throw reason; + }) as typeof Deno.readDir); + const run = await (async () => { + using _readDir = + failSnapshotReadAfterMarkingCopyFailed(SNAPSHOT_FAILED); + using _writeTextFile = failTextFileWrites(() => copyFailed); + + return await captureSimpleSnapshotMutation(root); + })(); + const record = await readOnlyRunRecord(root); + + expect(run).toMatchObject({ errors: [SNAPSHOT_FAILED], result: 1 }); + expect(record.status).toBe("copying"); + }); + }); + + test("stops the child when recording its pid fails", async () => { + await withTempDir(async (root) => { + await writeFakeMutationScript(root, "Deno.exit(0);\n"); + + const killCalls: (Deno.Signal | undefined)[] = []; + const process = controlledChild(42_424, (signal, finish) => { + killCalls.push(signal); + finish({ code: 143, signal: "SIGTERM", success: false }); + }); + using _command = stub( + denoCommand, + "Command", + childCommand(process.child), + ); + + using _writeTextFile = failRunningStatusWrite(); + + const run = await captureSimpleSnapshotMutation(root); + const record = await readOnlyRunRecord(root); + + expect(run.result).toBe(1); + expect(run.errors).toEqual(["record write failed"]); + expect(killCalls).toEqual([undefined]); + expect(record.status).toBe("failed"); + expect(record.exitCode).toBe(1); + }); + }); + + test("records interrupted when a running child exits after a signal", async () => { + await withTempDir(async (root) => { + await writeFakeMutationScript(root, "await new Promise(() => {});\n"); + + const originalKill = Deno.kill; + await withCapturedStopChild(async (getStopChild) => { + const run = captureSimpleSnapshotMutation(root); + const record = await waitForRunningRecord(root); + + getStopChild()?.(); + originalKill(record.pid!, "SIGKILL"); + await run; + + const finished = await readOnlyRunRecord(root); + expect(finished.status).toBe("interrupted"); + expect(finished.exitCode).toBe(130); + }); + }); + }); + + test("records interrupted when the signalled child already stopped", async () => { + await withTempDir(async (root) => { + await writeFakeMutationScript(root, "Deno.exit(0);\n"); + + let killCalls = 0; + const process = controlledChild(42_425, () => { + killCalls += 1; + throw new Error("already stopped"); + }); + using _command = stub( + denoCommand, + "Command", + childCommand(process.child), + ); + + await withCapturedStopChild(async (getStopChild) => { + const run = captureSimpleSnapshotMutation(root); + await waitForRunningRecord(root); + + getStopChild()?.(); + process.finish({ code: 0, signal: null, success: true }); + + expect((await run).result).toBe(130); + expect(killCalls).toBe(1); + const finished = await readOnlyRunRecord(root); + expect(finished.status).toBe("interrupted"); + expect(finished.exitCode).toBe(130); + }); + }); + }); + + test("escalates repeated interrupts", async () => { + await withTempDir(async (root) => { + await writeFakeMutationScript(root, "await new Promise(() => {});\n"); + + const originalKill = Deno.kill; + using _exit = stub(Deno, "exit", ((code) => { + throw new Error(`exit ${code}`); + }) as typeof Deno.exit); + await withCapturedStopChild(async (getStopChild) => { + const run = captureSimpleSnapshotMutation(root); + const record = await waitForRunningRecord(root); + + expect(getStopChild()).toBeDefined(); + getStopChild()?.(); + expect(() => getStopChild()?.()).toThrow("exit 130"); + + try { + originalKill(record.pid!, "SIGKILL"); + } catch { + // The supervisor may already have stopped it. + } + expect((await run).result).not.toBe(0); + }); + }); + }); + + test("records failure when starting the snapshot child throws", async () => { + await withTempDir(async (root) => { + await writeFakeMutationScript(root, "Deno.exit(0);\n"); + + using _addSignal = stub(Deno, "addSignalListener", (() => { + throw new Error("signals unavailable"); + }) as typeof Deno.addSignalListener); + using _removeSignal = stub(Deno, "removeSignalListener", (() => { + throw new Error("not registered"); + }) as typeof Deno.removeSignalListener); + using _execPath = stub(Deno, "execPath", (() => + join(root, "missing-deno")) as typeof Deno.execPath); + + const run = await captureSimpleSnapshotMutation(root); + const record = await readOnlyRunRecord(root); + + expect(run.result).toBe(1); + expect(run.errors).toHaveLength(1); + expect(run.errors[0]).toContain("missing-deno"); + expect(record.status).toBe("failed"); + expect(record.exitCode).toBe(1); + }); + }); +}); diff --git a/test/scripts/mutation-isolation.test.ts b/test/scripts/mutation-isolation.test.ts new file mode 100644 index 0000000000..165f24d823 --- /dev/null +++ b/test/scripts/mutation-isolation.test.ts @@ -0,0 +1,492 @@ +import { join } from "node:path"; +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { stub } from "@std/testing/mock"; +import { pathExists } from "#test-utils/files.ts"; +import { + copyMutationSnapshot, + createRunId, + formatRunList, + markFinished, + markInterrupted, + markRunning, + newRunRecord, + parseIsolationCommand, + readRunRecord, + readRunRecords, + rewriteMutationArgs, + runLockIsHeld, + runRoot, + runStartedRecently, + selectedRuns, + shouldCopySnapshotPath, + statusForExitCode, + visibleStatus, + withMutationRunLock, + workRoot, + writeRunRecord, +} from "../../scripts/mutation/isolation-state.ts"; +import { + captureMutationCommand, + runQuietMutationCommand, + withTempDir, + writeMovedRunRecord, +} from "./mutation-isolation-helpers.ts"; + +const cleanPassedRunWithRemoveError = async ( + root: string, + error: unknown, +): Promise<{ + clean: Awaited>; + passed: ReturnType; +}> => { + const passed = markFinished(newRunRecord("mutation-passed", [], root), 0); + await writeRunRecord(passed); + + const remove = Deno.remove; + using _remove = stub(Deno, "remove", ((path, options) => { + if (String(path) === passed.root) return Promise.reject(error); + return remove(path, options); + }) as typeof Deno.remove); + + const clean = await captureMutationCommand( + ["--clean", "mutation-passed"], + root, + ); + return { clean, passed }; +}; + +const expectCleanPassedRunRemovalFailure = async ( + root: string, + error: unknown, +): Promise => { + const { clean, passed } = await cleanPassedRunWithRemoveError(root, error); + + expect(clean).toEqual({ + errors: ["Failed to remove mutation-passed: permission denied"], + logs: [], + result: 1, + }); + expect(await pathExists(passed.root)).toBe(true); +}; + +describe("mutation isolation paths", () => { + test("copies source-like files and skips git, reports, secrets, dbs, and generated assets", async () => { + await withTempDir(async (dir) => { + const source = join(dir, "source"); + const snapshot = join(dir, "snapshot"); + await Deno.mkdir(join(source, "src", "ui", "static"), { + recursive: true, + }); + await Deno.mkdir(join(source, ".bin"), { recursive: true }); + await Deno.mkdir(join(source, ".git"), { recursive: true }); + await Deno.mkdir(join(source, "coverage"), { recursive: true }); + await Deno.writeTextFile(join(source, "src", "kept.ts"), "export {};\n"); + await Deno.writeTextFile(join(source, ".bin", "stripe-mock"), "mock"); + await Deno.writeTextFile(join(source, ".git", "config"), "git"); + await Deno.writeTextFile(join(source, "coverage", "lcov.info"), "cov"); + await Deno.writeTextFile(join(source, ".env"), "secret"); + await Deno.writeTextFile(join(source, "tickets.db"), "db"); + await Deno.writeTextFile( + join(source, "src", "ui", "static", "app.js"), + "js", + ); + await Deno.writeTextFile( + join(source, "src", "ui", "static", "style.css"), + "css", + ); + + await copyMutationSnapshot(source, snapshot); + + expect(await Deno.readTextFile(join(snapshot, "src", "kept.ts"))).toBe( + "export {};\n", + ); + expect( + await Deno.readTextFile(join(snapshot, ".bin", "stripe-mock")), + ).toBe("mock"); + expect(await pathExists(join(snapshot, ".git", "config"))).toBe(false); + expect(await pathExists(join(snapshot, "coverage", "lcov.info"))).toBe( + false, + ); + expect(await pathExists(join(snapshot, ".env"))).toBe(false); + expect(await pathExists(join(snapshot, "tickets.db"))).toBe(false); + expect( + await pathExists(join(snapshot, "src", "ui", "static", "app.js")), + ).toBe(false); + expect( + await pathExists(join(snapshot, "src", "ui", "static", "style.css")), + ).toBe(false); + }); + }); + + test("states which paths belong in a snapshot", () => { + expect(shouldCopySnapshotPath("")).toBe(true); + expect(shouldCopySnapshotPath("src/shared/dates.ts")).toBe(true); + expect(shouldCopySnapshotPath(".mutation-runs/run/work")).toBe(false); + expect(shouldCopySnapshotPath(".jscpd-report/index.html")).toBe(false); + expect(shouldCopySnapshotPath("coverage-test/lcov.info")).toBe(false); + expect(shouldCopySnapshotPath("local.db-wal")).toBe(false); + expect(shouldCopySnapshotPath("src/ui/static/order.js")).toBe(false); + }); + + test("rewrites only absolute project paths", () => { + const root = "/repo/tickets"; + const snapshot = "/repo/tickets/.mutation-runs/run/work"; + + expect( + rewriteMutationArgs(root, snapshot, [ + "--source", + "/repo/tickets", + "/repo/tickets/src/a.ts", + "test/a.test.ts", + "--harness", + "/tmp/outside.ts", + ]), + ).toEqual([ + "--source", + "/repo/tickets/.mutation-runs/run/work", + "/repo/tickets/.mutation-runs/run/work/src/a.ts", + "test/a.test.ts", + "--harness", + "/tmp/outside.ts", + ]); + expect(rewriteMutationArgs("/", "/snapshot", ["/repo/tickets"])).toEqual([ + "/snapshot/repo/tickets", + ]); + }); +}); + +describe("mutation isolation run records", () => { + test("creates deterministic ids and records state transitions", () => { + const id = createRunId(new Date("2026-07-09T12:34:56.789Z"), "abc12345"); + expect(id).toBe("mutation-20260709T123456Z-abc12345"); + + const record = newRunRecord(id, ["src/a.ts", "test/a.test.ts"], "/repo"); + expect(record.status).toBe("copying"); + expect(record.workRoot).toBe(workRoot(id, "/repo")); + + const running = markRunning(record, 42, "2026-07-09T12:35:00.000Z"); + expect(running).toMatchObject({ pid: 42, status: "running" }); + expect(markFinished(running, 0)).toMatchObject({ + exitCode: 0, + status: "passed", + }); + expect(markFinished(running, 1)).toMatchObject({ + exitCode: 1, + status: "failed", + }); + expect(markInterrupted(running)).toMatchObject({ + exitCode: 130, + status: "interrupted", + }); + }); + + test("maps exit codes to run status", () => { + expect(statusForExitCode(0)).toBe("passed"); + expect(statusForExitCode(130)).toBe("interrupted"); + expect(statusForExitCode(2)).toBe("failed"); + }); + + test("treats a run as recently started only within the grace period", () => { + const now = new Date("2026-07-10T12:00:00.000Z"); + const fresh = markRunning( + newRunRecord("fresh", [], "/repo", "2026-07-10T11:59:45.000Z"), + 1, + "2026-07-10T11:59:45.000Z", + ); + const stale = markRunning( + newRunRecord("stale", [], "/repo", "2026-07-10T11:00:00.000Z"), + 2, + "2026-07-10T11:00:00.000Z", + ); + expect(runStartedRecently(fresh, now)).toBe(true); + expect(runStartedRecently(stale, now)).toBe(false); + }); + + test("writes, reads, sorts, and ignores broken records", async () => { + await withTempDir(async (root) => { + expect(await readRunRecords(root)).toEqual([]); + + const older = newRunRecord("older", [], root, "2026-07-09T10:00:00.000Z"); + const newer = newRunRecord("newer", [], root, "2026-07-09T11:00:00.000Z"); + await writeRunRecord(older); + await writeRunRecord(newer); + await Deno.mkdir(join(root, ".mutation-runs", "broken"), { + recursive: true, + }); + await Deno.writeTextFile(join(root, ".mutation-runs", "not-a-dir"), ""); + await Deno.writeTextFile( + join(root, ".mutation-runs", "broken", "run.json"), + "{not-json", + ); + + const records = await readRunRecords(root); + expect(records.map((record) => record.id)).toEqual(["newer", "older"]); + expect(await readRunRecord(join(root, "missing.json"))).toBeNull(); + }); + }); + + test("reads records from the current run directory", async () => { + await withTempDir(async (root) => { + const { id, record } = await writeMovedRunRecord(root); + + expect(await readRunRecords(root)).toEqual([ + { + ...record, + root: runRoot(id, root), + workRoot: workRoot(id, root), + }, + ]); + }); + }); + + test("surfaces unreadable run directories", async () => { + await withTempDir(async (root) => { + const fileRoot = join(root, "file-root"); + await Deno.writeTextFile(fileRoot, ""); + + await expect(readRunRecords(fileRoot)).rejects.toThrow( + Deno.errors.NotADirectory, + ); + }); + }); + + test("reports whether a run lock is held", async () => { + await withTempDir(async (root) => { + const record = { root }; + expect(await runLockIsHeld(record, 10)).toBe(false); + await withMutationRunLock(root, async () => { + expect(await runLockIsHeld(record, 10)).toBe(true); + }); + expect(await runLockIsHeld(record, 10)).toBe(false); + + const fileRoot = join(root, "file-root"); + await Deno.writeTextFile(fileRoot, ""); + expect(await runLockIsHeld({ root: fileRoot }, 10)).toBe(false); + }); + }); +}); + +describe("mutation isolation commands", () => { + test("parses management commands and passes mutation args through", () => { + expect(parseIsolationCommand([])).toEqual({ + kind: "invalid", + message: "Mutation source and test globs are required.", + }); + expect(parseIsolationCommand(["--help"])).toEqual({ kind: "help" }); + expect(parseIsolationCommand(["--list"])).toEqual({ kind: "list" }); + expect(parseIsolationCommand(["kill", "run-1", "--force"])).toEqual({ + force: true, + kind: "kill", + target: "run-1", + }); + expect(parseIsolationCommand(["--kill"])).toEqual({ + kind: "invalid", + message: "A run id or all is required for --kill.", + }); + expect(parseIsolationCommand(["clean", "finished"])).toEqual({ + kind: "clean", + target: "finished", + }); + expect(parseIsolationCommand(["clean"])).toEqual({ + kind: "invalid", + message: "A run id, all, or finished is required for --clean.", + }); + expect(parseIsolationCommand(["src/a.ts", "test/a.test.ts"])).toEqual({ + args: ["src/a.ts", "test/a.test.ts"], + kind: "run", + }); + }); + + test("selects records by target", () => { + const running = markRunning( + newRunRecord("mutation-running", ["src/a.ts"], "/repo"), + 10, + ); + const copying = newRunRecord("mutation-copying", ["src/c.ts"], "/repo"); + const passed = markFinished( + newRunRecord("mutation-passed", ["src/b.ts"], "/repo"), + 0, + ); + const failed = markFinished( + newRunRecord("mutation-failed", ["src/d.ts"], "/repo"), + 1, + ); + const interrupted = markInterrupted( + newRunRecord("mutation-interrupted", ["src/e.ts"], "/repo"), + ); + const records = [running, copying, passed, failed, interrupted]; + + expect(selectedRuns(records, "all").map((record) => record.id)).toEqual([ + "mutation-running", + "mutation-copying", + "mutation-passed", + "mutation-failed", + "mutation-interrupted", + ]); + expect( + selectedRuns(records, "finished").map((record) => record.id), + ).toEqual(["mutation-passed", "mutation-failed", "mutation-interrupted"]); + expect(selectedRuns(records, "mutation-running")).toEqual([running]); + expect(selectedRuns(records, "mutation-runn")).toEqual([running]); + expect(selectedRuns(records, "mutation-")).toEqual([]); + expect(selectedRuns(records, "missing")).toEqual([]); + }); + + test("formats list output", () => { + const running = markRunning( + newRunRecord("mutation-running", ["src/a.ts"], "/repo"), + 10, + ); + + expect(visibleStatus(running, false)).toBe("stale"); + expect(formatRunList([], new Set(), "/repo")).toEqual([ + "No isolated mutation runs.", + ]); + expect( + formatRunList([running], new Set(["mutation-running"]), "/repo"), + ).toEqual([ + "mutation-running running pid=10 exit=- work=.mutation-runs/mutation-running/work args=src/a.ts", + ]); + expect(formatRunList([running], new Set(), "/repo")).toEqual([ + "mutation-running stale pid=10 exit=- work=.mutation-runs/mutation-running/work args=src/a.ts", + ]); + expect( + formatRunList([markFinished(running, 1)], new Set(), "/repo"), + ).toEqual([ + "mutation-running failed pid=10 exit=1 work=.mutation-runs/mutation-running/work args=src/a.ts", + ]); + expect( + formatRunList([newRunRecord("empty", [], "/repo")], new Set(), "/repo"), + ).toEqual(["empty copying pid=- exit=- work=.mutation-runs/empty/work"]); + }); + + test("cleans only the current run directory", async () => { + await withTempDir(async (root) => { + const { id, oldRunRoot } = await writeMovedRunRecord(root); + await Deno.mkdir(oldRunRoot, { recursive: true }); + await Deno.writeTextFile(join(oldRunRoot, "keep.txt"), "old"); + + expect(await runQuietMutationCommand(["--clean", "all"], root)).toBe(0); + + expect(await pathExists(runRoot(id, root))).toBe(false); + expect(await pathExists(join(oldRunRoot, "keep.txt"))).toBe(true); + }); + }); + + test("skips active runs during cleanup", async () => { + await withTempDir(async (root) => { + const copying = newRunRecord("mutation-copying", [], root); + const staleCopying = newRunRecord("mutation-stale-copying", [], root); + const running = markRunning( + newRunRecord("mutation-running", [], root), + Deno.pid, + ); + const starting = markRunning( + newRunRecord("mutation-starting", [], root), + Deno.pid, + ); + const stale = markRunning( + newRunRecord("mutation-stale", [], root), + 99_999_999, + ); + const noPid = { + ...newRunRecord("mutation-nopid", [], root), + status: "running" as const, + }; + const passed = markFinished(newRunRecord("mutation-passed", [], root), 0); + for (const record of [ + copying, + staleCopying, + running, + starting, + stale, + noPid, + passed, + ]) { + await writeRunRecord(record); + } + + expect( + await runQuietMutationCommand(["--clean", "mutation-starting"], root), + ).toBe(1); + await withMutationRunLock(copying.root, async () => { + await withMutationRunLock(running.root, async () => { + expect( + await runQuietMutationCommand( + ["--clean", "mutation-running"], + root, + ), + ).toBe(1); + expect(await runQuietMutationCommand(["--clean", "all"], root)).toBe( + 0, + ); + }); + }); + + expect(await pathExists(copying.root)).toBe(true); + expect(await pathExists(staleCopying.root)).toBe(false); + expect(await pathExists(running.root)).toBe(true); + expect(await pathExists(starting.root)).toBe(true); + expect(await pathExists(stale.root)).toBe(false); + expect(await pathExists(noPid.root)).toBe(false); + expect(await pathExists(passed.root)).toBe(false); + }); + }); + + test("cleans stale running records whose pid was reused after the grace period", async () => { + await withTempDir(async (root) => { + const staleReused = markRunning( + newRunRecord( + "mutation-stale-reused", + [], + root, + "2026-01-01T00:00:00.000Z", + ), + Deno.pid, + "2026-01-01T00:00:00.000Z", + ); + await writeRunRecord(staleReused); + + expect(runStartedRecently(staleReused)).toBe(false); + + expect( + await runQuietMutationCommand( + ["--clean", "mutation-stale-reused"], + root, + ), + ).toBe(0); + expect(await pathExists(staleReused.root)).toBe(false); + }); + }); + + test("reports cleanup removal failures", async () => { + await withTempDir(async (root) => { + await expectCleanPassedRunRemovalFailure( + root, + new Error("permission denied"), + ); + }); + }); + + test("treats missing run directories as already removed", async () => { + await withTempDir(async (root) => { + const { clean } = await cleanPassedRunWithRemoveError( + root, + new Deno.errors.NotFound("already gone"), + ); + + expect(clean).toEqual({ + errors: [], + logs: ["Removed mutation-passed."], + result: 0, + }); + }); + }); + + test("reports cleanup removal failures from thrown values", async () => { + await withTempDir(async (root) => { + await expectCleanPassedRunRemovalFailure(root, "permission denied"); + }); + }); +}); diff --git a/test/scripts/process.test.ts b/test/scripts/process.test.ts new file mode 100644 index 0000000000..de5bd9cca7 --- /dev/null +++ b/test/scripts/process.test.ts @@ -0,0 +1,103 @@ +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { + processExists, + stopProcess, + stopProcessNow, +} from "../../scripts/process.ts"; + +const stopped = (code = 0): Deno.CommandStatus => ({ + code, + signal: null, + success: code === 0, +}); + +const fakeChild = ( + onKill: ( + signal: Deno.Signal | undefined, + finish: (status?: Deno.CommandStatus) => void, + fail: (error?: Error) => void, + ) => void, +) => { + let refed = false; + const calls: (Deno.Signal | undefined)[] = []; + let resolveStatus: (status: Deno.CommandStatus) => void = () => {}; + let rejectStatus: (error: Error) => void = () => {}; + const status = new Promise((resolve, reject) => { + resolveStatus = resolve; + rejectStatus = reject; + }); + const finish = (status = stopped()) => resolveStatus(status); + const fail = (error = new Error("stopped")) => rejectStatus(error); + const child = { + kill: (signal?: Deno.Signal) => { + calls.push(signal); + onKill(signal, finish, fail); + }, + ref: () => { + refed = true; + }, + status, + } as unknown as Deno.ChildProcess; + + return { calls, child, refed: () => refed }; +}; + +describe("script process helpers", () => { + test("checks process liveness without spawning a shell command", () => { + expect(processExists(Deno.pid)).toBe(true); + expect(processExists(99_999_999)).toBe(false); + }); + + test("stops a child process gracefully and closes resources", async () => { + let closed = false; + const process = fakeChild((_signal, finish) => finish()); + + await stopProcess(process.child, 50, () => { + closed = true; + return Promise.resolve(); + }); + + expect(process.refed()).toBe(true); + expect(closed).toBe(true); + expect(process.calls).toEqual([undefined]); + }); + + test("force-stops a child process that ignores the first signal", async () => { + const process = fakeChild((signal, _finish, fail) => { + if (signal === "SIGKILL") fail(); + }); + + await stopProcess(process.child, 1); + + expect(process.calls).toEqual([undefined, "SIGKILL"]); + }); + + test("still closes resources when the graceful signal fails", async () => { + let closed = false; + const child = { + kill: () => { + throw new Error("already stopped"); + }, + ref: () => {}, + status: Promise.resolve(stopped()), + } as unknown as Deno.ChildProcess; + + await stopProcess(child, 50, () => { + closed = true; + return Promise.resolve(); + }); + + expect(closed).toBe(true); + }); + + test("ignores already-stopped children in immediate stop", () => { + const child = { + kill: () => { + throw new Error("already stopped"); + }, + } as unknown as Deno.ChildProcess; + + expect(() => stopProcessNow(child)).not.toThrow(); + }); +}); diff --git a/test/scripts/test-coverage.test.ts b/test/scripts/test-coverage.test.ts index c5f28dcfcf..08be1f3eb5 100644 --- a/test/scripts/test-coverage.test.ts +++ b/test/scripts/test-coverage.test.ts @@ -2,6 +2,7 @@ import { dirname, join } from "node:path"; import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { bracket } from "#fp"; +import { pathExists } from "#test-utils/files.ts"; import { removeOldCoverageOutput } from "../../scripts/coverage-output.ts"; const withTempCoverageDir = bracket( @@ -15,22 +16,13 @@ const withTempFile = bracket( (path: string) => Deno.remove(path).catch(() => {}), ); -const pathExists = async (path: string): Promise => { - try { - await Deno.stat(path); - return true; - } catch (error) { - if (error instanceof Deno.errors.NotFound) return false; - throw error; - } -}; - describe("removeOldCoverageOutput", () => { test("removes stale coverage files before a coverage run", async () => { await withTempCoverageDir(async (coverageDir) => { const staleFile = join(coverageDir, "old.json"); await Deno.mkdir(coverageDir); await Deno.writeTextFile(staleFile, "stale coverage"); + expect(await pathExists(coverageDir)).toBe(true); await removeOldCoverageOutput(coverageDir); @@ -48,6 +40,9 @@ describe("removeOldCoverageOutput", () => { test("surfaces filesystem errors other than missing coverage output", async () => { await withTempFile(async (filePath) => { + await expect(pathExists(join(filePath, "coverage"))).rejects.toThrow( + Deno.errors.NotADirectory, + ); await expect( removeOldCoverageOutput(join(filePath, "coverage")), ).rejects.toThrow(Deno.errors.NotADirectory); diff --git a/test/test-utils/files.ts b/test/test-utils/files.ts new file mode 100644 index 0000000000..a4dd7bc6ca --- /dev/null +++ b/test/test-utils/files.ts @@ -0,0 +1,9 @@ +export const pathExists = async (path: string): Promise => { + try { + await Deno.stat(path); + return true; + } catch (error) { + if (error instanceof Deno.errors.NotFound) return false; + throw error; + } +};