├── .all-contributorsrc ├── .editorconfig ├── .gitignore ├── .npmrc ├── .prettierrc ├── .snyk ├── .travis.yml ├── LICENSE ├── README.md ├── __tests__ ├── budgets-analyzer.test.js ├── helpers.test.js ├── reporter.test.js └── score-analyzer.test.js ├── bin └── cli.js ├── codechecks-01.png ├── codechecks-02.png ├── demo ├── .dockerignore ├── .npmrc ├── Dockerfile ├── Makefile ├── index.html └── package.json ├── lib ├── budgets-analyzer.js ├── calculate-results.js ├── config.js ├── helpers.js ├── lighthouse-reporter.js └── score-analyzer.js ├── lighthouse-cli.gif ├── logo.png ├── package-lock.json ├── package.json └── screenshot-ui.png /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "lighthouse-ci", 3 | "projectOwner": "andreasonny83", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "contributors": [ 12 | { 13 | "login": "andreasonny83", 14 | "name": "Andrea Sonny", 15 | "avatar_url": "https://avatars0.githubusercontent.com/u/8806300?v=4", 16 | "profile": "https://about.me/andreasonny83", 17 | "contributions": [ 18 | "question", 19 | "code", 20 | "doc" 21 | ] 22 | }, 23 | { 24 | "login": "celsosantarosa", 25 | "name": "Celso Santa Rosa", 26 | "avatar_url": "https://avatars1.githubusercontent.com/u/1007970?v=4", 27 | "profile": "https://snap-ci.com", 28 | "contributions": [ 29 | "code" 30 | ] 31 | }, 32 | { 33 | "login": "BenAHammond", 34 | "name": "Ben Hammond", 35 | "avatar_url": "https://avatars3.githubusercontent.com/u/3516389?v=4", 36 | "profile": "https://github.com/BenAHammond", 37 | "contributions": [ 38 | "bug", 39 | "code" 40 | ] 41 | }, 42 | { 43 | "login": "alexecus", 44 | "name": "Alex Tenepere", 45 | "avatar_url": "https://avatars1.githubusercontent.com/u/12739106?v=4", 46 | "profile": "https://github.com/alexecus", 47 | "contributions": [ 48 | "bug", 49 | "code" 50 | ] 51 | }, 52 | { 53 | "login": "ikigeg", 54 | "name": "Michael Griffiths", 55 | "avatar_url": "https://avatars0.githubusercontent.com/u/8846301?v=4", 56 | "profile": "https://ikigeg.com", 57 | "contributions": [ 58 | "code" 59 | ] 60 | }, 61 | { 62 | "login": "cmarkwell", 63 | "name": "Connor Markwell", 64 | "avatar_url": "https://avatars0.githubusercontent.com/u/23330646?v=4", 65 | "profile": "https://github.com/cmarkwell", 66 | "contributions": [ 67 | "code" 68 | ] 69 | }, 70 | { 71 | "login": "Juuro", 72 | "name": "Sebastian Engel", 73 | "avatar_url": "https://avatars2.githubusercontent.com/u/559017?v=4", 74 | "profile": "https://github.com/Juuro", 75 | "contributions": [ 76 | "bug", 77 | "code" 78 | ] 79 | }, 80 | { 81 | "login": "asmagin", 82 | "name": "Alex Smagin", 83 | "avatar_url": "https://avatars3.githubusercontent.com/u/1803342?v=4", 84 | "profile": "https://asmagin.com/", 85 | "contributions": [ 86 | "code", 87 | "ideas" 88 | ] 89 | }, 90 | { 91 | "login": "marcschaller", 92 | "name": "Marc Schaller", 93 | "avatar_url": "https://avatars2.githubusercontent.com/u/31402947?v=4", 94 | "profile": "https://github.com/marcschaller", 95 | "contributions": [ 96 | "bug", 97 | "code" 98 | ] 99 | }, 100 | { 101 | "login": "Remi-p", 102 | "name": "Rémi Perrot", 103 | "avatar_url": "https://avatars3.githubusercontent.com/u/6367611?v=4", 104 | "profile": "https://github.com/Remi-p", 105 | "contributions": [ 106 | "bug", 107 | "code" 108 | ] 109 | } 110 | ], 111 | "commitConvention": "none" 112 | } 113 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [Makefile] 12 | indent_style = tab 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # Lighthouse 21 | /Lighthouse 22 | /demo/Lighthouse 23 | budget.json 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # Optional npm cache directory 48 | .npm 49 | 50 | # Optional eslint cache 51 | .eslintcache 52 | 53 | # Optional REPL history 54 | .node_repl_history 55 | 56 | # Output of 'npm pack' 57 | *.tgz 58 | 59 | # Yarn Integrity file 60 | .yarn-integrity 61 | 62 | # dotenv environment variables file 63 | .env 64 | 65 | # next.js build output 66 | .next 67 | 68 | # vuepress build output 69 | .vuepress/dist 70 | 71 | # Serverless directories 72 | .serverless 73 | 74 | # VSCode 75 | .vscode/ 76 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "semi": true, 4 | "singleQuote": true, 5 | "printWidth": 120, 6 | "useTabs": false, 7 | "tabWidth": 2, 8 | "bracketSpacing": true 9 | } 10 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.16.0 3 | ignore: {} 4 | # patches apply the minimum changes required to fix a vulnerability 5 | patch: 6 | SNYK-JS-LODASH-567746: 7 | - lighthouse > inquirer > lodash: 8 | patched: '2020-07-02T08:17:50.395Z' 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '10.16' 4 | before_script: 5 | - export DISPLAY=:99.0 6 | - export CHROME_PATH="$(pwd)/chrome-linux/chrome" 7 | services: 8 | - xvfb 9 | addons: 10 | chrome: stable 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 AndreaSonny (https://github.com/andreasonny83) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lighthouse CI 2 | [![Build Status](https://travis-ci.com/andreasonny83/lighthouse-ci.svg?branch=main)](https://travis-ci.com/andreasonny83/lighthouse-ci) 3 | [![All Contributors](https://img.shields.io/badge/all_contributors-8-orange.svg?style=flat-square)](#contributors) 4 | [![npm version](https://badge.fury.io/js/lighthouse-ci.svg)](https://badge.fury.io/js/lighthouse-ci) 5 | [![npm](https://img.shields.io/npm/dt/lighthouse-ci.svg)](https://www.npmjs.com/package/lighthouse-ci) 6 | [![Known Vulnerabilities](https://snyk.io/test/github/andreasonny83/lighthouse-ci/badge.svg?targetFile=package.json)](https://snyk.io/test/github/andreasonny83/lighthouse-ci?targetFile=package.json) 7 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) 8 | [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/xojs/xo) 9 | 10 | > A useful wrapper around Google Lighthouse CLI 11 | 12 | ## NOTE 13 | 14 | #### Node v12 is now the minimum required version starting from Lighthouse CI v.1.13.0 15 | 16 | 17 | Lighthouse CI logo 18 | 19 | 20 | 21 | ## Install 22 | 23 | ``` 24 | $ npm install -g lighthouse-ci 25 | ``` 26 | 27 | ## Table of Contents 28 | 29 | - [Lighthouse CI](#lighthouse-ci) 30 | - [NOTE](#note) 31 | - [Node v12 is now the minimum required version starting from Lighthouse CI v.1.13.0](#node-v12-is-now-the-minimum-required-version-starting-from-lighthouse-ci-v1130) 32 | - [Install](#install) 33 | - [Table of Contents](#table-of-contents) 34 | - [Usage](#usage) 35 | - [CLI](#cli) 36 | - [Lighthouse flags](#lighthouse-flags) 37 | - [Chrome flags](#chrome-flags) 38 | - [Configuration](#configuration) 39 | - [Budgets](#budgets) 40 | - [Option 1.](#option-1) 41 | - [Option 2.](#option-2) 42 | - [Option 3.](#option-3) 43 | - [Performance Budget](#performance-budget) 44 | - [Timing Budget](#timing-budget) 45 | - [Codechecks](#codechecks) 46 | - [Demo App](#demo-app) 47 | - [How to](#how-to) 48 | - [Test a page that requires authentication](#test-a-page-that-requires-authentication) 49 | - [Wait for post-load JavaScript to execute before ending a trace](#wait-for-post-load-javascript-to-execute-before-ending-a-trace) 50 | - [Contributors](#contributors) 51 | - [License](#license) 52 | 53 | ## Usage 54 | 55 | ```sh 56 | lighthouse-ci --help 57 | ``` 58 | 59 | ## CLI 60 | 61 | ``` 62 | $ lighthouse-ci --help 63 | 64 | Usage 65 | $ lighthouse-ci 66 | 67 | Example 68 | $ lighthouse-ci https://example.com 69 | $ lighthouse-ci https://example.com -s 70 | $ lighthouse-ci https://example.com --score=75 71 | $ lighthouse-ci https://example.com --accessibility=90 --seo=80 72 | $ lighthouse-ci https://example.com --accessibility=90 --seo=80 --report=folder 73 | $ lighthouse-ci https://example.com --report=folder --config-path=configs.json 74 | 75 | Options 76 | -s, --silent Run Lighthouse without printing report log 77 | --report= Generate an HTML report inside a specified folder 78 | --filename= Specify the name of the generated HTML report file (requires --report) 79 | -json, --jsonReport Generate JSON report in addition to HTML (requires --report) 80 | --config-path The path to the Lighthouse config JSON (read more here: https://github.com/GoogleChrome/lighthouse/blob/master/docs/configuration.md) 81 | --budget-path The path to the Lighthouse budgets config JSON (read more here: https://developers.google.com/web/tools/lighthouse/audits/budgets) 82 | --score= Specify a score threshold for the CI to pass 83 | --performance= Specify a minimal performance score for the CI to pass 84 | --pwa= Specify a minimal pwa score for the CI to pass 85 | --accessibility= Specify a minimal accessibility score for the CI to pass 86 | --best-practice= [DEPRECATED] Use best-practices instead 87 | --best-practices= Specify a minimal best-practice score for the CI to pass 88 | --seo= Specify a minimal seo score for the CI to pass 89 | --fail-on-budgets Specify CI should fail if budgets are exceeded 90 | --budget.. Specify individual budget threshold (if --budget-path not set) 91 | 92 | In addition to listed "lighthouse-ci" configuration flags, it is also possible to pass any native "lighthouse" flag 93 | To see the full list of available flags, please refer to the official Google Lighthouse documentation at https://github.com/GoogleChrome/lighthouse#cli-options 94 | ``` 95 | 96 | ## Lighthouse flags 97 | 98 | In addition to listed `lighthouse-ci` configuration flags, it is also possible to pass any native `lighthouse` flags. 99 | 100 | To see the full list of available flags, please refer to the official [Google Lighthouse documentation](https://github.com/GoogleChrome/lighthouse#cli-options). 101 | 102 | eg. 103 | 104 | ```sh 105 | # Launches browser, collects artifacts, saves them to disk (in `./test-report/`) and quits 106 | $ lighthouse-ci --gather-mode=test-report https://my.website.com 107 | # skips browser interaction, loads artifacts from disk (in `./test-report/`), runs audits on them, generates report 108 | $ lighthouse-ci --audit-mode=test-report https://my.website.com 109 | ``` 110 | 111 | ### Chrome flags 112 | 113 | In addition of the lighthouse flags, you can also specify extra chrome flags 114 | comma separated. 115 | 116 | eg. 117 | 118 | ```sh 119 | $ lighthouse-ci --chrome-flags=--cellular-only,--force-ui-direction=rtl https://my.website.com 120 | ``` 121 | 122 | eg. 123 | 124 | ```sh 125 | $ lighthouse-ci --emulated-form-factor desktop --seo 92 https://my.website.com 126 | ``` 127 | 128 | ## Configuration 129 | 130 | Lighthouse CI allows you to pass a custom Lighthouse configuration file. 131 | Read [Lighthouse Configuration](https://github.com/GoogleChrome/lighthouse/blob/master/docs/configuration.md) 132 | to learn more about the configuration options available. 133 | 134 | Just generate your configuration file. For example this `config.json` 135 | 136 | ```json 137 | { 138 | "extends": "lighthouse:default", 139 | "audits": [ 140 | "user-timings", 141 | "critical-request-chains" 142 | ], 143 | 144 | "categories": { 145 | "performance": { 146 | "name": "Performance Metrics", 147 | "description": "Sample description", 148 | "audits": [ 149 | {"id": "user-timings", "weight": 1}, 150 | {"id": "critical-request-chains", "weight": 1} 151 | ] 152 | } 153 | } 154 | } 155 | ``` 156 | 157 | Then run Lighthouse CI with the `--config-path` flag 158 | 159 | ```sh 160 | $ lighthouse-ci https://example.com --report=reports --config-path=config.json 161 | ``` 162 | 163 | The generated report inside `reports` folder will follow the custom configuration listed under the `config.json` file. 164 | 165 | ## Budgets 166 | 167 | Lighthouse CI allows you to pass a budget configuration file (see [Lighthouse Budgets](https://developers.google.com/web/tools/lighthouse/audits/budgets)). 168 | There are several options to pass a budget config: 169 | 170 | #### Option 1. 171 | Add configurations to your `config.json` file like and use instructions above. 172 | ``` json 173 | { 174 | "extends": "lighthouse:default", 175 | "settings": { 176 | "budgets": [ 177 | { 178 | "resourceCounts": [ 179 | { "resourceType": "total", "budget": 10 }, 180 | ], 181 | "resourceSizes": [ 182 | { "resourceType": "total", "budget": 100 }, 183 | ] 184 | } 185 | ] 186 | } 187 | } 188 | ``` 189 | #### Option 2. 190 | Generate `budget.json` with content like: 191 | ``` json 192 | [ 193 | { 194 | "resourceCounts": [ 195 | { "resourceType": "total", "budget": 10 }, 196 | ], 197 | "resourceSizes": [ 198 | { "resourceType": "total", "budget": 100 }, 199 | ] 200 | } 201 | ] 202 | ``` 203 | 204 | Then run Lighthouse CI with the `--budget-path` flag 205 | 206 | ```sh 207 | $ lighthouse-ci https://example.com --report=reports --budget-path=budget.json 208 | ``` 209 | 210 | #### Option 3. 211 | Pass individual parameters via CLI 212 | 213 | ```sh 214 | $ lighthouse-ci https://example.com --report=reports --budget.counts.total=20 --budget.sizes.fonts=100000 215 | ``` 216 | 217 | ### Performance Budget 218 | 219 | Performance budgets can be specified inside your [budget configuration file)(#budgets). 220 | 221 | You can specify any available [performance budget](https://github.com/GoogleChrome/lighthouse/blob/master/docs/performance-budgets.md#budgetjson) like in the following example 222 | 223 | ```json 224 | [ 225 | { 226 | "path": "/*", 227 | "resourceSizes": [ 228 | { 229 | "resourceType": "script", 230 | "budget": 400000 231 | }, 232 | { 233 | "resourceType": "total", 234 | "budget": 5050 235 | } 236 | ], 237 | "resourceCounts": [ 238 | { 239 | "resourceType": "total", 240 | "budget": 95 241 | }, 242 | { 243 | "resourceType": "third-party", 244 | "budget": 55 245 | } 246 | ] 247 | } 248 | ] 249 | ``` 250 | 251 | ### Timing Budget 252 | 253 | Timing budgets can be specified inside your [budget configuration file)(#budgets). 254 | 255 | You can specify any available [timing budget](https://github.com/GoogleChrome/lighthouse/blob/master/docs/performance-budgets.md#timing-budgets) like in the following example 256 | 257 | ```json 258 | [ 259 | { 260 | "path": "/*", 261 | "timings": [ 262 | { 263 | "metric": "interactive", 264 | "budget": 100 265 | }, 266 | { 267 | "metric": "first-meaningful-paint", 268 | "budget": 100 269 | } 270 | ] 271 | } 272 | ] 273 | ``` 274 | 275 | ## Codechecks 276 | 277 | You can now easily integrate Lighthouse-CI as part of your automated CI with [codechecks.io](https://codechecks.io/). 278 | 279 | 280 | 281 | **Running Lighthouse-CI with Codechecks** 282 | 283 | ```sh 284 | $ npm install --save-dev @codechecks/client @codechecks/lighthouse-keeper 285 | ``` 286 | 287 | Now, create a `codechecks.yml` (json is supported as well) file required for codechecks to automatically run against your project. 288 | 289 | `codechecks.yml:` 290 | 291 | ```yml 292 | checks: 293 | - name: lighthouse-keeper 294 | options: 295 | # just provide path to your build 296 | buildPath: ./build 297 | # or full url 298 | # url: https://google.com 299 | # ... 300 | ``` 301 | 302 | Read more from the official documentation from [https://github.com/codechecks/lighthouse-keeper](https://github.com/codechecks/lighthouse-keeper). 303 | 304 | Read more about Codechecks on the [official project website](https://codechecks.io/) 305 | 306 | ## Demo App 307 | 308 | This project contains a demo folder where a project as been created for demo purposes only. 309 | Once inside the `demo` folder, if you have Docker installed on your machine, you can simply launch the demo app inside a Docker container with `make demo`. 310 | 311 | If you just want to run the demo locally, make sure to install the node dependencies first with `npm install`, 312 | then run the demo with: 313 | 314 | ``` 315 | $ npm start 316 | ``` 317 | 318 | ## How to 319 | 320 | ### Test a page that requires authentication 321 | 322 | By default `lighthouse-cli` is just creating the report against a specific URL without letting the engineer to interact with the browser. 323 | Sometimes, however, the page for which you want to generate the report, requires the user to be authenticated. 324 | Depending on the authentication mechanism, you can inject extra header information into the page. 325 | 326 | ```sh 327 | lighthouse-ci https://example.com --extra-headers=./extra-headers.js 328 | ``` 329 | 330 | Where `extra-headers.json` contains: 331 | 332 | ```js 333 | module.exports = { 334 | Authorization: 'Bearer MyAccessToken', 335 | Cookie: "user=MySecretCookie;" 336 | }; 337 | ``` 338 | 339 | ### Wait for post-load JavaScript to execute before ending a trace 340 | 341 | Your website might require extra time to load and execute all the JavaScript logic. 342 | It is possible to let LightHouse wait for a certain amount of time, before ending a trace, 343 | by providing a `pauseAfterLoadMs` value to a custom configuration file. 344 | 345 | eg. 346 | 347 | ```sh 348 | lighthouse-ci https://example.com --config-path ./config.json 349 | ``` 350 | 351 | Where `config.json` contains: 352 | 353 | ```json 354 | { 355 | "extends": "lighthouse:default", 356 | "passes": [{ 357 | "recordTrace": true, 358 | "pauseAfterLoadMs": 5000, 359 | "networkQuietThresholdMs": 5000 360 | }] 361 | } 362 | ``` 363 | 364 | ## Contributors 365 | 366 | Thanks goes to these wonderful people ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)): 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 |

