Expose database backup inventory and schedule execution evidence while keeping scheduled backups due after unchanged settings saves. - Display backup files, empty and error states, and automatic schedule details in Settings. - Reconcile the shared backup routine at engine startup and retain its next run when settings are unchanged. - Add API, localization, documentation, and regression coverage for backup status and scheduling. Files changed: .changeset/fn-9040-database-backups-visibility.md | 7 ++ docs/dashboard-guide.md | 2 +- docs/settings-reference.md | 4 + .../src/__tests__/backup-schedule-status.test.ts | 124 +++++++++++++++++++++ .../core/src/automation/global-routine-store.ts | 31 +++++- packages/core/src/backup/backup.ts | 95 ++++++++++++++-- packages/core/src/index.gate.ts | 4 +- packages/core/src/index.ts | 4 +- .../app/api/agents/agent-import-generation.ts | 14 +++ packages/dashboard/app/api/legacy.ts | 1 + .../dashboard/app/components/SettingsModal.tsx | 12 +- .../__tests__/SettingsModal.test-harness.tsx | 7 +- .../__tests__/SettingsModalNodeRouting.test.tsx | 2 +- .../components/__tests__/settings-mobile.test.tsx | 2 +- .../settings/sections/DatabaseBackupsSection.css | 24 ++++ .../settings/sections/DatabaseBackupsSection.tsx | 67 ++++++----- .../__tests__/DatabaseBackupsSection.test.tsx | 45 ++++++++ .../register-system-maintenance-routes.test.ts | 47 +++++++- .../routes/register-system-maintenance-routes.ts | 46 ++++++-- .../src/__tests__/in-process-runtime.pg.test.ts | 15 ++- packages/engine/src/runtimes/in-process-runtime.ts | 13 +++ packages/i18n/locales/en/app.json | 17 ++- packages/i18n/locales/es/app.json | 17 ++- packages/i18n/locales/fr/app.json | 17 ++- packages/i18n/locales/ko/app.json | 17 ++- packages/i18n/locales/pt-BR/app.json | 17 ++- packages/i18n/locales/zh-CN/app.json | 17 ++- packages/i18n/locales/zh-TW/app.json | 17 ++- packages/i18n/src/resources.d.ts | 28 ++++- 29 files changed, 623 insertions(+), 90 deletions(-) Fusion-Task-Id: FN-9040 Fusion-Task-Lineage: 29a803a2-0784-4a99-b7cb-2f67d105254b Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
294 lines
14 KiB
TypeScript
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: [], count: 0, totalSize: 0, schedule: { enabled: false, cronExpression: "0 2 * * *", routineRegistered: false } });
|
|
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");
|
|
});
|
|
});
|