From c7f7e04ecd32c1e39fa26dc3c37ccdbf4f92cb91 Mon Sep 17 00:00:00 2001 From: Leon Krix Date: Sat, 24 Oct 2026 10:09:26 +0200 Subject: [PATCH 1/2] test: use the playwright-layout-guard package and fix a racy Root Cause test --- CLAUDE.md | 2 +- PLAN.md | 2 +- package.json | 1 + playwright.config.ts | 6 +- pnpm-lock.yaml | 13 ++ pnpm-workspace.yaml | 4 + src/content/notes/a-page-that-never-moves.mdx | 16 +- .../notes/snippets/layout-guard-moved.snippet | 6 +- src/content/projects/website.yaml | 2 +- src/games/README.md | 2 +- src/games/word-radar/README.md | 2 +- tests/build-output/e2e-coverage.test.ts | 2 +- tests/e2e/fixtures.ts | 44 ++-- tests/e2e/layout-guard-canary.spec.ts | 90 +++++++++ tests/e2e/layout-guard.ts | 191 ------------------ tests/e2e/root-cause.spec.ts | 4 + 16 files changed, 157 insertions(+), 230 deletions(-) create mode 100644 tests/e2e/layout-guard-canary.spec.ts delete mode 100644 tests/e2e/layout-guard.ts diff --git a/CLAUDE.md b/CLAUDE.md index bc8a60b..98f4b59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -247,7 +247,7 @@ CI checks the code before it is deployed. These look at what is actually running ## Games / Playground (Phase 4) -Full plan in PLAN.md, Phase 4. The foundation (4.0) is built and **Tech Words (4.1) is live** (`src/games/tech-words/`, its README has the rules, the data format and how to add words; `?seed=`, `?length=` and `?category=` in the address prepare a round, which the end-to-end tests use); Root Cause (4.2), Link Up (4.4), Word Radar (4.3) and Backbone (4.5) are live too (the longer write-up of a game is a note, see "Notes"; the panel "How it is built" on the game page stays short and links to it): registry `src/games/registry.ts`, shared code in `src/games/shared/`, pages `src/pages/games/[...slug].astro`, section `src/components/games/Playground.astro`, frame `src/layouts/GameLayout.astro`, how-to in `src/games/README.md`. By convention a game folder has `GamePage.astro` and `Preview.astro` (found by name, tested), a live game needs a budget in `GAME_BUDGETS` in `scripts/lighthouse.mjs`, and a game going live is picked up by the live check from the sitemap (extend the check only when the game needs more, such as an API). **Nothing on any page may ever move** when something appears, changes or loads (at **every** width: a text wraps at another point on another operating system, so a layout that is calm on Windows can jump on the Linux build server; `tests/e2e/word-radar-widths.spec.ts` plays a round at twelve widths from 296 to 1100 px in both languages, and checks that the page is never wider than the screen. The server draws the skeleton in both languages and the page shows the one of the address (see Word Radar below), and the game replaces it, so a box must not depend on the length of its texts: reserve the room for the longest, or the same height for one and two lines). Enforced for every browser test by the **layout guard** (`tests/e2e/layout-guard.ts`, automatic in `tests/e2e/fixtures.ts`; it fails a test when an element moves or the page changes its height, with exceptions marked `data-layout-fluid` and a few that are measured anew: resize, `
`, address change). Every game renders a **skeleton** (the same boxes, empty) on the server as the placeholder of its island and while it loads, so the page is exactly as high before the game as after it; space for results and messages is reserved. Details and rules in `src/games/README.md`, "Layout: nothing moves". No page is left out: `tests/e2e/pages.ts` lists every page, `tests/e2e/layout.spec.ts` opens each one (the 404 page too), reads it down to the end and fails when anything moves, and `tests/build-output/e2e-coverage.test.ts` fails when a built page is missing from the list, when a browser test takes `test` from Playwright instead of `./fixtures` (no guard), or when `layoutGuard: false` has no comment with a reason. A new page or a new dynamic state therefore has to stay calm, and the guard only ignores what is not visible (`visibility: hidden`, `display: none`) or measured anew on purpose (see above). The rules that apply while building: +Full plan in PLAN.md, Phase 4. The foundation (4.0) is built and **Tech Words (4.1) is live** (`src/games/tech-words/`, its README has the rules, the data format and how to add words; `?seed=`, `?length=` and `?category=` in the address prepare a round, which the end-to-end tests use); Root Cause (4.2), Link Up (4.4), Word Radar (4.3) and Backbone (4.5) are live too (the longer write-up of a game is a note, see "Notes"; the panel "How it is built" on the game page stays short and links to it): registry `src/games/registry.ts`, shared code in `src/games/shared/`, pages `src/pages/games/[...slug].astro`, section `src/components/games/Playground.astro`, frame `src/layouts/GameLayout.astro`, how-to in `src/games/README.md`. By convention a game folder has `GamePage.astro` and `Preview.astro` (found by name, tested), a live game needs a budget in `GAME_BUDGETS` in `scripts/lighthouse.mjs`, and a game going live is picked up by the live check from the sitemap (extend the check only when the game needs more, such as an API). **Nothing on any page may ever move** when something appears, changes or loads (at **every** width: a text wraps at another point on another operating system, so a layout that is calm on Windows can jump on the Linux build server; `tests/e2e/word-radar-widths.spec.ts` plays a round at twelve widths from 296 to 1100 px in both languages, and checks that the page is never wider than the screen. The server draws the skeleton in both languages and the page shows the one of the address (see Word Radar below), and the game replaces it, so a box must not depend on the length of its texts: reserve the room for the longest, or the same height for one and two lines). Enforced for every browser test by the **layout guard** (the package `playwright-layout-guard` from npm, my own open source package, repository `leonkrix/playwright-layout-guard`; since 2026-10-24 it replaces the guard that used to live in `tests/e2e/layout-guard.ts`; it is switched on for every test in `tests/e2e/fixtures.ts` with the option `pageHeight: 'any'`, so a page that gets lower fails as well; it fails a test when an element moves or the page changes its height, with exceptions marked `data-layout-fluid` and a few that are measured anew: resize, `
`, address change; `tests/e2e/layout-guard-canary.spec.ts` makes pages move on purpose and expects the guard to fail, so a broken wiring cannot go unnoticed; a change of the guard itself is made in the package, released there, and then updated here). Every game renders a **skeleton** (the same boxes, empty) on the server as the placeholder of its island and while it loads, so the page is exactly as high before the game as after it; space for results and messages is reserved. Details and rules in `src/games/README.md`, "Layout: nothing moves". No page is left out: `tests/e2e/pages.ts` lists every page, `tests/e2e/layout.spec.ts` opens each one (the 404 page too), reads it down to the end and fails when anything moves, and `tests/build-output/e2e-coverage.test.ts` fails when a built page is missing from the list, when a browser test takes `test` from Playwright instead of `./fixtures` (no guard), or when `layoutGuard: false` has no comment with a reason. A new page or a new dynamic state therefore has to stay calm, and the guard only ignores what is not visible (`visibility: hidden`, `display: none`) or measured anew on purpose (see above). The rules that apply while building: - A home page section (working name **Playground**) with tiles, and one page per game under `/games/`, plus `/games/` as overview. The section and the nav entry exist only while at least one game is live (registry `src/games/registry.ts`, `status: 'hidden' | 'live'`). **The home page shows at most four tiles** (decided 2026-10-18, so the section stays short while the list grows): a game with `home: false` in the registry (Tech Words) is left out there and comes last on `/games/`, which shows every live game; `homeGames()` is the list of the home page, and the button "All games (N)" under the tiles leads to the overview. The order of the registry is the order of both lists (Root Cause, Backbone, Word Radar, Link Up, Tech Words). - **English only. Unlimited play, no daily mode, no saving.** Nothing is stored on the device (no localStorage, sessionStorage, IndexedDB or cookies), so the privacy policy statement "nothing is stored on your device" stays true. The guard is **site-wide**, not game-specific: a source scan of `src/`, a scan of the published JavaScript in `dist/` (small commented allow-list for libraries) and a Playwright runtime check that storage and cookies are empty after visiting every page and playing every game (`tests/build-output/storage-guard.test.ts` and `tests/e2e/privacy.spec.ts`; the e2e page list in `tests/e2e/fixtures.ts` includes the game pages automatically once a game is live). Adding any storage requires a privacy policy update first (both languages), see Legal. diff --git a/PLAN.md b/PLAN.md index e52d6d5..bab28b9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -285,7 +285,7 @@ Also settled: the victim never gets clues; clues are rare, varied per person and ### Layout guard (added 2026-10-13) -- [x] A generic layout guard for all browser tests (`tests/e2e/layout-guard.ts`, automatic through `fixtures.ts`): every element is watched on every frame from the moment the page has loaded, and a test fails when something moves or the page changes its height. It found real shifts on every game page (the placeholder of an island was a guess of the height: 170 to 540 pixels of jump below the game, at the very bottom of the page, so Lighthouse did not see it), in the contact form and a few details of the games. Fixed: every game has a skeleton (the same boxes, rendered on the server as the placeholder and shown while the game loads), the random level of Root Cause and the width of two controls got reserved space. Checked by taking a reserved space away: the tests fail. +- [x] A generic layout guard for all browser tests (first `tests/e2e/layout-guard.ts`, since 2026-10-24 the open source package `playwright-layout-guard`, automatic through `fixtures.ts`): every element is watched on every frame from the moment the page has loaded, and a test fails when something moves or the page changes its height. It found real shifts on every game page (the placeholder of an island was a guess of the height: 170 to 540 pixels of jump below the game, at the very bottom of the page, so Lighthouse did not see it), in the contact form and a few details of the games. Fixed: every game has a skeleton (the same boxes, rendered on the server as the placeholder and shown while the game loads), the random level of Root Cause and the width of two controls got reserved space. Checked by taking a reserved space away: the tests fail. - [x] **The contact form is calm too (2026-10-13, decided with Leon):** the form is in the page from the start (the script no longer shows it; hidden only for `scripting: none`), the messages of the fields are short one-line texts in the gap under the field (the gap grew by 4 pixels), the progress and the problem with sending take the place of the privacy note, and the confirmation lies on the form in one grid cell, so it is as high as the form. Its tests run with the guard on. Checked by putting a message back into the flow: the test fails. - [x] **Every page is covered** (2026-10-13): `tests/e2e/pages.ts` is the one list of pages, `tests/e2e/layout.spec.ts` opens each page (and the 404 page), reads it to the end and fails when anything moves, a build-output test fails when a built page is not in the list or a browser test does not use the guarded fixture, and `layoutGuard: false` needs a comment. The 404 and privacy specs now use the guarded fixture too (`expectedPageStatus: 404`). diff --git a/package.json b/package.json index 7408aa8..7564dae 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "husky": "^9.1.7", "lighthouse": "^13.5.0", "lint-staged": "^17.6.0", + "playwright-layout-guard": "0.2.0", "prettier": "^3.9.10", "prettier-plugin-astro": "^1.1.1", "prettier-plugin-tailwindcss": "^0.8.1", diff --git a/playwright.config.ts b/playwright.config.ts index db4a5fb..3884bc7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,5 @@ import { defineConfig, devices } from '@playwright/test'; +import type { LayoutGuardOptions } from 'playwright-layout-guard'; // The ports: `astro dev` is 4321 (WebStorm or Claude, only one of them at a time), the browser tests // start their own `astro preview` on 4322, Lighthouse uses 4323. A server that already stands on @@ -7,7 +8,7 @@ import { defineConfig, devices } from '@playwright/test'; const port = 4322; const isCI = Boolean(process.env.CI); -export default defineConfig({ +export default defineConfig<{ layoutGuard: boolean | LayoutGuardOptions }>({ testDir: './tests/e2e', fullyParallel: true, forbidOnly: isCI, @@ -25,6 +26,9 @@ export default defineConfig({ reporter: isCI ? [['list'], ['github'], ['html', { open: 'never' }]] : [['list']], use: { baseURL: `http://localhost:${String(port)}`, + // On this site not even the height of a page may change, also not when it gets lower (the default + // of the layout guard is to count only a page that gets higher) + layoutGuard: { pageHeight: 'any' }, trace: 'on-first-retry', // Fail fast instead of waiting minutes when the browser cannot start launchOptions: { timeout: 60_000 }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abd975d..221fed5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -263,6 +263,9 @@ importers: lint-staged: specifier: ^17.6.0 version: 17.6.0 + playwright-layout-guard: + specifier: 0.2.0 + version: 0.2.0(@playwright/test@1.64.1) prettier: specifier: ^3.9.10 version: 3.9.10 @@ -3250,6 +3253,12 @@ packages: engines: {node: '>=20'} hasBin: true + playwright-layout-guard@0.2.0: + resolution: {integrity: sha512-UmlGwQDtt1XNmOPADFhIInAc6DJaOClx5WlaMTRzRvTVcd3VBfg1pbPejBS/ykGeCV/Y8GfARqYtMbP0HF70cA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@playwright/test': '>=1.45.0' + playwright@1.64.1: resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} engines: {node: '>=20'} @@ -7282,6 +7291,10 @@ snapshots: playwright-core@1.64.1: {} + playwright-layout-guard@0.2.0(@playwright/test@1.64.1): + dependencies: + '@playwright/test': 1.64.1 + playwright@1.64.1: dependencies: playwright-core: 1.64.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 02365ef..51dd646 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,7 @@ allowBuilds: esbuild: true workerd: true +# My own package (playwright-layout-guard, published by me): the minimum release age of pnpm does +# not apply to it. Remove the entry when the version is old enough or when it is updated. +minimumReleaseAgeExclude: + - playwright-layout-guard@0.2.0 diff --git a/src/content/notes/a-page-that-never-moves.mdx b/src/content/notes/a-page-that-never-moves.mdx index 66c6626..8592eca 100644 --- a/src/content/notes/a-page-that-never-moves.mdx +++ b/src/content/notes/a-page-that-never-moves.mdx @@ -5,10 +5,11 @@ tag: 'Site ยท Layout' code: tests/e2e group: site order: 2 -updated: '2026-10-14' +updated: '2026-10-24' --- import Code from '@/components/notes/Code.astro'; +import ExternalLink from '@/components/ExternalLink.astro'; import layoutGuardMoved from './snippets/layout-guard-moved.snippet?raw'; import layoutGuardReport from './snippets/layout-guard-report.snippet?raw'; import Steps from '@/components/notes/Steps.astro'; @@ -45,7 +46,7 @@ The first version was a tracker for a single game, written when a message under - **The contact form pushed the page** when an error or the confirmation appeared. - **A few details in the games**: the area for a random level in Root Cause and the width of two controls. -This is what a report looks like. It is a real one, from the contact form while I made it fail on purpose: +This is what a report looked like in the first version. It is a real one, from the contact form while I made it fail on purpose (the package names the element with a short path and says how far it moved, in pixels): @@ -87,9 +88,20 @@ And one limit remains: the guard only sees states that a test triggers. A new dy There is a second way a page can move that no state of a game causes: the fonts. A web font arrives after the first paint on a slow line and the text is set again. That has its own note, with the numbers: [Fonts that arrive late](/notes/fonts-that-arrive-late/). +## From a file to a package + +The guard started as one file of this repository. On 24 October 2026 it became an open source package, playwright-layout-guard, and this site now uses it from npm like anybody else would. Taking it out was only worth it if nothing got worse, so I checked that before I switched. + +I wrote a test that puts the old guard and the package side by side on the same page and then makes the page move on purpose, after it has settled: a banner above everything, a block that grows, one that moves sideways, a text that wraps differently, something added at the end of the page, something removed from the end, and three things that are not shifts (one pixel, a `transform`, scrolling). That is 22 pages at desktop and phone size and 10 kinds of fault, 440 runs. Both guards found the same faults and stayed quiet about the same controls. One difference showed up: the old guard also reported a page that got **lower**, the package counted only a page that got higher. That became an option, `pageHeight: 'any'`, and this site switches it on. + +Running the whole suite with the package found three bugs in the package that none of its own tests had found. A page that writes its document again made it throw. A resize a moment after the load made it report what the page did in reaction (eight tests of the Backbone map failed with up to 131 shifts). And on a phone, a shift that makes the page wider than the window was swallowed. All three have a test now, and the fixes are in version 0.2.0. + +The guard is code that I do not look at every day, so I added a canary. `layout-guard-canary.spec.ts` makes the page move on purpose, and the guard has to fail the test. If the wiring ever breaks (a fixture without the guard, an option that switches it off, a package update that changes a default), the pages would be calm for the wrong reason, and the canary is the test that notices. + ## What it protects - Every browser test of every page, at desktop and phone size, from load to the end of the test. - Every game from the loading state to a finished round, including a 320 pixel wide screen. - The contact form with an error, with a failed sending and with the confirmation. - The rule that an exception needs a reason, and that every browser test uses the fixture that has the guard. +- The guard itself, through the canary that moves a page on purpose. diff --git a/src/content/notes/snippets/layout-guard-moved.snippet b/src/content/notes/snippets/layout-guard-moved.snippet index 416453c..d26b670 100644 --- a/src/content/notes/snippets/layout-guard-moved.snippet +++ b/src/content/notes/snippets/layout-guard-moved.snippet @@ -2,6 +2,6 @@ // right edge changed. One that only grows or shrinks did not move: what // it pushes did, and that is reported at the thing that moved. const moved = - Math.abs(top - oldTop) > 1 || - (Math.abs(left - oldLeft) > 1 && - Math.abs(left + width - (oldLeft + oldWidth)) > 1); + Math.abs(top - oldTop) > tolerance || + (Math.abs(left - oldLeft) > tolerance && + Math.abs(left + width - (oldLeft + oldWidth)) > tolerance); diff --git a/src/content/projects/website.yaml b/src/content/projects/website.yaml index 21751f9..4094d01 100644 --- a/src/content/projects/website.yaml +++ b/src/content/projects/website.yaml @@ -12,7 +12,7 @@ stats: label: automated tests (unit, build output, browser, accessibility) highlights: - Static portfolio built with Astro, TypeScript, React islands and Tailwind CSS, hosted on Cloudflare, with a command palette (Ctrl K) and a small terminal that load only when they are opened. Its content is typed YAML that is validated at build time, so a typo fails the build instead of shipping. - - Every change goes through a pull request and automated gates before it goes live, from linting, type checks and unit tests to build-output checks (security headers, CSP, legal pages), end-to-end tests with accessibility scans and a layout guard that fails a test when anything on any page moves or changes size, Lighthouse budgets, CodeQL, dependency review and OpenSSF Scorecard. After the deployment a live check of the published site (headers, redirects, legal pages, contact form, certificate, domain, mail DNS) runs every few hours, plus a weekly link check. + - Every change goes through a pull request and automated gates before it goes live, from linting, type checks and unit tests to build-output checks (security headers, CSP, legal pages), end-to-end tests with accessibility scans and a layout guard (my own open source package) that fails a test when anything on any page moves or changes size, Lighthouse budgets, CodeQL, dependency review and OpenSSF Scorecard. After the deployment a live check of the published site (headers, redirects, legal pages, contact form, certificate, domain, mail DNS) runs every few hours, plus a weekly link check. - A contact form without any third-party service. A Cloudflare Function validates the message with the same rules as the browser, checks origin, a signed time token, a honeypot and a rate limit, and hands it to my own mailbox over SMTP. - Privacy and security by design with a strict content security policy, HSTS, no cookies of its own, self-hosted fonts and obfuscated contact data. - Five browser games in the [Playground](/#playground) further down this page, written as tested pure functions, playable with the keyboard, with nothing stored on your device. How they and this site are built is written up in [Behind the scenes](/notes/). diff --git a/src/games/README.md b/src/games/README.md index 122eab8..5001e75 100644 --- a/src/games/README.md +++ b/src/games/README.md @@ -38,7 +38,7 @@ src/games// ## Layout: nothing moves -Every browser test runs with the **layout guard** (`tests/e2e/layout-guard.ts`, switched on in `tests/e2e/fixtures.ts` for every test of every page): from the moment the page has loaded, it looks at every element on every frame and fails the test when an element that was already there changes its place on the page, and when the page changes its height. So everything that appears later (a message, a result, a list that fills, the game itself) must have its space reserved. How this is done in a game: +Every browser test runs with the **layout guard** (the package `playwright-layout-guard`, switched on in `tests/e2e/fixtures.ts` for every test of every page): from the moment the page has loaded, it looks at every element on every frame and fails the test when an element that was already there changes its place on the page, and when the page changes its height. So everything that appears later (a message, a result, a list that fills, the game itself) must have its space reserved. How this is done in a game: - **A skeleton.** The game page renders, on the server, a picture of the game before it has started: the same boxes (field, bars, tiles, buttons, space for results), empty and without function (`Skeleton.tsx`, put into the `fallback` slot of the island in `GamePage.astro`). The game shows the same skeleton while its data loads. Since the CSS is the same, the page is exactly as high before the game as after it, at every width. If the real game gets another box, the skeleton needs it too: the guard finds out when they differ. (A skeleton must not import code that needs the browser at load time, such as a Web Worker: use `import type` for types.) - **Reserved space** for everything that appears during play (`min-height` for messages and results, a fixed-height box that scrolls for a list that grows). diff --git a/src/games/word-radar/README.md b/src/games/word-radar/README.md index 74772cb..0e7a360 100644 --- a/src/games/word-radar/README.md +++ b/src/games/word-radar/README.md @@ -16,7 +16,7 @@ A word is hidden. Guess words, and the game tells you how close each one is in * The address can prepare a round: `?seed=anything`. The same seed always hides the same word (the end-to-end tests use this). Digits are the seed itself (that is what the link of the game contains), any other text is turned into a seed. -The page reserves the space of everything that appears later (the message, the newest guess, the result), and the list scrolls inside a box of fixed height, so **nothing on the page moves** while you play. Every browser test watches the layout on every frame (the generic layout guard, `tests/e2e/layout-guard.ts`); the tests of this game play a full round on desktop and phone and give up on a 320 pixel wide screen. +The page reserves the space of everything that appears later (the message, the newest guess, the result), and the list scrolls inside a box of fixed height, so **nothing on the page moves** while you play. Every browser test watches the layout on every frame (the generic layout guard, the package `playwright-layout-guard`); the tests of this game play a full round on desktop and phone and give up on a 320 pixel wide screen. ## Design decisions diff --git a/tests/build-output/e2e-coverage.test.ts b/tests/build-output/e2e-coverage.test.ts index ccaa5e2..78eb4b0 100644 --- a/tests/build-output/e2e-coverage.test.ts +++ b/tests/build-output/e2e-coverage.test.ts @@ -11,7 +11,7 @@ import { distDir, readPages } from './helpers'; const e2eDir = join(import.meta.dirname, '..', 'e2e'); /** Files in tests/e2e that are not tests */ -const helpers = new Set(['fixtures.ts', 'layout-guard.ts', 'pages.ts']); +const helpers = new Set(['fixtures.ts', 'pages.ts']); /** The address of a built page: dist/games/index.html is /games/ */ function addressOf(file: string): string { diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts index 6a0a8a8..9f1465c 100644 --- a/tests/e2e/fixtures.ts +++ b/tests/e2e/fixtures.ts @@ -1,6 +1,6 @@ -import { expect, type Page, test as base } from '@playwright/test'; +import { expect, mergeTests, type Page, test as base } from '@playwright/test'; +import { test as guarded } from 'playwright-layout-guard'; -import { layoutGuardScript, REPORT } from './layout-guard'; import { notFoundPath, pages } from './pages'; export { notFoundPath, pages }; @@ -8,46 +8,29 @@ export { notFoundPath, pages }; /** * Like Playwright's `test`, but every test automatically fails when the page logs a console * error, throws an exception, violates the CSP or receives an HTTP error response, and when - * anything on the page moves or changes its size (the layout guard, see layout-guard.ts; switch it - * off for a test with `test.use({ layoutGuard: false })` and say why). A test that opens an address - * on purpose that answers with an error status (the 404 page) says so with - * `test.use({ expectedPageStatus: 404 })`; that status is then fine for the page itself, and still - * not for anything it loads. + * anything on the page moves or changes its size (the layout guard, the package + * playwright-layout-guard; switch it off for a test with `test.use({ layoutGuard: false })` and say + * why). A test that opens an address on purpose that answers with an error status (the 404 page) + * says so with `test.use({ expectedPageStatus: 404 })`; that status is then fine for the page itself, + * and still not for anything it loads. */ -export const test = base.extend<{ +const own = base.extend<{ issues: string[]; - layoutGuard: boolean; - layoutShifts: string[]; expectedPageStatus: number | null; }>({ - layoutGuard: [true, { option: true }], expectedPageStatus: [null, { option: true }], - layoutShifts: [ - async ({ page, layoutGuard }, use) => { - const shifts: string[] = []; - if (layoutGuard) { - await page.exposeFunction(REPORT, (message: string) => { - shifts.push(message); - }); - await page.addInitScript(layoutGuardScript, REPORT); - } - await use(shifts); - expect(shifts, 'something on the page moved or changed its size').toEqual([]); - }, - { auto: true }, - ], issues: [ async ({ page, expectedPageStatus }, use) => { const issues: string[] = []; page.on('console', (message) => { // The browser logs the error status of the page itself as a console error; the response // check below still catches the same status for anything the page loads - const own = + const ownStatus = expectedPageStatus !== null && message .text() .includes(`the server responded with a status of ${String(expectedPageStatus)}`); - if (message.type() === 'error' && !own) { + if (message.type() === 'error' && !ownStatus) { issues.push(`console error: ${message.text()}`); } }); @@ -68,6 +51,13 @@ export const test = base.extend<{ ], }); +export const test = mergeTests(guarded, own).extend<{ layoutShifts: readonly unknown[] }>({ + // What the guard found so far, for the tests that look at it in the middle of a round + layoutShifts: async ({ guard }, use) => { + await use(guard.shifts); + }, +}); + export { expect } from '@playwright/test'; /** diff --git a/tests/e2e/layout-guard-canary.spec.ts b/tests/e2e/layout-guard-canary.spec.ts new file mode 100644 index 0000000..90d325d --- /dev/null +++ b/tests/e2e/layout-guard-canary.spec.ts @@ -0,0 +1,90 @@ +import { type Page } from '@playwright/test'; + +import { expect, test } from './fixtures'; + +// The layout guard (the package playwright-layout-guard, switched on in fixtures.ts) is on for every +// test, and a calm page is what it is supposed to find. That is also what it would look like if the +// wiring broke: a fixture that leaves it out, an option that switches it off, a package update that +// changes a default. Nothing would fail, the pages would just be calm. So these tests make the page +// move on purpose, and the guard must fail them. `test.fail()` turns the expected failure into a +// pass, and a guard that does not notice turns it into a red test. + +async function openHome(page: Page): Promise { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + // Past the first moments after the load, in which the guard still takes in the page + await page.waitForTimeout(500); +} + +test.describe('the guard fails a test when', () => { + test.fail(); + + test('a banner pushes the page down', async ({ page }) => { + await openHome(page); + await page.evaluate(() => { + const banner = document.createElement('div'); + banner.style.height = '48px'; + document.body.insertBefore(banner, document.body.firstChild); + }); + await page.waitForTimeout(600); + }); + + test('a block moves sideways', async ({ page }) => { + await openHome(page); + await page.evaluate(() => { + const block = document.querySelector('main > *'); + if (block) { + block.style.position = 'relative'; + block.style.marginLeft = '12px'; + block.style.left = '12px'; + } + }); + await page.waitForTimeout(600); + }); + + test('the page gets lower (the option pageHeight: any of this site)', async ({ page }) => { + await openHome(page); + await page.evaluate(() => { + document.body.lastElementChild?.remove(); + }); + await page.waitForTimeout(600); + }); + + test('something is added at the end of the page', async ({ page }) => { + await openHome(page); + await page.evaluate(() => { + const extra = document.createElement('div'); + extra.style.height = '300px'; + document.body.append(extra); + }); + await page.waitForTimeout(600); + }); +}); + +test.describe('the guard leaves a test alone when', () => { + test('the page is scrolled and the window is resized', async ({ page }) => { + await openHome(page); + await page.evaluate(() => { + window.scrollTo(0, 600); + }); + await page.waitForTimeout(300); + await page.evaluate(() => { + window.scrollTo(0, 0); + }); + await page.setViewportSize({ width: 600, height: 900 }); + await page.waitForTimeout(800); + await expect(page.locator('main')).toBeVisible(); + }); + + test('something moves with a transform, which is not a layout change', async ({ page }) => { + await openHome(page); + await page.evaluate(() => { + const block = document.querySelector('main > *'); + if (block) { + block.style.transform = 'translateY(40px)'; + } + }); + await page.waitForTimeout(600); + await expect(page.locator('main')).toBeVisible(); + }); +}); diff --git a/tests/e2e/layout-guard.ts b/tests/e2e/layout-guard.ts deleted file mode 100644 index 2937752..0000000 --- a/tests/e2e/layout-guard.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * The layout guard: in every browser test, nothing that is already on the page may move or change - * its size, not even for a single frame. That is what makes dynamic content calm: a message, a - * result, a loaded game or a list that fills must have its space reserved beforehand, otherwise the - * things next to it and below it jump. - * - * How it works: from the moment a page has loaded, a script looks at every element of the page on - * every frame and remembers where it was first seen (its place on the page and its size, from the - * layout, so effects with `transform` do not count). When an element is found somewhere else, the - * guard reports it, and the test fails when it ends. New elements are not - * a problem in themselves, only what they push away. Elements that are not visible - * (`visibility: hidden`, `display: none`) are not watched. - * - * Exceptions, on purpose and few: - * - `data-layout-fluid` marks a box whose inner parts are meant to rearrange (the tiles of a - * board, a list that grows): the box itself must stay put, what is inside is not watched. - * - What the visitor asks for is not a surprise: the view is measured anew after a resize of the - * window, after a `
` was opened or closed, and after a change of the address (also - * when a page changes it itself, for example a game that opens a level). - * - A test that changes a page on purpose can switch the guard off with - * `test.use({ layoutGuard: false })`, with a comment that says why. - */ - -/** The name under which the page tells the test about a problem */ -export const REPORT = '__reportLayout'; - -/** Runs in the page (it is serialized, so it must not use anything from this file). */ -export function layoutGuardScript(reportName: string): void { - type Box = [top: number, left: number, width: number, height: number]; - let baselines = new WeakMap(); - const reported = new Set(); - // After something that the visitor asked for, the page needs a moment to show the result (a game - // that opens a level draws it a few frames after it changed the address): the guard only looks - // again after that - let quietUntil = 0; - // Sticky and fixed parts (the header) change their offset while the page scrolls: not a layout change - // Words inside a line (display: inline) change their size whenever their text changes: that moves - // nothing by itself, the block that holds them is watched instead - const inline = new WeakMap(); - const isInline = (element: Element): boolean => { - let known = inline.get(element); - if (known === undefined) { - known = getComputedStyle(element).display === 'inline'; - inline.set(element, known); - } - return known; - }; - const floating = new WeakMap(); - const isFloating = (element: Element): boolean => { - let known = floating.get(element); - if (known === undefined) { - const parent = element.parentElement; - known = - (parent !== null && isFloating(parent)) || - ['sticky', 'fixed'].includes(getComputedStyle(element).position); - floating.set(element, known); - } - return known; - }; - const report = (window as unknown as Record void>)[reportName]; - - const place = (element: HTMLElement): Box | null => { - // Hidden elements and fixed ones (the menu, the toast of the browser) have no offset parent - if (element.offsetParent === null) { - return null; - } - let top = 0; - let left = 0; - for ( - let node: HTMLElement | null = element; - node !== null; - node = node.offsetParent as HTMLElement | null - ) { - top += node.offsetTop; - left += node.offsetLeft; - } - return [top, left, element.offsetWidth, element.offsetHeight]; - }; - - const describe = (element: HTMLElement): string => { - const classes = [...element.classList].slice(0, 3).join('.'); - const text = element.textContent.trim().replace(/\s+/g, ' ').slice(0, 30); - return `<${element.tagName.toLowerCase()}${element.id !== '' ? `#${element.id}` : ''}${classes !== '' ? `.${classes}` : ''}> "${text}"`; - }; - - const sample = (): void => { - for (const element of document.body.querySelectorAll('*')) { - // Pictures (SVG) have no place of their own in the layout, and scripts and styles are not shown - if ( - !(element instanceof HTMLElement) || - ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(element.tagName) - ) { - continue; - } - // Buttons and links lift or shift a little when pointed at, and what they hold does not - // rearrange: only the button itself is watched - const holder = element.parentElement?.closest('a, button'); - if (holder !== null && holder !== undefined) { - continue; - } - // Inside a fluid box the parts may rearrange, the box itself is watched like any other - const fluid = element.closest('[data-layout-fluid]'); - if (fluid !== null && fluid !== element) { - continue; - } - if (isFloating(element) || isInline(element)) { - continue; - } - // Nobody sees what is invisible (the confirmation of a form that waits under the form): it - // may fill with text, and it counts as new when it is shown - if (!element.checkVisibility({ visibilityProperty: true })) { - baselines.delete(element); - continue; - } - const now = place(element); - if (now === null) { - continue; - } - const before = baselines.get(element); - baselines.set(element, now); - if (before === undefined || performance.now() < quietUntil) { - continue; - } - // Moved: its top changed, or both its left and its right edge changed. A box that only grows or - // shrinks (text that changes, a line that wraps) is not a move: what it pushes is moved, and that is - // reported at the thing that moved. - const [top, left, width] = now; - const [oldTop, oldLeft, oldWidth] = before; - const moved = - Math.abs(top - oldTop) > 1 || - (Math.abs(left - oldLeft) > 1 && Math.abs(left + width - (oldLeft + oldWidth)) > 1); - if (moved) { - const message = `${describe(element)}: [top, left, width, height] ${before.join(', ')} to ${now.join(', ')}`; - if (!reported.has(message)) { - reported.add(message); - report?.(message); - } - } - } - // The page itself: a higher page means that something that was not there was added in the flow - const height = document.documentElement.scrollHeight; - const known = (window as unknown as { __pageHeight?: number | undefined }).__pageHeight; - (window as unknown as { __pageHeight?: number | undefined }).__pageHeight = height; - if (known !== undefined && Math.abs(known - height) > 1 && performance.now() >= quietUntil) { - const message = `the page: height ${String(known)} to ${String(height)}`; - if (!reported.has(message)) { - reported.add(message); - report?.(message); - } - } - window.requestAnimationFrame(sample); - }; - - // What the visitor asks for (a new window size, an opened panel) is not a surprise - const forget = (): void => { - quietUntil = performance.now() + 600; - baselines = new WeakMap(); - (window as unknown as { __pageHeight?: number | undefined }).__pageHeight = undefined; - }; - window.addEventListener('resize', forget); - document.addEventListener('toggle', forget, true); - // The toggle event of a
comes a moment after the panel has opened (a task of its own), and - // a frame can be sampled in between on a busy machine: the click on the summary is the first sign - document.addEventListener( - 'click', - (event) => { - if (event.target instanceof Element && event.target.closest('summary') !== null) { - forget(); - } - }, - true, - ); - window.addEventListener('popstate', forget); - // A page that changes its own address (a game that opens a level) shows something new on purpose - for (const name of ['pushState', 'replaceState'] as const) { - const original = history[name].bind(history); - history[name] = (...args: Parameters): void => { - original(...args); - forget(); - }; - } - - const start = (): void => { - window.requestAnimationFrame(sample); - }; - if (document.readyState === 'complete') { - start(); - } else { - window.addEventListener('load', start, { once: true }); - } -} diff --git a/tests/e2e/root-cause.spec.ts b/tests/e2e/root-cause.spec.ts index ad46bf5..9fe3d23 100644 --- a/tests/e2e/root-cause.spec.ts +++ b/tests/e2e/root-cause.spec.ts @@ -305,6 +305,9 @@ test.describe('Root Cause', () => { test('makes a random level in the look that was asked for', async ({ page }) => { await page.goto(PATH); + // The page shows the skeleton of the game first: the choices must be made in the game, otherwise + // the game takes over a moment later and starts again with its own defaults + await gameReady(page); await page.getByLabel('Difficulty').selectOption('easy'); await page.getByLabel('Size').selectOption('4'); await page.getByLabel('Look of the level').selectOption('outage'); @@ -347,6 +350,7 @@ test.describe('Root Cause', () => { page, }) => { await page.goto(PATH); + await gameReady(page); await expect(page.locator('.rc-card')).toHaveCount(18); expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); From c70ab38ab10a9c1ee9908ee41e72aac0fe49e6a2 Mon Sep 17 00:00:00 2001 From: Leon Krix Date: Sat, 24 Oct 2026 10:52:29 +0200 Subject: [PATCH 2/2] fix: keep the undici override when merging main --- pnpm-workspace.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 51dd646..8fccfc9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,11 @@ allowBuilds: esbuild: true workerd: true +# miniflare (inside wrangler) pins undici 7.29.1 exactly, and 7.29.1 has known denial of service +# findings (patched in 7.30.1; found by pnpm audit on 2026-10-24). Written as a range of the bad +# versions, so it does nothing once miniflare asks for a patched version itself: then delete it. +overrides: + 'undici@>=7.0.0 <7.30.1': 7.30.1 # My own package (playwright-layout-guard, published by me): the minimum release age of pnpm does # not apply to it. Remove the entry when the version is old enough or when it is updated. minimumReleaseAgeExclude: