* Stop two web E2E tests from racing on main Replies read themselves aloud by default since the voice update, so in the group chat test the last bot's automatic read-aloud could beat the manual Speak click. The test turns automatic reading off for its two bots. Opening the browser panel on a running computer now only fetches the screen, as it did before the panel change, instead of also reloading the whole thread on every computer state change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 * Wait for the speaking switch to settle in the voice test The app turns the local voice on by itself on first start and keeps the switch disabled meanwhile. The helper read "off" during that window and clicked a disabled switch; it now retries until the switch is on, clicking only when it is enabled and off. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 * Fix main after the git-clone push: lint, i18n and abort tests - Lint: formatting in two files and no assignment inside an expression in the steering poller. - i18n: German for the new "Resume" and "Stopped" labels on web, and de/ru/zh on mobile; catalogs re-extracted. - Tests follow the intended change that a caller abort ends a turn as "stopped" instead of failing it as a provider error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 * Record runtime failures again instead of leaving the run running A runtime error closes the stream through withRuntimeCleanup, which aborts the run's own controller. The catch treated that as an outside stop and returned without finalizing, so a failed run stayed running forever and its delegated task never reported back. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 --------- Co-authored-by: Claude <noreply@anthropic.com>
341 lines
14 KiB
TypeScript
341 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");
|
|
// Replies read themselves aloud by default; keep that off so only the Speak click below talks.
|
|
for (const botId of [researcherId, writerId]) {
|
|
await rpc(page, "bots/update", { botId, autoSpeak: false });
|
|
}
|
|
|
|
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);
|
|
});
|