tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 }