diff --git a/packages/cli/src/commands/__tests__/update.test.ts b/packages/cli/src/commands/__tests__/update.test.ts index 641dc7c49f..2f68b82e77 100644 --- a/packages/cli/src/commands/__tests__/update.test.ts +++ b/packages/cli/src/commands/__tests__/update.test.ts @@ -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: "" }); diff --git a/packages/dashboard/app/hooks/__tests__/useUpdateCheck.test.ts b/packages/dashboard/app/hooks/__tests__/useUpdateCheck.test.ts index 55af23107e..0c4131ffa5 100644 --- a/packages/dashboard/app/hooks/__tests__/useUpdateCheck.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useUpdateCheck.test.ts @@ -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", diff --git a/packages/dashboard/src/__tests__/update-check-route.test.ts b/packages/dashboard/src/__tests__/update-check-route.test.ts index a36838348b..e7ff33c98a 100644 --- a/packages/dashboard/src/__tests__/update-check-route.test.ts +++ b/packages/dashboard/src/__tests__/update-check-route.test.ts @@ -63,6 +63,9 @@ function createMockStore(overrides: Partial = {}): 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); + + 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({ diff --git a/packages/dashboard/src/__tests__/update-check.test.ts b/packages/dashboard/src/__tests__/update-check.test.ts index 3998c5fea4..8ad02cdc9d 100644 --- a/packages/dashboard/src/__tests__/update-check.test.ts +++ b/packages/dashboard/src/__tests__/update-check.test.ts @@ -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 () => { diff --git a/packages/desktop/src/__tests__/native.test.ts b/packages/desktop/src/__tests__/native.test.ts index 47ff30b809..3ebc0c85f1 100644 --- a/packages/desktop/src/__tests__/native.test.ts +++ b/packages/desktop/src/__tests__/native.test.ts @@ -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();