Public: anyone can read this; the crew can change it.
A JUnit XML test report in a repository opens as its results: pass, fail and skip counts, then each failing case with its message #11076
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11072-a-junit-xml-test-report-in-a-repository → main 5 commits fork 3 threads · 3 unresolved
8 files
changed +475
−1
docs/design/areas/render.md +4
| @@ -35,3 +35,7 @@ A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the exis | ||
| 35 | 35 | ## SARIF findings (.sarif) |
| 36 | 36 | |
| 37 | 37 | `src/web/render/sarif.ts` draws one existing `.table` (`data-dense`) per run under an `h3` with the tool's name, with the level counts as a plain line above. `.sarif-view` is only a hook on the wrapper and has no rule. How it works: `docs/features/file-sarif.md`. |
| 38 | ||
| 39 | ## JUnit results (.junit.xml, TEST-*.xml) | |
| 40 | ||
| 41 | `src/web/render/junit.ts` draws the totals as a plain line, opened by one sentence of what the file is, then per suite an `h3`, one existing `.table` (`data-dense`) of its failing cases with each body in a `pre` below, and the passing cases inside one native `details`. `.junit-view` is only a hook on the wrapper and has no rule. The XML reader is its own: no DOCTYPE, no entity but the five predefined, at most 2,000 cases drawn (failing first). How it works: `docs/features/file-junit.md`. | |
docs/features/file-junit.md added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | # A JUnit XML report opens as its results | |
| 2 | ||
| 3 | Test runners (JUnit, pytest, vitest, Go via gotestsum) write JUnit XML. A file named `*.junit.xml` or `TEST-*.xml`, or any `.xml` whose root is `<testsuites>` or `<testsuite>`, opens as results instead of raw XML; Source is one tap away. Any other `.xml` stays source. | |
| 4 | ||
| 5 | - **Lead line:** one sentence ("2 of 40 tests fail."), then the totals: tests, failures, errors, skipped, time. | |
| 6 | - **Per suite:** a heading, then one `.table` (`data-dense`) of failing cases (failures and errors first): case, result and type, message, time. A case's body (stack trace) follows in a `pre`, cut at 40 lines and 8,000 characters. | |
| 7 | - **Passing and skipped** cases sit folded in one native `details`. | |
| 8 | - **Cap:** at most 2,000 cases are drawn, failing first; the page says how many were left out. Files over 5 MB keep Source. | |
| 9 | - **Fallback:** malformed XML, a DOCTYPE (refused, never expanded), nesting or element counts past the bounds, or a root that is not a test report shows Source with the reason. | |
| 10 | - The XML reader is its own: no entity but the five predefined. Every string is escaped. No script, no new CSS (`.junit-view` is a hook with no rule). | |
| 11 | ||
| 12 | **Terminal:** opening such a file in the workspace shows a table of result, case and message, headed by a totals row (`junitTable` in `src/tui/screens/workspace.ts`, over the same `parseJunit`), "N failing cases" in the status line; `s` flips to the source. A file that is not a report opens as source with the same reason. | |
| 13 | ||
| 14 | Code: `src/web/render/junit.ts`, `src/tui/screens/workspace.ts`. Tests: `test/render-junit.test.ts`, `test/tui-workspace.test.ts`. |
src/tui/screens/workspace.ts +26 −1
| @@ -59,6 +59,7 @@ import { MAX_COLUMNS, parseCsv, type Table } from "../../text/csv.ts"; | ||
| 59 | 59 | import { parseGeoJson, propertyRows } from "../../web/render/geojson.ts"; |
| 60 | 60 | import { gcodeFacts, parseGcode } from "../../web/render/gcode.ts"; |
| 61 | 61 | import { gpxFacts, parseGpx } from "../../web/render/gpx.ts"; |
| 62 | import { CASE_CAP, parseJunit } from "../../web/render/junit.ts"; | |
| 62 | 63 | import { RESULT_CAP, parseSarif } from "../../web/render/sarif.ts"; |
| 63 | 64 | import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts"; |
| 64 | 65 | import { fileSize } from "../../web/ui.ts"; |
| @@ -417,6 +418,23 @@ function sarifTable(source: string): Table | string { | ||
| 417 | 418 | return { header: ["Level", "Rule", "Location", "Message", "Tool"], rows, more: all.length - rows.length, delimiter: ",", columns: 5 }; |
| 418 | 419 | } |
| 419 | 420 | |
| 421 | /** A JUnit report, by the web's own rule (file name, or root element). */ | |
| 422 | function isJunitPath(path: string, source: Uint8Array): boolean { | |
| 423 | return /\.xml$/i.test(path) && fileKind(path, source.subarray(0, 4096)) === "junit"; | |
| 424 | } | |
| 425 | ||
| 426 | /** A JUnit report as a table: a totals row, then each failing case with its message; the reason when it is not drawn. Shares the web's parser. */ | |
| 427 | function junitTable(source: string): Table | string { | |
| 428 | const parsed = parseJunit(source); | |
| 429 | if ("error" in parsed) return `not drawn as test results: ${parsed.error}`; | |
| 430 | const t = parsed.totals; | |
| 431 | const bad = parsed.suites.flatMap((s) => | |
| 432 | s.cases.filter((c) => c.status === "failed" || c.status === "error").map((c) => [c.status === "error" ? "error" : "failed", (c.classname === "" ? c.name : `${c.classname} › ${c.name}`) || "(unnamed)", c.message.replace(/\s+/g, " ")]), | |
| 433 | ); | |
| 434 | const rows = [["total", `${t.tests} tests`, `${t.failures + t.errors} of ${t.tests} tests fail · ${t.skipped} skipped`], ...bad.slice(0, CASE_CAP)]; | |
| 435 | return { header: ["Result", "Case", "Message"], rows, more: bad.length - Math.min(bad.length, CASE_CAP), delimiter: ",", columns: 3 }; | |
| 436 | } | |
| 437 | ||
| 420 | 438 | /** `.gcode`: a sliced print, drawn as a table of its facts (layers, box, filament, time). Shares the web's parser. */ |
| 421 | 439 | function isGcodePath(path: string): boolean { |
| 422 | 440 | return /\.(gcode|gco)$/i.test(path); |
| @@ -641,6 +659,11 @@ class Tab { | ||
| 641 | 659 | this.table = typeof findings === "string" ? null : findings; |
| 642 | 660 | this.fault = typeof findings === "string" ? findings : ""; |
| 643 | 661 | if (this.table !== null) this.grid = layoutTable(this.table); |
| 662 | } else if (isJunitPath(this.path, source)) { | |
| 663 | const results = junitTable(new TextDecoder().decode(source)); | |
| 664 | this.table = typeof results === "string" ? null : results; | |
| 665 | this.fault = typeof results === "string" ? results : ""; | |
| 666 | if (this.table !== null) this.grid = layoutTable(this.table); | |
| 644 | 667 | } else if (isGcodePath(this.path)) { |
| 645 | 668 | this.table = gcodeTable(new TextDecoder().decode(source)); |
| 646 | 669 | if (this.table !== null) this.grid = layoutTable(this.table); |
| @@ -2755,7 +2778,9 @@ export function workspaceScreen( | ||
| 2755 | 2778 | ? "" |
| 2756 | 2779 | : `${t.path} · ${ |
| 2757 | 2780 | t.tabular && t.table !== null |
| 2758 | ? tableStatus(t.table, t.unread, isSubtitlePath(t.path), isIcsPath(t.path), isGpxPath(t.path), isVcfPath(t.path), isGeoJsonPath(t.path), isJsonlPath(t.path), isSarifPath(t.path)) | |
| 2781 | ? t.raw !== null && isJunitPath(t.path, t.raw) | |
| 2782 | ? plural(t.table.rows.length - 1 + t.table.more, "failing case") | |
| 2783 | : tableStatus(t.table, t.unread, isSubtitlePath(t.path), isIcsPath(t.path), isGpxPath(t.path), isVcfPath(t.path), isGeoJsonPath(t.path), isJsonlPath(t.path), isSarifPath(t.path)) | |
| 2759 | 2784 | : `${plural( |
| 2760 | 2785 | t.reading ? t.doc.length : t.plain.length, |
| 2761 | 2786 | "line", |
src/web/render/junit.ts added +259
| @@ -0,0 +1,259 @@ | ||
| 1 | /** | |
| 2 | * A JUnit XML report as its results: totals (tests, failures, errors, | |
| 3 | * skipped, time) at the top, then each suite with its failing and erroring | |
| 4 | * cases first (name, class, time, the message, the first `BODY_LINES` lines of | |
| 5 | * the body in a `<pre>`) and the passing ones folded. The XML reader here is | |
| 6 | * the whole of its parser: one forward scan, no dependency, and no entity | |
| 7 | * beyond the five predefined and numeric character references. A DOCTYPE | |
| 8 | * (where entity expansion lives) is refused, as are unbalanced tags, nesting | |
| 9 | * past `MAX_DEPTH` and more than `MAX_NODES` elements; the page then falls | |
| 10 | * back to the source with the reason. At most `CASE_CAP` cases are drawn, | |
| 11 | * failing ones before any passing one, and the page says how many were left | |
| 12 | * out. Every string is escaped. | |
| 13 | */ | |
| 14 | ||
| 15 | import { escapeHtml, html, raw } from "../html.ts"; | |
| 16 | import { fileSize } from "../ui.ts"; | |
| 17 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 18 | ||
| 19 | export const CASE_CAP = 2000; | |
| 20 | export const BODY_LINES = 40; | |
| 21 | const MAX_DEPTH = 100; | |
| 22 | const MAX_NODES = 500_000; | |
| 23 | /** Text kept per element: a body is shown by its first lines only. */ | |
| 24 | const MAX_TEXT = 64_000; | |
| 25 | const MAX_BODY_CHARS = 8000; | |
| 26 | ||
| 27 | export interface XmlNode { | |
| 28 | readonly name: string; | |
| 29 | readonly attrs: Readonly<Record<string, string>>; | |
| 30 | readonly children: XmlNode[]; | |
| 31 | text: string; | |
| 32 | } | |
| 33 | export type Xml = { readonly root: XmlNode } | { readonly error: string }; | |
| 34 | ||
| 35 | const NAMED: Readonly<Record<string, string>> = { lt: "<", gt: ">", amp: "&", quot: '"', apos: "'" }; | |
| 36 | ||
| 37 | /** The five predefined entities and numeric references; any other `&…;` stays as written. */ | |
| 38 | function decode(s: string): string { | |
| 39 | if (!s.includes("&")) return s; | |
| 40 | return s.replace(/&(#x[0-9a-fA-F]{1,6}|#[0-9]{1,7}|[a-z]{2,4});/g, (m, ref: string) => { | |
| 41 | if (ref[0] !== "#") return NAMED[ref] ?? m; | |
| 42 | const code = ref[1] === "x" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10); | |
| 43 | const ok = code === 9 || code === 10 || code === 13 || (code >= 0x20 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) && code !== 0xfffe && code !== 0xffff); | |
| 44 | return ok ? String.fromCodePoint(code) : m; | |
| 45 | }); | |
| 46 | } | |
| 47 | ||
| 48 | const NAME = /[^\s=\/>"'<]+/y; | |
| 49 | const ATTR = /\s*([^\s=\/>"'<]+)\s*=\s*(?:"([^"<]*)"|'([^'<]*)')/y; | |
| 50 | ||
| 51 | /** Read XML into a tree of elements. Pure; never throws; one pass over the text. */ | |
| 52 | export function parseXml(source: string): Xml { | |
| 53 | const s = source.replace(/^/, ""); | |
| 54 | const stack: XmlNode[] = []; | |
| 55 | let root: XmlNode | undefined; | |
| 56 | let nodes = 0; | |
| 57 | let i = 0; | |
| 58 | const addText = (t: string): void => { | |
| 59 | const top = stack[stack.length - 1]; | |
| 60 | if (top !== undefined && top.text.length < MAX_TEXT) top.text += t; | |
| 61 | }; | |
| 62 | while (i < s.length) { | |
| 63 | const lt = s.indexOf("<", i); | |
| 64 | const end = lt === -1 ? s.length : lt; | |
| 65 | if (end > i) { | |
| 66 | const t = s.slice(i, end); | |
| 67 | if (stack.length > 0) addText(decode(t)); | |
| 68 | else if (t.trim() !== "") return { error: "it has text outside its root element" }; | |
| 69 | } | |
| 70 | if (lt === -1) break; | |
| 71 | i = lt; | |
| 72 | if (s.startsWith("<!--", i)) { | |
| 73 | const e = s.indexOf("-->", i + 4); | |
| 74 | if (e === -1) return { error: "a comment is never closed" }; | |
| 75 | i = e + 3; | |
| 76 | } else if (s.startsWith("<![CDATA[", i)) { | |
| 77 | const e = s.indexOf("]]>", i + 9); | |
| 78 | if (e === -1) return { error: "a CDATA section is never closed" }; | |
| 79 | if (stack.length === 0) return { error: "it has text outside its root element" }; | |
| 80 | addText(s.slice(i + 9, e)); | |
| 81 | i = e + 3; | |
| 82 | } else if (s.startsWith("<?", i)) { | |
| 83 | const e = s.indexOf("?>", i + 2); | |
| 84 | if (e === -1) return { error: "a processing instruction is never closed" }; | |
| 85 | i = e + 2; | |
| 86 | } else if (s.startsWith("<!", i)) { | |
| 87 | return { error: "it has a DOCTYPE or declaration, which is not read (entity expansion)" }; | |
| 88 | } else if (s.startsWith("</", i)) { | |
| 89 | const e = s.indexOf(">", i + 2); | |
| 90 | if (e === -1) return { error: "a closing tag is never finished" }; | |
| 91 | const name = s.slice(i + 2, e).trim(); | |
| 92 | const open = stack.pop(); | |
| 93 | if (open === undefined || open.name !== name) return { error: `the closing tag </${name.slice(0, 40)}> does not match an open element` }; | |
| 94 | i = e + 1; | |
| 95 | } else { | |
| 96 | NAME.lastIndex = i + 1; | |
| 97 | const m = NAME.exec(s); | |
| 98 | if (m === null) return { error: "a tag has no name" }; | |
| 99 | const attrs: Record<string, string> = {}; | |
| 100 | let p = NAME.lastIndex; | |
| 101 | for (;;) { | |
| 102 | ATTR.lastIndex = p; | |
| 103 | const a = ATTR.exec(s); | |
| 104 | if (a === null) break; | |
| 105 | attrs[a[1]!] = decode(a[2] ?? a[3] ?? ""); | |
| 106 | p = ATTR.lastIndex; | |
| 107 | } | |
| 108 | while (p < s.length && /\s/.test(s[p]!)) p++; | |
| 109 | const selfClosing = s[p] === "/"; | |
| 110 | if (selfClosing) p++; | |
| 111 | if (s[p] !== ">") return { error: "a tag is malformed" }; | |
| 112 | if (++nodes > MAX_NODES) return { error: "it has too many elements" }; | |
| 113 | const node: XmlNode = { name: m[0], attrs, children: [], text: "" }; | |
| 114 | const parent = stack[stack.length - 1]; | |
| 115 | if (parent !== undefined) parent.children.push(node); | |
| 116 | else if (root !== undefined) return { error: "it has more than one root element" }; | |
| 117 | else root = node; | |
| 118 | if (!selfClosing) { | |
| 119 | if (stack.length >= MAX_DEPTH) return { error: "its elements nest too deeply" }; | |
| 120 | stack.push(node); | |
| 121 | } | |
| 122 | i = p + 1; | |
| 123 | } | |
| 124 | } | |
| 125 | if (stack.length > 0) return { error: `<${stack[stack.length - 1]!.name}> is never closed` }; | |
| 126 | if (root === undefined) return { error: "it has no root element" }; | |
| 127 | return { root }; | |
| 128 | } | |
| 129 | ||
| 130 | export type Status = "failed" | "error" | "skipped" | "passed"; | |
| 131 | export interface Case { | |
| 132 | readonly name: string; | |
| 133 | readonly classname: string; | |
| 134 | readonly time: number; | |
| 135 | readonly status: Status; | |
| 136 | readonly message: string; | |
| 137 | readonly kind: string; | |
| 138 | readonly body: string; | |
| 139 | } | |
| 140 | export interface Suite { | |
| 141 | readonly name: string; | |
| 142 | readonly cases: readonly Case[]; | |
| 143 | } | |
| 144 | export interface Totals { | |
| 145 | readonly tests: number; | |
| 146 | readonly failures: number; | |
| 147 | readonly errors: number; | |
| 148 | readonly skipped: number; | |
| 149 | readonly time: number; | |
| 150 | } | |
| 151 | export type Junit = { readonly suites: Suite[]; readonly totals: Totals } | { readonly error: string }; | |
| 152 | ||
| 153 | const seconds = (v: string | undefined): number => { | |
| 154 | const n = v === undefined ? NaN : Number(v); | |
| 155 | return Number.isFinite(n) && n >= 0 ? n : 0; | |
| 156 | }; | |
| 157 | ||
| 158 | function toCase(n: XmlNode): Case { | |
| 159 | const bad = n.children.find((c) => c.name === "failure" || c.name === "error"); | |
| 160 | const skip = n.children.find((c) => c.name === "skipped"); | |
| 161 | const status: Status = bad === undefined ? (skip !== undefined ? "skipped" : "passed") : bad.name === "failure" ? "failed" : "error"; | |
| 162 | const el = bad ?? skip; | |
| 163 | return { | |
| 164 | name: n.attrs.name ?? "", | |
| 165 | classname: n.attrs.classname ?? "", | |
| 166 | time: seconds(n.attrs.time), | |
| 167 | status, | |
| 168 | message: el?.attrs.message ?? "", | |
| 169 | kind: el?.attrs.type ?? "", | |
| 170 | body: el?.text ?? "", | |
| 171 | }; | |
| 172 | } | |
| 173 | ||
| 174 | /** Parse a JUnit report's text. Pure; never throws. */ | |
| 175 | export function parseJunit(source: string): Junit { | |
| 176 | const xml = parseXml(source); | |
| 177 | if ("error" in xml) return { error: xml.error }; | |
| 178 | const root = xml.root; | |
| 179 | if (root.name !== "testsuites" && root.name !== "testsuite") return { error: "its root element is not <testsuites> or <testsuite>" }; | |
| 180 | const suites: Suite[] = []; | |
| 181 | // Iterative walk: a suite is any <testsuite> with cases of its own. | |
| 182 | const work: XmlNode[] = [root]; | |
| 183 | while (work.length > 0) { | |
| 184 | const n = work.pop()!; | |
| 185 | const cases = n.children.filter((c) => c.name === "testcase").map(toCase); | |
| 186 | if (n.name === "testsuite" && (cases.length > 0 || !n.children.some((c) => c.name === "testsuite"))) | |
| 187 | suites.push({ name: n.attrs.name ?? "", cases }); | |
| 188 | for (let k = n.children.length - 1; k >= 0; k--) if (n.children[k]!.name === "testsuite") work.push(n.children[k]!); | |
| 189 | } | |
| 190 | const all = suites.flatMap((s) => s.cases); | |
| 191 | const count = (st: Status): number => all.filter((c) => c.status === st).length; | |
| 192 | const declared = root.name === "testsuites" && root.attrs.time !== undefined ? seconds(root.attrs.time) : undefined; | |
| 193 | const time = declared ?? all.reduce((t, c) => t + c.time, 0); | |
| 194 | return { suites, totals: { tests: all.length, failures: count("failed"), errors: count("error"), skipped: count("skipped"), time } }; | |
| 195 | } | |
| 196 | ||
| 197 | const fmtTime = (t: number): string => `${Number(t.toFixed(3))} s`; | |
| 198 | ||
| 199 | function body(text: string): string { | |
| 200 | const lines = text.replace(/\r\n?/g, "\n").replace(/^\n+|\s+$/g, "").split("\n"); | |
| 201 | let out = lines.slice(0, BODY_LINES).join("\n"); | |
| 202 | const cut = lines.length > BODY_LINES; | |
| 203 | if (out.length > MAX_BODY_CHARS) out = out.slice(0, MAX_BODY_CHARS); | |
| 204 | if (out === "") return ""; | |
| 205 | return `<pre>${escapeHtml(out)}</pre>${cut ? `<p class="muted small">${(lines.length - BODY_LINES).toLocaleString()} more lines in the raw file.</p>` : ""}`; | |
| 206 | } | |
| 207 | ||
| 208 | const label = (c: Case): string => (c.classname === "" ? c.name : c.name === "" ? c.classname : `${c.classname} › ${c.name}`) || "(unnamed)"; | |
| 209 | ||
| 210 | /** One plain sentence of what the file is and how it went. */ | |
| 211 | export function leadSentence(t: Totals): string { | |
| 212 | const bad = t.failures + t.errors; | |
| 213 | return bad === 0 ? `This is a test report: all ${t.tests.toLocaleString()} tests pass.` : `This is a test report: ${bad.toLocaleString()} of ${t.tests.toLocaleString()} tests fail.`; | |
| 214 | } | |
| 215 | ||
| 216 | export function junit(input: RenderInput): Rendered { | |
| 217 | const doc = parseJunit(new TextDecoder().decode(input.body)); | |
| 218 | if ("error" in doc) return { content: html``, lines: 0, fallback: `This file is not drawn as test results: ${doc.error}.` }; | |
| 219 | const t = doc.totals; | |
| 220 | const badTotal = t.failures + t.errors; | |
| 221 | let badLeft = Math.min(badTotal, CASE_CAP); | |
| 222 | let goodLeft = CASE_CAP - badLeft; | |
| 223 | let drawn = 0; | |
| 224 | let groups = ""; | |
| 225 | for (const suite of doc.suites) { | |
| 226 | const bad = suite.cases.filter((c) => c.status === "failed" || c.status === "error").slice(0, badLeft); | |
| 227 | badLeft -= bad.length; | |
| 228 | const good = suite.cases.filter((c) => c.status === "passed" || c.status === "skipped").slice(0, goodLeft); | |
outdated
Principle 7: each failing case is a section/h4 box with prose; use the existing .table (data-dense) rows like sarif.ts, with the body in a pre below. |
||
| 229 | goodLeft -= good.length; | |
| 230 | drawn += bad.length + good.length; | |
| 231 | groups += `<h3>${escapeHtml(suite.name || "Unnamed suite")} <span class="muted small">${suite.cases.length.toLocaleString()} ${suite.cases.length === 1 ? "test" : "tests"}</span></h3>`; | |
| 232 | if (bad.length > 0) { | |
| 233 | const rows = bad | |
| 234 | .map( | |
| 235 | (c) => | |
| 236 | `<tr><td>${escapeHtml(label(c))}</td><td>${c.status === "error" ? "error" : "failed"}${c.kind === "" ? "" : ` · ${escapeHtml(c.kind)}`}</td><td>${escapeHtml(c.message.replace(/\s+/g, " "))}</td><td>${fmtTime(c.time)}</td></tr>`, | |
| 237 | ) | |
| 238 | .join(""); | |
| 239 | groups += `<table class="table" data-dense aria-label="${escapeHtml(suite.name || "Unnamed suite")} failing cases"><thead><tr><th scope="col">Case</th><th scope="col">Result</th><th scope="col">Message</th><th scope="col">Time</th></tr></thead><tbody>${rows}</tbody></table>`; | |
| 240 | for (const c of bad) { | |
| 241 | const b = body(c.body); | |
| 242 | if (b !== "") groups += `<p class="muted small">${escapeHtml(label(c))}</p>${b}`; | |
outdated
Principle 1 and the terminal door: the tally is bare numbers; lead with a plain sentence (e.g. '2 of 40 tests fail.'). Also the terminal has no screen for this kind. |
||
| 243 | } | |
| 244 | } | |
| 245 | if (good.length > 0) { | |
| 246 | const items = good.map((c) => `<li>${escapeHtml(label(c))} <span class="muted small">${c.status === "skipped" ? "skipped" : fmtTime(c.time)}</span></li>`).join(""); | |
| 247 | groups += `<details><summary>${good.length.toLocaleString()} passing or skipped</summary><ul>${items}</ul></details>`; | |
| 248 | } | |
| 249 | } | |
| 250 | const out = t.tests - drawn; | |
| 251 | const more = | |
| 252 | out > 0 | |
| 253 | ? `<p class="muted small">Showing ${drawn.toLocaleString()} of ${t.tests.toLocaleString()} cases, ${out.toLocaleString()} left out. The rest is in the raw file.</p>` | |
| 254 | : ""; | |
| 255 | const tally = `${leadSentence(t)} ${t.tests.toLocaleString()} tests · ${t.failures.toLocaleString()} failures · ${t.errors.toLocaleString()} errors · ${t.skipped.toLocaleString()} skipped · ${fmtTime(t.time)}`; | |
| 256 | const content = html`<div class="junit-view"><p>${tally}</p>${raw(groups)}${raw(more)}</div>`; | |
| 257 | const verdict = badTotal === 0 ? "all passing" : `${badTotal.toLocaleString()} failing`; | |
| 258 | return { content, lines: 0, summary: `${t.tests.toLocaleString()} tests · ${verdict} · ${fileSize(input.size)}` }; | |
| 259 | } |
src/web/render/kinds.ts +17
| @@ -38,6 +38,7 @@ export type FileKind = | ||
| 38 | 38 | | "mail" |
| 39 | 39 | | "gcode" |
| 40 | 40 | | "sarif" |
| 41 | | "junit" | |
| 41 | 42 | | "text" |
| 42 | 43 | | "binary"; |
| 43 | 44 | |
| @@ -74,6 +75,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 74 | 75 | mail: 5 * MB, |
| 75 | 76 | gcode: 50 * MB, |
| 76 | 77 | sarif: 5 * MB, |
| 78 | junit: 5 * MB, | |
| 77 | 79 | text: 1 * MB, |
| 78 | 80 | binary: 0, |
| 79 | 81 | }; |
| @@ -228,6 +230,20 @@ const extension = (path: string): string => { | ||
| 228 | 230 | return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase(); |
| 229 | 231 | }; |
| 230 | 232 | |
| 233 | /** | |
| 234 | * A JUnit report is an `.xml` file named `*.junit.xml` or `TEST-*.xml`, or | |
| 235 | * whose root element (past the prolog and comments) is `<testsuites>` or | |
| 236 | * `<testsuite>`. Any other `.xml` stays source. | |
| 237 | */ | |
| 238 | function isJunit(path: string, head: Uint8Array): boolean { | |
| 239 | if (extension(path) !== "xml") return false; | |
| 240 | const name = path.slice(path.lastIndexOf("/") + 1); | |
| 241 | if (/\.junit\.xml$/i.test(name) || /^TEST-.+\.xml$/.test(name)) return true; | |
| 242 | const text = new TextDecoder().decode(head).replace(/^\uFEFF/, "").replace(/<\?[\s\S]*?\?>|<!--[\s\S]*?-->/g, ""); | |
| 243 | const root = /<([A-Za-z_][\w.:-]*)/.exec(text); | |
| 244 | return root?.[1] === "testsuites" || root?.[1] === "testsuite"; | |
| 245 | } | |
| 246 | ||
| 231 | 247 | /** Git's own heuristic: a NUL in the first 8 KB means binary. */ |
| 232 | 248 | const looksBinary = (head: Uint8Array): boolean => head.subarray(0, 8000).includes(0); |
| 233 | 249 | |
| @@ -249,6 +265,7 @@ export function fileKind(path: string, head: Uint8Array): FileKind { | ||
| 249 | 265 | if (media !== undefined) return media.kind; |
| 250 | 266 | if (isArchive(path, head)) return "archive"; |
| 251 | 267 | if (looksBinary(head)) return "binary"; |
| 268 | if (isJunit(path, head)) return "junit"; | |
| 252 | 269 | return TEXTUAL[extension(path)] ?? "text"; |
| 253 | 270 | } |
| 254 | 271 |
src/web/render/renderers.ts +2
| @@ -17,6 +17,7 @@ import { gcode } from "./gcode.ts"; | ||
| 17 | 17 | import { gpx } from "./gpx.ts"; |
| 18 | 18 | import { font } from "./font.ts"; |
| 19 | 19 | import { ics } from "./ics.ts"; |
| 20 | import { junit } from "./junit.ts"; | |
| 20 | 21 | import { vcf } from "./vcf.ts"; |
| 21 | 22 | import { htmlPreview } from "./html_preview.ts"; |
| 22 | 23 | import { image } from "./image.ts"; |
| @@ -93,6 +94,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 93 | 94 | calendar: ics, |
| 94 | 95 | contacts: vcf, |
| 95 | 96 | sarif, |
| 97 | junit, | |
| 96 | 98 | mail: eml, |
| 97 | 99 | archive, |
| 98 | 100 | font, |
test/render-junit.test.ts added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | /** JUnit XML reports as results: a mixed report, malformed input, a DOCTYPE bomb, the cap, escaping, the registry. */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { CASE_CAP, junit, parseJunit, parseXml } from "../src/web/render/junit.ts"; | |
| 4 | import { fileKind } from "../src/web/render/kinds.ts"; | |
| 5 | import { RENDERERS } from "../src/web/render/renderers.ts"; | |
| 6 | ||
| 7 | const enc = (s: string): Uint8Array => new TextEncoder().encode(s); | |
| 8 | const draw = (text: string) => junit({ path: "r/TEST-a.xml", size: text.length, body: enc(text), media: "/m", download: "/d" }); | |
| 9 | ||
| 10 | const MIXED = `<?xml version="1.0" encoding="UTF-8"?> | |
| 11 | <testsuites time="3.5"> | |
| 12 | <testsuite name="math" tests="4"> | |
| 13 | <testcase classname="m.Add" name="adds" time="0.1"/> | |
| 14 | <testcase classname="m.Add" name="overflows" time="0.2"><failure message="expected 3 & got 4" type="AssertionError">at add (a.ts:1) | |
| 15 | at run (b.ts:2)</failure></testcase> | |
| 16 | <testcase classname="m.Div" name="by zero" time="0.3"><error message="boom" type="Error"><![CDATA[<trace> here]]></error></testcase> | |
| 17 | <testcase classname="m.Div" name="later"><skipped/></testcase> | |
| 18 | </testsuite> | |
| 19 | </testsuites>`; | |
| 20 | ||
| 21 | describe("junit", () => { | |
| 22 | test("the registry names it by file name and by root, and an ordinary .xml stays source", () => { | |
| 23 | expect(fileKind("x/out.junit.xml", enc("<a/>"))).toBe("junit"); | |
| 24 | expect(fileKind("x/TEST-foo.xml", enc("<a/>"))).toBe("junit"); | |
| 25 | expect(fileKind("x/report.xml", enc('<?xml version="1.0"?><!-- c --><testsuite name="a"/>'))).toBe("junit"); | |
| 26 | expect(fileKind("x/report.xml", enc("<testsuites/>"))).toBe("junit"); | |
| 27 | expect(fileKind("x/pom.xml", enc("<project><testsuite/></project>"))).toBe("text"); | |
| 28 | expect(fileKind("x/report.txt", enc("<testsuite/>"))).toBe("text"); | |
| 29 | expect(RENDERERS.junit).toBe(junit); | |
| 30 | }); | |
| 31 | ||
| 32 | test("a mixed report: totals, failing cases first with message and body, passing folded", () => { | |
| 33 | const r = draw(MIXED); | |
| 34 | expect(r.fallback).toBeUndefined(); | |
| 35 | const h = String(r.content); | |
| 36 | expect(h).toContain("This is a test report: 2 of 4 tests fail. 4 tests · 1 failures · 1 errors · 1 skipped · 3.5 s"); | |
| 37 | expect(h).toContain("m.Add › overflows"); | |
| 38 | expect(h).toContain("expected 3 & got 4"); | |
| 39 | expect(h).toContain("<pre>at add (a.ts:1)\nat run (b.ts:2)</pre>"); | |
| 40 | expect(h).toContain("<trace> here"); | |
| 41 | expect(h.indexOf("overflows")).toBeLessThan(h.indexOf("<details>")); | |
| 42 | expect(h).toContain("2 passing or skipped"); | |
| 43 | expect(r.summary).toContain("2 failing"); | |
| 44 | }); | |
| 45 | ||
| 46 | test("a body over 8000 characters is cut", () => { | |
| 47 | const h = String(draw(`<testsuite name="s"><testcase name="t"><failure>${"x".repeat(9000)}</failure></testcase></testsuite>`).content); | |
| 48 | expect(h).toContain("x".repeat(8000)); | |
| 49 | expect(h).not.toContain("x".repeat(8001)); | |
| 50 | }); | |
| 51 | ||
| 52 | test("failing cases are rows of a dense table, not boxes", () => { | |
| 53 | const h = String(draw(MIXED).content); | |
| 54 | expect(h).toContain('<table class="table" data-dense'); | |
| 55 | expect(h).not.toContain("<section>"); | |
| 56 | expect(h).not.toContain("<h4>"); | |
| 57 | }); | |
| 58 | ||
| 59 | test("a body keeps its first 40 lines", () => { | |
| 60 | const lines = Array.from({ length: 60 }, (_, i) => `line${i}`).join("\n"); | |
| 61 | const h = String(draw(`<testsuite name="s"><testcase name="t"><failure>${lines}</failure></testcase></testsuite>`).content); | |
| 62 | expect(h).toContain("line39"); | |
| 63 | expect(h).not.toContain("line40"); | |
| 64 | expect(h).toContain("20 more lines"); | |
| 65 | }); | |
| 66 | ||
| 67 | test("malformed files fall back with a reason", () => { | |
| 68 | for (const bad of ["<testsuite><testcase></testsuite>", "<testsuite>", "", "<testsuite name='a", "<a/><b/>", "<project/>", "plain text"]) { | |
| 69 | const r = draw(bad); | |
| 70 | expect(r.fallback).toContain("not drawn as test results"); | |
| 71 | } | |
| 72 | }); | |
| 73 | ||
| 74 | test("a DOCTYPE bomb is refused without being expanded", () => { | |
| 75 | const bomb = `<?xml version="1.0"?><!DOCTYPE lolz [<!ENTITY a "aaaaaaaaaa"><!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;"><!ENTITY c "&b;&b;&b;&b;&b;&b;&b;&b;">]><testsuite name="&c;"><testcase name="&c;"/></testsuite>`; | |
| 76 | const r = draw(bomb); | |
| 77 | expect(r.fallback).toContain("DOCTYPE"); | |
| 78 | expect(fileKind("x/report.xml", enc(bomb))).toBe("junit"); | |
| 79 | }); | |
| 80 | ||
| 81 | test("only the five predefined entities expand; others stay as written", () => { | |
| 82 | const x = parseXml(`<a b="<&AB&foo;">"'></a>`); | |
| 83 | if ("error" in x) throw new Error(x.error); | |
| 84 | expect(x.root.attrs.b).toBe("<&AB&foo;"); | |
| 85 | expect(x.root.text).toBe(`"'>`); | |
| 86 | }); | |
| 87 | ||
| 88 | test("the cap: failing cases are drawn first and the page says how many were left out", () => { | |
| 89 | const n = CASE_CAP + 500; | |
| 90 | let cases = '<testcase name="bad"><failure message="x"/></testcase>'; | |
| 91 | for (let i = 0; i < n; i++) cases += `<testcase name="ok${i}"/>`; | |
| 92 | const r = draw(`<testsuite name="big">${cases}</testsuite>`); | |
| 93 | const h = String(r.content); | |
| 94 | expect(h).toContain(`Showing ${CASE_CAP.toLocaleString()} of ${(n + 1).toLocaleString()} cases, 501 left out`); | |
| 95 | expect(h).toContain("<td>bad</td>"); | |
| 96 | expect(h).toContain("ok1997"); | |
| 97 | expect(h).not.toContain("ok1999<"); | |
| 98 | }); | |
| 99 | ||
| 100 | test("deep nesting and huge element counts are bounded", () => { | |
| 101 | expect("error" in parseXml("<a>".repeat(1000))).toBe(true); | |
| 102 | expect("error" in parseXml(`<testsuite>${"<x/>".repeat(600_000)}</testsuite>`)).toBe(true); | |
| 103 | }); | |
| 104 | ||
| 105 | test("a <script> in a message, a name or a body is escaped", () => { | |
| 106 | const x = `<testsuite name="<script>s()</script>"><testcase classname="c" name="<img onerror=x>"><failure message="<script>alert(1)</script>"><![CDATA[<script>alert(2)</script>]]></failure></testcase></testsuite>`; | |
| 107 | const h = String(draw(x).content); | |
| 108 | expect(h).not.toContain("<script"); | |
| 109 | expect(h).not.toContain("<img"); | |
| 110 | expect(h).toContain("<script>alert(1)</script>"); | |
| 111 | expect(h).toContain("<script>alert(2)</script>"); | |
| 112 | }); | |
| 113 | ||
| 114 | test("parseJunit totals fall back to the sum of cases when no time is declared", () => { | |
| 115 | const j = parseJunit(`<testsuite><testcase name="a" time="1.5"/><testcase name="b" time="x"/></testsuite>`); | |
| 116 | if ("error" in j) throw new Error(j.error); | |
| 117 | expect(j.totals).toEqual({ tests: 2, failures: 0, errors: 0, skipped: 0, time: 1.5 }); | |
| 118 | }); | |
| 119 | }); |
test/tui-workspace.test.ts +34
| @@ -567,6 +567,40 @@ describe("the terminal workspace", () => { | ||
| 567 | 567 | } |
| 568 | 568 | }); |
| 569 | 569 | |
| 570 | test("a JUnit report opens as its totals and failing cases, sharing the web parser", async () => { | |
| 571 | const made = await createRepo(forge, alice, { name: "junitrepo", visibility: "public", description: "tests" }); | |
| 572 | if (!made.ok) throw new Error("repo failed"); | |
| 573 | const xml = '<testsuite name="s"><testcase classname="m.A" name="ok"/><testcase classname="m.A" name="bad"><failure message="expected 1 got 2"/></testcase></testsuite>'; | |
| 574 | await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: "TEST-a.xml", content: xml }, { path: "TEST-b.xml", content: "<nope" }] }); | |
| 575 | const h = boot(); | |
| 576 | try { | |
| 577 | await h.settle(); | |
| 578 | await open(h, "junitrepo"); | |
| 579 | await openFile(h, "TEST-a.xml"); | |
| 580 | expect(h.text()).toContain("1 of 2 tests fail"); | |
| 581 | expect(h.text()).toContain("m.A › bad"); | |
| 582 | expect(h.text()).toContain("expected 1 got 2"); | |
| 583 | expect(h.text()).toContain("1 failing case"); | |
| 584 | } finally { | |
| 585 | h.stop(); | |
| 586 | } | |
| 587 | }); | |
| 588 | ||
| 589 | test("a JUnit-named file that is not a report opens as source and says why", async () => { | |
| 590 | const made = await createRepo(forge, alice, { name: "badjunit", visibility: "public", description: "tests" }); | |
| 591 | if (!made.ok) throw new Error("repo failed"); | |
| 592 | await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: "TEST-b.xml", content: "<root/>" }] }); | |
| 593 | const h = boot(); | |
| 594 | try { | |
| 595 | await h.settle(); | |
| 596 | await open(h, "badjunit"); | |
| 597 | await openFile(h, "TEST-b.xml"); | |
| 598 | expect(h.text()).toContain("not drawn as test results"); | |
| 599 | } finally { | |
| 600 | h.stop(); | |
| 601 | } | |
| 602 | }); | |
| 603 | ||
| 570 | 604 | test("a .gcode opens as its facts, sharing the web parser, and s flips to the source", async () => { |
| 571 | 605 | const made = await createRepo(forge, alice, { name: "prints3d", visibility: "public", description: "slices" }); |
| 572 | 606 | if (!made.ok) throw new Error("repo failed"); |
Add docs/features/file-junit.md and link it here, as the SARIF section does with file-sarif.md.