Files
my-pi/pi-permission-system/test/config-modal.test.ts

322 lines
9.4 KiB
TypeScript

import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test, vi } from "vitest";
import { loadUnifiedConfig } from "#src/config-loader";
import { registerPermissionSystemCommand } from "#src/config-modal";
import type { CommandConfigStore } from "#src/config-store";
import {
DEFAULT_EXTENSION_CONFIG,
normalizePermissionSystemConfig,
type PermissionSystemExtensionConfig,
} from "#src/extension-config";
import type { Rule, Ruleset } from "#src/rule";
vi.mock("@earendil-works/pi-coding-agent", () => ({
getSettingsListTheme: () => ({}),
}));
vi.mock("@earendil-works/pi-tui", () => ({
SettingsList: class {
handleInput(): void {}
updateValue(): void {}
render(): string[] {
return [];
}
invalidate(): void {}
},
}));
type Notification = { message: string; level: "info" | "warning" | "error" };
type CommandContextStub = {
hasUI: boolean;
ui: {
notify(message: string, level: "info" | "warning" | "error"): void;
custom<T>(
renderer: (...args: unknown[]) => unknown,
options?: unknown,
): Promise<T>;
};
};
function createCommandContext(hasUI: boolean): {
ctx: CommandContextStub;
notifications: Notification[];
getCustomCalls(): number;
} {
const notifications: Notification[] = [];
let customCalls = 0;
return {
ctx: {
hasUI,
ui: {
notify(message: string, level: "info" | "warning" | "error") {
notifications.push({ message, level });
},
async custom<T>(
_renderer: (...args: unknown[]) => unknown,
_options?: unknown,
): Promise<T> {
customCalls += 1;
return undefined as T;
},
},
},
notifications,
getCustomCalls: () => customCalls,
};
}
function lastNotification(notifications: Notification[]): Notification {
return notifications[notifications.length - 1];
}
test("permission-system command completions expose top-level config actions", () => {
const baseDir = mkdtempSync(
join(tmpdir(), "pi-permission-system-command-completions-"),
);
const configPath = join(baseDir, "config.json");
let config: PermissionSystemExtensionConfig = { ...DEFAULT_EXTENSION_CONFIG };
try {
const configStore: CommandConfigStore = {
current: () => config,
save: (next) => {
config = next;
},
};
const controller = {
config: configStore,
configPath,
getActiveAgentConfigRules: () => [] as Ruleset,
};
let definition: {
description: string;
getArgumentCompletions?: (
argumentPrefix: string,
) => Array<{ value: string; label: string; description?: string }> | null;
handler: (args: string, ctx: CommandContextStub) => Promise<void>;
} | null = null;
registerPermissionSystemCommand(
{
registerCommand(_name: string, nextDefinition: typeof definition) {
definition = nextDefinition;
},
} as never,
controller,
);
expect(definition!.getArgumentCompletions).toBeTypeOf("function");
const topLevel = definition!.getArgumentCompletions?.("");
expect(Array.isArray(topLevel)).toBeTruthy();
expect(topLevel?.some((item) => item.value === "show")).toBeTruthy();
expect(topLevel?.some((item) => item.value === "reset")).toBeTruthy();
const filtered = definition!.getArgumentCompletions?.("pa");
expect(filtered?.map((item) => item.value)).toEqual(["path"]);
expect(definition!.getArgumentCompletions?.("path extra")).toBe(null);
expect(definition!.getArgumentCompletions?.("zzz")).toBe(null);
} finally {
rmSync(baseDir, { recursive: true, force: true });
}
});
test("permission-system command handlers manage config summary, persistence, and modal routing", async () => {
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-command-"));
const configPath = join(baseDir, "config.json");
let config: PermissionSystemExtensionConfig = {
debugLog: true,
permissionReviewLog: false,
yoloMode: true,
doublePressToConfirm: true,
};
try {
writeFileSync(
configPath,
`${JSON.stringify(normalizePermissionSystemConfig(config), null, 2)}\n`,
"utf-8",
);
const configStore: CommandConfigStore = {
current: () => config,
save: (next) => {
const currentConfig = normalizePermissionSystemConfig(
loadUnifiedConfig(configPath).config,
);
const normalized = normalizePermissionSystemConfig(next);
writeFileSync(
configPath,
`${JSON.stringify(normalized, null, 2)}\n`,
"utf-8",
);
config = normalizePermissionSystemConfig(
loadUnifiedConfig(configPath).config,
);
expect(config).not.toEqual(currentConfig);
},
};
const controller = {
config: configStore,
configPath,
getActiveAgentConfigRules: () => [] as Ruleset,
};
let registeredName = "";
let definition: {
description: string;
getArgumentCompletions?: (
argumentPrefix: string,
) => Array<{ value: string; label: string; description?: string }> | null;
handler: (args: string, ctx: CommandContextStub) => Promise<void>;
} | null = null;
registerPermissionSystemCommand(
{
registerCommand(name: string, nextDefinition: typeof definition) {
registeredName = name;
definition = nextDefinition;
},
} as never,
controller,
);
expect(registeredName).toBe("permission-system");
expect(definition!.description).toContain("Configure pi-permission-system");
const infoCtx = createCommandContext(true);
await definition!.handler("show", infoCtx.ctx);
expect(lastNotification(infoCtx.notifications).message).toContain(
"yoloMode=on",
);
expect(lastNotification(infoCtx.notifications).message).toContain(
"debugLog=on",
);
await definition!.handler("path", infoCtx.ctx);
expect(lastNotification(infoCtx.notifications).message).toBe(
`permission-system config: ${configPath}`,
);
await definition!.handler("help", infoCtx.ctx);
expect(lastNotification(infoCtx.notifications).message).toContain(
"Usage: /permission-system",
);
await definition!.handler("reset", infoCtx.ctx);
expect(config).toEqual(DEFAULT_EXTENSION_CONFIG);
expect(lastNotification(infoCtx.notifications).message).toBe(
"Permission system settings reset to defaults.",
);
const persisted = JSON.parse(readFileSync(configPath, "utf8")) as Record<
string,
unknown
>;
expect(persisted).toEqual(DEFAULT_EXTENSION_CONFIG);
await definition!.handler("unknown", infoCtx.ctx);
expect(lastNotification(infoCtx.notifications).level).toBe("warning");
expect(lastNotification(infoCtx.notifications).message).toContain(
"Usage: /permission-system",
);
const headlessCtx = createCommandContext(false);
await definition!.handler("", headlessCtx.ctx);
expect(lastNotification(headlessCtx.notifications).message).toBe(
"/permission-system requires interactive TUI mode.",
);
const modalCtx = createCommandContext(true);
await definition!.handler("", modalCtx.ctx);
expect(modalCtx.getCustomCalls()).toBe(1);
} finally {
rmSync(baseDir, { recursive: true, force: true });
}
});
test("show output includes rule origins when getComposedRules is provided", async () => {
const config = { ...DEFAULT_EXTENSION_CONFIG };
const composedRules: Rule[] = [
{
surface: "read",
pattern: "*",
action: "allow",
layer: "config",
origin: "global",
},
{
surface: "bash",
pattern: "rm *",
action: "deny",
layer: "config",
origin: "project",
},
];
const controller = {
config: { current: () => config, save: () => {} } as CommandConfigStore,
configPath: "/fake/config.json",
getActiveAgentConfigRules: () => composedRules,
};
let definition: {
handler: (args: string, ctx: CommandContextStub) => Promise<void>;
} | null = null;
registerPermissionSystemCommand(
{
registerCommand(_name: string, nextDef: typeof definition) {
definition = nextDef;
},
} as never,
controller,
);
const ctx = createCommandContext(true);
await definition!.handler("show", ctx.ctx);
const msg = lastNotification(ctx.notifications).message;
expect(msg).toContain("global");
expect(msg).toContain("project");
expect(msg).toContain("read");
expect(msg).toContain("bash");
});
test("show output omits rule summary when getComposedRules is not provided", async () => {
const config = { ...DEFAULT_EXTENSION_CONFIG, yoloMode: true };
const controller = {
config: { current: () => config, save: () => {} } as CommandConfigStore,
configPath: "/fake/config.json",
getActiveAgentConfigRules: () => [] as Ruleset,
};
let definition: {
handler: (args: string, ctx: CommandContextStub) => Promise<void>;
} | null = null;
registerPermissionSystemCommand(
{
registerCommand(_name: string, nextDef: typeof definition) {
definition = nextDef;
},
} as never,
controller,
);
const ctx = createCommandContext(true);
await definition!.handler("show", ctx.ctx);
const msg = lastNotification(ctx.notifications).message;
// Config knobs still present.
expect(msg).toContain("yoloMode=on");
// No rule annotation lines.
expect(msg).not.toContain("(global)");
});