import { expect, type Locator, type Page, type Route, type TestInfo } from "@playwright/test"; import type { Routine } from "@rakazo/contracts"; type RpcEnvelope = { json?: Record }; 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)"); } /** A project's row in the sidebar; it opens the project's main conversation. */ export function projectRow(scope: Page | Locator, name: string) { return scope.getByTestId(/^project-row-/).filter({ hasText: name }); } 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 createTestRoutine( page: Page, botId: string, overrides: { name?: string; prompt?: string; crons?: string[]; timezone?: string; active?: boolean; webhookEnabled?: boolean; githubEnabled?: boolean; messageProvider?: string | null; } = {}, ) { return rpc(page, "routines/create", { botId, name: "Test routine", prompt: "Run the check-in", crons: ["0 9 * * *"], timezone: "UTC", active: true, notify: true, webhookEnabled: false, githubEnabled: false, messageProvider: null, ...overrides, }); } /** Opens the side panel on the agent's overview, where its routines are listed. */ export async function openAgentOverview(page: Page, title = "Agent computer") { const overview = page.getByTestId("side-panel").locator('[data-tab="overview"]'); // The header button toggles the panel, so only press it while the panel is closed. if (!(await overview.isVisible())) await page.getByTitle(title).click(); await overview.click(); } export async function rpc(page: Page, procedure: string, body: unknown): Promise { 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) => Record, ) { 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); } const onboardingContinueButton = /^(Continue|Weiter)$/i; const onboardingSkipButton = /^(Skip|Überspringen)$/i; const onboardingLanguageSkipButton = /^(Skip|Überspringen) ·/i; /** 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: onboardingSkipButton }).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|Gestalte dein Oo/i }); const naming = page.getByTestId("onboarding-name-step"); const language = page.getByRole("heading", { name: /Choose your language|Wähle deine Sprache/i, }); const voice = page.getByRole("heading", { name: /Pick a voice|Wähle eine Stimme/i }); const integrations = page.getByRole("heading", { name: /Server integrations|Server-Integrationen/i, }); 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: onboardingContinueButton }).click(); await expect(naming).toBeVisible(); if (options.ooName) { await page.getByTestId("onboarding-oo-name-input").fill(options.ooName); await naming.getByRole("button", { name: onboardingContinueButton }).click(); } else { await page.getByRole("button", { name: onboardingSkipButton }).click(); } await expect(language).toBeVisible(); await page.getByRole("button", { name: onboardingLanguageSkipButton }).click(); await expect(voice).toBeVisible(); await page.getByRole("button", { name: onboardingSkipButton }).click(); } if (await visible(integrations, 8_000)) { if (testInfo) await captureScreenshot(page, testInfo, "02-connect-apps"); await page.getByRole("button", { name: onboardingSkipButton }).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: onboardingSkipButton }); 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|Dein evOo erstellen/i }), ).toBeVisible({ timeout: 5_000, }); }).toPass({ timeout: 45_000 }); if (testInfo) await captureScreenshot(page, testInfo, "01-sign-up"); await page.getByPlaceholder(/Your name|Dein Name/i).fill(name); await page.getByPlaceholder(/Your email address|Deine E-Mail-Adresse/i).fill(email); await page.getByPlaceholder(/^Password$|^Passwort$/i).fill(password); await page.getByRole("button", { name: /Create account|Konto erstellen/i }).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, }); const openNav = page.getByTestId("open-navigation"); if (await openNav.isVisible().catch(() => false)) { await openNav.click(); } const menuTrigger = page.getByTestId("user-menu-trigger"); await expect(menuTrigger).toBeVisible({ timeout: 30_000 }); await menuTrigger.scrollIntoViewIfNeeded(); await menuTrigger.click(); await page.getByRole("button", { name: /^Settings$|^Einstellungen$/i }).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: 60_000, }); await expect(page.getByRole("combobox", { name: /^Message$|^Nachricht$/i })).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; } export const ALLOWED_CONSOLE_ERRORS = [ /Failed to load resource.*404/i, /Failed to load resource.*401/i, /favicon/i, /ERR_ABORTED/i, /Content Security Policy/i, /net::ERR_/i, /React Router Future Flag/i, /DevTools failed to load SourceMap/i, ]; /** Collect unexpected console errors and page errors for the rest of the test. */ export function attachConsoleGuard(page: Page) { const errors: string[] = []; page.on("console", (msg) => { if (msg.type() !== "error") return; const text = msg.text(); if (ALLOWED_CONSOLE_ERRORS.some((pattern) => pattern.test(text))) return; errors.push(text); }); page.on("pageerror", (err) => errors.push(err.message)); return errors; } /** True when the document does not scroll horizontally (±1px tolerance). */ export async function assertNoHorizontalOverflow(page: Page) { const ok = await page.evaluate(() => { const doc = document.documentElement; return doc.scrollWidth <= doc.clientWidth + 1; }); expect(ok, `horizontal overflow on ${page.url()}`).toBe(true); } const MOBILE_TOUCH_MIN = 44; /** * Primary chrome controls must meet 44×44px on touch viewports, either by box size or `rk-touch-target`. */ export async function assertPrimaryTouchTargets(page: Page, selectors: string[]) { for (const selector of selectors) { const locator = page.locator(selector).first(); await expect(locator, selector).toBeVisible(); const ok = await locator.evaluate((node, min) => { const rect = node.getBoundingClientRect(); if (rect.width >= min && rect.height >= min) return true; return node.classList.contains("rk-touch-target"); }, MOBILE_TOUCH_MIN); expect(ok, `${selector} touch target`).toBe(true); } }