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:
@@ -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
|
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
|
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
|
`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
|
`@playwright/test`, so every test is counted, every page it opens (a second person's context
|
||||||
native-bridge check are not.
|
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
|
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
|
`/mnt/fast/appdata/node-exporter/textfile/ipx_coverage.prom`; node-exporter's textfile collector
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// What the browser tests run of the page's script (#390). With IPX_COVERAGE=1, web/build.mts
|
// 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
|
// 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.
|
// 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.
|
// 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';
|
import { CoverageReport, type CoverageReportOptions } from 'monocart-coverage-reports';
|
||||||
|
|
||||||
export const coverageOptions: CoverageReportOptions = {
|
export const coverageOptions: CoverageReportOptions = {
|
||||||
@@ -18,14 +18,105 @@ export const coverageOptions: CoverageReportOptions = {
|
|||||||
sourceFilter: (p) => p.startsWith('web/src/'),
|
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 }>({
|
export const test = base.extend<{ coverage: void }>({
|
||||||
coverage: [
|
coverage: [
|
||||||
async ({ page }, use) => {
|
async ({ page, browser }, use) => {
|
||||||
if (!process.env.IPX_COVERAGE) return use();
|
if (!process.env.IPX_COVERAGE) return use();
|
||||||
// Kept across a reload or a sign-in, which several tests make.
|
const entries: Entry[] = [];
|
||||||
await page.coverage.startJSCoverage({ resetOnNavigation: false });
|
const done = await watchAll(page, browser, entries);
|
||||||
await use();
|
try {
|
||||||
await new CoverageReport(coverageOptions).add(await page.coverage.stopJSCoverage());
|
await use();
|
||||||
|
} finally {
|
||||||
|
await done();
|
||||||
|
}
|
||||||
|
await new CoverageReport(coverageOptions).add(entries);
|
||||||
},
|
},
|
||||||
{ auto: true },
|
{ auto: true },
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user