diff --git a/docs/testing.md b/docs/testing.md index 7b72a4bd2..7891e748f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -127,7 +127,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es - For a broad sweep, redirect to a file and read it after: `npx vitest run --bail=1 > /tmp/test-output.txt 2>&1` - Never re-run a suite another agent already reported green. - For full-suite confidence, push to CI and check GitHub Actions. -- Never run Playwright E2E (`packages/app/e2e/*.spec.ts`) locally — defer to CI. +- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow. ## Agent authentication in tests diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index 146416f9b..883dc86bc 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -188,7 +188,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise { - const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-")); +async function createFakeToolBin(): Promise { + const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-tool-bin-")); const ghPath = path.join(binDir, "gh"); await writeFile( ghPath, @@ -284,6 +284,27 @@ forwardToRealGh(); `, ); await chmod(ghPath, 0o755); + + const fakeEditorSource = `#!/usr/bin/env node +const fs = require("fs"); +const path = require("path"); +const recordPath = process.env.PASEO_E2E_EDITOR_RECORD_PATH; + +if (recordPath) { + fs.appendFileSync(recordPath, JSON.stringify({ + command: path.basename(process.argv[1]), + args: process.argv.slice(2), + cwd: process.cwd(), + at: Date.now() + }) + "\\n"); +} +`; + for (const editorCommand of ["cursor", "code"]) { + const editorPath = path.join(binDir, editorCommand); + await writeFile(editorPath, fakeEditorSource); + await chmod(editorPath, 0o755); + } + return binDir; } @@ -599,7 +620,8 @@ interface DaemonSpawnArgs { relayPort: number; metroPort: number; paseoHome: string; - fakeGhBinDir: string; + fakeToolBinDir: string; + editorRecordPath: string; dictation: DictationConfig; buffer: ReturnType; } @@ -613,8 +635,9 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess { cwd: serverDir, env: { ...process.env, - PATH: `${args.fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`, + PATH: `${args.fakeToolBinDir}${path.delimiter}${process.env.PATH ?? ""}`, PASEO_HOME: args.paseoHome, + PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath, PASEO_SERVER_ID: "srv_e2e_test_daemon", PASEO_LISTEN: `0.0.0.0:${args.port}`, PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`, @@ -678,9 +701,9 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise { } else if (paseoHome) { console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`); } - if (fakeGhBinDir) { - await rm(fakeGhBinDir, { recursive: true, force: true }); - fakeGhBinDir = null; + if (fakeToolBinDir) { + await rm(fakeToolBinDir, { recursive: true, force: true }); + fakeToolBinDir = null; } } @@ -694,7 +717,8 @@ export default async function globalSetup() { const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME); const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1"; paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"))); - fakeGhBinDir = await createFakeGhBin(); + const editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl"); + fakeToolBinDir = await createFakeToolBin(); const metroLineBuffer = createLineBuffer(); const daemonLineBuffer = createLineBuffer(); @@ -712,7 +736,8 @@ export default async function globalSetup() { relayPort, metroPort, paseoHome, - fakeGhBinDir, + fakeToolBinDir, + editorRecordPath, dictation, buffer: daemonLineBuffer, }); @@ -742,6 +767,7 @@ export default async function globalSetup() { process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64; process.env.E2E_METRO_PORT = String(metroPort); process.env.E2E_PASEO_HOME = paseoHome; + process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath; console.log( `[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`, ); diff --git a/packages/app/e2e/workspace-open-in-editor.spec.ts b/packages/app/e2e/workspace-open-in-editor.spec.ts new file mode 100644 index 000000000..3d37629a3 --- /dev/null +++ b/packages/app/e2e/workspace-open-in-editor.spec.ts @@ -0,0 +1,113 @@ +import { readFile, rm } from "node:fs/promises"; +import { expect, test, type Page } from "./fixtures"; +import { gotoAppShell, openSettings } from "./helpers/app"; +import { injectDesktopBridge } from "./helpers/desktop-updates"; +import { clickSettingsBackToWorkspace } from "./helpers/settings"; + +interface EditorOpenRecord { + command: string; + args: string[]; +} + +function requireE2EEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`${name} is not set.`); + } + return value; +} + +async function readEditorOpenRecords(recordPath: string): Promise { + try { + const contents = await readFile(recordPath, "utf8"); + return contents + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as EditorOpenRecord); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return []; + } + throw error; + } +} + +async function chooseEditorTarget(page: Page, targetId: "vscode"): Promise { + await expect(page.getByTestId("workspace-open-in-editor-primary")).toBeVisible({ + timeout: 30_000, + }); + await page.getByTestId("workspace-open-in-editor-caret").click(); + await expect(page.getByTestId("workspace-open-in-editor-menu")).toBeVisible(); + await page.getByTestId(`workspace-open-in-editor-item-${targetId}`).click(); +} + +async function expectEditorOpened(input: { + recordPath: string; + command: string; + workspacePath: string; + afterCount: number; +}): Promise { + await expect + .poll( + async () => { + const records = await readEditorOpenRecords(input.recordPath); + return records + .slice(input.afterCount) + .some( + (record) => + record.command === input.command && record.args.includes(input.workspacePath), + ); + }, + { timeout: 30_000 }, + ) + .toBe(true); +} + +test.describe("Workspace open in editor", () => { + test("keeps the selected editor target after leaving and returning to the workspace", async ({ + page, + withWorkspace, + }) => { + test.setTimeout(90_000); + + const serverId = requireE2EEnv("E2E_SERVER_ID"); + const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH"); + await rm(recordPath, { force: true }); + await injectDesktopBridge(page, { serverId }); + + const workspace = await withWorkspace({ prefix: "workspace-editor-target-" }); + await workspace.navigateTo(); + + await chooseEditorTarget(page, "vscode"); + await expectEditorOpened({ + recordPath, + command: "code", + workspacePath: workspace.repoPath, + afterCount: 0, + }); + const recordsAfterSelection = (await readEditorOpenRecords(recordPath)).length; + + await openSettings(page); + await clickSettingsBackToWorkspace(page); + await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); + + await page.getByTestId("workspace-open-in-editor-primary").click(); + await expectEditorOpened({ + recordPath, + command: "code", + workspacePath: workspace.repoPath, + afterCount: recordsAfterSelection, + }); + const recordsAfterReturnOpen = (await readEditorOpenRecords(recordPath)).length; + + await gotoAppShell(page); + await workspace.navigateTo(); + await page.getByTestId("workspace-open-in-editor-primary").click(); + await expectEditorOpened({ + recordPath, + command: "code", + workspacePath: workspace.repoPath, + afterCount: recordsAfterReturnOpen, + }); + }); +}); diff --git a/packages/app/src/hooks/use-preferred-editor.ts b/packages/app/src/hooks/use-preferred-editor.ts index f4c97f7db..3d4bb0291 100644 --- a/packages/app/src/hooks/use-preferred-editor.ts +++ b/packages/app/src/hooks/use-preferred-editor.ts @@ -19,6 +19,9 @@ export function resolvePreferredEditorId( availableEditorIds: readonly EditorTargetId[], storedEditorId: EditorTargetId | null | undefined, ): EditorTargetId | null { + if (storedEditorId === undefined) { + return null; + } if ( storedEditorId && availableEditorIds.some((availableEditorId) => availableEditorId === storedEditorId) @@ -50,7 +53,7 @@ export function usePreferredEditor() { ); return { - preferredEditorId: data ?? null, + preferredEditorId: isPending ? undefined : (data ?? null), isLoading: isPending, updatePreferredEditor, }; diff --git a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx index ec9808058..3376bb5c6 100644 --- a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx +++ b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx @@ -1,4 +1,4 @@ -import { type ReactElement, useCallback, useEffect, useMemo } from "react"; +import { type ReactElement, useCallback, useMemo } from "react"; import { ActivityIndicator, Pressable, @@ -178,13 +178,6 @@ export function WorkspaceOpenInEditorButton({ ); const primaryOption = targets.find((target) => target.id === effectivePreferredEditorId) ?? null; - useEffect(() => { - if (!effectivePreferredEditorId || effectivePreferredEditorId === preferredEditorId) { - return; - } - void updatePreferredEditor(effectivePreferredEditorId).catch(() => undefined); - }, [effectivePreferredEditorId, preferredEditorId, updatePreferredEditor]); - const openMutation = useMutation({ mutationFn: (target: OpenTarget) => Promise.resolve(target.onOpen()), onError: (error: unknown) => {