Files
civitai__civitai/vitest.config.mts
T
Justin Maier 2df0241a4f feat(test-cache): skip unit test files unchanged since they last passed (#4971)
* feat(test-cache): shadow-record what a content-keyed result cache would skip

Phase 1 of a test-result cache: nothing is skipped. A vitest reporter keys
each test file on the content of every first-party module it depends on, plus
the inputs no import records (lockfile, vitest config, tsconfig, node,
platform), and records files that passed in full. A later run whose key
matches is one the cache WOULD skip; if that file then fails, the key missed a
dependency and the run is logged as a false skip.

Dependencies come from vite's server-side ssr module graph, not
diagnostic().importDurations: on a fixture, importDurations missed an
`await import()` made inside a test body, and the ssr graph caught it cold and
warm. The graph also leaves out the subtree behind a vi.mock factory, which
never executes, while keeping the mocked module itself.

The store lives in the COMMON git dir, shared by every worktree, and keys use
repo-relative paths, so one tree's green run covers every tree whose files are
identical.

Files that read the filesystem, spawn processes, or import a non-literal
specifier always run (243 of 1,880, 6.5% of modelled worker time).

The queue gains a hot-configurable cache mode (`test config --cache shadow`,
TEST_CACHE_MODE in the skill .env). In shadow mode a queued unit run gets the
primary checkout's reporter appended, plus `--reporter=default` when the caller
named none, so turning it on never strips a run's normal output.

Fixture sequence, each step as predicted: cold 0 skipped; unchanged 1/1;
runtime-imported dep changed 0; unchanged again 1/1; dep behind a mock changed
still 1/1; env-driven failure with an unchanged key flagged as 1 false skip.
89-file yardstick, cold then warm: 89/89 passed both times, warm would skip
85/89 (98% of worker time), 0 false skips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 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>

* fix(test-cache): close three false-skip paths found by review

Adversarial review of af72b46854 confirmed three ways a failing test could
be skipped green, each reproduced on a fixture:

- An input edited while the run was in flight was fingerprinted at the end
  and recorded as the version that passed. Records are now refused for any
  input modified after the run started (directories by their subtree).
- A run that failed on an unhandled error still recorded its files, because
  the module state stays "passed". Nothing is recorded from a run with
  unhandled errors, or an interrupted one.
- A computed import in a HELPER (`import(/* @vite-ignore */ file)`, the form
  pending-review-mute.test.ts uses) was invisible to the key. Every
  first-party module in the closure is now scanned, the comment form is
  matched, and importing child_process/worker_threads/cluster in the closure
  keeps a file uncached however it is called.

Also: a false skip now deletes that file's records, so clearing TRIPPED
cannot revive it; TRIPPED is written first and atomically, and an unreadable
marker reads as tripped; package.json files and pnpm's installed lock are in
the salt; a sibling that would shadow an import (foo.ts beside foo/index.ts)
changes the key; the sequencer skips nothing on a file-filtered run or when
the cache reporter is not loaded; messages go to stderr; a malformed sample
rate falls back to 5%; the fs tracker loads first and covers access/open/
realpath/readlink.

Two regressions inside this round, caught by a positive control that ordinary
tests still record: scanning the fs tracker's own closure (which imports
child_process) marked every test uncacheable, and a call-name pattern matched
`regex.exec(` in src/__tests__/setup.ts. The tracker is excluded as
instrumentation, and process use is detected by import, not call name.

