diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index 02218658d..c2195390d 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -43,6 +43,18 @@ export interface SeedDaemonClient { fetchAgents(options?: { scope?: "active" }): Promise<{ entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>; }>; + fetchRecentProviderSessions(options: { + cwd: string; + providers: string[]; + limit: number; + }): Promise<{ + entries: Array<{ + providerId: string; + providerHandleId: string; + cwd: string; + firstPromptPreview?: string | null; + }>; + }>; updateAgent(agentId: string, updates: { name?: string }): Promise; waitForAgentUpsert( agentId: string, diff --git a/packages/app/e2e/import-session.opencode.real.spec.ts b/packages/app/e2e/import-session.opencode.real.spec.ts new file mode 100644 index 000000000..ae000c663 --- /dev/null +++ b/packages/app/e2e/import-session.opencode.real.spec.ts @@ -0,0 +1,230 @@ +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { expect, test, type Page } from "./fixtures"; +import { connectSeedClient, type SeededWorkspace } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; +import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; + +const OPENCODE_REAL_MODEL = "openrouter/google/gemini-2.5-flash-lite"; +const OPENCODE_SEED_TIMEOUT_MS = 45_000; +const PASEO_REPO_PATH = path.resolve(__dirname, "../../.."); + +interface OpenCodeSeedResult { + stdout: string; + stderr: string; + code: number | null; + signal: NodeJS.Signals | null; + timedOut: boolean; +} + +interface ImportableOpenCodeSession { + providerHandleId: string; +} + +interface OpenCodeImportScenario { + workspace: SeededWorkspace; + prompt: string; + promptPreview: string; + response: string; +} + +let workspace: SeededWorkspace | null = null; + +test.setTimeout(150_000); + +test.afterEach(async () => { + await workspace?.cleanup().catch(() => undefined); + workspace = null; +}); + +test("imports a real OpenCode session from the workspace import sheet", async ({ page }) => { + const scenario = await seedPaseoWorkspaceWithOpenCodeSession(); + workspace = scenario.workspace; + const importableSession = await waitForImportableOpenCodeSession(scenario); + await openWorkspace(page, scenario.workspace); + + await importOpenCodeSession(page, importableSession); + + await expectImportSheetClosed(page); + await expectImportedSessionOpen(page, scenario); +}); + +async function seedPaseoWorkspaceWithOpenCodeSession(): Promise { + const response = `PASEO_OPENCODE_IMPORT_E2E_OK_${randomUUID().slice(0, 8)}`; + const prompt = `Do not use tools. Reply with exactly: ${response}`; + const promptPreview = JSON.stringify(prompt); + await launchOpenCodeSessionInWorkspace(PASEO_REPO_PATH, prompt); + const client = await connectSeedClient(); + try { + const opened = await client.openProject(PASEO_REPO_PATH); + if (!opened.workspace) { + throw new Error(opened.error ?? `Failed to open project ${PASEO_REPO_PATH}`); + } + return { + prompt, + promptPreview, + response, + workspace: { + client, + repoPath: PASEO_REPO_PATH, + workspaceId: opened.workspace.id, + workspaceName: opened.workspace.name, + workspaceDirectory: opened.workspace.workspaceDirectory, + projectId: opened.workspace.projectId, + projectDisplayName: opened.workspace.projectDisplayName, + cleanup: async () => { + await client.close().catch(() => undefined); + }, + }, + }; + } catch (error) { + await client.close().catch(() => undefined); + throw error; + } +} + +async function launchOpenCodeSessionInWorkspace(repoPath: string, prompt: string): Promise { + const result = await runOpenCodeSeed(repoPath, prompt); + if (result.code !== 0 || result.timedOut) { + throw new Error(formatOpenCodeLaunchError(result, prompt)); + } +} + +function openCodeSeedArgs(repoPath: string, prompt: string): string[] { + return [ + "run", + "--print-logs", + "--log-level", + "INFO", + "--dir", + repoPath, + "--model", + OPENCODE_REAL_MODEL, + "--format", + "json", + prompt, + ]; +} + +function runOpenCodeSeed(repoPath: string, prompt: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn("opencode", openCodeSeedArgs(repoPath, prompt), { + cwd: repoPath, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + }, OPENCODE_SEED_TIMEOUT_MS); + + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + child.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on("close", (code, signal) => { + clearTimeout(timeout); + resolve({ stdout, stderr, code, signal, timedOut }); + }); + }); +} + +function formatOpenCodeLaunchError(result: OpenCodeSeedResult, prompt: string): string { + return [ + "OpenCode launch failed", + `command: ${["opencode", ...openCodeSeedArgs(PASEO_REPO_PATH, prompt)].join(" ")}`, + `exit: ${result.code ?? "null"}`, + result.signal ? `signal: ${result.signal}` : null, + result.timedOut ? `timed out after ${OPENCODE_SEED_TIMEOUT_MS}ms` : null, + result.stdout.trim() ? `stdout:\n${result.stdout.trim()}` : null, + result.stderr.trim() ? `stderr:\n${result.stderr.trim()}` : null, + ] + .filter((part): part is string => part !== null) + .join("\n\n"); +} + +async function openWorkspace(page: Page, seed: SeededWorkspace): Promise { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(buildHostWorkspaceRoute(getServerId(), seed.workspaceId)); + await waitForWorkspaceTabsVisible(page); +} + +async function waitForImportableOpenCodeSession( + scenario: OpenCodeImportScenario, +): Promise { + let importableSession: ImportableOpenCodeSession | null = null; + await expect + .poll( + async () => { + importableSession = await findImportableOpenCodeSession(scenario); + return importableSession?.providerHandleId ?? ""; + }, + { + timeout: 15_000, + intervals: [500, 1_000], + }, + ) + .not.toBe(""); + return importableSession!; +} + +async function findImportableOpenCodeSession( + scenario: OpenCodeImportScenario, +): Promise { + const sessions = await scenario.workspace.client.fetchRecentProviderSessions({ + cwd: scenario.workspace.repoPath, + providers: ["opencode"], + limit: 5, + }); + const entry = sessions.entries.find( + (session) => + session.providerId === "opencode" && session.firstPromptPreview === scenario.promptPreview, + ); + if (!entry) { + return null; + } + return { providerHandleId: entry.providerHandleId }; +} + +async function importOpenCodeSession( + page: Page, + session: ImportableOpenCodeSession, +): Promise { + await page.getByRole("button", { name: "Workspace actions" }).click(); + await page.getByTestId("workspace-header-import-agent").click(); + await expect(page.getByTestId("import-session-sheet")).toBeVisible({ timeout: 15_000 }); + + const importSheet = page.getByTestId("import-session-sheet"); + const sessionRow = importSheet.getByTestId( + `import-session-session-opencode-${session.providerHandleId}`, + ); + await expect(sessionRow).toBeVisible({ timeout: 60_000 }); + await sessionRow.click(); +} + +async function expectImportSheetClosed(page: Page): Promise { + await expect(page.getByTestId("import-session-sheet")).toHaveCount(0, { timeout: 15_000 }); +} + +async function expectImportedSessionOpen( + page: Page, + scenario: OpenCodeImportScenario, +): Promise { + await expect( + page.locator('[data-testid="user-message"]', { hasText: scenario.promptPreview }), + ).toBeVisible({ timeout: 30_000 }); + await expect( + page.locator('[data-testid="assistant-message"]', { hasText: scenario.response }), + ).toBeVisible({ timeout: 30_000 }); +} diff --git a/packages/app/package.json b/packages/app/package.json index c68fdd877..53bfc2432 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -22,7 +22,7 @@ "test": "vitest run", "test:browser": "vitest run --project browser", "test:e2e": "playwright test --project='Desktop Chrome'", - "test:e2e:real": "playwright test --project=real-provider", + "test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider", "test:e2e:ui": "playwright test --ui", "build": "npm run build:web", "build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web", diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 071c7c5a0..cc0d117e7 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ baseURL, trace: "retain-on-failure", screenshot: "only-on-failure", - video: "retain-on-failure", + video: process.env.E2E_RECORD_VIDEO === "1" ? "on" : "retain-on-failure", }, projects: [ {