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 let token: string;14 const list = async (query = "") => (await operator.get(`/admin/issues${query}`)).text();15 const text = async (path: string) => (await inst.app.request(path)).text();16 const file = (repo: string, title: string, body: string, type: "bug" | "idea" | "question") =>17 fileIssue(inst.kepr, {18 repo,19 title,20 body,21 type,22 version: "",23 reporterName: "",24 contactEmail: "",25 ipHash: "x",26 filedAt: 1,27 });28 const id = (repo: string, n: number) =>29 (30 inst.kepr31 .query<{ id: number }, [string, number]>(32 "SELECT id FROM issues WHERE repo = ? AND number = ?",33 )34 .get(repo, n) as { id: number }35 ).id;3637 beforeAll(async () => {38 const repos = await makeRepos(["app", "tools"]);39 inst = await instance(repos.bucketDir);40 operator = new Browser(inst.app);41 await operator.signIn();42 for (const repo of ["app", "tools"]) {43 await operator.post(`/admin/repos/${repo}`, { public: "1" });44 }45 file("app", "Crash on start", "A segfault in main", "bug");46 file("app", "Dark mode", "Please", "idea");47 token = file("app", "How to install", "Which command?", "question").token;48 file("tools", "Tools broken", "Nothing works", "bug");49 addEntry(inst.kepr, id("app", 2), "operator", "Planned.", 2);50 addEntry(inst.kepr, id("app", 3), "operator", "Use the script.", 2);51 followUp = addEntry(inst.kepr, id("app", 3), "reporter", "Buy cheap pills here", 3);52 setClosed(inst.kepr, id("tools", 1), 4);53 });5455 test("every tab carries its count", async () => {56 const page = await list();57 expect(page).toMatch(/>\s*open <span class="muted">3<\/span>/);58 expect(page).toMatch(/>\s*awaiting reply <span class="muted">2<\/span>/);59 expect(page).toMatch(/>\s*closed <span class="muted">1<\/span>/);60 expect(page).toMatch(/>\s*hidden <span class="muted">0<\/span>/);61 });6263 test("awaiting reply is the open issues where the reporter spoke last", async () => {64 const page = await list("?filter=awaiting");65 expect(page).toContain('href="/admin/issues/app/1"');66 expect(page).toContain('href="/admin/issues/app/3"');67 expect(page).not.toContain('href="/admin/issues/app/2"');68 const rows = (await list()).split('class="issue-row"');69 const row = (n: number) => rows.find((r) => r.includes(`/admin/issues/app/${n}"`)) ?? "";70 expect(row(1)).toContain("awaiting reply");71 expect(row(2)).not.toContain("awaiting reply");72 });7374 test("narrows by repo, type and words in the title or text, keeping the form filled", async () => {75 const closedTools = await list("?filter=closed&repo=tools");76 expect(closedTools).toContain('href="/admin/issues/tools/1"');77 expect(closedTools).toContain('<option value="tools" selected>');78 expect(await list("?repo=tools")).not.toContain('href="/admin/issues/app/1"');79 const ideas = await list("?type=idea");80 expect(ideas).toContain('href="/admin/issues/app/2"');81 expect(ideas).not.toContain('href="/admin/issues/app/1"');82 const words = await list("?q=SEGFAULT");83 expect(words).toContain('href="/admin/issues/app/1"');84 expect(words).not.toContain('href="/admin/issues/app/2"');85 expect(words).toContain('value="SEGFAULT"');86 expect(words).toMatch(/>\s*open <span class="muted">1<\/span>/);87 88 expect(await list("?repo=nowhere&type=nonsense")).toContain('href="/admin/issues/app/2"');89 });9091 test("hiding one follow-up removes it publicly, keeps it marked in the console", async () => {92 const act = (fields: Record<string, string>) => operator.post("/admin/issues/app/3", fields);93 expect((await act({ action: "hide_entry", entry: String(followUp) })).status).toBe(303);94 expect(await text("/app/issues/3")).not.toContain("cheap pills");95 expect(await text("/app/issues/3")).toContain("Use the script.");96 const console = await (await operator.get("/admin/issues/app/3")).text();97 expect(console).toContain("cheap pills");98 expect(console).toMatch(/hidden[\s\S]*?cheap pills/);99 100 expect(await list("?filter=awaiting")).not.toContain('href="/admin/issues/app/3"');101 expect((await act({ action: "unhide_entry", entry: String(followUp) })).status).toBe(303);102 expect(await text("/app/issues/3")).toContain("cheap pills");103 const other = operator.post("/admin/issues/app/1", {104 action: "hide_entry",105 entry: String(followUp),106 });107 expect((await other).status).toBe(404);108 expect((await act({ action: "hide_entry", entry: "x" })).status).toBe(404);109 });110111 test("pausing new issues stops filing but keeps the list and follow-ups", async () => {112 const pause = await operator.post("/admin/repos/app", {113 public: "1",114 issues_paused: "1",115 });116 expect(pause.status).toBe(303);117 expect(await (await operator.get("/admin/repos?repo=app")).text()).toMatch(118 /name="issues_paused" value="1" checked/,119 );120 const form = await inst.app.request("/app/issues/new");121 expect(form.status).toBe(200);122 const formText = await form.text();123 expect(formText).toContain("app is not taking new issues right now.");124 expect(formText).not.toContain('name="title"');125 const filed = await inst.app.request("/app/issues/new", {126 method: "POST",127 body: new URLSearchParams({ title: "t", body: "b", type: "bug" }),128 });129 expect(filed.status).toBe(403);130 expect(await text("/app/issues")).not.toContain("report an issue →");131 expect(await text("/app/issues")).toContain('href="/app/issues/1"');132 expect(await text(`/app/issues/3?token=${token}`)).toContain('name="body"');133 await operator.post("/admin/repos/app", { public: "1" });134 expect(await text("/app/issues/new")).toContain('name="title"');135 });136});