import { chatCaret, chatInput, expectChatValue } from "./chat-input"; import { expectInsideViewport, expectNoHorizontalOverflow } from "./responsive"; import { expect, test } from "./room"; import type { Chat } from "../packages/protocol/index"; import type { Page } from "@playwright/test"; function chatPane(page: Page) { return page.getByRole("complementary", { includeHidden: true, name: "Chat" }); } async function enablePlanner(page: Page): Promise { await page.route("**/api/session", async route => { let response = await route.fetch(); let session = await response.json() as Record; await route.fulfill({ response, json: { ...session, agent: true } }); }); } /** github.com is not reachable from the harness; answer with a flat tile, and fail one login. */ async function stubAvatars(page: Page): Promise { await page.route("https://github.com/*.png*", route => { let login = new URL(route.request().url()).pathname.slice(1, -".png".length); if (login === "bo") return route.abort(); return route.fulfill({ contentType: "image/svg+xml", body: ``, }); }); } function names(page: Page) { return chatPane(page).getByRole("listbox", { name: "Mentions" }).getByRole("option") .evaluateAll(options => options.map(option => option.getAttribute("aria-label"))); } test("the @ picker lists the Planner, people here and past authors, and sends only @chopin to the Planner", async ({ join, page }) => { let sent: Chat.Send[] = []; await enablePlanner(page); await stubAvatars(page); await page.routeWebSocket("**/ws?**", route => { let server = route.connectToServer(); route.onMessage(message => { if (typeof message === "string") { try { let frame = JSON.parse(message) as Chat.Send; if (frame.kind === "chat:send") sent.push(frame); } catch { // The server still owns malformed and non-chat frames. } } server.send(message); }); server.onMessage(message => route.send(message)); }); let ana = await join("ana"); // `bo` speaks and leaves, so appears only as a Chat author. let leaving = await join("bo"); let bo = chatInput(chatPane(leaving)); await bo.fill("Morning, both."); await chatPane(leaving).getByRole("button", { name: "Send message" }).click(); await expect(chatPane(ana).getByText("Morning, both.")).toBeVisible(); await leaving.context().close(); await join("cy"); await expect(ana.getByRole("group", { name: /People here: ana, cy$/ })).toBeVisible(); let chat = chatPane(ana); let draft = chatInput(chat); let list = chat.getByRole("listbox", { name: "Mentions" }); await expect(draft).toHaveAttribute("role", "combobox"); await draft.fill("Ask @"); await expect(list).toBeVisible(); await expect(draft).toHaveAttribute("aria-expanded", "true"); expect(await names(ana)).toEqual(["chopin", "cy", "bo"]); await expect(list.getByRole("option", { name: "cy" }).locator("img")).toHaveAttribute( "src", "https://github.com/cy.png?size=40", ); // The failed photograph falls back to the login's initial. await expect(list.getByRole("option", { name: "bo" }).locator("img")).toHaveCount(0); await expect(list.getByRole("option", { name: "bo" })).toHaveText("bbo"); await expect(list.getByRole("option", { name: "chopin" })).toHaveAttribute( "aria-selected", "true", ); await draft.press("ArrowDown"); await expect(list.getByRole("option", { name: "cy" })).toHaveAttribute("aria-selected", "true"); await expect(draft).toHaveAttribute( "aria-activedescendant", (await list.getByRole("option", { name: "cy" }).getAttribute("id"))!, ); await draft.press("ArrowUp"); await draft.press("ArrowUp"); await expect(list.getByRole("option", { name: "bo" })).toHaveAttribute("aria-selected", "true"); await draft.pressSequentially("C"); expect(await names(ana)).toEqual(["chopin", "cy"]); await draft.press("Backspace"); await draft.pressSequentially("ch"); expect(await names(ana)).toEqual(["chopin"]); await draft.press("Enter"); await expectChatValue(draft, "Ask @chopin "); await expect(list).toHaveCount(0); await expect(draft).toBeFocused(); await draft.pressSequentially("what is open?"); await chat.getByRole("button", { name: "Send message" }).click(); await expect.poll(() => sent.at(-1)?.text).toBe("Ask @chopin what is open?"); expect(sent.at(-1)?.to).toBe("planner"); await expectChatValue(draft, ""); await draft.press("Shift+Tab"); await draft.fill("Thanks @cy"); await draft.press("Enter"); await expectChatValue(draft, "Thanks @cy "); expect(await chatCaret(draft)).toBe("Thanks @cy ".length); await draft.pressSequentially("for looking."); await chat.getByRole("button", { name: "Send message" }).click(); await expect.poll(() => sent.at(-1)?.text).toBe("Thanks @cy for looking."); expect(sent.at(-1)?.to).toBe("room"); await expectChatValue(draft, ""); await draft.fill("Mail a@b"); await expect(list).toHaveCount(0); await draft.fill("@c"); await expect(list).toBeVisible(); await draft.press("Tab"); await draft.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())) ) ); await expect(draft).not.toBeFocused(); await expectChatValue(draft, "@c"); await draft.focus(); await draft.press("Escape"); await expect(list).toHaveCount(0); await expectChatValue(draft, "@c"); await draft.fill("@zzz"); await expect(list).toHaveCount(0); await draft.press("Enter"); await expect.poll(() => sent.at(-1)?.text).toBe("@zzz"); }); test("the @ picker stays inside a narrow Chat panel", async ({ join, page }) => { await enablePlanner(page); await stubAvatars(page); await page.setViewportSize({ width: 390, height: 520 }); let opened = await join("ana"); await opened.getByRole("navigation", { name: "Workspace view" }) .getByRole("button", { name: /Chat/ }).click(); let chat = chatPane(opened); let draft = chatInput(chat); await draft.fill("@"); let list = chat.locator("[data-chat-mention-picker]"); await expect(list).toBeVisible(); expect(await names(opened)).toEqual(["chopin"]); await expectInsideViewport(list); await expectNoHorizontalOverflow(opened); let panel = await chat.boundingBox(); let box = await list.boundingBox(); expect(box!.x).toBeGreaterThanOrEqual(panel!.x); expect(box!.x + box!.width).toBeLessThanOrEqual(panel!.x + panel!.width); }); test("the @ picker closes on Escape or an outside press and keeps the draft", async ({ join, page }) => { await enablePlanner(page); await stubAvatars(page); let ana = await join("ana"); await join("cy"); let chat = chatPane(ana); let draft = chatInput(chat); let list = chat.getByRole("listbox", { name: "Mentions" }); await draft.fill("Ask @"); await expect(list).toBeVisible(); await draft.press("Escape"); await expect(list).toHaveCount(0); await expectChatValue(draft, "Ask @"); await expect(draft).toBeFocused(); await draft.fill("Ask @c"); await expect(list).toBeVisible(); await ana.mouse.click(5, 5); await expect(list).toHaveCount(0); await expectChatValue(draft, "Ask @c"); }); test("the @ picker closes when focus leaves the composer", async ({ join, page }) => { await enablePlanner(page); await stubAvatars(page); let ana = await join("ana"); await join("cy"); let chat = chatPane(ana); let draft = chatInput(chat); let list = chat.getByRole("listbox", { name: "Mentions" }); await draft.fill("Ask @c"); await expect(list).toBeVisible(); await chat.getByRole("button", { name: "Send message" }).focus(); await expect(list).toHaveCount(0); await expectChatValue(draft, "Ask @c"); }); test("without a Planner a manually addressed message gets a local notice", async ({ join }) => { let page = await join("ana"); let chat = chatPane(page); let input = chatInput(chat); await expect(chat.getByText("Chopin unavailable", { exact: true })).toBeVisible(); await input.fill("@"); await expect(chat.getByRole("listbox", { name: "Mentions" })).toHaveCount(0); await input.fill("@chopin are you there?"); await chat.getByRole("button", { name: "Send message" }).click(); // The transcript drops the leading mention from what it shows, so find the // sent message by what was sent rather than by the draft still on screen. await expectChatValue(input, ""); await expect(chat.locator('[data-chat-raw="@chopin are you there?"]')).toBeVisible(); let notice = chat.getByText("Chopin is off on this server. Your message went to the room only.", { exact: true, }); await expect(notice).toBeVisible(); await page.reload(); await expect(chatInput(chat)).toBeVisible(); await expect(notice).toHaveCount(0); });