mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
322 lines
9.4 KiB
TypeScript
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)");
|
|
});
|