import { describe, expect, test } from "bun:test"; import { HYSTERIA_HASHES_ASSET, HYSTERIA_LINUX_AMD64_ASSET, compareVersionParts, parseAppTag, parseUpstreamHashes, parseVersion, renderLockEnv, selectHashesAsset, selectLatestStableRelease, selectLinuxAmd64Asset, selectReleaseByVersion, type GithubRelease } from "../src/build/hysteriaRelease"; /** Upstream отдаёт URL с неэкранированным слэшем в теге; берём его как есть. */ function assetUrl(tag: string, name = HYSTERIA_LINUX_AMD64_ASSET): string { return `https://github.com/HyNetworks/hysteria/releases/download/${tag}/${name}`; } function defaultAssets(tag: string) { return [ { name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: assetUrl(tag) }, { name: HYSTERIA_HASHES_ASSET, browser_download_url: assetUrl(tag, HYSTERIA_HASHES_ASSET) } ]; } function release(tag: string, overrides: Partial = {}): GithubRelease { return { tag_name: tag, draft: false, prerelease: false, published_at: "2026-08-23T00:59:00Z", assets: defaultAssets(tag), ...overrides }; } /** Реалистичная выборка: stable, prerelease, draft, чужие семейства тегов. */ const UPSTREAM_FIXTURE: GithubRelease[] = [ release("app/v2.13.0-rc1", { prerelease: true }), release("app/v2.13.0", { prerelease: true }), release("app/v2.14.0", { draft: true }), release("app/v2.12.2"), release("app/v2.12.1"), release("app/v2.9.10"), release("app/v2.9.2"), release("core/v2.12.2"), release("docs/v1.0.0"), release("v2.12.2") ]; describe("selectLatestStableRelease", () => { test("выбирает последнюю стабильную app-версию", () => { const resolved = selectLatestStableRelease(UPSTREAM_FIXTURE); expect(resolved.version).toBe("v2.12.2"); expect(resolved.tag).toBe("app/v2.12.2"); }); test("игнорирует prerelease", () => { const resolved = selectLatestStableRelease([release("app/v2.12.2"), release("app/v2.13.0", { prerelease: true })]); expect(resolved.version).toBe("v2.12.2"); }); test("игнорирует draft", () => { const resolved = selectLatestStableRelease([release("app/v2.12.2"), release("app/v2.13.0", { draft: true })]); expect(resolved.version).toBe("v2.12.2"); }); test("игнорирует чужие семейства тегов", () => { const resolved = selectLatestStableRelease([release("core/v9.9.9"), release("app/v2.12.2")]); expect(resolved.version).toBe("v2.12.2"); }); test("игнорирует тег без префикса app/", () => { expect(() => selectLatestStableRelease([release("v2.12.2")])).toThrow(/no stable/); }); test("сравнение числовое, а не лексикографическое", () => { const resolved = selectLatestStableRelease([release("app/v2.9.2"), release("app/v2.9.10")]); expect(resolved.version).toBe("v2.9.10"); }); test("порядок элементов в ответе API не влияет на результат", () => { const reversed = [...UPSTREAM_FIXTURE].reverse(); expect(selectLatestStableRelease(reversed).version).toBe("v2.12.2"); }); test("невалидный semver в теге отбрасывается", () => { expect(() => selectLatestStableRelease([release("app/v2.12"), release("app/vNEXT")])).toThrow( /no stable/ ); }); test("пустой список релизов даёт понятную ошибку", () => { expect(() => selectLatestStableRelease([])).toThrow(/no stable/); }); test("берёт browser_download_url ровно как отдал API, без пересборки строки", () => { const resolved = selectLatestStableRelease(UPSTREAM_FIXTURE); expect(resolved.artifactUrl).toBe( "https://github.com/HyNetworks/hysteria/releases/download/app/v2.12.2/hysteria-linux-amd64" ); }); test("процентное кодирование в url upstream не переписывается", () => { const encoded = "https://github.com/HyNetworks/hysteria/releases/download/app%2Fv2.12.2/hysteria-linux-amd64"; const resolved = selectLatestStableRelease([ release("app/v2.12.2", { assets: [ { name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: encoded }, { name: HYSTERIA_HASHES_ASSET, browser_download_url: assetUrl("app/v2.12.2", HYSTERIA_HASHES_ASSET) } ] }) ]); expect(resolved.artifactUrl).toBe(encoded); }); test("резолв отдаёт и url ассета с контрольными суммами", () => { const resolved = selectLatestStableRelease(UPSTREAM_FIXTURE); expect(resolved.hashesUrl).toBe( "https://github.com/HyNetworks/hysteria/releases/download/app/v2.12.2/hashes.txt" ); }); // Без hashes.txt проверять происхождение артефакта нечем, поэтому такой // релиз для production-сборки непригоден. test("релиз без hashes.txt отклоняется", () => { const noHashes = release("app/v2.12.2", { assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: assetUrl("app/v2.12.2") }] }); expect(() => selectLatestStableRelease([noHashes])).toThrow(/has no hashes\.txt asset/); }); }); describe("selectHashesAsset", () => { test("находит ассет контрольных сумм", () => { expect(selectHashesAsset(release("app/v2.12.2")).name).toBe(HYSTERIA_HASHES_ASSET); }); test("дублирующийся hashes.txt — ошибка", () => { const ambiguous = release("app/v2.12.2", { assets: [ ...defaultAssets("app/v2.12.2"), { name: HYSTERIA_HASHES_ASSET, browser_download_url: "https://example.com/evil" } ] }); expect(() => selectHashesAsset(ambiguous)).toThrow(/ambiguous/); }); }); describe("parseUpstreamHashes", () => { // Реальный формат релиза app/v2.12.2. const UPSTREAM_HASHES = [ "6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 build/hysteria-linux-amd64", "f24f63bece90b91b4e47d392f0afa4d8fe6d38785cca0589c1d121fd53b8e189 build/hysteria-linux-amd64-avx", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa build/hysteria-linux-arm64" ].join("\n"); test("достаёт сумму нужного артефакта", () => { expect(parseUpstreamHashes(UPSTREAM_HASHES, HYSTERIA_LINUX_AMD64_ASSET)).toBe( "6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94" ); }); // Ключевой момент: -avx не должен совпасть по префиксу с базовым артефактом. test("не путает hysteria-linux-amd64 с hysteria-linux-amd64-avx", () => { expect(parseUpstreamHashes(UPSTREAM_HASHES, HYSTERIA_LINUX_AMD64_ASSET)).not.toBe( "f24f63bece90b91b4e47d392f0afa4d8fe6d38785cca0589c1d121fd53b8e189" ); expect(parseUpstreamHashes(UPSTREAM_HASHES, "hysteria-linux-amd64-avx")).toBe( "f24f63bece90b91b4e47d392f0afa4d8fe6d38785cca0589c1d121fd53b8e189" ); }); test("работает без префикса каталога", () => { const flat = `6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 ${HYSTERIA_LINUX_AMD64_ASSET}`; expect(parseUpstreamHashes(flat, HYSTERIA_LINUX_AMD64_ASSET)).toBe( "6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94" ); }); test("понимает форму sha256: и бинарный маркер '*'", () => { const alternative = [ `sha256:6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 *${HYSTERIA_LINUX_AMD64_ASSET}` ].join("\n"); expect(parseUpstreamHashes(alternative, HYSTERIA_LINUX_AMD64_ASSET)).toBe( "6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94" ); }); test("верхний регистр нормализуется", () => { const upper = `6493DFFFD55B5883F64C76C63880ECC32988F0C568C9CA9014907877B4D55F94 ${HYSTERIA_LINUX_AMD64_ASSET}`; expect(parseUpstreamHashes(upper, HYSTERIA_LINUX_AMD64_ASSET)).toBe( "6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94" ); }); test("комментарии и пустые строки игнорируются", () => { const noisy = [ "# hysteria release checksums", "", `6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 build/${HYSTERIA_LINUX_AMD64_ASSET}` ].join("\n"); expect(parseUpstreamHashes(noisy, HYSTERIA_LINUX_AMD64_ASSET)).toBe( "6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94" ); }); test("отсутствие нужной строки — ошибка, а не тихий пропуск проверки", () => { expect(() => parseUpstreamHashes(UPSTREAM_HASHES, "hysteria-linux-riscv64")).toThrow( /no SHA-256 entry for hysteria-linux-riscv64/ ); expect(() => parseUpstreamHashes("", HYSTERIA_LINUX_AMD64_ASSET)).toThrow(/no SHA-256 entry/); }); test("противоречивые записи — ошибка, а не выбор первой", () => { const conflicting = [ `6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 build/${HYSTERIA_LINUX_AMD64_ASSET}`, `bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb dist/${HYSTERIA_LINUX_AMD64_ASSET}` ].join("\n"); expect(() => parseUpstreamHashes(conflicting, HYSTERIA_LINUX_AMD64_ASSET)).toThrow(/conflicting/); }); test("повторяющаяся одинаковая запись ошибкой не считается", () => { const duplicated = [ `6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 build/${HYSTERIA_LINUX_AMD64_ASSET}`, `6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 ${HYSTERIA_LINUX_AMD64_ASSET}` ].join("\n"); expect(parseUpstreamHashes(duplicated, HYSTERIA_LINUX_AMD64_ASSET)).toBe( "6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94" ); }); test("строка с мусором вместо digest игнорируется", () => { const garbage = `not-a-sha build/${HYSTERIA_LINUX_AMD64_ASSET}`; expect(() => parseUpstreamHashes(garbage, HYSTERIA_LINUX_AMD64_ASSET)).toThrow(/no SHA-256 entry/); }); }); describe("selectLinuxAmd64Asset", () => { test("отсутствующий linux-amd64 asset — ошибка", () => { const broken = release("app/v2.12.2", { assets: [{ name: "hysteria-linux-arm64", browser_download_url: assetUrl("app/v2.12.2") }] }); expect(() => selectLatestStableRelease([broken])).toThrow(/has no hysteria-linux-amd64 asset/); }); test("дублирующийся asset — ошибка, а не случайный выбор", () => { const ambiguous = release("app/v2.12.2", { assets: [ { name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: assetUrl("app/v2.12.2") }, { name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: "https://example.com/evil" } ] }); expect(() => selectLatestStableRelease([ambiguous])).toThrow(/ambiguous/); }); test("non-https url отклоняется", () => { const insecure = release("app/v2.12.2", { assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: "http://github.com/x" }] }); expect(() => selectLinuxAmd64Asset(insecure)).toThrow(/not https/); }); test("пустой url отклоняется", () => { const empty = release("app/v2.12.2", { assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: "" }] }); expect(() => selectLinuxAmd64Asset(empty)).toThrow(/empty asset download url/); }); test("частичное совпадение имени asset не принимается", () => { const partial = release("app/v2.12.2", { assets: [ { name: "hysteria-linux-amd64-avx", browser_download_url: assetUrl("app/v2.12.2") } ] }); expect(() => selectLinuxAmd64Asset(partial)).toThrow(/has no hysteria-linux-amd64 asset/); }); }); describe("selectReleaseByVersion", () => { test("находит явно запрошенную версию, даже если она не последняя", () => { const resolved = selectReleaseByVersion(UPSTREAM_FIXTURE, "v2.12.1"); expect(resolved.version).toBe("v2.12.1"); expect(resolved.tag).toBe("app/v2.12.1"); }); test("позволяет осознанно закрепить prerelease по явному запросу", () => { const resolved = selectReleaseByVersion(UPSTREAM_FIXTURE, "v2.13.0"); expect(resolved.tag).toBe("app/v2.13.0"); // при этом автоматический выбор её по-прежнему не берёт expect(selectLatestStableRelease(UPSTREAM_FIXTURE).version).toBe("v2.12.2"); }); test("draft по явному запросу отклоняется", () => { expect(() => selectReleaseByVersion(UPSTREAM_FIXTURE, "v2.14.0")).toThrow(/draft release/); }); test("несуществующая версия даёт ошибку", () => { expect(() => selectReleaseByVersion(UPSTREAM_FIXTURE, "v9.9.9")).toThrow(/was not found/); }); test("невалидный формат override отклоняется", () => { for (const bad of ["2.12.2", "v2.12", "latest", ""]) { expect(() => selectReleaseByVersion(UPSTREAM_FIXTURE, bad)).toThrow(/invalid Hysteria version override/); } }); }); describe("парсинг версий", () => { test("parseAppTag", () => { expect(parseAppTag("app/v2.12.2")?.version).toBe("v2.12.2"); expect(parseAppTag(" app/v2.12.2 ")?.version).toBe("v2.12.2"); expect(parseAppTag("app/v2.12.2-rc1")).toBeNull(); expect(parseAppTag("app/2.12.2")).toBeNull(); }); test("parseVersion", () => { expect(parseVersion("v2.12.2")).toEqual([2, 12, 2]); expect(parseVersion("v2.12.2-rc1")).toBeNull(); }); test("compareVersionParts", () => { expect(compareVersionParts([2, 12, 2], [2, 12, 1])).toBe(1); expect(compareVersionParts([2, 9, 10], [2, 9, 2])).toBe(1); expect(compareVersionParts([2, 12, 2], [2, 12, 2])).toBe(0); expect(compareVersionParts([1, 99, 99], [2, 0, 0])).toBe(-1); }); }); describe("renderLockEnv", () => { const resolved = selectLatestStableRelease(UPSTREAM_FIXTURE); const sha = "a".repeat(64); test("содержит замороженные версию, url и sha256", () => { const rendered = renderLockEnv(resolved, sha, "latest-stable", "2026-08-27T00:00:00Z"); expect(rendered).toContain("HYSTERIA_VERSION=v2.12.2"); expect(rendered).toContain(`HYSTERIA_ARTIFACT_URL=${resolved.artifactUrl}`); expect(rendered).toContain(`HYSTERIA_ARTIFACT_SHA256=${sha}`); expect(rendered).toContain("HYSTERIA_RESOLUTION=latest-stable"); expect(rendered).toContain("HYSTERIA_RESOLVED_AT=2026-08-27T00:00:00Z"); }); test("невалидный sha256 отклоняется", () => { for (const bad of ["", "abc", "replace-with-real-sha256", "A".repeat(64), "z".repeat(64)]) { expect(() => renderLockEnv(resolved, bad, "latest-stable", "2026-08-27T00:00:00Z")).toThrow( /invalid Hysteria artifact sha256/ ); } }); });