Files
civitai__civitai/test/component-setup.tsx
Zachary Lowden 36e28b7792 fix(test): give the component harness the app's :root custom properties (#4403)
* fix(test): give the component harness the app's :root custom properties, so components stop laying out against undefined vars

The component-test harness loads no app stylesheet, so every var(--header-height)
in a component under test resolved to "". That is not a missing nicety: an
unresolvable var() makes the whole declaration invalid at computed-value time, so
calc(100dvh - var(--header-height)) silently computed to 0px and the component
laid out differently under test than in production. 33 TS/TSX files and 7
stylesheets read that property today, so the divergence was repo-wide and nothing
reported it -- a layout assertion could be green against geometry no user sees.

Measured end to end in real Chromium, innerHeight 896:

  without the injection:  --header-height = ""      calc(...) = "0px"
  with it:                --header-height = "60px"  calc(...) = "836px"

EXTRACTED, NOT IMPORTED. Importing globals.css pulls the real cascade -- Tailwind
preflight, @layer ordering, element defaults -- which changes the rendered
geometry of every existing test (Mantine Group starts centring its items, so
same-line assertions written against `top` begin failing against correct code;
that hazard is already documented in the harvest-component-tests skill). Taking
only the :root custom properties gives components the values they read and leaves
the cascade exactly as the suite has always had it. The A/B says it worked:
189 files / 2078 tests before, 190 / 2079 after -- exactly the one file and one
test this adds, zero regressions.

PARSED, NOT RESTATED. Hardcoding --header-height: 60px in the setup would be a
fourth copy of the constant civitai#4379 existed to consolidate, and it would
drift the first time the header is resized. The values come from globals.css
itself. The extraction throws loudly if the :root block stops matching or
declares nothing, because a silent revert here puts every component test back to
laying out against undefined properties with nothing to say so.

src/components/AppLayout/rootCustomProperties.browser.test.tsx is the guard, and
it is the only thing that proves the injection reaches the document rather than
merely that the parse succeeded. Its expected value is derived from
window.innerHeight and from the property it just read, never hardcoded, so it
cannot pass by coincidence on a differently-sized runner. Negative control run:
with the setup reverted it fails with v: "" / computed: "0px".

Retires the prohibition #4379 had to add. Both the constant's comment and
PageBlockHost's now record that the var is safe again and that keeping the
interpolation is a deliberate non-change -- switching would leave AppHeader as
the constant's only consumer and need the CSS/TS binding guard re-pointed, which
is a refactor with its own risk and no user-visible benefit.

`*.css?raw` needed an ambient declaration; added beside the CSS-modules ones
rather than pulling in vite/client, which would add unrelated globals to the
Next app's program.

Verified: full unit project 22164 passed / 0 failed; full component project
190 files / 2079 tests, 0 failed; typecheck 0; eslint --max-warnings=0; prettier.

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

* fix(test): close the two silent-divergence paths the audit found in the :root extraction

Both reintroduced the exact failure this PR exists to remove -- a component
harness laying out against custom properties that are wrong or missing, with the
whole suite green -- so they land here rather than as a follow-up.

WRONG-BLOCK CAPTURE, AND A GUARD THAT COULD NOT SEE IT. The extraction takes the
FIRST `:root` match anywhere in globals.css, so a second one earlier in the file
(inside an @media, say) was injected UNCONDITIONALLY while the real block's other
properties were dropped entirely. The guard could not catch it because its
expectation was derived from the property it had just read -- it graded the
injection against itself. That is the self-referential-assertion trap, and it is
the second time this arc has produced one.

Two fixes, because they close different halves:
  - the setup now refuses more than one `:root {` outright, rather than guessing
    which was meant. Measured: with a conditional :root added above the real one,
    the setup THROWS before any test runs (loud, 0 executed).
  - the guard now pins the injected value to HEADER_HEIGHT_PX. Not circular:
    pageRunScrollContract.test.ts (GATING tier) already binds that constant to
    globals.css independently, so this compares the injected value against a
    number a different guard ties to the stylesheet. Measured: changing the CSS
    value alone now fails the guard, where before it passed.

