import { describe, expect, test } from "bun:test"; import { HYSTERIA_LINUX_AMD64_ASSET, compareVersionParts, parseAppTag, parseVersion, renderLockEnv, selectLatestStableRelease, selectLinuxAmd64Asset, selectReleaseByVersion, type GithubRelease } from "../src/build/hysteriaRelease"; /** Upstream отдаёт URL с неэкранированным слэшем в теге; берём его как есть. */ function assetUrl(tag: string): string { return `https://github.com/HyNetworks/hysteria/releases/download/${tag}/${HYSTERIA_LINUX_AMD64_ASSET}`; } function release(tag: string, overrides: Partial = {}): GithubRelease { return { tag_name: tag, draft: false, prerelease: false, published_at: "2026-08-23T00:59:00Z", assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: assetUrl(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 }] }) ]); expect(resolved.artifactUrl).toBe(encoded); }); }); 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/ ); } }); });