// node --test scripts/ci/lib/latest-feed-guard.test.mjs import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { test } from "node:test"; import { fileURLToPath, URL } from "node:url"; import { compareSemver, decideLatestFeedUpdate, parseUpdateMetadataVersion, publishLatestAlias, readPublishedFeedVersion, } from "./latest-feed-guard.mjs"; const LATEST_YML_191 = [ "version: 1.9.1", "files:", " - url: D3RO-Voice-Setup-1.9.1.exe", " sha512: abc", " size: 123", "path: D3RO-Voice-Setup-1.9.1.exe", "sha512: abc", "releaseDate: '2026-09-20T00:00:00.000Z'", "", ].join("\n"); test("parseUpdateMetadataVersion reads the top-level version of electron-builder metadata", () => { assert.equal(parseUpdateMetadataVersion(LATEST_YML_191), "1.9.1"); assert.equal(parseUpdateMetadataVersion("version: '1.10.0'\n"), "1.10.0"); assert.equal(parseUpdateMetadataVersion('version: "2.0.0-beta.1"\r\n'), "2.0.0-beta.1"); assert.equal(parseUpdateMetadataVersion("files: []\n"), null); assert.equal(parseUpdateMetadataVersion("version: garbage\n"), null); assert.equal(parseUpdateMetadataVersion(""), null); }); test("compareSemver orders numerically, not lexically, and ranks stable above prerelease", () => { assert.ok(compareSemver("1.10.0", "1.9.1") > 0); assert.ok(compareSemver("1.9.0", "1.9.1") < 0); assert.equal(compareSemver("v1.9.1", "1.9.1"), 0); assert.ok(compareSemver("2.0.0", "2.0.0-beta.1") > 0); assert.ok(compareSemver("2.0.0-alpha.1", "2.0.0-beta.1") < 0); assert.throws(() => compareSemver("x", "1.0.0")); }); test("decideLatestFeedUpdate refuses to roll the latest alias back to an older tag", () => { assert.deepEqual(decideLatestFeedUpdate({ publishingVersion: "1.9.0", publishedVersion: "1.9.1" }), { update: false, reason: "newer-published", }); assert.deepEqual(decideLatestFeedUpdate({ publishingVersion: "1.9.1", publishedVersion: "1.9.0" }), { update: true, reason: "older-published", }); assert.deepEqual(decideLatestFeedUpdate({ publishingVersion: "1.9.1", publishedVersion: "1.9.1" }), { update: true, reason: "same-version", }); assert.deepEqual(decideLatestFeedUpdate({ publishingVersion: "1.9.1", publishedVersion: undefined }), { update: true, reason: "no-feed", }); assert.deepEqual(decideLatestFeedUpdate({ publishingVersion: "1.9.1", publishedVersion: null }), { update: true, reason: "unreadable-feed", }); assert.throws(() => decideLatestFeedUpdate({ publishingVersion: "nope", publishedVersion: "1.0.0" })); }); function fakeFetch(status, body = "") { const calls = []; const impl = async (url, init) => { calls.push({ url, init }); return new Response(status === 404 ? "not found" : body, { status }); }; return { impl, calls }; } test("readPublishedFeedVersion maps 404 to no feed, 200 to the parsed version, and fails closed otherwise", async () => { const missing = fakeFetch(404); assert.equal(await readPublishedFeedVersion({ url: "https://x/latest/latest.yml", fetchImpl: missing.impl }), undefined); assert.match(missing.calls[0].url, /^https:\/\/x\/latest\/latest\.yml\?ts=\d+$/); assert.equal(missing.calls[0].init.cache, "no-store"); const present = fakeFetch(200, LATEST_YML_191); assert.equal(await readPublishedFeedVersion({ url: "https://x/latest/latest.yml", fetchImpl: present.impl }), "1.9.1"); const broken = fakeFetch(200, "not yaml"); assert.equal(await readPublishedFeedVersion({ url: "https://x/latest/latest.yml", fetchImpl: broken.impl }), null); const failing = fakeFetch(502, "bad gateway"); await assert.rejects( readPublishedFeedVersion({ url: "https://x/latest/latest.yml", fetchImpl: failing.impl }), /HTTP 502/, ); }); test("regression: retrying an older tag's job does not overwrite latest.yml or update-policy.json", async () => { const uploaded = []; const result = await publishLatestAlias({ publishingVersion: "1.9.0", files: [{ name: "D3RO-Voice-Setup-1.9.0.exe" }, { name: "latest.yml" }, { name: "update-policy.json" }], readPublishedVersion: async () => "1.9.1", upload: async (file) => { uploaded.push(file.name); }, }); assert.equal(result.update, false); assert.equal(result.publishedVersion, "1.9.1"); assert.deepEqual(uploaded, []); }); test("publishLatestAlias uploads every file in the given order for a newer tag", async () => { const uploaded = []; const result = await publishLatestAlias({ publishingVersion: "1.9.1", files: [{ name: "setup.exe" }, { name: "latest.yml" }, { name: "update-policy.json" }], readPublishedVersion: async () => "1.9.0", upload: async (file) => { uploaded.push(file.name); }, }); assert.equal(result.update, true); assert.deepEqual(uploaded, ["setup.exe", "latest.yml", "update-policy.json"]); }); test("publishLatestAlias uploads nothing when the published version cannot be read", async () => { const uploaded = []; await assert.rejects( publishLatestAlias({ publishingVersion: "1.9.1", files: [{ name: "latest.yml" }], readPublishedVersion: async () => { throw new Error("HTTP 500"); }, upload: async (file) => { uploaded.push(file.name); }, }), /HTTP 500/, ); assert.deepEqual(uploaded, []); }); test("the Forgejo publisher routes every latest-alias upload through the guard", () => { const source = readFileSync( fileURLToPath(new URL("../publish-forgejo-release.mjs", import.meta.url)), "utf8", ); assert.match(source, /publishLatestAlias\(/); // latest/ 경로에 직접 업로드하는 호출이 guard 밖에 남아 있으면 안 된다. const directLatestUploads = [...source.matchAll(/uploadToRegistry\([^;]*?packageLatestUrl/gs)].length; assert.equal(directLatestUploads, 1, "only the guarded upload callback may target packageLatestUrl"); });