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();