Merge branch 'archive-worktree-redirect'

This commit is contained in:
Mohamed Boudra
2026-01-23 15:10:38 +07:00
7 changed files with 81 additions and 10 deletions

View File

@@ -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();

View File

@@ -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<Page, string[]>();
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).

View File

@@ -41,6 +41,7 @@ async function waitForServer(port: number, timeout = 15000): Promise<void> {
}
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;

View File

@@ -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) => {

View File

@@ -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
});

View File

@@ -562,7 +562,7 @@ function AgentScreenContent({
if (!agent) {
return (
<View style={styles.container}>
<View style={styles.container} testID="agent-not-found">
<MenuHeader title="Agent" />
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Agent not found</Text>

View File

@@ -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";