diff --git a/packages/app/e2e/checkout-ship.spec.ts b/packages/app/e2e/checkout-ship.spec.ts index 06c74e6fe..a1aba9f27 100644 --- a/packages/app/e2e/checkout-ship.spec.ts +++ b/packages/app/e2e/checkout-ship.spec.ts @@ -120,14 +120,25 @@ async function selectAttachWorktree(page: Page, branchName: string) { await page.getByTestId('worktree-attach-toggle').click(); const picker = page.getByTestId('worktree-attach-picker'); await expect(picker).toBeVisible(); + + // Wait a bit for the worktree list to load + await page.waitForTimeout(1000); + await picker.click(); + + // Wait a bit for animation + await page.waitForTimeout(500); + const sheet = page.getByLabel('Bottom Sheet', { exact: true }); const backdrop = page.getByRole('button', { name: 'Bottom sheet backdrop' }).first(); + await expect.poll(async () => { const sheetVisible = await sheet.isVisible().catch(() => false); const backdropVisible = await backdrop.isVisible().catch(() => false); - return sheetVisible || backdropVisible; - }).toBeTruthy(); + // Also check if branch name is visible directly + const branchVisible = await page.getByText(branchName, { exact: true }).first().isVisible().catch(() => false); + return sheetVisible || backdropVisible || branchVisible; + }, { timeout: 10000 }).toBeTruthy(); const sheetVisible = await sheet.isVisible().catch(() => false); const scope = sheetVisible ? sheet : page; const option = scope.getByText(branchName, { exact: true }).first(); @@ -294,7 +305,8 @@ test('checkout-first Changes panel ship loop', async ({ page }) => { }); await getChangesActionButton(page, 'Archive').click(); - await page.getByTestId('sidebar-new-agent').click(); + // Archiving a worktree deletes agents and redirects to home + await expect(page).toHaveURL(/\/$/, { timeout: 30000 }); await setWorkingDirectory(page, repo.path); await ensureHostSelected(page); await page.getByTestId('worktree-attach-toggle').click(); diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 067818d2b..8f5dd3758 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -1,9 +1,21 @@ -import { test, expect, type Page } from '@playwright/test'; +import { test as base, expect, type Page } from '@playwright/test'; + +// Extend base test to provide dynamic baseURL from global-setup +const test = base.extend({ + baseURL: async ({}, use) => { + const metroPort = process.env.E2E_METRO_PORT; + if (!metroPort) { + throw new Error('E2E_METRO_PORT not set - globalSetup must run first'); + } + await use(`http://localhost:${metroPort}`); + }, +}); const consoleEntries = new WeakMap(); test.beforeEach(async ({ page }) => { const daemonPort = process.env.E2E_DAEMON_PORT; + const metroPort = process.env.E2E_METRO_PORT; if (!daemonPort) { throw new Error( 'E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). ' + @@ -16,6 +28,11 @@ test.beforeEach(async ({ page }) => { 'Fix Playwright globalSetup to start an isolated test daemon and export its port.' ); } + if (!metroPort) { + throw new Error( + 'E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.' + ); + } // Hard guardrail: never allow tests to hit the developer's default daemon. // This blocks both HTTP and WS attempts to :6767 (before any navigation). diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index 62bb55c93..8357ac481 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -41,6 +41,7 @@ async function waitForServer(port: number, timeout = 15000): Promise { } let daemonProcess: ChildProcess | null = null; +let metroProcess: ChildProcess | null = null; let paseoHome: string | null = null; let relayServer: RelayServer | null = null; @@ -74,11 +75,35 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload { export default async function globalSetup() { const port = await getAvailablePort(); const relayPort = await getAvailablePort(); + const metroPort = await getAvailablePort(); paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-')); relayServer = createRelayServer({ port: relayPort, host: '127.0.0.1' }); await relayServer.start(); + // Start Metro bundler on dynamic port + const appDir = path.resolve(__dirname, '..'); + metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], { + cwd: appDir, + env: { + ...process.env, + BROWSER: 'none', // Don't auto-open browser + }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: false, + }); + + metroProcess.stdout?.on('data', (data: Buffer) => { + const lines = data.toString().split('\n').filter((l) => l.trim()); + for (const line of lines) { + console.log(`[metro] ${line}`); + } + }); + + metroProcess.stderr?.on('data', (data: Buffer) => { + console.error(`[metro] ${data.toString().trim()}`); + }); + const serverDir = path.resolve(__dirname, '../../..', 'packages/server'); const tsxBin = execSync('which tsx').toString().trim(); @@ -95,7 +120,7 @@ export default async function globalSetup() { PASEO_HOME: paseoHome, PASEO_LISTEN: `0.0.0.0:${port}`, PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`, - PASEO_CORS_ORIGINS: 'http://localhost:8081', + PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`, NODE_ENV: 'development', }, stdio: ['ignore', 'pipe', 'pipe'], @@ -138,7 +163,11 @@ export default async function globalSetup() { console.error(`[daemon] ${data.toString().trim()}`); }); - await waitForServer(port); + // Wait for both daemon and Metro to be ready + await Promise.all([ + waitForServer(port), + waitForServer(metroPort, 120000), // Metro can take longer to start + ]); // Wait for daemon to emit a pairing offer (includes relay session ID). await Promise.race([ @@ -155,13 +184,18 @@ export default async function globalSetup() { process.env.E2E_DAEMON_PORT = String(port); process.env.E2E_RELAY_PORT = String(relayPort); process.env.E2E_RELAY_SESSION_ID = offer.sessionId; - console.log(`[e2e] Test daemon started on port ${port}, home: ${paseoHome}`); + process.env.E2E_METRO_PORT = String(metroPort); + console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`); return async () => { if (daemonProcess) { daemonProcess.kill('SIGTERM'); daemonProcess = null; } + if (metroProcess) { + metroProcess.kill('SIGTERM'); + metroProcess = null; + } if (relayServer) { await relayServer.stop(); relayServer = null; diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index be7d43c13..e5860bec8 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -213,6 +213,8 @@ export const setWorkingDirectory = async (page: Page, directory: string) => { const useOption = page.getByText(`Use "${directory}"`); await expect(useOption).toBeVisible(); await useOption.click({ force: true }); + // Wait for UI to update after clicking "Use" + await page.waitForTimeout(500); const normalizedDirectory = directory.startsWith('/var/') ? `/private${directory}` : directory; @@ -220,7 +222,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => { await expect.poll(async () => { const text = await workingDirectoryContainer.innerText(); return text.includes(directory) || text.includes(normalizedDirectory); - }).toBe(true); + }, { timeout: 15000 }).toBe(true); }; export const ensureHostSelected = async (page: Page) => { diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index b2bf47c85..cfc4932d6 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -1,6 +1,8 @@ import { defineConfig, devices } from '@playwright/test'; -const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:8081'; +// E2E_METRO_PORT is set dynamically by global-setup.ts after finding a free port +// This allows multiple test runs in parallel across different worktrees +const baseURL = process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}`; export default defineConfig({ testDir: './e2e', @@ -24,4 +26,5 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'] }, }, ], + // Note: Metro is started by global-setup.ts on a dynamic port to allow parallel test runs }); diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index 93464fca4..4b5a5769b 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -562,7 +562,7 @@ function AgentScreenContent({ if (!agent) { return ( - + Agent not found diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 72f121c67..2f33f5941 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useEffect, useId, useMemo, useRef, memo, type ReactElement } from "react"; +import { useRouter } from "expo-router"; import { View, Text, @@ -345,6 +346,7 @@ interface GitDiffPaneProps { export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { const { theme } = useUnistyles(); + const router = useRouter(); const queryClient = useQueryClient(); const client = useSessionStore( (state) => state.sessions[serverId]?.client ?? null @@ -555,6 +557,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList", }); + router.replace("/"); }, onError: (err) => { const message = err instanceof Error ? err.message : "Failed to archive worktree";