feat(test-cache): skip unit test files unchanged since they last passed

Turns the shadow recorder into a real cache. Before a queued unit run, a
custom sequencer re-fingerprints each test file's recorded dependencies and
drops the files whose key still matches a recorded pass; vitest runs exactly
the list the sequencer returns. Reusing the OLD dependency list is sound:
gaining a dependency means editing a file already in the list, which changes
the key.

A key covers the test file, every first-party module it imports (from vite's
module graph, in whichever environment loaded it, so happy-dom files count
too), every file or directory it read at runtime (a setup-file fs tracker,
directories fingerprinted by their whole subtree), and the lockfile, configs,
node, platform, vitest and the cache's own code. Records are shared by every
worktree through the common git dir, up to 8 per test file, so two trees on
different code both stay fast.

Still always run: files that spawn, glob, or import a computed specifier (19
files, 0.7% of modelled worker time). Known blind spot: environment
variables are not in the key.

A random ~5% of skippable files run anyway. If one fails, the cache predicted
a pass it could not deliver; it writes TRIPPED.json and runs everything until
a human removes it. Modes off|shadow|on are hot-configurable on the queue
(`test config --cache on`); never on in CI, never applied to a named-files run.

Measured, 89-file yardstick: cold 294s, warm 24s (85 skipped, 3 re-sampled,
0 false skips). Fixture scenarios: runtime-imported dep edited, fixture file
edited, dependency of a happy-dom test edited each re-ran exactly the affected
file; an env-driven failure was flagged as a false skip and tripped the cache.

Fixes found by those checks: a fully cached run exited 1 ("no test files");
44 happy-dom files were never cached; a builtin heuristic dropped top-level
directories like `src` from the key.

