import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os, { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createPiSession, type PiSession, } from "../../../test/fixture/create-pi-session.ts"; import { getHostSection, type HostSnapshot, renderHostSection, } from "../host.ts"; import { EMPTY_HORST_SETTINGS, parseHorst, resolveHorstTools, } from "../settings.ts"; const horstPath = fileURLToPath(new URL("../index.ts", import.meta.url)); describe("parseHorst", () => { it("keeps well-formed tool deltas and lowercases keys", () => { const settings = parseHorst({ preferredShell: " nu ", os: { WIN32: { tools: ["+powershell", "-bash"] } }, hosts: { WinBox: { tools: ["-write"] } }, }); expect(settings).toEqual({ os: { win32: ["+powershell", "-bash"] }, hosts: { winbox: ["-write"] }, preferredShell: "nu", }); expect(parseHorst({})).toEqual(EMPTY_HORST_SETTINGS); expect(parseHorst({ os: { lnx: { tools: [] } } })).toEqual({ os: { lnx: [] }, hosts: {}, }); }); it.each([ [undefined, "expected an object"], [[], "expected an object"], [{ preferredShell: " " }, "preferredShell must be a non-empty string"], [{ preferredShell: 42 }, "preferredShell must be a non-empty string"], [{ hosts: "nope" }, "hosts must be an object"], [{ os: { lnx: { flat: {} } } }, "os.lnx.tools must be an array"], [ { os: { lnx: { tools: { add: ["grep"], remove: ["bash"] } } } }, 'use ["+name", "-name"] instead of { add, remove }', ], [{ hosts: { a: { tools: "-read" } } }, "hosts.a.tools must be an array"], ...[42, "read", "+", "-", "+read*", "+ read", "-read "].map( (tool): [unknown, string] => [ { os: { lnx: { tools: [tool] } } }, "os.lnx.tools must contain only +name/-name modifiers", ], ), ] satisfies [unknown, string][])("rejects %j", (raw, message) => { expect(() => parseHorst(raw)).toThrow(message); }); }); describe("resolveHorstTools", () => { it.each([ ["linux", "lnx"], ["darwin", "mac"], ["win32", "win32"], ])("maps %s to %s", (platform, key) => { expect( resolveHorstTools({ os: { [key]: ["+grep"] }, hosts: {} }, platform, "x"), ).toEqual(["+grep"]); }); it("matches hostnames case-insensitively and appends host modifiers after os", () => { const settings = { os: { win32: ["+powershell", "-bash"] }, hosts: { winbox: ["-powershell", "+bash", "-write"] }, }; expect(resolveHorstTools(settings, "win32", "WinBox")).toEqual([ "+powershell", "-bash", "-powershell", "+bash", "-write", ]); }); it("returns undefined without a match", () => { expect( resolveHorstTools(EMPTY_HORST_SETTINGS, "linux", "x"), ).toBeUndefined(); }); }); describe("renderHostSection", () => { it("renders host facts without a misleading environment shell or editor", () => { const host: HostSnapshot = { hostname: "winbox", osLabel: "Fedora Linux 42", kernel: "6.14.0", arch: "x64", cpuModel: "AMD Ryzen 9", cpuCount: 24, memGiB: 32, }; const section = renderHostSection(host); expect(section).toContain("hostname: winbox"); expect(section).toContain("os: Fedora Linux 42"); expect(section).toContain("cpu: AMD Ryzen 9 × 24"); expect(section).toContain("mem: 32 GiB"); expect(section).not.toContain("Stable host environment facts:"); expect(section).not.toContain("shell:"); expect(section).not.toContain("editor:"); expect( renderHostSection({ ...host, loginShell: "/bin/bash" }, "nu"), ).toContain("login shell: /bin/bash\npreferred shell: nu"); }); it("reads account login shell independently of inherited SHELL", async () => { vi.resetModules(); const userInfo = vi.spyOn(os, "userInfo").mockReturnValue({ ...os.userInfo(), shell: "/bin/zsh", }); try { const { getHostSection: freshSection } = await import("../host.ts"); expect(await freshSection()).toContain("login shell: /bin/zsh"); expect(await freshSection()).not.toContain("preferred shell:"); } finally { userInfo.mockRestore(); } }); it("omits unavailable account shell", async () => { for (const shell of [null, "", "throws"] as const) { vi.resetModules(); const userInfo = vi.spyOn(os, "userInfo"); if (shell === "throws") userInfo.mockImplementation(() => { throw new Error("unavailable"); }); else userInfo.mockReturnValue({ ...os.userInfo(), shell }); try { const { getHostSection: freshSection } = await import("../host.ts"); expect(await freshSection()).not.toContain("login shell:"); } finally { userInfo.mockRestore(); } } }); }); describe("horst owned fixture", () => { let session: PiSession | undefined; afterEach(async () => { await session?.dispose(); session = undefined; }); it("loads in real Pi extension runtime", async () => { session = await createPiSession({ extensionPaths: [horstPath] }); expect(session.extensions.map((extension) => extension.path)).toContain( horstPath, ); }); it("applies the host tool delta from project settings", async () => { const root = mkdtempSync(path.join(tmpdir(), "horst-delta-")); try { mkdirSync(path.join(root, ".pi"), { recursive: true }); writeFileSync( path.join(root, ".pi", "settings.json"), JSON.stringify({ horst: { hosts: { [os.hostname().toLowerCase()]: { tools: ["+grep", "-grep", "+find", "+find", "-write"], }, }, }, }), ); session = await createPiSession({ cwd: root, extensionPaths: [horstPath], }); await session.session.extensionRunner.emit({ type: "session_start", reason: "startup", }); const active = session.session.getActiveToolNames(); expect(active).not.toContain("grep"); expect(active.filter((name) => name === "find")).toHaveLength(1); expect(active).not.toContain("write"); } finally { await session?.dispose(); session = undefined; rmSync(root, { recursive: true, force: true }); } }); it("injects the stable host section each turn", async () => { session = await createPiSession({ extensionPaths: [horstPath] }); const runner = session.session.extensionRunner; const first = await runner.emitBeforeAgentStart( { messageType: "user", prompt: "prompt" }, { cwd: session.cwd, }, ); const second = await runner.emitBeforeAgentStart( { messageType: "user", prompt: "prompt" }, { cwd: session.cwd, }, ); const { horst } = first.systemPromptOptions.sections; expect(horst).toBe(second.systemPromptOptions.sections["horst"]); expect(horst).toContain(`hostname: ${os.hostname()}`); await expect(getHostSection()).resolves.toBe(horst); }); it("injects project shell preference even without a tool delta", async () => { const root = mkdtempSync(path.join(tmpdir(), "horst-shell-")); try { mkdirSync(path.join(root, ".pi"), { recursive: true }); writeFileSync( path.join(root, ".pi", "settings.json"), JSON.stringify({ horst: { preferredShell: " fish " } }), ); session = await createPiSession({ cwd: root, extensionPaths: [horstPath], }); const runner = session.session.extensionRunner; await runner.emit({ type: "session_start", reason: "startup" }); const first = await runner.emitBeforeAgentStart( { messageType: "user", prompt: "prompt" }, { cwd: session.cwd, }, ); const second = await runner.emitBeforeAgentStart( { messageType: "user", prompt: "prompt" }, { cwd: session.cwd, }, ); expect(first.systemPromptOptions.sections["horst"]).toContain( "preferred shell: fish", ); expect(second.systemPromptOptions.sections["horst"]).toBe( first.systemPromptOptions.sections["horst"], ); } finally { await session?.dispose(); session = undefined; rmSync(root, { recursive: true, force: true }); } }); });