Coverage.ts (14763B)
1 /** 2 * @license 3 * Copyright 2017 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 7 import type {Protocol} from 'devtools-protocol'; 8 9 import type {CDPSession} from '../api/CDPSession.js'; 10 import {EventEmitter} from '../common/EventEmitter.js'; 11 import {debugError, PuppeteerURL} from '../common/util.js'; 12 import {assert} from '../util/assert.js'; 13 import {DisposableStack} from '../util/disposable.js'; 14 15 /** 16 * The CoverageEntry class represents one entry of the coverage report. 17 * @public 18 */ 19 export interface CoverageEntry { 20 /** 21 * The URL of the style sheet or script. 22 */ 23 url: string; 24 /** 25 * The content of the style sheet or script. 26 */ 27 text: string; 28 /** 29 * The covered range as start and end positions. 30 */ 31 ranges: Array<{start: number; end: number}>; 32 } 33 34 /** 35 * The CoverageEntry class for JavaScript 36 * @public 37 */ 38 export interface JSCoverageEntry extends CoverageEntry { 39 /** 40 * Raw V8 script coverage entry. 41 */ 42 rawScriptCoverage?: Protocol.Profiler.ScriptCoverage; 43 } 44 45 /** 46 * Set of configurable options for JS coverage. 47 * @public 48 */ 49 export interface JSCoverageOptions { 50 /** 51 * Whether to reset coverage on every navigation. 52 */ 53 resetOnNavigation?: boolean; 54 /** 55 * Whether anonymous scripts generated by the page should be reported. 56 */ 57 reportAnonymousScripts?: boolean; 58 /** 59 * Whether the result includes raw V8 script coverage entries. 60 */ 61 includeRawScriptCoverage?: boolean; 62 /** 63 * Whether to collect coverage information at the block level. 64 * If true, coverage will be collected at the block level (this is the default). 65 * If false, coverage will be collected at the function level. 66 */ 67 useBlockCoverage?: boolean; 68 } 69 70 /** 71 * Set of configurable options for CSS coverage. 72 * @public 73 */ 74 export interface CSSCoverageOptions { 75 /** 76 * Whether to reset coverage on every navigation. 77 */ 78 resetOnNavigation?: boolean; 79 } 80 81 /** 82 * The Coverage class provides methods to gather information about parts of 83 * JavaScript and CSS that were used by the page. 84 * 85 * @remarks 86 * To output coverage in a form consumable by {@link https://github.com/istanbuljs | Istanbul}, 87 * see {@link https://github.com/istanbuljs/puppeteer-to-istanbul | puppeteer-to-istanbul}. 88 * 89 * @example 90 * An example of using JavaScript and CSS coverage to get percentage of initially 91 * executed code: 92 * 93 * ```ts 94 * // Enable both JavaScript and CSS coverage 95 * await Promise.all([ 96 * page.coverage.startJSCoverage(), 97 * page.coverage.startCSSCoverage(), 98 * ]); 99 * // Navigate to page 100 * await page.goto('https://example.com'); 101 * // Disable both JavaScript and CSS coverage 102 * const [jsCoverage, cssCoverage] = await Promise.all([ 103 * page.coverage.stopJSCoverage(), 104 * page.coverage.stopCSSCoverage(), 105 * ]); 106 * let totalBytes = 0; 107 * let usedBytes = 0; 108 * const coverage = [...jsCoverage, ...cssCoverage]; 109 * for (const entry of coverage) { 110 * totalBytes += entry.text.length; 111 * for (const range of entry.ranges) usedBytes += range.end - range.start - 1; 112 * } 113 * console.log(`Bytes used: ${(usedBytes / totalBytes) * 100}%`); 114 * ``` 115 * 116 * @public 117 */ 118 export class Coverage { 119 #jsCoverage: JSCoverage; 120 #cssCoverage: CSSCoverage; 121 122 /** 123 * @internal 124 */ 125 constructor(client: CDPSession) { 126 this.#jsCoverage = new JSCoverage(client); 127 this.#cssCoverage = new CSSCoverage(client); 128 } 129 130 /** 131 * @internal 132 */ 133 updateClient(client: CDPSession): void { 134 this.#jsCoverage.updateClient(client); 135 this.#cssCoverage.updateClient(client); 136 } 137 138 /** 139 * @param options - Set of configurable options for coverage defaults to 140 * `resetOnNavigation : true, reportAnonymousScripts : false,` 141 * `includeRawScriptCoverage : false, useBlockCoverage : true` 142 * @returns Promise that resolves when coverage is started. 143 * 144 * @remarks 145 * Anonymous scripts are ones that don't have an associated url. These are 146 * scripts that are dynamically created on the page using `eval` or 147 * `new Function`. If `reportAnonymousScripts` is set to `true`, anonymous 148 * scripts URL will start with `debugger://VM` (unless a magic //# sourceURL 149 * comment is present, in which case that will the be URL). 150 */ 151 async startJSCoverage(options: JSCoverageOptions = {}): Promise<void> { 152 return await this.#jsCoverage.start(options); 153 } 154 155 /** 156 * Promise that resolves to the array of coverage reports for 157 * all scripts. 158 * 159 * @remarks 160 * JavaScript Coverage doesn't include anonymous scripts by default. 161 * However, scripts with sourceURLs are reported. 162 */ 163 async stopJSCoverage(): Promise<JSCoverageEntry[]> { 164 return await this.#jsCoverage.stop(); 165 } 166 167 /** 168 * @param options - Set of configurable options for coverage, defaults to 169 * `resetOnNavigation : true` 170 * @returns Promise that resolves when coverage is started. 171 */ 172 async startCSSCoverage(options: CSSCoverageOptions = {}): Promise<void> { 173 return await this.#cssCoverage.start(options); 174 } 175 176 /** 177 * Promise that resolves to the array of coverage reports 178 * for all stylesheets. 179 * 180 * @remarks 181 * CSS Coverage doesn't include dynamically injected style tags 182 * without sourceURLs. 183 */ 184 async stopCSSCoverage(): Promise<CoverageEntry[]> { 185 return await this.#cssCoverage.stop(); 186 } 187 } 188 189 /** 190 * @public 191 */ 192 export class JSCoverage { 193 #client: CDPSession; 194 #enabled = false; 195 #scriptURLs = new Map<string, string>(); 196 #scriptSources = new Map<string, string>(); 197 #subscriptions?: DisposableStack; 198 #resetOnNavigation = false; 199 #reportAnonymousScripts = false; 200 #includeRawScriptCoverage = false; 201 202 /** 203 * @internal 204 */ 205 constructor(client: CDPSession) { 206 this.#client = client; 207 } 208 209 /** 210 * @internal 211 */ 212 updateClient(client: CDPSession): void { 213 this.#client = client; 214 } 215 216 async start( 217 options: { 218 resetOnNavigation?: boolean; 219 reportAnonymousScripts?: boolean; 220 includeRawScriptCoverage?: boolean; 221 useBlockCoverage?: boolean; 222 } = {}, 223 ): Promise<void> { 224 assert(!this.#enabled, 'JSCoverage is already enabled'); 225 const { 226 resetOnNavigation = true, 227 reportAnonymousScripts = false, 228 includeRawScriptCoverage = false, 229 useBlockCoverage = true, 230 } = options; 231 this.#resetOnNavigation = resetOnNavigation; 232 this.#reportAnonymousScripts = reportAnonymousScripts; 233 this.#includeRawScriptCoverage = includeRawScriptCoverage; 234 this.#enabled = true; 235 this.#scriptURLs.clear(); 236 this.#scriptSources.clear(); 237 this.#subscriptions = new DisposableStack(); 238 const clientEmitter = this.#subscriptions.use( 239 new EventEmitter(this.#client), 240 ); 241 clientEmitter.on('Debugger.scriptParsed', this.#onScriptParsed.bind(this)); 242 clientEmitter.on( 243 'Runtime.executionContextsCleared', 244 this.#onExecutionContextsCleared.bind(this), 245 ); 246 await Promise.all([ 247 this.#client.send('Profiler.enable'), 248 this.#client.send('Profiler.startPreciseCoverage', { 249 callCount: this.#includeRawScriptCoverage, 250 detailed: useBlockCoverage, 251 }), 252 this.#client.send('Debugger.enable'), 253 this.#client.send('Debugger.setSkipAllPauses', {skip: true}), 254 ]); 255 } 256 257 #onExecutionContextsCleared(): void { 258 if (!this.#resetOnNavigation) { 259 return; 260 } 261 this.#scriptURLs.clear(); 262 this.#scriptSources.clear(); 263 } 264 265 async #onScriptParsed( 266 event: Protocol.Debugger.ScriptParsedEvent, 267 ): Promise<void> { 268 // Ignore puppeteer-injected scripts 269 if (PuppeteerURL.isPuppeteerURL(event.url)) { 270 return; 271 } 272 // Ignore other anonymous scripts unless the reportAnonymousScripts option is true. 273 if (!event.url && !this.#reportAnonymousScripts) { 274 return; 275 } 276 try { 277 const response = await this.#client.send('Debugger.getScriptSource', { 278 scriptId: event.scriptId, 279 }); 280 this.#scriptURLs.set(event.scriptId, event.url); 281 this.#scriptSources.set(event.scriptId, response.scriptSource); 282 } catch (error) { 283 // This might happen if the page has already navigated away. 284 debugError(error); 285 } 286 } 287 288 async stop(): Promise<JSCoverageEntry[]> { 289 assert(this.#enabled, 'JSCoverage is not enabled'); 290 this.#enabled = false; 291 292 const result = await Promise.all([ 293 this.#client.send('Profiler.takePreciseCoverage'), 294 this.#client.send('Profiler.stopPreciseCoverage'), 295 this.#client.send('Profiler.disable'), 296 this.#client.send('Debugger.disable'), 297 ]); 298 299 this.#subscriptions?.dispose(); 300 301 const coverage = []; 302 const profileResponse = result[0]; 303 304 for (const entry of profileResponse.result) { 305 let url = this.#scriptURLs.get(entry.scriptId); 306 if (!url && this.#reportAnonymousScripts) { 307 url = 'debugger://VM' + entry.scriptId; 308 } 309 const text = this.#scriptSources.get(entry.scriptId); 310 if (text === undefined || url === undefined) { 311 continue; 312 } 313 const flattenRanges = []; 314 for (const func of entry.functions) { 315 flattenRanges.push(...func.ranges); 316 } 317 const ranges = convertToDisjointRanges(flattenRanges); 318 if (!this.#includeRawScriptCoverage) { 319 coverage.push({url, ranges, text}); 320 } else { 321 coverage.push({url, ranges, text, rawScriptCoverage: entry}); 322 } 323 } 324 return coverage; 325 } 326 } 327 328 /** 329 * @public 330 */ 331 export class CSSCoverage { 332 #client: CDPSession; 333 #enabled = false; 334 #stylesheetURLs = new Map<string, string>(); 335 #stylesheetSources = new Map<string, string>(); 336 #eventListeners?: DisposableStack; 337 #resetOnNavigation = false; 338 339 constructor(client: CDPSession) { 340 this.#client = client; 341 } 342 343 /** 344 * @internal 345 */ 346 updateClient(client: CDPSession): void { 347 this.#client = client; 348 } 349 350 async start(options: {resetOnNavigation?: boolean} = {}): Promise<void> { 351 assert(!this.#enabled, 'CSSCoverage is already enabled'); 352 const {resetOnNavigation = true} = options; 353 this.#resetOnNavigation = resetOnNavigation; 354 this.#enabled = true; 355 this.#stylesheetURLs.clear(); 356 this.#stylesheetSources.clear(); 357 this.#eventListeners = new DisposableStack(); 358 const clientEmitter = this.#eventListeners.use( 359 new EventEmitter(this.#client), 360 ); 361 clientEmitter.on('CSS.styleSheetAdded', this.#onStyleSheet.bind(this)); 362 clientEmitter.on( 363 'Runtime.executionContextsCleared', 364 this.#onExecutionContextsCleared.bind(this), 365 ); 366 367 await Promise.all([ 368 this.#client.send('DOM.enable'), 369 this.#client.send('CSS.enable'), 370 this.#client.send('CSS.startRuleUsageTracking'), 371 ]); 372 } 373 374 #onExecutionContextsCleared(): void { 375 if (!this.#resetOnNavigation) { 376 return; 377 } 378 this.#stylesheetURLs.clear(); 379 this.#stylesheetSources.clear(); 380 } 381 382 async #onStyleSheet(event: Protocol.CSS.StyleSheetAddedEvent): Promise<void> { 383 const header = event.header; 384 // Ignore anonymous scripts 385 if (!header.sourceURL) { 386 return; 387 } 388 try { 389 const response = await this.#client.send('CSS.getStyleSheetText', { 390 styleSheetId: header.styleSheetId, 391 }); 392 this.#stylesheetURLs.set(header.styleSheetId, header.sourceURL); 393 this.#stylesheetSources.set(header.styleSheetId, response.text); 394 } catch (error) { 395 // This might happen if the page has already navigated away. 396 debugError(error); 397 } 398 } 399 400 async stop(): Promise<CoverageEntry[]> { 401 assert(this.#enabled, 'CSSCoverage is not enabled'); 402 this.#enabled = false; 403 const ruleTrackingResponse = await this.#client.send( 404 'CSS.stopRuleUsageTracking', 405 ); 406 await Promise.all([ 407 this.#client.send('CSS.disable'), 408 this.#client.send('DOM.disable'), 409 ]); 410 this.#eventListeners?.dispose(); 411 412 // aggregate by styleSheetId 413 const styleSheetIdToCoverage = new Map(); 414 for (const entry of ruleTrackingResponse.ruleUsage) { 415 let ranges = styleSheetIdToCoverage.get(entry.styleSheetId); 416 if (!ranges) { 417 ranges = []; 418 styleSheetIdToCoverage.set(entry.styleSheetId, ranges); 419 } 420 ranges.push({ 421 startOffset: entry.startOffset, 422 endOffset: entry.endOffset, 423 count: entry.used ? 1 : 0, 424 }); 425 } 426 427 const coverage: CoverageEntry[] = []; 428 for (const styleSheetId of this.#stylesheetURLs.keys()) { 429 const url = this.#stylesheetURLs.get(styleSheetId); 430 assert( 431 typeof url !== 'undefined', 432 `Stylesheet URL is undefined (styleSheetId=${styleSheetId})`, 433 ); 434 const text = this.#stylesheetSources.get(styleSheetId); 435 assert( 436 typeof text !== 'undefined', 437 `Stylesheet text is undefined (styleSheetId=${styleSheetId})`, 438 ); 439 const ranges = convertToDisjointRanges( 440 styleSheetIdToCoverage.get(styleSheetId) || [], 441 ); 442 coverage.push({url, ranges, text}); 443 } 444 445 return coverage; 446 } 447 } 448 449 function convertToDisjointRanges( 450 nestedRanges: Array<{startOffset: number; endOffset: number; count: number}>, 451 ): Array<{start: number; end: number}> { 452 const points = []; 453 for (const range of nestedRanges) { 454 points.push({offset: range.startOffset, type: 0, range}); 455 points.push({offset: range.endOffset, type: 1, range}); 456 } 457 // Sort points to form a valid parenthesis sequence. 458 points.sort((a, b) => { 459 // Sort with increasing offsets. 460 if (a.offset !== b.offset) { 461 return a.offset - b.offset; 462 } 463 // All "end" points should go before "start" points. 464 if (a.type !== b.type) { 465 return b.type - a.type; 466 } 467 const aLength = a.range.endOffset - a.range.startOffset; 468 const bLength = b.range.endOffset - b.range.startOffset; 469 // For two "start" points, the one with longer range goes first. 470 if (a.type === 0) { 471 return bLength - aLength; 472 } 473 // For two "end" points, the one with shorter range goes first. 474 return aLength - bLength; 475 }); 476 477 const hitCountStack = []; 478 const results: Array<{ 479 start: number; 480 end: number; 481 }> = []; 482 let lastOffset = 0; 483 // Run scanning line to intersect all ranges. 484 for (const point of points) { 485 if ( 486 hitCountStack.length && 487 lastOffset < point.offset && 488 hitCountStack[hitCountStack.length - 1]! > 0 489 ) { 490 const lastResult = results[results.length - 1]; 491 if (lastResult && lastResult.end === lastOffset) { 492 lastResult.end = point.offset; 493 } else { 494 results.push({start: lastOffset, end: point.offset}); 495 } 496 } 497 lastOffset = point.offset; 498 if (point.type === 0) { 499 hitCountStack.push(point.range.count); 500 } else { 501 hitCountStack.pop(); 502 } 503 } 504 // Filter out empty ranges. 505 return results.filter(range => { 506 return range.end - range.start > 0; 507 }); 508 }