fixture.ts

8.8 KB · raw

1import { mkdir, mkdtemp, writeFile } from "node:fs/promises";2import { tmpdir } from "node:os";3import { join } from "node:path";4import { blake3 } from "@noble/hashes/blake3.js";5import { bytesToHex } from "@noble/hashes/utils.js";6import type { ReleaseFile } from "../src/bucket/repo.ts";78/**9 * §4.3 of the resolutions: these tests are pinned to one koh, so a stale binary cannot make a pass10 * look real. The version is checked before any fixture runs; the message says how to fix it.11 */12const PINNED_KOH = (await Bun.file(join(import.meta.dir, "koh-version.txt")).text()).trim();13const RUNNING_KOH = (() => {14	try {15		return Bun.spawnSync(["koh", "--version"]).stdout.toString().trim();16	} catch {17		return "";18	}19})();20if (RUNNING_KOH !== `koh ${PINNED_KOH}`) {21	throw new Error(22		`these tests are pinned to koh ${PINNED_KOH}, but koh on PATH reports "${RUNNING_KOH}" — ` +23			"install the matching koh (see koh's AGENTS.md), or update tests/koh-version.txt " +24			"once you have verified the new one",25	);26}2728export type Fixture = {29	bucketDir: string;30	repo: string;31	repoDir: string;32	/** Every save, newest first, checkpoint included. */33	saveIds: string[];34	/** Strips a path from every save with koh, so tests can sync before and after. */35	strip(path: string): Promise<void>;36};3738// The smallest valid PNG (1×1), so the fixture has a real binary file.39const PNG = Uint8Array.from(40	atob(41		"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",42	),43	(c) => c.charCodeAt(0),44);4546const README = `<!-- a leading comment and a centred title are not the intro -->4748<p align="center">DEMO</p>4950# Demo5152A small repo for **Kepr's** tests.5354See [the guide](docs/guide.md), [the site](https://example.com) and [a trap](javascript:alert(1)).5556![logo](docs/logo.png)5758<script>alert("raw html")</script>59`;6061/**62 * Builds a genuine Koh 3 folder remote with koh itself, under a throwaway HOME. The line is:63 * "add docs, a secret and a deep helper" (flagged) → a second later "add readme and entry point"64 * (agent and session) → a checkpoint holding an edit to the guide → "say hello" (head).65 */66export async function makeKohBucket(repo = "demo"): Promise<Fixture> {67	const root = await mkdtemp(join(tmpdir(), "kepr-fixture-"));68	const home = join(root, "home");69	const bucketDir = join(root, "bucket");70	const repoDir = join(root, "work", repo);71	await mkdir(home);72	await mkdir(join(repoDir, "src/lib/deep"), { recursive: true });73	await mkdir(join(repoDir, "docs"));74	await mkdir(join(repoDir, "vendor"));75	const koh = (...args: string[]) => run(repoDir, home, args);7677	koh("init");78	koh("remote", "folder", bucketDir);79	await writeFile(join(repoDir, "docs/guide.md"), "# Guide\n\nRead me.\n");80	await writeFile(join(repoDir, "docs/logo.png"), PNG);81	await writeFile(join(repoDir, "secret.txt"), "hunter2\n");82	await writeFile(83		join(repoDir, "src/lib/deep/util.ts"),84		"// The *one* helper. Deep down.\nexport const one = 1;\n",85	);86	await writeFile(join(repoDir, "vendor/big.c"), "int x;\n".repeat(100));87	koh("save", "add docs, a secret and a deep helper");88	const first = JSON.parse(koh("view", "--json")).head_id as string;89	koh("flag", first, "--reason", "holds a secret");90	// Saves a second apart, so the timeline has time to spread over.91	Bun.sleepSync(1100);92	await writeFile(join(repoDir, "README.md"), README);93	await writeFile(join(repoDir, "src/main.ts"), "// Entry point.\nconsole.log('hi');\n");94	koh("save", "add readme and entry point", "--agent", "test-agent", "--session", "fixture");95	// The guide's edit is first recorded by the checkpoint; the Atlas must credit it to "say hello".96	await writeFile(join(repoDir, "docs/guide.md"), "# Guide\n\nRead me. It is short.\n");97	koh("checkpoint", "before the second change");98	await writeFile(join(repoDir, "src/main.ts"), "// Entry point.\nconsole.log('hello');\n");99	koh("save", "say hello");100	koh("offer");101102	const log = JSON.parse(koh("view", "--json")) as { saves: { id: string }[] };103	return {104		bucketDir,105		repo,106		repoDir,107		saveIds: log.saves.map((save) => save.id),108		strip: (path) => stripPath(repoDir, home, path),109	};110}111112function run(cwd: string, home: string, args: string[]): string {113	const result = Bun.spawnSync(["koh", ...args], { cwd, env: env(home) });114	if (result.exitCode !== 0) {115		throw new Error(116			`koh ${args.join(" ")} failed: ${result.stderr.toString()}${result.stdout.toString()}`,117		);118	}119	return result.stdout.toString();120}121122function env(home: string) {123	return { PATH: process.env.PATH, HOME: home, NO_COLOR: "1" };124}125126/** `koh strip` insists on a terminal and a typed confirmation, so it runs under a Bun PTY. */127export async function stripPath(cwd: string, home: string, path: string): Promise<void> {128	let output = "";129	const proc = Bun.spawn(["koh", "strip", path], {130		cwd,131		env: env(home),132		terminal: { cols: 120, rows: 24, data: (_term, data) => (output += data.toString()) },133	});134	proc.terminal?.write(`y\n${path}\n`);135	const code = await proc.exited;136	proc.terminal?.close();137	if (code !== 0) throw new Error(`koh strip ${path} failed: ${output}`);138}139140/** What `makeRepos` hands back: the folder remote, its saves, and a way to run koh in a repo. */141export type FixtureRepos = {142	bucketDir: string;143	saves: Record<string, string>;144	/** Runs koh in one of the repos, under the fixture's HOME. */145	koh(repo: string, ...args: string[]): string;146};147148/** Repos with one koh save each, all offered to one folder remote: enough history for releases. */149export async function makeRepos(names: string[]): Promise<FixtureRepos> {150	const root = await mkdtemp(join(tmpdir(), "kepr-repos-"));151	const home = join(root, "home");152	const bucketDir = join(root, "bucket");153	await mkdir(home);154	const saves: Record<string, string> = {};155	for (const [i, name] of names.entries()) {156		const repoDir = join(root, "work", name);157		await mkdir(repoDir, { recursive: true });158		run(repoDir, home, ["init"]);159		if (i === 0) run(repoDir, home, ["remote", "folder", bucketDir]);160		await writeFile(join(repoDir, "README.md"), `# ${name}\n\nThe ${name} project.\n`);161		run(repoDir, home, ["save", `release ${name}`]);162		saves[name] = JSON.parse(run(repoDir, home, ["view", "--json"])).head_id;163	}164	return {165		bucketDir,166		saves,167		koh: (repo, ...args) => run(join(root, "work", repo), home, args),168	};169}170171/** A throwaway HOME, a repo folder and a bucket, with koh runnable inside (§4.3's pinned binary). */172export async function kohWorkspace(repo = "shipped"): Promise<{173	root: string;174	home: string;175	repoDir: string;176	bucketDir: string;177	run(...args: string[]): string;178}> {179	const root = await mkdtemp(join(tmpdir(), "kepr-workspace-"));180	const home = join(root, "home");181	const repoDir = join(root, "work", repo);182	const bucketDir = join(root, "bucket");183	await mkdir(home);184	await mkdir(repoDir, { recursive: true });185	return { root, home, repoDir, bucketDir, run: (...args) => run(repoDir, home, args) };186}187188/** A release file with its bytes, or only as a record declares it (the bytes are then a stand-in). */189export type FixtureFile = { platform: string; name: string; bytes: Uint8Array } | ReleaseFile;190191export type FixtureRelease = {192	version: string;193	save: string;194	notes?: string;195	publishedAt?: number;196	files: FixtureFile[];197};198199/**200 * Publishes a release straight into a folder remote, in §11.2's order: the files, then release.json,201 * then `releases/latest`. `kohWorkspace` plus `koh offer --builds` is the real thing202 * (tests/kohrelease.test.ts); this writes them by hand for fixtures that need several versions, an203 * old release, or hashes that no build produced.204 */205export async function writeRelease(206	bucketDir: string,207	repo: string,208	release: FixtureRelease,209	latest = true,210): Promise<void> {211	const dir = join(bucketDir, repo, "releases", release.version);212	const files: ReleaseFile[] = [];213	for (const file of release.files) {214		const bytes = "bytes" in file ? file.bytes : new TextEncoder().encode(`stand-in ${file.name}`);215		await mkdir(join(dir, file.platform), { recursive: true });216		await writeFile(join(dir, file.platform, file.name), bytes);217		files.push(218			"bytes" in file219				? {220						platform: file.platform,221						name: file.name,222						size: bytes.length,223						blake3: bytesToHex(blake3(bytes)),224						sha256: new Bun.CryptoHasher("sha256").update(bytes).digest("hex"),225					}226				: file,227		);228	}229	const record = {230		format: 1,231		repo,232		version: release.version,233		save: release.save,234		published_at: release.publishedAt ?? 1790000000,235		machine: "fixture",236		notes: release.notes ?? "",237		files,238	};239	await writeFile(join(dir, "release.json"), JSON.stringify(record, null, 2));240	if (latest) await writeFile(join(bucketDir, repo, "releases", "latest"), release.version);241}242243/** This machine's platform, which is what `koh build` builds for with no flags. */244export function platform(): string {245	const os = process.platform === "darwin" ? "macos" : "linux";246	const arch = process.arch === "arm64" ? "aarch64" : "x86_64";247	return `${os}-${arch}`;248}