evOo-local/apps/web/e2e/spaces.spec.ts
cynarAI 3c3961f242
fix(api): keep host folders to the deployment owner (#112)
spaces.create linked any workspacePath to the new project, and
computer.files and computer.readFile then listed and read files under
that folder for anyone in the space. On a deployment with signups, any
member could link / or the API's own checkout and read whatever the API
process can read, such as its .env file. Linking also ran git in the
chosen folder and stored its remote URL.

A linked folder lives on the host, and the host is the deployment
owner's machine, the rule the onboarding scan already follows:

- spaces.create refuses a folder from anyone but the deployment owner,
  before the space is created or the folder is touched.
- Project files are listed and read only for the deployment owner, so a
  space a member linked before this change stays closed too. Space
  navigation no longer shows a linked folder's path and git remote to
  other members.
- A linked folder is stored as its real path, and listing and reading
  follow symlinks only while they stay inside it. Symlinks that leave
  the folder or lead nowhere are left out of a listing instead of
  failing it.
- The web New project dialog offers the folder field only to the
  deployment owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EyQw3AEaHYXegvgvTPAsi1
2026-10-03 18:32:48 +02:00

248 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 }) => {
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/);
});
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();
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();
});