Files
fusion/packages/dashboard/app/components/__tests__/UpdateAvailableBanner.test.tsx
gsxdsm ba643fc384 FN-9042: fix dashboard update installation feedback
Make dashboard update actions report clear terminal outcomes and avoid unsupported installs.

- Distinguish update checks, no-op responses, installation failures, and unsupported installation methods.
- Block source-checkout, Homebrew, and missing-npm global installation attempts with actionable guidance.
- Surface update results in dashboard controls and cover API, updater, and UI behavior with tests.

Files changed:
 .changeset/fn-9042-update-now-outcomes.md          |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 docs/settings-reference.md                         |   2 +-
 packages/dashboard/app/api/settings/settings.ts    |   2 +
 .../dashboard/app/components/SettingsModal.tsx     |  18 +--
 .../app/components/UpdateAvailableBanner.tsx       |  11 +-
 .../__tests__/SettingsModal.general.test.tsx       |  20 ++++
 .../__tests__/UpdateAvailableBanner.test.tsx       |  19 ++++
 .../dashboard/src/__tests__/auto-update.test.ts    |  38 ++++++-
 .../dashboard/src/__tests__/update-check.test.ts   |  19 +++-
 packages/dashboard/src/auto-update.ts              |  33 +++++-
 .../__tests__/register-update-check-routes.test.ts | 104 ++++++++++++++++++
 .../src/routes/register-update-check-routes.ts     |  28 +++--
 packages/dashboard/src/server.ts                   |   9 +-
 packages/dashboard/src/update-check.ts             | 122 ++++++++++-----------
 15 files changed, 338 insertions(+), 96 deletions(-)

Fusion-Task-Id: FN-9042

Fusion-Task-Lineage: 22ae5540-e5b5-43cf-ad16-c7aa05782593

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-14 12:59:23 -07:00

