From f3376d4ddf1aff2ac1d1cb1ca1ff1cc32c7d727d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 04:02:47 +0000 Subject: [PATCH] =?UTF-8?q?fix(test):=20=E6=8B=92=E7=BB=9D=E4=B8=A4?= =?UTF-8?q?=E7=A7=8D=E9=9D=99=E9=BB=98=E5=81=87=E7=BB=BF=E7=9A=84=20vitest?= =?UTF-8?q?=20=E8=B0=83=E7=94=A8,=E5=B9=B6=E6=8A=8A=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E8=B7=91=E6=B3=95=E5=86=99=E8=BF=9B=20AGENTS.md=20(#3378,=20#3?= =?UTF-8?q?288)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两条调用方式会在「一个目标测试都没跑」的情况下报绿,且没有任何 0-match 信号: - #3378 包级 cwd:`pnpm --filter test` / `cd packages/x && pnpm exec vitest`。 vitest 把 root 定成该目录,根级 projects 的 include(packages/**、examples/**、 scripts/**)相对它匹配不到任何文件,只有以绝对路径引入的 apps/console project 仍解析成功 —— 于是跑 @object-ui/console 的 22 个文件并报 `Test Files 22 passed (22)`,本包(app-shell 281 个)一个没跑。 - #3288 `-- `:pnpm 把 `--` 原样转发,vitest 的解析在 `--` 处停止,路径过滤 在 vitest 看到之前就没了,退回默认集合(叠加上一条即别人的包)后全绿。 scripts/vitest-invocation-guard.mjs 在 vitest.config.mts 顶部拦下两者:vitest root 不是仓根 → 拒绝;`--` 之后还有参数 → 拒绝;点名了具体文件却不存在 → 拒绝。并且 `passWithNoTests` 改为只在「命令行没点名任何文件」时才为 true —— 过滤零匹配从此非零退出。 报错正文写清机制并给出唯一正确跑法(仓根 `pnpm exec vitest run `),AGENTS.md 同步落成条文。 不动 17 个 per-package vitest.config.ts,也不改 39 条包级 test 脚本(#3240 另议): 它们现在只是失败,不再撒谎。CI 的根级调用(pnpm test --shard=N/4)完全不受影响。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- AGENTS.md | 32 ++ .../__tests__/vitest-invocation-guard.test.ts | 222 +++++++++++ scripts/vitest-invocation-guard.mjs | 376 ++++++++++++++++++ vitest.config.mts | 19 +- 4 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 scripts/__tests__/vitest-invocation-guard.test.ts create mode 100644 scripts/vitest-invocation-guard.mjs diff --git a/AGENTS.md b/AGENTS.md index dafb771004..35c5d1579a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,6 +150,38 @@ export const SchemaRenderer = ({ schema }: { schema: UIComponent }) => { - 任务结束:停**自己起的**后台服务(见下方"服务纪律";别按端口杀别人的)、清 `.playwright-mcp/`。 - 改完代码提交时:功能改进(feature)需写 changeset(`pnpm changeset`);纯 bug 修复不需要。 +### 怎么跑测试(有两种写法会静默假绿 —— 现已机械拦截) + +**唯一正确的跑法:在【仓库根目录】执行,路径相对仓根书写,前面不要加 `--`。** + +```bash +pnpm exec vitest run packages//src/.test.ts # 只跑一个文件 +pnpm exec vitest run packages// # 只跑一个包 +pnpm test # 全量(CI 就是它,可加 --shard=1/4) +``` + +AGENTS.md 的「只跑受影响的包」指的是**用上面的路径过滤缩小范围**,不是 `cd` 进包里、也不是 +`pnpm --filter test` —— 那两条恰好就是下面的陷阱。 + +- **陷阱一:让 vitest 的 cwd 落在包目录里(objectui#3378)。** `pnpm --filter test`、 + `turbo run test`、`cd packages/x && pnpm exec vitest` 都属于这类。vitest 把 root 定成该 + 目录,根级 projects(`unit`/`dom`/`dom-heavy`)的 include(`packages/**`、`examples/**`、 + `scripts/**`)相对它匹配不到任何文件;只有以**绝对路径**引入的 `apps/console` project 仍解析 + 成功。于是跑的是 `@object-ui/console` 的 22 个文件、报 `Test Files 22 passed (22)`,而本包 + (app-shell 有 281 个)一个都没跑。**没有 "0 tests matched" 信号** —— 计数是 22 不是 0, + `passWithNoTests` 根本不参与,按包级约定验证的 agent 会据此报「整包绿」。 +- **陷阱二:把路径挂在 `--` 后面(objectui#3288)。** `pnpm --filter test -- --run `: + pnpm 把 `--` **原样**转发进脚本,vitest 的 CLI 解析在 `--` 处停止,后面的一切(包括你的路径) + 在 vitest 看到之前就没了 —— 不是「被忽略并警告」,是压根不存在。于是退回默认集合(叠加陷阱一 + 就是别人的包),新加的测试文件零执行、输出全绿。 +- **两条现在都会直接失败**,由 `scripts/vitest-invocation-guard.mjs` 在 `vitest.config.mts` 顶部 + 拦下:vitest root 不是仓根 → 拒绝;`--` 后面还有参数 → 拒绝。报错正文会指出机制并给出上面的 + 正确命令。包级 `test` 脚本的存废是 objectui#3240;在那之前它们只失败,不撒谎。 +- **路径过滤零匹配也不再是绿的**:一旦命令行点名了文件,`passWithNoTests` 自动关闭 —— + 写错的路径 / 相对错目录的路径 → 非零退出,而不是「跑了 0 个文件然后绿」。 +- 确需从包目录启动,把 root 显式指回仓根:`pnpm exec vitest run --root ../.. packages//`。 + 真要临时绕过 guard(自担风险):`OBJECTUI_VITEST_GUARD=off`。 + ### 测试纪律(flaky 测试:先找竞态,别调超时) 单跑稳定绿、全量并行下偶发红的测试,**根因几乎总是同一个**:一段**无界的模块加载被计入了一个有界的窗口**。满并行下 Vite 的 transform 管线是饱和的(单 `dom-heavy` 项目就 ~60s transform),实测一次首包 `import()` 可达 **976ms** —— 已吃掉 RTL `findBy`/`waitFor` 默认 **1000ms** 预算的 97.6%。于是断言在和模块加载器抢时间,红绿取决于机器负载而不是被测代码。 diff --git a/scripts/__tests__/vitest-invocation-guard.test.ts b/scripts/__tests__/vitest-invocation-guard.test.ts new file mode 100644 index 0000000000..2bf1a06843 --- /dev/null +++ b/scripts/__tests__/vitest-invocation-guard.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// @ts-expect-error — plain-JS CI helper, intentionally untyped +import { + cliHasTestFilters, + evaluateVitestInvocation, + parseVitestArgv, +} from '../vitest-invocation-guard.mjs'; + +/** + * Two invocations of this repo's Vitest passed while running none of the tests + * the caller asked for: + * + * - objectui#3378: `pnpm --filter @object-ui/app-shell test` — i.e. `vitest + * run` with the cwd inside the package — printed `Test Files 22 passed (22)` + * where all 22 files belong to `@object-ui/console` and app-shell's own 281 + * never ran. The root-level projects glob `packages/**` RELATIVE to the + * cwd-derived root, so from inside a package they match nothing; only the + * `apps/console` project, brought in by absolute path, still resolves. + * + * - objectui#3288: `pnpm --filter test -- --run ` — pnpm forwards + * the `--` verbatim, Vitest's parser stops there, and the path filter is + * 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. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +// Paths that cannot exist, so `realpath()` falls back to a plain resolve and the +// verdict is decided by the injected inputs alone. +const FAKE_ROOT = '/nonexistent-objectui-repo'; +const FAKE_PKG = `${FAKE_ROOT}/packages/fields`; + +const argvFor = (...args: string[]) => ['/usr/bin/node', `${FAKE_ROOT}/bin/vitest`, ...args]; + +const judge = ( + args: string[], + { + cwd = FAKE_ROOT, + exists = () => true, + env = {}, + }: { cwd?: string; exists?: (p: string) => boolean; env?: Record } = {} +) => evaluateVitestInvocation({ argv: argvFor(...args), cwd, repoRoot: FAKE_ROOT, exists, env }); + +describe('parseVitestArgv', () => { + it('reads the subcommand, positionals and flags apart', () => { + const parsed = parseVitestArgv( + argvFor('run', '--project', 'unit', '--shard=1/4', 'packages/fields/src/a.test.ts') + ); + + expect(parsed.subcommand).toBe('run'); + expect(parsed.positionals).toEqual(['packages/fields/src/a.test.ts']); + expect(parsed.flags['--project']).toBe('unit'); + expect(parsed.flags['--shard']).toBe('1/4'); + expect(parsed.afterDoubleDash).toEqual([]); + }); + + it('does not mistake a value-taking flag value for a file filter', () => { + // `unit` and `verbose` are values, not paths. Reading them as filters would + // make `pnpm test:unit` look like a filtered run and flip passWithNoTests + // underneath it. + expect(parseVitestArgv(argvFor('run', '--project', 'unit')).positionals).toEqual([]); + expect(parseVitestArgv(argvFor('run', '--reporter', 'verbose')).positionals).toEqual([]); + }); + + it('collects everything after a bare `--` separately', () => { + const parsed = parseVitestArgv(argvFor('run', '--', '--run', 'packages/fields/src/a.test.ts')); + + // Exactly the shape pnpm builds for + // `pnpm --filter @object-ui/fields test -- --run `. + expect(parsed.positionals).toEqual([]); + expect(parsed.afterDoubleDash).toEqual(['--run', 'packages/fields/src/a.test.ts']); + }); + + it('treats a leading subcommand as the subcommand, not a filter', () => { + expect(parseVitestArgv(argvFor('run')).positionals).toEqual([]); + expect(parseVitestArgv(argvFor('list')).subcommand).toBe('list'); + }); +}); + +describe('cliHasTestFilters — what switches passWithNoTests off', () => { + it('is true when the CLI names files', () => { + expect(cliHasTestFilters(argvFor('run', 'packages/fields/src/a.test.ts'))).toBe(true); + // A bare substring filter counts too: "I asked for a subset and got zero" + // is the failure, whether or not the subset was spelled as a path. + expect(cliHasTestFilters(argvFor('run', 'useRecordQuery'))).toBe(true); + }); + + it('is false for the unfiltered runs CI makes', () => { + expect(cliHasTestFilters(argvFor('run'))).toBe(false); + expect(cliHasTestFilters(argvFor('run', '--shard=1/4'))).toBe(false); + expect(cliHasTestFilters(argvFor('run', '--project', 'unit'))).toBe(false); + expect(cliHasTestFilters(argvFor('run', '--coverage.reporter=json'))).toBe(false); + }); + + it('is false for `related` and `--changed`, which may legitimately match nothing', () => { + expect(cliHasTestFilters(argvFor('related', 'packages/fields/src/index.ts'))).toBe(false); + expect(cliHasTestFilters(argvFor('run', '--changed', 'HEAD~1'))).toBe(false); + }); +}); + +describe('evaluateVitestInvocation — the invocations CI and humans get right', () => { + it('passes an unfiltered run from the repo root', () => { + expect(judge(['run'])).toBeNull(); + }); + + it('passes CI shapes (sharded, coverage) unchanged', () => { + expect(judge(['run', '--shard=1/4'])).toBeNull(); + expect(judge(['run', '--coverage.reporter=json', '--coverage.reporter=text'])).toBeNull(); + }); + + it('passes the canonical path-filtered run from the repo root', () => { + expect(judge(['run', 'packages/fields/src/a.test.ts'])).toBeNull(); + }); + + it('passes a package-cwd run that points --root back at the repo root', () => { + // The one legitimate way to launch from inside a package: Vitest's root — + // and therefore every project `include` — is the repo root again. + expect(judge(['run', '--root', '../..', 'packages/fields/'], { cwd: FAKE_PKG })).toBeNull(); + }); +}); + +describe('evaluateVitestInvocation — objectui#3378, the package-cwd false green', () => { + it('refuses a run whose Vitest root is inside a package', () => { + const verdict = judge(['run'], { cwd: FAKE_PKG }); + + expect(verdict?.code).toBe('package-cwd'); + expect(verdict?.message).toContain('objectui#3378'); + // The message has to carry the mechanism, not just "don't do that": the + // 22-file console collection is the fingerprint the reader already saw. + expect(verdict?.message).toContain('22'); + expect(verdict?.message).toContain('apps/console'); + // ...and the replacement command, spelled with the caller's own package. + expect(verdict?.message).toContain('pnpm exec vitest run packages/fields/'); + }); + + it('refuses `vitest list` from a package directory too, not only `run`', () => { + // `cd packages/app-shell && pnpm exec vitest list` is the reproduction in + // objectui#3378; a guard covering only `run` would leave it lying. + expect(judge(['list'], { cwd: `${FAKE_ROOT}/packages/app-shell` })?.code).toBe('package-cwd'); + }); + + it('refuses a --root pointing somewhere other than this repo root', () => { + expect(judge(['run', '--root', '/somewhere/else'], { cwd: FAKE_ROOT })?.code).toBe( + 'package-cwd' + ); + }); +}); + +describe('evaluateVitestInvocation — objectui#3288, the filter that never lands', () => { + it('refuses arguments parked after a bare `--`', () => { + const verdict = judge(['run', '--', '--run', 'packages/fields/src/a.test.ts']); + + expect(verdict?.code).toBe('double-dash-args'); + expect(verdict?.message).toContain('objectui#3288'); + expect(verdict?.message).toContain('--run packages/fields/src/a.test.ts'); + }); + + it('names BOTH traps when the `--` run also came from a package directory', () => { + // `pnpm --filter test -- --run ` trips #3288 and #3378 at once. + // Reporting only the first would send the caller back for a second lap. + const verdict = judge(['run', '--', '--run', 'packages/fields/src/a.test.ts'], { + cwd: FAKE_PKG, + }); + + expect(verdict?.code).toBe('double-dash-args'); + expect(verdict?.message).toContain('objectui#3288'); + expect(verdict?.message).toContain('objectui#3378'); + }); + + it('refuses a concrete test path that does not exist from the Vitest root', () => { + const verdict = judge(['run', 'packages/fields/src/typo.test.ts'], { exists: () => false }); + + expect(verdict?.code).toBe('missing-path-filter'); + expect(verdict?.message).toContain('packages/fields/src/typo.test.ts'); + }); + + it('leaves non-path substring filters to passWithNoTests, not to the path check', () => { + // `vitest run useRecordQuery` is a legitimate substring filter — it names no + // file, so "does this path exist" has nothing to say about it. Zero matches + // still fails, via passWithNoTests being off for filtered runs. + expect(judge(['run', 'useRecordQuery'], { exists: () => false })).toBeNull(); + }); + + it('does not read a directory filter as a missing file', () => { + expect(judge(['run', 'packages/fields/'], { exists: () => false })).toBeNull(); + }); +}); + +describe('evaluateVitestInvocation — the escape hatch', () => { + it('stands down for OBJECTUI_VITEST_GUARD=off', () => { + expect(judge(['run'], { cwd: FAKE_PKG, env: { OBJECTUI_VITEST_GUARD: 'off' } })).toBeNull(); + }); + + it('is on by default (unset, or an unrelated value, does not disable it)', () => { + expect(judge(['run'], { cwd: FAKE_PKG, env: {} })?.code).toBe('package-cwd'); + expect(judge(['run'], { cwd: FAKE_PKG, env: { OBJECTUI_VITEST_GUARD: 'on' } })?.code).toBe( + 'package-cwd' + ); + }); +}); + +describe('the root config actually wires the guard', () => { + // Without this, the guard is a well-tested module nothing calls — the shape + // objectui#2879 hit when eslint-rules shipped tests no project globbed. + const config = fs.readFileSync(path.join(repoRoot, 'vitest.config.mts'), 'utf8'); + + it('calls the guard from vitest.config.mts', () => { + expect(config).toContain('assertCanonicalVitestInvocation({ repoRoot: __dirname })'); + }); + + it('derives passWithNoTests from the CLI instead of hard-coding true', () => { + expect(config).toContain('passWithNoTests: !cliHasTestFilters(process.argv)'); + expect(config).not.toContain('passWithNoTests: true'); + }); +}); diff --git a/scripts/vitest-invocation-guard.mjs b/scripts/vitest-invocation-guard.mjs new file mode 100644 index 0000000000..fed64fb931 --- /dev/null +++ b/scripts/vitest-invocation-guard.mjs @@ -0,0 +1,376 @@ +#!/usr/bin/env node +/** + * 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.) + * + * ## Trap 1 — Vitest launched with the cwd inside a package (objectui#3378) + * + * cd packages/app-shell && pnpm exec vitest run + * # identical to what `pnpm --filter @object-ui/app-shell test` runs + * => Test Files 22 passed (22) <- all 22 belong to @object-ui/console; + * app-shell's own 281 files never ran + * + * Vitest sets its `root` to the cwd. The root-level projects (`unit`, `dom`, + * `dom-heavy`) declare `include` relative to that root — `packages/**`, + * `examples/**`, `scripts/**` — and from inside `packages/app-shell` those + * globs resolve to `packages/app-shell/packages/**`, matching nothing at all. + * The one project that still resolves is `apps/console`, because the root + * config brings it in by ABSOLUTE path. So the run collects 22 foreign files, + * passes them, and prints a green summary. Nothing in that output says "0 tests + * matched" — the count is 22, not 0, so `passWithNoTests` never even enters the + * picture. An agent verifying "this package is green" by the package-level + * convention reports success for a package it never executed. + * + * ## Trap 2 — a path filter that never reaches Vitest (objectui#3288) + * + * pnpm --filter @object-ui/fields test -- --run packages/fields/src/a.test.ts + * => Test Files 22 passed (22) <- again @object-ui/console's files + * + * pnpm forwards the `--` VERBATIM into the script, so Vitest is invoked as + * `vitest run -- --run packages/fields/src/a.test.ts`. Vitest's CLI parser + * stops at `--` and discards everything after it, including the path. The + * filter is not "ignored with a warning" — it is gone before Vitest sees a + * path, and the run falls back to the default set (which, per trap 1, is + * somebody else's package). The newly added test file executes zero times and + * the output is green. + * + * The same miss one step removed: a filter that DOES reach Vitest but matches + * nothing (a typo, or a path spelled relative to the wrong directory) also used + * to exit 0, because the root config set `passWithNoTests: true` + * unconditionally. `cliHasTestFilters()` below is what the root config now uses + * to switch that off for exactly the runs that asked for specific files: "I + * named files and zero matched" is an error, while "no filter, and one project + * happens to hold no files" stays fine. + * + * ## The canonical invocation + * + * pnpm exec vitest run packages//src/.test.ts # from the REPO ROOT + * + * From the repo root, and with NO `--` in front of the paths. AGENTS.md + * (§测试纪律) carries the same lines. + * + * ## 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 + * `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. + * + * Escape hatch, documented in AGENTS.md: `OBJECTUI_VITEST_GUARD=off`. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Vitest's positional subcommands. The first bare word is the subcommand, not a + * file filter — `vitest run foo` filters on `foo`; `vitest run` filters on + * nothing. + */ +export const SUBCOMMANDS = new Set([ + 'run', + 'watch', + 'dev', + 'related', + 'bench', + 'typecheck', + 'list', + 'init', +]); + +/** + * Flags whose NEXT argv token is their value, so that token is not a file + * filter. Only the space-separated form needs listing; `--flag=value` is + * self-delimiting and handled structurally. + * + * A flag missing here can only cost a false positive, and only when its value + * also looks like a concrete `*.test.*` path that does not exist — so the list + * covers the flags this repo actually passes plus the common Vitest ones, + * rather than mirroring Vitest's whole CLI (which would drift). + */ +export const VALUE_FLAGS = new Set([ + '--project', + '--config', + '-c', + '--root', + '-r', + '--dir', + '--reporter', + '--outputFile', + '--shard', + '--testNamePattern', + '-t', + '--testTimeout', + '--hookTimeout', + '--teardownTimeout', + '--slowTestThreshold', + '--maxWorkers', + '--minWorkers', + '--maxConcurrency', + '--pool', + '--environment', + '--environmentOptions', + '--retry', + '--bail', + '--exclude', + '--include', + '--changed', + '--browser', + '--mode', + '--cache.dir', + '--coverage.reporter', + '--coverage.provider', + '--coverage.reportsDirectory', + '--sequence.seed', +]); + +/** A positional that names one concrete test file rather than a substring. */ +const CONCRETE_TEST_PATH = /[\\/].*\.(test|spec)\.(c|m)?[jt]sx?$/; + +/** + * Split a `process.argv`-shaped array into the parts this guard reasons about. + * + * @param {string[]} argv full `process.argv` (node binary + script + args) + * @returns {{ subcommand: string | null, positionals: string[], afterDoubleDash: string[], flags: Record }} + */ +export function parseVitestArgv(argv) { + const args = argv.slice(2); + /** @type {string[]} */ + const positionals = []; + /** @type {string[]} */ + const afterDoubleDash = []; + /** @type {Record} */ + const flags = {}; + let subcommand = null; + + for (let i = 0; i < args.length; i += 1) { + const token = args[i]; + + if (token === '--') { + afterDoubleDash.push(...args.slice(i + 1)); + break; + } + + if (token.startsWith('-')) { + const eq = token.indexOf('='); + if (eq !== -1) { + flags[token.slice(0, eq)] = token.slice(eq + 1); + continue; + } + const next = args[i + 1]; + if (VALUE_FLAGS.has(token) && next !== undefined && !next.startsWith('-')) { + flags[token] = next; + i += 1; + continue; + } + flags[token] = true; + continue; + } + + if (subcommand === null && positionals.length === 0 && SUBCOMMANDS.has(token)) { + subcommand = token; + continue; + } + + positionals.push(token); + } + + return { subcommand, positionals, afterDoubleDash, flags }; +} + +/** + * Did this invocation ask for a specific subset of test files? + * + * The root config feeds this into `passWithNoTests`: a filtered run that + * matches nothing is a failed run; an unfiltered one is not. + * + * @param {string[]} argv full `process.argv` + */ +export function cliHasTestFilters(argv) { + const { subcommand, positionals, flags } = parseVitestArgv(argv); + // `vitest related ` takes SOURCE paths and legitimately resolves + // to zero tests; `--changed` likewise when the diff touches no tested file. + if (subcommand === 'related') return false; + if (flags['--changed'] !== undefined) return false; + return positionals.length > 0; +} + +/** Resolve symlinks when possible; fall back to a plain resolve (unit tests pass fake paths). */ +function realpath(p) { + try { + return fs.realpathSync.native(p); + } catch { + return path.resolve(p); + } +} + +const RULE = '='.repeat(78); + +function box(title, lines) { + return ['', RULE, ` ${title}`, RULE, ...lines.map((l) => (l ? ` ${l}` : '')), RULE, ''].join('\n'); +} + +function canonicalLines(pkgDir) { + const file = pkgDir ? `${pkgDir}/src/.test.ts` : 'packages//src/.test.ts'; + const dir = pkgDir ? `${pkgDir}/` : 'packages//'; + return [ + '正确跑法 —— 一律在【仓库根目录】执行,路径前【不要】加 `--`:', + '', + ` pnpm exec vitest run ${file} # 只跑一个文件`, + ` pnpm exec vitest run ${dir} # 只跑一个包`, + ' pnpm test # 全量(CI 跑的就是它)', + ]; +} + +/** + * Judge one Vitest invocation. Pure: every input is injected. + * + * @param {object} input + * @param {string[]} input.argv full `process.argv` + * @param {string} input.cwd + * @param {string} input.repoRoot directory holding `vitest.config.mts` + * @param {(p: string) => boolean} [input.exists] + * @param {Record} [input.env] + * @returns {{ code: string, message: string } | null} null = nothing wrong with this invocation + */ +export function evaluateVitestInvocation({ + argv, + cwd, + repoRoot, + exists = (p) => fs.existsSync(p), + env = {}, +}) { + const off = String(env.OBJECTUI_VITEST_GUARD ?? '').toLowerCase(); + if (off === 'off' || off === '0' || off === 'false') return null; + + const { positionals, afterDoubleDash, flags } = parseVitestArgv(argv); + + const rootFlag = typeof flags['--root'] === 'string' ? flags['--root'] : flags['-r']; + const vitestRoot = + typeof rootFlag === 'string' ? realpath(path.resolve(cwd, rootFlag)) : realpath(cwd); + const root = realpath(repoRoot); + const rootIsRepoRoot = vitestRoot === root; + + // The package directory this run stands in, so the message can name the + // caller's own package instead of a `` placeholder. + const rel = path.relative(root, realpath(cwd)); + const pkgDir = + rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel.split(path.sep).join('/') : null; + + if (afterDoubleDash.length > 0) { + const alsoWrongCwd = rootIsRepoRoot + ? [] + : [ + '', + `顺带:本次 cwd 不是仓库根(${cwd})—— 那是 objectui#3378 的同类陷阱。`, + 'vitest 会把 root 定成 cwd,根级 projects 的 include 匹配不到本包任何文件,', + '只有以绝对路径引入的 apps/console project 仍解析成功,于是跑的是别人的包。', + ]; + return { + code: 'double-dash-args', + message: box('vitest 调用被拒绝:`--` 之后的参数会被 vitest 静默丢弃 (objectui#3288)', [ + `被丢弃的参数: ${afterDoubleDash.join(' ')}`, + '', + 'pnpm 把 `--` 原样转发给脚本,而 vitest 的 CLI 解析在 `--` 处停止 ——', + '`--` 之后的一切(包括你的路径过滤)在 vitest 看到之前就没了。于是跑的是', + '默认集合、输出一片绿,你想跑的那个文件一次都没执行。', + ...alsoWrongCwd, + '', + ...canonicalLines(pkgDir), + '', + '确需绕过(自担风险): OBJECTUI_VITEST_GUARD=off', + ]), + }; + } + + if (!rootIsRepoRoot) { + const backToRoot = path.relative(realpath(cwd), root) || '.'; + return { + code: 'package-cwd', + message: box('vitest 调用被拒绝:从包目录跑 vitest 会静默跑错测试集 (objectui#3378)', [ + `vitest root: ${vitestRoot}`, + `仓库根: ${root}`, + '', + 'vitest 把 root 定成了上面那个目录,根级 projects(unit/dom/dom-heavy)的 include', + '(`packages/**`、`examples/**`、`scripts/**`)相对它匹配不到任何文件;只有以', + '【绝对路径】引入的 apps/console project 仍解析成功。结果:跑了 @object-ui/console', + '的 22 个文件、报 `Test Files 22 passed (22)`,本包的一个都没跑 —— 而且没有任何', + '"0 tests matched" 信号,计数是 22 不是 0。这就是所谓假绿。', + '', + '`pnpm --filter test`、`turbo run test`、`cd packages/x && pnpm exec vitest`', + '都会落进这里。包级 test 脚本的存废是 objectui#3240;在那之前它们只失败,不撒谎。', + '', + ...canonicalLines(pkgDir), + '', + '确实要从包目录启动,就把 root 显式指回仓根(路径依旧相对仓根书写):', + ` pnpm exec vitest run --root ${backToRoot} ${pkgDir ? `${pkgDir}/` : 'packages//'}`, + '', + '确需绕过(自担风险): OBJECTUI_VITEST_GUARD=off', + ]), + }; + } + + const missing = positionals.filter((p) => { + if (!CONCRETE_TEST_PATH.test(p)) return false; + return !exists(path.resolve(vitestRoot, p)) && !exists(path.resolve(cwd, p)); + }); + + if (missing.length > 0) { + return { + code: 'missing-path-filter', + message: box('vitest 调用被拒绝:路径过滤指向不存在的文件 (objectui#3288)', [ + `找不到: ${missing.join(', ')}`, + `解析基准(vitest root): ${vitestRoot}`, + '', + 'vitest 把位置参数当作【相对 root 的】过滤串:写错的路径不会报错,它只是匹配不到。', + '再叠上 `passWithNoTests`,整个跑就绿着退出,而你新加的覆盖一次都没执行。', + '路径请相对【仓库根】书写。', + '', + ...canonicalLines(pkgDir), + '', + '确需绕过(自担风险): OBJECTUI_VITEST_GUARD=off', + ]), + }; + } + + return null; +} + +/** + * Guard entry point used by `vitest.config.mts`. + * + * Writes with `fs.writeSync(2, …)` rather than `console.error`: writes to a + * PIPE are asynchronous on POSIX, and the `process.exit()` on the next line + * would drop the message exactly when the run is piped into `grep`/`tee` — + * which is how CI logs and agent verification runs read it. + * + * @param {object} input + * @param {string} input.repoRoot + * @param {string[]} [input.argv] + * @param {string} [input.cwd] + * @param {Record} [input.env] + * @param {(verdict: { code: string, message: string }) => void} [input.onFail] + */ +export function assertCanonicalVitestInvocation({ + repoRoot, + argv = process.argv, + cwd = process.cwd(), + env = process.env, + onFail = (verdict) => { + fs.writeSync(2, `${verdict.message}\n`); + process.exit(1); + }, +}) { + const verdict = evaluateVitestInvocation({ argv, cwd, repoRoot, env }); + if (verdict) onFail(verdict); + return verdict; +} diff --git a/vitest.config.mts b/vitest.config.mts index 3cf35c6f81..794a35055b 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,10 +1,21 @@ import { defineConfig } from 'vitest/config'; import path from 'path'; import { fileURLToPath } from 'url'; +// @ts-expect-error — plain-JS CI helper, intentionally untyped (`allowJs: false`) +import { assertCanonicalVitestInvocation, cliHasTestFilters } from './scripts/vitest-invocation-guard.mjs'; const __filename = fileURLToPath(import.meta.url); 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). +assertCanonicalVitestInvocation({ repoRoot: __dirname }); + // Shared exclude list for the root-level projects below. (Project-level // `exclude` replaces — does not merge with — the inherited one, so each // project spells out the full list.) @@ -183,7 +194,13 @@ export default defineConfig({ }, path.resolve(__dirname, './apps/console/vitest.config.ts'), ], - passWithNoTests: true, + // Tolerate an empty collection ONLY for unfiltered runs (a `--project` + // slice, or a shard whose projects hold no files, is legitimately empty). + // The moment the CLI names files, "zero matched" is the defect being + // reported: a mis-spelled path, or one spelled relative to the wrong + // directory, used to exit 0 here and read as a green run over coverage that + // never executed (objectui#3288). + passWithNoTests: !cliHasTestFilters(process.argv), // Performance: use threads (lighter than forks). Isolation is enabled to // prevent module-graph and DOM state leakage across files (which previously // caused thousands of order-dependent failures).