Keep workspace editor target selection
This commit is contained in:
+1
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user