front.test.ts

11.6 KB · raw

1import type { Database } from "bun:sqlite";2import { beforeAll, describe, expect, test } from "bun:test";3import { mkdir, mkdtemp, unlink, 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 { syncAll } from "../src/bucket/sync.ts";9import { openMirror } from "../src/mirror/store.ts";10import { createApp } from "../src/server/app.ts";11import { openKeprDb } from "../src/server/db.ts";12import { makeRepos } from "./fixture.ts";1314let app: Hono;15let kepr: Database;16let dataDir: string;17let alphaSaves: { id: string; message: string; is_checkpoint: boolean }[];18let vaultSave: string;19const body = async (path: string) => (await app.request(path)).text();2021// alpha (public): a save, a checkpoint holding a draft no real save keeps, then a second save.22// vault (private): one save whose message, category, language and ID must never show.23beforeAll(async () => {24	const repos = await makeRepos(["alpha", "vault"]);25	const root = join(repos.bucketDir, "..");26	await writeFile(join(root, "work/alpha/draft.txt"), "a draft only a checkpoint holds\n");27	repos.koh("alpha", "checkpoint", "checkpoint message never shown");28	await unlink(join(root, "work/alpha/draft.txt"));29	await writeFile(join(root, "work/alpha/README.md"), "# alpha\n\nThe alpha project, grown.\n");30	await writeFile(join(root, "work/alpha/main.ts"), "export const a = 1;\n");31	repos.koh("alpha", "save", "grow alpha");32	// HEAD is a checkpoint adding a new language: none of it may reach a public stat.33	await writeFile(join(root, "work/alpha/tool.py"), "print(1)\n");34	repos.koh("alpha", "checkpoint", "checkpoint at head adds python");35	repos.koh("alpha", "offer");36	await writeFile(join(root, "work/vault/main.zig"), "const x = 1;\n");37	repos.koh("vault", "save", "vault message");38	alphaSaves = JSON.parse(repos.koh("alpha", "view", "--json")).saves;39	vaultSave = JSON.parse(repos.koh("vault", "view", "--json")).head_id;4041	dataDir = await mkdtemp(join(tmpdir(), "kepr-front-"));42	kepr = openKeprDb(dataDir);43	const bucket = openBucket({ kind: "folder", path: repos.bucketDir });44	await syncAll({45		bucket,46		kepr,47		mirrorDir: join(dataDir, "mirror"),48	});49	kepr.run("UPDATE repos SET public = 1, category = 'tools' WHERE name = 'alpha'");50	kepr.run("UPDATE repos SET category = 'secretcat' WHERE name = 'vault'");51	for (const [key, value] of [52		["handle", "ada"],53		["tagline", "A library of small tools."],54		["bio", "Writes small tools."],55		["location", "Lisbon"],56		["link", "https://ada.example"],57		["building_since", "may 2026"],58	]) {59		kepr.query("INSERT INTO settings (key, value) VALUES (?, ?)").run(key, value);60	}61	kepr.run(62		`INSERT INTO issues (repo, number, title, body, type, filed_at, hidden) VALUES63		('alpha', 1, 'a', 'b', 'bug', 1, 0), ('alpha', 2, 'c', 'd', 'bug', 1, 1),64		('vault', 1, 'e', 'f', 'bug', 1, 0)`,65	);66	kepr.run(67		`INSERT INTO downloads (repo, version, platform, count) VALUES68		('alpha', '1.0.0', 'linux-x86_64', 7), ('alpha', '0.9.0', 'linux-x86_64', 100),69		('vault', '1.0.0', 'linux-x86_64', 1000)`,70	);71	kepr.run("INSERT INTO release_settings (repo, version, hidden) VALUES ('alpha', '0.9.0', 1)");72	app = createApp(73		{74			domain: "kepr.example",75			dataDir,76			adminPasswordHash: "$argon2id$x",77			bucket: { kind: "folder", path: repos.bucketDir },78		},79		kepr,80	);81});8283const checkpoints = () => alphaSaves.filter((save) => save.is_checkpoint).map((save) => save.id);84const realSaves = () => alphaSaves.filter((save) => !save.is_checkpoint);8586describe("front page", () => {87	test("counts distinct objects of public repos' real saves only", async () => {88		// alpha's real saves hold .kohignore, two READMEs and main.ts; its mirror also holds the89		// draft and tool.py only checkpoints kept, and the private repo's objects are elsewhere.90		const mirror = openMirror(join(dataDir, "mirror"), "alpha");91		expect(mirror.query("SELECT COUNT(*) AS n FROM objects").get()).toEqual({ n: 6 });92		expect(await body("/")).toContain("The library knows<br>4 things.");93	});9495	test("the ticker, cloud and cards show public saves, never checkpoints or private repos", async () => {96		const html = await body("/");97		expect(html).toContain('<div class="ticker" aria-hidden="true">');98		expect(html).toContain("<span>grow alpha</span>");99		expect(html).toContain("<span>release alpha</span>");100		expect(html).toContain('class="tc-tag tc-5">tools<');101		expect(html).toContain('class="tc-tag tc-5">TypeScript<');102		expect(html).toContain('<span class="repo-card-saves mono">2 saves</span>');103		expect(html).toContain("The alpha project, grown.");104		expect(html).toContain("A library of small tools.");105		expect(html).toContain('href="/docs">run your own library →</a>');106		for (const secret of [107			"checkpoint message",108			"vault",109			"secretcat",110			"Zig",111			...checkpoints(),112			vaultSave,113		]) {114			expect(html).not.toContain(secret);115		}116	});117118	test("a checkpoint at head adds nothing to the public stats", async () => {119		const mirror = openMirror(join(dataDir, "mirror"), "alpha");120		const state = (key: string) => mirror.query("SELECT value FROM state WHERE key = ?").get(key);121		expect(checkpoints()).toContain((state("head") as { value: string }).value);122		// Lines and languages are counted at the newest real save, so tool.py is in neither.123		expect(state("stats_save")).toEqual({ value: realSaves()[0]?.id });124		for (const path of ["/", "/@ada", "/alpha"]) {125			const html = await body(path);126			expect(html).toContain("TypeScript");127			expect(html).not.toContain("Python");128		}129		expect(await body("/browse")).not.toContain("Python");130	});131132	test("the nav has browse, docs, support and log in, as Kepr 2's", async () => {133		const html = await body("/");134		expect(html).toContain(135			'<a href="/browse">browse</a><a href="/docs">docs</a><a href="/support">support</a><a href="/sign-in?next=/">log in</a>',136		);137	});138139	test("browse lists public repos as Kepr 2's cards: name, saves, description, category", async () => {140		const html = await body("/browse");141		expect(html).toContain('<a class="repo-card" href="/alpha">');142		expect(html).toContain(`${realSaves().length} saves</span>`);143		expect(html).toContain('<p class="repo-card-cat">tools</p>');144		expect(html).not.toContain("vault");145	});146});147148describe("profile", () => {149	test("a handle that isn't the configured one is a 404", async () => {150		expect((await app.request("/@ada")).status).toBe(200);151		expect((await app.request("/@someone")).status).toBe(404);152		expect((await app.request("/@")).status).toBe(404);153	});154155	test("stats leave out private repos, checkpoints, hidden issues and hidden releases", async () => {156		const numbers = [...(await body("/@ada")).matchAll(/profile-stat-number">([^<]*)</g)].map(157			(m) => m[1],158		);159		// repos, saves, issues, downloads160		expect(numbers).toEqual(["1", String(realSaves().length), "1", "7"]);161	});162163	test("recent activity and the hex backdrop use real public saves only", async () => {164		const html = await body("/@ada");165		expect(html).toContain('class="profile-timeline" tabindex="0"');166		expect(html).toContain('role="region" aria-label="recent activity"');167		for (const save of realSaves()) {168			expect(html).toContain(`<span class="hex-col">${save.id}</span>`);169			expect(html).toContain(`href="/alpha/saves/${save.id}">${save.message}</a>`);170		}171		for (const id of checkpoints()) expect(html).not.toContain(id);172		expect(html).not.toContain(vaultSave);173		expect(html).not.toContain("vault");174		expect(html).not.toContain("Zig");175	});176177	test("shows the settings, the handle in the signature font, and the language bar", async () => {178		const html = await body("/@ada");179		expect(html).toContain('<h1 class="profile-username">ada</h1>');180		expect(html).toContain("building since may 2026");181		expect(html).toContain("◈ operator");182		expect(html).toContain("Writes small tools.");183		expect(html).toContain("Lisbon");184		expect(html).toContain('href="https://ada.example"');185		expect(html).toContain('<span class="profile-lang-label">TypeScript</span>');186		expect(html).toContain('<meta property="og:title" content="@ada — kepr">');187	});188189	test("an uploaded signature is served from data/assets and replaces the handle", async () => {190		await mkdir(join(dataDir, "assets"), { recursive: true });191		await writeFile(join(dataDir, "assets/sig.svg"), "<svg xmlns='http://www.w3.org/2000/svg'/>");192		kepr.run("INSERT INTO settings (key, value) VALUES ('signature', 'sig.svg')");193		try {194			expect(await body("/@ada")).toContain(195				'<img class="profile-signature" src="/static/assets/sig.svg" alt="ada">',196			);197			const asset = await app.request("/static/assets/sig.svg");198			expect(asset.status).toBe(200);199			expect(asset.headers.get("content-type")).toContain("image/svg+xml");200			await mkdir(join(dataDir, "assets/logos"));201			await writeFile(join(dataDir, "assets/logos/mark.svg"), "<svg/>");202			expect((await app.request("/static/assets/logos/mark.svg")).status).toBe(200);203			// `..` spelled so the URL parser leaves it for the route, and a backslash; kepr.db sits204			// right above assets/, so any of these reaching the disk would serve it.205			for (const path of [206				"/static/assets/../kepr.db",207				"/static/assets/..%2Fkepr.db",208				"/static/assets/%2e%2e%2fkepr.db",209				"/static/assets/..%5Ckepr.db",210				"/static/assets%2F..%2Fkepr.db",211				"/static/%00",212				"/static/assets/%00",213				"/static/css/%01kepr.css",214			]) {215				expect((await app.request(path)).status).toBe(404);216			}217		} finally {218			kepr.run("DELETE FROM settings WHERE key = 'signature'");219		}220	});221222	test("with no handle set there is no profile", async () => {223		kepr.run("UPDATE settings SET value = '' WHERE key = 'handle'");224		try {225			expect((await app.request("/@ada")).status).toBe(404);226		} finally {227			kepr.run("UPDATE settings SET value = 'ada' WHERE key = 'handle'");228		}229	});230});231232describe("docs and support", () => {233	test("render the repo's Markdown, links left as written", async () => {234		const docs = await body("/docs");235		expect(docs).toContain("<h1>Your work, in public</h1>");236		expect(docs).toContain("<code>/setup</code>");237		const support = await body("/support");238		expect(support).toContain('<a href="/docs">self-hosting docs</a>');239		expect(support).toContain('<meta name="description" content="Found a bug');240	});241});242243test("motion stops under prefers-reduced-motion", async () => {244	const css = await Bun.file(join(import.meta.dir, "../public/css/kepr.css")).text();245	const blocks = [...css.matchAll(/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g)]246		.map((m) => m[1] as string)247		.join("\n");248	for (const selector of [".ticker-row", ".ticker-track", ".hex-col", ".tri-outer"]) {249		expect(blocks).toContain(selector);250	}251	expect(blocks).toContain("animation: none;");252});253254test("a file two public repos share is one thing the library knows", async () => {255	const repos = await makeRepos(["one", "two"]);256	for (const repo of ["one", "two"]) {257		await writeFile(join(repos.bucketDir, "..", "work", repo, "LICENSE"), "the same licence\n");258		repos.koh(repo, "save", "add the licence");259		repos.koh(repo, "offer");260	}261	const dir = await mkdtemp(join(tmpdir(), "kepr-front-"));262	const db = openKeprDb(dir);263	const bucket = openBucket({ kind: "folder", path: repos.bucketDir });264	await syncAll({ bucket, kepr: db, mirrorDir: join(dir, "mirror") });265	db.run("UPDATE repos SET public = 1");266	const shared = createApp(267		{268			domain: "kepr.example",269			dataDir: dir,270			adminPasswordHash: "$argon2id$x",271			bucket: { kind: "folder", path: repos.bucketDir },272		},273		db,274	);275	// Two READMEs that differ; koh's default .kohignore and the licence, each shared by both.276	expect(await (await shared.request("/")).text()).toContain("The library knows<br>4 things.");277});