import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os, { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createTestSession, type TestSession } from "../../../test/harness"; import { getHostSection, type HostSnapshot, renderHostSection, } from "../host.ts"; import { EMPTY_HORST_SETTINGS, parseHorst, resolveHorstTools, } from "../settings.ts"; const horstPath = () => path.resolve(import.meta.dirname, "../index.ts"); describe("parseHorst", () => { it("keeps well-formed tool deltas and lowercases keys", () => { const settings = parseHorst({ preferredShell: " nu ", os: { WIN32: { tools: { add: ["powershell"], remove: ["bash"] } } }, hosts: { WinBox: { tools: { remove: ["write"] } } }, }); expect(settings).toEqual({ os: { win32: { add: ["powershell"], remove: ["bash"] } }, hosts: { winbox: { remove: ["write"] } }, preferredShell: "nu", }); expect(parseHorst({})).toEqual(EMPTY_HORST_SETTINGS); expect(parseHorst({ os: { lnx: { tools: { add: [] } } } })).toEqual( EMPTY_HORST_SETTINGS, ); }); 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 object"], [ { os: { lnx: { tools: { add: [42] } } } }, "os.lnx.tools.add must be an array of non-empty strings", ], [ { hosts: { a: { tools: { remove: "read" } } } }, "hosts.a.tools.remove must be an array of non-empty strings", ], ])("rejects %j", (raw, message) => { expect(() => parseHorst(raw)).toThrow(message); }); }); describe("resolveHorstTools", () => { it("maps platform names to os keys", () => { expect( resolveHorstTools( { os: { lnx: { add: ["grep"] } }, hosts: {} }, "linux", "x", ), ).toEqual({ add: ["grep"] }); expect( resolveHorstTools( { os: { mac: { add: ["grep"] } }, hosts: {} }, "darwin", "x", ), ).toEqual({ add: ["grep"] }); expect( resolveHorstTools( { os: { win32: { add: ["powershell"] } }, hosts: {} }, "win32", "x", ), ).toEqual({ add: ["powershell"] }); }); it("matches hostnames case-insensitively and wins per field over os", () => { const settings = { os: { win32: { add: ["powershell"], remove: ["bash"] } }, hosts: { winbox: { remove: ["write"] } }, }; expect(resolveHorstTools(settings, "win32", "WinBox")).toEqual({ add: ["powershell"], remove: ["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 pi-test-harness", () => { let t: TestSession | undefined; afterEach(() => { t?.dispose(); t = undefined; }); it("loads in real Pi extension runtime", async () => { t = await createTestSession({ extensions: [horstPath()] }); expect( t.session.extensionRunner.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: { add: ["grep", "find"], remove: ["write"] }, }, }, }, }), ); t = await createTestSession({ cwd: root, extensions: [horstPath()] }); const active = t.session.getActiveToolNames(); expect(active).toContain("grep"); expect(active).toContain("find"); expect(active).not.toContain("write"); } finally { t?.dispose(); t = undefined; rmSync(root, { recursive: true, force: true }); } }); it("injects the stable host section each turn", async () => { t = await createTestSession({ extensions: [horstPath()] }); const runner = t.session.extensionRunner; const first = await runner.emitBeforeAgentStart("prompt", undefined, { cwd: t.cwd, }); const second = await runner.emitBeforeAgentStart("prompt", undefined, { cwd: t.cwd, }); const { horst } = first.systemPromptOptions.sections; expect(horst).toBe(second.systemPromptOptions.sections.horst); expect(horst).toContain(`hostname: ${os.hostname()}`); 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 " } }), ); t = await createTestSession({ cwd: root, extensions: [horstPath()] }); const runner = t.session.extensionRunner; const first = await runner.emitBeforeAgentStart("prompt", undefined, { cwd: t.cwd, }); const second = await runner.emitBeforeAgentStart("prompt", undefined, { cwd: t.cwd, }); expect(first.systemPromptOptions.sections.horst).toContain( "preferred shell: fish", ); expect(second.systemPromptOptions.sections.horst).toBe( first.systemPromptOptions.sections.horst, ); } finally { t?.dispose(); t = undefined; rmSync(root, { recursive: true, force: true }); } }); });