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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11072-a-junit-xml-test-report-in-a-repository → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/11072-a-junit-xml-test-report-in-a-repository was merged into main as 129f573.

Review

Sign in to review this change.
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
3535 ## SARIF findings (.sarif)
3636
3737 `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`.
outdated

Add docs/features/file-junit.md and link it here, as the SARIF section does with file-sarif.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";
5959 import { parseGeoJson, propertyRows } from "../../web/render/geojson.ts";
6060 import { gcodeFacts, parseGcode } from "../../web/render/gcode.ts";
6161 import { gpxFacts, parseGpx } from "../../web/render/gpx.ts";
62 import { CASE_CAP, parseJunit } from "../../web/render/junit.ts";
6263 import { RESULT_CAP, parseSarif } from "../../web/render/sarif.ts";
6364 import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts";
6465 import { fileSize } from "../../web/ui.ts";
@@ -417,6 +418,23 @@ function sarifTable(source: string): Table | string {
417418 return { header: ["Level", "Rule", "Location", "Message", "Tool"], rows, more: all.length - rows.length, delimiter: ",", columns: 5 };
418419 }
419420
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
420438 /** `.gcode`: a sliced print, drawn as a table of its facts (layers, box, filament, time). Shares the web's parser. */
421439 function isGcodePath(path: string): boolean {
422440 return /\.(gcode|gco)$/i.test(path);
@@ -641,6 +659,11 @@ class Tab {
641659 this.table = typeof findings === "string" ? null : findings;
642660 this.fault = typeof findings === "string" ? findings : "";
643661 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);
644667 } else if (isGcodePath(this.path)) {
645668 this.table = gcodeTable(new TextDecoder().decode(source));
646669 if (this.table !== null) this.grid = layoutTable(this.table);
@@ -2755,7 +2778,9 @@ export function workspaceScreen(
27552778 ? ""
27562779 : `${t.path} · ${
27572780 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))
27592784 : `${plural(
27602785 t.reading ? t.doc.length : t.plain.length,
27612786 "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 =
3838 | "mail"
3939 | "gcode"
4040 | "sarif"
41 | "junit"
4142 | "text"
4243 | "binary";
4344
@@ -74,6 +75,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
7475 mail: 5 * MB,
7576 gcode: 50 * MB,
7677 sarif: 5 * MB,
78 junit: 5 * MB,
7779 text: 1 * MB,
7880 binary: 0,
7981 };
@@ -228,6 +230,20 @@ const extension = (path: string): string => {
228230 return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
229231 };
230232
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
231247 /** Git's own heuristic: a NUL in the first 8 KB means binary. */
232248 const looksBinary = (head: Uint8Array): boolean => head.subarray(0, 8000).includes(0);
233249
@@ -249,6 +265,7 @@ export function fileKind(path: string, head: Uint8Array): FileKind {
249265 if (media !== undefined) return media.kind;
250266 if (isArchive(path, head)) return "archive";
251267 if (looksBinary(head)) return "binary";
268 if (isJunit(path, head)) return "junit";
252269 return TEXTUAL[extension(path)] ?? "text";
253270 }
254271
src/web/render/renderers.ts +2
@@ -17,6 +17,7 @@ import { gcode } from "./gcode.ts";
1717 import { gpx } from "./gpx.ts";
1818 import { font } from "./font.ts";
1919 import { ics } from "./ics.ts";
20 import { junit } from "./junit.ts";
2021 import { vcf } from "./vcf.ts";
2122 import { htmlPreview } from "./html_preview.ts";
2223 import { image } from "./image.ts";
@@ -93,6 +94,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
9394 calendar: ics,
9495 contacts: vcf,
9596 sarif,
97 junit,
9698 mail: eml,
9799 archive,
98100 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 &amp; 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 &amp; got 4");
39 expect(h).toContain("<pre>at add (a.ts:1)\nat run (b.ts:2)</pre>");
40 expect(h).toContain("&lt;trace&gt; 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="&lt;&amp;&#65;&#x42;&foo;">&quot;&apos;&gt;</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="&lt;script&gt;s()&lt;/script&gt;"><testcase classname="c" name="&lt;img onerror=x&gt;"><failure message="&lt;script&gt;alert(1)&lt;/script&gt;"><![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("&lt;script&gt;alert(1)&lt;/script&gt;");
111 expect(h).toContain("&lt;script&gt;alert(2)&lt;/script&gt;");
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", () => {
567567 }
568568 });
569569
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
570604 test("a .gcode opens as its facts, sharing the web parser, and s flips to the source", async () => {
571605 const made = await createRepo(forge, alice, { name: "prints3d", visibility: "public", description: "slices" });
572606 if (!made.ok) throw new Error("repo failed");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs