Skip to content

Commit 4d07dc2

Browse files
committed
test(dogfood): every test file runs in its own temporary working directory
Showcase boots write .objectstack/data/showcase_external.db relative to the cwd: the declared external datasource auto-connects (SQLite creates the file) and onEnable provisions its federated tables there. From the package directory 92 files left it behind, 7 of them populated, so a later boot's federated state depended on shard composition and file order. A setupFiles module, wired in both projects, chdirs each file into its own directory under a run-level temporary root and restores the cwd in afterAll, where it throws when packages/qa/dogfood/.objectstack/data exists. A root globalSetup clears a stale .objectstack at the start and removes the run root at the end; its teardown judges nothing. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-authored-by: Claude <noreply@anthropic.com>
1 parent e6dc7a2 commit 4d07dc2

3 files changed

Lines changed: 157 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Run-level half of the dogfood suite's per-file working directory (#21914).
4+
// The per-file half, and the guard, live in `./per-file-cwd.setup.ts`. Both are
5+
// wired in `../vitest.config.ts`.
6+
//
7+
// At the START of a run it does two things:
8+
//
9+
// 1. It clears a stale `packages/qa/dogfood/.objectstack`. Such a directory is
10+
// left by a developer's earlier run on a tree without this isolation, or by
11+
// a crashed run. The guard judges only what THIS run leaves, so an old
12+
// leftover never reds a run that wrote nothing.
13+
// 2. It creates ONE temporary root for the run and hands it to every worker
14+
// through `provide` / `inject`. Each test file makes its own working
15+
// directory under that root.
16+
//
17+
// At the END of the run it removes that root, and with it every per-file
18+
// directory. The removal is run-level, not per-file: on the `shared-showcase`
19+
// project (`isolate: false`) one memoized boot serves every file on a worker,
20+
// and its SQLite handles stay open in the directory of the file that booted it.
21+
//
22+
// ⛔ This teardown never JUDGES anything. On vitest 4.1.11 an error thrown from
23+
// a globalSetup teardown is printed as `error during close` and the run still
24+
// exits 0 (measured), so a guard placed here would be a false green. The guard
25+
// is a throwing `afterAll` in the per-file module, which fails a test file.
26+
import { mkdtempSync, rmSync } from 'node:fs';
27+
import { tmpdir } from 'node:os';
28+
import { join } from 'node:path';
29+
import { fileURLToPath } from 'node:url';
30+
import type { TestProject } from 'vitest/node';
31+
32+
/** `packages/qa/dogfood`, resolved from this module's own location. */
33+
const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url));
34+
35+
declare module 'vitest' {
36+
export interface ProvidedContext {
37+
/** The run's temporary root; each test file makes its working directory under it. */
38+
dogfoodCwdRoot: string;
39+
}
40+
}
41+
42+
let runRoot: string | undefined;
43+
44+
export function setup(project: TestProject): void {
45+
rmSync(join(PACKAGE_ROOT, '.objectstack'), { recursive: true, force: true });
46+
runRoot = mkdtempSync(join(tmpdir(), 'os-dogfood-run-'));
47+
project.provide('dogfoodCwdRoot', runRoot);
48+
}
49+
50+
export function teardown(): void {
51+
if (runRoot) rmSync(runRoot, { recursive: true, force: true });
52+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Every dogfood test file runs in its OWN temporary working directory (#21914).
4+
// Wired as `setupFiles` in BOTH projects of `../vitest.config.ts`; the run-level
5+
// half is `./per-file-cwd.global-setup.ts`.
6+
//
7+
// ## Why
8+
//
9+
// The showcase declares its external datasource with a cwd-relative file,
10+
// `.objectstack/data/showcase_external.db`. Every showcase boot auto-connects
11+
// it, and SQLite CREATES the file when it is missing. The showcase's `onEnable`
12+
// also provisions its federated tables there. Before this module, every file
13+
// that booted the showcase from `packages/qa/dogfood` left that database in the
14+
// package directory: 92 files (measured), 7 of them with the tables populated.
15+
// A later boot on the same runner then found or missed the federated tables
16+
// depending on which files ran before it, so its outcome depended on shard
17+
// composition and file order.
18+
//
19+
// ## What it does
20+
//
21+
// At module top level, which runs before the test file's own imports, it makes
22+
// a directory under the run's temporary root and `chdir`s into it. `afterAll`
23+
// restores the previous working directory. The directories are removed at the
24+
// end of the run by the globalSetup, not here: the memoized `shared-showcase`
25+
// boot keeps its SQLite handles open in the first file's directory.
26+
//
27+
// The invariant for every dogfood author: a file runs in its own temporary
28+
// cwd, so anything cwd-relative it writes is its own and disappears with the
29+
// run. A file that READS a package-relative path must resolve it from its
30+
// module's location (`new URL('..', import.meta.url)`), never from
31+
// `process.cwd()`.
32+
//
33+
// ## The guard
34+
//
35+
// `afterAll` THROWS when `packages/qa/dogfood/.objectstack/data` exists, so a
36+
// file that writes outside its temporary cwd fails the run. The globalSetup
37+
// clears a stale directory at the start of the run, so the guard judges only
38+
// what this run leaves.
39+
import { afterAll, inject } from 'vitest';
40+
import { existsSync, mkdtempSync, readdirSync } from 'node:fs';
41+
import { join } from 'node:path';
42+
import { fileURLToPath } from 'node:url';
43+
44+
/** `packages/qa/dogfood`, resolved from this module's own location. */
45+
const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url));
46+
/** What a file must never leave in the package directory. */
47+
const LEFTOVER = join(PACKAGE_ROOT, '.objectstack', 'data');
48+
49+
const runRoot = inject('dogfoodCwdRoot');
50+
if (!runRoot) {
51+
throw new Error(
52+
'per-file-cwd.setup.ts: no run root was provided. The globalSetup ' +
53+
'`test/per-file-cwd.global-setup.ts` must be wired in packages/qa/dogfood/vitest.config.ts; ' +
54+
'without it this file would run in the package directory.',
55+
);
56+
}
57+
58+
const previousCwd = process.cwd();
59+
const presentAtStart = existsSync(LEFTOVER);
60+
process.chdir(mkdtempSync(join(runRoot, 'file-')));
61+
62+
afterAll(() => {
63+
process.chdir(previousCwd);
64+
if (!existsSync(LEFTOVER)) return;
65+
let entries: string;
66+
try {
67+
entries = readdirSync(LEFTOVER).join(', ') || '(empty)';
68+
} catch (e) {
69+
entries = `(unreadable: ${(e as Error).message})`;
70+
}
71+
throw new Error(
72+
`${LEFTOVER} exists after this test file ran. Entries: ${entries}. ` +
73+
'Every dogfood file runs in its own temporary working directory ' +
74+
'(test/per-file-cwd.setup.ts), so something wrote into the package directory instead: ' +
75+
'an absolute path built from the package root, or a process.chdir() back to it before a boot. ' +
76+
'Fix the writer so it writes only relative to its own working directory. ' +
77+
'The file named here may not be the writer: a file running at the same time on another ' +
78+
`worker can leave it too. It was ${presentAtStart ? 'ALREADY present' : 'absent'} ` +
79+
'when this file started.',
80+
);
81+
});

