From 07923d665bbd84368bc93b603682a23f52b19404 Mon Sep 17 00:00:00 2001 From: rays Date: Sat, 7 Nov 2026 14:37:49 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA --- CLAUDE.md | 7 ++- tests/ui/coverage.ts | 105 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 103 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 113ea6b..cd0dc25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/tests/ui/coverage.ts b/tests/ui/coverage.ts index 224c712..f1d7428 100644 --- a/tests/ui/coverage.ts +++ b/tests/ui/coverage.ts @@ -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> { + const cdp = await page.context().newCDPSession(page); + const scripts = new Map(); + 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 Promise>(); + 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 }); - await use(); - await new CoverageReport(coverageOptions).add(await page.coverage.stopJSCoverage()); + const entries: Entry[] = []; + const done = await watchAll(page, browser, entries); + try { + await use(); + } finally { + await done(); + } + await new CoverageReport(coverageOptions).add(entries); }, { auto: true }, ],