A CSS COMMENT INSIDE :root SILENTLY DROPPED THE DECLARATION AFTER IT. split(';')
leaves the comment glued to the next declaration, which then fails
startsWith('--') and vanishes -- and no count reached zero, so nothing threw.
Measured on the real file: adding `/* keep in sync with AppFooter */` above
--footer-height parsed 3 properties down to 2, with all 190 files green.
--footer-height is read inside calc() by the auctions page and CollectionsLayout,
so documenting a variable -- the most ordinary edit imaginable -- silently
reintroduced the divergence. Comments are now stripped before splitting, and the
parse must account for every property the block declares. Positive control for
that count assertion, so it is not inert: a nested rule inside :root (legal CSS,
and the `[^}]*` capture truncates at its brace) gives parsed=3 vs declared=4 and
throws.

Also restores the tier caveat the previous commit deleted: this guard lives in
preview/component-tests, which is REPORT-ONLY -- it tells you, it does not stop
you. The CSS-to-TS binding it leans on is in the gating tier.

And widens the calc assertion from an exact px string to a 1px tolerance. 100dvh
resolves to the layout viewport, which is fractional on a non-integer device-scale
factor, so an exact match would flake on a differently-configured runner and read
as a real break. The failure it must catch is 0px, not a rounding difference.

Sweep: wrong-block KILLED (setup throws), value-diverges KILLED, injection removed
KILLED, --header-height filtered out KILLED, a NEW property added to :root stays
GREEN, baseline and post-restore GREEN.
Full component project 190 files / 2079 tests, 0 failed; typecheck 0; prettier;
eslint clean on the changed files.

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

* fix(test): strip CSS comments from the WHOLE file first, and derive the coverage check from the file rather than the captured block

The delta re-audit found the previous round shipped a guard that could not detect
the defect it was written for, one that graded a truncated string against itself,
and a new false-throw class with a 2079-test blast radius. All three had one root
cause and one edit.

THE COUNT ASSERTION WAS INERT AGAINST ITS OWN DEFECT. A comment sitting between
`;` and the next `--name` removes a member from BOTH sides symmetrically: it kills
the split(';') fragment AND destroys the `[;{]\s*--` anchor the declared-count
regex needs. Counts stay equal, nothing throws, the property is silently gone.
Measured in the real harness with the comment-strip disabled: setup did not throw,
the guard stayed green, --footer-height resolved to "".

AND IT WAS SELF-REFERENTIAL. `declared` was computed from the same captured
substring the parse used, so it structurally could not see a truncation of the
block itself — a `}` inside a comment inside :root (comments were stripped AFTER
extraction, so they still truncated it) dropped --buzz-color with counts equal.
That is the same trap this PR's previous round removed from the browser test,
reintroduced one file over.

THE ONE-`:root` REFUSAL WAS ORDER-BLIND AND RAN ON RAW TEXT. Its own comment named
the hazard as a second block EARLIER in the file; the code counted every `:root {`
anywhere, comments included. So an ordinary dark-mode override AFTER the base
block, a commented-out block, or prose merely mentioning ":root {" each threw and
took all 190 files to "Tests no tests" — in a report-only tier. globals.css
already contains commented-out CSS after :root, so that was live.

ONE EDIT FIXES ALL THREE, and it is simpler than what it replaces:
  - strip comments ONCE, from the whole file, before anything looks at it;
  - drop the >1-`:root` refusal entirely;
  - check COVERAGE BY NAME against every `:root` block in the file — every custom
    property declared anywhere on :root must survive extraction.

That needs no brace-counting and no block-ordering logic, and it is strictly
stronger: a conditional first block is caught because the real block's other
properties go missing; a truncated capture is caught the same way; a legitimate
later override declares the same NAMES, so it is not a false failure.

