diff --git a/packages/cli/src/commands/__tests__/dashboard-concurrency-settings.test.ts b/packages/cli/src/commands/__tests__/dashboard-concurrency-settings.test.ts index 6c09ace623..831a405e50 100644 --- a/packages/cli/src/commands/__tests__/dashboard-concurrency-settings.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard-concurrency-settings.test.ts @@ -3,14 +3,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const harness = vi.hoisted(() => { let settings: Record = {}; let latestTui: FakeDashboardTui | undefined; + let resolveTuiReady: ((tui: FakeDashboardTui) => void) | undefined; + let tuiReady = new Promise((resolve) => { resolveTuiReady = resolve; }); + let resolveTaskStoreBarrier: (() => void) | undefined; + let taskStoreBarrier: Promise = Promise.resolve(); class FakeDashboardTui { settingsPayloads: Array> = []; + callbackPayloads: Array> = []; callbacks: Record = {}; + interactiveData: Record = {}; boardScopedProjectPath: string | null = null; constructor() { latestTui = this; + resolveTuiReady?.(this); } start = vi.fn(async () => undefined); @@ -19,11 +26,14 @@ const harness = vi.hoisted(() => { setSystemInfo = vi.fn(); setReady = vi.fn(); setTaskStats = vi.fn(); - setInteractiveData = vi.fn(); + setInteractiveData = vi.fn((data: Record) => { this.interactiveData = data; }); onBoardScopeChange = vi.fn(); hydrateVitestKillSettings = vi.fn(); log = vi.fn(); - setCallbacks = vi.fn((callbacks: Record) => { this.callbacks = callbacks; }); + setCallbacks = vi.fn((callbacks: Record) => { + this.callbackPayloads.push(callbacks); + this.callbacks = callbacks; + }); setSettings = vi.fn((payload: Record) => { this.settingsPayloads.push(payload); }); } @@ -37,6 +47,9 @@ const harness = vi.hoisted(() => { } const listeners = new Map void>>(); + const emitStoreEvent = (event: string, ...args: unknown[]) => { + for (const listener of listeners.get(event) ?? []) listener(...args); + }; const store: Record = { on: (event: string, listener: (...args: any[]) => void) => { listeners.set(event, [...(listeners.get(event) ?? []), listener]); @@ -52,6 +65,9 @@ const harness = vi.hoisted(() => { getFusionDir: vi.fn(() => "/repo/.fusion"), getRootDir: vi.fn(() => "/repo"), getSettings: vi.fn(async () => settings), + updateSettings: vi.fn(async (patch: Record) => { + settings = { ...settings, ...patch }; + }), getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn(async () => ({})), updateSettings: vi.fn(async () => undefined) })), getPluginStore: vi.fn(() => ({ init: vi.fn(async () => undefined) })), healthCheck: vi.fn(async () => ({ ok: true })), @@ -82,10 +98,25 @@ const harness = vi.hoisted(() => { store, getSettings: () => settings, latestTui: () => latestTui, + waitForTui: () => tuiReady, + emitStoreEvent, + holdTaskStore: () => { + taskStoreBarrier = new Promise((resolve) => { resolveTaskStoreBarrier = resolve; }); + }, + releaseTaskStore: () => resolveTaskStoreBarrier?.(), + createTaskStore: async () => { + await taskStoreBarrier; + return { taskStore: store, shutdown: vi.fn(async () => undefined) }; + }, setSettings: (next: Record) => { settings = next; }, reset: () => { + resolveTaskStoreBarrier?.(); settings = {}; latestTui = undefined; + resolveTuiReady = undefined; + tuiReady = new Promise((resolve) => { resolveTuiReady = resolve; }); + taskStoreBarrier = Promise.resolve(); + resolveTaskStoreBarrier = undefined; vi.clearAllMocks(); }, }; @@ -100,11 +131,16 @@ vi.mock("@fusion/core", async (importOriginal) => { } return { ...actual, - createTaskStoreForBackend: vi.fn(async () => ({ taskStore: harness.store, shutdown: vi.fn(async () => undefined) })), + createTaskStoreForBackend: vi.fn(() => harness.createTaskStore()), AutomationStore: NoopStore, AgentStore: class extends NoopStore { listAgents = vi.fn(async () => []); }, PluginLoader: class { loadAllPlugins = vi.fn(async () => ({ loaded: 0, errors: 0 })); getPluginSkills = vi.fn(() => []); }, MissionStore: NoopStore, + CentralCore: class { + init = vi.fn(async () => undefined); + listProjects = vi.fn(async () => []); + listNodes = vi.fn(async () => []); + }, setHostTaskStore: vi.fn(), setDiagnosticDbHealthCheck: vi.fn(), setDiagnosticStoreListenerCheck: vi.fn(), @@ -138,29 +174,73 @@ vi.mock("../dashboard-startup-chain.js", () => ({ }), })); +const { DEFAULT_PROJECT_SETTINGS, resolveEffectiveConcurrency, resolveWorktreeCapacityLimit } = await import("@fusion/core"); const { runDashboard } = await import("../dashboard.js"); +type SettingsCallbacks = { + onTogglePause: (paused: boolean) => Promise>; + getSettings: () => Promise>; +}; + +function getSettingsCallbacks(tui: InstanceType): SettingsCallbacks { + const pauseCallbacks = tui.callbackPayloads.find((callbacks) => "onTogglePause" in callbacks); + return { + onTogglePause: pauseCallbacks?.onTogglePause as SettingsCallbacks["onTogglePause"], + getSettings: tui.interactiveData.getSettings as SettingsCallbacks["getSettings"], + }; +} + +function expectedConcurrency(settings: Record): Pick, "maxConcurrent" | "maxWorktrees"> { + const capacity = resolveEffectiveConcurrency(settings); + return { + maxConcurrent: capacity.maxConcurrent, + maxWorktrees: resolveWorktreeCapacityLimit({ ...settings, worktreeLimitEnabled: true }), + }; +} + /** - * FNXC:CapacityModel 2026-08-21-17:43: - * The reported console mismatch was in dashboard startup, not the standalone mapping helper. - * Run the real TTY branch with a controlled live store and capture DashboardTUI.setSettings so - * future callback changes cannot reintroduce private defaults or bypass the shared resolver. + * FNXC:CapacityModel 2026-08-21-23:57: + * Callback-level coverage is required because a correct mapping helper cannot detect a + * runDashboard callback that stops reading live settings and reintroduces private defaults. */ describe("dashboard TUI concurrency settings", () => { beforeEach(() => harness.reset()); it.each([ - ["unset", {}, { maxConcurrent: 2, maxWorktrees: 4 }], - ["configured", { maxConcurrent: 6, maxWorktrees: 9 }, { maxConcurrent: 6, maxWorktrees: 9 }], - ["worktree-bound", { maxConcurrent: 8, maxWorktrees: 4, worktreeLimitEnabled: true }, { maxConcurrent: 8, maxWorktrees: 4 }], - ])("hydrates %s live settings through runDashboard's TUI setter", async (_state, settings, expected) => { + ["unset", {}], + ["configured", { maxConcurrent: 6, maxWorktrees: 9 }], + ["worktree-bound", { maxConcurrent: 8, maxWorktrees: 4, worktreeLimitEnabled: true }], + ] as const)("drives %s live settings through every runDashboard TUI callback", async (_state, settings) => { harness.setSettings(settings); + const expected = expectedConcurrency(settings); await runDashboard(0, { noEngine: true, noAuth: true }); await new Promise((resolve) => setImmediate(resolve)); const tui = harness.latestTui(); - expect(harness.store.getSettings).toHaveBeenCalled(); + expect(tui).toBeDefined(); expect(tui?.setSettings).toHaveBeenCalledWith(expect.objectContaining(expected)); + + const callbacks = getSettingsCallbacks(tui!); + await expect(callbacks.onTogglePause(true)).resolves.toEqual(expect.objectContaining(expected)); + await expect(callbacks.getSettings()).resolves.toEqual(expect.objectContaining(expected)); + + tui!.setSettings.mockClear(); + harness.emitStoreEvent("settings:updated", { settings, previous: {} }); + await new Promise((resolve) => setImmediate(resolve)); + expect(tui!.setSettings).toHaveBeenCalledWith(expect.objectContaining(expected)); + }); + + it("uses DEFAULT_PROJECT_SETTINGS when onTogglePause runs before a store exists", async () => { + harness.holdTaskStore(); + const dashboard = runDashboard(0, { noEngine: true, noAuth: true }); + const tui = await harness.waitForTui(); + + await expect(getSettingsCallbacks(tui).onTogglePause(true)).resolves.toEqual(expect.objectContaining({ + maxConcurrent: DEFAULT_PROJECT_SETTINGS.maxConcurrent, + maxWorktrees: DEFAULT_PROJECT_SETTINGS.maxWorktrees, + })); + harness.releaseTaskStore(); + await dashboard; }); });