mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
36e28b7792
* 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>
258 lines
12 KiB
TypeScript
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 });
|
|
}
|