Files

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);
});
});
});