Skip to content

Commit 44813ba

Browse files
os-trumpclaude
andauthored
test(cli): split the suite into named unit and integration vitest tiers (#14536)
* test(cli): split the suite into named unit and integration vitest projects Two named tiers in packages/cli/vitest.config.ts: `unit` (the local default) and `integration` (spawns the real CLI or boots a real kernel/driver; CI-mandatory, local on demand). `vitest run` with no --project still runs every file, so CI is unchanged by construction. The tier predicate is behavioural, not the *.e2e.test.ts name: an explicit INTEGRATION_FILES list, kept equal to what each file's comment-masked source does by test/vitest-tiers-partition.test.ts, which also pins that the two projects partition every test file on disk (a file matching no project would fall out of `pnpm test` too). Both projects use `extends: true` so the resolve.alias table and server.deps.external carry into each; the console-intercept disarm is repeated per project because the root one is inert under projects. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * test(cli): route two merge-queue-flagged files to the integration tier The partition pin caught both in the merge queue, on main after this branch's base: test/build-multi-package-artifact.e2e.test.ts (new; spawns the CLI through bin/run-dev.js under tsx) and src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts (existing; now constructs a real ObjectQL engine). Both join INTEGRATION_FILES; the header's population line moves to the merged tree (230 files = 158 unit + 72 integration). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9afd9f2 commit 44813ba

2 files changed

Lines changed: 396 additions & 1 deletion

File tree

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The two tiers of this package's suite stay a PARTITION, and the integration
5+
* list stays equal to what the files DO (#13504).
6+
*
7+
* `vitest.config.ts` splits the suite into two named projects — `unit` (the
8+
* local default) and `integration` (spawns the real CLI or boots a real
9+
* kernel/driver; CI-mandatory, local on demand). Two things can rot under a
10+
* split like that, and both rot silently, which is why this pin exists:
11+
*
12+
* 1. A test file that matches NO project is not run by `vitest run` at all —
13+
* not by the fast tier AND not by `pnpm test` in CI, because with
14+
* `projects` configured the root run IS the union of the projects. A file
15+
* matching BOTH runs twice and reports twice. So the first two cases hold
16+
* `unit ⊎ integration = every test file on disk`, read from vitest's own
17+
* resolution (`vitest list --filesOnly`, with and without `--project`)
18+
* against a filesystem walk — the config's spelling is judged by what
19+
* vitest actually collects, never by re-reading the config.
20+
*
21+
* 2. `INTEGRATION_FILES` is an explicit list, and the `*.e2e.test.ts` NAME is
22+
* not the predicate (the ACCEPT on #13504 measured 18 of 220 files where
23+
* name and behaviour disagree). So the third case re-derives the tier of
24+
* every file from its comment-masked SOURCE and fails when the list and
25+
* the derivation disagree — a new spawner cannot land in the fast tier
26+
* unnoticed, and a stale entry cannot linger. The predicate, in code
27+
* position (comments masked by `scripts/js-comment-mask.mjs`):
28+
*
29+
* SPAWN = calls `runServe(` (the helper in `test/helpers/serve-process.ts`
30+
* whose body spawns the source entry), OR value-imports
31+
* `node:child_process` AND (names an entry basename — the
32+
* `run-dev` / `run` scripts under `bin/` — OR imports `CLI` /
33+
* `TSX` from that helper OR names the `tsx` binary under
34+
* `node_modules/.bin`);
35+
* KERNEL = value-imports `bootSchemaStack` from `schema-migrate`, OR
36+
* value-imports `better-sqlite3`, OR value-imports any
37+
* `@objectstack/driver-*` package, OR constructs `new ObjectQL(`.
38+
* INTEGRATION = SPAWN ∨ KERNEL.
39+
*
40+
* Value imports only: `import type { … } from '@objectstack/driver-sql'`
41+
* loads nothing, a spelling list that SAYS `'better-sqlite3'` opens no
42+
* database, and `expect(deps).toContain('better-sqlite3')` boots nothing —
43+
* every one of those was a false positive of the text-match census this
44+
* predicate replaced. An import statement is one `import … from '<spec>'`
45+
* span containing neither `;` nor another `from` (every import in this
46+
* package's tests ends in `;`, measured on 00ff228fe0).
47+
*
48+
* The fourth case classifies THIS file: it imports `node:child_process` (to
49+
* ask vitest for its file lists) and must still read as `unit`, which is the
50+
* predicate's own regression test against matching its own source.
51+
*
52+
* Runs in the `unit` tier and needs no built `dist/`: it spawns `vitest list`,
53+
* which only globs, and reads sources.
54+
*/
55+
56+
import { execFileSync } from 'node:child_process';
57+
import { readdirSync, readFileSync } from 'node:fs';
58+
import { createRequire } from 'node:module';
59+
import { dirname, join, relative, resolve } from 'node:path';
60+
import { fileURLToPath } from 'node:url';
61+
import { describe, expect, it } from 'vitest';
62+
import { maskComments } from '../../../scripts/js-comment-mask.mjs';
63+
import { childEnv } from './helpers/serve-process.js';
64+
65+
const HERE = dirname(fileURLToPath(import.meta.url));
66+
const PKG = resolve(HERE, '..');
67+
const THIS_FILE = relative(PKG, fileURLToPath(import.meta.url));
68+
const require = createRequire(import.meta.url);
69+
const VITEST_ENTRY = resolve(dirname(require.resolve('vitest/package.json')), 'vitest.mjs');
70+
71+
// ---------------------------------------------------------------------------
72+
// The predicate
73+
// ---------------------------------------------------------------------------
74+
75+
interface ValueImport {
76+
clause: string;
77+
spec: string;
78+
}
79+
80+
/** One `import … from '<spec>'` statement; `import type` is skipped. */
81+
const IMPORT_RE = /\bimport\s+(?!type\b)((?:(?!\bfrom\b)[^;])*?)\bfrom\s+['"]([^'"]+)['"]/g;
82+
83+
function valueImports(code: string): ValueImport[] {
84+
const out: ValueImport[] = [];
85+
for (const m of code.matchAll(IMPORT_RE)) out.push({ clause: m[1], spec: m[2] });
86+
return out;
87+
}
88+
89+
/** Inline `type X` specifiers do not make a value import of `X`. */
90+
function importsValue(imports: ValueImport[], spec: RegExp, name?: RegExp): boolean {
91+
return imports.some((i) => spec.test(i.spec) && (!name || name.test(i.clause.replace(/\btype\s+\w+/g, ''))));
92+
}
93+
94+
export interface TierSignals {
95+
runServe: boolean;
96+
childProcess: boolean;
97+
entryBasename: boolean;
98+
helperCliOrTsx: boolean;
99+
tsxBin: boolean;
100+
bootSchemaStack: boolean;
101+
betterSqlite3: boolean;
102+
driverPackage: boolean;
103+
objectQLCtor: boolean;
104+
}
105+
106+
export function tierSignals(maskedCode: string): TierSignals {
107+
const imports = valueImports(maskedCode);
108+
return {
109+
runServe: /\brunServe\s*[(]/.test(maskedCode),
110+
childProcess: importsValue(imports, /^(?:node:)?child_process$/),
111+
entryBasename: /\brun(?:-dev)?[.]js\b/.test(maskedCode),
112+
helperCliOrTsx: importsValue(imports, /helpers\/serve-process(?:\.js)?$/, /\b(?:CLI|TSX)\b/),
113+
tsxBin: /[.]bin[/]tsx\b/.test(maskedCode),
114+
bootSchemaStack: importsValue(imports, /schema-migrate(?:\.js)?$/, /\bbootSchemaStack\b/),
115+
betterSqlite3: importsValue(imports, /^better-sqlite3$/) || /require[(]\s*['"]better-sqlite3['"]\s*[)]/.test(maskedCode),
116+
driverPackage: importsValue(imports, /^@objectstack\/driver-/),
117+
objectQLCtor: /new\s+ObjectQL\s*[(]/.test(maskedCode),
118+
};
119+
}
120+
121+
export function isIntegration(s: TierSignals): boolean {
122+
const spawn = s.runServe || (s.childProcess && (s.entryBasename || s.helperCliOrTsx || s.tsxBin));
123+
const kernel = s.bootSchemaStack || s.betterSqlite3 || s.driverPackage || s.objectQLCtor;
124+
return spawn || kernel;
125+
}
126+
127+
function firedSignals(s: TierSignals): string {
128+
return (Object.keys(s) as Array<keyof TierSignals>).filter((k) => s[k]).join(', ') || 'none';
129+
}
130+
131+
// ---------------------------------------------------------------------------
132+
// The two readings: the filesystem, and vitest's own resolution
133+
// ---------------------------------------------------------------------------
134+
135+
const TEST_FILE_RE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
136+
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', 'coverage']);
137+
138+
function walk(dir: string, out: string[] = []): string[] {
139+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
140+
if (SKIP_DIRS.has(entry.name)) continue;
141+
const abs = join(dir, entry.name);
142+
if (entry.isDirectory()) walk(abs, out);
143+
else if (TEST_FILE_RE.test(entry.name)) out.push(relative(PKG, abs));
144+
}
145+
return out;
146+
}
147+
148+
/** `vitest list --filesOnly [--project NAME]`, one relative path per line. */
149+
function vitestFiles(project?: string): string[] {
150+
const args = [VITEST_ENTRY, 'list', '--filesOnly', ...(project ? ['--project', project] : [])];
151+
const out = execFileSync(process.execPath, args, {
152+
cwd: PKG,
153+
env: childEnv(),
154+
encoding: 'utf8',
155+
stdio: ['ignore', 'pipe', 'pipe'],
156+
});
157+
return out
158+
.split('\n')
159+
.map((line) => line.trim())
160+
.filter(Boolean)
161+
.map((line) => line.replace(/^\[[^\]]+\]\s+/, ''));
162+
}
163+
164+
/** The explicit list as `vitest.config.ts` declares it — read as text, not imported. */
165+
function declaredIntegrationFiles(): string[] {
166+
const masked = maskComments(readFileSync(join(PKG, 'vitest.config.ts'), 'utf8'));
167+
const block = /INTEGRATION_FILES\s*=\s*\[([^\]]*)\]/.exec(masked);
168+
if (!block) throw new Error('vitest.config.ts no longer declares `INTEGRATION_FILES = [ … ]`');
169+
return Array.from(block[1].matchAll(/['"]([^'"]+)['"]/g), (m) => m[1]);
170+
}
171+
172+
const sorted = (xs: Iterable<string>): string[] => [...xs].sort();
173+
174+
describe('the two tiers of packages/cli (#13504)', () => {
175+
const onDisk = sorted(walk(PKG));
176+
const all = sorted(vitestFiles());
177+
const unit = sorted(vitestFiles('unit'));
178+
const integration = sorted(vitestFiles('integration'));
179+
180+
it('every test file on disk is one vitest collects with no --project (what `pnpm test` runs)', () => {
181+
expect(onDisk.length).toBeGreaterThan(100);
182+
expect(all, 'vitest run collects a different population than the filesystem holds').toEqual(onDisk);
183+
});
184+
185+
it('unit and integration partition that population — no file in both, none in neither', () => {
186+
const inBoth = unit.filter((f) => integration.includes(f));
187+
expect(inBoth, 'files matched by BOTH projects (they would run and report twice)').toEqual([]);
188+
const union = sorted([...unit, ...integration]);
189+
expect(union, 'files matched by NEITHER project fall out of every tier, including CI').toEqual(all);
190+
});
191+
192+
it('INTEGRATION_FILES equals the behavioural predicate over every file on disk', () => {
193+
const declared = declaredIntegrationFiles();
194+
expect(sorted(new Set(declared)), 'INTEGRATION_FILES carries a duplicate').toEqual(sorted(declared));
195+
expect(sorted(declared), 'an INTEGRATION_FILES entry names no file vitest can find').toEqual(integration);
196+
197+
const missing: string[] = [];
198+
const stale: string[] = [];
199+
for (const file of onDisk) {
200+
const signals = tierSignals(maskComments(readFileSync(join(PKG, file), 'utf8')));
201+
const predicted = isIntegration(signals);
202+
const listed = integration.includes(file);
203+
if (predicted && !listed) missing.push(`${file} [${firedSignals(signals)}]`);
204+
if (!predicted && listed) stale.push(file);
205+
}
206+
expect(
207+
missing,
208+
'files that spawn the CLI or boot a kernel/driver but are NOT in INTEGRATION_FILES (add them)',
209+
).toEqual([]);
210+
expect(stale, 'INTEGRATION_FILES entries that neither spawn nor boot (remove them)').toEqual([]);
211+
});
212+
213+
it('this pin is itself unit-tier: importing child_process to ask vitest is not spawning the CLI', () => {
214+
const signals = tierSignals(maskComments(readFileSync(join(PKG, THIS_FILE), 'utf8')));
215+
expect(signals.childProcess).toBe(true);
216+
expect(isIntegration(signals), `fired: ${firedSignals(signals)}`).toBe(false);
217+
expect(unit).toContain(THIS_FILE);
218+
});
219+
});

0 commit comments

Comments
 (0)