Fixture battery, positive control first (recorded 2, then skipped 2): edited
mid-run not recorded and next run fails as it should; leaked rejection
blocks recording; helper computed import and namespaced child_process not
recorded; shadowing file re-runs; false skip trips and forgets. 89-file
yardstick: cold 63s, warm 14s, 84 skipped, 0 false skips; the two files kept
uncached are pending-review-mute (the reviewer's case) and a computed-import
hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(test-cache): close the false skips the second review found

Re-review of fbe832cbdc confirmed three more false skips and one breakage:

- A dependency deleted or renamed mid-run was recorded as `missing`, which
  the next run matched. changedSince now asks the parent directory, whose
  mtime moves on a removal, and a module that was in the graph but is gone
  at the end refuses the record outright.
- `createRequire(...)('child_process')`, `process.getBuiltinModule(...)` and
  `from"node:child_process"` walked past the import-syntax patterns. The
  module name is now matched as a string anywhere, plus the common spawn
  wrappers. The graph-level builtin check is deleted: builtins never enter
  vite's graph, so it could not fire.
- The tracker's wrappers dropped properties living on the function, so
  `fs.realpathSync.native` (called by next off-Windows) vanished with the
  cache on. Own properties are copied and `.native` is wrapped.
- An unhandled error now blocks only the file vitest attributes it to
  (VITEST_TEST_PATH, verified present on a leaked rejection); an
  unattributed one still blocks the run. The key is taken before the change
  check, closing the window between them. A TRIPPED rename that EPERMs
  writes in place instead of aborting before records are forgotten.

The reporter and sequencer now have their own tests, driven with fake
vitest objects (scripts/__tests__/test-cache-reporter.test.ts), led by a
positive control that an ordinary file IS recorded. 13 revert controls,
each red on its own named test. The changedSince test now actually moves
one input past the run start per case, and covers deletion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(test-cache): stop the round-2 fixes from making the cache inert

Third review of 3101975fe1 found no new false skips, but two regressions
from the previous round that left the cache recording NOTHING on the real
repo — every test ran, safely, and none was ever skipped:

- The process check matched a bare quoted 'cluster', an ordinary value in
  the Redis and telemetry code every test's setup reaches: 1880/1880 unit
  tests uncacheable (36/1880 without it). It now matches the module name
  only in import-shaped positions: from, import(, require(,
  getBuiltinModule(, and a call on a call (createRequire(...)('...')).
- The deletion check read a missing PARENT as "changed". Every test probes
  __snapshots__/<file>.snap in a directory that usually never existed, so
  every record was refused. It now asks the nearest existing ancestor,
  which still moves when a file or a whole subtree is removed.

The per-run memo on the change check is restored (~19s of synchronous work
at the end of a full run without it).

The fake-driven positive control stayed green through both, because the
fakes modelled neither the snapshot probe nor a setup closure mentioning
'cluster'. Added:
- a fake control shaped like a real file (snapshot probe + that closure);
- scripts/__tests__/test-cache-e2e.test.ts, which runs REAL vitest with the
  real sequencer, reporter and tracker over a one-file fixture twice and
  asserts recorded 1, then ran 0 / skipped 1 (3.8s).
Reverting either fix reddens both. The e2e test's first control did NOT
redden, which exposed that the tracker exclusion covered the whole
scripts/test-cache/ directory — including the fixture's own setup file. It
now excludes exactly scripts/test-cache/fs-tracker.mjs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 01:13:43 -06:00

561 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)`).
//
// The flat cap of 8 that lived here (#3900) existed because several agents each running a full
// suite at once saturated the box. The dev-server test queue now serialises `test:unit:run` at
// concurrency 1 (#3947), so the unit suite no longer competes with a second copy of itself.
//
// Set `VITEST_MAX_WORKERS=<n>` to size a run. Vitest reads that name itself, in `resolveConfig`,
// per project and AFTER this file — so the value here can only ever agree with it, never clamp it.
// In particular it does NOT respect the browser pool's `min(12, cpus - 1)` default: `getThreadsCount`
// returns `project.config.maxWorkers` unclamped, so a number above 12 raises the Chromium instance
// count past what upstream considers safe rather than lowering it.
//
// A run routed through the dev-server queue does not see the caller's environment at all — the
// daemon spawns it with its own — so cap those with the CLI flag instead, which is forwarded:
// `pnpm run test:unit:run --max-workers=8`.
//
// `undefined` is genuinely identical to omitting the key — every consumer is a truthiness / `??`
// check, and `configDefaults` has no `maxWorkers`.
// (Unrelated but adjacent: `test.poolOptions` was removed in Vitest 4, so `poolOptions.forks.maxForks`
// does nothing apart from logging a deprecation.)
// Read here as well as natively so the knob survives Vitest dropping its own read; today the two
// always resolve to the same number, so this cannot disagree with it.
const envMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? '', 10);
const maxWorkers = Number.isFinite(envMaxWorkers) && envMaxWorkers > 0 ? envMaxWorkers : undefined;
const componentMaxWorkers = maxWorkers;
// `component` runs in its own group. The throw this used to dodge — `groupSpecs` refusing two
// projects that share a group but resolve to different worker counts, reported as
// `Test Files: no tests` rather than as a failure — can no longer happen now that nothing here
// sets a per-project `maxWorkers` the others don't also get. What the group still buys is that a
// bare `vitest run` serialises the browser project against the node one instead of interleaving
// them; keep it for that, not for the throw.
//
// 🔴 `unit` and `unit-native` deliberately set NO per-project `maxWorkers`, and that is a trade
// rather than an omission. Per-project `maxWorkers` does apply at runtime — measured, not inferred
// from the types — but two projects with different counts must sit in different `sequence.groupOrder`
// values, and different groups run SERIALLY. So giving either project its own worker count costs the
// concurrency between them, which for a 1059/6 split is a bad exchange: the six-file project would
// gate the other 1059 rather than filling spare capacity beside it.
//
// Declared statically below AND re-asserted here, deliberately. Static alone is not enough: a CLI
// `--sequence.*` flag REPLACES `test.sequence` wholesale (cliOverrides is a spread, not a merge)
// and takes `groupOrder` with it. The plugin alone is not enough either: this file is outside
// tsconfig's `include`, so nothing typechecks the hook name — a typo would leave the plugin inert
// and silent. Keeping both means one has to fail before anything breaks.
//
// Known gap, unfixable by pinning: `--sequence.groupOrder=1` moves the OTHER projects onto this
// same value, and no constant can dodge that. No script or workflow passes it.
const COMPONENT_GROUP_ORDER = 1;
const componentGroupOrderPlugin = {
name: 'civitai:component-group-order',
configureVitest({ project }: { project: any }) {
project.config.sequence.groupOrder = COMPONENT_GROUP_ORDER;
},
};
// Same both-belts reasoning one project down, for the setting whose failure is a SIGSEGV rather than
// an empty run. `unit-native`'s static `pool: 'forks'` loses to a CLI `--pool=threads`, which is the
// one flag someone experimenting on `unit` will reach for — and the six sharp files would follow it
// onto a thread pool and crash AFTER printing a green summary. `configureVitest` runs after
// `resolveProjects(cliOptions)`, and `getFilePoolName` reads `project.config.pool` when each
// specification is created, so re-asserting here outranks the flag.
const NATIVE_POOL = 'forks' as const;
const nativePoolPlugin = {
name: 'civitai:unit-native-pool',
configureVitest({ project }: { project: any }) {
project.config.pool = NATIVE_POOL;
},
};
// Mirror the workspace `@civitai/*` package mappings from tsconfig.json `paths` so Vitest
// (which doesn't read tsconfig paths, and these packages aren't symlinked into the root
// node_modules) resolves them the same way the app build does. `@civitai/auth` is omitted —
// it IS symlinked in node_modules and resolves via its own exports map. Subpath regex must come
// before the bare entry so `@civitai/redis/client` doesn't get caught by the bare `@civitai/redis`.
const civitaiWorkspacePkgs = [
'db-schema',
'db',
'redis',
'clickhouse',
'axiom',
// `@civitai/flipt` (packages/civitai-flipt) backs src/server/flipt/client.ts, which the
// feature-flag and image suites pull in. Same story as the others: not symlinked into root
// node_modules, so without this alias those suites fail to collect.
'flipt',
'telemetry',
'brand',
// `@civitai/notifications` (packages/civitai-notifications) is re-exported by
// src/server/common/enums.ts; without this alias Vitest can't resolve it and
// the whole server suite cascades (enums.ts → BlockRegistry undefined → …).
'notifications',
// `@civitai/buzz` (packages/civitai-buzz) is imported by
// src/shared/constants/buzz.constants.ts + src/server/services/buzz.service.ts —
// both pulled in transitively by blocks.router.ts and model-version.service.ts.
// Same story as notifications: not symlinked into root node_modules, so without
// this alias every suite touching the buzz chain fails to collect.
'buzz',
];
const civitaiAlias = civitaiWorkspacePkgs.flatMap((p) => {
const src = path.resolve(__dirname, `packages/civitai-${p}/src`).replace(/\\/g, '/');
return [
{ find: new RegExp(`^@civitai/${p}/(.*)$`), replacement: `${src}/$1` },
{ find: `@civitai/${p}`, replacement: `${src}/index` },
];
});
const alias = [{ find: '~', replacement: path.resolve(__dirname, './src') }, ...civitaiAlias];
// Browser-mode (`component` project) alias: stub the native `sharp` module.
// A few `.browser.test.tsx` tests import a Next *page* to render its client shell;
// the page's `getServerSideProps` transitively pulls a server service that does
// `import sharp from 'sharp'`. Next strips that server-only graph from real client
// builds, but Vitest's browser build does not — so esbuild's optimizeDeps scan
// follows the import into sharp and dies bundling its native
// `require('../build/Release/sharp-*.node')`, killing the WHOLE component suite
// before any test runs. (The tests `vi.mock` server-side-helpers, but that is a
// runtime interception and can't stop the build-time static scan.) The `unit`
// (node) project keeps the real sharp. Must precede the `~` entry so it wins.
const componentAlias = [
{ find: /^sharp$/, replacement: path.resolve(__dirname, 'test/stubs/sharp.ts') },
...alias,
];
// `sharp` 0.32.6's native addon is not context-aware, so a `worker_threads` worker that has run a
// libvips operation segfaults when the thread is torn down — AFTER the tests pass and the summary
// prints, which is why it surfaces as an exit code with no failing test. That takes the whole run
// down, so it is what keeps the unit suite on the slow `forks` pool.
//
// It is a RACE, not a threshold. Measured on these six files alone, three repeats per width:
// vmThreads crashed 3/3 at 1 worker, 2/3 at 2, 1/3 at 3, 0/3 at 4; the `threads` pool crashed at
// every width tried. So a clean run proves nothing about the next one, and CI's 4 vCPU (~3 workers)
// sits squarely in the band that failed 1-in-3. Do not read a green run here as safety.
//
// IMPORTING sharp is harmless — only executing an operation arms it. 100 test files carry sharp in
// their static closure; exactly these six call it. That set was measured by aliasing sharp to a
// recording proxy and running all 100 under `forks`
// (`scripts/test-perf/sharp-probe-config.mts`), not by grepping and not by a crash-scan — with a
// race, "ran alone and didn't crash" builds the list out of the files that got lucky.
//
// Regenerate after touching any sharp call site:
// node node_modules/vitest/vitest.mjs run --config scripts/test-perf/sharp-probe-config.mts \
// --project unit --pool=forks $(cat .test-perf/sharp-candidates.txt)
//
// The upstream fix is sharp >= 0.33, which makes the addon context-aware. That is a native-dep bump
// on a repo where a playwright bump cost 59 CI specs, so it is deliberately not bundled with this.
//
// 🔴 The split exists; `unit` stays on `forks` anyway. Two reasons, and the second is the blocker:
// `threads` measured 1.04x at 4 workers and 0.94x at 16 on the 90-file yardstick — no win at either
// width. And it segfaults MID-RUN on the full suite at roughly 1 in 4, after completing hundreds of
// files cleanly, with no `unit-native` file having run — so a second crasher exists that is not
// sharp and is not diagnosed. Unconfirmed hypothesis: `threads` puts every worker in ONE process,
// so 31 registries of ~1,320 modules share one address space where `forks` gives each its own.
// Unconfirmed because the reporter records no heap figures for these runs.
//
// So switching `unit` to `threads` buys nothing and costs an undiagnosed intermittent SIGSEGV.
// What the split does buy: the sharp crash is deterministic and gone, and anyone who wants to
// EXPERIMENT with `threads` (`--pool=threads`) no longer has to fight it as well.
const sharpExecutingTestFiles = [
'src/server/services/blocks/__tests__/block-image-upload.persist.test.ts',
'src/server/services/blocks/__tests__/listing-asset-upload-integrity.test.ts',
'src/server/services/blocks/__tests__/listing-meta.datauri-raster.integration.test.ts',
'src/server/services/blocks/__tests__/offsite-listing.service.test.ts',
'src/server/utils/__tests__/listing-asset-exif-fixture.test.ts',
'src/server/utils/__tests__/stored-image-probe.test.ts',
];
// Shared by `unit` and `unit-native`, which differ ONLY in pool and in which files they claim.
// Every other setting has to stay identical or the split changes behaviour as well as scheduling.
const unitTestConfig = {
globals: true,
environment: 'node' as const,
exclude: ['node_modules', 'tests/**/*'], // Exclude Playwright tests
// 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.
// Tracker FIRST, so reads made while the main setup file loads are recorded too.
setupFiles: [
...(testCacheMode() !== 'off' ? ['scripts/test-cache/fs-tracker.mjs'] : []),
'src/__tests__/setup.ts',
],
// 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
// overran the old 10s default — a PASS→FAIL that tracked CI load, not code.
// 60s absorbs that contention while still bounding a genuine hang (these are
// mocked-I/O tests; nothing should legitimately approach a minute).
//
// 🔴 But do NOT treat this ceiling as the place to solve that cost. A cold
// `await import(...)` reached from a TEST BODY is charged to that ONE test's
// budget, so the file's whole transform lands inside a single 60s clock:
// get-models-raw.transient-503 spent 99.9% of its runtime in one test that
// way (2726ms of a 2730ms file under a 4-CPU quota) and went red purely on
// ambient runner speed. Hoist the import to MODULE SCOPE instead — `vi.mock`
// is hoisted above imports, so the mocks still apply, and the transform then
// lands in Vitest's COLLECTION phase, which no timeout bounds (Vitest has
// only testTimeout / hookTimeout / teardownTimeout). Where a module genuinely
// must load per-suite, a `beforeAll` with an explicit long timeout is the
// fallback (see prisma-inconsistent-orphan-relations). Raising this number
// only moves the cliff.
testTimeout: 60000,
// Same cold-`await import()` graph is paid in some suites' beforeAll/beforeEach
// (e.g. file-download-lookup, listForModel.behavior). Vitest's default
// hookTimeout is 10s — too tight for that transform on a saturated CI box — so
// match testTimeout. Without this a hoisted import flakes the hook instead.
hookTimeout: 60000,
deps: {
inline: [/@civitai\/client/],
// Every test file gets a fresh module registry — under `forks` with `isolate: true` it is
// literally a fresh child process per file (measured: N files at maxWorkers=1 give N distinct
// pids), so Node's module cache dies with it and each externalised package is imported cold
// once per file that reaches it. Pre-bundling collapses a package's many-hundred-file native
// load into one chunk, paid once for the run.
//
// 🔴 The list is confined to packages NOTHING mocks, and that is load-bearing rather than
// conservative. Pre-bundling wraps a package as a CJS-interop chunk, so a `vi.mock` factory
// that returns only named exports stops satisfying its consumers:
// Error: [vitest] No "default" export is defined on the "redis" mock.
// The `importOriginal` form does NOT protect against this — it guards a different failure
// (exports going missing as the graph grows). Measured: adding `redis` and `@aws-sdk/client-s3`
// here takes four mock-holding files from 92 tests passing to 7 collected.
//
// Before adding a package, check `vi.mock('<pkg>'` across `src`, `test`, `packages` and `apps`
// returns nothing — a subpath form (`vi.mock('pg/lib/x')`) counts. The four excluded on those
// grounds:
// `redis`, `@aws-sdk/client-s3`, `@aws-sdk/lib-storage` (5 / 2 / 3 mocking files) — worth
// ~275s, and need `default` added to six mock factories first.
// `pg` (2 mocking files, both in `packages/civitai-db`) — dropped from this list for exactly
// that reason, not because it is cheap.
//
// 🔴 A zero-mocker package can still be unsafe, for a SECOND reason: a package that loads a
// non-JS SIDECAR at runtime breaks when it is relocated. `@electric-sql/pglite` passes the
// mock check and was still reverted out of this list — pre-bundling moves the module into
// `node_modules/.vite/vitest/<hash>/deps_ssr/` while its WASM payload stays behind, so every
// PGlite-backed suite dies on
// ENOENT: open '…/deps_ssr/pglite.data'
// Measured: 7 `*.behavior.test.ts` files went from 87 passing to 0, reported as SKIPPED rather
// than failed, so the suite total stayed at 22,636 while 87 fewer tests actually executed.
// Compare the PASSED count, never the total, when changing this list.
// The three biggest fan-in packages measured over the test-reachable graph are blocked the same
// way and are the follow-up this list is building toward:
// `@tabler/icons-react` 5,952 package files / 166 importers — 2 mocking files
// `next` 3,459 / 114 — 10 mocking files
// `@mantine/core` 1,180 / 249 — 4 mocking files
optimizer: {
ssr: {
enabled: true,
include: [
'lodash-es',
'googleapis',
'@tiptap/html',
'@axiomhq/axiom-node',
'@aws-sdk/s3-request-presigner',
// Added below: each verified to have ZERO `vi.mock` callers anywhere in the workspace,
// and the whole suite re-run to confirm the PASSED count is unchanged. Not the total —
// see the PGlite note above, where the total stayed put while 87 tests stopped running.
//
// 🔴 A THIRD HAZARD CLASS, beyond mock-callers and non-JS sidecars: SUBPATH DUAL-INSTANCE.
// `noDiscovery: true` + `entries: []` means only the BARE specifier is pre-bundled, so a
// subpath import still resolves through the module runner to the original files and the
// two forms become distinct copies. Live here today: `zod/v4` (4 files), `zustand/*`
// (50), `react-dom/*` (26). No failure observed — but `instanceof` across the two copies
// is the shape that breaks, e.g. `error instanceof z.ZodError` in
// src/pages/api/admin/manage-sanity-checks.ts. Check subpath usage before adding a
// package whose identity is compared with `instanceof`.
'zod',
// 'react' is NOT here on purpose. Vitest hardcodes
// `exclude = ['vitest', 'react', 'vue']` in resolveOptimizerConfig and filters `include`
// against it, so an entry for it is silently dropped — measured: `deps_ssr/` contains
// react-dom.js but no react.js. Listing it would read as a delivered win that never
// happened.
'react-dom',
'zustand',
'immer',
'clsx',
'uuid',
'prom-client',
'@tanstack/react-query',
'@paddle/paddle-node-sdk',
],
},
},
},
// A transform cache on disk, keyed per module and shared between separate `vitest run`
// invocations — where `node_modules/.vite` only collapses the optimizer's work, this survives the
// process boundary that `pool: 'forks'` + `isolate: true` creates for every single test file.
//
// Scoped to `unit`/`unit-native` rather than set at the root `test` block on purpose. A root-level
// value is inherited by EVERY project (vitest resolves it into each project's config even without
// `extends: true`), which would silently opt in the browser `component` project and the
// `packages/*` + `apps/*` suites — none of which are covered by the run that verified this.
// Widening it is a follow-up with its own verification, not a freebie.
//
// Staleness is self-correcting rather than something to remember: the cache carries a format
// version, and `ensureCacheIntegrity()` hashes the lockfile on startup and nukes the whole cache
// when it moves, so a dependency bump can't leave a stale transform behind. It writes to
// `node_modules/.experimental-vitest-cache`, so it is gitignored with the rest of `node_modules`.
experimental: { fsModuleCache: true },
};
// ─────────────────────────────────────────────────────────────────────────────
// SHARED BY BOTH BROWSER PROJECTS (`component` and `geometry`).
//
// 🔴 ONE COPY, DELIBERATELY. The two differ in exactly two settings — their glob
// and their setup file — and every other browser setting has to stay identical or
// the split changes behaviour as well as which cascade is loaded. A duplicated
// `optimizeDeps.include` in particular would regenerate the cold-cache reload
// failure documented on it the first time one copy is updated and the other is not.
// ─────────────────────────────────────────────────────────────────────────────
// dedupe React so a transitive dep can't pull a second copy — a second
// React makes `useContext` read a null context and crashes some Mantine
// components (e.g. @mantine/dropzone) in browser mode, notably on a COLD
// optimizeDeps cache (fresh CI runs). Canonical fix; protects every
// browser test from this class of dual-React crash.
const browserResolve = { alias: componentAlias, dedupe: ['react', 'react-dom'] };
// Pre-bundle deps the browser setups mock/import so Vitest doesn't
// discover them mid-run and trigger a "Vite unexpectedly reloaded a
// test" warning (a flake vector).
//
// On a COLD optimizeDeps cache (every fresh CI/preview run), Vite was
// discovering `vitest-browser-react` + `react/jsx-dev-runtime` only when
// `test/component-setup.tsx` first imported them — mid-run — and reloading.
// The reload tears down the module context while the setup is still
// importing `vitest-browser-react`, so that package evaluates OUTSIDE a live
// vitest runner → "Vitest failed to find the runner" → "Failed to import test
// file test/component-setup.tsx" → EVERY browser test fails to load. (Warm
// cache hid it: the 2nd local run always passed.) Pre-bundling them here makes
// the optimize pass happen BEFORE the run starts, so there's no mid-run reload.
const browserOptimizeDeps = {
include: [
'next/router',
'vitest-browser-react',
'react/jsx-dev-runtime',
'react/jsx-runtime',
// `react-dom/server` — used by the SSR→hydrate tests
// (`*.ssrHydration.browser.test.tsx`) to produce real server HTML and
// hydrate it. WITHOUT this entry Vite discovers it mid-run and emits a
// SEPARATE optimized chunk that carries its OWN copy of `react`, so
// `ReactCurrentDispatcher.current` is null inside it and every hook call
// during `renderToString` throws
// `Cannot read properties of null (reading 'useState')` — the dual-React
// failure the `dedupe` above exists to prevent, arriving through the
// optimizer rather than through a transitive dependency. Listing it here
// pre-bundles it in the same pass as `react`/`react-dom`, so all three
// share one instance.
'react-dom/server',
],
// `@vitest/browser` seeds optimizeDeps.entries from EVERY browser test file
// (globTestFiles), not just the one you ran. The review app-listing browser tests
// (src/tests/pages/apps/review/review-{detail-page,queue-nav}.browser.test.tsx, from
// #3298 on main) each import their page, which pulls in `createServerSideProps` ->
// `appRouter` -> app-listing-assets.service.ts's
// `await import('sharp')`. Those tests `vi.mock` `server-side-helpers` so `sharp` is
// never evaluated at runtime — but esbuild's static scan follows the import anyway and
// can't pre-bundle sharp's native binding (a template-literal `require` of a `.node`
// file), failing the whole browser project with "No loader is configured for '.node'
// files" regardless of which test you targeted. Exclude it; no browser test needs it.
exclude: ['sharp'],
};
// 🔴 A FACTORY, NOT A SHARED OBJECT — and the difference is observable.
// Vitest STAMPS each browser instance with a name derived from the project that
// owns it (`<project> (chromium)`). Handing two projects the SAME `instances`
// array therefore lets the first project's label survive onto the second's
// output: with a shared object the `geometry` project's failures printed under
// `|component (chromium)|`, which is a run report naming the wrong tier — the
// most expensive kind of wrong, because it points every reader at the wrong
// setup file. Each project gets its own object.
const browserModeOptions = () => ({
enabled: true as const,
// `--no-sandbox` + `--disable-dev-shm-usage`: required to launch
// Chromium as root inside the Tekton CI container (node:20 pod runs
// as UID 0; without --no-sandbox Chromium refuses to start, and the
// container's small /dev/shm crashes it without --disable-dev-shm-usage).
// Harmless locally.
// CI installs Playwright's own Chromium into the image (env unset),
// so it resolves by revision as normal. `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`
// is the escape hatch for a host whose browser bundle does not carry the
// revision this playwright release pins (the NixOS
// `PLAYWRIGHT_BROWSERS_PATH` case) — it bypasses the revision lookup.
// The better fix is to point PLAYWRIGHT_BROWSERS_PATH at a bundle whose
// version EQUALS this repo's `playwright` pin (1.57.x → chromium-1200),
// rather than moving the pin; see CLAUDE.md "Browser/component tests on
// NixOS". A mismatch does not say "no browser" — it collects every file
// and executes none, which reads as a broken suite.
provider: playwright({
launchOptions: {
args: ['--no-sandbox', '--disable-dev-shm-usage'],
...(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH }
: {}),
},
}),
headless: true as const,
instances: [{ browser: 'chromium' }],
});
/** Everything a browser project needs outside its own `test` block. */
const browserProjectShell = () => ({
resolve: { ...browserResolve },
plugins: [componentGroupOrderPlugin],
optimizeDeps: { ...browserOptimizeDeps },
});
/**
* Everything both browser projects put INSIDE `test`, minus `name`, `include` and
* `setupFiles`.
*
* 🔴 BOTH PROJECTS TAKE THE SAME `maxWorkers` AND THE SAME `groupOrder`, AND THAT
* PAIRING IS REQUIRED, NOT COSMETIC. `groupSpecs` refuses two projects that share a
* `sequence.groupOrder` but resolve to different worker counts, and it reports that
* refusal as `Test Files: no tests` rather than as a failure — the reassuring-zero
* shape. Sharing one object is what makes them impossible to drift apart.
*/
const browserTestShell = () => ({
maxWorkers: componentMaxWorkers,
// See componentGroupOrderPlugin — this is the static half. Costs only a bare `vitest run`,
// which serialises the browser projects against the node ones instead of interleaving
// them; CI and every script select one project at a time.
sequence: { groupOrder: COMPONENT_GROUP_ORDER },
globals: true as const,
browser: browserModeOptions(),
});
// Four Vitest projects sharing one config/runner:
// - `unit` = the node-env suite, minus the six sharp-executing files.
// - `unit-native` = those six files, on `forks`, for the reason above.
// - `component` = browser-mode (real Chromium via Playwright) for React
// components/widgets. Distinct `.browser.test.tsx` glob so the
// unit project never boots a browser (its include is `.test.ts`
// only, so `.tsx` is already excluded — the glob is explicit
// belt-and-suspenders). See `test/component-setup.tsx`.
// - `geometry` = browser-mode with the REAL app cascade loaded and an EXPLICIT
// viewport, for tests that assert PIXELS. Distinct
// `.geometry.test.tsx` glob, disjoint from both of the above. See
// `test/geometry-setup.tsx` for why it is a second project rather
// than a change to the shared one.
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`
// project's `include` is root-relative (`src/**`, `scripts/**`), and CI runs
// `vitest run --project unit`, so ~330 tests across nine workspace packages ran only
// for whoever remembered `pnpm --filter <pkg> test` by hand. That is how the
// schema-drift detector (#3591) shipped with 81 tests CI never executed.
//
// Globbed on the CONFIG FILE, not the directory. A bare `packages/*` glob would also
// adopt the packages that have no vitest config, and Vitest would give each a
// default config whose `include` (`**/*.{test,spec}.?(c|m)[jt]s?(x)`) is not the
// include those packages were written against.
//
// Project names come from each package's `package.json` `name` (`@civitai/auth`,
// `@civitai/db-schema`, ...), because none of the package configs set `test.name`.
// That is what `--project '@civitai/*'` selects; see the `test:packages:run` script.
// Keeping them as separate projects (rather than folding their globs into `unit`)
// preserves each package's own config — `@civitai/db-queries`, for instance, sources
// DATABASE_URL from the root `.env` so its DB-backed tier self-skips without one.
'packages/*/vitest.config.ts',
'packages/*/vitest.config.mts',
// The `apps/*` suites, on exactly the same footing and for exactly the same reason:
// nothing in CI invoked them either. Same CONFIG-FILE glob, same rationale — a bare
// `apps/*` glob would adopt any app that has no vitest config and hand it a default
// `include` it was never written against. Every app carries one today, but the glob
// stays keyed on the config file so the next app added is opted in deliberately.
//
// Unlike the packages, these set `test.name` themselves (`app:auth`,
// `app:notifications`, ...) rather than inheriting their `package.json` name. That is
// load-bearing, not cosmetic: every app is ALSO published as `@civitai/*`
// (`@civitai/auth-app`, and `@civitai/orchestrator-gateway` with no suffix at all), so
// on the default naming there is no pattern that selects apps without packages or
// packages without apps — `--project '@civitai/*'` would silently start sweeping the
// apps into the "Package unit tests" job. An `app:` prefix keeps the two selectors
// disjoint and structural. Drop a `name` and that app falls back into the packages
// bucket; scripts/ci/assert-workspace-suites-ran.mjs fails the apps job when it does.
'apps/*/vitest.config.ts',
'apps/*/vitest.config.mts',
{
resolve: { alias },
test: {
...unitTestConfig,
name: 'unit',
// `scripts/` is included so the typecheck wrapper's outcome
// classifier (scripts/typecheck.mjs) is covered — it is the thing that
// decides whether a run counts as a pass, so it needs a suite of its
// own rather than a one-off manual check.
include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'],
// The six sharp files are excluded rather than merely routed elsewhere, so that
// `--project unit <a sharp file>` reports "No test files found" instead of running it
// on `threads` and segfaulting. A legible empty run beats an exit code with no failing
// test — which is what the crash looks like from the outside.
exclude: [...unitTestConfig.exclude, ...sharpExecutingTestFiles],
},
},
{
resolve: { alias },
plugins: [nativePoolPlugin],
test: {
...unitTestConfig,
name: 'unit-native',
include: sharpExecutingTestFiles,
// Pinned, not inherited: `unit` may be pointed at `threads` for an experiment, and these
// six must not follow it there. Every process-based pool survives the sharp teardown
// (`forks` and `vmForks` both measured clean); every thread-based one races and loses.
// Declared here AND re-asserted by `nativePoolPlugin`, for the reason given at its
// definition: a static value alone loses to a CLI `--pool`, and a plugin alone is
// unchecked by anything (this file is outside tsconfig's `include`).
pool: NATIVE_POOL,
},
},
{
...browserProjectShell(),
test: {
...browserTestShell(),
name: 'component',
include: ['src/**/*.browser.test.tsx'],
// process-shim MUST come first (no imports) so it runs before any
// component's module graph reads `process.env` at import time.
setupFiles: ['test/browser-process-shim.ts', 'test/component-setup.tsx'],
},
},
{
// The GEOMETRY tier. Same browser, same shell, different cascade.
//
// 🔴 THE GLOB IS DISJOINT FROM EVERY OTHER PROJECT'S, ON PURPOSE.
// `component` claims `src/**/*.browser.test.tsx` and `unit` claims
// `src/**/*.test.ts` — `.ts`, so `.tsx` is already outside it. Nothing
// that runs today changes project and no file is collected twice; a
// `.geometry.browser.test.tsx` spelling would have been claimed by BOTH
// browser projects and run under two different cascades, which is the one
// outcome that would make a red here uninterpretable.
...browserProjectShell(),
test: {
...browserTestShell(),
name: 'geometry',
include: ['src/**/*.geometry.test.tsx'],
setupFiles: ['test/browser-process-shim.ts', 'test/geometry-setup.tsx'],
},
},
],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/server/services/**', 'src/server/jobs/**'],
},
},
});