diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index 72ff6d64..38e43707 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -406,6 +406,13 @@ publication before it can select ancestor Compose inputs or write legacy files. An active adopted receipt remains a selection boundary even if its candidate is removed; missing or edited candidates refuse execution. +Project commands look for an adoption before ordinary discovery. The search goes up +from the start directory to the nearest project root or the filesystem root, as far +as ordinary discovery goes, with no depth limit. Ordinary discovery reads an active +adoption as native input, so an adoption the search missed would reach the native +path. From a directory with no project, however deep, a command gets the ordinary +missing-project error. Unsafe adoption state is still refused with `E_CONFIG_INVALID`. + The distinct execution owner consumes the saved binding. It never sends these resources through ordinary native startup, which allocates a different Compose namespace and native volume names. `up --detach`, `restart` and `down` start, restart and @@ -435,14 +442,16 @@ failing, with status `ok` and the message Doctor uses `warn` for a skip that points at something to fix, such as no `.hack/` found. These skips apply to every adopted project by design, so they are not warnings. A pending adoption is a warning that asks for explicit recovery, and the `--fix` steps -treat it like an active one. The adoption search stops after 64 parent directories. -From a deeper start directory doctor finds no adoption and uses ordinary project -discovery instead of failing. The +treat it like an active one. Doctor uses the same adoption search as project +commands, so from a directory with no adoption above it, however deep, doctor uses +ordinary project discovery instead of failing. The `--fix` plan says that it leaves the adoption's inputs, retained containers and state alone. For the project it only removes tracked generated files from the Git index (the files stay on disk) and keeps an env override that the adoption pins. It leaves `.hack/.gitignore` unchanged; a committed `.hack.secret.key` is also added to the -root `.gitignore`. Global repairs still run. +root `.gitignore`. Global repairs still run. The tracked generated files warning for +an adopted project says that `--fix` leaves `.hack/.gitignore` unchanged, so the +files must be kept out of later commits. ## Rollback and interruption recovery diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 52ea034d..fe281a49 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -3018,13 +3018,19 @@ async function checkTrackedGeneratedFiles({ message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`, }; } - return await checkTrackedGeneratedFilesAt(ctx); + return await checkTrackedGeneratedFilesAt({ ...ctx, adopted: false }); } -/** The tracked generated-files check for a resolved project (legacy or adopted). */ +/** + * The tracked generated-files check for a resolved project (legacy or + * adopted). For an adopted project `--fix` only untracks the files and leaves + * the adoption's `.gitignore` unchanged, so the warning says that instead of + * promising to ensure it (HACK-1380). + */ async function checkTrackedGeneratedFilesAt(ctx: { readonly projectRoot: string; readonly projectDirName: string; + readonly adopted: boolean; }): Promise { const name = "generated files"; const inspection = await inspectTrackedGeneratedFiles({ @@ -3057,7 +3063,9 @@ async function checkTrackedGeneratedFilesAt(ctx: { return { name, status: "warn", - message: `Machine-local generated files are tracked in git: ${offenders}. Run 'hack doctor --fix' to untrack them (files stay on disk) and ensure the committed ${ctx.projectDirName}/.gitignore.`, + message: ctx.adopted + ? `Machine-local generated files are tracked in git: ${offenders}. Run 'hack doctor --fix' to untrack them (files stay on disk). It leaves the adopted project's ${ctx.projectDirName}/.gitignore unchanged, so keep these files out of later commits.` + : `Machine-local generated files are tracked in git: ${offenders}. Run 'hack doctor --fix' to untrack them (files stay on disk) and ensure the committed ${ctx.projectDirName}/.gitignore.`, }; } @@ -3346,7 +3354,7 @@ async function runAdoptedProjectChecks(opts: { await runCheck( progress, "generated files", - () => checkTrackedGeneratedFilesAt(adopted), + () => checkTrackedGeneratedFilesAt({ ...adopted, adopted: true }), { timeoutMs: 5000 } ), await runCheck(progress, COMPOSE_ENV_AT_REST_CHECK, () => diff --git a/src/lib/doctor-adopted-project.ts b/src/lib/doctor-adopted-project.ts index cdf1e53d..497b7dcd 100644 --- a/src/lib/doctor-adopted-project.ts +++ b/src/lib/doctor-adopted-project.ts @@ -1,6 +1,5 @@ import { join } from "node:path"; import { HACK_PROJECT_DIR_PRIMARY } from "../constants.ts"; -import { HackCliError } from "./cli-result.ts"; import { COMPOSE_BACKEND_CHECK } from "./doctor-compose-backend.ts"; import { selectLegacyComposeAdoptedRoot } from "./native-compose-adoption-command.ts"; import { inspectLegacyComposeAdoptionSelection } from "./native-compose-adoption-marker.ts"; @@ -26,26 +25,18 @@ export type DoctorAdoptedProject = { /** * The adopted project that owns `startDir`, or null when discovery from there - * reaches no active or pending adoption (or a nearer legacy project), or when - * the adoption selector's ancestor bound ends the search first. Unsafe - * adoption state throws the adoption selector's closed, value-free refusal. + * reaches no active or pending adoption (or a nearer legacy project). The + * adoption selector walks every ancestor that ordinary discovery walks, so a + * deep start directory with no adoption above it is null here, not a throw + * (HACK-1380). Unsafe adoption state throws the selector's closed, value-free + * refusal (`E_CONFIG_INVALID`). */ export async function findDoctorAdoptedProject(opts: { readonly startDir: string; }): Promise { - let projectRoot: string | null; - try { - projectRoot = await selectLegacyComposeAdoptedRoot({ - cwd: opts.startDir, - }); - } catch (error: unknown) { - if (isAdoptionSearchBoundRefusal(error)) { - // A start directory more than the selector's 64 ancestors deep with no - // root on the way: no adoption was found, so ordinary discovery decides. - return null; - } - throw error; - } + const projectRoot = await selectLegacyComposeAdoptedRoot({ + cwd: opts.startDir, + }); if (!projectRoot) { return null; } @@ -62,26 +53,12 @@ export async function findDoctorAdoptedProject(opts: { }; } -/** - * Whether `error` is the adoption selector's ancestor-bound refusal. Without - * `--project` or `--path`, `selectLegacyComposeAdoptedRoot` throws - * `E_NATIVE_PROJECT_UNSUPPORTED` only when it walked its 64 ancestors without - * reaching a root; unsafe adoption state is a different code - * (`E_CONFIG_INVALID`) and still propagates. - */ -function isAdoptionSearchBoundRefusal(error: unknown): boolean { - return ( - error instanceof HackCliError && - error.code === "E_NATIVE_PROJECT_UNSUPPORTED" - ); -} - /** * The adopted project doctor reports, or null after ordinary discovery's * native-input refusal passed. An adopted project's inputs belong to its * adoption, so that discovery would refuse it as native input; doctor selects - * it first (HACK-1311). A start directory too deep for the adoption selector - * is not an adoption, so it gets ordinary discovery instead of a throw. + * it first (HACK-1311). A start directory with no adoption above it, however + * deep, gets ordinary discovery instead of a throw. */ export async function preflightDoctorProject(opts: { readonly startDir: string; diff --git a/src/lib/native-compose-adoption-command.ts b/src/lib/native-compose-adoption-command.ts index 6ab5d4b2..4c3a1517 100644 --- a/src/lib/native-compose-adoption-command.ts +++ b/src/lib/native-compose-adoption-command.ts @@ -58,10 +58,25 @@ async function registeredAdoptedRoot(project: string) { return state === "active" || state === "pending" ? registered.repoRoot : null; } -/** Check private adoption ownership before ordinary authored discovery can allocate a fresh native namespace. */ +/** + * Check private adoption ownership before ordinary authored discovery can + * allocate a fresh native namespace. Returns the adopted project root, or null + * when there is no active or pending adoption on the way to the nearest + * project root or the filesystem root. Unsafe adoption state throws the + * marker's closed, value-free refusal (`E_CONFIG_INVALID`). + * + * Invariant: this walk covers every ancestor that ordinary discovery + * (`discoverProjectInputs`) covers. That discovery reads an active adoption as + * native input, so an adoption this walk missed would reach the native + * authored path. The walk is therefore unbounded, like that discovery: it + * ends at a project root or the filesystem root, since `dirname` of an + * absolute path always reaches `/`. It once stopped after 64 directories and + * then refused every command, even with no project at all, as an unsupported + * adopted command (HACK-1380). + */ export async function selectLegacyComposeAdoptedRoot( options: Pick -) { +): Promise { if (options.path && options.project !== undefined) { throw new CliUsageError("Use either --path or --project (not both)."); } @@ -70,7 +85,7 @@ export async function selectLegacyComposeAdoptedRoot( } let current = resolve(options.cwd, options.path ?? "."); let nearestLegacy = false; - for (let depth = 0; depth < 64; depth++) { + while (true) { const state = await inspectLegacyComposeAdoptionSelection({ projectRoot: current, }); @@ -92,7 +107,6 @@ export async function selectLegacyComposeAdoptedRoot( } current = parent; } - unsupported(); } function validate(options: NativeComposeCommandOptions) { if ( diff --git a/tests/doctor-adopted-generated-files.test.ts b/tests/doctor-adopted-generated-files.test.ts new file mode 100644 index 00000000..cc6face5 --- /dev/null +++ b/tests/doctor-adopted-generated-files.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { inspectLegacyComposeAdoptionSelection } from "../src/lib/native-compose-adoption-marker.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; +import { + retainedSourceBindFixture, + SOURCE_BIND_CANARY, +} from "./helpers/retained-source-bind-adoption.ts"; + +/** + * HACK-1380: in an adopted (`hack config adopt`) project, `hack doctor --fix` + * only untracks tracked generated files and leaves the adoption's + * `.hack/.gitignore` unchanged. The `generated files` warning must say so + * instead of promising to ensure the committed `.hack/.gitignore`. The + * fixture is the synthetic retained source-bind adoption (no real engine); + * Git runs only against the fixture checkout with no global or system config. + */ +let h: Awaited>; +beforeEach(async () => { + h = await retainedSourceBindFixture(); +}); +afterEach(async () => { + await h.cleanup(); +}); + +const GIT_ENV = { + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", +} as const; + +function git(args: readonly string[]): void { + const result = Bun.spawnSync(["/usr/bin/git", ...args], { + cwd: h.root, + env: { PATH: "/usr/bin:/bin", HOME: h.outer, ...GIT_ENV }, + stdout: "ignore", + stderr: "pipe", + }); + if (result.exitCode !== 0) { + throw new Error(`git ${args[0]} failed in the fixture checkout.`); + } +} + +/** Read-only `doctor --json` in a private HOME and HACK_HOME, stub engine only. */ +async function doctorJson(): Promise { + const state = join(h.outer, "state"); + await mkdir(state, { recursive: true }); + const child = Bun.spawn( + [ + process.execPath, + resolve(import.meta.dir, "..", "index.ts"), + "doctor", + "--path", + h.root, + "--json", + ], + { + cwd: h.root, + env: privateCliEnv({ + root: h.outer, + env: { + HACK_HOME: state, + HACK_GLOBAL_CONFIG_PATH: join(state, "hack.config.json"), + HACK_DAEMON_DISABLE: "1", + HACK_NO_INTERACTIVE: "1", + HACK_LOGGER: "console", + HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK: "1", + XDG_CACHE_HOME: join(h.outer, "cache"), + PATH: `${h.outer}:/usr/bin:/bin`, + ...GIT_ENV, + }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const [, stdout] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return stdout; +} + +test("doctor's generated files warning for an adopted project says --fix leaves .hack/.gitignore unchanged", async () => { + // A real Git checkout with a committed generated file. + await rm(join(h.root, ".git"), { recursive: true }); + git(["init", "-q"]); + await writeFile(join(h.root, ".hack/.env.state.json"), "{}\n", { + mode: 0o600, + }); + git(["add", "-f", ".hack/.env.state.json"]); + const store = await h.store(); + try { + const generation = await store.prepare({ binary: h.compiler }); + await store.publish({ generation, binary: h.compiler }); + } finally { + await store.close(); + } + expect( + await inspectLegacyComposeAdoptionSelection({ projectRoot: h.root }) + ).toBe("active"); + + const stdout = await doctorJson(); + expect(stdout).not.toContain(SOURCE_BIND_CANARY); + const parsed: unknown = JSON.parse(stdout); + const checks = ( + parsed as { + data: { checks: { id: string; status: string; detail: string }[] }; + } + ).data.checks; + expect(checks.find((check) => check.id === "generated files")).toMatchObject({ + status: "warn", + detail: + "Machine-local generated files are tracked in git: .hack/.env.state.json. Run 'hack doctor --fix' to untrack them (files stay on disk). It leaves the adopted project's .hack/.gitignore unchanged, so keep these files out of later commits.", + }); + expect(stdout).not.toContain("ensure the committed"); +}, 120_000); diff --git a/tests/doctor-adopted-project.test.ts b/tests/doctor-adopted-project.test.ts index dc395acc..1aa09955 100644 --- a/tests/doctor-adopted-project.test.ts +++ b/tests/doctor-adopted-project.test.ts @@ -242,17 +242,16 @@ test("doctor reports a pending adoption as a project warning that asks for recov ).toEqual([]); }, 120_000); -test("a start directory deeper than the adoption search bound gets ordinary discovery instead of a throw", async () => { - // More ancestors than the adoption selector walks (64), with no project root - // on the way: the selector's bound ends the search. +test("a start directory deeper than the old adoption search bound gets ordinary discovery instead of a throw", async () => { + // More ancestors than the adoption selector once walked (64), with no + // project root on the way. The selector now walks every ancestor (HACK-1380) + // and finds no adoption, so doctor needs no catch of its own. let deep = join(h.outer, "deep"); for (let level = 0; level < 70; level++) { deep = join(deep, "d"); } await mkdir(deep, { recursive: true }); - await expect( - selectLegacyComposeAdoptedRoot({ cwd: deep }) - ).rejects.toMatchObject({ code: "E_NATIVE_PROJECT_UNSUPPORTED" }); + expect(await selectLegacyComposeAdoptedRoot({ cwd: deep })).toBeNull(); expect(await findDoctorAdoptedProject({ startDir: deep })).toBeNull(); expect(await preflightDoctorProject({ startDir: deep })).toBeNull(); @@ -265,8 +264,8 @@ test("a start directory deeper than the adoption search bound gets ordinary disc }, 60_000); test("unsafe adoption state still refuses doctor with the selector's closed error", async () => { - // A state root that is not a directory is unsafe adoption state, not the - // search bound, so doctor must not read it as "no adoption". + // A state root that is not a directory is unsafe adoption state, so doctor + // must not read it as "no adoption". const root = join(h.outer, "unsafe"); await mkdir(join(root, ".hack/.internal"), { recursive: true }); await writeFile( diff --git a/tests/native-compose-adoption-search-depth.test.ts b/tests/native-compose-adoption-search-depth.test.ts new file mode 100644 index 00000000..02773155 --- /dev/null +++ b/tests/native-compose-adoption-search-depth.test.ts @@ -0,0 +1,255 @@ +import { expect, test } from "bun:test"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { findDoctorAdoptedProject } from "../src/lib/doctor-adopted-project.ts"; +import { selectLegacyComposeAdoptedRoot } from "../src/lib/native-compose-adoption-command.ts"; +import { inspectLegacyComposeAdoptionSelection } from "../src/lib/native-compose-adoption-marker.ts"; +import { expectCodedRefusal } from "./helpers/closed-refusal.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; +import { + retainedSourceBindFixture, + SOURCE_BIND_CANARY, +} from "./helpers/retained-source-bind-adoption.ts"; + +/** + * HACK-1380: project commands look for an adoption before ordinary discovery. + * That search once stopped after 64 directories and then refused the command + * as an unsupported adopted command (`E_NATIVE_PROJECT_UNSUPPORTED`), even + * with no project at all. It now walks as far as ordinary discovery does, so + * a start directory 70 levels deep behaves like a shallow one: no project + * gets the ordinary missing-project error, an adoption above is still + * selected, and unsafe adoption state is still refused (`E_CONFIG_INVALID`). + * + * The CLI runs in a private HOME and HACK_HOME with no Docker client config; + * the adopted cases use the synthetic retained source-bind engine only. + */ + +/** More directory levels than the old 64-directory search covered. */ +const DEPTH = 70; + +/** Commands that reach the adoption search through native dispatch or open. */ +const COMMANDS: readonly (readonly string[])[] = [ + ["ps"], + ["up", "--detach"], + ["down"], + ["logs", "--no-follow"], + ["open"], +]; + +async function deepDirectory(base: string): Promise { + let deep = base; + for (let level = 0; level < DEPTH; level++) { + deep = join(deep, "d"); + } + await mkdir(deep, { recursive: true }); + return deep; +} + +type CliRun = { readonly exit: number; readonly output: string }; + +/** Runs the source CLI from `cwd` with private state under `root`. */ +async function cli(opts: { + readonly root: string; + readonly cwd: string; + readonly args: readonly string[]; +}): Promise { + const state = join(opts.root, "state"); + await mkdir(state, { recursive: true }); + const child = Bun.spawn( + [ + process.execPath, + resolve(import.meta.dir, "..", "index.ts"), + ...opts.args, + ], + { + cwd: opts.cwd, + env: privateCliEnv({ + root: opts.root, + env: { + HACK_HOME: state, + HACK_GLOBAL_CONFIG_PATH: join(state, "hack.config.json"), + HACK_DAEMON_DISABLE: "1", + HACK_NO_INTERACTIVE: "1", + HACK_LOGGER: "console", + HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK: "1", + XDG_CACHE_HOME: join(opts.root, "cache"), + // The adopted fixture's synthetic engine is `/docker`; with no + // fixture there, no `docker` resolves at all. + PATH: `${opts.root}:/usr/bin:/bin`, + }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const [exit, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { exit, output: stdout + stderr }; +} + +/** Prepares and publishes the fixture's adoption. */ +async function adopt( + h: Awaited> +): Promise { + const store = await h.store(); + try { + const generation = await store.prepare({ binary: h.compiler }); + await store.publish({ generation, binary: h.compiler }); + } finally { + await store.close(); + } +} + +/** + * Leaves the published adoption pending: a retained `start` whose callback + * reports failure keeps its journal. The callback runs no engine command. + */ +async function leavePending( + h: Awaited> +): Promise { + const store = await h.store(); + try { + const active = await store.loadActive(); + if (!active) { + throw new Error("Synthetic active generation missing; values omitted."); + } + const outcome = await store.withMutation({ + generation: active, + operation: "start", + services: [], + binary: h.compiler, + deadline: Date.now() + 15_000, + run: async () => 1, + }); + if (outcome !== 1) { + throw new Error("Synthetic failed operation completed; values omitted."); + } + } finally { + await store.close(); + } +} + +test("a project command 70 directories deep with no project gets the ordinary missing-project error, not an adopted refusal", async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "adoption-search-depth-")) + ); + try { + const shallow = join(root, "shallow", "d"); + await mkdir(shallow, { recursive: true }); + const deep = await deepDirectory(join(root, "deep")); + + expect(await selectLegacyComposeAdoptedRoot({ cwd: deep })).toBeNull(); + expect(await findDoctorAdoptedProject({ startDir: deep })).toBeNull(); + + for (const args of COMMANDS) { + const expected = await cli({ root, cwd: shallow, args }); + const actual = await cli({ root, cwd: deep, args }); + expect({ args, ...actual }).toEqual({ args, ...expected }); + expect(actual.exit).toBe(1); + expect(actual.output).toContain("No .hack/ (or legacy .dev/) found"); + expect(actual.output).not.toContain("E_NATIVE_PROJECT_UNSUPPORTED"); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}, 120_000); + +test("an active adoption more than 64 directories above the start directory is still selected", async () => { + const h = await retainedSourceBindFixture(); + try { + await adopt(h); + const shallow = join(h.root, "source"); + const deep = await deepDirectory(shallow); + + expect(await selectLegacyComposeAdoptedRoot({ cwd: deep })).toBe(h.root); + expect(await findDoctorAdoptedProject({ startDir: deep })).toMatchObject({ + projectRoot: h.root, + state: "active", + }); + + // ps lists the retained container from either depth. + const ps = await cli({ root: h.outer, cwd: deep, args: ["ps"] }); + expect(ps).toEqual({ exit: 0, output: "db\texited\n" }); + expect(ps).toEqual( + await cli({ root: h.outer, cwd: shallow, args: ["ps"] }) + ); + // up stays on the adopted path from depth too: ordinary discovery reads + // an active adoption as native input, so a missed adoption would reach + // native startup instead. + const up = ["up", "--detach"]; + expect(await cli({ root: h.outer, cwd: deep, args: up })).toEqual( + await cli({ root: h.outer, cwd: shallow, args: up }) + ); + expect( + await inspectLegacyComposeAdoptionSelection({ projectRoot: h.root }) + ).toBe("active"); + } finally { + await h.cleanup(); + } +}, 120_000); + +test("a pending adoption more than 64 directories above the start directory still asks for explicit recovery", async () => { + const h = await retainedSourceBindFixture(); + try { + await adopt(h); + await leavePending(h); + const shallow = join(h.root, "source"); + const deep = await deepDirectory(shallow); + + expect(await selectLegacyComposeAdoptedRoot({ cwd: deep })).toBe(h.root); + expect(await findDoctorAdoptedProject({ startDir: deep })).toMatchObject({ + projectRoot: h.root, + state: "pending", + }); + for (const args of [["ps"], ["down"]]) { + const actual = await cli({ root: h.outer, cwd: deep, args }); + expect(actual).toEqual(await cli({ root: h.outer, cwd: shallow, args })); + expect(actual.exit).toBe(1); + expect(actual.output).toContain("E_CONFIG_INVALID"); + expect(actual.output).not.toContain(SOURCE_BIND_CANARY); + } + expect( + await inspectLegacyComposeAdoptionSelection({ projectRoot: h.root }) + ).toBe("pending"); + } finally { + await h.cleanup(); + } +}, 120_000); + +test("unsafe adoption state more than 64 directories above the start directory is still refused, closed and value-free", async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "adoption-search-depth-")) + ); + try { + // An adoption state root that is a file is unsafe adoption state. + const project = join(root, "unsafe"); + await mkdir(join(project, ".hack/.internal"), { recursive: true }); + await writeFile( + join(project, ".hack/.internal/legacy-compose-adoption-v1"), + SOURCE_BIND_CANARY + ); + const deep = await deepDirectory(project); + + const error = await expectCodedRefusal( + selectLegacyComposeAdoptedRoot({ cwd: deep }), + "E_CONFIG_INVALID", + "values omitted" + ); + expect(String(error)).not.toContain(SOURCE_BIND_CANARY); + expect(String(error)).not.toContain(project); + + const run = await cli({ root, cwd: deep, args: ["ps"] }); + expect(run.exit).toBe(1); + expect(run.output).toContain("E_CONFIG_INVALID"); + expect(run.output).not.toContain("E_NATIVE_PROJECT_UNSUPPORTED"); + expect(run.output).not.toContain(SOURCE_BIND_CANARY); + expect(run.output).not.toContain(project); + } finally { + await rm(root, { recursive: true, force: true }); + } +}, 120_000);