front.test.ts

8.9 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	await writeFile(join(root, "work/vault/main.zig"), "const x = 1;\n");33	repos.koh("vault", "save", "vault message");34	alphaSaves = JSON.parse(repos.koh("alpha", "log", "--json")).saves;35	vaultSave = JSON.parse(repos.koh("vault", "log", "--json")).head_id;3637	dataDir = await mkdtemp(join(tmpdir(), "kepr-front-"));38	kepr = openKeprDb(dataDir);39	const bucket = openBucket({ kind: "folder", path: repos.bucketDir });40	await syncAll({41		bucket,42		kepr,43		mirrorDir: join(dataDir, "mirror"),44		tarballDir: join(dataDir, "tarballs"),45	});46	kepr.run("UPDATE repos SET public = 1, category = 'tools' WHERE name = 'alpha'");47	kepr.run("UPDATE repos SET category = 'secretcat' WHERE name = 'vault'");48	for (const [key, value] of [49		["handle", "ada"],50		["tagline", "A library of small tools."],51		["bio", "Writes small tools."],52		["location", "Lisbon"],53		["link", "https://ada.example"],54		["building_since", "may 2026"],55	]) {56		kepr.query("INSERT INTO settings (key, value) VALUES (?, ?)").run(key, value);57	}58	kepr.run(59		`INSERT INTO issues (repo, number, title, body, type, filed_at, hidden) VALUES60		('alpha', 1, 'a', 'b', 'bug', 1, 0), ('alpha', 2, 'c', 'd', 'bug', 1, 1),61		('vault', 1, 'e', 'f', 'bug', 1, 0)`,62	);63	kepr.run(64		`INSERT INTO downloads (repo, version, platform, count) VALUES65		('alpha', '1.0.0', 'linux-x86_64', 7), ('alpha', '0.9.0', 'linux-x86_64', 100),66		('vault', '1.0.0', 'linux-x86_64', 1000)`,67	);68	kepr.run("INSERT INTO release_settings (repo, version, hidden) VALUES ('alpha', '0.9.0', 1)");69	app = createApp(70		{71			domain: "kepr.example",72			dataDir,73			adminPasswordHash: "$argon2id$x",74			bucket: { kind: "folder", path: repos.bucketDir },75		},76		kepr,77	);78});7980const checkpoint = () => alphaSaves.find((save) => save.is_checkpoint)?.id as string;81const realSaves = () => alphaSaves.filter((save) => !save.is_checkpoint);8283describe("front page", () => {84	test("counts distinct objects of public repos' real saves only", async () => {85		// alpha's real saves hold .kohignore, two READMEs and main.ts; its mirror also holds the86		// draft only the checkpoint kept, and the private repo's objects are elsewhere.87		const mirror = openMirror(join(dataDir, "mirror"), "alpha");88		expect(mirror.query("SELECT COUNT(*) AS n FROM objects").get()).toEqual({ n: 5 });89		expect(await body("/")).toContain("The library knows<br>4 things.");90	});9192	test("the ticker, cloud and cards show public saves, never checkpoints or private repos", async () => {93		const html = await body("/");94		expect(html).toContain('<div class="ticker" aria-hidden="true">');95		expect(html).toContain("<span>grow alpha</span>");96		expect(html).toContain("<span>release alpha</span>");97		expect(html).toContain('class="tc-tag tc-5">tools<');98		expect(html).toContain('class="tc-tag tc-5">TypeScript<');99		expect(html).toContain('<span class="repo-card-saves mono">2 saves</span>');100		expect(html).toContain("The alpha project, grown.");101		expect(html).toContain("A library of small tools.");102		expect(html).toContain('href="/docs">run your own library →</a>');103		for (const secret of [104			"checkpoint message",105			"vault",106			"secretcat",107			"Zig",108			checkpoint(),109			vaultSave,110		]) {111			expect(html).not.toContain(secret);112		}113	});114115	test("the nav has browse, docs and support, and no login link", async () => {116		const html = await body("/");117		expect(html).toContain(118			'<a href="/browse">browse</a><a href="/docs">docs</a><a href="/support">support</a>',119		);120		expect(html).not.toContain("sign-in");121	});122123	test("browse lists public repos as cards", async () => {124		const html = await body("/browse");125		expect(html).toContain('<a class="repo-card" href="/alpha">');126		expect(html).not.toContain("vault");127		// One tick per real save.128		expect(html.match(/<line x1=/g)?.length).toBe(realSaves().length);129	});130});131132describe("profile", () => {133	test("a handle that isn't the configured one is a 404", async () => {134		expect((await app.request("/@ada")).status).toBe(200);135		expect((await app.request("/@someone")).status).toBe(404);136		expect((await app.request("/@")).status).toBe(404);137	});138139	test("stats leave out private repos, checkpoints, hidden issues and hidden releases", async () => {140		const numbers = [...(await body("/@ada")).matchAll(/profile-stat-number">([^<]*)</g)].map(141			(m) => m[1],142		);143		// repos, saves, issues, downloads144		expect(numbers).toEqual(["1", String(realSaves().length), "1", "7"]);145	});146147	test("recent activity and the hex backdrop use real public saves only", async () => {148		const html = await body("/@ada");149		for (const save of realSaves()) {150			expect(html).toContain(`<span class="hex-col">${save.id}</span>`);151			expect(html).toContain(`href="/alpha/saves/${save.id}">${save.message}</a>`);152		}153		expect(html).not.toContain(checkpoint());154		expect(html).not.toContain(vaultSave);155		expect(html).not.toContain("vault");156		expect(html).not.toContain("Zig");157	});158159	test("shows the settings, the handle in the signature font, and the language bar", async () => {160		const html = await body("/@ada");161		expect(html).toContain('<h1 class="profile-username">ada</h1>');162		expect(html).toContain("building since may 2026");163		expect(html).toContain("◈ operator");164		expect(html).toContain("Writes small tools.");165		expect(html).toContain("Lisbon");166		expect(html).toContain('href="https://ada.example"');167		expect(html).toContain('<span class="profile-lang-label">TypeScript</span>');168		expect(html).toContain('<meta property="og:title" content="@ada — kepr">');169	});170171	test("an uploaded signature is served from data/assets and replaces the handle", async () => {172		await mkdir(join(dataDir, "assets"), { recursive: true });173		await writeFile(join(dataDir, "assets/sig.svg"), "<svg xmlns='http://www.w3.org/2000/svg'/>");174		kepr.run("INSERT INTO settings (key, value) VALUES ('signature', 'sig.svg')");175		try {176			expect(await body("/@ada")).toContain(177				'<img class="profile-signature" src="/static/assets/sig.svg" alt="ada">',178			);179			const asset = await app.request("/static/assets/sig.svg");180			expect(asset.status).toBe(200);181			expect(asset.headers.get("content-type")).toContain("image/svg+xml");182			expect((await app.request("/static/assets/../kepr.db")).status).toBe(404);183		} finally {184			kepr.run("DELETE FROM settings WHERE key = 'signature'");185		}186	});187188	test("with no handle set there is no profile", async () => {189		kepr.run("UPDATE settings SET value = '' WHERE key = 'handle'");190		try {191			expect((await app.request("/@ada")).status).toBe(404);192		} finally {193			kepr.run("UPDATE settings SET value = 'ada' WHERE key = 'handle'");194		}195	});196});197198describe("docs and support", () => {199	test("render the repo's Markdown, links left as written", async () => {200		const docs = await body("/docs");201		expect(docs).toContain("<h1>Run your own library</h1>");202		expect(docs).toContain("<code>admin_password_hash</code>");203		const support = await body("/support");204		expect(support).toContain('<a href="/docs">self-hosting docs</a>');205		expect(support).toContain('<meta name="description" content="Found a bug');206	});207});208209test("motion stops under prefers-reduced-motion", async () => {210	const css = await Bun.file(join(import.meta.dir, "../public/css/kepr.css")).text();211	const blocks = [...css.matchAll(/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g)]212		.map((m) => m[1] as string)213		.join("\n");214	for (const selector of [".ticker-row", ".ticker-track", ".hex-col", ".tri-outer"]) {215		expect(blocks).toContain(selector);216	}217	expect(blocks).toContain("animation: none;");218});