evOo-local/apps/web/e2e/spaces.spec.ts
Claude 311551da04
feat: land in the main conversation, led by the main bot
After onboarding the user ended up in a 1:1 chat with "Chief", and the
"Hauptthread" was a group the web client created and found by name. The
main bot had no conversation it actually led, and addressing another bot
there did nothing unless the user had added it as a member first.

Now the main conversation is a real place:

- bots.ensureMain creates or returns the main bot. Bootstrap and space
  navigation return `main` ({ botId, groupId, spaceId }) once the main
  bot exists; reading navigation never creates a bot.
- The main bot leads the main conversation and answers when nobody is
  addressed: message routing, follow-ups, reactions and clearing the
  thread fall back to the group's lead instead of its first member.
- Addressing a same-space bot that is not a member yet, by @Name or a
  mention chip, makes it join before the message is routed, so it
  answers in the conversation. Specialists from other spaces do not join.
- The main bot cannot be archived or deleted, the main conversation
  cannot be archived or deleted or have its members edited, and child
  bots never dissolve it. Renaming the main bot renames the conversation.
- Web: onboarding uses bots.ensureMain and finishes in the main
  conversation. The main conversation is the first sidebar entry in every
  space, shown with the main bot's avatar and name; from another space it
  switches back to the home space. The main bot is not listed again among
  the bots, its 1:1 thread stays reachable by URL. The client no longer
  creates or detects the "Hauptthread" by name, refetches a group when it
  changes, and shows the speaker of any of the user's bots in a group.
- Onboarding no longer resets the step when its bootstrap resolves after
  the user has already moved on, and new spaces open in the app instead
  of walking onboarding again.

Tests: group mention routing with a default responder, lead follow-ups,
child-bot guards, a journey for the main conversation (lead answers,
addressed bot joins, guards, rename), authorization for bots.ensureMain,
and an E2E spec that lands in the main conversation, gets an answer from
the lead, sees an addressed bot join, and reaches the conversation from
another space. Existing E2E specs start in the main bot's thread through
a helper or use the main conversation where that is what they test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 00:24:16 +00:00

223 lines
10 KiB
TypeScript

import { expect, type Locator, test } from "@playwright/test";
import {
captureScreenshot,
completeOnboarding,
openNewSpace,
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 }) => {
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/);
await page.waitForURL(/\/app/);
await page.waitForLoadState("networkidle");
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(page.getByTestId("project-start-main")).toBeVisible();
await expect(page.getByTestId("project-start-main")).toHaveText(/Starten/);
});
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();
await supportSpace.getByRole("button", { name: "Select Customer support" }).click();
await page.waitForURL(/\/app(?:\/)?$/);
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.toBe(supportSpaceId);
// The 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(0);
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
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 empty 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, with no bot of its own.
await page.waitForURL(/\/app(?:\/)?$/);
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(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
expect(await rpc<unknown[]>(page, "bots/list", {})).toEqual([]);
await captureScreenshot(page, testInfo, "new-space-empty");
// The empty space stays selectable instead of trapping the user.
await expect(projekte.getByRole("button", { name: "Select Temporary" }).first()).toBeVisible();
// 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/);
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(?:\/)?$/);
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/);
await expect(projekte.getByRole("button", { name: "Select Side" }).first()).toBeVisible();
await expect(page.getByTestId("project-start-main")).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();
});