import { expect, type Page, type Route, test } from "@playwright/test"; import { captureScreenshot, completeOnboarding, createNamedBot, fetchRpcEnvelope, fulfillFetchedRpc, openNewGroup, rpc, signup, waitForChatComposer, } from "./helpers"; async function createBot(page: Page, name: string) { return createNamedBot(page, name); } function patchGroupMembers(groups: unknown) { if (!Array.isArray(groups)) return groups; return groups.map((group) => { if (!group || typeof group !== "object" || !("members" in group)) return group; const members = (group as { members?: unknown }).members; if (!Array.isArray(members)) return group; return { ...group, members: members.map((member, index) => { if (!member || typeof member !== "object") return member; const row = member as { status?: string }; return { ...row, status: index === 0 ? "running" : row.status }; }), }; }); } /** Sidebar avatars read bootstrap and spaces/list, not groups/list. */ async function showFirstMemberWorking(route: Route) { const fetched = await fetchRpcEnvelope(route); const payload = fetched.body.json; if (!payload) { await fulfillFetchedRpc(route, fetched); return; } if ("groups" in payload) payload.groups = patchGroupMembers(payload.groups); if (Array.isArray(payload.spaces)) { payload.spaces = payload.spaces.map((space) => { if (!space || typeof space !== "object") return space; return { ...space, groups: patchGroupMembers((space as { groups?: unknown }).groups) }; }); } if (payload.current && typeof payload.current === "object") { const current = payload.current as { groups?: unknown }; current.groups = patchGroupMembers(current.groups); } await fulfillFetchedRpc(route, fetched); } test("create group from + and see two bots in one transcript", async ({ page }, testInfo) => { const stamp = Date.now(); await signup(page, `group-${stamp}@rakazo.test`, "password12", "Group E2E"); await completeOnboarding(page); await page.goto("/app"); await page.waitForURL(/\/app\/[^/]+$/); const researcherId = await createBot(page, "Researcher"); const writerId = await createBot(page, "Research Writer"); await openNewGroup(page); await page.locator("label:has-text('Name') input").fill("Draft team"); const panel = page.getByTestId("side-panel"); await panel.getByRole("button", { name: "Researcher" }).click(); await panel.getByRole("button", { name: "Research Writer" }).click(); await captureScreenshot(page, testInfo, "group-creation"); await page.route("**/rpc/groups/create", async (route) => route.abort("failed")); await page.getByRole("button", { name: "Create group", exact: true }).click(); await expect(panel.getByRole("alert")).toHaveText("Failed to fetch"); await expect(page.getByRole("button", { name: "Create group", exact: true })).toBeEnabled(); await page.unroute("**/rpc/groups/create"); await page.getByRole("button", { name: "Create group", exact: true }).click(); await page.waitForURL(/\/app\/g\/[^/]+$/); const groupUrl = page.url(); const draftGroupId = new URL(groupUrl).pathname.split("/").at(-1)!; const reviewGroup = await rpc<{ id: string }>(page, "groups/create", { name: "Review team", botIds: [researcherId, writerId], }); await rpc(page, "voice/connect", { provider: "scripted", apiKey: "fake-group-voice-key", }); await page.reload({ waitUntil: "domcontentloaded" }); await expect(page).toHaveURL(groupUrl); await waitForChatComposer(page); const groups = await rpc< Array<{ id: string; members: Array<{ botId: string; name: string; color: string; status?: string }>; }> >(page, "groups/list", {}); const groupSnapshot = await rpc<{ members?: Array<{ botId: string; name: string; color: string; status?: string }>; }>(page, "threads/get", { groupId: draftGroupId }); await page.route("**/rpc/bootstrap", showFirstMemberWorking); await page.route("**/rpc/spaces/list", showFirstMemberWorking); await page.route("**/rpc/groups/list", async (route) => { await route.fulfill({ contentType: "application/json", body: JSON.stringify({ json: groups.map((group) => ({ ...group, members: group.members.map((member, index) => ({ ...member, status: index === 0 ? "running" : "idle", })), })), }), }); }); await page.route("**/rpc/threads/get", async (route) => { await route.fulfill({ contentType: "application/json", body: JSON.stringify({ json: { ...groupSnapshot, members: groupSnapshot.members?.map((member, index) => ({ ...member, status: index === 0 ? "running" : "idle", })), }, }), }); }); await page.reload(); // Anchor ^ so Now/Recent activity rows ("Bot · Draft team, …") do not match. const groupAvatar = page .locator("aside") .first() .getByRole("button", { name: /^Draft team/ }) .locator(".rakazo-group-avatar"); await expect(groupAvatar).toBeVisible(); await expect(groupAvatar.locator(".rakazo-bot-avatar")).toHaveCount(2); const workingAvatar = groupAvatar.locator('[data-working="true"]'); await expect(workingAvatar).toHaveCount(1); await expect(workingAvatar.locator(".rakazo-bot-avatar-ring")).toHaveCSS( "animation-name", "rakazo-avatar-spin", ); await captureScreenshot(page, testInfo, "group-avatar-active"); await page.unroute("**/rpc/bootstrap", showFirstMemberWorking); await page.unroute("**/rpc/spaces/list", showFirstMemberWorking); await page.unroute("**/rpc/groups/list"); await page.unroute("**/rpc/threads/get"); await page.reload(); await page.getByTestId("bot-settings-trigger").click(); const desktopSettings = page.getByTestId("side-panel"); const groupName = desktopSettings.locator("label:has-text('Name') input"); await groupName.fill("Unsaved Draft team name"); const projekte = page.locator(".sidebar-projekte"); await page.goto(`/app/g/${reviewGroup.id}`); await page.getByTestId("bot-settings-trigger").click(); await expect(groupName).toHaveValue("Review team"); await page.goto(`/app/g/${draftGroupId}`); await page.getByTestId("bot-settings-trigger").click(); await expect(groupName).toHaveValue("Draft team"); await page.route("**/rpc/groups/update", async (route) => route.abort("failed")); await desktopSettings.getByRole("button", { name: "Save", exact: true }).click(); await expect(desktopSettings.getByRole("alert")).toHaveText("Failed to fetch"); await expect(desktopSettings.getByRole("button", { name: "Save", exact: true })).toBeEnabled(); await page.unroute("**/rpc/groups/update"); await desktopSettings.getByRole("button", { name: "Save", exact: true }).click(); // The panel closes once the save lands; switching chats before that races the layout change. await expect(desktopSettings).toHaveAttribute("data-panel", "closed"); await page.getByRole("combobox", { name: "Message" }).fill("@Researcher unfinished draft"); await projekte .getByRole("button", { name: /^Review team/ }) .first() .click(); await expect(page).toHaveURL(new RegExp(`/app/g/${reviewGroup.id}`)); await expect(page.getByRole("combobox", { name: "Message" })).toHaveValue(""); // Each chat keeps its own unsent draft. await projekte .getByRole("button", { name: /^Draft team/ }) .first() .click(); await expect(page).toHaveURL(new RegExp(`/app/g/${draftGroupId}`)); await expect(page.getByRole("combobox", { name: "Message" })).toHaveValue( "@Researcher unfinished draft", ); await page.getByRole("combobox", { name: "Message" }).fill(""); const composer = page.getByRole("combobox", { name: "Message" }); await composer.fill("@Res"); await captureScreenshot(page, testInfo, "group-mention-picker"); await page.getByRole("option", { name: "@Research Writer", exact: true }).click(); await expect( page.getByTestId("mention-chip").filter({ hasText: "Research Writer" }), ).toBeVisible(); await composer.fill("turn the sources into a draft. @Res"); await page.getByRole("option", { name: "@Researcher", exact: true }).click(); await expect(page.getByTestId("mention-chip").filter({ hasText: "Researcher" })).toBeVisible(); await composer.fill(`${await composer.inputValue()}gather sources.`); await composer.press("Enter"); await expect(page.getByTestId("transcript")).toContainText(/handled|on it|gather/i, { timeout: 60_000, }); const transcript = page.getByTestId("transcript"); await expect(transcript.getByText("Researcher", { exact: true }).first()).toBeVisible(); await expect(transcript.getByText("Research Writer", { exact: true }).first()).toBeVisible(); const researcherSpeak = transcript .locator("[data-message-id]") .filter({ has: page.getByText("Researcher", { exact: true }) }) .filter({ has: page.getByRole("button", { name: "Speak this reply" }) }) .first() .getByRole("button", { name: "Speak this reply" }); const [speechRequest] = await Promise.all([ page.waitForRequest( (request) => request.url().includes("/api/voice/speak") && request.method() === "POST", ), researcherSpeak.click(), ]); expect(speechRequest.postDataJSON()).toMatchObject({ botId: researcherId }); await captureScreenshot(page, testInfo, "group-transcript"); await composer.fill("@Res"); await page.getByRole("option", { name: "@Research Writer", exact: true }).click(); await expect( page.getByTestId("mention-chip").filter({ hasText: "Research Writer" }), ).toBeVisible(); await composer.fill("ask me which city to use"); await composer.press("Enter"); // threads/get can observe waiting_input before the shell realtime feed paints the ask card. await expect .poll( async () => { const snapshot = await rpc<{ run?: { status: string } | null }>(page, "threads/get", { groupId: draftGroupId, }); return snapshot.run?.status ?? null; }, { timeout: 60_000 }, ) .toBe("waiting_input"); const cityAsk = page.locator("p").filter({ hasText: /^Which city should I use\?$/ }); if ((await cityAsk.count()) === 0) { await page.reload({ waitUntil: "domcontentloaded" }); await waitForChatComposer(page); } await expect(cityAsk).toBeVisible({ timeout: 15_000 }); await page.getByRole("button", { name: "Edit first" }).click(); await page.getByRole("textbox", { name: "Answer" }).fill("Paris"); await page.getByRole("button", { name: "Send answer" }).click(); await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 }); const replyRow = transcript .locator("[data-message-id]") .filter({ has: page.getByRole("button", { name: "Speak this reply" }) }) .first(); const replyRail = replyRow.getByTestId("message-hover-rail"); await expect .poll(async () => { await replyRow.hover(); return replyRail.evaluate((element) => getComputedStyle(element).pointerEvents); }) .toBe("auto"); const replyButton = replyRail.getByRole("button", { name: "Reply" }); await replyButton.focus(); await replyButton.click(); await expect(page.getByTestId("reply-chip")).toContainText(/Replying to/); await page.getByRole("button", { name: "Cancel reply" }).click(); let releaseReviewSnapshot!: () => void; let sawReviewSnapshot!: () => void; const reviewSnapshotReleased = new Promise((resolve) => { releaseReviewSnapshot = resolve; }); const reviewSnapshotIntercepted = new Promise((resolve) => { sawReviewSnapshot = resolve; }); await page.route("**/rpc/threads/get", async (route) => { if (route.request().postData()?.includes(reviewGroup.id) !== true) { await route.continue(); return; } sawReviewSnapshot(); await reviewSnapshotReleased; await route.continue(); }); await projekte .getByRole("button", { name: /^Review team/ }) .first() .click(); await reviewSnapshotIntercepted; await expect(page).toHaveURL(new RegExp(`/app/g/${reviewGroup.id}$`)); await expect(page.getByTestId("transcript")).not.toContainText("Answered: Paris"); releaseReviewSnapshot(); await waitForChatComposer(page); await page.unroute("**/rpc/threads/get"); await projekte .getByRole("button", { name: /^Draft team/ }) .first() .click(); await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible(); await composer.fill( "@Researcher write path notes/group-preview.md and attach it to the thread says # Group artifact", ); await composer.press("Enter"); const groupMarkdown = page.getByRole("button", { name: "Preview group-preview.md" }); await expect(groupMarkdown).toBeVisible({ timeout: 30_000 }); await groupMarkdown.click(); // Wide windows open the file beside the group conversation. const markdownPane = page.getByTestId("artifact-pane"); await expect(markdownPane.getByRole("heading", { name: "Group artifact" })).toBeVisible(); await markdownPane.getByRole("button", { name: "Close panel" }).click(); await expect(markdownPane).toHaveCount(0); await page.setViewportSize({ width: 390, height: 844 }); await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible(); expect((await transcript.boundingBox())?.width).toBeGreaterThan(350); await page.getByRole("button", { name: "Open navigation" }).click(); await expect(page.getByRole("button", { name: "Close navigation" })).toBeVisible(); await page.getByRole("button", { name: "Close navigation" }).click(); await page.getByTestId("bot-settings-trigger").click(); const settings = page.getByTestId("side-panel"); await expect(settings).toHaveAttribute("data-panel", "group-settings"); expect((await settings.boundingBox())?.width).toBeLessThanOrEqual(390); await captureScreenshot(page, testInfo, "group-settings-mobile"); await rpc(page, "groups/remove", { groupId: reviewGroup.id }); await page.goto(`/app/g/${reviewGroup.id}`, { waitUntil: "domcontentloaded" }); await page.waitForURL(/\/app\/(?!g\/)[^/]+$/); await waitForChatComposer(page); });