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:
gsxdsm
2026-07-09 23:46:21 -07:00
parent 18841d76a0
commit cc901c2b9d
5 changed files with 206 additions and 18 deletions

View File

@@ -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: "" });

View File

@@ -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",

View File

@@ -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({

View File

@@ -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 () => {

View File

@@ -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();