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}` : "", 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( "", ); 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(""); await vi.waitFor(() => expect(context.invalidate).toHaveBeenCalledTimes(1), ); const highlighted = tool ?.renderCall?.({ command }, theme, context) .render(1000) .join("\n") ?? ""; expect(highlighted).toContain("value"); expect(highlighted).toContain('"safe"'); 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 [ "if", "true", "{", "$value", "|", ">", '"safe"', ]) 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); }, ); });