evOo-local/apps/web/e2e/spaces.spec.ts
cynarAI 8449dec6c2
feat: give every project its own main agent (#125)
Each project (space) now has a main agent and a main conversation of its
own. The user talks to it, and it delegates to the project's specialists.
Creating a project sets both up and opens the conversation, and deleting
an otherwise empty project removes them with it.


Claude-Session: https://claude.ai/code/session_01R8hSsKTB3W3e61wD6kvzur

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 00:38:39 +02:00

262 lines
12 KiB
TypeScript

import { expect, type Locator, test } from "@playwright/test";
import {
captureScreenshot,
completeOnboarding,
openNewSpace,
routePatchRpc,
rpc,
signup,
unterhaltungenOnly,
} from "./helpers";
function projectSpace(projekte: Locator, name: string) {
return projekte.locator('[data-sidebar-group^="project-space:"]').filter({
hasText: name,
exact: true,
});
}
test("Projekte + opens new project dialog instead of group form", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create");
await completeOnboarding(page);
await page.goto("/app");
await page.waitForURL(/\/app\/[^/]+$/);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
await expect(page.getByRole("dialog", { name: "New project" })).toBeVisible();
await expect(page.getByRole("button", { name: "Create project", exact: true })).toBeVisible();
await expect(page.getByTestId("side-panel")).not.toHaveAttribute("data-panel", "create-group");
await expect(page.getByText("New group", { exact: true })).toHaveCount(0);
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Customer project");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await expect(page).not.toHaveURL(/\/onboarding/);
// The new project opens its conversation with the project's main agent.
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect(projekte.getByText("Customer project", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(unterhaltungenOnly(page).getByText("Customer project", { exact: true })).toHaveCount(
0,
);
await expect(
projectSpace(projekte, "Customer project").getByRole("button", {
name: "Chief, main conversation",
}),
).toBeVisible();
await expect(page.getByTestId("project-start-main")).toHaveCount(0);
await captureScreenshot(page, testInfo, "new-project-main-conversation");
});
for (const owner of [true, false]) {
const role = owner ? "owner" : "member";
const title = owner
? "new project dialog offers a host folder to the deployment owner"
: "new project dialog offers no host folder to a member";
test(title, async ({ page }, testInfo) => {
await signup(page, `proj-folder-${role}-${Date.now()}@rakazo.test`, "password12", "Folder");
await completeOnboarding(page);
// Whoever signs up first owns the deployment, which depends on spec order.
await routePatchRpc(page, "bootstrap", (json) => ({
...json,
me: { ...(json.me as Record<string, unknown>), isDeploymentOwner: owner },
}));
await page.goto("/app");
await page.waitForURL(/\/app\/[^/]+$/);
await page.locator(".sidebar-projekte").getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await expect(dialog.getByLabel("Name")).toBeVisible();
await expect(dialog.getByLabel("Project folder")).toHaveCount(owner ? 1 : 0);
await captureScreenshot(page, testInfo, `new-project-${role}`);
});
}
test("spaces stay invisible by default and chat creation requires approval", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `spaces-${stamp}@rakazo.test`, "password12", "Space Owner");
await completeOnboarding(page);
const sidebar = page.locator("aside").first();
const personalSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
expect(personalSpaceId).toBeTruthy();
await expect(sidebar.getByText("Personal", { exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await captureScreenshot(page, testInfo, "single-space-sidebar");
await openNewSpace(page);
const dialog = page.getByRole("dialog", { name: "New space" });
await expect(dialog.getByLabel("Name")).toBeVisible();
await dialog.getByLabel("Name").fill("Customer support");
await captureScreenshot(page, testInfo, "new-space-dialog");
await dialog.getByRole("button", { name: "Cancel" }).click();
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("Create a space named Customer support");
await composer.press("Enter");
await expect(page.getByRole("button", { name: "Create space", exact: true })).toBeVisible({
timeout: 15_000,
});
await expect(page.getByRole("button", { name: "Cancel", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Always allow this tool" })).toHaveCount(0);
await expect(sidebar.getByText("Customer support", { exact: true })).toHaveCount(0);
await captureScreenshot(page, testInfo, "create-space-chat-approval");
await page.getByRole("button", { name: "Create space", exact: true }).click();
await expect(page.getByText("Created", { exact: true })).toBeVisible();
const projekte = page.locator(".sidebar-projekte");
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible({
timeout: 15_000,
});
await expect(unterhaltungenOnly(page).getByText("Customer support", { exact: true })).toHaveCount(
0,
);
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
const supportSpace = projekte.locator('[data-sidebar-group^="project-space:"]').filter({
hasText: "Customer support",
});
const supportSpaceGroup = await supportSpace.getAttribute("data-sidebar-group");
const supportSpaceId = supportSpaceGroup?.match(/^project-space:([^:]+)/)?.[1];
expect(supportSpaceId).toBeTruthy();
// A project created from chat comes with its own main agent, reached through its conversation.
await supportSpace.getByRole("button", { name: "Chief, main conversation" }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/);
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.toBe(supportSpaceId);
// The home main conversation stays the first entry while another space is open.
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toHaveCount(0);
await captureScreenshot(page, testInfo, "spaces-sidebar");
await page.evaluate((id) => {
window.localStorage.setItem("rakazo:space-id", id!);
}, personalSpaceId);
await page.reload();
await page.waitForURL(/\/app\/[^/]+$/);
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.toBe(personalSpaceId);
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
});
test("a new space opens its main conversation and can be deleted from its menu", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `spaces-delete-${stamp}@rakazo.test`, "password12", "Space Owner");
await completeOnboarding(page);
const sidebar = page.locator("aside").first();
await openNewSpace(page);
const dialog = page.getByRole("dialog", { name: "New space" });
await dialog.getByLabel("Name").fill("Temporary");
await dialog.getByRole("button", { name: "Create space", exact: true }).click();
// Onboarding runs once per user: a new space opens directly in the conversation with its
// own main agent, its only bot.
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
const projekte = page.locator(".sidebar-projekte");
await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(
projectSpace(projekte, "Temporary").getByRole("button", { name: "Chief, main conversation" }),
).toBeVisible();
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toHaveCount(0);
expect(await rpc<Array<{ kind: string }>>(page, "bots/list", {})).toEqual([
expect.objectContaining({ kind: "main" }),
]);
await captureScreenshot(page, testInfo, "new-space-main-conversation");
// Deletion is discoverable through the visible Space actions menu.
await projectSpace(projekte, "Temporary")
.getByRole("button", { name: "Actions for Temporary" })
.click();
await captureScreenshot(page, testInfo, "space-actions-menu");
await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible();
await page.getByRole("menuitem", { name: "Delete space" }).click();
const deleteSpaceDialog = page.getByRole("alertdialog", { name: "Delete Temporary?" });
await expect(deleteSpaceDialog).toBeVisible();
await captureScreenshot(page, testInfo, "delete-space-dialog");
await deleteSpaceDialog.getByRole("button", { name: "Delete", exact: true }).click();
await expect(sidebar.getByText("Temporary", { exact: true })).toHaveCount(0);
await expect(page).not.toHaveURL(/\/onboarding/);
// The project's main agent went with it.
await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
// A stale onboarding URL recovers without duplicating the bot.
await page.goto("/onboarding");
await page.waitForURL(/\/app/);
await expect(page).not.toHaveURL(/\/onboarding/);
await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
});
test("deleting the last bot in a space stays in the app after first use", async ({ page }) => {
const stamp = Date.now();
await signup(page, `spaces-empty-${stamp}@rakazo.test`, "password12", "Space Owner");
await completeOnboarding(page);
const sidebar = page.locator("aside").first();
// The main bot cannot be archived, so the archived section comes from a specialist.
const helper = await rpc<{ id: string }>(page, "bots/create", {
name: "Helper",
title: "",
description: "",
notifyOnFinish: true,
computerMode: "team",
});
await rpc(page, "bots/archive", { botId: helper.id });
await page.reload({ waitUntil: "domcontentloaded" });
await expect(sidebar.getByRole("button", { name: /^Archived/ })).toBeVisible({
timeout: 30_000,
});
await openNewSpace(page);
const dialog = page.getByRole("dialog", { name: "New space" });
await dialog.getByLabel("Name").fill("Side");
await dialog.getByRole("button", { name: "Create space", exact: true }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
const projekte = page.locator(".sidebar-projekte");
await expect(projekte.getByText("Side", { exact: true })).toBeVisible();
// Delete the only bot in the new space: the app must stay put instead of
// forcing onboarding again.
const sideBot = await rpc<{ id: string }>(page, "bots/create", {
name: "Side bot",
title: "",
description: "",
notifyOnFinish: true,
computerMode: "team",
});
await rpc(page, "bots/remove", { botId: sideBot.id, deleteMemories: false });
await expect(page).not.toHaveURL(/\/onboarding/);
await page.goto("/app");
await expect(page).not.toHaveURL(/\/onboarding/);
// The project's main conversation stays, so the project opens there.
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect(
projectSpace(projekte, "Side").getByRole("button", { name: "Chief, main conversation" }),
).toBeVisible();
await projectSpace(projekte, "Side").getByRole("button", { name: "Actions for Side" }).click();
await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible();
await page.getByRole("menuitem", { name: "Delete space" }).click();
await page
.getByRole("alertdialog", { name: "Delete Side?" })
.getByRole("button", { name: "Delete", exact: true })
.click();
await expect(sidebar.getByText("Side", { exact: true })).toHaveCount(0);
const archivedToggle = sidebar.getByRole("button", { name: /^Archived/ });
await expect(archivedToggle).toBeVisible({ timeout: 30_000 });
await archivedToggle.click();
await expect(sidebar.getByText("Helper", { exact: true })).toBeVisible();
});