BangSo/apps/web/e2e/team-computer.spec.ts

313 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, type Page, test } from "@playwright/test";
import {
activeBotId,
captureScreenshot,
completeOnboarding,
openNewBot,
realSandboxTimeout,
rpc,
signup,
} from "./helpers";
test("Team Computer gives bots a home folder plus shared space while Private stays isolated", async ({
page,
}, testInfo) => {
const stamp = Date.now();
const personalMarker = `personal-${stamp}`;
const sharedMarker = `shared-${stamp}`;
const privateMarker = `private-${stamp}`;
await signup(page, `team-computer-${stamp}@rakazo.test`, "password12", "Team Computer");
await completeOnboarding(page);
const chiefId = activeBotId(page);
await openComputerPanel(page);
await expect(page.getByText("Team Computer", { exact: true }).last()).toBeVisible();
await captureScreenshot(page, testInfo, "41-team-computer");
const writerId = await createBot(page, "Writer", "team");
await sendAndWait(
page,
writerId,
`write a file in your home called notes/result.txt that says ${personalMarker}`,
);
await expect(readFile(page, writerId, "notes/result.txt")).resolves.toContain(personalMarker);
await expect(readFileResponse(page, chiefId, "notes/result.txt")).resolves.toMatchObject({
ok: false,
});
await expect(readFile(page, chiefId, `bots/${writerId}/notes/result.txt`)).resolves.toContain(
personalMarker,
);
await captureScreenshot(page, testInfo, "42-team-bot-home-folder");
await sendAndWait(
page,
writerId,
`write a file called shared/notes/result.txt that says ${sharedMarker}`,
);
await expect(readFile(page, chiefId, "shared/notes/result.txt")).resolves.toContain(sharedMarker);
const privateId = await createBot(page, "Private Writer", "dedicated");
await openComputerPanel(page);
await expect(page.getByText("Private Writers computer", { exact: true }).last()).toBeVisible();
await captureScreenshot(page, testInfo, "43-private-computer");
await expect(readFileResponse(page, privateId, "notes/result.txt")).resolves.toMatchObject({
ok: false,
});
await sendAndWait(
page,
privateId,
`write a file in your home called notes/result.txt that says ${privateMarker}`,
);
await expect(readFile(page, privateId, "notes/result.txt")).resolves.toContain(privateMarker);
await expect(readFileResponse(page, chiefId, "notes/result.txt")).resolves.toMatchObject({
ok: false,
});
await setComputerMode(page, "Private Writer", privateId, "team");
await expect(readFileResponse(page, privateId, "notes/result.txt")).resolves.toMatchObject({
ok: false,
});
await expect(readFile(page, privateId, "shared/notes/result.txt")).resolves.toContain(
sharedMarker,
);
await captureScreenshot(page, testInfo, "44-private-bot-joined-team-computer");
await setComputerMode(page, "Private Writer", privateId, "dedicated");
await expect(readFile(page, privateId, "notes/result.txt")).resolves.toContain(privateMarker);
await expect(readFile(page, writerId, "notes/result.txt")).resolves.toContain(personalMarker);
await captureScreenshot(page, testInfo, "45-private-computer-restored");
});
test("user control leaves another Team bot's screen available", async ({ page }, testInfo) => {
const stamp = Date.now();
const marker = `after-release-${stamp}`;
await signup(page, `team-control-${stamp}@rakazo.test`, "password12", "Team Control");
await completeOnboarding(page);
const chiefId = activeBotId(page);
const workerId = await createBot(page, "Worker", "team");
await openBot(page, "Chief");
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: "Take control", exact: true }).click();
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
await page.getByRole("button", { name: "Close computer" }).click();
await openBot(page, "Worker");
const workerRunId = await sendMessage(
page,
`write a file in your home called notes/result.txt that says ${marker}`,
);
await waitForRun(page, workerId, workerRunId);
await expect(
rpc<{ controlHolder: string; controlBotId: string | null }>(page, "computer/status", {
botId: workerId,
}),
).resolves.toMatchObject({ controlHolder: "user", controlBotId: chiefId });
await expect(readFile(page, workerId, "notes/result.txt")).resolves.toContain(marker);
await captureScreenshot(page, testInfo, "46-team-computer-user-control-allows-parallel-bot");
await rpc(page, "computer/release", { botId: chiefId });
await expect(
rpc<{ controlHolder: string; controlBotId: string | null }>(page, "computer/status", {
botId: chiefId,
}),
).resolves.toMatchObject({ controlHolder: "bot", controlBotId: null });
await expect(readFileResponse(page, chiefId, "notes/result.txt")).resolves.toMatchObject({
ok: false,
});
await captureScreenshot(page, testInfo, "47-team-computer-control-released");
});
test("an active Team bot must be stopped before user takeover", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(
page,
`active-team-control-${stamp}@rakazo.test`,
"password12",
"Active Team Control",
);
await completeOnboarding(page);
const chiefId = activeBotId(page);
await sendMessage(page, "keep working until I stop you");
await expect
.poll(async () => (await threadSnapshot(page, chiefId)).run?.status ?? "idle")
.toBe("running");
await captureScreenshot(page, testInfo, "48-active-team-bot-blocks-takeover");
await expect
.poll(
async () => (await rpc<{ state: string }>(page, "computer/status", { botId: chiefId })).state,
)
.toBe("running");
await expect
.poll(
async () =>
(
await rpc<{ busyBotName: string | null }>(page, "computer/status", {
botId: chiefId,
})
).busyBotName,
)
.not.toBeNull();
const takeover = await rpcResponse(page, "computer/takeover", { botId: chiefId });
expect(takeover.ok).toBe(false);
expect(takeover.status).toBe(409);
await expect
.poll(async () => (await threadSnapshot(page, chiefId)).run?.status ?? "idle")
.toBe("running");
await page.getByTitle("Agent computer").click();
const takeControl = page.getByRole("button", { name: /Take control/i }).first();
await expect(takeControl).toBeDisabled();
await expect(page.getByText(/is using it/i).first()).toBeVisible();
await captureScreenshot(page, testInfo, "48b-take-control-blocked-while-busy");
// Stop through the shell so the client refreshes computer status (API stop alone
// does not emit a terminal thread event).
await page.getByRole("button", { name: "Stop", exact: true }).click();
await waitForIdle(page, chiefId);
await expect
.poll(
async () =>
(
await rpc<{ busyBotName: string | null }>(page, "computer/status", {
botId: chiefId,
})
).busyBotName,
)
.toBeNull();
await expect(takeControl).toBeEnabled();
await takeControl.click();
await expect(
page.getByTestId("side-panel").getByText("You have control", { exact: true }),
).toBeVisible();
await captureScreenshot(page, testInfo, "49-team-computer-takeover-after-stop");
await rpc(page, "computer/release", { botId: chiefId });
});
async function createBot(page: Page, name: string, mode: "team" | "dedicated") {
await openNewBot(page);
await expect(page.getByText("New bot", { exact: true })).toBeVisible();
const team = page.getByRole("button", { name: "Team", exact: true });
const privateComputer = page.getByRole("button", { name: "Private", exact: true });
await expect(team).toHaveAttribute("aria-pressed", "true");
if (mode === "dedicated") await privateComputer.click();
await expect(mode === "team" ? team : privateComputer).toHaveAttribute("aria-pressed", "true");
await page.getByPlaceholder("Name this bot").fill(name);
await page.getByRole("button", { name: "Create", exact: true }).click();
await page.waitForURL(/\/app\/[^/]+$/);
await expect(page.getByPlaceholder(`Message ${name}`)).toBeVisible();
return activeBotId(page);
}
async function setComputerMode(
page: Page,
botName: string,
botId: string,
mode: "team" | "dedicated",
) {
await page.getByRole("button", { name: botName, exact: true }).last().click();
const settings = page.getByTestId("bot-settings");
await expect(settings.locator("label:has-text('Name') input")).toHaveValue(botName);
const advanced = settings.getByTestId("bot-settings-advanced");
await advanced.evaluate((element) => {
(element as HTMLDetailsElement).open = true;
});
await settings
.getByRole("button", { name: mode === "team" ? "Team" : "Private", exact: true })
.click();
await settings.getByRole("button", { name: "Save", exact: true }).click();
await expect
.poll(async () => {
const bots = await rpc<Array<{ id: string; computerMode: string }>>(page, "bots/list", {});
return bots.find((bot) => bot.id === botId)?.computerMode;
})
.toBe(mode);
}
async function openBot(page: Page, name: string) {
await page
.getByRole("complementary")
.getByRole("button", { name: new RegExp(`^${name}`) })
.click();
await expect(page.getByPlaceholder(`Message ${name}`)).toBeVisible();
}
async function openComputerPanel(page: Page) {
await page.getByTitle("Agent computer").click();
await expect(page.getByRole("button", { name: "Take control", exact: true })).toBeVisible();
}
async function sendAndWait(page: Page, botId: string, text: string) {
const runId = await sendMessage(page, text);
await waitForRun(page, botId, runId);
}
async function sendMessage(page: Page, text: string) {
const composer = page.getByPlaceholder(/Message/);
await composer.fill(text);
const sent = page.waitForResponse(
(response) =>
response.url().includes("/rpc/threads/send") && response.request().method() === "POST",
);
await page.keyboard.press("Enter");
const response = await sent;
expect(response.ok()).toBe(true);
const result = (await response.json()) as { json?: { runId?: string } };
if (!result.json?.runId) throw new Error("threads/send did not return a run id");
return result.json.runId;
}
async function waitForRun(page: Page, botId: string, runId: string) {
await expect
.poll(
async () => {
const snapshot = await threadSnapshot(page, botId);
if (snapshot.run?.id === runId) return false;
return snapshot.messages.some((message) => message.runId === runId);
},
{
timeout: realSandboxTimeout(90_000, 20_000),
message: `run ${runId} must finish before its result is inspected`,
},
)
.toBe(true);
}
async function waitForIdle(page: Page, botId: string) {
await expect
.poll(async () => (await threadSnapshot(page, botId)).run?.status ?? "idle", {
timeout: realSandboxTimeout(90_000, 20_000),
})
.toBe("idle");
}
function threadSnapshot(page: Page, botId: string) {
return rpc<{
run: { id: string; status: string } | null;
messages: Array<{ runId?: string | null }>;
}>(page, "threads/get", { botId });
}
async function readFile(page: Page, botId: string, path: string) {
const result = await rpc<{ content: string }>(page, "computer/readFile", { botId, path });
return result.content;
}
async function readFileResponse(page: Page, botId: string, path: string) {
return rpcResponse(page, "computer/readFile", { botId, path });
}
async function rpcResponse(page: Page, procedure: string, body: unknown) {
const response = await page.request.post(`/rpc/${procedure}`, { data: { json: body } });
return { ok: response.ok(), status: response.status() };
}