* fix(e2e): restore computer-update progress chip after reload Harden the computer maintenance Playwright flow so mocked update state survives a full page reload: stable background-chip test id, explicit aria-label, wait for shell readiness and the updates RPC, and explicit JSON route fulfillment. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): bump Expo SDK 57 patches and harden flaky web e2e Sync mobile Expo packages for frozen-lockfile typecheck and read RPC fetch metadata before response bodies in Playwright route helpers. Stabilize empty-project Starten, onboarding model catalog, and secret-card reload specs. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(e2e): stabilize RPC intercepts and project Starten flow Fulfill patched RPC routes with string bodies, ignore benign route races, target project Starten by test id, and wait for thread hydration after reload. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(e2e): drop threads/get wait after secret-card reload Reload does not always refetch threads/get; poll the saved ask card instead. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(e2e): reserve unreachable setup port per desktop smoke run Avoid CI races where a port reserved in beforeAll is rebound before the unreachable-address check runs. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * ci: retry product checks after runner OOM on web e2e Previous Web E2E job exited 137 during the Playwright suite; no failing spec was reported. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(e2e): wait for shell before opening user settings After reload, the user menu is not interactive until bootstrap finishes. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
328 lines
14 KiB
TypeScript
328 lines
14 KiB
TypeScript
import { expect, type Page, type Route, test } from "@playwright/test";
|
|
import {
|
|
captureScreenshot,
|
|
completeOnboarding,
|
|
createNamedBot,
|
|
fetchRpcEnvelope,
|
|
fulfillFetchedRpc,
|
|
openNewGroup,
|
|
rpc,
|
|
signup,
|
|
} 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();
|
|
await expect(page).toHaveURL(groupUrl);
|
|
await expect(page.getByRole("combobox", { name: "Message" })).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/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();
|
|
|
|
await page.getByRole("combobox", { name: "Message" }).fill("@Researcher unfinished draft");
|
|
await projekte
|
|
.getByRole("button", { name: /^Review team/ })
|
|
.first()
|
|
.click();
|
|
await expect(page.getByRole("combobox", { name: "Message" })).toHaveValue("");
|
|
await projekte
|
|
.getByRole("button", { name: /^Draft team/ })
|
|
.first()
|
|
.click();
|
|
await expect(page.getByRole("combobox", { name: "Message" })).toHaveValue("");
|
|
|
|
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 expect(page.getByRole("combobox", { name: "Message" })).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 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 expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
|
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();
|
|
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();
|
|
});
|