pi-mono/packages/coding-agent/test/external-editor.test.ts
Petr Houška 6454d07ec3
fix(coding-agent): start WSL interop helpers from / and report external editor failures (#11627)
Fixes #11592.

## Problem

WSL starts Windows programs in the current directory, translated to
`\\wsl.localhost\<distro>\<cwd>`. When that path exceeds MAX_PATH, every
Windows program fails to start with `Invalid argument`. pi inherits its
cwd when spawning `powershell.exe`, so in a deep directory:

- clipboard image paste returns `null` (the PowerShell backend fails,
stderr is discarded)
- copy via `powershell.exe` (WSL without WSLg) fails
- opening the browser for sign-in fails (`xdg-open` on WSL runs
`explorer.exe`)

Reproduced on WSL (FedoraLinux-44): `powershell.exe` and `cmd.exe` work
from a 225-char cwd and fail from 230. With an image on the Windows
clipboard, `readClipboardImage()` returns the PNG from `/tmp` and `null`
from a 245-char cwd; with this change it returns the PNG from both.
`LongPathsEnabled=1` does not help.

## Changes

- `runClipboardCommand` and `openBrowser` run from `/` on POSIX. These
tools never use the cwd (temp files are passed by absolute path), and
`/` is always the shortest UNC path. `tmpdir()` was not used because
`$TMPDIR` can itself be deep.
- The external editor keeps pi's cwd on purpose, so project-relative
paths and integrated terminals keep working. A Windows editor such as
`code` can still fail from a deep WSL directory, as with git's
`core.editor`. A comment documents this.
- External editor failures were silent: the UI redrew over the error
immediately. pi now shows `Failed to launch external editor: <cmd>`,
`External editor exited with code N; prompt unchanged`, or the signal
name. This shows in the status line for the main editor and inline in
the extension editor dialog.

## Testing

- New tests: clipboard commands run from `/` (POSIX); an editor that
cannot be launched is reported (POSIX); the existing failure test checks
the message.
- Verified on WSL by running the real modules: clipboard image paste
from a deep cwd, and editor success, exit code 1, launch failure, and
SIGTERM.
- On Windows, the 3 existing `external-editor.test.ts` tests already
fail on `main`: the editor command is split on spaces, so `C:\Program
Files\nodejs\node.exe` breaks. Not addressed here.

This PR is AI-generated.
2026-10-30 21:47:58 +01:00

72 lines
2.8 KiB
TypeScript

import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { type ExternalEditorResult, editInExternalEditor } from "../src/modes/interactive/external-editor.ts";
const editorFixturePath = fileURLToPath(new URL("./fixtures/fake-external-editor.mjs", import.meta.url));
interface EditorCapture {
filePath: string;
content: string;
entries: string[];
directoryMode: number;
}
async function runExternalEditor(fixtureFlag?: "--fail" | "--empty"): Promise<{
result: ExternalEditorResult;
capture: EditorCapture;
}> {
const testDirectory = mkdtempSync(join(tmpdir(), "pi-external-editor-test-"));
const capturePath = join(testDirectory, "capture.json");
try {
const result = await editInExternalEditor({
command: `${process.execPath} ${editorFixturePath} ${capturePath}${fixtureFlag ? ` ${fixtureFlag}` : ""}`,
content: "original",
});
const capture = JSON.parse(readFileSync(capturePath, "utf-8")) as EditorCapture;
return { result, capture };
} finally {
rmSync(testDirectory, { recursive: true, force: true });
}
}
describe("editInExternalEditor", () => {
it("edits a prompt inside a private temporary directory", async () => {
const { result, capture } = await runExternalEditor();
const directory = dirname(capture.filePath);
expect(result).toEqual({ status: "complete", content: "edited" });
expect(dirname(directory)).toBe(tmpdir());
expect(basename(directory)).toMatch(/^pi-editor-.+$/);
expect(basename(capture.filePath)).toBe("prompt.md");
expect(capture.entries).toEqual(["prompt.md"]);
expect(capture.content).toBe("original");
if (process.platform !== "win32") {
expect(capture.directoryMode & 0o077).toBe(0);
}
expect(existsSync(directory)).toBe(false);
});
it("keeps the original content when the editor exits unsuccessfully", async () => {
const { result, capture } = await runExternalEditor("--fail");
expect(result).toEqual({ status: "failed", message: "External editor exited with code 1; prompt unchanged" });
expect(existsSync(dirname(capture.filePath))).toBe(false);
});
it("returns empty content when the editor clears the prompt", async () => {
const { result } = await runExternalEditor("--empty");
expect(result).toEqual({ status: "complete", content: "" });
});
// #11592: launch failures must be reported, not silently ignored.
it.skipIf(process.platform === "win32")("reports an editor that cannot be launched", async () => {
const command = "pi-external-editor-does-not-exist";
expect(await editInExternalEditor({ command, content: "original" })).toEqual({
status: "failed",
message: `Failed to launch external editor: ${command}`,
});
});
});