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("Projects + 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: "Lead, 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), 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: "Lead, 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: /^Lead/ })).toHaveCount(1); await expect(projekte.getByRole("button", { name: "Start", 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: "Lead, main conversation" }), ).toBeVisible(); await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0); expect(await rpc>(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: "Lead, 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(); });