diff --git a/.changeset/vitest-invocation-guard-package-cwd-coverage-5406.md b/.changeset/vitest-invocation-guard-package-cwd-coverage-5406.md new file mode 100644 index 0000000000..61e50a177f --- /dev/null +++ b/.changeset/vitest-invocation-guard-package-cwd-coverage-5406.md @@ -0,0 +1,31 @@ +--- +--- + +Test tooling only — this publishes nothing, declared explicitly with an empty frontmatter +rather than left undeclared. + +`assertCanonicalVitestInvocation` refuses a package-cwd Vitest run because such a run uses +a different config than CI does, so its green says nothing about CI. Its docstring claimed +that "every per-package `vitest.config.ts` re-exports the root config, and a package +without one resolves upward to it, so no package-level path skips this file". Measured, by +running `pnpm exec vitest run` from every directory that carries a config: 8 package +configs import the root config and were refused, 2 packages carry no config and resolve +upward and were refused, and **11 standalone configs never mention the root file at all** +(`plugin-calendar`, `-charts`, `-detail`, `-form`, `-gantt`, `-grid`, `-kanban`, `-list`, +`-map`, `-timeline`, `-view`) — nothing imported the guard from there, so it never ran. +From `packages/plugin-grid`, one such run printed `Test Files 1 passed (1)` / +`Tests 5 passed (5)` and exited 0, under a config carrying no `@object-ui/*` alias table +at all where the root config maps ~40 specifiers at sibling `src/`. The guard's hole sat +exactly where the divergence — and therefore the false-green risk — was largest. + +Those 11 configs now call the guard themselves, through a new +`repoRootFrom(import.meta.url)` landmark search rather than a hand-counted `../..` (which +resolves to a real directory when the count is wrong, so the guard would keep issuing +verdicts computed against the wrong root). The docstring and the root config's call-site +comment now describe the three routes a config can take instead of asserting one of them, +and the claim is enforced rather than restated: the guard's own test walks every +`vitest.config.*` in the repo and fails on any that takes neither route. + +No package `src/` is touched and the configs' test semantics are unchanged, so no +`@object-ui/*` package changes behaviour and there is nothing here for a consumer to +upgrade to. diff --git a/AGENTS.md b/AGENTS.md index b4c94a9dd4..6c8700c71a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,9 +186,18 @@ AGENTS.md 的「只跑受影响的包」指的是**用上面的路径过滤缩 pnpm 把 `--` **原样**转发进脚本,vitest 的 CLI 解析在 `--` 处停止,后面的一切(包括你的路径) 在 vitest 看到之前就没了 —— 不是「被忽略并警告」,是压根不存在。于是退回默认集合(叠加陷阱一 就是别人的包),新加的测试文件零执行、输出全绿。 -- **两条现在都会直接失败**,由 `scripts/vitest-invocation-guard.mjs` 在 `vitest.config.mts` 顶部 - 拦下:vitest root 不是仓根 → 拒绝;`--` 后面还有参数 → 拒绝。报错正文会指出机制并给出上面的 - 正确命令。包级 `test` 脚本的存废是 objectui#3240;在那之前它们只失败,不撒谎。 +- **两条现在都会直接失败**,由 `scripts/vitest-invocation-guard.mjs` 拦下:vitest root 不是仓根 + → 拒绝;`--` 后面还有参数 → 拒绝。报错正文会指出机制并给出上面的正确命令。包级 `test` 脚本的 + 存废是 objectui#3240;在那之前它们只失败,不撒谎。 + - **拦截点不止 `vitest.config.mts` 一处**(objectui#5406)。vitest 只加载「启动目录里的那份」 + config,所以根 config 顶部那一次调用,只覆盖得到「本包没有 config(向上找到根 config)」或 + 「本包 config import 了根 config(import 即执行其模块作用域)」这两条路。11 个**独立**的 + `packages/plugin-*/vitest.config.ts` 两条都不占——它们自带 `happy-dom` + `globals` + 本地 + setup 且**完全没有 alias 表**,于是从包目录跑就用上了一份 CI 从不使用的 config,而 guard + 根本没被 import。实测:`cd packages/plugin-grid && pnpm exec vitest run + src/__tests__/ObjectGrid.exportOptionsKeys.test.ts` 曾经报 `Test Files 1 passed (1)` / + `Tests 5 passed (5)` 并以 0 退出。这 11 份现在各自调用 guard;新增任何一份 `vitest.config.*` + 若两条路都不占,`scripts/__tests__/vitest-invocation-guard.test.ts` 会红。 - **路径过滤零匹配也不再是绿的**:一旦命令行点名了文件,`passWithNoTests` 自动关闭 —— 写错的路径 / 相对错目录的路径 → 非零退出,而不是「跑了 0 个文件然后绿」。 - 确需从包目录启动,把 root 显式指回仓根:`pnpm exec vitest run --root ../.. packages//`。 diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md index 5ad4aa4ce7..45a0668278 100644 --- a/QUICK_REFERENCE.md +++ b/QUICK_REFERENCE.md @@ -36,8 +36,11 @@ Not `pnpm --filter test`, not `turbo run test`, not `cd packages/x && pnpm vitest`, and never a path behind `--`. Each of those moved vitest's root into a package, where the root `unit`/`dom`/`dom-heavy` projects match nothing and only `apps/console` resolves: 22 foreign files passed, your package never ran, output green -(objectui#3378/#3288). A guard in `vitest.config.mts` now **exits non-zero** on all of -them and prints the correct invocation: +(objectui#3378/#3288). A guard now **exits non-zero** on all of them and prints the +correct invocation. It is wired into `vitest.config.mts` *and* into each of the 11 +standalone per-package configs (`packages/plugin-grid/vitest.config.ts` and its ten +siblings), because Vitest loads the config in the directory it was launched from — the +root call alone left those 11 uncovered (objectui#5406): ``` vitest 调用被拒绝:从包目录跑 vitest 会静默跑错测试集 (objectui#3378) diff --git a/packages/plugin-calendar/vitest.config.ts b/packages/plugin-calendar/vitest.config.ts index 9da57d02cb..b8c4c93f40 100644 --- a/packages/plugin-calendar/vitest.config.ts +++ b/packages/plugin-calendar/vitest.config.ts @@ -3,6 +3,28 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; +// objectui#5406 — this config is STANDALONE: it does not import the root +// `vitest.config.mts`, so nothing routed a package-cwd run here through the +// invocation guard, the way a root-importing config does as a side effect of +// the import. Without the call below, `cd` into this package and `pnpm exec +// vitest run` executes the suite under a config CI never uses — no +// `@object-ui/*` alias table, a different setup file, no project split — so +// its green says nothing about CI. (Measured on the sibling `plugin-grid`, +// whose config was identical to this one: one such run printed +// `Test Files 1 passed (1)` / `Tests 5 passed (5)` and exited 0.) +// +// Deleting this reopens the hole for this package alone, silently — which is +// why it is enforced rather than written down: +// `scripts/__tests__/vitest-invocation-guard.test.ts` walks every +// `vitest.config.*` in the repo and fails on any that neither imports the root +// config nor calls the guard. +import { + assertCanonicalVitestInvocation, + repoRootFrom, +} from '../../scripts/vitest-invocation-guard.mjs'; + +assertCanonicalVitestInvocation({ repoRoot: repoRootFrom(import.meta.url) }); + export default defineConfig({ plugins: [react()], test: { diff --git a/packages/plugin-charts/vitest.config.ts b/packages/plugin-charts/vitest.config.ts index 9da57d02cb..b8c4c93f40 100644 --- a/packages/plugin-charts/vitest.config.ts +++ b/packages/plugin-charts/vitest.config.ts @@ -3,6 +3,28 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; +// objectui#5406 — this config is STANDALONE: it does not import the root +// `vitest.config.mts`, so nothing routed a package-cwd run here through the +// invocation guard, the way a root-importing config does as a side effect of +// the import. Without the call below, `cd` into this package and `pnpm exec +// vitest run` executes the suite under a config CI never uses — no +// `@object-ui/*` alias table, a different setup file, no project split — so +// its green says nothing about CI. (Measured on the sibling `plugin-grid`, +// whose config was identical to this one: one such run printed +// `Test Files 1 passed (1)` / `Tests 5 passed (5)` and exited 0.) +// +// Deleting this reopens the hole for this package alone, silently — which is +// why it is enforced rather than written down: +// `scripts/__tests__/vitest-invocation-guard.test.ts` walks every +// `vitest.config.*` in the repo and fails on any that neither imports the root +// config nor calls the guard. +import { + assertCanonicalVitestInvocation, + repoRootFrom, +} from '../../scripts/vitest-invocation-guard.mjs'; + +assertCanonicalVitestInvocation({ repoRoot: repoRootFrom(import.meta.url) }); + export default defineConfig({ plugins: [react()], test: { diff --git a/packages/plugin-detail/vitest.config.ts b/packages/plugin-detail/vitest.config.ts index 9da57d02cb..b8c4c93f40 100644 --- a/packages/plugin-detail/vitest.config.ts +++ b/packages/plugin-detail/vitest.config.ts @@ -3,6 +3,28 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; +// objectui#5406 — this config is STANDALONE: it does not import the root +// `vitest.config.mts`, so nothing routed a package-cwd run here through the +// invocation guard, the way a root-importing config does as a side effect of +// the import. Without the call below, `cd` into this package and `pnpm exec +// vitest run` executes the suite under a config CI never uses — no +// `@object-ui/*` alias table, a different setup file, no project split — so +// its green says nothing about CI. (Measured on the sibling `plugin-grid`, +// whose config was identical to this one: one such run printed +// `Test Files 1 passed (1)` / `Tests 5 passed (5)` and exited 0.) +// +// Deleting this reopens the hole for this package alone, silently — which is +// why it is enforced rather than written down: +// `scripts/__tests__/vitest-invocation-guard.test.ts` walks every +// `vitest.config.*` in the repo and fails on any that neither imports the root +// config nor calls the guard. +import { + assertCanonicalVitestInvocation, + repoRootFrom, +} from '../../scripts/vitest-invocation-guard.mjs'; + +assertCanonicalVitestInvocation({ repoRoot: repoRootFrom(import.meta.url) }); + export default defineConfig({ plugins: [react()], test: { diff --git a/packages/plugin-form/vitest.config.ts b/packages/plugin-form/vitest.config.ts index 622c8a27de..28840604c4 100644 --- a/packages/plugin-form/vitest.config.ts +++ b/packages/plugin-form/vitest.config.ts @@ -2,6 +2,28 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +// objectui#5406 — this config is STANDALONE: it does not import the root +// `vitest.config.mts`, so nothing routed a package-cwd run here through the +// invocation guard, the way a root-importing config does as a side effect of +// the import. Without the call below, `cd` into this package and `pnpm exec +// vitest run` executes the suite under a config CI never uses — no +// `@object-ui/*` alias table, a different setup file, no project split — so +// its green says nothing about CI. (Measured on the sibling `plugin-grid`, +// whose config was identical to this one: one such run printed +// `Test Files 1 passed (1)` / `Tests 5 passed (5)` and exited 0.) +// +// Deleting this reopens the hole for this package alone, silently — which is +// why it is enforced rather than written down: +// `scripts/__tests__/vitest-invocation-guard.test.ts` walks every +// `vitest.config.*` in the repo and fails on any that neither imports the root +// config nor calls the guard. +import { + assertCanonicalVitestInvocation, + repoRootFrom, +} from '../../scripts/vitest-invocation-guard.mjs'; + +assertCanonicalVitestInvocation({ repoRoot: repoRootFrom(import.meta.url) }); + export default defineConfig({ plugins: [react()], test: { diff --git a/packages/plugin-gantt/vitest.config.ts b/packages/plugin-gantt/vitest.config.ts index 9da57d02cb..b8c4c93f40 100644 --- a/packages/plugin-gantt/vitest.config.ts +++ b/packages/plugin-gantt/vitest.config.ts @@ -3,6 +3,28 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; +// objectui#5406 — this config is STANDALONE: it does not import the root +// `vitest.config.mts`, so nothing routed a package-cwd run here through the +// invocation guard, the way a root-importing config does as a side effect of +// the import. Without the call below, `cd` into this package and `pnpm exec +// vitest run` executes the suite under a config CI never uses — no +// `@object-ui/*` alias table, a different setup file, no project split — so +// its green says nothing about CI. (Measured on the sibling `plugin-grid`, +// whose config was identical to this one: one such run printed +// `Test Files 1 passed (1)` / `Tests 5 passed (5)` and exited 0.) +// +// Deleting this reopens the hole for this package alone, silently — which is +// why it is enforced rather than written down: +// `scripts/__tests__/vitest-invocation-guard.test.ts` walks every +// `vitest.config.*` in the repo and fails on any that neither imports the root +// config nor calls the guard. +import { + assertCanonicalVitestInvocation, + repoRootFrom, +} from '../../scripts/vitest-invocation-guard.mjs'; + +assertCanonicalVitestInvocation({ repoRoot: repoRootFrom(import.meta.url) }); + export default defineConfig({ plugins: [react()], test: { diff --git a/packages/plugin-grid/vitest.config.ts b/packages/plugin-grid/vitest.config.ts index 9da57d02cb..b8c4c93f40 100644 --- a/packages/plugin-grid/vitest.config.ts +++ b/packages/plugin-grid/vitest.config.ts @@ -3,6 +3,28 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; +// objectui#5406 — this config is STANDALONE: it does not import the root +// `vitest.config.mts`, so nothing routed a package-cwd run here through the +// invocation guard, the way a root-importing config does as a side effect of +// the import. Without the call below, `cd` into this package and `pnpm exec +// vitest run` executes the suite under a config CI never uses — no +// `@object-ui/*` alias table, a different setup file, no project split — so +// its green says nothing about CI. (Measured on the sibling `plugin-grid`, +// whose config was identical to this one: one such run printed +// `Test Files 1 passed (1)` / `Tests 5 passed (5)` and exited 0.) +// +// Deleting this reopens the hole for this package alone, silently — which is +// why it is enforced rather than written down: +// `scripts/__tests__/vitest-invocation-guard.test.ts` walks every +// `vitest.config.*` in the repo and fails on any that neither imports the root +// config nor calls the guard. +import { + assertCanonicalVitestInvocation, + repoRootFrom, +} from '../../scripts/vitest-invocation-guard.mjs'; + +assertCanonicalVitestInvocation({ repoRoot: repoRootFrom(import.meta.url) }); + export default defineConfig({ plugins: [react()], test: { diff --git a/packages/plugin-kanban/vitest.config.ts b/packages/plugin-kanban/vitest.config.ts index 622c8a27de..28840604c4 100644 --- a/packages/plugin-kanban/vitest.config.ts +++ b/packages/plugin-kanban/vitest.config.ts @@ -2,6 +2,28 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +// objectui#5406 — this config is STANDALONE: it does not import the root +// `vitest.config.mts`, so nothing routed a package-cwd run here through the +// invocation guard, the way a root-importing config does as a side effect of +// the import. Without the call below, `cd` into this package and `pnpm exec +// vitest run` executes the suite under a config CI never uses — no +// `@object-ui/*` alias table, a different setup file, no project split — so +// its green says nothing about CI. (Measured on the sibling `plugin-grid`, +// whose config was identical to this one: one such run printed +// `Test Files 1 passed (1)` / `Tests 5 passed (5)` and exited 0.) +// +// Deleting this reopens the hole for this package alone, silently — which is +// why it is enforced rather than written down: +// `scripts/__tests__/vitest-invocation-guard.test.ts` walks every +// `vitest.config.*` in the repo and fails on any that neither imports the root +// config nor calls the guard. +import { + assertCanonicalVitestInvocation, + repoRootFrom, +} from '../../scripts/vitest-invocation-guard.mjs'; + +assertCanonicalVitestInvocation({ repoRoot: repoRootFrom(import.meta.url) }); + export default defineConfig({ plugins: [react()], test: { diff --git a/packages/plugin-list/vitest.config.ts b/packages/plugin-list/vitest.config.ts index 622c8a27de..28840604c4 100644 --- a/packages/plugin-list/vitest.config.ts +++ b/packages/plugin-list/vitest.config.ts @@ -2,6 +2,28 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +// objectui#5406 — this config is STANDALONE: it does not import the root +// `vitest.config.mts`, so nothing routed a package-cwd run here through the +// invocation guard, the way a root-importing config does as a side effect of +// the import. Without the call below, `cd` into this package and `pnpm exec +// vitest run` executes the suite under a config CI never uses — no +// `@object-ui/*` alias table, a different setup file, no project split — so +// its green says nothing about CI. (Measured on the sibling `plugin-grid`, +// whose config was identical to this one: one such run printed +// `Test Files 1 passed (1)` / `Tests 5 passed (5)` and exited 0.) +// +// Deleting this reopens the hole for this package alone, silently — which is +// why it is enforced rather than written down: +// `scripts/__tests__/vitest-invocation-guard.test.ts` walks every +// `vitest.config.*` in the repo and fails on any that neither imports the root +// config nor calls the guard. +import { + assertCanonicalVitestInvocation, + repoRootFrom, +} from '../../scripts/vitest-invocation-guard.mjs'; + +assertCanonicalVitestInvocation({ repoRoot: repoRootFrom(import.meta.url) }); + export default defineConfig({ plugins: [react()], test: { diff --git a/packages/plugin-map/vitest.config.ts b/packages/plugin-map/vitest.config.ts index 9da57d02cb..b8c4c93f40 100644 --- a/packages/plugin-map/vitest.config.ts +++ b/packages/plugin-map/vitest.config.ts @@ -3,6 +3,28 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; +// objectui#5406 — this config is STANDALONE: it does not import the root +// `vitest.config.mts`, so nothing routed a package-cwd run here through the +// invocation guard, the way a root-importing config does as a side effect of +// the import. Without the call below, `cd` into this package and `pnpm exec +// vitest run` executes the suite under a config CI never uses — no +// `@object-ui/*` alias table, a different setup file, no project split — so +// its green says nothing about CI. (Measured on the sibling `plugin-grid`, +// whose config was identical to this one: one such run printed +// `Test Files 1 passed (1)` / `Tests 5 passed (5)` and exited 0.) +// +// Deleting this reopens the hole for this package alone, silently — which is +// why it is enforced rather than written down: +// `scripts/__tests__/vitest-invocation-guard.test.ts` walks every +// `vitest.config.*` in the repo and fails on any that neither imports the root +// config nor calls the guard. +import { + assertCanonicalVitestInvocation, + repoRootFrom, +} from '../../scripts/vitest-invocation-guard.mjs'; + +assertCanonicalVitestInvocation({ repoRoot: repoRootFrom(import.meta.url) }); + export default defineConfig({ plugins: [react()], test: { diff --git a/packages/plugin-timeline/vitest.config.ts b/packages/plugin-timeline/vitest.config.ts index 9da57d02cb..b8c4c93f40 100644 --- a/packages/plugin-timeline/vitest.config.ts +++ b/packages/plugin-timeline/vitest.config.ts @@ -3,6 +3,28 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; +// objectui#5406 — this config is STANDALONE: it does not import the root +// `vitest.config.mts`, so nothing routed a package-cwd run here through the +// invocation guard, the way a root-importing config does as a side effect of +// the import. Without the call below, `cd` into this package and `pnpm exec +// vitest run` executes the suite under a config CI never uses — no +// `@object-ui/*` alias table, a different setup file, no project split — so +// its green says nothing about CI. (Measured on the sibling `plugin-grid`, +// whose config was identical to this one: one such run printed +// `Test Files 1 passed (1)` / `Tests 5 passed (5)` and exited 0.) +// +// Deleting this reopens the hole for this package alone, silently — which is +// why it is enforced rather than written down: +// `scripts/__tests__/vitest-invocation-guard.test.ts` walks every +// `vitest.config.*` in the repo and fails on any that neither imports the root +// config nor calls the guard. +import { + assertCanonicalVitestInvocation, + repoRootFrom, +} from '../../scripts/vitest-invocation-guard.mjs'; + +assertCanonicalVitestInvocation({ repoRoot: repoRootFrom(import.meta.url) }); + export default defineConfig({ plugins: [react()], test: { diff --git a/packages/plugin-view/vitest.config.ts b/packages/plugin-view/vitest.config.ts index 622c8a27de..28840604c4 100644 --- a/packages/plugin-view/vitest.config.ts +++ b/packages/plugin-view/vitest.config.ts @@ -2,6 +2,28 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +// objectui#5406 — this config is STANDALONE: it does not import the root +// `vitest.config.mts`, so nothing routed a package-cwd run here through the +// invocation guard, the way a root-importing config does as a side effect of +// the import. Without the call below, `cd` into this package and `pnpm exec +// vitest run` executes the suite under a config CI never uses — no +// `@object-ui/*` alias table, a different setup file, no project split — so +// its green says nothing about CI. (Measured on the sibling `plugin-grid`, +// whose config was identical to this one: one such run printed +// `Test Files 1 passed (1)` / `Tests 5 passed (5)` and exited 0.) +// +// Deleting this reopens the hole for this package alone, silently — which is +// why it is enforced rather than written down: +// `scripts/__tests__/vitest-invocation-guard.test.ts` walks every +// `vitest.config.*` in the repo and fails on any that neither imports the root +// config nor calls the guard. +import { + assertCanonicalVitestInvocation, + repoRootFrom, +} from '../../scripts/vitest-invocation-guard.mjs'; + +assertCanonicalVitestInvocation({ repoRoot: repoRootFrom(import.meta.url) }); + export default defineConfig({ plugins: [react()], test: { diff --git a/scripts/__tests__/vitest-invocation-guard.test.ts b/scripts/__tests__/vitest-invocation-guard.test.ts index bf299ffa23..f2f7043c81 100644 --- a/scripts/__tests__/vitest-invocation-guard.test.ts +++ b/scripts/__tests__/vitest-invocation-guard.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; // Plain-JS CI helper. Its types are INFERRED from the .mjs source by // `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here — @@ -12,6 +12,7 @@ import { cliHasTestFilters, evaluateVitestInvocation, parseVitestArgv, + repoRootFrom, } from '../vitest-invocation-guard.mjs'; /** @@ -30,8 +31,9 @@ import { * discarded before Vitest ever sees a path. * * Neither warned, and neither counted zero: both summaries said 22. These tests - * pin the guard that now refuses them, and the wiring in `vitest.config.mts` - * that makes it unskippable. + * pin the guard that now refuses them, and the wiring that makes it + * unskippable — which is the root config PLUS every package config that does + * not lead back to it (objectui#5406; the last two describes below). */ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -224,3 +226,195 @@ describe('the root config actually wires the guard', () => { expect(config).not.toContain('passWithNoTests: true'); }); }); + +/** + * objectui#5406 — wiring the ROOT config is not the same as wiring the repo. + * + * Vitest loads the config it finds in the directory it was launched from. The + * call in `vitest.config.mts` therefore only reaches a package-cwd run when + * that package's config resolution ends at the root file, and the guard's own + * docstring used to assert that it always does ("Every per-package + * `vitest.config.ts` re-exports the root config"). Measured on the tree that + * shipped that sentence, by running `pnpm exec vitest run` from each directory + * carrying a config: + * + * REFUSED apps/console, examples/schema-catalog, packages/components, + * packages/core, packages/fields, packages/plugin-dashboard, + * packages/react, packages/types (8 — they import the root config) + * REFUSED packages/app-shell, packages/mobile (no config; the lookup walks up) + * ACCEPTED packages/plugin-{calendar,charts,detail,form,gantt,grid,kanban, + * list,map,timeline,view} (11 — standalone configs) + * + * The accepted set is not a technicality. Those 11 configs declare `happy-dom` + * + `globals` + a local `vitest.setup.ts` and NO alias table, where the root + * config maps ~40 `@object-ui/*` specifiers at a sibling package's `src/`. So a + * run there both collects the package's own files AND resolves them + * differently from CI — the false green this guard exists to refuse, arriving + * through the door the guard was documented to have locked. Measured: + * + * cd packages/plugin-grid + * pnpm exec vitest run src/__tests__/ObjectGrid.exportOptionsKeys.test.ts + * => RUN v4.1.10 /…/packages/plugin-grid + * Test Files 1 passed (1) + * Tests 5 passed (5) # exit 0, no guard output at all + * + * The 11 now call the guard themselves. This block is what stops number 12 from + * arriving the same way: the coverage claim is checked against the tree instead + * of being restated in a comment. Same defect class as objectui#3944/#3904 — + * configuration that declares a property nothing enforces. + */ +describe('objectui#5406 — every vitest config in the repo routes through the guard', () => { + /** Directories that never hold a config we control (or hold copies of other branches). */ + const SKIP_DIRS = new Set([ + 'node_modules', + 'dist', + 'build', + 'coverage', + '.git', + '.turbo', + '.next', + '.playwright-mcp', + 'playwright-report', + 'test-results', + 'storybook-static', + ]); + + /** `vitest.config.ts` / `.mts` / `.js` / … — every spelling Vitest will load. */ + const CONFIG_NAME = /^vitest\.config\.(c|m)?[jt]s$/; + + function findConfigs(dir: string, out: string[] = []): string[] { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + // `.wt-*` are in-repo worktrees of OTHER branches (the root config + // excludes them from every project for the same reason). + if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.wt-')) continue; + findConfigs(path.join(dir, entry.name), out); + } else if (entry.isFile() && CONFIG_NAME.test(entry.name)) { + out.push(path.relative(repoRoot, path.join(dir, entry.name)).split(path.sep).join('/')); + } + } + return out; + } + + const configs = findConfigs(repoRoot); + const rootConfig = 'vitest.config.mts'; + const packageConfigs = configs.filter((c) => c !== rootConfig); + + const read = (rel: string) => fs.readFileSync(path.join(repoRoot, rel), 'utf8'); + + // Match the IMPORT SPECIFIER, not a mention of the filename. A bare + // `text.includes('vitest.config.mts')` was the first spelling here and it was + // wrong in the direction that matters: the eleven standalone configs name the + // root file in the comment explaining why they do NOT import it, so every one + // of them classified as route 2 and the "no config skips the guard" case went + // vacuously green over exactly the configs it exists to catch. Pinned below. + const specifier = (module: string) => + new RegExp(String.raw`(?:from|import|require)\s*\(?\s*['"][^'"]*` + module + String.raw`['"]`); + const ROOT_CONFIG_IMPORT = specifier(String.raw`vitest\.config\.mts`); + const GUARD_IMPORT = specifier(String.raw`vitest-invocation-guard\.mjs`); + + /** Route 2: importing the root config runs its module scope, guard included. */ + const importsRootConfig = (rel: string) => ROOT_CONFIG_IMPORT.test(read(rel)); + /** Route 3: the config imports the guard and calls it itself. */ + const callsGuard = (rel: string) => { + const text = read(rel); + return GUARD_IMPORT.test(text) && text.includes('assertCanonicalVitestInvocation('); + }; + + it('finds the configs — the walk is not silently empty', () => { + // A guard whose enumeration breaks reports success over nothing. Pin that + // the walk reaches the root file, reaches into subdirectories, and returns + // a count in the right order of magnitude (19 at the time of writing). + expect(configs).toContain(rootConfig); + expect(packageConfigs.length).toBeGreaterThanOrEqual(12); + expect(packageConfigs.some((c) => c.startsWith('packages/'))).toBe(true); + expect(packageConfigs.some((c) => c.startsWith('apps/'))).toBe(true); + }); + + it('leaves no config able to skip the guard', () => { + const unguarded = packageConfigs.filter((c) => !importsRootConfig(c) && !callsGuard(c)); + + expect( + unguarded, + `These vitest configs neither import ${rootConfig} nor call the guard, so a run launched ` + + 'from their directory skips it entirely and can go green under a config CI never uses ' + + '(objectui#5406). Add to the top of each:\n\n' + + " import {\n assertCanonicalVitestInvocation,\n repoRootFrom,\n } from '../../scripts/vitest-invocation-guard.mjs';\n\n" + + ' assertCanonicalVitestInvocation({ repoRoot: repoRootFrom(import.meta.url) });\n\n' + + 'Do NOT add an exemption here — an exemption is how this guard got its hole.' + ).toEqual([]); + }); + + it('still has both routes represented, so neither branch above is dead', () => { + // Without this, deleting every standalone config (or every root-importing + // one) would leave half the check above vacuously true. + expect(packageConfigs.filter(importsRootConfig).length).toBeGreaterThan(0); + expect(packageConfigs.filter((c) => !importsRootConfig(c)).length).toBeGreaterThan(0); + // The two configs this card measured, one per route. If either legitimately + // changes route, move it here rather than dropping the pin. + expect(importsRootConfig('packages/core/vitest.config.ts')).toBe(true); + expect(callsGuard('packages/plugin-grid/vitest.config.ts')).toBe(true); + + // …and the two routes are told apart by the IMPORT, not by the filename + // appearing somewhere in the file. plugin-grid's comment names + // `vitest.config.mts` while deliberately not importing it; a substring + // check reads that as route 2 and stops looking at the very configs this + // block exists for. + expect(read('packages/plugin-grid/vitest.config.ts')).toContain('vitest.config.mts'); + expect(importsRootConfig('packages/plugin-grid/vitest.config.ts')).toBe(false); + }); + + it('has every self-calling config derive the repo root instead of counting `..`', () => { + // `repoRoot: path.resolve(__dirname, '../..')` fails SILENTLY when the + // count is wrong: the resolved directory exists, the comparison runs, and + // the verdict is computed against the wrong root. `repoRootFrom` searches + // for the landmark and throws when it is not there. + const selfCalling = packageConfigs.filter((c) => !importsRootConfig(c) && callsGuard(c)); + expect(selfCalling.length).toBeGreaterThan(0); + for (const rel of selfCalling) { + expect(read(rel), `${rel} calls the guard with a hand-counted repo root`).toContain( + 'repoRootFrom(import.meta.url)' + ); + } + }); +}); + +describe('repoRootFrom — the landmark search the standalone configs use', () => { + it('resolves the repo root from a package config, at any depth', () => { + for (const rel of ['packages/plugin-grid/vitest.config.ts', 'apps/console/vitest.config.ts']) { + expect(repoRootFrom(pathToFileURL(path.join(repoRoot, rel)).href)).toBe(repoRoot); + } + }); + + it('throws instead of returning a plausible-but-wrong directory', () => { + // The failure this exists to avoid is a WRONG root, not a missing one: a + // wrong root still compares, so the guard would keep issuing verdicts. + expect(() => repoRootFrom(pathToFileURL('/nonexistent-objectui-5406/x.ts').href)).toThrow( + /vitest\.config\.mts/ + ); + }); + + it('composes with the verdict: a real package cwd is refused against the real root', () => { + // End-to-end over real paths — the fake-path cases above cannot catch a + // repoRoot that resolves to the wrong real directory. + const cwd = path.join(repoRoot, 'packages/plugin-grid'); + const derived = repoRootFrom(pathToFileURL(path.join(cwd, 'vitest.config.ts')).href); + + expect( + evaluateVitestInvocation({ + argv: ['/usr/bin/node', path.join(repoRoot, 'bin/vitest'), 'run'], + cwd, + repoRoot: derived, + })?.code + ).toBe('package-cwd'); + + // …and the canonical repo-root invocation still passes through it. + expect( + evaluateVitestInvocation({ + argv: ['/usr/bin/node', path.join(repoRoot, 'bin/vitest'), 'run', 'packages/plugin-grid/'], + cwd: repoRoot, + repoRoot: derived, + }) + ).toBeNull(); + }); +}); diff --git a/scripts/vitest-invocation-guard.mjs b/scripts/vitest-invocation-guard.mjs index fed64fb931..bcb1f84345 100644 --- a/scripts/vitest-invocation-guard.mjs +++ b/scripts/vitest-invocation-guard.mjs @@ -2,12 +2,57 @@ /** * Rejects the two Vitest invocations that silently produce a FALSE GREEN. * - * Called from the top of `vitest.config.mts`, so it covers EVERY entry point - * into this repo's Vitest: `pnpm test`, `pnpm --filter test`, - * `turbo run test`, and a bare `pnpm exec vitest` typed in any directory. - * (Every per-package `vitest.config.ts` re-exports the root config, and a - * package without one — e.g. `packages/app-shell` — resolves upward to it, so - * no package-level path skips this file.) + * Called from the top of `vitest.config.mts` AND from the top of every + * per-package `vitest.config.ts` that does not lead back to it, so it covers + * EVERY entry point into this repo's Vitest: `pnpm test`, + * `pnpm --filter test`, `turbo run test`, and a bare `pnpm exec vitest` + * typed in any directory. + * + * ## How a config reaches this file — and the 11 that did not (objectui#5406) + * + * Vitest loads the config it finds in the directory it was launched from, so + * "the root config calls the guard" only covers a package-cwd run when that + * package's own config leads back to the root file. Three routes exist; this + * docstring used to claim the first two covered everything, and was wrong + * about 11 of the 18 non-root configs: + * + * 1. NO `vitest.config.*` in the package (`packages/app-shell`, + * `packages/mobile`, ~30 others). The lookup walks up and lands on the + * root config, whose module scope runs the guard. Always covered. + * 2. A package config that IMPORTS the root config — re-exporting it + * (`packages/core`, `react`, `types`), merging a Vite config into it + * (`components`, `fields`, `plugin-dashboard`, `apps/console`), or + * stripping one key off it (`examples/schema-catalog`). Importing it + * executes its module scope, so the guard runs as a side effect. 8 + * configs, all measured refusing a package-cwd run. + * 3. A STANDALONE package config that never mentions the root file. Nothing + * imports this module, so the guard never ran: 11 configs + * (`plugin-calendar`, `-charts`, `-detail`, `-form`, `-gantt`, `-grid`, + * `-kanban`, `-list`, `-map`, `-timeline`, `-view`), each declaring its + * own `happy-dom` + `globals` + local setup and NO alias table at all. + * That is not a harmless difference: the root config maps every + * `@object-ui/*` specifier to a sibling package's `src/`, and without it + * the same import resolves through `node_modules` — a genuinely different + * config from the one CI runs. Measured before the fix, from + * `packages/plugin-grid`: + * + * pnpm exec vitest run src/__tests__/ObjectGrid.exportOptionsKeys.test.ts + * => RUN v4.1.10 /…/packages/plugin-grid <- root is the PACKAGE + * Test Files 1 passed (1) + * Tests 5 passed (5) <- exit 0, guard silent + * + * Route 3 now calls this module directly — same effect as route 2, + * without the import: + * + * import { assertCanonicalVitestInvocation, repoRootFrom } + * from '../../scripts/vitest-invocation-guard.mjs'; + * assertCanonicalVitestInvocation({ repoRoot: repoRootFrom(import.meta.url) }); + * + * Because a docstring is exactly what failed here, the claim above is now + * ENFORCED rather than written down: `scripts/__tests__/vitest-invocation-guard + * .test.ts` walks the repo for every `vitest.config.*` and fails on any that + * takes none of the three routes. A new standalone config cannot reopen the + * hole silently. * * ## Trap 1 — Vitest launched with the cwd inside a package (objectui#3378) * @@ -58,10 +103,14 @@ * ## Deliberately strict * * Any run whose Vitest root is not the repo root is refused, rather than - * refused only when it collects zero of its own files. Under the current root - * config the two are the same set — no package-cwd run can match its own files - * — and "root == repo root" is one comparison an agent can hold in its head, - * unlike a heuristic that fires only sometimes. Whether the 39 package-level + * refused only when it collects zero of its own files. "Collects zero of its + * own files" would have been the WEAKER trigger, and objectui#5406 shows why: + * under the 11 standalone configs a package-cwd run does collect the package's + * own files and can go green — that green just says nothing about CI, which + * runs those same files under the root config's aliases, project split and + * setup files. The defect is the divergent config, not the empty collection. + * "root == repo root" is also one comparison an agent can hold in its head, + * unlike a heuristic that fires only sometimes. Whether the package-level * `test` scripts should exist at all is objectui#3240 and not this guard's * call; until that is decided they fail loudly instead of lying. * @@ -70,6 +119,46 @@ import fs from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** The file whose directory IS the repo root, by definition of this guard. */ +const ROOT_CONFIG = 'vitest.config.mts'; + +/** + * Locate the repo root from a config file's own `import.meta.url`, by walking + * up to the directory that holds `vitest.config.mts`. + * + * Why the standalone package configs call this instead of spelling + * `path.resolve(__dirname, '../..')` eleven times: that literal is a silent + * failure waiting to happen. A wrong number of `..` yields a directory that + * exists, `evaluateVitestInvocation` compares the cwd against it, and the + * guard goes on reporting a verdict computed from the wrong root — no error, + * no signal. Searching for a landmark either finds the real root or throws. + * + * Why `import.meta.url` of the CONFIG rather than of this module: Vite may + * hand the config to Node's own ESM loader (`configLoader: 'native'`, the mode + * objectui#3384 documents) or bundle it to a `…timestamp-*.mjs` written + * ALONGSIDE the config. Both leave the config's own directory correct, while + * only the first leaves this module's path meaningful. + * + * @param {string} metaUrl the caller's `import.meta.url` + * @returns {string} absolute path of the directory holding `vitest.config.mts` + */ +export function repoRootFrom(metaUrl) { + let dir = path.dirname(fileURLToPath(metaUrl)); + for (;;) { + if (fs.existsSync(path.join(dir, ROOT_CONFIG))) return dir; + const parent = path.dirname(dir); + if (parent === dir) { + throw new Error( + `vitest-invocation-guard: walked up from ${fileURLToPath(metaUrl)} without finding ` + + `${ROOT_CONFIG}. That file's directory is what defines this repo's root, so the ` + + 'guard cannot judge an invocation without it.' + ); + } + dir = parent; + } +} /** * Vitest's positional subcommands. The first bare word is the subcommand, not a diff --git a/skills/objectui/guides/project-setup.md b/skills/objectui/guides/project-setup.md index 2dd82a563f..bf5642ec08 100644 --- a/skills/objectui/guides/project-setup.md +++ b/skills/objectui/guides/project-setup.md @@ -309,9 +309,11 @@ Note that tests are scoped by a **path filter from the repo root**, not by root: the root-level `unit`/`dom`/`dom-heavy` projects declare their `include` globs relative to that root and match nothing, while the `apps/console` project — brought in by absolute path — still resolves. The run then executes console's 22 files, reports -`Test Files 22 passed (22)`, and never touches the package you asked for. A guard in -`vitest.config.mts` rejects those invocations with a non-zero exit and prints the -correct form: +`Test Files 22 passed (22)`, and never touches the package you asked for. A guard rejects +those invocations with a non-zero exit and prints the correct form. It is wired into +`vitest.config.mts` and into each standalone per-package config +(`packages/plugin-grid/vitest.config.ts` and its ten siblings), since Vitest loads +whichever config sits in the directory it was launched from (objectui#5406): ``` vitest 调用被拒绝:从包目录跑 vitest 会静默跑错测试集 (objectui#3378) diff --git a/vitest.config.mts b/vitest.config.mts index 9857b3e929..2b3869ebc6 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -9,11 +9,21 @@ const __dirname = path.dirname(__filename); // Refuse the two invocations that pass while running none of the tests the // caller asked for — a package-cwd run (objectui#3378) and a path filter that -// never reaches Vitest (objectui#3288). Every per-package `vitest.config.ts` -// re-exports this file, and a package without one resolves upward to it, so -// this is the single choke point for both. Mechanism, message and the one -// canonical invocation: scripts/vitest-invocation-guard.mjs (and AGENTS.md -// §测试纪律, which spells the same three commands out). +// never reaches Vitest (objectui#3288). +// +// This call covers every run whose config resolution ends up here: `pnpm test`, +// and any package-cwd run in a package that either has no `vitest.config.*` of +// its own (the lookup walks up to this file) or has one that imports this file. +// It is NOT the single choke point — that claim used to be written both here +// and in the guard's docstring, and was wrong for the 11 packages carrying a +// STANDALONE `vitest.config.ts` (objectui#5406): nothing imported this module +// from there, so the guard never ran. Those 11 now call the guard themselves, +// and `scripts/__tests__/vitest-invocation-guard.test.ts` fails if any +// `vitest.config.*` in the repo takes neither route. +// +// Mechanism, message and the one canonical invocation: +// scripts/vitest-invocation-guard.mjs (and AGENTS.md §测试纪律, which spells the +// same three commands out). assertCanonicalVitestInvocation({ repoRoot: __dirname }); // Shared exclude list for the root-level projects below. (Project-level