CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
badges.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.
| 3648956 | 1 | /** |
| 2 | * Block J10 — Badge renderer tests + route smokes. | |
| 3 | */ | |
| 4 | ||
| 5 | import { describe, it, expect } from "bun:test"; | |
| 6 | import app from "../app"; | |
| 7 | import { | |
| 8 | colorForState, | |
| 9 | escapeXml, | |
| 10 | estimateTextWidth, | |
| 11 | renderBadge, | |
| 12 | } from "../lib/badge"; | |
| 13 | ||
| 14 | describe("badge — escapeXml", () => { | |
| 15 | it("escapes the five standard chars", () => { | |
| 16 | expect(escapeXml("<a & b > c \"d\" 'e'")).toBe( | |
| 17 | "<a & b > c "d" 'e'" | |
| 18 | ); | |
| 19 | }); | |
| 20 | ||
| 21 | it("returns empty for empty input", () => { | |
| 22 | expect(escapeXml("")).toBe(""); | |
| 23 | }); | |
| 24 | }); | |
| 25 | ||
| 26 | describe("badge — estimateTextWidth", () => { | |
| 27 | it("returns 0 for empty", () => { | |
| 28 | expect(estimateTextWidth("")).toBe(0); | |
| 29 | }); | |
| 30 | ||
| 31 | it("scales roughly linearly with string length", () => { | |
| 32 | const a = estimateTextWidth("abcd"); | |
| 33 | const b = estimateTextWidth("abcdabcd"); | |
| 34 | expect(b).toBeGreaterThan(a); | |
| 35 | expect(b).toBeLessThan(a * 2.5); | |
| 36 | }); | |
| 37 | ||
| 38 | it("handles punctuation + digits without throwing", () => { | |
| 39 | expect(() => estimateTextWidth("Hi! 12.3%")).not.toThrow(); | |
| 40 | }); | |
| 41 | }); | |
| 42 | ||
| 43 | describe("badge — colorForState", () => { | |
| 44 | it("maps success / passed to green", () => { | |
| 45 | expect(colorForState("success")).toBe("green"); | |
| 46 | expect(colorForState("passed")).toBe("green"); | |
| 47 | }); | |
| 48 | ||
| 49 | it("maps pending to yellow", () => { | |
| 50 | expect(colorForState("pending")).toBe("yellow"); | |
| 51 | }); | |
| 52 | ||
| 53 | it("maps failure / failed / error to red", () => { | |
| 54 | expect(colorForState("failure")).toBe("red"); | |
| 55 | expect(colorForState("failed")).toBe("red"); | |
| 56 | expect(colorForState("error")).toBe("red"); | |
| 57 | }); | |
| 58 | ||
| 59 | it("unknown → grey", () => { | |
| 60 | expect(colorForState("unknown" as any)).toBe("grey"); | |
| 61 | }); | |
| 62 | }); | |
| 63 | ||
| 64 | describe("badge — renderBadge", () => { | |
| 65 | it("emits well-formed SVG with label + value + title", () => { | |
| 66 | const svg = renderBadge({ label: "build", value: "passing", color: "green" }); | |
| 67 | expect(svg.startsWith("<svg")).toBe(true); | |
| 68 | expect(svg.includes("</svg>")).toBe(true); | |
| 69 | expect(svg.includes("build")).toBe(true); | |
| 70 | expect(svg.includes("passing")).toBe(true); | |
| 71 | expect(svg.includes("<title>build: passing</title>")).toBe(true); | |
| 72 | }); | |
| 73 | ||
| 74 | it("honours named colours", () => { | |
| 75 | expect(renderBadge({ label: "x", value: "y", color: "red" })).toContain( | |
| 76 | "#da3633" | |
| 77 | ); | |
| 78 | expect(renderBadge({ label: "x", value: "y", color: "green" })).toContain( | |
| 79 | "#2ea043" | |
| 80 | ); | |
| 81 | }); | |
| 82 | ||
| 83 | it("falls back to grey for unknown colour", () => { | |
| 84 | const svg = renderBadge({ label: "x", value: "y", color: "puce" }); | |
| 85 | expect(svg).toContain("#586069"); | |
| 86 | }); | |
| 87 | ||
| 88 | it("accepts hex colour literals", () => { | |
| 89 | const svg = renderBadge({ label: "x", value: "y", color: "#abc" }); | |
| 90 | expect(svg).toContain("#abc"); | |
| 91 | }); | |
| 92 | ||
| 93 | it("escapes markup in label + value", () => { | |
| 94 | const svg = renderBadge({ label: "<evil>", value: "\"v&v'", color: "blue" }); | |
| 95 | expect(svg).toContain("<evil>"); | |
| 96 | expect(svg).toContain(""v&v'"); | |
| 97 | expect(svg).not.toContain("<evil>"); | |
| 98 | }); | |
| 99 | ||
| 100 | it("clamps ridiculously long inputs to ≤64 chars each", () => { | |
| 101 | const long = "z".repeat(200); | |
| 102 | const svg = renderBadge({ label: long, value: long }); | |
| 103 | // "z" is not used in any SVG attribute / tag name, so every occurrence | |
| 104 | // in the output comes from our label / value payloads. Each of label + | |
| 105 | // value appears in: <title>, aria-label, shadow <text>, main <text> — | |
| 106 | // so at most 64 chars × 2 payloads × 4 occurrences = 512. Unclamped a | |
| 107 | // 200-char payload would produce well over 1000. | |
| 108 | const matches = svg.match(/z/g) || []; | |
| 109 | expect(matches.length).toBeLessThan(600); | |
| 110 | expect(matches.length).toBeGreaterThan(0); | |
| 111 | // Sanity: SVG should be well under the unclamped size. | |
| 112 | expect(svg.length).toBeLessThan(2500); | |
| 113 | }); | |
| 114 | }); | |
| 115 | ||
| 116 | describe("badge — routes", () => { | |
| 117 | it("GET /:o/:r/badge/gates.svg returns SVG (even when repo missing)", async () => { | |
| 118 | const res = await app.request("/alice/nope/badge/gates.svg"); | |
| 119 | expect(res.status).toBe(200); | |
| 120 | expect(res.headers.get("content-type")).toContain("image/svg+xml"); | |
| 121 | const body = await res.text(); | |
| 122 | expect(body.startsWith("<svg")).toBe(true); | |
| 123 | }); | |
| 124 | ||
| 125 | it("GET /:o/:r/badge/issues.svg returns SVG", async () => { | |
| 126 | const res = await app.request("/alice/nope/badge/issues.svg"); | |
| 127 | expect(res.status).toBe(200); | |
| 128 | expect(res.headers.get("content-type")).toContain("image/svg+xml"); | |
| 129 | }); | |
| 130 | ||
| 131 | it("GET /:o/:r/badge/prs.svg returns SVG", async () => { | |
| 132 | const res = await app.request("/alice/nope/badge/prs.svg"); | |
| 133 | expect(res.status).toBe(200); | |
| 134 | expect(res.headers.get("content-type")).toContain("image/svg+xml"); | |
| 135 | }); | |
| 136 | ||
| 137 | it("GET /:o/:r/badge/status.svg returns SVG", async () => { | |
| 138 | const res = await app.request("/alice/nope/badge/status.svg"); | |
| 139 | expect(res.status).toBe(200); | |
| 140 | expect(res.headers.get("content-type")).toContain("image/svg+xml"); | |
| 141 | }); | |
| 142 | ||
| 143 | it("GET /:o/:r/badge/status/:context.svg returns SVG", async () => { | |
| 144 | const res = await app.request("/alice/nope/badge/status/ci.svg"); | |
| 145 | expect(res.status).toBe(200); | |
| 146 | expect(res.headers.get("content-type")).toContain("image/svg+xml"); | |
| 147 | const body = await res.text(); | |
| 148 | expect(body).toContain("ci"); | |
| 149 | }); | |
| 150 | ||
| 151 | it("responses are Cache-Control aware", async () => { | |
| 152 | const res = await app.request("/alice/nope/badge/gates.svg"); | |
| 153 | const cc = res.headers.get("cache-control") || ""; | |
| 154 | expect(cc).toContain("max-age"); | |
| 155 | }); | |
| 156 | }); |