|
| 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