import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("@fusion/core", () => { const DEFAULT_SETTINGS = { maxConcurrent: 2, maxWorktrees: 4, autoResolveConflicts: true, smartConflictResolution: true, requirePlanApproval: false, ntfyEnabled: false, ntfyTopic: undefined, worktreeNaming: "random", githubTokenConfigured: false, defaultProvider: undefined, defaultModelId: undefined, defaultNodeId: undefined, unavailableNodePolicy: undefined, }; return { GlobalSettingsStore: vi.fn(), DEFAULT_SETTINGS, }; }); vi.mock("../../project-context.js", () => ({ resolveProject: vi.fn(), })); import { GlobalSettingsStore, DEFAULT_SETTINGS } from "@fusion/core"; import { resolveProject } from "../../project-context.js"; import { runSettingsShow, runSettingsSet, parseValue, VALID_SETTINGS } from "../settings.js"; function makeSettings(overrides: Record = {}) { return { ...DEFAULT_SETTINGS, ...overrides }; } describe("settings commands", () => { let logSpy: ReturnType; let errorSpy: ReturnType; let exitSpy: ReturnType; beforeEach(() => { vi.clearAllMocks(); logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => { throw new Error(`process.exit:${code ?? 0}`); }); }); afterEach(() => { logSpy.mockRestore(); errorSpy.mockRestore(); exitSpy.mockRestore(); }); it("exposes expected valid settings and parser behavior", () => { expect(VALID_SETTINGS).toContain("maxConcurrent"); expect(VALID_SETTINGS).toContain("defaultNodeId"); expect(VALID_SETTINGS).toContain("unavailableNodePolicy"); expect(parseValue("ntfyEnabled", "yes")).toBe(true); expect(parseValue("maxConcurrent", "4")).toBe(4); expect(parseValue("worktreeNaming", "task-id")).toBe("task-id"); expect(parseValue("defaultNodeId", "node-abc-123")).toBe("node-abc-123"); expect(parseValue("unavailableNodePolicy", "block")).toBe("block"); expect(parseValue("unavailableNodePolicy", "fallback-local")).toBe("fallback-local"); expect(() => parseValue("unavailableNodePolicy", "invalid")).toThrow(/block, fallback-local/); }); it("runSettingsShow without project uses global settings even if a project could resolve", async () => { const getSettings = vi.fn().mockResolvedValue(makeSettings({ ntfyEnabled: true })); (GlobalSettingsStore as unknown as ReturnType).mockImplementation(() => ({ init: vi.fn().mockResolvedValue(undefined), getSettings, })); vi.mocked(resolveProject).mockResolvedValue({ projectId: "proj-1", projectName: "demo-project", projectPath: "/projects/demo", isRegistered: true, store: { getSettings: vi.fn() } as any, }); await runSettingsShow(); expect(getSettings).toHaveBeenCalled(); expect(resolveProject).not.toHaveBeenCalled(); expect(logSpy).toHaveBeenCalledWith(" fn Global Settings"); }); it("runSettingsShow with project uses project store", async () => { const getSettings = vi.fn().mockResolvedValue(makeSettings({ maxConcurrent: 5 })); vi.mocked(resolveProject).mockResolvedValue({ projectId: "proj-1", projectName: "demo-project", projectPath: "/projects/demo", isRegistered: true, store: { getSettings } as any, }); await runSettingsShow("demo-project"); expect(resolveProject).toHaveBeenCalledWith("demo-project"); expect(logSpy).toHaveBeenCalledWith(" fn Settings for project 'demo-project'"); }); it("runSettingsSet without project updates global-only settings", async () => { const updateSettings = vi.fn().mockResolvedValue(makeSettings({ ntfyEnabled: true })); const getSettings = vi.fn().mockResolvedValue(makeSettings({ ntfyEnabled: true })); (GlobalSettingsStore as unknown as ReturnType).mockImplementation(() => ({ init: vi.fn().mockResolvedValue(undefined), updateSettings, getSettings, })); await runSettingsSet("ntfyEnabled", "true"); expect(updateSettings).toHaveBeenCalledWith({ ntfyEnabled: true }); expect(resolveProject).not.toHaveBeenCalled(); }); it("runSettingsSet with project updates project-only settings", async () => { const updateSettings = vi.fn().mockResolvedValue(makeSettings({ maxConcurrent: 6 })); const getSettings = vi.fn().mockResolvedValue(makeSettings({ maxConcurrent: 6 })); vi.mocked(resolveProject).mockResolvedValue({ projectId: "proj-1", projectName: "demo-project", projectPath: "/projects/demo", isRegistered: true, store: { updateSettings, getSettings } as any, }); await runSettingsSet("maxConcurrent", "6", "demo-project"); expect(resolveProject).toHaveBeenCalledWith("demo-project"); expect(updateSettings).toHaveBeenCalledWith({ maxConcurrent: 6 }); }); it("rejects global-only settings for project scope", async () => { await expect(runSettingsSet("ntfyEnabled", "true", "demo-project")).rejects.toThrow("process.exit:1"); expect(errorSpy).toHaveBeenCalledWith('Error: Setting "ntfyEnabled" is global-only. Omit --project to update it.'); }); it("rejects project-only settings without explicit project scope", async () => { await expect(runSettingsSet("maxConcurrent", "4")).rejects.toThrow("process.exit:1"); expect(errorSpy).toHaveBeenCalledWith('Error: Setting "maxConcurrent" is project-only. Use --project or run from a project directory.'); expect(resolveProject).not.toHaveBeenCalled(); }); it("runSettingsSet with project updates runStepsInNewSessions", async () => { const updateSettings = vi.fn().mockResolvedValue(makeSettings({ runStepsInNewSessions: true })); const getSettings = vi.fn().mockResolvedValue(makeSettings({ runStepsInNewSessions: true })); vi.mocked(resolveProject).mockResolvedValue({ projectId: "proj-1", projectName: "demo-project", projectPath: "/projects/demo", isRegistered: true, store: { updateSettings, getSettings } as any, }); await runSettingsSet("runStepsInNewSessions", "true", "demo-project"); expect(updateSettings).toHaveBeenCalledWith({ runStepsInNewSessions: true }); }); it("runSettingsSet with project updates maxParallelSteps", async () => { const updateSettings = vi.fn().mockResolvedValue(makeSettings({ maxParallelSteps: 3 })); const getSettings = vi.fn().mockResolvedValue(makeSettings({ maxParallelSteps: 3 })); vi.mocked(resolveProject).mockResolvedValue({ projectId: "proj-1", projectName: "demo-project", projectPath: "/projects/demo", isRegistered: true, store: { updateSettings, getSettings } as any, }); await runSettingsSet("maxParallelSteps", "3", "demo-project"); expect(updateSettings).toHaveBeenCalledWith({ maxParallelSteps: 3 }); }); it("runSettingsSet updates defaultNodeId and unavailableNodePolicy", async () => { const updateSettings = vi.fn().mockResolvedValue(makeSettings({ defaultNodeId: "my-node", unavailableNodePolicy: "fallback-local" })); const getSettings = vi.fn().mockResolvedValue(makeSettings({ defaultNodeId: "my-node", unavailableNodePolicy: "fallback-local" })); vi.mocked(resolveProject).mockResolvedValue({ projectId: "proj-1", projectName: "demo-project", projectPath: "/projects/demo", isRegistered: true, store: { updateSettings, getSettings } as any, }); await runSettingsSet("defaultNodeId", "my-node", "demo-project"); await runSettingsSet("unavailableNodePolicy", "fallback-local", "demo-project"); expect(updateSettings).toHaveBeenNthCalledWith(1, { defaultNodeId: "my-node" }); expect(updateSettings).toHaveBeenNthCalledWith(2, { unavailableNodePolicy: "fallback-local" }); }); it("rejects maxParallelSteps values outside range", async () => { vi.mocked(resolveProject).mockResolvedValue({ projectId: "proj-1", projectName: "demo-project", projectPath: "/projects/demo", isRegistered: true, store: { updateSettings: vi.fn(), getSettings: vi.fn() } as any, }); await expect(runSettingsSet("maxParallelSteps", "5", "demo-project")).rejects.toThrow("process.exit:1"); expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Value out of range for maxParallelSteps")); }); it("runSettingsShow displays Execution section with step-session settings", async () => { const getSettings = vi.fn().mockResolvedValue(makeSettings({ runStepsInNewSessions: true, maxParallelSteps: 3, })); vi.mocked(resolveProject).mockResolvedValue({ projectId: "proj-1", projectName: "demo-project", projectPath: "/projects/demo", isRegistered: true, store: { getSettings } as any, }); await runSettingsShow("demo-project"); const output = logSpy.mock.calls.map((args) => args.join(" ")).join("\n"); expect(output).toContain("Execution"); expect(output).toContain("Run Steps In New Sessions"); expect(output).toContain("Max Parallel Steps"); }); it("runSettingsShow includes Node Routing section", async () => { const getSettings = vi.fn().mockResolvedValue(makeSettings({ defaultNodeId: "node-abc", unavailableNodePolicy: "block", })); vi.mocked(resolveProject).mockResolvedValue({ projectId: "proj-1", projectName: "demo-project", projectPath: "/projects/demo", isRegistered: true, store: { getSettings } as any, }); await runSettingsShow("demo-project"); const output = logSpy.mock.calls.map((args) => args.join(" ")).join("\n"); expect(output).toContain("Node Routing"); expect(output).toContain("Default Node Id"); expect(output).toContain("Unavailable Node Policy"); }); });