From 6305c03fd97ba4ca7addd9104d3b8cb5a21ea76b Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Fri, 6 Nov 2026 18:11:50 -0800 Subject: [PATCH] test(browser): fix three flaky journeys seen across open PRs - channel-completion: build the mentions fixture once and serve the bundle, as mentions.spec.mjs does (#2776). Each test started its own dev server, and the first cold navigation hit the 15s timeout on both engines in #2935's flake check. The build keeps the dev server's unrounded CSS: Tailwind's optimizer and Vite's CSS minifier round the 20/14 line-height to 1.42857, moving the popup 1/32px off the 4px gap WebKit measures. - profiles: after Escape closes the profile panel, wait for the address to leave the profile before reopening Members. Focus returns before the panel's history return lands; in #2909's WebKit flake check the reopened dialog was replaced by the profile panel. - timeline edge(): repeat the edge gesture while it makes progress. One Linux WebKit wheel ended 199px short of the top in history-prefix (#2937); each extra gesture must still move, so an immobile timeline fails. Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen --- tests/browser/channel-completion.spec.mjs | 441 +++++++++++----------- tests/browser/profiles.spec.mjs | 10 + tests/browser/timeline.mjs | 20 +- 3 files changed, 246 insertions(+), 225 deletions(-) diff --git a/tests/browser/channel-completion.spec.mjs b/tests/browser/channel-completion.spec.mjs index ca6f71517c..821dd62615 100644 --- a/tests/browser/channel-completion.spec.mjs +++ b/tests/browser/channel-completion.spec.mjs @@ -1,86 +1,167 @@ import { test, expect } from "./playwright-test.mjs"; -import { createServer } from "./vite-server.mjs"; +import { build, preview } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/postcss"; +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, fixture; +// A dev server serves this fixture as about 1,600 modules, and each test +// started its own. On a busy runner the first navigation took longer than the +// navigation timeout to load them, so build the page once and serve the bundle, +// as mentions.spec.mjs does. +test.beforeAll(async () => { + const root = fileURLToPath(new URL("../../", import.meta.url)); + directory = await mkdtemp(join(tmpdir(), "buzz-channel-completion-")); + const config = { + root, + configFile: false, + envDir: false, + logLevel: "error", + plugins: [react()], + // Tailwind's optimizer and Vite's CSS minifier each round the 20/14 + // line-height token to 1.42857, which moves the popup 1/32px off the 4px + // gap measured below. Serve the unrounded CSS the dev server served. + css: { postcss: { plugins: [tailwindcss({ optimize: false })] } }, + // 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/mentions.html") }, + outDir: join(directory, "dist"), + emptyOutDir: true, + target: "esnext", + cssMinify: false, + }, + }; + await build(config); + server = await preview({ + ...config, + preview: { host: "127.0.0.1", port: 0, strictPort: true }, + }); + fixture = `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/mentions.html`; +}); +test.afterAll(async () => { + if (server) await new Promise((resolve) => server.httpServer.close(resolve)); + if (directory) await rm(directory, { recursive: true, force: true }); +}); + test("channel completion preserves native editing, focus, popup geometry and signed send/reply content", async ({ page, }) => { // Browser boundary: real editor transactions/undo, portal layout and plugin-to-send wiring. // Channel eligibility, ranking and stale-list permutations live in colocated Vitest tests. - const server = await createServer({ - root: fileURLToPath(new URL("../../", import.meta.url)), - configFile: false, - envFile: false, - plugins: [react()], - logLevel: "error", - server: { - host: "127.0.0.1", - port: 0, - open: false, - watch: { ignored: ["**/src-tauri/**", "**/target/**"] }, - }, - }); const errors = watchPageErrors(page); - try { - await server.listen(); - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/mentions.html?channels&stream&test-controls`, - ); - const input = page.getByRole("textbox"); - await expect(input).toBeVisible(); - await expect - .poll(() => page.evaluate(() => window.mentionFixture.list().status)) - .toBe("ready"); - await expect - .poll(() => page.evaluate(() => window.mentionFixture.reads().pending)) - .toBe(0); - // A `#` query reads the public channel directory after its typing pause. - // That read belongs to the query, so the baseline holds whichever side of - // the pause a keystroke lands. - const sessionReads = () => - page.evaluate(() => { - const { kinds, queries } = window.mentionFixture.reads(); - return kinds.filter((_, index) => !queries[index]); - }); - const reads = await sessionReads(); - expect(reads.length).toBeGreaterThan(0); - const list = page.getByRole("listbox", { name: "Channel suggestions" }); - await input.pressSequentially("#"); - await expect(list.getByRole("option")).toHaveCount(2); - await expect(input).toBeFocused(); - await expect - .poll(() => - list.evaluate((element) => { - const popup = element.parentElement; - return ( - document.querySelector("form").getBoundingClientRect().top - - popup.getBoundingClientRect().bottom - ); - }), - ) - .toBe(4); - expect( - await list.evaluate((element) => element.closest("form") === null), - ).toBe(true); - await expect(list.locator("..")).toHaveCSS("width", "480px"); - await input.press("ArrowDown"); - await expect( - list.getByRole("option", { name: "Other Private channel" }), - ).toHaveAttribute("aria-selected", "true"); - await input.press("Tab"); - await expect(list).toHaveCount(0); - await expect(input).toBeFocused(); - await expect(input).toHaveJSProperty( - "value", - "[\\#Other](buzz://channel/other) ", - ); - await input.press("ControlOrMeta+z"); - await expect(input).toHaveJSProperty("value", "#"); - // Undo restores the replaced query selection. Collapse it before extending. - await input.press("ArrowRight"); - await input.pressSequentially("Gen"); + await page.goto(`${fixture}?channels&stream&test-controls`); + const input = page.getByRole("textbox"); + await expect(input).toBeVisible(); + await expect + .poll(() => page.evaluate(() => window.mentionFixture.list().status)) + .toBe("ready"); + await expect + .poll(() => page.evaluate(() => window.mentionFixture.reads().pending)) + .toBe(0); + // A `#` query reads the public channel directory after its typing pause. + // That read belongs to the query, so the baseline holds whichever side of + // the pause a keystroke lands. + const sessionReads = () => + page.evaluate(() => { + const { kinds, queries } = window.mentionFixture.reads(); + return kinds.filter((_, index) => !queries[index]); + }); + const reads = await sessionReads(); + expect(reads.length).toBeGreaterThan(0); + const list = page.getByRole("listbox", { name: "Channel suggestions" }); + await input.pressSequentially("#"); + await expect(list.getByRole("option")).toHaveCount(2); + await expect(input).toBeFocused(); + await expect + .poll(() => + list.evaluate((element) => { + const popup = element.parentElement; + return ( + document.querySelector("form").getBoundingClientRect().top - + popup.getBoundingClientRect().bottom + ); + }), + ) + .toBe(4); + expect( + await list.evaluate((element) => element.closest("form") === null), + ).toBe(true); + await expect(list.locator("..")).toHaveCSS("width", "480px"); + await input.press("ArrowDown"); + await expect( + list.getByRole("option", { name: "Other Private channel" }), + ).toHaveAttribute("aria-selected", "true"); + await input.press("Tab"); + await expect(list).toHaveCount(0); + await expect(input).toBeFocused(); + await expect(input).toHaveJSProperty( + "value", + "[\\#Other](buzz://channel/other) ", + ); + await input.press("ControlOrMeta+z"); + await expect(input).toHaveJSProperty("value", "#"); + // Undo restores the replaced query selection. Collapse it before extending. + await input.press("ArrowRight"); + await input.pressSequentially("Gen"); + await expect(list.getByRole("option")).toHaveCount(1); + await input.press("Enter"); + await expect(input).toHaveJSProperty( + "value", + "[\\#General](buzz://channel/c) ", + ); + await expect(input.locator("[data-link-renderer]")).toHaveText("General"); + await expect( + input.locator('[data-link-renderer] svg[aria-hidden="true"]'), + ).toHaveCount(1); + await expect(input).toBeFocused(); + expect(await page.evaluate(() => window.mentionFixture.publications)).toEqual( + [], + ); + expect(await sessionReads()).toEqual(reads); + // Stable IDs and source survive draft persistence, not just the mounted input. + await page.reload(); + await expect(input).toHaveJSProperty( + "value", + "[\\#General](buzz://channel/c) ", + ); + await input.press("Enter"); + await expect + .poll(() => page.evaluate(() => window.mentionFixture.publications.length)) + .toBe(1); + const sent = await page.evaluate(() => window.mentionFixture.publications[0]); + expect(sent.content).toBe("[\\#General](buzz://channel/c)"); + expect(sent.tags).toContainEqual(["h", "c"]); + expect(sent.tags.filter(([name]) => name === "p")).toEqual([]); + await page.getByRole("button", { name: "Toggle thread" }).click(); + await input.pressSequentially("#Ot"); + await list.getByRole("option", { name: "Other Private channel" }).click(); + await expect(input).toBeFocused(); + await expect(input).toHaveJSProperty( + "value", + "[\\#Other](buzz://channel/other) ", + ); + await input.press("Enter"); + await expect + .poll(() => page.evaluate(() => window.mentionFixture.publications.length)) + .toBe(2); + const reply = await page.evaluate( + () => window.mentionFixture.publications[1], + ); + expect(reply.content).toBe("[\\#Other](buzz://channel/other)"); + expect(reply.tags).toContainEqual(["e", "a".repeat(64), "", "reply"]); + expect(reply.tags.filter(([name]) => name === "p")).toEqual([]); + // Rich marks and autocomplete share one native transaction owner. + for (const shortcut of ["ControlOrMeta+b", "ControlOrMeta+i"]) { + await input.press(shortcut); + await input.pressSequentially("#Gen"); await expect(list.getByRole("option")).toHaveCount(1); await input.press("Enter"); await expect(input).toHaveJSProperty( @@ -91,161 +172,79 @@ test("channel completion preserves native editing, focus, popup geometry and sig await expect( input.locator('[data-link-renderer] svg[aria-hidden="true"]'), ).toHaveCount(1); - await expect(input).toBeFocused(); - expect( - await page.evaluate(() => window.mentionFixture.publications), - ).toEqual([]); - expect(await sessionReads()).toEqual(reads); - // Stable IDs and source survive draft persistence, not just the mounted input. - await page.reload(); - await expect(input).toHaveJSProperty( - "value", - "[\\#General](buzz://channel/c) ", - ); - await input.press("Enter"); - await expect - .poll(() => - page.evaluate(() => window.mentionFixture.publications.length), - ) - .toBe(1); - const sent = await page.evaluate( - () => window.mentionFixture.publications[0], - ); - expect(sent.content).toBe("[\\#General](buzz://channel/c)"); - expect(sent.tags).toContainEqual(["h", "c"]); - expect(sent.tags.filter(([name]) => name === "p")).toEqual([]); - await page.getByRole("button", { name: "Toggle thread" }).click(); - await input.pressSequentially("#Ot"); - await list.getByRole("option", { name: "Other Private channel" }).click(); - await expect(input).toBeFocused(); - await expect(input).toHaveJSProperty( - "value", - "[\\#Other](buzz://channel/other) ", - ); - await input.press("Enter"); - await expect - .poll(() => - page.evaluate(() => window.mentionFixture.publications.length), - ) - .toBe(2); - const reply = await page.evaluate( - () => window.mentionFixture.publications[1], - ); - expect(reply.content).toBe("[\\#Other](buzz://channel/other)"); - expect(reply.tags).toContainEqual(["e", "a".repeat(64), "", "reply"]); - expect(reply.tags.filter(([name]) => name === "p")).toEqual([]); - // Rich marks and autocomplete share one native transaction owner. - for (const shortcut of ["ControlOrMeta+b", "ControlOrMeta+i"]) { - await input.press(shortcut); - await input.pressSequentially("#Gen"); - await expect(list.getByRole("option")).toHaveCount(1); - await input.press("Enter"); - await expect(input).toHaveJSProperty( - "value", - "[\\#General](buzz://channel/c) ", - ); - await expect(input.locator("[data-link-renderer]")).toHaveText("General"); - await expect( - input.locator('[data-link-renderer] svg[aria-hidden="true"]'), - ).toHaveCount(1); - await input.press(shortcut); - await input.press("ControlOrMeta+a"); - await input.press("Backspace"); - } - await input.pressSequentially("PR #1234"); - await expect(input).toHaveJSProperty("value", "PR #1234"); - await expect(list).toHaveCount(0); - await input.press("Enter"); - await expect - .poll(() => - page.evaluate(() => window.mentionFixture.publications.length), - ) - .toBe(3); - expect( - await page.evaluate(() => window.mentionFixture.publications[2].content), - ).toBe("PR #1234"); - await page.setViewportSize({ width: 360, height: 740 }); - await input.pressSequentially("#"); - await expect(list).toBeVisible(); - const bounds = await list.locator("..").boundingBox(); - expect(bounds.x).toBeGreaterThanOrEqual(0); - expect(bounds.x + bounds.width).toBeLessThanOrEqual(360); - expect(bounds.y).toBeGreaterThanOrEqual(0); - expect(bounds.y + bounds.height).toBeLessThanOrEqual(740); - await input.press("Escape"); - await expect(list).toHaveCount(0); - await expect(input).toHaveJSProperty("value", "#"); - expect(errors.unexplained()).toEqual([]); - } finally { - await server.close(); + await input.press(shortcut); + await input.press("ControlOrMeta+a"); + await input.press("Backspace"); } + await input.pressSequentially("PR #1234"); + await expect(input).toHaveJSProperty("value", "PR #1234"); + await expect(list).toHaveCount(0); + await input.press("Enter"); + await expect + .poll(() => page.evaluate(() => window.mentionFixture.publications.length)) + .toBe(3); + expect( + await page.evaluate(() => window.mentionFixture.publications[2].content), + ).toBe("PR #1234"); + await page.setViewportSize({ width: 360, height: 740 }); + await input.pressSequentially("#"); + await expect(list).toBeVisible(); + const bounds = await list.locator("..").boundingBox(); + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(360); + expect(bounds.y).toBeGreaterThanOrEqual(0); + expect(bounds.y + bounds.height).toBeLessThanOrEqual(740); + await input.press("Escape"); + await expect(list).toHaveCount(0); + await expect(input).toHaveJSProperty("value", "#"); + expect(errors.unexplained()).toEqual([]); }); test("channel completion finds an open channel the viewer has not joined", async ({ page, }) => { // Browser boundary: the real store's public search and its read-only preview. - const server = await createServer({ - root: fileURLToPath(new URL("../../", import.meta.url)), - configFile: false, - envFile: false, - plugins: [react()], - logLevel: "error", - server: { - host: "127.0.0.1", - port: 0, - open: false, - watch: { ignored: ["**/src-tauri/**", "**/target/**"] }, - }, - }); const errors = watchPageErrors(page); - try { - await server.listen(); - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/mentions.html?channels&stream&open-channels&test-controls`, - ); - const input = page.getByRole("textbox"); - await expect(input).toBeVisible(); - await expect - .poll(() => page.evaluate(() => window.mentionFixture.list().status)) - .toBe("ready"); - const list = page.getByRole("listbox", { name: "Channel suggestions" }); - await input.pressSequentially("#Ge"); - const options = list.getByRole("option"); - await expect(options).toHaveCount(2); - await expect(options.nth(0)).toContainText("General"); - await expect(options.nth(1)).toContainText("Gemstones"); - await expect(options.nth(1)).toContainText("Not joined"); - await input.press("ArrowDown"); - await input.press("Enter"); - await expect(input).toHaveJSProperty( - "value", - "[\\#Gemstones](buzz://channel/open) ", - ); - // Each edit remounts the completion. While the next lookup is held, the - // still-matching open row stays, so Enter completes instead of sending. - await input.press("ControlOrMeta+a"); - await input.press("Backspace"); - await input.pressSequentially("#Gem"); - await expect(options).toHaveCount(1); - await expect(options.nth(0)).toContainText("Gemstones"); - await page.evaluate(() => window.mentionFixture.holdChannelReads()); - await input.pressSequentially("s"); - await expect( - page.getByRole("status").filter({ hasText: "Searching open channels…" }), - ).toBeVisible(); - await expect(options).toHaveCount(1); - await input.press("Enter"); - await expect(input).toHaveJSProperty( - "value", - "[\\#Gemstones](buzz://channel/open) ", - ); - expect( - await page.evaluate(() => window.mentionFixture.publications), - ).toEqual([]); - await page.evaluate(() => window.mentionFixture.releaseChannelReads()); - expect(errors.unexplained()).toEqual([]); - } finally { - await server.close(); - } + await page.goto(`${fixture}?channels&stream&open-channels&test-controls`); + const input = page.getByRole("textbox"); + await expect(input).toBeVisible(); + await expect + .poll(() => page.evaluate(() => window.mentionFixture.list().status)) + .toBe("ready"); + const list = page.getByRole("listbox", { name: "Channel suggestions" }); + await input.pressSequentially("#Ge"); + const options = list.getByRole("option"); + await expect(options).toHaveCount(2); + await expect(options.nth(0)).toContainText("General"); + await expect(options.nth(1)).toContainText("Gemstones"); + await expect(options.nth(1)).toContainText("Not joined"); + await input.press("ArrowDown"); + await input.press("Enter"); + await expect(input).toHaveJSProperty( + "value", + "[\\#Gemstones](buzz://channel/open) ", + ); + // Each edit remounts the completion. While the next lookup is held, the + // still-matching open row stays, so Enter completes instead of sending. + await input.press("ControlOrMeta+a"); + await input.press("Backspace"); + await input.pressSequentially("#Gem"); + await expect(options).toHaveCount(1); + await expect(options.nth(0)).toContainText("Gemstones"); + await page.evaluate(() => window.mentionFixture.holdChannelReads()); + await input.pressSequentially("s"); + await expect( + page.getByRole("status").filter({ hasText: "Searching open channels…" }), + ).toBeVisible(); + await expect(options).toHaveCount(1); + await input.press("Enter"); + await expect(input).toHaveJSProperty( + "value", + "[\\#Gemstones](buzz://channel/open) ", + ); + expect(await page.evaluate(() => window.mentionFixture.publications)).toEqual( + [], + ); + await page.evaluate(() => window.mentionFixture.releaseChannelReads()); + expect(errors.unexplained()).toEqual([]); }); diff --git a/tests/browser/profiles.spec.mjs b/tests/browser/profiles.spec.mjs index 2206230032..61e6e527e5 100644 --- a/tests/browser/profiles.spec.mjs +++ b/tests/browser/profiles.spec.mjs @@ -1165,6 +1165,13 @@ test("member rows hand off to Profiles without trapping or losing keyboard focus const profileTab = page .getByRole("tablist", { name: "Panel tabs" }) .getByRole("tab", { name: "Viewer", exact: true }); + // Escape retires the profile visit with a native Back that can land after + // focus returns. A dialog opened before it lands is undone by it, so wait + // for the address to leave the profile before reopening Members. + const profileReturned = () => + expect(page).toHaveURL( + (url) => !decodeURIComponent(url.hash).includes('"detail"'), + ); await expect(dialog).toHaveCount(0); await expect(profileTab).toBeFocused(); await expect( @@ -1173,12 +1180,14 @@ test("member rows hand off to Profiles without trapping or losing keyboard focus await profileTab.press("Escape"); await expect(panel).toHaveCount(0); await expect(members).toBeFocused(); + await profileReturned(); await members.press("Enter"); await row.focus(); await row.press("Enter"); await expect(profileTab).toBeFocused(); await profileTab.press("Escape"); await expect(members).toBeFocused(); + await profileReturned(); // Every row, including the viewer and protected identities, has one // always-visible action slot. Hover/focus never changes row geometry. await members.click(); @@ -1243,6 +1252,7 @@ test("member rows hand off to Profiles without trapping or losing keyboard focus await expect(profileTab).toBeFocused(); await profileTab.press("Escape"); await expect(members).toBeFocused(); + await profileReturned(); await members.press("Enter"); } await dialog.getByRole("button", { name: "Close" }).click(); diff --git a/tests/browser/timeline.mjs b/tests/browser/timeline.mjs index e14c2f8707..08f49067d1 100644 --- a/tests/browser/timeline.mjs +++ b/tests/browser/timeline.mjs @@ -153,11 +153,23 @@ export async function edge(page, direction, scroller = history(page)) { direction, ); await scroller.hover(); - // Send one real gesture for the actual distance, not an arbitrary 100,000px + // Send real gestures for the actual distance, not an arbitrary 100,000px // overshoot. At the boundary, retain input so production can initiate paging. - const remaining = await distance(); - if (remaining >= 4) await wheel(page, direction * remaining, scroller); - else await page.mouse.wheel(0, direction * Math.max(1, remaining)); + let remaining = await distance(); + if (remaining < 4) + await page.mouse.wheel(0, direction * Math.max(1, remaining)); + // A gesture can end short of the edge, as one did 199px from the top in + // Linux WebKit (see upper). Each further gesture must make real progress, + // so an immobile timeline still fails. + for (let gesture = 0; remaining >= 4 && gesture < 4; gesture++) { + await wheel(page, direction * remaining, scroller); + const before = remaining; + remaining = await distance(); + if (remaining >= 4) + expect(remaining, "edge gesture moves toward the edge").toBeLessThan( + before, + ); + } await expect .poll(distance, { message: "wheel reaches timeline edge" }) .toBeLessThan(4);