The API's coverage is measured, and every suite's is in Grafana (#394)

The cargo tests put web.rs at 17% and daemon.rs at 10%, because the
browser suite, which drives the API, ran a daemon nobody measured.
`sh scripts/coverage.sh api` builds an instrumented ipx and
ipx-directory in the capped coverage container and runs the browser
suite here against them (IPX_API_COVERAGE): web.rs is at 80% and
daemon.rs at 77%. What it does not reach: access.rs (Cloudflare Access,
0%; its unit tests 73%) and the directory's public pages (site.rs, 4%).

The coverage image is Ubuntu 24.04 now, as code-server is: built on
Debian trixie, the binaries needed glibc 2.41 and code-server has 2.39.
In that mode the suite starts those binaries rather than building its
own, its tests' `ipx` commands too (global-setup's IPX_BIN), passes
LLVM_PROFILE_FILE to both servers, and stops them with SIGTERM, since
an instrumented binary writes its profile as it exits and Playwright
otherwise kills them outright.

Reports go to target/coverage/cargo, api and page. Each run ends with
scripts/coverage-metrics.ts, which writes every suite's lines covered
and in all, by crate, to node-exporter's textfile directory; the
monitoring project's node-exporter now reads it (one flag, its compose
file backed up beside it), so Prometheus keeps them as
ipx_coverage_lines, and the iPX dashboard's Coverage row draws them:
each suite now, over time, and by part. The cargo tests' totals hold
their own code, which runs whole, so they read higher than the code
alone; the panels say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 03:33:49 +00:00
parent 352e2b77a5
commit 194a9250e7
10 changed files with 226 additions and 47 deletions

View File

@@ -0,0 +1,72 @@
// The last coverage run of each suite, for Grafana (#394): lines the tests reach and lines there
// are, by suite and part, as a file node-exporter's textfile collector serves to Prometheus.
//
// node scripts/coverage-metrics.ts (scripts/coverage.sh runs it after each report)
//
// Suites: `cargo` (the cargo tests, target/coverage/cargo), `browser` (the browser suite against an
// instrumented ipx and ipx-directory, target/coverage/api) and `page` (web/src under the browser
// suite, target/coverage/page). Parts: the crate, or `page`. Each run writes every suite that has
// a report, so one suite's run leaves the others' numbers as they were.
import fs from 'node:fs';
import path from 'node:path';
const COVERAGE = path.join(import.meta.dirname, '..', 'target', 'coverage');
// node-exporter reads it as /host/... (it mounts / at /host), on /mnt/fast beside its appdata.
const OUT = process.env.IPX_COVERAGE_TEXTFILE ?? '/mnt/fast/appdata/node-exporter/textfile';
type Count = { covered: number, total: number };
type Suite = { parts: Map<string, Count>, at: number };
/// The crate a path in cargo-llvm-cov's summary belongs to: directory/src/... is ipx-directory's.
const crateOf = (file: string) => file.startsWith('directory/') ? 'ipx-directory' : file.startsWith('feed/') ? 'ipx-feed' : 'ipx';
function add(parts: Map<string, Count>, part: string, covered: number, total: number) {
const c = parts.get(part) ?? { covered: 0, total: 0 };
c.covered += covered; c.total += total;
parts.set(part, c);
}
/// cargo-llvm-cov's summary: a line a file, Lines and Missed Lines its 8th and 9th columns.
function rust(dir: string): Suite | null {
const file = path.join(COVERAGE, dir, 'summary.txt');
if (!fs.existsSync(file)) return null;
const parts = new Map<string, Count>();
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const cols = line.trim().split(/\s+/);
if (cols.length < 10 || !cols[0]!.endsWith('.rs')) continue;
const lines = Number(cols[7]), missed = Number(cols[8]);
if (Number.isFinite(lines) && Number.isFinite(missed)) add(parts, crateOf(cols[0]!), lines - missed, lines);
}
return { parts, at: fs.statSync(file).mtimeMs / 1000 };
}
function page(): Suite | null {
const file = path.join(COVERAGE, 'page', 'summary.json');
if (!fs.existsSync(file)) return null;
const { lines } = JSON.parse(fs.readFileSync(file, 'utf8')) as { lines: ({ file: string } & Count)[] };
const parts = new Map<string, Count>();
for (const l of lines) add(parts, 'page', l.covered, l.total);
return { parts, at: fs.statSync(file).mtimeMs / 1000 };
}
const suites: [string, Suite | null][] = [['cargo', rust('cargo')], ['browser', rust('api')], ['page', page()]];
const out = [
'# HELP ipx_coverage_lines Lines of iPX the tests reached and lines there are, as each suite\'s last coverage run found them (#394).',
'# TYPE ipx_coverage_lines gauge',
];
for (const [suite, s] of suites) if (s) for (const [part, c] of s.parts) {
out.push(`ipx_coverage_lines{suite="${suite}",part="${part}",state="covered"} ${c.covered}`);
out.push(`ipx_coverage_lines{suite="${suite}",part="${part}",state="total"} ${c.total}`);
}
out.push('# HELP ipx_coverage_run_timestamp_seconds When each suite\'s last coverage report was made.');
out.push('# TYPE ipx_coverage_run_timestamp_seconds gauge');
for (const [suite, s] of suites) if (s) out.push(`ipx_coverage_run_timestamp_seconds{suite="${suite}"} ${Math.round(s.at)}`);
fs.mkdirSync(OUT, { recursive: true });
// Written aside and renamed in, so a scrape never reads half a file.
const file = path.join(OUT, 'ipx_coverage.prom');
fs.writeFileSync(file + '.tmp', out.join('\n') + '\n');
fs.renameSync(file + '.tmp', file);
console.log(`coverage for Grafana: ${file}`);
for (const [suite, s] of suites) if (s) for (const [part, c] of s.parts)
console.log(` ${suite} ${part}: ${(100 * c.covered / c.total).toFixed(1)}% of ${c.total} lines`);