// Covers §24's selected-repo workspace: the summary, detected and shown languages, where the // description comes from, and the repo's releases. import { Database } from "bun:sqlite"; import { beforeAll, describe, expect, test } from "bun:test"; import { mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { openKeprDb } from "../src/server/db.ts"; import { Browser, type Instance, instance } from "./browser.ts"; import { makeRepos, writeRelease } from "./fixture.ts"; describe("the selected repo's workspace (§24)", () => { let inst: Instance; let browser: Browser; let bucketDir: string; const workspace = async () => (await browser.get("/admin/repos?repo=app")).text(); const save = (fields: Record) => browser.post("/admin/repos/app", { public: "1", ...fields }); beforeAll(async () => { const repos = await makeRepos(["app"]); const work = join(repos.bucketDir, "..", "work", "app"); await writeFile(join(work, "main.ts"), "export const a = 1;\nexport const b = 2;\n"); await writeFile(join(work, "tool.py"), "print(1)\n"); repos.koh("app", "save", "add sources"); repos.koh("app", "offer"); const head = JSON.parse(repos.koh("app", "view", "--json")).head_id as string; for (const version of ["1.0.0", "1.1.0"]) { await writeRelease(repos.bucketDir, "app", { version, save: head, files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }], }); } bucketDir = repos.bucketDir; inst = await instance(repos.bucketDir); inst.kepr.run( `INSERT INTO issues (repo, number, title, body, type, filed_at) VALUES ('app', 1, 'Broken', 'It broke', 'bug', 0)`, ); browser = new Browser(inst.app); await browser.signIn(); await save({}); await inst.app.request("/app/releases/1.1.0/linux-x86_64"); await inst.app.request("/app/releases/1.1.0/linux-x86_64"); }); test("summarises saves, lines, the newest version, open issues and downloads", async () => { const page = await workspace(); expect(page).toContain('class="admin-vitals"'); expect(page).toMatch(/2<\/strong>saves<\/span>/); expect(page).toMatch(/3<\/strong>lines<\/span>/); expect(page).toMatch(/1\.1\.0<\/strong>newest version<\/span>/); expect(page).toMatch(/1<\/strong>open issues<\/span>/); expect(page).toMatch(/2<\/strong>downloads<\/span>/); }); test("one click per repo: its tab holds overview, settings, builds and archives together", async () => { const page = await workspace(); expect(page).toContain('href="/admin/repos?repo=app" aria-current="page"'); for (const mark of [ 'class="admin-vitals"', 'name="hidden_paths"', 'action="/admin/releases/app/1.1.0"', 'action="/admin/repos/app/tarballs/delete"', ]) { expect(page).toContain(mark); } for (const id of ["overview", "settings", "builds", "archives"]) { expect(page).toContain(`
`); } const saved = await save({}); expect(saved.headers.get("location")).toBe("/admin/repos?repo=app&saved=1"); const refused = await save({ languages: "a, b, c, d, e, f" }); expect(await refused.text()).toContain('name="hidden_paths"'); }); test("lists the detected languages with their lines", async () => { const page = await workspace(); expect(page).toContain('TypeScript 2 lines'); expect(page).toContain('Python 1 line'); }); test("edits the description and details; blank hands them back to the README", async () => { expect(await workspace()).toMatch(/]*name="description"[^>]*><\/textarea>/); const saved = await save({ description: "Own *words*", details: "## Usage\r\nRun it." }); expect(saved.status).toBe(303); const page = await workspace(); expect(page).toContain("Own *words*"); expect(page).toContain("## Usage\nRun it."); const repo = await (await inst.app.request("/app")).text(); expect(repo).toContain("Own words"); expect(repo).toContain("Run it."); const long = await save({ details: "x".repeat(60_001) }); expect(long.status).toBe(400); expect(await long.text()).toContain("Keep the details under 60,000 characters."); await save({ description: "", details: "" }); expect(inst.kepr.query("SELECT description, details FROM repo_text").get()).toEqual({ description: "", details: "", }); }); test("edits its builds in place, with visibility and downloads", async () => { inst.kepr.run( "INSERT INTO release_settings (repo, version, hidden) VALUES ('app', '1.0.0', 1)", ); const page = await workspace(); expect(page).toContain('action="/admin/releases/app/1.1.0"'); expect(page).toContain("shown2 downloads"); expect(page).toContain("hidden0 downloads"); inst.kepr.run("DELETE FROM release_settings"); }); test("shown languages replace the header's detected language; blank brings it back", async () => { const header = async () => (await inst.app.request("/app")).text(); expect(await header()).toContain('TypeScript'); expect((await save({ languages: " Zig , TypeScript" })).status).toBe(303); const shown = await header(); expect(shown).toContain('Zig'); expect(shown).toContain('TypeScript'); expect(await workspace()).toContain('value="Zig, TypeScript"'); expect((await save({ languages: "" })).status).toBe(303); const detected = await header(); expect(detected).not.toContain('Zig'); expect(detected).toContain('TypeScript'); }); test("refuses more than five languages, a long name or a control character", async () => { const refusals: [string, string][] = [ ["a, b, c, d, e, f", "up to five languages."], ["x".repeat(31), "a language name is at most 30 characters."], ["Zig\u0007", "a language name is printable text."], ]; for (const [languages, error] of refusals) { const response = await save({ languages }); expect(response.status).toBe(400); expect(await response.text()).toContain(error); } expect(await workspace()).not.toContain('value="Zig'); }); test("deletes the repo's kept tarballs from the bucket", async () => { await save({}); const head = (await inst.app.request("/app/archive/HEAD.tar.gz")).headers.get("location"); expect((await inst.app.request(head ?? "")).status).toBe(200); const kept = new Bun.Glob("**/*.tar.gz"); const keptIn = () => Array.fromAsync(kept.scan(join(bucketDir, ".kepr", "tarballs", "app"))); expect(await keptIn()).toHaveLength(1); expect(await workspace()).toContain('action="/admin/repos/app/tarballs/delete"'); const deleted = await browser.post("/admin/repos/app/tarballs/delete", {}); expect(deleted.headers.get("location")).toBe("/admin/repos?repo=app&saved=1#archives"); expect(await keptIn()).toEqual([]); expect((await inst.app.request(head ?? "")).status).toBe(200); }); }); test("an older kepr.db gains the shown-languages column when opened", async () => { const dir = await mkdtemp(join(tmpdir(), "kepr-old-db-")); const old = new Database(join(dir, "kepr.db")); old.run("CREATE TABLE repos (name TEXT PRIMARY KEY, public INTEGER NOT NULL DEFAULT 0)"); old.run("INSERT INTO repos (name) VALUES ('app')"); old.close(); const db = openKeprDb(dir); expect(db.query<{ languages: string }, []>("SELECT languages FROM repos").get()?.languages).toBe( "", ); }); test("an older kepr.db loses card order and install lines, keeping each repo", async () => { const dir = await mkdtemp(join(tmpdir(), "kepr-old-db-")); const old = new Database(join(dir, "kepr.db")); old.run(`CREATE TABLE repos ( name TEXT PRIMARY KEY, public INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, install1 TEXT NOT NULL DEFAULT '', install2 TEXT NOT NULL DEFAULT '', install3 TEXT NOT NULL DEFAULT '')`); old.run("INSERT INTO repos (name, public, install1) VALUES ('app', 1, 'nix profile add app')"); old.close(); const db = openKeprDb(dir); const columns = db.query<{ name: string }, []>("PRAGMA table_info(repos)").all(); expect(columns.map((column) => column.name)).not.toContain("install1"); expect(columns.map((column) => column.name)).not.toContain("sort_order"); expect(db.query("SELECT name, public FROM repos").all()).toEqual([{ name: "app", public: 1 }]); }); test("no two elements of a repo's workspace share an id, whatever its versions are called", async () => { const repos = await makeRepos(["app"]); const head = JSON.parse(repos.koh("app", "view", "--json")).head_id as string; for (const version of ["category", "rename", "description", "1.0.0", "1.0.0-notes"]) { await writeRelease(repos.bucketDir, "app", { version, save: head, files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }], }); } const inst = await instance(repos.bucketDir); const browser = new Browser(inst.app); await browser.signIn(); const page = await (await browser.get("/admin/repos?repo=app")).text(); const ids = [...page.matchAll(/ id="([^"]+)"/g)].map((match) => match[1]); expect(ids.length).toBeGreaterThan(10); expect(ids.filter((id, i) => ids.indexOf(id) !== i)).toEqual([]); });