FN-7762: expand update-notification test coverage across CLI, dashboard, and desktop surfaces
Broadens regression coverage for the npm-release update-check invariant so it holds across every consuming surface, not just the reported repro. - Replace the update-check route/service semver spot-checks with a parametrized case matrix (equal, newer, older, prerelease/build metadata, short/long version segments) to close false-positive/false-negative gaps. - Add dedicated route-level tests asserting the update-check API route surfaces the same invariant. - Add CLI update command tests covering notification rendering across version-comparison cases. - Add desktop native update-check tests covering the same invariant on the desktop shell. - Add dashboard useUpdateCheck hook tests verifying consistent notification behavior for the hook consumers. Files changed: packages/cli/src/commands/__tests__/update.test.ts | 59 ++++++++++++++++++ .../app/hooks/__tests__/useUpdateCheck.test.ts | 25 ++++++++ .../src/__tests__/update-check-route.test.ts | 71 ++++++++++++++++++++++ .../dashboard/src/__tests__/update-check.test.ts | 42 +++++++------ packages/desktop/src/__tests__/native.test.ts | 27 ++++++++ 5 files changed, 206 insertions(+), 18 deletions(-) Fusion-Task-Id: FN-7762 Fusion-Task-Lineage: 6ab34312-fb4e-487a-aefc-2ab133bf79af Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -184,6 +184,65 @@ describe("runUpdate", () => {
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error installing update: network down");
|
||||
});
|
||||
|
||||
|
||||
it("uses identical comparison semantics for CLI update notifications", async () => {
|
||||
/*
|
||||
* FNXC:UpdateNotifications 2026-07-09-00:00:
|
||||
* The CLI command is the install-capable update surface. It must agree with the dashboard detector so fresh npm releases notify in --check/--json mode, while equal, older, and version-string edge cases stay quiet.
|
||||
*/
|
||||
const cases = [
|
||||
{ latest: "1.2.3", current: "1.2.3", expectedExitCode: 0, expectedAvailable: false },
|
||||
{ latest: "1.2.4", current: "1.2.3", expectedExitCode: 1, expectedAvailable: true },
|
||||
{ latest: "1.2.2", current: "1.2.3", expectedExitCode: 0, expectedAvailable: false },
|
||||
{ latest: "1.2.4-beta.1", current: "1.2.3", expectedExitCode: 1, expectedAvailable: true },
|
||||
{ latest: "1.2.3+build.7", current: "1.2.3", expectedExitCode: 0, expectedAvailable: false },
|
||||
{ latest: "1.2", current: "1.2.0", expectedExitCode: 0, expectedAvailable: false },
|
||||
{ latest: "1.2.0.9", current: "1.2.0", expectedExitCode: 0, expectedAvailable: false },
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: testCase.latest } }) }));
|
||||
readFileSyncMock.mockReturnValueOnce(JSON.stringify({ name: "@runfusion/fusion", version: testCase.current }));
|
||||
logSpy.mockClear();
|
||||
process.exitCode = 0;
|
||||
|
||||
await runUpdate({ check: true, json: true });
|
||||
|
||||
const output = logSpy.mock.calls[0]?.[0] as string;
|
||||
expect(JSON.parse(output), `${testCase.latest} vs ${testCase.current}`).toMatchObject({
|
||||
currentVersion: testCase.current,
|
||||
latestVersion: testCase.latest,
|
||||
updateAvailable: testCase.expectedAvailable,
|
||||
updated: false,
|
||||
});
|
||||
expect(process.exitCode).toBe(testCase.expectedExitCode);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not announce equal or older cached fallback metadata", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));
|
||||
|
||||
for (const latestVersion of ["1.2.3", "1.2.2"]) {
|
||||
getCachedUpdateStatusMock.mockReturnValueOnce({
|
||||
updateAvailable: true,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion,
|
||||
});
|
||||
logSpy.mockClear();
|
||||
process.exitCode = 0;
|
||||
|
||||
await runUpdate({ check: true, json: true });
|
||||
|
||||
const output = logSpy.mock.calls.at(-1)?.[0] as string;
|
||||
expect(JSON.parse(output)).toMatchObject({
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion,
|
||||
updateAvailable: false,
|
||||
});
|
||||
expect(process.exitCode).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles semver comparisons for major, minor, and patch", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "2.0.0" } }) }));
|
||||
execAsyncMock.mockResolvedValue({ stdout: "ok", stderr: "" });
|
||||
|
||||
@@ -32,6 +32,31 @@ describe("useUpdateCheck", () => {
|
||||
expect(result.current.currentVersion).toBe("0.6.0");
|
||||
});
|
||||
|
||||
|
||||
it("only exposes an update notification for a strictly newer release", async () => {
|
||||
/*
|
||||
* FNXC:UpdateNotifications 2026-07-09-00:00:
|
||||
* The banner hook must be a pass-through notification gate: newer API results become visible banner state, while equal, older, disabled, and unresolved results remain silent.
|
||||
*/
|
||||
const cases = [
|
||||
{ response: { currentVersion: "1.2.3", latestVersion: "1.2.4", updateAvailable: true }, expected: true },
|
||||
{ response: { currentVersion: "1.2.3", latestVersion: "1.2.3", updateAvailable: false }, expected: false },
|
||||
{ response: { currentVersion: "1.2.3", latestVersion: "1.2.2", updateAvailable: false }, expected: false },
|
||||
{ response: { currentVersion: "0.0.0", latestVersion: null, updateAvailable: false, error: "Current Fusion version is unavailable" }, expected: false },
|
||||
{ response: { currentVersion: "1.2.3", latestVersion: null, updateAvailable: false, disabled: true }, expected: false },
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
mockCheckForUpdate.mockResolvedValueOnce(testCase.response);
|
||||
const { result, unmount } = renderHook(() => useUpdateCheck());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.updateAvailable).toBe(testCase.expected);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("dismiss stores session flag", async () => {
|
||||
mockCheckForUpdate.mockResolvedValueOnce({
|
||||
currentVersion: "0.6.0",
|
||||
|
||||
@@ -63,6 +63,9 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
unarchiveTask: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn(),
|
||||
getGlobalSettingsStore: vi.fn().mockReturnValue({
|
||||
getSettings: vi.fn().mockResolvedValue({ updateCheckEnabled: true, updateCheckFrequency: "daily" }),
|
||||
}),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
addSteeringComment: vi.fn(),
|
||||
@@ -112,6 +115,74 @@ afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
|
||||
describe("GET /api/update-check", () => {
|
||||
it("announces a newly published release through the dashboard route", async () => {
|
||||
/*
|
||||
* FNXC:UpdateNotifications 2026-07-09-00:00:
|
||||
* The dashboard banner consumes /update-check, so this route must pass a fresh npm-release result through unchanged instead of hiding it behind settings, cache, or route shaping.
|
||||
*/
|
||||
updateCheckMocks.performUpdateCheck.mockResolvedValueOnce({
|
||||
currentVersion: CLI_PACKAGE_VERSION,
|
||||
latestVersion: "99.0.0",
|
||||
updateAvailable: true,
|
||||
lastChecked: 123,
|
||||
});
|
||||
|
||||
const app = createServer(createMockStore());
|
||||
const response = await performGet(app, "/api/update-check");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(updateCheckMocks.performUpdateCheck).toHaveBeenCalledWith(expect.any(String), CLI_PACKAGE_VERSION, {
|
||||
frequency: "daily",
|
||||
});
|
||||
expect(response.body).toEqual({
|
||||
currentVersion: CLI_PACKAGE_VERSION,
|
||||
latestVersion: "99.0.0",
|
||||
updateAvailable: true,
|
||||
lastChecked: 123,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not announce updates when update checks are disabled", async () => {
|
||||
const store = createMockStore({
|
||||
getGlobalSettingsStore: vi.fn().mockReturnValue({
|
||||
getSettings: vi.fn().mockResolvedValue({ updateCheckEnabled: false }),
|
||||
}),
|
||||
} as Partial<TaskStore>);
|
||||
|
||||
const app = createServer(store);
|
||||
const response = await performGet(app, "/api/update-check");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(updateCheckMocks.performUpdateCheck).not.toHaveBeenCalled();
|
||||
expect(response.body).toMatchObject({
|
||||
currentVersion: CLI_PACKAGE_VERSION,
|
||||
latestVersion: null,
|
||||
updateAvailable: false,
|
||||
disabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("forces refresh so manual cadence can still check for a new release", async () => {
|
||||
updateCheckMocks.performUpdateCheck.mockResolvedValueOnce({
|
||||
currentVersion: CLI_PACKAGE_VERSION,
|
||||
latestVersion: "99.0.0",
|
||||
updateAvailable: true,
|
||||
lastChecked: 456,
|
||||
});
|
||||
|
||||
const app = createServer(createMockStore());
|
||||
const response = await performRequest(app, "POST", "/api/update-check/refresh");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(updateCheckMocks.performUpdateCheck).toHaveBeenCalledWith(expect.any(String), CLI_PACKAGE_VERSION, {
|
||||
force: true,
|
||||
});
|
||||
expect(response.body.updateAvailable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/update-check/install", () => {
|
||||
it("installs when a newer version is available", async () => {
|
||||
updateCheckMocks.performUpdateCheck.mockResolvedValueOnce({
|
||||
|
||||
@@ -100,29 +100,35 @@ describe("update-check", () => {
|
||||
expect(result.updateAvailable).toBe(true);
|
||||
});
|
||||
|
||||
it("handles semver comparisons for equal, newer, and older registry versions", async () => {
|
||||
it("handles registry version comparisons for the update-notification invariant", async () => {
|
||||
/*
|
||||
* FNXC:UpdateNotifications 2026-07-09-00:00:
|
||||
* The dashboard core is the shared npm-release detector for routes and banners. A strictly newer latest tag must announce an update, while equal, older, prerelease/build-equivalent, and short/long segment variants must not create false positives.
|
||||
*/
|
||||
const cases = [
|
||||
{ latest: "1.2.3", current: "1.2.3", expected: false },
|
||||
{ latest: "1.2.4", current: "1.2.3", expected: true },
|
||||
{ latest: "1.2.2", current: "1.2.3", expected: false },
|
||||
{ latest: "1.2.4-beta.1", current: "1.2.3", expected: true },
|
||||
{ latest: "1.2.3+build.7", current: "1.2.3", expected: false },
|
||||
{ latest: "1.2", current: "1.2.0", expected: false },
|
||||
{ latest: "1.2.0.9", current: "1.2.0", expected: false },
|
||||
{ latest: "1.10.0", current: "1.9.9", expected: true },
|
||||
];
|
||||
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
json: async () => ({ "dist-tags": { latest: "1.2.3" } }),
|
||||
});
|
||||
const equalResult = await performUpdateCheck(fusionDir, "1.2.3");
|
||||
expect(equalResult.updateAvailable).toBe(false);
|
||||
for (const testCase of cases) {
|
||||
await clearUpdateCheckCache(fusionDir);
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
json: async () => ({ "dist-tags": { latest: testCase.latest } }),
|
||||
});
|
||||
|
||||
await clearUpdateCheckCache(fusionDir);
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
json: async () => ({ "dist-tags": { latest: "1.2.4" } }),
|
||||
});
|
||||
const newerResult = await performUpdateCheck(fusionDir, "1.2.3");
|
||||
expect(newerResult.updateAvailable).toBe(true);
|
||||
const result = await performUpdateCheck(fusionDir, testCase.current);
|
||||
|
||||
await clearUpdateCheckCache(fusionDir);
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
json: async () => ({ "dist-tags": { latest: "1.2.2" } }),
|
||||
});
|
||||
const olderResult = await performUpdateCheck(fusionDir, "1.2.3");
|
||||
expect(olderResult.updateAvailable).toBe(false);
|
||||
expect(result.updateAvailable, `${testCase.latest} vs ${testCase.current}`).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed without fetching when current version is unresolved", async () => {
|
||||
|
||||
@@ -380,6 +380,33 @@ describe("native integrations", () => {
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
it("treats updater events as the desktop notification invariant", async () => {
|
||||
/*
|
||||
* FNXC:UpdateNotifications 2026-07-09-00:00:
|
||||
* Desktop release detection is delegated to electron-updater. Only update-available may announce a new release; update-not-available must reach the renderer without reusing the update-available notification channel.
|
||||
*/
|
||||
const { setupAutoUpdater } = await importNativeModule();
|
||||
|
||||
setupAutoUpdater(mocks.browserWindow as never);
|
||||
await vi.dynamicImportSettled();
|
||||
|
||||
mocks.updaterHandlers.get("update-available")?.({ version: "9.9.9" });
|
||||
mocks.updaterHandlers.get("update-not-available")?.({ version: "1.2.3" });
|
||||
|
||||
expect(mocks.browserWindow.webContents.send).toHaveBeenCalledWith(
|
||||
"update-available",
|
||||
expect.objectContaining({ version: "9.9.9" }),
|
||||
);
|
||||
expect(mocks.browserWindow.webContents.send).toHaveBeenCalledWith(
|
||||
"update-not-available",
|
||||
expect.objectContaining({ version: "1.2.3" }),
|
||||
);
|
||||
expect(mocks.notificationInstances.map((item) => item.options.title)).toEqual(
|
||||
expect.arrayContaining(["Fusion Update Available", "Fusion is up to date"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("error handler sends renderer IPC and does not crash", async () => {
|
||||
const { setupAutoUpdater } = await importNativeModule();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user