fixture.ts

7.0 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";78export type Fixture = {9	bucketDir: string;10	repo: string;11	repoDir: string;12	/** Every save, newest first, checkpoint included. */13	saveIds: string[];14	/** Strips a path from every save with koh3, so tests can sync before and after. */15	strip(path: string): Promise<void>;16};1718// The smallest valid PNG (1×1), so the fixture has a real binary file.19const PNG = Uint8Array.from(20	atob(21		"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",22	),23	(c) => c.charCodeAt(0),24);2526const README = `<!-- a leading comment and a centred title are not the intro -->2728<p align="center">DEMO</p>2930# Demo3132A small repo for **Kepr's** tests.3334See [the guide](docs/guide.md), [the site](https://example.com) and [a trap](javascript:alert(1)).3536![logo](docs/logo.png)3738<script>alert("raw html")</script>39`;4041/**42 * Builds a genuine Koh 3 folder remote with koh3 itself, under a throwaway HOME. The line is:43 * "add docs, a secret and a deep helper" (flagged) → a second later "add readme and entry point"44 * (agent and session) → a checkpoint holding an edit to the guide → "say hello" (head).45 */46export async function makeKohBucket(repo = "demo"): Promise<Fixture> {47	const root = await mkdtemp(join(tmpdir(), "kepr-fixture-"));48	const home = join(root, "home");49	const bucketDir = join(root, "bucket");50	const repoDir = join(root, "work", repo);51	await mkdir(home);52	await mkdir(join(repoDir, "src/lib/deep"), { recursive: true });53	await mkdir(join(repoDir, "docs"));54	await mkdir(join(repoDir, "vendor"));55	const koh = (...args: string[]) => run(repoDir, home, args);5657	koh("init");58	koh("remote", "folder", bucketDir);59	await writeFile(join(repoDir, "docs/guide.md"), "# Guide\n\nRead me.\n");60	await writeFile(join(repoDir, "docs/logo.png"), PNG);61	await writeFile(join(repoDir, "secret.txt"), "hunter2\n");62	await writeFile(63		join(repoDir, "src/lib/deep/util.ts"),64		"// The *one* helper. Deep down.\nexport const one = 1;\n",65	);66	await writeFile(join(repoDir, "vendor/big.c"), "int x;\n".repeat(100));67	koh("save", "add docs, a secret and a deep helper");68	const first = JSON.parse(koh("log", "--json")).head_id as string;69	koh("flag", first, "--reason", "holds a secret");70	// Saves a second apart, so the timeline has time to spread over.71	Bun.sleepSync(1100);72	await writeFile(join(repoDir, "README.md"), README);73	await writeFile(join(repoDir, "src/main.ts"), "// Entry point.\nconsole.log('hi');\n");74	koh("save", "add readme and entry point", "--agent", "test-agent", "--session", "fixture");75	// The guide's edit is first recorded by the checkpoint; the Atlas must credit it to "say hello".76	await writeFile(join(repoDir, "docs/guide.md"), "# Guide\n\nRead me. It is short.\n");77	koh("checkpoint", "before the second change");78	await writeFile(join(repoDir, "src/main.ts"), "// Entry point.\nconsole.log('hello');\n");79	koh("save", "say hello");80	koh("offer");8182	const log = JSON.parse(koh("log", "--json")) as { saves: { id: string }[] };83	return {84		bucketDir,85		repo,86		repoDir,87		saveIds: log.saves.map((save) => save.id),88		strip: (path) => strip(repoDir, home, path),89	};90}9192function run(cwd: string, home: string, args: string[]): string {93	const result = Bun.spawnSync(["koh3", ...args], { cwd, env: env(home) });94	if (result.exitCode !== 0) {95		throw new Error(96			`koh3 ${args.join(" ")} failed: ${result.stderr.toString()}${result.stdout.toString()}`,97		);98	}99	return result.stdout.toString();100}101102function env(home: string) {103	return { PATH: process.env.PATH, HOME: home, NO_COLOR: "1" };104}105106// `koh strip` insists on a terminal and a typed confirmation, so it runs under a Bun PTY.107async function strip(cwd: string, home: string, path: string): Promise<void> {108	let output = "";109	const proc = Bun.spawn(["koh3", "strip", path], {110		cwd,111		env: env(home),112		terminal: { cols: 120, rows: 24, data: (_term, data) => (output += data.toString()) },113	});114	proc.terminal?.write(`y\n${path}\n`);115	const code = await proc.exited;116	proc.terminal?.close();117	if (code !== 0) throw new Error(`koh3 strip ${path} failed: ${output}`);118}119120/** Repos with one koh3 save each, all offered to one folder remote: enough history for releases. */121export async function makeRepos(names: string[]): Promise<{122	bucketDir: string;123	saves: Record<string, string>;124	/** Runs koh3 in one of the repos, under the fixture's HOME. */125	koh(repo: string, ...args: string[]): string;126}> {127	const root = await mkdtemp(join(tmpdir(), "kepr-repos-"));128	const home = join(root, "home");129	const bucketDir = join(root, "bucket");130	await mkdir(home);131	const saves: Record<string, string> = {};132	for (const [i, name] of names.entries()) {133		const repoDir = join(root, "work", name);134		await mkdir(repoDir, { recursive: true });135		run(repoDir, home, ["init"]);136		if (i === 0) run(repoDir, home, ["remote", "folder", bucketDir]);137		await writeFile(join(repoDir, "README.md"), `# ${name}\n\nThe ${name} project.\n`);138		run(repoDir, home, ["save", `release ${name}`]);139		saves[name] = JSON.parse(run(repoDir, home, ["log", "--json"])).head_id;140	}141	return {142		bucketDir,143		saves,144		koh: (repo, ...args) => run(join(root, "work", repo), home, args),145	};146}147148/** A release file with its bytes, or only as a record declares it (the bytes are then a stand-in). */149export type FixtureFile = { platform: string; name: string; bytes: Uint8Array } | ReleaseFile;150151export type FixtureRelease = {152	version: string;153	save: string;154	notes?: string;155	publishedAt?: number;156	files: FixtureFile[];157};158159/**160 * Publishes a release into a folder remote as `koh offer --builds` does (§11.2): the files, then161 * release.json, then `releases/latest`. koh3 3.0.72 has no `--builds`, so this writes them itself.162 */163export async function writeRelease(164	bucketDir: string,165	repo: string,166	release: FixtureRelease,167	latest = true,168): Promise<void> {169	const dir = join(bucketDir, repo, "releases", release.version);170	const files: ReleaseFile[] = [];171	for (const file of release.files) {172		const bytes = "bytes" in file ? file.bytes : new TextEncoder().encode(`stand-in ${file.name}`);173		await mkdir(join(dir, file.platform), { recursive: true });174		await writeFile(join(dir, file.platform, file.name), bytes);175		files.push(176			"bytes" in file177				? {178						platform: file.platform,179						name: file.name,180						size: bytes.length,181						blake3: bytesToHex(blake3(bytes)),182						sha256: new Bun.CryptoHasher("sha256").update(bytes).digest("hex"),183					}184				: file,185		);186	}187	const record = {188		format: 1,189		repo,190		version: release.version,191		save: release.save,192		published_at: release.publishedAt ?? 1790000000,193		machine: "fixture",194		notes: release.notes ?? "",195		files,196	};197	await writeFile(join(dir, "release.json"), JSON.stringify(record, null, 2));198	if (latest) await writeFile(join(bucketDir, repo, "releases", "latest"), release.version);199}