Files
fusion/packages/dashboard/app/components/__tests__/SettingsModalNodeRouting.test.tsx
gsxdsm 18d654a5ff capacity, part 3: delete the globalMaxConcurrent setting, API and UI (#2529)
Part 3 of the capacity simplification, and the half that removes the
**knob**. Enforcement (shared semaphore, runtime wiring) went in #2509;
this removes everything an operator or API client can still see, so
nothing is left readable-but-ignored.

## Deleted

Settings key + schema default · CentralCore’s
`getGlobalConcurrencyState` / `updateGlobalConcurrency` /
`acquireGlobalSlot` / `releaseGlobalSlot` and the `concurrency:changed`
event · the whole Global Concurrency block in `async-central-core` ·
`PUT /api/global-concurrency` · the Scheduling · Global settings section
· the footer and Command Center global sliders · the dead
`getGlobalConcurrencyLimit` reader whose only caller went in #2509.

## Kept, deliberately

**`GET /api/global-concurrency` survives as telemetry only** — live
`currentlyActive` / `projectsActive` from CentralCore’s side-effect-safe
source. “How busy is this machine?” is still a real question once the
cap that used to answer it is gone. It no longer reports
`globalMaxConcurrent`/`queuedCount`: those came from the deleted cap and
from slot bookkeeping production code never incremented, so publishing
them was publishing zeros dressed as state.

**`useGlobalConcurrency` becomes read-only.** Everything that existed to
*persist* went with the cap — the 500 ms debounce, the save-state
machine, the commit-on-close/unmount flush, the slider clamp, the
`interactive` gate. The module-level shared store is **kept**: its
original justification (two mounted consumers drift apart with private
copies) holds for a polled read exactly as it did for a cap, and one
fetch now serves both.

The live “N running (all projects)” readout survives in both surfaces,
moved onto the per-project row.

## Two sections become one

Scheduling · Global existed to host exactly one control. With it deleted
the section renders an empty pane, so the Global/Project pair merges
back into **“Scheduling”**. An empty nav entry is a promise of settings
that are not there.

## One real fix found on the way

`SchedulingSection`’s `concurrencyLoading` gated the **project**
concurrency inputs on the **global**-concurrency fetch — never the right
source, since `maxConcurrent` and `maxWorktrees` come from the settings
form. It is repointed at the form’s own load, preserving the invariant
it existed for: a concurrency input stays disabled until its live value
arrives, so an operator cannot overwrite a resolved limit with a blank
fallback.

## Migration

A stored `globalMaxConcurrent` is **ignored** — it is a project-blob key
nothing reads, so dropping it needs no schema change. The
`central.global_concurrency` **table** is dropped in a follow-up; this
slice stops seeding and reading it first, so that drop has no live
writer to race.

## Verification, and how the wider suite was controlled

`pnpm lint` clean · core/engine/dashboard `tsc` clean · `pnpm test:gate`
green (309 + 10 + 71) · dashboard settings/footer/command-center/hooks
**2237/2237** · core `central-core-backend` 9/9.

The broader dashboard suite shows failures, and I checked rather than
assumed: running the suspect files on **clean main** reproduces
`api-git` (49), `TaskDetailModal.rendering` (28) and `settings-mobile`
(17) identically. Two were genuinely mine —
`SettingsModal.scheduling-merge` (0 on main, 17 on this branch: my nav
rename) and one `settings-mobile` picker case asserting `scheduling` is
a scoped pair — and both are fixed.

Tests for deleted behaviour are removed with it (footer
confirm/cancel/flush/dedupe, global marker geometry, the hook’s PUT
case, the CentralCore slot cases), each carrying a note on what it
guarded and where the surviving **project-side** equivalent lives.
Fixture-only references were updated, not deleted.

Nothing booted.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:23:13 -07:00

294 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { SettingsModal } from "../SettingsModal";
const mockFetchSettings = vi.fn();
const mockFetchSettingsByScope = vi.fn();
const mockUpdateSettings = vi.fn();
const mockFetchAuthStatus = vi.fn();
const mockFetchModels = vi.fn();
const mockFetchBackups = vi.fn();
const mockFetchMemoryFiles = vi.fn();
const mockFetchMemoryFile = vi.fn();
const mockFetchGlobalConcurrency = vi.fn();
const mockFetchMemoryBackendStatus = vi.fn();
const mockFetchGitRemotesDetailed = vi.fn();
const mockFetchDashboardHealth = vi.fn();
const mockCheckForUpdates = vi.fn();
const mockFetchRemoteSettings = vi.fn();
const mockUseMemoryBackendStatus = vi.fn();
const mockNodes = [
{ id: "node-local", name: "Local Machine", type: "local", status: "online", maxConcurrent: 4, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
{ id: "node-remote-1", name: "Alpha Worker", type: "remote", status: "online", maxConcurrent: 8, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
{ id: "node-remote-2", name: "Beta Node", type: "remote", status: "offline", maxConcurrent: 4, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
];
vi.mock("../../api", () => ({
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
fetchSettingsByScope: (...args: unknown[]) => mockFetchSettingsByScope(...args),
updateSettings: (...args: unknown[]) => mockUpdateSettings(...args),
updateGlobalSettings: vi.fn(),
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
fetchCustomProviders: vi.fn(() => Promise.resolve({ providers: [] })),
createCustomProvider: vi.fn(() => Promise.resolve({ provider: {} })),
updateCustomProvider: vi.fn(() => Promise.resolve({ provider: {} })),
deleteCustomProvider: vi.fn(() => Promise.resolve(undefined)),
fetchBackups: (...args: unknown[]) => mockFetchBackups(...args),
fetchMemoryFiles: (...args: unknown[]) => mockFetchMemoryFiles(...args),
fetchMemoryFile: (...args: unknown[]) => mockFetchMemoryFile(...args),
saveMemoryFile: vi.fn(),
compactMemory: vi.fn(),
fetchGlobalConcurrency: (...args: unknown[]) => mockFetchGlobalConcurrency(...args),
updateGlobalConcurrency: vi.fn(),
fetchMemoryBackendStatus: (...args: unknown[]) => mockFetchMemoryBackendStatus(...args),
testMemoryRetrieval: vi.fn(),
installQmd: vi.fn(),
fetchGitRemotesDetailed: (...args: unknown[]) => mockFetchGitRemotesDetailed(...args),
fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args),
checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args),
fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args),
updateRemoteSettings: vi.fn(),
fetchRemoteStatus: vi.fn(),
activateRemoteProvider: vi.fn(),
startRemoteTunnel: vi.fn(),
stopRemoteTunnel: vi.fn(),
regenerateRemotePersistentToken: vi.fn(),
generateShortLivedRemoteToken: vi.fn(),
fetchRemoteQr: vi.fn(),
fetchRemoteUrl: vi.fn(),
triggerMemoryDreams: vi.fn(),
exportSettings: vi.fn(),
importSettings: vi.fn(),
createBackup: vi.fn(),
testNtfyNotification: vi.fn(),
loginProvider: vi.fn(),
logoutProvider: vi.fn(),
fetchProjects: vi.fn(() => Promise.resolve([])),
/*
FNXC:DashboardMocks 2026-07-28-17:35:
The auto-update / restart-supervision work (system-info probe + update install/restart) added new
`../api` runtime exports that SettingsModal reads on mount. This hardcoded api mock must expose them
or vitest throws "No <export> is defined on the mock" the moment Settings renders. Type-only exports
are erased at runtime and intentionally omitted.
*/
fetchSystemInfo: vi.fn(() => Promise.resolve({ supervised: true, restartSupported: true })),
installUpdate: vi.fn(() => Promise.resolve({ ok: true })),
requestSystemRestart: vi.fn(() => Promise.resolve({ ok: true })),
installCloudflared: vi.fn(() => Promise.resolve({ ok: true })),
submitProviderManualCode: vi.fn(() => Promise.resolve({ ok: true })),
fetchPlugins: vi.fn(() => Promise.resolve({ plugins: [] })),
cancelProviderLogin: vi.fn(() => Promise.resolve(undefined)),
saveApiKey: vi.fn(() => Promise.resolve({ ok: true })),
clearApiKey: vi.fn(() => Promise.resolve({ ok: true })),
testNotification: vi.fn(() => Promise.resolve({ ok: true })),
fetchGitRemotes: vi.fn(() => Promise.resolve({ remotes: [] })),
fetchGitBranches: vi.fn(() => Promise.resolve({ branches: [] })),
}));
vi.mock("../../hooks/useNodes", () => ({
useNodes: vi.fn(() => ({
nodes: mockNodes,
loading: false,
error: null,
refresh: vi.fn(),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
healthCheck: vi.fn(),
})),
}));
vi.mock("../../hooks/useMemoryBackendStatus", () => ({
useMemoryBackendStatus: (...args: unknown[]) => mockUseMemoryBackendStatus(...args),
}));
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
return { ...actual, Globe: () => <span />, Folder: () => <span />, RefreshCw: () => <span />, Star: () => <span />, HelpCircle: () => <span />, Loader2: () => <span /> };
});
vi.mock("../PluginManager", () => ({ PluginManager: () => <div /> }));
vi.mock("../PiExtensionsManager", () => ({ PiExtensionsManager: () => <div /> }));
vi.mock("../PluginSlot", () => ({ PluginSlot: () => <div /> }));
vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({ useWorkspaceFileBrowser: () => ({ isOpen: false, open: vi.fn(), close: vi.fn(), entries: [], loading: false, error: null, selectedPath: null, navigateTo: vi.fn(), selectPath: vi.fn() }) }));
vi.mock("../FileBrowser", () => ({ FileBrowser: () => <div /> }));
const baseSettings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: true,
overlapIgnorePaths: [],
autoMerge: true,
mergeStrategy: "direct",
pushAfterMerge: false,
pushRemote: "origin",
recycleWorktrees: false,
worktreeNaming: "random",
includeTaskIdInCommit: true,
};
function renderModal() {
// FNXC:DashboardMocks 2026-07-13-14:00 (round 11):
// node-routing is in ADVANCED_SETTINGS_SECTION_IDS, so the nav item is hidden
// unless Advanced settings is enabled. Passing initialSection="node-routing"
// enables advanced mode (SettingsModal derives showAdvancedSettings from the
// requested section) so the "Node Routing" nav button renders and is clickable.
return render(<SettingsModal onClose={() => {}} addToast={() => {}} initialSection="node-routing" />);
}
async function ready() {
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
});
}
async function openNodeRouting() {
await ready();
fireEvent.click(screen.getAllByText("Node Routing")[0]);
}
describe("SettingsModal Node Routing section", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchSettings.mockResolvedValue(baseSettings);
mockFetchSettingsByScope.mockResolvedValue({ global: baseSettings, project: {} });
mockFetchAuthStatus.mockResolvedValue({ providers: [] });
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 });
mockFetchMemoryFiles.mockResolvedValue({ files: [] });
mockFetchMemoryFile.mockResolvedValue({ path: ".fusion/memory/MEMORY.md", content: "" });
mockFetchGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 10 });
mockFetchMemoryBackendStatus.mockResolvedValue({ currentBackend: "file", capabilities: { readable: true, writable: true, supportsAtomicWrite: true, hasConflictResolution: false, persistent: true }, availableBackends: ["file"], qmdAvailable: false, qmdInstallCommand: null });
mockUseMemoryBackendStatus.mockReturnValue({ status: null, currentBackend: "file", capabilities: { readable: true, writable: true, supportsAtomicWrite: true, hasConflictResolution: false, persistent: true }, availableBackends: ["file"], loading: false, error: null, refresh: vi.fn() });
mockFetchGitRemotesDetailed.mockResolvedValue([]);
mockFetchDashboardHealth.mockResolvedValue({ status: "ok", version: "1.2.3", uptime: 123 });
mockCheckForUpdates.mockResolvedValue(undefined);
mockFetchRemoteSettings.mockResolvedValue({ settings: {} });
mockUpdateSettings.mockResolvedValue({ success: true });
});
it("renders section heading and description", async () => {
renderModal();
await openNodeRouting();
expect(screen.getByRole("heading", { name: "Node Routing" })).toBeInTheDocument();
expect(screen.getByText(/Configure how tasks are routed to execution nodes/)).toBeInTheDocument();
});
/*
FNXC:SettingsScope 2026-07-15-18:52:
Was "shows project scope banner". The section-level banner is removed — it asserted one scope for a whole section, which was false wherever a section mixed them — so scope now rides on each row's badge.
The requirement is unchanged (an operator can tell these settings are project-scoped); only the element carrying it moved.
*/
it("marks its settings as project-scoped", async () => {
renderModal();
await openNodeRouting();
const badges = screen.getAllByTestId("settings-field-row-scope");
expect(badges.length).toBeGreaterThan(0);
expect(badges.map((b) => b.textContent)).toContain("project");
});
it("shows local execution selected when no default node is set", async () => {
renderModal();
await openNodeRouting();
expect(screen.getByLabelText("Default Execution Node")).toHaveValue("");
expect(screen.getByRole("option", { name: "Local execution (no default node)" })).toBeInTheDocument();
});
it("shows current node when defaultNodeId is configured", async () => {
mockFetchSettings.mockResolvedValue({ ...baseSettings, defaultNodeId: "node-remote-1" });
renderModal();
await openNodeRouting();
expect(screen.getByLabelText("Default Execution Node")).toHaveValue("node-remote-1");
expect(screen.getByText(/Selected node:/i)).toBeInTheDocument();
expect(screen.getByLabelText("Node status: Online")).toBeInTheDocument();
});
it("lists all available nodes in selector", async () => {
renderModal();
await openNodeRouting();
expect(screen.getByRole("option", { name: /Local Machine \(Online\)/ })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /Alpha Worker \(Online\)/ })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /Beta Node \(Offline\)/ })).toBeInTheDocument();
});
it("changing default node updates form", async () => {
renderModal();
await openNodeRouting();
fireEvent.change(screen.getByLabelText("Default Execution Node"), { target: { value: "node-remote-1" } });
expect(screen.getByLabelText("Default Execution Node")).toHaveValue("node-remote-1");
});
it("defaults unavailable node policy to block", async () => {
renderModal();
await openNodeRouting();
expect(screen.getByLabelText("Unavailable Node Policy")).toHaveValue("block");
});
it("shows fallback-local when configured", async () => {
mockFetchSettings.mockResolvedValue({ ...baseSettings, unavailableNodePolicy: "fallback-local" });
renderModal();
await openNodeRouting();
expect(screen.getByLabelText("Unavailable Node Policy")).toHaveValue("fallback-local");
});
it("changing policy updates form", async () => {
renderModal();
await openNodeRouting();
fireEvent.change(screen.getByLabelText("Unavailable Node Policy"), { target: { value: "fallback-local" } });
expect(screen.getByLabelText("Unavailable Node Policy")).toHaveValue("fallback-local");
});
it("save persists both defaultNodeId and unavailableNodePolicy", async () => {
renderModal();
await openNodeRouting();
fireEvent.change(screen.getByLabelText("Default Execution Node"), { target: { value: "node-remote-1" } });
fireEvent.change(screen.getByLabelText("Unavailable Node Policy"), { target: { value: "fallback-local" } });
await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalledTimes(1));
const payload = mockUpdateSettings.mock.calls[0][0];
expect(payload).toMatchObject({
defaultNodeId: "node-remote-1",
unavailableNodePolicy: "fallback-local",
});
});
it.each([
["block", "Block execution"],
["fallback-local", "Fall back to local"],
])("renders policy option %s (%s)", async (_value, label) => {
renderModal();
await openNodeRouting();
expect(screen.getByRole("option", { name: label })).toBeInTheDocument();
});
it("removes routing controls from scheduling section", async () => {
renderModal();
await ready();
fireEvent.click(screen.getByRole("button", { name: "Scheduling" }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Scheduling" })).toBeInTheDocument();
});
expect(screen.queryByLabelText("Default Execution Node")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Unavailable Node Policy")).not.toBeInTheDocument();
});
it("applies description and note classes for node routing spacing", async () => {
renderModal();
await openNodeRouting();
const descriptionText = screen.getByText(/Configure how tasks are routed to execution nodes/);
const noteText = screen.getByText(/These settings apply at the project level/);
expect(descriptionText).toBeInTheDocument();
expect(noteText).toBeInTheDocument();
expect(descriptionText.closest("p")).toHaveClass("settings-section-description");
expect(noteText.closest("p")).toHaveClass("settings-node-routing-note");
});
});