evOo-local/apps/web/e2e/group-chats.spec.ts
Cursor Agent ae0b8c256d Rename sidebar Team section to Conversations (Unterhaltungen)
Conversation threads (1:1 and group) live under Unterhaltungen with the
group-start action. Projekte keeps thematic project creation only; thread
lists no longer split across Team and Projekte.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-09-21 17:15:44 +00:00

279 lines
12 KiB
TypeScript

import { expect, type Page, test } from "@playwright/test";
import {
captureScreenshot,
completeOnboarding,
createNamedBot,
openNewGroup,
rpc,
signup,
} from "./helpers";
async function createBot(page: Page, name: string) {
return createNamedBot(page, name);
}
test("start group conversation lives under Conversations, not Projekte", async ({ page }) => {
const stamp = Date.now();
await signup(page, `group-ia-${stamp}@rakazo.test`, "password12", "Group IA");
await completeOnboarding(page);
await page.goto("/app");
await page.waitForURL(/\/app\/[^/]+$/);
const conversations = page.locator(".sidebar-conversations");
await expect(conversations.getByText("Conversations", { exact: true })).toBeVisible();
const startGroup = conversations.getByTestId("start-group-conversation");
await expect(startGroup).toBeVisible();
await expect(page.locator(".sidebar-projekte").getByTestId("start-group-conversation")).toHaveCount(
0,
);
await expect(page.locator(".sidebar-projekte")).not.toContainText("Start a group conversation");
});
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();
await expect(page).toHaveURL(groupUrl);
await expect(page.getByRole("combobox", { name: "Message Draft team" })).toBeVisible();
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/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/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 sidebar = page.locator("aside").first();
await sidebar.getByRole("button", { name: /^Review team/ }).click();
await expect(groupName).toHaveValue("Review team");
await sidebar.getByRole("button", { name: /^Draft team/ }).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();
await page
.getByRole("combobox", { name: "Message Draft team" })
.fill("@Researcher unfinished draft");
await sidebar.getByRole("button", { name: /^Review team/ }).click();
await expect(page.getByRole("combobox", { name: "Message Review team" })).toHaveValue("");
await sidebar.getByRole("button", { name: /^Draft team/ }).click();
await expect(page.getByRole("combobox", { name: "Message Draft team" })).toHaveValue("");
const composer = page.getByRole("combobox", { name: "Message Draft team" });
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("div")
.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 expect(page.getByRole("combobox", { name: "Message Draft team" })).toBeVisible({
timeout: 15_000,
});
}
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 firstMessage = transcript.locator("[data-message-id]").first();
await firstMessage.hover();
const replyButton = firstMessage.getByRole("button", { name: "Reply" });
await expect(replyButton).toBeVisible();
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 sidebar.getByRole("button", { name: /^Review team/ }).click();
await reviewSnapshotIntercepted;
await expect(page).toHaveURL(new RegExp(`/app/g/${reviewGroup.id}$`));
await expect(page.getByTestId("transcript")).not.toContainText("Answered: Paris");
releaseReviewSnapshot();
await expect(page.getByRole("combobox", { name: "Message Review team" })).toBeVisible();
await page.unroute("**/rpc/threads/get");
await sidebar.getByRole("button", { name: /^Draft team/ }).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();
const markdownDialog = page.getByRole("dialog", { name: "group-preview.md" });
await expect(markdownDialog.getByRole("heading", { name: "Group artifact" })).toBeVisible();
await markdownDialog.getByRole("button", { name: "Close preview" }).click();
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}`);
await page.waitForURL(/\/app\/(?!g\/)[^/]+$/);
await expect(page.getByRole("combobox", { name: /Message/ })).toBeVisible();
});