1234import { Database } from "bun:sqlite";5import { beforeAll, describe, expect, test } from "bun:test";6import { mkdtemp, writeFile } from "node:fs/promises";7import { tmpdir } from "node:os";8import { join } from "node:path";9import { openKeprDb } from "../src/server/db.ts";10import { Browser, type Instance, instance } from "./browser.ts";11import { makeRepos, writeRelease } from "./fixture.ts";1213describe("the selected repo's workspace (§24)", () => {14 let inst: Instance;15 let browser: Browser;16 let bucketDir: string;17 const workspace = async () => (await browser.get("/admin/repos?repo=app")).text();18 const save = (fields: Record<string, string>) =>19 browser.post("/admin/repos/app", { public: "1", ...fields });2021 beforeAll(async () => {22 const repos = await makeRepos(["app"]);23 const work = join(repos.bucketDir, "..", "work", "app");24 await writeFile(join(work, "main.ts"), "export const a = 1;\nexport const b = 2;\n");25 await writeFile(join(work, "tool.py"), "print(1)\n");26 repos.koh("app", "save", "add sources");27 repos.koh("app", "offer");28 const head = JSON.parse(repos.koh("app", "view", "--json")).head_id as string;29 for (const version of ["1.0.0", "1.1.0"]) {30 await writeRelease(repos.bucketDir, "app", {31 version,32 save: head,33 files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],34 });35 }36 bucketDir = repos.bucketDir;37 inst = await instance(repos.bucketDir);38 inst.kepr.run(39 `INSERT INTO issues (repo, number, title, body, type, filed_at)40 VALUES ('app', 1, 'Broken', 'It broke', 'bug', 0)`,41 );42 browser = new Browser(inst.app);43 await browser.signIn();44 await save({});45 await inst.app.request("/app/releases/1.1.0/linux-x86_64");46 await inst.app.request("/app/releases/1.1.0/linux-x86_64");47 });4849 test("summarises saves, lines, the newest version, open issues and downloads", async () => {50 const page = await workspace();51 expect(page).toContain('class="admin-vitals"');52 expect(page).toMatch(/<strong>2<\/strong><span class="mono">saves<\/span>/);53 expect(page).toMatch(/<strong>3<\/strong><span class="mono">lines<\/span>/);54 expect(page).toMatch(/<strong>1\.1\.0<\/strong><span class="mono">newest version<\/span>/);55 expect(page).toMatch(/<strong>1<\/strong><span class="mono">open issues<\/span>/);56 expect(page).toMatch(/<strong>2<\/strong><span class="mono">downloads<\/span>/);57 });5859 test("one click per repo: its tab holds overview, settings, builds and archives together", async () => {60 const page = await workspace();61 expect(page).toContain('href="/admin/repos?repo=app" aria-current="page"');62 for (const mark of [63 'class="admin-vitals"',64 'name="hidden_paths"',65 'action="/admin/releases/app/1.1.0"',66 'action="/admin/repos/app/tarballs/delete"',67 ]) {68 expect(page).toContain(mark);69 }70 for (const id of ["overview", "settings", "builds", "archives"]) {71 expect(page).toContain(`<section class="repo-section" id="${id}">`);72 }73 const saved = await save({});74 expect(saved.headers.get("location")).toBe("/admin/repos?repo=app&saved=1");75 const refused = await save({ languages: "a, b, c, d, e, f" });76 expect(await refused.text()).toContain('name="hidden_paths"');77 });7879 test("lists the detected languages with their lines", async () => {80 const page = await workspace();81 expect(page).toContain('TypeScript <span class="muted">2 lines</span>');82 expect(page).toContain('Python <span class="muted">1 line</span>');83 });8485 test("edits the description and details; blank hands them back to the README", async () => {86 expect(await workspace()).toMatch(/<textarea[^>]*name="description"[^>]*><\/textarea>/);87 const saved = await save({ description: "Own *words*", details: "## Usage\r\nRun it." });88 expect(saved.status).toBe(303);89 const page = await workspace();90 expect(page).toContain("Own *words*</textarea>");91 expect(page).toContain("## Usage\nRun it.</textarea>");92 const repo = await (await inst.app.request("/app")).text();93 expect(repo).toContain("Own <em>words</em>");94 expect(repo).toContain("Run it.");95 const long = await save({ details: "x".repeat(60_001) });96 expect(long.status).toBe(400);97 expect(await long.text()).toContain("Keep the details under 60,000 characters.");98 await save({ description: "", details: "" });99 expect(inst.kepr.query("SELECT description, details FROM repo_text").get()).toEqual({100 description: "",101 details: "",102 });103 });104105 test("edits its builds in place, with visibility and downloads", async () => {106 inst.kepr.run(107 "INSERT INTO release_settings (repo, version, hidden) VALUES ('app', '1.0.0', 1)",108 );109 const page = await workspace();110 expect(page).toContain('action="/admin/releases/app/1.1.0"');111 expect(page).toContain("<span>shown</span><span>2 downloads</span>");112 expect(page).toContain("<span>hidden</span><span>0 downloads</span>");113 inst.kepr.run("DELETE FROM release_settings");114 });115116 test("shown languages replace the header's detected language; blank brings it back", async () => {117 const header = async () => (await inst.app.request("/app")).text();118 expect(await header()).toContain('<span class="lang-badge">TypeScript</span>');119 expect((await save({ languages: " Zig , TypeScript" })).status).toBe(303);120 const shown = await header();121 expect(shown).toContain('<span class="lang-badge">Zig</span>');122 expect(shown).toContain('<span class="lang-badge">TypeScript</span>');123 expect(await workspace()).toContain('value="Zig, TypeScript"');124 expect((await save({ languages: "" })).status).toBe(303);125 const detected = await header();126 expect(detected).not.toContain('<span class="lang-badge">Zig</span>');127 expect(detected).toContain('<span class="lang-badge">TypeScript</span>');128 });129130 test("refuses more than five languages, a long name or a control character", async () => {131 const refusals: [string, string][] = [132 ["a, b, c, d, e, f", "up to five languages."],133 ["x".repeat(31), "a language name is at most 30 characters."],134 ["Zig\u0007", "a language name is printable text."],135 ];136 for (const [languages, error] of refusals) {137 const response = await save({ languages });138 expect(response.status).toBe(400);139 expect(await response.text()).toContain(error);140 }141 expect(await workspace()).not.toContain('value="Zig');142 });143144 test("deletes the repo's kept tarballs from the bucket", async () => {145 await save({});146 const head = (await inst.app.request("/app/archive/HEAD.tar.gz")).headers.get("location");147 expect((await inst.app.request(head ?? "")).status).toBe(200);148 const kept = new Bun.Glob("**/*.tar.gz");149 const keptIn = () => Array.fromAsync(kept.scan(join(bucketDir, ".kepr", "tarballs", "app")));150 expect(await keptIn()).toHaveLength(1);151 expect(await workspace()).toContain('action="/admin/repos/app/tarballs/delete"');152 const deleted = await browser.post("/admin/repos/app/tarballs/delete", {});153 expect(deleted.headers.get("location")).toBe("/admin/repos?repo=app&saved=1#archives");154 expect(await keptIn()).toEqual([]);155 expect((await inst.app.request(head ?? "")).status).toBe(200);156 });157});158159test("an older kepr.db gains the shown-languages column when opened", async () => {160 const dir = await mkdtemp(join(tmpdir(), "kepr-old-db-"));161 const old = new Database(join(dir, "kepr.db"));162 old.run("CREATE TABLE repos (name TEXT PRIMARY KEY, public INTEGER NOT NULL DEFAULT 0)");163 old.run("INSERT INTO repos (name) VALUES ('app')");164 old.close();165 const db = openKeprDb(dir);166 expect(db.query<{ languages: string }, []>("SELECT languages FROM repos").get()?.languages).toBe(167 "",168 );169});170171test("an older kepr.db loses card order and install lines, keeping each repo", async () => {172 const dir = await mkdtemp(join(tmpdir(), "kepr-old-db-"));173 const old = new Database(join(dir, "kepr.db"));174 old.run(`CREATE TABLE repos (175 name TEXT PRIMARY KEY, public INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,176 install1 TEXT NOT NULL DEFAULT '', install2 TEXT NOT NULL DEFAULT '',177 install3 TEXT NOT NULL DEFAULT '')`);178 old.run("INSERT INTO repos (name, public, install1) VALUES ('app', 1, 'nix profile add app')");179 old.close();180 const db = openKeprDb(dir);181 const columns = db.query<{ name: string }, []>("PRAGMA table_info(repos)").all();182 expect(columns.map((column) => column.name)).not.toContain("install1");183 expect(columns.map((column) => column.name)).not.toContain("sort_order");184 expect(db.query("SELECT name, public FROM repos").all()).toEqual([{ name: "app", public: 1 }]);185});186187test("no two elements of a repo's workspace share an id, whatever its versions are called", async () => {188 const repos = await makeRepos(["app"]);189 const head = JSON.parse(repos.koh("app", "view", "--json")).head_id as string;190 for (const version of ["category", "rename", "description", "1.0.0", "1.0.0-notes"]) {191 await writeRelease(repos.bucketDir, "app", {192 version,193 save: head,194 files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],195 });196 }197 const inst = await instance(repos.bucketDir);198 const browser = new Browser(inst.app);199 await browser.signIn();200 const page = await (await browser.get("/admin/repos?repo=app")).text();201 const ids = [...page.matchAll(/ id="([^"]+)"/g)].map((match) => match[1]);202 expect(ids.length).toBeGreaterThan(10);203 expect(ids.filter((id, i) => ids.indexOf(id) !== i)).toEqual([]);204});