Skip to content

Commit dfb60ae

Browse files
os-litantclaude
andauthored
fix(test): point test:integration at projects that exist, and pin the agreement (#7327)
`pnpm test:integration` was `vitest run --project ui` while no project named `ui` existed, so the script could not run at all: $ pnpm exec vitest run --project ui Error: No projects matched the filter "ui". exit 1 The drift is structural, not a typo. `ui` was real once — declared in `vitest.workspace.ts` (1bdba06) as the complement of `unit`: every `*.test.{ts,tsx}` under `packages`/`apps`/`examples` except the four pure-logic packages. 85c8724 deleted that file because Vitest 4 removed `defineWorkspace` and had been silently ignoring it, which took `unit` and `ui` down together. `unit` was later re-declared inline in `vitest.config.mts` (e850c56, which never touched `package.json`), so `test:unit` started resolving again by accident; nothing ever re-declared `ui`. The modern equivalent of what `ui` covered is the two DOM tiers, so the script now reads `--project dom --project dom-heavy`. No project named `ui` is added: the stale name moves to the projects that exist rather than the config growing one to match it. `scripts/__tests__/package-scripts-vitest-projects.test.ts` pins the two halves to each other — every `--project` name a root script passes must be a project this repo declares. The assertion is a subset check, so an over-wide declared set can never fail it; each leg of the derivation therefore carries its own control asserted against a name measured on this tree, including the `@object-ui/console` project, whose name Vitest derives from that directory's `package.json` and which appears as no `name:` literal anywhere. Measured: pin red before the script change (`pnpm test:integration → --project ui`), 8/8 green after; `pnpm test:integration --shard=1/16` runs 92 files / 1054 tests green where it previously could not start. Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho Co-authored-by: Claude <noreply@anthropic.com>
1 parent c93b4d5 commit dfb60ae

2 files changed

Lines changed: 198 additions & 1 deletion

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"build:all": "turbo run build",
2323
"test": "vitest run",
2424
"test:unit": "vitest run --project unit",
25-
"test:integration": "vitest run --project ui",
25+
"test:integration": "vitest run --project dom --project dom-heavy",
2626
"test:dist": "turbo run test:dist --filter=@object-ui/components",
2727
"site:dev": "pnpm --filter @object-ui/site dev",
2828
"site:build": "pnpm --filter @object-ui/site build",
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { describe, expect, it } from 'vitest';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
6+
/**
7+
* `pnpm test:integration` was `vitest run --project ui` while no project named
8+
* `ui` existed anywhere in the config (objectui#7096).
9+
*
10+
* The drift is structural, not a typo. `ui` was a real project once: it was
11+
* declared in `vitest.workspace.ts` (1bdba0693, 2026-02-28) as the COMPLEMENT
12+
* of `unit` — every `*.test.{ts,tsx}` under `packages`/`apps`/`examples` except
13+
* the four pure-logic packages. That file was deleted in 85c872487 (2026-05-24)
14+
* because Vitest 4 removed `defineWorkspace` and had been silently ignoring it,
15+
* which took `unit` AND `ui` down together. `unit` came back as an inline
16+
* project in `vitest.config.mts` (e850c5695) and `test:unit` started resolving
17+
* again by accident — that commit never touched `package.json`. Nothing ever
18+
* re-declared `ui`, so `test:integration` was left naming a project that had
19+
* stopped existing three months earlier, and no run of it ever reported that:
20+
* a script nobody invokes is a script nobody sees fail.
21+
*
22+
* So the two halves are pinned to each other here: every `--project` name a
23+
* ROOT script passes must be a project this repo actually declares.
24+
*
25+
* Direction matters. The assertion is `script names ⊆ declared names`, so an
26+
* over-wide declared set never fails — which makes every leg of the derivation
27+
* silently vacuous unless it has its own control. Each one below is asserted
28+
* against a name measured on this tree.
29+
*/
30+
31+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
32+
const ROOT_PACKAGE_JSON = path.join(repoRoot, 'package.json');
33+
const ROOT_VITEST_CONFIG = path.join(repoRoot, 'vitest.config.mts');
34+
35+
/**
36+
* Every `--project` value in a command string, in order.
37+
*
38+
* Written here rather than reused from `scripts/vitest-invocation-guard.mjs`:
39+
* that parser keeps flags in a plain object, so a REPEATED flag collapses to
40+
* its last value — and `--project dom --project dom-heavy` (what `test:integration`
41+
* became) is exactly that shape. Reusing it would have checked `dom-heavy` and
42+
* quietly skipped `dom`.
43+
*/
44+
function projectNamesIn(command: string): string[] {
45+
const names: string[] = [];
46+
const tokens = command.split(/\s+/).filter(Boolean);
47+
48+
for (let i = 0; i < tokens.length; i += 1) {
49+
const token = tokens[i];
50+
if (token.startsWith('--project=')) {
51+
names.push(token.slice('--project='.length));
52+
continue;
53+
}
54+
if (token === '--project' && tokens[i + 1] !== undefined) {
55+
names.push(tokens[i + 1]);
56+
i += 1;
57+
}
58+
}
59+
60+
return names;
61+
}
62+
63+
/** `{ scriptName: [project, …] }` for every root script that filters on a project. */
64+
function projectFiltersInRootScripts(): Record<string, string[]> {
65+
const pkg = JSON.parse(fs.readFileSync(ROOT_PACKAGE_JSON, 'utf8')) as {
66+
scripts?: Record<string, string>;
67+
};
68+
const out: Record<string, string[]> = {};
69+
70+
for (const [scriptName, command] of Object.entries(pkg.scripts ?? {})) {
71+
const names = projectNamesIn(command);
72+
if (names.length > 0) out[scriptName] = names;
73+
}
74+
75+
return out;
76+
}
77+
78+
/**
79+
* Every project name this repo declares, from BOTH shapes the `projects` array
80+
* uses:
81+
*
82+
* - inline objects with a literal `name:` (`unit`, `dom`, `dom-heavy`, and the
83+
* env-gated `dist`) — read off the source text rather than by importing the
84+
* config, because an import answers a DIFFERENT question: `dist` only
85+
* materialises when `OBJECTUI_DIST_PINS=1`, so the imported list depends on
86+
* the environment while the declaration surface does not. Importing would
87+
* also execute that file's module scope (including its `--project dist`
88+
* argv guard, which throws) inside this test process.
89+
* - a path to another config (`./apps/console/vitest.config.ts`), whose
90+
* project name Vitest derives from that directory's `package.json` when the
91+
* config declares none. `@object-ui/console` is a usable `--project` filter
92+
* and appears as no `name:` literal anywhere, so a pin that read only the
93+
* literals would go red on a root script that legitimately named it.
94+
*/
95+
function declaredProjectNames(): Set<string> {
96+
const configText = fs.readFileSync(ROOT_VITEST_CONFIG, 'utf8');
97+
const names = new Set<string>();
98+
99+
for (const [, name] of configText.matchAll(/\bname:\s*'([^']+)'/g)) names.add(name);
100+
101+
for (const [, relative] of configText.matchAll(/__dirname,\s*'(\.\/[^']*vitest\.config\.[cm]?ts)'/g)) {
102+
const configPath = path.resolve(repoRoot, relative);
103+
if (!fs.existsSync(configPath)) continue;
104+
105+
const ownName = fs.readFileSync(configPath, 'utf8').match(/\bname:\s*'([^']+)'/);
106+
if (ownName) {
107+
names.add(ownName[1]);
108+
continue;
109+
}
110+
111+
const packageJsonPath = path.join(path.dirname(configPath), 'package.json');
112+
if (!fs.existsSync(packageJsonPath)) continue;
113+
const { name } = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { name?: string };
114+
if (name) names.add(name);
115+
}
116+
117+
return names;
118+
}
119+
120+
describe('projectNamesIn', () => {
121+
it('collects EVERY --project value, not just the last one', () => {
122+
// The regression this exists for: `test:integration` passes two.
123+
expect(projectNamesIn('vitest run --project dom --project dom-heavy')).toEqual([
124+
'dom',
125+
'dom-heavy',
126+
]);
127+
});
128+
129+
it('reads the `--project=name` form too', () => {
130+
expect(projectNamesIn('vitest run --project=unit')).toEqual(['unit']);
131+
});
132+
133+
it('finds nothing in a command that filters on no project', () => {
134+
expect(projectNamesIn('vitest run')).toEqual([]);
135+
// `--project` is not a prefix match: `--projects` is a different flag.
136+
expect(projectNamesIn('vitest run --projects foo')).toEqual([]);
137+
});
138+
});
139+
140+
describe('root package.json --project filters', () => {
141+
it('names at least the scripts this pin exists for (an extractor that found nothing would pass vacuously)', () => {
142+
const filters = projectFiltersInRootScripts();
143+
144+
expect(filters['test:unit']).toEqual(['unit']);
145+
expect(filters['test:integration']).toBeDefined();
146+
expect(filters['test:integration']!.length).toBeGreaterThan(0);
147+
});
148+
149+
it('every project a root script filters on is declared', () => {
150+
const declared = declaredProjectNames();
151+
const offenders: string[] = [];
152+
153+
for (const [scriptName, names] of Object.entries(projectFiltersInRootScripts())) {
154+
for (const name of names) {
155+
if (!declared.has(name)) {
156+
offenders.push(`pnpm ${scriptName} → --project ${name}`);
157+
}
158+
}
159+
}
160+
161+
expect(
162+
offenders,
163+
`Root scripts filter on ${offenders.length} project(s) that vitest.config.mts does not ` +
164+
`declare, so those scripts cannot run:\n ${offenders.join('\n ')}\n` +
165+
`Declared: ${[...declaredProjectNames()].sort().join(', ')}\n` +
166+
'Point the script at a project that exists — do NOT add a project to make the ' +
167+
'stale name resolve (objectui#7096).'
168+
).toEqual([]);
169+
});
170+
});
171+
172+
describe('declaredProjectNames controls', () => {
173+
it('finds the inline projects (a regex matching nothing would make the pin vacuous)', () => {
174+
const declared = declaredProjectNames();
175+
176+
expect(declared.has('unit')).toBe(true);
177+
expect(declared.has('dom')).toBe(true);
178+
expect(declared.has('dom-heavy')).toBe(true);
179+
// Declared inside the `OBJECTUI_DIST_PINS` branch — present in the source
180+
// text either way, which is what this reads.
181+
expect(declared.has('dist')).toBe(true);
182+
});
183+
184+
it('finds the project brought in by config path, whose name is nowhere a `name:` literal', () => {
185+
// Measured: `pnpm exec vitest list --project @object-ui/console` resolves.
186+
expect(declaredProjectNames().has('@object-ui/console')).toBe(true);
187+
});
188+
189+
it('does not answer yes to everything', () => {
190+
// The negative half of the control: a derivation that returned a universal
191+
// set would satisfy the subset assertion above no matter what the scripts
192+
// said. Deliberately a name nobody would ever declare, so this control
193+
// stays a control and does not quietly become policy about which project
194+
// names are allowed.
195+
expect(declaredProjectNames().has('__no_such_vitest_project__')).toBe(false);
196+
});
197+
});

0 commit comments

Comments
 (0)