313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
|
|
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 Writer’s 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() };
|
|||
|
|
}
|