From e92cf5fe4a182210d0868da410b20aeb6a32e60f Mon Sep 17 00:00:00 2001 From: Zachary Lowden Date: Thu, 3 Sep 2026 16:40:40 -0500 Subject: [PATCH] test(geometry): a browser tier that loads the real cascade at a phone viewport (#4601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/lint.yml | 163 ++++++ .gitignore | 1 + package.json | 2 + .../PageBlockHostFillHeight.geometry.test.tsx | 351 +++++++++++++ .../geometryHarness.geometry.test.tsx | 227 +++++++++ test/cascade-layer-order.css | 16 + test/geometry-setup.tsx | 482 ++++++++++++++++++ vitest.config.mts | 238 +++++---- 8 files changed, 1394 insertions(+), 86 deletions(-) create mode 100644 src/components/AppBlocks/PageBlockHostFillHeight.geometry.test.tsx create mode 100644 src/tests/geometry/geometryHarness.geometry.test.tsx create mode 100644 test/cascade-layer-order.css create mode 100644 test/geometry-setup.tsx diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5370fc8026..fb8212a284 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -763,3 +763,166 @@ jobs: # selected a real package, and collects every failure. See its header. - name: Typecheck apps 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-/..." 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 diff --git a/.gitignore b/.gitignore index 5c23b1d4fc..127d4597ac 100644 --- a/.gitignore +++ b/.gitignore @@ -179,6 +179,7 @@ docs/superpowers/ packages-report.json apps-report.json unit-report.json +geometry-report.json /.opencode # Generated by scripts/test-perf/* — measurement output, not source diff --git a/package.json b/package.json index a102f9ec98..574303536c 100644 --- a/package.json +++ b/package.json @@ -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:component": "node scripts/test-component-run.mjs", "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", "tsscript": "NODE_ENV=development tsx", "madge:orphans": "madge --orphans --image ./public/orphans-graph.svg --ts-config ./tsconfig.json --extensions ts,tsx src/", diff --git a/src/components/AppBlocks/PageBlockHostFillHeight.geometry.test.tsx b/src/components/AppBlocks/PageBlockHostFillHeight.geometry.test.tsx new file mode 100644 index 0000000000..a0f4beea79 --- /dev/null +++ b/src/components/AppBlocks/PageBlockHostFillHeight.geometry.test.tsx @@ -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 + * `
` 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()), + 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 + * `
` 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( +
+
+
+
+ + + +
+
+
+
, + 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( +
+
+
+
+ + + +
+
+
+
, + 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); + }); +}); diff --git a/src/tests/geometry/geometryHarness.geometry.test.tsx b/src/tests/geometry/geometryHarness.geometry.test.tsx new file mode 100644 index 0000000000..76ae7e7cf3 --- /dev/null +++ b/src/tests/geometry/geometryHarness.geometry.test.tsx @@ -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(
probe
); + + 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(
probe
, 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(
probe
); + 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 { … }` 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( + + ); + 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 ` 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( + <> + +
+ child +
+
+ +
+ child +
+
+ + ); + + 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( +
+
content
+
+ ); + + 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(
); + const el = document.querySelector('[data-testid="empty"]') as HTMLElement; + expect(childrenUnionBox(el)).toBeNull(); + }); +}); diff --git a/test/cascade-layer-order.css b/test/cascade-layer-order.css new file mode 100644 index 0000000000..4bfb6dbdfd --- /dev/null +++ b/test/cascade-layer-order.css @@ -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 `