248 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { UpdateAvailableBanner } from "../UpdateAvailableBanner";
const mockFetchSystemInfo = vi.hoisted(() => vi.fn());
const mockInstallUpdate = vi.hoisted(() => vi.fn());
const mockRequestSystemRestart = vi.hoisted(() => vi.fn());
vi.mock("../../api", () => ({
fetchSystemInfo: (...args: unknown[]) => mockFetchSystemInfo(...args),
installUpdate: (...args: unknown[]) => mockInstallUpdate(...args),
requestSystemRestart: (...args: unknown[]) => mockRequestSystemRestart(...args),
}));
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
return {
...actual,
RefreshCw: ({ className }: { className?: string }) => <span data-testid="icon-refresh" className={className} />,
};
});
const successfulInstall = { currentVersion: "0.6.0", latestVersion: "0.7.0", updated: true };
function renderBanner() {
return render(<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={vi.fn()} />);
}
async function completeInstall() {
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
await screen.findByText("Updated to v0.7.0 — restart Fusion to apply");
}
describe("UpdateAvailableBanner", () => {
beforeEach(() => {
mockFetchSystemInfo.mockReset();
mockInstallUpdate.mockReset();
mockRequestSystemRestart.mockReset();
mockFetchSystemInfo.mockResolvedValue({ restartSupported: true });
mockInstallUpdate.mockResolvedValue(successfulInstall);
mockRequestSystemRestart.mockResolvedValue({ scheduled: true });
});
it("renders version information with release notes and learn more links", () => {
renderBanner();
expect(screen.getByText(/Update available: v0.7.0 \(current: v0.6.0\)/)).toBeInTheDocument();
expect(screen.getByText("fn update")).toBeInTheDocument();
expect(screen.getByText(/or pull this source checkout/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Release notes" })).toHaveAttribute(
"href",
"https://github.com/Runfusion/Fusion/blob/main/CHANGELOG.md",
);
expect(screen.getByRole("link", { name: "Learn more" })).toHaveAttribute("href", "https://runfusion.ai");
});
it("dismiss button calls onDismiss", () => {
const onDismiss = vi.fn();
render(<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={onDismiss} />);
fireEvent.click(screen.getByRole("button", { name: "Dismiss update notice" }));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it("can be hidden by parent on dismiss", () => {
function Harness() {
const [visible, setVisible] = useState(true);
if (!visible) return null;
return (
<UpdateAvailableBanner
latestVersion="0.7.0"
currentVersion="0.6.0"
onDismiss={() => setVisible(false)}
/>
);
}
render(<Harness />);
expect(screen.getByRole("status")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Dismiss update notice" }));
expect(screen.queryByRole("status")).toBeNull();
});
it("disables update-now while installing and then renders an enabled restart button", async () => {
let resolveInstall: ((result: typeof successfulInstall) => void) | undefined;
mockInstallUpdate.mockReturnValueOnce(new Promise((resolve) => {
resolveInstall = resolve;
}));
renderBanner();
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
expect(screen.getByRole("button", { name: "Updating…" })).toBeDisabled();
expect(screen.getByTestId("icon-refresh")).toHaveClass("spinning");
resolveInstall?.(successfulInstall);
await screen.findByText("Updated to v0.7.0 — restart Fusion to apply");
expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeEnabled();
expect(screen.queryByRole("button", { name: "Update now" })).not.toBeInTheDocument();
});
it("restarts a supervised host with the update-banner reason", async () => {
renderBanner();
await completeInstall();
fireEvent.click(screen.getByRole("button", { name: "Restart Fusion" }));
await waitFor(() => expect(mockRequestSystemRestart).toHaveBeenCalledWith("update-banner"));
expect(await screen.findByText("Restarting… Your connection will close shortly.")).toBeInTheDocument();
});
/*
FNXC:UpdateBanner 2026-07-25-10:05:
Restart capability is advisory, not a hard block: the button always reaches the
server so an operator sees the real refusal instead of a control that silently
does nothing (a failed or stale /system/info probe used to disable it outright).
*/
it("shows manual guidance but still attempts the restart when unsupported", async () => {
mockFetchSystemInfo.mockResolvedValueOnce({ restartSupported: false });
mockRequestSystemRestart.mockRejectedValueOnce(new Error("Restart is not available: no supervising parent."));
renderBanner();
await completeInstall();
const restartButton = screen.getByRole("button", { name: "Restart Fusion" });
expect(restartButton).toBeEnabled();
expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument();
fireEvent.click(restartButton);
await waitFor(() => expect(mockRequestSystemRestart).toHaveBeenCalledWith("update-banner"));
expect(await screen.findByText(/Restart is not available: no supervising parent\./)).toBeInTheDocument();
});
it("allows a restart attempt while system info is still loading", async () => {
mockFetchSystemInfo.mockReturnValueOnce(new Promise(() => {}));
renderBanner();
await completeInstall();
expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeEnabled();
expect(screen.queryByText(/Needs a supervising parent/)).not.toBeInTheDocument();
});
it("shows manual guidance when system info cannot be loaded", async () => {
mockFetchSystemInfo.mockRejectedValueOnce(new Error("network unavailable"));
renderBanner();
await completeInstall();
await waitFor(() => expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument());
expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeEnabled();
});
it("shows a disabled spinning restart action while a restart request is in flight", async () => {
mockRequestSystemRestart.mockReturnValueOnce(new Promise(() => {}));
renderBanner();
await completeInstall();
fireEvent.click(screen.getByRole("button", { name: "Restart Fusion" }));
expect(screen.getByRole("button", { name: /Restarting/ })).toBeDisabled();
expect(screen.getByTestId("icon-refresh")).toHaveClass("spinning");
});
it("shows a re-clickable inline error when restart rejects", async () => {
mockRequestSystemRestart.mockRejectedValueOnce(new Error("restart conflict"));
renderBanner();
await completeInstall();
fireEvent.click(screen.getByRole("button", { name: "Restart Fusion" }));
expect(await screen.findByText("restart conflict")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeEnabled();
});
it("shows a re-clickable inline error when restart is not scheduled", async () => {
mockRequestSystemRestart.mockResolvedValueOnce({ scheduled: false });
renderBanner();
await completeInstall();
fireEvent.click(screen.getByRole("button", { name: "Restart Fusion" }));
expect(await screen.findByText("Restart could not be scheduled. Try restarting Fusion manually.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeEnabled();
});
it("shows install errors inline without rendering a restart button or removing retry", async () => {
mockInstallUpdate.mockResolvedValueOnce({
currentVersion: "0.6.0",
latestVersion: "0.7.0",
updated: false,
error: "permission denied",
});
renderBanner();
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
await waitFor(() => expect(mockInstallUpdate).toHaveBeenCalledTimes(1));
expect(await screen.findByText("Update failed: permission denied")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Update now" })).toBeEnabled();
expect(screen.queryByRole("button", { name: "Restart Fusion" })).not.toBeInTheDocument();
});
it.each([
["installed", { ...successfulInstall, outcome: "installed" }, /Updated to v0\.7\.0/],
["no-update-available", { currentVersion: "0.6.0", latestVersion: "0.7.0", updated: false, outcome: "no-update-available", message: "Fusion is already up to date." }, /already up to date/i],
["check-failed", { currentVersion: "0.6.0", latestVersion: null, updated: false, outcome: "check-failed", error: "registry unavailable", message: "Could not check for updates: registry unavailable" }, /Could not check for updates: registry unavailable/],
["unsupported-install-method", { currentVersion: "0.6.0", latestVersion: "0.7.0", updated: false, outcome: "unsupported-install-method", message: "Use pull and rebuild for this source checkout." }, /pull and rebuild/i],
["failed", { currentVersion: "0.6.0", latestVersion: "0.7.0", updated: false, outcome: "failed", error: "npm failed", message: "npm failed" }, /Update failed: npm failed/],
["legacy fallback", { currentVersion: "0.6.0", latestVersion: "0.7.0", updated: false }, /Update did not complete — see the Fusion logs/],
])("renders the %s install outcome in a live status", async (_outcome, response, expected) => {
mockInstallUpdate.mockResolvedValueOnce(response);
renderBanner();
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
const status = await screen.findByText(expected);
expect(status).toHaveAttribute("aria-live", "polite");
if (_outcome === "check-failed") expect(status).not.toHaveTextContent(/up to date/i);
if (_outcome === "installed") expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeInTheDocument();
});
it.each([
["supported", true],
["unsupported", false],
])("keeps the mobile action row and %s restart control in the document", async (_state, restartSupported) => {
const previousWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 480 });
mockFetchSystemInfo.mockResolvedValueOnce({ restartSupported });
renderBanner();
await completeInstall();
const actions = document.querySelector(".update-available-banner__actions");
const restartButton = screen.getByRole("button", { name: "Restart Fusion" });
expect(actions).toBeInTheDocument();
expect(actions).toContainElement(restartButton);
expect(restartButton).toBeInTheDocument();
expect(restartButton).toHaveProperty("disabled", false);
if (!restartSupported) expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument();
Object.defineProperty(window, "innerWidth", { configurable: true, value: previousWidth });
});
});