Coverage measures the page's Node tests and runs the Rust on Postgres (#407)

page-unit.ts and native-bridge.ts run the page's script outside a
browser, so the page's coverage counted none of what they test, and
native.ts read as mostly untested. In a coverage run they now run the
script from a file of its own, named as the browser names it, under
NODE_V8_COVERAGE (scripts/coverage-node.sh, before the browser suite in
npm run coverage:page and coverage.sh api), and the report's teardown
takes their record in, mapped to web/src by the build's source map: the
page's lines went from 73.3% to 80.0% of 3,043, native.ts to 93 of 120.

The cargo tests in coverage.sh's container run on Postgres, as
production does, when IPX_TEST_DATABASE_URL names the _test database,
from the shell or else /src/.envrc (that variable only); on SQLite
without it. The integration tests' own files are left out of the
totals; a crate's inline mod tests cannot be on stable Rust.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 04:57:32 +00:00
parent 7994f93f31
commit f6ba08d506
7 changed files with 56 additions and 11 deletions

View File

@@ -7,6 +7,9 @@
// native.ts replaces that surface on the element and a proxy that answers everything would prove
// nothing about whether it worked.
import vm from 'node:vm';
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { webcrypto } from 'node:crypto';
// The VM's side is the page's script, whose types are its own (web/src): a test reaches into it by
@@ -142,4 +145,15 @@ class MediaElement {
get readyState() { return this._ready; }
}
export { makeContext, vm, MediaElement };
/// The name to run the page's script under. In a coverage run (IPX_COVERAGE=1, #407), a file of its
/// own named as the browser names it, so NODE_V8_COVERAGE's record of it, which the page's report
/// takes in (tests/ui/coverage-teardown.ts), maps back to web/src through its inline source map.
function scriptName(js: string, name: string): string {
if (!process.env.IPX_COVERAGE) return name;
const file = path.resolve('target/coverage/node-page/app.js');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, js);
return pathToFileURL(file).href;
}
export { makeContext, vm, MediaElement, scriptName };

View File

@@ -7,7 +7,7 @@
// road, with nothing to look at.
//
// node tests/native-bridge.ts
import { makeContext, vm } from './dom-stub.ts';
import { makeContext, vm, scriptName } from './dom-stub.ts';
import { buildPage } from '../web/build.mts';
const { html, js: script } = buildPage('index.html');
@@ -19,7 +19,7 @@ const ok = (cond: unknown, what: string) => { if (!cond) { console.error('FAIL:
{
const { ctx } = makeContext(html, script, { media: true });
vm.createContext(ctx);
vm.runInContext(script, ctx, { filename: 'browser', timeout: 5000 });
vm.runInContext(script, ctx, { filename: scriptName(script, 'browser'), timeout: 5000 });
ok(ctx.window.ipxNative === undefined, 'the bridge installed in a plain browser');
const audio = ctx.document.querySelector('#audio');
audio.src = '/media/1';
@@ -35,7 +35,7 @@ if (!media) throw new Error('makeContext gave no media element for { media: true
const audio = media;
ctx.window.webkit = { messageHandlers: { ipx: { postMessage: (m: Posted) => posted.push(m) } } };
vm.createContext(ctx);
vm.runInContext(script, ctx, { filename: 'shell', timeout: 5000 });
vm.runInContext(script, ctx, { filename: scriptName(script, 'shell'), timeout: 5000 });
const last = (t: string) => [...posted].reverse().find(m => m.t === t);
const since = () => posted.splice(0, posted.length);

View File

@@ -9,13 +9,13 @@
// a second <script> on a page would, so `run` evaluates an expression there.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeContext, vm } from './dom-stub.ts';
import { makeContext, vm, scriptName } from './dom-stub.ts';
import { buildPage } from '../web/build.mts';
const { html, js } = buildPage('index.html');
const { ctx } = makeContext(html, js);
vm.createContext(ctx);
vm.runInContext(js, ctx, { filename: 'index.html<script>', timeout: 5000 });
vm.runInContext(js, ctx, { filename: scriptName(js, 'index.html<script>'), timeout: 5000 });
// What the page's script answers, from inside the VM: its types are the script's, not this file's.
const run = (expr: string): any => vm.runInContext(expr, ctx);
// Time as the page sees it, for the helpers that count from now.

View File

@@ -5,9 +5,15 @@ import path from 'node:path';
import { CoverageReport } from 'monocart-coverage-reports';
import { coverageOptions } from './coverage.ts';
/// Where a coverage run's page unit and native-bridge tests write V8's record of what they ran.
export const NODE_COVERAGE = 'target/coverage/node-v8';
export default async function teardown() {
if (!process.env.IPX_COVERAGE) return;
const results = await new CoverageReport(coverageOptions).generate();
const report = new CoverageReport(coverageOptions);
// What the page's Node tests ran of it, when the run measured them first (NODE_V8_COVERAGE, #407).
if (fs.existsSync(NODE_COVERAGE)) await report.addFromDir(NODE_COVERAGE);
const results = await report.generate();
if (!results) return;
const lines = results.files.map((f) => ({ file: f.sourcePath, covered: f.summary.lines.covered, total: f.summary.lines.total }));
fs.writeFileSync(path.join(coverageOptions.outputDir!, 'summary.json'), JSON.stringify({ lines }, null, 2));