evOo-local/apps/web/e2e/inbox.spec.ts
Claude e13dbe5ffa
feat(inbox): list and answer items from all of the user's spaces
The inbox in the main conversation only showed asks, approvals, takeovers and
unread threads of the current space. The main conversation lives in the home
space, so anything a bot needed in a project space never reached it.

- inbox.list now covers every space the user belongs to (except spaces being
  deleted), still only the user's own bots, runs and threads. Items from
  another space carry that space's id and name.
- The web inbox shows the space name next to the bot for those items, answers
  them with that space's header so the existing thread procedures check
  membership and ownership for that space, and opens their thread in it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:27:39 +00:00

155 lines
6.6 KiB
TypeScript

import { expect, type Page, test } from "@playwright/test";
import type { Group } from "@rakazo/contracts";
import { captureScreenshot, routePatchRpc, rpc, signup, waitForChatComposer } from "./helpers";
test("answers a specialist's question from the main conversation", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `inbox-${stamp}@rakazo.test`, "password12", "Inbox");
// Bots come from RPC: this spec is about the inbox, not the onboarding flow.
await page.waitForURL(/\/(onboarding|app)/, { timeout: 30_000 });
const researcher = await createBot(page, "Researcher");
await rpc(page, "threads/send", { botId: researcher.id, text: "ask me which city to use" });
await expect
.poll(async () => (await activeRun(page, researcher.id))?.status ?? null, { timeout: 60_000 })
.toBe("waiting_input");
await openMainConversation(page, researcher.id);
const inbox = page.getByRole("region", { name: "Needs you" });
const ask = inbox.locator("[data-kind='ask']");
await expect(ask).toBeVisible({ timeout: 30_000 });
await expect(ask).toContainText("Researcher");
await expect(ask).toContainText("Which city should I use?");
await expect(page.getByRole("combobox", { name: "Message" })).toBeInViewport();
await captureScreenshot(page, testInfo, "inbox-ask");
await ask.getByRole("button", { name: "Edit first" }).click();
await ask.getByRole("textbox", { name: "Answer" }).fill("Paris");
await ask.getByRole("button", { name: "Send answer" }).click();
await expect(ask).toHaveCount(0);
await expect.poll(async () => activeRun(page, researcher.id), { timeout: 60_000 }).toBeNull();
// The answer resumed the specialist in its own thread; its reply arrives as unread.
const reply = inbox.locator("[data-kind='unread']").filter({ hasText: "Researcher" });
await expect(reply).toContainText("Paris", { timeout: 30_000 });
await captureScreenshot(page, testInfo, "inbox-unread-reply");
await reply.getByRole("button").click();
await page.waitForURL(new RegExp(`/app/${researcher.id}$`));
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
});
test("answers a question from another space in the main conversation", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `inbox-cross-${stamp}@rakazo.test`, "password12", "Inbox Cross");
await page.waitForURL(/\/(onboarding|app)/, { timeout: 30_000 });
const helper = await createBot(page, "Helper");
const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" });
const scout = await rpcInSpace<{ id: string }>(page, side.id, "bots/create", {
name: "Scout",
title: "",
description: "",
notifyOnFinish: true,
computerMode: "team",
});
await rpcInSpace(page, side.id, "threads/send", {
botId: scout.id,
text: "ask me which city to use",
});
const runInSide = async () =>
(
await rpcInSpace<{ run?: { status: string } | null }>(page, side.id, "threads/get", {
botId: scout.id,
})
).run ?? null;
await expect
.poll(async () => (await runInSide())?.status ?? null, { timeout: 60_000 })
.toBe("waiting_input");
await openMainConversation(page, helper.id);
const inbox = page.getByRole("region", { name: "Needs you" });
const ask = inbox.locator("[data-kind='ask']");
await expect(ask).toContainText("Scout", { timeout: 30_000 });
await expect(ask.getByTestId("main-inbox-space")).toHaveText("· Side project");
await captureScreenshot(page, testInfo, "inbox-cross-space");
await ask.getByRole("button", { name: "Edit first" }).click();
await ask.getByRole("textbox", { name: "Answer" }).fill("Paris");
await ask.getByRole("button", { name: "Send answer" }).click();
await expect(ask).toHaveCount(0);
await expect.poll(runInSide, { timeout: 60_000 }).toBeNull();
// The reply arrives as unread from the other space and opens there.
const reply = inbox.locator("[data-kind='unread']").filter({ hasText: "Scout" });
await expect(reply).toContainText("Paris", { timeout: 45_000 });
await expect(reply.getByTestId("main-inbox-space")).toHaveText("· Side project");
await reply.getByRole("button").click();
await page.waitForURL(new RegExp(`/app/${scout.id}$`));
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
expect(await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))).toBe(side.id);
});
async function rpcInSpace<T = unknown>(
page: Page,
spaceId: string,
procedure: string,
body: unknown,
): Promise<T> {
const response = await page.request.post(`/rpc/${procedure}`, {
data: { json: body },
headers: { "x-rakazo-space-id": spaceId },
});
const parsed = (await response.json()) as { json?: T; error?: { message?: string } };
if (!response.ok() || parsed.error) {
throw new Error(`${procedure} ${response.status()}: ${parsed.error?.message ?? "failed"}`);
}
return parsed.json as T;
}
async function createBot(page: Page, name: string) {
return rpc<{ id: string }>(page, "bots/create", {
name,
title: "",
description: "",
notifyOnFinish: true,
computerMode: "team",
});
}
async function activeRun(page: Page, botId: string) {
const snapshot = await rpc<{ run?: { status: string } | null }>(page, "threads/get", { botId });
return snapshot.run ?? null;
}
/**
* Opens the user's main conversation. Until the app sets one up, a group with the specialist
* stands in for it and is reported to the client as the main conversation.
*/
async function openMainConversation(page: Page, specialistId: string) {
// The shell's first load is where the app sets up the main conversation.
await rpc(page, "bootstrap", {});
const groups = await rpc<Group[]>(page, "groups/list", {});
let main = groups.find((group) => group.kind === "main");
if (!main) {
const lead = await createBot(page, "Lead");
const stand = await rpc<Group>(page, "groups/create", {
name: "Main",
botIds: [lead.id, specialistId],
});
const asMain = (list: unknown) =>
Array.isArray(list)
? list.map((group) => (group?.id === stand.id ? { ...group, kind: "main" } : group))
: list;
await routePatchRpc(page, "bootstrap", (json) => ({ ...json, groups: asMain(json.groups) }));
await routePatchRpc(page, "spaces/list", (json) => {
const current = json.current as { groups?: unknown } | undefined;
return current ? { ...json, current: { ...current, groups: asMain(current.groups) } } : json;
});
main = stand;
}
await page.goto(`/app/g/${main.id}`, { waitUntil: "domcontentloaded" });
await waitForChatComposer(page);
}