Files
fusion/packages/dashboard/app/components/__tests__/UpdateAvailableBanner.test.tsx
gsxdsm e8c2d516af FN-6386: add one-click update installation
Add a dashboard update action that installs available Fusion releases directly from the UI.

- Add an install-update API route backed by npm global install with bin-collision retry handling.
- Add Update now controls, loading/error/success states, and responsive styling to the update banner and settings modal.
- Extend localization, documentation, changeset coverage, and update-check tests for the new install flow.

Files changed:
 .changeset/fn-6386-update-now-button.md            |   5 +
 docs/dashboard-guide.md                            |   4 +
 docs/settings-reference.md                         |   2 +
 packages/dashboard/app/api/legacy.ts               |  13 ++
 .../dashboard/app/components/SettingsModal.css     |  38 +++++-
 .../dashboard/app/components/SettingsModal.tsx     |  96 ++++++++++++--
 .../app/components/UpdateAvailableBanner.css       |  46 +++++++
 .../app/components/UpdateAvailableBanner.tsx       | 103 ++++++++++++---
 .../components/__tests__/SettingsModal.test.tsx    |  79 +++++++++++
 .../__tests__/UpdateAvailableBanner.test.tsx       |  57 +++++++-
 .../components/__tests__/settings-mobile.test.tsx  |  19 +++
 .../src/__tests__/update-check-route.test.ts       |  78 ++++++++++-
 .../dashboard/src/__tests__/update-check.test.ts   |  45 +++++++
 .../src/routes/register-update-check-routes.ts     |  25 +++-
 packages/dashboard/src/update-check.ts             |  93 +++++++++++++
 packages/i18n/locales/en/app.json                  |  14 +-
 packages/i18n/src/resources.d.ts                   | 146 ++++++++++++++++++++-
 17 files changed, 819 insertions(+), 44 deletions(-)

Fusion-Task-Id: FN-6386

Fusion-Task-Lineage: bcccf253-c0f1-493e-b849-b189a65d1479
2026-06-13 16:30:28 -07:00

106 lines
4.1 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 mockInstallUpdate = vi.hoisted(() => vi.fn());
vi.mock("../../api", () => ({
installUpdate: (...args: unknown[]) => mockInstallUpdate(...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} />,
};
});
describe("UpdateAvailableBanner", () => {
beforeEach(() => {
mockInstallUpdate.mockReset();
mockInstallUpdate.mockResolvedValue({ currentVersion: "0.6.0", latestVersion: "0.7.0", updated: true });
});
it("renders version information with release notes and learn more links", () => {
render(
<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={vi.fn()} />,
);
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 shows restart hint", async () => {
let resolveInstall: ((result: { currentVersion: string; latestVersion: string; updated: boolean }) => void) | undefined;
mockInstallUpdate.mockReturnValueOnce(new Promise((resolve) => {
resolveInstall = resolve;
}));
render(<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
expect(screen.getByRole("button", { name: "Updating…" })).toBeDisabled();
expect(screen.getByTestId("icon-refresh")).toHaveClass("spinning");
resolveInstall?.({ currentVersion: "0.6.0", latestVersion: "0.7.0", updated: true });
expect(await screen.findByText("Updated to v0.7.0 — restart Fusion to apply")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Update now" })).not.toBeInTheDocument();
});
it("shows install errors inline without removing retry button", async () => {
mockInstallUpdate.mockResolvedValueOnce({
currentVersion: "0.6.0",
latestVersion: "0.7.0",
updated: false,
error: "permission denied",
});
render(<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={vi.fn()} />);
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" })).not.toBeDisabled();
});
});