Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/qa/dogfood/test/enterprise-organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;

Expand Down
52 changes: 52 additions & 0 deletions packages/qa/dogfood/test/per-file-cwd.global-setup.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
81 changes: 81 additions & 0 deletions packages/qa/dogfood/test/per-file-cwd.setup.ts
Original file line number Diff line number Diff line change
@@ -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.',
);
});
24 changes: 24 additions & 0 deletions packages/qa/dogfood/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestUserConfig, 'shard'>),
// #21914: the run-level half of the per-file working directory (section above).
globalSetup: ['./test/per-file-cwd.global-setup.ts'],
projects: [
{
test: {
Expand All @@ -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],
},
},
{
Expand Down Expand Up @@ -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],
},
},
],
Expand Down
Loading