- Sidebar: Unterhaltungen (1:1-Bots im Home-Space) und Projekte (Projekt-Spaces + Gruppen) getrennt; Projekt-Spaces erscheinen nicht mehr unter Team/Unterhaltungen - + in Unterhaltungen öffnet Bot-Picker (bestehenden Bot öffnen oder neuen Bot erstellen) - Chat-Header: Bot-Avatar wieder sichtbar, Status nur bei Aktivität, kein doppeltes „1:1 conversation“ - Composer: generisches „Nachricht…“ statt Name dreifach - OoStage: kein doppelter Bot-Name bei laufendem Verlauf Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
import { expect, type Page, test } from "@playwright/test";
|
||
import {
|
||
activeBotId,
|
||
captureScreenshot,
|
||
completeOnboarding,
|
||
createNamedBot,
|
||
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 screen/).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.getByTestId("computer-preview").hover();
|
||
await page.getByTestId("computer-preview-open").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("a failed control release keeps the computer open for retry", async ({ page }, testInfo) => {
|
||
await signup(page, `team-release-${Date.now()}@rakazo.test`, "password12", "Team Release");
|
||
await completeOnboarding(page);
|
||
await page.getByTitle("Agent computer").click();
|
||
await page.getByTestId("computer-preview").hover();
|
||
await page.getByTestId("computer-preview-open").click();
|
||
const chrome = page.getByTestId("computer-chrome");
|
||
const release = chrome.getByRole("button", { name: "Release", exact: true });
|
||
await expect(release).toBeVisible();
|
||
await page.route("**/rpc/computer/release", (route) =>
|
||
route.fulfill({ status: 500, body: "release unavailable" }),
|
||
);
|
||
await release.click();
|
||
await expect(page.getByText("Could not continue", { exact: true }).last()).toBeVisible();
|
||
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
|
||
await captureScreenshot(page, testInfo, "48-team-computer-release-retry");
|
||
await page.unroute("**/rpc/computer/release");
|
||
await release.click();
|
||
await expect(page.getByRole("button", { name: "Close computer" })).toBeHidden();
|
||
});
|
||
|
||
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 sidePanel = page.getByTestId("side-panel");
|
||
await expect(sidePanel.getByRole("button", { name: /Take control/i })).toHaveCount(0);
|
||
await page.getByTestId("computer-preview").hover();
|
||
const openBusy = sidePanel.getByTestId("computer-preview-open");
|
||
await expect(openBusy).toBeVisible();
|
||
await expect(openBusy.getByText("Open", { exact: true })).toBeVisible();
|
||
await openBusy.click();
|
||
const chrome = page.getByTestId("computer-chrome");
|
||
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
|
||
// Open while the bot is busy must not grant control (takeover stays blocked).
|
||
await expect(chrome.getByText("You have control", { exact: true })).toHaveCount(0);
|
||
await expect(chrome.getByRole("button", { name: /Take control/i })).toHaveCount(0);
|
||
await captureScreenshot(page, testInfo, "48b-open-while-busy-no-control");
|
||
await page.getByRole("button", { name: "Close computer" }).click();
|
||
|
||
// 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();
|
||
|
||
// After stop, Open via hover is the takeover path (no Take control button).
|
||
await page.getByTestId("computer-preview").hover();
|
||
await page.getByTestId("computer-preview-open").click();
|
||
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
|
||
await expect(chrome.getByText("You have control", { exact: true })).toBeVisible();
|
||
await expect(chrome.getByRole("button", { name: /Take control/i })).toHaveCount(0);
|
||
await expect(chrome.getByRole("button", { name: "Release", exact: true })).toBeVisible();
|
||
await captureScreenshot(page, testInfo, "49-team-computer-open-after-stop");
|
||
await chrome.getByRole("button", { name: "Release", exact: true }).click();
|
||
// Release closes the overlay and clears control without a DB edit.
|
||
await expect(page.getByRole("button", { name: "Close computer" })).toHaveCount(0);
|
||
});
|
||
|
||
async function createBot(page: Page, name: string, mode: "team" | "dedicated") {
|
||
return createNamedBot(page, name, { computerMode: mode });
|
||
}
|
||
|
||
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.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||
}
|
||
|
||
async function openComputerPanel(page: Page) {
|
||
await page.getByTitle("Agent computer").click();
|
||
await expect(page.getByTestId("computer-preview")).toBeVisible();
|
||
await expect(page.getByTestId("computer-preview-open")).toHaveCount(1);
|
||
}
|
||
|
||
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() };
|
||
}
|