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 }, ],