mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
480 lines
17 KiB
TypeScript
480 lines
17 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// ── Module mocks (hoisted) ─────────────────────────────────────────────────
|
|
|
|
const {
|
|
mockLoadAndMergeConfigs,
|
|
mockLoadUnifiedConfig,
|
|
mockSyncPermissionSystemStatus,
|
|
mockBuildResolvedConfigLogEntry,
|
|
mockExistsSync,
|
|
mockMkdirSync,
|
|
mockWriteFileSync,
|
|
mockRenameSync,
|
|
mockUnlinkSync,
|
|
} = vi.hoisted(() => ({
|
|
mockLoadAndMergeConfigs: vi.fn(),
|
|
mockLoadUnifiedConfig: vi.fn(),
|
|
mockSyncPermissionSystemStatus: vi.fn(),
|
|
mockBuildResolvedConfigLogEntry: vi.fn(),
|
|
mockExistsSync: vi.fn<(path: string) => boolean>(),
|
|
mockMkdirSync: vi.fn(),
|
|
mockWriteFileSync: vi.fn(),
|
|
mockRenameSync: vi.fn(),
|
|
mockUnlinkSync: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../src/config-loader", () => ({
|
|
loadAndMergeConfigs: mockLoadAndMergeConfigs,
|
|
loadUnifiedConfig: mockLoadUnifiedConfig,
|
|
}));
|
|
|
|
vi.mock("../src/status", () => ({
|
|
syncPermissionSystemStatus: mockSyncPermissionSystemStatus,
|
|
}));
|
|
|
|
vi.mock("../src/config-reporter", () => ({
|
|
buildResolvedConfigLogEntry: mockBuildResolvedConfigLogEntry,
|
|
}));
|
|
|
|
vi.mock("node:fs", () => ({
|
|
existsSync: mockExistsSync,
|
|
mkdirSync: mockMkdirSync,
|
|
writeFileSync: mockWriteFileSync,
|
|
renameSync: mockRenameSync,
|
|
unlinkSync: mockUnlinkSync,
|
|
default: {
|
|
existsSync: mockExistsSync,
|
|
mkdirSync: mockMkdirSync,
|
|
writeFileSync: mockWriteFileSync,
|
|
renameSync: mockRenameSync,
|
|
unlinkSync: mockUnlinkSync,
|
|
},
|
|
}));
|
|
|
|
// ── Imports ────────────────────────────────────────────────────────────────
|
|
|
|
import type {
|
|
ExtensionCommandContext,
|
|
ExtensionContext,
|
|
} from "@earendil-works/pi-coding-agent";
|
|
import {
|
|
ConfigStore,
|
|
type ConfigStoreDeps,
|
|
type ResolvedPolicyPathProvider,
|
|
} from "#src/config-store";
|
|
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
import type { ResolvedPolicyPaths } from "#src/policy-loader";
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function makePolicyPathProvider(
|
|
paths?: Partial<ResolvedPolicyPaths>,
|
|
): ResolvedPolicyPathProvider {
|
|
return {
|
|
getResolvedPolicyPaths: vi.fn(
|
|
(): ResolvedPolicyPaths => ({
|
|
globalConfigPath: "/agent/config.json",
|
|
globalConfigExists: false,
|
|
projectConfigPath: null,
|
|
projectConfigExists: false,
|
|
agentsDir: "/agent/agents",
|
|
agentsDirExists: false,
|
|
projectAgentsDir: null,
|
|
projectAgentsDirExists: false,
|
|
...paths,
|
|
}),
|
|
),
|
|
};
|
|
}
|
|
|
|
function makeLogger() {
|
|
return {
|
|
debug: vi.fn<(event: string, details?: Record<string, unknown>) => void>(),
|
|
review: vi.fn<(event: string, details?: Record<string, unknown>) => void>(),
|
|
};
|
|
}
|
|
|
|
function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
|
return {
|
|
cwd: "/test/project",
|
|
hasUI: false,
|
|
ui: { notify: vi.fn(), setStatus: vi.fn() },
|
|
sessionManager: { getEntries: vi.fn(), addEntry: vi.fn() },
|
|
...overrides,
|
|
} as unknown as ExtensionContext;
|
|
}
|
|
|
|
function makeCommandCtx(
|
|
overrides: Partial<ExtensionCommandContext> = {},
|
|
): ExtensionCommandContext {
|
|
return {
|
|
cwd: "/test/project",
|
|
ui: { notify: vi.fn(), setStatus: vi.fn() },
|
|
...overrides,
|
|
} as unknown as ExtensionCommandContext;
|
|
}
|
|
|
|
function makeStore(overrides: Partial<ConfigStoreDeps> = {}): {
|
|
store: ConfigStore;
|
|
logger: ReturnType<typeof makeLogger>;
|
|
} {
|
|
const logger = makeLogger();
|
|
const deps: ConfigStoreDeps = {
|
|
agentDir: "/test/agent",
|
|
policyPaths: makePolicyPathProvider(),
|
|
logger,
|
|
...overrides,
|
|
};
|
|
return { store: new ConfigStore(deps), logger };
|
|
}
|
|
|
|
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
|
|
describe("ConfigStore", () => {
|
|
beforeEach(() => {
|
|
mockLoadAndMergeConfigs.mockReset().mockReturnValue({
|
|
merged: { ...DEFAULT_EXTENSION_CONFIG },
|
|
issues: [],
|
|
});
|
|
mockLoadUnifiedConfig.mockReset().mockReturnValue({ config: {} });
|
|
mockSyncPermissionSystemStatus.mockReset();
|
|
mockBuildResolvedConfigLogEntry
|
|
.mockReset()
|
|
.mockReturnValue({ resolved: true });
|
|
mockExistsSync.mockReset().mockReturnValue(false);
|
|
mockMkdirSync.mockReset();
|
|
mockWriteFileSync.mockReset();
|
|
mockRenameSync.mockReset();
|
|
mockUnlinkSync.mockReset();
|
|
});
|
|
|
|
// ── current() ─────────────────────────────────────────────────────────
|
|
|
|
describe("current()", () => {
|
|
it("returns DEFAULT_EXTENSION_CONFIG before any refresh", () => {
|
|
const { store } = makeStore();
|
|
expect(store.current()).toEqual(DEFAULT_EXTENSION_CONFIG);
|
|
});
|
|
});
|
|
|
|
// ── refresh() ─────────────────────────────────────────────────────────
|
|
|
|
describe("refresh()", () => {
|
|
it("uses the passed ctx cwd for loadAndMergeConfigs and includes the project scope when trusted", () => {
|
|
const { store } = makeStore();
|
|
store.refresh(makeCtx({ cwd: "/my/project" }), true);
|
|
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
|
|
"/test/agent",
|
|
"/my/project",
|
|
expect.any(String),
|
|
{ includeProjectScope: true },
|
|
);
|
|
});
|
|
|
|
it("withholds the project scope when the project is untrusted", () => {
|
|
const { store } = makeStore();
|
|
store.refresh(makeCtx({ cwd: "/my/project" }), false);
|
|
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
|
|
"/test/agent",
|
|
"/my/project",
|
|
expect.any(String),
|
|
{ includeProjectScope: false },
|
|
);
|
|
});
|
|
|
|
it("uses empty string cwd when no ctx is provided", () => {
|
|
const { store } = makeStore();
|
|
store.refresh(undefined, true);
|
|
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
|
|
"/test/agent",
|
|
"",
|
|
expect.any(String),
|
|
{ includeProjectScope: true },
|
|
);
|
|
});
|
|
|
|
it("updates current() with normalized merged result", () => {
|
|
const { store } = makeStore();
|
|
mockLoadAndMergeConfigs.mockReturnValue({
|
|
merged: { debugLog: true, permissionReviewLog: false, yoloMode: false },
|
|
issues: [],
|
|
});
|
|
store.refresh(undefined, true);
|
|
expect(store.current().debugLog).toBe(true);
|
|
expect(store.current().permissionReviewLog).toBe(false);
|
|
});
|
|
|
|
it("writes config.loaded debug log", () => {
|
|
const { store, logger } = makeStore();
|
|
store.refresh(undefined, true);
|
|
expect(logger.debug).toHaveBeenCalledWith(
|
|
"config.loaded",
|
|
expect.objectContaining({ debugLog: false }),
|
|
);
|
|
});
|
|
|
|
it("sets warning when issues are present", () => {
|
|
const { store } = makeStore();
|
|
const ctx = makeCtx({ hasUI: false });
|
|
mockLoadAndMergeConfigs.mockReturnValue({
|
|
merged: { ...DEFAULT_EXTENSION_CONFIG },
|
|
issues: ["legacy config detected"],
|
|
});
|
|
store.refresh(ctx, true);
|
|
// Verify the warning is tracked (next identical call should not re-notify)
|
|
const mockNotify = vi.fn();
|
|
const ctx2 = makeCtx({
|
|
hasUI: true,
|
|
ui: { notify: mockNotify } as never,
|
|
});
|
|
mockLoadAndMergeConfigs.mockReturnValue({
|
|
merged: { ...DEFAULT_EXTENSION_CONFIG },
|
|
issues: ["legacy config detected"],
|
|
});
|
|
store.refresh(ctx2, true);
|
|
// Same warning — should not re-notify
|
|
expect(mockNotify).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("notifies UI when a new warning appears and hasUI is true", () => {
|
|
const mockNotify = vi.fn();
|
|
const { store } = makeStore();
|
|
const ctx = makeCtx({ hasUI: true, ui: { notify: mockNotify } as never });
|
|
mockLoadAndMergeConfigs.mockReturnValue({
|
|
merged: { ...DEFAULT_EXTENSION_CONFIG },
|
|
issues: ["new warning"],
|
|
});
|
|
store.refresh(ctx, true);
|
|
expect(mockNotify).toHaveBeenCalledWith("new warning", "warning");
|
|
});
|
|
|
|
it("does not re-notify the same warning on subsequent calls", () => {
|
|
const mockNotify = vi.fn();
|
|
const { store } = makeStore();
|
|
const ctx = makeCtx({ hasUI: true, ui: { notify: mockNotify } as never });
|
|
mockLoadAndMergeConfigs.mockReturnValue({
|
|
merged: { ...DEFAULT_EXTENSION_CONFIG },
|
|
issues: ["persistent warning"],
|
|
});
|
|
store.refresh(ctx, true);
|
|
store.refresh(ctx, true);
|
|
expect(mockNotify).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("clears warning when no issues on next refresh", () => {
|
|
const mockNotify = vi.fn();
|
|
const { store } = makeStore();
|
|
// First call: set a warning
|
|
const ctxWithUI = makeCtx({
|
|
hasUI: true,
|
|
ui: { notify: mockNotify } as never,
|
|
});
|
|
mockLoadAndMergeConfigs.mockReturnValue({
|
|
merged: { ...DEFAULT_EXTENSION_CONFIG },
|
|
issues: ["warning"],
|
|
});
|
|
store.refresh(ctxWithUI, true);
|
|
// Second call: no issues — warning should clear
|
|
mockLoadAndMergeConfigs.mockReturnValue({
|
|
merged: { ...DEFAULT_EXTENSION_CONFIG },
|
|
issues: [],
|
|
});
|
|
store.refresh(undefined, true);
|
|
// Third call: same warning reappears — should notify again (dedup cleared)
|
|
mockLoadAndMergeConfigs.mockReturnValue({
|
|
merged: { ...DEFAULT_EXTENSION_CONFIG },
|
|
issues: ["warning"],
|
|
});
|
|
store.refresh(ctxWithUI, true);
|
|
expect(mockNotify).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("calls syncPermissionSystemStatus when hasUI is true", () => {
|
|
const { store } = makeStore();
|
|
const ctx = makeCtx({ hasUI: true });
|
|
store.refresh(ctx, true);
|
|
expect(mockSyncPermissionSystemStatus).toHaveBeenCalledWith(
|
|
ctx,
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("does not call syncPermissionSystemStatus when hasUI is false", () => {
|
|
const { store } = makeStore();
|
|
const ctx = makeCtx({ hasUI: false });
|
|
store.refresh(ctx, true);
|
|
expect(mockSyncPermissionSystemStatus).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("carries piInfrastructureReadPaths from merged config into current()", () => {
|
|
const { store } = makeStore();
|
|
mockLoadAndMergeConfigs.mockReturnValue({
|
|
merged: { piInfrastructureReadPaths: ["/extra/path"] },
|
|
issues: [],
|
|
});
|
|
store.refresh(undefined, true);
|
|
expect(store.current().piInfrastructureReadPaths).toEqual([
|
|
"/extra/path",
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ── save() ─────────────────────────────────────────────────────────────
|
|
|
|
describe("save()", () => {
|
|
it("writes merged config to the global path", () => {
|
|
const { store } = makeStore();
|
|
mockLoadUnifiedConfig.mockReturnValue({
|
|
config: { permission: { "*": "ask" } },
|
|
});
|
|
const next = { ...DEFAULT_EXTENSION_CONFIG, debugLog: true };
|
|
const ctx = makeCommandCtx();
|
|
store.save(next, ctx);
|
|
expect(mockWriteFileSync).toHaveBeenCalledWith(
|
|
expect.stringContaining(".tmp"),
|
|
expect.stringContaining('"debugLog": true'),
|
|
"utf-8",
|
|
);
|
|
expect(mockRenameSync).toHaveBeenCalled();
|
|
});
|
|
|
|
it("updates current() after a successful save", () => {
|
|
const { store } = makeStore();
|
|
const next = { ...DEFAULT_EXTENSION_CONFIG, debugLog: true };
|
|
store.save(next, makeCommandCtx());
|
|
expect(store.current().debugLog).toBe(true);
|
|
});
|
|
|
|
it("calls syncPermissionSystemStatus after a successful save", () => {
|
|
const { store } = makeStore();
|
|
const ctx = makeCommandCtx();
|
|
store.save({ ...DEFAULT_EXTENSION_CONFIG }, ctx);
|
|
expect(mockSyncPermissionSystemStatus).toHaveBeenCalledWith(
|
|
ctx,
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("writes config.saved debug log after a successful save", () => {
|
|
const { store, logger } = makeStore();
|
|
store.save({ ...DEFAULT_EXTENSION_CONFIG }, makeCommandCtx());
|
|
expect(logger.debug).toHaveBeenCalledWith(
|
|
"config.saved",
|
|
expect.objectContaining({ debugLog: false }),
|
|
);
|
|
});
|
|
|
|
it("notifies with error and returns early when write fails", () => {
|
|
const mockNotify = vi.fn();
|
|
const ctx = makeCommandCtx({ ui: { notify: mockNotify } as never });
|
|
const { store, logger } = makeStore();
|
|
mockMkdirSync.mockImplementation(() => {
|
|
throw new Error("disk full");
|
|
});
|
|
store.save({ ...DEFAULT_EXTENSION_CONFIG }, ctx);
|
|
expect(mockNotify).toHaveBeenCalledWith(
|
|
expect.stringContaining("Failed to save"),
|
|
"error",
|
|
);
|
|
// current() is not updated on failure
|
|
expect(store.current()).toEqual(DEFAULT_EXTENSION_CONFIG);
|
|
// no debug log on failure
|
|
expect(logger.debug).not.toHaveBeenCalledWith(
|
|
"config.saved",
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("attempts cleanup of tmp file when write fails and tmp exists", () => {
|
|
const ctx = makeCommandCtx();
|
|
const { store } = makeStore();
|
|
mockMkdirSync.mockImplementation(() => {
|
|
throw new Error("disk full");
|
|
});
|
|
mockExistsSync.mockReturnValue(true);
|
|
store.save({ ...DEFAULT_EXTENSION_CONFIG }, ctx);
|
|
expect(mockUnlinkSync).toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves an existing global toolInputPreviewMaxLength on save", () => {
|
|
const { store } = makeStore();
|
|
// Simulate a global config.json that already has the preview-length field.
|
|
mockLoadUnifiedConfig.mockReturnValue({
|
|
config: { toolInputPreviewMaxLength: 800 },
|
|
});
|
|
store.save({ ...DEFAULT_EXTENSION_CONFIG }, makeCommandCtx());
|
|
expect(mockWriteFileSync).toHaveBeenCalledWith(
|
|
expect.stringContaining(".tmp"),
|
|
expect.stringContaining('"toolInputPreviewMaxLength": 800'),
|
|
"utf-8",
|
|
);
|
|
});
|
|
|
|
it("preserves an existing global piInfrastructureReadPaths on save", () => {
|
|
const { store } = makeStore();
|
|
// Simulate a global config.json that already has the infra-paths field.
|
|
mockLoadUnifiedConfig.mockReturnValue({
|
|
config: { piInfrastructureReadPaths: ["/extra/path"] },
|
|
});
|
|
store.save({ ...DEFAULT_EXTENSION_CONFIG }, makeCommandCtx());
|
|
expect(mockWriteFileSync).toHaveBeenCalledWith(
|
|
expect.stringContaining(".tmp"),
|
|
expect.stringContaining('"piInfrastructureReadPaths"'),
|
|
"utf-8",
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── logResolvedPaths() ─────────────────────────────────────────────────
|
|
|
|
describe("logResolvedPaths()", () => {
|
|
it("writes config.resolved to both review and debug logs", () => {
|
|
const { store, logger } = makeStore();
|
|
store.logResolvedPaths();
|
|
expect(logger.review).toHaveBeenCalledWith(
|
|
"config.resolved",
|
|
expect.any(Object),
|
|
);
|
|
expect(logger.debug).toHaveBeenCalledWith(
|
|
"config.resolved",
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("calls getResolvedPolicyPaths from the provider", () => {
|
|
const mockProvider = makePolicyPathProvider();
|
|
const { store } = makeStore({ policyPaths: mockProvider });
|
|
store.logResolvedPaths();
|
|
expect(mockProvider.getResolvedPolicyPaths).toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes legacy detection results to buildResolvedConfigLogEntry", () => {
|
|
const { store } = makeStore();
|
|
// Make one legacy path exist
|
|
mockExistsSync.mockImplementation((p: string) =>
|
|
p.includes("policies.json"),
|
|
);
|
|
store.logResolvedPaths("/some/project");
|
|
expect(mockBuildResolvedConfigLogEntry).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
legacyGlobalPolicyDetected: expect.any(Boolean),
|
|
legacyProjectPolicyDetected: expect.any(Boolean),
|
|
legacyExtensionConfigDetected: expect.any(Boolean),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not check project legacy path when no cwd is provided", () => {
|
|
const { store } = makeStore();
|
|
store.logResolvedPaths(); // no cwd
|
|
// existsSync called for global and ext-config legacy paths only (not project)
|
|
const calls = mockExistsSync.mock.calls.map(([p]: [string]) => p);
|
|
const projectCalls = calls.filter(
|
|
(p) => p.includes("/null/") || p.includes("null"),
|
|
);
|
|
expect(projectCalls).toHaveLength(0);
|
|
});
|
|
});
|
|
});
|