diff --git a/packages/app/e2e/helpers/composer.ts b/packages/app/e2e/helpers/composer.ts index 456b94030..b39dc1b65 100644 --- a/packages/app/e2e/helpers/composer.ts +++ b/packages/app/e2e/helpers/composer.ts @@ -4,6 +4,17 @@ function composerInput(page: Page) { return page.getByRole("textbox", { name: "Message agent..." }).first(); } +export function composerLocator(page: Page) { + return composerInput(page); +} + +export async function expectComposerVisible( + page: Page, + options?: { timeout?: number }, +): Promise { + await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 }); +} + export async function submitMessage(page: Page, text: string): Promise { const input = composerInput(page); await expect(input).toBeEditable({ timeout: 30_000 }); diff --git a/packages/app/e2e/helpers/launcher.ts b/packages/app/e2e/helpers/launcher.ts index 63e3a3f6f..814cf052b 100644 --- a/packages/app/e2e/helpers/launcher.ts +++ b/packages/app/e2e/helpers/launcher.ts @@ -174,6 +174,19 @@ export async function sampleTabsDuringTransition( return snapshots; } +export function terminalSurfaceLocator(page: Page) { + return page.locator('[data-testid="terminal-surface"]').first(); +} + +export async function expectAgentTabActive(page: Page, agentId: string): Promise { + const tabTestId = `workspace-tab-agent_${agentId}`; + await expect(page.getByTestId(tabTestId).filter({ visible: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(getActiveTabTestId(page)).resolves.toBe(tabTestId); +} + // ─── Workspace setup ─────────────────────────────────────────────────────── /** Create a temp git repo and return its path with a cleanup function. */ diff --git a/packages/app/e2e/helpers/settings.ts b/packages/app/e2e/helpers/settings.ts index a7d24d637..5ef01b068 100644 --- a/packages/app/e2e/helpers/settings.ts +++ b/packages/app/e2e/helpers/settings.ts @@ -165,3 +165,74 @@ export async function expectAboutContent(page: Page): Promise { export async function expectGeneralContent(page: Page): Promise { await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible(); } + +export async function expectHostLabelDisplayed(page: Page): Promise { + await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible(); + await expect(page.getByTestId("host-page-label-input")).toHaveCount(0); +} + +export async function clickEditHostLabel(page: Page): Promise { + await page.getByTestId("host-page-label-edit-button").click(); +} + +export async function expectHostLabelEditMode(page: Page, expectedLabel: string): Promise { + await expect(page.getByTestId("host-page-label-input")).toBeVisible(); + await expect(page.getByTestId("host-page-label-input")).toHaveValue(expectedLabel); + await expect(page.getByTestId("host-page-label-save")).toBeVisible(); +} + +export async function expectHostConnectionsCard(page: Page, port: string): Promise { + const card = page.getByTestId("host-page-connections-card"); + await expect(card).toBeVisible(); + await expect(page.getByText("Connections", { exact: true })).toBeVisible(); + await expect( + card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)), + ).toBeVisible(); +} + +export async function expectHostInjectMcpCard(page: Page): Promise { + const card = page.getByTestId("host-page-inject-mcp-card"); + await expect(card).toBeVisible(); + await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible(); +} + +export async function expectHostActionCards(page: Page): Promise { + await expect(page.getByTestId("host-page-restart-card")).toBeVisible(); + await expect(page.getByTestId("host-page-restart-button")).toBeVisible(); + await expect(page.getByTestId("host-page-providers-card")).toBeVisible(); + await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible(); + await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible(); +} + +export async function expectHostNoLocalOnlyRows(page: Page): Promise { + await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0); + await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0); +} + +export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise { + const sidebar = page.getByTestId("settings-sidebar"); + await expect(sidebar).toBeVisible(); + await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0); + await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0); + await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0); + await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0); + await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible(); + await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible(); + await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible(); +} + +export async function expectHostPageVisible(page: Page, serverId: string): Promise { + await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible(); +} + +export async function expectLocalHostEntryFirst(page: Page, serverId: string): Promise { + const sidebar = page.getByTestId("settings-sidebar"); + await expect(sidebar).toBeVisible({ timeout: 15_000 }); + await expect(sidebar.locator('[data-testid^="settings-host-entry-"]').first()).toHaveAttribute( + "data-testid", + `settings-host-entry-${serverId}`, + ); + const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`); + await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible(); + await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible(); +} diff --git a/packages/app/e2e/helpers/terminal-perf.ts b/packages/app/e2e/helpers/terminal-perf.ts index 696cb19b2..da6011292 100644 --- a/packages/app/e2e/helpers/terminal-perf.ts +++ b/packages/app/e2e/helpers/terminal-perf.ts @@ -1,4 +1,4 @@ -import type { Page } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { randomUUID } from "node:crypto"; @@ -263,6 +263,34 @@ export async function measureKeystrokeLatency(page: Page, char: string): Promise ); } +export async function expectTerminalSurfaceVisible( + page: Page, + options?: { timeout?: number }, +): Promise { + await expect(page.locator('[data-testid="terminal-surface"]').first()).toBeVisible({ + timeout: options?.timeout ?? 20_000, + }); +} + +export async function focusTerminalSurface(page: Page): Promise { + await expectTerminalSurfaceVisible(page); + await page.locator('[data-testid="terminal-surface"]').first().click(); +} + +export async function typeInTerminal(page: Page, text: string): Promise { + await page + .locator('[data-testid="terminal-surface"]') + .first() + .pressSequentially(text, { delay: 0 }); +} + +export async function waitForTerminalAttached(page: Page): Promise { + await page + .locator('[data-testid="terminal-attach-loading"]') + .waitFor({ state: "hidden", timeout: 10_000 }) + .catch(() => undefined); +} + export function computePercentile(samples: number[], p: number): number { const sorted = [...samples].sort((a, b) => a - b); const index = Math.ceil((p / 100) * sorted.length) - 1; diff --git a/packages/app/e2e/helpers/workspace-setup.ts b/packages/app/e2e/helpers/workspace-setup.ts index f542c1534..cefb34298 100644 --- a/packages/app/e2e/helpers/workspace-setup.ts +++ b/packages/app/e2e/helpers/workspace-setup.ts @@ -6,6 +6,7 @@ import { expect, type Page } from "@playwright/test"; import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes"; import { gotoAppShell } from "./app"; import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory"; +import { switchWorkspaceViaSidebar } from "./workspace-ui"; import type { SessionOutboundMessage } from "@server/shared/messages"; interface WorkspaceSetupDaemonClient { @@ -315,6 +316,30 @@ export async function fetchWorkspaceById( return workspace; } +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 }); +} + +export async function openWorkspaceScriptsMenu(page: Page): Promise { + await page.getByTestId("workspace-scripts-button").click(); + await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 }); +} + +export async function startWorkspaceScriptFromMenu(page: Page, scriptName: string): Promise { + await page.getByTestId(`workspace-scripts-start-${scriptName}`).click(); +} + +export async function closeWorkspaceScriptsMenu(page: Page): Promise { + await page.getByTestId("workspace-scripts-menu-backdrop").click(); +} + export async function waitForWorkspaceSetupProgress( client: WorkspaceSetupDaemonClient, predicate: (payload: WorkspaceSetupProgressPayload) => boolean, diff --git a/packages/app/e2e/helpers/workspace-tabs.ts b/packages/app/e2e/helpers/workspace-tabs.ts index 564818791..62b5fc1fd 100644 --- a/packages/app/e2e/helpers/workspace-tabs.ts +++ b/packages/app/e2e/helpers/workspace-tabs.ts @@ -70,6 +70,29 @@ export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise } } +export async function expectWorkspaceTabsAbsent(page: Page): Promise { + await expect(page.getByTestId("workspace-tabs-row")).toHaveCount(0); +} + +export async function expectNoTerminalTabs(page: Page): Promise { + await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0); +} + +export async function clickFirstTerminalTab( + page: Page, + options?: { timeout?: number }, +): Promise { + const tab = page.locator('[data-testid^="workspace-tab-terminal_"]').first(); + await expect(tab).toBeVisible({ timeout: options?.timeout ?? 30_000 }); + await tab.click(); +} + +export async function expectFirstTerminalTabContains(page: Page, text: string): Promise { + await expect(page.locator('[data-testid^="workspace-tab-terminal_"]').first()).toContainText( + text, + ); +} + export async function sampleWorkspaceTabIds( page: Page, options: { durationMs?: number; intervalMs?: number } = {}, diff --git a/packages/app/e2e/helpers/workspace-ui.ts b/packages/app/e2e/helpers/workspace-ui.ts index 2d194f8b4..efce48db8 100644 --- a/packages/app/e2e/helpers/workspace-ui.ts +++ b/packages/app/e2e/helpers/workspace-ui.ts @@ -114,6 +114,49 @@ export async function expectWorkspaceHeader( }); } +export async function expectReconnectingToastVisible( + page: Page, + options?: { timeout?: number }, +): Promise { + await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({ + timeout: options?.timeout ?? 30_000, + }); +} + +export async function expectReconnectingToastGone( + page: Page, + options?: { timeout?: number }, +): Promise { + await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, { + timeout: options?.timeout ?? 30_000, + }); +} + +export async function expectHostConnectingOrOffline( + page: Page, + options?: { timeout?: number }, +): Promise { + await expect( + page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i), + ).toBeVisible({ timeout: options?.timeout ?? 30_000 }); +} + +export async function expectMenuButtonVisible(page: Page): Promise { + await expect(page.getByTestId("menu-button")).toBeVisible(); +} + +export async function expectWorkspaceHeaderAbsent(page: Page): Promise { + await expect(page.getByTestId("workspace-header-title")).toHaveCount(0); +} + +export function workspaceDeckEntryLocator(page: Page, serverId: string, workspaceId: string) { + return page.getByTestId(`workspace-deck-entry-${serverId}:${workspaceId}`); +} + +export async function expectWorkspaceDeckEntryCount(page: Page, count: number): Promise { + await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(count); +} + export async function seedWorkspaceActivity(page: Page, marker: string): Promise { const input = page.getByRole("textbox", { name: "Message agent..." }); await expect(input).toBeEditable({ timeout: 30_000 }); diff --git a/packages/app/e2e/launcher-tab.spec.ts b/packages/app/e2e/launcher-tab.spec.ts index db919a94e..0509d0071 100644 --- a/packages/app/e2e/launcher-tab.spec.ts +++ b/packages/app/e2e/launcher-tab.spec.ts @@ -13,7 +13,10 @@ import { waitForTabWithTitle, measureTileTransition, sampleTabsDuringTransition, + terminalSurfaceLocator, } from "./helpers/launcher"; +import { expectComposerVisible, composerLocator } from "./helpers/composer"; +import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf"; import { connectTerminalClient, setupDeterministicPrompt, @@ -49,9 +52,7 @@ test.describe("Tab creation", () => { await pressNewTabShortcut(page); - // Should show the composer directly (no launcher panel) - const composer = page.getByRole("textbox", { name: "Message agent..." }); - await expect(composer.first()).toBeVisible({ timeout: 15_000 }); + await expectComposerVisible(page); }); test("opening two new tabs creates two draft tabs", async ({ page }) => { @@ -78,9 +79,7 @@ test.describe("Tab creation", () => { await clickNewChat(page); - // Draft composer should appear (the agent message input) - const composer = page.getByRole("textbox", { name: "Message agent..." }); - await expect(composer.first()).toBeVisible({ timeout: 15_000 }); + await expectComposerVisible(page); const tabsAfter = await getTabTestIds(page); const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length; @@ -93,9 +92,7 @@ test.describe("Tab creation", () => { await clickNewTerminal(page); - // Terminal surface should appear - const terminal = page.locator('[data-testid="terminal-surface"]'); - await expect(terminal.first()).toBeVisible({ timeout: 20_000 }); + await expectTerminalSurfaceVisible(page); const tabsAfter = await getTabTestIds(page); const terminalTabs = tabsAfter.filter((id) => id.includes("terminal")); @@ -142,17 +139,16 @@ test.describe("Terminal title propagation", () => { await gotoWorkspace(page, workspaceId); await clickNewTerminal(page); - const terminal = page.locator('[data-testid="terminal-surface"]'); - await expect(terminal.first()).toBeVisible({ timeout: 20_000 }); - await terminal.first().click(); + await expectTerminalSurfaceVisible(page); + await terminalSurfaceLocator(page).click(); await setupDeterministicPrompt(page); // Send OSC 0 (set window title) escape sequence const testTitle = `E2E-Title-${Date.now()}`; - await terminal - .first() - .pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 }); + await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { + delay: 0, + }); // Wait for the tab to reflect the new title await waitForTabWithTitle(page, testTitle, 15_000); @@ -172,22 +168,22 @@ test.describe("Terminal title propagation", () => { await gotoWorkspace(page, workspaceId); await clickNewTerminal(page); - const terminal = page.locator('[data-testid="terminal-surface"]'); - await expect(terminal.first()).toBeVisible({ timeout: 20_000 }); - await terminal.first().click(); + await expectTerminalSurfaceVisible(page); + await terminalSurfaceLocator(page).click(); await setupDeterministicPrompt(page); // Fire many rapid title changes — only the last should stick const finalTitle = `Final-${Date.now()}`; for (let i = 0; i < 5; i++) { - await terminal - .first() - .pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 }); + await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { + delay: 0, + }); } - await terminal - .first() - .pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 }); + await terminalSurfaceLocator(page).pressSequentially( + `printf '\\033]0;${finalTitle}\\007'\n`, + { delay: 0 }, + ); // The tab should eventually settle on the final title await waitForTabWithTitle(page, finalTitle, 15_000); @@ -227,11 +223,10 @@ test.describe("Tab transitions (no flash)", () => { test.setTimeout(30_000); await gotoWorkspace(page, workspaceId); - const terminal = page.locator('[data-testid="terminal-surface"]'); const elapsed = await measureTileTransition( page, () => clickNewTerminal(page), - terminal.first(), + terminalSurfaceLocator(page), 20_000, ); @@ -244,9 +239,12 @@ test.describe("Tab transitions (no flash)", () => { test("New agent tab click shows composer without flash", async ({ page }) => { await gotoWorkspace(page, workspaceId); - const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); - - const elapsed = await measureTileTransition(page, () => clickNewChat(page), composer, 10_000); + const elapsed = await measureTileTransition( + page, + () => clickNewChat(page), + composerLocator(page), + 10_000, + ); // Draft creation is fully in-memory — should be fast // We use a generous budget here because CI can be slow, but the key assertion diff --git a/packages/app/e2e/settings-host-page.spec.ts b/packages/app/e2e/settings-host-page.spec.ts index 44492ca06..80f0d4c24 100644 --- a/packages/app/e2e/settings-host-page.spec.ts +++ b/packages/app/e2e/settings-host-page.spec.ts @@ -1,7 +1,20 @@ -import { test, expect, type Page } from "./fixtures"; +import { test } from "./fixtures"; import { gotoAppShell, openSettings } from "./helpers/app"; import { TEST_HOST_LABEL } from "./helpers/daemon-registry"; -import { expectSettingsHeader, openSettingsHost } from "./helpers/settings"; +import { + expectSettingsHeader, + openSettingsHost, + expectHostLabelDisplayed, + clickEditHostLabel, + expectHostLabelEditMode, + expectHostConnectionsCard, + expectHostInjectMcpCard, + expectHostActionCards, + expectHostNoLocalOnlyRows, + expectRetiredSidebarSectionsAbsent, + expectHostPageVisible, + expectLocalHostEntryFirst, +} from "./helpers/settings"; function getSeededServerId(): string { const serverId = process.env.E2E_SERVER_ID; @@ -19,12 +32,6 @@ function getSeededDaemonPort(): string { return port; } -async function expectHostLabelHeader(page: Page) { - 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); -} - test.describe("Settings host page", () => { test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({ page, @@ -36,26 +43,11 @@ test.describe("Settings host page", () => { await openSettings(page); await openSettingsHost(page, serverId); - // Label renders in the detail header with a pencil edit affordance; the input is hidden until edit. - await expectHostLabelHeader(page); - - // Connections is its own section with a "Connections" heading and the seeded endpoint row. - const connectionsCard = page.getByTestId("host-page-connections-card"); - await expect(connectionsCard).toBeVisible(); - await expect(page.getByText("Connections", { exact: true })).toBeVisible(); - await expect( - connectionsCard.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)), - ).toBeVisible(); - - const injectMcpCard = page.getByTestId("host-page-inject-mcp-card"); - await expect(injectMcpCard).toBeVisible(); - await expect(injectMcpCard.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible(); - - await expect(page.getByTestId("host-page-restart-card")).toBeVisible(); - await expect(page.getByTestId("host-page-restart-button")).toBeVisible(); - await expect(page.getByTestId("host-page-providers-card")).toBeVisible(); - await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible(); - await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible(); + await expectSettingsHeader(page, TEST_HOST_LABEL); + await expectHostLabelDisplayed(page); + await expectHostConnectionsCard(page, port); + await expectHostInjectMcpCard(page); + await expectHostActionCards(page); }); test("clicking the label pencil reveals the inline editor", async ({ page }) => { @@ -65,13 +57,9 @@ test.describe("Settings host page", () => { await openSettings(page); await openSettingsHost(page, serverId); - await expect(page.getByTestId("host-page-label-input")).toHaveCount(0); - - await page.getByTestId("host-page-label-edit-button").click(); - - await expect(page.getByTestId("host-page-label-input")).toBeVisible(); - await expect(page.getByTestId("host-page-label-input")).toHaveValue(TEST_HOST_LABEL); - await expect(page.getByTestId("host-page-label-save")).toBeVisible(); + await expectHostLabelDisplayed(page); + await clickEditHostLabel(page); + await expectHostLabelEditMode(page, TEST_HOST_LABEL); }); test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({ @@ -84,28 +72,14 @@ test.describe("Settings host page", () => { 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 - // (gated by useIsLocalDaemon); the seeded host is remote, so it must - // not appear. The daemon-lifecycle card is still local-host only. - await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0); - await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0); + await expectHostNoLocalOnlyRows(page); }); test("settings sidebar does not expose retired top-level sections", async ({ page }) => { await gotoAppShell(page); await openSettings(page); - const sidebar = page.getByTestId("settings-sidebar"); - await expect(sidebar).toBeVisible(); - - await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0); - await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0); - await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0); - await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0); - - await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible(); - await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible(); - await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible(); + await expectRetiredSidebarSectionsAbsent(page); }); test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({ @@ -116,9 +90,10 @@ test.describe("Settings host page", () => { await gotoAppShell(page); await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`); - await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible(); - await expectHostLabelHeader(page); - await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible(); + await expectHostPageVisible(page, serverId); + await expectSettingsHeader(page, TEST_HOST_LABEL); + await expectHostLabelDisplayed(page); + await expectHostActionCards(page); }); test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => { @@ -160,18 +135,6 @@ test.describe("Settings host page", () => { await gotoAppShell(page); await openSettings(page); - - const sidebar = page.getByTestId("settings-sidebar"); - await expect(sidebar).toBeVisible({ timeout: 15000 }); - - const hostEntries = sidebar.locator('[data-testid^="settings-host-entry-"]'); - await expect(hostEntries.first()).toHaveAttribute( - "data-testid", - `settings-host-entry-${serverId}`, - ); - - const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`); - await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible(); - await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible(); + await expectLocalHostEntryFirst(page, serverId); }); }); diff --git a/packages/app/e2e/settings-toggle-tab-regression.spec.ts b/packages/app/e2e/settings-toggle-tab-regression.spec.ts index 603702e61..d28910c08 100644 --- a/packages/app/e2e/settings-toggle-tab-regression.spec.ts +++ b/packages/app/e2e/settings-toggle-tab-regression.spec.ts @@ -1,12 +1,12 @@ import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; -import { expect, test, type Page } from "./fixtures"; +import { expect, test } from "./fixtures"; import { archiveAgentFromDaemon, connectArchiveTabDaemonClient, createIdleAgent, openWorkspaceWithAgents, } from "./helpers/archive-tab"; -import { getActiveTabTestId, waitForTabBar } from "./helpers/launcher"; +import { waitForTabBar, expectAgentTabActive } from "./helpers/launcher"; import { createTempGitRepo } from "./helpers/workspace"; function getServerId(): string { @@ -17,12 +17,15 @@ function getServerId(): string { return serverId; } -async function pressSettingsToggleShortcut(page: Page) { +async function pressSettingsToggleShortcut(page: import("@playwright/test").Page) { const modifier = process.platform === "darwin" ? "Meta" : "Control"; await page.keyboard.press(`${modifier}+Comma`); } -async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") { +async function expectSendBehavior( + page: import("@playwright/test").Page, + expected: "interrupt" | "queue", +) { await expect .poll(async () => { const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings")); @@ -35,12 +38,11 @@ async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") { } async function openAgentRouteAndExpectFocused(input: { - page: Page; + page: import("@playwright/test").Page; serverId: string; workspaceId: string; agentId: string; }) { - const expectedActiveTabId = `workspace-tab-agent_${input.agentId}`; await input.page.goto( buildHostAgentDetailRoute(input.serverId, input.agentId, input.workspaceId), ); @@ -49,10 +51,7 @@ async function openAgentRouteAndExpectFocused(input: { { timeout: 60_000 }, ); await waitForTabBar(input.page); - await expect( - input.page.getByTestId(expectedActiveTabId).filter({ visible: true }), - ).toHaveAttribute("aria-selected", "true"); - await expect(getActiveTabTestId(input.page)).resolves.toBe(expectedActiveTabId); + await expectAgentTabActive(input.page, input.agentId); } test.describe("Settings toggle tab regression", () => { @@ -79,13 +78,7 @@ test.describe("Settings toggle tab regression", () => { await openWorkspaceWithAgents(page, [firstAgent, secondAgent]); await waitForTabBar(page); - - const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`; - await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute( - "aria-selected", - "true", - ); - await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId); + await expectAgentTabActive(page, secondAgent.id); await pressSettingsToggleShortcut(page); await expect(page).toHaveURL(/\/settings\/general$/); @@ -98,19 +91,11 @@ test.describe("Settings toggle tab regression", () => { await pressSettingsToggleShortcut(page); await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path)); await waitForTabBar(page); - await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute( - "aria-selected", - "true", - ); - await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId); + await expectAgentTabActive(page, secondAgent.id); await page.reload(); await waitForTabBar(page); - await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute( - "aria-selected", - "true", - ); - await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId); + await expectAgentTabActive(page, secondAgent.id); } finally { for (const agentId of agentIds) { await archiveAgentFromDaemon(client, agentId).catch(() => undefined); @@ -152,14 +137,10 @@ test.describe("Settings toggle tab regression", () => { agentId: secondAgent.id, }); - const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`; for (let attempt = 0; attempt < 5; attempt += 1) { await page.reload(); await waitForTabBar(page); - await expect( - page.getByTestId(expectedActiveTabId).filter({ visible: true }), - ).toHaveAttribute("aria-selected", "true"); - await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId); + await expectAgentTabActive(page, secondAgent.id); } } finally { for (const agentId of agentIds) { diff --git a/packages/app/e2e/workspace-cwd.spec.ts b/packages/app/e2e/workspace-cwd.spec.ts index 146b3da87..daa0b5db7 100644 --- a/packages/app/e2e/workspace-cwd.spec.ts +++ b/packages/app/e2e/workspace-cwd.spec.ts @@ -1,6 +1,12 @@ -import { expect, test } from "./fixtures"; +import { test } from "./fixtures"; import { clickNewTerminal } from "./helpers/launcher"; -import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf"; +import { + expectTerminalSurfaceVisible, + focusTerminalSurface, + typeInTerminal, + setupDeterministicPrompt, + waitForTerminalContent, +} from "./helpers/terminal-perf"; test.describe("Workspace cwd correctness", () => { test("main checkout workspace opens terminals in the project root", async ({ @@ -13,13 +19,10 @@ test.describe("Workspace cwd correctness", () => { await workspace.navigateTo(); await clickNewTerminal(page); - const terminal = page.locator('[data-testid="terminal-surface"]'); - await expect(terminal.first()).toBeVisible({ timeout: 20_000 }); - await terminal.first().click(); - + await expectTerminalSurfaceVisible(page); + await focusTerminalSurface(page); await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`); - await terminal.first().pressSequentially("pwd\n", { delay: 0 }); - + await typeInTerminal(page, "pwd\n"); await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000); }); @@ -33,12 +36,10 @@ test.describe("Workspace cwd correctness", () => { await workspace.navigateTo(); await clickNewTerminal(page); - const terminal = page.locator('[data-testid="terminal-surface"]'); - await expect(terminal.first()).toBeVisible({ timeout: 20_000 }); - await terminal.first().click(); - + await expectTerminalSurfaceVisible(page); + await focusTerminalSurface(page); await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`); - await terminal.first().pressSequentially("pwd\n", { delay: 0 }); + await typeInTerminal(page, "pwd\n"); await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000); }); }); diff --git a/packages/app/e2e/workspace-navigation-regression.spec.ts b/packages/app/e2e/workspace-navigation-regression.spec.ts index 5fcbd5beb..1182e7234 100644 --- a/packages/app/e2e/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/workspace-navigation-regression.spec.ts @@ -1,7 +1,7 @@ import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; import type { WebSocketRoute } from "@playwright/test"; import { expect, test, type Page } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; +import { gotoAppShell, openSettings } from "./helpers/app"; import { archiveAgentFromDaemon, connectArchiveTabDaemonClient, @@ -15,17 +15,26 @@ import { connectNewWorkspaceDaemonClient, openProjectViaDaemon, } from "./helpers/new-workspace"; +import { expectComposerVisible } from "./helpers/composer"; import { createTempGitRepo } from "./helpers/workspace"; import { getVisibleWorkspaceAgentTabIds, expectOnlyWorkspaceAgentTabsVisible, waitForWorkspaceTabsVisible, + expectWorkspaceTabsAbsent, } from "./helpers/workspace-tabs"; import { expectSidebarWorkspaceSelected, expectWorkspaceHeader, + expectWorkspaceHeaderAbsent, + expectMenuButtonVisible, + expectHostConnectingOrOffline, + expectReconnectingToastVisible, + expectReconnectingToastGone, switchWorkspaceViaSidebar, waitForSidebarHydration, + workspaceDeckEntryLocator, + expectWorkspaceDeckEntryCount, } from "./helpers/workspace-ui"; const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i; @@ -62,6 +71,10 @@ async function expectNoLoadingWorkspacePane( } } +async function expectNoLoadingPane(page: Page): Promise { + await expect(page.getByText(LOADING_WORKSPACE_TEXT_PATTERN)).toHaveCount(0); +} + async function installDaemonWebSocketGate(page: Page, daemonPort: string) { let acceptingConnections = true; const activeSockets = new Set(); @@ -159,35 +172,31 @@ test.describe("Workspace navigation regression", () => { title: workspace.workspaceName, subtitle: workspace.projectDisplayName, }); - await expect(page.getByTestId("workspace-tabs-row")).toBeVisible({ timeout: 30_000 }); + await waitForWorkspaceTabsVisible(page); await expectWorkspaceTabVisible(page, agent.id); await daemonGate.drop(); - await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({ - timeout: 30_000, - }); + await expectReconnectingToastVisible(page); await expectWorkspaceHeader(page, { title: workspace.workspaceName, subtitle: workspace.projectDisplayName, }); - await expect(page.getByTestId("workspace-tabs-row")).toBeVisible(); - await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); - await expect(page.getByText(LOADING_WORKSPACE_TEXT_PATTERN)).toHaveCount(0); + await waitForWorkspaceTabsVisible(page); + await expectComposerVisible(page); + await expectNoLoadingPane(page); const monitorReconnect = expectNoLoadingWorkspacePane(page, { label: "host reconnect", }); daemonGate.restore(); - await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, { - timeout: 30_000, - }); + await expectReconnectingToastGone(page); await monitorReconnect; await expectWorkspaceHeader(page, { title: workspace.workspaceName, subtitle: workspace.projectDisplayName, }); - await expect(page.getByTestId("workspace-tabs-row")).toBeVisible(); - await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); + await waitForWorkspaceTabsVisible(page); + await expectComposerVisible(page); } finally { daemonGate.restore(); for (const agentId of agentIds) { @@ -222,16 +231,11 @@ test.describe("Workspace navigation regression", () => { `/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`, ); - await expect( - page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i), - ).toBeVisible({ timeout: 30_000 }); - await expect(page.getByTestId("menu-button")).toBeVisible(); - await expect(page.getByTestId("workspace-header-title")).toHaveCount(0); - await expect(page.getByTestId("workspace-tabs-row")).toHaveCount(0); - - const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first(); - await expect(settingsButton).toBeVisible({ timeout: 30_000 }); - await settingsButton.click(); + await expectHostConnectingOrOffline(page); + await expectMenuButtonVisible(page); + await expectWorkspaceHeaderAbsent(page); + await expectWorkspaceTabsAbsent(page); + await openSettings(page); await expect(page).toHaveURL(/\/settings\/general$/); }); @@ -270,6 +274,13 @@ test.describe("Workspace navigation regression", () => { await waitForSidebarHydration(page); await openWorkspaceWithAgents(page, [firstAgent, secondAgent]); + const firstDeckEntry = workspaceDeckEntryLocator(page, serverId, firstWorkspace.workspaceId); + const secondDeckEntry = workspaceDeckEntryLocator( + page, + serverId, + secondWorkspace.workspaceId, + ); + await switchWorkspaceViaSidebar({ page, serverId, @@ -300,13 +311,6 @@ test.describe("Workspace navigation regression", () => { await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([ `workspace-tab-agent_${firstAgent.id}`, ]); - - const firstDeckEntry = page.getByTestId( - `workspace-deck-entry-${serverId}:${firstWorkspace.workspaceId}`, - ); - const secondDeckEntry = page.getByTestId( - `workspace-deck-entry-${serverId}:${secondWorkspace.workspaceId}`, - ); await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 }); await switchWorkspaceViaSidebar({ @@ -342,7 +346,7 @@ test.describe("Workspace navigation regression", () => { await expect(firstDeckEntry).toBeAttached(); await expect(firstDeckEntry).toBeHidden(); await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 }); - await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2); + await expectWorkspaceDeckEntryCount(page, 2); await page.evaluate( ({ agentId, serverId: targetServerId }) => { @@ -371,7 +375,7 @@ test.describe("Workspace navigation regression", () => { await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]); await expect(firstDeckEntry).toBeAttached(); await expect(firstDeckEntry).toBeHidden(); - await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2); + await expectWorkspaceDeckEntryCount(page, 2); await switchWorkspaceViaSidebar({ page, @@ -385,7 +389,7 @@ test.describe("Workspace navigation regression", () => { await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 }); await expect(secondDeckEntry).toBeAttached(); await expect(secondDeckEntry).toBeHidden(); - await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2); + await expectWorkspaceDeckEntryCount(page, 2); await page.reload(); await waitForSidebarHydration(page); diff --git a/packages/app/e2e/workspace-setup-runtime.spec.ts b/packages/app/e2e/workspace-setup-runtime.spec.ts index 22085cea1..289be962a 100644 --- a/packages/app/e2e/workspace-setup-runtime.spec.ts +++ b/packages/app/e2e/workspace-setup-runtime.spec.ts @@ -1,32 +1,16 @@ import { existsSync } from "node:fs"; import { expect, test } from "./fixtures"; import { createTempGitRepo } from "./helpers/workspace"; -import { clickNewTerminal, waitForTabBar } from "./helpers/launcher"; +import { clickNewTerminal } from "./helpers/launcher"; +import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf"; import { connectWorkspaceSetupClient, createWorkspaceThroughDaemon, findWorktreeWorkspaceForProject, + navigateToWorkspaceViaSidebar, openHomeWithProject, } 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; -} - -async function navigateToWorkspaceViaSidebar( - page: import("@playwright/test").Page, - workspaceId: string, -): Promise { - const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`); - await expect(row).toBeVisible({ timeout: 30_000 }); - await row.click(); - await waitForTabBar(page); -} - test.describe("Workspace setup runtime authority", () => { test.describe.configure({ retries: 1 }); @@ -48,7 +32,6 @@ test.describe("Workspace setup runtime authority", () => { expect(wsInfo.workspaceDirectory).not.toBe(repo.path); expect(existsSync(wsInfo.workspaceDirectory)).toBe(true); - // Navigate to the workspace via sidebar await openHomeWithProject(page, repo.path); await navigateToWorkspaceViaSidebar(page, workspaceId); await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); @@ -87,9 +70,7 @@ test.describe("Workspace setup runtime authority", () => { await navigateToWorkspaceViaSidebar(page, workspaceId); await clickNewTerminal(page); - - const terminal = page.locator('[data-testid="terminal-surface"]'); - await expect(terminal.first()).toBeVisible({ timeout: 20_000 }); + await expectTerminalSurfaceVisible(page); // Verify terminal is listed under the worktree directory, not the original repo await expect diff --git a/packages/app/e2e/workspace-setup-streaming.spec.ts b/packages/app/e2e/workspace-setup-streaming.spec.ts index 24ddd71c6..b9a1dd24a 100644 --- a/packages/app/e2e/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/workspace-setup-streaming.spec.ts @@ -1,23 +1,28 @@ import { test, expect } from "./fixtures"; import { createTempGitRepo } from "./helpers/workspace"; -import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { + waitForWorkspaceTabsVisible, + expectNoTerminalTabs, + clickFirstTerminalTab, + expectFirstTerminalTabContains, +} from "./helpers/workspace-tabs"; +import { clickNewChat } from "./helpers/launcher"; +import { expectComposerVisible } from "./helpers/composer"; +import { openFileExplorer, expectExplorerEntryVisible } from "./helpers/file-explorer"; +import { expectTerminalSurfaceVisible, waitForTerminalAttached } from "./helpers/terminal-perf"; import { connectWorkspaceSetupClient, createWorkspaceThroughDaemon, expectSetupPanel, openHomeWithProject, + navigateToWorkspaceViaSidebar, + openWorkspaceScriptsMenu, + startWorkspaceScriptFromMenu, + closeWorkspaceScriptsMenu, seedProjectForWorkspaceSetup, waitForWorkspaceSetupProgress, } 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; -} - interface WorkspaceScriptStarter { startWorkspaceScript( workspaceId: string, @@ -30,18 +35,6 @@ interface WorkspaceScriptStarter { }>; } -/** Click the sidebar row for a workspace (by ID) and wait for navigation. */ -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 expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); -} - test.describe("Workspace setup streaming", () => { test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => { const client = await connectWorkspaceSetupClient(); @@ -100,31 +93,15 @@ test.describe("Workspace setup streaming", () => { }); await completed; - // Navigate to workspace and verify it's usable await openHomeWithProject(page, repo.path); await navigateToWorkspaceViaSidebar(page, workspace.id); await waitForWorkspaceTabsVisible(page); - await page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first().click(); - await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({ - timeout: 30_000, - }); - - const explorerToggle = page.getByTestId("workspace-explorer-toggle").first(); - if ((await explorerToggle.getAttribute("aria-label")) === "Open explorer") { - await explorerToggle.click(); - } - await expect(explorerToggle).toHaveAttribute("aria-label", "Close explorer", { - timeout: 30_000, - }); - await page.getByTestId("explorer-tab-files").click(); - await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 }); - await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({ - timeout: 30_000, - }); - await expect(page.getByText("src", { exact: true }).first()).toBeVisible({ - timeout: 30_000, - }); + await clickNewChat(page); + await expectComposerVisible(page, { timeout: 30_000 }); + await openFileExplorer(page); + await expectExplorerEntryVisible(page, "README.md"); + await expectExplorerEntryVisible(page, "src"); } finally { await client.close(); await repo.cleanup(); @@ -273,33 +250,14 @@ test.describe("Workspace setup streaming", () => { await navigateToWorkspaceViaSidebar(page, workspace.id); await waitForWorkspaceTabsVisible(page); - - await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0); - await page.getByTestId("workspace-scripts-button").click(); - await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 }); - await page.getByTestId("workspace-scripts-start-web").click(); - await page.getByTestId("workspace-scripts-menu-backdrop").click(); - - const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first(); - await expect(terminalTab).toBeVisible({ timeout: 30_000 }); - - await terminalTab.click(); - - // Verify the terminal surface rendered - const terminalSurface = page.getByTestId("terminal-surface").first(); - await expect(terminalSurface).toBeVisible({ timeout: 10_000 }); - - // Wait for terminal to fully attach (loading overlay gone) - await page - .locator('[data-testid="terminal-attach-loading"]') - .waitFor({ state: "hidden", timeout: 10_000 }) - .catch(() => { - // overlay may never appear if attachment is instant - }); - - await terminalSurface.click(); - - await expect(terminalTab).toContainText("web"); + await expectNoTerminalTabs(page); + await openWorkspaceScriptsMenu(page); + await startWorkspaceScriptFromMenu(page, "web"); + await closeWorkspaceScriptsMenu(page); + await clickFirstTerminalTab(page); + await expectTerminalSurfaceVisible(page, { timeout: 10_000 }); + await waitForTerminalAttached(page); + await expectFirstTerminalTabContains(page, "web"); } finally { await client.close(); await repo.cleanup();