Full unit suite, cache off: Test Files 1 failed | 1904 passed | 3 skipped
(1908); the one failure is rest-error-envelope-ledger, which fails identically
on origin/main CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Justin Maier
2026-09-18 20:33:14 -06:00
parent fe3bbf27cc
commit af72b46854
11 changed files with 681 additions and 285 deletions
+1 -1
View File
@@ -1067,7 +1067,7 @@ Commands:
test config [n] Show or set the concurrency limit (0 pauses the queue)
[--max-workers <n>|none] also caps each run's vitest pool
[--typecheck <n>] sets the typecheck lane's limit
[--cache off|shadow] records what a result cache would skip
[--cache off|shadow|on] result cache: on skips unchanged tests
wt stale List worktrees whose PR merged (read-only)
wt rm <path> Remove a worktree safely (unlinks junctions first)
[--stop-server] [--force]
+2 -2
View File
@@ -181,8 +181,8 @@ function loadSkillConfig() {
}
case 'TEST_CACHE_MODE':
// Degrades rather than throws, like every setting the module-scope queue consumes.
if (['off', 'shadow'].includes(value)) config.testCacheMode = value;
else if (value) console.error(`Ignoring TEST_CACHE_MODE=${value} (want off or shadow)`);
if (['off', 'shadow', 'on'].includes(value)) config.testCacheMode = value;
else if (value) console.error(`Ignoring TEST_CACHE_MODE=${value} (want off, shadow or on)`);
break;
case 'TEST_MAX_WORKERS': {
// Same reasoning as TEST_CONCURRENCY above — this feeds a constructor that throws, and
@@ -14,7 +14,6 @@
import { EventEmitter } from 'events';
import { spawn, execFileSync } from 'child_process';
import { closeSync, existsSync, openSync, readSync, unlinkSync } from 'fs';
import { fileURLToPath } from 'url';
import { join } from 'path';
import { tmpdir } from 'os';
import { randomUUID } from 'crypto';
@@ -212,23 +211,22 @@ export function workerCapArgv(maxWorkers, args) {
return [`--max-workers=${maxWorkers}`];
}
export const CACHE_MODES = ['off', 'shadow'];
export const CACHE_MODES = ['off', 'shadow', 'on'];
// The daemon lives in the primary checkout, so this is the primary's copy of the reporter — the
// same file for every worktree's run, whichever branch that worktree is on. It imports node
// builtins only, which is what makes running it against another tree's suite safe.
export const SHADOW_REPORTER = fileURLToPath(
new URL('../../../../scripts/test-cache/reporter.mjs', import.meta.url)
);
// The WORKTREE's copy, not the daemon's: the sequencer and fs tracker come from that tree's
// vitest config, and all three must share one key definition (scripts/test-cache/core.mjs) or
// every lookup misses. A tree without the files simply runs uncached.
export const cacheReporterPath = (worktree) => join(worktree, 'scripts', 'test-cache', 'reporter.mjs');
/**
* The reporter arguments a queued unit run should carry. Naming any `--reporter` replaces vitest's
* default, so a caller who named none gets `default` back beside the shadow one — otherwise turning
* the shadow on would silently strip every queued run's normal output.
* The reporter arguments a queued unit run should carry. The reporter is passed on the command line
* rather than from the config because a caller's own `--reporter` replaces config reporters — and
* that would drop the false-skip check while the sequencer went on skipping. Naming any
* `--reporter` also replaces vitest's default, so a caller who named none gets `default` back.
*/
export function cacheReporterArgv(cacheMode, args, reporterPath = SHADOW_REPORTER) {
if (cacheMode !== 'shadow') return [];
if (!existsSync(reporterPath)) return [];
export function cacheReporterArgv(cacheMode, args, reporterPath) {
if (cacheMode === 'off') return [];
if (!reporterPath || !existsSync(reporterPath)) return [];
const named = args.some((a) => /^--reporter(?:=|$)/.test(String(a)));
return [...(named ? [] : ['--reporter=default']), `--reporter=${reporterPath}`];
}
@@ -252,7 +250,7 @@ export function defaultStartRun({
...args,
...(capWorkers ? workerCapArgv(maxWorkers, args) : []),
// Unit runs only: the reporter is a vitest reporter, and tsc would reject the flag.
...(capWorkers ? cacheReporterArgv(cacheMode, args) : []),
...(capWorkers ? cacheReporterArgv(cacheMode, args, cacheReporterPath(worktree)) : []),
];
onLog('info', `> ${pnpm} ${argv.join(' ')}`);
@@ -275,7 +273,12 @@ export function defaultStartRun({
// enqueue a second run and wait for it, while this one holds the slot that run needs — a
// deadlock on every full-suite run, not a race. Concurrency is not the fix: each logical run
// would need two slots, so N agents starting together still fill them all with waiters.
env: { ...process.env, CIVITAI_TEST_QUEUE: '0' },
env: {
...process.env,
CIVITAI_TEST_QUEUE: '0',
// Read by the worktree's vitest config, sequencer, tracker and reporter alike.
CIVITAI_TEST_CACHE: RUN_KINDS[normalizeKind(kind)].capWorkers ? cacheMode : 'off',
},
// The same fd twice: one file description, one shared offset, so the two streams append in
// the order they were actually written. See createOutputCapture.
stdio: ['ignore', capture.writeFd, capture.writeFd],
+20
View File
@@ -175,6 +175,21 @@ as running all of it. Two source files gave the identical number.
count: when 17 tests fail across 7 files, `git stash` and re-run those same files to see whether they
already failed on `main`. On Windows several do — see the portability notes below.
#### Queued unit runs can be served from a result cache
When the dev-server queue has the cache on (`cli.mjs test config --cache on`), a queued `test:unit:run`
skips every test file whose inputs are unchanged since it last passed — its source, every module it imports,
every file it read, and the lockfile/configs. Results are shared between worktrees. It prints what it did:
```
[test-cache] 1880 test files: 41 ran, 1839 skipped as unchanged since they last passed. 92 unchanged file(s) re-run to verify; false skips: 0.
```
A random ~5% of the unchanged files run anyway. If one of those fails, the cache predicted a pass it could not
deliver — a **false skip** — and it trips itself off (`TRIPPED.json` in the cache dir) until a human looks.
**Known blind spot:** environment variables are not part of the key. Never on in CI; never applied to a run
that names files. Code and details: `scripts/test-cache/`.
#### Worker count: uncapped by default, `VITEST_MAX_WORKERS` / `--max-workers` to size it
A suite uses Vitest's own worker count (`cpus - 1` in run mode, `floor(cpus / 2)` in watch; the browser pool `min(12, cpus - 1)`).
@@ -696,6 +711,11 @@ as a pass to anyone checking an exit code or skimming a summary. **Validate any
that file collected a nonzero count** — it was 308 tests on one base. If it reports 0, the run tells you nothing
about your change, whatever the summary says.
On a queued run with the result cache on, that file can be absent from the output for a legitimate reason:
it was skipped as unchanged since it last passed. The `[test-cache]` line names how many files were skipped.
To apply this check, confirm the file either ran with a nonzero count or is not in your diff's reach —
or run it by name, which never goes through the cache.
**A fresh worktree also has no `.envrc`.** It's gitignored, so it never comes with the checkout, and you silently
get system Node instead of the flake's pinned version. Measured (when the flake still shipped node 22): system
Node **26.5.0** against the flake's **22.22.2** produced 7 spurious `window.localStorage is undefined` failures
@@ -11,7 +11,7 @@ vi.mock('child_process', async (importOriginal) => ({
// Lives under scripts/ because the daemon is not part of the app's module graph — same arrangement
// as the rest of the queue's tests.
const { defaultStartRun, TestQueue, cacheReporterArgv, SHADOW_REPORTER } = await import(
const { defaultStartRun, TestQueue, cacheReporterArgv, cacheReporterPath } = await import(
'../../.claude/skills/dev-server/scripts/test-queue.mjs'
);
@@ -163,15 +163,20 @@ describe("the queue caps each run's vitest pool", () => {
});
/**
* Shadow mode rides on the fleet's real queued runs, so the one thing it must not do is change
* The cache rides on the fleet's real queued runs, so the one thing its wiring must not do is change
* them. Naming any `--reporter` replaces vitest's default so a caller who named none has to get
* `default` back, or turning the shadow on silently strips every queued run's normal output.
* `default` back, or turning the cache on silently strips every queued run's normal output.
*/
describe('shadow cache reporter on queued runs', () => {
describe('result cache on queued runs', () => {
const argvOf = (call: number) => spawn.mock.calls[call][1] as string[];
const envOf = (call: number) =>
(spawn.mock.calls[call][2] as { env: Record<string, string> }).env;
// The repo's own tree, so the reporter file really exists and the argv is not vacuously empty.
const worktree = process.cwd();
const reporter = cacheReporterPath(worktree);
const start = (opts: Record<string, unknown>) => {
const handle = defaultStartRun({
worktree: '/repo',
worktree,
args: [],
onLog: () => undefined,
onExit: () => undefined,
@@ -180,52 +185,56 @@ describe('shadow cache reporter on queued runs', () => {
handle.dispose();
};
it('adds nothing while the cache is off', () => {
it('adds nothing and tells the run the cache is off while it is off', () => {
start({ cacheMode: 'off' });
expect(argvOf(0)).toEqual(['run', 'test:unit:run']);
expect(envOf(0).CIVITAI_TEST_CACHE).toBe('off');
});
it('keeps the default reporter beside the shadow one when the caller named none', () => {
start({ cacheMode: 'shadow' });
it('keeps the default reporter beside the cache one when the caller named none', () => {
start({ cacheMode: 'on' });
expect(argvOf(0)).toEqual([
'run',
'test:unit:run',
'--reporter=default',
`--reporter=${SHADOW_REPORTER}`,
`--reporter=${reporter}`,
]);
expect(envOf(0).CIVITAI_TEST_CACHE).toBe('on');
});
it('adds only the shadow reporter when the caller chose their own', () => {
it('adds only the cache reporter when the caller chose their own', () => {
start({ cacheMode: 'shadow', args: ['--reporter=json'] });
expect(argvOf(0)).toEqual([
'run',
'test:unit:run',
'--reporter=json',
`--reporter=${SHADOW_REPORTER}`,
`--reporter=${reporter}`,
]);
});
it('never hands the vitest reporter to a typecheck', () => {
start({ cacheMode: 'shadow', kind: 'typecheck' });
// tsc would reject a vitest reporter, and the cache has nothing to skip in a typecheck.
it('leaves a typecheck untouched', () => {
start({ cacheMode: 'on', kind: 'typecheck' });
expect(argvOf(0)).toEqual(['run', 'typecheck']);
expect(envOf(0).CIVITAI_TEST_CACHE).toBe('off');
});
// A checkout without the reporter file must degrade to a plain run, never to a vitest that
// cannot load its reporter and fails every queued suite.
// A tree without the cache files runs uncached rather than as a vitest that cannot load its
// reporter and fails every queued suite.
it('adds nothing when the reporter file is absent', () => {
expect(cacheReporterArgv('shadow', [], '/nowhere/reporter.mjs')).toEqual([]);
expect(cacheReporterArgv('on', [], '/nowhere/reporter.mjs')).toEqual([]);
});
it("hands the queue's cache mode to the runner it starts", () => {
const startRun = vi.fn<(opts: { cacheMode: string }) => EventEmitter>(() => new EventEmitter());
const queue = new TestQueue({ concurrency: 1, cacheMode: 'shadow', startRun }) as unknown as {
const queue = new TestQueue({ concurrency: 1, cacheMode: 'on', startRun }) as unknown as {
request: (run: { worktree: string; args: string[] }) => unknown;
};
queue.request({ worktree: '/repo', args: [] });
expect(startRun.mock.calls[0][0]).toMatchObject({ cacheMode: 'shadow' });
expect(startRun.mock.calls[0][0]).toMatchObject({ cacheMode: 'on' });
});
it('refuses an unknown cache mode', () => {
expect(() => new TestQueue({ concurrency: 1, cacheMode: 'on' })).toThrow(/cacheMode must be/);
expect(() => new TestQueue({ concurrency: 1, cacheMode: 'yes' })).toThrow(/cacheMode must be/);
});
});
+92 -36
View File
@@ -1,18 +1,32 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { describe, expect, it } from 'vitest';
import * as Reporter from '../test-cache/reporter.mjs';
import * as Core from '../test-cache/core.mjs';
type Node = { id: string; importedModules: Set<Node> };
type Fingerprint = (rel: string) => string;
const { closureOf, normaliseId, isCoveredElsewhere, isUncacheableSource } = Reporter as unknown as {
closureOf: (
graph: { getModuleById: (id: string) => Node | undefined },
id: string
) => Set<string> | null;
normaliseId: (id: string, root: string) => string;
isCoveredElsewhere: (rel: string) => boolean;
isUncacheableSource: (source: string) => boolean;
};
const { closureOf, toRel, isCoveredElsewhere, alwaysRuns, keyFor, makeFingerprinter, mode } =
Core as unknown as {
closureOf: (
g: { getModuleById: (id: string) => Node | undefined },
id: string
) => Set<string> | null;
toRel: (id: string, root: string) => string | null;
isCoveredElsewhere: (rel: string | null) => boolean;
alwaysRuns: (source: string) => boolean;
keyFor: (a: {
salt: string;
project: string;
testRel: string;
entries: string[];
fingerprint: Fingerprint;
}) => string;
makeFingerprinter: (root: string) => Fingerprint;
mode: (env: Record<string, string | undefined>) => string;
};
function graphOf(edges: Record<string, string[]>) {
const nodes = new Map<string, Node>();
@@ -31,54 +45,96 @@ describe('the dependency set a key is built from', () => {
expect([...closureOf(graph, 't.test.ts')!].sort()).toEqual(['a.ts', 'b.ts', 'c.ts']);
});
// null, not an empty set: "not in the graph" must make the file uncacheable, never key it on
// nothing — an empty dependency set is a key that no change can ever invalidate.
// null, not an empty set: an empty dependency set is a key no change can ever invalidate.
it('reports a file missing from the graph as unknown rather than dependency-free', () => {
expect(closureOf(graphOf({}), 'missing.test.ts')).toBeNull();
});
});
describe('keys are portable between worktrees', () => {
// The whole point of keying on content: one tree's green run covers every other tree whose files
// are identical. A key that embedded the worktree path would never hit across trees.
it('gives the same id for the same file in two worktrees', () => {
const a = normaliseId('C:/Dev/wt/one/src/a.ts?v=123', 'C:\\Dev\\wt\\one');
const b = normaliseId('C:/Dev/wt/two/src/a.ts', 'C:/Dev/wt/two/');
expect(a).toBe('src/a.ts');
expect(b).toBe('src/a.ts');
// One tree's green run covers every other tree whose files are identical. A key that embedded
// the worktree path would never hit across trees.
it('gives the same path for the same file in two worktrees, URL or path', () => {
expect(toRel('C:/Dev/wt/one/src/a.ts?v=123', 'C:\\Dev\\wt\\one')).toBe('src/a.ts');
expect(toRel('file:///C:/Dev/wt/two/src/a.ts', 'C:/Dev/wt/two/')).toBe('src/a.ts');
});
it('leaves dependencies to the lockfile when they live in node_modules or are builtins', () => {
// A read outside the repo (a temp file the test wrote itself) is not an input anyone else shares.
it('drops absolute paths outside the repo', () => {
expect(toRel('D:/elsewhere/x.json', 'C:/Dev/wt/one')).toBeNull();
expect(isCoveredElsewhere(null)).toBe(true);
});
it('leaves node_modules and builtins to the lockfile', () => {
expect(isCoveredElsewhere('node_modules/.vite/vitest/x/deps_ssr/zod.js')).toBe(true);
expect(isCoveredElsewhere('crypto')).toBe(true);
expect(isCoveredElsewhere('node:fs')).toBe(true);
expect(isCoveredElsewhere('src/server/services/image.service.ts')).toBe(false);
expect(isCoveredElsewhere('packages/civitai-shared/src/lazy.ts')).toBe(false);
});
});
/**
* A test whose inputs are not imports has a dependency the graph cannot see, so it must always run.
* The largest group is the convention guards, which read source as TEXT: a change to the code they
* police changes no import edge of theirs.
*/
describe('the key', () => {
const root = mkdtempSync(join(tmpdir(), 'test-cache-'));
mkdirSync(join(root, 'src/sub'), { recursive: true });
writeFileSync(join(root, 't.test.ts'), 'test');
writeFileSync(join(root, 'src/a.ts'), 'a');
const key = (entries: string[]) =>
keyFor({
salt: 's',
project: 'unit',
testRel: 't.test.ts',
entries,
fingerprint: makeFingerprinter(root),
});
it('does not depend on the order dependencies were discovered in', () => {
expect(key(['src/a.ts', 'src'])).toBe(key(['src', 'src/a.ts']));
});
it('changes when a dependency changes', () => {
const before = key(['src/a.ts']);
writeFileSync(join(root, 'src/a.ts'), 'a2');
expect(key(['src/a.ts'])).not.toBe(before);
});
// A convention guard lists a directory recursively in ONE call. A file added three levels down
// changes what that call returns, so it has to change the key.
it('changes when a file appears anywhere below a directory that was read', () => {
const before = key(['src']);
writeFileSync(join(root, 'src/sub/new.ts'), 'n');
expect(key(['src'])).not.toBe(before);
});
});
describe('tests that always run', () => {
it.each([
["const s = readFileSync('src/x.ts', 'utf8');"],
['const files = readdirSync(dir);'],
["spawn(process.execPath, ['x.mjs']);"],
['const m = await import(`./pages/${name}`);'],
['const m = await import(target);'],
])('are recognised: %s', (source) => {
expect(isUncacheableSource(source)).toBe(true);
["const files = globSync('src/**/*.ts');"],
["import fg from 'fast-glob';"],
])('recognises %s', (source) => {
expect(alwaysRuns(source)).toBe(true);
});
// A literal dynamic import IS in the module graph — measured, it is exactly the case
// `diagnostic().importDurations` missed and the ssr graph caught — so it stays cacheable.
it.each([["const m = await import('./dep');"], ["import { a } from './a';"]])(
'does not flag an ordinary import: %s',
// Plain file reads are no longer a reason to always run: the tracker records them into the key.
// A literal dynamic import is in the module graph.
it.each([["readFileSync('src/x.ts', 'utf8');"], ["await import('./dep');"]])(
'leaves %s cacheable',
(source) => {
expect(isUncacheableSource(source)).toBe(false);
expect(alwaysRuns(source)).toBe(false);
}
);
});
// CI must always run everything: it is the check that still runs when nothing local does.
describe('when the cache is active', () => {
it('is off in CI whatever the mode says', () => {
expect(mode({ CI: 'true', CIVITAI_TEST_CACHE: 'on' })).toBe('off');
});
it('is off unless a known mode is named', () => {
expect(mode({})).toBe('off');
expect(mode({ CIVITAI_TEST_CACHE: 'yes' })).toBe('off');
expect(mode({ CIVITAI_TEST_CACHE: 'on' })).toBe('on');
});
});
+229
View File
@@ -0,0 +1,229 @@
/**
* The one definition of a test file's cache key, shared by the sequencer (which decides BEFORE a
* run what to skip) and the reporter (which records AFTER a run what passed). If the two computed
* a key differently, every lookup would miss or worse, hit on the wrong thing so neither
* computes it itself.
*
* A key covers: the test file, every first-party module it depends on, every file or directory it
* read at runtime, and the inputs nothing else records (lockfile, configs, node, platform, vitest,
* and this cache's own code). Anything under node_modules is left to the lockfile.
*
* Node builtins only: the queue runs the reporter from the primary checkout against any worktree.
*/
import { createHash } from 'node:crypto';
import { isBuiltin } from 'node:module';
import { execFileSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, isAbsolute, join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const CACHE_FORMAT = 2;
export const MODES = ['off', 'shadow', 'on'];
export const RECORDS_PER_TEST = 8;
const HERE = dirname(fileURLToPath(import.meta.url));
// Inputs every test depends on that no import edge or file read records. This cache's own code is
// among them: a fix to how reads are captured must invalidate everything recorded without it.
const GLOBAL_INPUTS = ['pnpm-lock.yaml', 'vitest.config.mts', 'tsconfig.json'];
const OWN_CODE = ['core.mjs', 'fs-tracker.mjs', 'sequencer.mjs', 'reporter.mjs'];
// What stays uncacheable even with file reads tracked: a child process reads what it likes, and a
// dynamic import whose specifier is computed is invisible to the module graph. 19 files, 0.7% of
// modelled worker time, measured 2026-09-19.
const ALWAYS_RUN_SOURCE = [
/\b(?:spawn|spawnSync|execSync|execFileSync|execFile)\(/,
/import\(\s*(?:`[^`]*\$\{|[A-Za-z_$])/,
// A glob's result changes when a MATCHING file is added anywhere, and a pattern is not a path
// whose state can be fingerprinted.
/\b(?:globSync|glob|globby|fastGlob|fg)\(|from ['"](?:fast-glob|globby|glob|tinyglobby)['"]/,
];
export function mode(env = process.env) {
if (env.CI) return 'off';
const m = env.CIVITAI_TEST_CACHE;
return MODES.includes(m) ? m : 'off';
}
export const sha = (data) => createHash('sha256').update(data).digest('hex');
export function toRel(id, root) {
let p = String(id);
if (p.startsWith('file://')) p = fileURLToPath(p);
p = p.split('?')[0].replace(/\\/g, '/');
const r = root.replace(/\\/g, '/').replace(/\/$/, '');
if (p.toLowerCase().startsWith(r.toLowerCase() + '/')) return p.slice(r.length + 1);
return isAbsolute(p) || /^[A-Za-z]:\//.test(p) ? null : p;
}
/** A dependency the lockfile already covers, or that is not a file in this repo at all. */
export function isCoveredElsewhere(rel) {
if (rel === null) return true;
// `isBuiltin`, not "has no slash and no dot": that heuristic also matched a top-level directory
// like `src`, which the convention guards list — dropping the one read that sees a new file.
if (isBuiltin(rel)) return true;
if (rel.startsWith('node:')) return true;
if (rel.startsWith('node_modules/') || rel.includes('/node_modules/')) return true;
if (rel.startsWith('.git/')) return true;
return false;
}
export function alwaysRuns(source) {
return ALWAYS_RUN_SOURCE.some((re) => re.test(source));
}
/** Transitive imports in one vite environment's module graph. null when the root is absent. */
export function closureOf(graph, id) {
const root = graph.getModuleById(id);
if (!root) return null;
const out = new Set();
const seen = new Set([root]);
const stack = [root];
while (stack.length) {
for (const dep of stack.pop().importedModules) {
if (!dep?.id || seen.has(dep)) continue;
seen.add(dep);
out.add(dep.id);
stack.push(dep);
}
}
return out;
}
/**
* What a path looks like right now, as a string that changes exactly when the path does: a file's
* content hash, a directory's sorted listing, or its absence. A directory counts because a test
* that lists one (every convention guard does) depends on which files are in it.
*/
export function makeFingerprinter(root) {
const memo = new Map();
return (rel) => {
if (memo.has(rel)) return memo.get(rel);
const abs = join(root, rel);
let fp;
try {
const st = statSync(abs);
fp = st.isDirectory() ? `dir:${sha(listTree(abs).join('\n'))}` : `file:${sha(readFileSync(abs))}`;
} catch {
fp = 'missing';
}
memo.set(rel, fp);
return fp;
};
}
/**
* A directory's whole subtree, not its top level: `readdirSync(dir, { recursive: true })` is one
* recorded call, and a file added three levels down changes what it returns.
*/
function listTree(abs) {
const out = [];
const walk = (d, prefix) => {
for (const e of readdirSync(d, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name === '.git') continue;
const rel = prefix ? `${prefix}/${e.name}` : e.name;
out.push(e.isDirectory() ? `${rel}/` : rel);
if (e.isDirectory()) walk(join(d, e.name), rel);
}
};
walk(abs, '');
return out.sort();
}
export function globalSalt(root, vitestVersion, fingerprint = makeFingerprinter(root)) {
const own = OWN_CODE.map((f) => {
try {
return `${f}:${sha(readFileSync(join(HERE, f)))}`;
} catch {
return `${f}:missing`;
}
});
return sha(
JSON.stringify([
CACHE_FORMAT,
process.version,
process.platform,
vitestVersion,
GLOBAL_INPUTS.map((f) => [f, fingerprint(f)]),
own,
])
);
}
/** The key. `entries` is every repo-relative path the file depends on; order does not matter. */
export function keyFor({ salt, project, testRel, entries, fingerprint }) {
const parts = [...new Set(entries)]
.filter((rel) => !isCoveredElsewhere(rel))
.sort()
.map((rel) => `${rel}\0${fingerprint(rel)}`);
return sha([salt, project, testRel, fingerprint(testRel), ...parts].join('\n'));
}
export const identity = (project, testRel) => sha(`${project}\0${testRel}`);
// ------------------------------------------------------------------------------------------ store
export function cacheDir(root) {
if (process.env.CIVITAI_TEST_CACHE_DIR) return process.env.CIVITAI_TEST_CACHE_DIR;
// The COMMON git dir is shared by every worktree, so one tree's green result is reusable by
// another, and it is never inside a checkout's own files.
const common = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd: root })
.toString()
.trim();
return join(isAbsolute(common) ? common : join(root, common), 'civitai-test-cache', `v${CACHE_FORMAT}`);
}
/**
* Every recorded pass for one test file, newest first. More than one so two worktrees on different
* versions of the same code can BOTH stay fast, instead of evicting each other's record.
*/
export function recordsFor(dir, project, testRel) {
const d = join(dir, 'rec', identity(project, testRel));
if (!existsSync(d)) return [];
const out = [];
for (const name of readdirSync(d)) {
if (!name.endsWith('.json')) continue;
try {
out.push({ ...JSON.parse(readFileSync(join(d, name), 'utf8')), mtime: statSync(join(d, name)).mtimeMs });
} catch {
/* a half-written or foreign file is not a record */
}
}
return out.sort((a, b) => b.mtime - a.mtime);
}
export function writeRecord(dir, project, testRel, record) {
const d = join(dir, 'rec', identity(project, testRel));
mkdirSync(d, { recursive: true });
const final = join(d, `${record.key}.json`);
const tmp = `${final}.${process.pid}.tmp`;
writeFileSync(tmp, JSON.stringify(record));
renameSync(tmp, final);
const all = readdirSync(d).filter((n) => n.endsWith('.json'));
if (all.length > RECORDS_PER_TEST) {
const byAge = all
.map((n) => ({ n, t: statSync(join(d, n)).mtimeMs }))
.sort((a, b) => a.t - b.t);
for (const { n } of byAge.slice(0, all.length - RECORDS_PER_TEST)) rmSync(join(d, n), { force: true });
}
}
export const trippedPath = (dir) => join(dir, 'TRIPPED.json');
export function tripped(dir) {
try {
return JSON.parse(readFileSync(trippedPath(dir), 'utf8'));
} catch {
return null;
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Setup file: records every path a test file touches through `fs`, so a test whose inputs are
* files a fixture, or a whole source tree read as text by a convention guard can be keyed on
* them. Without it those 224 files (5.9% of modelled worker time) would have to run every time.
*
* Patching the CommonJS `fs` object and then calling `syncBuiltinESMExports` is what makes this
* reach `import { readFileSync } from 'node:fs'` too measured on a fixture: the ESM named import
* went through the wrapper. Loaded only when the cache is on; it is inert otherwise.
*
* The paths leave the worker as the file task's `meta`, which vitest serialises to the main process
* where the reporter reads it. A per-file Set is correct because the unit projects isolate every
* file in its own process; were that ever turned off, reads would pool across files, which only
* makes keys stricter.
*/
import { createRequire, syncBuiltinESMExports } from 'node:module';
import { afterAll } from 'vitest';
import { mode } from './core.mjs';
if (mode() !== 'off') {
const require = createRequire(import.meta.url);
const fs = require('node:fs');
const reads = new Set();
const note = (p) => {
if (typeof p === 'string') reads.add(p);
else if (p instanceof URL) reads.add(p.href);
else if (Buffer.isBuffer(p)) reads.add(p.toString());
};
const wrap = (obj, name) => {
const orig = obj[name];
if (typeof orig !== 'function') return;
obj[name] = function (p, ...rest) {
note(p);
return orig.call(this, p, ...rest);
};
};
for (const name of [
'readFileSync',
'readFile',
'readdirSync',
'readdir',
'statSync',
'lstatSync',
'stat',
'lstat',
'existsSync',
'opendirSync',
'opendir',
'createReadStream',
]) {
wrap(fs, name);
}
for (const name of ['readFile', 'readdir', 'stat', 'lstat', 'opendir']) {
wrap(fs.promises, name);
}
syncBuiltinESMExports();
// The first argument must be an object pattern — vitest 4 parses hook signatures as fixtures and
// rejects anything else — and the file's suite arrives second.
// eslint-disable-next-line no-empty-pattern
afterAll(({}, suite) => {
suite.meta.testCacheReads = [...reads];
});
}
+118 -210
View File
@@ -1,103 +1,27 @@
/**
* Test-result cache, SHADOW MODE. Records what a content-keyed cache WOULD have skipped, and never
* skips anything. Every test still runs; this only watches.
* Records, AFTER a run, which test files passed and what they depended on and checks the cache's
* own work. Pairs with scripts/test-cache/sequencer.mjs, which read these records before the run.
*
* vitest run --reporter=default --reporter=scripts/test-cache/reporter.mjs
* Dependencies come from vite's server-side ssr module graph, not `diagnostic().importDurations`:
* measured on a fixture, importDurations recorded a static import and MISSED an `await import()`
* made inside a test body, while the ssr graph caught both, cold and warm. File reads come from
* scripts/test-cache/fs-tracker.mjs via the file task's meta.
*
* A test file's key is a hash of every first-party module it depends on, plus the inputs that are
* not imports (lockfile, config, node, platform). A file that passed in full is recorded under its
* key; a later run whose key matches is one the cache would skip. If that file then FAILS, the key
* missed a dependency a false skip, the one outcome that disqualifies the cache.
* The check: every file the sequencer found unchanged but ran anyway all of them in `shadow`
* mode, the random sample in `on` was predicted to pass. One that fails is a FALSE SKIP: the key
* missed a dependency, and in `on` mode that file would have been reported green without running.
* The reporter then trips the cache so every later run executes in full until a human looks.
*
* Why the dependency set comes from vite's server-side module graph and not from
* `diagnostic().importDurations`: measured on a fixture, importDurations recorded a static import
* and MISSED an `await import()` made inside a test body a module the test demonstrably loaded.
* The ssr graph recorded both, cold and warm. It also leaves out the subtree behind a `vi.mock`
* factory, which never executes, and keeps the mocked module itself, which is the safe direction.
*
* 🔴 This file must never change a run's outcome. Everything is wrapped; a failure here is logged
* and swallowed. It imports node builtins only, because the queue runs it from the primary
* checkout against whatever worktree the suite belongs to.
* 🔴 This file must never change a run's outcome. Everything is caught and logged. It imports node
* builtins only.
*/
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import {
appendFileSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
writeFileSync,
} from 'node:fs';
import { isAbsolute, join, relative } from 'node:path';
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
export const CACHE_FORMAT = 1;
import * as core from './core.mjs';
// Inputs every test depends on that no import edge records. Changing any of them invalidates the
// whole cache, which is the correct response to a changed toolchain.
const GLOBAL_INPUTS = ['pnpm-lock.yaml', 'vitest.config.mts', 'tsconfig.json'];
// A test that reads the filesystem or spawns processes has inputs the module graph cannot see — a
// fixture, a scanned source tree, a script's behaviour. The convention guards (`no-*.test.ts`) are
// the largest group: they read source as TEXT. Such files always run. Measured at 243 of 1,880 unit
// files, and 6.5% of modelled worker time, because they are mostly cheap.
const UNCACHEABLE_SOURCE = [
/\b(?:readFileSync|readFile|readdirSync|readdir|globSync|statSync|existsSync|opendirSync)\b/,
/\b(?:spawn|spawnSync|execSync|execFileSync|execFile)\(/,
// A dynamic import whose specifier is not a literal is invisible to static analysis.
/import\(\s*(?:`[^`]*\$\{|[A-Za-z_$])/,
];
export function sha(data) {
return createHash('sha256').update(data).digest('hex');
}
/** Repo-relative, forward-slashed, query-stripped — so two worktrees produce the same key. */
export function normaliseId(id, root) {
const bare = id.split('?')[0].replace(/\\/g, '/');
const rootFwd = root.replace(/\\/g, '/').replace(/\/$/, '');
if (bare.toLowerCase().startsWith(rootFwd.toLowerCase() + '/')) return bare.slice(rootFwd.length + 1);
return bare;
}
/** Dependencies whose identity is the lockfile's job, or that are not files at all. */
export function isCoveredElsewhere(rel) {
if (!rel.includes('/') && !rel.includes('.')) return true; // node builtins: `fs`, `crypto`
if (rel.startsWith('node:')) return true;
if (rel.startsWith('node_modules/') || rel.includes('/node_modules/')) return true;
return false;
}
export function isUncacheableSource(source) {
return UNCACHEABLE_SOURCE.some((re) => re.test(source));
}
/** Transitive imports of `id` in one environment's module graph, memoised across a run. */
export function closureOf(graph, id, memo = new Map()) {
if (memo.has(id)) return memo.get(id);
const out = new Set();
const root = graph.getModuleById(id);
if (!root) {
memo.set(id, null);
return null;
}
const stack = [root];
const seen = new Set([root]);
while (stack.length) {
const node = stack.pop();
for (const dep of node.importedModules) {
if (!dep?.id || seen.has(dep)) continue;
seen.add(dep);
out.add(dep.id);
stack.push(dep);
}
}
memo.set(id, out);
return out;
}
/** Fully passed: every case ran and passed. A skipped case means part of the file never ran. */
/** Every case ran and passed. A skipped case means part of the file never ran. */
export function fullyPassed(testModule) {
if (testModule.state() !== 'passed') return false;
for (const test of testModule.children.allTests()) {
@@ -106,17 +30,7 @@ export function fullyPassed(testModule) {
return true;
}
function cacheDir(root) {
if (process.env.CIVITAI_TEST_CACHE_DIR) return process.env.CIVITAI_TEST_CACHE_DIR;
// The COMMON git dir is shared by every worktree of the repo, which is what makes one tree's
// green result reusable by another, and it is never inside a checkout's own files.
const common = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd: root })
.toString()
.trim();
return join(isAbsolute(common) ? common : join(root, common), 'civitai-test-cache', `v${CACHE_FORMAT}`);
}
export default class TestCacheShadowReporter {
export default class TestCacheReporter {
onInit(vitest) {
this.vitest = vitest;
this.startedAt = Date.now();
@@ -126,143 +40,137 @@ export default class TestCacheShadowReporter {
try {
this.record(testModules);
} catch (err) {
console.error(`[test-cache] shadow recorder failed, run unaffected: ${err?.stack ?? err}`);
console.error(`[test-cache] recorder failed, run unaffected: ${err?.stack ?? err}`);
}
}
record(testModules) {
if (!testModules.length) return;
const mode = core.mode();
if (mode === 'off') return;
const state = globalThis.__civitaiTestCache ?? { hits: new Set(), sampled: new Set(), skipped: [], total: testModules.length };
const root = this.vitest.config.root;
// A name filter runs part of every file, so nothing it passes may be recorded as a whole pass.
const filtered = Boolean(this.vitest.config.testNamePattern);
const dir = cacheDir(root);
mkdirSync(join(dir, 'pass'), { recursive: true });
const fileHash = new Map();
const hashFile = (rel) => {
if (!fileHash.has(rel)) {
const abs = join(root, rel);
fileHash.set(rel, existsSync(abs) ? sha(readFileSync(abs)) : null);
}
return fileHash.get(rel);
};
const globalSalt = sha(
JSON.stringify([
CACHE_FORMAT,
process.version,
process.platform,
this.vitest.version,
GLOBAL_INPUTS.map((f) => [f, hashFile(f)]),
])
);
const dir = core.cacheDir(root);
mkdirSync(dir, { recursive: true });
const fingerprint = core.makeFingerprinter(root);
const salt = core.globalSalt(root, this.vitest.version, fingerprint);
const rows = [];
const memo = new Map();
for (const m of testModules) {
const server = m.project.vite;
const graph = server.environments?.ssr?.moduleGraph;
const rel = normaliseId(m.moduleId, root);
const project = m.project.name;
const testRel = core.toRel(m.moduleId, root);
const d = m.diagnostic();
const ms = (d.prepareDuration ?? 0) + (d.setupDuration ?? 0) + (d.collectDuration ?? 0) + (d.duration ?? 0);
const row = { file: rel, project: m.project.name, ms, passed: fullyPassed(m), key: null, why: null };
const row = {
file: testRel,
project,
ms: (d.prepareDuration ?? 0) + (d.setupDuration ?? 0) + (d.collectDuration ?? 0) + (d.duration ?? 0),
passed: fullyPassed(m),
wasHit: state.hits.has(`${project}\0${m.moduleId}`),
why: null,
};
rows.push(row);
if (row.wasHit && !row.passed) row.falseSkip = true;
let reason = null;
const source = hashFile(rel) === null ? null : readFileSync(join(root, rel), 'utf8');
if (!graph) reason = 'no ssr module graph';
else if (source === null) reason = 'test file unreadable';
else if (isUncacheableSource(source)) reason = 'reads fs / spawns / non-literal import';
const deps = new Set();
if (!reason) {
const own = closureOf(graph, m.moduleId, memo);
if (own === null) reason = 'test file not in module graph';
else {
for (const id of own) deps.add(id);
for (const setup of m.project.config.setupFiles ?? []) {
const s = closureOf(graph, setup, memo);
if (s === null) {
reason = 'setup file not in module graph';
break;
}
deps.add(setup);
for (const id of s) deps.add(id);
}
}
const reads = m.meta()?.testCacheReads;
// Whichever environment transformed the file. A `// @vitest-environment happy-dom` test goes
// through the web transform, so its modules are in `client`, not `ssr` — measured: such a
// file was absent from the ssr graph and fell out of the cache entirely.
const graph = Object.values(m.project.vite?.environments ?? {})
.map((env) => env.moduleGraph)
.find((g) => g?.getModuleById(m.moduleId));
let source = null;
try {
source = readFileSync(join(root, testRel), 'utf8');
} catch {
/* handled below */
}
if (!reason) {
const parts = [];
for (const id of deps) {
const depRel = normaliseId(id, root);
if (isCoveredElsewhere(depRel)) continue;
const h = hashFile(depRel);
if (h === null) {
reason = `dependency unreadable: ${depRel}`;
break;
}
parts.push(`${depRel}\0${h}`);
}
if (!reason) {
parts.sort();
row.key = sha([globalSalt, m.project.name, rel, hashFile(rel), ...parts].join('\n'));
row.deps = parts.length;
}
if (!project.startsWith('unit')) row.why = 'not a unit project';
else if (!row.passed) row.why = 'did not fully pass';
else if (filtered) row.why = 'name-filtered run';
else if (source === null) row.why = 'test file unreadable';
else if (core.alwaysRuns(source)) row.why = 'spawns / computed import / glob';
// No tracker, no record: without it, a read made by a helper module is invisible.
else if (!Array.isArray(reads)) row.why = 'file reads not tracked';
else if (!graph) row.why = 'no ssr module graph';
if (row.why) continue;
const entries = new Set();
const own = core.closureOf(graph, m.moduleId);
if (own === null) {
row.why = 'test file not in module graph';
continue;
}
row.why = reason;
}
// Look up EVERYTHING before writing anything, so a file cannot "hit" on its own result.
for (const row of rows) {
row.wouldSkip = Boolean(row.key && existsSync(join(dir, 'pass', `${row.key}.json`)));
row.falseSkip = row.wouldSkip && !row.passed;
}
if (!filtered) {
for (const row of rows) {
if (!row.key || !row.passed || row.wouldSkip) continue;
const final = join(dir, 'pass', `${row.key}.json`);
const tmp = `${final}.${process.pid}.tmp`;
writeFileSync(tmp, JSON.stringify({ file: row.file, at: new Date().toISOString(), ms: row.ms }));
renameSync(tmp, final);
for (const id of own) entries.add(core.toRel(id, root));
let setupMissing = false;
for (const setup of m.project.config.setupFiles ?? []) {
const s = core.closureOf(graph, setup);
if (s === null) {
setupMissing = true;
break;
}
entries.add(core.toRel(setup, root));
for (const id of s) entries.add(core.toRel(id, root));
}
if (setupMissing) {
row.why = 'setup file not in module graph';
continue;
}
for (const p of reads) entries.add(core.toRel(p, root));
const kept = [...entries].filter((rel) => !core.isCoveredElsewhere(rel));
const key = core.keyFor({ salt, project, testRel, entries: kept, fingerprint });
core.writeRecord(dir, project, testRel, { key, entries: kept, at: new Date().toISOString(), ms: row.ms });
row.recorded = true;
}
const falseSkips = rows.filter((r) => r.falseSkip).map((r) => r.file);
if (falseSkips.length) {
writeFileSync(
core.trippedPath(dir),
JSON.stringify({ at: new Date().toISOString(), root, mode, falseSkips }, null, 2)
);
}
const skipped = state.skipped?.length ?? 0;
const summary = {
at: new Date().toISOString(),
mode,
root,
head: safeGit(root, ['rev-parse', 'HEAD']),
dirty: safeGit(root, ['status', '--porcelain']) !== '',
filtered,
files: rows.length,
cacheable: rows.filter((r) => r.key).length,
wouldSkip: rows.filter((r) => r.wouldSkip).length,
totalMs: Math.round(rows.reduce((n, r) => n + r.ms, 0)),
savedMs: Math.round(rows.filter((r) => r.wouldSkip).reduce((n, r) => n + r.ms, 0)),
falseSkips: rows.filter((r) => r.falseSkip).map((r) => r.file),
uncacheable: tally(rows.filter((r) => !r.key).map((r) => r.why?.replace(/:.*$/, ''))),
total: rows.length + skipped,
ran: rows.length,
skipped,
hitsRan: rows.filter((r) => r.wasHit).length,
recorded: rows.filter((r) => r.recorded).length,
ranMs: Math.round(rows.reduce((n, r) => n + r.ms, 0)),
hitMs: Math.round(rows.filter((r) => r.wasHit).reduce((n, r) => n + r.ms, 0)),
falseSkips,
notRecorded: tally(rows.filter((r) => r.why).map((r) => r.why)),
wallMs: Date.now() - this.startedAt,
};
appendFileSync(join(dir, 'shadow-ledger.jsonl'), JSON.stringify(summary) + '\n');
appendFileSync(join(dir, 'ledger.jsonl'), JSON.stringify(summary) + '\n');
const verify =
mode === 'shadow'
? `${summary.hitsRan} unchanged file(s) ran (shadow mode skips nothing)`
: `${summary.hitsRan} unchanged file(s) re-run to verify`;
console.log(
`[test-cache] shadow: ${summary.wouldSkip}/${summary.files} files would have been skipped ` +
`(${Math.round((summary.savedMs / Math.max(summary.totalMs, 1)) * 100)}% of worker time); ` +
`false skips: ${summary.falseSkips.length}`
`[test-cache] ${summary.total} test files: ${summary.ran} ran, ${skipped} skipped as unchanged ` +
`since they last passed. ${verify}; false skips: ${falseSkips.length}.`
);
if (falseSkips.length) {
console.error(
`[test-cache] 🔴 FALSE SKIP — the cache predicted these would pass and they did not:\n` +
falseSkips.map((f) => ` ${f}`).join('\n') +
`\n The cache is now TRIPPED: every run executes in full until ${core.trippedPath(dir)} is removed.`
);
}
}
}
function tally(values) {
const out = {};
for (const v of values) out[v ?? 'unknown'] = (out[v ?? 'unknown'] ?? 0) + 1;
for (const v of values) out[v] = (out[v] ?? 0) + 1;
return out;
}
function safeGit(root, args) {
try {
return execFileSync('git', args, { cwd: root }).toString().trim();
} catch {
return null;
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Decides, BEFORE a run starts, which test files the cache lets it skip. Vitest runs exactly the
* list `sort()` returns measured: a sequencer that dropped a deliberately failing file left the
* run green with that file never executed.
*
* A file is skippable when one of its recorded passes still matches: the record lists every path
* the file depended on last time it passed, and re-fingerprinting those paths now reproduces the
* recorded key. Reusing the OLD dependency list is sound, because the only way to gain a dependency
* is to edit a file already in the list and that edit changes the key.
*
* In `on` mode a random sample of the hits runs anyway. If a sampled hit then fails, the key missed
* something; the reporter trips the cache off and says so. The sample is the standing check that
* the cache is still telling the truth without it a false skip is a green run nobody can see.
*/
import { BaseSequencer } from 'vitest/node';
import * as core from './core.mjs';
const SAMPLE_RATE = Number(process.env.CIVITAI_TEST_CACHE_SAMPLE ?? 0.05);
export const runKey = (spec) => `${spec.project.name}\0${spec.moduleId}`;
export default class TestCacheSequencer extends BaseSequencer {
async sort(files) {
const state = {
mode: core.mode(),
total: files.length,
skipped: [],
hits: new Set(),
sampled: new Set(),
tripped: null,
};
globalThis.__civitaiTestCache = state;
// A name filter runs part of each file, so a partial run must not be traded for a skip either.
if (state.mode === 'off' || this.ctx.config.testNamePattern) return super.sort(files);
try {
const root = this.ctx.config.root;
const dir = core.cacheDir(root);
state.tripped = core.tripped(dir);
const fingerprint = core.makeFingerprinter(root);
const salt = core.globalSalt(root, this.ctx.version, fingerprint);
const keep = [];
for (const spec of files) {
const project = spec.project.name;
const testRel = core.toRel(spec.moduleId, root);
const hit =
project.startsWith('unit') &&
testRel !== null &&
core
.recordsFor(dir, project, testRel)
.some((rec) => core.keyFor({ salt, project, testRel, entries: rec.entries, fingerprint }) === rec.key);
if (!hit) {
keep.push(spec);
continue;
}
state.hits.add(runKey(spec));
if (state.mode === 'shadow' || state.tripped || Math.random() < SAMPLE_RATE) {
if (state.mode === 'on' && !state.tripped) state.sampled.add(runKey(spec));
keep.push(spec);
continue;
}
state.skipped.push(testRel);
}
if (state.mode === 'on') {
console.log(
state.tripped
? `[test-cache] TRIPPED since ${state.tripped.at} — running everything. ` +
`Delete ${core.trippedPath(dir)} once the cause is understood.`
: `[test-cache] running ${keep.length} of ${files.length} test files: ` +
`${state.skipped.length} skipped as unchanged since they last passed, ` +
`${state.sampled.size} unchanged ones re-run to verify the cache.`
);
}
// A run the cache skipped ENTIRELY must still pass. Vitest otherwise reports "No test files
// found" and exits 1 — measured on the fixture: a fully cached run exited 1 with nothing
// failing. Set only here, so a run that genuinely matched no files still fails as before.
if (keep.length === 0 && state.skipped.length > 0) this.ctx.config.passWithNoTests = true;
return super.sort(keep);
} catch (err) {
console.error(`[test-cache] could not read the cache, running everything: ${err?.stack ?? err}`);
state.skipped = [];
state.sampled = new Set();
return super.sort(files);
}
}
}
+12 -1
View File
@@ -2,6 +2,9 @@ import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import path from 'path';
import { mode as testCacheMode } from './scripts/test-cache/core.mjs';
import TestCacheSequencer from './scripts/test-cache/sequencer.mjs';
// Worker count is UNCAPPED by default — Vitest's own resolution applies untouched (`cpus - 1` in
// run mode, `floor(cpus / 2)` in watch; the browser pool sizes itself at `min(12, cpus - 1)`).
//
@@ -175,7 +178,12 @@ const unitTestConfig = {
globals: true,
environment: 'node' as const,
exclude: ['node_modules', 'tests/**/*'], // Exclude Playwright tests
setupFiles: ['src/__tests__/setup.ts'],
// The fs tracker records which files each test reads, for the result cache. It is loaded only
// when the queue turned the cache on (CIVITAI_TEST_CACHE) and never in CI — see scripts/test-cache.
setupFiles: [
'src/__tests__/setup.ts',
...(testCacheMode() !== 'off' ? ['scripts/test-cache/fs-tracker.mjs'] : []),
],
// Several unit tests cold-`await import(...)` a large Next API-page / service
// module graph (mocked I/O, but a real ~916s TS transform). With the suite's
// worker pool saturated, that legitimate cold transform races for CPU and
@@ -438,6 +446,9 @@ export default defineConfig({
resolve: { alias },
test: {
maxWorkers,
// Root-level because vitest builds ONE sequencer for the whole run. It only ever skips files in
// the unit projects, and only when the cache is on.
...(testCacheMode() !== 'off' ? { sequence: { sequencer: TestCacheSequencer } } : {}),
projects: [
// The `packages/*` suites, referenced by their OWN config files rather than
// re-declared here. Until this line existed, nothing in CI invoked them: the `unit`