diff --git a/packages/app/e2e/codex-plan-approval.spec.ts b/packages/app/e2e/codex-plan-approval.spec.ts index b6e20ab07..616f2168c 100644 --- a/packages/app/e2e/codex-plan-approval.spec.ts +++ b/packages/app/e2e/codex-plan-approval.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "./fixtures"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; -import { allowPermission, waitForPermissionPrompt } from "./helpers/app"; +import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions"; import { connectTerminalClient } from "./helpers/terminal-perf"; import { createTempGitRepo } from "./helpers/workspace"; diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 45b1344b9..9c551ac00 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -1,12 +1,13 @@ import { test as base, expect, type Page } from "@playwright/test"; import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry"; +import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace"; // Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`. // `test.beforeEach` declared at the top level of a non-test fixture file is unreliable // across spec-file boundaries — Playwright sometimes skips it for the first test of a // subsequent spec when multiple specs run in the same worker. Auto fixtures run // reliably for every test that uses this `test` object. -const test = base.extend<{ paseoE2ESetup: void }>({ +const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({ baseURL: async ({}, provide) => { const metroPort = process.env.E2E_METRO_PORT; if (!metroPort) { @@ -102,6 +103,11 @@ const test = base.extend<{ paseoE2ESetup: void }>({ }, { auto: true }, ], + withWorkspace: async ({ page }, provide) => { + const handle = createWithWorkspace(page); + await provide(handle.withWorkspace); + await handle.cleanup(); + }, }); export { test, expect, type Page }; diff --git a/packages/app/e2e/helpers/agent-stream.ts b/packages/app/e2e/helpers/agent-stream.ts new file mode 100644 index 000000000..b1d3a2adf --- /dev/null +++ b/packages/app/e2e/helpers/agent-stream.ts @@ -0,0 +1,17 @@ +import { expect, type Page } from "@playwright/test"; + +export async function awaitAssistantMessage(page: Page, hasText?: string | RegExp): Promise { + const messages = page.getByTestId("assistant-message"); + const target = hasText === undefined ? messages.first() : messages.filter({ hasText }).first(); + await expect(target).toBeVisible({ timeout: 30_000 }); +} + +export async function awaitToolCall(page: Page, toolName: string | RegExp): Promise { + await expect( + page.getByTestId("tool-call-badge").filter({ hasText: toolName }).first(), + ).toBeVisible({ timeout: 30_000 }); +} + +export async function expectAgentIdle(page: Page, timeout = 30_000): Promise { + await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, { timeout }); +} diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index f9268c5fe..de77a76c4 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -658,23 +658,6 @@ export const createAgentInRepo = async ( await createAgent(page, config.prompt); }; -export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => { - const promptText = page.getByTestId("permission-request-question").first(); - await expect(promptText).toBeVisible({ timeout }); -}; - -export const allowPermission = async (page: Page) => { - const acceptButton = page.getByTestId("permission-request-accept").first(); - await expect(acceptButton).toBeVisible({ timeout: 5000 }); - await acceptButton.click(); -}; - -export const denyPermission = async (page: Page) => { - const denyButton = page.getByTestId("permission-request-deny").first(); - await expect(denyButton).toBeVisible({ timeout: 5000 }); - await denyButton.click(); -}; - export async function waitForAgentFinishUI(page: Page, timeout = 30000) { // Wait for the stop button to disappear const stopButton = page.getByRole("button", { name: /stop|cancel/i }); diff --git a/packages/app/e2e/helpers/composer.ts b/packages/app/e2e/helpers/composer.ts new file mode 100644 index 000000000..456b94030 --- /dev/null +++ b/packages/app/e2e/helpers/composer.ts @@ -0,0 +1,18 @@ +import { expect, type Page } from "@playwright/test"; + +function composerInput(page: Page) { + return page.getByRole("textbox", { name: "Message agent..." }).first(); +} + +export async function submitMessage(page: Page, text: string): Promise { + const input = composerInput(page); + await expect(input).toBeEditable({ timeout: 30_000 }); + await input.fill(text); + await input.press("Enter"); +} + +export async function cancelAgent(page: Page): Promise { + const stopButton = page.getByRole("button", { name: /stop|cancel/i }).first(); + await expect(stopButton).toBeVisible({ timeout: 10_000 }); + await stopButton.click(); +} diff --git a/packages/app/e2e/helpers/permissions.ts b/packages/app/e2e/helpers/permissions.ts new file mode 100644 index 000000000..977e371bf --- /dev/null +++ b/packages/app/e2e/helpers/permissions.ts @@ -0,0 +1,17 @@ +import { expect, type Page } from "@playwright/test"; + +export async function waitForPermissionPrompt(page: Page, timeout = 30_000): Promise { + await expect(page.getByTestId("permission-request-question").first()).toBeVisible({ timeout }); +} + +export async function allowPermission(page: Page): Promise { + const acceptButton = page.getByTestId("permission-request-accept").first(); + await expect(acceptButton).toBeVisible({ timeout: 5_000 }); + await acceptButton.click(); +} + +export async function denyPermission(page: Page): Promise { + const denyButton = page.getByTestId("permission-request-deny").first(); + await expect(denyButton).toBeVisible({ timeout: 5_000 }); + await denyButton.click(); +} diff --git a/packages/app/e2e/helpers/settings.ts b/packages/app/e2e/helpers/settings.ts new file mode 100644 index 000000000..c7c8e69e3 --- /dev/null +++ b/packages/app/e2e/helpers/settings.ts @@ -0,0 +1,52 @@ +import { expect, type Page } from "@playwright/test"; + +const SECTION_LABELS = { + general: "General", + shortcuts: "Shortcuts", + integrations: "Integrations", + permissions: "Permissions", + diagnostics: "Diagnostics", + about: "About", +} as const; + +export type SettingsSection = keyof typeof SECTION_LABELS | "projects"; + +export async function openSettingsSection(page: Page, section: SettingsSection): Promise { + const sidebar = page.getByTestId("settings-sidebar"); + await expect(sidebar).toBeVisible(); + + if (section === "projects") { + await page.getByTestId("settings-projects").click(); + await expect(page).toHaveURL(/\/settings\/projects$/); + return; + } + + await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click(); + await expect(page).toHaveURL(new RegExp(`/settings/${section}$`)); +} + +export async function openSettingsHost(page: Page, serverId: string): Promise { + await page.getByTestId(`settings-host-entry-${serverId}`).click(); + await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible(); +} + +export async function expectSettingsHeader(page: Page, title: string): Promise { + await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title); +} + +export async function openAddHostFlow(page: Page): Promise { + await page.getByTestId("settings-add-host").click(); + await expect(page.getByText("Add connection", { exact: true })).toBeVisible(); +} + +export async function selectHostConnectionType( + page: Page, + type: "direct" | "relay", +): Promise { + const label = type === "direct" ? "Direct connection" : "Paste pairing link"; + await page.getByRole("button", { name: label }).click(); +} + +export async function toggleHostAdvanced(page: Page): Promise { + await page.getByTestId("direct-host-advanced-toggle").click(); +} diff --git a/packages/app/e2e/helpers/sidebar.ts b/packages/app/e2e/helpers/sidebar.ts new file mode 100644 index 000000000..00ba11de6 --- /dev/null +++ b/packages/app/e2e/helpers/sidebar.ts @@ -0,0 +1,21 @@ +import { expect, type Page } from "@playwright/test"; + +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; +} + +export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise { + const row = page.getByTestId(`sidebar-workspace-row-${requireServerId()}:${workspaceId}`); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.click(); +} + +export async function expectWorkspaceListed(page: Page, name: string): Promise { + await expect( + page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ hasText: name }).first(), + ).toBeVisible({ timeout: 30_000 }); +} diff --git a/packages/app/e2e/helpers/with-workspace.ts b/packages/app/e2e/helpers/with-workspace.ts new file mode 100644 index 000000000..8a2067152 --- /dev/null +++ b/packages/app/e2e/helpers/with-workspace.ts @@ -0,0 +1,105 @@ +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 { + connectWorkspaceSetupClient, + openHomeWithProject, + type WorkspaceSetupDaemonClient, +} from "./workspace-setup"; + +export interface CreatedWorkspace { + workspaceId: string; + repoPath: string; + navigateTo(): Promise; +} + +export interface WithWorkspaceOptions { + worktree?: boolean; + prefix?: string; +} + +export type WithWorkspace = (options?: WithWorkspaceOptions) => Promise; + +interface WorktreeRecord { + repoPath: string; + worktreePath: string; +} + +export interface WithWorkspaceHandle { + withWorkspace: WithWorkspace; + cleanup: () => Promise; +} + +export function createWithWorkspace(page: Page): WithWorkspaceHandle { + let client: WorkspaceSetupDaemonClient | null = null; + const repos: Array<{ cleanup: () => Promise }> = []; + const worktrees: WorktreeRecord[] = []; + + const withWorkspace: WithWorkspace = async (options) => { + if (!client) { + client = await connectWorkspaceSetupClient(); + } + const prefix = options?.prefix ?? (options?.worktree ? "wt-" : "ws-"); + const repo = await createTempGitRepo(prefix); + repos.push(repo); + + let workspacePath = repo.path; + if (options?.worktree) { + const tempRoot = await realpath("/tmp"); + workspacePath = path.join( + tempRoot, + `paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const branchName = `paseo-wt-${Date.now()}`; + execSync( + `git worktree add ${JSON.stringify(workspacePath)} -b ${JSON.stringify(branchName)} main`, + { cwd: repo.path, stdio: "ignore" }, + ); + worktrees.push({ repoPath: repo.path, worktreePath: workspacePath }); + // Register the parent project so the sidebar lists it before we navigate. + await client.openProject(repo.path); + } + + const opened = await client.openProject(workspacePath); + if (!opened.workspace) { + throw new Error(opened.error ?? `Failed to open project ${workspacePath}`); + } + const workspaceId = opened.workspace.id; + + return { + workspaceId, + repoPath: workspacePath, + navigateTo: async () => { + await openHomeWithProject(page, repo.path); + await selectWorkspaceInSidebar(page, workspaceId); + await waitForTabBar(page); + }, + }; + }; + + return { + withWorkspace, + cleanup: async () => { + for (const { repoPath, worktreePath } of worktrees) { + try { + execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, { + cwd: repoPath, + stdio: "ignore", + }); + } catch { + // Best-effort cleanup so the original test failure is preserved. + } + } + for (const repo of repos) { + await repo.cleanup(); + } + if (client) { + await client.close().catch(() => undefined); + } + }, + }; +} diff --git a/packages/app/e2e/settings-host-page.spec.ts b/packages/app/e2e/settings-host-page.spec.ts index 79847e719..44492ca06 100644 --- a/packages/app/e2e/settings-host-page.spec.ts +++ b/packages/app/e2e/settings-host-page.spec.ts @@ -1,6 +1,7 @@ import { test, expect, type Page } from "./fixtures"; import { gotoAppShell, openSettings } from "./helpers/app"; import { TEST_HOST_LABEL } from "./helpers/daemon-registry"; +import { expectSettingsHeader, openSettingsHost } from "./helpers/settings"; function getSeededServerId(): string { const serverId = process.env.E2E_SERVER_ID; @@ -18,13 +19,8 @@ function getSeededDaemonPort(): string { return port; } -async function openHostPage(page: Page, serverId: string) { - await page.getByTestId(`settings-host-entry-${serverId}`).click(); - await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible(); -} - async function expectHostLabelHeader(page: Page) { - await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL); + await expectSettingsHeader(page, TEST_HOST_LABEL); await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible(); await expect(page.getByTestId("host-page-label-input")).toHaveCount(0); } @@ -38,7 +34,7 @@ test.describe("Settings host page", () => { await gotoAppShell(page); await openSettings(page); - await openHostPage(page, serverId); + await openSettingsHost(page, serverId); // Label renders in the detail header with a pencil edit affordance; the input is hidden until edit. await expectHostLabelHeader(page); @@ -67,7 +63,7 @@ test.describe("Settings host page", () => { await gotoAppShell(page); await openSettings(page); - await openHostPage(page, serverId); + await openSettingsHost(page, serverId); await expect(page.getByTestId("host-page-label-input")).toHaveCount(0); @@ -85,7 +81,7 @@ test.describe("Settings host page", () => { await gotoAppShell(page); await openSettings(page); - await openHostPage(page, serverId); + await openSettingsHost(page, serverId); // TODO: add local-daemon fixture for positive Pair/Daemon coverage. // Pair-device now lives behind a row that only the local host sees diff --git a/packages/app/e2e/workspace-lifecycle.spec.ts b/packages/app/e2e/workspace-lifecycle.spec.ts index 8aba73fad..8e04244ea 100644 --- a/packages/app/e2e/workspace-lifecycle.spec.ts +++ b/packages/app/e2e/workspace-lifecycle.spec.ts @@ -1,39 +1,9 @@ -import { execSync } from "node:child_process"; -import { realpathSync } from "node:fs"; -import path from "node:path"; -import { expect, test } from "./fixtures"; -import { waitForTabBar } from "./helpers/launcher"; -import { createTempGitRepo } from "./helpers/workspace"; +import { test } from "./fixtures"; import { createAgentChatFromLauncher, createStandaloneTerminalFromLauncher, expectTerminalCwd, } from "./helpers/workspace-lifecycle"; -import { - connectWorkspaceSetupClient, - openHomeWithProject, - seedProjectForWorkspaceSetup, -} from "./helpers/workspace-setup"; - -function getServerId(): string { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } - return serverId; -} - -/** Navigate to a workspace via sidebar row testID and wait for the tab bar. */ -async function navigateToWorkspaceViaSidebar( - page: import("@playwright/test").Page, - workspaceId: string, -): Promise { - const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`; - const row = page.getByTestId(testId); - await expect(row).toBeVisible({ timeout: 30_000 }); - await row.click(); - await waitForTabBar(page); -} test.describe("Workspace lifecycle", () => { // The first test after a spec-file switch can intermittently fail because @@ -42,154 +12,36 @@ test.describe("Workspace lifecycle", () => { test.describe.configure({ retries: 1 }); test.describe("Main checkout", () => { - test("creates an agent chat via New Chat", async ({ page }) => { + test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => { test.setTimeout(60_000); - - const client = await connectWorkspaceSetupClient(); - const repo = await createTempGitRepo("lifecycle-main-chat-"); - - try { - await seedProjectForWorkspaceSetup(client, repo.path); - const workspaceResult = await client.openProject(repo.path); - if (!workspaceResult.workspace) { - throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`); - } - const workspaceId = workspaceResult.workspace.id; - - await openHomeWithProject(page, repo.path); - await navigateToWorkspaceViaSidebar(page, workspaceId); - await createAgentChatFromLauncher(page); - } finally { - await client.close(); - await repo.cleanup(); - } + const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" }); + await workspace.navigateTo(); + await createAgentChatFromLauncher(page); }); - test("creates a terminal with correct CWD", async ({ page }) => { + test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => { test.setTimeout(60_000); - - const client = await connectWorkspaceSetupClient(); - const repo = await createTempGitRepo("lifecycle-main-shell-"); - - try { - await seedProjectForWorkspaceSetup(client, repo.path); - const workspaceResult = await client.openProject(repo.path); - if (!workspaceResult.workspace) { - throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`); - } - const workspaceId = workspaceResult.workspace.id; - - await openHomeWithProject(page, repo.path); - await navigateToWorkspaceViaSidebar(page, workspaceId); - await createStandaloneTerminalFromLauncher(page); - await expectTerminalCwd(page, repo.path); - } finally { - await client.close(); - await repo.cleanup(); - } + const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" }); + await workspace.navigateTo(); + await createStandaloneTerminalFromLauncher(page); + await expectTerminalCwd(page, workspace.repoPath); }); }); test.describe("Worktree workspace", () => { - test("creates an agent chat via New Chat", async ({ page }) => { + test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => { test.setTimeout(90_000); - - const client = await connectWorkspaceSetupClient(); - const repo = await createTempGitRepo("lifecycle-wt-chat-"); - const resolvedTmp = realpathSync("/tmp"); - const worktreePath = path.join( - resolvedTmp, - `paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - const branchName = `lifecycle-wt-chat-${Date.now()}`; - let worktreeCreated = false; - - try { - await seedProjectForWorkspaceSetup(client, repo.path); - - execSync( - `git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, - { - cwd: repo.path, - stdio: "ignore", - }, - ); - worktreeCreated = true; - - const workspaceResult = await client.openProject(worktreePath); - if (!workspaceResult.workspace) { - throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`); - } - const workspaceId = workspaceResult.workspace.id; - - await openHomeWithProject(page, repo.path); - await navigateToWorkspaceViaSidebar(page, workspaceId); - await createAgentChatFromLauncher(page); - } finally { - if (worktreeCreated) { - try { - execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, { - cwd: repo.path, - stdio: "ignore", - }); - } catch { - // Best-effort cleanup so test failures preserve the original error. - } - } - await client.close(); - await repo.cleanup(); - } + const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" }); + await workspace.navigateTo(); + await createAgentChatFromLauncher(page); }); - test("creates a terminal with correct CWD", async ({ page }) => { + test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => { test.setTimeout(90_000); - - const client = await connectWorkspaceSetupClient(); - const repo = await createTempGitRepo("lifecycle-wt-shell-"); - const resolvedTmp = realpathSync("/tmp"); - const worktreePath = path.join( - resolvedTmp, - `paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - const branchName = `lifecycle-wt-shell-${Date.now()}`; - let worktreeCreated = false; - - try { - await seedProjectForWorkspaceSetup(client, repo.path); - - execSync( - `git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, - { - cwd: repo.path, - stdio: "ignore", - }, - ); - worktreeCreated = true; - - const workspaceResult = await client.openProject(worktreePath); - if (!workspaceResult.workspace) { - throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`); - } - const workspaceId = workspaceResult.workspace.id; - - await openHomeWithProject(page, repo.path); - await navigateToWorkspaceViaSidebar(page, workspaceId); - await createStandaloneTerminalFromLauncher(page); - await expectTerminalCwd(page, worktreePath); - } finally { - if (worktreeCreated) { - try { - execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, { - cwd: repo.path, - stdio: "ignore", - }); - } catch { - // Best-effort cleanup so test failures preserve the original error. - } - } - await client.close(); - await repo.cleanup(); - } + const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" }); + await workspace.navigateTo(); + await createStandaloneTerminalFromLauncher(page); + await expectTerminalCwd(page, workspace.repoPath); }); }); });