diff --git a/packages/app/e2e/00-sessions-empty.spec.ts b/packages/app/e2e/00-sessions-empty.spec.ts index d08e6d55b..48cbb9e87 100644 --- a/packages/app/e2e/00-sessions-empty.spec.ts +++ b/packages/app/e2e/00-sessions-empty.spec.ts @@ -2,15 +2,12 @@ // Sessions history is daemon-global — any agent created by a prior spec hides the empty state. // If the beforeAll probe below fails, a spec sorted before this file is creating agents. import { test } from "./fixtures"; -import { - connectArchiveTabDaemonClient, - expectSessionsEmptyState, - openSessions, -} from "./helpers/archive-tab"; +import { connectSeedClient } from "./helpers/seed-client"; +import { expectSessionsEmptyState, openSessions } from "./helpers/archive-tab"; test.describe("Sessions screen empty state", () => { test.beforeAll(async () => { - const client = await connectArchiveTabDaemonClient(); + const client = await connectSeedClient(); try { const history = await client.fetchAgentHistory({ page: { limit: 1 } }); if (history.entries.length > 0) { diff --git a/packages/app/e2e/acp-provider-catalog.spec.ts b/packages/app/e2e/acp-provider-catalog.spec.ts index 9332bf5cc..55b24add5 100644 --- a/packages/app/e2e/acp-provider-catalog.spec.ts +++ b/packages/app/e2e/acp-provider-catalog.spec.ts @@ -1,5 +1,6 @@ import { test } from "./fixtures"; import { gotoAppShell, openSettings } from "./helpers/app"; +import { getServerId } from "./helpers/server-id"; import { expectProviderInstalledInSettings, installAcpCatalogProvider, @@ -12,19 +13,11 @@ const ACP_PROVIDER = { name: "Hermes", }; -function getSeededServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); - } - return serverId; -} - test.describe("ACP provider catalog", () => { test("adds a catalog provider from settings", async ({ page }) => { await gotoAppShell(page); await openSettings(page); - await openSettingsHost(page, getSeededServerId()); + await openSettingsHost(page, getServerId()); await openAddProviderModal(page); await installAcpCatalogProvider(page, ACP_PROVIDER.name); diff --git a/packages/app/e2e/archive-tab.spec.ts b/packages/app/e2e/archive-tab.spec.ts index 813627816..58ac6f6c1 100644 --- a/packages/app/e2e/archive-tab.spec.ts +++ b/packages/app/e2e/archive-tab.spec.ts @@ -1,12 +1,12 @@ import { randomUUID } from "node:crypto"; import { test } from "./fixtures"; +import { connectSeedClient } from "./helpers/seed-client"; import { createTempGitRepo } from "./helpers/workspace"; import { archiveAgentFromDaemon, archiveAgentFromSessions, clickSessionRow, closeWorkspaceAgentTab, - connectArchiveTabDaemonClient, createIdleAgent, expectArchivedAgentFocused, expectSessionRowArchived, @@ -21,14 +21,14 @@ import { } from "./helpers/archive-tab"; test.describe("Archive tab reconciliation", () => { - let client: Awaited>; + let client: Awaited>; let tempRepo: { path: string; cleanup: () => Promise }; test.describe.configure({ timeout: 300_000 }); test.beforeAll(async () => { tempRepo = await createTempGitRepo("archive-tab-"); - client = await connectArchiveTabDaemonClient(); + client = await connectSeedClient(); }); test.afterAll(async () => { diff --git a/packages/app/e2e/client-slash-commands.spec.ts b/packages/app/e2e/client-slash-commands.spec.ts index 9954b01cd..2937a9f34 100644 --- a/packages/app/e2e/client-slash-commands.spec.ts +++ b/packages/app/e2e/client-slash-commands.spec.ts @@ -1,8 +1,6 @@ import { expect, test, type Page } from "./fixtures"; -import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { composerLocator, expectComposerVisible, submitMessage } from "./helpers/composer"; -import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf"; -import { createTempGitRepo } from "./helpers/workspace"; +import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; import { expectSessionRowArchived, expectWorkspaceTabHidden, @@ -11,22 +9,12 @@ import { } from "./helpers/archive-tab"; interface SlashCommandScenario { - agent: { id: string }; - client: TerminalPerfDaemonClient; - cwd: string; + agentId: string; title: string; } const REPLACEMENT_PROMPT = "Replacement prompt after slash clear."; -function getServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } - return serverId; -} - async function withOpenReadyMockAgent( page: Page, input: { @@ -36,68 +24,23 @@ async function withOpenReadyMockAgent( }, run: (scenario: SlashCommandScenario) => Promise, ): Promise { - const repo = await createTempGitRepo("client-slash-command-"); - const client = await connectTerminalClient(); - - try { - await openProject(client, repo.path); - const agent = await createReadyMockAgent(client, { - cwd: repo.path, - title: input.title, - model: input.model, - modeId: input.modeId, - }); - await openActiveAgentTab(page, { cwd: repo.path, agentId: agent.id }); - - await run({ agent, client, cwd: repo.path, title: input.title }); - } finally { - await client.close(); - await repo.cleanup(); - } -} - -async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise { - const opened = await client.openProject(cwd); - if (!opened.workspace) { - throw new Error(opened.error ?? `Failed to open project ${cwd}`); - } -} - -async function createReadyMockAgent( - client: TerminalPerfDaemonClient, - input: { - cwd: string; - title: string; - model?: string; - modeId?: string; - }, -): Promise<{ id: string }> { - const agent = await client.createAgent({ - provider: "mock", - cwd: input.cwd, + const session = await seedMockAgentWorkspace({ + repoPrefix: "client-slash-command-", title: input.title, - modeId: input.modeId ?? "load-test", - model: input.model ?? "ten-second-stream", + model: input.model, + modeId: input.modeId, initialPrompt: "Prepare a client slash command test agent.", }); - return { id: agent.id }; -} -async function openActiveAgentTab( - page: Page, - input: { cwd: string; agentId: string }, -): Promise { - const agentUrl = `${buildHostWorkspaceRoute( - getServerId(), - input.cwd, - )}?open=${encodeURIComponent(`agent:${input.agentId}`)}`; - await page.goto(agentUrl); - await page.waitForURL( - (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), - { timeout: 60_000 }, - ); - await expectWorkspaceTabVisible(page, input.agentId); - await expectComposerVisible(page); + try { + await openAgentRoute(page, session); + await expectWorkspaceTabVisible(page, session.agentId); + await expectComposerVisible(page); + + await run({ agentId: session.agentId, title: input.title }); + } finally { + await session.cleanup(); + } } async function runClientSlashCommand(page: Page, command: "/quit" | "/clear"): Promise { @@ -169,9 +112,9 @@ async function waitForReplacementAgentId(page: Page, oldAgentId: string): Promis test.describe("Client slash commands", () => { test("slash quit archives the active agent and removes its tab", async ({ page }) => { - await withOpenReadyMockAgent(page, { title: "Slash quit e2e" }, async ({ agent, title }) => { + await withOpenReadyMockAgent(page, { title: "Slash quit e2e" }, async ({ agentId, title }) => { await runClientSlashCommand(page, "/quit"); - await expectWorkspaceTabHidden(page, agent.id); + await expectWorkspaceTabHidden(page, agentId); await expectAgentArchivedInSessions(page, title); }); }); @@ -180,9 +123,9 @@ test.describe("Client slash commands", () => { await withOpenReadyMockAgent( page, { title: "Slash quit autocomplete e2e" }, - async ({ agent, title }) => { + async ({ agentId, title }) => { await selectClientSlashCommand(page, "/qu", "/exit"); - await expectWorkspaceTabHidden(page, agent.id); + await expectWorkspaceTabHidden(page, agentId); await expectAgentArchivedInSessions(page, title); }, ); @@ -192,12 +135,12 @@ test.describe("Client slash commands", () => { await withOpenReadyMockAgent( page, { title: "Slash clear e2e", model: "ten-second-stream", modeId: "load-test" }, - async ({ agent, title }) => { + async ({ agentId, title }) => { await runClientSlashCommand(page, "/clear"); - await expectWorkspaceTabHidden(page, agent.id); + await expectWorkspaceTabHidden(page, agentId); await expectReplacementDraftMatchesPreviousSetup(page); await createAgentFromReplacementDraft(page); - await waitForReplacementAgentId(page, agent.id); + await waitForReplacementAgentId(page, agentId); await expectAgentArchivedInSessions(page, title); }, ); diff --git a/packages/app/e2e/codex-plan-approval.spec.ts b/packages/app/e2e/codex-plan-approval.spec.ts index 616f2168c..ff2c9aedc 100644 --- a/packages/app/e2e/codex-plan-approval.spec.ts +++ b/packages/app/e2e/codex-plan-approval.spec.ts @@ -1,16 +1,6 @@ import { expect, test } from "./fixtures"; -import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions"; -import { connectTerminalClient } from "./helpers/terminal-perf"; -import { createTempGitRepo } from "./helpers/workspace"; - -function getServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } - return serverId; -} +import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; test.describe("Codex plan approval", () => { test("shows a single actionable plan panel and removes it after implementation starts", async ({ @@ -18,29 +8,14 @@ test.describe("Codex plan approval", () => { }) => { test.setTimeout(180_000); - const repo = await createTempGitRepo("codex-plan-approval-"); - const client = await connectTerminalClient(); + const session = await seedMockAgentWorkspace({ + repoPrefix: "codex-plan-approval-", + title: "Codex plan approval e2e", + initialPrompt: "Emit synthetic plan approval.", + }); try { - const workspaceResult = await client.openProject(repo.path); - if (!workspaceResult.workspace) { - throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`); - } - - const agent = await client.createAgent({ - provider: "mock", - cwd: repo.path, - title: "Codex plan approval e2e", - modeId: "load-test", - model: "ten-second-stream", - initialPrompt: "Emit synthetic plan approval.", - }); - - const agentUrl = `${buildHostWorkspaceRoute( - getServerId(), - repo.path, - )}?open=${encodeURIComponent(`agent:${agent.id}`)}`; - await page.goto(agentUrl); + await openAgentRoute(page, session); await waitForPermissionPrompt(page, 120_000); @@ -54,8 +29,7 @@ test.describe("Codex plan approval", () => { }); await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0); } finally { - await client.close(); - await repo.cleanup(); + await session.cleanup(); } }); }); diff --git a/packages/app/e2e/composer-attachments.spec.ts b/packages/app/e2e/composer-attachments.spec.ts index a7d20cdfa..14460763d 100644 --- a/packages/app/e2e/composer-attachments.spec.ts +++ b/packages/app/e2e/composer-attachments.spec.ts @@ -23,16 +23,12 @@ import { expectGithubAttachmentPill, openGithubWorkspace, } from "./helpers/composer"; -import { - connectNewWorkspaceDaemonClient, - delayBrowserAgentCreatedStatus, - openNewWorkspaceComposer, - openProjectViaDaemon, -} from "./helpers/new-workspace"; +import { delayBrowserAgentCreatedStatus, openNewWorkspaceComposer } from "./helpers/new-workspace"; import { gotoAppShell } from "./helpers/app"; import { waitForSidebarHydration, switchWorkspaceViaSidebar } from "./helpers/workspace-ui"; -import { createTempGitRepo } from "./helpers/workspace"; +import { seedWorkspace } from "./helpers/seed-client"; import { hasGithubAuth, createTempGithubRepo } from "./helpers/github-fixtures"; +import { getServerId } from "./helpers/server-id"; const MINIMAL_PNG = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", @@ -237,27 +233,23 @@ test.describe("Composer attachments", () => { test("composer is locked while new workspace agent is being created", async ({ page }) => { test.setTimeout(120_000); - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) throw new Error("E2E_SERVER_ID is not set."); + const serverId = getServerId(); - const repo = await createTempGitRepo("attach-lock-"); const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page); - const daemonClient = await connectNewWorkspaceDaemonClient(); + const workspace = await seedWorkspace({ repoPrefix: "attach-lock-" }); try { - const openedProject = await openProjectViaDaemon(daemonClient, repo.path); - await gotoAppShell(page); await waitForSidebarHydration(page); await switchWorkspaceViaSidebar({ page, serverId, - targetWorkspacePath: openedProject.workspaceId, + targetWorkspacePath: workspace.workspaceId, }); await openNewWorkspaceComposer(page, { - projectKey: openedProject.projectKey, - projectDisplayName: openedProject.projectDisplayName, + projectKey: workspace.projectId, + projectDisplayName: workspace.projectDisplayName, }); await fillComposerDraft(page, "lock test prompt"); const createButton = page @@ -277,8 +269,7 @@ test.describe("Composer attachments", () => { await expectComposerEditable(page); } finally { agentCreatedDelay.release(); - await daemonClient.close().catch(() => undefined); - await repo.cleanup(); + await workspace.cleanup(); } }); }); diff --git a/packages/app/e2e/composer-autocomplete.spec.ts b/packages/app/e2e/composer-autocomplete.spec.ts index 7522e199a..c7d80e857 100644 --- a/packages/app/e2e/composer-autocomplete.spec.ts +++ b/packages/app/e2e/composer-autocomplete.spec.ts @@ -1,9 +1,8 @@ -import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { expect, test, type Page } from "./fixtures"; import { composerLocator, expectComposerVisible } from "./helpers/composer"; -import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf"; -import { createTempGitRepo } from "./helpers/workspace"; +import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; import { expectWorkspaceTabVisible } from "./helpers/archive-tab"; +import { daemonWsRoutePattern } from "./helpers/daemon-port"; const TEST_COMMANDS = [ { @@ -99,28 +98,8 @@ async function getTopTestIdAtPoint(page: Page, x: number, y: number) { ); } -function getServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } - return serverId; -} - -function getDaemonPort(): string { - const daemonPort = process.env.E2E_DAEMON_PORT; - if (!daemonPort || daemonPort === "6767") { - throw new Error("E2E_DAEMON_PORT must point at the isolated e2e daemon."); - } - return daemonPort; -} - -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - async function installListCommandsStub(page: Page): Promise { - await page.routeWebSocket(new RegExp(`:${escapeRegex(getDaemonPort())}\\b`), (ws) => { + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { const server = ws.connectToServer(); ws.onMessage((message) => { @@ -163,58 +142,26 @@ async function installListCommandsStub(page: Page): Promise { }); } -async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise { - const opened = await client.openProject(cwd); - if (!opened.workspace) { - throw new Error(opened.error ?? `Failed to open project ${cwd}`); - } -} - -async function cleanupWithin(timeoutMs: number, cleanup: () => Promise): Promise { - const operation = cleanup().catch(() => undefined); - await Promise.race([operation, new Promise((resolve) => setTimeout(resolve, timeoutMs))]); -} - async function openReadyMockAgent( page: Page, options?: { expectWorkspaceTab?: boolean }, ): Promise<{ cleanup: () => Promise; }> { - const repo = await createTempGitRepo("autocomplete-popover-"); - const client = await connectTerminalClient(); + const session = await seedMockAgentWorkspace({ + repoPrefix: "autocomplete-popover-", + title: "Autocomplete popover regression", + }); try { - await openProject(client, repo.path); - const agent = await client.createAgent({ - provider: "mock", - cwd: repo.path, - title: "Autocomplete popover regression", - modeId: "load-test", - model: "ten-second-stream", - }); - const route = `${buildHostWorkspaceRoute( - getServerId(), - repo.path, - )}?open=${encodeURIComponent(`agent:${agent.id}`)}`; - await page.goto(route); - await page.waitForURL( - (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), - { timeout: 60_000 }, - ); + await openAgentRoute(page, session); if (options?.expectWorkspaceTab !== false) { - await expectWorkspaceTabVisible(page, agent.id); + await expectWorkspaceTabVisible(page, session.agentId); } await expectComposerVisible(page); - return { - cleanup: async () => { - await cleanupWithin(2_000, () => client.close()); - await cleanupWithin(2_000, () => repo.cleanup()); - }, - }; + return { cleanup: session.cleanup }; } catch (error) { - await cleanupWithin(2_000, () => client.close()); - await cleanupWithin(2_000, () => repo.cleanup()); + await session.cleanup(); throw error; } } diff --git a/packages/app/e2e/desktop-updates.spec.ts b/packages/app/e2e/desktop-updates.spec.ts index 8198b4988..bf475cd8d 100644 --- a/packages/app/e2e/desktop-updates.spec.ts +++ b/packages/app/e2e/desktop-updates.spec.ts @@ -1,5 +1,6 @@ import { test } from "./fixtures"; import { gotoAppShell } from "./helpers/app"; +import { getServerId } from "./helpers/server-id"; import { loadRealDaemonState, injectDesktopBridge, @@ -17,20 +18,12 @@ import { expectDaemonStatusVersion, } from "./helpers/desktop-updates"; -function getSeededServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); - } - return serverId; -} - // No Playwright Electron runner exists; we simulate the desktop bridge via // addInitScript so Electron-gated UI activates without a real Electron process. test.describe("Desktop updates", () => { test("update banner appears in the sidebar when an app update is available", async ({ page }) => { await injectDesktopBridge(page, { - serverId: getSeededServerId(), + serverId: getServerId(), updateAvailable: true, latestVersion: "1.2.3", }); @@ -41,7 +34,7 @@ test.describe("Desktop updates", () => { test("clicking install shows the installing state on the callout", async ({ page }) => { await injectDesktopBridge(page, { - serverId: getSeededServerId(), + serverId: getServerId(), updateAvailable: true, latestVersion: "1.2.3", slowInstall: true, @@ -58,7 +51,7 @@ test.describe("Desktop daemon management", () => { test("disabling built-in daemon management shows confirm dialog with correct copy", async ({ page, }) => { - const serverId = getSeededServerId(); + const serverId = getServerId(); await injectDesktopBridge(page, { serverId, manageBuiltInDaemon: true, @@ -74,7 +67,7 @@ test.describe("Desktop daemon management", () => { }); test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => { - const serverId = getSeededServerId(); + const serverId = getServerId(); await injectDesktopBridge(page, { serverId, manageBuiltInDaemon: true, @@ -89,7 +82,7 @@ test.describe("Desktop daemon management", () => { }); test("confirming the dialog disables built-in daemon management", async ({ page }) => { - const serverId = getSeededServerId(); + const serverId = getServerId(); await injectDesktopBridge(page, { serverId, manageBuiltInDaemon: true, @@ -106,7 +99,7 @@ test.describe("Desktop daemon management", () => { test("daemon status panel renders version, PID, and log path from the real daemon", async ({ page, }) => { - const serverId = getSeededServerId(); + const serverId = getServerId(); const realState = await loadRealDaemonState(); await injectDesktopBridge(page, { serverId, @@ -124,7 +117,7 @@ test.describe("Desktop daemon management", () => { }); test("stopping and restarting the daemon updates the PID", async ({ page }) => { - const serverId = getSeededServerId(); + const serverId = getServerId(); const realState = await loadRealDaemonState(); await injectDesktopBridge(page, { serverId, diff --git a/packages/app/e2e/file-explorer-collapse.spec.ts b/packages/app/e2e/file-explorer-collapse.spec.ts index 67ed4cc3a..830b63a6b 100644 --- a/packages/app/e2e/file-explorer-collapse.spec.ts +++ b/packages/app/e2e/file-explorer-collapse.spec.ts @@ -9,41 +9,31 @@ import { openFileFromExplorer, } from "./helpers/file-explorer"; import { gotoWorkspace } from "./helpers/launcher"; -import { createTempGitRepo } from "./helpers/workspace"; -import { - connectWorkspaceSetupClient, - type WorkspaceSetupDaemonClient, -} from "./helpers/workspace-setup"; +import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; -let tempRepo: { path: string; cleanup: () => Promise }; -let workspaceId: string; -let seedClient: WorkspaceSetupDaemonClient; +let workspace: SeededWorkspace; test.beforeAll(async () => { - tempRepo = await createTempGitRepo("file-explorer-collapse-", { - files: [ - { path: "assets/logo.png", content: "image bytes for explorer e2e\n" }, - { path: "docs/guide.md", content: "# Guide\n" }, - ], + workspace = await seedWorkspace({ + repoPrefix: "file-explorer-collapse-", + repo: { + files: [ + { path: "assets/logo.png", content: "image bytes for explorer e2e\n" }, + { path: "docs/guide.md", content: "# Guide\n" }, + ], + }, }); - seedClient = await connectWorkspaceSetupClient(); - const result = await seedClient.openProject(tempRepo.path); - if (!result.workspace) { - throw new Error(result.error ?? "Failed to seed workspace"); - } - workspaceId = result.workspace.id; }); test.afterAll(async () => { - await seedClient?.close(); - await tempRepo?.cleanup(); + await workspace?.cleanup(); }); test.describe("File explorer collapse", () => { test("collapses an opened image file parent folder and still expands other folders", async ({ page, }) => { - await gotoWorkspace(page, workspaceId); + await gotoWorkspace(page, workspace.workspaceId); await openFileExplorer(page); await expandFolder(page, "assets"); diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 9c551ac00..0cf61ba21 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -1,4 +1,5 @@ import { test as base, expect, type Page } from "@playwright/test"; +import { getE2EDaemonPort } from "./helpers/daemon-port"; import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry"; import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace"; @@ -17,20 +18,8 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>( }, paseoE2ESetup: [ async ({ page }, provide, testInfo) => { - const daemonPort = process.env.E2E_DAEMON_PORT; + const daemonPort = getE2EDaemonPort(); const metroPort = process.env.E2E_METRO_PORT; - if (!daemonPort) { - throw new Error( - "E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " + - "Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.", - ); - } - if (daemonPort === "6767") { - throw new Error( - "E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " + - "Fix Playwright globalSetup to start an isolated test daemon and export its port.", - ); - } if (!metroPort) { throw new Error( "E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.", diff --git a/packages/app/e2e/helpers/agent-bottom-anchor.ts b/packages/app/e2e/helpers/agent-bottom-anchor.ts index 61362ec1c..a7ff60305 100644 --- a/packages/app/e2e/helpers/agent-bottom-anchor.ts +++ b/packages/app/e2e/helpers/agent-bottom-anchor.ts @@ -1,8 +1,4 @@ import { expect, type Page } from "@playwright/test"; -import { randomUUID } from "node:crypto"; -import { loadDaemonClientConstructor } from "./daemon-client-loader"; -import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory"; -import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; const NEAR_BOTTOM_THRESHOLD_PX = 72; @@ -13,147 +9,6 @@ export interface ScrollMetrics { distanceFromBottom: number; } -export interface SeededAgent { - id: string; - title: string; - expectedTailText: string; - url: string; - workspaceUrl: string; -} - -export interface DaemonClientInstance { - connect(): Promise; - close(): Promise; - createAgent(options: { - provider: string; - model: string; - thinkingOptionId: string; - modeId: string; - cwd: string; - title: string; - initialPrompt: string; - }): Promise<{ id: string }>; - sendAgentMessage(agentId: string, text: string): Promise; - waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>; -} - -function getDaemonWsUrl(): string { - const daemonPort = process.env.E2E_DAEMON_PORT; - if (!daemonPort) { - throw new Error("E2E_DAEMON_PORT is not set."); - } - return `ws://127.0.0.1:${daemonPort}/ws`; -} - -function getServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } - return serverId; -} - -function buildReplyBlock(label: string, lineCount = 14): string { - return Array.from({ length: lineCount }, (_, index) => { - const line = (index + 1).toString().padStart(2, "0"); - return `${label} line ${line} anchor verification text keeps wrapping stable across resize and composer growth.`; - }).join("\n"); -} - -function buildProtocolMessage(label: string, lineCount = 14): string { - return [ - "For every message in this chat, reply with exactly the text after the final line `REPLY:`.", - "Do not add extra words, bullets, markdown fences, or tool calls.", - "REPLY:", - buildReplyBlock(label, lineCount), - ].join("\n"); -} - -function buildReplyMessage(label: string, lineCount = 14): string { - return ["REPLY:", buildReplyBlock(label, lineCount)].join("\n"); -} - -export function createReplyTurn(label: string): { - message: string; - expectedReply: string; -} { - return { - message: buildReplyMessage(label), - expectedReply: buildReplyBlock(label), - }; -} - -interface DaemonClientConfig { - url: string; - clientId: string; - clientType: "cli"; - webSocketFactory?: NodeWebSocketFactory; -} - -export async function connectDaemonClient(): Promise { - const DaemonClient = await loadDaemonClientConstructor< - DaemonClientConfig, - DaemonClientInstance - >(); - const webSocketFactory = createNodeWebSocketFactory(); - const client = new DaemonClient({ - url: getDaemonWsUrl(), - clientId: `app-e2e-${randomUUID()}`, - clientType: "cli", - webSocketFactory, - }); - await client.connect(); - return client; -} - -export async function seedBottomAnchorAgent(input: { - client: DaemonClientInstance; - cwd: string; - title?: string; - turnCount?: number; - lineCount?: number; -}): Promise { - const title = input.title ?? `bottom-anchor-${Date.now()}`; - const turnCount = Math.max(3, input.turnCount ?? 5); - const lineCount = Math.max(14, input.lineCount ?? 14); - const created = await input.client.createAgent({ - provider: "codex", - model: "gpt-5.4-mini", - thinkingOptionId: "low", - modeId: "full-access", - cwd: input.cwd, - title, - initialPrompt: buildProtocolMessage(`${title}-turn-00`, lineCount), - }); - const initialFinish = await input.client.waitForFinish(created.id, 120000); - if (initialFinish.status !== "idle") { - throw new Error( - `Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`, - ); - } - - let expectedTailText = buildReplyBlock(`${title}-turn-00`, lineCount); - for (let index = 1; index < turnCount; index += 1) { - const label = `${title}-turn-${index.toString().padStart(2, "0")}`; - expectedTailText = buildReplyBlock(label, lineCount); - await input.client.sendAgentMessage(created.id, buildReplyMessage(label, lineCount)); - const finish = await input.client.waitForFinish(created.id, 120000); - if (finish.status !== "idle") { - throw new Error( - `Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`, - ); - } - } - - return { - id: created.id, - title, - expectedTailText, - url: `${buildHostWorkspaceRoute(getServerId(), input.cwd)}?open=${encodeURIComponent(`agent:${created.id}`)}`, - workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd), - }; -} - function getVisibleChatScroll(page: Page) { return page.locator('[data-testid="agent-chat-scroll"]:visible').first(); } @@ -190,48 +45,6 @@ export async function readScrollMetrics(page: Page): Promise { }); } -export async function scrollUpFromBottom(page: Page, pixels: number): Promise { - const scrollViewport = getVisibleChatScroll(page); - await expect(scrollViewport).toHaveCount(1, { timeout: 30000 }); - let remaining = Math.max(0, pixels); - while (remaining > 0) { - const delta = Math.min(240, remaining); - await scrollViewport.evaluate((element: Element, step: number) => { - const scrollContainer = element as HTMLElement; - scrollContainer.dispatchEvent( - new WheelEvent("wheel", { - deltaY: -step, - bubbles: true, - cancelable: true, - }), - ); - scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step); - scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true })); - }, delta); - remaining -= delta; - - if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) { - return; - } - } -} - -export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise { - await expect(getVisibleChatScroll(page)).toBeVisible({ timeout: 60000 }); - await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({ - timeout: 60000, - }); - await expect(page.getByTestId("agent-loading")).toHaveCount(0, { timeout: 60000 }); - if (expectedTailText) { - await expect - .poll(async () => { - const metrics = await readScrollMetrics(page); - return metrics.contentHeight; - }) - .toBeGreaterThan(0); - } -} - export async function expectNearBottom(page: Page): Promise { await expect .poll(async () => { @@ -241,15 +54,6 @@ export async function expectNearBottom(page: Page): Promise { .toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX); } -export async function expectDetachedFromBottom(page: Page): Promise { - await expect - .poll(async () => { - const metrics = await readScrollMetrics(page); - return metrics.distanceFromBottom; - }) - .toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX); -} - export async function waitForContentGrowth( page: Page, previousContentHeight: number, @@ -262,11 +66,3 @@ export async function waitForContentGrowth( .toBeGreaterThan(previousContentHeight); return readScrollMetrics(page); } - -export async function getChatContainerKey(page: Page): Promise { - return getVisibleChatScroll(page).evaluate((element) => { - const nativeId = (element as HTMLElement).id; - const prefix = "agent-chat-scroll-"; - return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null; - }); -} diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index cf94c1b30..a0d49e967 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -1,8 +1,5 @@ import { expect, type Page } from "@playwright/test"; - -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} +import { escapeRegex } from "./regex"; export const gotoAppShell = async (page: Page) => { await page.goto("/"); diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index bda873d9e..ec0ef57b7 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -1,8 +1,8 @@ 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 { getE2EDaemonPort } from "./daemon-port"; +import { getServerId } from "./server-id"; import { waitForWorkspaceTabsVisible } from "./workspace-tabs"; import { buildHostAgentDetailRoute, @@ -16,90 +16,40 @@ export interface ArchiveTabAgent { 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()}`, + endpoint: `127.0.0.1:${getE2EDaemonPort()}`, 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; +/** + * The slice of a daemon client `createIdleAgent` needs: spawn an agent and await + * its idle upsert. The shared seed client satisfies it, so a spec can seed an + * idle agent from the same client it uses for everything else. + */ +export interface IdleAgentSeedClient { + createAgent(options: { + provider: string; + model: string; + modeId: string; + cwd: string; + title: string; + }): Promise<{ id: string }>; + waitForAgentUpsert( + agentId: string, + predicate: (snapshot: { status: string }) => boolean, + timeout?: number, + ): Promise<{ status: string }>; } export async function createIdleAgent( - client: ArchiveTabDaemonClient, + client: IdleAgentSeedClient, input: { cwd: string; title: string }, ): Promise { const created = await client.createAgent({ @@ -125,7 +75,7 @@ export async function createIdleAgent( } export async function archiveAgentFromDaemon( - client: ArchiveTabDaemonClient, + client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> }, agentId: string, ): Promise { await client.archiveAgent(agentId); diff --git a/packages/app/e2e/helpers/composer.ts b/packages/app/e2e/helpers/composer.ts index 774f136d0..01c3228bb 100644 --- a/packages/app/e2e/helpers/composer.ts +++ b/packages/app/e2e/helpers/composer.ts @@ -1,9 +1,10 @@ import { expect, type Page } from "@playwright/test"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { createTempGitRepo } from "./workspace"; -import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf"; +import { connectSeedClient, type SeedDaemonClient } from "./seed-client"; import { connectWorkspaceSetupClient, openHomeWithProject } from "./workspace-setup"; import { selectWorkspaceInSidebar } from "./sidebar"; +import { getServerId } from "./server-id"; import { waitForTabBar } from "./launcher"; function composerInput(page: Page) { @@ -145,7 +146,7 @@ export async function selectGithubOption( } export interface MockAgentSetup { - client: TerminalPerfDaemonClient; + client: SeedDaemonClient; repo: Awaited>; } @@ -154,11 +155,10 @@ export async function startRunningMockAgent( page: Page, opts: { prefix: string; model: string; prompt: string }, ): Promise { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) throw new Error("E2E_SERVER_ID is not set."); + const serverId = getServerId(); const repo = await createTempGitRepo(opts.prefix); - const client = await connectTerminalClient(); + const client = await connectSeedClient(); const opened = await client.openProject(repo.path); if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project"); const agent = await client.createAgent({ diff --git a/packages/app/e2e/helpers/daemon-client-loader.ts b/packages/app/e2e/helpers/daemon-client-loader.ts index c2bc0ccbd..17e1a8327 100644 --- a/packages/app/e2e/helpers/daemon-client-loader.ts +++ b/packages/app/e2e/helpers/daemon-client-loader.ts @@ -1,5 +1,8 @@ +import { randomUUID } from "node:crypto"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { getE2EDaemonPort } from "./daemon-port"; +import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory"; export async function loadDaemonClientConstructor(): Promise< new (config: ClientConfig) => ClientInstance @@ -13,3 +16,40 @@ export async function loadDaemonClientConstructor( }; return mod.DaemonClient; } + +interface E2EDaemonClientConfig { + url: string; + clientId: string; + clientType: "cli"; + appVersion?: string; + webSocketFactory?: NodeWebSocketFactory; +} + +function resolveDaemonWsUrl(): string { + return `ws://127.0.0.1:${getE2EDaemonPort()}/ws`; +} + +export interface ConnectDaemonClientOptions { + clientIdPrefix: string; + appVersion?: string; +} + +/** + * Connects an in-test daemon client over the isolated E2E daemon's WebSocket. + * The port-6767 guard keeps tests off the developer daemon. Each helper passes + * its own typed client interface as the generic. + */ +export async function connectDaemonClient }>( + options: ConnectDaemonClientOptions, +): Promise { + const DaemonClient = await loadDaemonClientConstructor(); + const client = new DaemonClient({ + url: resolveDaemonWsUrl(), + clientId: `${options.clientIdPrefix}-${randomUUID()}`, + clientType: "cli", + appVersion: options.appVersion, + webSocketFactory: createNodeWebSocketFactory(), + }); + await client.connect(); + return client; +} diff --git a/packages/app/e2e/helpers/daemon-port.ts b/packages/app/e2e/helpers/daemon-port.ts new file mode 100644 index 000000000..3051773fc --- /dev/null +++ b/packages/app/e2e/helpers/daemon-port.ts @@ -0,0 +1,38 @@ +import { escapeRegex } from "./regex"; + +/** + * Resolves the isolated E2E daemon's port, which Playwright's globalSetup + * publishes into the environment before any spec runs. Helpers and specs that + * build daemon WebSocket URLs, route patterns, or host endpoints share this + * accessor instead of re-reading the env var. + * + * The port-6767 guard is a hard guardrail: 6767 is the developer's default + * daemon, which manages real agents. The e2e port is never legitimately 6767, + * so refusing it here keeps every test off the developer daemon. + */ +export 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 must not point at the developer daemon (6767)."); + } + return port; +} + +/** + * Playwright `routeWebSocket` matcher for a WebSocket on `port`. Matches the + * `:` segment at a word boundary, so it catches the URL regardless of + * host or path. Use this when intercepting connections to an arbitrary port + * (e.g. blocking an unreachable test host); for the E2E daemon itself, prefer + * `daemonWsRoutePattern()`. + */ +export function wsRoutePatternForPort(port: string): RegExp { + return new RegExp(`:${escapeRegex(port)}\\b`); +} + +/** `routeWebSocket` matcher for the isolated E2E daemon's WebSocket. */ +export function daemonWsRoutePattern(): RegExp { + return wsRoutePatternForPort(getE2EDaemonPort()); +} diff --git a/packages/app/e2e/helpers/desktop-updates.ts b/packages/app/e2e/helpers/desktop-updates.ts index 345b3a8fe..9171542bd 100644 --- a/packages/app/e2e/helpers/desktop-updates.ts +++ b/packages/app/e2e/helpers/desktop-updates.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { expect, type Page } from "@playwright/test"; import { openSettings } from "./app"; +import { getE2EDaemonPort } from "./daemon-port"; import { openSettingsHost } from "./settings"; interface DaemonApiStatus { @@ -26,9 +27,8 @@ export interface RealDaemonState { * E2E_PASEO_HOME directory. Call this in Node test code (not in the browser). */ export async function loadRealDaemonState(): Promise { - const port = process.env.E2E_DAEMON_PORT; + const port = getE2EDaemonPort(); const paseoHome = process.env.E2E_PASEO_HOME; - if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first"); if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first"); const resp = await fetch(`http://127.0.0.1:${port}/api/status`); diff --git a/packages/app/e2e/helpers/launcher.ts b/packages/app/e2e/helpers/launcher.ts index 814cf052b..65f195a29 100644 --- a/packages/app/e2e/helpers/launcher.ts +++ b/packages/app/e2e/helpers/launcher.ts @@ -1,17 +1,10 @@ import { expect, type Page } from "@playwright/test"; import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; import { createTempGitRepo } from "./workspace"; +import { getServerId } from "./server-id"; // ─── Navigation ──────────────────────────────────────────────────────────── -function getServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); - } - return serverId; -} - /** Navigate to a workspace and wait for the tab bar to appear. */ export async function gotoWorkspace(page: Page, cwd: string): Promise { const route = buildHostWorkspaceRoute(getServerId(), cwd); diff --git a/packages/app/e2e/helpers/mock-agent.ts b/packages/app/e2e/helpers/mock-agent.ts new file mode 100644 index 000000000..4eef1d7b3 --- /dev/null +++ b/packages/app/e2e/helpers/mock-agent.ts @@ -0,0 +1,69 @@ +import type { Page } from "@playwright/test"; +import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; +import { seedWorkspace, type SeedDaemonClient } from "./seed-client"; +import { getServerId } from "./server-id"; + +export interface MockAgentWorkspace { + agentId: string; + cwd: string; + client: SeedDaemonClient; + cleanup(): Promise; +} + +export interface MockAgentOptions { + repoPrefix: string; + title: string; + initialPrompt?: string; + model?: string; + modeId?: string; + featureValues?: Record; +} + +/** + * Seeds a temp git repo, opens it as a project, and creates a ready mock-provider + * agent in it via the daemon. Returns the agent id plus a cleanup that closes the + * client and removes the repo. Pair with {@link openAgentRoute} to drive the UI. + */ +export async function seedMockAgentWorkspace( + options: MockAgentOptions, +): Promise { + const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix }); + try { + const agent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + title: options.title, + modeId: options.modeId ?? "load-test", + model: options.model ?? "ten-second-stream", + initialPrompt: options.initialPrompt, + featureValues: options.featureValues, + }); + return { + agentId: agent.id, + cwd: workspace.repoPath, + client: workspace.client, + cleanup: workspace.cleanup, + }; + } catch (error) { + await workspace.cleanup(); + throw error; + } +} + +export function buildAgentRoute(cwd: string, agentId: string): string { + return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent( + `agent:${agentId}`, + )}`; +} + +/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */ +export async function openAgentRoute( + page: Page, + input: { cwd: string; agentId: string }, +): Promise { + await page.goto(buildAgentRoute(input.cwd, input.agentId)); + await page.waitForURL( + (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), + { timeout: 60_000 }, + ); +} diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index 22146428f..9b52b27cf 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -1,10 +1,9 @@ -import { randomUUID } from "node:crypto"; import { expect, type Page } from "@playwright/test"; import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client"; import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; -import { loadDaemonClientConstructor } from "./daemon-client-loader"; +import { connectDaemonClient } from "./daemon-client-loader"; +import { daemonWsRoutePattern } from "./daemon-port"; import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui"; -import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory"; type NewWorkspaceDaemonClient = Pick< InternalDaemonClient, @@ -16,13 +15,6 @@ type NewWorkspaceDaemonClient = Pick< | "openProject" >; -interface NewWorkspaceDaemonClientConfig { - url: string; - clientId: string; - clientType: "cli"; - webSocketFactory?: NodeWebSocketFactory; -} - type OpenProjectPayload = Awaited>; export interface OpenedProject { @@ -32,21 +24,6 @@ export interface OpenedProject { workspaceName: 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 getDaemonWsUrl(): string { - return `ws://127.0.0.1:${getDaemonPort()}/ws`; -} - function requireWorkspace(payload: OpenProjectPayload) { if (payload.error) { throw new Error(payload.error); @@ -69,19 +46,9 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul } export async function connectNewWorkspaceDaemonClient(): Promise { - const DaemonClient = await loadDaemonClientConstructor< - NewWorkspaceDaemonClientConfig, - NewWorkspaceDaemonClient - >(); - const webSocketFactory = createNodeWebSocketFactory(); - const client = new DaemonClient({ - url: getDaemonWsUrl(), - clientId: `app-e2e-new-workspace-${randomUUID()}`, - clientType: "cli", - webSocketFactory, + return connectDaemonClient({ + clientIdPrefix: "app-e2e-new-workspace", }); - await client.connect(); - return client; } export async function openProjectViaDaemon( @@ -316,12 +283,7 @@ export interface AgentCreatedDelayControl { export async function delayBrowserAgentCreatedStatus( page: Page, ): Promise { - const daemonPort = process.env.E2E_DAEMON_PORT; - if (!daemonPort) { - throw new Error("E2E_DAEMON_PORT is not set."); - } - - const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`); + const daemonPortPattern = daemonWsRoutePattern(); const createRequestIds = new Set(); const delayedForwards: Array<() => void> = []; let releaseRequested = false; diff --git a/packages/app/e2e/helpers/project-settings.ts b/packages/app/e2e/helpers/project-settings.ts index bed766cd5..a6628a683 100644 --- a/packages/app/e2e/helpers/project-settings.ts +++ b/packages/app/e2e/helpers/project-settings.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { expect, type Page } from "@playwright/test"; import type { WebSocketRoute } from "@playwright/test"; import { gotoAppShell, openSettings } from "./app"; +import { daemonWsRoutePattern } from "./daemon-port"; // --- Navigation --- @@ -170,19 +171,13 @@ export async function unblockPaseoConfigWrites(repoPath: string): Promise // --- WebSocket helpers --- -function buildDaemonPortPattern(): RegExp { - const port = process.env.E2E_DAEMON_PORT; - if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first"); - return new RegExp(`:${port.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`); -} - // Proxies all daemon WS traffic transparently until a read_project_config_request // is seen, then closes that connection (triggering readQuery.isError). Subsequent // connections pass through so the Reload action can succeed. export async function installReadTransportFailure(page: Page): Promise { let armed = true; - await page.routeWebSocket(buildDaemonPortPattern(), (ws) => { + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { const server = ws.connectToServer(); ws.onMessage((message) => { @@ -230,7 +225,7 @@ export async function installDaemonConnectionGate( let acceptingConnections = true; const activeSockets = new Set(); - await page.routeWebSocket(buildDaemonPortPattern(), (ws) => { + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { if (!acceptingConnections) { void ws.close({ code: 1001 }); return; diff --git a/packages/app/e2e/helpers/regex.ts b/packages/app/e2e/helpers/regex.ts new file mode 100644 index 000000000..4f526af79 --- /dev/null +++ b/packages/app/e2e/helpers/regex.ts @@ -0,0 +1,8 @@ +/** + * Escape a literal string so it can be embedded safely inside a `RegExp`. + * Used across the suite to build dynamic patterns from daemon ports, URLs, + * workspace routes, and user-visible text without regex injection. + */ +export function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/packages/app/e2e/helpers/rewind-flow.ts b/packages/app/e2e/helpers/rewind-flow.ts index 14cd857da..ddfb1c015 100644 --- a/packages/app/e2e/helpers/rewind-flow.ts +++ b/packages/app/e2e/helpers/rewind-flow.ts @@ -4,14 +4,15 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { expect, type Page } from "@playwright/test"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { expectComposerEditable, submitMessage } from "./composer"; -import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf"; +import { connectSeedClient, type SeedDaemonClient } from "./seed-client"; +import { getServerId } from "./server-id"; export type RewindFlowProvider = "claude" | "codex" | "opencode" | "pi"; export type RewindFlowMode = "conversation" | "files" | "both"; export interface AgentHandle { page: Page; - client: TerminalPerfDaemonClient; + client: SeedDaemonClient; agentId: string; cwd: string; provider: RewindFlowProvider; @@ -33,14 +34,6 @@ interface ProviderLaunchConfig { const SEND_TIMEOUT_MS = 240_000; const REWIND_TIMEOUT_MS = 120_000; -function getServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } - return serverId; -} - function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig { switch (provider) { case "claude": @@ -173,7 +166,7 @@ export async function launchAgent(input: { writeFileSync(`${input.cwd}/README.md`, "# Paseo rewind flow\n", "utf8"); execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" }); execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" }); - const client = await connectTerminalClient(); + const client = await connectSeedClient(); const opened = await client.openProject(input.cwd); if (!opened.workspace) { throw new Error(opened.error ?? `Failed to open project ${input.cwd}`); @@ -324,7 +317,7 @@ export async function cleanupRewindFlow(input: { } async function fetchTimelineEpoch(handle: AgentHandle): Promise { - const client = handle.client as TerminalPerfDaemonClient & { + const client = handle.client as SeedDaemonClient & { fetchAgentTimeline: ( agentId: string, options?: { direction?: "head" | "tail"; projection?: "canonical"; limit?: number }, diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts new file mode 100644 index 000000000..02218658d --- /dev/null +++ b/packages/app/e2e/helpers/seed-client.ts @@ -0,0 +1,145 @@ +import path from "node:path"; +import { readFileSync } from "node:fs"; +import { connectDaemonClient } from "./daemon-client-loader"; +import { createTempDirectory, createTempGitRepo } from "./workspace"; + +/** + * The general-purpose E2E daemon client used to seed and drive state out of + * band (workspaces, agents, terminals) while the UI is exercised through the + * browser. Domain-specific helpers wrap it for their own flows; specs should + * prefer those wrappers over reaching for this client directly. + */ +export interface SeedDaemonClient { + connect(): Promise; + close(): Promise; + openProject(cwd: string): Promise<{ + workspace: { + id: string; + name: string; + projectId: string; + projectDisplayName: string; + projectRootPath: string; + workspaceDirectory: string; + } | null; + error: string | null; + }>; + createTerminal( + cwd: string, + name?: string, + ): Promise<{ + terminal: { id: string; name: string; cwd: string } | null; + error: string | null; + }>; + createAgent(options: { + provider: string; + cwd: string; + title?: string; + modeId?: string; + model?: string; + thinkingOptionId?: string; + featureValues?: Record; + initialPrompt?: string; + }): Promise<{ id: string; status: string }>; + fetchAgents(options?: { scope?: "active" }): Promise<{ + entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>; + }>; + updateAgent(agentId: string, updates: { name?: string }): Promise; + waitForAgentUpsert( + agentId: string, + predicate: (snapshot: { status: string }) => boolean, + timeout?: number, + ): Promise<{ status: string }>; + sendAgentMessage(agentId: string, text: string): Promise; + waitForFinish( + agentId: string, + timeout?: number, + ): Promise<{ status: string; final?: { lastError?: string | null } | null }>; + archiveAgent(agentId: string): Promise<{ archivedAt: string }>; + fetchAgentHistory(options?: { + page?: { limit: number }; + }): Promise<{ entries: Array<{ id: string }> }>; + subscribeTerminal( + terminalId: string, + ): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>; + sendTerminalInput( + terminalId: string, + message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number }, + ): void; + onTerminalStreamEvent( + handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void, + ): () => void; + killTerminal(terminalId: string): Promise<{ error: string | null }>; +} + +export async function connectSeedClient(): Promise { + return connectDaemonClient({ + clientIdPrefix: "seed", + appVersion: loadAppVersion(), + }); +} + +/** + * A temp project opened as a workspace, with a seed client connected to drive + * it out of band. `cleanup` closes the client and removes the project. This is + * the canonical bootstrap for specs that need a real workspace plus daemon + * access; domain helpers (e.g. mock-agent) build on it rather than re-rolling + * the trio. `repoPath` is the project root on disk (git repo or plain dir). + */ +export interface SeededWorkspace { + client: SeedDaemonClient; + repoPath: string; + workspaceId: string; + workspaceName: string; + workspaceDirectory: string; + /** Stable project identity the daemon groups workspaces under. */ + projectId: string; + /** Project label the UI shows (owner/repo for known remotes, else basename). */ + projectDisplayName: string; + cleanup(): Promise; +} + +export async function seedWorkspace(options: { + repoPrefix: string; + /** Repo fixture options; only applies to git projects (the default). */ + repo?: Parameters[1]; + /** Set to false to seed a plain non-git directory instead of a git repo. */ + git?: boolean; +}): Promise { + const project = + options.git === false + ? await createTempDirectory(options.repoPrefix) + : await createTempGitRepo(options.repoPrefix, options.repo); + const client = await connectSeedClient(); + try { + const opened = await client.openProject(project.path); + if (!opened.workspace) { + throw new Error(opened.error ?? `Failed to open project ${project.path}`); + } + return { + client, + repoPath: project.path, + workspaceId: opened.workspace.id, + workspaceName: opened.workspace.name, + workspaceDirectory: opened.workspace.workspaceDirectory, + projectId: opened.workspace.projectId, + projectDisplayName: opened.workspace.projectDisplayName, + cleanup: async () => { + await client.close().catch(() => undefined); + await project.cleanup().catch(() => undefined); + }, + }; + } catch (error) { + await client.close().catch(() => undefined); + await project.cleanup().catch(() => undefined); + throw error; + } +} + +function loadAppVersion(): string { + const packageJsonPath = path.resolve(__dirname, "../../package.json"); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown }; + if (typeof packageJson.version !== "string" || packageJson.version.length === 0) { + throw new Error(`Missing app version in ${packageJsonPath}`); + } + return packageJson.version; +} diff --git a/packages/app/e2e/helpers/server-id.ts b/packages/app/e2e/helpers/server-id.ts new file mode 100644 index 000000000..ae0fac983 --- /dev/null +++ b/packages/app/e2e/helpers/server-id.ts @@ -0,0 +1,13 @@ +/** + * Resolves the isolated E2E daemon's server id, which Playwright's globalSetup + * publishes into the environment before any spec runs. Helpers and specs that + * build host routes or `sidebar-workspace-row-${serverId}:${id}` selectors share + * this accessor instead of re-reading the env var. + */ +export function getServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); + } + return serverId; +} diff --git a/packages/app/e2e/helpers/settings.ts b/packages/app/e2e/helpers/settings.ts index 7302cebbf..b57feaa67 100644 --- a/packages/app/e2e/helpers/settings.ts +++ b/packages/app/e2e/helpers/settings.ts @@ -1,9 +1,6 @@ import { expect, type Page } from "@playwright/test"; -import { requireServerId } from "./sidebar"; - -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} +import { escapeRegex } from "./regex"; +import { getServerId } from "./server-id"; const SECTION_LABELS = { general: "General", @@ -113,13 +110,13 @@ export async function expectHostSettingsUrl(page: Page, serverId: string): Promi } export async function verifyLegacyHostSettingsRedirect(page: Page): Promise { - const serverId = requireServerId(); + const serverId = getServerId(); await page.goto(`/h/${encodeURIComponent(serverId)}/settings`); await expectHostSettingsUrl(page, serverId); } export async function openCompactSettingsHost(page: Page): Promise { - const serverId = requireServerId(); + const serverId = getServerId(); await openSettingsHost(page, serverId); await expectHostSettingsUrl(page, serverId); } diff --git a/packages/app/e2e/helpers/sidebar.ts b/packages/app/e2e/helpers/sidebar.ts index d9bb82111..e39f0a543 100644 --- a/packages/app/e2e/helpers/sidebar.ts +++ b/packages/app/e2e/helpers/sidebar.ts @@ -1,15 +1,8 @@ import { expect, type Page } from "@playwright/test"; - -export function requireServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); - } - return serverId; -} +import { getServerId } from "./server-id"; export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise { - const row = page.getByTestId(`sidebar-workspace-row-${requireServerId()}:${workspaceId}`); + const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`); await expect(row).toBeVisible({ timeout: 30_000 }); await row.click(); } diff --git a/packages/app/e2e/helpers/startup-dsl.ts b/packages/app/e2e/helpers/startup-dsl.ts index 136320996..a3e771782 100644 --- a/packages/app/e2e/helpers/startup-dsl.ts +++ b/packages/app/e2e/helpers/startup-dsl.ts @@ -1,5 +1,6 @@ import { expect, type Page } from "../fixtures"; import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry"; +import { wsRoutePatternForPort } from "./daemon-port"; const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once"; const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce"; @@ -78,7 +79,7 @@ class StartupScenario { } for (const port of this.blockedEndpointPorts) { - await this.page.routeWebSocket(new RegExp(`:${escapeRegex(port)}\\b`), async (ws) => { + await this.page.routeWebSocket(wsRoutePatternForPort(port), async (ws) => { await ws.close({ code: 1008, reason: "Blocked unreachable startup test host." }); }); } @@ -227,7 +228,3 @@ function buildStoredHost(input: { function buildStoredCreateAgentPreferences(serverId: string) { return buildCreateAgentPreferences(serverId); } - -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} diff --git a/packages/app/e2e/helpers/terminal-dsl.ts b/packages/app/e2e/helpers/terminal-dsl.ts index cf0b42a86..fa9125b35 100644 --- a/packages/app/e2e/helpers/terminal-dsl.ts +++ b/packages/app/e2e/helpers/terminal-dsl.ts @@ -1,11 +1,7 @@ import type { Page } from "@playwright/test"; import { createTempGitRepo } from "./workspace"; -import { - connectTerminalClient, - navigateToTerminal, - setupDeterministicPrompt, - type TerminalPerfDaemonClient, -} from "./terminal-perf"; +import { navigateToTerminal, setupDeterministicPrompt } from "./terminal-perf"; +import { connectSeedClient, type SeedDaemonClient } from "./seed-client"; interface TempRepo { path: string; @@ -19,12 +15,12 @@ export interface TerminalInstance { } export class TerminalE2EHarness { - readonly client: TerminalPerfDaemonClient; + readonly client: SeedDaemonClient; readonly tempRepo: TempRepo; readonly workspaceId: string; private constructor(input: { - client: TerminalPerfDaemonClient; + client: SeedDaemonClient; tempRepo: TempRepo; workspaceId: string; }) { @@ -35,7 +31,7 @@ export class TerminalE2EHarness { static async create(input: { tempPrefix: string }): Promise { const tempRepo = await createTempGitRepo(input.tempPrefix); - const client = await connectTerminalClient(); + const client = await connectSeedClient(); const seedResult = await client.openProject(tempRepo.path); if (!seedResult.workspace) { await client.close().catch(() => {}); diff --git a/packages/app/e2e/helpers/terminal-perf.ts b/packages/app/e2e/helpers/terminal-perf.ts index 31ad6f797..8d17df503 100644 --- a/packages/app/e2e/helpers/terminal-perf.ts +++ b/packages/app/e2e/helpers/terminal-perf.ts @@ -1,111 +1,6 @@ import { expect, type Page } from "@playwright/test"; -import path from "node:path"; -import { randomUUID } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { loadDaemonClientConstructor } from "./daemon-client-loader"; -import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory"; import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; - -export interface TerminalPerfDaemonClient { - connect(): Promise; - close(): Promise; - openProject(cwd: string): Promise<{ - workspace: { id: string; name: string; projectRootPath: string } | null; - error: string | null; - }>; - createTerminal( - cwd: string, - name?: string, - ): Promise<{ - terminal: { id: string; name: string; cwd: string } | null; - error: string | null; - }>; - createAgent(options: { - provider: string; - cwd: string; - title?: string; - modeId?: string; - model?: string; - thinkingOptionId?: string; - featureValues?: Record; - initialPrompt?: string; - }): Promise<{ id: string; status: string }>; - fetchAgents(options?: { scope?: "active" }): Promise<{ - entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>; - }>; - updateAgent(agentId: string, updates: { name?: string }): Promise; - waitForAgentUpsert( - agentId: string, - predicate: (snapshot: { status: string }) => boolean, - timeout?: number, - ): Promise<{ status: string }>; - sendAgentMessage(agentId: string, text: string): Promise; - waitForFinish( - agentId: string, - timeout?: number, - ): Promise<{ status: string; final?: { lastError?: string | null } | null }>; - subscribeTerminal( - terminalId: string, - ): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>; - sendTerminalInput( - terminalId: string, - message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number }, - ): void; - onTerminalStreamEvent( - handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void, - ): () => void; - killTerminal(terminalId: string): Promise<{ error: string | null }>; -} - -function getDaemonWsUrl(): string { - const daemonPort = process.env.E2E_DAEMON_PORT; - if (!daemonPort) { - throw new Error("E2E_DAEMON_PORT is not set."); - } - return `ws://127.0.0.1:${daemonPort}/ws`; -} - -function getServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } - return serverId; -} - -interface TerminalPerfDaemonClientConfig { - url: string; - clientId: string; - clientType: "cli"; - appVersion?: string; - webSocketFactory?: NodeWebSocketFactory; -} - -export async function connectTerminalClient(): Promise { - const DaemonClient = await loadDaemonClientConstructor< - TerminalPerfDaemonClientConfig, - TerminalPerfDaemonClient - >(); - const webSocketFactory = createNodeWebSocketFactory(); - const client = new DaemonClient({ - url: getDaemonWsUrl(), - clientId: `terminal-perf-${randomUUID()}`, - clientType: "cli", - appVersion: loadAppVersion(), - webSocketFactory, - }); - await client.connect(); - return client; -} - -function loadAppVersion(): string { - const packageJsonPath = path.resolve(__dirname, "../../package.json"); - const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown }; - if (typeof packageJson.version !== "string" || packageJson.version.length === 0) { - throw new Error(`Missing app version in ${packageJsonPath}`); - } - return packageJson.version; -} +import { getServerId } from "./server-id"; export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string { const serverId = getServerId(); diff --git a/packages/app/e2e/helpers/with-workspace.ts b/packages/app/e2e/helpers/with-workspace.ts index 8a2067152..86ce84ae9 100644 --- a/packages/app/e2e/helpers/with-workspace.ts +++ b/packages/app/e2e/helpers/with-workspace.ts @@ -1,10 +1,9 @@ import { execSync } from "node:child_process"; -import { realpath } from "node:fs/promises"; import path from "node:path"; import type { Page } from "@playwright/test"; import { waitForTabBar } from "./launcher"; import { selectWorkspaceInSidebar } from "./sidebar"; -import { createTempGitRepo } from "./workspace"; +import { createTempGitRepo, resolveTempRoot } from "./workspace"; import { connectWorkspaceSetupClient, openHomeWithProject, @@ -49,7 +48,7 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle { let workspacePath = repo.path; if (options?.worktree) { - const tempRoot = await realpath("/tmp"); + const tempRoot = await resolveTempRoot(); workspacePath = path.join( tempRoot, `paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`, diff --git a/packages/app/e2e/helpers/workspace-setup.ts b/packages/app/e2e/helpers/workspace-setup.ts index 103a1d40e..1c0a5afae 100644 --- a/packages/app/e2e/helpers/workspace-setup.ts +++ b/packages/app/e2e/helpers/workspace-setup.ts @@ -1,58 +1,25 @@ import { realpathSync } from "node:fs"; -import { randomUUID } from "node:crypto"; import { expect, type Page } from "@playwright/test"; +import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client"; import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes"; import { gotoAppShell } from "./app"; -import { loadDaemonClientConstructor } from "./daemon-client-loader"; -import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory"; +import { connectDaemonClient } from "./daemon-client-loader"; +import { getServerId } from "./server-id"; import { switchWorkspaceViaSidebar } from "./workspace-ui"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; -interface WorkspaceSetupDaemonClient { - connect(): Promise; - close(): Promise; - openProject(cwd: string): Promise<{ - workspace: { - id: string; - name: string; - workspaceDirectory: string; - projectRootPath: string; - } | null; - error: string | null; - }>; - createPaseoWorktree(input: { cwd: string; worktreeSlug?: string }): Promise<{ - workspace: { - id: string; - name: string; - workspaceDirectory: string; - projectRootPath: string; - } | null; - error: string | null; - }>; - fetchWorkspaces(): Promise<{ - entries: Array<{ - id: string; - name: string; - workspaceDirectory: string; - projectRootPath: string; - }>; - }>; - fetchAgents(): Promise<{ - entries: Array<{ - agent: { id: string; cwd: string; workspaceId?: string | null }; - }>; - }>; - fetchAgent(agentId: string): Promise<{ - agent: { id: string; cwd: string } | null; - project: unknown; - } | null>; - listTerminals(cwd: string): Promise<{ - cwd?: string; - terminals: Array<{ id: string; cwd: string; name: string }>; - error?: string | null; - }>; - subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void; -} +type WorkspaceSetupDaemonClient = Pick< + InternalDaemonClient, + | "close" + | "connect" + | "createPaseoWorktree" + | "fetchAgent" + | "fetchAgents" + | "fetchWorkspaces" + | "listTerminals" + | "openProject" + | "subscribeRawMessages" +>; export type WorkspaceSetupProgressPayload = Extract< SessionOutboundMessage, @@ -61,43 +28,30 @@ export type WorkspaceSetupProgressPayload = Extract< export type { WorkspaceSetupDaemonClient }; -function getDaemonWsUrl(): string { - const daemonPort = process.env.E2E_DAEMON_PORT; - if (!daemonPort) { - throw new Error("E2E_DAEMON_PORT is not set."); - } - return `ws://127.0.0.1:${daemonPort}/ws`; +export async function connectWorkspaceSetupClient(): Promise { + return connectDaemonClient({ clientIdPrefix: "workspace-setup" }); } -export async function connectWorkspaceSetupClient(): Promise { - const DaemonClient = await loadDaemonClientConstructor< - { - url: string; - clientId: string; - clientType: "cli"; - webSocketFactory?: NodeWebSocketFactory; - }, - WorkspaceSetupDaemonClient - >(); - const webSocketFactory = createNodeWebSocketFactory(); - const client = new DaemonClient({ - url: getDaemonWsUrl(), - clientId: `workspace-setup-${randomUUID()}`, - clientType: "cli", - webSocketFactory, - }); - await client.connect(); - return client; +export async function openProjectViaDaemon( + client: WorkspaceSetupDaemonClient, + repoPath: string, +): Promise<{ id: string; name: string; workspaceDirectory: string }> { + const result = await client.openProject(repoPath); + if (!result.workspace || result.error) { + throw new Error(result.error ?? `Failed to open project ${repoPath}`); + } + return { + id: result.workspace.id, + name: result.workspace.name, + workspaceDirectory: result.workspace.workspaceDirectory, + }; } export async function seedProjectForWorkspaceSetup( client: WorkspaceSetupDaemonClient, repoPath: string, ): Promise { - const result = await client.openProject(repoPath); - if (!result.workspace || result.error) { - throw new Error(result.error ?? `Failed to open project ${repoPath}`); - } + await openProjectViaDaemon(client, repoPath); } export function projectNameFromPath(repoPath: string): string { @@ -304,11 +258,11 @@ export async function navigateToWorkspaceViaSidebar( page: Page, workspaceId: string, ): Promise { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } - await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspaceId }); + await switchWorkspaceViaSidebar({ + page, + serverId: getServerId(), + targetWorkspacePath: workspaceId, + }); } export async function openWorkspaceScriptsMenu(page: Page): Promise { diff --git a/packages/app/e2e/helpers/workspace-ui.ts b/packages/app/e2e/helpers/workspace-ui.ts index efce48db8..81617a78d 100644 --- a/packages/app/e2e/helpers/workspace-ui.ts +++ b/packages/app/e2e/helpers/workspace-ui.ts @@ -1,6 +1,7 @@ import { expect, type Page } from "@playwright/test"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { gotoHome } from "./app"; +import { escapeRegex } from "./regex"; export async function openNewAgentComposer(page: Page): Promise { await gotoHome(page); @@ -23,10 +24,6 @@ export function workspaceLabelFromPath(value: string): string { return parts[parts.length - 1] ?? normalized; } -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - function candidateWorkspaceIds(inputPath: string): string[] { const trimmed = inputPath.replace(/\/+$/, ""); const candidates = new Set([trimmed]); diff --git a/packages/app/e2e/helpers/workspace.ts b/packages/app/e2e/helpers/workspace.ts index b64f3589f..275f36a0a 100644 --- a/packages/app/e2e/helpers/workspace.ts +++ b/packages/app/e2e/helpers/workspace.ts @@ -9,6 +9,20 @@ interface TempRepo { cleanup: () => Promise; } +export interface TempDirectory { + path: string; + cleanup: () => Promise; +} + +/** + * The temp root for E2E fixtures. On macOS we resolve symlinks (/tmp → + * /private/tmp) so fixture paths match the daemon's resolved paths; on Windows + * `/tmp` doesn't exist, so fall back to the OS temp dir. + */ +export async function resolveTempRoot(): Promise { + return process.platform === "win32" ? tmpdir() : await realpath("/tmp"); +} + async function configureRemote(input: { repoPath: string; withRemote: boolean; @@ -22,6 +36,15 @@ async function configureRemote(input: { execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" }); execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" }); execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" }); + if (originUrl) { + // Relabel origin to a display URL after the local tracking remote is set + // up, so project grouping shows the remote's owner/repo while branch + // tracking refs still resolve locally (no fetch from the synthetic URL). + execSync(`git remote set-url origin ${JSON.stringify(originUrl)}`, { + cwd: repoPath, + stdio: "ignore", + }); + } return; } if (originUrl) { @@ -44,9 +67,7 @@ export const createTempGitRepo = async ( }, ): Promise => { // Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping. - // Resolve symlinks (macOS: /tmp → /private/tmp) so paths match the daemon's resolved paths. - const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp"); - const repoPath = await mkdtemp(path.join(tempRoot, prefix)); + const repoPath = await mkdtemp(path.join(await resolveTempRoot(), prefix)); const withRemote = options?.withRemote ?? false; execSync("git init -b main", { cwd: repoPath, stdio: "ignore" }); @@ -110,6 +131,21 @@ export const createTempGitRepo = async ( }; }; +/** + * A plain (non-git) directory opened as a project. The daemon shows its + * basename as the project name, since there's no remote to group under. + */ +export async function createTempDirectory(prefix = "paseo-e2e-dir-"): Promise { + const dirPath = await mkdtemp(path.join(await resolveTempRoot(), prefix)); + await writeFile(path.join(dirPath, "README.md"), "# Temp Directory\n"); + return { + path: dirPath, + cleanup: async () => { + await rm(dirPath, { recursive: true, force: true }); + }, + }; +} + export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{ currentBranch: string; hasAncestor: (ref: string) => boolean; diff --git a/packages/app/e2e/launcher-tab.spec.ts b/packages/app/e2e/launcher-tab.spec.ts index 0509d0071..9d4e4d5a4 100644 --- a/packages/app/e2e/launcher-tab.spec.ts +++ b/packages/app/e2e/launcher-tab.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from "./fixtures"; -import { createTempGitRepo } from "./helpers/workspace"; import { gotoWorkspace, assertNewChatTileVisible, @@ -16,30 +15,19 @@ import { terminalSurfaceLocator, } from "./helpers/launcher"; import { expectComposerVisible, composerLocator } from "./helpers/composer"; -import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf"; -import { - connectTerminalClient, - setupDeterministicPrompt, - type TerminalPerfDaemonClient, -} from "./helpers/terminal-perf"; +import { expectTerminalSurfaceVisible, setupDeterministicPrompt } from "./helpers/terminal-perf"; +import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; // ─── Shared state ────────────────────────────────────────────────────────── -let tempRepo: { path: string; cleanup: () => Promise }; -let workspaceId: string; -let seedClient: TerminalPerfDaemonClient; +let workspace: SeededWorkspace; test.beforeAll(async () => { - tempRepo = await createTempGitRepo("launcher-e2e-"); - seedClient = await connectTerminalClient(); - const result = await seedClient.openProject(tempRepo.path); - if (!result.workspace) throw new Error(result.error ?? "Failed to seed workspace"); - workspaceId = result.workspace.id; + workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" }); }); test.afterAll(async () => { - if (seedClient) await seedClient.close(); - if (tempRepo) await tempRepo.cleanup(); + await workspace?.cleanup(); }); // ═══════════════════════════════════════════════════════════════════════════ @@ -48,7 +36,7 @@ test.afterAll(async () => { test.describe("Tab creation", () => { test("Cmd+T opens a new agent tab with composer", async ({ page }) => { - await gotoWorkspace(page, workspaceId); + await gotoWorkspace(page, workspace.workspaceId); await pressNewTabShortcut(page); @@ -56,7 +44,7 @@ test.describe("Tab creation", () => { }); test("opening two new tabs creates two draft tabs", async ({ page }) => { - await gotoWorkspace(page, workspaceId); + await gotoWorkspace(page, workspace.workspaceId); const countBefore = await countTabsOfKind(page, "draft"); @@ -75,7 +63,7 @@ test.describe("Tab creation", () => { }); test("clicking new agent tab creates a draft tab", async ({ page }) => { - await gotoWorkspace(page, workspaceId); + await gotoWorkspace(page, workspace.workspaceId); await clickNewChat(page); @@ -88,7 +76,7 @@ test.describe("Tab creation", () => { test("clicking terminal button creates a standalone terminal", async ({ page }) => { test.setTimeout(45_000); - await gotoWorkspace(page, workspaceId); + await gotoWorkspace(page, workspace.workspaceId); await clickNewTerminal(page); @@ -100,7 +88,7 @@ test.describe("Tab creation", () => { }); test("tab bar shows action buttons per pane", async ({ page }) => { - await gotoWorkspace(page, workspaceId); + await gotoWorkspace(page, workspace.workspaceId); await assertSingleNewTabButton(page); await assertNewChatTileVisible(page); await assertTerminalTileVisible(page); @@ -117,26 +105,16 @@ test.describe("Terminal title propagation", () => { // must re-render before the assertion deadline. Allow retries. test.describe.configure({ retries: 2 }); - let client: TerminalPerfDaemonClient; - - test.beforeAll(async () => { - client = await connectTerminalClient(); - }); - - test.afterAll(async () => { - if (client) await client.close(); - }); - test.skip("terminal tab title updates from OSC title escape sequence", async ({ page }) => { test.setTimeout(60_000); - const result = await client.createTerminal(tempRepo.path, "title-test"); + const result = await workspace.client.createTerminal(workspace.repoPath, "title-test"); if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`); const terminalId = result.terminal.id; try { // Navigate to workspace and open a terminal - await gotoWorkspace(page, workspaceId); + await gotoWorkspace(page, workspace.workspaceId); await clickNewTerminal(page); await expectTerminalSurfaceVisible(page); @@ -153,19 +131,19 @@ test.describe("Terminal title propagation", () => { // Wait for the tab to reflect the new title await waitForTabWithTitle(page, testTitle, 15_000); } finally { - await client.killTerminal(terminalId).catch(() => {}); + await workspace.client.killTerminal(terminalId).catch(() => {}); } }); test.skip("title debouncing coalesces rapid changes", async ({ page }) => { test.setTimeout(60_000); - const result = await client.createTerminal(tempRepo.path, "debounce-test"); + const result = await workspace.client.createTerminal(workspace.repoPath, "debounce-test"); if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`); const terminalId = result.terminal.id; try { - await gotoWorkspace(page, workspaceId); + await gotoWorkspace(page, workspace.workspaceId); await clickNewTerminal(page); await expectTerminalSurfaceVisible(page); @@ -188,7 +166,7 @@ test.describe("Terminal title propagation", () => { // The tab should eventually settle on the final title await waitForTabWithTitle(page, finalTitle, 15_000); } finally { - await client.killTerminal(terminalId).catch(() => {}); + await workspace.client.killTerminal(terminalId).catch(() => {}); } }); }); @@ -199,7 +177,7 @@ test.describe("Terminal title propagation", () => { test.describe("Tab transitions (no flash)", () => { test("New agent tab transition has no blank intermediate tab state", async ({ page }) => { - await gotoWorkspace(page, workspaceId); + await gotoWorkspace(page, workspace.workspaceId); // Sample tabs at high frequency across the transition const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page), 2_000, 30); @@ -221,7 +199,7 @@ test.describe("Tab transitions (no flash)", () => { test("Terminal transition completes within visual budget", async ({ page }) => { test.setTimeout(30_000); - await gotoWorkspace(page, workspaceId); + await gotoWorkspace(page, workspace.workspaceId); const elapsed = await measureTileTransition( page, @@ -237,7 +215,7 @@ test.describe("Tab transitions (no flash)", () => { }); test("New agent tab click shows composer without flash", async ({ page }) => { - await gotoWorkspace(page, workspaceId); + await gotoWorkspace(page, workspace.workspaceId); const elapsed = await measureTileTransition( page, diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts index 5823510b1..ac2247c6f 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/new-workspace.spec.ts @@ -26,6 +26,7 @@ import { selectPickerOptionByKeyboard, } from "./helpers/new-workspace"; import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace"; +import { getServerId } from "./helpers/server-id"; import { expectSidebarWorkspaceSelected, expectWorkspaceHeader, @@ -61,10 +62,7 @@ test.describe("New workspace flow", () => { }); test("sidebar workspace navigation updates URL and header", async ({ page }) => { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } + const serverId = getServerId(); const firstRepo = await createTempGitRepo("workspace-nav-a-"); const secondRepo = await createTempGitRepo("workspace-nav-b-"); @@ -118,10 +116,7 @@ test.describe("New workspace flow", () => { }); test("same-project workspaces switch content without requiring refresh", async ({ page }) => { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } + const serverId = getServerId(); const repo = await createTempGitRepo("workspace-nav-same-project-"); @@ -201,10 +196,7 @@ test.describe("New workspace flow", () => { test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({ page, }) => { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } + const serverId = getServerId(); const tempRepo = await createTempGitRepo("new-workspace-"); @@ -276,10 +268,7 @@ test.describe("New workspace flow", () => { }); test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } + const serverId = getServerId(); const tempRepo = await createTempGitRepo("new-workspace-optimistic-"); const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page); @@ -355,10 +344,7 @@ test.describe("New workspace flow", () => { }); test("selected branch becomes the base of a new workspace worktree", async ({ page }) => { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } + const serverId = getServerId(); const tempRepo = await createTempGitRepo("new-workspace-ref-", { branches: ["main", "dev"], diff --git a/packages/app/e2e/pr-pane.spec.ts b/packages/app/e2e/pr-pane.spec.ts index 2ac00b577..faea7bfce 100644 --- a/packages/app/e2e/pr-pane.spec.ts +++ b/packages/app/e2e/pr-pane.spec.ts @@ -10,6 +10,7 @@ import { gotoWorkspace } from "./helpers/launcher"; import { hasGithubAuth, createTempGithubRepo, type GhRepoFixture } from "./helpers/github-fixtures"; import { connectWorkspaceSetupClient, + openProjectViaDaemon, type WorkspaceSetupDaemonClient, } from "./helpers/workspace-setup"; @@ -50,11 +51,8 @@ test.describe("PR pane", () => { }); for (const pr of repoFixture.prs) { - const result = await seedClient.openProject(pr.localPath); - if (!result.workspace) { - throw new Error(result.error ?? `Failed to open project ${pr.localPath}`); - } - workspaceByTitle.set(pr.title, result.workspace.id); + const workspace = await openProjectViaDaemon(seedClient, pr.localPath); + workspaceByTitle.set(pr.title, workspace.id); } }); diff --git a/packages/app/e2e/projects-settings.spec.ts b/packages/app/e2e/projects-settings.spec.ts index c01f336a9..d3d1ea3a6 100644 --- a/packages/app/e2e/projects-settings.spec.ts +++ b/packages/app/e2e/projects-settings.spec.ts @@ -1,8 +1,7 @@ import { chmod, readFile } from "node:fs/promises"; import path from "node:path"; import { expect, test as base } from "./fixtures"; -import { connectNewWorkspaceDaemonClient, openProjectViaDaemon } from "./helpers/new-workspace"; -import { createTempGitRepo } from "./helpers/workspace"; +import { seedWorkspace } from "./helpers/seed-client"; import { blockPaseoConfigWrites, bumpPaseoConfigOnDisk, @@ -63,38 +62,36 @@ const initialPaseoConfig = { const test = base.extend({ editableProject: async ({ page: _page }, provide) => { - const client = await connectNewWorkspaceDaemonClient(); - const repo = await createTempGitRepo("projects-settings-", { - paseoConfig: initialPaseoConfig, + const workspace = await seedWorkspace({ + repoPrefix: "projects-settings-", + repo: { paseoConfig: initialPaseoConfig }, }); - const openedProject = await openProjectViaDaemon(client, repo.path); await provide({ - name: openedProject.projectDisplayName, - path: repo.path, + name: workspace.projectDisplayName, + path: workspace.repoPath, }); - await client.close(); // Defensive: restore directory write permission in case the test left it blocked - // (write_failed test), so that repo.cleanup() can remove files inside. - await chmod(repo.path, 0o755).catch(() => undefined); - await repo.cleanup(); + // (write_failed test), so that cleanup can remove files inside. + await chmod(workspace.repoPath, 0o755).catch(() => undefined); + await workspace.cleanup(); }, gitlabRemoteProject: async ({ page: _page }, provide) => { - const client = await connectNewWorkspaceDaemonClient(); - const repo = await createTempGitRepo("projects-settings-gitlab-", { - paseoConfig: initialPaseoConfig, - originUrl: "https://gitlab.com/acme/app.git", + const workspace = await seedWorkspace({ + repoPrefix: "projects-settings-gitlab-", + repo: { + paseoConfig: initialPaseoConfig, + originUrl: "https://gitlab.com/acme/app.git", + }, }); - const openedProject = await openProjectViaDaemon(client, repo.path); await provide({ - name: openedProject.projectDisplayName, - path: repo.path, + name: workspace.projectDisplayName, + path: workspace.repoPath, }); - await client.close(); - await repo.cleanup(); + await workspace.cleanup(); }, }); diff --git a/packages/app/e2e/rewind-menu.ui-contract.spec.ts b/packages/app/e2e/rewind-menu.ui-contract.spec.ts index 3f0ebfb64..51e8dc848 100644 --- a/packages/app/e2e/rewind-menu.ui-contract.spec.ts +++ b/packages/app/e2e/rewind-menu.ui-contract.spec.ts @@ -1,7 +1,5 @@ import { expect, test, type Page } from "./fixtures"; -import { buildHostWorkspaceRoute } from "@/utils/host-routes"; -import { connectTerminalClient } from "./helpers/terminal-perf"; -import { createTempGitRepo } from "./helpers/workspace"; +import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; import { composerLocator, expectComposerDraft, @@ -12,50 +10,24 @@ import { // UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`. -function getServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } - return serverId; -} - -async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise { - const agentUrl = `${buildHostWorkspaceRoute( - getServerId(), - input.cwd, - )}?open=${encodeURIComponent(`agent:${input.agentId}`)}`; - await page.goto(agentUrl); - await page.waitForURL( - (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), - { timeout: 60_000 }, - ); - await expectComposerVisible(page); -} - async function expectUserMessageCount(page: Page, expected: number): Promise { await expect(page.getByTestId("user-message")).toHaveCount(expected); } test.describe("Rewind sheet", () => { test("rewinds from a user message sheet option", async ({ page }) => { - const repo = await createTempGitRepo("rewind-e2e-"); - const client = await connectTerminalClient(); const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn."; const secondPrompt = "Prepare deleted rewind turn assistant content."; const replacementPrompt = "emit 1 coalesced agent stream updates for replacement rewind turn."; + const session = await seedMockAgentWorkspace({ + repoPrefix: "rewind-e2e-", + title: "Rewind e2e", + initialPrompt: firstPrompt, + }); try { - await client.openProject(repo.path); - const agent = await client.createAgent({ - provider: "mock", - cwd: repo.path, - title: "Rewind e2e", - modeId: "load-test", - model: "ten-second-stream", - initialPrompt: firstPrompt, - }); - await openAgent(page, { cwd: repo.path, agentId: agent.id }); + await openAgentRoute(page, session); + await expectComposerVisible(page); await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible(); await expectUserMessageCount(page, 1); @@ -116,31 +88,25 @@ test.describe("Rewind sheet", () => { await expectComposerDraft(page, replacementPrompt); await expectUserMessageCount(page, 1); } finally { - await client.close(); - await repo.cleanup(); + await session.cleanup(); } }); test("surfaces rewind failures without crashing the page", async ({ page }) => { - const repo = await createTempGitRepo("rewind-failure-e2e-"); - const client = await connectTerminalClient(); const firstPrompt = "emit 1 coalesced agent stream updates for failed rewind turn."; const rewindError = "No file checkpoint found for message rewind-failure-e2e."; + const session = await seedMockAgentWorkspace({ + repoPrefix: "rewind-failure-e2e-", + title: "Rewind failure e2e", + initialPrompt: firstPrompt, + featureValues: { + mockRewindError: rewindError, + }, + }); try { - await client.openProject(repo.path); - const agent = await client.createAgent({ - provider: "mock", - cwd: repo.path, - title: "Rewind failure e2e", - modeId: "load-test", - model: "ten-second-stream", - featureValues: { - mockRewindError: rewindError, - }, - initialPrompt: firstPrompt, - }); - await openAgent(page, { cwd: repo.path, agentId: agent.id }); + await openAgentRoute(page, session); + await expectComposerVisible(page); await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible(); @@ -160,8 +126,7 @@ test.describe("Rewind sheet", () => { await page.getByTestId("rewind-menu-trigger").first().click(); await expect(page.getByTestId("rewind-menu-content")).toBeVisible(); } finally { - await client.close(); - await repo.cleanup(); + await session.cleanup(); } }); }); diff --git a/packages/app/e2e/settings-host-page.spec.ts b/packages/app/e2e/settings-host-page.spec.ts index 80f0d4c24..76c9ab5d2 100644 --- a/packages/app/e2e/settings-host-page.spec.ts +++ b/packages/app/e2e/settings-host-page.spec.ts @@ -1,6 +1,8 @@ import { test } from "./fixtures"; import { gotoAppShell, openSettings } from "./helpers/app"; +import { getE2EDaemonPort } from "./helpers/daemon-port"; import { TEST_HOST_LABEL } from "./helpers/daemon-registry"; +import { getServerId } from "./helpers/server-id"; import { expectSettingsHeader, openSettingsHost, @@ -16,28 +18,12 @@ import { expectLocalHostEntryFirst, } from "./helpers/settings"; -function getSeededServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); - } - return serverId; -} - -function getSeededDaemonPort(): string { - const port = process.env.E2E_DAEMON_PORT; - if (!port) { - throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup)."); - } - return port; -} - test.describe("Settings host page", () => { test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({ page, }) => { - const serverId = getSeededServerId(); - const port = getSeededDaemonPort(); + const serverId = getServerId(); + const port = getE2EDaemonPort(); await gotoAppShell(page); await openSettings(page); @@ -51,7 +37,7 @@ test.describe("Settings host page", () => { }); test("clicking the label pencil reveals the inline editor", async ({ page }) => { - const serverId = getSeededServerId(); + const serverId = getServerId(); await gotoAppShell(page); await openSettings(page); @@ -65,7 +51,7 @@ test.describe("Settings host page", () => { test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({ page, }) => { - const serverId = getSeededServerId(); + const serverId = getServerId(); await gotoAppShell(page); await openSettings(page); @@ -85,7 +71,7 @@ test.describe("Settings host page", () => { test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({ page, }) => { - const serverId = getSeededServerId(); + const serverId = getServerId(); await gotoAppShell(page); await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`); @@ -97,7 +83,7 @@ test.describe("Settings host page", () => { }); test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => { - const serverId = getSeededServerId(); + const serverId = getServerId(); // Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the // seeded host to the local daemon. `manageBuiltInDaemon: false` (returned diff --git a/packages/app/e2e/settings-toggle-tab-regression.spec.ts b/packages/app/e2e/settings-toggle-tab-regression.spec.ts index d28910c08..6edd3f1a5 100644 --- a/packages/app/e2e/settings-toggle-tab-regression.spec.ts +++ b/packages/app/e2e/settings-toggle-tab-regression.spec.ts @@ -1,21 +1,9 @@ import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; import { expect, test } from "./fixtures"; -import { - archiveAgentFromDaemon, - connectArchiveTabDaemonClient, - createIdleAgent, - openWorkspaceWithAgents, -} from "./helpers/archive-tab"; +import { createIdleAgent, openWorkspaceWithAgents } from "./helpers/archive-tab"; import { waitForTabBar, expectAgentTabActive } from "./helpers/launcher"; -import { createTempGitRepo } from "./helpers/workspace"; - -function getServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } - return serverId; -} +import { seedWorkspace } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; async function pressSettingsToggleShortcut(page: import("@playwright/test").Page) { const modifier = process.platform === "darwin" ? "Meta" : "Control"; @@ -61,20 +49,17 @@ test.describe("Settings toggle tab regression", () => { page, }) => { const serverId = getServerId(); - const client = await connectArchiveTabDaemonClient(); - const repo = await createTempGitRepo("settings-toggle-tab-"); - const agentIds: string[] = []; + const workspace = await seedWorkspace({ repoPrefix: "settings-toggle-tab-" }); try { - const firstAgent = await createIdleAgent(client, { - cwd: repo.path, + const firstAgent = await createIdleAgent(workspace.client, { + cwd: workspace.repoPath, title: `settings-toggle-a-${Date.now()}`, }); - const secondAgent = await createIdleAgent(client, { - cwd: repo.path, + const secondAgent = await createIdleAgent(workspace.client, { + cwd: workspace.repoPath, title: `settings-toggle-b-${Date.now()}`, }); - agentIds.push(firstAgent.id, secondAgent.id); await openWorkspaceWithAgents(page, [firstAgent, secondAgent]); await waitForTabBar(page); @@ -89,7 +74,7 @@ test.describe("Settings toggle tab regression", () => { await expectSendBehavior(page, "interrupt"); await pressSettingsToggleShortcut(page); - await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path)); + await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, workspace.repoPath)); await waitForTabBar(page); await expectAgentTabActive(page, secondAgent.id); @@ -97,11 +82,7 @@ test.describe("Settings toggle tab regression", () => { await waitForTabBar(page); await expectAgentTabActive(page, secondAgent.id); } finally { - for (const agentId of agentIds) { - await archiveAgentFromDaemon(client, agentId).catch(() => undefined); - } - await client.close().catch(() => undefined); - await repo.cleanup(); + await workspace.cleanup(); } }); @@ -109,31 +90,28 @@ test.describe("Settings toggle tab regression", () => { page, }) => { const serverId = getServerId(); - const client = await connectArchiveTabDaemonClient(); - const repo = await createTempGitRepo("agent-route-refresh-"); - const agentIds: string[] = []; + const workspace = await seedWorkspace({ repoPrefix: "agent-route-refresh-" }); try { - const firstAgent = await createIdleAgent(client, { - cwd: repo.path, + const firstAgent = await createIdleAgent(workspace.client, { + cwd: workspace.repoPath, title: `agent-route-refresh-a-${Date.now()}`, }); - const secondAgent = await createIdleAgent(client, { - cwd: repo.path, + const secondAgent = await createIdleAgent(workspace.client, { + cwd: workspace.repoPath, title: `agent-route-refresh-b-${Date.now()}`, }); - agentIds.push(firstAgent.id, secondAgent.id); await openAgentRouteAndExpectFocused({ page, serverId, - workspaceId: repo.path, + workspaceId: workspace.repoPath, agentId: firstAgent.id, }); await openAgentRouteAndExpectFocused({ page, serverId, - workspaceId: repo.path, + workspaceId: workspace.repoPath, agentId: secondAgent.id, }); @@ -143,11 +121,7 @@ test.describe("Settings toggle tab regression", () => { await expectAgentTabActive(page, secondAgent.id); } } finally { - for (const agentId of agentIds) { - await archiveAgentFromDaemon(client, agentId).catch(() => undefined); - } - await client.close().catch(() => undefined); - await repo.cleanup(); + await workspace.cleanup(); } }); }); diff --git a/packages/app/e2e/sidebar-workspace-rename.spec.ts b/packages/app/e2e/sidebar-workspace-rename.spec.ts index b51d0f13c..fe8de73df 100644 --- a/packages/app/e2e/sidebar-workspace-rename.spec.ts +++ b/packages/app/e2e/sidebar-workspace-rename.spec.ts @@ -1,17 +1,9 @@ import { execSync } from "node:child_process"; import { test, expect, type Page } from "./fixtures"; import { gotoAppShell } from "./helpers/app"; -import { createTempGitRepo } from "./helpers/workspace"; -import { connectWorkspaceSetupClient } from "./helpers/workspace-setup"; +import { seedWorkspace } from "./helpers/seed-client"; import { captureWsSessionFrames } from "./helpers/rename"; - -function getServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); - } - return serverId; -} +import { getServerId } from "./helpers/server-id"; function workspaceRowTestId(workspaceId: string): string { return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; @@ -21,21 +13,6 @@ function workspaceRenameModalTestId(workspaceId: string, suffix: string): string return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`; } -async function openProjectViaDaemon( - client: Awaited>, - cwd: string, -): Promise<{ id: string; name: string; workspaceDirectory: string }> { - const result = await client.openProject(cwd); - if (!result.workspace || result.error) { - throw new Error(result.error ?? `Failed to open project ${cwd}`); - } - return { - id: String(result.workspace.id), - name: result.workspace.name, - workspaceDirectory: result.workspace.workspaceDirectory, - }; -} - async function openRenameModal(page: Page, workspaceId: string) { const serverId = getServerId(); const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`); @@ -59,12 +36,10 @@ test.describe("Sidebar workspace rename", () => { test("renaming via kebab updates the branch name on disk and in the sidebar", async ({ page, }) => { - const client = await connectWorkspaceSetupClient(); - const repo = await createTempGitRepo("sidebar-rename-"); + const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-" }); try { - const workspace = await openProjectViaDaemon(client, repo.path); - expect(workspace.name).toBe("main"); + expect(workspace.workspaceName).toBe("main"); const renameRequests = captureWsSessionFrames( page, @@ -76,18 +51,18 @@ test.describe("Sidebar workspace rename", () => { ); await gotoAppShell(page); - await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toBeVisible({ + await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({ timeout: 30_000, }); - const input = await openRenameModal(page, workspace.id); + const input = await openRenameModal(page, workspace.workspaceId); await expect(input).toHaveValue("main"); await input.fill("Feature Rename 2"); - await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click(); + await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click(); await expect(input).toHaveCount(0, { timeout: 15_000 }); - await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText( + await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText( "feature-rename-2", { timeout: 15_000 }, ); @@ -99,40 +74,42 @@ test.describe("Sidebar workspace rename", () => { }); const currentBranchOnDisk = execSync("git branch --show-current", { - cwd: repo.path, + cwd: workspace.repoPath, stdio: "pipe", }) .toString() .trim(); expect(currentBranchOnDisk).toBe("feature-rename-2"); } finally { - await client.close(); - await repo.cleanup(); + await workspace.cleanup(); } }); test("rename surfaces server errors inline and keeps the modal open", async ({ page }) => { - const client = await connectWorkspaceSetupClient(); - const repo = await createTempGitRepo("sidebar-rename-error-", { branches: ["taken"] }); + const workspace = await seedWorkspace({ + repoPrefix: "sidebar-rename-error-", + repo: { branches: ["taken"] }, + }); try { - const workspace = await openProjectViaDaemon(client, repo.path); - await gotoAppShell(page); - const input = await openRenameModal(page, workspace.id); + const input = await openRenameModal(page, workspace.workspaceId); await expect(input).toHaveValue("main"); await input.fill("taken"); - await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click(); + await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click(); - const errorNode = page.getByTestId(workspaceRenameModalTestId(workspace.id, "error")); + const errorNode = page.getByTestId( + workspaceRenameModalTestId(workspace.workspaceId, "error"), + ); await expect(errorNode).toBeVisible({ timeout: 15_000 }); await expect(errorNode).toContainText(/already exists|branch/i); await expect(input).toBeVisible(); - await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText("main"); + await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText( + "main", + ); } finally { - await client.close(); - await repo.cleanup(); + await workspace.cleanup(); } }); }); diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index 5abc44775..bc01852a8 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -1,6 +1,3 @@ -import { execSync } from "node:child_process"; -import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; import path from "node:path"; import { test, expect } from "./fixtures"; import { gotoAppShell } from "./helpers/app"; @@ -10,59 +7,17 @@ import { expectMobileAgentSidebarVisible, openMobileAgentSidebar, } from "./helpers/sidebar"; -import { createTempGitRepo } from "./helpers/workspace"; +import { seedWorkspace } from "./helpers/seed-client"; import { expectWorkspaceHeader } from "./helpers/workspace-ui"; -import { connectWorkspaceSetupClient } from "./helpers/workspace-setup"; +import { getServerId } from "./helpers/server-id"; +import { escapeRegex } from "./helpers/regex"; -function getServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); - } - return serverId; -} +const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git"; function getWorkspaceRowTestId(workspaceId: string): string { return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; } -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function setGitHubRemote(repoPath: string): void { - execSync("git remote set-url origin https://github.com/test-owner/test-repo.git", { - cwd: repoPath, - stdio: "ignore", - }); -} - -async function createTempDirectory(prefix = "paseo-e2e-dir-") { - const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp"); - const dirPath = await mkdtemp(path.join(tempRoot, prefix)); - await writeFile(path.join(dirPath, "README.md"), "# Temp Directory\n"); - return { - path: dirPath, - cleanup: async () => { - await rm(dirPath, { recursive: true, force: true }); - }, - }; -} - -async function openProjectViaDaemon( - client: Awaited>, - cwd: string, -): Promise<{ id: string; name: string }> { - const result = await client.openProject(cwd); - if (!result.workspace || result.error) { - throw new Error(result.error ?? `Failed to open project ${cwd}`); - } - return { - id: result.workspace.id, - name: result.workspace.name, - }; -} - async function openWorkspaceFromSidebar( page: import("@playwright/test").Page, workspaceId: string, @@ -92,15 +47,15 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo test.describe("Sidebar workspace list", () => { test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => { - const client = await connectWorkspaceSetupClient(); - const repo = await createTempGitRepo("sidebar-remote-", { withRemote: true }); + const workspace = await seedWorkspace({ + repoPrefix: "sidebar-remote-", + repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL }, + }); try { - setGitHubRemote(repo.path); - const workspace = await openProjectViaDaemon(client, repo.path); await gotoAppShell(page); await waitForSidebarProject(page, "test-owner/test-repo"); - await waitForSidebarWorkspace(page, workspace.id); + await waitForSidebarWorkspace(page, workspace.workspaceId); const projectRow = page .locator('[data-testid^="sidebar-project-row-"]') @@ -108,81 +63,73 @@ test.describe("Sidebar workspace list", () => { .first(); await expect(projectRow).toBeVisible({ timeout: 30_000 }); - await expect(projectRow).not.toContainText(path.basename(repo.path)); + await expect(projectRow).not.toContainText(path.basename(workspace.repoPath)); } finally { - await client.close(); - await repo.cleanup(); + await workspace.cleanup(); } }); test("project shows workspace under it", async ({ page }) => { - const client = await connectWorkspaceSetupClient(); - const repo = await createTempGitRepo("sidebar-workspace-under-project-"); + const workspace = await seedWorkspace({ repoPrefix: "sidebar-workspace-under-project-" }); try { - const workspace = await openProjectViaDaemon(client, repo.path); await gotoAppShell(page); - await waitForSidebarProject(page, path.basename(repo.path)); - await waitForSidebarWorkspace(page, workspace.id); + await waitForSidebarProject(page, path.basename(workspace.repoPath)); + await waitForSidebarWorkspace(page, workspace.workspaceId); } finally { - await client.close(); - await repo.cleanup(); + await workspace.cleanup(); } }); test("non-git project shows directory name", async ({ page }) => { - const client = await connectWorkspaceSetupClient(); - const project = await createTempDirectory("sidebar-directory-"); + const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false }); try { - await openProjectViaDaemon(client, project.path); await gotoAppShell(page); - const projectRow = await waitForSidebarProject(page, path.basename(project.path)); - await expect(projectRow).toContainText(path.basename(project.path)); + const directoryName = path.basename(workspace.repoPath); + const projectRow = await waitForSidebarProject(page, directoryName); + await expect(projectRow).toContainText(directoryName); } finally { - await client.close(); - await project.cleanup(); + await workspace.cleanup(); } }); test("workspace header shows correct title and subtitle", async ({ page }) => { - const client = await connectWorkspaceSetupClient(); - const repo = await createTempGitRepo("sidebar-header-", { withRemote: true }); + const workspace = await seedWorkspace({ + repoPrefix: "sidebar-header-", + repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL }, + }); try { - setGitHubRemote(repo.path); - const workspace = await openProjectViaDaemon(client, repo.path); await gotoAppShell(page); await waitForSidebarProject(page, "test-owner/test-repo"); - await waitForSidebarWorkspace(page, workspace.id); - await openWorkspaceFromSidebar(page, workspace.id); + await waitForSidebarWorkspace(page, workspace.workspaceId); + await openWorkspaceFromSidebar(page, workspace.workspaceId); await expectWorkspaceHeader(page, { - title: workspace.name, + title: workspace.workspaceName, subtitle: "test-owner/test-repo", }); } finally { - await client.close(); - await repo.cleanup(); + await workspace.cleanup(); } }); test("git project shows branch name in workspace row", async ({ page }) => { - const client = await connectWorkspaceSetupClient(); - const repo = await createTempGitRepo("sidebar-branch-"); + const workspace = await seedWorkspace({ repoPrefix: "sidebar-branch-" }); try { - const workspace = await openProjectViaDaemon(client, repo.path); await gotoAppShell(page); - await waitForSidebarProject(page, path.basename(repo.path)); + await waitForSidebarProject(page, path.basename(workspace.repoPath)); - expect(workspace.name).toBe("main"); - await expect(await waitForSidebarWorkspace(page, workspace.id)).toContainText("main"); + expect(workspace.workspaceName).toBe("main"); + await expect(await waitForSidebarWorkspace(page, workspace.workspaceId)).toContainText( + "main", + ); } finally { - await client.close(); - await repo.cleanup(); + await workspace.cleanup(); } }); }); diff --git a/packages/app/e2e/startup-loading.spec.ts b/packages/app/e2e/startup-loading.spec.ts index 677ecfd02..38fd29c3d 100644 --- a/packages/app/e2e/startup-loading.spec.ts +++ b/packages/app/e2e/startup-loading.spec.ts @@ -1,22 +1,8 @@ import { test } from "./fixtures"; +import { getE2EDaemonPort } from "./helpers/daemon-port"; +import { getServerId } from "./helpers/server-id"; import { startupScenario } from "./helpers/startup-dsl"; -function getE2EDaemonPort(): string { - const port = process.env.E2E_DAEMON_PORT; - if (!port) { - throw new Error("E2E_DAEMON_PORT is not set."); - } - return port; -} - -function getE2EServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } - return serverId; -} - test.describe("Startup loading presentation", () => { test("mobile reconnect keeps connection recovery actions visible", async ({ page }) => { const startup = await startupScenario(page) @@ -49,7 +35,7 @@ test.describe("Startup loading presentation", () => { test("host-route refresh does not render route chrome around the bootstrap splash", async ({ page, }) => { - const serverId = getE2EServerId(); + const serverId = getServerId(); const startup = await startupScenario(page) .withPendingDesktopDaemon() .withBlockedPort(getE2EDaemonPort()) diff --git a/packages/app/e2e/terminal-performance.spec.ts b/packages/app/e2e/terminal-performance.spec.ts index 307ee5637..3ed3f4fae 100644 --- a/packages/app/e2e/terminal-performance.spec.ts +++ b/packages/app/e2e/terminal-performance.spec.ts @@ -1,14 +1,10 @@ import { test, expect } from "./fixtures"; -import { createTempGitRepo } from "./helpers/workspace"; +import { TerminalE2EHarness } from "./helpers/terminal-dsl"; import { - connectTerminalClient, - navigateToTerminal, - setupDeterministicPrompt, waitForTerminalContent, measureKeystrokeLatency, computePercentile, round2, - type TerminalPerfDaemonClient, type LatencySample, } from "./helpers/terminal-perf"; @@ -20,43 +16,26 @@ const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1"; const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip; terminalPerfDescribe("Terminal wire performance", () => { - let client: TerminalPerfDaemonClient; - let tempRepo: { path: string; cleanup: () => Promise }; - let workspaceId: string; + let harness: TerminalE2EHarness; test.beforeAll(async () => { - tempRepo = await createTempGitRepo("perf-"); - client = await connectTerminalClient(); - // Seed the workspace in the daemon so the app can resolve the path - const seedResult = await client.openProject(tempRepo.path); - if (!seedResult.workspace) throw new Error(seedResult.error ?? "Failed to seed workspace"); - workspaceId = seedResult.workspace.id; + harness = await TerminalE2EHarness.create({ tempPrefix: "perf-" }); }); test.afterAll(async () => { - if (client) { - await client.close(); - } - if (tempRepo) { - await tempRepo.cleanup(); - } + await harness?.cleanup(); }); test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => { test.setTimeout(90_000); - const result = await client.createTerminal(tempRepo.path, "throughput"); - if (!result.terminal) { - throw new Error(`Failed to create terminal: ${result.error}`); - } - const terminalId = result.terminal.id; - + const created = await harness.createTerminal({ name: "throughput" }); try { - await navigateToTerminal(page, { workspaceId, terminalId }); - await setupDeterministicPrompt(page); + await harness.openTerminal(page, { terminalId: created.id }); + await harness.setupPrompt(page); const sentinel = `PERF_DONE_${Date.now()}`; - const terminal = page.locator('[data-testid="terminal-surface"]'); + const terminal = harness.terminalSurface(page); const startMs = Date.now(); await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 }); @@ -97,25 +76,20 @@ terminalPerfDescribe("Terminal wire performance", () => { `${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`, ).toBeLessThan(THROUGHPUT_BUDGET_MS); } finally { - await client.killTerminal(terminalId).catch(() => {}); + await harness.killTerminal(created.id); } }); test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => { test.setTimeout(60_000); - const result = await client.createTerminal(tempRepo.path, "latency"); - if (!result.terminal) { - throw new Error(`Failed to create terminal: ${result.error}`); - } - const terminalId = result.terminal.id; - + const created = await harness.createTerminal({ name: "latency" }); try { - await navigateToTerminal(page, { workspaceId, terminalId }); - await setupDeterministicPrompt(page); + await harness.openTerminal(page, { terminalId: created.id }); + await harness.setupPrompt(page); // Ensure clean prompt state - const terminal = page.locator('[data-testid="terminal-surface"]'); + const terminal = harness.terminalSurface(page); await terminal.press("Control+c"); await page.waitForTimeout(200); @@ -166,7 +140,7 @@ terminalPerfDescribe("Terminal wire performance", () => { `Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`, ).toBeLessThan(KEYSTROKE_P95_BUDGET_MS); } finally { - await client.killTerminal(terminalId).catch(() => {}); + await harness.killTerminal(created.id); } }); }); diff --git a/packages/app/e2e/user-message-contract.ui-contract.spec.ts b/packages/app/e2e/user-message-contract.ui-contract.spec.ts index bba6bde94..4a536368c 100644 --- a/packages/app/e2e/user-message-contract.ui-contract.spec.ts +++ b/packages/app/e2e/user-message-contract.ui-contract.spec.ts @@ -1,7 +1,5 @@ import { expect, test, type Page } from "./fixtures"; -import { buildHostWorkspaceRoute } from "@/utils/host-routes"; -import { connectTerminalClient } from "./helpers/terminal-perf"; -import { createTempGitRepo } from "./helpers/workspace"; +import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; import { composerLocator, expectComposerEditable, @@ -10,27 +8,6 @@ import { submitMessage, } from "./helpers/composer"; -function getServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } - return serverId; -} - -async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise { - const agentUrl = `${buildHostWorkspaceRoute( - getServerId(), - input.cwd, - )}?open=${encodeURIComponent(`agent:${input.agentId}`)}`; - await page.goto(agentUrl); - await page.waitForURL( - (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), - { timeout: 60_000 }, - ); - await expectComposerVisible(page); -} - async function expectUserMessageCount(page: Page, expected: number): Promise { await expect(page.getByTestId("user-message")).toHaveCount(expected, { timeout: 15_000 }); } @@ -50,8 +27,10 @@ async function expectNoLoadingRegressionAfterIdle(page: Page): Promise { test.describe("User message UI contract", () => { test("dedupes mock provider user_message echoes across multi-turn sends", async ({ page }) => { - const repo = await createTempGitRepo("user-message-contract-e2e-"); - const client = await connectTerminalClient(); + const session = await seedMockAgentWorkspace({ + repoPrefix: "user-message-contract-e2e-", + title: "User message contract e2e", + }); const prompts = [ "emit 1 coalesced agent stream updates for user message contract turn one.", "emit 1 coalesced agent stream updates for user message contract turn two.", @@ -59,15 +38,8 @@ test.describe("User message UI contract", () => { ]; try { - await client.openProject(repo.path); - const agent = await client.createAgent({ - provider: "mock", - cwd: repo.path, - title: "User message contract e2e", - modeId: "load-test", - model: "ten-second-stream", - }); - await openAgent(page, { cwd: repo.path, agentId: agent.id }); + await openAgentRoute(page, session); + await expectComposerVisible(page); for (let index = 0; index < prompts.length; index += 1) { const prompt = prompts[index]!; @@ -85,8 +57,7 @@ test.describe("User message UI contract", () => { await expectUserMessageCount(page, 3); await expectIdleComposer(page); } finally { - await client.close(); - await repo.cleanup(); + await session.cleanup(); } }); }); diff --git a/packages/app/e2e/workspace-agent-tab-rename.spec.ts b/packages/app/e2e/workspace-agent-tab-rename.spec.ts index 91d04613d..7e8185d19 100644 --- a/packages/app/e2e/workspace-agent-tab-rename.spec.ts +++ b/packages/app/e2e/workspace-agent-tab-rename.spec.ts @@ -1,22 +1,11 @@ import { randomUUID } from "node:crypto"; import { test, expect, type Page } from "./fixtures"; -import { createTempGitRepo } from "./helpers/workspace"; -import { - connectArchiveTabDaemonClient, - createIdleAgent, - expectWorkspaceTabVisible, -} from "./helpers/archive-tab"; +import { seedWorkspace } from "./helpers/seed-client"; +import { createIdleAgent, expectWorkspaceTabVisible } from "./helpers/archive-tab"; import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; import { buildHostAgentDetailRoute } from "@/utils/host-routes"; import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename"; - -function getServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); - } - return serverId; -} +import { getServerId } from "./helpers/server-id"; async function openAgentInWorkspace(page: Page, agent: { id: string; cwd: string }) { await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.cwd)); @@ -34,13 +23,12 @@ test.describe("Workspace agent tab rename", () => { }) => { test.setTimeout(120_000); - const client = await connectArchiveTabDaemonClient(); - const repo = await createTempGitRepo("workspace-agent-rename-"); + const workspace = await seedWorkspace({ repoPrefix: "workspace-agent-rename-" }); try { const initialTitle = `agent-rename-${randomUUID().slice(0, 8)}`; - const agent = await createIdleAgent(client, { - cwd: repo.path, + const agent = await createIdleAgent(workspace.client, { + cwd: workspace.repoPath, title: initialTitle, }); @@ -81,8 +69,7 @@ test.describe("Workspace agent tab rename", () => { expect(lastFrame.name).toBe(renamed); expect(lastFrame.requestId.length).toBeGreaterThan(0); } finally { - await client.close(); - await repo.cleanup(); + await workspace.cleanup(); } }); }); diff --git a/packages/app/e2e/workspace-agent-title-handoff.spec.ts b/packages/app/e2e/workspace-agent-title-handoff.spec.ts index 5aa2352df..b24ef5003 100644 --- a/packages/app/e2e/workspace-agent-title-handoff.spec.ts +++ b/packages/app/e2e/workspace-agent-title-handoff.spec.ts @@ -1,9 +1,9 @@ import { test, expect, type Page } from "./fixtures"; -import { createTempGitRepo } from "./helpers/workspace"; import { expectComposerVisible, submitMessage } from "./helpers/composer"; -import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf"; +import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client"; import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; import { captureWsSessionFrames } from "./helpers/rename"; +import { getServerId } from "./helpers/server-id"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; interface WorkspaceTabProbeRecord { @@ -20,14 +20,6 @@ interface CapturedCreateAgentFrame { configTitle: string | null; } -function getServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } - return serverId; -} - async function installWorkspaceTabProbe(page: Page): Promise { await page.evaluate(() => { type ProbeRecord = WorkspaceTabProbeRecord; @@ -122,10 +114,7 @@ function recordHasLoadingTitle(record: WorkspaceTabProbeRecord): boolean { return record.tabs.some(tabHasLoadingTitle); } -async function waitForCreatedAgentId( - client: TerminalPerfDaemonClient, - cwd: string, -): Promise { +async function waitForCreatedAgentId(client: SeedDaemonClient, cwd: string): Promise { await expect .poll( async () => { @@ -146,7 +135,7 @@ async function waitForCreatedAgentId( } async function fetchActiveAgentTitle( - client: TerminalPerfDaemonClient, + client: SeedDaemonClient, agentId: string, ): Promise { const result = await client.fetchAgents({ scope: "active" }); @@ -179,15 +168,9 @@ test.describe("Workspace agent title handoff", () => { test.setTimeout(120_000); await page.setViewportSize({ width: 1440, height: 900 }); - const client = await connectTerminalClient(); - const repo = await createTempGitRepo("workspace-title-handoff-"); + const workspace = await seedWorkspace({ repoPrefix: "workspace-title-handoff-" }); try { - const opened = await client.openProject(repo.path); - if (!opened.workspace) { - throw new Error(opened.error ?? "Failed to open test workspace"); - } - const createFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => { const config = (inner.config ?? {}) as Record; return { @@ -196,7 +179,7 @@ test.describe("Workspace agent title handoff", () => { }; }); - await page.goto(buildHostWorkspaceRoute(getServerId(), opened.workspace.id)); + await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId)); await waitForWorkspaceTabsVisible(page); await page.getByTestId("workspace-new-agent-tab").click(); await expectComposerVisible(page); @@ -206,7 +189,7 @@ test.describe("Workspace agent title handoff", () => { await installWorkspaceTabProbe(page); await submitMessage(page, `${promptTitle}\n\nMake the UI state deterministic.`); - const agentId = await waitForCreatedAgentId(client, repo.path); + const agentId = await waitForCreatedAgentId(workspace.client, workspace.repoPath); await expect .poll(() => countCreateFramesForPrompt(createFrames, promptTitle), { timeout: 10_000, @@ -219,9 +202,9 @@ test.describe("Workspace agent title handoff", () => { await waitForPromptTabAgentActions(page, promptTitle); - await client.updateAgent(agentId, { name: generatedTitle }); + await workspace.client.updateAgent(agentId, { name: generatedTitle }); await expect - .poll(() => fetchActiveAgentTitle(client, agentId), { timeout: 10_000 }) + .poll(() => fetchActiveAgentTitle(workspace.client, agentId), { timeout: 10_000 }) .toBe(generatedTitle); await expect(page.getByRole("button", { name: generatedTitle }).first()).toBeVisible({ timeout: 15_000, @@ -232,8 +215,7 @@ test.describe("Workspace agent title handoff", () => { expect(records.some((record) => recordHasTabLabel(record, generatedTitle))).toBe(true); expect(records.filter(recordHasLoadingTitle)).toEqual([]); } finally { - await client.close().catch(() => undefined); - await repo.cleanup(); + await workspace.cleanup(); } }); }); diff --git a/packages/app/e2e/workspace-cwd.spec.ts b/packages/app/e2e/workspace-cwd.spec.ts index bbfc7978d..831cad77d 100644 --- a/packages/app/e2e/workspace-cwd.spec.ts +++ b/packages/app/e2e/workspace-cwd.spec.ts @@ -1,5 +1,6 @@ -import { expect, type Page, test } from "./fixtures"; +import { expect, test } from "./fixtures"; import { clickNewChat, clickNewTerminal } from "./helpers/launcher"; +import { captureWsSessionFrames } from "./helpers/rename"; import { expectTerminalSurfaceVisible, focusTerminalSurface, @@ -8,53 +9,13 @@ import { waitForTerminalContent, } from "./helpers/terminal-perf"; -async function installCreateAgentRequestRecorder(page: Page): Promise { - await page.addInitScript(() => { - const requests: unknown[] = []; - ( - window as typeof window & { - __paseoE2eCreateAgentRequests?: unknown[]; - } - ).__paseoE2eCreateAgentRequests = requests; - const originalSend = WebSocket.prototype.send; - WebSocket.prototype.send = function (data) { - if (typeof data === "string") { - try { - const parsed = JSON.parse(data) as { - type?: unknown; - message?: { type?: unknown }; - }; - if (parsed.type === "session" && parsed.message?.type === "create_agent_request") { - requests.push(parsed.message); - } - } catch { - // Ignore non-JSON frames. - } - } - return originalSend.call(this, data); - }; - }); +interface CreateAgentFrame { + initialPrompt: string | null; + cwd: string | null; } -async function getRecordedCreateAgentCwd(page: Page, message: string): Promise { - return page.evaluate((expectedMessage) => { - const requests = - ( - window as typeof window & { - __paseoE2eCreateAgentRequests?: Array<{ - initialPrompt?: string; - config?: { cwd?: string }; - }>; - } - ).__paseoE2eCreateAgentRequests ?? []; - - for (const request of requests) { - if (request.initialPrompt === expectedMessage) { - return request.config?.cwd ?? null; - } - } - return null; - }, message); +function cwdForPrompt(frames: CreateAgentFrame[], prompt: string): string | null { + return frames.find((frame) => frame.initialPrompt === prompt)?.cwd ?? null; } test.describe("Workspace cwd correctness", () => { @@ -78,7 +39,14 @@ test.describe("Workspace cwd correctness", () => { test("draft tab creates an agent in the workspace cwd", async ({ page, withWorkspace }) => { test.setTimeout(60_000); - await installCreateAgentRequestRecorder(page); + const createAgentFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => { + const config = (inner.config ?? {}) as Record; + return { + initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null, + cwd: typeof config.cwd === "string" ? config.cwd : null, + }; + }); + const workspace = await withWorkspace({ prefix: "workspace-cwd-draft-agent-" }); await workspace.navigateTo(); @@ -97,7 +65,7 @@ test.describe("Workspace cwd correctness", () => { }); await expect - .poll(async () => getRecordedCreateAgentCwd(page, message), { timeout: 30_000 }) + .poll(() => cwdForPrompt(createAgentFrames, message), { timeout: 30_000 }) .toBe(workspace.repoPath); }); diff --git a/packages/app/e2e/workspace-navigation-regression.spec.ts b/packages/app/e2e/workspace-navigation-regression.spec.ts index d9c0647d8..7566c35de 100644 --- a/packages/app/e2e/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/workspace-navigation-regression.spec.ts @@ -3,20 +3,14 @@ import type { WebSocketRoute } from "@playwright/test"; import { expect, test, type Page } from "./fixtures"; import { gotoAppShell, openSettings } from "./helpers/app"; import { - archiveAgentFromDaemon, - connectArchiveTabDaemonClient, createIdleAgent, expectWorkspaceTabHidden, expectWorkspaceTabVisible, openWorkspaceWithAgents, } from "./helpers/archive-tab"; -import { - archiveLocalWorkspaceFromDaemon, - connectNewWorkspaceDaemonClient, - openProjectViaDaemon, -} from "./helpers/new-workspace"; import { expectComposerVisible } from "./helpers/composer"; -import { createTempGitRepo } from "./helpers/workspace"; +import { daemonWsRoutePattern } from "./helpers/daemon-port"; +import { seedWorkspace } from "./helpers/seed-client"; import { getVisibleWorkspaceAgentTabIds, expectOnlyWorkspaceAgentTabsVisible, @@ -37,13 +31,10 @@ import { expectWorkspaceDeckEntryCount, } from "./helpers/workspace-ui"; import { clickSettingsBackToWorkspace } from "./helpers/settings"; +import { getServerId } from "./helpers/server-id"; const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i; -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - async function expectNoLoadingWorkspacePane( page: Page, input: { label: string; durationMs?: number }, @@ -88,11 +79,11 @@ async function closeFirstVisibleDraftTab(page: Page): Promise { await closeButton.first().click(); } -async function installDaemonWebSocketGate(page: Page, daemonPort: string) { +async function installDaemonWebSocketGate(page: Page) { let acceptingConnections = true; const activeSockets = new Set(); - await page.routeWebSocket(new RegExp(`:${escapeRegex(daemonPort)}\\b`), (ws) => { + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { if (!acceptingConnections) { void ws.close({ code: 1008, reason: "Blocked by workspace reconnect regression test." }); return; @@ -166,32 +157,17 @@ test.describe("Workspace navigation regression", () => { }); test("keeps the workspace rendered while reconnecting to the host", async ({ page }) => { - const serverId = process.env.E2E_SERVER_ID; - const daemonPort = process.env.E2E_DAEMON_PORT; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } - if (!daemonPort) { - throw new Error("E2E_DAEMON_PORT is not set."); - } + const serverId = getServerId(); - const daemonGate = await installDaemonWebSocketGate(page, daemonPort); + const daemonGate = await installDaemonWebSocketGate(page); - const workspaceClient = await connectNewWorkspaceDaemonClient(); - const archiveClient = await connectArchiveTabDaemonClient(); - const workspaceIds = new Set(); - const agentIds: string[] = []; - const repo = await createTempGitRepo("workspace-reconnect-"); + const workspace = await seedWorkspace({ repoPrefix: "workspace-reconnect-" }); try { - const workspace = await openProjectViaDaemon(workspaceClient, repo.path); - workspaceIds.add(workspace.workspaceId); - - const agent = await createIdleAgent(archiveClient, { - cwd: repo.path, + const agent = await createIdleAgent(workspace.client, { + cwd: workspace.repoPath, title: `workspace-reconnect-${Date.now()}`, }); - agentIds.push(agent.id); await gotoAppShell(page); await waitForSidebarHydration(page); @@ -231,31 +207,16 @@ test.describe("Workspace navigation regression", () => { await expectComposerVisible(page); } finally { daemonGate.restore(); - for (const agentId of agentIds) { - await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined); - } - for (const workspaceId of workspaceIds) { - await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined); - } - await archiveClient.close().catch(() => undefined); - await workspaceClient.close().catch(() => undefined); - await repo.cleanup(); + await workspace.cleanup(); } }); test("cold offline workspace route gates the screen interior but keeps settings reachable", async ({ page, }) => { - const serverId = process.env.E2E_SERVER_ID; - const daemonPort = process.env.E2E_DAEMON_PORT; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } - if (!daemonPort) { - throw new Error("E2E_DAEMON_PORT is not set."); - } + const serverId = getServerId(); - await page.routeWebSocket(new RegExp(`:${escapeRegex(daemonPort)}\\b`), async (ws) => { + await page.routeWebSocket(daemonWsRoutePattern(), async (ws) => { await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." }); }); @@ -272,22 +233,12 @@ test.describe("Workspace navigation regression", () => { }); test("cold workspace URL keeps sidebar workspace navigation functional", async ({ page }) => { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } + const serverId = getServerId(); - const workspaceClient = await connectNewWorkspaceDaemonClient(); - const workspaceIds = new Set(); - const firstRepo = await createTempGitRepo("workspace-cold-url-a-"); - const secondRepo = await createTempGitRepo("workspace-cold-url-b-"); + const firstWorkspace = await seedWorkspace({ repoPrefix: "workspace-cold-url-a-" }); + const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-cold-url-b-" }); try { - const firstWorkspace = await openProjectViaDaemon(workspaceClient, firstRepo.path); - const secondWorkspace = await openProjectViaDaemon(workspaceClient, secondRepo.path); - workspaceIds.add(firstWorkspace.workspaceId); - workspaceIds.add(secondWorkspace.workspaceId); - await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId)); await waitForSidebarHydration(page); await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), { @@ -304,45 +255,28 @@ test.describe("Workspace navigation regression", () => { timeout: 30_000, }); } finally { - for (const workspaceId of workspaceIds) { - await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined); - } - await workspaceClient.close().catch(() => undefined); - await secondRepo.cleanup(); - await firstRepo.cleanup(); + await secondWorkspace.cleanup(); + await firstWorkspace.cleanup(); } }); test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({ page, }) => { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } + const serverId = getServerId(); - const workspaceClient = await connectNewWorkspaceDaemonClient(); - const archiveClient = await connectArchiveTabDaemonClient(); - const workspaceIds = new Set(); - const agentIds: string[] = []; - const firstRepo = await createTempGitRepo("workspace-nav-reg-a-"); - const secondRepo = await createTempGitRepo("workspace-nav-reg-b-"); + const firstWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-a-" }); + const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-b-" }); try { - const firstWorkspace = await openProjectViaDaemon(workspaceClient, firstRepo.path); - const secondWorkspace = await openProjectViaDaemon(workspaceClient, secondRepo.path); - workspaceIds.add(firstWorkspace.workspaceId); - workspaceIds.add(secondWorkspace.workspaceId); - - const firstAgent = await createIdleAgent(archiveClient, { - cwd: firstRepo.path, + const firstAgent = await createIdleAgent(firstWorkspace.client, { + cwd: firstWorkspace.repoPath, title: `workspace-nav-a-${Date.now()}`, }); - const secondAgent = await createIdleAgent(archiveClient, { - cwd: secondRepo.path, + const secondAgent = await createIdleAgent(secondWorkspace.client, { + cwd: secondWorkspace.repoPath, title: `workspace-nav-b-${Date.now()}`, }); - agentIds.push(firstAgent.id, secondAgent.id); await gotoAppShell(page); await waitForSidebarHydration(page); @@ -487,16 +421,8 @@ test.describe("Workspace navigation regression", () => { `workspace-tab-agent_${firstAgent.id}`, ]); } finally { - for (const agentId of agentIds) { - await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined); - } - for (const workspaceId of workspaceIds) { - await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined); - } - await archiveClient.close().catch(() => undefined); - await workspaceClient.close().catch(() => undefined); - await secondRepo.cleanup(); - await firstRepo.cleanup(); + await secondWorkspace.cleanup(); + await firstWorkspace.cleanup(); } }); }); diff --git a/packages/app/e2e/workspace-pane-remount.spec.ts b/packages/app/e2e/workspace-pane-remount.spec.ts index ef1c2be5a..6762cdb1f 100644 --- a/packages/app/e2e/workspace-pane-remount.spec.ts +++ b/packages/app/e2e/workspace-pane-remount.spec.ts @@ -1,12 +1,9 @@ import { buildHostAgentDetailRoute } from "@/utils/host-routes"; import { expect, test } from "./fixtures"; -import { - archiveAgentFromDaemon, - connectArchiveTabDaemonClient, - createIdleAgent, -} from "./helpers/archive-tab"; +import { createIdleAgent } from "./helpers/archive-tab"; import { expectComposerVisible } from "./helpers/composer"; -import { createTempGitRepo } from "./helpers/workspace"; +import { seedWorkspace } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; test.describe("Workspace pane mounting", () => { @@ -14,21 +11,15 @@ test.describe("Workspace pane mounting", () => { page, }) => { test.setTimeout(90_000); - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } + const serverId = getServerId(); - const client = await connectArchiveTabDaemonClient(); - const repo = await createTempGitRepo("pane-remount-"); - let agentId: string | null = null; + const workspace = await seedWorkspace({ repoPrefix: "pane-remount-" }); try { - const agent = await createIdleAgent(client, { - cwd: repo.path, + const agent = await createIdleAgent(workspace.client, { + cwd: workspace.repoPath, title: `pane-remount-${Date.now()}`, }); - agentId = agent.id; await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd)); await page.waitForURL( @@ -54,11 +45,7 @@ test.describe("Workspace pane mounting", () => { const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected); expect(originalStillConnected).toBe(true); } finally { - if (agentId) { - await archiveAgentFromDaemon(client, agentId).catch(() => undefined); - } - await client.close().catch(() => undefined); - await repo.cleanup(); + await workspace.cleanup(); } }); }); diff --git a/packages/app/e2e/workspace-setup-runtime.spec.ts b/packages/app/e2e/workspace-setup-runtime.spec.ts index 289be962a..bd312c139 100644 --- a/packages/app/e2e/workspace-setup-runtime.spec.ts +++ b/packages/app/e2e/workspace-setup-runtime.spec.ts @@ -9,6 +9,7 @@ import { findWorktreeWorkspaceForProject, navigateToWorkspaceViaSidebar, openHomeWithProject, + seedProjectForWorkspaceSetup, } from "./helpers/workspace-setup"; test.describe("Workspace setup runtime authority", () => { @@ -21,7 +22,7 @@ test.describe("Workspace setup runtime authority", () => { const repo = await createTempGitRepo("workspace-setup-chat-"); try { - await client.openProject(repo.path); + await seedProjectForWorkspaceSetup(client, repo.path); const workspace = await createWorkspaceThroughDaemon(client, { cwd: repo.path, worktreeSlug: `setup-chat-${Date.now()}`, @@ -48,7 +49,7 @@ test.describe("Workspace setup runtime authority", () => { const repo = await createTempGitRepo("workspace-setup-terminal-"); try { - await client.openProject(repo.path); + await seedProjectForWorkspaceSetup(client, repo.path); // Create workspace via daemon API since the new workspace screen // no longer has a standalone terminal button diff --git a/packages/app/e2e/workspace-terminal-tab-rename.spec.ts b/packages/app/e2e/workspace-terminal-tab-rename.spec.ts index 54a485fa8..9d93f7f94 100644 --- a/packages/app/e2e/workspace-terminal-tab-rename.spec.ts +++ b/packages/app/e2e/workspace-terminal-tab-rename.spec.ts @@ -1,50 +1,42 @@ import { test, expect } from "./fixtures"; -import { createTempGitRepo } from "./helpers/workspace"; -import { connectTerminalClient, navigateToTerminal } from "./helpers/terminal-perf"; +import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl"; import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename"; test.describe("Workspace terminal tab rename", () => { + let harness: TerminalE2EHarness; + + test.beforeAll(async () => { + harness = await TerminalE2EHarness.create({ tempPrefix: "workspace-terminal-rename-" }); + }); + + test.afterAll(async () => { + await harness?.cleanup(); + }); + test("right-click rename sends terminal.rename.request and updates the tab label", async ({ page, }) => { test.setTimeout(60_000); - const client = await connectTerminalClient(); - const repo = await createTempGitRepo("workspace-terminal-rename-"); + const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({ + terminalId: String(inner.terminalId ?? ""), + title: String(inner.title ?? ""), + requestId: String(inner.requestId ?? ""), + })); - try { - const seeded = await client.openProject(repo.path); - if (!seeded.workspace) { - throw new Error(seeded.error ?? "Failed to seed workspace"); - } - const workspaceId = seeded.workspace.id; - - const created = await client.createTerminal(repo.path); - if (!created.terminal) { - throw new Error(created.error ?? "Failed to create terminal"); - } - const terminalId = created.terminal.id; - - const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({ - terminalId: String(inner.terminalId ?? ""), - title: String(inner.title ?? ""), - requestId: String(inner.requestId ?? ""), - })); - - await navigateToTerminal(page, { workspaceId, terminalId }); - - const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first(); + await withTerminalInApp(page, harness, { name: "rename-target" }, async (terminal) => { + const tab = page.getByTestId(`workspace-tab-terminal_${terminal.id}`).first(); await expect(tab).toBeVisible({ timeout: 15_000 }); await tab.click({ button: "right" }); - await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({ + await expect(page.getByTestId(`workspace-tab-context-terminal_${terminal.id}`)).toBeVisible({ timeout: 10_000, }); - const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`); + const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminal.id}-rename`); await expect(renameItem).toBeVisible({ timeout: 10_000 }); await renameItem.click(); - const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`; + const modalPrefix = `workspace-tab-rename-modal-terminal-${terminal.id}`; const input = renameModalInput(page, modalPrefix); await expect(input).toBeVisible({ timeout: 10_000 }); @@ -56,12 +48,9 @@ test.describe("Workspace terminal tab rename", () => { expect(renameFrames.length).toBeGreaterThan(0); const lastFrame = renameFrames.at(-1)!; - expect(lastFrame.terminalId).toBe(terminalId); + expect(lastFrame.terminalId).toBe(terminal.id); expect(lastFrame.title).toBe("My Renamed Terminal"); expect(lastFrame.requestId.length).toBeGreaterThan(0); - } finally { - await client.close(); - await repo.cleanup(); - } + }); }); });