// 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 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, type Browser, type Page } from '@playwright/test'; import { CoverageReport, type CoverageReportOptions } from 'monocart-coverage-reports'; export const coverageOptions: CoverageReportOptions = { name: 'iPX page', outputDir: 'target/coverage/page', reports: ['v8', 'lcovonly', 'console-summary', 'markdown-details'], // The pages' scripts; the service worker is a worker of its own, which a page's coverage does // not see. entryFilter: (e) => /\/(app|admin|login)\.js(\?|$)/.test(e.url), 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, browser }, use) => { if (!process.env.IPX_COVERAGE) return use(); const entries: Entry[] = []; const done = await watchAll(page, browser, entries); try { await use(); } finally { await done(); } await new CoverageReport(coverageOptions).add(entries); }, { auto: true }, ], }); export { expect };