Verified, each case the audit named:
  comment glued to the next property        -> property SURVIVES (probe asserts it)
  comment containing `{ }` before a property -> SURVIVES
  dark-mode :root override after the base    -> GREEN (was a false throw)
  commented-out :root block after            -> GREEN (was a false throw)
  prose mentioning ':root {' after           -> GREEN (was a false throw)
  conditional :root FIRST                    -> setup THROWS, loudly
  --header-height removed from :root         -> guard FAILS
  css value != HEADER_HEIGHT_PX              -> guard FAILS
  a NEW property added to :root              -> GREEN
  baseline / post-restore                    -> GREEN

Also corrects the non-circularity claim on the value pin, which was overstated:
the ledger binds CSS<->TS and this binds INJECTED<->TS, so together they bind
INJECTED<->CSS. That is a composition, not two independent paths, and given a
green ledger the pin is close to tautological — its residual live coverage is
--header-height's own value being mangled inside the block. Worth keeping, and
worth describing accurately.

Full component project 190 files / 2079 tests, 0 failed (run alone); gating
pageRunScrollContract ledger 10/10; typecheck 0; prettier.

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

* refactor(test): let the BROWSER parse globals.css instead of three regexes that cannot agree where a CSS block ends

Four audit rounds, four defects, one cause. Each round fixed what it aimed at and
shipped the next one, and every one came from asking a regex to decide the extent
of a CSS block:

  round 1  a comment glued to the next property dropped it, silently
  round 2  the count check was inert against exactly that, and graded a truncated
           string against itself
  round 3  "refuse >1 :root" blacked out all 2079 tests on an ordinary dark-mode
           override; and `[^{]*` attributed an unrelated rule's properties to
           :root because the string ":root" appeared in a content: value
  round 4  a `}` or `;` inside a STRING value still truncated the capture --
           --footer-height and --buzz-color silently undefined across all 190
           component files, verified in Chromium, everything green

The regexes are gone. `CSSStyleSheet.replaceSync` hands the whole problem to the
engine that will actually evaluate these properties, so comments, strings,
nesting and at-rules stop being our concern. The distinction that mattered most
falls out of the rule types for free: a :root inside @media/@supports/@container
is a CSSMediaRule and is skipped on purpose -- such a property is undefined
outside its condition, so injecting it unconditionally would be a lie -- while
@layer only orders the cascade and is descended into. A grouped selector
(`:root, html`) is matched on its parts.

The guard now sweeps EVERY injected property rather than the one it is named
after. That is what round 4 found: it asserted only --header-height while two
other properties were undefined everywhere, and --footer-height is read inside
calc() by 8 files, --buzz-color by 20+ SCSS modules. Checking only the property
you happened to think of is how a guard reads as coverage while providing almost
none. It also asserts the injected list is non-empty first, so the sweep cannot
pass vacuously.

