1234import { beforeAll, describe, expect, test } from "bun:test";5import { addEntry, fileIssue, setClosed } from "../src/issues/store.ts";6import { Browser, type Instance, instance } from "./browser.ts";7import { makeRepos } from "./fixture.ts";89describe("issue triage (§23, §24)", () => {10 let inst: Instance;11 let operator: Browser;12 let followUp: number;13 const list = async (query = "") => (await operator.get(`/admin/issues${query}`)).text();14 const text = async (path: string) => (await inst.app.request(path)).text();15 const file = (repo: string, title: string, body: string, type: "bug" | "idea" | "question") =>16 fileIssue(inst.kepr, {17 repo,18 title,19 body,20 type,21 version: "",22 reporterName: "",23 contactEmail: "",24 ipHash: "x",25 filedAt: 1,26 });27 const id = (repo: string, n: number) =>28 (29 inst.kepr30 .query<{ id: number }, [string, number]>(31 "SELECT id FROM issues WHERE repo = ? AND number = ?",32 )33 .get(repo, n) as { id: number }34 ).id;3536 beforeAll(async () => {37 const repos = await makeRepos(["app", "tools"]);38 inst = await instance(repos.bucketDir);39 operator = new Browser(inst.app);40 await operator.signIn();41 for (const repo of ["app", "tools"]) {42 await operator.post(`/admin/repos/${repo}`, { public: "1" });43 }44 file("app", "Crash on start", "A segfault in main", "bug");45 file("app", "Dark mode", "Please", "idea");46 file("app", "How to install", "Which command?", "question");47 file("tools", "Tools broken", "Nothing works", "bug");48 addEntry(inst.kepr, id("app", 2), { author: "operator", body: "Planned.", at: 2 });49 addEntry(inst.kepr, id("app", 3), { author: "operator", body: "Use the script.", at: 2 });50 followUp = addEntry(inst.kepr, id("app", 3), {51 author: "reporter",52 body: "Buy cheap pills here",53 at: 3,54 });55 setClosed(inst.kepr, id("tools", 1), 4);56 });5758 test("every tab carries its count", async () => {59 const page = await list();60 expect(page).toMatch(/>\s*open <span class="muted">3<\/span>/);61 expect(page).toMatch(/>\s*awaiting reply <span class="muted">2<\/span>/);62 expect(page).toMatch(/>\s*closed <span class="muted">1<\/span>/);63 expect(page).toMatch(/>\s*hidden <span class="muted">0<\/span>/);64 });6566 test("awaiting reply is the open issues where the reporter spoke last", async () => {67 const page = await list("?filter=awaiting");68 expect(page).toContain('href="/admin/issues/app/1"');69 expect(page).toContain('href="/admin/issues/app/3"');70 expect(page).not.toContain('href="/admin/issues/app/2"');71 const rows = (await list()).split('class="issue-row"');72 const row = (n: number) => rows.find((r) => r.includes(`/admin/issues/app/${n}"`)) ?? "";73 expect(row(1)).toContain("awaiting reply");74 expect(row(2)).not.toContain("awaiting reply");75 });7677 test("narrows by repo, type and words in the title or text, keeping the form filled", async () => {78 const closedTools = await list("?filter=closed&repo=tools");79 expect(closedTools).toContain('href="/admin/issues/tools/1"');80 expect(closedTools).toContain('<option value="tools" selected>');81 expect(await list("?repo=tools")).not.toContain('href="/admin/issues/app/1"');82 const ideas = await list("?type=idea");83 expect(ideas).toContain('href="/admin/issues/app/2"');84 expect(ideas).not.toContain('href="/admin/issues/app/1"');85 const words = await list("?q=SEGFAULT");86 expect(words).toContain('href="/admin/issues/app/1"');87 expect(words).not.toContain('href="/admin/issues/app/2"');88 expect(words).toContain('value="SEGFAULT"');89 expect(words).toMatch(/>\s*open <span class="muted">1<\/span>/);90 91 expect(await list("?repo=nowhere&type=nonsense")).toContain('href="/admin/issues/app/2"');92 });9394 test("hiding one follow-up removes it publicly, keeps it marked in the console", async () => {95 const act = (fields: Record<string, string>) => operator.post("/admin/issues/app/3", fields);96 expect((await act({ action: "hide_entry", entry: String(followUp) })).status).toBe(303);97 expect(await text("/app/issues/3")).not.toContain("cheap pills");98 expect(await text("/app/issues/3")).toContain("Use the script.");99 const console = await (await operator.get("/admin/issues/app/3")).text();100 expect(console).toContain("cheap pills");101 expect(console).toMatch(/hidden[\s\S]*?cheap pills/);102 103 expect(await list("?filter=awaiting")).not.toContain('href="/admin/issues/app/3"');104 expect((await act({ action: "unhide_entry", entry: String(followUp) })).status).toBe(303);105 expect(await text("/app/issues/3")).toContain("cheap pills");106 const other = operator.post("/admin/issues/app/1", {107 action: "hide_entry",108 entry: String(followUp),109 });110 expect((await other).status).toBe(404);111 expect((await act({ action: "hide_entry", entry: "x" })).status).toBe(404);112 });113114 test("pausing new issues stops filing but keeps the list and follow-ups", async () => {115 const pause = await operator.post("/admin/repos/app", {116 public: "1",117 issues_paused: "1",118 });119 expect(pause.status).toBe(303);120 expect(await (await operator.get("/admin/repos?repo=app")).text()).toMatch(121 /name="issues_paused" value="1" checked/,122 );123 const form = await inst.app.request("/app/issues/new");124 expect(form.status).toBe(200);125 const formText = await form.text();126 expect(formText).toContain("app is not taking new issues right now.");127 expect(formText).not.toContain('name="title"');128 const filed = await inst.app.request("/app/issues/new", {129 method: "POST",130 body: new URLSearchParams({ title: "t", body: "b", type: "bug" }),131 });132 expect(filed.status).toBe(403);133 expect(await text("/app/issues")).not.toContain("report an issue →");134 expect(await text("/app/issues")).toContain('href="/app/issues/1"');135 expect(await text("/app/issues/3")).toContain('name="body"');136 await operator.post("/admin/repos/app", { public: "1" });137 expect(await text("/app/issues/new")).toContain('name="title"');138 });139140 test("ticked issues are closed, reopened, hidden and unhidden together", async () => {141 const ticked = [id("app", 1), id("app", 2)];142 const bulk = (action: string, issues = ticked) => {143 const form = new FormData();144 form.set("action", action);145 form.set("filter", "closed");146 for (const issue of issues) form.append("issue", String(issue));147 return operator.post("/admin/issues", form);148 };149 const status = () =>150 inst.kepr151 .query("SELECT status, hidden FROM issues WHERE id IN (?, ?) ORDER BY id")152 .all(...ticked);153 expect(await list()).toContain(`name="issue" value="${ticked[0]}"`);154 const closed = await bulk("close");155 expect(closed.status).toBe(303);156 expect(closed.headers.get("location")).toBe("/admin/issues?filter=closed");157 expect(status()).toEqual([158 { status: "closed", hidden: 0 },159 { status: "closed", hidden: 0 },160 ]);161 await bulk("hide");162 expect(status().map((row) => (row as { hidden: number }).hidden)).toEqual([1, 1]);163 await bulk("unhide");164 await bulk("reopen");165 expect(status()).toEqual([166 { status: "open", hidden: 0 },167 { status: "open", hidden: 0 },168 ]);169 expect((await bulk("close", [999_999])).status).toBe(404);170 expect((await bulk("explode")).status).toBe(404);171 });172173 test("blocking a reporter lists the block under the tabs until it is lifted", async () => {174 expect(await list()).not.toContain("blocked reporters");175 const blocked = await operator.post("/admin/issues/app/2", { action: "block" });176 expect(blocked.status).toBe(303);177 const issue = await (await operator.get("/admin/issues/app/2")).text();178 expect(issue).toContain("reporter blocked");179 const page = await list();180 expect(page).toContain("blocked reporters");181 expect(page).toContain('href="/admin/issues/app/2"');182 const block = /name="block" value="(\d+)"/.exec(page)?.[1] ?? "";183 expect(block).not.toBe("");184 expect((await operator.post("/admin/issues", { action: "unblock", block: "999" })).status).toBe(185 404,186 );187 expect((await operator.post("/admin/issues", { action: "unblock", block })).status).toBe(303);188 expect(await list()).not.toContain("blocked reporters");189 });190});