Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

scripts/codegen/pi-ext-settings.test.ts

Raw
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, it } from "vitest";
import { sandboxEnv, withProcessEnv } from "../../test/harness";
import {
	parseNumberSetting,
	resolveSetting,
	type SettingDeclaration,
} from "./templates/pi-ext-settings.ts";

let root: string | undefined;
afterEach(() => {
	if (root) rmSync(root, { recursive: true, force: true });
	root = undefined;
});

function resolve<T>(
	declaration: SettingDeclaration<T>,
	input: {
		user?: unknown;
		project?: unknown;
		env?: Record<string, string>;
		flags?: Record<string, string | boolean>;
		trusted?: boolean;
	},
) {
	root = mkdtempSync(join(tmpdir(), "pi-ext-settings-"));
	const env = sandboxEnv(root, { env: input.env });
	const write = (dir: string, value: unknown) => {
		mkdirSync(dir, { recursive: true });
		writeFileSync(join(dir, "settings.json"), JSON.stringify(value));
	};
	if (input.user !== undefined)
		write(join(env.HOME ?? "", ".pi/agent"), input.user);
	if (input.project !== undefined) write(join(root, ".pi"), input.project);
	const cwd = root;
	return withProcessEnv(env, () =>
		resolveSetting(
			{ getFlag: (name) => input.flags?.[name] },
			{ cwd, isProjectTrusted: () => input.trusted ?? true },
			declaration,
		),
	);
}

const block: SettingDeclaration<Record<string, unknown>> = {
	key: "demo",
	parse: (raw) => {
		if (typeof raw !== "object" || raw === null || Array.isArray(raw))
			return undefined;
		const value = raw as Record<string, unknown>;
		if ("bad" in value) throw new Error("demo.bad is not allowed");
		return value;
	},
	default: {},
};

it("deep-merges objects project over user, replacing arrays and scalars", async () => {
	const result = await resolve(block, {
		user: {
			demo: { a: 1, list: [1, 2], nested: { keep: true, over: "user" } },
		},
		project: { demo: { list: [3], nested: { over: "project" } } },
	});
	expect(result).toEqual({
		ok: true,
		source: "project",
		value: {
			a: 1,
			list: [3],
			nested: { keep: true, over: "project" },
		},
	});
});

it("keeps __proto__ keys as own properties when merging", async () => {
	root = mkdtempSync(join(tmpdir(), "pi-ext-settings-"));
	const env = sandboxEnv(root);
	const agent = join(env.HOME ?? "", ".pi/agent");
	mkdirSync(agent, { recursive: true });
	mkdirSync(join(root, ".pi"), { recursive: true });
	writeFileSync(join(agent, "settings.json"), '{"demo":{"a":1}}');
	writeFileSync(
		join(root, ".pi", "settings.json"),
		'{"demo":{"__proto__":{"polluted":true}}}',
	);
	const cwd = root;
	const result = await withProcessEnv(env, () =>
		resolveSetting(
			{ getFlag: () => undefined },
			{ cwd, isProjectTrusted: () => true },
			block,
		),
	);
	if (!result.ok) throw new Error(result.error);
	expect(Object.hasOwn(result.value, "__proto__")).toBe(true);
	expect(Object.getPrototypeOf(result.value)).toBe(Object.prototype);
	expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});

it("parses numbers from settings and numeric strings from env or flags only", () => {
	expect(parseNumberSetting(70, "user")).toBe(70);
	expect(parseNumberSetting("70", "env")).toBe(70);
	expect(parseNumberSetting("2.5", "flag")).toBe(2.5);
	expect(parseNumberSetting("70", "project")).toBeUndefined();
	for (const raw of ["", " ", "abc", "Infinity", Number.NaN, true, null])
		expect(parseNumberSetting(raw, "env"), String(raw)).toBeUndefined();
});

it("ignores untrusted project objects", async () => {
	const result = await resolve(block, {
		user: { demo: { a: 1 } },
		project: { demo: { a: 2 } },
		trusted: false,
	});
	expect(result).toEqual({ ok: true, source: "user", value: { a: 1 } });
});

it("reports thrown parser messages with the merged source", async () => {
	const result = await resolve(block, {
		user: { demo: { a: 1 } },
		project: { demo: { bad: true } },
	});
	expect(result).toEqual({
		ok: false,
		source: "project",
		error:
			"Invalid demo from merged project and user settings: demo.bad is not allowed",
	});
});

it("keeps a non-object project value from falling through or merging", async () => {
	const result = await resolve(block, {
		user: { demo: { a: 1 } },
		project: { demo: "oops" },
	});
	expect(result).toEqual({
		ok: false,
		source: "project",
		error: "Invalid demo from project settings",
	});
});

it("prefers flag, then env, over settings", async () => {
	const text: SettingDeclaration<string> = {
		key: "demo.name",
		parse: (raw) => (typeof raw === "string" ? raw : undefined),
		default: "default",
		flag: { name: "demo-name", type: "string", description: "demo" },
		env: "PI_DEMO_NAME",
	};
	const settings = { user: { demo: { name: "user" } } };
	expect(
		await resolve(text, {
			...settings,
			env: { PI_DEMO_NAME: "env" },
			flags: { "demo-name": "flag" },
		}),
	).toEqual({ ok: true, source: "flag", value: "flag" });
	expect(
		await resolve(text, { ...settings, env: { PI_DEMO_NAME: "env" } }),
	).toEqual({ ok: true, source: "env", value: "env" });
	expect(await resolve(text, {})).toEqual({
		ok: true,
		source: "default",
		value: "default",
	});
});