import { randomUUID } from "node:crypto"; import { mkdtemp, realpath, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { checkToolAvailability, resolveCommand, spawnToText } from "../spawn"; const originalPath = process.env["PATH"]; const originalPathAlt = process.env["Path"]; afterEach(() => { if (originalPath === undefined) delete process.env["PATH"]; else process.env["PATH"] = originalPath; if (originalPathAlt === undefined) delete process.env["Path"]; else process.env["Path"] = originalPathAlt; }); async function makeFakeTool(dir: string, name: string) { if (process.platform === "win32") { const toolPath = path.join(dir, `${name}.cmd`); await writeFile(toolPath, "@echo off\r\nexit /b 0\r\n"); return toolPath; } const toolPath = path.join(dir, name); await writeFile(toolPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); return toolPath; } describe("checkToolAvailability", () => { it("finds executables on PATH without requiring Unix which", async () => { const dir = await mkdtemp(path.join(tmpdir(), "pi-web-path-")); const name = `fake-tool-${randomUUID()}`; await makeFakeTool(dir, name); process.env["PATH"] = dir; await expect(checkToolAvailability(name, "missing")).resolves.toBeNull(); }); it("does not keep stale negative cache entries after PATH changes", async () => { const dir = await mkdtemp(path.join(tmpdir(), "pi-web-path-")); const emptyDir = await mkdtemp(path.join(tmpdir(), "pi-web-empty-path-")); const name = `fake-tool-${randomUUID()}`; process.env["PATH"] = emptyDir; await expect(checkToolAvailability(name, "missing")).resolves.toBe( "missing", ); await makeFakeTool(dir, name); process.env["PATH"] = dir; await expect(checkToolAvailability(name, "missing")).resolves.toBeNull(); }); it("resolves explicit command paths but rejects directories", async () => { const dir = await mkdtemp(path.join(tmpdir(), "pi-web-explicit-path-")); const name = `fake-tool-${randomUUID()}`; const executable = await makeFakeTool(dir, name); const resolved = await resolveCommand(path.join(dir, name)); if (!resolved) throw new Error("Explicit command fixture was not resolved"); // PATHEXT casing is not filesystem identity on Windows. expect(await realpath(resolved)).toBe(await realpath(executable)); expect(await resolveCommand(dir)).toBeNull(); }); it("runs PATH-resolved tools without a shell-specific lookup utility", async () => { const dir = await mkdtemp(path.join(tmpdir(), "pi web path ")); const name = `fake-tool-${randomUUID()}`; await makeFakeTool(dir, name); process.env["PATH"] = dir; await expect(spawnToText(name, ["arg with spaces"])).resolves.toBe(""); }); });