Keep workspace editor target selection

This commit is contained in:
Mohamed Boudra
2026-05-19 21:30:43 +07:00
parent 84fb7e9e06
commit ad9b149bf7
5 changed files with 155 additions and 20 deletions
+1 -1
View File
@@ -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 <path> --bail=1 > /tmp/test-output.txt 2>&1` - For a broad sweep, redirect to a file and read it after: `npx vitest run <path> --bail=1 > /tmp/test-output.txt 2>&1`
- Never re-run a suite another agent already reported green. - Never re-run a suite another agent already reported green.
- For full-suite confidence, push to CI and check GitHub Actions. - 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 ## Agent authentication in tests
+36 -10
View File
@@ -188,7 +188,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
let daemonProcess: ChildProcess | null = null; let daemonProcess: ChildProcess | null = null;
let metroProcess: ChildProcess | null = null; let metroProcess: ChildProcess | null = null;
let paseoHome: string | null = null; let paseoHome: string | null = null;
let fakeGhBinDir: string | null = null; let fakeToolBinDir: string | null = null;
let relayProcess: ChildProcess | null = null; let relayProcess: ChildProcess | null = null;
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null { function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
@@ -209,8 +209,8 @@ interface OfferPayload {
relay: { endpoint: string }; relay: { endpoint: string };
} }
async function createFakeGhBin(): Promise<string> { async function createFakeToolBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-")); const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-tool-bin-"));
const ghPath = path.join(binDir, "gh"); const ghPath = path.join(binDir, "gh");
await writeFile( await writeFile(
ghPath, ghPath,
@@ -284,6 +284,27 @@ forwardToRealGh();
`, `,
); );
await chmod(ghPath, 0o755); 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; return binDir;
} }
@@ -599,7 +620,8 @@ interface DaemonSpawnArgs {
relayPort: number; relayPort: number;
metroPort: number; metroPort: number;
paseoHome: string; paseoHome: string;
fakeGhBinDir: string; fakeToolBinDir: string;
editorRecordPath: string;
dictation: DictationConfig; dictation: DictationConfig;
buffer: ReturnType<typeof createLineBuffer>; buffer: ReturnType<typeof createLineBuffer>;
} }
@@ -613,8 +635,9 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
cwd: serverDir, cwd: serverDir,
env: { env: {
...process.env, ...process.env,
PATH: `${args.fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`, PATH: `${args.fakeToolBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome, PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon", PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`, PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`, PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
@@ -678,9 +701,9 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
} else if (paseoHome) { } else if (paseoHome) {
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`); console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
} }
if (fakeGhBinDir) { if (fakeToolBinDir) {
await rm(fakeGhBinDir, { recursive: true, force: true }); await rm(fakeToolBinDir, { recursive: true, force: true });
fakeGhBinDir = null; fakeToolBinDir = null;
} }
} }
@@ -694,7 +717,8 @@ export default async function globalSetup() {
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME); const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1"; const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"))); 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 metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer(); const daemonLineBuffer = createLineBuffer();
@@ -712,7 +736,8 @@ export default async function globalSetup() {
relayPort, relayPort,
metroPort, metroPort,
paseoHome, paseoHome,
fakeGhBinDir, fakeToolBinDir,
editorRecordPath,
dictation, dictation,
buffer: daemonLineBuffer, buffer: daemonLineBuffer,
}); });
@@ -742,6 +767,7 @@ export default async function globalSetup() {
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64; process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
process.env.E2E_METRO_PORT = String(metroPort); process.env.E2E_METRO_PORT = String(metroPort);
process.env.E2E_PASEO_HOME = paseoHome; process.env.E2E_PASEO_HOME = paseoHome;
process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath;
console.log( console.log(
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`, `[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
); );
@@ -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<EditorOpenRecord[]> {
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<void> {
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<void> {
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,
});
});
});
@@ -19,6 +19,9 @@ export function resolvePreferredEditorId(
availableEditorIds: readonly EditorTargetId[], availableEditorIds: readonly EditorTargetId[],
storedEditorId: EditorTargetId | null | undefined, storedEditorId: EditorTargetId | null | undefined,
): EditorTargetId | null { ): EditorTargetId | null {
if (storedEditorId === undefined) {
return null;
}
if ( if (
storedEditorId && storedEditorId &&
availableEditorIds.some((availableEditorId) => availableEditorId === storedEditorId) availableEditorIds.some((availableEditorId) => availableEditorId === storedEditorId)
@@ -50,7 +53,7 @@ export function usePreferredEditor() {
); );
return { return {
preferredEditorId: data ?? null, preferredEditorId: isPending ? undefined : (data ?? null),
isLoading: isPending, isLoading: isPending,
updatePreferredEditor, updatePreferredEditor,
}; };
@@ -1,4 +1,4 @@
import { type ReactElement, useCallback, useEffect, useMemo } from "react"; import { type ReactElement, useCallback, useMemo } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Pressable, Pressable,
@@ -178,13 +178,6 @@ export function WorkspaceOpenInEditorButton({
); );
const primaryOption = targets.find((target) => target.id === effectivePreferredEditorId) ?? null; 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({ const openMutation = useMutation({
mutationFn: (target: OpenTarget) => Promise.resolve(target.onOpen()), mutationFn: (target: OpenTarget) => Promise.resolve(target.onOpen()),
onError: (error: unknown) => { onError: (error: unknown) => {