diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index de77a76c4..cf94c1b30 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -1,217 +1,36 @@ import { expect, type Page } from "@playwright/test"; -import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry"; function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function getE2EDaemonPort(): string { - const port = process.env.E2E_DAEMON_PORT; - if (!port) { - throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup)."); - } - if (port === "6767") { - throw new Error( - "E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.", - ); - } - return port; -} - -async function ensureE2EStorageSeeded(page: Page): Promise { - const port = getE2EDaemonPort(); - const expectedEndpoint = `127.0.0.1:${port}`; - const expectedServerId = process.env.E2E_SERVER_ID; - if (!expectedServerId) { - throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); - } - - const needsReset = await page.evaluate( - ({ expectedEndpoint: endpoint, expectedServerId: serverId }) => { - const raw = localStorage.getItem("@paseo:daemon-registry"); - if (!raw) return true; - try { - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed) || parsed.length !== 1) return true; - const entry = parsed[0] as { serverId?: string; connections?: unknown }; - if (entry?.serverId !== serverId) return true; - const connections = entry?.connections; - if (!Array.isArray(connections)) return true; - if ( - connections.some( - (c: { type?: string; endpoint?: string }) => - c?.type === "directTcp" && - typeof c?.endpoint === "string" && - /:6767\b/.test(c.endpoint), - ) - ) - return true; - return !connections.some( - (c: { type?: string; endpoint?: string }) => - c?.type === "directTcp" && c?.endpoint === endpoint, - ); - } catch { - return true; - } - }, - { expectedEndpoint, expectedServerId }, - ); - - if (!needsReset) { - return; - } - - const nowIso = new Date().toISOString(); - const daemon = buildSeededHost({ - serverId: expectedServerId, - endpoint: expectedEndpoint, - nowIso, - }); - const preferences = buildCreateAgentPreferences(expectedServerId); - await page.evaluate( - ({ daemon: seededDaemon, preferences: seededPreferences }) => { - localStorage.setItem("@paseo:e2e", "1"); - localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon])); - localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences)); - localStorage.removeItem("@paseo:settings"); - }, - { daemon, preferences }, - ); - - await page.reload(); -} - -function parseRegistryEntry(registryRaw: string): { serverId: string; connections: unknown } { - let registry: unknown; - try { - registry = JSON.parse(registryRaw); - } catch { - throw new Error("E2E expected @paseo:daemon-registry to be valid JSON."); - } - if (!Array.isArray(registry) || registry.length !== 1) { - throw new Error( - `E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`, - ); - } - const daemon = registry[0] as { serverId?: string; connections?: unknown }; - if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) { - throw new Error( - `E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`, - ); - } - return { serverId: daemon.serverId, connections: daemon.connections }; -} - -function assertDaemonConnections(connections: unknown, expectedEndpoint: string): void { - if ( - !Array.isArray(connections) || - !connections.some( - (c: { type?: string; endpoint?: string }) => - c?.type === "directTcp" && c?.endpoint === expectedEndpoint, - ) - ) { - throw new Error( - `E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`, - ); - } - if ( - Array.isArray(connections) && - connections.some( - (c: { type?: string; endpoint?: string }) => - c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint), - ) - ) { - throw new Error( - `E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`, - ); - } -} - -function assertPreferencesMatch(prefsRaw: string, serverId: string): void { - try { - const prefs = JSON.parse(prefsRaw) as { serverId?: string }; - if (prefs?.serverId !== serverId) { - throw new Error( - `E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${serverId}) (got ${String(prefs?.serverId)}).`, - ); - } - } catch (error) { - if (error instanceof Error) throw error; - throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.", { - cause: error, - }); - } -} - -async function assertE2EUsesSeededTestDaemon(page: Page): Promise { - const port = getE2EDaemonPort(); - const expectedEndpoint = `127.0.0.1:${port}`; - const expectedServerId = process.env.E2E_SERVER_ID; - if (!expectedServerId) { - throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); - } - - const snapshot = await page.evaluate(() => { - const registryRaw = localStorage.getItem("@paseo:daemon-registry"); - const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences"); - return { registryRaw, prefsRaw }; - }); - - if (!snapshot.registryRaw) { - throw new Error("E2E expected @paseo:daemon-registry to be set before app load."); - } - - const { serverId, connections } = parseRegistryEntry(snapshot.registryRaw); - if (serverId !== expectedServerId) { - throw new Error( - `E2E expected seeded daemon serverId to be ${expectedServerId} (got ${serverId}).`, - ); - } - assertDaemonConnections(connections, expectedEndpoint); - - if (!snapshot.prefsRaw) { - throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load."); - } - assertPreferencesMatch(snapshot.prefsRaw, serverId); -} - export const gotoAppShell = async (page: Page) => { await page.goto("/"); - await ensureE2EStorageSeeded(page); }; export const gotoHome = async (page: Page) => { await gotoAppShell(page); - const composer = page.getByRole("textbox", { name: "Message agent..." }); - if ( - !(await composer - .first() - .isVisible() - .catch(() => false)) - ) { - const addProjectCta = page.getByText("Add a project", { exact: true }).first(); - const addProjectSidebar = page.getByText("Add project", { exact: true }).first(); - const newAgentButton = page.getByText("New agent", { exact: true }).first(); + const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); + const entryButton = page + .getByText("Add a project", { exact: true }) + .or(page.getByText("Add project", { exact: true })) + .or(page.getByText("New agent", { exact: true })) + .first(); - await expect - .poll( - async () => - (await addProjectCta.isVisible().catch(() => false)) || - (await addProjectSidebar.isVisible().catch(() => false)) || - (await newAgentButton.isVisible().catch(() => false)), - { timeout: 10000 }, - ) - .toBe(true); + await expect + .poll( + async () => + (await composer.isVisible().catch(() => false)) || + (await entryButton.isVisible().catch(() => false)), + { timeout: 10_000 }, + ) + .toBe(true); - if (await addProjectCta.isVisible().catch(() => false)) { - await addProjectCta.click(); - } else if (await addProjectSidebar.isVisible().catch(() => false)) { - await addProjectSidebar.click(); - } else { - await newAgentButton.click(); - } + if (!(await composer.isVisible().catch(() => false))) { + await entryButton.click(); } - await expect(composer.first()).toBeVisible({ timeout: 30000 }); + + await expect(composer).toBeVisible({ timeout: 30_000 }); }; export const openSettings = async (page: Page) => { @@ -341,46 +160,6 @@ export const setWorkingDirectory = async (page: Page, directory: string) => { }; export const ensureHostSelected = async (page: Page) => { - await ensureE2EStorageSeeded(page); - - // Absolute verification that we're using the per-run e2e daemon (never :6767). - // Also self-heal a rare case where app code rewrites daemon IDs after boot, by - // realigning create-agent-preferences.serverId to the sole seeded daemon. - try { - await assertE2EUsesSeededTestDaemon(page); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!/create-agent-preferences\.serverId/i.test(message)) { - throw error; - } - - const fix = await page.evaluate(() => { - const registryRaw = localStorage.getItem("@paseo:daemon-registry"); - const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences"); - if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const; - const registry = JSON.parse(registryRaw) as Array<{ serverId?: string }>; - const prefs = JSON.parse(prefsRaw) as { serverId?: string }; - if (!Array.isArray(registry) || registry.length !== 1) - return { ok: false, reason: "registry shape" } as const; - const serverId = registry[0]?.serverId; - if (typeof serverId !== "string" || serverId.length === 0) - return { ok: false, reason: "missing serverId" } as const; - prefs.serverId = serverId; - localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs)); - // Prevent the fixture's init-script from overwriting the corrected prefs on reload. - const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1"; - localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce); - return { ok: true } as const; - }); - - if (!fix.ok) { - throw error; - } - - await page.reload(); - await assertE2EUsesSeededTestDaemon(page); - } - const input = page.getByRole("textbox", { name: "Message agent..." }); await expect(input).toBeVisible(); @@ -392,7 +171,7 @@ export const ensureHostSelected = async (page: Page) => { if (await selectHostLabel.isVisible()) { await selectHostLabel.click(); - // E2E safety: we enforce a single seeded daemon, so the option should be unambiguous. + // We enforce a single seeded daemon, so the option should be unambiguous. const localhostOption = page.getByText("localhost", { exact: true }).first(); const daemonIdOption = page .getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true }) @@ -657,40 +436,3 @@ export const createAgentInRepo = async ( await setWorkingDirectory(page, config.directory); await createAgent(page, config.prompt); }; - -export async function waitForAgentFinishUI(page: Page, timeout = 30000) { - // Wait for the stop button to disappear - const stopButton = page.getByRole("button", { name: /stop|cancel/i }); - - // First, let's debug what's happening - wait a bit to see the state - await page.waitForTimeout(2000); - - // Check if stop button is visible - const isVisible = await stopButton.isVisible().catch(() => false); - - if (isVisible) { - // If stop button is still visible after permission denial, - // it might be that the agent is waiting for something. - // Let's check if there's a tool call result or other UI indication - - // Look for any indication that the agent has processed the permission denial - const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i); - - // Wait for the tool call result to appear - await expect(toolCallResult) - .toBeVisible({ timeout: 10000 }) - .catch(() => { - // If no specific message, just wait for the button to disappear - }); - - // Now wait for the stop button to disappear - await expect(stopButton).not.toBeVisible({ timeout }); - } -} - -export async function getToolCallCount(page: Page): Promise { - // Tool calls are rendered as ExpandableBadge components with tool names like Bash, Write, Read, etc. - // They appear as pressable badges in the agent stream - const toolCallBadges = page.locator('[data-testid="tool-call-badge"]'); - return toolCallBadges.count(); -} diff --git a/packages/app/e2e/helpers/launcher.ts b/packages/app/e2e/helpers/launcher.ts index 241055b9e..63e3a3f6f 100644 --- a/packages/app/e2e/helpers/launcher.ts +++ b/packages/app/e2e/helpers/launcher.ts @@ -68,20 +68,6 @@ export async function getActiveTabTestId(page: Page): Promise { // ─── Tab actions ─────────────────────────────────────────────────────────── -/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */ -export async function clickNewTabButton(page: Page): Promise { - const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(); - await expect(button).toBeVisible({ timeout: 10_000 }); - await button.click(); -} - -/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */ -export async function clickNewTerminalButton(page: Page): Promise { - const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(); - await expect(button).toBeVisible({ timeout: 10_000 }); - await button.click(); -} - /** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open a new tab. */ export async function pressNewTabShortcut(page: Page): Promise { const modifier = process.platform === "darwin" ? "Meta" : "Control"; @@ -90,11 +76,6 @@ export async function pressNewTabShortcut(page: Page): Promise { // ─── Tab bar assertions ─────────────────────────────────────────────────── -/** @deprecated The launcher panel was removed. Actions go directly to their target. */ -export async function waitForLauncherPanel(_page: Page): Promise { - // No-op: the launcher panel no longer exists. -} - /** Assert the new agent tab button is visible in the tab bar. */ export async function assertNewChatTileVisible(page: Page): Promise { await expect( @@ -119,7 +100,7 @@ export async function clickNewChat(page: Page): Promise { } /** Click the new terminal button to create a terminal tab. */ -export async function clickTerminal(page: Page): Promise { +export async function clickNewTerminal(page: Page): Promise { const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(); await expect(button).toBeVisible({ timeout: 10_000 }); await button.click(); diff --git a/packages/app/e2e/helpers/workspace-lifecycle.ts b/packages/app/e2e/helpers/workspace-lifecycle.ts index a462bbfd1..383872850 100644 --- a/packages/app/e2e/helpers/workspace-lifecycle.ts +++ b/packages/app/e2e/helpers/workspace-lifecycle.ts @@ -1,5 +1,5 @@ import { expect, type Page } from "@playwright/test"; -import { clickNewChat, clickTerminal } from "./launcher"; +import { clickNewChat, clickNewTerminal } from "./launcher"; import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf"; function terminalSurface(page: Page) { @@ -20,7 +20,7 @@ export async function expectTerminalCwd(page: Page, expectedPath: string): Promi } export async function createStandaloneTerminalFromLauncher(page: Page): Promise { - await clickTerminal(page); + await clickNewTerminal(page); await expect(terminalSurface(page)).toBeVisible({ timeout: 20_000 }); } diff --git a/packages/app/e2e/launcher-tab.spec.ts b/packages/app/e2e/launcher-tab.spec.ts index 35cdce0e9..db919a94e 100644 --- a/packages/app/e2e/launcher-tab.spec.ts +++ b/packages/app/e2e/launcher-tab.spec.ts @@ -5,10 +5,9 @@ import { assertNewChatTileVisible, assertTerminalTileVisible, assertSingleNewTabButton, - clickNewTabButton, pressNewTabShortcut, clickNewChat, - clickTerminal, + clickNewTerminal, countTabsOfKind, getTabTestIds, waitForTabWithTitle, @@ -77,7 +76,7 @@ test.describe("Tab creation", () => { test("clicking new agent tab creates a draft tab", async ({ page }) => { await gotoWorkspace(page, workspaceId); - await clickNewTabButton(page); + await clickNewChat(page); // Draft composer should appear (the agent message input) const composer = page.getByRole("textbox", { name: "Message agent..." }); @@ -92,7 +91,7 @@ test.describe("Tab creation", () => { test.setTimeout(45_000); await gotoWorkspace(page, workspaceId); - await clickTerminal(page); + await clickNewTerminal(page); // Terminal surface should appear const terminal = page.locator('[data-testid="terminal-surface"]'); @@ -141,7 +140,7 @@ test.describe("Terminal title propagation", () => { try { // Navigate to workspace and open a terminal await gotoWorkspace(page, workspaceId); - await clickTerminal(page); + await clickNewTerminal(page); const terminal = page.locator('[data-testid="terminal-surface"]'); await expect(terminal.first()).toBeVisible({ timeout: 20_000 }); @@ -171,7 +170,7 @@ test.describe("Terminal title propagation", () => { try { await gotoWorkspace(page, workspaceId); - await clickTerminal(page); + await clickNewTerminal(page); const terminal = page.locator('[data-testid="terminal-surface"]'); await expect(terminal.first()).toBeVisible({ timeout: 20_000 }); @@ -231,7 +230,7 @@ test.describe("Tab transitions (no flash)", () => { const terminal = page.locator('[data-testid="terminal-surface"]'); const elapsed = await measureTileTransition( page, - () => clickTerminal(page), + () => clickNewTerminal(page), terminal.first(), 20_000, ); diff --git a/packages/app/e2e/workspace-cwd.spec.ts b/packages/app/e2e/workspace-cwd.spec.ts index 8f1fba891..146b3da87 100644 --- a/packages/app/e2e/workspace-cwd.spec.ts +++ b/packages/app/e2e/workspace-cwd.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "./fixtures"; -import { clickTerminal } from "./helpers/launcher"; +import { clickNewTerminal } from "./helpers/launcher"; import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf"; test.describe("Workspace cwd correctness", () => { @@ -11,7 +11,7 @@ test.describe("Workspace cwd correctness", () => { const workspace = await withWorkspace({ prefix: "workspace-cwd-main-" }); await workspace.navigateTo(); - await clickTerminal(page); + await clickNewTerminal(page); const terminal = page.locator('[data-testid="terminal-surface"]'); await expect(terminal.first()).toBeVisible({ timeout: 20_000 }); @@ -31,7 +31,7 @@ test.describe("Workspace cwd correctness", () => { const workspace = await withWorkspace({ worktree: true, prefix: "workspace-cwd-worktree-" }); await workspace.navigateTo(); - await clickTerminal(page); + await clickNewTerminal(page); const terminal = page.locator('[data-testid="terminal-surface"]'); await expect(terminal.first()).toBeVisible({ timeout: 20_000 }); diff --git a/packages/app/e2e/workspace-setup-runtime.spec.ts b/packages/app/e2e/workspace-setup-runtime.spec.ts index 41f0e34ab..22085cea1 100644 --- a/packages/app/e2e/workspace-setup-runtime.spec.ts +++ b/packages/app/e2e/workspace-setup-runtime.spec.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; import { expect, test } from "./fixtures"; import { createTempGitRepo } from "./helpers/workspace"; -import { clickTerminal, waitForTabBar } from "./helpers/launcher"; +import { clickNewTerminal, waitForTabBar } from "./helpers/launcher"; import { connectWorkspaceSetupClient, createWorkspaceThroughDaemon, @@ -86,7 +86,7 @@ test.describe("Workspace setup runtime authority", () => { await openHomeWithProject(page, repo.path); await navigateToWorkspaceViaSidebar(page, workspaceId); - await clickTerminal(page); + await clickNewTerminal(page); const terminal = page.locator('[data-testid="terminal-surface"]'); await expect(terminal.first()).toBeVisible({ timeout: 20_000 });