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