repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/notes/__tests__/settings.test.ts
Rawimport { 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);
},
);
});