evOo-local/apps/web/e2e/group-chats.spec.ts
cynarAI 263e3a31f8
Fix red main: mobile group kind, i18n catalogs, project picker check (#218)
* Fix red main: mobile group kind, i18n catalogs, project picker check

Main CI failed on typecheck, unit tests and web E2E after the last two
direct pushes. MobileGroup now carries the optional chat kind the mobile
screens read, the Lingui catalogs are re-extracted with German strings
for the new voice texts, the project overview plus icon meets the UI
scale guard, and the sidebar E2E expects the renamed project lead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8L4XgqjAemFR2remB5hVY

* Format files merged from main

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

* Align E2E with the canvas close button and per-chat drafts

The in-app browser now closes from the side panel header, and each chat
keeps its own unsent draft. The group chat test waits for the settings
save to land before switching chats, so the switch no longer races the
panel closing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-20 09:34:40 +02:00

337 lines
14 KiB
TypeScript

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<void>((resolve) => {
releaseReviewSnapshot = resolve;
});
const reviewSnapshotIntercepted = new Promise<void>((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);
});