import { randomUUID } from "node:crypto"; import { expect, type Page } from "@playwright/test"; import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry"; import { loadDaemonClientConstructor } from "./daemon-client-loader"; import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory"; import { waitForWorkspaceTabsVisible } from "./workspace-tabs"; import { buildHostAgentDetailRoute, buildHostSessionsRoute, buildHostWorkspaceRoute, } from "@/utils/host-routes"; export interface ArchiveTabAgent { id: string; title: string; cwd: string; } interface ArchiveTabDaemonClient { connect(): Promise; close(): Promise; createAgent(options: { provider: string; model: string; thinkingOptionId?: string; modeId: string; cwd: string; title: string; initialPrompt?: string; }): Promise<{ id: string }>; archiveAgent(agentId: string): Promise<{ archivedAt: string }>; waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>; waitForAgentUpsert( agentId: string, predicate: (snapshot: { status: string }) => boolean, timeout?: number, ): Promise<{ status: string }>; fetchAgentHistory(options?: { page?: { limit: number }; }): Promise<{ entries: Array<{ id: string }> }>; } function getDaemonPort(): string { const daemonPort = process.env.E2E_DAEMON_PORT; if (!daemonPort) { throw new Error("E2E_DAEMON_PORT is not set."); } if (daemonPort === "6767") { throw new Error("E2E_DAEMON_PORT must not point at the developer daemon."); } return daemonPort; } function getServerId(): string { const serverId = process.env.E2E_SERVER_ID; if (!serverId) { throw new Error("E2E_SERVER_ID is not set."); } return serverId; } function getDaemonWsUrl(): string { return `ws://127.0.0.1:${getDaemonPort()}/ws`; } function buildSeededStoragePayload() { const nowIso = new Date().toISOString(); return { daemon: buildSeededHost({ serverId: getServerId(), endpoint: `127.0.0.1:${getDaemonPort()}`, nowIso, }), preferences: buildCreateAgentPreferences(getServerId()), }; } interface ArchiveTabDaemonClientConfig { url: string; clientId: string; clientType: "cli"; webSocketFactory?: NodeWebSocketFactory; } export async function connectArchiveTabDaemonClient(): Promise { const DaemonClient = await loadDaemonClientConstructor< ArchiveTabDaemonClientConfig, ArchiveTabDaemonClient >(); const webSocketFactory = createNodeWebSocketFactory(); const client = new DaemonClient({ url: getDaemonWsUrl(), clientId: `app-e2e-archive-tab-${randomUUID()}`, clientType: "cli", webSocketFactory, }); await client.connect(); return client; } export async function createIdleAgent( client: ArchiveTabDaemonClient, input: { cwd: string; title: string }, ): Promise { const created = await client.createAgent({ provider: "opencode", model: "opencode/gpt-5-nano", modeId: "bypassPermissions", cwd: input.cwd, title: input.title, }); const snapshot = await client.waitForAgentUpsert( created.id, (agent) => agent.status === "idle", 30_000, ); if (snapshot.status !== "idle") { throw new Error(`Expected agent ${created.id} to become idle, got ${snapshot.status}.`); } return { id: created.id, title: input.title, cwd: input.cwd, }; } export async function archiveAgentFromDaemon( client: ArchiveTabDaemonClient, agentId: string, ): Promise { await client.archiveAgent(agentId); } export async function primeAdditionalPage(page: Page): Promise { const seedNonce = randomUUID(); const { daemon, preferences } = buildSeededStoragePayload(); await page.route(/:(6767)\b/, (route) => route.abort()); await page.routeWebSocket(/:(6767)\b/, async (ws) => { await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." }); }); await page.addInitScript( ({ daemon: seededDaemon, preferences: seededPreferences, seedNonce: nonce }) => { const disableOnceKey = "@paseo:e2e-disable-default-seed-once"; const disableValue = localStorage.getItem(disableOnceKey); if (disableValue) { localStorage.removeItem(disableOnceKey); if (disableValue === nonce) { return; } } localStorage.setItem("@paseo:e2e", "1"); localStorage.setItem("@paseo:e2e-seed-nonce", nonce); localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon])); localStorage.removeItem("@paseo:settings"); localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences)); }, { daemon, preferences, seedNonce }, ); await page.goto("/"); } export async function resetSeededPageState(page: Page): Promise { const { daemon, preferences } = buildSeededStoragePayload(); await page.goto("/"); await page.evaluate( ({ daemon: seededDaemon, preferences: seededPreferences }) => { localStorage.clear(); localStorage.setItem("@paseo:e2e", "1"); localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon])); localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences)); localStorage.removeItem("@paseo:settings"); }, { daemon, preferences }, ); await page.goto("/"); } export async function openWorkspaceWithAgents( page: Page, agents: [ArchiveTabAgent, ArchiveTabAgent], ): Promise { const serverId = getServerId(); for (const agent of agents) { await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd)); // The workspace layout consumes `?open=agent:xxx`, returns null during the effect, // then replaces the URL with the clean workspace route after preparing the tab. // On CI, Expo Router's rootNavigationState may take time to initialize, // so we allow a generous timeout here (matching terminal-perf pattern). await page.waitForURL( (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), { timeout: 60_000 }, ); await waitForWorkspaceTabsVisible(page); await expectWorkspaceTabVisible(page, agent.id); } } export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise { await expect( page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first(), ).toBeVisible({ timeout: 30_000 }); } export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise { await expect( page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }), ).toHaveCount(0, { timeout: 30_000, }); } export async function expectWorkspaceArchiveOutcome( page: Page, input: { archivedAgentId: string; survivingAgentId: string }, ): Promise { await expectWorkspaceTabHidden(page, input.archivedAgentId); await expectWorkspaceTabVisible(page, input.survivingAgentId); } export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise { const closeButton = page.getByTestId(`workspace-agent-close-${agentId}`).filter({ visible: true, }); await expect(closeButton.first()).toBeVisible({ timeout: 30_000 }); await closeButton.first().click(); await expectWorkspaceTabHidden(page, agentId); } export async function expectArchivedAgentFocused(page: Page, agentId: string): Promise { await expectWorkspaceTabVisible(page, agentId); await expect( page.getByText("This agent is archived").filter({ visible: true }).first(), ).toBeVisible({ timeout: 30_000, }); } export async function reloadWorkspace(page: Page, workspaceId: string): Promise { const serverId = getServerId(); await page.goto(buildHostWorkspaceRoute(serverId, workspaceId)); await waitForWorkspaceTabsVisible(page); } export async function openSessions(page: Page): Promise { const sessionsButton = page.getByTestId("sidebar-sessions"); await expect(sessionsButton).toBeVisible({ timeout: 30_000 }); await sessionsButton.click(); await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), { timeout: 30_000, }); await expect(page.getByText("Sessions", { exact: true }).last()).toBeVisible({ timeout: 30_000, }); } const AGENT_ROW_SELECTOR = '[data-testid^="agent-row-"]'; function getSessionRowByTitle(page: Page, title: string) { return page.locator(AGENT_ROW_SELECTOR).filter({ hasText: title }).first(); } export async function expectSessionRowVisible(page: Page, title: string): Promise { await expect(getSessionRowByTitle(page, title)).toBeVisible({ timeout: 30_000 }); } export async function expectSessionRowArchived(page: Page, title: string): Promise { await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 }); } export async function clickSessionRow(page: Page, title: string): Promise { const row = getSessionRowByTitle(page, title); await expect(row).toBeVisible({ timeout: 30_000 }); await row.click(); } export async function expectSessionsEmptyState(page: Page): Promise { // Guard: if session rows appear, a prior spec polluted the shared daemon — see 00-sessions-empty.spec.ts. await expect(page.locator(AGENT_ROW_SELECTOR)).toHaveCount(0, { timeout: 5_000 }); await expect(page.getByText("No sessions yet")).toBeVisible({ timeout: 30_000 }); } export async function archiveAgentFromSessions( page: Page, input: { agentId: string; title: string }, ): Promise { const row = getSessionRowByTitle(page, input.title); await expect(row).toBeVisible({ timeout: 30_000 }); const box = await row.boundingBox(); if (!box) { throw new Error(`Could not read bounding box for session row ${input.agentId}.`); } // Long-press the row. Idle agents are archived immediately (no modal). // Running/initializing agents show a confirmation modal instead. await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); await page.waitForTimeout(900); await page.mouse.up(); // If a confirmation modal appears (running agent), click the archive button. const archiveButton = page.getByTestId("agent-action-archive").first(); const modalVisible = await archiveButton.isVisible().catch(() => false); if (modalVisible) { await archiveButton.click(); } await expectSessionRowArchived(page, input.title); }