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=` 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 ~9–16s 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(''` 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//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 (` (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 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 ` 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/**'], }, }, });