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();20212223beforeAll(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 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 tarballDir: join(dataDir, "tarballs"),49 });50 kepr.run("UPDATE repos SET public = 1, category = 'tools' WHERE name = 'alpha'");51 kepr.run("UPDATE repos SET category = 'secretcat' WHERE name = 'vault'");52 for (const [key, value] of [53 ["handle", "ada"],54 ["tagline", "A library of small tools."],55 ["bio", "Writes small tools."],56 ["location", "Lisbon"],57 ["link", "https://ada.example"],58 ["building_since", "may 2026"],59 ]) {60 kepr.query("INSERT INTO settings (key, value) VALUES (?, ?)").run(key, value);61 }62 kepr.run(63 `INSERT INTO issues (repo, number, title, body, type, filed_at, hidden) VALUES64 ('alpha', 1, 'a', 'b', 'bug', 1, 0), ('alpha', 2, 'c', 'd', 'bug', 1, 1),65 ('vault', 1, 'e', 'f', 'bug', 1, 0)`,66 );67 kepr.run(68 `INSERT INTO downloads (repo, version, platform, count) VALUES69 ('alpha', '1.0.0', 'linux-x86_64', 7), ('alpha', '0.9.0', 'linux-x86_64', 100),70 ('vault', '1.0.0', 'linux-x86_64', 1000)`,71 );72 kepr.run("INSERT INTO release_settings (repo, version, hidden) VALUES ('alpha', '0.9.0', 1)");73 app = createApp(74 {75 domain: "kepr.example",76 dataDir,77 adminPasswordHash: "$argon2id$x",78 bucket: { kind: "folder", path: repos.bucketDir },79 },80 kepr,81 );82});8384const checkpoints = () => alphaSaves.filter((save) => save.is_checkpoint).map((save) => save.id);85const realSaves = () => alphaSaves.filter((save) => !save.is_checkpoint);8687describe("front page", () => {88 test("counts distinct objects of public repos' real saves only", async () => {89 90 91 const mirror = openMirror(join(dataDir, "mirror"), "alpha");92 expect(mirror.query("SELECT COUNT(*) AS n FROM objects").get()).toEqual({ n: 6 });93 expect(await body("/")).toContain("The library knows<br>4 things.");94 });9596 test("the ticker, cloud and cards show public saves, never checkpoints or private repos", async () => {97 const html = await body("/");98 expect(html).toContain('<div class="ticker" aria-hidden="true">');99 expect(html).toContain("<span>grow alpha</span>");100 expect(html).toContain("<span>release alpha</span>");101 expect(html).toContain('class="tc-tag tc-5">tools<');102 expect(html).toContain('class="tc-tag tc-5">TypeScript<');103 expect(html).toContain('<span class="repo-card-saves mono">2 saves</span>');104 expect(html).toContain("The alpha project, grown.");105 expect(html).toContain("A library of small tools.");106 expect(html).toContain('href="/docs">run your own library →</a>');107 for (const secret of [108 "checkpoint message",109 "vault",110 "secretcat",111 "Zig",112 ...checkpoints(),113 vaultSave,114 ]) {115 expect(html).not.toContain(secret);116 }117 });118119 test("a checkpoint at head adds nothing to the public stats", async () => {120 const mirror = openMirror(join(dataDir, "mirror"), "alpha");121 const state = (key: string) => mirror.query("SELECT value FROM state WHERE key = ?").get(key);122 expect(checkpoints()).toContain((state("head") as { value: string }).value);123 124 expect(state("stats_save")).toEqual({ value: realSaves()[0]?.id });125 for (const path of ["/", "/@ada", "/alpha"]) {126 const html = await body(path);127 expect(html).toContain("TypeScript");128 expect(html).not.toContain("Python");129 }130 expect(await body("/browse")).not.toContain("Python");131 });132133 test("the nav has browse, docs, support and log in, as Kepr 2's", async () => {134 const html = await body("/");135 expect(html).toContain(136 '<a href="/browse">browse</a><a href="/docs">docs</a><a href="/support">support</a><a href="/sign-in?next=/">log in</a>',137 );138 });139140 test("browse lists public repos as Kepr 2's cards: name, saves, description, category", async () => {141 const html = await body("/browse");142 expect(html).toContain('<a class="repo-card" href="/alpha">');143 expect(html).toContain(`${realSaves().length} saves</span>`);144 expect(html).toContain('<p class="repo-card-cat">tools</p>');145 expect(html).not.toContain("vault");146 });147});148149describe("profile", () => {150 test("a handle that isn't the configured one is a 404", async () => {151 expect((await app.request("/@ada")).status).toBe(200);152 expect((await app.request("/@someone")).status).toBe(404);153 expect((await app.request("/@")).status).toBe(404);154 });155156 test("stats leave out private repos, checkpoints, hidden issues and hidden releases", async () => {157 const numbers = [...(await body("/@ada")).matchAll(/profile-stat-number">([^<]*)</g)].map(158 (m) => m[1],159 );160 161 expect(numbers).toEqual(["1", String(realSaves().length), "1", "7"]);162 });163164 test("recent activity and the hex backdrop use real public saves only", async () => {165 const html = await body("/@ada");166 expect(html).toContain('class="profile-timeline" tabindex="0"');167 expect(html).toContain('role="region" aria-label="recent activity"');168 for (const save of realSaves()) {169 expect(html).toContain(`<span class="hex-col">${save.id}</span>`);170 expect(html).toContain(`href="/alpha/saves/${save.id}">${save.message}</a>`);171 }172 for (const id of checkpoints()) expect(html).not.toContain(id);173 expect(html).not.toContain(vaultSave);174 expect(html).not.toContain("vault");175 expect(html).not.toContain("Zig");176 });177178 test("shows the settings, the handle in the signature font, and the language bar", async () => {179 const html = await body("/@ada");180 expect(html).toContain('<h1 class="profile-username">ada</h1>');181 expect(html).toContain("building since may 2026");182 expect(html).toContain("◈ operator");183 expect(html).toContain("Writes small tools.");184 expect(html).toContain("Lisbon");185 expect(html).toContain('href="https://ada.example"');186 expect(html).toContain('<span class="profile-lang-label">TypeScript</span>');187 expect(html).toContain('<meta property="og:title" content="@ada — kepr">');188 });189190 test("an uploaded signature is served from data/assets and replaces the handle", async () => {191 await mkdir(join(dataDir, "assets"), { recursive: true });192 await writeFile(join(dataDir, "assets/sig.svg"), "<svg xmlns='http://www.w3.org/2000/svg'/>");193 kepr.run("INSERT INTO settings (key, value) VALUES ('signature', 'sig.svg')");194 try {195 expect(await body("/@ada")).toContain(196 '<img class="profile-signature" src="/static/assets/sig.svg" alt="ada">',197 );198 const asset = await app.request("/static/assets/sig.svg");199 expect(asset.status).toBe(200);200 expect(asset.headers.get("content-type")).toContain("image/svg+xml");201 await mkdir(join(dataDir, "assets/logos"));202 await writeFile(join(dataDir, "assets/logos/mark.svg"), "<svg/>");203 expect((await app.request("/static/assets/logos/mark.svg")).status).toBe(200);204 205 206 for (const path of [207 "/static/assets/../kepr.db",208 "/static/assets/..%2Fkepr.db",209 "/static/assets/%2e%2e%2fkepr.db",210 "/static/assets/..%5Ckepr.db",211 "/static/assets%2F..%2Fkepr.db",212 "/static/%00",213 "/static/assets/%00",214 "/static/css/%01kepr.css",215 ]) {216 expect((await app.request(path)).status).toBe(404);217 }218 } finally {219 kepr.run("DELETE FROM settings WHERE key = 'signature'");220 }221 });222223 test("with no handle set there is no profile", async () => {224 kepr.run("UPDATE settings SET value = '' WHERE key = 'handle'");225 try {226 expect((await app.request("/@ada")).status).toBe(404);227 } finally {228 kepr.run("UPDATE settings SET value = 'ada' WHERE key = 'handle'");229 }230 });231});232233describe("docs and support", () => {234 test("render the repo's Markdown, links left as written", async () => {235 const docs = await body("/docs");236 expect(docs).toContain("<h1>Run your own library</h1>");237 expect(docs).toContain("<code>admin_password_hash</code>");238 const support = await body("/support");239 expect(support).toContain('<a href="/docs">self-hosting docs</a>');240 expect(support).toContain('<meta name="description" content="Found a bug');241 });242});243244test("motion stops under prefers-reduced-motion", async () => {245 const css = await Bun.file(join(import.meta.dir, "../public/css/kepr.css")).text();246 const blocks = [...css.matchAll(/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g)]247 .map((m) => m[1] as string)248 .join("\n");249 for (const selector of [".ticker-row", ".ticker-track", ".hex-col", ".tri-outer"]) {250 expect(blocks).toContain(selector);251 }252 expect(blocks).toContain("animation: none;");253});