import { expect, type Page } from '@playwright/test'; function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function getE2EDaemonPort(): string { const port = process.env.E2E_DAEMON_PORT; if (!port) { throw new Error('E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).'); } if (port === '6767') { throw new Error('E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.'); } return port; } async function ensureE2EStorageSeeded(page: Page): Promise { const port = getE2EDaemonPort(); const expectedEndpoint = `127.0.0.1:${port}`; const needsReset = await page.evaluate(({ expectedEndpoint }) => { const raw = localStorage.getItem('@paseo:daemon-registry'); if (!raw) return true; try { const parsed = JSON.parse(raw); if (!Array.isArray(parsed) || parsed.length !== 1) return true; const entry = parsed[0] as any; const endpoints = entry?.endpoints; if (!Array.isArray(endpoints)) return true; if (endpoints.some((e: unknown) => typeof e === 'string' && /:6767\b/.test(e))) return true; return !endpoints.some((e: unknown) => e === expectedEndpoint); } catch { return true; } }, { expectedEndpoint }); if (!needsReset) { return; } const nowIso = new Date().toISOString(); await page.evaluate( ({ expectedEndpoint, nowIso }) => { localStorage.setItem('@paseo:e2e', '1'); localStorage.setItem( '@paseo:daemon-registry', JSON.stringify([ { id: 'e2e-test-daemon', label: 'localhost', endpoints: [expectedEndpoint], relay: null, metadata: null, createdAt: nowIso, updatedAt: nowIso, }, ]) ); localStorage.setItem( '@paseo:create-agent-preferences', JSON.stringify({ serverId: 'e2e-test-daemon', provider: 'claude', providerPreferences: { claude: { model: 'haiku' }, codex: { model: 'gpt-5.1-codex-mini' }, }, }) ); localStorage.removeItem('@paseo:settings'); }, { expectedEndpoint, nowIso } ); await page.reload(); } async function assertE2EUsesSeededTestDaemon(page: Page): Promise { const port = getE2EDaemonPort(); const expectedEndpoint = `127.0.0.1:${port}`; const snapshot = await page.evaluate(() => { const registryRaw = localStorage.getItem('@paseo:daemon-registry'); const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences'); return { registryRaw, prefsRaw }; }); if (!snapshot.registryRaw) { throw new Error('E2E expected @paseo:daemon-registry to be set before app load.'); } let registry: any; try { registry = JSON.parse(snapshot.registryRaw); } catch { throw new Error('E2E expected @paseo:daemon-registry to be valid JSON.'); } if (!Array.isArray(registry) || registry.length !== 1) { throw new Error( `E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : 'non-array'}).` ); } const daemon = registry[0]; if (typeof daemon?.id !== 'string' || daemon.id.length === 0) { throw new Error(`E2E expected seeded daemon to have a string id (got ${String(daemon?.id)}).`); } const endpoints: unknown = daemon?.endpoints; if (!Array.isArray(endpoints) || !endpoints.some((e) => e === expectedEndpoint)) { throw new Error( `E2E expected seeded daemon endpoints to include ${expectedEndpoint} (got ${JSON.stringify(endpoints)}).` ); } if (Array.isArray(endpoints) && endpoints.some((e) => typeof e === 'string' && /:6767\b/.test(e))) { throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(endpoints)}).`); } if (!snapshot.prefsRaw) { throw new Error('E2E expected @paseo:create-agent-preferences to be set before app load.'); } try { const prefs = JSON.parse(snapshot.prefsRaw) as any; if (prefs?.serverId !== daemon.id) { throw new Error( `E2E expected create-agent-preferences.serverId to match seeded daemon id (${daemon.id}) (got ${String(prefs?.serverId)}).` ); } } catch (error) { if (error instanceof Error) throw error; throw new Error('E2E expected @paseo:create-agent-preferences to be valid JSON.'); } } export const gotoHome = async (page: Page) => { await page.goto('/'); await ensureE2EStorageSeeded(page); await expect(page.getByText('New Agent', { exact: true }).first()).toBeVisible(); await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible(); }; export const openSettings = async (page: Page) => { const settingsLink = page.getByText('Settings', { exact: true }).first(); await expect(settingsLink).toBeVisible(); await settingsLink.click(); await expect(page).toHaveURL(/\/settings$/); }; export const setWorkingDirectory = async (page: Page, directory: string) => { const workingDirectoryLabel = page.getByText('WORKING DIRECTORY', { exact: true }).first(); await expect(workingDirectoryLabel).toBeVisible(); const workingDirectorySelect = page.getByTestId('working-directory-select').first(); const input = page.getByRole('textbox', { name: '/path/to/project' }); const worktreePicker = page.getByTestId('worktree-attach-picker'); const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }); const closeBottomSheet = async () => { const bottomSheetBackdrop = page .getByRole('button', { name: 'Bottom sheet backdrop' }) .first(); const bottomSheetHandle = page .getByRole('slider', { name: 'Bottom sheet handle' }) .first(); for (let attempt = 0; attempt < 3; attempt += 1) { if (!(await bottomSheetBackdrop.isVisible())) { return; } await bottomSheetBackdrop.click({ force: true }); await page.keyboard.press('Escape').catch(() => undefined); await page.waitForTimeout(200); } if (await bottomSheetBackdrop.isVisible()) { const box = await bottomSheetHandle.boundingBox(); if (box) { const startX = box.x + box.width / 2; const startY = box.y + box.height / 2; await page.mouse.move(startX, startY); await page.mouse.down(); await page.mouse.move(startX, startY + 400); await page.mouse.up(); await page.waitForTimeout(200); } } }; const closeWorktreeSheetIfOpen = async () => { if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) { return; } const attachToggle = page.getByTestId('worktree-attach-toggle'); if (await attachToggle.isVisible()) { await attachToggle.click({ force: true }); await page.waitForTimeout(200); } await closeBottomSheet(); }; await closeWorktreeSheetIfOpen(); if (!(await input.isVisible())) { await closeBottomSheet(); await workingDirectorySelect.click({ force: true }); if (!(await input.isVisible())) { await closeBottomSheet(); await workingDirectorySelect.click({ force: true }); } await expect(input).toBeVisible(); } const trimmedDirectory = directory.replace(/\/+$/, ''); await input.fill(trimmedDirectory); await input.press('Enter'); // Desktop web supports selecting via Enter; mobile may require clicking the explicit option. const useOption = page.getByTestId('working-directory-custom-option').first(); if (await useOption.isVisible().catch(() => false)) { await expect(useOption).toContainText(`Use "${trimmedDirectory}"`); await useOption.click({ force: true }); } // Wait for the sheet to close after clicking "Use" await expect(input).not.toBeVisible({ timeout: 10000 }); const directoryCandidates = new Set([trimmedDirectory]); if (trimmedDirectory.startsWith('/var/')) { directoryCandidates.add(`/private${trimmedDirectory}`); } if (trimmedDirectory.startsWith('/private/var/')) { directoryCandidates.add(trimmedDirectory.replace(/^\/private/, '')); } const basename = trimmedDirectory.split('/').filter(Boolean).pop() ?? trimmedDirectory; await expect.poll(async () => { const text = await workingDirectorySelect.innerText().catch(() => ''); if (text.includes(basename)) return true; for (const candidate of directoryCandidates) { if (text.includes(candidate)) return true; } return false; }, { timeout: 30000 }).toBe(true); }; export const ensureHostSelected = async (page: Page) => { await ensureE2EStorageSeeded(page); // Absolute verification that we're using the per-run e2e daemon (never :6767). // Also self-heal a rare case where app code rewrites daemon IDs after boot, by // realigning create-agent-preferences.serverId to the sole seeded daemon. try { await assertE2EUsesSeededTestDaemon(page); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (!/create-agent-preferences\.serverId/i.test(message)) { throw error; } const fix = await page.evaluate(() => { const registryRaw = localStorage.getItem('@paseo:daemon-registry'); const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences'); if (!registryRaw || !prefsRaw) return { ok: false, reason: 'missing storage' } as const; const registry = JSON.parse(registryRaw) as any[]; const prefs = JSON.parse(prefsRaw) as any; if (!Array.isArray(registry) || registry.length !== 1) return { ok: false, reason: 'registry shape' } as const; const daemonId = registry[0]?.id; if (typeof daemonId !== 'string' || daemonId.length === 0) return { ok: false, reason: 'missing daemon id' } as const; prefs.serverId = daemonId; localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(prefs)); // Prevent the fixture's init-script from overwriting the corrected prefs on reload. const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1'; localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce); return { ok: true } as const; }); if (!fix.ok) { throw error; } await page.reload(); await assertE2EUsesSeededTestDaemon(page); } const input = page.getByRole('textbox', { name: 'Message agent...' }); await expect(input).toBeVisible(); if (await input.isEditable()) { return; } const selectHostLabel = page.getByText('Select host', { exact: true }); if (await selectHostLabel.isVisible()) { await selectHostLabel.click(); // E2E safety: we enforce a single seeded daemon, so the option should be unambiguous. const localhostOption = page.getByText('localhost', { exact: true }).first(); const daemonIdOption = page.getByText('e2e-test-daemon', { exact: true }).first(); if (await localhostOption.isVisible()) { await localhostOption.click(); } else { await expect(daemonIdOption).toBeVisible(); await daemonIdOption.click(); } } await expect(input).toBeEditable(); }; export const createAgent = async (page: Page, message: string) => { const input = page.getByRole('textbox', { name: 'Message agent...' }); await expect(input).toBeEditable(); await input.fill(message); await input.press('Enter'); // Expo Router navigations can be "same-document" updates, so avoid waiting for a full `load`. await page.waitForURL(/\/agent\//, { waitUntil: 'commit' }); const userBubble = page .getByRole('button', { name: 'Copy message' }) .filter({ hasText: message }) .first(); await expect(userBubble).toBeVisible(); }; export interface AgentConfig { directory: string; provider?: string; model?: string; mode?: string; prompt: string; } export const selectProvider = async (page: Page, provider: string) => { const providerLabel = page.getByText('PROVIDER', { exact: true }).first(); await expect(providerLabel).toBeVisible(); await providerLabel.click(); const option = page.getByText(provider, { exact: true }).first(); await expect(option).toBeVisible(); await option.click(); }; export const selectModel = async (page: Page, model: string) => { const modelLabel = page.getByText('MODEL', { exact: true }).first(); await expect(modelLabel).toBeVisible(); await modelLabel.click(); // Wait for the model dropdown to open const searchInput = page.getByRole('textbox', { name: /search model/i }); await expect(searchInput).toBeVisible({ timeout: 10000 }); // Type to search/filter models await searchInput.fill(model); const dialog = page.getByRole('dialog'); const option = dialog .getByText(new RegExp(`^${escapeRegex(model)}$`, 'i')) .first(); await expect(option).toBeVisible({ timeout: 30000 }); await option.click({ force: true }); // Wait for dropdown to close await expect(searchInput).not.toBeVisible({ timeout: 5000 }); }; export const selectMode = async (page: Page, mode: string) => { const modeLabel = page.getByText('MODE', { exact: true }).first(); await expect(modeLabel).toBeVisible(); await modeLabel.click(); // Wait for the mode dropdown to open const searchInput = page.getByRole('textbox', { name: /search mode/i }); await expect(searchInput).toBeVisible({ timeout: 10000 }); // Type to filter modes await searchInput.fill(mode); const dialog = page.getByRole('dialog'); const option = dialog .getByText(new RegExp(`^${escapeRegex(mode)}$`, 'i')) .first(); await expect(option).toBeVisible(); await option.click({ force: true }); // Wait for dropdown to close await expect(searchInput).not.toBeVisible({ timeout: 5000 }); }; export const createAgentWithConfig = async (page: Page, config: AgentConfig) => { await gotoHome(page); await ensureHostSelected(page); await setWorkingDirectory(page, config.directory); if (config.provider) { await selectProvider(page, config.provider); } if (config.model) { await selectModel(page, config.model); } if (config.mode) { await selectMode(page, config.mode); } await createAgent(page, config.prompt); }; export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => { const promptText = page.getByText('How would you like to proceed?').first(); await expect(promptText).toBeVisible({ timeout }); }; export const allowPermission = async (page: Page) => { const allowButton = page.getByText('Allow', { exact: true }).first(); await expect(allowButton).toBeVisible({ timeout: 5000 }); await allowButton.click(); }; export const denyPermission = async (page: Page) => { const denyButton = page.getByText('Deny', { exact: true }).first(); await expect(denyButton).toBeVisible({ timeout: 5000 }); await denyButton.click(); }; export async function waitForAgentIdle(page: Page, timeout = 30000) { const stopButton = page.getByRole('button', { name: /stop|cancel/i }); await expect(stopButton).not.toBeVisible({ timeout }); } export async function getToolCallCount(page: Page): Promise { // Tool calls are rendered as ExpandableBadge components with tool names like Bash, Write, Read, etc. // They appear as pressable badges in the agent stream const toolCallBadges = page.locator('[data-testid="tool-call-badge"]'); return toolCallBadges.count(); }