diff --git a/packages/app/e2e/archive-tab.spec.ts b/packages/app/e2e/archive-tab.spec.ts index c0b5b2c4f..2e165bd42 100644 --- a/packages/app/e2e/archive-tab.spec.ts +++ b/packages/app/e2e/archive-tab.spec.ts @@ -19,7 +19,7 @@ test.describe("Archive tab reconciliation", () => { let client: Awaited>; let tempRepo: { path: string; cleanup: () => Promise }; - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 300_000 }); test.beforeAll(async () => { tempRepo = await createTempGitRepo("archive-tab-"); diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index 0e96d42e9..2eed4b6c5 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -188,11 +188,16 @@ export async function openWorkspaceWithAgents( const serverId = getServerId(); for (const agent of agents) { await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd)); - // Wait for the ?open=agent:xxx intent to be consumed by the layout effect. - // The layout removes the query param via history.replaceState once processed. - await page.waitForFunction(() => !window.location.search.includes("open="), { - timeout: 15_000, - }); + + // The workspace layout consumes `?open=agent:xxx`, returns null during the effect, + // then replaces the URL with the clean workspace route after preparing the tab. + // On CI, Expo Router's rootNavigationState may take time to initialize, + // so we allow a generous timeout here (matching terminal-perf pattern). + await page.waitForURL( + (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), + { timeout: 60_000 }, + ); + await waitForWorkspaceTabsVisible(page); await expectWorkspaceTabVisible(page, agent.id); } diff --git a/packages/app/e2e/helpers/launcher.ts b/packages/app/e2e/helpers/launcher.ts index a823d8a58..887717e46 100644 --- a/packages/app/e2e/helpers/launcher.ts +++ b/packages/app/e2e/helpers/launcher.ts @@ -79,9 +79,10 @@ export async function clickNewTerminalButton(page: Page): Promise { await button.click(); } -/** Press Cmd+T (macOS) to open a new tab. */ +/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open a new tab. */ export async function pressNewTabShortcut(page: Page): Promise { - await page.keyboard.press("Meta+t"); + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await page.keyboard.press(`${modifier}+t`); } // ─── Tab bar assertions ─────────────────────────────────────────────────── diff --git a/packages/app/e2e/helpers/workspace-ui.ts b/packages/app/e2e/helpers/workspace-ui.ts index 848ab24f1..03e1fac51 100644 --- a/packages/app/e2e/helpers/workspace-ui.ts +++ b/packages/app/e2e/helpers/workspace-ui.ts @@ -6,6 +6,17 @@ export async function openNewAgentComposer(page: Page): Promise { await gotoHome(page); } +/** + * Wait for the sidebar to show at least one project row, indicating that the + * WebSocket connection is up and workspace hydration has completed. + */ +export async function waitForSidebarHydration(page: Page, timeout = 60_000): Promise { + await page + .locator('[data-testid^="sidebar-project-row-"]') + .first() + .waitFor({ state: "visible", timeout }); +} + export function workspaceLabelFromPath(value: string): string { const normalized = value.replace(/\\/g, "/").replace(/\/+$/, ""); const parts = normalized.split("/").filter(Boolean); @@ -62,7 +73,7 @@ export async function waitForWorkspaceInSidebar( const selector = candidates .map((id) => `[data-testid="sidebar-workspace-row-${input.serverId}:${id}"]`) .join(","); - await page.locator(selector).first().waitFor({ state: "visible", timeout: 30_000 }); + await page.locator(selector).first().waitFor({ state: "visible", timeout: 60_000 }); } export async function expectWorkspaceHeader( diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts index 588e81e19..098d0a1c9 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/new-workspace.spec.ts @@ -13,6 +13,7 @@ import { createTempGitRepo } from "./helpers/workspace"; import { expectWorkspaceHeader, switchWorkspaceViaSidebar, + waitForSidebarHydration, waitForWorkspaceInSidebar, workspaceLabelFromPath, } from "./helpers/workspace-ui"; @@ -22,7 +23,7 @@ test.describe("New workspace flow", () => { const localWorkspaceIds = new Set(); const createdWorktreeIds = new Set(); - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 240_000 }); test.beforeEach(async () => { client = await connectNewWorkspaceDaemonClient(); @@ -58,7 +59,16 @@ test.describe("New workspace flow", () => { localWorkspaceIds.add(secondWorkspace.workspaceId); await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId)); - await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId)); + await waitForSidebarHydration(page); + + // The app may redirect to a different workspace before hydration completes. + // Re-navigate if we ended up on the wrong workspace. + const currentUrl = page.url(); + const expectedUrl = buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId); + if (!currentUrl.includes(expectedUrl)) { + await page.goto(expectedUrl); + } + await waitForWorkspaceInSidebar(page, { serverId, workspaceId: firstWorkspace.workspaceId, @@ -112,7 +122,15 @@ test.describe("New workspace flow", () => { localWorkspaceIds.add(openedProject.workspaceId); await page.goto(buildHostWorkspaceRoute(serverId, openedProject.workspaceId)); - await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, openedProject.workspaceId)); + await waitForSidebarHydration(page); + + // Re-navigate if the app redirected before hydration completed. + const currentUrl = page.url(); + const expectedUrl = buildHostWorkspaceRoute(serverId, openedProject.workspaceId); + if (!currentUrl.includes(expectedUrl)) { + await page.goto(expectedUrl); + } + await waitForWorkspaceInSidebar(page, { serverId, workspaceId: openedProject.workspaceId, diff --git a/packages/app/e2e/workspace-setup-streaming.spec.ts b/packages/app/e2e/workspace-setup-streaming.spec.ts index 0d39db2cb..c2b143337 100644 --- a/packages/app/e2e/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/workspace-setup-streaming.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from "./fixtures"; import { createTempGitRepo } from "./helpers/workspace"; import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { waitForTerminalContent } from "./helpers/terminal-perf"; import { connectWorkspaceSetupClient, createWorkspaceThroughDaemon, @@ -271,14 +272,22 @@ test.describe("Workspace setup streaming", () => { await terminalTab.click(); // Verify the terminal surface rendered - await expect(page.getByTestId("terminal-surface").first()).toBeVisible({ timeout: 10_000 }); + const terminalSurface = page.getByTestId("terminal-surface").first(); + await expect(terminalSurface).toBeVisible({ timeout: 10_000 }); - // Verify the terminal output contains "listening on" (xterm renders text in .xterm-rows). - // The script is spawned after setup completes and needs time for the shell to start, - // the command to be typed, and Node to boot the HTTP server. Give extra time on CI. - await expect(page.locator(".xterm-rows").first()).toContainText("listening on", { - timeout: 60_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(); + + // Verify the terminal output contains "listening on" via xterm buffer API. + // The .xterm-rows CSS selector is fragile; reading the buffer directly is reliable. + await waitForTerminalContent(page, (text) => text.includes("listening on"), 60_000); } finally { await client.close(); await repo.cleanup();