Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/notes/__tests__/settings.test.ts

Raw
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
	createPiSession,
	type PiSession,
} from "../../../test/fixture/create-pi-session.ts";

const extensionPath = fileURLToPath(new URL("../index.ts", import.meta.url));
const DEFAULTS = ["alt+n", "ctrl+enter", "ctrl+alt+enter"];

type UICall = { method: string; args: unknown[] };

function buildRecordingUI(calls: UICall[]): ExtensionUIContext {
	const record = (method: string, args: unknown[]): void => {
		calls.push({ method, args });
	};
	const ui = {
		async select(title: string, options: string[]) {
			record("select", [title, options]);
			return options[0];
		},
		async confirm(title: string, message: string) {
			record("confirm", [title, message]);
			return true;
		},
		async input(title: string, placeholder?: string) {
			record("input", [title, placeholder]);
			return "";
		},
		async editor(title: string, prefill?: string) {
			record("editor", [title, prefill]);
			return "";
		},
		notify(message: string, type?: string) {
			record("notify", [message, type]);
		},
		onTerminalInput() {
			return () => {};
		},
		setStatus(key: string, text: string) {
			record("setStatus", [key, text]);
		},
		setWorkingMessage(message: string) {
			record("setWorkingMessage", [message]);
		},
		setWidget(key: string, content: unknown) {
			record("setWidget", [key, content]);
		},
		setFooter(...args: unknown[]) {
			record("setFooter", args);
		},
		setHeader(...args: unknown[]) {
			record("setHeader", args);
		},
		setTitle(title: string) {
			record("setTitle", [title]);
		},
		async custom() {
			return undefined;
		},
		pasteToEditor(...args: unknown[]) {
			record("pasteToEditor", args);
		},
		setEditorText(...args: unknown[]) {
			record("setEditorText", args);
		},
		getEditorText() {
			return "";
		},
		setEditorComponent(...args: unknown[]) {
			record("setEditorComponent", args);
		},
		get theme() {
			return {
				fg: (_color: unknown, text: string) => text,
				bold: (text: string) => text,
				italic: (text: string) => text,
				strikethrough: (text: string) => text,
			};
		},
		getAllThemes() {
			return [];
		},
		getTheme() {
			return undefined;
		},
		setTheme() {
			return { success: false, error: "Test mode" };
		},
		getToolsExpanded() {
			return false;
		},
		setToolsExpanded() {},
	};
	return ui as unknown as ExtensionUIContext;
}

type Case = {
	user?: unknown;
	project?: unknown;
	env?: Record<string, string>;
};

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

	async function start(input: Case) {
		root = mkdtempSync(path.join(tmpdir(), "pi-ext-notes-settings-"));
		if ("project" in input) {
			const dir = path.join(root, ".pi");
			mkdirSync(dir, { recursive: true });
			writeFileSync(
				path.join(dir, "settings.json"),
				JSON.stringify({ notes: input.project }),
			);
		}
		// Shortcuts load before any session, from process.cwd().
		vi.spyOn(process, "cwd").mockReturnValue(root);
		session = await createPiSession({
			cwd: root,
			extensionPaths: [extensionPath],
			...("user" in input ? { userSettings: { notes: input.user } } : {}),
			...(input.env ? { env: input.env } : {}),
		});
		const extension = session.extensions[0];
		if (!extension) throw new Error("missing extension");
		const calls: UICall[] = [];
		session.session.extensionRunner.setUIContext(
			buildRecordingUI(calls),
			"print",
		);
		const before = calls.filter((call) => call.method === "notify").length;
		await session.session.extensionRunner.emit({
			type: "session_start",
			reason: "startup",
		});
		const warnings = calls
			.filter((call) => call.method === "notify")
			.slice(before)
			.filter((call) => call.args[1] === "warning")
			.map((call) => call.args[0]);
		return { shortcuts: [...extension.shortcuts.keys()], warnings };
	}

	it.each([
		["default", {}, DEFAULTS],
		[
			"user",
			{
				user: {
					toggleShortcut: "alt+j",
					addFromEditorShortcut: "alt+k",
					sendPickerShortcut: "alt+l",
				},
			},
			["alt+j", "alt+k", "alt+l"],
		],
		[
			"env over user",
			{
				user: { toggleShortcut: "alt+j" },
				env: {
					PI_NOTES_TOGGLE_SHORTCUT: "alt+x",
					PI_NOTES_ADD_FROM_EDITOR_SHORTCUT: "alt+y",
					PI_NOTES_SEND_PICKER_SHORTCUT: "alt+z",
				},
			},
			["alt+x", "alt+y", "alt+z"],
		],
		[
			"trusted project ignored",
			{
				user: { toggleShortcut: "alt+j" },
				project: { toggleShortcut: "alt+p", sendPickerShortcut: "alt+q" },
			},
			["alt+j", "ctrl+enter", "ctrl+alt+enter"],
		],
		[
			"swapped shortcuts stay unique",
			{
				user: {
					toggleShortcut: "ctrl+enter",
					addFromEditorShortcut: "alt+n",
				},
			},
			["ctrl+enter", "alt+n", "ctrl+alt+enter"],
		],
	] satisfies [string, Case, string[]][])(
		"%s",
		async (_name, input, expected) => {
			const { shortcuts, warnings } = await start(input);
			expect(shortcuts).toEqual(expected);
			expect(warnings).toEqual([]);
		},
	);

	it.each([
		[
			"wrong type",
			{ user: { toggleShortcut: 1 } },
			[
				"Invalid notes.toggleShortcut from user settings: must be a non-empty string",
			],
		],
		[
			"blank user value",
			{ user: { sendPickerShortcut: " " } },
			[
				"Invalid notes.sendPickerShortcut from user settings: must be a non-empty string",
			],
		],
		[
			"empty env does not fall through to user",
			{
				user: { addFromEditorShortcut: "alt+k" },
				env: { PI_NOTES_ADD_FROM_EDITOR_SHORTCUT: "" },
			},
			[
				"Invalid notes.addFromEditorShortcut from environment variable PI_NOTES_ADD_FROM_EDITOR_SHORTCUT: must be a non-empty string",
			],
		],
		[
			"duplicates",
			{ user: { toggleShortcut: "alt+j", sendPickerShortcut: "alt+j" } },
			["Duplicate notes shortcuts: alt+j, ctrl+enter, alt+j"],
		],
	] satisfies [string, Case, string[]][])(
		"%s warns and keeps defaults",
		async (_name, input, expected) => {
			const { shortcuts, warnings } = await start(input);
			expect(shortcuts).toEqual(DEFAULTS);
			expect(warnings).toEqual(expected);
		},
	);
});