From 7cf4c8a177b68819e3708cb4024ab5473d82a6fc Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 6 Nov 2026 23:40:36 +1100 Subject: [PATCH 1/2] test(typeahead): serve the fixture pages as a build so cold loads fit Flake check (chromium, 11/11) on PR 2507 failed typeahead.spec.mjs:28 twice with "TimeoutError: page.goto: Timeout 15000ms exceeded" on /tests/fixtures/mentions.html?test-controls. Both times it was the first test in the shard's second worker (16.4 s and 16.6 s). The PR does not touch that page's module graph; it only selects the spec into the 10x lane. - https://github.com/block/buzz-app/actions/runs/42195228817/job/127817183790 - https://github.com/block/buzz-app/actions/runs/42169660635/job/127734602256 The spec took its baseURL from source-fixture.mjs, a worker-scoped Vite dev server that transforms modules on first request. The four pages it opens (mentions, typeahead, emoji and messages) are 1,595 modules, and mentions.html alone is 1,589. Whichever page a worker opened first loaded them cold, and on a busy runner that ran past the navigation timeout. Build the four pages once per worker in beforeAll and serve the bundle with build and preview from vite-server.mjs. This is the config voice-notes.spec.mjs has used since PR 2566 and PR 2776 uses for mentions.spec.mjs. The build defines import.meta.env.DEV and NODE_ENV as development, so StrictMode's checks still apply. test.use points baseURL at the preview server, so every page.goto path is unchanged. No assertions or cases change, and the navigation timeout is unchanged. Only this spec changes, so PR 2776 still merges cleanly. Validation: local on macOS (16 cores, load average 10-41) with the Playwright CI config, not hosted CI. - Fail-before / pass-after: a temporary vite-server.mjs probe (not committed) held the globals.css transform for 16 s in the dev server and the build. Main's :28 failed with the CI error (Chromium 15.1 s, WebKit 19.8 s). So did main's first typeahead.html, emoji.html and messages.html journeys (15.1 s each, Chromium). On this branch the same tests passed: :83 in 0.96 s (Chromium) and 0.97 s (WebKit), the others in 0.5-1.4 s. The whole file passed 24/24, with the 16 s spent in beforeAll. - Full file: Chromium 24/24, WebKit 24/24. - Chromium --repeat-each=10 --workers=2, two alternating runs each. Main: 240/240 in 3.2 and 3.3 min (839 s and 855 s user CPU), first test per worker 4.2-4.8 s. This branch: 240/240 in 2.0 and 1.9 min (352 s and 339 s user CPU), first test per worker 0.9 s. - flake-check select, run for PR 2507's diff, a MessageRow.tsx-only change, tests/fixtures/mentions.tsx and tests/fixtures/mentions.html: every selection matches main's apart from the spec's 55-line shift. PR 2507's diff still selects the spec through messages.html. The MessageRow.tsx-only change still reaches it through emoji.html (spread 7), though the 40-journey cap drops it there, as on main. The spec still links to all four pages. - tests/integration/flake-check.test.mjs and vite-fixture.test.mjs: 33/33. Biome: clean. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- tests/browser/typeahead.spec.mjs | 57 +++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/tests/browser/typeahead.spec.mjs b/tests/browser/typeahead.spec.mjs index 278f309d63..f5477094c8 100644 --- a/tests/browser/typeahead.spec.mjs +++ b/tests/browser/typeahead.spec.mjs @@ -1,6 +1,61 @@ -import { test, expect } from "./source-fixture.mjs"; +import { test, expect } from "./playwright-test.mjs"; +import { build, preview } from "./vite-server.mjs"; +import react from "@vitejs/plugin-react"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { watchPageErrors } from "./page-errors.mjs"; +let server, directory, origin; +// A dev server serves these fixtures as about 1,600 modules, transformed on +// first request. On a busy runner a worker's first navigation took longer than +// the navigation timeout to load them, so build the pages once and serve the +// bundle. +test.beforeAll(async () => { + const root = fileURLToPath(new URL("../../", import.meta.url)); + directory = await mkdtemp(join(tmpdir(), "buzz-typeahead-")); + const config = { + root, + configFile: false, + envDir: false, + logLevel: "error", + plugins: [react()], + // Development React, as the dev server served, keeps StrictMode's checks. + define: { + "import.meta.env.DEV": "true", + "process.env.NODE_ENV": '"development"', + }, + build: { + rollupOptions: { + input: [ + "tests/fixtures/emoji.html", + "tests/fixtures/mentions.html", + "tests/fixtures/messages.html", + "tests/fixtures/typeahead.html", + ].map((page) => join(root, page)), + }, + outDir: join(directory, "dist"), + emptyOutDir: true, + target: "esnext", + }, + }; + await build(config); + server = await preview({ + ...config, + preview: { host: "127.0.0.1", port: 0, strictPort: true }, + }); + origin = `http://127.0.0.1:${server.httpServer.address().port}`; +}); +test.afterAll(async () => { + if (server) await new Promise((resolve) => server.httpServer.close(resolve)); + if (directory) await rm(directory, { recursive: true, force: true }); +}); +// Journeys open the pages by path. The bundle's port is known only after +// beforeAll, which runs before each test's fixtures. +// biome-ignore lint/correctness/noEmptyPattern: Playwright requires a destructured fixture argument. +test.use({ baseURL: async ({}, use) => use(origin) }); + const open = async (page) => { await page.goto("/tests/fixtures/mentions.html?test-controls"); return page.getByRole("textbox", { name: "Message #General" }); From bbf2e2ed21f40606994b38245d1587a2a8bf72fe Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Sat, 7 Nov 2026 00:02:57 +1100 Subject: [PATCH 2/2] test(browser): share the fixture preview build in previewFixtures Kalvin's review on PR 2793 noted that typeahead.spec.mjs repeated the build and preview setup from voice-notes.spec.mjs. A change to the development-mode defines, port binding or teardown would need the same edit in both specs. previewFixtures(pages) in vite-server.mjs now owns that lifecycle. It builds the repository-relative pages into a temp directory, with import.meta.env.DEV and NODE_ENV set to development so StrictMode's checks still apply. It previews them on an OS-assigned port and returns the origin with a close() that stops the server and removes the build. If the build or preview fails, it removes the directory itself and rethrows; the specs used to leave that to afterAll. Both specs now call it from beforeAll and afterAll. mentions.spec.mjs is unchanged, so PR 2776 still merges cleanly. typeahead's baseURL fixture reads fixtures?.origin. Playwright also resolves baseURL for the beforeAll hook, before the bundle exists. The previous code passed an undefined origin there, and so does this. No cases, assertions, navigation timeout or page.goto URLs change. Both specs still name their pages, so flake-check still links them. Validation: local on macOS (load average 9-13) with the Playwright CI config, not hosted CI. - Chromium: typeahead 24/24, voice-notes 5/5. An earlier run that read fixtures.origin without ?. failed in typeahead's beforeAll with "Cannot read properties of undefined (reading 'origin')". - A probe that passed a missing page saw previewFixtures reject with the build error and leave no buzz-fixture-preview- directory. None remained after the spec runs either. - tests/integration/vite-fixture.test.mjs and flake-check.test.mjs: 33/33. Biome: clean. - flake-check select, run on the previous and new heads for PR 2507's diff, a MessageRow.tsx-only change, tests/fixtures/mentions.tsx, tests/fixtures/mentions.html and tests/fixtures/voice-notes.tsx: every selection matches apart from a 36-line shift in both specs. The module graph still links typeahead.spec.mjs to emoji, mentions, messages and typeahead.html, and voice-notes.spec.mjs to voice-notes.html. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- tests/browser/typeahead.spec.mjs | 64 +++++++----------------------- tests/browser/vite-server.mjs | 43 ++++++++++++++++++++ tests/browser/voice-notes.spec.mjs | 50 ++++------------------- 3 files changed, 64 insertions(+), 93 deletions(-) diff --git a/tests/browser/typeahead.spec.mjs b/tests/browser/typeahead.spec.mjs index f5477094c8..7efbac1bc7 100644 --- a/tests/browser/typeahead.spec.mjs +++ b/tests/browser/typeahead.spec.mjs @@ -1,60 +1,24 @@ import { test, expect } from "./playwright-test.mjs"; -import { build, preview } from "./vite-server.mjs"; -import react from "@vitejs/plugin-react"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { previewFixtures } from "./vite-server.mjs"; import { watchPageErrors } from "./page-errors.mjs"; -let server, directory, origin; -// A dev server serves these fixtures as about 1,600 modules, transformed on -// first request. On a busy runner a worker's first navigation took longer than -// the navigation timeout to load them, so build the pages once and serve the -// bundle. +let fixtures; +// A dev server serves these fixtures as about 1,600 modules, and a worker's +// first navigation outlasted the navigation timeout on a busy runner. test.beforeAll(async () => { - const root = fileURLToPath(new URL("../../", import.meta.url)); - directory = await mkdtemp(join(tmpdir(), "buzz-typeahead-")); - const config = { - root, - configFile: false, - envDir: false, - logLevel: "error", - plugins: [react()], - // Development React, as the dev server served, keeps StrictMode's checks. - define: { - "import.meta.env.DEV": "true", - "process.env.NODE_ENV": '"development"', - }, - build: { - rollupOptions: { - input: [ - "tests/fixtures/emoji.html", - "tests/fixtures/mentions.html", - "tests/fixtures/messages.html", - "tests/fixtures/typeahead.html", - ].map((page) => join(root, page)), - }, - outDir: join(directory, "dist"), - emptyOutDir: true, - target: "esnext", - }, - }; - await build(config); - server = await preview({ - ...config, - preview: { host: "127.0.0.1", port: 0, strictPort: true }, - }); - origin = `http://127.0.0.1:${server.httpServer.address().port}`; -}); -test.afterAll(async () => { - if (server) await new Promise((resolve) => server.httpServer.close(resolve)); - if (directory) await rm(directory, { recursive: true, force: true }); + fixtures = await previewFixtures([ + "tests/fixtures/emoji.html", + "tests/fixtures/mentions.html", + "tests/fixtures/messages.html", + "tests/fixtures/typeahead.html", + ]); }); +test.afterAll(() => fixtures?.close()); // Journeys open the pages by path. The bundle's port is known only after -// beforeAll, which runs before each test's fixtures. +// beforeAll, which runs before each test's fixtures. Playwright also resolves +// baseURL for beforeAll itself, before the bundle exists. // biome-ignore lint/correctness/noEmptyPattern: Playwright requires a destructured fixture argument. -test.use({ baseURL: async ({}, use) => use(origin) }); +test.use({ baseURL: async ({}, use) => use(fixtures?.origin) }); const open = async (page) => { await page.goto("/tests/fixtures/mentions.html?test-controls"); diff --git a/tests/browser/vite-server.mjs b/tests/browser/vite-server.mjs index 9caa841733..8c52306b8c 100644 --- a/tests/browser/vite-server.mjs +++ b/tests/browser/vite-server.mjs @@ -136,6 +136,49 @@ export const preview = (config) => }), ); +// A dev server transforms a page's modules on first request, so a worker's +// first navigation to a large fixture can outlast the navigation timeout on a +// busy runner. This builds the repository-relative pages once and serves the +// bundle. Development React, as a dev server serves, keeps StrictMode's checks. +// close() stops the server and removes the build. +export async function previewFixtures(pages) { + const directory = await mkdtemp(join(tmpdir(), "buzz-fixture-preview-")); + try { + const config = { + root, + configFile: false, + envDir: false, + logLevel: "error", + plugins: [react()], + define: { + "import.meta.env.DEV": "true", + "process.env.NODE_ENV": '"development"', + }, + build: { + rollupOptions: { input: pages.map((page) => join(root, page)) }, + outDir: join(directory, "dist"), + emptyOutDir: true, + target: "esnext", + }, + }; + await build(config); + const server = await preview({ + ...config, + preview: { host: "127.0.0.1", port: 0, strictPort: true }, + }); + return { + origin: `http://127.0.0.1:${server.httpServer.address().port}`, + async close() { + await new Promise((resolve) => server.httpServer.close(resolve)); + await rm(directory, { recursive: true, force: true }); + }, + }; + } catch (error) { + await rm(directory, { recursive: true, force: true }); + throw error; + } +} + // Run once per test run, before any test: it scans and bundles the fixture // dependencies so no server does that work inside a navigation budget. export async function prepareFixtureDependencies() { diff --git a/tests/browser/voice-notes.spec.mjs b/tests/browser/voice-notes.spec.mjs index b796f231b1..f47dd49cb7 100644 --- a/tests/browser/voice-notes.spec.mjs +++ b/tests/browser/voice-notes.spec.mjs @@ -1,49 +1,13 @@ import { watchPageErrors } from "./page-errors.mjs"; import { test, expect } from "./playwright-test.mjs"; -import { build, preview } from "./vite-server.mjs"; -import react from "@vitejs/plugin-react"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -let server, directory, url; -// A dev server serves this fixture as about 800 modules. On a busy runner, -// WebKit's first navigation took longer than the navigation timeout to load -// them, so build the page once and serve the bundle. +import { previewFixtures } from "./vite-server.mjs"; +let fixtures; +// A dev server serves this fixture as about 800 modules, and WebKit's first +// navigation outlasted the navigation timeout on a busy runner. test.beforeAll(async () => { - const root = fileURLToPath(new URL("../../", import.meta.url)); - directory = await mkdtemp(join(tmpdir(), "buzz-voice-notes-")); - const config = { - root, - configFile: false, - envDir: false, - logLevel: "error", - plugins: [react()], - // Development React, as the dev server served, keeps StrictMode's checks. - define: { - "import.meta.env.DEV": "true", - "process.env.NODE_ENV": '"development"', - }, - build: { - rollupOptions: { - input: join(root, "tests/fixtures/voice-notes.html"), - }, - outDir: join(directory, "dist"), - emptyOutDir: true, - target: "esnext", - }, - }; - await build(config); - server = await preview({ - ...config, - preview: { host: "127.0.0.1", port: 0, strictPort: true }, - }); - url = `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/voice-notes.html`; -}); -test.afterAll(async () => { - if (server) await new Promise((resolve) => server.httpServer.close(resolve)); - if (directory) await rm(directory, { recursive: true, force: true }); + fixtures = await previewFixtures(["tests/fixtures/voice-notes.html"]); }); +test.afterAll(() => fixtures?.close()); test.beforeEach(async ({ page }) => { await page.route("**/api/relay/media?*", async (route) => { const url = new URL(route.request().url()).searchParams.get("url"); @@ -59,7 +23,7 @@ test.beforeEach(async ({ page }) => { body: Buffer.from(bytes), }); }); - await page.goto(url); + await page.goto(`${fixtures.origin}/tests/fixtures/voice-notes.html`); await page.evaluate(() => { const mic = { mode: "allow",