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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11073-an-lcov-coverage-file-in-a-repository-op → main 6 commits fork 5 threads · 5 unresolved
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 "../../ | ||
| 50 | 50 | import type { TreeEntry } from "../../git/read.ts"; |
| 51 | 51 | import { parseUnifiedDiff, type Diff } from "../../git/diff.ts"; |
| 52 | 52 | 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"; | |
| 54 | 54 | import { downloadHref } from "../../web/pages/media.ts"; |
| 55 | 55 | import { isModelPath, modelFacts } from "../../web/render/model-facts.ts"; |
| 56 | 56 | import type { RepoFacts, RepoSource } from "../../view/source.ts"; |
| @@ -61,6 +61,7 @@ import { gcodeFacts, parseGcode } from "../../web/render/gcode.ts"; | ||
| 61 | 61 | import { gpxFacts, parseGpx } from "../../web/render/gpx.ts"; |
| 62 | 62 | import { CASE_CAP, parseJunit } from "../../web/render/junit.ts"; |
| 63 | 63 | import { RESULT_CAP, parseSarif } from "../../web/render/sarif.ts"; |
| 64 | import { RECORD_CAP, parseLcov, worstFirst, type Ratio } from "../../web/render/lcov.ts"; | |
| 64 | 65 | import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts"; |
| 65 | 66 | import { fileSize } from "../../web/ui.ts"; |
| 66 | 67 | import { parseEml } from "../../web/render/eml-parse.ts"; |
| @@ -418,6 +419,21 @@ function sarifTable(source: string): Table | string { | ||
| 418 | 419 | return { header: ["Level", "Rule", "Location", "Message", "Tool"], rows, more: all.length - rows.length, delimiter: ",", columns: 5 }; |
| 419 | 420 | } |
| 420 | 421 | |
| 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); | |
| 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 | ||
| 421 | 437 | /** A JUnit report, by the web's own rule (file name, or root element). */ |
| 422 | 438 | function isJunitPath(path: string, source: Uint8Array): boolean { |
| 423 | 439 | return /\.xml$/i.test(path) && fileKind(path, source.subarray(0, 4096)) === "junit"; |
| @@ -659,6 +675,11 @@ class Tab { | ||
| 659 | 675 | this.table = typeof findings === "string" ? null : findings; |
| 660 | 676 | this.fault = typeof findings === "string" ? findings : ""; |
| 661 | 677 | 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); | |
| 662 | 683 | } else if (isJunitPath(this.path, source)) { |
| 663 | 684 | const results = junitTable(new TextDecoder().decode(source)); |
| 664 | 685 | this.table = typeof results === "string" ? null : results; |
| @@ -2780,7 +2801,7 @@ export function workspaceScreen( | ||
| 2780 | 2801 | t.tabular && t.table !== null |
| 2781 | 2802 | ? t.raw !== null && isJunitPath(t.path, t.raw) |
| 2782 | 2803 | ? 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)) | |
| 2784 | 2805 | : `${plural( |
| 2785 | 2806 | t.reading ? t.doc.length : t.plain.length, |
| 2786 | 2807 | "line", |
| @@ -3114,9 +3135,9 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void { | ||
| 3114 | 3135 | } |
| 3115 | 3136 | |
| 3116 | 3137 | /** `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 { | |
| 3118 | 3139 | 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")}${ | |
| 3120 | 3141 | unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : "" |
| 3121 | 3142 | }${ |
| 3122 | 3143 | table.more > 0 |
src/web/pages/browse.ts +2 −2
| @@ -87,7 +87,7 @@ import { | ||
| 87 | 87 | } from "./explorer.ts"; |
| 88 | 88 | import { accountOnly, hostLabel, originIcon } from "./origins.ts"; |
| 89 | 89 | import { refSetFrom } from "./refs.ts"; |
| 90 | import { kindByName } from "../render/kinds.ts"; | |
| 90 | import { kindByName, needsPaths } from "../render/kinds.ts"; | |
| 91 | 91 | import { stageRow, stageStateFor } from "../stage-row.ts"; |
| 92 | 92 | import { |
| 93 | 93 | blobView, |
| @@ -510,7 +510,7 @@ export async function browseBlob(ctx: Ctx): Promise<Response> { | ||
| 510 | 510 | const blob = await scope.source.blob(ref, path); |
| 511 | 511 | if (!blob.ok) return notFoundPage(ctx); |
| 512 | 512 | 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; | |
| 514 | 514 | const pane = renderBlob(scope, { |
| 515 | 515 | refs, |
| 516 | 516 | ref, |
src/web/pages/repo.ts +2 −2
| @@ -91,7 +91,7 @@ import { csvJs, isCsvPath } from "../render/csv.ts"; | ||
| 91 | 91 | import { fontJs } from "../render/font.ts"; |
| 92 | 92 | import { INLINE_MAX, keepGcode, keptGcode, layerCount } from "../render/gcode.ts"; |
| 93 | 93 | 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"; | |
| 95 | 95 | import { RENDERERS, rendererFor } from "../render/renderers.ts"; |
| 96 | 96 | import { downloadHref, mediaHref } from "./media.ts"; |
| 97 | 97 | import { browseBase, localScope, type RepoScope } from "../scope.ts"; |
| @@ -1773,7 +1773,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1773 | 1773 | rows = await rowsOffThread(shown.body, path, all ? MAX_RENDER_LINES : FIRST_LINES); |
| 1774 | 1774 | if (rows !== undefined) notePhase(ctx.request, "highlight", performance.now() - began); |
| 1775 | 1775 | } |
| 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. |
||
| 1777 | 1777 | const pane = renderBlob(scope, { |
| 1778 | 1778 | ...(rows !== undefined ? { rows } : {}), |
| 1779 | 1779 | ...(exists !== undefined ? { exists } : {}), |
src/web/render/kinds.ts +11
| @@ -38,6 +38,7 @@ export type FileKind = | ||
| 38 | 38 | | "mail" |
| 39 | 39 | | "gcode" |
| 40 | 40 | | "sarif" |
| 41 | | "lcov" | |
| 41 | 42 | | "junit" |
| 42 | 43 | | "text" |
| 43 | 44 | | "binary"; |
| @@ -75,6 +76,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 75 | 76 | mail: 5 * MB, |
| 76 | 77 | gcode: 50 * MB, |
| 77 | 78 | sarif: 5 * MB, |
| 79 | lcov: 5 * MB, | |
| 78 | 80 | junit: 5 * MB, |
| 79 | 81 | text: 1 * MB, |
| 80 | 82 | binary: 0, |
| @@ -198,8 +200,12 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = { | ||
| 198 | 200 | gcode: "gcode", |
| 199 | 201 | gco: "gcode", |
| 200 | 202 | sarif: "sarif", |
| 203 | lcov: "lcov", | |
| 201 | 204 | }; |
| 202 | 205 | |
| 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 | ||
| 203 | 209 | /** |
| 204 | 210 | * Archives: the extension names the kind and the bytes confirm it. Their |
| 205 | 211 | * 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 { | ||
| 265 | 271 | if (media !== undefined) return media.kind; |
| 266 | 272 | if (isArchive(path, head)) return "archive"; |
| 267 | 273 | if (looksBinary(head)) return "binary"; |
| 274 | if (isLcovName(path)) return "lcov"; | |
| 268 | 275 | if (isJunit(path, head)) return "junit"; |
| 269 | 276 | return TEXTUAL[extension(path)] ?? "text"; |
| 270 | 277 | } |
| 271 | 278 | |
| 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 | ||
| 272 | 282 | /** |
| 273 | 283 | * What a file's name says it is, for a file too large to have been read: |
| 274 | 284 | * 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 { | ||
| 276 | 286 | */ |
| 277 | 287 | export function kindByName(path: string): FileKind { |
| 278 | 288 | const ext = extension(path); |
| 289 | if (isLcovName(path)) return "lcov"; | |
| 279 | 290 | return MEDIA[ext]?.kind ?? TEXTUAL[ext] ?? "text"; |
| 280 | 291 | } |
| 281 | 292 |
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: |
||
| 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"; | ||
| 28 | 28 | import { notebook } from "./notebook.ts"; |
| 29 | 29 | import { patch } from "./patch.ts"; |
| 30 | 30 | import { pdf } from "./pdf.ts"; |
| 31 | import { lcov } from "./lcov.ts"; | |
| 31 | 32 | import { sarif } from "./sarif.ts"; |
| 32 | 33 | import { sqlite } from "./sqlite.ts"; |
| 33 | 34 | import { subtitles } from "./subtitles.ts"; |
| @@ -94,6 +95,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 94 | 95 | calendar: ics, |
| 95 | 96 | contacts: vcf, |
| 96 | 97 | sarif, |
| 98 | lcov, | |
| 97 | 99 | junit, |
| 98 | 100 | mail: eml, |
| 99 | 101 | archive, |
test/fuzz-renderers.test.ts +6
| @@ -19,6 +19,7 @@ import { geojson } from "../src/web/render/geojson.ts"; | ||
| 19 | 19 | import { subtitles } from "../src/web/render/subtitles.ts"; |
| 20 | 20 | import { patch } from "../src/web/render/patch.ts"; |
| 21 | 21 | import { notebook } from "../src/web/render/notebook.ts"; |
| 22 | import { lcov } from "../src/web/render/lcov.ts"; | |
| 22 | 23 | import { sarif } from "../src/web/render/sarif.ts"; |
| 23 | 24 | import { gcode } from "../src/web/render/gcode.ts"; |
| 24 | 25 | import { config } from "../src/web/render/config.ts"; |
| @@ -115,6 +116,11 @@ const SAMPLES: Record<string, { path: string; render: Renderer; files: readonly | ||
| 115 | 116 | render: sarif, |
| 116 | 117 | 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}}}]}]}]}'], |
| 117 | 118 | }, |
| 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 | }, | |
| 118 | 124 | srt: { |
| 119 | 125 | path: "talk.srt", |
| 120 | 126 | render: subtitles, |
test/render-kinds.test.ts +1
| @@ -82,6 +82,7 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = { | ||
| 82 | 82 | gcode: [enc("G28\nG1 X1 E1\n"), "gcode", "application/octet-stream"], |
| 83 | 83 | gco: [enc("G28\n"), "gcode", "application/octet-stream"], |
| 84 | 84 | sarif: [enc('{"runs":[]}'), "sarif", "application/octet-stream"], |
| 85 | lcov: [enc("SF:a.ts\nend_of_record\n"), "lcov", "application/octet-stream"], | |
| 85 | 86 | }; |
| 86 | 87 | |
| 87 | 88 | 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("<script>"); | |
| 71 | }); | |
| 72 | }); |
test/tui-workspace.test.ts +28
| @@ -567,6 +567,34 @@ describe("the terminal workspace", () => { | ||
| 567 | 567 | } |
| 568 | 568 | }); |
| 569 | 569 | |
| 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 | ||
| 570 | 598 | test("a JUnit report opens as its totals and failing cases, sharing the web parser", async () => { |
| 571 | 599 | const made = await createRepo(forge, alice, { name: "junitrepo", visibility: "public", description: "tests" }); |
| 572 | 600 | if (!made.ok) throw new Error("repo failed"); |
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.