Public: anyone can read this; the crew can change it.

An lcov coverage file in a repository opens as a table: each source file's line and branch coverage, worst first, linked to the file #11077

Merged opened this

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

Merged

torque/forge:torque/11073-an-lcov-coverage-file-in-a-repository-op → main 6 commits fork 5 threads · 5 unresolved

  • Merged. torque/11073-an-lcov-coverage-file-in-a-repository-op was merged into main as fee995c.

Review

Sign in to review this change.
11 files changed +319 −8
docs/features/file-lcov.md added +14
@@ -0,0 +1,14 @@
1 # An lcov coverage file opens as its coverage
2
3 `bun test --coverage-reporter=lcov` and `geninfo` write lcov tracefiles. A file named `lcov.info` (or `*.lcov`) opens as a table instead of raw text; Source is one tap away.
4
5 - **Totals** at the top: lines, functions and, when any record has them, branches, as hit over found.
6 - **One row per source file** (`SF:`), worst line coverage first, ties by path: a `<meter>` bar, then lines (`LH`/`LF`), functions (`FNH`/`FNF`) and branches (`BRH`/`BRF`) as `hit/found · percent`. A record with no `LF`/`LH` counts lines from its `DA:` entries.
7 - **File** links to that file (same repository, same ref) when the path is plain relative and exists at the ref; otherwise it is text.
8 - **Cap:** at most 5,000 files are drawn; the page says how many were left out.
9 - **Fallback:** a malformed file (missing `end_of_record`, `LH` above `LF`, a counter outside a record) shows Source with the reason.
10 - Every string is escaped. No script, no new CSS: it uses the `table` and `meter` components.
11
12 **Terminal:** opening an lcov file in the workspace shows File, Lines, Functions and Branches, worst first, "N files" in the status line (`lcovTable` in `src/tui/screens/workspace.ts`, over the same `parseLcov`); `s` flips to the source.
13
14 Code: `src/web/render/lcov.ts`, `src/tui/screens/workspace.ts`. Tests: `test/render-lcov.test.ts`, `test/tui-workspace.test.ts`.
src/tui/screens/workspace.ts +25 −4
@@ -50,7 +50,7 @@ import { canAdmin, canWrite, DECISIONS_BY, getRepoById, type Repo } from "../../
5050 import type { TreeEntry } from "../../git/read.ts";
5151 import { parseUnifiedDiff, type Diff } from "../../git/diff.ts";
5252 import { renderDiff as renderPatch } from "../ui/diff.ts";
53 import { fileKind, NO_BYTES } from "../../web/render/kinds.ts";
53 import { fileKind, kindByName, NO_BYTES } from "../../web/render/kinds.ts";
5454 import { downloadHref } from "../../web/pages/media.ts";
5555 import { isModelPath, modelFacts } from "../../web/render/model-facts.ts";
5656 import type { RepoFacts, RepoSource } from "../../view/source.ts";
@@ -61,6 +61,7 @@ import { gcodeFacts, parseGcode } from "../../web/render/gcode.ts";
6161 import { gpxFacts, parseGpx } from "../../web/render/gpx.ts";
6262 import { CASE_CAP, parseJunit } from "../../web/render/junit.ts";
6363 import { RESULT_CAP, parseSarif } from "../../web/render/sarif.ts";
64 import { RECORD_CAP, parseLcov, worstFirst, type Ratio } from "../../web/render/lcov.ts";
6465 import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts";
6566 import { fileSize } from "../../web/ui.ts";
6667 import { parseEml } from "../../web/render/eml-parse.ts";
@@ -418,6 +419,21 @@ function sarifTable(source: string): Table | string {
418419 return { header: ["Level", "Rule", "Location", "Message", "Tool"], rows, more: all.length - rows.length, delimiter: ",", columns: 5 };
419420 }
420421
422 /** `lcov.info` / `.lcov`: a coverage tracefile, drawn as a table per source file, worst line coverage first. */
423 function isLcovPath(path: string): boolean {
424 return kindByName(path) === "lcov";
425 }
426
427 /** The coverage of an lcov file as a table; the reason, as the page words it, when it is not lcov. Shares the web's parser. */
428 function lcovTable(source: string): Table | string {
429 const parsed = parseLcov(source);
430 if ("error" in parsed) return `not drawn as coverage: ${parsed.error}`;
431 const pct = (r: Ratio | undefined): string => (r === undefined || r.found === 0 ? "-" : `${r.hit}/${r.found} ${(Math.floor((r.hit / r.found) * 1000) / 10).toFixed(1)}%`);
432 const sorted = worstFirst(parsed.records);
outdated

This repeats the web's worst-first sort (no-lines last, ties by path). Export the sort from lcov.ts and call it here, so the page and the terminal stay in step.

433 const rows = sorted.slice(0, RECORD_CAP).map((r) => [r.file, pct(r.lines), pct(r.functions), pct(r.branches)]);
434 return { header: ["File", "Lines", "Functions", "Branches"], rows, more: sorted.length - rows.length, delimiter: ",", columns: 4 };
435 }
436
421437 /** A JUnit report, by the web's own rule (file name, or root element). */
422438 function isJunitPath(path: string, source: Uint8Array): boolean {
423439 return /\.xml$/i.test(path) && fileKind(path, source.subarray(0, 4096)) === "junit";
@@ -659,6 +675,11 @@ class Tab {
659675 this.table = typeof findings === "string" ? null : findings;
660676 this.fault = typeof findings === "string" ? findings : "";
661677 if (this.table !== null) this.grid = layoutTable(this.table);
678 } else if (isLcovPath(this.path)) {
679 const coverage = lcovTable(new TextDecoder().decode(source));
680 this.table = typeof coverage === "string" ? null : coverage;
681 this.fault = typeof coverage === "string" ? coverage : "";
682 if (this.table !== null) this.grid = layoutTable(this.table);
662683 } else if (isJunitPath(this.path, source)) {
663684 const results = junitTable(new TextDecoder().decode(source));
664685 this.table = typeof results === "string" ? null : results;
@@ -2780,7 +2801,7 @@ export function workspaceScreen(
27802801 t.tabular && t.table !== null
27812802 ? t.raw !== null && isJunitPath(t.path, t.raw)
27822803 ? 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))
2804 : 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), isLcovPath(t.path))
27842805 : `${plural(
27852806 t.reading ? t.doc.length : t.plain.length,
27862807 "line",
@@ -3114,9 +3135,9 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void {
31143135 }
31153136
31163137 /** `80 rows, first 50 shown · 3 more columns`: what the table leaves out. */
3117 function tableStatus(table: Table, unread = 0, subs = false, events = false, facts = false, people = false, features = false, jsonl = false, findings = false): string {
3138 function tableStatus(table: Table, unread = 0, subs = false, events = false, facts = false, people = false, features = false, jsonl = false, findings = false, files = false): string {
31183139 const rows = table.rows.length + table.more;
3119 return `${plural(rows, subs ? "cue" : events ? "event" : people ? "contact" : facts ? "fact" : features ? "feature" : findings ? "finding" : "row")}${
3140 return `${plural(rows, subs ? "cue" : events ? "event" : people ? "contact" : facts ? "fact" : features ? "feature" : findings ? "finding" : files ? "file" : "row")}${
31203141 unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : ""
31213142 }${
31223143 table.more > 0
src/web/pages/browse.ts +2 −2
@@ -87,7 +87,7 @@ import {
8787 } from "./explorer.ts";
8888 import { accountOnly, hostLabel, originIcon } from "./origins.ts";
8989 import { refSetFrom } from "./refs.ts";
90 import { kindByName } from "../render/kinds.ts";
90 import { kindByName, needsPaths } from "../render/kinds.ts";
9191 import { stageRow, stageStateFor } from "../stage-row.ts";
9292 import {
9393 blobView,
@@ -510,7 +510,7 @@ export async function browseBlob(ctx: Ctx): Promise<Response> {
510510 const blob = await scope.source.blob(ref, path);
511511 if (!blob.ok) return notFoundPage(ctx);
512512 const refs = await refSetFrom(scope.source);
513 const exists = kindByName(path) === "sarif" ? await pathsAt(scope, ref) : undefined;
513 const exists = needsPaths(kindByName(path)) ? await pathsAt(scope, ref) : undefined;
514514 const pane = renderBlob(scope, {
515515 refs,
516516 ref,
src/web/pages/repo.ts +2 −2
@@ -91,7 +91,7 @@ import { csvJs, isCsvPath } from "../render/csv.ts";
9191 import { fontJs } from "../render/font.ts";
9292 import { INLINE_MAX, keepGcode, keptGcode, layerCount } from "../render/gcode.ts";
9393 import { hasTable } from "../render/sqlite.ts";
94 import { fileKind, HEAD_BYTES, kindByName, RENDER_CAP } from "../render/kinds.ts";
94 import { fileKind, HEAD_BYTES, kindByName, needsPaths, RENDER_CAP } from "../render/kinds.ts";
9595 import { RENDERERS, rendererFor } from "../render/renderers.ts";
9696 import { downloadHref, mediaHref } from "./media.ts";
9797 import { browseBase, localScope, type RepoScope } from "../scope.ts";
@@ -1773,7 +1773,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
17731773 rows = await rowsOffThread(shown.body, path, all ? MAX_RENDER_LINES : FIRST_LINES);
17741774 if (rows !== undefined) notePhase(ctx.request, "highlight", performance.now() - began);
17751775 }
1776 const exists = kindByName(path) === "sarif" ? await pathsAt(scope, ref) : undefined;
1776 const exists = needsPaths(kindByName(path)) ? await pathsAt(scope, ref) : undefined;
outdated

This check is duplicated in browse.ts. Name it once in kinds.ts.

17771777 const pane = renderBlob(scope, {
17781778 ...(rows !== undefined ? { rows } : {}),
17791779 ...(exists !== undefined ? { exists } : {}),
src/web/render/kinds.ts +11
@@ -38,6 +38,7 @@ export type FileKind =
3838 | "mail"
3939 | "gcode"
4040 | "sarif"
41 | "lcov"
4142 | "junit"
4243 | "text"
4344 | "binary";
@@ -75,6 +76,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
7576 mail: 5 * MB,
7677 gcode: 50 * MB,
7778 sarif: 5 * MB,
79 lcov: 5 * MB,
7880 junit: 5 * MB,
7981 text: 1 * MB,
8082 binary: 0,
@@ -198,8 +200,12 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = {
198200 gcode: "gcode",
199201 gco: "gcode",
200202 sarif: "sarif",
203 lcov: "lcov",
201204 };
202205
206 /** `lcov.info`: the name `lcov` and `bun test` write, whatever its extension. */
207 const isLcovName = (path: string): boolean => /(^|\/)lcov\.info$/i.test(path);
208
203209 /**
204210 * Archives: the extension names the kind and the bytes confirm it. Their
205211 * media type is never an archive type; the media route sends octet-stream.
@@ -265,10 +271,14 @@ export function fileKind(path: string, head: Uint8Array): FileKind {
265271 if (media !== undefined) return media.kind;
266272 if (isArchive(path, head)) return "archive";
267273 if (looksBinary(head)) return "binary";
274 if (isLcovName(path)) return "lcov";
268275 if (isJunit(path, head)) return "junit";
269276 return TEXTUAL[extension(path)] ?? "text";
270277 }
271278
279 /** Kinds whose renderer links paths to files in the repository, so the page reads the ref's file list. */
280 export const needsPaths = (kind: FileKind): boolean => kind === "sarif" || kind === "lcov";
281
272282 /**
273283 * What a file's name says it is, for a file too large to have been read:
274284 * the bytes are not here to confirm it. Only a renderer that fetches the
@@ -276,6 +286,7 @@ export function fileKind(path: string, head: Uint8Array): FileKind {
276286 */
277287 export function kindByName(path: string): FileKind {
278288 const ext = extension(path);
289 if (isLcovName(path)) return "lcov";
279290 return MEDIA[ext]?.kind ?? TEXTUAL[ext] ?? "text";
280291 }
281292
src/web/render/lcov.ts added +156
@@ -0,0 +1,156 @@
1 /**
2 * An lcov tracefile (`lcov.info`, `*.lcov`; the format `geninfo(1)` documents
3 * and `bun test --coverage-reporter=lcov` writes) as a table: total line and
4 * function coverage at the top, then one row per `SF:` record with lines
5 * (`LH`/`LF`), functions (`FNH`/`FNF`) and branches (`BRH`/`BRF`) hit over
6 * found, worst line coverage first, each with a `<meter>` bar (no script).
7 * When a record has no `LF`/`LH`, they are counted from its `DA:line,hits`
8 * lines. A source path links to that file in the same repository when it is
9 * a plain relative path that exists at the ref (`RenderInput.exists`);
10 * otherwise it is plain text. At most `RECORD_CAP` records are drawn and the
11 * rest counted. One linear pass; a malformed file falls back to the source
12 * with the reason. 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 import { relativePath } from "./sarif.ts";
19
20 export const RECORD_CAP = 5000;
21
22 export interface Ratio {
23 readonly hit: number;
24 readonly found: number;
25 }
26 export interface Record_ {
27 readonly file: string;
28 readonly lines: Ratio;
29 readonly functions: Ratio | undefined;
30 readonly branches: Ratio | undefined;
31 }
32 export type Lcov = { readonly records: Record_[] } | { readonly error: string };
33
34 const COUNTERS = new Set(["LF", "LH", "FNF", "FNH", "BRF", "BRH"]);
35
36 interface Open {
37 file: string;
38 n: Record<string, number>;
39 daFound: number;
40 daHit: number;
41 line: number;
42 }
43
44 /** Parse an lcov file's text in one pass. Pure; never throws. */
45 export function parseLcov(source: string): Lcov {
46 const records: Record_[] = [];
47 let cur: Open | undefined;
48 let lineNo = 0;
49 let pos = 0;
50 const text = source.replace(/^/, "");
51 const close = (o: Open): string | undefined => {
52 const n = o.n;
53 const ratio = (f: string, h: string, da?: Ratio): Ratio | undefined => {
54 if (n[f] === undefined && n[h] === undefined) return da;
55 if (n[f] === undefined || n[h] === undefined) return undefined;
56 return { hit: n[h]!, found: n[f]! };
57 };
58 const lines = ratio("LF", "LH", { hit: o.daHit, found: o.daFound });
59 const functions = ratio("FNF", "FNH");
60 const branches = ratio("BRF", "BRH");
61 if ((n.LF === undefined) !== (n.LH === undefined)) return `line ${o.line}: ${o.file} has only one of LF and LH`;
62 if ((n.FNF === undefined) !== (n.FNH === undefined)) return `line ${o.line}: ${o.file} has only one of FNF and FNH`;
63 if ((n.BRF === undefined) !== (n.BRH === undefined)) return `line ${o.line}: ${o.file} has only one of BRF and BRH`;
64 for (const r of [lines, functions, branches])
65 if (r !== undefined && r.hit > r.found) return `line ${o.line}: ${o.file} hits more than it finds`;
66 records.push({ file: o.file, lines: lines!, functions, branches });
67 return undefined;
68 };
69 while (pos <= text.length) {
70 let end = text.indexOf("\n", pos);
71 if (end < 0) end = text.length;
72 const line = text.slice(pos, end).trim();
73 pos = end + 1;
74 lineNo++;
75 if (line === "") continue;
76 const colon = line.indexOf(":");
77 const key = colon < 0 ? line : line.slice(0, colon);
78 const val = colon < 0 ? "" : line.slice(colon + 1);
79 if (key === "SF") {
80 if (cur !== undefined) return { error: `line ${lineNo}: a new SF record starts before end_of_record` };
81 if (val === "") return { error: `line ${lineNo}: SF has no path` };
82 cur = { file: val, n: {}, daFound: 0, daHit: 0, line: lineNo };
83 } else if (key === "end_of_record") {
84 if (cur === undefined) return { error: `line ${lineNo}: end_of_record with no SF record` };
85 const bad = close(cur);
86 if (bad !== undefined) return { error: bad };
87 cur = undefined;
88 } else if (key === "TN") {
89 continue;
90 } else if (COUNTERS.has(key)) {
91 if (cur === undefined) return { error: `line ${lineNo}: ${key} outside an SF record` };
92 if (!/^\d{1,12}$/.test(val)) return { error: `line ${lineNo}: ${key} is not a count` };
93 cur.n[key] = Number(val);
94 } else if (key === "DA") {
95 if (cur === undefined) return { error: `line ${lineNo}: DA outside an SF record` };
96 const m = /^\d+,(-?\d+)/.exec(val);
97 if (m === null) return { error: `line ${lineNo}: DA is not "line,hits"` };
98 cur.daFound++;
99 if (Number(m[1]) > 0) cur.daHit++;
100 }
101 // FN, FNDA, BRDA, VER and the like: not needed for the table.
102 }
103 if (cur !== undefined) return { error: `the record for ${cur.file} has no end_of_record` };
104 if (records.length === 0) return { error: "it has no SF records" };
105 return { records };
106 }
107
108 const pct = (r: Ratio | undefined): number | undefined => (r === undefined || r.found === 0 ? undefined : (r.hit / r.found) * 100);
109 /** Records ordered worst line coverage first; a file with no lines last; ties by path. Both doors (page and terminal) draw this order. */
110 export function worstFirst(records: readonly Record_[]): Record_[] {
111 const key = (r: Record_): number => pct(r.lines) ?? 101;
112 return [...records].sort((a, b) => key(a) - key(b) || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
113 }
114 const fmt = (p: number | undefined): string => (p === undefined ? "—" : `${(Math.floor(p * 10) / 10).toFixed(1)}%`);
115 const sum = (rs: readonly (Ratio | undefined)[]): Ratio | undefined => {
116 let any = false;
117 let hit = 0;
118 let found = 0;
119 for (const r of rs) if (r !== undefined) (any = true), (hit += r.hit), (found += r.found);
120 return any ? { hit, found } : undefined;
121 };
122 const cell = (r: Ratio | undefined): string =>
123 r === undefined ? "<td>—</td>" : `<td>${r.hit.toLocaleString()}/${r.found.toLocaleString()} · ${fmt(pct(r))}</td>`;
124 const bar = (r: Ratio | undefined, label: string): string =>
125 r === undefined || r.found === 0 ? "" : `<meter min="0" max="${r.found}" value="${r.hit}" aria-label="${escapeHtml(label)}"></meter>`;
126 const encodePath = (path: string): string => path.split("/").map(encodeURIComponent).join("/");
127
128 export function lcov(input: RenderInput): Rendered {
129 const doc = parseLcov(new TextDecoder().decode(input.body));
130 if ("error" in doc) return { content: html``, lines: 0, fallback: `This file is not drawn as coverage: ${doc.error}.` };
131 const all = doc.records;
132 const sorted = worstFirst(all);
133 const shown = sorted.slice(0, RECORD_CAP);
134 const showBranches = all.some((r) => r.branches !== undefined);
135
136 const name = (file: string): string => {
137 const rel = relativePath(file);
138 if (input.repo === undefined || input.exists === undefined || rel === undefined || rel !== file || !input.exists(file)) return escapeHtml(file);
139 return `<a href="${escapeHtml(`${input.repo.base}/blob/${input.repo.ref}/${encodePath(file)}`)}">${escapeHtml(file)}</a>`;
140 };
141 let rows = "";
142 for (const r of shown)
143 rows += `<tr><td>${name(r.file)}</td><td>${bar(r.lines, `${r.file} line coverage`)}</td>${cell(r.lines)}${cell(r.functions)}${showBranches ? cell(r.branches) : ""}</tr>`;
144 const out = all.length - shown.length;
145 const more =
146 out > 0
147 ? `<p class="muted small">Showing ${RECORD_CAP.toLocaleString()} of ${all.length.toLocaleString()} files, ${out.toLocaleString()} left out. The rest is in the raw file.</p>`
148 : "";
149 const lines = sum(all.map((r) => r.lines));
150 const fns = sum(all.map((r) => r.functions));
outdated

Principle 7 and the design docs: lcov-view is an unstyled, undocumented class. Drop it, or document it in docs/design/areas/.

151 const branches = sum(all.map((r) => r.branches));
152 const top = [`${fmt(pct(lines))} of lines`, `${fmt(pct(fns))} of functions`, ...(branches ? [`${fmt(pct(branches))} of branches`] : [])].join(" · ");
153 const head = `<tr><th scope="col">File</th><th scope="col">Lines</th><th scope="col">Lines hit</th><th scope="col">Functions hit</th>${showBranches ? '<th scope="col">Branches hit</th>' : ""}</tr>`;
154 const content = html`<div><p>${top}</p><table class="table" data-dense aria-label="Coverage by file, worst first"><thead>${raw(head)}</thead><tbody>${raw(rows)}</tbody></table>${raw(more)}</div>`;
155 return { content, lines: 0, summary: `${all.length.toLocaleString()} ${all.length === 1 ? "file" : "files"} · ${fmt(pct(lines))} lines · ${fileSize(input.size)}` };
156 }
src/web/render/renderers.ts +2
@@ -28,6 +28,7 @@ import { model3d } from "./model3d.ts";
2828 import { notebook } from "./notebook.ts";
2929 import { patch } from "./patch.ts";
3030 import { pdf } from "./pdf.ts";
31 import { lcov } from "./lcov.ts";
3132 import { sarif } from "./sarif.ts";
3233 import { sqlite } from "./sqlite.ts";
3334 import { subtitles } from "./subtitles.ts";
@@ -94,6 +95,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
9495 calendar: ics,
9596 contacts: vcf,
9697 sarif,
98 lcov,
9799 junit,
98100 mail: eml,
99101 archive,
test/fuzz-renderers.test.ts +6
@@ -19,6 +19,7 @@ import { geojson } from "../src/web/render/geojson.ts";
1919 import { subtitles } from "../src/web/render/subtitles.ts";
2020 import { patch } from "../src/web/render/patch.ts";
2121 import { notebook } from "../src/web/render/notebook.ts";
22 import { lcov } from "../src/web/render/lcov.ts";
2223 import { sarif } from "../src/web/render/sarif.ts";
2324 import { gcode } from "../src/web/render/gcode.ts";
2425 import { config } from "../src/web/render/config.ts";
@@ -115,6 +116,11 @@ const SAMPLES: Record<string, { path: string; render: Renderer; files: readonly
115116 render: sarif,
116117 files: ['{"version":"2.1.0","runs":[{"tool":{"driver":{"name":"T"}},"results":[{"ruleId":"R","level":"error","message":{"text":"m"},"locations":[{"physicalLocation":{"artifactLocation":{"uri":"src/a.ts"},"region":{"startLine":3}}}]}]}]}'],
117118 },
119 lcov: {
120 path: "lcov.info",
121 render: lcov,
122 files: ["TN:\nSF:src/a.ts\nFNF:2\nFNH:1\nDA:1,1\nDA:2,0\nLF:2\nLH:1\nBRF:4\nBRH:2\nend_of_record\n"],
123 },
118124 srt: {
119125 path: "talk.srt",
120126 render: subtitles,
test/render-kinds.test.ts +1
@@ -82,6 +82,7 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = {
8282 gcode: [enc("G28\nG1 X1 E1\n"), "gcode", "application/octet-stream"],
8383 gco: [enc("G28\n"), "gcode", "application/octet-stream"],
8484 sarif: [enc('{"runs":[]}'), "sarif", "application/octet-stream"],
85 lcov: [enc("SF:a.ts\nend_of_record\n"), "lcov", "application/octet-stream"],
8586 };
8687
8788 describe("the registry of file kinds", () => {
test/render-lcov.test.ts added +72
@@ -0,0 +1,72 @@
1 /** lcov files as a coverage table: worst first, links, absolute paths, malformed records, the cap. */
2 import { describe, expect, test } from "bun:test";
3 import { RECORD_CAP, lcov, parseLcov } from "../src/web/render/lcov.ts";
4 import { fileKind, kindByName } 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, repo?: { base: string; ref: string }, files: readonly string[] = ["src/a.ts", "src/b.ts"]) =>
9 lcov({ path: "lcov.info", size: text.length, body: enc(text), media: "/m", download: "/d", ...(repo ? { repo, exists: (p: string) => files.includes(p) } : {}) });
10 const rec = (file: string, lh: number, lf: number, extra = "") => `SF:${file}\nFNF:2\nFNH:1\nLF:${lf}\nLH:${lh}\n${extra}end_of_record\n`;
11 const REPO = { base: "/o/n", ref: "main" };
12
13 describe("lcov", () => {
14 test("the registry names lcov.info and *.lcov and a renderer draws them", () => {
15 expect(fileKind("coverage/lcov.info", enc("TN:\n"))).toBe("lcov");
16 expect(fileKind("x/a.LCOV", enc("TN:\n"))).toBe("lcov");
17 expect(kindByName("lcov.info")).toBe("lcov");
18 expect(fileKind("other.info", enc("TN:\n"))).toBe("text");
19 expect(RENDERERS.lcov).toBe(lcov);
20 });
21
22 test("a two-file report: totals on top, worst first, hit/found, bars, links", () => {
23 const r = draw(rec("src/a.ts", 9, 10) + rec("src/b.ts", 1, 4, "BRF:4\nBRH:2\n"), REPO);
24 expect(r.fallback).toBeUndefined();
25 const h = String(r.content);
26 // 10 of 14 lines; 2 of 4 functions; 2 of 4 branches (hand count).
27 expect(h).toContain("71.4% of lines · 50.0% of functions · 50.0% of branches");
28 expect(h.indexOf("src/b.ts")).toBeLessThan(h.indexOf("src/a.ts"));
29 expect(h).toContain('<a href="/o/n/blob/main/src/a.ts">src/a.ts</a>');
30 expect(h).toContain("1/4 · 25.0%");
31 expect(h).toContain("2/4 · 50.0%");
32 expect(h).toContain('<meter min="0" max="4" value="1"');
33 expect(r.summary).toContain("2 files");
34 });
35
36 test("lines are counted from DA when LF/LH are absent", () => {
37 const p = parseLcov("SF:a.ts\nDA:1,3\nDA:2,0\nDA:3,1\nend_of_record\n");
38 expect("records" in p && p.records[0]!.lines).toEqual({ hit: 2, found: 3 });
39 });
40
41 test("an absolute path, a missing file, and an unrelated ref are plain text", () => {
42 const h = String(draw(rec("/home/ci/src/a.ts", 1, 2) + rec("src/gone.ts", 1, 2) + rec("../x.ts", 1, 2), REPO).content);
43 expect(h).not.toContain("<a ");
44 expect(h).toContain("/home/ci/src/a.ts");
45 const none = String(draw(rec("src/a.ts", 1, 2)).content);
46 expect(none).not.toContain("<a ");
47 });
48
49 test("a malformed record falls back with the reason", () => {
50 for (const bad of ["LF:3\n", "SF:a.ts\nLF:x\nend_of_record\n", "SF:a.ts\nLF:1\nend_of_record\n", "SF:a.ts\nLF:1\nLH:2\nend_of_record\n", "SF:a.ts\nDA:1,1\n", "SF:a\nSF:b\n", "end_of_record\n", "", "hello\n"]) {
51 const r = draw(bad);
52 expect(r.fallback, bad).toContain("This file is not drawn as coverage:");
53 }
54 expect(draw("SF:a.ts\nLF:x\nend_of_record\n").fallback).toContain("line 2");
55 });
56
57 test("the cap draws 5,000 records and counts the rest; one pass stays fast", () => {
58 let text = "";
59 for (let i = 0; i < RECORD_CAP + 7; i++) text += rec(`f${i}.ts`, i % 5, 5);
60 const t = performance.now();
61 const h = String(draw(text).content);
62 expect(performance.now() - t).toBeLessThan(1000);
63 expect(h.match(/<tr><td>/g)?.length).toBe(RECORD_CAP);
64 expect(h).toContain("Showing 5,000 of 5,007 files, 7 left out");
65 });
66
67 test("a <script> path arrives escaped", () => {
68 const h = String(draw(rec("<script>alert(1)</script>.ts", 1, 2), REPO, ["<script>alert(1)</script>.ts"]).content);
69 expect(h).not.toContain("<script>");
70 expect(h).toContain("&lt;script&gt;");
71 });
72 });
test/tui-workspace.test.ts +28
@@ -567,6 +567,34 @@ describe("the terminal workspace", () => {
567567 }
568568 });
569569
570 test("an lcov.info opens as coverage per file, worst first, and a broken one says why", async () => {
571 const made = await createRepo(forge, alice, { name: "covered", visibility: "public", description: "coverage" });
572 if (!made.ok) throw new Error("repo failed");
573 const info = "SF:src/good.ts\nLF:10\nLH:10\nend_of_record\nSF:src/bad.ts\nLF:4\nLH:1\nFNF:2\nFNH:1\nend_of_record\n";
574 await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: "lcov.info", content: info }, { path: "sub/lcov.info", content: "SF:x\n" }] });

Title says a broken file says why, but nothing asserts it, and sub/lcov.info is committed and never opened. Open it and check the reason, or remove both.

575 const h = boot();
576 try {
577 await h.settle();
578 await open(h, "covered");
579 await openFile(h, "lcov.info");
580 expect(h.text()).toContain("1/4 25.0%");
581 expect(h.text()).toContain("2 files");
582 expect(h.text().indexOf("src/bad.ts")).toBeLessThan(h.text().indexOf("src/good.ts"));
583 } finally {
584 h.stop();
585 }
586 const broken = boot();
587 try {
588 await broken.settle();
589 await open(broken, "covered");
590 await openFile(broken, "sub/lcov.info");
591 expect(broken.text()).toContain("SF:x");
592 expect(broken.text()).toContain("not drawn as coverage: the record for x has no end_of_record");
593 } finally {
594 broken.stop();
595 }
596 });
597
570598 test("a JUnit report opens as its totals and failing cases, sharing the web parser", async () => {
571599 const made = await createRepo(forge, alice, { name: "junitrepo", visibility: "public", description: "tests" });
572600 if (!made.ok) throw new Error("repo failed");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs