Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/horst/__tests__/settings.test.ts

Raw
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os, { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
	createPiSession,
	type PiSession,
} from "../../../test/fixture/create-pi-session.ts";

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

interface UiCall {
	args: unknown[];
}

/**
 * Install a recording UI context so `ctx.ui.notify` calls are captured.
 */
function installRecordingNotify(session: PiSession): UiCall[] {
	const notifyCalls: UiCall[] = [];
	session.session.extensionRunner.setUIContext(
		{
			setStatus: () => {},
			notify: (...args: unknown[]) => notifyCalls.push({ args }),
		} as never,
		"print",
	);
	return notifyCalls;
}

type Case = { user?: unknown; project?: unknown; untrusted?: boolean };

describe("horst settings resolution", () => {
	let session: PiSession | undefined;
	let root: string | undefined;
	afterEach(async () => {
		await session?.dispose();
		session = undefined;
		if (root) rmSync(root, { recursive: true, force: true });
		root = undefined;
	});

	async function start(input: Case) {
		root = mkdtempSync(path.join(tmpdir(), "pi-ext-horst-settings-"));
		if ("project" in input) {
			mkdirSync(path.join(root, ".pi"), { recursive: true });
			writeFileSync(
				path.join(root, ".pi", "settings.json"),
				JSON.stringify({ horst: input.project }),
			);
		}
		session = await createPiSession({
			cwd: root,
			extensionPaths: [extensionPath],
			...("user" in input ? { userSettings: { horst: input.user } } : {}),
		});
		if (input.untrusted)
			session.session.settingsManager.setProjectTrusted(false);
		const notifyCalls = installRecordingNotify(session);
		const runner = session.session.extensionRunner;
		await runner.emit({ type: "session_start", reason: "startup" });
		const { horst } = (
			await runner.emitBeforeAgentStart(
				{ messageType: "user", prompt: "prompt" },
				{
					cwd: session.cwd,
				},
			)
		).systemPromptOptions.sections;
		const warnings = notifyCalls
			.filter((call) => call.args[1] === "warning")
			.map((call) => call.args[0]);
		return {
			shell: /preferred shell: (.*)/.exec(horst ?? "")?.[1],
			active: session.session.getActiveToolNames(),
			warnings: [...new Set(warnings)],
		};
	}

	it.each([
		["default", {}, undefined],
		["user", { user: { preferredShell: "nu" } }, "nu"],
		[
			"project over user",
			{ user: { preferredShell: "nu" }, project: { preferredShell: "fish" } },
			"fish",
		],
		[
			"user kept when project omits key",
			{ user: { preferredShell: "nu" }, project: { hosts: {} } },
			"nu",
		],
		[
			"untrusted project ignored",
			{
				user: { preferredShell: "nu" },
				project: { preferredShell: "fish" },
				untrusted: true,
			},
			"nu",
		],
		[
			"untrusted project ignored without user",
			{ project: { preferredShell: "fish" }, untrusted: true },
			undefined,
		],
	] satisfies [string, Case, string | undefined][])(
		"preferredShell: %s",
		async (_name, input, expected) => {
			const { shell, warnings } = await start(input);
			expect(shell).toBe(expected);
			expect(warnings).toEqual([]);
		},
	);

	it("composes user OS and project host modifiers in order", async () => {
		const osKey = { linux: "lnx", darwin: "mac", win32: "win32" }[
			process.platform as "linux" | "darwin" | "win32"
		];
		const { active, warnings } = await start({
			user: { os: { [osKey]: { tools: ["+grep", "+find", "-write"] } } },
			project: { hosts: { [host]: { tools: ["-grep", "+write"] } } },
		});
		expect(active).not.toContain("grep");
		expect(active).toContain("find");
		expect(active).toContain("write");
		expect(warnings).toEqual([]);
	});

	it("replaces tool arrays instead of merging them", async () => {
		const { active } = await start({
			user: { hosts: { [host]: { tools: ["+grep"] } } },
			project: { hosts: { [host]: { tools: ["+find"] } } },
		});
		expect(active).toContain("find");
		expect(active).not.toContain("grep");
	});

	it("allows an empty project array to clear inherited host modifiers", async () => {
		const { active, warnings } = await start({
			user: { hosts: { [host]: { tools: ["-write"] } } },
			project: { hosts: { [host]: { tools: [] } } },
		});
		expect(active).toContain("write");
		expect(warnings).toEqual([]);
	});

	it("ignores unknown names with one warning per tool", async () => {
		const { active, warnings } = await start({
			user: { hosts: { [host]: { tools: ["+absent", "-absent", "+grep"] } } },
		});
		expect(active).toContain("grep");
		expect(active).not.toContain("absent");
		expect(warnings).toEqual(["horst: unknown tools: absent"]);
	});

	it.each([
		[
			"invalid project",
			{
				user: {
					preferredShell: "nu",
					hosts: { [host]: { tools: ["+grep"] } },
				},
				project: { preferredShell: 42 },
			},
			"horst: Invalid horst from merged project and user settings: preferredShell must be a non-empty string; settings ignored",
		],
		[
			"non-object project",
			{ user: { preferredShell: "nu" }, project: "nu" },
			"horst: Invalid horst from project settings: expected an object; settings ignored",
		],
		[
			"invalid user",
			{ user: { hosts: { [host]: { tools: ["grep"] } } } },
			`horst: Invalid horst from user settings: hosts.${host}.tools must contain only +name/-name modifiers with non-empty exact tool names, not patterns; settings ignored`,
		],
	] satisfies [string, Case, string][])(
		"rejects %s without falling through",
		async (_name, input, warning) => {
			const { shell, active, warnings } = await start(input);
			expect(shell).toBeUndefined();
			expect(active).not.toContain("grep");
			expect(warnings).toEqual([warning]);
		},
	);
});