‎packages/qa/dogfood/vitest.config.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,30 @@ runProjectCliOverridePreflight({
131131
// task. `shard` is typed through `TestUserConfig` because vitest declares it on
132132
// its CLI options and not on `InlineConfig`; it sits on the ROOT `test` block
133133
// (the projects below do not carry it), where vitest resolves it for all of them.
134+
//
135+
// #21914 -- EVERY TEST FILE RUNS IN ITS OWN TEMPORARY WORKING DIRECTORY.
136+
// Showcase boots write `.objectstack/data/showcase_external.db` relative to the
137+
// cwd. From the package directory that file outlived the run, and a later boot's
138+
// federated state depended on which files ran before it. Two halves:
139+
// - `PER_FILE_CWD` is a `setupFiles` entry named in EACH project below, because
140+
// inline projects inherit nothing from this root block (the same measured gap
141+
// as `disableConsoleIntercept`). It `chdir`s into a fresh directory before the
142+
// test file's imports, restores the cwd in `afterAll`, and THROWS there when
143+
// `.objectstack/data` exists in the package directory: that throw is the guard.
144+
// - The `globalSetup` below is ROOT-level: one run, one call, covering both
145+
// projects and each `OS_TEST_SHARD` slice (measured). It clears a stale
146+
// `.objectstack` at the start and removes the run's temporary root at the end.
147+
// Its teardown judges nothing, because a throw there exits 0 on vitest 4.1.11.
148+
// Both modules' headers carry the rest, including what a dogfood author owes.
149+
const PER_FILE_CWD = './test/per-file-cwd.setup.ts';
150+
134151
export default defineConfig({
135152
test: {
136153
// The file-level slice, when the dogfood gate runs one (#20820) -- see the
137154
// section above `export default` for why it is spread and typed this way.
138155
...({ shard: process.env.OS_TEST_SHARD } satisfies Pick<TestUserConfig, 'shard'>),
156+
// #21914: the run-level half of the per-file working directory (section above).
157+
globalSetup: ['./test/per-file-cwd.global-setup.ts'],
139158
projects: [
140159
{
141160
test: {
@@ -152,6 +171,9 @@ export default defineConfig({
152171
name: 'shared-showcase',
153172
include: SHARED_SHOWCASE,
154173
isolate: false,
174+
// #21914: PER PROJECT, like the two settings above. With `isolate: false`
175+
// the module still runs once per file, so each file gets its own cwd.
176+
setupFiles: [PER_FILE_CWD],
155177
},
156178
},
157179
{
@@ -289,6 +311,8 @@ export default defineConfig({
289311
name: 'isolated',
290312
include: ['test/**/*.test.ts'],
291313
exclude: SHARED_SHOWCASE,
314+
// #21914: PER PROJECT, as in `shared-showcase` above.
315+
setupFiles: [PER_FILE_CWD],
292316
},
293317
},
294318
],

0 commit comments

Comments
 (0)