FN-8138: add restart controls after Settings updates
Expose a supervised restart affordance after an in-app update succeeds. - Detect restart capability while update results are visible and handle scheduled or failed restart requests - Add accessible restart status, capability guidance, responsive styling, and English translations - Cover restart states across desktop and mobile Settings modal tests Files changed: .../fn-8138-settings-restart-after-update.md | 7 ++ docs/dashboard-guide.md | 2 +- .../dashboard/app/components/SettingsModal.css | 21 +++- .../dashboard/app/components/SettingsModal.tsx | 110 ++++++++++++++-- .../__tests__/SettingsModal.general.test.tsx | 139 ++++++++++++++++++++- .../__tests__/SettingsModal.test-harness.tsx | 4 + .../components/__tests__/settings-mobile.test.tsx | 4 +- packages/i18n/locales/en/app.json | 4 + 8 files changed, 274 insertions(+), 17 deletions(-) Fusion-Task-Id: FN-8138 Fusion-Task-Lineage: 24e8cb9a-ad64-4419-b3ce-461850624365 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8138-settings-restart-after-update.md
Normal file
7
.changeset/fn-8138-settings-restart-after-update.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Add a one-click "Restart Fusion" button to the Settings modal after an in-app update.
|
||||||
|
category: feature
|
||||||
|
dev: Adds a capability-aware restart affordance to SettingsModal's footer update-success state; reuses POST /api/system/restart via requestSystemRestart and restartSupported, with a disabled manual-restart fallback when unsupervised.
|
||||||
@@ -6,7 +6,7 @@ The Fusion dashboard is the main control plane for tasks, agents, missions, sett
|
|||||||
|
|
||||||
## Dashboard Updates
|
## Dashboard Updates
|
||||||
|
|
||||||
When Fusion detects a newer `@runfusion/fusion` release, the Settings modal footer shows the available version with **Learn more** and **Update now** actions. **Update now** installs the latest global package with npm; after it succeeds, the dashboard update banner offers a one-click **Restart Fusion** action because the already-running dashboard server is unchanged until restart. When Fusion is unsupervised (for example, started with `--no-supervise`), that banner action is disabled and explains that Fusion must be restarted manually.
|
When Fusion detects a newer `@runfusion/fusion` release, the Settings modal footer shows the available version with **Learn more** and **Update now** actions. **Update now** installs the latest global package with npm; after it succeeds, both the Settings update-success state and dashboard update banner offer a one-click **Restart Fusion** action because the already-running dashboard server is unchanged until restart. When Fusion is unsupervised (for example, started with `--no-supervise`), either action remains disabled and explains that Fusion must be restarted manually.
|
||||||
|
|
||||||
## Settings discovery
|
## Settings discovery
|
||||||
|
|
||||||
|
|||||||
@@ -375,17 +375,23 @@ Fix the invariant for BOTH presentations (standalone modal + embedded SettingsVi
|
|||||||
min-width: max-content;
|
min-width: max-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:SettingsUpdate 2026-07-16-00:00:
|
||||||
|
FN-8138 adds a restart control and manual-restart explanation after an update.
|
||||||
|
Let this inner result flow wrap on narrow screens while the existing footer rail
|
||||||
|
remains horizontally scrollable, so the new controls never create a clipped shell.
|
||||||
|
*/
|
||||||
.settings-modal .settings-update-check {
|
.settings-modal .settings-update-check {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: wrap;
|
||||||
row-gap: 0;
|
row-gap: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-modal .settings-update-result {
|
.settings-modal .settings-update-result {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
line-height: 1;
|
line-height: normal;
|
||||||
white-space: nowrap;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-modal .settings-footer-help-btn {
|
.settings-modal .settings-footer-help-btn {
|
||||||
@@ -517,6 +523,13 @@ Fix the invariant for BOTH presentations (standalone modal + embedded SettingsVi
|
|||||||
animation: settings-update-spin 1s linear infinite;
|
animation: settings-update-spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-update-install-succeeded {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-update-install-status {
|
.settings-update-install-status {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties, type Dispatch, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type SetStateAction } from "react";
|
import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties, type Dispatch, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type SetStateAction } from "react";
|
||||||
import { Globe, Folder, GitBranch, RefreshCw, Star, Settings as SettingsIcon, Search, X as SearchToggleCloseIcon } from "lucide-react";
|
import { Globe, Folder, GitBranch, Power, RefreshCw, Star, Settings as SettingsIcon, Search, X as SearchToggleCloseIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
resolveGitlabConfig,
|
resolveGitlabConfig,
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
|
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
|
||||||
import { DEFAULT_GLOBAL_SETTINGS } from "@fusion/core";
|
import { DEFAULT_GLOBAL_SETTINGS } from "@fusion/core";
|
||||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, installUpdate, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api";
|
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, installUpdate, fetchSystemInfo, requestSystemRestart, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api";
|
||||||
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, UpdateInstallResponse, OAuthDeviceCodeInfo } from "../api";
|
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, UpdateInstallResponse, OAuthDeviceCodeInfo } from "../api";
|
||||||
import { resolveScopedMcpSettings, splitSettingsSave, type McpSettingsScope } from "./settings/save-split";
|
import { resolveScopedMcpSettings, splitSettingsSave, type McpSettingsScope } from "./settings/save-split";
|
||||||
import {
|
import {
|
||||||
@@ -1307,6 +1307,10 @@ export function SettingsModal({
|
|||||||
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | null>(null);
|
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | null>(null);
|
||||||
const [updateInstallLoading, setUpdateInstallLoading] = useState(false);
|
const [updateInstallLoading, setUpdateInstallLoading] = useState(false);
|
||||||
const [updateInstallResult, setUpdateInstallResult] = useState<UpdateInstallResponse | null>(null);
|
const [updateInstallResult, setUpdateInstallResult] = useState<UpdateInstallResponse | null>(null);
|
||||||
|
const [restartSupported, setRestartSupported] = useState<boolean | undefined>();
|
||||||
|
const [restartLoading, setRestartLoading] = useState(false);
|
||||||
|
const [restartScheduled, setRestartScheduled] = useState(false);
|
||||||
|
const [restartError, setRestartError] = useState<string | null>(null);
|
||||||
const gitHubStarCount = useGitHubStarCount();
|
const gitHubStarCount = useGitHubStarCount();
|
||||||
const [starClicked, markStarClicked] = useStarClickedFlag();
|
const [starClicked, markStarClicked] = useStarClickedFlag();
|
||||||
const [prefixError, setPrefixError] = useState<string | null>(null);
|
const [prefixError, setPrefixError] = useState<string | null>(null);
|
||||||
@@ -1830,6 +1834,10 @@ export function SettingsModal({
|
|||||||
const handleCheckForUpdates = useCallback(async () => {
|
const handleCheckForUpdates = useCallback(async () => {
|
||||||
setUpdateCheckLoading(true);
|
setUpdateCheckLoading(true);
|
||||||
setUpdateInstallResult(null);
|
setUpdateInstallResult(null);
|
||||||
|
setRestartSupported(undefined);
|
||||||
|
setRestartLoading(false);
|
||||||
|
setRestartScheduled(false);
|
||||||
|
setRestartError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await checkForUpdates();
|
const result = await checkForUpdates();
|
||||||
@@ -1855,6 +1863,9 @@ export function SettingsModal({
|
|||||||
const handleInstallUpdate = useCallback(async () => {
|
const handleInstallUpdate = useCallback(async () => {
|
||||||
setUpdateInstallLoading(true);
|
setUpdateInstallLoading(true);
|
||||||
setUpdateInstallResult(null);
|
setUpdateInstallResult(null);
|
||||||
|
setRestartLoading(false);
|
||||||
|
setRestartScheduled(false);
|
||||||
|
setRestartError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await installUpdate(projectId);
|
const result = await installUpdate(projectId);
|
||||||
@@ -1882,6 +1893,53 @@ export function SettingsModal({
|
|||||||
}
|
}
|
||||||
}, [addToast, appVersion, projectId, t, updateCheckResult]);
|
}, [addToast, appVersion, projectId, t, updateCheckResult]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!updateCheckResult?.updateAvailable && updateInstallResult?.updated !== true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setRestartSupported(undefined);
|
||||||
|
|
||||||
|
void fetchSystemInfo()
|
||||||
|
.then((info) => {
|
||||||
|
if (!cancelled) setRestartSupported(info.restartSupported);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Fail closed: system capability fetch errors must not expose an unavailable restart action.
|
||||||
|
if (!cancelled) setRestartSupported(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [updateCheckResult?.updateAvailable, updateInstallResult?.updated]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:SettingsUpdate 2026-07-16-00:00:
|
||||||
|
After a successful in-app update, the Settings footer must offer the same supervised
|
||||||
|
one-click restart as SystemControlsArea. The FN-8134-deferred Settings surface keeps
|
||||||
|
the control disabled with manual-restart guidance unless restartSupported is true.
|
||||||
|
*/
|
||||||
|
const handleRestart = useCallback(async () => {
|
||||||
|
if (restartLoading || restartSupported !== true) return;
|
||||||
|
|
||||||
|
setRestartLoading(true);
|
||||||
|
setRestartError(null);
|
||||||
|
try {
|
||||||
|
const result = await requestSystemRestart("settings-update");
|
||||||
|
if (result.scheduled) {
|
||||||
|
setRestartScheduled(true);
|
||||||
|
} else {
|
||||||
|
setRestartError(t("settings.general.restartFailed", "Restart could not be scheduled. Try restarting Fusion manually."));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setRestartError(getErrorMessage(error) || t("settings.general.restartFailed", "Restart could not be scheduled. Try restarting Fusion manually."));
|
||||||
|
} finally {
|
||||||
|
setRestartLoading(false);
|
||||||
|
}
|
||||||
|
}, [restartLoading, restartSupported, t]);
|
||||||
|
|
||||||
const renderUpdateCheckResultContent = useCallback(() => {
|
const renderUpdateCheckResultContent = useCallback(() => {
|
||||||
if (!updateCheckResult) {
|
if (!updateCheckResult) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1909,10 +1967,48 @@ export function SettingsModal({
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
{installSucceeded ? (
|
{installSucceeded ? (
|
||||||
<span className="settings-update-install-status settings-update-install-status--success" aria-live="polite">
|
<span className="settings-update-install-succeeded">
|
||||||
{t("settings.general.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", {
|
<span className="settings-update-install-status settings-update-install-status--success" aria-live="polite">
|
||||||
version: updateInstallResult.latestVersion ?? updateCheckResult.latestVersion,
|
{t("settings.general.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", {
|
||||||
})}
|
version: updateInstallResult.latestVersion ?? updateCheckResult.latestVersion,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{restartScheduled ? (
|
||||||
|
<span className="settings-update-install-status" aria-live="polite">
|
||||||
|
{t("settings.general.restarting", "Restarting… Your connection will close shortly.")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm settings-update-now-btn"
|
||||||
|
onClick={() => {
|
||||||
|
void handleRestart();
|
||||||
|
}}
|
||||||
|
disabled={restartSupported !== true || restartLoading}
|
||||||
|
>
|
||||||
|
{restartLoading ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw size={12} className="spinning" aria-hidden="true" />
|
||||||
|
{t("settings.general.restarting", "Restarting…")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Power size={12} aria-hidden="true" />
|
||||||
|
{t("settings.general.restartNow", "Restart Fusion")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{restartSupported !== true && (
|
||||||
|
<span className="settings-update-install-status" aria-live="polite">
|
||||||
|
{t("settings.general.restartUnavailable", "Needs a supervising parent — restart Fusion manually without --no-supervise.")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{restartError && (
|
||||||
|
<span className="settings-update-install-status settings-update-install-status--error" aria-live="polite">
|
||||||
|
{restartError}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -1943,7 +2039,7 @@ export function SettingsModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return t("settings.general.upToDate", "You're up to date ✓");
|
return t("settings.general.upToDate", "You're up to date ✓");
|
||||||
}, [handleInstallUpdate, t, updateCheckResult, updateInstallLoading, updateInstallResult]);
|
}, [handleInstallUpdate, handleRestart, restartError, restartLoading, restartScheduled, restartSupported, t, updateCheckResult, updateInstallLoading, updateInstallResult]);
|
||||||
|
|
||||||
// Load auth status when the authentication section is active
|
// Load auth status when the authentication section is active
|
||||||
const loadAuthStatus = useCallback(async () => {
|
const loadAuthStatus = useCallback(async () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, it, expect, vi } from "vitest";
|
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor, within, cleanup } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor, within, cleanup } from "@testing-library/react";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { SettingsModal } from "../SettingsModal";
|
import { SettingsModal } from "../SettingsModal";
|
||||||
@@ -42,6 +42,8 @@ import {
|
|||||||
mockFetchDashboardHealth,
|
mockFetchDashboardHealth,
|
||||||
mockCheckForUpdates,
|
mockCheckForUpdates,
|
||||||
mockInstallUpdate,
|
mockInstallUpdate,
|
||||||
|
mockFetchSystemInfo,
|
||||||
|
mockRequestSystemRestart,
|
||||||
mockFetchRemoteSettings,
|
mockFetchRemoteSettings,
|
||||||
mockUpdateRemoteSettings,
|
mockUpdateRemoteSettings,
|
||||||
mockFetchRemoteStatus,
|
mockFetchRemoteStatus,
|
||||||
@@ -117,6 +119,8 @@ vi.mock("../../api", async (importOriginal) => {
|
|||||||
fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args),
|
fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args),
|
||||||
checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args),
|
checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args),
|
||||||
installUpdate: (...args: unknown[]) => mockInstallUpdate(...args),
|
installUpdate: (...args: unknown[]) => mockInstallUpdate(...args),
|
||||||
|
fetchSystemInfo: (...args: unknown[]) => mockFetchSystemInfo(...args),
|
||||||
|
requestSystemRestart: (...args: unknown[]) => mockRequestSystemRestart(...args),
|
||||||
fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args),
|
fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args),
|
||||||
updateRemoteSettings: (...args: unknown[]) => mockUpdateRemoteSettings(...args),
|
updateRemoteSettings: (...args: unknown[]) => mockUpdateRemoteSettings(...args),
|
||||||
fetchRemoteStatus: (...args: unknown[]) => mockFetchRemoteStatus(...args),
|
fetchRemoteStatus: (...args: unknown[]) => mockFetchRemoteStatus(...args),
|
||||||
@@ -151,11 +155,13 @@ vi.mock("../../hooks/useConfirm", () => ({
|
|||||||
useConfirm: () => ({ confirm: (...args: unknown[]) => mockConfirm(...args) }),
|
useConfirm: () => ({ confirm: (...args: unknown[]) => mockConfirm(...args) }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let viewportMode: "mobile" | "desktop" = "mobile";
|
||||||
|
|
||||||
vi.mock("../../hooks/useViewportMode", () => ({
|
vi.mock("../../hooks/useViewportMode", () => ({
|
||||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||||
getViewportMode: () => "mobile",
|
getViewportMode: () => viewportMode,
|
||||||
isMobileViewport: () => true,
|
isMobileViewport: () => viewportMode === "mobile",
|
||||||
useViewportMode: () => "mobile",
|
useViewportMode: () => viewportMode,
|
||||||
}));
|
}));
|
||||||
vi.mock("lucide-react", async (importOriginal) => {
|
vi.mock("lucide-react", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("lucide-react")>();
|
const actual = await importOriginal<typeof import("lucide-react")>();
|
||||||
@@ -198,6 +204,131 @@ vi.mock("../FileBrowser", () => ({
|
|||||||
describe("SettingsModal", () => {
|
describe("SettingsModal", () => {
|
||||||
installSettingsModalEnv();
|
installSettingsModalEnv();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
viewportMode = "mobile";
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableUpdate = {
|
||||||
|
currentVersion: "1.2.3",
|
||||||
|
latestVersion: "2.0.0",
|
||||||
|
updateAvailable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function renderUpdatedSettings() {
|
||||||
|
mockCheckForUpdates.mockResolvedValue(availableUpdate);
|
||||||
|
renderModal();
|
||||||
|
await waitForSettingsModalReady();
|
||||||
|
await settingsModalUser.click(screen.getByRole("button", { name: "Check for updates" }));
|
||||||
|
await screen.findByRole("button", { name: "Update now" });
|
||||||
|
await settingsModalUser.click(screen.getByRole("button", { name: "Update now" }));
|
||||||
|
return screen.findByRole("button", { name: "Restart Fusion" });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("update restart affordance", () => {
|
||||||
|
it("renders an enabled restart button after a successful update on desktop", async () => {
|
||||||
|
viewportMode = "desktop";
|
||||||
|
|
||||||
|
const restartButton = await renderUpdatedSettings();
|
||||||
|
|
||||||
|
expect(restartButton).toBeEnabled();
|
||||||
|
expect(restartButton).toHaveAccessibleName("Restart Fusion");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a wrapping, enabled restart control after a successful update on mobile", async () => {
|
||||||
|
const restartButton = await renderUpdatedSettings();
|
||||||
|
|
||||||
|
expect(restartButton).toBeEnabled();
|
||||||
|
expect(restartButton).toHaveAccessibleName("Restart Fusion");
|
||||||
|
expect(restartButton.closest(".settings-update-install-succeeded")).toBeInTheDocument();
|
||||||
|
expect(settingsModalCss).toMatch(/\.settings-modal \.settings-update-check\s*\{[^}]*flex-wrap: wrap;/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests the supervised restart with the Settings update reason", async () => {
|
||||||
|
const restartButton = await renderUpdatedSettings();
|
||||||
|
|
||||||
|
await settingsModalUser.click(restartButton);
|
||||||
|
|
||||||
|
expect(mockRequestSystemRestart).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRequestSystemRestart).toHaveBeenCalledWith("settings-update");
|
||||||
|
expect(await screen.findByText(/Restarting… Your connection will close shortly/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the restart button disabled with manual guidance when unsupported", async () => {
|
||||||
|
mockFetchSystemInfo.mockResolvedValue({ supervised: false, restartSupported: false });
|
||||||
|
|
||||||
|
const restartButton = await renderUpdatedSettings();
|
||||||
|
|
||||||
|
expect(restartButton).toBeDisabled();
|
||||||
|
expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the restart button disabled while system information is loading", async () => {
|
||||||
|
mockFetchSystemInfo.mockReturnValue(new Promise(() => {}));
|
||||||
|
|
||||||
|
const restartButton = await renderUpdatedSettings();
|
||||||
|
|
||||||
|
expect(restartButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed with manual guidance when system information cannot load", async () => {
|
||||||
|
mockFetchSystemInfo.mockRejectedValue(new Error("unavailable"));
|
||||||
|
|
||||||
|
const restartButton = await renderUpdatedSettings();
|
||||||
|
|
||||||
|
await waitFor(() => expect(restartButton).toBeDisabled());
|
||||||
|
expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the restart button and shows a spinner while scheduling", async () => {
|
||||||
|
mockRequestSystemRestart.mockReturnValue(new Promise(() => {}));
|
||||||
|
const restartButton = await renderUpdatedSettings();
|
||||||
|
|
||||||
|
await settingsModalUser.click(restartButton);
|
||||||
|
|
||||||
|
expect(restartButton).toBeDisabled();
|
||||||
|
expect(within(restartButton).getByTestId("icon-refresh")).toHaveClass("spinning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an inline error and allows retry when restart scheduling rejects", async () => {
|
||||||
|
mockRequestSystemRestart.mockRejectedValue(new Error("Restart unavailable"));
|
||||||
|
const restartButton = await renderUpdatedSettings();
|
||||||
|
|
||||||
|
await settingsModalUser.click(restartButton);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Restart unavailable")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an inline error and allows retry when restart scheduling returns false", async () => {
|
||||||
|
mockRequestSystemRestart.mockResolvedValue({ scheduled: false });
|
||||||
|
const restartButton = await renderUpdatedSettings();
|
||||||
|
|
||||||
|
await settingsModalUser.click(restartButton);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Restart could not be scheduled/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the retry path and hides restart when update installation fails", async () => {
|
||||||
|
mockInstallUpdate.mockResolvedValue({
|
||||||
|
currentVersion: "1.2.3",
|
||||||
|
latestVersion: "2.0.0",
|
||||||
|
updated: false,
|
||||||
|
error: "Install failed",
|
||||||
|
});
|
||||||
|
mockCheckForUpdates.mockResolvedValue(availableUpdate);
|
||||||
|
renderModal();
|
||||||
|
await waitForSettingsModalReady();
|
||||||
|
await settingsModalUser.click(screen.getByRole("button", { name: "Check for updates" }));
|
||||||
|
await screen.findByRole("button", { name: "Update now" });
|
||||||
|
await settingsModalUser.click(screen.getByRole("button", { name: "Update now" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Update failed: Install failed/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Update now" })).toBeEnabled();
|
||||||
|
expect(screen.queryByRole("button", { name: "Restart Fusion" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const deepwikiServer = {
|
const deepwikiServer = {
|
||||||
name: "deepwiki",
|
name: "deepwiki",
|
||||||
transport: "stdio" as const,
|
transport: "stdio" as const,
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export const mockFetchProjects = vi.fn();
|
|||||||
export const mockFetchDashboardHealth = vi.fn();
|
export const mockFetchDashboardHealth = vi.fn();
|
||||||
export const mockCheckForUpdates = vi.fn();
|
export const mockCheckForUpdates = vi.fn();
|
||||||
export const mockInstallUpdate = vi.fn();
|
export const mockInstallUpdate = vi.fn();
|
||||||
|
export const mockFetchSystemInfo = vi.fn();
|
||||||
|
export const mockRequestSystemRestart = vi.fn();
|
||||||
export const mockFetchRemoteSettings = vi.fn();
|
export const mockFetchRemoteSettings = vi.fn();
|
||||||
export const mockUpdateRemoteSettings = vi.fn();
|
export const mockUpdateRemoteSettings = vi.fn();
|
||||||
export const mockFetchRemoteStatus = vi.fn();
|
export const mockFetchRemoteStatus = vi.fn();
|
||||||
@@ -368,6 +370,8 @@ export function installSettingsModalEnv() {
|
|||||||
mockFetchDashboardHealth.mockResolvedValue({ status: "ok", version: "1.2.3", uptime: 123 });
|
mockFetchDashboardHealth.mockResolvedValue({ status: "ok", version: "1.2.3", uptime: 123 });
|
||||||
mockCheckForUpdates.mockResolvedValue(undefined);
|
mockCheckForUpdates.mockResolvedValue(undefined);
|
||||||
mockInstallUpdate.mockResolvedValue({ currentVersion: "1.2.3", latestVersion: "2.0.0", updated: true });
|
mockInstallUpdate.mockResolvedValue({ currentVersion: "1.2.3", latestVersion: "2.0.0", updated: true });
|
||||||
|
mockFetchSystemInfo.mockResolvedValue({ supervised: true, restartSupported: true });
|
||||||
|
mockRequestSystemRestart.mockResolvedValue({ scheduled: true });
|
||||||
mockFetchRemoteSettings.mockResolvedValue({
|
mockFetchRemoteSettings.mockResolvedValue({
|
||||||
settings: {
|
settings: {
|
||||||
remoteActiveProvider: null,
|
remoteActiveProvider: null,
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ vi.mock("../../api", () => ({
|
|||||||
fetchDashboardHealth: vi.fn(() => Promise.resolve({ status: "ok", version: "1.2.3", uptime: 120 })),
|
fetchDashboardHealth: vi.fn(() => Promise.resolve({ status: "ok", version: "1.2.3", uptime: 120 })),
|
||||||
checkForUpdates: vi.fn(() => Promise.resolve({ currentVersion: "1.0.0", latestVersion: "2.0.0", updateAvailable: true })),
|
checkForUpdates: vi.fn(() => Promise.resolve({ currentVersion: "1.0.0", latestVersion: "2.0.0", updateAvailable: true })),
|
||||||
installUpdate: vi.fn(() => Promise.resolve({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true })),
|
installUpdate: vi.fn(() => Promise.resolve({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true })),
|
||||||
|
fetchSystemInfo: vi.fn(() => Promise.resolve({ supervised: true, restartSupported: true })),
|
||||||
|
requestSystemRestart: vi.fn(() => Promise.resolve({ scheduled: true })),
|
||||||
fetchGlobalSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
fetchGlobalSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||||
// SettingsModal renders ProjectDefaultWorkflowField → WorkflowSelector, which loads these on mount.
|
// SettingsModal renders ProjectDefaultWorkflowField → WorkflowSelector, which loads these on mount.
|
||||||
fetchWorkflows: vi.fn(() => Promise.resolve([])),
|
fetchWorkflows: vi.fn(() => Promise.resolve([])),
|
||||||
@@ -788,7 +790,7 @@ describe("SettingsModal mobile adaptations", () => {
|
|||||||
expectMobileRule(css, ".settings-modal .settings-modal-footer-version", "flex: 0 0 auto;");
|
expectMobileRule(css, ".settings-modal .settings-modal-footer-version", "flex: 0 0 auto;");
|
||||||
expectMobileRule(css, ".settings-modal .settings-modal-footer-version", "min-width: max-content;");
|
expectMobileRule(css, ".settings-modal .settings-modal-footer-version", "min-width: max-content;");
|
||||||
expectMobileRule(css, ".settings-modal .settings-update-check", "align-items: center;");
|
expectMobileRule(css, ".settings-modal .settings-update-check", "align-items: center;");
|
||||||
expectMobileRule(css, ".settings-modal .settings-update-check", "flex-wrap: nowrap;");
|
expectMobileRule(css, ".settings-modal .settings-update-check", "flex-wrap: wrap;");
|
||||||
expectMobileRule(css, ".settings-modal .settings-version-check-btn", "line-height: 1;");
|
expectMobileRule(css, ".settings-modal .settings-version-check-btn", "line-height: 1;");
|
||||||
expectMobileRule(css, ".settings-modal .settings-version-check-btn", "white-space: nowrap;");
|
expectMobileRule(css, ".settings-modal .settings-version-check-btn", "white-space: nowrap;");
|
||||||
expectMobileRule(css, ".settings-modal .settings-modal-version", "display: inline-flex;");
|
expectMobileRule(css, ".settings-modal .settings-modal-version", "display: inline-flex;");
|
||||||
|
|||||||
@@ -5909,6 +5909,10 @@
|
|||||||
"updateNow": "Update now",
|
"updateNow": "Update now",
|
||||||
"updateSuccess": "Updated to v{{version}} — restart Fusion to apply",
|
"updateSuccess": "Updated to v{{version}} — restart Fusion to apply",
|
||||||
"updateSuccessToast": "Update installed. Restart Fusion to apply it.",
|
"updateSuccessToast": "Update installed. Restart Fusion to apply it.",
|
||||||
|
"restartFailed": "Restart could not be scheduled. Try restarting Fusion manually.",
|
||||||
|
"restartNow": "Restart Fusion",
|
||||||
|
"restartUnavailable": "Needs a supervising parent — restart Fusion manually without --no-supervise.",
|
||||||
|
"restarting": "Restarting… Your connection will close shortly.",
|
||||||
"updating": "Updating…",
|
"updating": "Updating…",
|
||||||
"upperBoundOnMessagesFetchedFromTheRoom": "Upper bound on messages fetched from the room store for compaction consideration. Default: 200.",
|
"upperBoundOnMessagesFetchedFromTheRoom": "Upper bound on messages fetched from the room store for compaction consideration. Default: 200.",
|
||||||
"upToDate": "You're up to date ✓",
|
"upToDate": "You're up to date ✓",
|
||||||
|
|||||||
Reference in New Issue
Block a user