diff --git a/packages/qa/dogfood/test/per-file-cwd.global-setup.ts b/packages/qa/dogfood/test/per-file-cwd.global-setup.ts index eaa656093d..284a6f6c9e 100644 --- a/packages/qa/dogfood/test/per-file-cwd.global-setup.ts +++ b/packages/qa/dogfood/test/per-file-cwd.global-setup.ts @@ -10,22 +10,34 @@ // left by a developer's earlier run on a tree without this isolation, or by // a crashed run. The guard judges only what THIS run leaves, so an old // leftover never reds a run that wrote nothing. -// 2. It creates ONE temporary root for the run and hands it to every worker -// through `provide` / `inject`. Each test file makes its own working -// directory under that root. +// 2. It reserves a TAG for the run, `os-dogfood-run-XXXXXX`, as a directory +// `mkdtempSync` creates under the system temp directory, and hands the tag +// (a name, never a path) to every worker through `provide` / `inject`. +// Each test file makes its own working directory directly under the system +// temp directory, named `-file-XXXXXX`. // -// At the END of the run it removes that root, and with it every per-file -// directory. The removal is run-level, not per-file: on the `shared-showcase` -// project (`isolate: false`) one memoized boot serves every file on a worker, -// and its SQLite handles stay open in the directory of the file that booted it. +// At the END of the run it removes every directory whose name starts with this +// run's `-file-`, then the reservation itself. Another run's directories +// carry another tag, so a concurrent run on the same machine is never touched. +// The removal is run-level, not per-file: on the `shared-showcase` project +// (`isolate: false`) one memoized boot serves every file on a worker, and its +// SQLite handles stay open in the directory of the file that booted it. +// +// Why a tag and not a shared parent path (#21924): every `mkdtempSync` base in +// this tree must be one the tree's scratch-directory scan can read, so that an +// in-tree fixture root can never hide behind an expression +// (`scripts/pm/dispatch-gates.mjs`, "no mkdtempSync site in this tree takes a +// base the scan cannot read"). A path handed over through `inject()` is such an +// expression. `join(tmpdir(), ...)` is not: it is outside the tree by +// construction, whatever name follows it. // // ⛔ This teardown never JUDGES anything. On vitest 4.1.11 an error thrown from // a globalSetup teardown is printed as `error during close` and the run still // exits 0 (measured), so a guard placed here would be a false green. The guard // is a throwing `afterAll` in the per-file module, which fails a test file. -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, readdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { TestProject } from 'vitest/node'; @@ -34,19 +46,29 @@ const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url)); declare module 'vitest' { export interface ProvidedContext { - /** The run's temporary root; each test file makes its working directory under it. */ - dogfoodCwdRoot: string; + /** The run's tag; each test file makes its working directory as `join(tmpdir(), '-file-')`. */ + dogfoodRunTag: string; } } -let runRoot: string | undefined; +/** The prefix of every per-file directory a run tagged `tag` creates under the system temp directory. */ +export function perFileDirPrefix(tag: string): string { + return `${tag}-file-`; +} + +let reservation: string | undefined; export function setup(project: TestProject): void { rmSync(join(PACKAGE_ROOT, '.objectstack'), { recursive: true, force: true }); - runRoot = mkdtempSync(join(tmpdir(), 'os-dogfood-run-')); - project.provide('dogfoodCwdRoot', runRoot); + reservation = mkdtempSync(join(tmpdir(), 'os-dogfood-run-')); + project.provide('dogfoodRunTag', basename(reservation)); } export function teardown(): void { - if (runRoot) rmSync(runRoot, { recursive: true, force: true }); + if (!reservation) return; + const prefix = perFileDirPrefix(basename(reservation)); + for (const name of readdirSync(tmpdir())) { + if (name.startsWith(prefix)) rmSync(join(tmpdir(), name), { recursive: true, force: true }); + } + rmSync(reservation, { recursive: true, force: true }); } diff --git a/packages/qa/dogfood/test/per-file-cwd.setup.ts b/packages/qa/dogfood/test/per-file-cwd.setup.ts index 0991eb9c55..dc1d3de3d4 100644 --- a/packages/qa/dogfood/test/per-file-cwd.setup.ts +++ b/packages/qa/dogfood/test/per-file-cwd.setup.ts @@ -19,10 +19,18 @@ // ## What it does // // At module top level, which runs before the test file's own imports, it makes -// a directory under the run's temporary root and `chdir`s into it. `afterAll` -// restores the previous working directory. The directories are removed at the -// end of the run by the globalSetup, not here: the memoized `shared-showcase` -// boot keeps its SQLite handles open in the first file's directory. +// a directory directly under the system temp directory, named with the run's +// tag (`-file-XXXXXX`), and `chdir`s into it. `afterAll` restores the +// previous working directory. The directories are removed at the end of the +// run by the globalSetup, which sweeps its own tag, not here: the memoized +// `shared-showcase` boot keeps its SQLite handles open in the first file's +// directory. +// +// The base is spelled `join(tmpdir(), ...)` on purpose (#21924): the tree's +// scratch-directory scan must be able to read every `mkdtempSync` base, and a +// path received through `inject()` is one it cannot read. Only the run's TAG +// comes through `inject()`, as a name component, and it is refused below if +// it could carry a separator. // // The invariant for every dogfood author: a file runs in its own temporary // cwd, so anything cwd-relative it writes is its own and disappears with the @@ -38,26 +46,31 @@ // what this run leaves. import { afterAll, inject } from 'vitest'; import { existsSync, mkdtempSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { perFileDirPrefix } from './per-file-cwd.global-setup.js'; /** `packages/qa/dogfood`, resolved from this module's own location. */ const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url)); /** What a file must never leave in the package directory. */ const LEFTOVER = join(PACKAGE_ROOT, '.objectstack', 'data'); -const runRoot = inject('dogfoodCwdRoot'); -if (!runRoot) { +const runTag = inject('dogfoodRunTag'); +if (!runTag) { throw new Error( - 'per-file-cwd.setup.ts: no run root was provided. The globalSetup ' + + 'per-file-cwd.setup.ts: no run tag was provided. The globalSetup ' + '`test/per-file-cwd.global-setup.ts` must be wired in packages/qa/dogfood/vitest.config.ts; ' + 'without it this file would run in the package directory.', ); } +if (/[\\/]|\.\./.test(runTag)) { + throw new Error(`per-file-cwd.setup.ts: the run tag ${JSON.stringify(runTag)} is not a plain directory name.`); +} const previousCwd = process.cwd(); const presentAtStart = existsSync(LEFTOVER); -process.chdir(mkdtempSync(join(runRoot, 'file-'))); +process.chdir(mkdtempSync(join(tmpdir(), perFileDirPrefix(runTag)))); afterAll(() => { process.chdir(previousCwd); diff --git a/packages/qa/dogfood/vitest.config.ts b/packages/qa/dogfood/vitest.config.ts index bbef0be21e..841365bfeb 100644 --- a/packages/qa/dogfood/vitest.config.ts +++ b/packages/qa/dogfood/vitest.config.ts @@ -143,7 +143,7 @@ runProjectCliOverridePreflight({ // `.objectstack/data` exists in the package directory: that throw is the guard. // - The `globalSetup` below is ROOT-level: one run, one call, covering both // projects and each `OS_TEST_SHARD` slice (measured). It clears a stale -// `.objectstack` at the start and removes the run's temporary root at the end. +// `.objectstack` at the start and removes the run's per-file directories at the end. // Its teardown judges nothing, because a throw there exits 0 on vitest 4.1.11. // Both modules' headers carry the rest, including what a dogfood author owes. const PER_FILE_CWD = './test/per-file-cwd.setup.ts';