1import { afterAll, beforeAll, describe, expect, test } from "bun:test";2import { cp, mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises";3import { tmpdir } from "node:os";4import { join } from "node:path";5import type { Server } from "bun";6import { openBucket } from "../src/bucket/reader.ts";7import { syncAll } from "../src/bucket/sync.ts";8import { createApp } from "../src/server/app.ts";9import { openKeprDb } from "../src/server/db.ts";10import { kohWorkspace } from "./fixture.ts";11121314151617181920212223const NEW_VERSION = "9.9.9";24const REPO = "koh";25const NOTES =26 "the updater replaces itself, end to end, through a Kepr that serves the release it was told about";272829type Feed = {30 version: string;31 notes?: string;32 platforms: Record<string, { url: string; blake3: string; sha256: string }>;33};343536type ReleaseRecord = { files: { platform: string; name: string }[] };373839type UpdateJson = { available?: boolean; installed?: string };4041describe("koh3 updating itself through Kepr", () => {42 let server: Server<undefined>;43 let checkSource = "";44 let installSource = "";45 let installed = "";46 let published = "";47 let checks: {48 code: number;49 json: UpdateJson;50 install: UpdateJson;51 refused: string;52 refusedStdout: string;53 baseline: Uint8Array;54 kept: Uint8Array;55 target: string;56 };5758 beforeAll(async () => {59 const space = await kohWorkspace(REPO);60 const koh = Bun.which("koh3") as string;61 const platformName = platform();62 installed = join(space.root, "bin", "koh3");63 published = join(space.repoDir, "dist", "koh3");64 await mkdir(join(space.root, "bin"), { recursive: true });65 await mkdir(join(space.repoDir, "dist"), { recursive: true });66 await cp(koh, installed);67 await cp(koh, published);68 const baseline = new Uint8Array(await Bun.file(installed).arrayBuffer());6970 await writeFile(join(space.repoDir, "VERSION"), `${NEW_VERSION}\n`);71 await writeFile(72 join(space.repoDir, "koh.build"),73 [74 "# a release is one file: this copies the one the test prepared",75 "command = mkdir -p out && cp dist/koh3 out/koh3",76 "output = out/koh3",77 `platforms = ${platformName}`,78 "",79 ].join("\n"),80 );81 space.run("init");82 space.run("remote", "folder", space.bucketDir);83 space.run("save", "the new koh");84 space.run("offer", "--builds", "--notes", NOTES);8586 const dataDir = await mkdtemp(join(tmpdir(), "kepr-update-"));87 const kepr = openKeprDb(dataDir);88 await syncAll({89 bucket: openBucket({ kind: "folder", path: space.bucketDir }),90 kepr,91 mirrorDir: join(dataDir, "mirror"),92 tarballDir: join(dataDir, "tarballs"),93 });94 kepr.run("UPDATE repos SET public = 1 WHERE name = 'koh'");9596 97 98 const config = (domain: string) => ({99 domain,100 dataDir,101 adminPasswordHash: "$argon2id$x",102 bucket: { kind: "folder" as const, path: space.bucketDir },103 });104 let app = createApp(config("127.0.0.1:1"), kepr);105 let local: Feed | null = null;106 const localPath = `/${REPO}/releases/latest.local.json`;107 server = Bun.serve({108 port: 0,109 fetch: (request) => {110 const url = new URL(request.url);111 if (url.pathname === localPath && local) return Response.json(local);112 return app.fetch(request);113 },114 });115 app = createApp(config(`127.0.0.1:${server.port}`), kepr);116 checkSource = `http://127.0.0.1:${server.port}/${REPO}/releases/latest.json`;117 installSource = `http://127.0.0.1:${server.port}${localPath}`;118119 120 121 const served: Feed = (await (await fetch(checkSource)).json()) as Feed;122 const record: ReleaseRecord = (await Bun.file(123 join(space.bucketDir, REPO, "releases", NEW_VERSION, "release.json"),124 ).json()) as ReleaseRecord;125 const artifact = record.files.find((file) => file.platform === platformName);126 if (!artifact) throw new Error("the published record has no file for this platform");127 const artifactPath = join(128 space.bucketDir,129 REPO,130 "releases",131 NEW_VERSION,132 platformName,133 artifact.name,134 );135 local = {136 ...served,137 platforms: {138 ...served.platforms,139 [platformName]: { ...served.platforms[platformName], url: `file://${artifactPath}` },140 },141 };142143 const check = await run(installed, space.home, checkSource, "update", "--check");144 const checkJson = await run(installed, space.home, checkSource, "update", "--check", "--json");145 const refused = await run(installed, space.home, checkSource, "update", "--yes", "--json");146 const kept = new Uint8Array(await Bun.file(installed).arrayBuffer());147 const install = await run(installed, space.home, installSource, "update", "--yes", "--json");148 checks = {149 code: check.exitCode,150 json: JSON.parse(checkJson.stdout) as UpdateJson,151 install: JSON.parse(install.stdout) as UpdateJson,152 refused: refused.stderr,153 refusedStdout: refused.stdout,154 baseline,155 kept,156 157 target: await realpath(installed),158 };159 });160161 afterAll(() => {162 server?.stop(true);163 });164165 166167168169170 async function run(path: string, home: string, source: string, ...args: string[]) {171 const proc = Bun.spawn([path, ...args], {172 env: { PATH: process.env.PATH, HOME: home, NO_COLOR: "1", KOH_UPDATE_SOURCE: source },173 stdout: "pipe",174 stderr: "pipe",175 });176 const [stdout, stderr, exitCode] = await Promise.all([177 new Response(proc.stdout).text(),178 new Response(proc.stderr).text(),179 proc.exited,180 ]);181 return { stdout, stderr, exitCode };182 }183184 test("--check reports an update and exits 1, and --json says the same thing", () => {185 expect(checks.code).toBe(1);186 expect(checks.json.available).toBe(true);187 });188189 test("a download it cannot reach is refused, and the binary is left alone", () => {190 191 192 expect(checks.refused).toContain("could not download");193 expect(checks.refusedStdout).toBe("");194 expect(Buffer.compare(checks.kept, checks.baseline)).toBe(0);195 });196197 test("the update replaced the running binary with the published file", async () => {198 expect(checks.install.installed).toBe(checks.target);199 const downloaded = new Uint8Array(await Bun.file(installed).arrayBuffer());200 const released = new Uint8Array(await Bun.file(published).arrayBuffer());201 if (process.platform === "darwin") {202 203 204 205 206 const signed = Bun.spawn(["codesign", "--verify", installed]);207 expect(await signed.exited).toBe(0);208 } else {209 expect(Buffer.compare(downloaded, released)).toBe(0);210 }211 212 const version = (await run(installed, tmpdir(), checkSource, "--version")).stdout.trim();213 expect(version.startsWith("koh ")).toBe(true);214 });215});216217218function platform(): string {219 const os = process.platform === "darwin" ? "macos" : "linux";220 const arch = process.arch === "arm64" ? "aarch64" : "x86_64";221 return `${os}-${arch}`;222}