releases.test.ts

8.1 KB · raw

1import type { Database } from "bun:sqlite";2import { beforeAll, describe, expect, test } from "bun:test";3import { mkdtemp, writeFile } from "node:fs/promises";4import { tmpdir } from "node:os";5import { join } from "node:path";6import type { Hono } from "hono";7import { openBucket } from "../src/bucket/reader.ts";8import { type SyncTarget, syncAll } from "../src/bucket/sync.ts";9import { openMirror } from "../src/mirror/store.ts";10import { compareVersions, visibleReleases } from "../src/releases/releases.ts";11import { createApp } from "../src/server/app.ts";12import { openKeprDb } from "../src/server/db.ts";13import { makeRepos, writeRelease } from "./fixture.ts";1415const bytes = (text: string) => new TextEncoder().encode(text);1617describe("release reading in sync", () => {18	let bucketDir: string;19	let save: string;20	let target: SyncTarget;21	const releases = () =>22		visibleReleases(target.kepr, openMirror(target.mirrorDir, "app"), "app").map((r) => r.version);23	const lastError = () =>24		(target.kepr.query("SELECT last_sync_error FROM repos").get() as { last_sync_error: string })25			.last_sync_error;2627	beforeAll(async () => {28		const repos = await makeRepos(["app"]);29		bucketDir = repos.bucketDir;30		save = repos.saves.app as string;31		const dataDir = await mkdtemp(join(tmpdir(), "kepr-releases-"));32		target = {33			bucket: openBucket({ kind: "folder", path: bucketDir }),34			kepr: openKeprDb(dataDir),35			mirrorDir: join(dataDir, "mirror"),36			tarballDir: join(dataDir, "tarballs"),37		};38		for (const version of ["1.9.0", "1.10.0", "1.2.3"]) {39			const files = [{ platform: "linux-x86_64", name: "app", bytes: bytes(version) }];40			await writeRelease(bucketDir, "app", { version, save, files }, version === "1.10.0");41		}42		await syncAll(target);43	});4445	test("reads every release.json, newest first by number", () => {46		expect(lastError()).toBeNull();47		expect(releases()).toEqual(["1.10.0", "1.9.0", "1.2.3"]);48	});4950	test("a version folder without release.json does not exist, and nothing is re-read until latest moves", async () => {51		await writeFile(join(bucketDir, "app/releases/2.0.0.tmp"), "half-written");52		const orphan = join(bucketDir, "app/releases/3.0.0/linux-x86_64");53		await Bun.write(join(orphan, "app"), "no record yet");54		await writeRelease(55			bucketDir,56			"app",57			{58				version: "1.11.0",59				save,60				files: [{ platform: "linux-x86_64", name: "app", bytes: bytes("x") }],61			},62			false,63		);64		await syncAll(target);65		expect(releases()).toEqual(["1.10.0", "1.9.0", "1.2.3"]);66		await writeFile(join(bucketDir, "app/releases/latest"), "1.11.0");67		await syncAll(target);68		expect(releases()).toEqual(["1.11.0", "1.10.0", "1.9.0", "1.2.3"]);69	});7071	test("hidden versions drop out and a notes override replaces the record's notes", () => {72		target.kepr.run(73			"INSERT INTO release_settings (repo, version, hidden, notes) VALUES ('app', '1.11.0', 1, NULL), ('app', '1.9.0', 0, 'Operator notes.')",74		);75		const shown = visibleReleases(target.kepr, openMirror(target.mirrorDir, "app"), "app");76		expect(shown.map((r) => [r.version, r.notes])).toEqual([77			["1.10.0", ""],78			["1.9.0", "Operator notes."],79			["1.2.3", ""],80		]);81		target.kepr.run("DELETE FROM release_settings");82	});8384	test("a release record that breaks §11.2 is the repo's last sync error", async () => {85		await writeRelease(bucketDir, "app", {86			version: "1.12.0",87			save,88			files: [{ platform: "plan9-mips", name: "app", bytes: bytes("x") }],89		});90		await syncAll(target);91		expect(lastError()).toContain("release record does not match");92		await writeRelease(bucketDir, "app", {93			version: "1.12.0",94			save,95			files: [{ platform: "linux-x86_64", name: "../app", bytes: bytes("x") }],96		});97		await syncAll(target);98		expect(lastError()).toContain("release record does not match");99	});100});101102test("versions compare as numbers, segment by segment", () => {103	const sorted = ["1.10.0", "1.9.9", "2", "1.9", "0.0.0", "1.10", "10.0.0"].sort(compareVersions);104	expect(sorted).toEqual(["0.0.0", "1.9", "1.9.9", "1.10", "1.10.0", "2", "10.0.0"]);105});106107describe("the builds panel", () => {108	let app: Hono;109	let saves: Record<string, string>;110	const get = (path: string) => app.request(path);111112	beforeAll(async () => {113		const repos = await makeRepos(["tool", "secret"]);114		saves = repos.saves;115		const tool = saves.tool as string;116		const b = repos.bucketDir;117		await writeRelease(b, "tool", {118			version: "0.9.0",119			save: tool,120			notes: "## Fixes\n\n- the *first* fix <script>",121			files: [{ platform: "linux-x86_64", name: "tool", bytes: bytes("old") }],122		});123		await writeRelease(b, "tool", {124			version: "0.10.0",125			save: tool,126			publishedAt: 1790100000,127			files: [128				{ platform: "macos-aarch64", name: "Tool.app.zip", bytes: bytes("mac") },129				{ platform: "android-arm64", name: "tool-0.10.0.apk", bytes: bytes("apk") },130			],131		});132		await writeRelease(b, "tool", {133			version: "0.11.0",134			save: "aaaaaaaaaaaa",135			files: [{ platform: "linux-x86_64", name: "tool", bytes: bytes("new") }],136		});137		await writeRelease(b, "secret", {138			version: "1.0.0",139			save: saves.secret as string,140			files: [{ platform: "linux-x86_64", name: "s", bytes: bytes("s") }],141		});142		app = await releaseApp(b, (kepr) => {143			kepr.run("UPDATE repos SET public = 1 WHERE name = 'tool'");144			kepr.run(145				"INSERT INTO downloads (repo, version, platform, count) VALUES ('tool', '0.10.0', 'android-arm64', 7)",146			);147		});148	});149150	test("lists versions newest first with their save, date, notes and files", async () => {151		const res = await get("/tool/releases");152		expect(res.status).toBe(200);153		const body = await res.text();154		const order = ["v0.11.0", "v0.10.0", "v0.9.0"].map((v) => body.indexOf(`>${v}<`));155		expect(order).toEqual([...order].sort((x, y) => x - y));156		expect(order[0]).toBeGreaterThan(0);157		expect(body).toContain(`href="/tool/saves/${saves.tool}"`);158		expect(body).toContain("2026-09-22 18:00");159		expect(body).toContain("<h2>Fixes</h2>");160		expect(body).toContain("&lt;script&gt;");161		// Empty notes: the save's message, or Kepr 2's line when the save is unknown.162		expect(body).toContain("<p>release tool</p>");163		expect(body).toContain("<p>Release 0.11.0 of tool.</p>");164	});165166	test("each file shows its label, size, SHA-256, download count and install hint", async () => {167		const body = await (await get("/tool/releases")).text();168		expect(body).toContain("Android · ARM64");169		expect(body).toContain("macOS · Apple Silicon");170		expect(body).toContain('href="/tool/releases/0.10.0/tool-android-arm64"');171		expect(body).toContain("3 B · 7 downloads");172		expect(body).toContain(new Bun.CryptoHasher("sha256").update("apk").digest("hex"));173		expect(body).toContain("unzip and drag to Applications");174		expect(body).toContain(175			"open on your device to install — allow installs from this source if prompted",176		);177	});178179	test("hidden versions don't show, and the repo header names the newest visible release", async () => {180		expect(await (await get("/tool")).text()).toContain("v0.11.0 · ");181		const kepr = releaseKepr.get(app);182		kepr?.run("INSERT INTO release_settings (repo, version, hidden) VALUES ('tool', '0.11.0', 1)");183		const body = await (await get("/tool/releases")).text();184		expect(body).not.toContain("0.11.0");185		expect(await (await get("/tool")).text()).toContain("v0.10.0 · ");186		kepr?.run("DELETE FROM release_settings");187	});188189	test("a private repo's builds are a 404", async () => {190		expect((await get("/secret/releases")).status).toBe(404);191	});192});193194const releaseKepr = new WeakMap<Hono, Database>();195196/** Syncs a folder remote into a fresh data dir and serves it as kepr.example. */197async function releaseApp(bucketDir: string, setup: (kepr: Database) => void): Promise<Hono> {198	const dataDir = await mkdtemp(join(tmpdir(), "kepr-release-app-"));199	const kepr = openKeprDb(dataDir);200	const bucket = openBucket({ kind: "folder", path: bucketDir });201	await syncAll({202		bucket,203		kepr,204		mirrorDir: join(dataDir, "mirror"),205		tarballDir: join(dataDir, "tarballs"),206	});207	setup(kepr);208	const app = createApp(209		{210			domain: "kepr.example",211			dataDir,212			adminPasswordHash: "$argon2id$x",213			bucket: { kind: "folder", path: bucketDir },214		},215		kepr,216	);217	releaseKepr.set(app, kepr);218	return app;219}