Files
fusion/packages/dashboard/app/components/__tests__/SettingsModalNodeRouting.test.tsx
Fusion 42276db737 feat(FN-2970): merge fusion/fn-2970
- CSS: fix margin on the node routing description in SettingsModal (`SettingsModal.css`)
- Dashboard: add CSS regression test for node routing description alignment (`SettingsModalNodeRouting.test.tsx`)
- Engine: update node routing policy and scheduler logic for improved routing behavior (`scheduler.ts`)
- Engine: update node routing policy tests and scheduler routing tests to cover new behavior

Commits merged:
- test(FN-2970): add CSS regression test for node routing description
- fix(FN-2970): fix settings node routing description margin
- feat(FN-2956): merge fusion/fn-2956

Files changed:
.../dashboard/app/components/SettingsModal.css     | 11 +++++++++-
 .../__tests__/SettingsModalNodeRouting.test.tsx    | 12 +++++++++--
 .../src/__tests__/node-routing-policy.test.ts      | 25 +++++++++++++++++++---
 .../src/__tests__/scheduler-node-routing.test.ts   | 18 ++++++++--------
 packages/engine/src/scheduler.ts                   |  8 ++++---
 5 files changed, 56 insertions(+), 18 deletions(-)

Fusion-Task-Id: FN-2970
2026-04-29 17:34:26 -07:00

261 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
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),
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(),
}));
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() {
return render(<SettingsModal onClose={() => {}} addToast={() => {}} />);
}
async function ready() {
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
});
}
async function openNodeRouting() {
await ready();
await userEvent.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();
});
it("shows project scope banner", async () => {
renderModal();
await openNodeRouting();
expect(screen.getByText(/These settings only affect this project\./i)).toBeInTheDocument();
});
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 userEvent.click(screen.getByRole("button", { name: "Save" }));
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();
await userEvent.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");
});
});