Andrea Sonny

💬 💻 📖

Celso Santa Rosa

💻

Ben Hammond

🐛 💻

Alex Tenepere

🐛 💻

Michael Griffiths

💻

Connor Markwell

💻

Sebastian Engel

🐛 💻

Alex Smagin

💻 🤔

Marc Schaller

🐛 💻

Rémi Perrot

🐛 💻
387 | 388 | 389 | 390 | 391 | 392 | This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome! 393 | 394 | ## License 395 | 396 | MIT 397 | 398 | --- 399 | 400 | Created with 🦄 by [andreasonny83](https://about.me/andreasonny83) 401 | -------------------------------------------------------------------------------- /__tests__/budgets-analyzer.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2018-2021 AndreaSonny (https://github.com/andreasonny83) 3 | * 4 | * This software is released under the MIT License. 5 | * https://opensource.org/licenses/MIT 6 | */ 7 | 8 | const analyzeBudgets = require('../lib/budgets-analyzer'); 9 | 10 | describe('budgets-analyzer', () => { 11 | const processExit = process.exit; 12 | 13 | beforeEach(() => { 14 | process.exit = jest.fn(); 15 | }); 16 | 17 | afterEach(() => { 18 | process.exit = processExit; 19 | }); 20 | 21 | it('should return `false` if any over-budget reported and `--fail-on-budgets` set to `true`', () => { 22 | const mockBudgetsReport = { 23 | 'total-count': '323 requests', 24 | 'total-size': '12677kb', 25 | }; 26 | 27 | const result = analyzeBudgets(mockBudgetsReport, true); 28 | 29 | expect(result).toEqual(false); 30 | }); 31 | 32 | it('should return `true` if no over-budget reported and `--fail-on-budgets` set to `true`', () => { 33 | const mockBudgetsReport = {}; 34 | 35 | const result = analyzeBudgets(mockBudgetsReport, true); 36 | 37 | expect(result).toEqual(true); 38 | }); 39 | 40 | it('should return `true` if any over-budget reported and `--fail-on-budgets` set to `false`', () => { 41 | const mockBudgetsReport = { 42 | 'total-count': '323 requests', 43 | 'total-size': '12677kb', 44 | }; 45 | 46 | const result = analyzeBudgets(mockBudgetsReport, true); 47 | 48 | expect(result).toEqual(false); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /__tests__/helpers.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2018-2021 AndreaSonny (https://github.com/andreasonny83) 3 | * 4 | * This software is released under the MIT License. 5 | * https://opensource.org/licenses/MIT 6 | */ 7 | 8 | const mkdirp = require('mkdirp'); 9 | const rimraf = require('rimraf'); 10 | const { 11 | clean, 12 | createDir, 13 | scoreReducer, 14 | createDefaultConfig, 15 | getOwnProps, 16 | convertToBudgetList, 17 | convertToResourceKey, 18 | } = require('../lib/helpers'); 19 | 20 | jest.mock('rimraf'); 21 | jest.mock('mkdirp'); 22 | 23 | describe('helpers', () => { 24 | beforeEach(() => { 25 | jest.resetAllMocks(); 26 | }); 27 | 28 | describe('clean', () => { 29 | it('should return a promise', () => { 30 | // Arrange 31 | const response = clean(); 32 | 33 | // Assert 34 | expect(response).toBeInstanceOf(Promise); 35 | }); 36 | 37 | it('should reject the promise if an error if present', () => { 38 | // Arrange 39 | const expectedError = 'err'; 40 | rimraf.mockImplementation((target, callback) => callback(expectedError)); 41 | 42 | // Act 43 | const response = clean(); 44 | 45 | // Assert 46 | expect(response).rejects.toEqual(expectedError); 47 | }); 48 | 49 | it('should try to remove a "lighthouse" folder', () => { 50 | // Arrange 51 | const expectedFolderName = './lighthouse/'; 52 | rimraf.mockImplementation((target, callback) => { 53 | expect(target).toEqual(expectedFolderName); 54 | callback(); 55 | }); 56 | 57 | // Act 58 | const response = clean(); 59 | 60 | // Assert 61 | expect(response).resolves.toEqual(); 62 | expect(rimraf).toHaveBeenCalledWith(expectedFolderName, expect.any(Function)); 63 | }); 64 | }); 65 | 66 | describe('createDir', () => { 67 | it('should return a promise', () => { 68 | // Arrange 69 | const response = createDir(); 70 | 71 | // Assert 72 | expect(response).toBeInstanceOf(Promise); 73 | }); 74 | 75 | it('should reject the promise if an error if present', () => { 76 | // Arrange 77 | const expectedError = 'err'; 78 | mkdirp.mockImplementation((target, callback) => callback(expectedError)); 79 | 80 | // Act 81 | const response = createDir(); 82 | 83 | // Assert 84 | expect(response).rejects.toEqual(expectedError); 85 | }); 86 | 87 | it('should try to create a "lighthouse" folder', () => { 88 | // Arrange 89 | const expectedFolderName = './lighthouse'; 90 | mkdirp.mockImplementation((target, callback) => { 91 | expect(target).toEqual(expectedFolderName); 92 | callback(); 93 | }); 94 | 95 | // Act 96 | const response = createDir(); 97 | 98 | // Assert 99 | expect(response).resolves.toEqual(); 100 | expect(mkdirp).toHaveBeenCalledWith(expectedFolderName, expect.any(Function)); 101 | }); 102 | }); 103 | 104 | describe('scoreReducer', () => { 105 | it('should return a score if present in the flags', () => { 106 | // Arrange 107 | const expectedScore = 'testScore'; 108 | const mockFlags = { score: expectedScore }; 109 | 110 | // Act 111 | const response = scoreReducer(mockFlags); 112 | 113 | // Assert 114 | expect(response).toEqual(expectedScore); 115 | }); 116 | 117 | it('should return an object of only known flags', () => { 118 | // Arrange 119 | const mockFlags = { 120 | performance: '10', 121 | accessibility: '90', 122 | test: '10', 123 | }; 124 | const mockScores = ['performance', 'pwa', 'accessibility']; 125 | const expectedResponse = { accessibility: '90', performance: '10' }; 126 | 127 | // Act 128 | const response = scoreReducer(mockFlags, mockScores); 129 | 130 | // Assert 131 | expect(response).toEqual(expectedResponse); 132 | }); 133 | }); 134 | 135 | describe('getOwnProps', () => { 136 | it('should return all own props', () => { 137 | // Arrange 138 | const expectedProp = 'foo'; 139 | const mockObject = { foo: 0 }; 140 | 141 | // Act 142 | const response = getOwnProps(mockObject); 143 | 144 | // Assert 145 | expect(response).not.toBeNull(); 146 | expect(response).toContain(expectedProp); 147 | expect(response).toHaveLength(1); 148 | }); 149 | 150 | it('should not return inherited props', () => { 151 | // Arrange 152 | const expectedProp = 'foo'; 153 | 154 | const Func = function () { 155 | this.foo = 1; 156 | }; 157 | 158 | const input = new Func(); 159 | 160 | Func.prototype.bar = 2; 161 | 162 | // Act 163 | const response = getOwnProps(input); 164 | 165 | // Assert 166 | expect(response).not.toBeNull(); 167 | expect(response).toContain(expectedProp); 168 | expect(response).toHaveLength(1); 169 | }); 170 | }); 171 | 172 | describe('createDefaultConfig', () => { 173 | it('should not override existing config', () => { 174 | // Arrange 175 | const expectedConfig = { foo: 1, bar: 2 }; 176 | 177 | // Act 178 | const response = createDefaultConfig(expectedConfig); 179 | 180 | // Assert 181 | expect(response).toEqual(expectedConfig); 182 | }); 183 | 184 | it('should set `extends` and `settings`', () => { 185 | // Arrange 186 | const expectedExtends = 'lighthouse:default'; 187 | 188 | // Act 189 | const response = createDefaultConfig(); 190 | 191 | // Assert 192 | expect(response.extends).toEqual(expectedExtends); 193 | expect(response.settings).not.toBeNull(); 194 | }); 195 | }); 196 | 197 | describe('convertToBudgetList', () => { 198 | it('Should return list of objects in specified format', () => { 199 | // Arrange 200 | const expected = [ 201 | { 202 | resourceType: 'script', 203 | budget: 1, 204 | }, 205 | { 206 | resourceType: 'total', 207 | budget: 2, 208 | }, 209 | ]; 210 | 211 | const input = { script: 1, total: 2 }; 212 | 213 | // Act 214 | const response = convertToBudgetList(input); 215 | 216 | // Assert 217 | expect(response).toHaveLength(2); 218 | expect(response).toEqual(expected); 219 | }); 220 | }); 221 | 222 | describe('convertToResourceKey', () => { 223 | it('Should convert `sizes` to `resourceSizes`', () => { 224 | // Arrange 225 | const expected = 'resourceSizes'; 226 | 227 | const input = 'sizes'; 228 | 229 | // Act 230 | const response = convertToResourceKey(input); 231 | 232 | // Assert 233 | expect(response).toEqual(expected); 234 | }); 235 | }); 236 | }); 237 | -------------------------------------------------------------------------------- /__tests__/reporter.test.js: -------------------------------------------------------------------------------- 1 | const writeReport = require('../lib/lighthouse-reporter'); 2 | 3 | describe('Reporter', () => { 4 | jest.setTimeout(20000); // Allows more time to run all tests 5 | 6 | it('should launch Chrome and generate a report', async () => { 7 | const result = await writeReport('http://example.com/'); 8 | expect(result).toEqual( 9 | expect.objectContaining({ 10 | categoryReport: { 11 | performance: expect.any(Number), 12 | accessibility: expect.any(Number), 13 | 'best-practices': expect.any(Number), 14 | seo: expect.any(Number), 15 | pwa: expect.any(Number), 16 | }, 17 | budgetsReport: expect.any(Object), 18 | htmlReport: expect.any(Object), 19 | jsonReport: expect.any(Object), 20 | }), 21 | ); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /__tests__/score-analyzer.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2018-2021 AndreaSonny (https://github.com/andreasonny83) 3 | * 4 | * This software is released under the MIT License. 5 | * https://opensource.org/licenses/MIT 6 | */ 7 | 8 | const analyzeScore = require('../lib/score-analyzer'); 9 | 10 | describe('score-analyzer', () => { 11 | const processExit = process.exit; 12 | 13 | beforeEach(() => { 14 | process.exit = jest.fn(); 15 | }); 16 | 17 | afterEach(() => { 18 | process.exit = processExit; 19 | }); 20 | 21 | it('a threshold must be specified', () => { 22 | const mockCategoryReport = { 23 | performance: 0.07, 24 | pwa: 0.36, 25 | accessibility: 0.57, 26 | 'best-practices': 0.69, 27 | seo: 0.73, 28 | }; 29 | 30 | expect(() => analyzeScore(mockCategoryReport)).toThrowError('Invalid threshold score.'); 31 | }); 32 | 33 | it('should return `false` if one or more category index is below the threshold', () => { 34 | const threshold = 75; 35 | const mockCategoryReport = { 36 | performance: 7, 37 | pwa: 36, 38 | accessibility: 57, 39 | 'best-practices': 69, 40 | seo: 73, 41 | }; 42 | 43 | const result = analyzeScore(mockCategoryReport, threshold); 44 | 45 | expect(result).toEqual(false); 46 | }); 47 | 48 | it('should return `true` if all the category indexes are above the threshold', () => { 49 | const threshold = 70; 50 | const mockCategoryReport = { 51 | performance: 70, 52 | pwa: 90, 53 | accessibility: 87, 54 | 'best-practices': 79, 55 | seo: 73, 56 | }; 57 | 58 | const result = analyzeScore(mockCategoryReport, threshold); 59 | 60 | expect(result).toEqual(true); 61 | }); 62 | 63 | describe('category thresholds', () => { 64 | const mockCategoryReport = { 65 | performance: 70, 66 | pwa: 90, 67 | accessibility: 10, 68 | 'best-practices': 10, 69 | seo: 10, 70 | }; 71 | 72 | it('should return `true` if the target categories indexes are above the thresholds', () => { 73 | const threshold = { 74 | performance: 70, 75 | pwa: 80, 76 | }; 77 | 78 | const result = analyzeScore(mockCategoryReport, threshold); 79 | 80 | expect(result).toEqual(true); 81 | }); 82 | 83 | it('should pass if the target categories indexes are above the "best-practices" thresholds', () => { 84 | const threshold = { 85 | 'best-practices': 10, 86 | }; 87 | 88 | const result = analyzeScore(mockCategoryReport, threshold); 89 | 90 | expect(result).toEqual(true); 91 | }); 92 | 93 | it('should pass if the target categories indexes are above the "seo" thresholds', () => { 94 | const threshold = { 95 | seo: 10, 96 | }; 97 | 98 | const result = analyzeScore(mockCategoryReport, threshold); 99 | 100 | expect(result).toEqual(true); 101 | }); 102 | 103 | it('should pass if the target categories indexes are above the "performance" thresholds', () => { 104 | const threshold = { 105 | performance: 70, 106 | }; 107 | 108 | const result = analyzeScore(mockCategoryReport, threshold); 109 | 110 | expect(result).toEqual(true); 111 | }); 112 | 113 | it('should pass if the target categories indexes are above the "pwa" thresholds', () => { 114 | const threshold = { 115 | pwa: 90, 116 | }; 117 | 118 | const result = analyzeScore(mockCategoryReport, threshold); 119 | 120 | expect(result).toEqual(true); 121 | }); 122 | 123 | it('should pass if the target categories indexes are above the "accessibility" thresholds', () => { 124 | const threshold = { 125 | accessibility: 10, 126 | }; 127 | 128 | const result = analyzeScore(mockCategoryReport, threshold); 129 | 130 | expect(result).toEqual(true); 131 | }); 132 | 133 | it('should fail if the target categories indexes are above the "best-practices" thresholds', () => { 134 | const threshold = { 135 | 'best-practices': 11, 136 | }; 137 | 138 | const result = analyzeScore(mockCategoryReport, threshold); 139 | 140 | expect(result).toEqual(false); 141 | }); 142 | 143 | it('should fail if the target categories indexes are above the "seo" thresholds', () => { 144 | const threshold = { 145 | seo: 11, 146 | }; 147 | 148 | const result = analyzeScore(mockCategoryReport, threshold); 149 | 150 | expect(result).toEqual(false); 151 | }); 152 | 153 | it('should fail if the target categories indexes are above the "performance" thresholds', () => { 154 | const threshold = { 155 | performance: 71, 156 | }; 157 | 158 | const result = analyzeScore(mockCategoryReport, threshold); 159 | 160 | expect(result).toEqual(false); 161 | }); 162 | 163 | it('should fail if the target categories indexes are above the "pwa" thresholds', () => { 164 | const threshold = { 165 | pwa: 91, 166 | }; 167 | 168 | const result = analyzeScore(mockCategoryReport, threshold); 169 | 170 | expect(result).toEqual(false); 171 | }); 172 | 173 | it('should fail if the target categories indexes are above the "accessibility" thresholds', () => { 174 | const threshold = { 175 | accessibility: 11, 176 | }; 177 | 178 | const result = analyzeScore(mockCategoryReport, threshold); 179 | 180 | expect(result).toEqual(false); 181 | }); 182 | }); 183 | 184 | it('should return `false` if one or more target category index is below the thresholds', () => { 185 | const threshold = { 186 | performance: 70, 187 | pwa: 80, 188 | }; 189 | const mockCategoryReport = { 190 | performance: 70, 191 | pwa: 79, 192 | accessibility: 10, 193 | 'best-practices': 10, 194 | seo: 10, 195 | }; 196 | 197 | const result = analyzeScore(mockCategoryReport, threshold); 198 | 199 | expect(result).toEqual(false); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Copyright (c) 2018-2021 AndreaSonny (https://github.com/andreasonny83) 5 | * 6 | * This software is released under the MIT License. 7 | * https://opensource.org/licenses/MIT 8 | */ 9 | 10 | const fs = require('fs'); 11 | const path = require('path'); 12 | const meow = require('meow'); 13 | const ora = require('ora'); 14 | const chalk = require('chalk'); 15 | const updateNotifier = require('update-notifier'); 16 | const pkg = require('../package.json'); 17 | 18 | const { getChromeFlags } = require('../lib/config'); 19 | const lighthouseReporter = require('../lib/lighthouse-reporter'); 20 | const { calculateResults } = require('../lib/calculate-results'); 21 | 22 | const spinner = ora({ 23 | color: 'yellow', 24 | }); 25 | 26 | const cli = meow( 27 | ` 28 | Usage 29 | $ lighthouse-ci 30 | 31 | Example 32 | $ lighthouse-ci https://example.com 33 | $ lighthouse-ci https://example.com -s 34 | $ lighthouse-ci https://example.com --score=75 35 | $ lighthouse-ci https://example.com --accessibility=90 --seo=80 36 | $ lighthouse-ci https://example.com --accessibility=90 --seo=80 --report=folder 37 | $ lighthouse-ci https://example.com -report=folder --config-path=configs.json 38 | 39 | Options 40 | -s, --silent Run Lighthouse without printing report log 41 | --report= Generate an HTML report inside a specified folder 42 | --filename= Specify the name of the generated HTML report file (requires --report) 43 | -json, --jsonReport Generate JSON report in addition to HTML (requires --report) 44 | --config-path The path to the Lighthouse config JSON (read more here: https://github.com/GoogleChrome/lighthouse/blob/master/docs/configuration.md) 45 | --budget-path The path to the Lighthouse budgets config JSON (read more here: https://developers.google.com/web/tools/lighthouse/audits/budgets) 46 | --score= Specify a score threshold for the CI to pass 47 | --performance= Specify a minimal performance score for the CI to pass 48 | --pwa= Specify a minimal pwa score for the CI to pass 49 | --accessibility= Specify a minimal accessibility score for the CI to pass 50 | --best-practice= [DEPRECATED] Use best-practices instead 51 | --best-practices= Specify a minimal best-practice score for the CI to pass 52 | --seo= Specify a minimal seo score for the CI to pass 53 | --fail-on-budgets Specify CI should fail if budgets are exceeded 54 | --budget.. Specify individual budget threshold (if --budget-path not set) 55 | 56 | In addition to listed "lighthouse-ci" configuration flags, it is also possible to pass any native "lighthouse" flag 57 | To see the full list of available flags, please refer to the official Google Lighthouse documentation at https://github.com/GoogleChrome/lighthouse#cli-options 58 | `, 59 | { 60 | flags: { 61 | report: { 62 | type: 'string', 63 | }, 64 | filename: { 65 | type: 'string', 66 | alias: 'f', 67 | default: 'report.html', 68 | }, 69 | jsonReport: { 70 | type: 'boolean', 71 | alias: 'json', 72 | default: false, 73 | }, 74 | silent: { 75 | type: 'boolean', 76 | alias: 's', 77 | default: false, 78 | }, 79 | score: { 80 | type: 'string', 81 | }, 82 | performance: { 83 | type: 'string', 84 | }, 85 | pwa: { 86 | type: 'string', 87 | }, 88 | accessibility: { 89 | type: 'string', 90 | }, 91 | bestPractice: { 92 | type: 'string', 93 | }, 94 | bestPractices: { 95 | type: 'string', 96 | }, 97 | seo: { 98 | type: 'string', 99 | }, 100 | failOnBudgets: { 101 | type: 'boolean', 102 | default: false, 103 | }, 104 | budget: { 105 | type: 'string', 106 | }, 107 | }, 108 | }, 109 | ); 110 | 111 | const { 112 | report, 113 | filename, 114 | json, 115 | jsonReport, 116 | silent, 117 | s, 118 | score, 119 | performance, 120 | pwa, 121 | accessibility, 122 | bestPractice, 123 | bestPractices, 124 | seo, 125 | failOnBudgets, 126 | ...lighthouseFlags 127 | } = cli.flags; 128 | 129 | const calculatedBestPractices = bestPractice || bestPractices; 130 | const flags = { 131 | report, 132 | filename, 133 | jsonReport: json || jsonReport, 134 | silent, 135 | s, 136 | score, 137 | performance, 138 | pwa, 139 | accessibility, 140 | ...(calculatedBestPractices && { 141 | 'best-practices': calculatedBestPractices, 142 | }), 143 | seo, 144 | failOnBudgets, 145 | }; 146 | 147 | async function init(args, chromeFlags) { 148 | const testUrl = args[0]; 149 | 150 | // Run Google Lighthouse 151 | const { categoryReport, budgetsReport, htmlReport, jsonReport } = await lighthouseReporter( 152 | testUrl, 153 | flags, 154 | chromeFlags, 155 | lighthouseFlags, 156 | ); 157 | const { silent } = flags; 158 | 159 | if (flags.report) { 160 | const outputPath = path.resolve(flags.report, flags.filename); 161 | await fs.writeFileSync(outputPath, htmlReport); 162 | 163 | if (flags.jsonReport && jsonReport) { 164 | const jsonReportPath = outputPath.replace(/\.[^.]+$/, '.json'); 165 | await fs.writeFileSync(jsonReportPath, jsonReport); 166 | } 167 | } 168 | 169 | return { 170 | categoryReport, 171 | budgetsReport, 172 | silent, 173 | }; 174 | } 175 | 176 | Promise.resolve() 177 | .then(() => { 178 | updateNotifier({ pkg }).notify(); 179 | 180 | if (cli.input.length === 0) { 181 | return cli.showHelp(); 182 | } 183 | 184 | spinner.text = `Running Lighthouse on ${cli.input} ...\n`; 185 | spinner.start(); 186 | 187 | return init(cli.input, getChromeFlags()); 188 | }) 189 | .then(({ categoryReport, budgetsReport, silent }) => { 190 | spinner.stop(); 191 | 192 | if (!silent) { 193 | for (const category in categoryReport) { 194 | if (typeof categoryReport[category] === 'undefined') { 195 | continue; 196 | } 197 | 198 | console.log(`${chalk.yellow(category)}: ${chalk.yellow(categoryReport[category])}`); 199 | } 200 | 201 | for (const budget in budgetsReport) { 202 | if (!budgetsReport[budget]) { 203 | continue; 204 | } 205 | 206 | if (budgetsReport[budget]) { 207 | console.log(`Budget '${chalk.yellow(budget)}' exceeded by ${chalk.yellow(budgetsReport[budget])}`); 208 | } 209 | } 210 | } 211 | 212 | return { categoryReport, budgetsReport }; 213 | }) 214 | .then(({ categoryReport, budgetsReport }) => { 215 | const result = calculateResults(flags, categoryReport, budgetsReport, failOnBudgets); 216 | 217 | if (result.passed) { 218 | console.log(chalk.green('\nAll checks are passing. 🎉\n')); 219 | return process.exit(0); 220 | } 221 | 222 | console.log(chalk.red('\nFailed. ❌')); 223 | 224 | if (result.score === false) { 225 | throw new Error('Target score not reached.'); 226 | } 227 | 228 | if (result.budget === false) { 229 | throw new Error('Target budget not reached.'); 230 | } 231 | 232 | throw new Error('lighthouse-ci test failed.'); 233 | }) 234 | .catch((error) => { 235 | spinner.stop(); 236 | console.log(chalk.red(error), '\n'); 237 | return process.exit(1); 238 | }); 239 | -------------------------------------------------------------------------------- /codechecks-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasonny83/lighthouse-ci/c600b8792a547dd193c5109c18c1fa3f5f2b48cb/codechecks-01.png -------------------------------------------------------------------------------- /codechecks-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasonny83/lighthouse-ci/c600b8792a547dd193c5109c18c1fa3f5f2b48cb/codechecks-02.png -------------------------------------------------------------------------------- /demo/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log -------------------------------------------------------------------------------- /demo/.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /demo/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10 2 | 3 | WORKDIR /demo 4 | 5 | ENV CHROME_BIN=/usr/bin/google-chrome-stable 6 | EXPOSE 8080 7 | 8 | # Install Chrome 9 | RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - 10 | RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' 11 | RUN apt-get update && apt-get install -y google-chrome-stable 12 | 13 | COPY package.json . 14 | 15 | RUN npm install 16 | 17 | COPY . . 18 | -------------------------------------------------------------------------------- /demo/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: demo build run dev 2 | 3 | build: 4 | docker build --rm -t sonny/lighthouse-demo . 5 | 6 | run: 7 | docker run --rm sonny/lighthouse-demo npm start 8 | 9 | dev: 10 | docker run -it --rm -v ${PWD}:/demo sonny/lighthouse-demo /bin/bash 11 | 12 | demo: build run 13 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 |

