import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createTestSession, type TestSession } from "../../../test/harness"; import openExtension, { __test, type FileCandidate } from "../index.ts"; const roots: string[] = []; let session: TestSession | undefined; function tempRoot(): string { const root = mkdtempSync(join(tmpdir(), "pi-open-test-")); roots.push(root); return root; } function file(root: string, path: string): string { const absolute = join(root, path); mkdirSync(join(absolute, ".."), { recursive: true }); writeFileSync(absolute, path); return absolute; } function entry(message: unknown): unknown { return { type: "message", message }; } function fakeExecutable(bin: string, name: string): string { const path = join(bin, process.platform === "win32" ? `${name}.exe` : name); copyFileSync(process.execPath, path); if (process.platform !== "win32") chmodSync(path, 0o700); return path; } async function linesEventually(path: string, count: number): Promise { for (let attempt = 0; attempt < 100; attempt++) { try { const lines = readFileSync(path, "utf8").trim().split("\n"); if (lines.length >= count) return lines; } catch {} await new Promise((resolveWait) => setTimeout(resolveWait, 10)); } throw new Error(`Timed out waiting for ${path}`); } afterEach(() => { session?.dispose(); session = undefined; for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20, }); }); describe("open candidates", () => { it("extracts existing path forms", () => { const root = tempRoot(); const markdown = file(root, "docs/with space.md"); const backtick = file(root, "src/backtick.ts"); const atPath = file(root, "TODO.md"); const plain = file(root, "plain.json"); expect( __test.extractTextPaths( "plain.json then `src/backtick.ts`, @TODO.md, and [docs](docs/with space.md); missing.ts", root, ), ).toEqual([plain, backtick, atPath, markdown]); }); it("ranks source first, recency second, and deduplicates paths", () => { const root = tempRoot(); const userOld = file(root, "user-old.ts"); const userNew = file(root, "user-new.ts"); const assistant = file(root, "assistant.ts"); const edited = file(root, "edited.ts"); const written = file(root, "written.ts"); const entries = [ entry({ role: "user", content: "@user-old.ts" }), entry({ role: "assistant", content: [ { type: "text", text: "`assistant.ts` and `user-new.ts`" }, { type: "toolCall", name: "write", arguments: { path: "written.ts" }, }, { type: "toolCall", name: "edit", arguments: { path: "edited.ts" } }, ], }), entry({ role: "user", content: "Use @user-new.ts" }), ]; expect(__test.collectCandidates(entries, root)).toEqual([ { path: userNew, displayPath: "user-new.ts", source: "user" }, { path: userOld, displayPath: "user-old.ts", source: "user" }, { path: assistant, displayPath: "assistant.ts", source: "assistant" }, { path: edited, displayPath: "edited.ts", source: "edit" }, { path: written, displayPath: "written.ts", source: "write" }, ]); }); it("limits scanning to the 20 newest existing files", () => { const root = tempRoot(); const entries = Array.from({ length: 25 }, (_, index) => { const name = `file-${index}.ts`; file(root, name); return entry({ role: "user", content: `@${name}` }); }); const candidates = __test.collectCandidates(entries, root); expect(candidates).toHaveLength(20); expect(candidates[0]?.displayPath).toBe("file-24.ts"); expect(candidates.at(-1)?.displayPath).toBe("file-5.ts"); }); it("fuzzy-picks by path while keeping the path prominent", async () => { const candidates: FileCandidate[] = [ { path: "/a/other.ts", displayPath: "src/other.ts", source: "user" }, { path: "/a/about-this.ts", displayPath: "src/about-this.ts", source: "assistant", }, ]; let rows: string[] = []; const selected = await __test.chooseCandidate(candidates, { mode: "tui", ui: { custom: async (factory: any) => { let result: string | undefined; const component = await factory( { requestRender() {} }, { fg: (_color: string, text: string) => text }, { matches: (data: string, action: string) => action === "tui.select.confirm" && data === "\r", }, (value: string | undefined) => { result = value; }, ); for (const key of ["a", "b", "t"]) component.handleInput(key); rows = component.render(120); component.handleInput("\r"); return result; }, }, } as any); expect(selected).toEqual(candidates[1]); expect(rows.join("\n")).toContain("src/about-this.ts"); }); it("uses the standard selector outside TUI mode", async () => { const candidates: FileCandidate[] = [ { path: "/a/one.ts", displayPath: "one.ts", source: "user" }, { path: "/a/two.ts", displayPath: "two.ts", source: "assistant" }, ]; const selected = await __test.chooseCandidate(candidates, { mode: "rpc", ui: { select: async () => "two.ts" }, } as any); expect(selected).toEqual(candidates[1]); }); }); describe("open extension", () => { it("writes enabled lifecycle debug records in an isolated sandbox", async () => { const root = mkdtempSync(join(tmpdir(), "pi-ext-open-debug-")); const original = { debug: process.env.PI_OPEN_DEBUG, state: process.env.XDG_STATE_HOME, }; try { process.env.PI_OPEN_DEBUG = "1"; process.env.XDG_STATE_HOME = join(root, "state"); vi.resetModules(); const { default: extension } = await import("../index.ts"); session = await createTestSession({ cwd: root, env: { PI_OPEN_DEBUG: "1", XDG_STATE_HOME: join(root, "state"), }, extensionFactories: [extension], }); await session.session.extensionRunner.emit({ type: "session_start", reason: "startup", }); await session.session.extensionRunner.emit({ type: "session_shutdown", reason: "quit", }); const events = readdirSync( join(root, "state", "pi-ext", "debug", "open"), ).flatMap((file) => readFileSync( join(root, "state", "pi-ext", "debug", "open", file), "utf8", ) .trim() .split("\n") .map((line) => JSON.parse(line)), ); expect(events).toEqual( expect.arrayContaining([ expect.objectContaining({ extension: "open", event: "session.start", }), expect.objectContaining({ extension: "open", event: "session.shutdown", }), ]), ); } finally { session?.dispose(); session = undefined; if (original.debug === undefined) delete process.env.PI_OPEN_DEBUG; else process.env.PI_OPEN_DEBUG = original.debug; if (original.state === undefined) delete process.env.XDG_STATE_HOME; else process.env.XDG_STATE_HOME = original.state; rmSync(root, { recursive: true, force: true }); } }); it("ends launch span once when candidate lookup throws", async () => { const root = mkdtempSync(join(tmpdir(), "pi-ext-open-debug-failure-")); const original = { debug: process.env.PI_OPEN_DEBUG, state: process.env.XDG_STATE_HOME, path: process.env.PATH, editor: process.env.EDITOR, pane: process.env.WEZTERM_PANE, }; try { const bin = join(root, "bin"); mkdirSync(bin); fakeExecutable(bin, "wezterm"); fakeExecutable(bin, "nvim"); process.env.PI_OPEN_DEBUG = "1"; process.env.XDG_STATE_HOME = join(root, "state"); process.env.PATH = bin; process.env.EDITOR = "nvim"; process.env.WEZTERM_PANE = "7"; vi.resetModules(); const { default: extension } = await import("../index.ts"); session = await createTestSession({ cwd: root, env: { PI_OPEN_DEBUG: "1", XDG_STATE_HOME: join(root, "state"), PATH: bin, EDITOR: "nvim", WEZTERM_PANE: "7", }, extensionFactories: [extension], }); const runner = session.session.extensionRunner; vi.spyOn(session.session.sessionManager, "getBranch").mockImplementation( () => { throw new Error("test failure"); }, ); const command = runner.extensions[0].commands.get("open"); await expect( command.handler("", runner.createCommandContext()), ).rejects.toThrow("test failure"); await runner.emit({ type: "session_shutdown", reason: "quit" }); const directory = join(root, "state", "pi-ext", "debug", "open"); const records = readdirSync(directory).flatMap((file) => readFileSync(join(directory, file), "utf8") .trim() .split("\n") .map((line) => JSON.parse(line)), ); const start = records.find( (record) => record.event === "open.launch.start", ); const terminal = records.filter( (record) => (record.event === "open.launch.finish" || record.event === "open.launch.error") && record.spanId === start?.spanId, ); expect(terminal).toEqual([ expect.objectContaining({ event: "open.launch.error", outcome: "failed", }), ]); } finally { session?.dispose(); session = undefined; if (original.debug === undefined) delete process.env.PI_OPEN_DEBUG; else process.env.PI_OPEN_DEBUG = original.debug; if (original.state === undefined) delete process.env.XDG_STATE_HOME; else process.env.XDG_STATE_HOME = original.state; if (original.path === undefined) delete process.env.PATH; else process.env.PATH = original.path; if (original.editor === undefined) delete process.env.EDITOR; else process.env.EDITOR = original.editor; if (original.pane === undefined) delete process.env.WEZTERM_PANE; else process.env.WEZTERM_PANE = original.pane; rmSync(root, { recursive: true, force: true }); } }); it("launches explicit and sole-mentioned files through a detached WezTerm split", async () => { const root = tempRoot(); const bin = join(root, "bin"); mkdirSync(bin); const wezterm = fakeExecutable(bin, "wezterm"); const editor = fakeExecutable(bin, "nvim"); const marker = join(root, "launches.jsonl"); const preload = join(root, "record.cjs"); writeFileSync( preload, `require("node:fs").appendFileSync(${JSON.stringify(marker)}, JSON.stringify(process.argv.slice(1)) + "\\n"); process.exit(0);\n`, ); const explicit = file(root, "explicit file.ts"); const mentioned = file(root, "mentioned.ts"); session = await createTestSession({ cwd: root, extensionFactories: [openExtension], env: { PATH: `${bin}${delimiter}${process.env.PATH ?? ""}`, EDITOR: "nvim", WEZTERM_PANE: "7", NODE_OPTIONS: `--require=${preload}`, }, }); const extension = session.session.extensionRunner.extensions[0]; const command = extension.commands.get("open"); const context = session.session.extensionRunner.createCommandContext(); expect(command).toBeDefined(); await command.handler('"explicit file.ts"', context); session.session.sessionManager.appendMessage({ role: "user", content: "Check @mentioned.ts", timestamp: Date.now(), }); await command.handler("", context); const launches = (await linesEventually(marker, 2)).map((line) => JSON.parse(line), ); const prefix = [ join(root, "cli"), "split-pane", "--pane-id", "7", "--right", "--percent", "69", "--cwd", root, "--", editor, ]; expect(launches).toHaveLength(2); expect(launches).toEqual( expect.arrayContaining([ [...prefix, explicit], [...prefix, mentioned], ]), ); expect(wezterm).toContain("wezterm"); }); it("launches files in a new foreground Termux session", async () => { const root = tempRoot(); const bin = join(root, "bin"); mkdirSync(bin); const am = fakeExecutable(bin, "am"); const editor = fakeExecutable(bin, "nvim"); const marker = join(root, "launches.jsonl"); const preload = join(root, "record.cjs"); writeFileSync( preload, `require("node:fs").appendFileSync(${JSON.stringify(marker)}, JSON.stringify(process.argv.slice(1)) + "\\n"); process.exit(0);\n`, ); const target = file(root, "termux file.ts"); session = await createTestSession({ cwd: root, extensionFactories: [openExtension], env: { PATH: `${bin}${delimiter}${process.env.PATH ?? ""}`, EDITOR: "nvim", TERMUX_VERSION: "0.118.3", TERMUX__USER_ID: "10", WEZTERM_PANE: undefined, NODE_OPTIONS: `--require=${preload}`, }, }); const extension = session.session.extensionRunner.extensions[0]; await extension.commands .get("open") .handler( '"termux file.ts"', session.session.extensionRunner.createCommandContext(), ); const launches = (await linesEventually(marker, 1)).map((line) => JSON.parse(line), ); expect(launches).toEqual([ [ join(root, "startservice"), "--user", "10", "-n", "com.termux/com.termux.app.RunCommandService", "-a", "com.termux.RUN_COMMAND", "--es", "com.termux.RUN_COMMAND_PATH", editor, "--esa", "com.termux.RUN_COMMAND_ARGUMENTS", target, "--es", "com.termux.RUN_COMMAND_WORKDIR", root, "--ez", "com.termux.RUN_COMMAND_BACKGROUND", "false", "--es", "com.termux.RUN_COMMAND_SESSION_ACTION", "0", "--es", "com.termux.RUN_COMMAND_LABEL", "Pi open", ], ]); expect(am).toContain("am"); expect(session.events.uiCallsFor("notify")).toEqual([]); }); it("warns when its required host context is unavailable", async () => { const root = tempRoot(); session = await createTestSession({ cwd: root, extensionFactories: [openExtension], env: { PATH: "", EDITOR: undefined, TERMUX_VERSION: undefined, WEZTERM_PANE: undefined, }, }); expect( session.events.uiCallsFor("notify").map((call) => call.args), ).toEqual([ ["/open: EDITOR is not set", "warning"], ["/open: wezterm is not on PATH", "warning"], ["/open: WEZTERM_PANE is not set", "warning"], ]); }); it("reports missing files without launching", async () => { const root = tempRoot(); const bin = join(root, "bin"); mkdirSync(bin); fakeExecutable(bin, "wezterm"); fakeExecutable(bin, "nvim"); session = await createTestSession({ cwd: root, extensionFactories: [openExtension], env: { PATH: bin, EDITOR: "nvim", WEZTERM_PANE: "7", }, }); const extension = session.session.extensionRunner.extensions[0]; await extension.commands .get("open") .handler( "missing.ts", session.session.extensionRunner.createCommandContext(), ); expect(session.events.uiCallsFor("notify").at(-1)?.args).toEqual([ "Not a file: missing.ts", "error", ]); }); });