- Add custom provider CRUD routes (`POST/GET/PUT/DELETE /api/custom-providers`) with auth validation and storage in `~/.fusion/settings.json`; registers via `register-custom-provider-routes.ts` - Add `CustomProviderForm` component for user-defined provider configuration (endpoint URL, API key, model ID); includes dedicated CSS - Add custom provider section to `SettingsModal` under the "Models" tab with provider list and add/disable controls - Add custom provider selection to `ModelOnboardingModal` onboarding flow so new users can use their own endpoints - Add changeset (`custom-openai-anthropic-providers.md`) for `@runfusion/fusion` minor release - Add unit tests for `CustomProviderForm`, `ModelOnboardingModal`, `SettingsModal`, custom-provider routes, and `useTasks` hook; update existing mocks Commits merged: - feat(FN-2959): complete Step 6 — add changeset and docs - test(FN-2959): update settings modal mocks for custom provider API - feat(FN-2959): complete Step 4 — add onboarding custom provider flow - feat(FN-2959): complete Step 3 — add settings custom provider disclosure - feat(FN-2959): complete Step 2 — add custom provider form component - feat(FN-2959): complete Step 1 — add custom provider CRUD routes - feat(FN-2975): merge fusion/fn-2975 Files changed: .changeset/custom-openai-anthropic-providers.md | 5 + packages/dashboard/app/api/legacy.ts | 41 ++++ .../app/components/CustomProviderForm.css | 45 ++++ .../app/components/CustomProviderForm.tsx | 203 ++++++++++++++++ .../app/components/ModelOnboardingModal.css | 14 ++ .../app/components/ModelOnboardingModal.tsx | 62 ++++- .../dashboard/app/components/SettingsModal.css | 38 +++ .../dashboard/app/components/SettingsModal.tsx | 91 +++++++- .../__tests__/CustomProviderForm.test.tsx | 60 +++++ .../__tests__/ModelOnboardingModal.test.tsx | 23 ++ .../components/__tests__/SettingsModal.test.tsx | 13 ++ .../__tests__/SettingsModalNodeRouting.test.tsx | 4 + .../components/__tests__/settings-mobile.test.tsx | 4 + .../dashboard/app/hooks/__tests__/useTasks.test.ts | 48 ++++ packages/dashboard/app/hooks/useTasks.ts | 4 +- packages/dashboard/src/auth-paths.ts | 4 + packages/dashboard/src/routes.ts | 2 + .../__tests__/custom-provider-routes.test.ts | 118 ++++++++++ .../src/routes/register-custom-provider-routes.ts | 254 +++++++++++++++++++++ 19 files changed, 1027 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-2959
265 lines
12 KiB
TypeScript
265 lines
12 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),
|
|
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(),
|
|
}));
|
|
|
|
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");
|
|
});
|
|
});
|