Allo!

11 | 12 | 13 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-project", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "serve": "http-server", 8 | "lighthouse": "lighthouse-ci http://127.0.0.1:8080 --report", 9 | "start": "concurrently -r -s first -k \"npm run serve\" \"npm run lighthouse\"" 10 | }, 11 | "author": "", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "concurrently": "^5.1.0", 15 | "http-server": "^0.12.1", 16 | "lighthouse-ci": "^1.10.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/budgets-analyzer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2018-2021 AndreaSonny (https://github.com/andreasonny83) 3 | * 4 | * This software is released under the MIT License. 5 | * https://opensource.org/licenses/MIT 6 | */ 7 | 8 | function analyzeBudgets(budgetsReport, failOnBudgets) { 9 | if (!failOnBudgets) { 10 | return true; 11 | } 12 | 13 | const budgetLength = Object.keys(budgetsReport || {}).length; 14 | 15 | return budgetLength === 0; 16 | } 17 | 18 | module.exports = analyzeBudgets; 19 | -------------------------------------------------------------------------------- /lib/calculate-results.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2018-2021 AndreaSonny (https://github.com/andreasonny83) 3 | * 4 | * This software is released under the MIT License. 5 | * https://opensource.org/licenses/MIT 6 | */ 7 | 8 | const { scoreReducer } = require('./helpers'); 9 | const analyzeScore = require('./score-analyzer'); 10 | const analyzeBudgets = require('./budgets-analyzer'); 11 | const { getScores } = require('./config'); 12 | 13 | const calculateResults = (flags, categoryReport, budgetsReport, failOnBudgets) => { 14 | let thresholds = scoreReducer(flags, getScores()); 15 | thresholds = 16 | Object.keys(thresholds).length === 0 17 | ? { 18 | score: 100, 19 | } 20 | : thresholds; 21 | 22 | if (thresholds && Object.keys(thresholds).length > 0) { 23 | const isScorePassing = analyzeScore(categoryReport, thresholds); 24 | const areBudgetsPassing = analyzeBudgets(budgetsReport, failOnBudgets); 25 | 26 | if (isScorePassing && areBudgetsPassing) { 27 | return { 28 | passed: true, 29 | }; 30 | } 31 | 32 | return { 33 | passed: false, 34 | score: isScorePassing, 35 | budget: areBudgetsPassing, 36 | }; 37 | } 38 | 39 | return { 40 | passed: false, 41 | }; 42 | }; 43 | 44 | module.exports = { 45 | calculateResults, 46 | }; 47 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2018-2021 AndreaSonny (https://github.com/andreasonny83) 3 | * 4 | * This software is released under the MIT License. 5 | * https://opensource.org/licenses/MIT 6 | */ 7 | 8 | const scores = ['performance', 'pwa', 'accessibility', 'best-practices', 'seo']; 9 | const chromeFlags = ['--disable-gpu', '--headless', '--no-zygote', '--no-sandbox']; 10 | 11 | const getScores = () => scores; 12 | const getChromeFlags = () => chromeFlags; 13 | 14 | module.exports = { getScores, getChromeFlags }; 15 | -------------------------------------------------------------------------------- /lib/helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2018-2021 AndreaSonny (https://github.com/andreasonny83) 3 | * 4 | * This software is released under the MIT License. 5 | * https://opensource.org/licenses/MIT 6 | */ 7 | 8 | const mkdirp = require('mkdirp'); 9 | const rimraf = require('rimraf'); 10 | 11 | const clean = () => 12 | new Promise((resolve, reject) => { 13 | rimraf('./lighthouse/', (err) => { 14 | if (err) { 15 | return reject(err); 16 | } 17 | 18 | return resolve(); 19 | }); 20 | }); 21 | 22 | const createDir = () => 23 | new Promise((resolve, reject) => { 24 | mkdirp('./lighthouse', (err) => { 25 | if (err) { 26 | return reject(err); 27 | } 28 | 29 | return resolve(); 30 | }); 31 | }); 32 | 33 | const scoreReducer = (flags, scoreList) => { 34 | if (flags.score) { 35 | return flags.score; 36 | } 37 | 38 | return scoreList.reduce((scores, flag) => { 39 | if (!Object.prototype.hasOwnProperty.call(flags, flag)) { 40 | return scores; 41 | } 42 | 43 | return { 44 | ...scores, 45 | [flag]: flags[flag], 46 | }; 47 | }, {}); 48 | }; 49 | 50 | const createDefaultConfig = (config) => { 51 | if (!config) { 52 | config = { 53 | extends: 'lighthouse:default', 54 | settings: {}, 55 | }; 56 | } 57 | 58 | if (!config.settings) { 59 | config.settings = {}; 60 | } 61 | 62 | return config; 63 | }; 64 | 65 | const getOwnProps = (object) => { 66 | return Object.keys(object).filter((key) => Object.prototype.hasOwnProperty.call(object, key)); 67 | }; 68 | 69 | const convertToBudgetList = (object) => { 70 | return getOwnProps(object) 71 | .filter((key) => object[key]) 72 | .reduce((acc, key) => { 73 | acc.push({ 74 | resourceType: key, 75 | budget: object[key], 76 | }); 77 | return acc; 78 | }, []); 79 | }; 80 | 81 | const convertToResourceKey = (key) => 'resource' + key.charAt(0).toUpperCase() + key.slice(1); 82 | 83 | module.exports = { 84 | clean, 85 | createDir, 86 | scoreReducer, 87 | createDefaultConfig, 88 | getOwnProps, 89 | convertToBudgetList, 90 | convertToResourceKey, 91 | }; 92 | -------------------------------------------------------------------------------- /lib/lighthouse-reporter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2018-2021 AndreaSonny (https://github.com/andreasonny83) 3 | * 4 | * This software is released under the MIT License. 5 | * https://opensource.org/licenses/MIT 6 | */ 7 | 8 | const fs = require('fs'); 9 | const path = require('path'); 10 | const { promisify } = require('util'); 11 | const lighthouse = require('lighthouse'); 12 | const chromeLauncher = require('chrome-launcher'); 13 | const ReportGenerator = require('lighthouse/report/generator/report-generator'); 14 | const chalk = require('chalk'); 15 | const { createDefaultConfig, getOwnProps, convertToBudgetList, convertToResourceKey } = require('./helpers'); 16 | 17 | const readFile = promisify(fs.readFile); 18 | 19 | const launchChromeAndRunLighthouse = async (url, chromeFlags, lighthouseFlags, configPath, budgetPath) => { 20 | const chrome = await chromeLauncher.launch({ 21 | chromeFlags, 22 | }); 23 | const flags = { 24 | port: chrome.port, 25 | output: 'json', 26 | ...lighthouseFlags, 27 | }; 28 | let config; 29 | 30 | if (flags.extraHeaders) { 31 | let extraHeadersString = flags.extraHeaders; 32 | if (extraHeadersString.slice(0, 1) !== '{') { 33 | extraHeadersString = await readFile(extraHeadersString, 'UTF-8'); 34 | } 35 | 36 | flags.extraHeaders = JSON.parse(extraHeadersString); 37 | } 38 | 39 | if (configPath) { 40 | try { 41 | const configJson = await readFile(path.resolve(configPath), 'UTF-8'); 42 | config = JSON.parse(configJson); 43 | } catch (error) { 44 | throw new Error(error.message); 45 | } 46 | } 47 | 48 | if (budgetPath) { 49 | try { 50 | const budgetJson = await readFile(path.resolve(budgetPath), 'UTF-8'); 51 | 52 | config = createDefaultConfig(config); 53 | 54 | config.settings.budgets = JSON.parse(budgetJson); 55 | } catch (error) { 56 | throw new Error(error.message); 57 | } 58 | } else if (flags && flags.budget) { 59 | config = createDefaultConfig(config); 60 | 61 | const { budget } = flags; 62 | 63 | const budgetConfigs = getOwnProps(budget) 64 | .filter((key) => budget[key] && (key === 'counts' || key === 'sizes')) 65 | .reduce( 66 | (acc, key) => { 67 | acc[convertToResourceKey(key)] = convertToBudgetList(budget[key]); 68 | return acc; 69 | }, 70 | { 71 | resourceSizes: [], 72 | resourceCounts: [], 73 | }, 74 | ); 75 | 76 | config.settings.budgets = [budgetConfigs]; 77 | } 78 | 79 | const result = await lighthouse(url, flags, config); 80 | await chrome.kill(); 81 | 82 | if (!result || !result.lhr) { 83 | throw new Error('Something went wrong when running Lighthouse against the given url'); 84 | } 85 | 86 | if (result.lhr.runtimeError) { 87 | throw new Error(result.lhr.runtimeError.message); 88 | } 89 | 90 | if (result.lhr.runWarnings.length > 0) { 91 | for (const warningMessage of result.lhr.runWarnings) { 92 | console.warn(`\n${chalk.yellow('WARNING:')} ${warningMessage}`); 93 | } 94 | 95 | console.warn('\n'); 96 | } 97 | 98 | return result; 99 | }; 100 | 101 | const createHtmlReport = (results, flags) => { 102 | if (flags.report) { 103 | return ReportGenerator.generateReportHtml(results); 104 | } 105 | 106 | return null; 107 | }; 108 | 109 | const createJsonReport = (results, flags) => { 110 | if (flags.report && flags.jsonReport) { 111 | return ReportGenerator.generateReport(results, 'json'); 112 | } 113 | 114 | return null; 115 | }; 116 | 117 | const createCategoryReport = (results) => { 118 | const { categories } = results; 119 | 120 | return getOwnProps(categories).reduce((categoryReport, categoryName) => { 121 | const category = results.categories[categoryName]; 122 | categoryReport[category.id] = Math.round(category.score * 100); 123 | return categoryReport; 124 | }, {}); 125 | }; 126 | 127 | const createBudgetsReport = (results) => { 128 | const items = 129 | (results.audits && 130 | results.audits['performance-budget'] && 131 | results.audits['performance-budget'].details && 132 | results.audits['performance-budget'].details.items) || 133 | []; 134 | 135 | const timings = 136 | (results.audits && 137 | results.audits['timing-budget'] && 138 | results.audits['timing-budget'].details && 139 | results.audits['timing-budget'].details.items) || 140 | []; 141 | 142 | const report = items.reduce((acc, object) => { 143 | if (object.countOverBudget) { 144 | acc[object.resourceType + '-count'] = `${object.countOverBudget}`; 145 | } 146 | 147 | if (object.sizeOverBudget) { 148 | acc[object.resourceType + '-size'] = `${Math.round(object.sizeOverBudget / 1024, 0)}kb`; 149 | } 150 | 151 | return acc; 152 | }, {}); 153 | 154 | const timingReport = timings.reduce((acc, { overBudget, metric }) => { 155 | if (overBudget && typeof overBudget === 'object') { 156 | const { value } = overBudget; 157 | if (value && typeof value === 'number') { 158 | acc[metric] = value; 159 | } 160 | } 161 | 162 | if (overBudget && typeof overBudget === 'number') { 163 | acc[metric] = `${overBudget}ms`; 164 | } 165 | 166 | return acc; 167 | }, {}); 168 | 169 | return { ...report, ...timingReport }; 170 | }; 171 | 172 | async function writeReport(url, flags = {}, defaultChromeFlags = [], lighthouseFlags = {}) { 173 | const { chromeFlags, configPath, budgetPath, ...extraLHFlags } = lighthouseFlags; 174 | const customChromeFlags = chromeFlags ? chromeFlags.split(',') : []; 175 | 176 | const lighthouseResult = await launchChromeAndRunLighthouse( 177 | url, 178 | [...defaultChromeFlags, ...customChromeFlags], 179 | extraLHFlags, 180 | configPath, 181 | budgetPath, 182 | ); 183 | 184 | const htmlReport = createHtmlReport(lighthouseResult.lhr, flags); 185 | const jsonReport = createJsonReport(lighthouseResult.lhr, flags); 186 | 187 | const categoryReport = createCategoryReport(lighthouseResult.lhr); 188 | const budgetsReport = createBudgetsReport(lighthouseResult.lhr); 189 | 190 | return { categoryReport, budgetsReport, htmlReport, jsonReport }; 191 | } 192 | 193 | module.exports = writeReport; 194 | -------------------------------------------------------------------------------- /lib/score-analyzer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2018-2021 AndreaSonny (https://github.com/andreasonny83) 3 | * 4 | * This software is released under the MIT License. 5 | * https://opensource.org/licenses/MIT 6 | */ 7 | 8 | function analyzeScores(thresholds, categoryReport) { 9 | for (const category in categoryReport) { 10 | if (!Object.prototype.hasOwnProperty.call(thresholds, category)) { 11 | continue; 12 | } 13 | 14 | if (Number(categoryReport[category]) < Number(thresholds[category])) { 15 | return false; 16 | } 17 | } 18 | 19 | return true; 20 | } 21 | 22 | function analyzeTotalScore(threshold, categoryReport) { 23 | for (const category in categoryReport) { 24 | if (Number(categoryReport[category]) < Number(threshold)) { 25 | return false; 26 | } 27 | } 28 | 29 | return true; 30 | } 31 | 32 | function analyzeScore(categoryReport, thresholds) { 33 | if (!thresholds || thresholds.length === 0) { 34 | throw new Error('Invalid threshold score.'); 35 | } 36 | 37 | return typeof thresholds === 'object' 38 | ? analyzeScores(thresholds, categoryReport) 39 | : analyzeTotalScore(thresholds, categoryReport); 40 | } 41 | 42 | module.exports = analyzeScore; 43 | -------------------------------------------------------------------------------- /lighthouse-cli.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasonny83/lighthouse-ci/c600b8792a547dd193c5109c18c1fa3f5f2b48cb/lighthouse-cli.gif -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasonny83/lighthouse-ci/c600b8792a547dd193c5109c18c1fa3f5f2b48cb/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lighthouse-ci", 3 | "version": "1.13.1", 4 | "description": "CLI implementation for running Lighthouse with any CI tool", 5 | "scripts": { 6 | "test": "npm run format && xo && jest --detectOpenHandles", 7 | "xo": "xo", 8 | "test:watch": "jest --watchAll --detectOpenHandles", 9 | "format": "prettier --write \"**/*.{js,js}\"", 10 | "contributors:add": "all-contributors add", 11 | "contributors:generate": "all-contributors generate", 12 | "release": "np", 13 | "snyk-protect": "snyk protect", 14 | "prepare": "npm run snyk-protect" 15 | }, 16 | "type": "commonjs", 17 | "dependencies": { 18 | "chrome-launcher": "^0.14.0", 19 | "lighthouse": "^8.4.0", 20 | "meow": "^9.0.0", 21 | "mkdirp": "^1.0.4", 22 | "ora": "^5.4.0", 23 | "rimraf": "^3.0.2", 24 | "update-notifier": "^5.1.0" 25 | }, 26 | "devDependencies": { 27 | "@types/jest": "^26.0.24", 28 | "@types/ora": "^3.2.0", 29 | "all-contributors-cli": "^6.20.0", 30 | "chalk": "^4.1.1", 31 | "jest": "^27.0.6", 32 | "np": "^7.5.0", 33 | "prettier": "^2.3.2", 34 | "snyk": "^1.660.0", 35 | "xo": "~0.36.0" 36 | }, 37 | "keywords": [ 38 | "devtools", 39 | "lighthouse", 40 | "ci" 41 | ], 42 | "bin": { 43 | "lighthouse-ci": "bin/cli.js" 44 | }, 45 | "files": [ 46 | "lib", 47 | "bin", 48 | "README.md", 49 | "LICENSE" 50 | ], 51 | "engines": { 52 | "node": "^12.20.0 || ^14.13.1 || >=16.0.0" 53 | }, 54 | "xo": { 55 | "prettier": true, 56 | "envs": [ 57 | "node", 58 | "es6", 59 | "jest" 60 | ], 61 | "rules": { 62 | "max-params": [ 63 | "error", 64 | 5 65 | ], 66 | "unicorn/no-reduce": 0 67 | } 68 | }, 69 | "author": "Andrea Sonny ", 70 | "license": "MIT", 71 | "repository": { 72 | "type": "git", 73 | "url": "git+https://github.com/andreasonny83/lighthouse-ci.git" 74 | }, 75 | "bugs": { 76 | "url": "https://github.com/andreasonny83/lighthouse-ci.git/issues" 77 | }, 78 | "homepage": "https://github.com/andreasonny83/lighthouse-ci.git#readme", 79 | "snyk": true 80 | } 81 | -------------------------------------------------------------------------------- /screenshot-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasonny83/lighthouse-ci/c600b8792a547dd193c5109c18c1fa3f5f2b48cb/screenshot-ui.png --------------------------------------------------------------------------------