Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,38 @@ export const SchemaRenderer = ({ schema }: { schema: UIComponent }) => {
- 任务结束:停**自己起的**后台服务(见下方"服务纪律";别按端口杀别人的)、清 `.playwright-mcp/`。
- 改完代码提交时:功能改进(feature)需写 changeset(`pnpm changeset`);纯 bug 修复不需要。

### 怎么跑测试(有两种写法会静默假绿 —— 现已机械拦截)

**唯一正确的跑法:在【仓库根目录】执行,路径相对仓根书写,前面不要加 `--`。**

```bash
pnpm exec vitest run packages/<pkg>/src/<file>.test.ts # 只跑一个文件
pnpm exec vitest run packages/<pkg>/ # 只跑一个包
pnpm test # 全量(CI 就是它,可加 --shard=1/4)
```

AGENTS.md 的「只跑受影响的包」指的是**用上面的路径过滤缩小范围**,不是 `cd` 进包里、也不是
`pnpm --filter <pkg> test` —— 那两条恰好就是下面的陷阱。

- **陷阱一:让 vitest 的 cwd 落在包目录里(objectui#3378)。** `pnpm --filter <pkg> 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 <pkg> test -- --run <paths>`:
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/<pkg>/`。
真要临时绕过 guard(自担风险):`OBJECTUI_VITEST_GUARD=off`。

### 测试纪律(flaky 测试:先找竞态,别调超时)

单跑稳定绿、全量并行下偶发红的测试,**根因几乎总是同一个**:一段**无界的模块加载被计入了一个有界的窗口**。满并行下 Vite 的 transform 管线是饱和的(单 `dom-heavy` 项目就 ~60s transform),实测一次首包 `import()` 可达 **976ms** —— 已吃掉 RTL `findBy`/`waitFor` 默认 **1000ms** 预算的 97.6%。于是断言在和模块加载器抢时间,红绿取决于机器负载而不是被测代码。
Expand Down
222 changes: 222 additions & 0 deletions scripts/__tests__/vitest-invocation-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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 <pkg> test -- --run <paths>` — 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<string, string> } = {}
) => 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 <path>`.
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 <pkg> test -- --run <paths>` 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');
});
});
Loading
Loading