Files
fusion/packages/dashboard/src/__tests__/update-check.test.ts
Fusion ba3d868c2d feat(FN-2944): merge fusion/fn-2944
- test(FN-2944): cover already checked out worktree conflict recovery
- fix(FN-2944): recognize git already checked out worktree conflict
- fix(engine): auto-recover from squash-merge orphan rebase failures

Fusion-Task-Id: FN-2944
2026-04-29 07:10:52 -07:00

296 lines
10 KiB
TypeScript

import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearUpdateCheckCache,
performUpdateCheck,
readCachedUpdateCheck,
ttlForFrequency,
__resetStartupRefreshFlag,
type UpdateCheckResult,
} from "../update-check.js";
describe("update-check", () => {
let fusionDir: string;
beforeEach(async () => {
fusionDir = await mkdtemp(join(tmpdir(), "fn-update-check-"));
vi.restoreAllMocks();
});
afterEach(async () => {
await clearUpdateCheckCache(fusionDir);
vi.restoreAllMocks();
});
it("returns cached result when cache is still fresh", async () => {
const cached: UpdateCheckResult = {
currentVersion: "0.6.0",
latestVersion: "0.7.0",
updateAvailable: true,
lastChecked: Date.now(),
};
await writeFile(join(fusionDir, "update-check.json"), JSON.stringify(cached), "utf-8");
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const result = await performUpdateCheck(fusionDir, "0.6.0");
expect(result).toEqual(cached);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("ignores a fresh cache entry after the installed version changes", async () => {
const cached: UpdateCheckResult = {
currentVersion: "0.8.1",
latestVersion: "0.8.3",
updateAvailable: true,
lastChecked: Date.now(),
};
await writeFile(join(fusionDir, "update-check.json"), JSON.stringify(cached), "utf-8");
const fetchSpy = vi.fn().mockResolvedValue({
json: async () => ({
"dist-tags": {
latest: "0.8.3",
},
}),
});
vi.stubGlobal("fetch", fetchSpy);
const result = await performUpdateCheck(fusionDir, "0.8.3");
expect(fetchSpy).toHaveBeenCalledOnce();
expect(result).toEqual({
currentVersion: "0.8.3",
latestVersion: "0.8.3",
updateAvailable: false,
lastChecked: expect.any(Number),
});
});
it("fetches latest version when cache is expired", async () => {
const stale: UpdateCheckResult = {
currentVersion: "0.6.0",
latestVersion: "0.6.0",
updateAvailable: false,
lastChecked: Date.now() - 25 * 60 * 60 * 1000,
};
await writeFile(join(fusionDir, "update-check.json"), JSON.stringify(stale), "utf-8");
const fetchSpy = vi.fn().mockResolvedValue({
json: async () => ({
"dist-tags": {
latest: "0.8.0",
},
}),
});
vi.stubGlobal("fetch", fetchSpy);
const result = await performUpdateCheck(fusionDir, "0.6.0");
expect(fetchSpy).toHaveBeenCalledOnce();
expect(result.latestVersion).toBe("0.8.0");
expect(result.updateAvailable).toBe(true);
});
it("handles semver comparisons for equal, newer, and older registry versions", async () => {
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);
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);
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);
});
it("returns a non-throwing error result when network fetch fails", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));
await expect(performUpdateCheck(fusionDir, "0.6.0")).resolves.toEqual(
expect.objectContaining({
currentVersion: "0.6.0",
latestVersion: null,
updateAvailable: false,
error: "network down",
}),
);
});
it("clearUpdateCheckCache removes the cache file", async () => {
const cachePath = join(fusionDir, "update-check.json");
await writeFile(cachePath, JSON.stringify({ ok: true }), "utf-8");
await clearUpdateCheckCache(fusionDir);
expect(existsSync(cachePath)).toBe(false);
});
it("readCachedUpdateCheck returns null for missing file and parsed result when present", async () => {
expect(readCachedUpdateCheck(fusionDir)).toBeNull();
const value: UpdateCheckResult = {
currentVersion: "0.6.0",
latestVersion: "0.7.0",
updateAvailable: true,
lastChecked: 123,
};
await writeFile(join(fusionDir, "update-check.json"), JSON.stringify(value), "utf-8");
expect(readCachedUpdateCheck(fusionDir)).toEqual(value);
});
describe("frequency", () => {
beforeEach(() => {
__resetStartupRefreshFlag();
});
it("ttlForFrequency: maps frequencies to expected windows", () => {
const day = 24 * 60 * 60 * 1000;
expect(ttlForFrequency(undefined)).toBe(day);
expect(ttlForFrequency("daily")).toBe(day);
expect(ttlForFrequency("weekly")).toBe(7 * day);
expect(ttlForFrequency("manual")).toBe(Number.POSITIVE_INFINITY);
expect(ttlForFrequency("on-startup")).toBe(Number.POSITIVE_INFINITY);
});
it("weekly: serves cache for up to 7 days, refetches on day 8", async () => {
// Day-6 cache: weekly should still serve it
const cached: UpdateCheckResult = {
currentVersion: "0.6.0",
latestVersion: "0.7.0",
updateAvailable: true,
lastChecked: Date.now() - 6 * 24 * 60 * 60 * 1000,
};
await writeFile(join(fusionDir, "update-check.json"), JSON.stringify(cached), "utf-8");
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const result = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "weekly" });
expect(result).toEqual(cached);
expect(fetchSpy).not.toHaveBeenCalled();
// Day-8 cache: weekly should refetch
await writeFile(
join(fusionDir, "update-check.json"),
JSON.stringify({ ...cached, lastChecked: Date.now() - 8 * 24 * 60 * 60 * 1000 }),
"utf-8",
);
fetchSpy.mockResolvedValueOnce({
json: async () => ({ "dist-tags": { latest: "0.9.0" } }),
});
const refetched = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "weekly" });
expect(fetchSpy).toHaveBeenCalledOnce();
expect(refetched.latestVersion).toBe("0.9.0");
});
it("manual: never hits the network unless force=true, returns cached or empty", async () => {
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
// No cache → returns synthetic empty result without fetching.
const empty = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "manual" });
expect(fetchSpy).not.toHaveBeenCalled();
expect(empty.latestVersion).toBeNull();
expect(empty.updateAvailable).toBe(false);
// With cache → returns cache without fetching even if "stale" by daily standards.
const cached: UpdateCheckResult = {
currentVersion: "0.6.0",
latestVersion: "0.7.0",
updateAvailable: true,
lastChecked: Date.now() - 30 * 24 * 60 * 60 * 1000,
};
await writeFile(join(fusionDir, "update-check.json"), JSON.stringify(cached), "utf-8");
const fromCache = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "manual" });
expect(fetchSpy).not.toHaveBeenCalled();
expect(fromCache).toEqual(cached);
// After an upgrade, ignore stale cached currentVersion instead of
// surfacing an outdated update banner.
const afterUpgrade = await performUpdateCheck(fusionDir, "0.8.3", { frequency: "manual" });
expect(fetchSpy).not.toHaveBeenCalled();
expect(afterUpgrade).toEqual({
currentVersion: "0.8.3",
latestVersion: null,
updateAvailable: false,
lastChecked: expect.any(Number),
});
// force=true (used by /update-check/refresh) overrides manual.
fetchSpy.mockResolvedValueOnce({
json: async () => ({ "dist-tags": { latest: "1.0.0" } }),
});
const forced = await performUpdateCheck(fusionDir, "0.6.0", {
frequency: "manual",
force: true,
});
expect(fetchSpy).toHaveBeenCalledOnce();
expect(forced.latestVersion).toBe("1.0.0");
});
it("on-startup: refreshes once per process, then serves cache", async () => {
const fetchSpy = vi.fn().mockResolvedValue({
json: async () => ({ "dist-tags": { latest: "0.8.0" } }),
});
vi.stubGlobal("fetch", fetchSpy);
// First call within the process: hits the network, writes cache.
const first = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "on-startup" });
expect(first.latestVersion).toBe("0.8.0");
expect(fetchSpy).toHaveBeenCalledOnce();
// Subsequent call: serves the just-written cache, no network.
const second = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "on-startup" });
expect(second.latestVersion).toBe("0.8.0");
expect(fetchSpy).toHaveBeenCalledOnce();
// Reset the per-process flag (simulates a fresh server boot) → next
// call refreshes again.
__resetStartupRefreshFlag();
fetchSpy.mockResolvedValueOnce({
json: async () => ({ "dist-tags": { latest: "0.9.0" } }),
});
const afterReboot = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "on-startup" });
expect(afterReboot.latestVersion).toBe("0.9.0");
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
});
it("persists fetched results to the cache file", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
json: async () => ({ "dist-tags": { latest: "0.7.0" } }),
}),
);
const result = await performUpdateCheck(fusionDir, "0.6.0");
const cachedRaw = await readFile(join(fusionDir, "update-check.json"), "utf-8");
expect(JSON.parse(cachedRaw)).toEqual(result);
});
});