Full case matrix -- all 16 correct, first clean run of it:
  comment glued to a property                 property SURVIVES
  comment containing `{ }`                    SURVIVES
  `}` inside a string value                   SURVIVES  (round-4 defect)
  `;` inside a string value                   SURVIVES
  dark-mode override ADDING a variable        GREEN     (was a blackout)
  `content: ":root"` + a later rule           GREEN     (was a blackout)
  @supports selector(:root)                   GREEN     (was a blackout)
  :root.theme-dark                            GREEN     (was a blackout)
  unterminated /* inside a string             GREEN     (was a blackout)
  commented-out :root block                   GREEN
  ALL properties moved into @media            setup THROWS, loudly
  css value != HEADER_HEIGHT_PX               guard FAILS
  a NEW property added to :root               GREEN
  grouped selector `:root, html {`            GREEN
  baseline / post-restore                     GREEN

Checked green-for-the-right-reason, not just green: under the `}`-in-a-string
case the extracted names are exactly --buzz-color, --footer-height,
--header-height, --quote. The regex version dropped two of those.

Full component project 190 files / 2080 tests, 0 failed (run alone); gating
pageRunScrollContract ledger 10/10; typecheck 0; eslint; prettier.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 22:36:07 -05:00

258 lines
12 KiB
TypeScript

/**
* Component-test scaffold (Vitest browser mode).
*
* Loaded as the `component` project's setupFile (vitest.config.mts) AND imported
* by `*.browser.test.tsx` files for `renderWithProviders`. Side effects on load:
* - mock `next/router` (pages-router components call `useRouter()` at render)
* - auto-`cleanup()` the rendered tree after every test
*
* `renderWithProviders` wraps the unit-under-test in the providers a generation
* leaf needs: a fresh React-Query client (`retry: false`) + MantineProvider.
* tRPC hooks are mocked per-test with `vi.mock('~/utils/trpc')` as we climb to
* data-driven inputs — this scaffold stays network-free.
*/
import React from 'react';
import { afterEach, vi } from 'vitest';
import { render, cleanup } from 'vitest-browser-react';
import { MantineProvider } from '@mantine/core';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Raw text, NOT a stylesheet import — see the block below for why that distinction
// is the whole point.
import globalsCss from '~/styles/globals.css?raw';
/**
* 🔴 THE APP'S ROOT CUSTOM PROPERTIES, AND ONLY THOSE.
*
* This harness loads no app stylesheet, so every `var(--header-height)` in a
* component under test resolved to `""`. That is not "a missing nicety": an
* unresolvable `var()` makes the whole declaration **invalid at computed-value
* time**, so `min-height: calc(100dvh - var(--header-height))` silently becomes
* `auto`/`0px` and the component lays out differently here than in production —
* measured in real Chromium. **33 TS/TSX files and 7 stylesheets read
* `--header-height` today**, so that divergence was repo-wide and invisible: a
* layout assertion could be green against geometry no user ever sees.
*
* 🔴 Why the properties are EXTRACTED rather than the stylesheet IMPORTED.
* Importing `~/styles/globals.css` pulls the real cascade — Tailwind preflight,
* `@layer` ordering, element defaults — which changes the rendered geometry of
* every existing test (Mantine `Group` starts centring its items, so same-line
* assertions written against `top` begin failing against correct code). That is a
* much larger change than this fixes. Taking only the `:root` custom properties
* gives components the values they read while leaving the cascade exactly as the
* suite has always had it.
*
* 🔴 Why they are PARSED rather than restated. Hardcoding `--header-height: 60px`
* here would be a fourth copy of a constant that civitai#4379 existed to
* consolidate, and it would drift silently the first time the header is resized.
* The values come from `globals.css` itself, so there is nothing to keep in step.
*
* 🔴 WHY THE BROWSER PARSES IT AND NOT A REGEX — THE EXPENSIVE LESSON HERE.
* Three successive attempts to extract the block with regexes each shipped a
* defect, and each fix introduced the next one: a comment glued to the following
* property dropped it; a `}` inside a comment or a string truncated the capture
* and silently dropped everything after it; a "refuse more than one `:root`" rule
* blacked out all 2079 component tests on an ordinary dark-mode override; and a
* name sweep with `[^{]*` attributed an unrelated rule's properties to `:root`
* because the string `":root"` appeared in a `content:` value. The common cause
* was asking several regexes to agree about where a CSS block ends. They cannot.
*
* `CSSStyleSheet.replaceSync` hands the whole problem to the engine that will
* actually evaluate these properties, so comments, strings, nesting and at-rules
* are simply not our problem — and the CONDITIONAL/UNCONDITIONAL distinction that
* mattered most falls out of the rule types for free: a `:root` inside `@media` /
* `@supports` / `@container` is a `CSSMediaRule` &c and is deliberately skipped
* (its properties are undefined outside that condition, so injecting them
* unconditionally would be a lie), while `@layer` merely orders the cascade and is
* descended into.
*/
const injectedRootProperties: string[] = (() => {
const sheet = new CSSStyleSheet();
sheet.replaceSync(globalsCss);
const rootRules: CSSStyleRule[] = [];
const walk = (rules: CSSRuleList) => {
for (const rule of Array.from(rules)) {
if (rule instanceof CSSStyleRule) {
// A grouped selector (`:root, html`) still applies unconditionally to the
// root, so match on the parts rather than the whole string.
if (rule.selectorText.split(',').some((s) => s.trim() === ':root')) rootRules.push(rule);
} else if (typeof CSSLayerBlockRule !== 'undefined' && rule instanceof CSSLayerBlockRule) {
walk(rule.cssRules);
}
// @media / @supports / @container are CONDITIONAL — skipped on purpose.
}
};
walk(sheet.cssRules);
// Later declarations win, as they would in the real cascade.
const values = new Map<string, string>();
for (const rule of rootRules) {
for (const name of Array.from(rule.style)) {
if (name.startsWith('--')) values.set(name, rule.style.getPropertyValue(name).trim());
}
}
if (values.size === 0) {
// Loud, not silent: with no properties injected, every component test goes
// back to laying out against undefined custom properties and nothing else
// would report it. If `globals.css` legitimately stopped declaring any, delete
// this block deliberately rather than letting it sit here doing nothing.
throw new Error(
'component-setup: found no custom properties on an unconditional `:root` rule in ' +
'src/styles/globals.css. Either they moved, or they are now declared only inside a ' +
'conditional at-rule (@media/@supports/@container), which this deliberately does not ' +
'inject because such a property is undefined outside its condition.'
);
}
const style = document.createElement('style');
style.setAttribute('data-source', 'component-setup:globals.css :root');
style.textContent = `:root { ${[...values].map(([n, v]) => `${n}: ${v}`).join('; ')}; }`;
document.head.appendChild(style);
return [...values.keys()];
})();
/**
* The custom-property names this setup injected. Exported so the guard in
* `src/components/AppLayout/rootCustomProperties.browser.test.tsx` can assert that
* EVERY one of them actually resolves in the document — not just the one property
* a hand-written test happened to name. An earlier guard checked only
* `--header-height`, and two other properties were silently undefined across all
* 190 component files while it stayed green.
*/
export const INJECTED_ROOT_PROPERTIES = injectedRootProperties;
// Stub the Next pages-router. Returns vi.fn()s so tests can assert navigation
// without a real router; extend per-test via `vi.mocked(useRouter)` if needed.
vi.mock('next/router', () => {
const router = {
push: vi.fn().mockResolvedValue(true),
replace: vi.fn().mockResolvedValue(true),
prefetch: vi.fn().mockResolvedValue(undefined),
back: vi.fn(),
forward: vi.fn(),
reload: vi.fn(),
beforePopState: vi.fn(),
query: {},
pathname: '/',
asPath: '/',
route: '/',
basePath: '',
isReady: true,
isFallback: false,
isPreview: false,
events: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
};
return {
// Real `next/router` is an ES module; flag the mock so a DEFAULT import
// (`import Router from 'next/router'`, used by `useCatchNavigation` and many
// other components) resolves to the singleton rather than the namespace
// object under esModuleInterop (whose `.events` would be undefined).
__esModule: true,
useRouter: () => router,
Router: router,
default: router,
withRouter: (Component: React.ComponentType) => Component,
};
});
// Mantine's `useClipboard` (and any copy affordance) calls
// `navigator.clipboard.writeText`. In CI's headless Chromium the page is an
// insecure context with no clipboard permission, so the REAL `writeText`
// rejects — `copied` never flips and the "Copied" affordance never renders.
// That made copy tests pass locally (Chromium grants the permission) but fail
// in CI. Stub a resolving clipboard so copy behaviour is deterministic and
// matches a real secure-context browser. Tests assert the "Copied" UI state,
// not the OS clipboard contents.
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: {
writeText: vi.fn().mockResolvedValue(undefined),
readText: vi.fn().mockResolvedValue(''),
},
});
// 🔴 `cleanup()` is ASYNC and the hook MUST await it. Returning the promise (or
// awaiting it) is the whole fix — do not "simplify" this back to a bare
// `cleanup();` statement inside a block body.
//
// vitest-browser-react 2.2.0 `cleanup()` (dist/pure-*.js:112) is
// `async function cleanup()`. Per mounted root it does
// `await act(async () => root.unmount())` and only THEN
// `document.body.removeChild(container)`. So the container removal happens after
// an await, in a later microtask.
//
// The previous form was `afterEach(() => { cleanup(); })` — a block body with no
// return, so the promise floated and Vitest received `undefined` and did not
// wait. Container removal then raced the NEXT test's render, leaving two mounted
// containers in `document.body` at once. Any `page.getByTestId(...)` query is
// document-scoped, so a testid that legitimately appears once per render resolved
// to 2 elements and failed with a strict-mode violation.
//
// That is exactly the shape of the long-running `AppListingDetailBody > the
// "Browse all apps" link is present even when the rail is empty` flake
// (`apps-browse-all` is rendered in exactly ONE place,
// `src/components/Apps/RelatedListings.tsx:105`): load-dependent, red on a busy
// CI box and green on a quiet one, and it moved between container indices.
afterEach(async () => {
await cleanup();
});
/**
* A 1x1 transparent PNG — the canonical image fixture for browser tests.
*
* 🔴 Use this ANY time a browser test needs an image source it will then assert
* on. A `data:` URI resolves synchronously and locally, so the `<img>` LOADS and
* survives for the whole test.
*
* An http(s) URL does NOT. Nothing serves it in the test browser, so the fetch
* fails and the element's real `error` event fires ~11 ms after mount. Whether
* that BREAKS a test depends on the component, and only the first case below can:
* - Mantine `Avatar` (and any bespoke `onError -> placeholder`, e.g.
* `src/components/Apps/AppListingCard.tsx:135`) renders a placeholder INSTEAD
* of the `<img>` — the element is DESTROYED ~11 ms after mount.
* - Mantine `Image` does NOT: it only swaps when `fallbackSrc` is set (and that
* prop appears zero times in `src/`), otherwise it re-renders the same `<img>`.
* So `expect(...querySelector('img')).not.toBeNull()` against an Avatar-backed
* fixture races that ~11 ms window — passing on a fast local machine and failing
* on a loaded CI box. That defect sat red on `main` across five PRs before #3551.
* (An earlier draft of this note said EVERY image-rendering component destroys
* the `<img>`; that was false — checked against the installed `@mantine/core`.)
*
* Deliberately testing the error path (an image that must FAIL to load) is a
* legitimate exception — see the escape hatch documented on the
* `local-rules/no-unloadable-image-fixture` ESLint rule.
*/
export const LOADABLE_IMAGE_DATA_URI =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
function Providers({ children }: { children: React.ReactNode }) {
// Fresh client per render so cache never leaks between tests.
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
return (
<QueryClientProvider client={queryClient}>
<MantineProvider>{children}</MantineProvider>
</QueryClientProvider>
);
}
/**
* Render a component under the standard generation-form provider stack.
*
* Providers are supplied via the `wrapper` option (not by manually wrapping
* `ui`) so the `rerender` returned by vitest-browser-react re-applies the SAME
* wrapper on every re-render. Manually wrapping (`render(<Providers>{ui}</…>)`)
* works for the initial render but `rerender(newUi)` replaces the root with the
* bare element — dropping MantineProvider/QueryClient and crashing any Mantine
* child with "MantineProvider was not found". Passing `wrapper` fixes that for
* every component test that drives prop changes via `rerender`.
*/
export function renderWithProviders(ui: React.ReactElement) {
return render(ui, { wrapper: Providers });
}