From 81d065d9274b2068316a895ebd22c26f19a1ecb2 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Sat, 5 Sep 2026 22:05:01 +0200 Subject: [PATCH 1/3] fix(frontend): the reduced-motion preference reaches the page `use.reducedMotion` was ignored by the runner-managed context on every Playwright from 1.55 to 1.62, so every project but the motion one believed it ran as a visitor who asked for reduced motion and in fact ran with the full choreography playing. Upstream fixed it in 1.63 (microsoft/playwright#42001), so the fix is the upgrade rather than emulating the preference ourselves. The dependabot hold on `>= 1.61` cited Yarn PnP, which this package left for the node-modules linker, so it goes with it. Two specs emulated the preference by hand because of this and no longer need to. The four in the motion project keep theirs: that project asks for no preference on purpose, so a reduced-motion test living there has to say so, and their comments now give that reason rather than pointing at #852. Closes #852 --- .github/dependabot.yml | 8 --- services/frontend/package.json | 2 +- .../tests/e2e/board-slice-open.motion.spec.ts | 9 +--- .../tests/e2e/boards-swipe.motion.spec.ts | 6 +-- .../tests/e2e/esports-season-on-show.spec.ts | 10 +--- .../frontend/tests/e2e/membership.spec.ts | 5 +- services/frontend/yarn.lock | 53 ++++++------------- 7 files changed, 22 insertions(+), 71 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1d43844a5..27dfe8261 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -33,14 +33,6 @@ updates: cooldown: default-days: 7 open-pull-requests-limit: 5 - # @playwright/test 1.61 rewrote its config loader to call - # `require(..., {conditions})` and to return module sources from an ESM - # `load` hook. Yarn PnP supports neither, so every version past 1.60 - # dies before the first test file is collected. Held back until PnP - # catches up or the frontend moves off it. - ignore: - - dependency-name: "@playwright/test" - versions: [ ">= 1.61" ] groups: frontend: applies-to: version-updates diff --git a/services/frontend/package.json b/services/frontend/package.json index 4550a105a..bcacaf041 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -61,7 +61,7 @@ "@eslint/js": "^10.0.1", "@hey-api/openapi-ts": "0.99.0", "@originjs/vite-plugin-commonjs": "^1.0.3", - "@playwright/test": "1.60.0", + "@playwright/test": "1.63.0", "@types/luxon": "^3.7.5", "@types/node": "^26.2.0", "@types/webfontloader": "^1.6.38", diff --git a/services/frontend/tests/e2e/board-slice-open.motion.spec.ts b/services/frontend/tests/e2e/board-slice-open.motion.spec.ts index f4570b93d..4b36449e9 100644 --- a/services/frontend/tests/e2e/board-slice-open.motion.spec.ts +++ b/services/frontend/tests/e2e/board-slice-open.motion.spec.ts @@ -267,13 +267,8 @@ test.describe("a member's slice opening on a phone", () => { test.describe("a member's slice opening for a visitor who asked for less motion", () => { test("clamps both movements to the ceiling the island allows", async ({page}) => { - /* - * Asked of the page rather than declared as an option, which is what the season spec next - * door does and for the same reason: on Playwright 1.60 `use.reducedMotion` does not reach - * the page at all, so `matchMedia` answers false however the option is set — #852. A test of - * what a visitor with the preference gets has to actually be one, so it says so here. Once - * #852 is fixed this line is what should go. - */ + // Asked of the page rather than taken from the project: this file runs in the motion + // project, which is the one project that asks for no preference at all. await page.emulateMedia({reducedMotion: "reduce"}) const watched = await boardOnAPhone(page) diff --git a/services/frontend/tests/e2e/boards-swipe.motion.spec.ts b/services/frontend/tests/e2e/boards-swipe.motion.spec.ts index 832ab3250..15528e1b3 100644 --- a/services/frontend/tests/e2e/boards-swipe.motion.spec.ts +++ b/services/frontend/tests/e2e/boards-swipe.motion.spec.ts @@ -266,10 +266,8 @@ test.describe("dragging the board page", () => { /** * What a visitor who asked for reduced motion gets, which is the gesture without its tails. * - * The preference is emulated for this test rather than taken from the project, because - * `use.reducedMotion` does not reach the page on Playwright 1.60 — #852 — so every - * "deterministic" project in this suite is in fact running with full motion. The same line and - * the same reasoning are in `esports-season-on-show.spec.ts`; when #852 is fixed, both go. + * The preference is emulated for this test rather than taken from the project: this file runs + * in the motion project, which is the one project that asks for no preference at all. * * The durations are read off the animations themselves rather than timed with a clock. Eight * workers share four vCPUs here, so a wall-clock measurement of "it settled quickly" is a diff --git a/services/frontend/tests/e2e/esports-season-on-show.spec.ts b/services/frontend/tests/e2e/esports-season-on-show.spec.ts index 5aafc35e4..4f4a2b9bf 100644 --- a/services/frontend/tests/e2e/esports-season-on-show.spec.ts +++ b/services/frontend/tests/e2e/esports-season-on-show.spec.ts @@ -118,15 +118,7 @@ test.describe("the season a page opens on", () => { }) test("crosses the seasons over rather than travelling, for a visitor who asked for less motion", async ({page}) => { - // Asked for here rather than left to the project. - // - // `use.reducedMotion: "reduce"` is set on every project but the motion one, and on - // Playwright 1.60 it does not reach the page: `matchMedia("(prefers-reduced-motion: - // reduce)")` answers false throughout the deterministic suites. That is a fault in the - // harness rather than in this behaviour — #852 — and it is not this spec's to fix, but a - // test of what a visitor with the preference gets has to actually be one. Once #852 is - // fixed this line is what should go. - await page.emulateMedia({reducedMotion: "reduce"}) + // The preference comes from the project, which every project but the motion one sets. await installApiMocks(page) await page.goto("/esports/competitive-scene") diff --git a/services/frontend/tests/e2e/membership.spec.ts b/services/frontend/tests/e2e/membership.spec.ts index 41b70432d..66107dbde 100644 --- a/services/frontend/tests/e2e/membership.spec.ts +++ b/services/frontend/tests/e2e/membership.spec.ts @@ -108,12 +108,9 @@ test.describe("membership page", () => { * The one moving thing on the page, for a visitor who asked for less of it: the words are * simply there, at rest, rather than arriving. * - * The preference is emulated here rather than left to the project: `use.reducedMotion` does - * not reach the page on Playwright 1.60 (#852), so a spec that assumes it is asserting the - * unreduced path under a reduced name. + * The preference comes from the project, which every project but the motion one sets. */ test("does not animate the pitch in for a visitor who asked for less motion", async ({page}) => { - await page.emulateMedia({reducedMotion: "reduce"}) await installApiMocks(page) await page.goto("/membership") diff --git a/services/frontend/yarn.lock b/services/frontend/yarn.lock index a611aa654..197c3f4b2 100644 --- a/services/frontend/yarn.lock +++ b/services/frontend/yarn.lock @@ -1291,14 +1291,14 @@ __metadata: languageName: node linkType: hard -"@playwright/test@npm:1.60.0": - version: 1.60.0 - resolution: "@playwright/test@npm:1.60.0" +"@playwright/test@npm:1.63.0": + version: 1.63.0 + resolution: "@playwright/test@npm:1.63.0" dependencies: - playwright: "npm:1.60.0" + playwright: "npm:1.63.0" bin: playwright: cli.js - checksum: 10c0/86b06e6437933e741c7cd43f362024e857e7bc28a55fcbb0553ef55e01a2a403c64f4786868de8af86a6e303fe99e98a18a42ba19489f43ae122e457f9e2d189 + checksum: 10c0/795cd06c2eb6cb6142e72792f4df8f97665aa8c551c16278a7f2b3f31664e0cff07ed3c066490da55daf2fe51bc18e9c1ec6cba05b2c37387a0c49ad63017db8 languageName: node linkType: hard @@ -2920,7 +2920,7 @@ __metadata: "@hey-api/openapi-ts": "npm:0.99.0" "@mdi/font": "npm:^7.4.47" "@originjs/vite-plugin-commonjs": "npm:^1.0.3" - "@playwright/test": "npm:1.60.0" + "@playwright/test": "npm:1.63.0" "@tailwindcss/vite": "npm:^4.3.3" "@types/luxon": "npm:^3.7.5" "@types/node": "npm:^26.2.0" @@ -4280,16 +4280,6 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:2.3.2": - version: 2.3.2 - resolution: "fsevents@npm:2.3.2" - dependencies: - node-gyp: "npm:latest" - checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b - conditions: os=darwin - languageName: node - linkType: hard - "fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" @@ -4300,15 +4290,6 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin": - version: 2.3.2 - resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" - dependencies: - node-gyp: "npm:latest" - conditions: os=darwin - languageName: node - linkType: hard - "fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" @@ -5946,27 +5927,23 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.60.0": - version: 1.60.0 - resolution: "playwright-core@npm:1.60.0" +"playwright-core@npm:1.63.0": + version: 1.63.0 + resolution: "playwright-core@npm:1.63.0" bin: playwright-core: cli.js - checksum: 10c0/99ccd43923b6e9355e0723b7fe221e6326efd4687f8dafff951313662aea11db51f542a9c2122c704c445fb9baae1c9ec9fa6f895126bbddd9fe92313f6942c9 + checksum: 10c0/39c1ebae4029ca2e9371e00a5588d962038e24b6e0bdb7d0d27374dde04838f349b83ae0c8b3b648653d0935cba7fb9011f1201adaab9dd0db7801ad6c75e9aa languageName: node linkType: hard -"playwright@npm:1.60.0": - version: 1.60.0 - resolution: "playwright@npm:1.60.0" +"playwright@npm:1.63.0": + version: 1.63.0 + resolution: "playwright@npm:1.63.0" dependencies: - fsevents: "npm:2.3.2" - playwright-core: "npm:1.60.0" - dependenciesMeta: - fsevents: - optional: true + playwright-core: "npm:1.63.0" bin: playwright: cli.js - checksum: 10c0/714ad76d85b4865d7e43c0012f9039800c1485373388973ed39d79339cee5ad467052d1e2f1eaeca107a1cb6e65342186a8578a4c3504853d84c3a691250d5db + checksum: 10c0/d1fd048a89be27c9aba6757dc4f70eff4a5163b49f439ad691767be8b2a482327d0dd1928b4f73c8fe0d8b33aab2c9dc2670424ab60f112d9e98e91b94e3b090 languageName: node linkType: hard From 6b01b4a51eab3721a6fe1c059bd96db69e4e7f9c Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Sun, 6 Sep 2026 00:13:34 +0200 Subject: [PATCH 2/3] test(island): the landing is watched from before the season is asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the preference finally reaching the page, the pass a visitor who asked for less motion gets is the clamped one: the two stops are on the page together for 116ms rather than 850ms. The band's landing was watched after the click and after a round trip for `data-swipe`, which on a runner with eight workers on four vCPUs costs more than the whole pass — so the sample landed after the stop leaving had gone and the band answered with one panel for a pass that did happen. The claim is unchanged: the slice being read is open in the frame the arriving season is first drawn in, with the pass still on. What changes is when it is watched. `landingFrom` installs the watch on the page before the change is asked for, the way `framesOf` and `heightsHeldFrom` already do, and answers with the frame the arriving stop is first drawn in. The motion spec makes the same claim about the long pass and watches it the same way. --- .../tests/e2e/esports-season-on-show.spec.ts | 9 ++-- .../e2e/esports-season-swipe.motion.spec.ts | 7 ++-- services/frontend/tests/e2e/sliceBand.ts | 42 ++++++++++++------- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/services/frontend/tests/e2e/esports-season-on-show.spec.ts b/services/frontend/tests/e2e/esports-season-on-show.spec.ts index 4f4a2b9bf..257a60ebc 100644 --- a/services/frontend/tests/e2e/esports-season-on-show.spec.ts +++ b/services/frontend/tests/e2e/esports-season-on-show.spec.ts @@ -1,6 +1,6 @@ import {expect, test} from "./test" import {installApiMocks} from "./mocks" -import {landing} from "./sliceBand" +import {landingFrom} from "./sliceBand" /** * The season a page opens on, and that it is the same season everything on it describes. @@ -95,14 +95,17 @@ test.describe("the season a page opens on", () => { await page.getByTestId("esports-game-CS2").hover() await expect(page.getByTestId("esports-game-CS2")).toHaveClass(/slice--open/) + // Watched from before the change is asked for: this visitor's pass is a tenth of a second, + // which is shorter than the round trip a loaded runner takes to install a watch. + const drawn = await landingFrom(page, SWIPE, "esports-game-CS2", "esports-game-CSGO") + await page.getByTestId("esports-season-node-19").click() await expect(page.getByTestId("season-swipe")).toHaveAttribute("data-swipe", "past") // Open in the frame the arriving season is first drawn in, while the pass is still on, // rather than growing again once it is over. CS:GO is what says the band is the older // season's, the slice being read having stood on the season before it as well. - const drawn = await landing(page, SWIPE, "esports-game-CS2", "esports-game-CSGO") - expect(drawn).toEqual({open: true, panels: 2}) + expect(await drawn()).toEqual({open: true, panels: 2}) // The season travelled. The subject did not change under them on the way. await expect(page.getByTestId("esports-game-CS2")).toHaveClass(/slice--open/) diff --git a/services/frontend/tests/e2e/esports-season-swipe.motion.spec.ts b/services/frontend/tests/e2e/esports-season-swipe.motion.spec.ts index 89d7a2c9e..db2ad7c8f 100644 --- a/services/frontend/tests/e2e/esports-season-swipe.motion.spec.ts +++ b/services/frontend/tests/e2e/esports-season-swipe.motion.spec.ts @@ -1,7 +1,7 @@ import {expect, test} from "./test" import {installApiMocks} from "./mocks" import {eightSeasonFixtures} from "./esportsStrip" -import {landing} from "./sliceBand" +import {landingFrom} from "./sliceBand" /** * Moving between seasons, and which way. @@ -66,12 +66,13 @@ test.describe("swiping between seasons", () => { await page.goto("/esports/valorant") await page.getByTestId("season-swipe").waitFor() + const drawn = await landingFrom(page, SWIPE, "team-roster-52") + await page.getByTestId(`esports-season-node-${OLDER}`).click() // Its team's slice is open in the frame the band is first drawn in, with the pass still on: // the pass is the whole animation, so nothing grows once it is over. - const drawn = await landing(page, SWIPE, "team-roster-52") - expect(drawn).toEqual({open: true, panels: 2}) + expect(await drawn()).toEqual({open: true, panels: 2}) // The season that left is gone rather than parked off-screen, and the one that arrived is // where the band always sits. diff --git a/services/frontend/tests/e2e/sliceBand.ts b/services/frontend/tests/e2e/sliceBand.ts index 81037916d..c3f6e04d8 100644 --- a/services/frontend/tests/e2e/sliceBand.ts +++ b/services/frontend/tests/e2e/sliceBand.ts @@ -103,25 +103,39 @@ export function arrivedOpen(seen: {open: boolean, height: number}[]): void { } /** - * How the band at [swipe] is drawn in the frame [testid] is first on the page: whether that - * slice is open, and how many panels the band holds, two of them saying the pass is still on. + * Watches the band at [swipe] from now on, answering with a read of the frame [gate] is first + * drawn in: whether [testid] is open in it, and how many panels the band holds, two of them + * saying the pass is still on. * - * Read at a frame boundary rather than polled, since the claim is about the first frame. [gate] - * is what says the arriving stop is the one being read, where the slice is not itself that proof; - * the band leaving answers to no name, its testids taken off it at the swap. + * Begun before the change is asked for, and counted on the page. A pass is over in the time a + * visitor who asked for less motion allows it — a tenth of a second — so a watch installed + * after the click is a race against the runner's own round trips, and a loaded one loses it: + * the band then answers with one panel for a pass that did happen. [gate] is what says the + * arriving stop is the one being read, where the slice is not itself that proof; the band + * leaving answers to no name, its testids taken off it at the swap. */ -export async function landing( +export async function landingFrom( page: Page, swipe: string, testid: string, gate = testid, -): Promise<{open: boolean, panels: number}> { - const handle = await page.waitForFunction(([sel, id, key]) => { - if (!document.querySelector(`[data-testid="${key}"]`)) return null - const slice = document.querySelector(`[data-testid="${id}"]`) - return { - open: slice?.className.includes("slice--open") ?? false, - panels: document.querySelectorAll(`${sel} > *`).length, +): Promise<() => Promise<{open: boolean, panels: number}>> { + await page.evaluate(([sel, id, key]) => { + const tick = () => { + if (!document.querySelector(`[data-testid="${key}"]`)) return requestAnimationFrame(tick) + const slice = document.querySelector(`[data-testid="${id}"]`) + ;(window as unknown as {__landing?: unknown}).__landing = { + open: slice?.className.includes("slice--open") ?? false, + panels: document.querySelectorAll(`${sel} > *`).length, + } } + delete (window as unknown as {__landing?: unknown}).__landing + requestAnimationFrame(tick) }, [swipe, testid, gate] as const) - return handle.jsonValue() + + return async () => { + const handle = await page.waitForFunction(() => (window as unknown as { + __landing?: {open: boolean, panels: number} + }).__landing ?? null) + return handle.jsonValue() + } } /** The height the band at [swipe] is holding right now, or nothing where it holds none. */ From 68342fc80446cb4152aaf3afaa73a761a0c32d7c Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Sun, 6 Sep 2026 00:17:36 +0200 Subject: [PATCH 3/3] docs(island): the specs stop citing a bug that is fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five comments explained themselves by pointing at #852, and each said something that is no longer true now the preference reaches the page. Two in the boards spec blamed the loose depth on the suite running with full motion. The looseness is right for a different reason: the dissolve is eased under the reduced ceiling too, so a depth read a moment after a press is still wherever it had got to. The drag spec said its emulated preference goes once #852 is fixed. It does not: the motion project asks for no preference on purpose, so a reduced-motion case living there has to set it itself. And the season-on-show header said the deterministic projects only mean to emulate the preference. They do now, which shortens the pass rather than removing it — the reason the landing is watched for from before the season is asked for. --- services/frontend/tests/e2e/boards.spec.ts | 16 ++++++++-------- .../tests/e2e/esports-season-drag.motion.spec.ts | 9 ++++----- .../tests/e2e/esports-season-on-show.spec.ts | 6 +++--- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/services/frontend/tests/e2e/boards.spec.ts b/services/frontend/tests/e2e/boards.spec.ts index 2cb070493..96882a5b4 100644 --- a/services/frontend/tests/e2e/boards.spec.ts +++ b/services/frontend/tests/e2e/boards.spec.ts @@ -48,10 +48,10 @@ const restingDissolve = (page: Page): Promise => page.evaluate(() => ( * it computes. A named direction in here at all is a picture fading the wrong way — to the * right, across the reading direction, which is the shape this one replaced. * - * The depth is left loose on purpose. This suite runs with full motion whatever the config says - * (#852), so the depth read a moment after a press is wherever the eased dissolve had got to, - * and a whole number of percent is a coin toss on how busy the machine is. Where it comes to - * rest is asserted frame by frame in the motion spec, which is the layer that can watch it. + * The depth is left loose on purpose. The dissolve is eased even under the reduced ceiling, so + * the depth read a moment after a press is wherever it had got to, and a whole number of percent + * is a coin toss on how busy the machine is. Where it comes to rest is asserted frame by frame in + * the motion spec, which is the layer that can watch it. */ const DOWNWARDS = /^linear-gradient\(rgb\(0, 0, 0\) 0px, rgb\(0, 0, 0\) \d+(?:\.\d+)?%, rgba\(0, 0, 0, 0\) 100%\)$/ @@ -793,10 +793,10 @@ test.describe("board page", () => { /* * How far down each of the two has gone, as a percentage of the picture's band. * - * Both read inside one `evaluate`, so both are the same frame's answer. This suite runs - * with full motion whatever the config says (#852), so the depth a moment after a press is - * wherever the ease had got to — which is exactly why the two have to be read together and - * why the pair is polled until it settles rather than sampled once. + * Both read inside one `evaluate`, so both are the same frame's answer. The dissolve is eased + * even under the reduced ceiling, so the depth a moment after a press is wherever it had got + * to — which is exactly why the two have to be read together and why the pair is polled until + * it settles rather than sampled once. */ const depths = () => member.getByRole("button").evaluate((body) => { const depth = (style: CSSStyleDeclaration) => { diff --git a/services/frontend/tests/e2e/esports-season-drag.motion.spec.ts b/services/frontend/tests/e2e/esports-season-drag.motion.spec.ts index 4f0354fc2..b970d1d84 100644 --- a/services/frontend/tests/e2e/esports-season-drag.motion.spec.ts +++ b/services/frontend/tests/e2e/esports-season-drag.motion.spec.ts @@ -365,11 +365,10 @@ test.describe("dragging the esports index between seasons", () => { /** * What a visitor who asked for reduced motion gets, which is the gesture without its tails. * - * The preference is emulated for this test rather than taken from the project, because - * `use.reducedMotion` does not reach the page on Playwright 1.60 — #852 — so every - * "deterministic" project in this suite is in fact running with full motion. The same line and - * the same reasoning are in `boards-swipe.motion.spec.ts` and `esports-season-on-show.spec.ts`; - * when #852 is fixed, all of them go. + * The preference is emulated for this test rather than taken from the project, because this + * file runs in the motion project, which asks for no preference on purpose. A reduced-motion + * case living here has to say so itself. The same line and the same reasoning are in + * `boards-swipe.motion.spec.ts` and `board-slice-open.motion.spec.ts`. * * The durations are read off the animations themselves rather than timed with a clock. Eight * workers share four vCPUs here, so a wall-clock measurement of "it settled quickly" is a diff --git a/services/frontend/tests/e2e/esports-season-on-show.spec.ts b/services/frontend/tests/e2e/esports-season-on-show.spec.ts index 257a60ebc..a4113aa9a 100644 --- a/services/frontend/tests/e2e/esports-season-on-show.spec.ts +++ b/services/frontend/tests/e2e/esports-season-on-show.spec.ts @@ -9,9 +9,9 @@ import {landingFrom} from "./sliceBand" * about which season is arrived at. The movement itself is in * `esports-season-swipe.motion.spec.ts`. * - * Those projects are meant to emulate reduced motion and do not: see #852. So nothing here may - * assume the choreography is switched off, and the one test below that is about the preference - * sets it for itself. + * Those projects run as a visitor who asked for reduced motion, which shortens the pass rather + * than removing it: the two stops are both on the page for the clamped duration, so a landing is + * watched for from before the season is asked for rather than sampled after the fact. * * CS:GO is the game that makes these assertions mean something: it played the older of the two * seasons and nothing since, so a page that lets the api pick a season per game shows it, and