mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
`npm test` ran config/vitest.config.ts, which includes the browser, visual and
perf suites. On any machine without chromium, a free port and per-machine PNG
baselines that fails ~87 tests on a clean master, so the repo's most obvious
command could not be used as a pass/fail signal. The workaround had spread into
four docs as "never run bare `npm test`" warnings.
`npm test` now runs config/vitest.ci.config.ts — byte-for-byte what CI runs — so
local green means CI green. Verified: 264 files, 5248 tests, exit 0.
The suites it leaves out are not abandoned; each has a command:
test:browser 5 Playwright files (chromium + a live server; codex-predictive-echo
also needs a real codex binary)
test:mobile unchanged — the above plus per-machine PNG baselines
test:perf 2 wall-clock benchmarks; need an otherwise idle machine
test:all the old everything-behaviour, kept reachable
test:ci is untouched (CI still calls it). test:watch and test:coverage follow
test onto the gate's config.
The more important half is the hole this closes. The exclusion list lived as
literals in one config and pointed one way only: a file excluded from CI and
added to no runner would be tested by NOTHING, silently, with every command
still green — vitest counts "no files matched a filter" as success. That is the
same shape as the #279/#280 blind spot already documented in CLAUDE.md.
So the globs moved to config/test-suites.ts, one array per REASON a suite cannot
run in CI, and all three configs derive from it. test/test-suite-partition.test.ts
then checks the arithmetic against the files on disk: it fails if any test file
is reachable by no runner, or by two. Confirmed it fires by orphaning a file and
watching it name it. The partition is exact today:
gate 264 + browser 5 + perf 2 + mobile 9 = 280 = every *.test.ts in the repo
⚠️ One sharp edge, deliberate and documented: a file filter must match its
runner. `npm test -- test/mobile/keyboard.test.ts` now matches nothing and exits
GREEN having run zero tests, because the gate's config excludes that path.
CLAUDE.md recommended exactly that command in the on-screen-keyboard note; that
line now says `npm run test:mobile -- <file>`, and the Testing section calls out
the trap, since a green run of zero tests is worse than a red one.
Docs synced: CLAUDE.md, AGENTS.md, .github/CONTRIBUTING.md, README.md,
README.zh-CN.md, and two ci.yml comments that claimed only test/mobile/** was
excluded — it is three suites, and 5 Playwright files rather than 3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
101 lines
4.4 KiB
TypeScript
101 lines
4.4 KiB
TypeScript
/**
|
|
* @fileoverview The test runners must PARTITION the repo: every test file
|
|
* reachable by exactly one command, no file reachable by none.
|
|
*
|
|
* `npm test` deliberately skips three suites (browser, mobile, perf) because
|
|
* they cannot pass on an arbitrary machine. The failure mode that creates is
|
|
* silent: exclude a file from the gate, forget to add it to a runner, and it is
|
|
* now tested by nothing — with every command still green, because vitest treats
|
|
* "no files matched" as success. That is not hypothetical; the exclusion list
|
|
* lived as literals in one config for its whole life, and nothing pointed the
|
|
* other way.
|
|
*
|
|
* So the globs live in config/test-suites.ts, every config derives from them,
|
|
* and this file checks the arithmetic actually works out on the files on disk.
|
|
*/
|
|
|
|
import { readdirSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { BROWSER_TEST_GLOBS, MOBILE_TEST_GLOBS, NON_CI_TEST_GLOBS, PERF_TEST_GLOBS } from '../config/test-suites';
|
|
|
|
const ROOT = resolve(import.meta.dirname, '..');
|
|
|
|
/** Every `*.test.ts` under test/, repo-relative, POSIX separators. */
|
|
function allTestFiles(dir = 'test'): string[] {
|
|
const out: string[] = [];
|
|
for (const entry of readdirSync(resolve(ROOT, dir), { withFileTypes: true })) {
|
|
const rel = `${dir}/${entry.name}`;
|
|
if (entry.isDirectory()) out.push(...allTestFiles(rel));
|
|
else if (entry.name.endsWith('.test.ts')) out.push(rel);
|
|
}
|
|
return out.sort();
|
|
}
|
|
|
|
/**
|
|
* Matches the three glob shapes test-suites.ts actually uses, and THROWS on
|
|
* anything else rather than quietly returning false — a glob this cannot read
|
|
* would otherwise make the partition below pass by mis-classifying it.
|
|
*/
|
|
function matches(glob: string, file: string): boolean {
|
|
if (glob.endsWith('/**')) return file.startsWith(glob.slice(0, -2));
|
|
if (!glob.includes('*')) return file === glob;
|
|
const star = glob.indexOf('*');
|
|
if (glob.indexOf('*', star + 1) !== -1) throw new Error(`unsupported glob (2+ wildcards): ${glob}`);
|
|
const [head, tail] = [glob.slice(0, star), glob.slice(star + 1)];
|
|
if (tail.includes('/')) throw new Error(`unsupported glob (wildcard before a slash): ${glob}`);
|
|
return file.startsWith(head) && file.endsWith(tail) && !file.slice(head.length).includes('/');
|
|
}
|
|
|
|
const claims = (globs: string[], file: string) => globs.some((g) => matches(g, file));
|
|
|
|
describe('test suite partition', () => {
|
|
it('routes every test file to exactly one runner', () => {
|
|
const runners = {
|
|
'test:browser': BROWSER_TEST_GLOBS,
|
|
'test:mobile': MOBILE_TEST_GLOBS,
|
|
'test:perf': PERF_TEST_GLOBS,
|
|
};
|
|
|
|
const orphaned: string[] = [];
|
|
const contested: string[] = [];
|
|
for (const file of allTestFiles()) {
|
|
const owners = Object.entries(runners)
|
|
.filter(([, globs]) => claims(globs, file))
|
|
.map(([name]) => name);
|
|
// Not in any excluded suite == owned by the gate, which is correct and
|
|
// the common case. Only >1 excluded owner is a bug.
|
|
if (owners.length > 1) contested.push(`${file} -> ${owners.join(' + ')}`);
|
|
// An excluded file with no runner is the silent hole this file exists for.
|
|
if (owners.length === 0 && claims(NON_CI_TEST_GLOBS, file)) orphaned.push(file);
|
|
}
|
|
|
|
expect(contested, 'a file claimed by two runners runs twice, or not at all').toEqual([]);
|
|
expect(orphaned, 'excluded from `npm test` but no runner picks it up — this file is tested by NOTHING').toEqual([]);
|
|
});
|
|
|
|
it('keeps NON_CI_TEST_GLOBS the union of the three excluded suites', () => {
|
|
// The gate excludes NON_CI_TEST_GLOBS; the runners include the three arrays.
|
|
// If the union drifts, the gate skips something no runner covers.
|
|
expect([...NON_CI_TEST_GLOBS].sort()).toEqual(
|
|
[...MOBILE_TEST_GLOBS, ...PERF_TEST_GLOBS, ...BROWSER_TEST_GLOBS].sort()
|
|
);
|
|
});
|
|
|
|
it('names only globs the matcher above can actually read', () => {
|
|
// matches() throws on shapes it would otherwise silently mis-classify.
|
|
for (const glob of NON_CI_TEST_GLOBS) expect(() => matches(glob, 'test/x.test.ts')).not.toThrow();
|
|
});
|
|
|
|
it('points every excluded glob at files that exist', () => {
|
|
// A stale entry (file renamed or deleted) makes its runner silently empty.
|
|
const files = allTestFiles();
|
|
for (const glob of NON_CI_TEST_GLOBS) {
|
|
expect(
|
|
files.some((f) => matches(glob, f)),
|
|
`${glob} matches no test file`
|
|
).toBe(true);
|
|
}
|
|
});
|
|
});
|