From 4d07dc299e48e300830b9a2c20b6e22c1bfa68c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 23:04:15 +0000 Subject: [PATCH 1/2] 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 --- .../dogfood/test/per-file-cwd.global-setup.ts | 52 ++++++++++++ .../qa/dogfood/test/per-file-cwd.setup.ts | 81 +++++++++++++++++++ packages/qa/dogfood/vitest.config.ts | 24 ++++++ 3 files changed, 157 insertions(+) create mode 100644 packages/qa/dogfood/test/per-file-cwd.global-setup.ts create mode 100644 packages/qa/dogfood/test/per-file-cwd.setup.ts diff --git a/packages/qa/dogfood/test/per-file-cwd.global-setup.ts b/packages/qa/dogfood/test/per-file-cwd.global-setup.ts new file mode 100644 index 00000000000..eaa656093d0 --- /dev/null +++ b/packages/qa/dogfood/test/per-file-cwd.global-setup.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Run-level half of the dogfood suite's per-file working directory (#21914). +// The per-file half, and the guard, live in `./per-file-cwd.setup.ts`. Both are +// wired in `../vitest.config.ts`. +// +// At the START of a run it does two things: +// +// 1. It clears a stale `packages/qa/dogfood/.objectstack`. Such a directory is +// 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. +// +// 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. +// +// ⛔ 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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { TestProject } from 'vitest/node'; + +/** `packages/qa/dogfood`, resolved from this module's own location. */ +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; + } +} + +let runRoot: 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); +} + +export function teardown(): void { + if (runRoot) rmSync(runRoot, { 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 new file mode 100644 index 00000000000..0991eb9c55f --- /dev/null +++ b/packages/qa/dogfood/test/per-file-cwd.setup.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Every dogfood test file runs in its OWN temporary working directory (#21914). +// Wired as `setupFiles` in BOTH projects of `../vitest.config.ts`; the run-level +// half is `./per-file-cwd.global-setup.ts`. +// +// ## Why +// +// The showcase declares its external datasource with a cwd-relative file, +// `.objectstack/data/showcase_external.db`. Every showcase boot auto-connects +// it, and SQLite CREATES the file when it is missing. The showcase's `onEnable` +// also provisions its federated tables there. Before this module, every file +// that booted the showcase from `packages/qa/dogfood` left that database in the +// package directory: 92 files (measured), 7 of them with the tables populated. +// A later boot on the same runner then found or missed the federated tables +// depending on which files ran before it, so its outcome depended on shard +// composition and file order. +// +// ## 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. +// +// 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 +// run. A file that READS a package-relative path must resolve it from its +// module's location (`new URL('..', import.meta.url)`), never from +// `process.cwd()`. +// +// ## The guard +// +// `afterAll` THROWS when `packages/qa/dogfood/.objectstack/data` exists, so a +// file that writes outside its temporary cwd fails the run. The globalSetup +// clears a stale directory at the start of the run, so the guard judges only +// what this run leaves. +import { afterAll, inject } from 'vitest'; +import { existsSync, mkdtempSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** `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) { + throw new Error( + 'per-file-cwd.setup.ts: no run root 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.', + ); +} + +const previousCwd = process.cwd(); +const presentAtStart = existsSync(LEFTOVER); +process.chdir(mkdtempSync(join(runRoot, 'file-'))); + +afterAll(() => { + process.chdir(previousCwd); + if (!existsSync(LEFTOVER)) return; + let entries: string; + try { + entries = readdirSync(LEFTOVER).join(', ') || '(empty)'; + } catch (e) { + entries = `(unreadable: ${(e as Error).message})`; + } + throw new Error( + `${LEFTOVER} exists after this test file ran. Entries: ${entries}. ` + + 'Every dogfood file runs in its own temporary working directory ' + + '(test/per-file-cwd.setup.ts), so something wrote into the package directory instead: ' + + 'an absolute path built from the package root, or a process.chdir() back to it before a boot. ' + + 'Fix the writer so it writes only relative to its own working directory. ' + + 'The file named here may not be the writer: a file running at the same time on another ' + + `worker can leave it too. It was ${presentAtStart ? 'ALREADY present' : 'absent'} ` + + 'when this file started.', + ); +}); diff --git a/packages/qa/dogfood/vitest.config.ts b/packages/qa/dogfood/vitest.config.ts index 6fad20fa15f..bbef0be21e5 100644 --- a/packages/qa/dogfood/vitest.config.ts +++ b/packages/qa/dogfood/vitest.config.ts @@ -131,11 +131,30 @@ runProjectCliOverridePreflight({ // task. `shard` is typed through `TestUserConfig` because vitest declares it on // its CLI options and not on `InlineConfig`; it sits on the ROOT `test` block // (the projects below do not carry it), where vitest resolves it for all of them. +// +// #21914 -- EVERY TEST FILE RUNS IN ITS OWN TEMPORARY WORKING DIRECTORY. +// Showcase boots write `.objectstack/data/showcase_external.db` relative to the +// cwd. From the package directory that file outlived the run, and a later boot's +// federated state depended on which files ran before it. Two halves: +// - `PER_FILE_CWD` is a `setupFiles` entry named in EACH project below, because +// inline projects inherit nothing from this root block (the same measured gap +// as `disableConsoleIntercept`). It `chdir`s into a fresh directory before the +// test file's imports, restores the cwd in `afterAll`, and THROWS there when +// `.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. +// 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'; + export default defineConfig({ test: { // The file-level slice, when the dogfood gate runs one (#20820) -- see the // section above `export default` for why it is spread and typed this way. ...({ shard: process.env.OS_TEST_SHARD } satisfies Pick), + // #21914: the run-level half of the per-file working directory (section above). + globalSetup: ['./test/per-file-cwd.global-setup.ts'], projects: [ { test: { @@ -152,6 +171,9 @@ export default defineConfig({ name: 'shared-showcase', include: SHARED_SHOWCASE, isolate: false, + // #21914: PER PROJECT, like the two settings above. With `isolate: false` + // the module still runs once per file, so each file gets its own cwd. + setupFiles: [PER_FILE_CWD], }, }, { @@ -289,6 +311,8 @@ export default defineConfig({ name: 'isolated', include: ['test/**/*.test.ts'], exclude: SHARED_SHOWCASE, + // #21914: PER PROJECT, as in `shared-showcase` above. + setupFiles: [PER_FILE_CWD], }, }, ], From 967ce88af337772d0a40b7d6dfaebfac1b901207 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 23:05:47 +0000 Subject: [PATCH 2/2] test(dogfood): the multi-org probe resolves its host from the package root Under the per-file temporary cwd, probeOrganizations() defaulted its host root to that temp directory, so its skip text told the reader to declare @objectstack/organizations in a /tmp directory's package.json (measured). The module-level probe now passes this package's root, resolved from the module's own location. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-authored-by: Claude --- packages/qa/dogfood/test/enterprise-organizations.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/qa/dogfood/test/enterprise-organizations.ts b/packages/qa/dogfood/test/enterprise-organizations.ts index 379509284f0..8028ff74dcc 100644 --- a/packages/qa/dogfood/test/enterprise-organizations.ts +++ b/packages/qa/dogfood/test/enterprise-organizations.ts @@ -94,6 +94,7 @@ */ import { createHostImporter, hostImportFailureKind } from '@objectstack/types/node'; +import { fileURLToPath } from 'node:url'; /** * The multi-org package (ADR-0105 D12 as amended by ADR-0132) these gates need. @@ -208,7 +209,9 @@ export async function probeOrganizations( * time when {@link MULTI_ORG_ENV} is declared but the package is missing — the * loud half of the contract. */ -const probe = await probeOrganizations(); +// #21914: every dogfood file runs in its own temporary cwd (test/per-file-cwd.setup.ts), +// so the host app is this package, resolved from this module's location, not the cwd. +const probe = await probeOrganizations(fileURLToPath(new URL('..', import.meta.url))); export const organizationsAvailable: boolean = probe.available;