mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
e92cf5fe4a
* test(geometry): a browser tier that loads the real cascade at a phone viewport
Adds a fourth Vitest project, `geometry`, and demonstrates it catching a defect
whose own source comment records that nothing rendered can see it.
WHAT THE GAP IS, AND WHAT IT IS NOT. The `component` project is NOT jsdom — it is
real headless Chromium via @vitest/browser-playwright, `page.viewport()` moves
`window.innerWidth`, and `getBoundingClientRect()` returns real boxes. What it is
missing is the STYLESHEET and the VIEWPORT. `test/component-setup.tsx` injects
only the `:root` custom properties parsed out of globals.css, so the document
holds 24 CSS rules: Mantine classes are styleless, Tailwind utilities are inert,
and any `getComputedStyle` assertion whose expected value is the CSS initial
value passes against a broken component. And nothing sets a viewport, so files
inherit the runner's silent 414x896.
THE SAME FIXTURE, THE SAME CORRECT SOURCE, IN BOTH TIERS (PageBlockHost in its
production shell chain):
`component` `geometry`
viewport 414 x 896 390 x 844 (default vs set)
CSS rules in the document 24 3,677
box-sizing on a bare div content-box border-box
`className="flex"` block flex
chrome bar height 200 31
host frame height 350 844
APP COLUMN HEIGHT 150 813
That last row is the argument. 150 is ALSO what the app column measures once the
recorded `flex: 1` defect is planted, so a threshold written in the `component`
tier would have to expect the number the DEFECT produces.
THE HARNESS. `test/geometry-setup.tsx` loads the production cascade in production
order — the `@layer tailwind-preflight, theme, mantine, modules;` statement first
(as _document.tsx emits it), then globals.css, then every `@mantine/*` layer
stylesheet _app.tsx imports. It defaults to a 390x844 phone and THROWS unless the
window reports back the size it asked for; tests assert `observed` against their
own literal on top of that. It exports measurement helpers: `box`,
`childrenUnionBox` (the union of child rects — `scrollHeight` is clamped to the
padding box and cannot see a parent taller than its content), `flexAxis`,
`flexLonghands` (longhands, because `getComputedStyle(el).flex` serialises
`1 1 220px` and `1 1 0%` identically), and `cascadeEvidence`.
WHY A PROJECT AND NOT A CHANGE TO THE SHARED SETUP. Loading the cascade in
`component-setup.tsx` moves existing numbers — measured, the same chrome bar is
200px there and 31px with the cascade, a 169px move on one element, under 212
files / 2,362 tests of which 14 read getBoundingClientRect and 20 read
getComputedStyle. The per-file import pattern (15 files do it today, 3 also take
globals.css) stays available and is not deprecated; what it cannot give is a
guarantee — those files each picked their own subset, none declares the @layer
order, none sets a viewport, and the "did my stylesheet load" guard is
re-hand-rolled per file. The `geometry` glob (`src/**/*.geometry.test.tsx`) is
disjoint from every other project's, so nothing that runs today changes project
and no file is collected twice.
DEMONSTRATED RED. `PageBlockHostFillHeight.geometry.test.tsx` asserts that the
app column reaches the bottom of the host frame at 390x844 and at 390x640.
Dropping `flex: 1` from `app-page-content` (the mutation PageBlockHost.tsx's own
comment records as invisible to every rendered tier) fails it:
the app column ends at y=181 inside a frame that ends at y=844 — 663px of the
phone is blank below a running App Block. The column measured 150px of the
frame's 844px, with flex longhands {"grow":"0","shrink":"1","basis":"auto"}.
Restoring the property returns it to 10/10. A second mutant — dropping `flex: 1`
from the frame's `fit === 'fill'` branch — collapses the column to 269px and is
caught by the literal floor rather than by the frame/content comparison, which
both mutations keep satisfied.
BOTH MUTANTS ARE ALSO CAUGHT IN THE NODE TIER TODAY, by verbatim source pins in
pageBlockHostMaxWidth.test.ts and pageRunScrollContract.test.ts. Stated plainly
so this is not read as claiming otherwise. The difference is what each guard can
SEE: a source pin is a claim about the text of one file, blind to a collapse
arriving from the cascade, from an ancestor or from a viewport, and it has to be
rewritten every time the block is legitimately reformatted.
WHAT RUNS THIS TODAY: NOTHING. lint.yml selects `--project 'unit*'`,
`'@civitai/*'` and `'app:*'`; no pattern matches `component` or `geometry`,
because the Actions runners install no Chromium. `component` has the preview
pipeline's report-only status; `geometry` has no CI home at all. Wiring one is a
pipeline change and deliberately not in this PR — but a harness nothing runs
rots, so it is said out loud in the setup file rather than left to be discovered.
Verification: typecheck 0 errors · geometry 2 files / 10 tests passed · component
212 files / 2,362 tests passed (unchanged) · scripts unit tier 24 files / 521
tests passed · eslint clean on both new test files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci(geometry): run the geometry tier, and refuse a green that collected nothing
Adds a `Geometry tests` job to lint.yml. Without it this harness runs in no gate
at all — the workflow selects projects by name (`unit*`, `@civitai/*`, `app:*`)
and none of those patterns matches `geometry`.
ALIGNED WITH THE `unit` JOB'S COMMENT, NOT AN OVERRIDE OF IT. That comment gives
exactly one reason for keeping browser tests out — "they need Chromium, which
this job does not install. That is the whole reason." — which is a statement
about what that job provides, not a ban on providing it. The same comment then
retracts the only other objection on the record ("Don't cite a cold-cache flake
as a reason to keep this job Chromium-free") and names vitest.config.mts's
dedupe + optimizeDeps pre-bundling as the canonical fix. A job that DOES install
Chromium satisfies the stated condition.
GEOMETRY ONLY. `component` stays ungated and that is now visible rather than
fixed. Measured on a 16-core box: `geometry` is 2 files / 10 tests in 9.07s;
`component` is 212 files / 2,362 tests in 112.92s wall, of which 334s is test
time spread across workers — so a 2-core runner (browser pool `min(12, cpus-1)`
= 1 instance) does not divide it. That is an order of magnitude more expensive,
with 212 files of pass/fail history this workflow has never seen. It belongs in
its own PR.
REPORT-ONLY, mirroring `unit` and for its stated reason: blocking a brand-new
tier from day one would red unrelated PRs and the job would be switched off
within a week. `main` has no required_status_checks, so nothing here blocks a
merge either way; `continue-on-error` only decides whether a red renders as red
or as red-but-ignored. The FLIP TO BLOCKING note says concretely what would make
that safe. The `unit` job's selectors and its `continue-on-error` are untouched.
CHROMIUM comes from `pnpm exec playwright install --with-deps chromium` — the
workspace-local playwright, so the revision follows this repo's own pin rather
than a second version written into the workflow. Desynchronising those two is
the documented failure mode (CLAUDE.md records 59 preview specs dying on a
revision mismatch with zero specs run). A local NixOS bundle mismatch is a
property of that host and is handled by PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; the
pin is not moved to accommodate it.
A GREEN VITEST RUN IS A CLAIM, NOT EVIDENCE — the hazard the `packages` job's
ledger exists for, one project over. `--project` matching nothing exits 0, and
this tier's glob is deliberately narrow, which is exactly the kind of pattern
that can quietly stop matching. The new step asserts floors of 2 files and 10
tests from the JSON report, with `if: always()` so it also fires when the tests
fail or the runner aborts without writing a report.
Two things measured rather than assumed while writing it: the file count comes
from `testResults.length`, NOT `numTotalTestSuites` — against a real report this
run is 2 files while that field reads 4, because it counts `describe` blocks.
And the gate script was extracted back out of the parsed YAML and executed on
all three arms before commit: real report -> exit 0 (2 files, 10 tests); a
report with `testResults: []` -> exit 1; a missing report -> exit 1 with its own
message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ci): the geometry job must be report-only on PRs ONLY, not on pushes to main
Caught by this repo's own gate on the previous commit's run:
`scripts/__tests__/main-branch-ci-coverage.test.ts` > "has no unconditionally
report-only job on the push path" went red (Unit tests shard 2, 1 failed / 5,624
passed).
The job was written with a literal `continue-on-error: true`. That is report-only
on EVERY event, including a push to `main`, where it produces a run reporting
`success` while the step underneath failed — the stale-TRUE shape that guard
exists to forbid, and the exact hazard the `unit` job's own comment spells out:
"A green that has to be disbelieved is worse than no run at all."
Now `${{ github.event_name == 'pull_request' }}`, byte-identical to `unit`'s.
Report-only on PRs (a new tier should not red unrelated work while it settles),
honest verdict on `main` (where the merge has already happened and there is no
unrelated work to protect). The guard accepts a conditional precisely because a
conditional can differ between a PR and a push; a literal cannot.
The comment now records this so the next reader does not "simplify" it back.
Red at c2359ba847 (CI), green at HEAD: the guard file is 1 file / 6 tests passed
locally, and the whole `scripts/` unit tier is 24 files / 521 tests passed. That
tier was run BEFORE the workflow existed on the previous commit, which is why it
did not catch this locally — a suite is only evidence about the tree it ran on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
483 lines
24 KiB
TypeScript
483 lines
24 KiB
TypeScript
/**
|
|
* GEOMETRY HARNESS — the `geometry` Vitest project's setup file.
|
|
*
|
|
* A second browser-mode project that renders against the REAL app cascade at an
|
|
* EXPLICIT viewport, so a test can assert PIXELS rather than attributes.
|
|
*
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* WHAT THE GAP ACTUALLY IS — AND WHAT IT IS NOT
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* 🔴 THE `component` PROJECT IS A REAL BROWSER WITH A REAL LAYOUT ENGINE. It runs
|
|
* headless Chromium through `@vitest/browser-playwright`, `page.viewport()`
|
|
* genuinely moves `window.innerWidth/Height` there, and `getBoundingClientRect()`
|
|
* returns real non-zero boxes. Anyone arriving here expecting "the component tier
|
|
* is jsdom, so it cannot lay out" should stop: that is false for this repo, and
|
|
* a harness justified on it would be solving a problem that does not exist.
|
|
*
|
|
* What that tier is missing is the STYLESHEET and the VIEWPORT:
|
|
*
|
|
* · `test/component-setup.tsx` parses the `:root` custom properties out of
|
|
* `globals.css` and injects ONLY those — measured, the document holds 24 CSS
|
|
* rules. So every Mantine class is styleless, every Tailwind utility is inert
|
|
* (`className="flex"` computes `display: block`), and a `var(--mantine-*)`
|
|
* written by a stylesheet is simply absent. A real layout engine with no
|
|
* stylesheet still cannot measure the real layout: it measures a DIFFERENT,
|
|
* internally-consistent one, which is worse than measuring nothing.
|
|
* · nothing sets a viewport, so every file inherits the runner's silent default.
|
|
*
|
|
* The consequence is the same either way and it is the thing to hold on to: a
|
|
* `getComputedStyle` assertion whose expected value happens to be the CSS INITIAL
|
|
* value (`nowrap`, `visible`, `static`, `auto`, `none`, `0px`) passes against a
|
|
* broken component, because an unstyled element reports exactly those. And the
|
|
* layout DECISION is testable without any of this — `data-layout="stacked"` is an
|
|
* attribute — which is the trap: a suite can pin every decision correctly and
|
|
* still ship the wrong sizing.
|
|
*
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* WHY A SECOND PROJECT, GIVEN THE CHEAPER OPTIONS
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* Two cheaper options exist and both were weighed:
|
|
*
|
|
* (a) LOAD THE CASCADE IN THE SHARED SETUP. Rejected. `component-setup.tsx`'s own
|
|
* header records that importing the real cascade changes the rendered geometry
|
|
* of existing tests, and that is not a guess — measured here, the SAME chrome
|
|
* bar is 200px with the shared setup and 31px with the cascade loaded, a 169px
|
|
* move on one element. 212 files / 2,362 tests run in that tier, 14 of them
|
|
* reading `getBoundingClientRect` and 20 reading `getComputedStyle`. Changing
|
|
* the cascade under all of them is a suite-wide rewrite, not a fix.
|
|
*
|
|
* (b) IMPORT THE STYLESHEET PER FILE. This already exists — 15 of the 212 browser
|
|
* test files import a Mantine stylesheet themselves and 3 also import
|
|
* `~/styles/globals.css` — and it stays available; nothing here deprecates it.
|
|
* What it does not give you is a GUARANTEE. Each of those files chose its own
|
|
* subset (7 take unlayered `@mantine/core/styles.css`, 2 take the layered
|
|
* variant the app actually ships), none declares the `@layer` ORDER that
|
|
* `_document.tsx` puts first in `<head>`, none sets a default viewport, and
|
|
* the "did my stylesheet actually load" guard has been re-hand-rolled per file
|
|
* (`assertLayoutIsReal` in `AppListingCard.browser.test.tsx` is one copy).
|
|
* A project makes the cascade, the viewport and the controls properties of the
|
|
* TIER rather than of whoever remembered.
|
|
*
|
|
* So the cascade lands in a NEW project with a NEW glob, and nothing that runs
|
|
* today changes — verified: the full `component` tier is 212 files / 2,362 tests
|
|
* passed, before and after. Vitest browser mode gives each test FILE its own
|
|
* iframe, so the two setups cannot leak into one another even when both run.
|
|
*
|
|
* 🔴 THE SAME FIXTURE, THE SAME CORRECT SOURCE, MEASURED IN BOTH TIERS
|
|
* (`PageBlockHost` in its production shell chain, 2026-09-03):
|
|
*
|
|
* `component` `geometry`
|
|
* viewport 414 x 896 390 x 844 (default vs set)
|
|
* CSS rules in the document 24 3,677
|
|
* box-sizing on a bare div content-box border-box
|
|
* `className="flex"` block flex
|
|
* chrome bar height 200 31
|
|
* host frame height 350 844
|
|
* APP COLUMN HEIGHT 150 813
|
|
*
|
|
* That last row is the whole argument. `150` is ALSO what the app column
|
|
* measures in this harness when the recorded `flex: 1` defect is planted — so an
|
|
* assertion written in the `component` tier would have to expect the number the
|
|
* DEFECT produces, and could not tell the two apart at all.
|
|
*
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* THE VIEWPORT IS SET AND THEN OBSERVED
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* The runner's default is **414 x 896** (measured 2026-09-03 against this repo's
|
|
* pinned Vitest 4.1.11 / Playwright provider, `devicePixelRatio: 1`). Note that
|
|
* it is a DEFAULT, not "unset": a suite that never calls `page.viewport()` is
|
|
* measuring 414px and does not say so, and the number is a property of the
|
|
* runner rather than of anything in this repo — a Vitest or Playwright bump can
|
|
* move it and no assertion would notice.
|
|
*
|
|
* `renderAtViewport` therefore SETS the viewport and then THROWS unless the
|
|
* window reports back the size it asked for. Tests are still expected to assert
|
|
* `observed` against a literal of their own — trusting a config is what produced
|
|
* the vacuous pass this harness exists to remove.
|
|
*
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* 🔴 WHAT RUNS THIS, TODAY: NOTHING. SAY IT OUT LOUD.
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* `.github/workflows/lint.yml` selects projects by name — `--project 'unit*'`,
|
|
* `--project '@civitai/*'`, `--project 'app:*'`. None of those patterns matches
|
|
* `component` and none matches `geometry`, because the Actions runners install no
|
|
* Chromium (the `unit` job's own comment says so). The `component` tier's only CI
|
|
* home is the preview pipeline's `preview / component-tests` status, which is
|
|
* report-only. This project has no CI home at all yet.
|
|
*
|
|
* That is a deliberate scope boundary, not an oversight: wiring a new job is a
|
|
* change to a pipeline, not to a test harness, and it deserves its own review. But
|
|
* it has to be stated, because a harness nothing runs rots — the assertions here
|
|
* would drift from the components they measure and nobody would learn of it until
|
|
* someone ran `pnpm test:geometry` by hand. Until this project is wired into a
|
|
* job, treat it as a tool you run deliberately, NOT as a gate you are behind.
|
|
*
|
|
* Two things are already true that make wiring it cheap when someone does:
|
|
* `pnpm run test:geometry` is the whole command, and the glob is disjoint from
|
|
* every other project's, so adding it cannot change what any existing job runs.
|
|
*/
|
|
|
|
// ── THE CASCADE, IN PRODUCTION ORDER ─────────────────────────────────────────
|
|
// Mirrors `src/pages/_document.tsx` (the layer-order declaration) followed by
|
|
// the stylesheet imports at the top of `src/pages/_app.tsx`, in that file's
|
|
// order. Import order IS the contract here — see `cascade-layer-order.css`.
|
|
import './cascade-layer-order.css';
|
|
import '~/styles/globals.css';
|
|
import '@mantine/core/styles.layer.css';
|
|
import '@mantine/dates/styles.layer.css';
|
|
import '@mantine/dropzone/styles.layer.css';
|
|
import '@mantine/notifications/styles.layer.css';
|
|
import '@mantine/nprogress/styles.layer.css';
|
|
import '@mantine/tiptap/styles.layer.css';
|
|
import 'mantine-react-table/styles.css';
|
|
|
|
import React from 'react';
|
|
import { afterEach, vi } from 'vitest';
|
|
import { page } from 'vitest/browser';
|
|
import { render, cleanup } from 'vitest-browser-react';
|
|
import { MantineProvider } from '@mantine/core';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
|
|
/**
|
|
* A phone. 390x844 is the iPhone 12/13/14/15 portrait logical viewport and the
|
|
* modal phone width in this app's own RUM.
|
|
*
|
|
* 🔴 IT IS DELIBERATELY NOT THE RUNNER'S 414x896 DEFAULT. Two reasons, and the
|
|
* second is the point of the harness. 414 sits ABOVE the 390/393 band most
|
|
* phones report, so a `min-width` breakpoint or a flex floor that misbehaves
|
|
* between 360 and 400 is invisible at the default; and a number the harness
|
|
* merely inherited cannot be asserted against, because there is nothing to
|
|
* compare it to that would not move with it.
|
|
*/
|
|
export const PHONE_VIEWPORT = { width: 390, height: 844 } as const;
|
|
|
|
/** A narrow phone — the low end of the band, for a second measurement point. */
|
|
export const NARROW_PHONE_VIEWPORT = { width: 360, height: 780 } as const;
|
|
|
|
export type Viewport = { readonly width: number; readonly height: number };
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Environment stubs.
|
|
//
|
|
// 🔴 DELIBERATELY A COPY OF `component-setup.tsx`'s STUBS, NOT AN IMPORT OF IT.
|
|
// Importing that module would re-run its `:root` extraction and inject a SECOND
|
|
// `:root` block on top of the real cascade — the one thing this harness exists
|
|
// to avoid. Factoring the stubs into a third shared module was rejected because
|
|
// `vi.mock` is hoisted PER MODULE by the Vitest transform, so moving the call
|
|
// changes when it registers; that is a behaviour change to the 484-test tier
|
|
// bought for a few lines of de-duplication. The two setups are expected to
|
|
// diverge on the cascade and to agree on the stubs; if you change one stub here,
|
|
// change it there.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
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 {
|
|
__esModule: true,
|
|
useRouter: () => router,
|
|
Router: router,
|
|
default: router,
|
|
withRouter: (Component: React.ComponentType) => Component,
|
|
};
|
|
});
|
|
|
|
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 — the same container-race that
|
|
// `component-setup.tsx` documents at length applies here unchanged.
|
|
afterEach(async () => {
|
|
await cleanup();
|
|
});
|
|
|
|
function Providers({ children }: { children: React.ReactNode }) {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false, gcTime: 0 },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
return (
|
|
<QueryClientProvider client={queryClient}>
|
|
<MantineProvider>{children}</MantineProvider>
|
|
</QueryClientProvider>
|
|
);
|
|
}
|
|
|
|
/** Two frames, so style application and layout have both settled. */
|
|
export function nextLayout(): Promise<void> {
|
|
return new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(() => res())));
|
|
}
|
|
|
|
/** What the WINDOW says its size is — never what the config asked for. */
|
|
export function observedViewport(): { width: number; height: number } {
|
|
return { width: window.innerWidth, height: window.innerHeight };
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// POSITIVE CONTROLS
|
|
//
|
|
// A harness wired to nothing reports a clean zero that is indistinguishable from
|
|
// a pass, so every claim this file makes about "the real stylesheet" has to be a
|
|
// non-zero COUNT or a resolved VALUE that could not exist without it.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/** Every rule in the document, at-rules descended into. */
|
|
export function loadedCssRuleCount(): number {
|
|
let n = 0;
|
|
const walk = (rules: CSSRuleList) => {
|
|
for (const rule of Array.from(rules)) {
|
|
n += 1;
|
|
const nested = (rule as CSSGroupingRule).cssRules;
|
|
if (nested) walk(nested);
|
|
}
|
|
};
|
|
for (const sheet of Array.from(document.styleSheets)) {
|
|
// A cross-origin sheet throws on `.cssRules`; none is expected here, and a
|
|
// silent skip would understate the count, so let it throw rather than hide.
|
|
walk(sheet.cssRules);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
/**
|
|
* Evidence, per source, that the cascade this harness claims to load is LOADED
|
|
* AND APPLIED — each entry a value that is impossible without the stylesheet it
|
|
* names, measured off a real element rather than read out of the CSSOM.
|
|
*
|
|
* Returned rather than asserted so a test states its own expectations; a helper
|
|
* that both measures and judges is one nobody can watch fail.
|
|
*/
|
|
/**
|
|
* The cascade-layer ORDER statement, and whether anything registered a layer
|
|
* before it.
|
|
*
|
|
* 🔴 THE INVARIANT IS "NOTHING NAMED A LAYER FIRST", NOT "IT IS RULE ZERO".
|
|
* A layer's priority is fixed at the first appearance of its NAME, so what has
|
|
* to hold is that no `@layer <name> { … }` block is parsed before the `@layer a,
|
|
* b, c;` statement. Asserting `document.styleSheets[0].cssRules[0]` instead
|
|
* looked equivalent and is not: the runner injects sheets of its own, so that
|
|
* check went red while the real invariant held — a control failing for a reason
|
|
* that had nothing to do with what it claims to protect.
|
|
*/
|
|
export function layerOrderEvidence(): {
|
|
/** The layer names in the `@layer a, b, c;` statement, or null if there is none. */
|
|
declaredOrder: string[] | null;
|
|
/** True when no `@layer <name> { … }` block precedes that statement. */
|
|
declaredBeforeAnyLayerBlock: boolean;
|
|
} {
|
|
let declaredOrder: string[] | null = null;
|
|
let sawLayerBlockFirst = false;
|
|
const haveStatement = typeof CSSLayerStatementRule !== 'undefined';
|
|
const haveBlock = typeof CSSLayerBlockRule !== 'undefined';
|
|
|
|
outer: for (const sheet of Array.from(document.styleSheets)) {
|
|
for (const rule of Array.from(sheet.cssRules)) {
|
|
if (haveStatement && rule instanceof CSSLayerStatementRule) {
|
|
declaredOrder = Array.from(rule.nameList);
|
|
break outer;
|
|
}
|
|
if (haveBlock && rule instanceof CSSLayerBlockRule) {
|
|
sawLayerBlockFirst = true;
|
|
break outer;
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
declaredOrder,
|
|
declaredBeforeAnyLayerBlock: declaredOrder !== null && !sawLayerBlockFirst,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Evidence that the cascade is loaded AND applied.
|
|
*
|
|
* 🔴 EVERY FIELD HERE HAS BEEN MEASURED IN BOTH TIERS AND KEPT ONLY IF IT
|
|
* DISAGREES. A control that reports the same value with and without the thing it
|
|
* is checking attributes nothing, and reads as a confirmation. The body's
|
|
* `margin-top` was in this list and was CUT for exactly that reason: measured
|
|
* 2026-09-03, it is `0px` in the `component` tier too — which loads 24 CSS rules
|
|
* and no preflight at all — so it was evidence of nothing. What survived, with
|
|
* both readings (`component` → `geometry`):
|
|
*
|
|
* ruleCount 24 → 3,677
|
|
* probeBoxSizing content-box → border-box
|
|
* tailwindFlexUtility block → flex
|
|
*/
|
|
export function cascadeEvidence(): {
|
|
ruleCount: number;
|
|
layerOrder: ReturnType<typeof layerOrderEvidence>;
|
|
/** Preflight's `*, ::before, ::after { box-sizing: border-box }`; UA default is `content-box`. */
|
|
probeBoxSizing: string;
|
|
/** `@tailwind utilities` — inert in the `component` tier, which loads none of it. */
|
|
tailwindFlexUtilityResolves: boolean;
|
|
/** A `theme`-layer rule from globals.css. */
|
|
htmlFontSize: string;
|
|
} {
|
|
const probe = document.createElement('div');
|
|
probe.className = 'flex';
|
|
document.body.appendChild(probe);
|
|
const probeStyle = getComputedStyle(probe);
|
|
const evidence = {
|
|
ruleCount: loadedCssRuleCount(),
|
|
layerOrder: layerOrderEvidence(),
|
|
probeBoxSizing: probeStyle.boxSizing,
|
|
tailwindFlexUtilityResolves: probeStyle.display === 'flex',
|
|
htmlFontSize: getComputedStyle(document.documentElement).fontSize,
|
|
};
|
|
probe.remove();
|
|
return evidence;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// MEASUREMENT
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/** A rounded border box. 2dp, so sub-pixel layout is visible but noise is not. */
|
|
export function box(el: Element): {
|
|
top: number;
|
|
right: number;
|
|
bottom: number;
|
|
left: number;
|
|
width: number;
|
|
height: number;
|
|
} {
|
|
const r = el.getBoundingClientRect();
|
|
const q = (n: number) => Math.round(n * 100) / 100;
|
|
return {
|
|
top: q(r.top),
|
|
right: q(r.right),
|
|
bottom: q(r.bottom),
|
|
left: q(r.left),
|
|
width: q(r.width),
|
|
height: q(r.height),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The union of an element's CHILD boxes — "how much box the content actually
|
|
* needs", against which the parent's own box can be compared.
|
|
*
|
|
* 🔴 `scrollHeight` is NOT this and does not answer the same question: it is
|
|
* clamped to the padding box, so a parent that is far TALLER than its content
|
|
* reports its own height and the overshoot is invisible. That is precisely the
|
|
* defect shape here — a 220px box around 53px of content — so the union is
|
|
* computed from the children's own rects.
|
|
*
|
|
* Returns `null` for an element with no element children.
|
|
*/
|
|
export function childrenUnionBox(el: Element): ReturnType<typeof box> | null {
|
|
const kids = Array.from(el.children);
|
|
if (kids.length === 0) return null;
|
|
const rects = kids.map((k) => k.getBoundingClientRect());
|
|
const q = (n: number) => Math.round(n * 100) / 100;
|
|
const top = Math.min(...rects.map((r) => r.top));
|
|
const left = Math.min(...rects.map((r) => r.left));
|
|
const bottom = Math.max(...rects.map((r) => r.bottom));
|
|
const right = Math.max(...rects.map((r) => r.right));
|
|
return {
|
|
top: q(top),
|
|
left: q(left),
|
|
bottom: q(bottom),
|
|
right: q(right),
|
|
width: q(right - left),
|
|
height: q(bottom - top),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A RESOLVED LONGHAND, by CSS property name.
|
|
*
|
|
* Read longhands, never the `flex` shorthand: `getComputedStyle(el).flex` is a
|
|
* serialisation, so `flex: 1` and `flex: 1 1 0%` are the same string and a
|
|
* `flex-basis` regression can hide inside it. `longhand(el, 'flex-basis')`
|
|
* returns `220px` or `0%` and cannot.
|
|
*/
|
|
export function longhand(el: Element, property: string): string {
|
|
return getComputedStyle(el).getPropertyValue(property).trim();
|
|
}
|
|
|
|
/** The three flex longhands together — the shape a `flex` shorthand bug lives in. */
|
|
export function flexLonghands(el: Element): { grow: string; shrink: string; basis: string } {
|
|
return {
|
|
grow: longhand(el, 'flex-grow'),
|
|
shrink: longhand(el, 'flex-shrink'),
|
|
basis: longhand(el, 'flex-basis'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The axis a flex CONTAINER lays its children out on — the fact that decides
|
|
* whether `flex-basis` is a width or a height, and the one nothing in the
|
|
* attribute-level tier can see.
|
|
*/
|
|
export function flexAxis(el: Element): 'row' | 'column' | 'none' {
|
|
const s = getComputedStyle(el);
|
|
if (s.display !== 'flex' && s.display !== 'inline-flex') return 'none';
|
|
return s.flexDirection.startsWith('column') ? 'column' : 'row';
|
|
}
|
|
|
|
/**
|
|
* Render at an EXPLICIT viewport and hand back what the window actually reports.
|
|
*
|
|
* Throws rather than warns on a mismatch: a viewport call that silently did
|
|
* nothing turns every geometry assertion in the file into a claim about a
|
|
* different screen, and that is the failure mode this project exists to remove.
|
|
* The throw is the harness's own floor — tests are still expected to assert
|
|
* `observed` against their own literal.
|
|
*/
|
|
export async function renderAtViewport(
|
|
ui: React.ReactElement,
|
|
viewport: Viewport = PHONE_VIEWPORT
|
|
): Promise<{ result: ReturnType<typeof render>; observed: { width: number; height: number } }> {
|
|
await page.viewport(viewport.width, viewport.height);
|
|
const result = render(ui, { wrapper: Providers });
|
|
await nextLayout();
|
|
const observed = observedViewport();
|
|
if (observed.width !== viewport.width || observed.height !== viewport.height) {
|
|
throw new Error(
|
|
`geometry harness: asked for a ${viewport.width}x${viewport.height} viewport but the window ` +
|
|
`reports ${observed.width}x${observed.height}. Every measurement in this file would be ` +
|
|
'about a screen nobody chose.'
|
|
);
|
|
}
|
|
// 🔴 A SEPARATE FIELD, NOT A PROPERTY BOLTED ONTO THE RENDER RESULT.
|
|
// `vitest-browser-react`'s return value does not accept an `Object.assign`ed
|
|
// key (it reads back `undefined`), so a helper that decorated it would hand
|
|
// every caller a silent `undefined` to assert against — a viewport check that
|
|
// can only ever compare `undefined` to a literal, i.e. exactly the vacuous
|
|
// shape this project exists to remove. Measured, not assumed.
|
|
return { result, observed };
|
|
}
|
|
|
|
/** Render under the provider stack WITHOUT touching the viewport. */
|
|
export function renderWithProviders(ui: React.ReactElement) {
|
|
return render(ui, { wrapper: Providers });
|
|
}
|
|
|
|
/** The 1x1 transparent PNG — same fixture, same reason, as `component-setup.tsx`. */
|
|
export const LOADABLE_IMAGE_DATA_URI =
|
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|