evOo-local/apps/web/e2e/helpers.ts
Cursor Agent e5e378a11a
Fix E2E specs for onboarding name step and Heute reload route
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 17:00:11 +00:00

313 lines
12 KiB
TypeScript

import { expect, type Locator, type Page, type Route, type TestInfo } from "@playwright/test";
type RpcEnvelope = { json?: Record<string, unknown> };
function isBenignRouteError(error: unknown) {
return /disposed|already handled|Target closed|Request context disposed/i.test(String(error));
}
/** Unterhaltungen rows only — `.sidebar-projekte` is nested inside the same shell block on main. */
export function unterhaltungenOnly(page: Page) {
return page.locator(".sidebar-unterhaltungen").locator(":scope > *:not(.sidebar-projekte)");
}
export function isRealSandboxProvider(provider = process.env.SANDBOX_PROVIDER) {
return provider === "e2b" || provider === "daytona" || provider === "box";
}
export function realSandboxTimeout(real: number, emulated: number) {
if (process.env.SANDBOX_PROVIDER === "box") return Math.max(real, 300_000);
return isRealSandboxProvider() ? real : emulated;
}
export function activeBotId(page: Page) {
const id = new URL(page.url()).pathname.split("/").filter(Boolean).at(-1);
if (!id || id === "app") throw new Error(`missing bot id in ${page.url()}`);
return id;
}
export async function rpc<T>(page: Page, procedure: string, body: unknown): Promise<T> {
const spaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
const response = await page.request.post(`/rpc/${procedure}`, {
data: { json: body },
headers: spaceId ? { "x-rakazo-space-id": spaceId } : undefined,
});
const parsed = (await response.json()) as { json?: T; error?: { message?: string } };
if (!response.ok() || parsed.error) {
throw new Error(`${procedure} ${response.status()}: ${parsed.error?.message ?? "failed"}`);
}
return parsed.json as T;
}
/** Read status and headers before the body so Playwright does not dispose the response early. */
export async function fetchRpcEnvelope(route: Route) {
const response = await route.fetch();
const status = response.status();
const headers = response.headers();
const body = (await response.json()) as RpcEnvelope;
return { status, headers, body };
}
/** Fulfill a fetched RPC response without passing a consumed `response` into `route.fulfill`. */
export async function fulfillFetchedRpc(
route: Route,
fetched: { status: number; headers: { [key: string]: string }; body: RpcEnvelope },
) {
await route.fulfill({
status: fetched.status,
headers: fetched.headers,
contentType: "application/json",
body: JSON.stringify(fetched.body),
});
}
/** Patch a fetched RPC response without passing a consumed `response` into `route.fulfill`. */
export async function routePatchRpc(
page: Page,
procedure: string,
patch: (json: Record<string, unknown>) => Record<string, unknown>,
) {
await page.route(`**/rpc/${procedure}`, async (route) => {
try {
const fetched = await fetchRpcEnvelope(route);
const json = fetched.body.json;
if (!json) {
await fulfillFetchedRpc(route, fetched);
return;
}
await fulfillFetchedRpc(route, { ...fetched, body: { json: patch(json) } });
} catch (error) {
if (isBenignRouteError(error)) return;
throw error;
}
});
}
async function visible(locator: Locator, timeout: number) {
return locator.isVisible({ timeout }).catch(() => false);
}
/** After the look picker, skip the optional Oo naming step (keeps the default name). */
export async function skipOnboardingNameStep(page: Page) {
await expect(page.getByTestId("onboarding-name-step")).toBeVisible();
await page.getByRole("button", { name: "Skip", exact: true }).click();
}
/**
* Walks the first-run wizard, which lands in the main conversation with the main bot's first
* onboarding question pending (the composer works regardless). Unless `mainConversation` is
* set, it then opens the main bot's own thread, where most specs start.
*/
export async function completeOnboarding(
page: Page,
testInfo?: TestInfo,
options: { mainConversation?: boolean; ooName?: string } = {},
) {
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
if (page.url().includes("/onboarding")) {
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
"data-onboarding-step",
/^(emoji|name|language|voice|integrations|scan|bot)$/,
{ timeout: 30_000 },
);
}
const design = page.getByRole("heading", { name: /Design your Oo/i });
const naming = page.getByTestId("onboarding-name-step");
const language = page.getByRole("heading", { name: /Choose your language/i });
const voice = page.getByRole("heading", { name: /Pick a voice/i });
const integrations = page.getByRole("heading", { name: "Server integrations", exact: true });
const expectedOoName = options.ooName ?? "Chief";
const chief = page.getByText(expectedOoName).first();
// Scripted CI has needsModel false, so a fresh owner starts on integrations.
// A model-gated run still opens the look picker first.
const entry = design.or(integrations).or(chief);
await expect(entry.first()).toBeVisible({ timeout: 20_000 });
if (await design.isVisible()) {
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(naming).toBeVisible();
if (options.ooName) {
await page.getByTestId("onboarding-oo-name-input").fill(options.ooName);
await naming.getByRole("button", { name: "Continue", exact: true }).click();
} else {
await page.getByRole("button", { name: "Skip", exact: true }).click();
}
await expect(language).toBeVisible();
await page.getByRole("button", { name: /Skip/i }).click();
await expect(voice).toBeVisible();
await page.getByRole("button", { name: "Skip", exact: true }).click();
}
if (await visible(integrations, 8_000)) {
if (testInfo) await captureScreenshot(page, testInfo, "02-connect-apps");
await page.getByRole("button", { name: "Skip", exact: true }).click();
}
// The scan step, when shown, either asks first (skip it) or advances by itself. Onboarding
// then lands in the main conversation.
const skipScan = page
.locator('[data-onboarding-step="scan"]')
.getByRole("button", { name: "Skip", exact: true });
await expect(async () => {
if (await skipScan.isVisible()) await skipScan.click();
expect(page.url()).toMatch(/\/app\/g\/[^/]+$/);
}).toPass({ timeout: 30_000 });
await expect(chief).toBeVisible({ timeout: 15_000 });
await waitForChatComposer(page);
if (testInfo) {
await captureScreenshot(page, testInfo, "03-create-first-bot");
await captureScreenshot(page, testInfo, "06-onboarding-complete");
}
if (!options.mainConversation) await openMainBotThread(page);
}
/** The main bot's own thread stays reachable by URL; the sidebar lists the main conversation. */
export async function openMainBotThread(page: Page) {
const main = await rpc<{ id: string }>(page, "bots/ensureMain", {});
await page.goto(`/app/${main.id}`, { waitUntil: "domcontentloaded" });
await page.waitForURL(new RegExp(`/app/${main.id}$`), {
timeout: 20_000,
waitUntil: "domcontentloaded",
});
await waitForChatComposer(page);
return main.id;
}
export async function signup(
page: Page,
email: string,
password: string,
name: string,
testInfo?: TestInfo,
) {
await expect(async () => {
await page.goto("/sign-up", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "Create your evOo" })).toBeVisible({
timeout: 5_000,
});
}).toPass({ timeout: 45_000 });
if (testInfo) await captureScreenshot(page, testInfo, "01-sign-up");
await page.getByPlaceholder("Your name").fill(name);
await page.getByPlaceholder("Your email address").fill(email);
await page.getByPlaceholder("Password").fill(password);
await page.getByRole("button", { name: "Create account" }).click();
}
export async function captureScreenshot(page: Page, testInfo: TestInfo, name: string) {
const screenshotPath = testInfo.outputPath(`${name}.png`);
await page.screenshot({
animations: "disabled",
caret: "hide",
fullPage: true,
path: screenshotPath,
});
await testInfo.attach(name, { contentType: "image/png", path: screenshotPath });
}
export async function openNewBot(page: Page) {
await page.getByTestId("create-menu-trigger").click();
await page.getByTestId("create-new-bot").click();
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create");
await expect(page.getByTestId("create-bot-form")).toBeVisible();
}
export async function openNewGroup(page: Page) {
await page.getByTestId("create-menu-trigger").click();
await page.getByTestId("create-new-group").click();
}
export async function openNewSpace(page: Page) {
await page.getByTestId("create-menu-trigger").click();
await page.getByTestId("create-new-space").click();
}
/** Open the create form from the + picker, submit, and wait for the new chat. */
export async function createBotFromPicker(
page: Page,
options: {
name?: string;
title?: string;
description?: string;
computerMode?: "team" | "dedicated";
} = {},
) {
const name = options.name ?? "New Bot";
await openNewBot(page);
const form = page.getByTestId("create-bot-form");
await form.locator("label:has-text('Name') input").fill(name);
if (options.title != null) {
await form.locator("label:has-text('Title') input").fill(options.title);
}
if (options.description != null) {
await form.locator("label:has-text('Description') textarea").fill(options.description);
}
if (options.computerMode === "dedicated") {
await form.getByTestId("create-bot-private").click();
} else if (options.computerMode === "team") {
await form.getByTestId("create-bot-team").click();
}
await form.getByRole("button", { name: "Create", exact: true }).click();
await page.waitForURL(/\/app\/[^/]+$/);
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
}
/** Open the user Settings overlay, optionally switching to a sidebar section. */
export async function openUserSettings(
page: Page,
section?:
| "general"
| "integrations"
| "models"
| "memory"
| "voice"
| "notifications"
| "usage"
| "computer"
| "updates"
| "privacy",
) {
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
timeout: 60_000,
});
await expect(page.getByTestId("user-menu-trigger")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("user-menu-trigger").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
const settings = page.getByTestId("user-settings");
await expect(settings).toBeVisible();
if (section && section !== "general") {
await settings.getByTestId(`settings-nav-${section}`).click();
}
return settings;
}
/** Wait until the shell thread is loaded and the message composer is interactive. */
export async function waitForChatComposer(page: Page) {
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
timeout: 30_000,
});
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
}
/** Create a named bot via RPC for test setup (skips the + picker). */
export async function createNamedBot(
page: Page,
name: string,
options: { computerMode?: "team" | "dedicated" } = {},
) {
const bot = await rpc<{ id: string; name: string }>(page, "bots/create", {
name,
title: "",
description: "",
notifyOnFinish: true,
computerMode: options.computerMode ?? "team",
});
await page.goto(`/app/${bot.id}`, { waitUntil: "domcontentloaded" });
await page.waitForURL(/\/app\/[^/]+$/, { timeout: 20_000 });
await waitForChatComposer(page);
return bot.id;
}