Page coverage keeps what a document ran before it unloads, and every page (#407)

The fixture took Playwright's page.coverage once, at the end: a reload
or a sign-in's redirect had by then thrown the first document's script
away with its counts, so reloadKeepingPlace read as never run though the
new-version test runs it, and the second people's pages (piper's, opal's)
were not recorded at all.

Each page a test opens, its own and those of the contexts it makes, now
records through a CDP session of the fixture's own (precise coverage,
sources fetched as each script is parsed), and every document stops at a
breakpoint in a beforeunload listener the fixture adds, where it is taken
before the document goes. Taking it on the navigation's event came after
the document had gone, and holding the document's request (Fetch, as
page.route before) held the take up behind it, the renderer answering
nothing while the navigation waited. A page or context is taken again as
it closes. Page lines 80.0% to 84.5%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
This commit is contained in:
2026-11-07 14:37:49 +00:00
parent f5a3847a21
commit 07923d665b
2 changed files with 103 additions and 9 deletions

View File

@@ -300,8 +300,11 @@ suite stops its servers with SIGTERM in that mode, since an instrumented binary
as it exits. The same run measures the page; `npm run coverage:page` does that alone, on a build of
the page with a source map to each `web/src` file (`IPX_COVERAGE=1`, `web/concat-map.mts`), into
`target/coverage/page`. The specs take `test` and `expect` from `tests/ui/coverage.ts`, not from
`@playwright/test`, so every test is counted. The service worker, the unit tests and the
native-bridge check are not.
`@playwright/test`, so every test is counted, every page it opens (a second person's context
too), and each document before it unloads: the fixture stops it in `beforeunload` at a breakpoint
of its own to take what it ran, since a reload or sign-in otherwise throws that away (#407). The
unit tests and the native-bridge check are measured in Node first (`scripts/coverage-node.sh`);
the service worker is not.
Each run ends with `node scripts/coverage-metrics.ts`, which writes every suite's last numbers to
`/mnt/fast/appdata/node-exporter/textfile/ipx_coverage.prom`; node-exporter's textfile collector

View File

@@ -1,11 +1,11 @@
// What the browser tests run of the page's script (#390). With IPX_COVERAGE=1, web/build.mts
// builds the script unminified with a source map to web/src, every test's page records V8's
// builds the script unminified with a source map to web/src, every page a test opens records V8's
// coverage of it, and the teardown (coverage-teardown.ts) writes the report, file by file, to
// target/coverage/page. Without it, `test` is Playwright's own.
//
// The specs take `test` and `expect` from here, not from @playwright/test, so no test is left out.
import { test as base, expect } from '@playwright/test';
import { test as base, expect, type Browser, type Page } from '@playwright/test';
import { CoverageReport, type CoverageReportOptions } from 'monocart-coverage-reports';
export const coverageOptions: CoverageReportOptions = {
@@ -18,14 +18,105 @@ export const coverageOptions: CoverageReportOptions = {
sourceFilter: (p) => p.startsWith('web/src/'),
};
const wanted = (url: string) => /\/(app|admin|login)\.js(\?|$)/.test(url);
type Entry = { url: string; scriptId: string; source: string; functions: unknown[] };
/// Records `page`'s coverage through a CDP session of its own, and answers a function that adds
/// what it ran since the last call to `into`. Playwright's page.coverage takes it once, at the
/// end, by which time a reload or a sign-in has thrown the first document's script away with
/// what it ran (#407): so it is taken as each document unloads, while its script is still there.
async function record(page: Page, into: Entry[]): Promise<() => Promise<void>> {
const cdp = await page.context().newCDPSession(page);
const scripts = new Map<string, { url: string; source: string }>();
cdp.on('Debugger.scriptParsed', (e) => {
if (!wanted(e.url)) return;
// Now, while the script is alive to be asked.
cdp.send('Debugger.getScriptSource', { scriptId: e.scriptId }).then(
({ scriptSource }) => scripts.set(e.scriptId, { url: e.url, source: scriptSource }),
() => {},
);
});
await cdp.send('Profiler.enable');
// Each take answers the counts since the one before; the report adds them up.
await cdp.send('Profiler.startPreciseCoverage', { callCount: true, detailed: true });
await cdp.send('Debugger.enable');
let taking = Promise.resolve();
const take = () =>
(taking = taking.then(async () => {
try {
const { result } = await cdp.send('Profiler.takePreciseCoverage');
for (const s of result) {
const known = scripts.get(s.scriptId);
if (known) into.push({ ...known, scriptId: s.scriptId, functions: s.functions });
}
} catch {
// The page has gone, and what it ran since the last take with it.
}
}));
// Each document, as it is about to go (a reload, a sign-in's redirect, a test's goto), stops
// at a breakpoint of the fixture's own and waits there for the take. On the navigation's event
// alone the take came after the old document had gone, and holding the document's request held
// the take up behind it: the renderer answers nothing while a navigation waits.
cdp.on('Debugger.paused', () => {
void take().finally(() => cdp.send('Debugger.resume').catch(() => {}));
});
await cdp.send('Debugger.setSkipAllPauses', { skip: false });
await cdp.send('Page.enable');
await cdp.send('Page.addScriptToEvaluateOnNewDocument', {
source: 'addEventListener("beforeunload", () => { debugger; });',
});
return take;
}
/// Every page the test opens, its own and those of the contexts it makes with browser.newContext
/// (a second person, piper), each taken before it or its context closes.
async function watchAll(page: Page, browser: Browser, into: Entry[]) {
const takes = new Map<Page, () => Promise<void>>();
const watch = async (p: Page) => {
takes.set(p, await record(p, into));
const close = p.close.bind(p);
p.close = async (o) => {
await takes.get(p)?.();
return close(o);
};
};
await watch(page);
const newContext = browser.newContext;
browser.newContext = async (o) => {
const ctx = await newContext.call(browser, o);
const newPage = ctx.newPage.bind(ctx);
ctx.newPage = async () => {
const p = await newPage();
await watch(p);
return p;
};
const close = ctx.close.bind(ctx);
ctx.close = async (o) => {
await Promise.all(ctx.pages().map((p) => takes.get(p)?.()));
return close(o);
};
return ctx;
};
return async () => {
// The browser is the worker's, shared by the next test.
browser.newContext = newContext;
await Promise.all([...takes].filter(([p]) => !p.isClosed()).map(([, take]) => take()));
};
}
export const test = base.extend<{ coverage: void }>({
coverage: [
async ({ page }, use) => {
async ({ page, browser }, use) => {
if (!process.env.IPX_COVERAGE) return use();
// Kept across a reload or a sign-in, which several tests make.
await page.coverage.startJSCoverage({ resetOnNavigation: false });
const entries: Entry[] = [];
const done = await watchAll(page, browser, entries);
try {
await use();
await new CoverageReport(coverageOptions).add(await page.coverage.stopJSCoverage());
} finally {
await done();
}
await new CoverageReport(coverageOptions).add(entries);
},
{ auto: true },
],