Files
civitai__civitai/vitest.config.mts
T
Zachary Lowden c318ef1fb8 fix(typecheck): make a crashed typecheck report as crashed, not as clean (#3619)
* fix(typecheck): make a crashed typecheck report as crashed, not as clean

`pnpm run typecheck` was `cross-env NODE_OPTIONS="--max_old_space_size=8192"
tsc --noEmit`. When the heap cap is too small for the program graph, V8 aborts
part way through checking, so tsc emits ZERO diagnostics and dies. cross-env
normalises the SIGABRT to exit 1, and V8's explanation goes to stderr — so a
caller that captures stdout gets an empty log, a bare non-zero exit, and no
type errors anywhere in it.

That is indistinguishable from a clean pass to anything that judges the run by
its output, which is what people and scripts actually do (a clean run also
prints nothing). Reproduced with a deliberate `const x: number = 'nope'` in
`src/`: at a 4096 MB cap the run reported 0 errors and hid it completely; the
same tree at 8192 MB reported it.

Measured cold on a clean checkout, with that error in place as a visibility
control:

  node 24.18.1  4096 -> OOM/0 diags   4608 -> OOM/0 diags
                5120 -> pass/found    8192 -> pass/found
  node 22.22.2  6144 -> pass/found    8192 -> pass/found

So the current 8192 is NOT at the cliff — the cliff is between 4608 and 5120,
and 8192 carries ~1.6x headroom. The number is left alone deliberately: the CI
runner has 16 GB, and a cap near that trades a self-describing V8 abort for a
kernel OOM-kill, which says less. Raising it would only move the cliff anyway.

What changes is that crossing the cliff becomes loud. `scripts/typecheck.mjs`
runs tsc and classifies the outcome:

  - clean            -> prints an explicit "typecheck: OK" line, so silence is
                        no longer what a pass looks like
  - type errors      -> passed through untouched, exit code preserved
  - crashed          -> a CRASHED banner naming the cause, on stdout AND stderr
                        (the original blind spot was a stdout-only capture),
                        plus a ::error:: annotation under Actions
  - exit 0 w/ diags  -> treated as a crash rather than trusted

Heap exhaustion, an outside kill (out of system RAM / a container limit) and an
unexplained abort are named separately, because the fix differs — an outside
kill wants a LOWER cap, not a higher one. The cap is passed as an argv flag
rather than via NODE_OPTIONS so an inherited NODE_OPTIONS cannot override it.

Override per-run with TYPECHECK_HEAP_MB=<mb>.

Covered by scripts/__tests__/typecheck.test.ts, which drives the classifier with
stub typecheckers (sub-second, vs minutes for a real run). Each of the five
cases was mutation-checked against the wrapper: 6/6 mutations killed, each by
its own test. One mutation initially SURVIVED and exposed a real gap in the
test — the crash banner is written to stderr, so asserting only on stdout let a
grep-poisoning regression through; both streams are asserted now.

CI already invoked this via `pnpm run typecheck` and so inherits the wrapper;
the step carries a comment against being "simplified" back to a bare tsc.

* fix(husky): stop the pre-push hook echoing success over a failed typecheck

The hook was:

    npm run typecheck

    echo "Typecheck successful"

`sh` without `set -e` runs the next line regardless of what the previous one
returned, and a script's exit status is its last command's — so the `echo`
became the hook's verdict. A failing typecheck on `main` printed "Typecheck
successful" and the push went through.

Measured against the real hook in a throwaway repo on `main`, with a stub `npm`
whose exit code is controlled:

    npm exit    hook exit (before)    hook exit (after)
       0              0                     0
       1              0                     1
     134              0                   134

Before, all three printed "Typecheck successful". The 134 row is the case this
matters most for: that is V8 aborting on heap exhaustion, which emits no
diagnostics at all, so the hook was echoing success over a typecheck that had
not merely failed but never finished. The failure message points at
scripts/typecheck.mjs, which distinguishes the two.

The branch/username guard above is unchanged, and still makes the hook a no-op
off `main`.
2026-08-04 13:48:02 -05:00

180 lines
9.9 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';
// 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',
'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,
];
// Two Vitest projects sharing one config/runner:
// - `unit` = the existing node-env suite, unchanged.
// - `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`.
export default defineConfig({
resolve: { alias },
test: {
projects: [
{
resolve: { alias },
test: {
name: 'unit',
globals: true,
environment: 'node',
// `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'],
exclude: ['node_modules', 'tests/**/*'], // Exclude Playwright tests
setupFiles: ['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/],
},
},
},
{
// 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
// component test from this class of dual-React crash.
resolve: { alias: componentAlias, dedupe: ['react', 'react-dom'] },
// Pre-bundle deps the component setup mocks/imports 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 component-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 component 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.
optimizeDeps: {
include: ['next/router', 'vitest-browser-react', 'react/jsx-dev-runtime', 'react/jsx-runtime'],
// `@vitest/browser` seeds optimizeDeps.entries from EVERY `*.browser.test.tsx` 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 component project with "No loader is configured for '.node'
// files" regardless of which test you targeted. Exclude it; no component test needs it.
exclude: ['sharp'],
},
test: {
name: 'component',
globals: true,
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'],
browser: {
enabled: true,
// `--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 uses Playwright's bundled Chromium (env unset). NixOS can't run
// that generic binary; point this at a system Chromium, e.g.
// `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=$(command -v chromium)`.
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,
instances: [{ browser: 'chromium' }],
},
},
},
],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/server/services/**', 'src/server/jobs/**'],
},
},
});