repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/open/__tests__/harness.test.ts
Rawimport {
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<string[]> {
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",
]);
});
});