mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
c318ef1fb8
* 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`.
180 lines
9.9 KiB
TypeScript
180 lines
9.9 KiB
TypeScript
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 ~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/],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
// 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/**'],
|
||
},
|
||
},
|
||
});
|