test(geometry): a browser tier that loads the real cascade at a phone viewport (#4601)

* 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>
This commit is contained in:
Zachary Lowden
2026-09-03 16:40:40 -05:00
committed by GitHub
parent d49ea2f7ec
commit e92cf5fe4a
8 changed files with 1394 additions and 86 deletions
+163
View File
@@ -763,3 +763,166 @@ jobs:
# selected a real package, and collects every failure. See its header. # selected a real package, and collects every failure. See its header.
- name: Typecheck apps - name: Typecheck apps
run: node scripts/ci/typecheck-apps.mjs run: node scripts/ci/typecheck-apps.mjs
geometry:
name: Geometry tests
runs-on: ubuntu-latest
# The browser-mode `geometry` project — real Chromium, the real app cascade,
# an explicit phone viewport. See `test/geometry-setup.tsx` for what it loads
# and why it is a separate project from `component`.
#
# 🔴 THIS IS THE FIRST JOB IN THIS WORKFLOW THAT INSTALLS A BROWSER, AND IT IS
# ALIGNED WITH THE `unit` JOB'S COMMENT RATHER THAN AN OVERRIDE OF IT.
# That comment gives exactly one reason for keeping browser tests out:
#
# "Node env, no browser — component tests (`*.browser.test.tsx`) are not
# here on purpose: they need Chromium, which this job does not install.
# That is the whole reason."
#
# It 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 points at vitest.config.mts's dedupe + optimizeDeps pre-bundling as
# the canonical fix for it. So a job that DOES install Chromium satisfies the
# stated condition instead of overriding a decision.
#
# 🔴 GEOMETRY ONLY; `component` IS STILL UNGATED, AND THAT IS NOW VISIBLE
# RATHER THAN FIXED. No project selector in this workflow matches it
# (`unit*`, `@civitai/*`, `app:*`), and its only CI home is the preview
# pipeline's report-only `preview / component-tests`. Adding it here is a
# much larger change than this harness and belongs in its own PR, on measured
# cost: locally, on a 16-core box, `geometry` is 2 files / 10 tests in 9.07s
# while `component` is 212 files / 2,362 tests in 112.92s wall — and 334s of
# that 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 a job an order
# of magnitude more expensive, with 212 files of pass/fail history nobody has
# ever seen in this workflow. Do it deliberately, not as a rider.
#
# REPORT-ONLY ON PULL REQUESTS, REAL VERDICT ON `main` — the same expression
# `unit` uses, and NOT a literal `true`. Both halves matter:
#
# PR side: blocking on 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; what
# this buys is that a red renders as red-but-ignored rather than as a
# failing check on someone else's work while the tier settles.
#
# PUSH side: on a push the merge has already happened, so there is no
# unrelated work to red. What `continue-on-error: true` WOULD buy there is
# a run reporting `success` while the step underneath failed — a green that
# has to be disbelieved, which is worse than no run at all.
#
# 🔴 THIS WAS WRITTEN AS A LITERAL `true` FIRST AND CI CAUGHT IT.
# `scripts/__tests__/main-branch-ci-coverage.test.ts` > "has no
# unconditionally report-only job on the push path" went red on the PR that
# added this job. The guard is not a style rule: it forbids exactly the
# stale-TRUE shape described above, and it accepts a conditional value
# BECAUSE a conditional can differ between a PR and a push while a literal
# cannot. Do not "simplify" this back to `true`.
#
# FLIP TO BLOCKING once a couple of weeks of runs show a clean pass rate, and
# remove `continue-on-error` and this paragraph together. What would make that
# safe, concretely: no run red for a reason other than a real geometry
# regression — in particular no Chromium launch failures and no timeouts on a
# 2-core runner. The tier is 10 tests and ~19s on a runner, so unlike `unit`
# there is no import/transform phase to blame; if it goes red it is either the
# code or the browser, and both are worth someone's attention.
continue-on-error: ${{ github.event_name == 'pull_request' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- name: Install
run: pnpm install --frozen-lockfile
# 🔴 THE WORKSPACE-LOCAL PLAYWRIGHT, NOT A PINNED VERSION WRITTEN HERE.
# `pnpm exec` resolves this repo's own `playwright` (^1.57.0), which knows
# the exact Chromium revision it wants and fetches THAT. A version named in
# this file would be a second pin to keep in step, and desynchronising the
# two is the documented failure: CLAUDE.md records a bump attempt where 59
# preview specs died with "Executable doesn't exist at
# .../chromium_headless_shell-<rev>/..." and not one spec ran.
#
# `--with-deps` installs the shared libraries Chromium needs on a bare
# ubuntu-latest image; `chromium` alone, because the browser config in
# vitest.config.mts declares `instances: [{ browser: 'chromium' }]` and
# firefox/webkit would be several hundred MB of download for nothing.
#
# NOTE FOR ANYONE DEBUGGING THIS FROM A NIXOS LAPTOP: a local
# revision mismatch (a nixpkgs `playwright-driver.browsers` bundle carrying
# chromium-1228 against this repo's 1200) is a property of THAT HOST, not of
# this repo. The escape hatch is `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`,
# honoured by vitest.config.mts. Do NOT move the repo's playwright pin to
# match a laptop — see CLAUDE.md, "Browser/component tests on NixOS".
- name: Install Chromium for Playwright
run: pnpm exec playwright install --with-deps chromium
- name: Geometry tests
run: pnpm exec vitest run --project geometry --reporter=default --reporter=json --outputFile=geometry-report.json
# 🔴 A GREEN VITEST RUN IS A CLAIM, NOT EVIDENCE — the same hazard the
# `packages` job's ledger exists for, one project over. `--project` matching
# NOTHING exits 0, and so does a config whose globs stopped resolving: this
# tier's glob (`src/**/*.geometry.test.tsx`) is deliberately disjoint from
# every other project's, which is exactly the kind of narrow pattern that
# can quietly stop matching. A job that silently runs zero tests is worse
# than no job, because it reports success.
#
# Both numbers are FLOORS, not equalities, so adding a geometry test never
# breaks this — only losing one does.
#
# 🔴 THE FILE COUNT COMES FROM `testResults.length`, NOT FROM
# `numTotalTestSuites`. Measured against a real report: this run is 2 files
# and `numTotalTestSuites` is 4, because that field counts `describe` blocks
# rather than files. A floor written against it would have been off by a
# factor that changes whenever someone adds a `describe`.
#
# `if: always()` so this runs when the tests FAIL too — an aborted runner
# writes no report at all, which is precisely the case this must catch, and
# it is indistinguishable from a healthy run if you only read the step's
# own status.
#
# All three arms were exercised before this was committed: a real report
# exits 0; a report with `testResults: []` / `numTotalTests: 0` exits 1; a
# missing report exits 1 with its own message.
- name: Assert the geometry tier actually collected something
if: always()
run: |
node - <<'JS'
// Minimum ledger for the `geometry` tier.
const MIN_FILES = 2;
const MIN_TESTS = 10;
const REPORT = 'geometry-report.json';
const fs = require('fs');
if (!fs.existsSync(REPORT)) {
console.error(
`geometry ledger: ${REPORT} was never written. The runner did not finish — a ` +
'browser that failed to launch, or a config error. That is NOT "the suite is ' +
'fine"; fail loudly.'
);
process.exit(1);
}
const r = JSON.parse(fs.readFileSync(REPORT, 'utf8'));
const files = Array.isArray(r.testResults) ? r.testResults.length : 0;
const tests = Number(r.numTotalTests ?? 0);
console.error(`geometry ledger: ${files} file(s), ${tests} test(s) collected`);
if (files < MIN_FILES || tests < MIN_TESTS) {
console.error(
`geometry ledger FAILED: expected at least ${MIN_FILES} file(s) and ${MIN_TESTS} ` +
`test(s), got ${files} and ${tests}. A vitest run whose --project matches ` +
'nothing exits 0, so a green verdict here would otherwise be a claim about the ' +
'selector, not about the code.'
);
process.exit(1);
}
JS
+1
View File
@@ -179,6 +179,7 @@ docs/superpowers/
packages-report.json packages-report.json
apps-report.json apps-report.json
unit-report.json unit-report.json
geometry-report.json
/.opencode /.opencode
# Generated by scripts/test-perf/* — measurement output, not source # Generated by scripts/test-perf/* — measurement output, not source
+2
View File
@@ -105,6 +105,8 @@
"test:lint-rules": "vitest run --project 'unit*' src/server/notifications/__tests__/notification-settings-polarity.test.ts src/server/schema/__tests__/track.addView.schema.test.ts src/server/services/__tests__/hub-filter-parity.test.ts src/server/services/__tests__/no-agent-ground-truth-write.test.ts src/server/services/__tests__/no-coerce-boolean-in-api.test.ts src/server/services/__tests__/no-direct-shared-module-mock.test.ts src/server/services/__tests__/no-doubled-free-slot-noun.test.ts src/server/services/__tests__/no-hand-typed-redis-key-constants.test.ts src/server/services/__tests__/no-io-in-transaction.test.ts src/server/services/__tests__/no-lint-rules-script-drift.test.ts src/server/services/__tests__/no-module-scope-cache.test.ts src/server/services/__tests__/no-pk-addressed-engagement-write.test.ts src/server/services/__tests__/no-server-infra-in-app-graph.test.ts src/server/services/__tests__/no-sharp-outside-native-project.test.ts src/server/services/__tests__/no-stale-moderator-route-probe.test.ts src/server/services/__tests__/no-static-html2canvas-import.test.ts src/server/services/__tests__/no-unbounded-paging-fake.test.ts src/server/services/__tests__/no-unguarded-billable-submit.test.ts src/server/services/__tests__/no-unguarded-user-text.test.ts src/server/services/__tests__/no-unloadable-image-fixture.test.ts src/server/services/__tests__/no-unmuteable-comment-processor.test.ts src/server/services/__tests__/no-unpriced-default-model.test.ts src/server/services/__tests__/no-unscoped-email-verification-exemption.test.ts src/server/services/__tests__/no-untruthy-query-gate.test.ts src/server/services/__tests__/no-unverified-provenance-write.test.ts src/server/services/__tests__/no-unwrapped-knob-rotation.test.ts src/server/services/__tests__/no-wholesale-module-mock.test.ts src/server/services/__tests__/poi-checks-strip-benign-phrases.test.ts src/server/services/__tests__/video-leaderboard-badge-staging.test.ts", "test:lint-rules": "vitest run --project 'unit*' src/server/notifications/__tests__/notification-settings-polarity.test.ts src/server/schema/__tests__/track.addView.schema.test.ts src/server/services/__tests__/hub-filter-parity.test.ts src/server/services/__tests__/no-agent-ground-truth-write.test.ts src/server/services/__tests__/no-coerce-boolean-in-api.test.ts src/server/services/__tests__/no-direct-shared-module-mock.test.ts src/server/services/__tests__/no-doubled-free-slot-noun.test.ts src/server/services/__tests__/no-hand-typed-redis-key-constants.test.ts src/server/services/__tests__/no-io-in-transaction.test.ts src/server/services/__tests__/no-lint-rules-script-drift.test.ts src/server/services/__tests__/no-module-scope-cache.test.ts src/server/services/__tests__/no-pk-addressed-engagement-write.test.ts src/server/services/__tests__/no-server-infra-in-app-graph.test.ts src/server/services/__tests__/no-sharp-outside-native-project.test.ts src/server/services/__tests__/no-stale-moderator-route-probe.test.ts src/server/services/__tests__/no-static-html2canvas-import.test.ts src/server/services/__tests__/no-unbounded-paging-fake.test.ts src/server/services/__tests__/no-unguarded-billable-submit.test.ts src/server/services/__tests__/no-unguarded-user-text.test.ts src/server/services/__tests__/no-unloadable-image-fixture.test.ts src/server/services/__tests__/no-unmuteable-comment-processor.test.ts src/server/services/__tests__/no-unpriced-default-model.test.ts src/server/services/__tests__/no-unscoped-email-verification-exemption.test.ts src/server/services/__tests__/no-untruthy-query-gate.test.ts src/server/services/__tests__/no-unverified-provenance-write.test.ts src/server/services/__tests__/no-unwrapped-knob-rotation.test.ts src/server/services/__tests__/no-wholesale-module-mock.test.ts src/server/services/__tests__/poi-checks-strip-benign-phrases.test.ts src/server/services/__tests__/video-leaderboard-badge-staging.test.ts",
"test:component": "node scripts/test-component-run.mjs", "test:component": "node scripts/test-component-run.mjs",
"test:component:watch": "vitest --project component", "test:component:watch": "vitest --project component",
"test:geometry": "vitest run --project geometry",
"test:geometry:watch": "vitest --project geometry",
"meilisearch:migrate": "NODE_ENV=development tsx scripts/oneoffs/meilisearch-migration.ts", "meilisearch:migrate": "NODE_ENV=development tsx scripts/oneoffs/meilisearch-migration.ts",
"tsscript": "NODE_ENV=development tsx", "tsscript": "NODE_ENV=development tsx",
"madge:orphans": "madge --orphans --image ./public/orphans-graph.svg --ts-config ./tsconfig.json --extensions ts,tsx src/", "madge:orphans": "madge --orphans --image ./public/orphans-graph.svg --ts-config ./tsconfig.json --extensions ts,tsx src/",
@@ -0,0 +1,351 @@
/**
* A FULL-PAGE APP BLOCK FILLS THE PHONE — MEASURED IN PIXELS.
*
* ─────────────────────────────────────────────────────────────────────────────
* THE DEFECT THIS IS BUILT AROUND, AND WHY IT IS THE RIGHT ONE TO DEMONSTRATE
* ─────────────────────────────────────────────────────────────────────────────
* `app-page-content` — the app's own column inside `PageBlockHost` — carries
* `flex: 1` because it took over the role of consuming the space the chrome
* leaves. That property is recorded IN THE SOURCE as load-bearing and, in the
* same breath, as uncovered by anything that renders:
*
* "🔴 `flex: 1` IS THE LOAD-BEARING ONE AND NOTHING RENDERED CATCHES ITS LOSS.
* Measured by mutation: dropping it leaves the FULL node suite and the FULL
* `AppBlocks` browser suite green while the app column collapses to ~150px at
* a 900px content height — a running App Block reduced to a sliver, with
* every tier green."
* — src/components/AppBlocks/PageBlockHost.tsx
*
* The only thing standing between that mutation and production today is a SOURCE
* pin in the node tier (`__tests__/pageBlockHostMaxWidth.test.ts`) which asserts
* the style block's TEXT verbatim. A source pin is a real guard and it is not the
* same guard as this one: it cannot see a collapse caused by anything other than
* that exact text changing — a parent losing its own height, a `min-height`
* arriving in the cascade, an ancestor becoming `display: block` — and it has to
* be rewritten every time the block is legitimately reformatted.
*
* This file asserts the CONSEQUENCE instead: at a phone viewport the app column
* reaches the bottom of the frame it lives in. Nothing about the spelling.
*
* ─────────────────────────────────────────────────────────────────────────────
* WHY IT COULD NOT BE WRITTEN IN THE `component` TIER
* ─────────────────────────────────────────────────────────────────────────────
* Not because that tier cannot lay out — it is real headless Chromium and its
* `getBoundingClientRect()` returns real boxes. Because of what it does and does
* not load. Two independent reasons, both measured:
*
* 1. The shell chain this fixture reproduces is TAILWIND (`flex flex-1
* flex-col overflow-hidden`, straight out of `AppLayout`). The `component`
* tier loads no Tailwind utilities at all, so every one of those classes
* computes `display: block` there and the fixture is not a flex column — the
* host would have no definite parent height and the measurement would be
* meaningless whether or not the defect exists.
* 2. `flex: 1` is only a HEIGHT because the container is a `column`. Without the
* Mantine + app cascade the container's own axis is not what production's is,
* so the property under test is not even on the axis being measured.
*
* And no viewport: the runner's silent default is 414x896, a size nothing in this
* suite chose.
*
* 🔴 MEASURED, THIS EXACT FIXTURE, CORRECT SOURCE, IN BOTH TIERS (2026-09-03):
*
* `component` `geometry`
* viewport 414 x 896 390 x 844
* CSS rules in the document 24 3,677
* `<main className="flex …">` block flex
* chrome bar height 200 31
* host frame height 350 844
* APP COLUMN HEIGHT 150 813
*
* `150` is also what the app column measures HERE with the recorded `flex: 1`
* defect planted. So the number the `component` tier reports for CORRECT code is
* the number the defect produces — an assertion written there could not have
* separated them at any threshold.
*
* ─────────────────────────────────────────────────────────────────────────────
* WHAT THIS DOES AND DOES NOT ADD OVER THE EXISTING GUARDS
* ─────────────────────────────────────────────────────────────────────────────
* Stated plainly, because the honest answer is not "it catches what nothing else
* does". Both mutations below were run against the node tier as well, and BOTH
* are caught there today by verbatim source pins:
*
* · drop `flex: 1` from `app-page-content` → this file fails with the column at
* 150px of an 844px frame; `__tests__/pageBlockHostMaxWidth.test.ts` also fails.
* · drop `flex: 1` from the frame's `fit === 'fill'` branch → this file fails
* with the column at 269px; `__tests__/pageRunScrollContract.test.ts` also fails.
*
* The difference is what each guard is ABLE to see. A verbatim source pin is a
* claim about the TEXT of one file: it cannot see a collapse that arrives from
* the cascade (a ledger entry in globals.css, a Mantine upgrade), from an
* ancestor, or from a viewport at which the arithmetic stops working — and it has
* to be rewritten every time the block is legitimately reformatted, which is when
* a pin is most likely to be relaxed. This file asserts the consequence and is
* indifferent to spelling.
*/
import { Box } from '@mantine/core';
import { describe, expect, test, vi } from 'vitest';
import type * as TrpcMod from '~/utils/trpc';
import {
PHONE_VIEWPORT,
box,
cascadeEvidence,
flexAxis,
flexLonghands,
renderAtViewport,
} from '../../../test/geometry-setup';
vi.mock('~/hooks/useCurrentUser', () => ({ useCurrentUser: () => null }));
// Spread the REAL module and override only what this render touches
// (local-rules/no-wholesale-module-mock). A one-key mock goes stale the moment
// the module gains an export and takes the whole file to "0 tests collected".
vi.mock('~/utils/trpc', async (importOriginal) => ({
...(await importOriginal<typeof TrpcMod>()),
setTrpcBatchingEnabled: vi.fn(),
trpc: {
generation: { resolveWildcardPack: { useMutation: () => ({ mutateAsync: vi.fn() }) } },
blocks: {
submitWorkflow: { useMutation: () => ({ mutateAsync: vi.fn() }) },
getMyBuzzBalance: { useMutation: () => ({ mutateAsync: vi.fn() }) },
getMyViewer: { useMutation: () => ({ mutateAsync: vi.fn() }) },
getMyBuzzTransactions: { useMutation: () => ({ mutateAsync: vi.fn() }) },
getMyBuzzAccounts: { useMutation: () => ({ mutateAsync: vi.fn() }) },
getMyDailyCompensation: { useMutation: () => ({ mutateAsync: vi.fn() }) },
estimateWorkflow: { useMutation: () => ({ mutateAsync: vi.fn() }) },
pollWorkflow: { useMutation: () => ({ mutateAsync: vi.fn() }) },
cancelWorkflow: { useMutation: () => ({ mutateAsync: vi.fn() }) },
queryAppWorkflows: { useMutation: () => ({ mutateAsync: vi.fn() }) },
cancelAppWorkflow: { useMutation: () => ({ mutateAsync: vi.fn() }) },
publishGenerationOutputs: { useMutation: () => ({ mutateAsync: vi.fn() }) },
getImagesByIds: { useMutation: () => ({ mutateAsync: vi.fn() }) },
},
apps: {
shared: {
append: { useMutation: () => ({ mutateAsync: vi.fn() }) },
update: { useMutation: () => ({ mutateAsync: vi.fn() }) },
vote: { useMutation: () => ({ mutateAsync: vi.fn() }) },
unvote: { useMutation: () => ({ mutateAsync: vi.fn() }) },
withdraw: { useMutation: () => ({ mutateAsync: vi.fn() }) },
report: { useMutation: () => ({ mutateAsync: vi.fn() }) },
},
storage: {
set: { useMutation: () => ({ mutateAsync: vi.fn() }) },
delete: { useMutation: () => ({ mutateAsync: vi.fn() }) },
},
},
useUtils: () => ({
apps: {
shared: {
list: { fetch: vi.fn() },
getCount: { fetch: vi.fn() },
getCounts: { fetch: vi.fn() },
get: { fetch: vi.fn() },
},
storage: {
get: { fetch: vi.fn() },
list: { fetch: vi.fn() },
getQuota: { fetch: vi.fn() },
},
},
}),
},
}));
// eslint-disable-next-line import/first
import { PageBlockHost } from '~/components/AppBlocks/PageBlockHost';
const SAME_ORIGIN_SRC = `${window.location.origin}/`;
const baseProps = {
appBlockId: 'apb_fillheight',
blockId: 'fill-height-app',
appId: 'app_fillheight',
blockInstanceId: 'page_apb_fillheight',
appName: 'Fill Height App',
iframeSrc: SAME_ORIGIN_SRC,
surface: 'page-run' as const,
bootSkeleton: false,
sandbox: 'allow-scripts',
trustTier: 'internal' as const,
slug: 'fill-height-app',
token: 'tok_fillheight',
expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
declaredScopes: [] as string[],
missingScopes: [] as string[],
needsConsent: false,
tokenError: false,
viewer: null,
theme: 'light' as const,
};
/**
* The production chain, reduced to what decides HEIGHT.
*
* Reproduces `AppLayout`'s no-scroll branch verbatim, in ITS OWN CLASSES —
* `MainContent`'s `no-scroll group flex flex-1 flex-col overflow-hidden` and the
* `<main className="flex flex-1 flex-col overflow-hidden">` inside it — followed
* by the run page's own wrapper `Box`. Copying the classes rather than
* paraphrasing them into inline styles is deliberate: the chain's behaviour IS
* those utilities, and a paraphrase would be a fixture asserting against itself.
*
* The outermost element is given the viewport's own height, because in
* production that box is the document and `scrollable: false` makes the whole
* column exactly one screen tall.
*/
function renderRunPageChain() {
return renderAtViewport(
<div
data-testid="shell-root"
className="flex flex-col"
style={{ height: PHONE_VIEWPORT.height }}
>
<div className="flex flex-1 overflow-hidden">
<div className="no-scroll group flex flex-1 flex-col overflow-hidden">
<main data-testid="layout-main" className="flex flex-1 flex-col overflow-hidden">
<Box
data-testid="page-wrapper"
style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}
>
<PageBlockHost {...baseProps} fit="fill" />
</Box>
</main>
</div>
</div>
</div>,
PHONE_VIEWPORT
);
}
function el(testid: string): HTMLElement {
const node = document.querySelector(`[data-testid="${testid}"]`);
if (!node) throw new Error(`geometry fixture: no element with data-testid="${testid}"`);
return node as HTMLElement;
}
describe('PageBlockHost — the app column fills the phone', () => {
/**
* 🔴 GUARD THE INSTRUMENT BEFORE READING IT.
*
* Every claim below is "this box is as tall as that box", which a fixture that
* is not a flex column satisfies for the wrong reason — or fails for the wrong
* reason, which is worse, because it reads as the defect. So the viewport, the
* cascade and the fixture's own axis are asserted first, each against a value
* that cannot be produced without the thing it is checking.
*/
test('POSITIVE CONTROL — a 390x844 phone, a loaded cascade, and a real flex column', async () => {
const { observed } = await renderRunPageChain();
expect(observed).toEqual({ width: 390, height: 844 });
const evidence = cascadeEvidence();
expect(
evidence.ruleCount,
`only ${evidence.ruleCount} CSS rules are loaded — this file is measuring an unstyled document`
).toBeGreaterThan(500);
expect(evidence.tailwindFlexUtilityResolves).toBe(true);
// The fixture's own chain. `flex flex-1 flex-col` is Tailwind, so in a tier
// without the utility layer each of these is `display: block` and `flexAxis`
// returns `'none'`.
expect(
flexAxis(el('shell-root')),
'the shell root is not a flex column — the Tailwind utility layer is not applied and this ' +
'fixture is not the chain it claims to reproduce'
).toBe('column');
expect(flexAxis(el('layout-main'))).toBe('column');
expect(flexAxis(el('page-wrapper'))).toBe('column');
expect(flexAxis(el('app-page-content'))).toBe('column');
// And the chain really has a definite height to distribute — without this,
// "the content fills the frame" is true of two zero-height boxes.
expect(box(el('shell-root')).height).toBe(844);
expect(box(el('page-wrapper')).height).toBeGreaterThan(700);
});
/**
* 🔴 THE REGRESSION CLAIM — asserted as PIXELS, on a RELATIONSHIP.
*
* Two boxes, in the right order:
* · `app-page-frame` — the host root; carries the chrome bar and spans the page.
* · `app-page-content` — the app's own column, which must consume everything the
* chrome leaves.
*
* The pair is what makes this coverage rather than an invariant guard. "The
* content has a height" alone is green on the mutant (150px is a height);
* "the content's bottom edge is the frame's bottom edge" is not, and cannot be
* satisfied by a column that stopped growing.
*/
test('the app column reaches the bottom of the frame at 390x844', async () => {
await renderRunPageChain();
const frame = box(el('app-page-frame'));
const content = box(el('app-page-content'));
const chrome = box(el('app-block-chrome'));
// The headline. A collapsed column's bottom sits far above the frame's.
expect(
content.bottom,
`the app column ends at y=${content.bottom} inside a frame that ends at y=${frame.bottom}` +
`${Math.round(frame.bottom - content.bottom)}px of the phone is blank below a running ` +
`App Block. The column measured ${content.height}px of the frame's ${frame.height}px, ` +
`with flex longhands ${JSON.stringify(flexLonghands(el('app-page-content')))}.`
).toBeCloseTo(frame.bottom, 1);
// The same fact stated as an amount, so a failure names the sliver rather
// than a coordinate. Frame minus chrome is exactly what `flex: 1` claims.
expect(
content.height,
`the app column is ${content.height}px tall; the frame is ${frame.height}px and the chrome ` +
`bar above it is ${chrome.height}px, so the column should be ` +
`${Math.round((frame.height - chrome.height) * 100) / 100}px.`
).toBeCloseTo(frame.height - chrome.height, 1);
// 🔴 A LITERAL FLOOR, deliberately not derived from any constant this render
// produced. The recorded mutant collapses the column to ~150px; at an 844px
// viewport the healthy value is ~800. 600 sits between the two with room on
// both sides, and — unlike the two comparisons above — it survives a mutation
// that shrinks the FRAME as well as the content, which would keep them equal.
expect(
content.height,
`the app column is ${content.height}px tall on an 844px-high phone — a running App Block ` +
'reduced to a sliver'
).toBeGreaterThan(600);
});
/**
* The second measurement point. A single viewport cannot distinguish "fills its
* parent" from "happens to be 800px tall", and a floor written at one height is
* exactly the kind of number that stops meaning anything when the harness's
* default moves.
*/
test('it fills a SHORTER phone too — the column tracks the viewport, not a constant', async () => {
const shortPhone = { width: 390, height: 640 } as const;
const { observed } = await renderAtViewport(
<div className="flex flex-col" style={{ height: shortPhone.height }}>
<div className="flex flex-1 overflow-hidden">
<div className="no-scroll group flex flex-1 flex-col overflow-hidden">
<main className="flex flex-1 flex-col overflow-hidden">
<Box style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<PageBlockHost {...baseProps} fit="fill" />
</Box>
</main>
</div>
</div>
</div>,
shortPhone
);
expect(observed).toEqual({ width: 390, height: 640 });
const frame = box(el('app-page-frame'));
const content = box(el('app-page-content'));
const chrome = box(el('app-block-chrome'));
expect(content.bottom).toBeCloseTo(frame.bottom, 1);
expect(content.height).toBeCloseTo(frame.height - chrome.height, 1);
// 640 - chrome, so materially SHORTER than the 844 case: the column is not a
// constant that happened to clear the floor above.
expect(content.height).toBeGreaterThan(400);
expect(content.height).toBeLessThan(640);
});
});
@@ -0,0 +1,227 @@
/**
* THE HARNESS'S OWN POSITIVE CONTROLS.
*
* 🔴 EVERY OTHER FILE IN THIS PROJECT IS A CLAIM ABOUT PIXELS, AND A PIXEL CLAIM
* IS ONLY WORTH THE CASCADE IT WAS MEASURED AGAINST. A harness that silently
* loaded nothing would compute `0` for every gap and `block` for every flex
* container, and a suite written against it would pass while observing nothing —
* which is precisely the failure in the `component` tier that this project
* exists to close. So the things the harness advertises are asserted here as
* non-zero COUNTS and as RESOLVED VALUES that cannot exist without the
* stylesheet that produces them.
*
* These are controls, not coverage: they are expected to be green forever, and
* the moment one goes red every geometry number in this project is void.
*/
import { Button, Group } from '@mantine/core';
import { describe, expect, test } from 'vitest';
import {
NARROW_PHONE_VIEWPORT,
PHONE_VIEWPORT,
box,
cascadeEvidence,
childrenUnionBox,
flexAxis,
flexLonghands,
loadedCssRuleCount,
observedViewport,
renderAtViewport,
} from '../../../test/geometry-setup';
describe('geometry harness — positive controls', () => {
/**
* 🔴 THE VIEWPORT IS OBSERVED, NOT TRUSTED.
*
* The runner's DEFAULT is 414x896 — measured against this repo's pinned Vitest
* 4.1.11 / Playwright provider on 2026-09-03. It is a default, not "unset", and
* that is the trap: a suite that never calls `page.viewport()` is measuring
* 414px and does not say so, and the number belongs to the runner rather than
* to anything in this repo, so a Vitest bump can move it with nothing to
* notice. Both directions are asserted here — the requested size is observed,
* AND it is not the default it would have inherited.
*/
test('the phone viewport this harness sets is the one the window reports', async () => {
const { observed } = await renderAtViewport(<div data-testid="probe">probe</div>);
expect(
observed,
'the window did not report the viewport the harness asked for, so every measurement in ' +
'this project is about a screen nobody chose'
).toEqual({ width: 390, height: 844 });
expect(observed).toEqual({ width: PHONE_VIEWPORT.width, height: PHONE_VIEWPORT.height });
// NEGATIVE HALF: 414x896 is what an unset viewport would have given, so a
// `page.viewport` call that silently did nothing lands exactly here.
expect(
observed.width,
'the observed width is the runner default (414), i.e. `page.viewport` did not take effect'
).not.toBe(414);
});
test('a SECOND, narrower viewport is also observed — the setting is not a one-off', async () => {
// One measurement is not a general claim: two points, a boundary and a
// middle of the phone band, so "the harness can set a viewport" is not
// resting on the single value the default happens not to equal.
const { observed } = await renderAtViewport(<div>probe</div>, NARROW_PHONE_VIEWPORT);
expect(observed).toEqual({ width: 360, height: 780 });
expect(observedViewport()).toEqual({ width: 360, height: 780 });
});
/**
* 🔴 THE CASCADE IS LOADED — one non-zero count and three resolved values, each
* measured in BOTH tiers and kept only because the two disagree.
*
* `component` `geometry`
* CSS rules 24 3,677
* box-sizing on a bare div content-box border-box
* `className="flex"` block flex
*
* The rule-count floor sits between those two numbers with a wide margin on
* each side, so it catches "wired to nothing" without becoming a gate that
* churns on a Mantine or Tailwind version bump.
*/
test('the REAL app cascade is loaded and applied', async () => {
await renderAtViewport(<div>probe</div>);
const evidence = cascadeEvidence();
expect(
evidence.ruleCount,
`the document holds only ${evidence.ruleCount} CSS rules — the app cascade did not load, ` +
'and every geometry assertion in this project is measuring an unstyled document'
).toBeGreaterThan(500);
// Tailwind preflight's `*, ::before, ::after { box-sizing: border-box }`. The
// UA default is `content-box`, which is what the `component` tier reports.
expect(evidence.probeBoxSizing).toBe('border-box');
// 🔴 `@tailwind utilities`. THE `component` TIER LOADS NONE OF IT, so a
// `className="flex"` computes `display: block` there. This repo's shell
// layout is Tailwind-heavy, which is why a whole layer of it is
// unexpressible in that tier and expressible here.
expect(
evidence.tailwindFlexUtilityResolves,
'a `className="flex"` element does not compute `display: flex` — the Tailwind utility ' +
'layer is missing, so any fixture built from the real shell classes is inert'
).toBe(true);
// The `@layer` ORDER statement, and the invariant that actually matters: no
// `@layer <name> { … }` block registered a layer name before it. The names
// are compared verbatim against `src/pages/_document.tsx`'s production
// string — a subset check would pass with a layer silently dropped.
expect(
evidence.layerOrder.declaredOrder,
'the cascade-layer order statement is missing or does not match production — layer ' +
'priority here is not the priority the app ships'
).toEqual(['tailwind-preflight', 'theme', 'mantine', 'modules']);
expect(
evidence.layerOrder.declaredBeforeAnyLayerBlock,
'a layered stylesheet was parsed BEFORE the order statement, so layer priority is being ' +
'decided by import order rather than by the declaration production uses'
).toBe(true);
});
/**
* The Mantine half, measured off a REAL rendered component rather than out of
* the CSSOM. `--mantine-*` custom properties are NOT a witness for this:
* `MantineProvider` injects those at runtime, so they resolve in the
* `component` tier too, which loads no Mantine stylesheet at all. What only the
* stylesheet can produce is a Mantine CLASS having geometry.
*/
test('Mantine component rules are loaded — a rendered Button has real geometry', async () => {
const before = loadedCssRuleCount();
expect(before).toBeGreaterThan(500);
await renderAtViewport(
<Button data-testid="mantine-button" size="sm">
Go
</Button>
);
const el = document.querySelector('[data-testid="mantine-button"]') as HTMLElement;
const b = box(el);
const padInlineStart = parseFloat(
getComputedStyle(el).getPropertyValue('padding-inline-start')
);
expect(
b.height,
`a Mantine Button rendered ${b.height}px tall — with no Mantine stylesheet it is an ` +
'unstyled inline element and every box measured in this project is meaningless'
).toBeGreaterThan(20);
expect(padInlineStart).toBeGreaterThan(0);
});
/**
* 🔴 THE AXIS AND THE LONGHANDS — the two facts the attribute tier cannot see,
* and the pair a `flex: 1 1 <len>` defect lives in.
*
* A Mantine `Group` is a `row` by default; forcing `flexDirection: 'column'` is
* a real shape in this repo (`ChromeSurfaceGroup` does exactly that). Under a
* harness with no Mantine stylesheet `Group` is `display: block`, so `flexAxis`
* returns `'none'` and neither arm below is observable.
*/
test('a flex container reports its AXIS, and a child reports its LONGHANDS', async () => {
await renderAtViewport(
<>
<Group data-testid="row-group">
<div data-testid="row-child" style={{ flex: '1 1 220px' }}>
child
</div>
</Group>
<Group data-testid="col-group" style={{ flexDirection: 'column' }}>
<div data-testid="col-child" style={{ flex: '1 1 220px' }}>
child
</div>
</Group>
</>
);
const rowGroup = document.querySelector('[data-testid="row-group"]') as HTMLElement;
const colGroup = document.querySelector('[data-testid="col-group"]') as HTMLElement;
expect(
flexAxis(rowGroup),
'a Mantine `Group` is not a flex row here — the Mantine stylesheet is not applied, so no ' +
'axis-dependent claim in this project can be believed'
).toBe('row');
expect(flexAxis(colGroup)).toBe('column');
// The longhand survives the shorthand's serialisation. `getComputedStyle(el).flex`
// renders `1 1 220px` and `1 1 0%` the same shape; `flex-basis` does not.
const rowChild = document.querySelector('[data-testid="row-child"]') as HTMLElement;
expect(flexLonghands(rowChild)).toEqual({ grow: '1', shrink: '1', basis: '220px' });
});
/**
* `childrenUnionBox` is the measurement the whole defect class turns on — "how
* much box does the content actually need", against which a parent's own box
* can be compared. `scrollHeight` cannot answer it (it is clamped to the
* padding box, so a parent far TALLER than its content reports its own
* height), which is why the helper exists at all. Both halves are asserted:
* the union is smaller than a deliberately-oversized parent, and `scrollHeight`
* is shown to be blind to exactly that gap.
*/
test('childrenUnionBox sees an oversized parent that scrollHeight cannot', async () => {
await renderAtViewport(
<div data-testid="oversized" style={{ height: 220, display: 'flex', alignItems: 'start' }}>
<div style={{ height: 53 }}>content</div>
</div>
);
const parent = document.querySelector('[data-testid="oversized"]') as HTMLElement;
const union = childrenUnionBox(parent);
expect(union).not.toBeNull();
expect(box(parent).height).toBe(220);
expect(union?.height).toBe(53);
// The blind instrument, named so nobody reaches for it: the parent's own
// scrollHeight reports 220 and the 167px of empty box is invisible in it.
expect(parent.scrollHeight).toBe(220);
});
test('childrenUnionBox returns null for a childless element rather than a fake zero', async () => {
await renderAtViewport(<div data-testid="empty" style={{ height: 40 }} />);
const el = document.querySelector('[data-testid="empty"]') as HTMLElement;
expect(childrenUnionBox(el)).toBeNull();
});
});
+16
View File
@@ -0,0 +1,16 @@
/* The cascade layer ORDER, as the first CSS the geometry harness parses.
*
* A layer's priority is fixed at its FIRST APPEARANCE in the CSSOM, so this has
* to be parsed before `globals.css` or any Mantine `styles.layer.css`. The
* production copy of this declaration is a `<style>` node emitted as the first
* child of `<head>` in `src/pages/_document.tsx`; a setup module cannot do the
* same imperatively, because ES imports are hoisted and evaluate before any
* statement in the module body. A stylesheet imported FIRST is the one ordering
* mechanism that runs early enough.
*
* Keep this string byte-identical to the one in `_document.tsx`. If they drift,
* the harness measures a different cascade from production while looking correct
* — Tailwind preflight would start winning over Mantine, or Mantine over CSS
* modules, and every geometry number would move for a reason nothing reports.
*/
@layer tailwind-preflight, theme, mantine, modules;
+482
View File
@@ -0,0 +1,482 @@
/**
* 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==';
+152 -86
View File
@@ -295,7 +295,133 @@ const unitTestConfig = {
experimental: { fsModuleCache: true }, experimental: { fsModuleCache: true },
}; };
// Three Vitest projects sharing one config/runner: // ─────────────────────────────────────────────────────────────────────────────
// SHARED BY BOTH BROWSER PROJECTS (`component` and `geometry`).
//
// 🔴 ONE COPY, DELIBERATELY. The two differ in exactly two settings — their glob
// and their setup file — and every other browser setting has to stay identical or
// the split changes behaviour as well as which cascade is loaded. A duplicated
// `optimizeDeps.include` in particular would regenerate the cold-cache reload
// failure documented on it the first time one copy is updated and the other is not.
// ─────────────────────────────────────────────────────────────────────────────
// 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
// browser test from this class of dual-React crash.
const browserResolve = { alias: componentAlias, dedupe: ['react', 'react-dom'] };
// Pre-bundle deps the browser setups mock/import 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 the 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 browser 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.
const browserOptimizeDeps = {
include: [
'next/router',
'vitest-browser-react',
'react/jsx-dev-runtime',
'react/jsx-runtime',
// `react-dom/server` — used by the SSR→hydrate tests
// (`*.ssrHydration.browser.test.tsx`) to produce real server HTML and
// hydrate it. WITHOUT this entry Vite discovers it mid-run and emits a
// SEPARATE optimized chunk that carries its OWN copy of `react`, so
// `ReactCurrentDispatcher.current` is null inside it and every hook call
// during `renderToString` throws
// `Cannot read properties of null (reading 'useState')` — the dual-React
// failure the `dedupe` above exists to prevent, arriving through the
// optimizer rather than through a transitive dependency. Listing it here
// pre-bundles it in the same pass as `react`/`react-dom`, so all three
// share one instance.
'react-dom/server',
],
// `@vitest/browser` seeds optimizeDeps.entries from EVERY browser test 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 browser project with "No loader is configured for '.node'
// files" regardless of which test you targeted. Exclude it; no browser test needs it.
exclude: ['sharp'],
};
// 🔴 A FACTORY, NOT A SHARED OBJECT — and the difference is observable.
// Vitest STAMPS each browser instance with a name derived from the project that
// owns it (`<project> (chromium)`). Handing two projects the SAME `instances`
// array therefore lets the first project's label survive onto the second's
// output: with a shared object the `geometry` project's failures printed under
// `|component (chromium)|`, which is a run report naming the wrong tier — the
// most expensive kind of wrong, because it points every reader at the wrong
// setup file. Each project gets its own object.
const browserModeOptions = () => ({
enabled: true as const,
// `--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 installs Playwright's own Chromium into the image (env unset),
// so it resolves by revision as normal. `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`
// is the escape hatch for a host whose browser bundle does not carry the
// revision this playwright release pins (the NixOS
// `PLAYWRIGHT_BROWSERS_PATH` case) — it bypasses the revision lookup.
// The better fix is to point PLAYWRIGHT_BROWSERS_PATH at a bundle whose
// version EQUALS this repo's `playwright` pin (1.57.x → chromium-1200),
// rather than moving the pin; see CLAUDE.md "Browser/component tests on
// NixOS". A mismatch does not say "no browser" — it collects every file
// and executes none, which reads as a broken suite.
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 as const,
instances: [{ browser: 'chromium' }],
});
/** Everything a browser project needs outside its own `test` block. */
const browserProjectShell = () => ({
resolve: { ...browserResolve },
plugins: [componentGroupOrderPlugin],
optimizeDeps: { ...browserOptimizeDeps },
});
/**
* Everything both browser projects put INSIDE `test`, minus `name`, `include` and
* `setupFiles`.
*
* 🔴 BOTH PROJECTS TAKE THE SAME `maxWorkers` AND THE SAME `groupOrder`, AND THAT
* PAIRING IS REQUIRED, NOT COSMETIC. `groupSpecs` refuses two projects that share a
* `sequence.groupOrder` but resolve to different worker counts, and it reports that
* refusal as `Test Files: no tests` rather than as a failure — the reassuring-zero
* shape. Sharing one object is what makes them impossible to drift apart.
*/
const browserTestShell = () => ({
maxWorkers: componentMaxWorkers,
// See componentGroupOrderPlugin — this is the static half. Costs only a bare `vitest run`,
// which serialises the browser projects against the node ones instead of interleaving
// them; CI and every script select one project at a time.
sequence: { groupOrder: COMPONENT_GROUP_ORDER },
globals: true as const,
browser: browserModeOptions(),
});
// Four Vitest projects sharing one config/runner:
// - `unit` = the node-env suite, minus the six sharp-executing files. // - `unit` = the node-env suite, minus the six sharp-executing files.
// - `unit-native` = those six files, on `forks`, for the reason above. // - `unit-native` = those six files, on `forks`, for the reason above.
// - `component` = browser-mode (real Chromium via Playwright) for React // - `component` = browser-mode (real Chromium via Playwright) for React
@@ -303,6 +429,11 @@ const unitTestConfig = {
// unit project never boots a browser (its include is `.test.ts` // unit project never boots a browser (its include is `.test.ts`
// only, so `.tsx` is already excluded — the glob is explicit // only, so `.tsx` is already excluded — the glob is explicit
// belt-and-suspenders). See `test/component-setup.tsx`. // belt-and-suspenders). See `test/component-setup.tsx`.
// - `geometry` = browser-mode with the REAL app cascade loaded and an EXPLICIT
// viewport, for tests that assert PIXELS. Distinct
// `.geometry.test.tsx` glob, disjoint from both of the above. See
// `test/geometry-setup.tsx` for why it is a second project rather
// than a change to the shared one.
export default defineConfig({ export default defineConfig({
resolve: { alias }, resolve: { alias },
test: { test: {
@@ -379,97 +510,32 @@ export default defineConfig({
}, },
}, },
{ {
// dedupe React so a transitive dep can't pull a second copy — a second ...browserProjectShell(),
// 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'] },
plugins: [componentGroupOrderPlugin],
// 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',
// `react-dom/server` — used by the SSR→hydrate tests
// (`*.ssrHydration.browser.test.tsx`) to produce real server HTML and
// hydrate it. WITHOUT this entry Vite discovers it mid-run and emits a
// SEPARATE optimized chunk that carries its OWN copy of `react`, so
// `ReactCurrentDispatcher.current` is null inside it and every hook call
// during `renderToString` throws
// `Cannot read properties of null (reading 'useState')` — the dual-React
// failure the `dedupe` above exists to prevent, arriving through the
// optimizer rather than through a transitive dependency. Listing it here
// pre-bundles it in the same pass as `react`/`react-dom`, so all three
// share one instance.
'react-dom/server',
],
// `@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: { test: {
...browserTestShell(),
name: 'component', name: 'component',
maxWorkers: componentMaxWorkers,
// See componentGroupOrderPlugin — this is the static half. Costs only a bare `vitest run`,
// which serialises the two projects instead of interleaving them; CI and every script
// select one project at a time.
sequence: { groupOrder: COMPONENT_GROUP_ORDER },
globals: true,
include: ['src/**/*.browser.test.tsx'], include: ['src/**/*.browser.test.tsx'],
// process-shim MUST come first (no imports) so it runs before any // process-shim MUST come first (no imports) so it runs before any
// component's module graph reads `process.env` at import time. // component's module graph reads `process.env` at import time.
setupFiles: ['test/browser-process-shim.ts', 'test/component-setup.tsx'], 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 // The GEOMETRY tier. Same browser, same shell, different cascade.
// as UID 0; without --no-sandbox Chromium refuses to start, and the //
// container's small /dev/shm crashes it without --disable-dev-shm-usage). // 🔴 THE GLOB IS DISJOINT FROM EVERY OTHER PROJECT'S, ON PURPOSE.
// Harmless locally. // `component` claims `src/**/*.browser.test.tsx` and `unit` claims
// CI installs Playwright's own Chromium into the image (env unset), // `src/**/*.test.ts` — `.ts`, so `.tsx` is already outside it. Nothing
// so it resolves by revision as normal. `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` // that runs today changes project and no file is collected twice; a
// is the escape hatch for a host whose browser bundle does not carry the // `.geometry.browser.test.tsx` spelling would have been claimed by BOTH
// revision this playwright release pins (the NixOS // browser projects and run under two different cascades, which is the one
// `PLAYWRIGHT_BROWSERS_PATH` case) — it bypasses the revision lookup. // outcome that would make a red here uninterpretable.
// The better fix is to point PLAYWRIGHT_BROWSERS_PATH at a bundle whose ...browserProjectShell(),
// version EQUALS this repo's `playwright` pin (1.57.x → chromium-1200), test: {
// rather than moving the pin; see CLAUDE.md "Browser/component tests on ...browserTestShell(),
// NixOS". A mismatch does not say "no browser" — it collects every file name: 'geometry',
// and executes none, which reads as a broken suite. include: ['src/**/*.geometry.test.tsx'],
provider: playwright({ setupFiles: ['test/browser-process-shim.ts', 'test/geometry-setup.tsx'],
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' }],
},
}, },
}, },
], ],