import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { SettingsManager } from "../src/core/settings-manager.js"; /** * Tests for the fix to a bug where external file changes to arrays were overwritten. * * The bug scenario was: * 1. Pi starts with settings.json containing packages: ["npm:some-pkg"] * 2. User externally edits file to packages: [] * 3. User changes an unrelated setting (e.g., theme) via UI * 4. save() would overwrite packages back to ["npm:some-pkg"] from stale in-memory state * * The fix tracks which fields were explicitly modified during the session, and only * those fields override file values during save(). */ describe("SettingsManager - External Edit Preservation", () => { const testDir = join(process.cwd(), "test-settings-bug-tmp"); const agentDir = join(testDir, "agent"); const projectDir = join(testDir, "project"); beforeEach(() => { if (existsSync(testDir)) { rmSync(testDir, { recursive: true }); } mkdirSync(agentDir, { recursive: true }); mkdirSync(join(projectDir, ".pi"), { recursive: true }); }); afterEach(() => { if (existsSync(testDir)) { rmSync(testDir, { recursive: true }); } }); it("should preserve file changes to packages array when changing unrelated setting", async () => { const settingsPath = join(agentDir, "settings.json"); // Initial state: packages has one item writeFileSync( settingsPath, JSON.stringify({ theme: "dark", packages: ["npm:pi-mcp-adapter"], }), ); // Pi starts up, loads settings into memory const manager = SettingsManager.create(projectDir, agentDir); // At this point, globalSettings.packages = ["npm:pi-mcp-adapter"] expect(manager.getPackages()).toEqual(["npm:pi-mcp-adapter"]); // User externally edits settings.json to remove the package const currentSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); currentSettings.packages = []; // User wants to remove this! writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2)); // Verify file was changed expect(JSON.parse(readFileSync(settingsPath, "utf-8")).packages).toEqual( [], ); // User changes an UNRELATED setting via UI (this triggers save) manager.setTheme("light"); await manager.flush(); // With the fix, packages should be preserved as [] (not reverted to startup value) const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); expect(savedSettings.packages).toEqual([]); expect(savedSettings.theme).toBe("light"); }); it("should preserve file changes to extensions array when changing unrelated setting", async () => { const settingsPath = join(agentDir, "settings.json"); writeFileSync( settingsPath, JSON.stringify({ theme: "dark", extensions: ["/old/extension.ts"], }), ); const manager = SettingsManager.create(projectDir, agentDir); // User externally updates extensions const currentSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); currentSettings.extensions = ["/new/extension.ts"]; writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2)); // Change unrelated setting manager.setDefaultThinkingLevel("high"); await manager.flush(); const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); // With the fix, extensions should be preserved (not reverted to startup value) expect(savedSettings.extensions).toEqual(["/new/extension.ts"]); }); it("should preserve external project settings changes when updating unrelated project field", async () => { const projectSettingsPath = join(projectDir, ".pi", "settings.json"); writeFileSync( projectSettingsPath, JSON.stringify({ extensions: ["./old-extension.ts"], prompts: ["./old-prompt.md"], }), ); const manager = SettingsManager.create(projectDir, agentDir); const currentProjectSettings = JSON.parse( readFileSync(projectSettingsPath, "utf-8"), ); currentProjectSettings.prompts = ["./new-prompt.md"]; writeFileSync( projectSettingsPath, JSON.stringify(currentProjectSettings, null, 2), ); manager.setProjectExtensionPaths(["./updated-extension.ts"]); await manager.flush(); const savedProjectSettings = JSON.parse( readFileSync(projectSettingsPath, "utf-8"), ); expect(savedProjectSettings.prompts).toEqual(["./new-prompt.md"]); expect(savedProjectSettings.extensions).toEqual(["./updated-extension.ts"]); }); it("should let in-memory project changes override external changes for the same project field", async () => { const projectSettingsPath = join(projectDir, ".pi", "settings.json"); writeFileSync( projectSettingsPath, JSON.stringify({ extensions: ["./initial-extension.ts"], }), ); const manager = SettingsManager.create(projectDir, agentDir); const currentProjectSettings = JSON.parse( readFileSync(projectSettingsPath, "utf-8"), ); currentProjectSettings.extensions = ["./external-extension.ts"]; writeFileSync( projectSettingsPath, JSON.stringify(currentProjectSettings, null, 2), ); manager.setProjectExtensionPaths(["./in-memory-extension.ts"]); await manager.flush(); const savedProjectSettings = JSON.parse( readFileSync(projectSettingsPath, "utf-8"), ); expect(savedProjectSettings.extensions).toEqual([ "./in-memory-extension.ts", ]); }); });