├── .gitignore
├── .npmignore
├── README.md
├── lib
└── lh-median-run.js
├── package.json
├── performance-leaderboard.js
├── src
├── AxeTester.js
├── LogUtil.js
├── ReadLog.js
├── ResultLogger.js
└── WriteLog.js
└── test
└── sample.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | package-lock.json
3 | .log
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .log
2 | test
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # performance-leaderboard
2 |
3 | A plugin to run Lighthouse against a set of urls to see which site is the fastest.
4 |
5 | * See also: [`performance-leaderboard-pagespeed-insights`](https://github.com/zachleat/performance-leaderboard-pagespeed-insights/) (runs Lighthouse through PageSpeed Insights)
6 |
7 | ## Installation
8 |
9 | ```sh
10 | npm install performance-leaderboard
11 | ```
12 |
13 | ## Features
14 |
15 | * Median Run Selection: `performance-leaderboard` will run Lighthouse on the same site multiple times and select the Median run. It factors in First Contentful Paint, Largest Contentful Paint, and Time to Interactive when [selecting the median run](https://github.com/zachleat/performance-leaderboard/blob/master/lib/lh-median-run.js#L55).
16 |
17 | ## Usage
18 |
19 | 1. Create a test file, say `sample.js`:
20 |
21 | ```js
22 | const PerfLeaderboard = require("performance-leaderboard");
23 |
24 | (async function() {
25 |
26 | let urls = [
27 | "https://www.gatsbyjs.org/",
28 | "https://nextjs.org/",
29 | "https://www.11ty.dev/",
30 | "https://vuejs.org/",
31 | "https://reactjs.org/",
32 | "https://jekyllrb.com/",
33 | "https://nuxtjs.org/",
34 | "https://gohugo.io/",
35 | ];
36 |
37 | // Create the options object (not required)
38 | const options = {
39 | axePuppeteerTimeout: 30000, // 30 seconds
40 | writeLogs: true, // Store audit data
41 | logDirectory: '.log', // Default audit data files stored at `.log`
42 | readFromLogDirectory: false, // Skip tests with existing logs
43 | // onlyCategories: ["performance", "accessibility"],
44 | chromeFlags: ['--headless'],
45 | freshChrome: "site", // or "run"
46 | launchOptions: {}, // Puppeteer launch options
47 | }
48 |
49 | // Run each site 3 times with default options
50 | console.log( await PerfLeaderboard(urls) );
51 |
52 | // Or run each site 5 times with default options
53 | console.log( await PerfLeaderboard(urls, 5) );
54 |
55 | // Or run each site 5 times with custom options
56 | console.log( await PerfLeaderboard(urls, 5, options) );
57 | })();
58 | ```
59 |
60 | 2. Run `node sample.js`.
61 |
62 |
63 | Sample Output
64 |
65 | ```js
66 | [
67 | {
68 | url: 'https://www.11ty.dev/',
69 | requestedUrl: 'https://www.11ty.dev/',
70 | timestamp: 1623525988492,
71 | ranks: { hundos: 1, performance: 1, accessibility: 1, cumulative: 1 },
72 | lighthouse: {
73 | version: '8.0.0',
74 | performance: 1,
75 | accessibility: 1,
76 | bestPractices: 1,
77 | seo: 1,
78 | total: 400
79 | },
80 | firstContentfulPaint: 1152.3029999999999,
81 | firstMeaningfulPaint: 1152.3029999999999,
82 | speedIndex: 1152.3029999999999,
83 | largestContentfulPaint: 1152.3029999999999,
84 | totalBlockingTime: 36,
85 | cumulativeLayoutShift: 0.02153049045138889,
86 | timeToInteractive: 1238.3029999999999,
87 | maxPotentialFirstInputDelay: 97,
88 | timeToFirstByte: 54.63900000000001,
89 | weight: {
90 | summary: '14 requests • 178 KiB',
91 | total: 182145,
92 | image: 124327,
93 | imageCount: 10,
94 | script: 7824,
95 | scriptCount: 1,
96 | document: 30431,
97 | font: 15649,
98 | fontCount: 1,
99 | stylesheet: 3914,
100 | stylesheetCount: 1,
101 | thirdParty: 15649,
102 | thirdPartyCount: 1
103 | },
104 | run: { number: 2, total: 3 },
105 | axe: { passes: 850, violations: 0 },
106 | }
107 | ]
108 | ```
109 |
110 |
111 |
112 | ## Rankings
113 |
114 | In the return object you’ll see a `ranks` object listing how this site compares to the other sites in the set. There are a bunch of different scoring algorithms you can choose from:
115 |
116 | * `ranks.performance`
117 | * The highest Lighthouse performance score.
118 | * Tiebreaker given to the lower SpeedIndex score.
119 | * `ranks.accessibility`
120 | * The highest Lighthouse accessibility score.
121 | * Tiebreaker given to lower Axe violations.
122 | * Second tiebreaker given to highest Axe passes (warning: each instance of an Axe rule passing is treated separately so this will weigh heavily in favor of larger pages)
123 | * `ranks.hundos`
124 | * The sum of all four Lighthouse scores.
125 | * Tiebreaker given to the lower Speed Index / Total Page Weight ratio.
126 | * `ranks.cumulative` (the same as `hundos` but with an Axe tiebreaker)
127 | * The sum of all four Lighthouse scores.
128 | * Tiebreaker given to the lower Axe violations.
129 | * Second tiebreaker given to the lower Speed Index / Total Page Weight ratio.
130 |
131 | ## Changelog
132 |
133 | * `v11.0.0` Update [`lighthouse` to v11](https://github.com/GoogleChrome/lighthouse/releases/tag/v11.0.0), requires Node >= 18.16.
134 | * `v10.0.0` Update [`lighthouse` to v10](https://github.com/GoogleChrome/lighthouse/releases/tag/v10.0.0), requires Node 16.
135 | * `v9.0.0` Update `lighthouse` to v9.0. Removes `carbonAudit`, upstream API was removed.
136 | * `v5.3.0` Update `lighthouse` from v8.2 to v8.5
137 | * `v5.2.0` Update `lighthouse` from v8.0 to v8.2
138 | * `v5.1.0` Adds `axePuppeteerTimeout` option. Adds `carbonAudit` option.
139 | * `v5.0.0` Update `lighthouse` to v8.0
140 | * `v4.1.0` Update `lighthouse` to v7.3
141 | * `v4.0.0` Major version upgrade of `lighthouse` dependency from v6.5 to v7.2
142 |
--------------------------------------------------------------------------------
/lib/lh-median-run.js:
--------------------------------------------------------------------------------
1 | // oh so slightly modified from https://github.com/GoogleChrome/lighthouse/blob/159cb8428cfb91452b1561ecaa0e8415e9eba742/lighthouse-core/lib/median-run.js
2 |
3 | /**
4 | * @license Copyright 2020 Lighthouse Authors. All Rights Reserved.
5 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
6 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
7 | */
8 | 'use strict';
9 |
10 | /** @param {LH.Result} lhr @param {string} auditName */
11 | const getNumericValue = (run, auditName) => {
12 | return run[auditName] || NaN;
13 | }
14 | // (lhr.audits[auditName] && lhr.audits[auditName].numericValue) || NaN;
15 |
16 | /**
17 | * @param {Array} numbers
18 | * @return {number}
19 | */
20 | function getMedianValue(numbers) {
21 | const sorted = numbers.slice().sort((a, b) => a - b);
22 | if (sorted.length % 2 === 1) return sorted[(sorted.length - 1) / 2];
23 | const lowerValue = sorted[sorted.length / 2 - 1];
24 | const upperValue = sorted[sorted.length / 2];
25 | return (lowerValue + upperValue) / 2;
26 | }
27 |
28 | /**
29 | * @param {LH.Result} lhr
30 | * @param {number} medianFcp
31 | * @param {number} medianInteractive
32 | * @param {number} medianLcp
33 | */
34 | function getMedianSortValue(lhr, medianFcp, medianInteractive, medianLcp) {
35 | const distanceFcp =
36 | medianFcp - getNumericValue(lhr, 'firstContentfulPaint');
37 | const distanceInteractive =
38 | medianInteractive - getNumericValue(lhr, 'timeToInteractive');
39 | const distanceLcp =
40 | medianLcp - getNumericValue(lhr, 'largestContentfulPaint');
41 |
42 | return distanceFcp * distanceFcp + distanceInteractive * distanceInteractive + distanceLcp * distanceLcp;
43 | }
44 |
45 | /**
46 | * We want the run that's closest to the median of the FCP and the median of the TTI.
47 | * We're using the Euclidean distance for that (https://en.wikipedia.org/wiki/Euclidean_distance).
48 | * We use FCP and TTI because they represent the earliest and latest moments in the page lifecycle.
49 | * We avoid the median of single measures like the performance score because they can still exhibit
50 | * outlier behavior at the beginning or end of load.
51 | *
52 | * @param {Array} runs
53 | * @return {LH.Result}
54 | */
55 | function computeMedianRun(runs, url) {
56 | const missingFcp = runs.some(run =>
57 | Number.isNaN(getNumericValue(run, 'firstContentfulPaint'))
58 | );
59 | const missingLcp = runs.some(run =>
60 | Number.isNaN(getNumericValue(run, 'largestContentfulPaint'))
61 | );
62 | const missingTti = runs.some(run =>
63 | Number.isNaN(getNumericValue(run, 'timeToInteractive'))
64 | );
65 |
66 | if (!runs.length) throw new Error(`No runs provided (${url})`);
67 | if (missingFcp) throw new Error(`Some runs were missing an FCP value (${url}): ${JSON.stringify(runs)}`);
68 | if (missingLcp) throw new Error(`Some runs were missing an LCP value (${url}): ${JSON.stringify(runs)}`);
69 | if (missingTti) throw new Error(`Some runs were missing a TTI value (${url}): ${JSON.stringify(runs)}`);
70 |
71 | const medianFcp = getMedianValue(
72 | runs.map(run => getNumericValue(run, 'firstContentfulPaint'))
73 | );
74 |
75 | const medianLcp = getMedianValue(
76 | runs.map(run => getNumericValue(run, 'largestContentfulPaint'))
77 | );
78 |
79 | const medianInteractive = getMedianValue(
80 | runs.map(run => getNumericValue(run, 'timeToInteractive'))
81 | );
82 |
83 | // console.log( { medianFcp, medianInteractive, medianLcp } );
84 |
85 | // Sort by proximity to the medians, breaking ties with the minimum TTI.
86 | const sortedByProximityToMedian = runs
87 | .slice()
88 | .sort(
89 | (a, b) =>
90 | getMedianSortValue(a, medianFcp, medianInteractive, medianLcp) -
91 | getMedianSortValue(b, medianFcp, medianInteractive, medianLcp) ||
92 | getNumericValue(a, 'timeToInteractive') - getNumericValue(b, 'timeToInteractive')
93 | );
94 |
95 | return sortedByProximityToMedian[0];
96 | }
97 |
98 | module.exports = {computeMedianRun};
99 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "performance-leaderboard",
3 | "version": "12.4.0-release.1",
4 | "main": "performance-leaderboard.js",
5 | "scripts": {
6 | "sample": "node test/sample"
7 | },
8 | "repository": {
9 | "type": "git",
10 | "url": "git+https://github.com/zachleat/performance-leaderboard.git"
11 | },
12 | "keywords": [
13 | "performance",
14 | "lighthouse"
15 | ],
16 | "author": {
17 | "name": "Zach Leatherman",
18 | "email": "zachleatherman@gmail.com",
19 | "url": "https://zachleat.com/"
20 | },
21 | "license": "MIT",
22 | "bugs": {
23 | "url": "https://github.com/zachleat/performance-leaderboard/issues"
24 | },
25 | "homepage": "https://github.com/zachleat/performance-leaderboard#readme",
26 | "dependencies": {
27 | "@axe-core/puppeteer": "^4.10.1",
28 | "byte-size": "^9.0.1",
29 | "chrome-launcher": "^1.1.2",
30 | "lighthouse": "^12.4.0",
31 | "lodash.get": "^4.4.2",
32 | "puppeteer": "^24.4.0",
33 | "slugify": "^1.6.6"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/performance-leaderboard.js:
--------------------------------------------------------------------------------
1 | const slugify = require("slugify");
2 | const chromePuppeteerPath = require("puppeteer").executablePath();
3 |
4 | const ResultLogger = require("./src/ResultLogger.js");
5 | const writeLog = require("./src/WriteLog.js");
6 | const readLog = require("./src/ReadLog.js");
7 | const log = require("./src/LogUtil.js");
8 |
9 | const NUMBER_OF_RUNS = 3;
10 | const LOG_DIRECTORY = ".log";
11 | const AXE_PUPPETEER_TIMEOUT = 30000;
12 |
13 | // Fix for `ProtocolError: Protocol error (Target.getTargetInfo): 'Target.getTargetInfo' wasn't found`
14 | process.on("unhandledRejection", (error, promise) => {
15 | log("Unhandled rejection in promise", error);
16 | });
17 |
18 | async function runLighthouse(urls, numberOfRuns = NUMBER_OF_RUNS, options = {}) {
19 | const chromeLauncher = await import("chrome-launcher");
20 | const { default: lighthouse } = await import("lighthouse");
21 |
22 | let opts = Object.assign({
23 | writeLogs: true,
24 | logDirectory: LOG_DIRECTORY,
25 | readFromLogDirectory: false,
26 | axePuppeteerTimeout: AXE_PUPPETEER_TIMEOUT,
27 | bypassAxe: [], // skip axe checks
28 | // onlyCategories: ["performance", "accessibility"],
29 | chromeFlags: [
30 | '--headless',
31 | '--disable-dev-shm-usage',
32 | '--ignore-certificate-errors',
33 | '--no-enable-error-reporting',
34 | ],
35 | freshChrome: "site", // or "run"
36 | launchOptions: {},
37 | // callback before each lighthouse test
38 | beforeHook: function(url) {}, // async compatible
39 | // callback after each lighthouse result
40 | afterHook: function(result) {}, // async compatible
41 | // deprecated
42 | resultHook: function(result) {}, // async compatible
43 | }, options);
44 |
45 | let config = null;
46 |
47 | let resultLog = new ResultLogger();
48 | resultLog.logDirectory = opts.logDirectory;
49 | resultLog.writeLogs = opts.writeLogs;
50 | resultLog.readFromLogs = opts.readFromLogDirectory;
51 | resultLog.axePuppeteerTimeout = opts.axePuppeteerTimeout;
52 | resultLog.bypassAxe = opts.bypassAxe;
53 |
54 | log( `Testing ${urls.length} site${urls.length !== 1 ? "s" : ""}:` );
55 |
56 | // SpeedIndex was much lower on repeat runs if we don’t
57 | // kill the chrome instance between runs of the same site
58 | for(let j = 0; j < numberOfRuns; j++) {
59 | let count = 0;
60 | let chrome;
61 | let portNumber;
62 |
63 | if(!opts.readFromLogDirectory && opts.freshChrome === "run") {
64 | chrome = await chromeLauncher.launch(Object.assign({
65 | chromeFlags: opts.chromeFlags,
66 | // reuse puppeteer chrome path
67 | chromePath: chromePuppeteerPath,
68 | }, opts.launchOptions));
69 |
70 | portNumber = chrome.port;
71 | }
72 | for(let url of urls) {
73 | if(!opts.readFromLogDirectory && opts.freshChrome === "site") {
74 | chrome = await chromeLauncher.launch(Object.assign({
75 | chromeFlags: opts.chromeFlags,
76 | // reuse puppeteer chrome path
77 | chromePath: chromePuppeteerPath,
78 | }, opts.launchOptions));
79 |
80 | portNumber = chrome.port;
81 | }
82 |
83 | log( `Lighthouse ${++count}/${urls.length} Run ${j+1}/${numberOfRuns}: ${url}` );
84 |
85 | if(opts.beforeHook && typeof opts.beforeHook === "function") {
86 | await opts.beforeHook(url);
87 | }
88 |
89 | try {
90 | slugify.extend({":": "-", "/": "-"});
91 | let filename = `lighthouse-${slugify(url)}-${j+1}-of-${numberOfRuns}.json`;
92 | let rawResult;
93 | if(opts.readFromLogDirectory) {
94 | rawResult = readLog(filename, opts.logDirectory);
95 | } else {
96 | let { lhr } = await lighthouse(url, {
97 | // logLevel: "info",
98 | port: portNumber,
99 | }, config);
100 |
101 | rawResult = lhr;
102 |
103 | if(opts.writeLogs) {
104 | await writeLog(filename, rawResult, opts.logDirectory);
105 | }
106 | }
107 |
108 | let afterHook = opts.afterHook || opts.resultHook; // resultHook is deprecated (renamed)
109 | if(afterHook && typeof afterHook === "function") {
110 | let ret = await afterHook(resultLog.mapResult(rawResult), rawResult);
111 | if(ret !== false) {
112 | resultLog.add(url, rawResult);
113 | }
114 | } else {
115 | resultLog.add(url, rawResult);
116 | }
117 | } catch(e) {
118 | log( `Logged an error with ${url}: `, e );
119 | resultLog.addError(url, e);
120 | }
121 |
122 | if(chrome && opts.freshChrome === "site") {
123 | await chrome.kill();
124 | }
125 | }
126 |
127 | if(chrome && opts.freshChrome === "run") {
128 | // Note that this needs to kill between runs for a fresh chrome profile
129 | // We don’t want the second run to be a repeat full-cache serviceworker view
130 | await chrome.kill();
131 | }
132 | }
133 |
134 | return await resultLog.getFinalSortedResults();
135 | }
136 |
137 | module.exports = runLighthouse;
138 |
--------------------------------------------------------------------------------
/src/AxeTester.js:
--------------------------------------------------------------------------------
1 | const slugify = require("slugify");
2 | const puppeteer = require("puppeteer");
3 | const { AxePuppeteer } = require("@axe-core/puppeteer");
4 |
5 | const writeLog = require("./WriteLog.js");
6 | const readLog = require("./ReadLog.js");
7 | const log = require("./LogUtil.js");
8 |
9 | class AxeTester {
10 | constructor() {
11 | this.bypassAxe = [];
12 | }
13 |
14 | set readFromLogs(doRead) {
15 | this._readFromLogs = doRead;
16 | }
17 |
18 | get readFromLogs() {
19 | return this._readFromLogs;
20 | }
21 |
22 | set writeLogs(doWrite) {
23 | this._writeLogs = doWrite;
24 | }
25 |
26 | get writeLogs() {
27 | return this._writeLogs;
28 | }
29 |
30 | set puppeteerTimeout(timeout) {
31 | this._puppeteerTimeout = timeout;
32 | }
33 |
34 | get puppeteerTimeout() {
35 | return this._puppeteerTimeout;
36 | }
37 |
38 | set logDirectory(dir) {
39 | this._logDir = dir;
40 | }
41 |
42 | get logDirectory() {
43 | return this._logDir;
44 | }
45 |
46 | async start() {
47 | this.browser = await puppeteer.launch({
48 | headless: "new"
49 | });
50 | }
51 |
52 | getLogFilename(url) {
53 | return `axe-${slugify(url)}.json`;
54 | }
55 |
56 | count(rawResults, key) {
57 | let count = 0;
58 | for(let entry of rawResults[key]) {
59 | if(entry.nodes.length) {
60 | count += entry.nodes.length;
61 | } else {
62 | count++;
63 | }
64 | }
65 | return count;
66 | }
67 |
68 | cleanResults(rawResults) {
69 | return {
70 | passes: this.count(rawResults, "passes"),
71 | violations: this.count(rawResults, "violations")
72 | }
73 | }
74 |
75 | getLogResults(url) {
76 | let rawResults = readLog(this.getLogFilename(url), this.logDirectory);
77 | if(rawResults === false) {
78 | return false;
79 | }
80 | return this.cleanResults(rawResults);
81 | }
82 |
83 | async fetchNewResults(url) {
84 | if((this.bypassAxe || []).includes(url)) {
85 | return {
86 | error: "Skipping via configuration option."
87 | }
88 | }
89 |
90 | this.page = await this.browser.newPage();
91 | await this.page.setBypassCSP(true);
92 | await this.page.goto(url, {
93 | waitUntil: ["load", "networkidle0"],
94 | timeout: this.puppeteerTimeout
95 | });
96 |
97 | const results = await new AxePuppeteer(this.page).analyze();
98 | if(this.writeLogs) {
99 | await writeLog(this.getLogFilename(url), results, this.logDirectory);
100 | }
101 | await this.page.close();
102 |
103 | return this.cleanResults(results);
104 | }
105 |
106 | async getResults(url) {
107 | try {
108 | let readResult = this.getLogResults(url);
109 | if(this.readFromLogs && readResult !== false) {
110 | return readResult;
111 | } else {
112 | return await this.fetchNewResults(url);
113 | }
114 | } catch(e) {
115 | log( `Axe error with ${url}`, e );
116 | if(this.page) {
117 | await this.page.close();
118 | }
119 |
120 | return {
121 | error: JSON.stringify(e)
122 | }
123 | }
124 | }
125 |
126 | async finish() {
127 | await this.browser.close()
128 | }
129 | }
130 | module.exports = AxeTester;
131 |
--------------------------------------------------------------------------------
/src/LogUtil.js:
--------------------------------------------------------------------------------
1 | module.exports = function log(...messages) {
2 | console.log("[performance-leaderboard]", ...messages);
3 | }
--------------------------------------------------------------------------------
/src/ReadLog.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const fs = require("fs");
3 |
4 | function readLogFromDate(dateObj, fileSlug, logDirectory) {
5 | let date = dateObj.toISOString().substr(0, 10);
6 | let dir = path.join(path.resolve("."), logDirectory, date);
7 |
8 | let filepath = path.join(dir, `${fileSlug}`);
9 | if(fs.existsSync(filepath)) {
10 | return require(filepath);
11 | }
12 | return false;
13 | }
14 |
15 | function readLog(fileSlug, logDirectory) {
16 | let result = readLogFromDate(new Date(), fileSlug, logDirectory);
17 | if(result !== false) {
18 | return result;
19 | }
20 |
21 | let yesterday = new Date();
22 | yesterday.setTime(yesterday.getTime() - 1000*60*60*23);
23 | return readLogFromDate(yesterday, fileSlug, logDirectory);
24 | }
25 |
26 | module.exports = readLog;
--------------------------------------------------------------------------------
/src/ResultLogger.js:
--------------------------------------------------------------------------------
1 | const lodashGet = require("lodash.get");
2 | const byteSize = require('byte-size');
3 |
4 | const AxeTester = require("./AxeTester.js");
5 | const LighthouseMedianRun = require("../lib/lh-median-run.js");
6 | const log = require("./LogUtil.js");
7 |
8 | class ResultLogger {
9 | constructor() {
10 | this.results = {};
11 | }
12 |
13 | set readFromLogs(doRead) {
14 | this._readFromLogs = doRead;
15 | }
16 |
17 | get readFromLogs() {
18 | return this._readFromLogs;
19 | }
20 |
21 | set writeLogs(doWrite) {
22 | this._writeLogs = doWrite;
23 | }
24 |
25 | get writeLogs() {
26 | return this._writeLogs;
27 | }
28 |
29 | set logDirectory(dir) {
30 | this._logDir = dir;
31 | }
32 |
33 | get logDirectory() {
34 | return this._logDir;
35 | }
36 |
37 | set axePuppeteerTimeout(timeout) {
38 | this._axePuppeteerTimeout = timeout;
39 | }
40 |
41 | get axePuppeteerTimeout() {
42 | return this._axePuppeteerTimeout;
43 | }
44 |
45 | _getGoodKeyCheckSort(a, b, key) {
46 | if(b[key] && a[key]) {
47 | return 0;
48 | } else if(a[key]) {
49 | return -1;
50 | } else if(b[key]) {
51 | return 1;
52 | }
53 | }
54 | _getBadKeyCheckSort(a, b, key) {
55 | if(b[key] && a[key]) {
56 | return 0;
57 | } else if(b[key]) {
58 | return -1;
59 | } else if(a[key]) {
60 | return 1;
61 | }
62 | }
63 | _getUndefinedCheckSort(a, b) {
64 | if(b === undefined && a === undefined) {
65 | return 0;
66 | } else if(b === undefined) {
67 | return -1;
68 | } else if(a === undefined) {
69 | return 1;
70 | }
71 | }
72 |
73 | sortByAccessibilityBeforeAxe(a, b) {
74 | if(a.error || b.error) {
75 | return this._getBadKeyCheckSort(a, b, "error");
76 | }
77 |
78 | // We want the lowest score here
79 | return a.lighthouse.accessibility - b.lighthouse.accessibility;
80 | }
81 |
82 | sortByAccessibility(a, b) {
83 | if(a.error || b.error) {
84 | return this._getBadKeyCheckSort(a, b, "error");
85 | }
86 |
87 | if(b.lighthouse.accessibility === a.lighthouse.accessibility) {
88 | if(!a.axe || !b.axe) {
89 | return this._getGoodKeyCheckSort(a, b, "axe");
90 | }
91 |
92 | if(a.axe.error || b.axe.error) {
93 | return this._getBadKeyCheckSort(a.axe, b.axe, "error");
94 | }
95 |
96 | if( b.axe.violations === a.axe.violations ) {
97 | // higher is better
98 | // TODO if this is equal, sort by performance?
99 | return b.axe.passes - a.axe.passes;
100 | }
101 |
102 | // lower is better
103 | return a.axe.violations - b.axe.violations;
104 | }
105 |
106 | return b.lighthouse.accessibility - a.lighthouse.accessibility;
107 | }
108 |
109 | sortByPerformance(a, b) {
110 | if(a.error || b.error) {
111 | return this._getBadKeyCheckSort(a, b, "error");
112 | }
113 |
114 | if(b.lighthouse.performance === a.lighthouse.performance) {
115 | // lower speed index scores are better
116 | return a.speedIndex - b.speedIndex;
117 | }
118 | // higher lighthouse scores are better
119 | return b.lighthouse.performance - a.lighthouse.performance;
120 | }
121 |
122 | // image, script, document, font, stylesheets only (no videos, no third parties)
123 | getTiebreakerWeight(result) {
124 | let upperLimitImages = 400000; // bytes
125 | let upperLimitFonts = 100000; // bytes
126 |
127 | return result.weight.document +
128 | result.weight.stylesheet +
129 | result.weight.script +
130 | Math.min(result.weight.font, upperLimitFonts) +
131 | Math.min(result.weight.image, upperLimitImages);
132 | }
133 |
134 | // speed index per KB
135 | // low speed index with high weight is more impressive 😇 (lower is better)
136 | // also add in TTFB and TBT
137 | getTiebreakerValue(result) {
138 | let weight = this.getTiebreakerWeight(result);
139 | return 50000 * result.speedIndex / weight + result.timeToFirstByte + result.totalBlockingTime;
140 | }
141 |
142 | getLighthouseSum(result) {
143 | return result.lighthouse.performance + result.lighthouse.accessibility + result.lighthouse.seo + result.lighthouse.bestPractices;
144 | }
145 |
146 | sortByTotalHundos(a, b) {
147 | if(a.error || b.error) {
148 | return this._getBadKeyCheckSort(a, b, "error");
149 | }
150 |
151 | let bSum = this.getLighthouseSum(b);
152 | let aSum = this.getLighthouseSum(a);
153 | if(bSum === aSum) {
154 | // lower is better
155 | return this.getTiebreakerValue(a) - this.getTiebreakerValue(b);
156 | }
157 |
158 | // higher is better
159 | return bSum - aSum;
160 | }
161 |
162 | sortByCumulativeScore(a, b) {
163 | if(a.error || b.error) {
164 | return this._getBadKeyCheckSort(a, b, "error");
165 | }
166 |
167 | let bSum = this.getLighthouseSum(b);
168 | let aSum = this.getLighthouseSum(a);
169 | if(bSum === aSum) {
170 | if(a.axe === undefined || b.axe === undefined) {
171 | return this._getUndefinedCheckSort(a.axe, b.axe);
172 | }
173 | if(a.axe.error || b.axe.error) {
174 | return this._getBadKeyCheckSort(a.axe, b.axe, "error");
175 | }
176 |
177 | if(a.axe.violations !== b.axe.violations) {
178 | // lower violations are better
179 | return a.axe.violations - b.axe.violations;
180 | }
181 |
182 | // lower is better
183 | return this.getTiebreakerValue(a) - this.getTiebreakerValue(b);
184 | }
185 |
186 | // higher is better
187 | return bSum - aSum;
188 | }
189 |
190 |
191 | _add(url, result) {
192 | if(!this.results[url]) {
193 | this.results[url] = [];
194 | }
195 | this.results[url].push(result);
196 | }
197 |
198 | add(url, rawResult) {
199 | let result = this.mapResult(rawResult);
200 | this._add(url, result);
201 | }
202 |
203 | addError(url, error) {
204 | this._add(url, {
205 | url: url,
206 | error: JSON.stringify(error)
207 | });
208 | }
209 |
210 | _getResultResourceSummaryItem(items, resourceType, prop) {
211 | let count = 0;
212 | let size = 0;
213 | if(items && items.length) {
214 | let subItems = items;
215 | if(resourceType) {
216 | subItems = items.filter(entry => {
217 | return (entry.resourceType || "").toLowerCase() === resourceType.toLowerCase();
218 | });
219 | }
220 |
221 | for(let item of subItems) {
222 | count++;
223 | size += item.transferSize;
224 | }
225 | }
226 | // prop: transferSize, requestCount
227 | if(prop === "transferSize") {
228 | return size;
229 | }
230 | if(prop === "requestCount") {
231 | return count;
232 | }
233 | return NaN;
234 | }
235 |
236 | mapResult(result) {
237 | // Bad certificate, maybe
238 | if(result.categories.performance.score === null &&
239 | result.categories.accessibility.score === null &&
240 | result.categories['best-practices'].score === null &&
241 | result.categories.seo.score === null) {
242 | return {
243 | url: result.finalUrl,
244 | error: "Unknown error."
245 | };
246 | }
247 |
248 |
249 | let requestSummary = {
250 | requestCount: 0,
251 | transferSize: 0,
252 | };
253 |
254 | let networkItems = result.audits['network-requests'].details.items;
255 | for (let request of networkItems) {
256 | requestSummary.requestCount += 1;
257 | requestSummary.transferSize += request.transferSize;
258 | }
259 | // 27 requests • 209 KiB
260 | let normalizedTransferSize = byteSize(requestSummary.transferSize, { units: 'iec' });
261 | let requestSummaryDisplayValue = `${requestSummary.requestCount} request${requestSummary.requestCount !== 1 ? "s" : ""} • ${normalizedTransferSize.value} ${normalizedTransferSize.unit}`;
262 |
263 | return {
264 | url: result.finalUrl,
265 | requestedUrl: result.requestedUrl,
266 | timestamp: Date.now(),
267 | ranks: {},
268 | lighthouse: {
269 | version: result.lighthouseVersion,
270 | performance: result.categories.performance.score,
271 | accessibility: result.categories.accessibility.score,
272 | bestPractices: result.categories['best-practices'].score,
273 | seo: result.categories.seo.score,
274 | total: result.categories.performance.score * 100 +
275 | result.categories.accessibility.score * 100 +
276 | result.categories['best-practices'].score * 100 +
277 | result.categories.seo.score * 100
278 | },
279 | firstContentfulPaint: result.audits['first-contentful-paint'].numericValue,
280 | firstMeaningfulPaint: result.audits['first-meaningful-paint'].numericValue,
281 | speedIndex: result.audits['speed-index'].numericValue,
282 | largestContentfulPaint: result.audits['largest-contentful-paint'].numericValue,
283 | totalBlockingTime: result.audits['total-blocking-time'].numericValue,
284 | cumulativeLayoutShift: result.audits['cumulative-layout-shift'].numericValue,
285 | timeToInteractive: result.audits['interactive'].numericValue,
286 | maxPotentialFirstInputDelay: result.audits['max-potential-fid'].numericValue,
287 | timeToFirstByte: result.audits['server-response-time'].numericValue,
288 | weight: {
289 | summary: requestSummaryDisplayValue,
290 | total: result.audits['total-byte-weight'].numericValue,
291 | image: this._getResultResourceSummaryItem(networkItems, "image", "transferSize"),
292 | imageCount: this._getResultResourceSummaryItem(networkItems, "image", "requestCount"),
293 | script: this._getResultResourceSummaryItem(networkItems, "script", "transferSize"),
294 | scriptCount: this._getResultResourceSummaryItem(networkItems, "script", "requestCount"),
295 | document: this._getResultResourceSummaryItem(networkItems, "document", "transferSize"),
296 | font: this._getResultResourceSummaryItem(networkItems, "font", "transferSize"),
297 | fontCount: this._getResultResourceSummaryItem(networkItems, "font", "requestCount"),
298 | stylesheet: this._getResultResourceSummaryItem(networkItems, "stylesheet", "transferSize"),
299 | stylesheetCount: this._getResultResourceSummaryItem(networkItems, "stylesheet", "requestCount"),
300 | thirdParty: this._getResultResourceSummaryItem(result.audits['third-party-summary'].details?.items, false, "transferSize"),
301 | thirdPartyCount: this._getResultResourceSummaryItem(result.audits['third-party-summary'].details?.items, false, "requestCount"),
302 | },
303 | };
304 | }
305 |
306 | getMedianResultForUrl(url) {
307 | if(this.results[url] && this.results[url].length) {
308 | let goodResults = this.results[url].filter(entry => entry && !entry.error && entry.lighthouse.performance !== null);
309 |
310 | goodResults = goodResults.map((entry, j) => {
311 | entry.run = {
312 | number: j + 1,
313 | total: goodResults.length
314 | };
315 | return entry;
316 | })
317 |
318 | if(!goodResults.length) {
319 | // if they’re all errors just return the first
320 | return this.results[url][0];
321 | }
322 |
323 | return LighthouseMedianRun.computeMedianRun(goodResults, url);
324 | }
325 | }
326 |
327 | getLowestResultForUrl(url, sortFn) {
328 | if(this.results[url] && this.results[url].length) {
329 | let results = this.results[url].filter(entry => entry && !entry.error).sort(sortFn);
330 | return results.length ? results[0] : null;
331 | }
332 | }
333 |
334 | async getFinalSortedResults() {
335 | let perfResults = [];
336 | // let errorResults = [];
337 | for(let url in this.results) {
338 | let result = this.getMedianResultForUrl(url);
339 | // if(result.error) {
340 | // errorResults.push(result);
341 | // } else {
342 | perfResults.push(result);
343 | // }
344 | }
345 |
346 | let sortByHundosFn = this.sortByTotalHundos.bind(this);
347 | perfResults.sort(sortByHundosFn).map((entry, index) => {
348 | if(entry && entry.ranks) {
349 | entry.ranks.hundos = index + 1;
350 | }
351 | return entry;
352 | });
353 |
354 | // Side quests
355 | let sideQuestProperties = [
356 | "-weight.total",
357 | "+weight.total",
358 | "-weight.document",
359 | "+weight.document",
360 | "-weight.script",
361 | "+weight.script",
362 | "-weight.image",
363 | "+weight.image",
364 | "-weight.font",
365 | "+weight.font",
366 | "+weight.fontCount",
367 | "-timeToFirstByte",
368 | "-totalBlockingTime",
369 | "-largestContentfulPaint",
370 | ];
371 |
372 | for(let prop of sideQuestProperties) {
373 | let [order, key] = [prop.slice(0, 1), prop.slice(1)];
374 |
375 | let incrementRank = 1;
376 | let incrementRankValue;
377 |
378 | perfResults.sort((a, b) => {
379 | if(order === "-") { // ascending, lower is better
380 | return lodashGet(a, key) - lodashGet(b, key);
381 | } else { // order === "+", descending, higher is better
382 | return lodashGet(b, key) - lodashGet(a, key);
383 | }
384 | }).map((entry, index) => {
385 | if(!entry.sidequests) {
386 | entry.sidequests = {};
387 | }
388 |
389 | let value = lodashGet(entry, key);
390 | if(!incrementRankValue) {
391 | incrementRankValue = value;
392 | } else if(incrementRankValue !== value) {
393 | incrementRank++;
394 | }
395 |
396 | entry.sidequests[prop] = incrementRank;
397 | incrementRankValue = value;
398 |
399 | return entry;
400 | });
401 | }
402 |
403 | let sortByPerfFn = this.sortByPerformance.bind(this);
404 | perfResults.sort(sortByPerfFn).map((entry, index) => {
405 | if(entry && entry.ranks) {
406 | entry.ranks.performance = index + 1;
407 | }
408 | return entry;
409 | });
410 |
411 | // Insert accessibilityRank into perfResults
412 | let a11yResults = [];
413 | let axeTester = new AxeTester();
414 | axeTester.logDirectory = this.logDirectory;
415 | axeTester.writeLogs = this.writeLogs;
416 | axeTester.readFromLogs = this.readFromLogs;
417 | axeTester.puppeteerTimeout = this.axePuppeteerTimeout;
418 | axeTester.bypassAxe = this.bypassAxe;
419 |
420 | await axeTester.start();
421 |
422 | let count = 0;
423 | let size = Object.keys(this.results).length;
424 | for(let url in this.results) {
425 | let result = this.getLowestResultForUrl(url, this.sortByAccessibilityBeforeAxe.bind(this));
426 |
427 | if(result) {
428 | log(`Axe scan ${++count}/${size}: ${url}`);
429 | result.axe = await axeTester.getResults(url);
430 |
431 | a11yResults.push(result);
432 | }
433 | }
434 |
435 | await axeTester.finish();
436 |
437 | a11yResults.sort(this.sortByAccessibility.bind(this));
438 |
439 | let a11yRank = 1;
440 | for(let a11yResult of a11yResults) {
441 | for(let perfResult of perfResults) {
442 | if(perfResult.url === a11yResult.url) {
443 | // overwrite the original Accessibility Score
444 | // as the lowest a11y result of X runs may be different than the median performance result from X runs
445 | perfResult.lighthouse.accessibility = a11yResult.lighthouse.accessibility;
446 | perfResult.ranks.accessibility = a11yRank;
447 | perfResult.axe = a11yResult.axe;
448 | }
449 | }
450 |
451 | a11yRank++;
452 | }
453 |
454 | // Cumulative Score (must run after axe scores)
455 | let sortByCumulativeFn = this.sortByCumulativeScore.bind(this);
456 | perfResults.sort(sortByCumulativeFn).map((entry, index) => {
457 | if(entry && entry.ranks) {
458 | entry.ranks.cumulative = index + 1;
459 | }
460 | return entry;
461 | });
462 |
463 | return perfResults;
464 | }
465 | }
466 |
467 | module.exports = ResultLogger;
468 |
--------------------------------------------------------------------------------
/src/WriteLog.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const fsp = fs.promises;
3 | const path = require("path");
4 |
5 | async function writeLog(fileSlug, rawResult, logDirectory) {
6 | let date = new Date().toISOString().substr(0, 10);
7 | let dir = path.join(path.resolve("."), logDirectory, date);
8 | await fsp.mkdir(dir, {
9 | recursive: true
10 | });
11 |
12 | let filepath = path.join(dir, `${fileSlug}`);
13 | return fsp.writeFile(filepath, JSON.stringify(rawResult, null, 2));
14 | }
15 |
16 | module.exports = writeLog;
--------------------------------------------------------------------------------
/test/sample.js:
--------------------------------------------------------------------------------
1 | const PerfLeaderboard = require("../.");
2 |
3 | (async function() {
4 | let urls = [
5 | "https://www.tattooed.dev/",
6 | "https://www.11ty.dev/",
7 | // "https://www.gatsbyjs.com/",
8 | // "https://vuejs.org/",
9 | // "https://reactjs.org/",
10 | // "https://nextjs.org/",
11 | // "https://amp.dev/",
12 | // "https://jekyllrb.com/",
13 | // "https://nuxtjs.org/",
14 | // "https://gridsome.org/",
15 | // "https://svelte.dev/",
16 | // "https://gohugo.io/",
17 | // "https://redwoodjs.com/"
18 | // "https://www.netlify.com/",
19 | ];
20 |
21 | let finalResults = await PerfLeaderboard(urls, 1, {
22 | // beforeHook: function(url) {
23 | // console.log( "hi to ", url );
24 | // },
25 | // afterHook: function(result) {
26 | // console.log( result );
27 | // }
28 | });
29 | console.log( finalResults );
30 | })();
31 |
--------------------------------------------------------------------------------