Blame · Line-by-line history
issue-templates.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 9a4eb42 | 1 | /** |
| 2 | * Block J17 — Multi-template issue selector. Pure parser unit tests + a | |
| 3 | * route-auth smoke to make sure the picker route is wired up. | |
| 4 | */ | |
| 5 | ||
| 6 | import { describe, it, expect } from "bun:test"; | |
| 7 | import app from "../app"; | |
| 8 | import { | |
| 9 | splitFrontmatter, | |
| 10 | parseFrontmatterMeta, | |
| 11 | slugFromFilename, | |
| 12 | buildTemplateFromFile, | |
| 13 | findTemplateBySlug, | |
| 14 | __internal, | |
| 15 | type IssueTemplate, | |
| 16 | } from "../lib/issue-templates"; | |
| 17 | ||
| 18 | describe("issue-templates — splitFrontmatter", () => { | |
| 19 | it("returns body as-is when there's no frontmatter", () => { | |
| 20 | const r = splitFrontmatter("hello world"); | |
| 21 | expect(r.frontmatter).toBeNull(); | |
| 22 | expect(r.body).toBe("hello world"); | |
| 23 | }); | |
| 24 | ||
| 25 | it("splits the standard ---\\n...\\n--- fence", () => { | |
| 26 | const content = "---\nname: Bug\nabout: Report\n---\nSteps to reproduce"; | |
| 27 | const r = splitFrontmatter(content); | |
| 28 | expect(r.frontmatter).toBe("name: Bug\nabout: Report"); | |
| 29 | expect(r.body).toBe("Steps to reproduce"); | |
| 30 | }); | |
| 31 | ||
| 32 | it("returns body as-is when fence is unterminated", () => { | |
| 33 | const content = "---\nname: Bug\nno closing fence"; | |
| 34 | const r = splitFrontmatter(content); | |
| 35 | expect(r.frontmatter).toBeNull(); | |
| 36 | expect(r.body).toBe(content); | |
| 37 | }); | |
| 38 | ||
| 39 | it("tolerates trailing whitespace on the closing fence line", () => { | |
| 40 | const content = "---\nname: Bug\n--- \nBody here"; | |
| 41 | const r = splitFrontmatter(content); | |
| 42 | expect(r.frontmatter).toBe("name: Bug"); | |
| 43 | expect(r.body).toBe("Body here"); | |
| 44 | }); | |
| 45 | ||
| 46 | it("handles CRLF line endings", () => { | |
| 47 | const content = "---\r\nname: Bug\r\n---\r\nBody"; | |
| 48 | const r = splitFrontmatter(content); | |
| 49 | // splitFrontmatter itself doesn't normalise CRLF in the split logic; the | |
| 50 | // parser normalises internally. We just need a usable body when the | |
| 51 | // frontmatter can't be matched to still roundtrip the raw content. | |
| 52 | expect(r.body.trim().length).toBeGreaterThan(0); | |
| 53 | }); | |
| 54 | }); | |
| 55 | ||
| 56 | describe("issue-templates — parseFrontmatterMeta", () => { | |
| 57 | it("returns an empty meta for empty input", () => { | |
| 58 | const m = parseFrontmatterMeta(""); | |
| 59 | expect(m.name).toBeNull(); | |
| 60 | expect(m.about).toBeNull(); | |
| 61 | expect(m.title).toBeNull(); | |
| 62 | expect(m.labels).toEqual([]); | |
| 63 | expect(m.assignees).toEqual([]); | |
| 64 | }); | |
| 65 | ||
| 66 | it("parses flat key: value pairs", () => { | |
| 67 | const m = parseFrontmatterMeta( | |
| 68 | [ | |
| 69 | "name: Bug report", | |
| 70 | "about: Something's broken", | |
| 71 | "title: '[Bug] '", | |
| 72 | ].join("\n") | |
| 73 | ); | |
| 74 | expect(m.name).toBe("Bug report"); | |
| 75 | expect(m.about).toBe("Something's broken"); | |
| 76 | expect(m.title).toBe("[Bug] "); | |
| 77 | }); | |
| 78 | ||
| 79 | it("strips double- and single-quoted values", () => { | |
| 80 | const m = parseFrontmatterMeta( | |
| 81 | ['name: "Quoted"', "about: 'single'"].join("\n") | |
| 82 | ); | |
| 83 | expect(m.name).toBe("Quoted"); | |
| 84 | expect(m.about).toBe("single"); | |
| 85 | }); | |
| 86 | ||
| 87 | it("parses flow-list labels: [a, \"b\", c]", () => { | |
| 88 | const m = parseFrontmatterMeta('labels: [bug, "high priority", triage]'); | |
| 89 | expect(m.labels).toEqual(["bug", "high priority", "triage"]); | |
| 90 | }); | |
| 91 | ||
| 92 | it("parses block-list labels (- bug / - triage)", () => { | |
| 93 | const m = parseFrontmatterMeta( | |
| 94 | ["labels:", " - bug", " - triage", " - needs-repro"].join("\n") | |
| 95 | ); | |
| 96 | expect(m.labels).toEqual(["bug", "triage", "needs-repro"]); | |
| 97 | }); | |
| 98 | ||
| 99 | it("parses block-list assignees", () => { | |
| 100 | const m = parseFrontmatterMeta( | |
| 101 | ["assignees:", " - alice", " - bob"].join("\n") | |
| 102 | ); | |
| 103 | expect(m.assignees).toEqual(["alice", "bob"]); | |
| 104 | }); | |
| 105 | ||
| 106 | it("ignores comment lines and blanks", () => { | |
| 107 | const m = parseFrontmatterMeta( | |
| 108 | ["# a comment", "", "name: X", "# tail"].join("\n") | |
| 109 | ); | |
| 110 | expect(m.name).toBe("X"); | |
| 111 | }); | |
| 112 | ||
| 113 | it("is case-insensitive on keys", () => { | |
| 114 | const m = parseFrontmatterMeta("Name: Casey\nLABELS: [x]"); | |
| 115 | expect(m.name).toBe("Casey"); | |
| 116 | expect(m.labels).toEqual(["x"]); | |
| 117 | }); | |
| 118 | ||
| 119 | it("ignores unknown keys", () => { | |
| 120 | const m = parseFrontmatterMeta("name: A\ncolor: ff0000"); | |
| 121 | expect(m.name).toBe("A"); | |
| 122 | expect(m.labels).toEqual([]); | |
| 123 | }); | |
| 124 | }); | |
| 125 | ||
| 126 | describe("issue-templates — slugFromFilename", () => { | |
| 127 | it("lowercases and normalises to hyphens", () => { | |
| 128 | expect(slugFromFilename("Bug Report.md")).toBe("bug-report"); | |
| 129 | expect(slugFromFilename("Feature_Request.md")).toBe("feature-request"); | |
| 130 | }); | |
| 131 | ||
| 132 | it("strips the .md / .markdown / .yml extensions", () => { | |
| 133 | expect(slugFromFilename("x.md")).toBe("x"); | |
| 134 | expect(slugFromFilename("x.yml")).toBe("x"); | |
| 135 | expect(slugFromFilename("x.yaml")).toBe("x"); | |
| 136 | }); | |
| 137 | ||
| 138 | it("clamps to 64 characters", () => { | |
| 139 | const base = "a".repeat(100); | |
| 140 | expect(slugFromFilename(`${base}.md`).length).toBe(64); | |
| 141 | }); | |
| 142 | ||
| 143 | it("strips unicode and collapses non-alnum runs", () => { | |
| 144 | expect(slugFromFilename(" !!! bug 😀 .md")).toBe("bug"); | |
| 145 | }); | |
| 146 | ||
| 147 | it("returns '' for an all-unicode name (caller picks fallback)", () => { | |
| 148 | expect(slugFromFilename("😀😀.md")).toBe(""); | |
| 149 | }); | |
| 150 | }); | |
| 151 | ||
| 152 | describe("issue-templates — buildTemplateFromFile", () => { | |
| 153 | it("merges filename + frontmatter + body", () => { | |
| 154 | const content = [ | |
| 155 | "---", | |
| 156 | "name: Bug", | |
| 157 | "about: Report a bug", | |
| 158 | "title: '[BUG] '", | |
| 159 | "labels: [bug, triage]", | |
| 160 | "---", | |
| 161 | "## Steps", | |
| 162 | "1. foo", | |
| 163 | ].join("\n"); | |
| 164 | const t = buildTemplateFromFile("bug.md", content, ".github/ISSUE_TEMPLATE"); | |
| 165 | expect(t.slug).toBe("bug"); | |
| 166 | expect(t.path).toBe(".github/ISSUE_TEMPLATE/bug.md"); | |
| 167 | expect(t.name).toBe("Bug"); | |
| 168 | expect(t.about).toBe("Report a bug"); | |
| 169 | expect(t.title).toBe("[BUG] "); | |
| 170 | expect(t.labels).toEqual(["bug", "triage"]); | |
| 171 | expect(t.body).toBe("## Steps\n1. foo"); | |
| 172 | }); | |
| 173 | ||
| 174 | it("falls back to the filename (sans extension) when name is missing", () => { | |
| 175 | const content = "---\nabout: X\n---\nbody"; | |
| 176 | const t = buildTemplateFromFile("feature-request.md", content, ".github"); | |
| 177 | expect(t.name).toBe("feature-request"); | |
| 178 | }); | |
| 179 | ||
| 180 | it("handles files without frontmatter", () => { | |
| 181 | const t = buildTemplateFromFile("plain.md", "Just a body\n", ".github"); | |
| 182 | expect(t.name).toBe("plain"); | |
| 183 | expect(t.about).toBeNull(); | |
| 184 | expect(t.labels).toEqual([]); | |
| 185 | expect(t.body).toBe("Just a body"); | |
| 186 | }); | |
| 187 | ||
| 188 | it("joins empty dir path without a leading slash", () => { | |
| 189 | const t = buildTemplateFromFile("x.md", "hi", ""); | |
| 190 | expect(t.path).toBe("x.md"); | |
| 191 | }); | |
| 192 | }); | |
| 193 | ||
| 194 | describe("issue-templates — findTemplateBySlug", () => { | |
| 195 | const items: IssueTemplate[] = [ | |
| 196 | buildTemplateFromFile("bug.md", "---\nname: Bug\n---\n", ".github"), | |
| 197 | buildTemplateFromFile("feature.md", "---\nname: Feature\n---\n", ".github"), | |
| 198 | ]; | |
| 199 | ||
| 200 | it("returns null for null/undefined slugs", () => { | |
| 201 | expect(findTemplateBySlug(items, null)).toBeNull(); | |
| 202 | expect(findTemplateBySlug(items, undefined)).toBeNull(); | |
| 203 | expect(findTemplateBySlug(items, "")).toBeNull(); | |
| 204 | }); | |
| 205 | ||
| 206 | it("returns null for an unknown slug", () => { | |
| 207 | expect(findTemplateBySlug(items, "missing")).toBeNull(); | |
| 208 | }); | |
| 209 | ||
| 210 | it("finds by exact slug", () => { | |
| 211 | expect(findTemplateBySlug(items, "bug")?.name).toBe("Bug"); | |
| 212 | expect(findTemplateBySlug(items, "feature")?.name).toBe("Feature"); | |
| 213 | }); | |
| 214 | }); | |
| 215 | ||
| 216 | describe("issue-templates — __internal", () => { | |
| 217 | it("exposes the pure helpers for parity", () => { | |
| 218 | expect(__internal.splitFrontmatter).toBe(splitFrontmatter); | |
| 219 | expect(__internal.parseFrontmatterMeta).toBe(parseFrontmatterMeta); | |
| 220 | expect(__internal.slugFromFilename).toBe(slugFromFilename); | |
| 221 | expect(__internal.buildTemplateFromFile).toBe(buildTemplateFromFile); | |
| 222 | expect(__internal.findTemplateBySlug).toBe(findTemplateBySlug); | |
| 223 | expect(__internal.TEMPLATE_DIRS.length).toBeGreaterThan(0); | |
| 224 | expect(typeof __internal.MAX_TEMPLATE_BYTES).toBe("number"); | |
| 225 | expect(typeof __internal.MAX_TEMPLATES).toBe("number"); | |
| 226 | }); | |
| 227 | }); | |
| 228 | ||
| 229 | describe("issue-templates — routes", () => { | |
| 230 | it("GET /:owner/:repo/issues/new requires auth", async () => { | |
| 231 | const res = await app.request("/alice/nope/issues/new"); | |
| 232 | // redirect to login OR 404 from repo resolve OR 401 JSON path | |
| 233 | expect([302, 401, 404].includes(res.status)).toBe(true); | |
| 234 | }); | |
| 235 | ||
| 236 | it("GET /:owner/:repo/issues/new?template=foo requires auth", async () => { | |
| 237 | const res = await app.request( | |
| 238 | "/alice/nope/issues/new?template=foo" | |
| 239 | ); | |
| 240 | expect([302, 401, 404].includes(res.status)).toBe(true); | |
| 241 | }); | |
| 242 | }); |