theNotoriousHxc
2/27/2018 - 2:00 AM

CSS/JS code coverage during lifecycle of page load

CSS/JS code coverage during lifecycle of page load

/**
 * @author ebidel@ (Eric Bidelman)
 * License Apache-2.0
 *
 * Shows how to use Puppeeteer's code coverage API to measure CSS/JS coverage across 
 * different points of time during loading. Great for determining if a lazy loading strategy
 * is paying off or working correctly.
 *
 * Install: 
 *   npm i puppeteer chalk cli-table
 * Run:
 *   URL=https://example.com node coverage.js
 */

const puppeteer = require('puppeteer');
const chalk = require('chalk');
const Table = require('cli-table');

const URL = process.env.URL || 'https://www.chromestatus.com/features';

const EVENTS = [
  'domcontentloaded',
  'load',
  'networkidle0',
];

function formatBytesToKB(bytes) {
  if (bytes > 1024) {
    const formattedNum = new Intl.NumberFormat('en-US', {maximumFractionDigits: 1}).format(bytes / 1024);
    return `${formattedNum}KB`;
  }
  return `${bytes} bytes`;
}

class UsageFormatter {
  constructor(stats) {
    this.stats = stats;
  }

  static eventLabel(event) {
    return chalk.magenta(event);
  }

  summary(used = this.stats.usedBytes, total = this.stats.totalBytes) {
    const percent = Math.round((used / total) * 100);
    return `${formatBytesToKB(used)}/${formatBytesToKB(total)} (${percent}%)`;
  }

  shortSummary(used, total = this.stats.totalBytes) {
    const percent = Math.round((used / total) * 100);
    return used ? `${formatBytesToKB(used)} (${percent}%)` : 0;
  }

  /**
   * Constructors a bar chart for the % usage of each value.
   * @param {!{jsUsed: number, cssUsed: number, totalBytes: number}=} stats Usage stats.
   * @return {string}
   */
  barGraph(stats = this.stats) {
    const maxBarWidth = 30;

    const jsSegment = ' '.repeat((stats.jsUsed / stats.totalBytes) * maxBarWidth);
    const cssSegment = ' '.repeat((stats.cssUsed / stats.totalBytes) * maxBarWidth);
    const unusedSegment = ' '.repeat(maxBarWidth - jsSegment.length - cssSegment.length);

    return chalk.bgRedBright(jsSegment) + chalk.bgBlueBright(cssSegment) +
           chalk.bgBlackBright(unusedSegment);
  }
}

const stats = new Map();

/**
 * @param {!Object} coverage
 * @param {string} type Either "css" or "js" to indicate which type of coverage.
 * @param {string} eventType The page event when the coverage was captured.
 */
function addUsage(coverage, type, eventType) {
  for (const entry of coverage) {
    if (!stats.has(entry.url)) {
      stats.set(entry.url, []);
    }

    const urlStats = stats.get(entry.url);

    let eventStats = urlStats.find(item => item.eventType === eventType);
    if (!eventStats) {
      eventStats = {
        cssUsed: 0,
        jsUsed: 0,
        get usedBytes() { return this.cssUsed + this.jsUsed; },
        totalBytes: 0,
        get percentUsed() {
          return this.totalBytes ? Math.round(this.usedBytes / this.totalBytes * 100) : 0;
        },
        eventType,
        url: entry.url,
      };
      urlStats.push(eventStats);
    }

    eventStats.totalBytes += entry.text.length;

    for (const range of entry.ranges) {
      eventStats[`${type}Used`] += range.end - range.start - 1;
    }
  }
}

async function collectCoverage() {
  const browser = await puppeteer.launch({headless: true});

  // Do separate load for each event. See
  // https://github.com/GoogleChrome/puppeteer/issues/1887
  const collectPromises = EVENTS.map(async event => {
    console.log(`Collecting coverage @ ${UsageFormatter.eventLabel(event)}...`);

    const page = await browser.newPage();

    await Promise.all([
      page.coverage.startJSCoverage(),
      page.coverage.startCSSCoverage()
    ]);

    await page.goto(URL, {waitUntil: event});

    const [jsCoverage, cssCoverage] = await Promise.all([
      page.coverage.stopJSCoverage(),
      page.coverage.stopCSSCoverage()
    ]);

    addUsage(cssCoverage, 'css', event);
    addUsage(jsCoverage, 'js', event);

    await page.close();
  });

  await Promise.all(collectPromises);

  return browser.close();
}

(async() => {

await collectCoverage();

for (const [url, vals] of stats) {
  console.log('\n' + chalk.cyan(url));

  const table = new Table({
    head: [
      'Event',
      `${chalk.bgRedBright(' JS ')} ${chalk.bgBlueBright(' CSS ')} % used`,
      'JS used',
      'CSS used',
      'Total bytes used'
    ],
    style: {head: ['white'], border: ['grey']}
  });

  EVENTS.forEach(event => {
    const usageForEvent = vals.filter(val => val.eventType === event);

    if (usageForEvent.length) {
      for (const stats of usageForEvent) {
        const formatter = new UsageFormatter(stats);
        table.push([
          UsageFormatter.eventLabel(stats.eventType),
          formatter.barGraph(),
          formatter.shortSummary(stats.jsUsed), // !== 0 ? `${formatBytesToKB(stats.jsUsed)}KB` : 0,
          formatter.shortSummary(stats.cssUsed),
          formatter.summary()
        ]);
      }
    } else {
      table.push([UsageFormatter.eventLabel(event), 'no usage found', '-', '-', '-']);
    }
  });

  console.log(table.toString());
}

// Print total usage for each event.
// console.log('\n');
EVENTS.forEach(event => {
  let totalBytes = 0;
  let totalUsedBytes = 0;

  const metrics = Array.from(stats.values());
  const statsForEvent = metrics.map(eventStatsForUrl => {
    const statsForEvent = eventStatsForUrl.filter(stat => stat.eventType === event)[0];
    // TODO: need to sum max totalBytes. Currently ignores stats if event didn't
    // have an entry. IOW, all total numerators should be max totalBytes seen for that event.
    if (statsForEvent) {
      totalBytes += statsForEvent.totalBytes;
      totalUsedBytes += statsForEvent.usedBytes;
    }
  });

  const percentUsed = Math.round(totalUsedBytes / totalBytes * 100);

  console.log(`Total used @ ${chalk.magenta(event)}: ${formatBytesToKB(totalUsedBytes)}/${formatBytesToKB(totalBytes)} (${percentUsed}%)`);
});

})();