diff --git a/quest/m0/test-cpu-timeout.md b/quest/m0/test-cpu-timeout.md deleted file mode 100644 index 56d77355..00000000 --- a/quest/m0/test-cpu-timeout.md +++ /dev/null @@ -1,54 +0,0 @@ -# [M] Tests time out on CPU time, not wall time - -## Goal - -A unit test fails for taking too long only when it spends too much CPU, so a -loaded host never fails a test that is merely waiting its turn, while a hung -test still fails. The merge-group build stops dropping PRs for tests that -passed vitest's 5 s wall-clock default under load. - -## Plan - -Evidence (2026-11-10, /quest-complete): 5 of the last 8 failed -`merge_group` builds on this repository failed only on `Test timed out in -5000ms` (or 20 s / 30 s per-test overrides) on the self-hosted runner, which -shares the desktop with about a dozen agents: - -- `test/review.test.ts`: four different tests across runs - [42849648498](https://github.com/moq-dev/quest/actions/runs/42849648498) - ([moq-dev/quest#602](https://github.com/moq-dev/quest/pull/602)), - [42854330578](https://github.com/moq-dev/quest/actions/runs/42854330578) - ([moq-dev/quest#595](https://github.com/moq-dev/quest/pull/595)) and - [42871151560](https://github.com/moq-dev/quest/actions/runs/42871151560) - ([moq-dev/quest#598](https://github.com/moq-dev/quest/pull/598)). -- `test/init.test.ts` (CRLF setup), `test/agent.test.ts` and - `test/land.test.ts`: runs - [42679505693](https://github.com/moq-dev/quest/actions/runs/42679505693) - and [42716547792](https://github.com/moq-dev/quest/actions/runs/42716547792). - -Each passed on re-queue. They are not flaky in their logic: a wall-clock -limit measures the host's load, and these tests assert nothing about speed. -No single PR caused this; the default timeout is as old as the suite. - -Decided 2026-11-10 (user, /quest-plan follow-up from /quest-complete): - -- Approach: ✅ a CPU-time watchdog. Each test gets a budget of CPU spent by - its worker and the processes it spawns (on Linux, from a cgroup's - `cpu.stat`), plus a wall-clock bound for hangs, since a hung test waiting - on a child or on IO spends no CPU and only wall time catches it (rejected: - a generous wall `testTimeout` as a hang bound only; keep 5 s and reduce the - queue build's load). -- Milestone: ✅ m0, since these drops slow every merge here (rejected: m1). - -Open for the implementer, with recommendations: - -- Platforms without the cgroup measurement (macOS, Windows, Linux without a - systemd user manager) decide at setup and use the wall hang bound alone, - per `quest context`'s rule against runtime fallback. -- Size the wall hang bound so a loaded host never reaches it on a test that - makes progress (minutes, not seconds), and the CPU budget from measured - per-test CPU, with headroom. -- Prefer reusing the gate's scope and `cpu.stat` sampling - ([gate measured CPU](https://github.com/moq-dev/quest/pull/607) is in - flight) over a second implementation. -- Remove the per-test wall overrides that only existed to survive load. diff --git a/test/budget.test.ts b/test/budget.test.ts new file mode 100644 index 00000000..40076195 --- /dev/null +++ b/test/budget.test.ts @@ -0,0 +1,14 @@ +import { spawnSync } from "node:child_process"; + +import { expect, test } from "vitest"; + +import { spent } from "./budget"; + +test.runIf(process.platform === "linux")("a test's CPU counts a child it waited for", () => { + const before = spent(); + // The child spins until it has spent 400 ms of CPU, however long a loaded host takes to give it that. + const spin = "while (process.cpuUsage().user + process.cpuUsage().system < 400_000);"; + expect(spawnSync(process.execPath, ["-e", spin]).status).toBe(0); + // Each of the four tick counters may round down by one tick. + expect(spent() - before).toBeGreaterThanOrEqual(400 - 40); +}); diff --git a/test/budget.ts b/test/budget.ts new file mode 100644 index 00000000..1be4e339 --- /dev/null +++ b/test/budget.ts @@ -0,0 +1,35 @@ +// Fails a test that spends more than CPU_BUDGET_MS of CPU, counting its hooks and the processes it +// spawned and waited for. A wall-clock limit would instead measure the host's load, so the wall +// timeout in vitest.config.ts only catches a test awaiting what never comes. + +import { readFileSync } from "node:fs"; + +import { afterEach, beforeEach } from "vitest"; + +/** The CPU one test may spend, five times the most any test spent on Linux (1.9 s, 2026-11-11). */ +const CPU_BUDGET_MS = 10_000; + +/** + * This process's CPU time and that of every child it waited for, in milliseconds. A child counts + * once reaped, as when a test awaits it. Node reports only its own time (`process.cpuUsage`). + */ +export function spent(): number { + const stat = readFileSync("/proc/self/stat", "utf8"); + // The command name, in parentheses, may hold spaces; utime, stime, cutime, and cstime follow it + // as fields 14 to 17. + const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" "); + // Linux reports these in USER_HZ, 100 per second on every architecture Node supports. + return fields.slice(11, 15).reduce((sum, ticks) => sum + Number(ticks), 0) * 10; +} + +// Only Linux exposes its children's CPU (`/proc`), so elsewhere the wall timeout alone applies. +if (process.platform === "linux") { + let start = 0; + beforeEach(() => { + start = spent(); + }); + afterEach(() => { + const used = spent() - start; + if (used > CPU_BUDGET_MS) throw new Error(`test spent ${used} ms of CPU, over its ${CPU_BUDGET_MS} ms budget`); + }); +} diff --git a/test/gate.test.ts b/test/gate.test.ts index eb191d88..b5210c85 100644 --- a/test/gate.test.ts +++ b/test/gate.test.ts @@ -568,7 +568,7 @@ test("a forward clock step forgets at most the last minute of admissions", async expect(events).toEqual([`sleep ${SETTLE_MS}`]); }); -test("concurrent processes never admit past the headroom, and admit every caller", { timeout: 30_000 }, async () => { +test("concurrent processes never admit past the headroom, and admit every caller", async () => { const directory = tempDir(); const ledger = join(directory, "ledger"); const go = join(directory, "go"); @@ -823,11 +823,9 @@ function stopped(pid: number): boolean { return ps.status !== 0 || ps.stdout.trim().startsWith("Z"); } -async function until(done: () => boolean, what: string) { - for (let tries = 0; !done(); tries++) { - if (tries === 200) throw new Error(`timed out waiting for ${what}`); - await new Promise((resolve) => setTimeout(resolve, 25)); - } +/** Polls until done; the test's wall bound catches a wait that never ends. */ +async function until(done: () => boolean) { + while (!done()) await new Promise((resolve) => setTimeout(resolve, 25)); } /** A command that starts a descendant which outlives it unless owned, recording its PID. */ @@ -883,26 +881,26 @@ function phase(dir: string, leaf: string): string[] { } describe.each(owners)("an owner using %s", (_name, run) => { - test("keeps the exact exit status and stops descendants left behind", { timeout: 30_000 }, async () => { + test("keeps the exact exit status and stops descendants left behind", async () => { const { command, pid } = descendant(tempDir(), "exit 7"); expect(await run(command, process.env, cancellation().cancel)).toBe(7); - await until(() => stopped(pid()), "the orphan to stop"); + await until(() => stopped(pid())); }); - test("cancelling stops a grandchild", { timeout: 30_000 }, async () => { + test("cancelling stops a grandchild", async () => { const dir = tempDir(); const ready = join(dir, "ready"); const { command, pid } = descendant(dir, `: > ${JSON.stringify(ready)}; wait`); const { stop, cancel } = cancellation(); const result = run(command, process.env, cancel); - await until(() => existsSync(ready), "the command to start"); + await created(ready); stop.abort("SIGTERM"); expect(await result).toBe(128 + constants.signals.SIGTERM); - await until(() => stopped(pid()), "the grandchild to stop"); + await until(() => stopped(pid())); }); for (const name of ["SIGTERM", "SIGHUP"] as const) - test(`${name} reaches a phase under Just, a trapping Bash, and GNU time`, { timeout: 30_000 }, async () => { + test(`${name} reaches a phase under Just, a trapping Bash, and GNU time`, async () => { const dir = tempDir(); const leaf = signalled(dir, ["SIGTERM", "SIGHUP"], 200); const { stop, cancel } = cancellation(); @@ -912,10 +910,10 @@ describe.each(owners)("an owner using %s", (_name, run) => { // The leaf runs until signalled, so a gate that left it running never returns. expect(await result).toBe(128 + constants.signals[name]); expect(leaf.log()).toEqual([name]); - await until(() => stopped(leaf.pid()), "the leaf to stop"); + await until(() => stopped(leaf.pid())); }); - test("lets a cancelled phase finish its cleanup after its wrappers exit", { timeout: 30_000 }, async () => { + test("lets a cancelled phase finish its cleanup after its wrappers exit", async () => { const dir = tempDir(); const leaf = signalled(dir, ["SIGTERM"], 200); const { stop, cancel } = cancellation(); @@ -926,7 +924,7 @@ describe.each(owners)("an owner using %s", (_name, run) => { expect(existsSync(leaf.done)).toBe(true); }); - test("a second signal kills a phase still cleaning up", { timeout: 30_000 }, async () => { + test("a second signal kills a phase still cleaning up", async () => { const dir = tempDir(); const leaf = signalled(dir, ["SIGTERM"]); const { stop, kill, cancel } = cancellation(); @@ -937,43 +935,37 @@ describe.each(owners)("an owner using %s", (_name, run) => { kill.abort(); // Signalled, whether Just exited on the first signal or the second killed it. expect(await result).toBeGreaterThan(128); - await until(() => stopped(leaf.pid()), "the leaf to stop"); + await until(() => stopped(leaf.pid())); }); for (const name of ["SIGTERM", "SIGHUP"] as const) - test( - `delivers ${name} once to a command under a nested gate, without killing it`, - { timeout: 30_000 }, - async () => { - const dir = tempDir(); - const leaf = signalled(dir, [name], 200); - const { stop, cancel } = cancellation(); - const env = { ...process.env, QUEST_GATE_ADMITTED: "1" }; - // Just also forwards SIGTERM to its own child, the nested gate, on top of the owner's. - const nested = [process.execPath, config, bin, "gate", process.execPath, leaf.script] - .map(quote) - .join(" "); - const result = run(just(dir, nested), env, cancel); - await created(leaf.ready); - stop.abort(name); - expect(await result).toBe(128 + constants.signals[name]); - expect(leaf.log()).toEqual([name]); - // The leaf finished its own cleanup: neither gate killed it. - expect(existsSync(leaf.done)).toBe(true); - }, - ); + test(`delivers ${name} once to a command under a nested gate, without killing it`, async () => { + const dir = tempDir(); + const leaf = signalled(dir, [name], 200); + const { stop, cancel } = cancellation(); + const env = { ...process.env, QUEST_GATE_ADMITTED: "1" }; + // Just also forwards SIGTERM to its own child, the nested gate, on top of the owner's. + const nested = [process.execPath, config, bin, "gate", process.execPath, leaf.script].map(quote).join(" "); + const result = run(just(dir, nested), env, cancel); + await created(leaf.ready); + stop.abort(name); + expect(await result).toBe(128 + constants.signals[name]); + expect(leaf.log()).toEqual([name]); + // The leaf finished its own cleanup: neither gate killed it. + expect(existsSync(leaf.done)).toBe(true); + }); - test("killing stops a command that ignores the request", { timeout: 30_000 }, async () => { + test("killing stops a command that ignores the request", async () => { const dir = tempDir(); const ready = join(dir, "ready"); const { command, pid } = descendant(dir, `trap '' TERM; : > ${JSON.stringify(ready)}; wait`); const { stop, kill, cancel } = cancellation(); const result = run(command, process.env, cancel); - await until(() => existsSync(ready), "the command to start"); + await created(ready); stop.abort("SIGTERM"); kill.abort(); expect(await result).not.toBe(0); - await until(() => stopped(pid()), "the grandchild to stop"); + await until(() => stopped(pid())); }); }); @@ -1022,43 +1014,35 @@ function outermostUnderJust(dir: string, command: string[]) { } describe.skipIf(process.platform === "win32")("an outermost gate owning a process group under Just", () => { - test( - "takes Just's copy of a SIGTERM it already handled as the same cancellation", - { timeout: 30_000 }, - async () => { - const dir = tempDir(); - const leaf = signalled(dir, ["SIGTERM"], 200); - const run = outermostUnderJust(dir, [process.execPath, leaf.script]); - await created(leaf.ready); - // A SIGTERM to Just's process group reaches the gate directly and again through Just, whose - // copy may come only after the gate handled the first. - run.gate("SIGTERM"); - await run.handled(1); - run.just("SIGTERM"); - await run.handled(2); - await run.exited; - expect(leaf.log()).toEqual(["SIGTERM"]); - // The leaf finished its own cleanup: the gate did not take Just's copy as a second signal. - expect(existsSync(leaf.done)).toBe(true); - }, - ); + test("takes Just's copy of a SIGTERM it already handled as the same cancellation", async () => { + const dir = tempDir(); + const leaf = signalled(dir, ["SIGTERM"], 200); + const run = outermostUnderJust(dir, [process.execPath, leaf.script]); + await created(leaf.ready); + // A SIGTERM to Just's process group reaches the gate directly and again through Just, whose + // copy may come only after the gate handled the first. + run.gate("SIGTERM"); + await run.handled(1); + run.just("SIGTERM"); + await run.handled(2); + await run.exited; + expect(leaf.log()).toEqual(["SIGTERM"]); + // The leaf finished its own cleanup: the gate did not take Just's copy as a second signal. + expect(existsSync(leaf.done)).toBe(true); + }); - test( - "lets the command finish its cleanup after a SIGTERM to Just's process group", - { timeout: 30_000 }, - async () => { - const dir = tempDir(); - const leaf = signalled(dir, ["SIGTERM"], 200); - const run = outermostUnderJust(dir, [process.execPath, leaf.script]); - await created(leaf.ready); - run.group("SIGTERM"); - await run.exited; - expect(leaf.log()).toEqual(["SIGTERM"]); - expect(existsSync(leaf.done)).toBe(true); - }, - ); + test("lets the command finish its cleanup after a SIGTERM to Just's process group", async () => { + const dir = tempDir(); + const leaf = signalled(dir, ["SIGTERM"], 200); + const run = outermostUnderJust(dir, [process.execPath, leaf.script]); + await created(leaf.ready); + run.group("SIGTERM"); + await run.exited; + expect(leaf.log()).toEqual(["SIGTERM"]); + expect(existsSync(leaf.done)).toBe(true); + }); - test("a SIGTERM beyond Just's copy kills a command still cleaning up", { timeout: 30_000 }, async () => { + test("a SIGTERM beyond Just's copy kills a command still cleaning up", async () => { const dir = tempDir(); const leaf = signalled(dir, ["SIGTERM"]); const run = outermostUnderJust(dir, [process.execPath, leaf.script]); @@ -1069,44 +1053,40 @@ describe.skipIf(process.platform === "win32")("an outermost gate owning a proces await run.handled(2); run.gate("SIGTERM"); await run.exited; - await until(() => stopped(leaf.pid()), "the leaf to stop"); + await until(() => stopped(leaf.pid())); }); - test( - "a repeated SIGINT, which Just does not forward, kills a command still cleaning up", - { timeout: 30_000 }, - async () => { - const dir = tempDir(); - const leaf = signalled(dir, ["SIGINT"]); - const run = outermostUnderJust(dir, [process.execPath, leaf.script]); - await created(leaf.ready); - run.group("SIGINT"); - await run.handled(1); - run.group("SIGINT"); - await run.exited; - await until(() => stopped(leaf.pid()), "the leaf to stop"); - }, - ); + test("a repeated SIGINT, which Just does not forward, kills a command still cleaning up", async () => { + const dir = tempDir(); + const leaf = signalled(dir, ["SIGINT"]); + const run = outermostUnderJust(dir, [process.execPath, leaf.script]); + await created(leaf.ready); + run.group("SIGINT"); + await run.handled(1); + run.group("SIGINT"); + await run.exited; + await until(() => stopped(leaf.pid())); + }); }); describe.runIf(process.platform === "linux")("a systemd scope", () => { - test("also stops a descendant that left its process group", { timeout: 30_000 }, async () => { + test("also stops a descendant that left its process group", async () => { const { command, pid } = descendant(tempDir(), "exit 3", { leave: true }); expect(await scoped(command, process.env, cancellation().cancel)).toBe(3); - await until(() => stopped(pid()), "the daemon to stop"); + await until(() => stopped(pid())); }); - test("reports its CPU use to the gate until it ends", { timeout: 30_000 }, async () => { + test("reports its CPU use to the gate until it ends", async () => { const host = fileHost(tempDir()); const unit = `quest-gate-${randomUUID()}.scope`; const done = join(tempDir(), "done"); expect(host.usage(unit)).toBeUndefined(); const spin = ["/bin/sh", "-c", `while [ ! -e ${quote(done)} ]; do :; done`]; const result = scope(unit)(spin, process.env, cancellation().cancel); - await until(() => (host.usage(unit) ?? 0) > 0, "the scope's CPU use to show"); + await until(() => (host.usage(unit) ?? 0) > 0); writeFileSync(done, ""); expect(await result).toBe(0); - await until(() => host.usage(unit) === undefined, "systemd to remove the scope"); + await until(() => host.usage(unit) === undefined); }); }); @@ -1149,7 +1129,7 @@ async function insideScope(launch: (command: string[], env: NodeJS.ProcessEnv) = } describe.runIf(process.platform === "linux")("a gate inside a Quest scope", () => { - test("runs its command in a background scope beside the enclosing one", { timeout: 30_000 }, async () => { + test("runs its command in a background scope beside the enclosing one", async () => { const report = await insideScope((command, env) => scoped(command, env, cancellation().cancel), true); expect(report).toMatchObject({ kind: "scope", status: 7, stopped: true, removed: true }); // Bound to the enclosing scope, a gate's scope here, so that scope's owner stops it too. @@ -1158,7 +1138,7 @@ describe.runIf(process.platform === "linux")("a gate inside a Quest scope", () = expect(report.path).toMatch(/\/background\.slice\/quest-gate-[0-9a-f-]+\.scope$/); }); - test("is stopped when the enclosing scope's owner kills it", { timeout: 30_000 }, async () => { + test("is stopped when the enclosing scope's owner kills it", async () => { const dir = tempDir(); const ready = join(dir, "ready"); const pid = join(dir, "pid"); @@ -1170,7 +1150,7 @@ describe.runIf(process.platform === "linux")("a gate inside a Quest scope", () = ]; const { stop, kill, cancel } = cancellation(); const result = scoped(nestedGate(dir, command), outside(), cancel); - await until(() => existsSync(ready), "the command to start"); + await created(ready); stop.abort("SIGTERM"); kill.abort(); await result; @@ -1218,7 +1198,7 @@ describe.runIf(process.platform === "linux")("a gate inside a Quest scope", () = return env; }; - test("lets a phase finish its cleanup once the enclosing scope signals it", { timeout: 30_000 }, async () => { + test("lets a phase finish its cleanup once the enclosing scope signals it", async () => { const dir = tempDir(); const leaf = signalled(dir, ["SIGTERM"], 200); const { stop, cancel } = cancellation(); @@ -1231,7 +1211,7 @@ describe.runIf(process.platform === "linux")("a gate inside a Quest scope", () = expect(existsSync(leaf.done)).toBe(true); }); - test("cancelled by its caller, signals every process in its scope", { timeout: 30_000 }, async () => { + test("cancelled by its caller, signals every process in its scope", async () => { const dir = tempDir(); const leaf = signalled(dir, ["SIGTERM"], 200); const abort = join(dir, "abort"); @@ -1362,13 +1342,13 @@ describe.runIf(process.platform === "win32")("a job object", () => { expect(owner({})).toEqual({ run: job }); }); - test("keeps the exact exit status and stops a detached descendant", { timeout: 30_000 }, async () => { + test("keeps the exact exit status and stops a detached descendant", async () => { const { command, pid } = detached(tempDir(), "process.exit(7);"); expect(await job(command, process.env, cancellation().cancel)).toBe(7); - await until(() => !running(pid()), "the detached descendant to stop"); + await until(() => !running(pid())); }); - test("killing stops the command and a detached descendant", { timeout: 30_000 }, async () => { + test("killing stops the command and a detached descendant", async () => { const dir = tempDir(); const ready = join(dir, "ready"); const { command, pid } = detached( @@ -1377,15 +1357,15 @@ describe.runIf(process.platform === "win32")("a job object", () => { ); const { kill, cancel } = cancellation(); const result = job(command, process.env, cancel); - await until(() => existsSync(ready), "the command to start"); + await created(ready); kill.abort(); expect(await result).not.toBe(0); - await until(() => !running(pid()), "the detached descendant to stop"); + await until(() => !running(pid())); }); }); describe.skipIf(process.platform === "win32")("a process group", () => { - test("delivers SIGINT once to a command under a nested gate", { timeout: 30_000 }, async () => { + test("delivers SIGINT once to a command under a nested gate", async () => { const dir = tempDir(); const ready = join(dir, "ready"); const count = join(dir, "count"); @@ -1401,13 +1381,13 @@ describe.skipIf(process.platform === "win32")("a process group", () => { const { stop, cancel } = cancellation(); const env = { ...process.env, QUEST_GATE_ADMITTED: "1" }; const result = group([process.execPath, bin, "gate", process.execPath, child], env, cancel); - await until(() => existsSync(ready), "the command to start"); + await created(ready); stop.abort("SIGINT"); expect(await result).toBe(128 + constants.signals.SIGINT); expect(readFileSync(count, "utf8")).toBe("1"); }); - test("leaves a descendant that started its own session", { timeout: 30_000 }, async () => { + test("leaves a descendant that started its own session", async () => { const { command, pid } = descendant(tempDir(), "exit 3", { leave: true }); expect(await group(command, process.env, cancellation().cancel)).toBe(3); try { diff --git a/test/land-fixer-diagnostics.test.ts b/test/land-fixer-diagnostics.test.ts index 5711160b..56205606 100644 --- a/test/land-fixer-diagnostics.test.ts +++ b/test/land-fixer-diagnostics.test.ts @@ -16,6 +16,7 @@ import { join } from "node:path"; import { startFixer, collectFixers } from "../src/cli/land-fixer"; import type { GitHubDependencies } from "../src/cli/github"; import type { Plan } from "../src/cli/land"; +import { created } from "./fixture"; // Linux only, like the land daemon: the fixture uses mkfifo and POSIX shells. test.skipIf(process.platform === "win32")( @@ -107,14 +108,11 @@ test.skipIf(process.platform === "win32")( state, readdirSync(state).find((name) => /^fix-job-.*\.json$/.test(name))!, ); - const deadline = Date.now() + 5000; - while (!existsSync(ready) && Date.now() < deadline) await new Promise((done) => setTimeout(done, 10)); - expect(existsSync(ready)).toBe(true); + await created(ready); expect(JSON.parse(readFileSync(path, "utf8")).identity).toMatch(/^[0-9a-f-]+:\d+$/); expect(await collectFixers(root, state, deps, async () => "fake")).toEqual([]); writeFileSync(release, "continue\n"); - while (!existsSync(`${path}.exit`) && Date.now() < deadline) - await new Promise((done) => setTimeout(done, 10)); + await created(`${path}.exit`); expect(readFileSync(`${path}.exit`, "utf8")).toBe("101"); const output = readFileSync(`${path}.log`, "utf8"); expect(output).toContain("prefix-0\n"); diff --git a/test/land-watch.test.ts b/test/land-watch.test.ts index 6fbfe8c4..6bfa40db 100644 --- a/test/land-watch.test.ts +++ b/test/land-watch.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { setTimeout as pause } from "node:timers/promises"; import { fileURLToPath } from "node:url"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test } from "vitest"; import type { GitHubDependencies } from "../src/cli/github"; import { readRemote } from "../src/cli/land"; @@ -28,9 +28,6 @@ import { import { FLAKY_INPUT, type KnownFlaky } from "../src/cli/land-fix"; import { configure, GIT_SETTINGS, initRepository, tempDir } from "./fixture"; -// The fixer tests merge with the quest driver and run fixer wrappers, which take longer than vitest's default. -vi.setConfig({ testTimeout: 30_000 }); - const REPOSITORY = { owner: "upstream", repo: "project" }; const BASE = "b".repeat(40); /** The base the merge group was built on, which its branch names. */ diff --git a/test/land.test.ts b/test/land.test.ts index cf955c32..26f0e732 100644 --- a/test/land.test.ts +++ b/test/land.test.ts @@ -83,9 +83,6 @@ import { GIT_SETTINGS, configure, initRepository, template, tempDir } from "./fi const bin = fileURLToPath(new URL("../src/cli/bin.ts", import.meta.url)); const bunfig = fileURLToPath(new URL("../bunfig.toml", import.meta.url)); -// Each daemon test drives many git merges. On the land gate's host under load 28 (2026-10-31), -// tests that take 1.1-1.8 s unloaded took 5.2-6.8 s and failed vitest's 5 s default. -vi.setConfig({ testTimeout: 30_000 }); const REPOSITORY = { owner: "upstream", repo: "project" }; const SQUASH: Target = { method: "squash" }; @@ -2488,7 +2485,7 @@ mv '${result}.new' '${result}' "ejected 1 merge_conflict", ]); expect(forge.comment(1)!.length).toBeLessThan(65_536); - }, 30_000); + }); test("bounds the ejection comment's log excerpt", () => { expect(excerpt("a < b")).toBe("a < b"); diff --git a/test/landed.test.ts b/test/landed.test.ts index 759cc1df..96422de8 100644 --- a/test/landed.test.ts +++ b/test/landed.test.ts @@ -2,15 +2,12 @@ import { execFileSync } from "node:child_process"; -import { expect, test, vi } from "vitest"; +import { expect, test } from "vitest"; import { Tree } from "./fixture"; const FINDING = "neither deletes nor edits its quest since main"; -// Each test spawns git up to a dozen times, which a busy Windows runner can stretch past 5 s. -vi.setConfig({ testTimeout: 30_000 }); - /** The baseline tree committed on `main`, then checked out on `branch`. */ function repo(branch: string): Tree { const tree = Tree.committed(); diff --git a/test/move.test.ts b/test/move.test.ts index 166f90cf..3573199f 100644 --- a/test/move.test.ts +++ b/test/move.test.ts @@ -4,7 +4,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { join } from "node:path"; -import { describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { move, remove } from "../src/core"; import { openPullRequests } from "../src/cli/claims"; @@ -12,9 +12,6 @@ import { GitHub, type GitHubDependencies } from "../src/cli/github"; import { main } from "../src/cli/main"; import { EPIC_README, M0_README, ROOT_README, TWO, Tree, initRepository, tempDir } from "./fixture"; -// Each test spawns git up to a dozen times, which a busy Windows runner can stretch past 5 s. -vi.setConfig({ testTimeout: 30_000 }); - function git(dir: string, ...args: string[]): string { return execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); } diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index f6c78d6c..17be4d10 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -57,9 +57,7 @@ function notes(): { note: (message: string) => void; seen: string[] } { const LINK = "quest/m0/epic/one.md:11: link does not resolve: ../../../dependency/missing.md"; const SKIPPED = "skipped links into submodule dependency: no local git directory holds its pinned commit"; -// Cases run up to ~35 real Git processes. Process creation on the Windows ARM runner -// costs ~80-250 ms while other test files start, so the 5 s unit-test default is too short. -describe("committed submodule links", { timeout: 20_000 }, () => { +describe("committed submodule links", () => { test("snapshot validation resolves links inside an initialized gitlink", () => { const { tree, head } = fixture(); tree.accepts(); @@ -136,7 +134,7 @@ describe("committed submodule links", { timeout: 20_000 }, () => { }); }); -describe("checkout submodule links", { timeout: 20_000 }, () => { +describe("checkout submodule links", () => { afterEach(() => { vi.unstubAllEnvs(); }); diff --git a/test/start.test.ts b/test/start.test.ts index 80a11fde..76c0eee3 100644 --- a/test/start.test.ts +++ b/test/start.test.ts @@ -25,9 +25,6 @@ const BRANCH = "quest/m0/epic/one"; const UPSTREAM = "https://github.com/example/project.git"; const FORK = "https://github.com/me/project.git"; -// Each test spawns git a few dozen times, which takes seconds on Windows runners. -vi.setConfig({ testTimeout: 30_000 }); - function git(cwd: string, ...args: string[]): string { return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); } diff --git a/test/worktree.run.test.ts b/test/worktree.run.test.ts index 5b7589cd..73816b69 100644 --- a/test/worktree.run.test.ts +++ b/test/worktree.run.test.ts @@ -77,7 +77,7 @@ function stopped(pid: number): boolean { } } -describe.runIf(process.platform === "linux")("supervised runs under systemd", { timeout: 30_000 }, () => { +describe.runIf(process.platform === "linux")("supervised runs under systemd", () => { it("forwards arguments and status, stops orphaned descendants, then releases", async () => { const { dir, home, worktrees, record } = await fixture(); const pidFile = join(dir, "orphan"); @@ -204,10 +204,7 @@ describe.runIf(process.platform === "linux")("supervised runs under systemd", { kill.abort(); expect(await result).toBe(143); // The gate killed the supervisor with everything else in its scope, and systemd the run's own. - for (let tries = 0; !stopped(leaf.pid()); tries++) { - if (tries === 200) throw new Error("the run's scope outlived the gate's"); - await new Promise((resolve) => setTimeout(resolve, 25)); - } + while (!stopped(leaf.pid())) await new Promise((resolve) => setTimeout(resolve, 25)); expect(await worktrees.stop(record.id)).toMatchObject({ phase: "released", runs: [] }); }); }); diff --git a/test/worktree.test.ts b/test/worktree.test.ts index a811cc05..94cceb71 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -79,11 +79,7 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); -// Cases run up to 20 real Git processes. Process creation on the Windows ARM runner -// costs ~80-250 ms while other test files start, so the 5 s unit-test default is too short. -// Cases that create checkouts with submodules clone each nested repository, running -// 100-300 Git processes, so they get 60 s. -describe("worktrees", { timeout: 20_000 }, () => { +describe("worktrees", () => { it("excludes .worktrees/ locally once, only when nothing ignores it", async () => { const { repo, worktrees } = fixture(); const exclude = join(git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir"), "info", "exclude"); @@ -415,7 +411,7 @@ describe("worktrees", { timeout: 20_000 }, () => { worktrees.restore(record.id); expect(existsSync(join(nested, "cache"))).toBe(false); expect(readFileSync(join(nested, "file"), "utf8")).toBe("local work"); - }, 60_000); + }); it("ignores inherited Git location variables that select a submodule", async () => { const { root, repo, worktrees } = fixture(); vi.stubEnv("GIT_ALLOW_PROTOCOL", "file"); @@ -442,7 +438,7 @@ describe("worktrees", { timeout: 20_000 }, () => { writeFileSync(join(path, "file"), "local work"); await expect(worktrees.remove(record.id)).rejects.toThrow("checkout has modified or untracked files: M file"); expect(readFileSync(join(path, "file"), "utf8")).toBe("local work"); - }, 60_000); + }); it("charges cloned module stores and keeps one that serves another worktree", async () => { const { root, repo, worktrees } = fixture(); vi.stubEnv("GIT_ALLOW_PROTOCOL", "file"); @@ -464,7 +460,7 @@ describe("worktrees", { timeout: 20_000 }, () => { git(join(record.path, "sub"), "worktree", "remove", elsewhere); await worktrees.remove(record.id); expect(existsSync(record.path)).toBe(false); - }, 60_000); + }); it("protects a live creation without giving interrupted-checkout removal advice", async () => { const { repo, worktrees } = fixture(); const creation = worktrees.create(repo, "live", 10_000_000); @@ -494,7 +490,7 @@ describe("worktrees", { timeout: 20_000 }, () => { } await worktrees.remove(record.id); expect(existsSync(record.path)).toBe(false); - }, 60_000); + }); it("keeps a partly initialized creation charged and gives recovery advice for its lock and submodules", async () => { const { root, repo, worktrees } = fixture(); vi.stubEnv("GIT_ALLOW_PROTOCOL", "file"); @@ -512,7 +508,7 @@ describe("worktrees", { timeout: 20_000 }, () => { expect(() => worktrees.release(record.id)).toThrow("git worktree unlock"); expect(() => worktrees.release(record.id)).toThrow("--force for submodules"); expect(worktrees.status().allocated).toBe(10_000_000); - }, 60_000); + }); it("keeps nested submodule files and commits, including branches whose HEAD is back at the pin", async () => { const { root, repo, worktrees } = fixture(); vi.stubEnv("GIT_ALLOW_PROTOCOL", "file"); @@ -581,7 +577,7 @@ describe("worktrees", { timeout: 20_000 }, () => { ); await worktrees.remove(record.id); expect(existsSync(record.path)).toBe(false); - }, 60_000); + }); it("serializes reservations across independent repositories and preserves failed allocations", async () => { const { root, repo, home, worktrees } = fixture(); const second = repository(join(root, "second")); @@ -715,7 +711,7 @@ describe("worktrees", { timeout: 20_000 }, () => { }); }); -describe("supervised runs", { timeout: 20_000 }, () => { +describe("supervised runs", () => { it("keeps scratch while any run is live and prunes it once the last one stops", async () => { const { finish, scopes } = fakeScopes(); const { repo, worktrees } = fixture(scopes); @@ -960,7 +956,7 @@ describe("cgroup kill", () => { }); }); -describe("Claude Code hooks", { timeout: 20_000 }, () => { +describe("Claude Code hooks", () => { const noForge = async () => false; /** A subagent's hook input, as Claude sends it from inside the subagent's checkout. */ const subagent = (event: string, cwd: string, agent: string) => ({ hook_event_name: event, cwd, agent_id: agent }); diff --git a/vitest.config.ts b/vitest.config.ts index 5f08700c..a5757b30 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,6 +22,14 @@ export default defineConfig({ env: { GIT_CONFIG_GLOBAL: "/dev/null" }, ...(live ? { include: ["test/live/**/*.test.ts"], testTimeout: 30 * 60_000, hookTimeout: 60_000 } - : { include: ["test/**/*.test.ts"], exclude: [...configDefaults.exclude, "test/live/**"] }), + : { + include: ["test/**/*.test.ts"], + exclude: [...configDefaults.exclude, "test/live/**"], + // A test's CPU budget (test/budget.ts) bounds how slow it may be; this wall-clock + // limit only catches a test awaiting what never comes, so a loaded host never reaches it. + setupFiles: ["test/budget.ts"], + testTimeout: 5 * 60_000, + hookTimeout: 5 * 60_000, + }), }, });