Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/open/__tests__/harness.test.ts

Raw
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<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",
		]);
	});
});