From ef2d98d2847e1ef644fc51565db243245723149a Mon Sep 17 00:00:00 2001 From: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Date: Mon, 28 Sep 2026 16:41:28 -0400 Subject: [PATCH] Open pages from search by keyboard once the palette is final openPage clicked a Search Buzz action by position. After a reload that restores a conversation, the channel list arrives after the palette opens and adds rows above Actions, so the target moves about 190px. A click at the old position lands between rows and the dialog stays open (thread-unread.spec.mjs:45 on webkit in CI). The palette also starts as a placeholder until the community connects. The connected palette replaces it, which resets selection and option ids. openPage now waits for the connected palette, selects the action with the keyboard, and presses Enter. Selection follows the action's key, so rows that arrive later cannot move it. Tests that hold or fail the relay session pass connected: false, and openPage checks that the placeholder is showing. A new global-search test holds the membership read so the rows arrive after selection, and checks that the action moved and stays selected. Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- tests/browser/global-search.spec.mjs | 63 +++++++++++++++++++++++ tests/browser/navigation-repairs.spec.mjs | 2 +- tests/browser/navigation.mjs | 42 +++++++++------ tests/browser/plugins.spec.mjs | 2 +- 4 files changed, 90 insertions(+), 19 deletions(-) diff --git a/tests/browser/global-search.spec.mjs b/tests/browser/global-search.spec.mjs index 4a27dddec..a0e3bc9a4 100644 --- a/tests/browser/global-search.spec.mjs +++ b/tests/browser/global-search.spec.mjs @@ -191,3 +191,66 @@ test.describe("public search destination", () => { ).toBe(true); }); }); + +test("keyboard selection follows its action while recent conversations arrive above it", async ({ + page, + app, +}) => { + await page.goto(app.origin); + const rail = page.getByRole("button", { + name: "Switch to Primary", + exact: true, + }); + await expect(rail).toBeVisible(); + // Hold the membership read across a reload so the channel list arrives after + // the palette has opened, as it can after a restored conversation. Other + // reads continue, so the held read stays well inside its own deadline. + const held = []; + let holding = true; + await page.route("**/api/relay/*/query", async (route) => { + const filters = route.request().postDataJSON(); + if (holding && filters.some(({ kinds }) => kinds?.includes(39002))) + await new Promise((resolve) => held.push(resolve)); + await route.continue(); + }); + try { + await page.reload(); + await expect(rail).toBeVisible(); + await button(page, "Search Buzz").click(); + const dialog = page.getByRole("dialog", { + name: "Search Buzz", + exact: true, + }); + const input = dialog.getByRole("combobox", { name: "Search Buzz" }); + const recent = dialog + .getByRole("group", { name: "Recent activity" }) + .getByRole("option"); + const projects = dialog + .getByRole("group", { name: "Actions" }) + .getByRole("option", { name: "Projects", exact: true }); + await expect(projects).toBeVisible(); + // The connected palette is mounted, but its channel list is still held. + await expect( + dialog.getByText("Connecting to this community…", { exact: true }), + ).toHaveCount(0); + await expect(recent).toHaveCount(0); + const id = await projects.getAttribute("id"); + while ((await input.getAttribute("aria-activedescendant")) !== id) + await input.press("ArrowDown"); + const before = await projects.boundingBox(); + holding = false; + for (const resolve of held.splice(0)) resolve(); + await expect(recent.first()).toBeVisible(); + // The arrivals moved the action; the selection stays with it. + expect((await projects.boundingBox()).y).toBeGreaterThan(before.y); + await expect(input).toHaveAttribute("aria-activedescendant", id); + await input.press("Enter"); + await expect(dialog).toHaveCount(0); + await expect( + page.getByRole("heading", { name: "Projects", exact: true }), + ).toBeVisible(); + } finally { + holding = false; + for (const resolve of held.splice(0)) resolve(); + } +}); diff --git a/tests/browser/navigation-repairs.spec.mjs b/tests/browser/navigation-repairs.spec.mjs index c61dd89b1..39c68f527 100644 --- a/tests/browser/navigation-repairs.spec.mjs +++ b/tests/browser/navigation-repairs.spec.mjs @@ -48,7 +48,7 @@ test("Retry navigation reconnects the failed target session", async ({ return route.continue(); }); await page.goto(app.origin); - await openPage(page, "Messages"); + await openPage(page, "Messages", { connected: false }); await expect( page.getByRole("heading", { name: "This destination couldn’t open" }), ).toBeVisible(); diff --git a/tests/browser/navigation.mjs b/tests/browser/navigation.mjs index 14ca9322f..752d65cde 100644 --- a/tests/browser/navigation.mjs +++ b/tests/browser/navigation.mjs @@ -7,31 +7,39 @@ export async function pageChoices(page) { .getByRole("group", { name: "Actions", exact: true }); } -export async function openPage(page, name) { +export async function openPage(page, name, { connected = true } = {}) { // The community rail appears after local startup has replaced the launch view. await expect( page.getByRole("button", { name: "Switch to Primary", exact: true }), ).toBeVisible(); const choices = await pageChoices(page); const dialog = page.getByRole("dialog", { name: "Search Buzz", exact: true }); + const input = dialog.getByRole("combobox", { name: "Search Buzz" }); const choice = choices.getByRole("option", { name, exact: true }); // Registered page actions are visible only after the plugin catalog is ready. await expect(choice).toBeVisible(); - // The list enters with a delayed transform. Its moving hit targets can miss - // a click in WebKit even after the option first becomes visible. - await expect - .poll( - () => - dialog - .locator(".search-palette-scroll") - .evaluate((element) => - element - .getAnimations() - .every((animation) => animation.playState === "finished"), - ), - { message: "search choices finish opening" }, - ) - .toBe(true); - await choice.click(); + // Until the community connects, the palette is a placeholder. The connected + // palette replaces it, which resets selection and option ids, so select only + // in the final palette. Pass `connected: false` when the test holds or fails + // the relay session: the placeholder then stays. + await expect( + dialog.getByText("Connecting to this community…", { exact: true }), + ).toHaveCount(connected ? 0 : 1); + // Conversation groups above Actions arrive with the channel list and move + // every row below them, so a pointer click can land between rows. Keyboard + // selection follows the choice itself, not its position. + const id = await choice.getAttribute("id"); + const limit = await dialog.getByRole("option").count(); + for ( + let step = 0; + (await input.getAttribute("aria-activedescendant")) !== id; + step++ + ) { + // Rows that arrive later are inserted above the selection, so moving down + // still reaches the choice. The bound only turns a regression into a failure. + expect(step, `keyboard reaches ${name}`).toBeLessThan(limit + 16); + await input.press("ArrowDown"); + } + await input.press("Enter"); await expect(dialog).not.toBeVisible(); } diff --git a/tests/browser/plugins.spec.mjs b/tests/browser/plugins.spec.mjs index b143e67b9..1b494498f 100644 --- a/tests/browser/plugins.spec.mjs +++ b/tests/browser/plugins.spec.mjs @@ -129,7 +129,7 @@ test("companion remains usable while Channels connection is pending", async ({ }); try { await page.goto(app.origin); - await openPage(page, "Messages"); + await openPage(page, "Messages", { connected: false }); await expect( page.getByText("Connecting to your relay…", { exact: true }), ).toBeVisible();