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 `