Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/nushell/__tests__/harness.test.ts

Raw
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { initTheme } from "@earendil-works/pi-coding-agent";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestSession, type TestSession } from "../../../test/harness";
import { findNu } from "../runtime.ts";

const extensionPath = path.resolve(import.meta.dirname, "../index.ts");

describe("nushell pi-test-harness", () => {
	let t: TestSession | undefined;
	let root: string | undefined;
	afterEach(() => {
		t?.dispose();
		t = undefined;
		if (root) rmSync(root, { recursive: true, force: true });
		root = undefined;
	});

	it("registers inert tools and toggles without changing unrelated tools", async () => {
		t = await createTestSession({ extensions: [extensionPath] });
		const runner = t.session.extensionRunner;
		const command = runner.getCommand("nushell");
		const ctx = runner.createCommandContext();
		expect(runner.extensions[0]?.tools.has("nu")).toBe(true);
		expect(runner.extensions[0]?.tools.has("nu_session")).toBe(true);
		expect(t.session.getActiveToolNames()).not.toContain("nu");
		expect(command?.getArgumentCompletions?.("re")).toEqual([
			{ value: "reset", label: "reset" },
		]);
		const initial = t.session.getActiveToolNames();
		await command?.handler("on", ctx);
		const enabled = t.session.getActiveToolNames();
		if (!findNu()) {
			expect(enabled).toEqual(initial);
			return;
		}
		expect(enabled).toContain("nu");
		expect(enabled).toContain("nu_session");
		expect(enabled).not.toContain("bash");
		expect(enabled).not.toContain("powershell");
		for (const name of initial.filter(
			(name) => name !== "bash" && name !== "powershell",
		))
			expect(enabled).toContain(name);
		await command?.handler("off", ctx);
		expect([...t.session.getActiveToolNames()].sort()).toEqual(
			[...initial].sort(),
		);
	});

	it("keeps shell tools active and warns when PATH has no executable Nu", async () => {
		t = await createTestSession({ extensions: [extensionPath] });
		root = mkdtempSync(path.join(tmpdir(), "pi-ext-nushell-path-"));
		mkdirSync(path.join(root, process.platform === "win32" ? "nu.exe" : "nu"));
		t.env.PATH = root;
		const runner = t.session.extensionRunner;
		const initial = t.session.getActiveToolNames();
		runner.setFlagValue("nushell", true);
		await runner.emit({ type: "session_start", reason: "startup" });
		expect(t.session.getActiveToolNames()).toEqual(initial);
		expect(t.events.uiCallsFor("notify").at(-1)?.args).toEqual([
			"Nushell executable not found on PATH; tools unchanged",
			"warning",
		]);
		await runner
			.getCommand("nushell")
			?.handler("on", runner.createCommandContext());
		expect(t.session.getActiveToolNames()).toEqual(initial);
		expect(t.events.uiCallsFor("notify").at(-1)?.args).toEqual([
			"Nushell executable not found on PATH; tools unchanged",
			"warning",
		]);
	});

	it("registers --nushell to enable both tools on session start", async () => {
		t = await createTestSession({ extensions: [extensionPath] });
		const runner = t.session.extensionRunner;
		expect(runner.getFlags().has("nushell")).toBe(true);
		runner.setFlagValue("nushell", true);
		await runner.emit({ type: "session_start", reason: "startup" });
		const active = t.session.getActiveToolNames();
		if (!findNu()) {
			expect(active).not.toContain("nu");
			return;
		}
		expect(active).toContain("nu");
		expect(active).toContain("nu_session");
		expect(active).not.toContain("bash");
		expect(active).not.toContain("powershell");
	});

	it.runIf(Boolean(findNu()))(
		"does not claim activation when Nu tools are excluded",
		async () => {
			t = await createTestSession({ extensions: [extensionPath] });
			const runner = t.session.extensionRunner;
			const initial = t.session.getActiveToolNames();
			const tools = t.session.getAllTools();
			vi.spyOn(t.session, "getAllTools").mockReturnValue(
				tools.filter((tool) => tool.name !== "nu_session"),
			);
			runner.setFlagValue("nushell", true);
			await runner.emit({ type: "session_start", reason: "startup" });
			expect(t.session.getActiveToolNames()).toEqual(initial);
			expect(t.events.uiCallsFor("notify").at(-1)?.args).toEqual([
				expect.stringContaining("nu_session"),
				"warning",
			]);
		},
	);

	it.runIf(Boolean(findNu()))(
		"renders both Nu commands with syntax highlighting and shell-style results",
		async () => {
			t = await createTestSession({ extensions: [extensionPath] });
			initTheme("default", false);
			const tools = t.session.extensionRunner.extensions[0]?.tools;
			const theme = {
				fg: (color: string, value: string) =>
					value ? `<${color}>${value}</${color}>` : "",
				bold: (value: string) => value,
			} as never;
			for (const name of ["nu", "nu_session"]) {
				const tool = tools?.get(name)?.definition;
				const icon = name === "nu" ? "" : "";
				expect(tool?.label).toBe(
					`${icon} ${name === "nu" ? "nu" : "nu session"}`,
				);
				const context = {
					state: {},
					executionStarted: false,
					argsComplete: false,
					invalidate: vi.fn(),
				} as never;
				const partial = tool?.renderCall?.(
					{ command: "let value" },
					theme,
					context,
				);
				expect(partial?.render(1000).join("\n")).not.toContain(
					"<syntaxVariable>",
				);
				context.argsComplete = true;
				context.executionStarted = true;
				const command = 'let value = "safe"';
				const call = tool?.renderCall?.({ command }, theme, context);
				const lines = call?.render(1000).join("\n") ?? "";
				expect(lines.replace(/<\/?[a-zA-Z]+>/g, "")).toContain(command);
				expect(lines).not.toContain("<syntaxVariable>");
				await vi.waitFor(() =>
					expect(context.invalidate).toHaveBeenCalledTimes(1),
				);
				const highlighted =
					tool
						?.renderCall?.({ command }, theme, context)
						.render(1000)
						.join("\n") ?? "";
				expect(highlighted).toContain("<syntaxVariable>value</syntaxVariable>");
				expect(highlighted).toContain('<syntaxString>"safe"</syntaxString>');
				expect(highlighted).not.toMatch(/\x1b\[/);
				const paletteCommand =
					'if true { let value = "safe"; $value | str uppercase; 2 > 1 }';
				tool?.renderCall?.({ command: paletteCommand }, theme, context);
				await vi.waitFor(() =>
					expect(context.invalidate).toHaveBeenCalledTimes(2),
				);
				const palette =
					tool
						?.renderCall?.({ command: paletteCommand }, theme, context)
						.render(2000)
						.join("\n") ?? "";
				for (const token of [
					"<syntaxFunction>if</syntaxFunction>",
					"<syntaxKeyword>true</syntaxKeyword>",
					"<syntaxPunctuation>{</syntaxPunctuation>",
					"<syntaxVariable>$value</syntaxVariable>",
					"<syntaxPunctuation>|</syntaxPunctuation>",
					"<syntaxOperator>></syntaxOperator>",
					'<syntaxString>"safe"</syntaxString>',
				])
					expect(palette).toContain(token);
				expect(lines).toContain(
					`${icon} ${name === "nu" ? "nu>" : "nu session>"}`,
				);
				const result = tool?.renderResult?.(
					{
						content: [
							{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix" },
						],
						details: undefined,
					},
					{ expanded: false, isPartial: false },
					theme,
					{ ...context, showImages: false, isError: false },
				);
				expect(result?.render(120).join("\n")).toContain("to expand");
			}
		},
	);

	it.runIf(Boolean(findNu()))(
		"keeps session state across calls but isolates fresh calls",
		async () => {
			t = await createTestSession({ extensions: [extensionPath] });
			const runner = t.session.extensionRunner;
			const ctx = runner.createCommandContext();
			const tool = (name: string) =>
				runner.extensions[0]?.tools.get(name)?.definition;
			const run = (name: string, command: string) =>
				tool(name)?.execute("call", { command }, undefined, undefined, ctx);
			const fresh = await run("nu", "1 + 2");
			expect(fresh?.content[0]).toMatchObject({ type: "text", text: "3\n" });
			await run("nu_session", "let value = 41");
			const persistent = await run("nu_session", "$value + 1");
			expect(persistent?.content[0]).toMatchObject({
				type: "text",
				text: expect.stringContaining("output:42"),
			});
			await expect(run("nu", "$value + 1")).rejects.toThrow();
			await expect(run("nu_session", "1 / 0")).rejects.toThrow(
				"Division by zero",
			);
			await expect(
				run("nu_session", 'error make {msg: "timeout from user"}'),
			).rejects.toThrow("timeout from user");
			const afterError = await run("nu_session", "$value");
			expect(afterError?.content[0]).toMatchObject({
				text: expect.stringContaining("output:41"),
			});
			const external = await run("nu_session", "^false");
			expect(external?.content[0]).toMatchObject({
				text: expect.stringContaining('output:""'),
			});
			await runner.getCommand("nushell")?.handler("reset", ctx);
			await expect(run("nu_session", "$value")).rejects.toThrow();
			await run("nu_session", "let value = 42");
			const controller = new AbortController();
			const pending = tool("nu_session")?.execute(
				"abort-call",
				{ command: "sleep 2sec" },
				controller.signal,
				undefined,
				ctx,
			);
			const abortTimer = setTimeout(() => controller.abort(), 50);
			try {
				await expect(pending).rejects.toThrow("state reset");
			} finally {
				clearTimeout(abortTimer);
			}
			await expect(run("nu_session", "$value")).rejects.toThrow();
			await runner.getCommand("nushell")?.handler("off", ctx);
		},
	);
});