singularity-forge/src/tests/update-check.test.ts
2026-05-05 16:31:53 +02:00

387 lines
11 KiB
TypeScript

import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, test } from "vitest";
import {
checkForUpdates,
compareSemver,
fetchLatestVersionFromRegistry,
readUpdateCache,
writeUpdateCache,
} from "../update-check.js";
// ---------------------------------------------------------------------------
// compareSemver
// ---------------------------------------------------------------------------
test("compareSemver returns 0 for equal versions", () => {
assert.equal(compareSemver("1.0.0", "1.0.0"), 0);
assert.equal(compareSemver("2.8.3", "2.8.3"), 0);
});
test("compareSemver returns 1 when first is greater", () => {
assert.equal(compareSemver("2.0.0", "1.0.0"), 1);
assert.equal(compareSemver("1.1.0", "1.0.0"), 1);
assert.equal(compareSemver("1.0.1", "1.0.0"), 1);
assert.equal(compareSemver("2.8.3", "2.7.1"), 1);
});
test("compareSemver returns -1 when first is smaller", () => {
assert.equal(compareSemver("1.0.0", "2.0.0"), -1);
assert.equal(compareSemver("1.0.0", "1.1.0"), -1);
assert.equal(compareSemver("1.0.0", "1.0.1"), -1);
assert.equal(compareSemver("2.3.11", "2.8.3"), -1);
});
test("compareSemver handles versions with different segment counts", () => {
assert.equal(compareSemver("1.0", "1.0.0"), 0);
assert.equal(compareSemver("1.0.0", "1.0"), 0);
assert.equal(compareSemver("1.0", "1.0.1"), -1);
assert.equal(compareSemver("1.0.1", "1.0"), 1);
});
// ---------------------------------------------------------------------------
// readUpdateCache / writeUpdateCache
// ---------------------------------------------------------------------------
test("readUpdateCache returns null for nonexistent file", (_t) => {
const tmp = mkdtempSync(join(tmpdir(), "sf-update-cache-"));
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
const result = readUpdateCache(join(tmp, "nonexistent"));
assert.equal(result, null);
});
test("readUpdateCache returns null for malformed JSON", (_t) => {
const tmp = mkdtempSync(join(tmpdir(), "sf-update-cache-"));
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
const cachePath = join(tmp, ".update-check");
writeFileSync(cachePath, "not json");
const result = readUpdateCache(cachePath);
assert.equal(result, null);
});
test("writeUpdateCache + readUpdateCache round-trips correctly", (_t) => {
const tmp = mkdtempSync(join(tmpdir(), "sf-update-cache-"));
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
const cachePath = join(tmp, ".update-check");
const cache = { lastCheck: Date.now(), latestVersion: "3.0.0" };
writeUpdateCache(cache, cachePath);
const result = readUpdateCache(cachePath);
assert.deepEqual(result, cache);
});
test("writeUpdateCache creates parent directories", (_t) => {
const tmp = mkdtempSync(join(tmpdir(), "sf-update-cache-"));
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
const cachePath = join(tmp, "nested", "dir", ".update-check");
writeUpdateCache(
{ lastCheck: Date.now(), latestVersion: "1.0.0" },
cachePath,
);
const raw = readFileSync(cachePath, "utf-8");
assert.ok(raw.includes("1.0.0"));
});
// ---------------------------------------------------------------------------
// checkForUpdates — integration tests with a local HTTP server
// ---------------------------------------------------------------------------
function startMockRegistry(
responseBody: object,
statusCode = 200,
): Promise<{ url: string; close: () => Promise<void> }> {
return new Promise((resolve) => {
const server = createServer((_req, res) => {
res.writeHead(statusCode, { "Content-Type": "application/json" });
res.end(JSON.stringify(responseBody));
});
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as { port: number };
resolve({
url: `http://127.0.0.1:${addr.port}`,
close: () => new Promise<void>((r) => server.close(() => r())),
});
});
});
}
test("checkForUpdates calls onUpdate when newer version is available", async (_t) => {
const tmp = mkdtempSync(join(tmpdir(), "sf-update-"));
const registry = await startMockRegistry({ version: "99.0.0" });
afterEach(async () => {
await registry.close();
rmSync(tmp, { recursive: true, force: true });
});
let called = false;
let reportedCurrent = "";
let reportedLatest = "";
await checkForUpdates({
currentVersion: "1.0.0",
cachePath: join(tmp, ".update-check"),
registryUrl: registry.url,
checkIntervalMs: 0,
fetchTimeoutMs: 5000,
onUpdate: (current, latest) => {
called = true;
reportedCurrent = current;
reportedLatest = latest;
},
});
assert.ok(called, "onUpdate should have been called");
assert.equal(reportedCurrent, "1.0.0");
assert.equal(reportedLatest, "99.0.0");
});
test("checkForUpdates does not call onUpdate when already on latest", async (_t) => {
const tmp = mkdtempSync(join(tmpdir(), "sf-update-"));
const registry = await startMockRegistry({ version: "1.0.0" });
afterEach(async () => {
await registry.close();
rmSync(tmp, { recursive: true, force: true });
});
let called = false;
await checkForUpdates({
currentVersion: "1.0.0",
cachePath: join(tmp, ".update-check"),
registryUrl: registry.url,
checkIntervalMs: 0,
fetchTimeoutMs: 5000,
onUpdate: () => {
called = true;
},
});
assert.ok(!called, "onUpdate should not be called when versions match");
});
test("checkForUpdates does not call onUpdate when current is ahead", async (_t) => {
const tmp = mkdtempSync(join(tmpdir(), "sf-update-"));
const registry = await startMockRegistry({ version: "1.0.0" });
afterEach(async () => {
await registry.close();
rmSync(tmp, { recursive: true, force: true });
});
let called = false;
await checkForUpdates({
currentVersion: "2.0.0",
cachePath: join(tmp, ".update-check"),
registryUrl: registry.url,
checkIntervalMs: 0,
fetchTimeoutMs: 5000,
onUpdate: () => {
called = true;
},
});
assert.ok(!called, "onUpdate should not be called when current is ahead");
});
test("checkForUpdates writes cache after successful fetch", async (_t) => {
const tmp = mkdtempSync(join(tmpdir(), "sf-update-"));
const cachePath = join(tmp, ".update-check");
const registry = await startMockRegistry({ version: "5.0.0" });
afterEach(async () => {
await registry.close();
rmSync(tmp, { recursive: true, force: true });
});
await checkForUpdates({
currentVersion: "1.0.0",
cachePath,
registryUrl: registry.url,
checkIntervalMs: 0,
fetchTimeoutMs: 5000,
onUpdate: () => {},
});
const cache = readUpdateCache(cachePath);
assert.ok(cache, "cache should exist after fetch");
assert.equal(cache!.latestVersion, "5.0.0");
assert.ok(cache!.lastCheck > 0);
});
test("checkForUpdates uses cache and skips fetch when checked recently", async (_t) => {
const tmp = mkdtempSync(join(tmpdir(), "sf-update-"));
const cachePath = join(tmp, ".update-check");
// Write a fresh cache entry
writeUpdateCache(
{ lastCheck: Date.now(), latestVersion: "10.0.0" },
cachePath,
);
// Start server that would return a different version — should NOT be reached
const registry = await startMockRegistry({ version: "20.0.0" });
afterEach(async () => {
await registry.close();
rmSync(tmp, { recursive: true, force: true });
});
let reportedLatest = "";
await checkForUpdates({
currentVersion: "1.0.0",
cachePath,
registryUrl: registry.url,
checkIntervalMs: 60 * 60 * 1000, // 1 hour
fetchTimeoutMs: 5000,
onUpdate: (_current, latest) => {
reportedLatest = latest;
},
});
// Should use cached version (10.0.0), not the server's (20.0.0)
assert.equal(reportedLatest, "10.0.0");
});
test("checkForUpdates skips notification when cache is fresh and versions match", async (_t) => {
const tmp = mkdtempSync(join(tmpdir(), "sf-update-"));
const cachePath = join(tmp, ".update-check");
writeUpdateCache(
{ lastCheck: Date.now(), latestVersion: "1.0.0" },
cachePath,
);
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
let called = false;
await checkForUpdates({
currentVersion: "1.0.0",
cachePath,
checkIntervalMs: 60 * 60 * 1000,
fetchTimeoutMs: 5000,
onUpdate: () => {
called = true;
},
});
assert.ok(
!called,
"onUpdate should not be called when cached version matches current",
);
});
test("checkForUpdates handles server error gracefully", async (_t) => {
const tmp = mkdtempSync(join(tmpdir(), "sf-update-"));
const registry = await startMockRegistry({}, 500);
afterEach(async () => {
await registry.close();
rmSync(tmp, { recursive: true, force: true });
});
let called = false;
await checkForUpdates({
currentVersion: "1.0.0",
cachePath: join(tmp, ".update-check"),
registryUrl: registry.url,
checkIntervalMs: 0,
fetchTimeoutMs: 5000,
onUpdate: () => {
called = true;
},
});
assert.ok(!called, "onUpdate should not be called on server error");
});
test("checkForUpdates handles network timeout gracefully", async (_t) => {
// Start a server that never responds
const server = createServer(() => {
/* intentionally never respond */
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const addr = server.address() as { port: number };
const tmp = mkdtempSync(join(tmpdir(), "sf-update-"));
afterEach(async () => {
await new Promise<void>((r) => server.close(() => r()));
rmSync(tmp, { recursive: true, force: true });
});
let called = false;
await checkForUpdates({
currentVersion: "1.0.0",
cachePath: join(tmp, ".update-check"),
registryUrl: `http://127.0.0.1:${addr.port}`,
checkIntervalMs: 0,
fetchTimeoutMs: 500, // Very short timeout
onUpdate: () => {
called = true;
},
});
assert.ok(!called, "onUpdate should not be called on timeout");
});
test("checkForUpdates handles missing version field in response", async (_t) => {
const tmp = mkdtempSync(join(tmpdir(), "sf-update-"));
const registry = await startMockRegistry({ name: "singularity-forge" }); // no version field
afterEach(async () => {
await registry.close();
rmSync(tmp, { recursive: true, force: true });
});
let called = false;
await checkForUpdates({
currentVersion: "1.0.0",
cachePath: join(tmp, ".update-check"),
registryUrl: registry.url,
checkIntervalMs: 0,
fetchTimeoutMs: 5000,
onUpdate: () => {
called = true;
},
});
assert.ok(
!called,
"onUpdate should not be called when response has no version",
);
});
test("fetchLatestVersionFromRegistry returns the registry version string", async (_t) => {
const registry = await startMockRegistry({ version: "2.67.0" });
afterEach(async () => {
await registry.close();
});
const latest = await fetchLatestVersionFromRegistry(registry.url, 5000);
assert.equal(latest, "2.67.0");
});
test("fetchLatestVersionFromRegistry returns null for blank version strings", async (_t) => {
const registry = await startMockRegistry({ version: "" });
afterEach(async () => {
await registry.close();
});
const latest = await fetchLatestVersionFromRegistry(registry.url, 5000);
assert.equal(latest, null);
});