├── .codecov.yml ├── .github ├── stale.yml └── workflows │ ├── foo-api.yml │ └── test.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── Dockerfile ├── Dockerfile.next ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── scripts ├── README.md └── docker-publish.sh └── src ├── LighthouseCheckError.js ├── __snapshots__ ├── index.test.js.snap ├── lighthouseConfig.test.js.snap ├── lighthouseOptions.test.js.snap └── triggerLighthouse.test.js.snap ├── bin ├── lighthouse-check-status.js └── lighthouse-check.js ├── constants.js ├── errorCodes.js ├── fetch.js ├── fetchAndWaitForLighthouseAudits.js ├── fetchLighthouseAudits.js ├── helpers ├── arguments.js ├── getHelpText.js ├── getLighthouseAuditTitlesByKey.js ├── getLighthouseAuditTitlesByKey.test.js ├── getLighthouseScoreColor.js ├── utils.js └── writeResults.js ├── index.js ├── index.test.js ├── lighthouseAuditTitles.js ├── lighthouseCheck.js ├── lighthouseConfig.js ├── lighthouseConfig.test.js ├── lighthouseOptions.js ├── lighthouseOptions.test.js ├── localLighthouse.js ├── localLighthouse.test.js ├── logResults.js ├── postPrComment.js ├── slackNotify.js ├── triggerLighthouse.js ├── triggerLighthouse.test.js └── validateStatus.js /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | threshold: 3 6 | patch: false 7 | changes: false 8 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 25 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 5 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - bug 8 | - p1 9 | - p2 10 | # Label to use when marking an issue as stale 11 | staleLabel: stale 12 | # Comment to post when marking an issue as stale. Set to `false` to disable 13 | markComment: > 14 | This issue has been automatically marked as stale because it has not had 15 | recent activity. It will be closed if no further activity occurs. Thank you 16 | for your contributions. 17 | # Comment to post when closing a stale issue. Set to `false` to disable 18 | closeComment: false 19 | -------------------------------------------------------------------------------- /.github/workflows/foo-api.yml: -------------------------------------------------------------------------------- 1 | name: Lighthouse Check via Foo API 2 | on: [pull_request] 3 | 4 | jobs: 5 | lighthouse-foo-api: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@master 9 | - name: Setup Node 10 | uses: actions/setup-node@v1 11 | with: 12 | node-version: '20.x' 13 | - run: npm install 14 | - run: npm run build 15 | - name: Run Lighthouse Check 16 | run: | 17 | node ./dist/bin/lighthouse-check.js --verbose \ 18 | --apiToken ${{ secrets.LIGHTHOUSE_CHECK_API_TOKEN }} \ 19 | --emulatedFormFactor all \ 20 | --isGitHubAction true \ 21 | --tag lighthouse-check \ 22 | --urls ${{ secrets.LIGHTHOUSE_CHECK_URLS }} \ 23 | --prCommentAccessToken "${{ secrets.GITHUB_TOKEN }}" \ 24 | --prCommentEnabled \ 25 | --prCommentUrl "https://api.github.com/repos/foo-software/lighthouse-check/pulls/$(echo $GITHUB_REF | awk 'BEGIN { FS = "/" } ; { print $3 }')/reviews" 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Lighthouse Check 2 | on: [pull_request] 3 | 4 | jobs: 5 | lighthouse: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@master 9 | - name: Setup Node 10 | uses: actions/setup-node@v1 11 | with: 12 | node-version: '20.x' 13 | - run: npm install 14 | - run: npm run build 15 | - name: Unit tests 16 | run: npm run test 17 | - name: Run Lighthouse Check 18 | run: | 19 | node ./dist/bin/lighthouse-check.js --verbose --prCommentEnabled \ 20 | --emulatedFormFactor "all" \ 21 | --urls "https://www.foo.software,https://www.foo.software/about" \ 22 | --prCommentAccessToken "${{ secrets.GITHUB_TOKEN }}" \ 23 | --prCommentUrl "https://api.github.com/repos/foo-software/lighthouse-check/pulls/$(echo $GITHUB_REF | awk 'BEGIN { FS = "/" } ; { print $3 }')/reviews" 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # https://github.com/github/gitignore/blob/master/Node.gitignore 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://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 | .env.test 65 | 66 | # parcel-bundler cache (https://parceljs.org/) 67 | .cache 68 | 69 | # next.js build output 70 | .next 71 | 72 | # nuxt.js build output 73 | .nuxt 74 | 75 | # vuepress build output 76 | .vuepress/dist 77 | 78 | # Serverless directories 79 | .serverless/ 80 | 81 | # FuseBox cache 82 | .fusebox/ 83 | 84 | # DynamoDB Local files 85 | .dynamodb/ 86 | 87 | # custom 88 | dist 89 | .DS_Store 90 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "importOrder": [ 3 | "^react", 4 | "", 5 | "(?=^[.]+/)(?!.*.css$)", 6 | "^.*.css$" 7 | ], 8 | "jsxSingleQuote": false, 9 | "singleQuote": true, 10 | "trailingComma": "all", 11 | "overrides": [ 12 | { 13 | "files": "*.ts", 14 | "options": { 15 | "parser": "typescript" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM foosoftware/lighthouse-check:9 2 | 3 | CMD ["lighthouse-check"] 4 | -------------------------------------------------------------------------------- /Dockerfile.next: -------------------------------------------------------------------------------- 1 | # Inspired by: 2 | # https://github.com/alpeware/chrome-headless-stable/blob/master/Dockerfile 3 | FROM ubuntu:20.04 4 | 5 | LABEL maintainer "Foo " 6 | 7 | # install node 8 | RUN apt-get update \ 9 | && apt-get -y install curl gnupg build-essential \ 10 | && curl -sL https://deb.nodesource.com/setup_20.x | bash - \ 11 | && apt-get -y install nodejs 12 | 13 | RUN node -v 14 | RUN npm -v 15 | 16 | ENV TZ=America/New_York 17 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 18 | 19 | # install puppeteer / chrome launcher dependencies 20 | # https://pptr.dev/troubleshooting#chrome-doesnt-launch-on-linux 21 | # https://source.chromium.org/chromium/chromium/src/+/main:chrome/installer/linux/debian/dist_package_versions.json 22 | RUN apt-get update -qqy \ 23 | && apt-get -qqy install \ 24 | libasound2 \ 25 | libatk-bridge2.0-0 \ 26 | libatk1.0-0 \ 27 | libatspi2.0-0 \ 28 | libc6 \ 29 | libcairo2 \ 30 | libcups2 \ 31 | libdbus-1-3 \ 32 | libdrm2 \ 33 | libexpat1 \ 34 | libgbm1 \ 35 | libglib2.0-0 \ 36 | libnspr4 \ 37 | libnss3 \ 38 | libpango-1.0-0 \ 39 | libpangocairo-1.0-0 \ 40 | libstdc++6 \ 41 | libuuid1 \ 42 | libx11-6 \ 43 | libx11-xcb1 \ 44 | libxcb-dri3-0 \ 45 | libxcb1 \ 46 | libxcomposite1 \ 47 | libxcursor1 \ 48 | libxdamage1 \ 49 | libxext6 \ 50 | libxfixes3 \ 51 | libxi6 \ 52 | libxkbcommon0 \ 53 | libxrandr2 \ 54 | libxrender1 \ 55 | libxshmfence1 \ 56 | libxss1 \ 57 | libxtst6 58 | 59 | # install chrome 60 | RUN apt-get update -qqy \ 61 | && apt-get -qqy install libnss3 libnss3-tools libfontconfig1 wget ca-certificates apt-transport-https inotify-tools \ 62 | gnupg \ 63 | && rm -rf /var/lib/apt/lists/* /var/cache/apt/* 64 | 65 | RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - \ 66 | && echo "deb https://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list \ 67 | && apt-get update -qqy \ 68 | && apt-get -qqy install google-chrome-stable \ 69 | && rm /etc/apt/sources.list.d/google-chrome.list \ 70 | && rm -rf /var/lib/apt/lists/* /var/cache/apt/* 71 | 72 | RUN google-chrome-stable --version 73 | 74 | RUN npm install @foo-software/lighthouse-check@10.1.2 -g 75 | 76 | CMD ["lighthouse-check"] 77 | # RUN npm install lighthouse -g 78 | 79 | # CMD ["lighthouse"] 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Foo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `@foo-software/lighthouse-check` 2 | 3 | > An NPM module and CLI to run Lighthouse audits programmatically. This project aims to add bells and whistles to automated Lighthouse testing for DevOps workflows. Easily implement in your Continuous Integration or Continuous Delivery pipeline. 4 | 5 | 6 | 7 | This project provides **two ways of running audits** - locally in your own environment or remotely via [Foo's Automated Lighthouse Check](https://www.foo.software/lighthouse) API. For basic usage, running locally will suffice, but if you'd like to maintain a historical record of Lighthouse audits and utilize other features, you can run audits remotely by following the [steps and examples](#foo-api-usage). 8 | 9 | # Features 10 | 11 | - [Simple usage](#basic-usage) - only one parameter required. 12 | - Run **multiple** Lighthouse audits with one command. 13 | - Optionally run Lighthouse remotely and save audits with the [Foo's Automated Lighthouse Check](https://www.foo.software/lighthouse) API. 14 | - Optionally [save an HTML report locally](#saving-reports-locally). 15 | - Optionally [save an HTML report in an AWS S3 bucket](#saving-reports-to-s3). 16 | - [Easy setup with Slack Webhooks](#implementing-with-slack). Just add your Webhook URL and `lighthouse-check` will send results and optionally include versioning data like branch, author, PR, etc (typically from GitHub). 17 | - PR comments of audit scores. 18 | - NPM module for programmatic [usage](#basic-usage). 19 | - CLI - see [CLI Usage](#cli-usage). 20 | - Docker - see [Docker Usage](#docker). 21 | - Support for implementations like [CircleCI](#implementing-with-circleci). 22 | 23 | # Table of Contents 24 | 25 | - [Install](#install) 26 | - [Usage](#usage) 27 | - [Basic Usage](#basic-usage) 28 | - [Foo's Automated Lighthouse Check API Usage](#foos-automated-lighthouse-check-api-usage) 29 | - [Saving Reports Locally](#saving-reports-locally) 30 | - [Saving Reports to S3](#saving-reports-to-s3) 31 | - [Implementing with Slack](#implementing-with-slack) 32 | - [Enabling PR Comments](#enabling-pr-comments) 33 | - [Enforcing Minimum Scores](#enforcing-minimum-scores) 34 | - [Implementing with CircleCI](#implementing-with-circleci) 35 | - [Implementing with GitHub Actions](#implementing-with-gitHub-actions) 36 | - [CLI](#cli) 37 | - [Docker](#docker) 38 | - [Options](#options) 39 | 40 | # Install 41 | 42 | ```bash 43 | npm install @foo-software/lighthouse-check 44 | ``` 45 | 46 | # Usage 47 | 48 | `@foo-software/lighthouse-check` provides several functionalities beyond standard Lighthouse audits. It's recommended to start with a basic implementation and expand on it as needed. 49 | 50 | ## Basic Usage 51 | 52 | Calling `lighthouseCheck` will run Lighthouse audits against `https://www.foo.software/lighthouse` and `https://www.foo.software/contact`. 53 | 54 | ```javascript 55 | import { lighthouseCheck } from '@foo-software/lighthouse-check'; 56 | 57 | (async () => { 58 | const response = await lighthouseCheck({ 59 | urls: [ 60 | 'https://www.foo.software/lighthouse', 61 | 'https://www.foo.software/contact', 62 | ], 63 | }); 64 | 65 | console.log('response', response); 66 | })(); 67 | ``` 68 | 69 | Or via CLI. 70 | 71 | ```bash 72 | $ lighthouse-check --urls "https://www.foo.software/lighthouse,https://www.foo.software/contact" 73 | ``` 74 | 75 | The CLI will log the results. 76 | 77 | lighthouse-check CLI output 78 | 79 | ## Foo's Automated Lighthouse Check API Usage 80 | 81 | [Foo's Automated Lighthouse Check](https://www.foo.software/lighthouse) can monitor your website's quality by running audits automatically! It can provide a historical record of audits over time to track progression and degradation of website quality. [Create a free account](https://www.foo.software/lighthouse/register) to get started. With this, not only will you have automatic audits, but also any that you trigger additionally. Below are steps to trigger audits on URLs that you've created in your account. 82 | 83 | #### Trigger Audits on All Pages in an Account 84 | 85 | - Navigate to [your account details](https://www.foo.software/lighthouse/account), click into "Account Management" and make note of the "API Token". 86 | - Use the account token as the [`apiToken` option](#options). 87 | 88 | > Basic example with the CLI 89 | 90 | ```bash 91 | $ lighthouse-check --apiToken "abcdefg" 92 | ``` 93 | 94 | #### Trigger Audits on Only Certain Pages in an Account 95 | 96 | - Navigate to [your account details](https://www.foo.software/lighthouse/account), click into "Account Management" and make note of the "API Token". 97 | - Navigate to [your dashboard](https://www.foo.software/lighthouse/dashboard) and once you've created URLs to monitor, click on the "More" link of the URL you'd like to use. From the URL details screen, click the "Edit" link at the top of the page. You should see an "API Token" on this page. It represents the token for this specific page (not to be confused with an **account** API token). 98 | - Use the account token as the [`apiToken` option](#options) and page token (or group of page tokens) as [`urls` option](#options). 99 | 100 | > Basic example with the CLI 101 | 102 | ```bash 103 | $ lighthouse-check --apiToken "abcdefg" \ 104 | --urls "hijklmnop,qrstuv" 105 | ``` 106 | 107 | You can combine usage with other options for a more advanced setup. Example below. 108 | 109 | > Runs audits remotely and posts results as comments in a PR 110 | 111 | ```bash 112 | $ lighthouse-check --apiToken "abcdefg" \ 113 | --urls "hijklmnop,qrstuv" \ 114 | --prCommentAccessToken "abcpersonaltoken" \ 115 | --prCommentUrl "https://api.github.com/repos/foo-software/lighthouse-check/pulls/3/reviews" 116 | ``` 117 | 118 | ## Saving Reports Locally 119 | 120 | You may notice above we had two lines of output; `Report` and `Local Report`. These values are populated when options are provided to save the report locally and to S3. These options are not required and can be used together or alone. 121 | 122 | Saving a report locally example below. 123 | 124 | ```javascript 125 | import { lighthouseCheck } from '@foo-software/lighthouse-check'; 126 | 127 | (async () => { 128 | const response = await lighthouseCheck({ 129 | // relative to the file. NOTE: when using the CLI `--outputDirectory` is relative 130 | // to where the command is being run from. 131 | outputDirectory: '../artifacts', 132 | urls: [ 133 | 'https://www.foo.software/lighthouse', 134 | 'https://www.foo.software/contact', 135 | ], 136 | }); 137 | 138 | console.log('response', response); 139 | })(); 140 | ``` 141 | 142 | Or via CLI. 143 | 144 | ```bash 145 | $ lighthouse-check --urls "https://www.foo.software/lighthouse,https://www.foo.software/contact" \ 146 | --ouputDirectory "./artifacts" 147 | ``` 148 | 149 | ## Saving Reports to S3 150 | 151 | ```javascript 152 | import { lighthouseCheck } from '@foo-software/lighthouse-check'; 153 | 154 | (async () => { 155 | const response = await lighthouseCheck({ 156 | awsAccessKeyId: 'abc123', 157 | awsBucket: 'my-bucket', 158 | awsRegion: 'us-east-1', 159 | awsSecretAccessKey: 'def456', 160 | urls: [ 161 | 'https://www.foo.software/lighthouse', 162 | 'https://www.foo.software/contact', 163 | ], 164 | }); 165 | 166 | console.log('response', response); 167 | })(); 168 | ``` 169 | 170 | Or via CLI. 171 | 172 | ```bash 173 | $ lighthouse-check --urls "https://www.foo.software/lighthouse,https://www.foo.software/contact" \ 174 | --awsAccessKeyId abc123 \ 175 | --awsBucket my-bucket \ 176 | --awsRegion us-east-1 \ 177 | --awsSecretAccessKey def456 \ 178 | ``` 179 | 180 | ## Implementing with Slack 181 | 182 | Below is a basic Slack implementation. To see how you can accomplish notifications with code versioning data - see the [CircleCI example](#implementing-with-circleci) (ie GitHub authors, PRs, branches, etc). 183 | 184 | ```javascript 185 | import { lighthouseCheck } from '@foo-software/lighthouse-check'; 186 | 187 | (async () => { 188 | const response = await lighthouseCheck({ 189 | slackWebhookUrl: 'https://www.my-slack-webhook-url.com', 190 | urls: [ 191 | 'https://www.foo.software/lighthouse', 192 | 'https://www.foo.software/contact', 193 | ], 194 | }); 195 | 196 | console.log('response', response); 197 | })(); 198 | ``` 199 | 200 | Or via CLI. 201 | 202 | ```bash 203 | $ lighthouse-check --urls "https://www.foo.software/lighthouse,https://www.foo.software/contact" \ 204 | --slackWebhookUrl "https://www.my-slack-webhook-url.com" 205 | ``` 206 | 207 | The below screenshot shows an advanced implementation as detailed in the [CircleCI example](#implementing-with-circleci). 208 | 209 | Lighthouse Check Slack notification 210 | 211 | ## Enabling PR Comments 212 | 213 | Populate [`prCommentAccessToken` and `prCommentUrl` options](#options) to enable comments on pull requests. 214 | 215 | Lighthouse Check PR comments 216 | 217 | ## Enforcing Minimum Scores 218 | 219 | You can use `validateStatus` to enforce minimum scores. This could be handy in a DevOps workflow for example. 220 | 221 | ```javascript 222 | import { 223 | lighthouseCheck, 224 | validateStatus, 225 | } from '@foo-software/lighthouse-check'; 226 | 227 | (async () => { 228 | try { 229 | const response = await lighthouseCheck({ 230 | awsAccessKeyId: 'abc123', 231 | awsBucket: 'my-bucket', 232 | awsRegion: 'us-east-1', 233 | awsSecretAccessKey: 'def456', 234 | urls: [ 235 | 'https://www.foo.software/lighthouse', 236 | 'https://www.foo.software/contact', 237 | ], 238 | }); 239 | 240 | const status = await validateStatus({ 241 | minAccessibilityScore: 90, 242 | minBestPracticesScore: 90, 243 | minPerformanceScore: 70, 244 | minProgressiveWebAppScore: 70, 245 | minSeoScore: 80, 246 | results: response, 247 | }); 248 | 249 | console.log('all good?', status); // 'all good? true' 250 | } catch (error) { 251 | console.log('error', error.message); 252 | 253 | // log would look like: 254 | // Minimum score requirements failed: 255 | // https://www.foo.software/lighthouse: Performance: minimum score: 70, actual score: 64 256 | // https://www.foo.software/contact: Performance: minimum score: 70, actual score: 44 257 | } 258 | })(); 259 | ``` 260 | 261 | Or via CLI. **Important**: `outputDirectory` value must be defined and the same in both commands. 262 | 263 | ```bash 264 | $ lighthouse-check --urls "https://www.foo.software/lighthouse,https://www.foo.software/contact" \ 265 | --outputDirectory /tmp/artifacts \ 266 | $ lighthouse-check-status --outputDirectory /tmp/artifacts \ 267 | --minAccessibilityScore 90 \ 268 | --minBestPracticesScore 90 \ 269 | --minPerformanceScore 70 \ 270 | --minProgressiveWebAppScore 70 \ 271 | --minSeoScore 80 272 | ``` 273 | 274 | ## Implementing with CircleCI 275 | 276 | In the below example we run Lighthouse audits on two URLs, save reports as artifacts, deploy reports to S3 and send a Slack notification with GitHub info. We defined environment variables like `LIGHTHOUSE_CHECK_AWS_BUCKET` in the [CircleCI project settings](https://circleci.com/docs/2.0/settings/#project-settings-page). 277 | 278 | This implementation utilizes a CircleCI Orb - [lighthouse-check-orb](https://circleci.com/orbs/registry/orb/foo-software/lighthouse-check). 279 | 280 | ```yaml 281 | version: 2.1 282 | 283 | orbs: 284 | lighthouse-check: foo-software/lighthouse-check@0.0.6 # ideally later :) 285 | 286 | jobs: 287 | test: 288 | executor: lighthouse-check/default 289 | steps: 290 | - lighthouse-check/audit: 291 | urls: https://www.foo.software/lighthouse,https://www.foo.software/contact 292 | # this serves as an example, however if the below environment variables 293 | # are set - the below params aren't even necessary. for example - if 294 | # LIGHTHOUSE_CHECK_AWS_ACCESS_KEY_ID is already set - you don't need 295 | # the line below. 296 | awsAccessKeyId: $LIGHTHOUSE_CHECK_AWS_ACCESS_KEY_ID 297 | awsBucket: $LIGHTHOUSE_CHECK_AWS_BUCKET 298 | awsRegion: $LIGHTHOUSE_CHECK_AWS_REGION 299 | awsSecretAccessKey: $LIGHTHOUSE_CHECK_AWS_SECRET_ACCESS_KEY 300 | slackWebhookUrl: $LIGHTHOUSE_CHECK_SLACK_WEBHOOK_URL 301 | 302 | workflows: 303 | test: 304 | jobs: 305 | - test 306 | ``` 307 | 308 | lighthouse-check CircleCI post-deploy 309 | 310 | Reports are saved as "artifacts". 311 | 312 | lighthouse-check CircleCI post-deploy artifacts 313 | 314 | Upon clicking the HTML file artifacts, we can see the full report! 315 | 316 | lighthouse-check CircleCI post-deploy artifact Lighthouse report 317 | 318 | In the example above we also uploaded reports to S3. Why would we do this? If we want to persist historical data - we don't want to rely on temporary cloud storage. 319 | 320 | ## Implementing with GitHub Actions 321 | 322 | Similar to the CircleCI implementation, we can also create a workflow implementation with [GitHub Actions](https://github.com/features/actions) using [`lighthouse-check-action`](https://github.com/foo-software/lighthouse-check-action). Example below. 323 | 324 | > `.github/workflows/test.yml` 325 | 326 | ```yaml 327 | name: Test Lighthouse Check 328 | on: [push] 329 | 330 | jobs: 331 | lighthouse-check: 332 | runs-on: ubuntu-latest 333 | steps: 334 | - uses: actions/checkout@master 335 | - run: mkdir /tmp/artifacts 336 | - name: Run Lighthouse 337 | uses: foo-software/lighthouse-check-action@master 338 | with: 339 | accessToken: ${{ secrets.LIGHTHOUSE_CHECK_GITHUB_ACCESS_TOKEN }} 340 | author: ${{ github.actor }} 341 | awsAccessKeyId: ${{ secrets.LIGHTHOUSE_CHECK_AWS_ACCESS_KEY_ID }} 342 | awsBucket: ${{ secrets.LIGHTHOUSE_CHECK_AWS_BUCKET }} 343 | awsRegion: ${{ secrets.LIGHTHOUSE_CHECK_AWS_REGION }} 344 | awsSecretAccessKey: ${{ secrets.LIGHTHOUSE_CHECK_AWS_SECRET_ACCESS_KEY }} 345 | branch: ${{ github.ref }} 346 | outputDirectory: /tmp/artifacts 347 | urls: 'https://www.foo.software/lighthouse,https://www.foo.software/contact' 348 | sha: ${{ github.sha }} 349 | slackWebhookUrl: ${{ secrets.LIGHTHOUSE_CHECK_WEBHOOK_URL }} 350 | - name: Upload artifacts 351 | uses: actions/upload-artifact@master 352 | with: 353 | name: Lighthouse reports 354 | path: /tmp/artifacts 355 | ``` 356 | 357 | ## Overriding Config and Option Defaults 358 | 359 | You can override default config and options by specifying `overridesJsonFile` option. Contents of this overrides JSON file can have two possible fields; `options` and `config`. These two fields are eventually used by Lighthouse to populate `opts` and `config` arguments respectively as illustrated in [Using programmatically](https://github.com/GoogleChrome/lighthouse/blob/master/docs/readme.md#using-programmatically). The two objects populating this JSON file are merged shallowly with the default [config](https://github.com/foo-software/lighthouse-check/blob/master/src/__snapshots__/lighthouseConfig.test.js.snap) and [options](https://github.com/foo-software/lighthouse-check/blob/master/src/__snapshots__/lighthouseOptions.test.js.snap). 360 | 361 | > Example content of `overridesJsonFile` 362 | 363 | ```json 364 | { 365 | "config": { 366 | "settings": { 367 | "onlyCategories": ["performance"] 368 | } 369 | }, 370 | "options": { 371 | "chromeFlags": ["--disable-dev-shm-usage"] 372 | } 373 | } 374 | ``` 375 | 376 | ## CLI 377 | 378 | Running `lighthouse-check` in the example below will run Lighthouse audits against `https://www.foo.software/lighthouse` and `https://www.foo.software/contact` and output a report in the '/tmp/artifacts' directory. 379 | 380 | Format is `--option `. Example below. 381 | 382 | ```bash 383 | $ lighthouse-check --urls "https://www.foo.software/lighthouse,https://www.foo.software/contact" \ 384 | --outputDirectory /tmp/artifacts 385 | ``` 386 | 387 | > `lighthouse-check-status` example 388 | 389 | ```bash 390 | $ lighthouse-check-status --outputDirectory /tmp/artifacts \ 391 | --minAccessibilityScore 90 \ 392 | --minBestPracticesScore 90 \ 393 | --minPerformanceScore 70 \ 394 | --minProgressiveWebAppScore 70 \ 395 | --minSeoScore 80 396 | ``` 397 | 398 | ## CLI Options 399 | 400 | All options mirror [the NPM module](#options). The only difference is that array options like `urls` are passed in as a comma-separated string as an argument using the CLI. 401 | 402 | ## Docker 403 | 404 | ```bash 405 | $ docker pull foosoftware/lighthouse-check:latest 406 | $ docker run foosoftware/lighthouse-check:latest \ 407 | lighthouse-check --verbose \ 408 | --urls "https://www.foo.software/lighthouse,https://www.foo.software/contact" 409 | ``` 410 | 411 | ## Options 412 | 413 | `lighthouse-check` functions accept a single configuration object. 414 | 415 | #### `lighthouseCheck` 416 | 417 | You can choose from two ways of running audits - locally in your own environment or remotely via Foo's Automated Lighthouse Check API. You can think of local runs as the default implementation. For directions about how to run remotely see the [Foo's Automated Lighthouse Check API Usage](#foo-api-usage) section. We denote which options are available to a run type with the `Run Type` values of either `local`, `remote`, or `both`. 418 | 419 | Below are options for the exported `lighthouseCheck` function or `lighthouse-check` command with CLI. 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 604 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | 642 | 643 | 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 | 655 | 656 | 657 | 658 | 659 | 660 | 661 | 662 |
NameDescriptionTypeRun TypeDefaultRequired
apiTokenThe foo.software account API token found in the dashboard.stringremoteundefinedno
authorFor Slack notifications: A user handle, typically from GitHub.stringbothundefinedno
awsAccessKeyIdThe AWS accessKeyId for an S3 bucket.stringlocalundefinedno
awsBucketThe AWS Bucket for an S3 bucket.stringlocalundefinedno
awsRegionThe AWS region for an S3 bucket.stringlocalundefinedno
awsSecretAccessKeyThe AWS secretAccessKey for an S3 bucket.stringlocalundefinedno
branchFor Slack notifications: A version control branch, typically from GitHub.stringbothundefinedno
configFileA configuration file path in JSON format which holds all options defined here. This file should be relative to the file being interpretted.stringbothundefinedno
extraHeadersHTTP Header key/value pairs to send in requests. If using the CLI this will need to be stringified, for example: "{\"Cookie\":\"monster=blue\", \"x-men\":\"wolverine\"}"objectlocalundefinedno
emulatedFormFactorLighthouse setting only used for local audits. See src/lighthouseConfig.js comments for details.oneOf(['mobile', 'desktop', 'all'])localundefinedno
localeA locale for Lighthouse reports. Example: jastringlocalundefinedno
maxRetriesThe maximum number of times to retry.Note: This is not supported when running against Foo's API as retry logic is already in place.numberlocal0no
maxWaitForLoadThe maximum amount of time to wait for a page to load in ms.numberlocalundefinedno
overridesJsonFileA JSON file with config and option fields to overrides defaults. Read more here.stringlocalundefinedno
outputDirectoryAn absolute directory path to output report. You can do this an an alternative or combined with an S3 upload.stringlocalundefinedno
prFor Slack notifications: A version control pull request URL, typically from GitHub.stringlocalundefinedno
prCommentAccessTokenAccess token of a user to post PR comments.stringbothundefinedno
prCommentEnabledIf true and prCommentAccessToken is set along with prCommentUrl, scores will be posted as comments.booleanbothtrueno
prCommentSaveOldIf true and PR comment options are set, new comments will be posted on every change vs only updating once comment with most recent scores.booleanbothfalseno
prCommentUrlAn endpoint to post comments to. Typically this will be from GitHub's API. Example: https://api.github.com/repos/:owner/:repo/pulls/:pull_number/reviewsstringbothundefinedno
slackWebhookUrlA Slack Incoming Webhook URL to send notifications to.stringbothundefinedno
shaFor Slack notifications: A version control sha, typically from GitHub.stringbothundefinedno
tagAn optional tag or name (example: build #2 or v0.0.2).stringremoteundefinedno
throttlingMethodLighthouse setting only used for local audits. See src/lighthouseConfig.js comments for details.oneOf(['simulate', 'devtools', 'provided'])localundefinedno
throttlingLighthouse setting only used for local audits. See src/lighthouseConfig.js comments for details.oneOf(['mobileSlow4G', 'mobileRegluar3G', 'desktopDense4G'])localundefinedno
timeoutMinutes to timeout. If wait is true (it is by default), we wait for results. If this timeout is reached before results are received an error is thrown.numberlocal10no
urlsAn array of URLs (or page API tokens if running remotely). In the CLI this value should be a comma-separated list.arraybothundefinedyes
verboseIf true, print out steps and results to the console.booleanbothtrueno
waitIf true, waits for all audit results to be returned, otherwise URLs are only enqueued.booleanremotetrueno
663 | 664 | #### `validateStatus` 665 | 666 | `results` parameter is required or alternatively `outputDirectory`. To utilize `outputDirectory` - the same value would also need to be specified when calling `lighthouseCheck`. 667 | 668 | 669 | 670 | 671 | 672 | 673 | 674 | 675 | 676 | 677 | 678 | 679 | 680 | 681 | 682 | 683 | 684 | 685 | 686 | 687 | 688 | 689 | 690 | 691 | 692 | 693 | 694 | 695 | 696 | 697 | 698 | 699 | 700 | 701 | 702 | 703 | 704 | 705 | 706 | 707 | 708 | 709 | 710 | 711 | 712 | 713 | 714 | 715 | 716 | 717 | 718 | 719 | 720 | 721 | 722 | 723 | 724 | 725 |
NameDescriptionTypeDefaultRequired
minAccessibilityScoreThe minimum accessibility Lighthouse score required.numberundefinedno
minBestPracticesScoreThe minimum best practices Lighthouse score required.numberundefinedno
minPerformanceScoreThe minimum performance Lighthouse score required.numberundefinedno
minProgressiveWebAppScoreThe minimum progressive web app Lighthouse score required.numberundefinedno
minSeoScoreThe minimum SEO Lighthouse score required.numberundefinedno
outputDirectoryAn absolute directory path to output report. When the results object isn't specified, this value will need to be.stringundefinedno
resultsA results object representing results of Lighthouse audits.numberundefinedno
726 | 727 | ## Return Payload 728 | 729 | `lighthouseCheck` function returns a promise which either resolves as an object or rejects as an error object. In both cases the payload will be of the same shape documented below. 730 | 731 | 732 | 733 | 734 | 735 | 736 | 737 | 738 | 739 | 740 | 741 | 742 | 743 | 744 | 745 | 746 | 747 | 748 | 749 | 750 | 751 | 752 |
NameDescriptionType
codeA code to signify failure or succes.oneOf(["SUCCESS", "ERROR_GENERIC", ...]) see errorCodes.js for all error codes.
dataAn array of results returned by the API.array
messageA message to elaborate on the code. This field isn't always populated.string
753 | 754 | ## Credits 755 | 756 | > This package was brought to you by [Foo - a website performance monitoring tool](https://www.foo.software/lighthouse). Create a **free account** with standard performance testing. Automatic website performance testing, uptime checks, charts showing performance metrics by day, month, and year. Foo also provides real time notifications when performance and uptime notifications when changes are detected. Users can integrate email, Slack and PagerDuty notifications. 757 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current', 8 | }, 9 | modules: 'commonjs', 10 | }, 11 | ], 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | modulePathIgnorePatterns: ['/dist/'], 3 | transform: { 4 | '^.+\\.(js|jsx)$': 'babel-jest', 5 | }, 6 | transformIgnorePatterns: ['/node_modules/(?!meow)'], 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@foo-software/lighthouse-check", 3 | "version": "10.1.2", 4 | "description": "An NPM module and CLI for automated Lighthouse audits.", 5 | "main": "dist/index.js", 6 | "engines": { 7 | "node": ">= 20.0.0" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/foo-software/lighthouse-check.git" 12 | }, 13 | "keywords": [ 14 | "lighthouse", 15 | "google", 16 | "cli", 17 | "performance", 18 | "accessibility", 19 | "seo", 20 | "progressive web app", 21 | "best practices", 22 | "website performance monitoring", 23 | "foo", 24 | "foo.software" 25 | ], 26 | "author": "Adam Henson (https://github.com/adamhenson)", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/foo-software/lighthouse-check/issues" 30 | }, 31 | "homepage": "https://github.com/foo-software/lighthouse-check#readme", 32 | "bin": { 33 | "lighthouse-check": "dist/bin/lighthouse-check.js", 34 | "lighthouse-check-status": "dist/bin/lighthouse-check-status.js" 35 | }, 36 | "scripts": { 37 | "clean": "rimraf dist", 38 | "codecov": "codecov", 39 | "build": "babel ./src --out-dir dist", 40 | "prepare": "npm run clean && npm run build && husky install", 41 | "prettier": "prettier --single-quote --write ./src", 42 | "test": "jest" 43 | }, 44 | "lint-staged": { 45 | "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [ 46 | "prettier --single-quote --write", 47 | "git add" 48 | ], 49 | "*.{js,css,md}": "prettier --write" 50 | }, 51 | "dependencies": { 52 | "@foo-software/lighthouse-persist": "^8.1.0", 53 | "@slack/webhook": "^6.1.0", 54 | "babel-jest": "^29.3.1", 55 | "lodash.get": "^4.4.2", 56 | "meow": "^7.1.1", 57 | "node-fetch": "^3.3.2", 58 | "ora": "^3.4.0", 59 | "table": "^6.8.1" 60 | }, 61 | "devDependencies": { 62 | "@babel/cli": "^7.19.3", 63 | "@babel/core": "^7.20.2", 64 | "@babel/node": "^7.20.2", 65 | "@babel/plugin-transform-modules-commonjs": "^7.19.6", 66 | "@babel/preset-env": "^7.20.2", 67 | "@babel/register": "^7.18.9", 68 | "@trivago/prettier-plugin-sort-imports": "^3.4.0", 69 | "codecov": "^3.8.3", 70 | "husky": "^8.0.2", 71 | "jest": "^29.3.1", 72 | "lint-staged": "^13.0.3", 73 | "prettier": "^2.8.0", 74 | "rimraf": "^3.0.2" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /scripts/README.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | Publish a tag that matches latest major package version. Make sure to update this line of the Dockerfile: `RUN npm install @foo-software/lighthouse-check@10 -g`. We also publish a "latest" tag. 4 | 5 | - `./scripts/docker-publish.sh -v 10` 6 | - `./scripts/docker-publish.sh -v latest` 7 | 8 | # Test `Dockerfile` 9 | 10 | Build the test image 11 | 12 | ```bash 13 | docker build --no-cache --platform=linux/amd64 --tag "lighthouse-check-test" . 14 | ``` 15 | 16 | Run it 17 | 18 | ```bash 19 | docker run lighthouse-check-test lighthouse-check --urls "https://www.foo.software" 20 | ``` 21 | -------------------------------------------------------------------------------- /scripts/docker-publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DOCKER_TAG_NAME="lighthouse-check" 4 | DOCKER_VERSION="production" 5 | DOCKER_USERNAME="foosoftware" 6 | DOCKERFILE_NAME="Dockerfile" 7 | 8 | # set values from flags -v (version) 9 | while getopts "v:" opt; do 10 | case $opt in 11 | v) 12 | DOCKER_VERSION=$OPTARG 13 | ;; 14 | esac 15 | done 16 | 17 | # if [ "$DOCKER_VERSION" == "base" ] ; then 18 | # DOCKERFILE_NAME="Dockerfile" 19 | # fi 20 | 21 | # if [ "$DOCKER_VERSION" == "dev" ] ; then 22 | # DOCKERFILE_NAME="Dockerfile-dev" 23 | # fi 24 | 25 | BUILD_COMMAND="docker build --no-cache --platform=linux/amd64 -t ${DOCKER_TAG_NAME} . -f ${DOCKERFILE_NAME}" 26 | 27 | echo "${BUILD_COMMAND}" 28 | eval $BUILD_COMMAND 29 | 30 | TAG_COMMAND="docker tag ${DOCKER_TAG_NAME} ${DOCKER_USERNAME}/${DOCKER_TAG_NAME}:${DOCKER_VERSION}" 31 | 32 | echo "${TAG_COMMAND}" 33 | eval $TAG_COMMAND 34 | 35 | PUBLISH_COMMAND="docker push ${DOCKER_USERNAME}/${DOCKER_TAG_NAME}:${DOCKER_VERSION}" 36 | 37 | echo "${PUBLISH_COMMAND}" 38 | eval $PUBLISH_COMMAND 39 | 40 | exit 0 41 | -------------------------------------------------------------------------------- /src/LighthouseCheckError.js: -------------------------------------------------------------------------------- 1 | export default class LighthouseCheckError extends Error { 2 | constructor(...args) { 3 | super(...args); 4 | Error.captureStackTrace(this, LighthouseCheckError); 5 | 6 | const [, options] = args; 7 | this.code = options.code; 8 | this.data = options.data; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`@foo-software/lighthouse-check should match snapshot 1`] = ` 4 | { 5 | "fetchAndWaitForLighthouseAudits": [Function], 6 | "fetchLighthouseAudits": [Function], 7 | "lighthouseCheck": [Function], 8 | "localLighthouse": [Function], 9 | "triggerLighthouse": [Function], 10 | "validateStatus": [Function], 11 | } 12 | `; 13 | -------------------------------------------------------------------------------- /src/__snapshots__/lighthouseConfig.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`config should match snapshot 1`] = ` 4 | { 5 | "desktop": { 6 | "extends": "lighthouse:default", 7 | "settings": { 8 | "emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4420.0 Safari/537.36 Chrome-Lighthouse", 9 | "formFactor": "desktop", 10 | "maxWaitForFcp": 15000, 11 | "maxWaitForLoad": 35000, 12 | "screenEmulation": { 13 | "deviceScaleFactor": 1, 14 | "disabled": false, 15 | "height": 940, 16 | "mobile": false, 17 | "width": 1350, 18 | }, 19 | "skipAudits": [ 20 | "uses-http2", 21 | ], 22 | "throttling": { 23 | "cpuSlowdownMultiplier": 1, 24 | "downloadThroughputKbps": 0, 25 | "requestLatencyMs": 0, 26 | "rttMs": 40, 27 | "throughputKbps": 10240, 28 | "uploadThroughputKbps": 0, 29 | }, 30 | }, 31 | }, 32 | "mobile": { 33 | "extends": "lighthouse:default", 34 | "settings": { 35 | "maxWaitForFcp": 15000, 36 | "maxWaitForLoad": 35000, 37 | "skipAudits": [ 38 | "uses-http2", 39 | ], 40 | }, 41 | }, 42 | "throttling": { 43 | "DEVTOOLS_RTT_ADJUSTMENT_FACTOR": 3.75, 44 | "DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR": 0.9, 45 | "desktopDense4G": { 46 | "cpuSlowdownMultiplier": 1, 47 | "downloadThroughputKbps": 0, 48 | "requestLatencyMs": 0, 49 | "rttMs": 40, 50 | "throughputKbps": 10240, 51 | "uploadThroughputKbps": 0, 52 | }, 53 | "mobileRegular3G": { 54 | "cpuSlowdownMultiplier": 4, 55 | "downloadThroughputKbps": 630, 56 | "requestLatencyMs": 1125, 57 | "rttMs": 300, 58 | "throughputKbps": 700, 59 | "uploadThroughputKbps": 630, 60 | }, 61 | "mobileSlow4G": { 62 | "cpuSlowdownMultiplier": 4, 63 | "downloadThroughputKbps": 1474.5600000000002, 64 | "requestLatencyMs": 562.5, 65 | "rttMs": 150, 66 | "throughputKbps": 1638.4, 67 | "uploadThroughputKbps": 675, 68 | }, 69 | }, 70 | } 71 | `; 72 | -------------------------------------------------------------------------------- /src/__snapshots__/lighthouseOptions.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`options should match snapshot 1`] = ` 4 | { 5 | "chromeFlags": [ 6 | "--disable-dev-shm-usage", 7 | "--disable-gpu", 8 | "--headless", 9 | "--no-sandbox", 10 | "--ignore-certificate-errors", 11 | ], 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /src/__snapshots__/triggerLighthouse.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`triggerLighthouse() on fail should return an expected response payload when all URLs failed to queue 1`] = ` 4 | { 5 | "code": "SOME_ERROR", 6 | "data": [ 7 | { 8 | "code": "SOME_ERROR", 9 | "message": "some error message", 10 | "status": 401, 11 | }, 12 | { 13 | "code": "SOME_ERROR", 14 | "message": "some error message", 15 | "status": 401, 16 | }, 17 | ], 18 | "error": [Error: All URLs failed to be enqueued.], 19 | } 20 | `; 21 | 22 | exports[`triggerLighthouse() on fail should return an expected response payload when all URLs failed to queue due to max limit reached 1`] = ` 23 | { 24 | "code": "ERROR_QUEUE_MAX_USED_DAY", 25 | "data": [ 26 | { 27 | "code": "ERROR_QUEUE_MAX_USED_DAY", 28 | "message": "Max limit of 5 triggers reached.", 29 | "status": 401, 30 | }, 31 | { 32 | "code": "ERROR_QUEUE_MAX_USED_DAY", 33 | "message": "Max limit of 5 triggers reached.", 34 | "status": 401, 35 | }, 36 | ], 37 | "error": [Error: Max limit of 5 triggers reached.], 38 | } 39 | `; 40 | 41 | exports[`triggerLighthouse() on fail should return an expected response payload when api key is invalid 1`] = ` 42 | { 43 | "code": "ERROR_UNAUTHORIZED", 44 | "error": [Error: Account wasn't found for the provided API token.], 45 | } 46 | `; 47 | 48 | exports[`triggerLighthouse() on fail should return an expected response payload when no pages are found 1`] = ` 49 | { 50 | "code": "ERROR_NO_URLS", 51 | "error": [Error: No URLs were found for this account.], 52 | } 53 | `; 54 | 55 | exports[`triggerLighthouse() on fail should return an expected response payload when no queue results are returned 1`] = ` 56 | { 57 | "code": "ERROR_NO_RESULTS", 58 | "error": [Error: No results.], 59 | } 60 | `; 61 | 62 | exports[`triggerLighthouse() on success should return an expected response payload 1`] = ` 63 | { 64 | "code": "SUCCESS", 65 | "data": [ 66 | { 67 | "code": "SUCCESS_QUEUE_ADD", 68 | "status": 200, 69 | }, 70 | { 71 | "code": "SUCCESS_QUEUE_ADD", 72 | "status": 200, 73 | }, 74 | ], 75 | "message": "2 URLs successfully enqueued for Lighthouse. Visit dashboard for results.", 76 | } 77 | `; 78 | 79 | exports[`triggerLighthouse() on success should return an expected response payload when some URLs failed to queue but some succeeded 1`] = ` 80 | { 81 | "code": "SUCCESS", 82 | "data": [ 83 | { 84 | "code": "ERROR_QUEUE_MAX_USED_DAY", 85 | "message": "Max limit of 5 triggers reached.", 86 | "status": 401, 87 | }, 88 | { 89 | "code": "ERROR_QUEUE_MAX_USED_DAY", 90 | "message": "Max limit of 5 triggers reached.", 91 | "status": 401, 92 | }, 93 | { 94 | "code": "SUCCESS_QUEUE_ADD", 95 | "status": 200, 96 | }, 97 | ], 98 | "message": "Only one of your account URLs were enqueued. Typically this occurs when daily limit has been met for a given URL. Check your account limits online.", 99 | } 100 | `; 101 | -------------------------------------------------------------------------------- /src/bin/lighthouse-check-status.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | import ora from 'ora'; 3 | import { NAME, NAME_STATUS } from '../constants'; 4 | import { ERROR_INVALID } from '../errorCodes'; 5 | import { convertOptionsFromArguments } from '../helpers/arguments'; 6 | import getHelpText from '../helpers/getHelpText'; 7 | import validateStatus from '../validateStatus'; 8 | 9 | const defaultOptions = { 10 | minAccessibilityScore: { 11 | type: 'number', 12 | value: undefined, 13 | }, 14 | minBestPracticesScore: { 15 | type: 'number', 16 | value: undefined, 17 | }, 18 | minPerformanceScore: { 19 | type: 'number', 20 | value: undefined, 21 | }, 22 | minProgressiveWebAppScore: { 23 | type: 'number', 24 | value: undefined, 25 | }, 26 | minSeoScore: { 27 | type: 'number', 28 | value: undefined, 29 | }, 30 | help: { 31 | type: 'boolean', 32 | value: undefined, 33 | }, 34 | outputDirectory: { 35 | type: 'string', 36 | value: undefined, 37 | }, 38 | verbose: { 39 | type: 'boolean', 40 | value: false, 41 | }, 42 | }; 43 | 44 | // override options with any that are passed in as arguments 45 | let params = convertOptionsFromArguments(defaultOptions); 46 | 47 | const init = async () => { 48 | const spinner = ora(`${NAME}: Running...\n`); 49 | 50 | try { 51 | if (!params.verbose) { 52 | console.log('\n'); 53 | spinner.start(); 54 | } 55 | 56 | await validateStatus(params); 57 | 58 | process.exit(); 59 | } catch (error) { 60 | if (!params.verbose) { 61 | spinner.stop(); 62 | } else { 63 | console.log('\n'); 64 | } 65 | 66 | if (error && error.code && error.code === ERROR_INVALID) { 67 | console.log('❌ ', `${error}`); 68 | } else { 69 | console.log( 70 | '❌ Something went wrong while attempting to enqueue URLs for Lighthouse. See the error below.\n\n', 71 | error, 72 | ); 73 | } 74 | 75 | console.log('\n'); 76 | process.exit(1); 77 | } 78 | }; 79 | 80 | if (params.help) { 81 | console.log(getHelpText(NAME_STATUS)); 82 | } else { 83 | init(); 84 | } 85 | -------------------------------------------------------------------------------- /src/bin/lighthouse-check.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | import fs from 'fs'; 3 | import ora from 'ora'; 4 | import path from 'path'; 5 | import { NAME } from '../constants'; 6 | import { convertOptionsFromArguments } from '../helpers/arguments'; 7 | import getHelpText from '../helpers/getHelpText'; 8 | import lighthouseCheck from '../lighthouseCheck'; 9 | 10 | const defaultOptions = { 11 | author: { 12 | type: 'string', 13 | value: undefined, 14 | }, 15 | apiToken: { 16 | type: 'string', 17 | value: undefined, 18 | }, 19 | awsAccessKeyId: { 20 | type: 'string', 21 | value: undefined, 22 | }, 23 | awsBucket: { 24 | type: 'string', 25 | value: undefined, 26 | }, 27 | awsRegion: { 28 | type: 'string', 29 | value: undefined, 30 | }, 31 | awsSecretAccessKey: { 32 | type: 'string', 33 | value: undefined, 34 | }, 35 | branch: { 36 | type: 'string', 37 | value: undefined, 38 | }, 39 | configFile: { 40 | type: 'string', 41 | value: undefined, 42 | }, 43 | device: { 44 | type: 'string', 45 | value: undefined, 46 | }, 47 | emulatedFormFactor: { 48 | type: 'string', 49 | value: undefined, 50 | }, 51 | extraHeaders: { 52 | type: 'object', 53 | value: undefined, 54 | }, 55 | locale: { 56 | type: 'string', 57 | value: undefined, 58 | }, 59 | help: { 60 | type: 'boolean', 61 | value: undefined, 62 | }, 63 | isGitHubAction: { 64 | type: 'boolean', 65 | value: undefined, 66 | }, 67 | isOrb: { 68 | type: 'boolean', 69 | value: undefined, 70 | }, 71 | maxRetries: { 72 | type: 'number', 73 | value: 0, 74 | }, 75 | maxWaitForLoad: { 76 | type: 'number', 77 | value: undefined, 78 | }, 79 | outputDirectory: { 80 | type: 'string', 81 | value: undefined, 82 | }, 83 | overridesJsonFile: { 84 | type: 'string', 85 | value: undefined, 86 | }, 87 | pr: { 88 | type: 'string', 89 | value: undefined, 90 | }, 91 | prCommentAccessToken: { 92 | type: 'string', 93 | value: undefined, 94 | }, 95 | prCommentEnabled: { 96 | type: 'boolean', 97 | value: true, 98 | }, 99 | prCommentSaveOld: { 100 | type: 'boolean', 101 | value: false, 102 | }, 103 | prCommentUrl: { 104 | type: 'string', 105 | value: undefined, 106 | }, 107 | sha: { 108 | type: 'string', 109 | value: undefined, 110 | }, 111 | slackWebhookUrl: { 112 | type: 'string', 113 | value: undefined, 114 | }, 115 | tag: { 116 | type: 'string', 117 | value: undefined, 118 | }, 119 | timeout: { 120 | type: 'number', 121 | value: undefined, 122 | }, 123 | throttling: { 124 | type: 'string', 125 | value: undefined, 126 | }, 127 | throttlingMethod: { 128 | type: 'string', 129 | value: undefined, 130 | }, 131 | urls: { 132 | type: 'array', 133 | value: undefined, 134 | }, 135 | verbose: { 136 | type: 'boolean', 137 | value: false, 138 | }, 139 | wait: { 140 | type: 'boolean', 141 | value: undefined, 142 | }, 143 | }; 144 | 145 | // override options with any that are passed in as arguments 146 | let params = convertOptionsFromArguments(defaultOptions); 147 | 148 | const init = async () => { 149 | const spinner = ora(`${NAME}: Running...\n`); 150 | 151 | try { 152 | if (params.configFile) { 153 | const configFile = path.resolve(params.configFile); 154 | const configJsonString = fs.readFileSync(configFile).toString(); 155 | const configJson = JSON.parse(configJsonString); 156 | 157 | // extend params with config json file contents 158 | params = { 159 | ...params, 160 | ...configJson, 161 | }; 162 | } 163 | 164 | if (!params.verbose) { 165 | console.log('\n'); 166 | spinner.start(); 167 | } 168 | 169 | await lighthouseCheck(params); 170 | 171 | process.exit(); 172 | } catch (error) { 173 | if (!params.verbose) { 174 | spinner.stop(); 175 | } else { 176 | console.log('\n'); 177 | } 178 | 179 | console.log( 180 | '❌ Something went wrong while attempting to enqueue URLs for Lighthouse. See the error below.\n\n', 181 | error, 182 | ); 183 | console.log('\n'); 184 | process.exit(1); 185 | } 186 | }; 187 | 188 | if (params.help) { 189 | console.log(getHelpText(NAME)); 190 | } else { 191 | init(); 192 | } 193 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | // non-alphabetical because some constants depend on others :( 2 | export const API_LIGHTHOUSE_AUDIT_PATH = '/lighthouseAudits/queueIds'; 3 | export const API_PAGES_PATH = '/pages'; 4 | export const API_QUEUE_ITEMS_PATH = '/queue/items'; 5 | 6 | // 10 minutes 7 | export const DEFAULT_FETCH_AND_WAIT_TIMEOUT_MINUTES = 10; 8 | 9 | // 10 seconds 10 | export const FETCH_POLL_INTERVAL_SECONDS = 10; 11 | export const FETCH_POLL_INTERVAL = FETCH_POLL_INTERVAL_SECONDS * 1000; 12 | export const LIGHTHOUSE_API_URL = 'https://www.foo.software/api/v1'; 13 | export const API_URL = process.env.API_URL || LIGHTHOUSE_API_URL; 14 | export const NAME = 'lighthouse-check'; 15 | export const NAME_RESULTS_JSON_FILE = 'results.json'; 16 | export const NAME_STATUS = 'lighthouse-check-status'; 17 | export const DEFAULT_TAG = NAME; 18 | export const SOURCE_GITHUB_ACTION = 'GitHub Action'; 19 | export const SOURCE_CIRCLECI_ORB = 'CircleCI Orb'; 20 | export const SUCCESS_CODE_GENERIC = 'SUCCESS'; 21 | export const TRIGGER_TYPE = 'lighthouseAudit'; 22 | -------------------------------------------------------------------------------- /src/errorCodes.js: -------------------------------------------------------------------------------- 1 | export const ERROR_GENERIC = 'ERROR_GENERIC'; 2 | export const ERROR_INVALID = 'ERROR_INVALID'; 3 | export const ERROR_NO_RESULTS = 'ERROR_NO_RESULTS'; 4 | export const ERROR_NO_URLS = 'ERROR_NO_URLS'; 5 | export const ERROR_RUNTIME = 'ERROR_RUNTIME'; 6 | export const ERROR_TIMEOUT = 'ERROR_TIMEOUT'; 7 | export const ERROR_UNEXPECTED_RESPONSE = 'ERROR_UNEXPECTED_RESPONSE'; 8 | export const ERROR_UNAUTHORIZED = 'ERROR_UNAUTHORIZED'; 9 | 10 | // from API 11 | export const ERROR_QUEUE_MAX_USED_DAY = 'ERROR_QUEUE_MAX_USED_DAY'; 12 | -------------------------------------------------------------------------------- /src/fetch.js: -------------------------------------------------------------------------------- 1 | // this should only be defined when working locally. 2 | if (process.env.API_URL) { 3 | process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = 0; 4 | } 5 | 6 | // a bit of a hack until we only support ES Modules. 7 | // at least this is the officially recommended hack 8 | // https://github.com/node-fetch/node-fetch#commonjs 9 | // note the key here: 10 | // https://github.com/microsoft/TypeScript/pull/44501#issue-914346744 11 | const fetch = (...args) => 12 | import('node-fetch').then(({ default: module }) => module(...args)); 13 | 14 | export default fetch; 15 | -------------------------------------------------------------------------------- /src/fetchAndWaitForLighthouseAudits.js: -------------------------------------------------------------------------------- 1 | import LighthouseCheckError from './LighthouseCheckError'; 2 | import { 3 | DEFAULT_FETCH_AND_WAIT_TIMEOUT_MINUTES, 4 | FETCH_POLL_INTERVAL, 5 | FETCH_POLL_INTERVAL_SECONDS, 6 | NAME, 7 | } from './constants'; 8 | import { ERROR_NO_RESULTS, ERROR_TIMEOUT } from './errorCodes'; 9 | import fetchLighthouseAudits from './fetchLighthouseAudits'; 10 | 11 | export default ({ 12 | apiToken, 13 | queueIds, 14 | timeout = DEFAULT_FETCH_AND_WAIT_TIMEOUT_MINUTES, 15 | verbose = true, 16 | }) => 17 | new Promise((resolve, reject) => { 18 | const timeoutMilliseconds = 60000 * timeout; 19 | const startTime = Date.now(); 20 | let fetchIndex = 0; 21 | 22 | const fetchData = (interval) => 23 | setTimeout(async () => { 24 | fetchIndex++; 25 | 26 | if (verbose) { 27 | console.log( 28 | `${NAME}:`, 29 | `Starting Lighthouse fetch attempt ${fetchIndex}.`, 30 | ); 31 | } 32 | 33 | const result = await fetchLighthouseAudits({ 34 | apiToken, 35 | queueIds, 36 | }); 37 | 38 | // do we have the expected number of results 39 | const areResultsExpected = 40 | result.data && result.data.length === queueIds.length; 41 | 42 | // have we exceeded the timeout 43 | const now = Date.now(); 44 | const hasExceededTimeout = now - startTime > timeoutMilliseconds; 45 | 46 | // has unexpected error 47 | const isErrorUnexpected = 48 | result.error && 49 | (!result.error.code || result.error.code !== ERROR_NO_RESULTS); 50 | 51 | const resultLength = !Array.isArray(result.data) 52 | ? 0 53 | : result.data.length; 54 | 55 | if (isErrorUnexpected) { 56 | if (verbose) { 57 | console.log( 58 | `${NAME}:`, 59 | 'An unexpected error occurred:\n', 60 | result.error, 61 | ); 62 | } 63 | reject(result.error); 64 | return; 65 | } else if (areResultsExpected) { 66 | const audits = result.data.map((current) => ({ 67 | emulatedFormFactor: current.device, 68 | id: current.pageId, 69 | name: current.name, 70 | report: current.report, 71 | url: current.url, 72 | tag: current.tag, 73 | scores: { 74 | accessibility: current.scoreAccessibility, 75 | bestPractices: current.scoreBestPractices, 76 | performance: current.scorePerformance, 77 | progressiveWebApp: current.scoreProgressiveWebApp, 78 | seo: current.scoreSeo, 79 | }, 80 | })); 81 | 82 | resolve(audits); 83 | return; 84 | } else if (hasExceededTimeout) { 85 | const errorMessage = `Received ${resultLength} out of ${queueIds.length} results. ${timeout} minute timeout reached.`; 86 | if (verbose) { 87 | console.log(`${NAME}:`, errorMessage); 88 | } 89 | 90 | reject( 91 | new LighthouseCheckError(errorMessage, { 92 | code: ERROR_TIMEOUT, 93 | }), 94 | ); 95 | return; 96 | } else { 97 | if (verbose) { 98 | console.log( 99 | `${NAME}:`, 100 | `Received ${resultLength} out of ${queueIds.length} results. Trying again in ${FETCH_POLL_INTERVAL_SECONDS} seconds.`, 101 | ); 102 | } 103 | 104 | fetchData(FETCH_POLL_INTERVAL); 105 | } 106 | }, interval); 107 | 108 | // we pass in 0 as the interval because we don't need to 109 | // wait the first time. 110 | fetchData(0); 111 | }); 112 | -------------------------------------------------------------------------------- /src/fetchLighthouseAudits.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash.get'; 2 | import LighthouseCheckError from './LighthouseCheckError'; 3 | import { 4 | API_LIGHTHOUSE_AUDIT_PATH, 5 | API_URL, 6 | SUCCESS_CODE_GENERIC, 7 | } from './constants'; 8 | import { ERROR_GENERIC, ERROR_NO_RESULTS } from './errorCodes'; 9 | import fetch from './fetch'; 10 | 11 | export default async ({ apiToken, queueIds }) => { 12 | try { 13 | const lighthouseAuditsResponse = await fetch( 14 | `${API_URL}${API_LIGHTHOUSE_AUDIT_PATH}/${queueIds.join()}`, 15 | { 16 | method: 'get', 17 | headers: { 18 | Authorization: apiToken, 19 | 'Content-Type': 'application/json', 20 | }, 21 | }, 22 | ); 23 | const lighthouseAuditsJson = await lighthouseAuditsResponse.json(); 24 | 25 | if (lighthouseAuditsJson.status >= 400) { 26 | throw new LighthouseCheckError('No results found.', { 27 | code: ERROR_NO_RESULTS, 28 | }); 29 | } 30 | 31 | const lighthouseResults = get( 32 | lighthouseAuditsJson, 33 | 'data.lighthouseaudit', 34 | [], 35 | ); 36 | 37 | // success 38 | return { 39 | code: SUCCESS_CODE_GENERIC, 40 | data: lighthouseResults, 41 | message: 'Lighthouse results successfully fetched.', 42 | }; 43 | } catch (error) { 44 | return { 45 | code: error.code || ERROR_GENERIC, 46 | error, 47 | }; 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /src/helpers/arguments.js: -------------------------------------------------------------------------------- 1 | import meow from 'meow'; 2 | 3 | // we should note that these values are expected from the command 4 | // line and that they're always strings. 5 | export const convertOptionsFromArguments = (options) => { 6 | const cli = meow(); 7 | 8 | return Object.keys(options).reduce((accumulator, current) => { 9 | // get the argument value 10 | const argumentValue = cli.flags[current]; 11 | const option = options[current]; 12 | 13 | // if the value doesn't exist from an argument, use the existing option / value 14 | let value = 15 | typeof argumentValue !== 'undefined' ? argumentValue : option.value; 16 | 17 | // format boolean 18 | if (option.type === 'boolean' && typeof value === 'string') { 19 | const lowerCaseValue = value.toLowerCase(); 20 | 21 | // convert string boolean to boolean 22 | if (lowerCaseValue === 'true') { 23 | value = true; 24 | } else if (lowerCaseValue === 'false') { 25 | value = false; 26 | } else if (lowerCaseValue === '') { 27 | // if we received an argument as a flag 28 | value = true; 29 | } 30 | } 31 | 32 | // format number 33 | if (option.type === 'number' && typeof value !== 'number') { 34 | value = Number(value); 35 | } 36 | 37 | // format array 38 | if (option.type === 'array' && typeof value === 'string') { 39 | value = value.split(','); 40 | } 41 | 42 | // format object 43 | if (option.type === 'object' && value) { 44 | value = JSON.parse(value); 45 | } 46 | 47 | if ( 48 | (option.type === 'string' && !value) || 49 | (option.type !== 'boolean' && typeof value === 'boolean') 50 | ) { 51 | value = undefined; 52 | } 53 | 54 | return { 55 | ...accumulator, 56 | [current]: value, 57 | }; 58 | }, {}); 59 | }; 60 | -------------------------------------------------------------------------------- /src/helpers/getHelpText.js: -------------------------------------------------------------------------------- 1 | export default (name) => ` 2 | Usage: ${name} --option 3 | 4 | See all options on GitHub: https://github.com/foo-software/lighthouse-check-cli#options 5 | `; 6 | -------------------------------------------------------------------------------- /src/helpers/getLighthouseAuditTitlesByKey.js: -------------------------------------------------------------------------------- 1 | import lighthouseAuditTitles from '../lighthouseAuditTitles'; 2 | 3 | export default (keys) => keys.map((current) => lighthouseAuditTitles[current]); 4 | -------------------------------------------------------------------------------- /src/helpers/getLighthouseAuditTitlesByKey.test.js: -------------------------------------------------------------------------------- 1 | import getLighthouseAuditTitlesByKey from './getLighthouseAuditTitlesByKey'; 2 | 3 | describe('getLighthouseAuditTitlesByKey', () => { 4 | it('should return the correct value of a command argument', () => { 5 | const expected = [ 6 | 'Accessibility', 7 | 'Best Practices', 8 | 'Performance', 9 | 'Progressive Web App', 10 | 'SEO', 11 | ]; 12 | 13 | const actual = getLighthouseAuditTitlesByKey([ 14 | 'accessibility', 15 | 'bestPractices', 16 | 'performance', 17 | 'progressiveWebApp', 18 | 'seo', 19 | ]); 20 | 21 | expect(actual).toEqual(expected); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/helpers/getLighthouseScoreColor.js: -------------------------------------------------------------------------------- 1 | // https://developers.google.com/web/tools/lighthouse/v3/scoring 2 | export default ({ isHex, score }) => { 3 | if (typeof score !== 'number') { 4 | return !isHex ? 'lightgrey' : '#e0e0e0'; 5 | } 6 | 7 | let scoreColor = !isHex ? 'green' : '#0cce6b'; 8 | 9 | // medium range 10 | if (score < 90) { 11 | scoreColor = !isHex ? 'orange' : '#ffa400'; 12 | } 13 | 14 | // bad 15 | if (score < 50) { 16 | scoreColor = !isHex ? 'red' : '#f74531'; 17 | } 18 | 19 | return scoreColor; 20 | }; 21 | -------------------------------------------------------------------------------- /src/helpers/utils.js: -------------------------------------------------------------------------------- 1 | export const delay = (timeout) => 2 | new Promise((resolve) => setTimeout(resolve, timeout)); 3 | -------------------------------------------------------------------------------- /src/helpers/writeResults.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { NAME_RESULTS_JSON_FILE } from '../constants'; 3 | 4 | export default ({ outputDirectory, results }) => { 5 | const resultsJsonFile = `${outputDirectory}/${NAME_RESULTS_JSON_FILE}`; 6 | fs.writeFileSync(resultsJsonFile, JSON.stringify(results)); 7 | }; 8 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as validateStatus } from './validateStatus'; 2 | export { default as fetchAndWaitForLighthouseAudits } from './fetchAndWaitForLighthouseAudits'; 3 | export { default as fetchLighthouseAudits } from './fetchLighthouseAudits'; 4 | export { default as lighthouseCheck } from './lighthouseCheck'; 5 | export { default as localLighthouse } from './localLighthouse'; 6 | export { default as triggerLighthouse } from './triggerLighthouse'; 7 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import * as lighthouseCheckApi from '.'; 2 | 3 | describe('@foo-software/lighthouse-check', () => { 4 | it('should match snapshot', () => { 5 | expect(lighthouseCheckApi).toMatchSnapshot(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/lighthouseAuditTitles.js: -------------------------------------------------------------------------------- 1 | export default { 2 | accessibility: 'Accessibility', 3 | bestPractices: 'Best Practices', 4 | performance: 'Performance', 5 | progressiveWebApp: 'Progressive Web App', 6 | seo: 'SEO', 7 | }; 8 | -------------------------------------------------------------------------------- /src/lighthouseCheck.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import LighthouseCheckError from './LighthouseCheckError'; 3 | import { NAME, SUCCESS_CODE_GENERIC } from './constants'; 4 | import { ERROR_NO_RESULTS, ERROR_RUNTIME } from './errorCodes'; 5 | import fetchAndWaitForLighthouseAudits from './fetchAndWaitForLighthouseAudits'; 6 | import writeResults from './helpers/writeResults'; 7 | import localLighthouse from './localLighthouse'; 8 | import logResults from './logResults'; 9 | import postPrComment from './postPrComment'; 10 | import slackNotify from './slackNotify'; 11 | import triggerLighthouse from './triggerLighthouse'; 12 | 13 | export default ({ 14 | apiToken, 15 | author, 16 | awsAccessKeyId, 17 | awsBucket, 18 | awsRegion, 19 | awsSecretAccessKey, 20 | 21 | // `device` and `emulatedFormFactor` are the same things, but we 22 | // support them both to accommodate older consumers. `device` should 23 | // be the standard moving forward. 24 | device, 25 | 26 | // we should really update this someday to use `formFactor` which 27 | // is now used by Lighthouse 28 | emulatedFormFactor: paramEmulatedFormFactor = 'mobile', 29 | extraHeaders, 30 | branch, 31 | isGitHubAction, 32 | isOrb, 33 | locale, 34 | maxWaitForLoad, 35 | maxRetries = 0, 36 | outputDirectory, 37 | overridesJsonFile, 38 | pr, 39 | prCommentAccessToken, 40 | prCommentEnabled = true, 41 | prCommentSaveOld = false, 42 | prCommentUrl, 43 | sha, 44 | tag, 45 | throttling, 46 | throttlingMethod, 47 | timeout, 48 | urls, 49 | verbose = true, 50 | wait = true, 51 | slackWebhookUrl, 52 | }) => 53 | new Promise(async (resolve, reject) => { 54 | try { 55 | const emulatedFormFactor = device || paramEmulatedFormFactor; 56 | const outputDirectoryPath = !outputDirectory 57 | ? outputDirectory 58 | : path.resolve(outputDirectory); 59 | 60 | // we either get the result from the API or directly from 61 | // running a lighthouse audit locally. 62 | const isLocalAudit = !apiToken; 63 | 64 | // if we're auditing through the foo.software API, 65 | // otherwise we're using Lighthouse directly, locally 66 | if (!isLocalAudit) { 67 | const triggerResult = await triggerLighthouse({ 68 | apiToken, 69 | device: emulatedFormFactor, 70 | isGitHubAction, 71 | isOrb, 72 | tag, 73 | timeout, 74 | urls, 75 | verbose, 76 | }); 77 | 78 | if (triggerResult.error) { 79 | reject(triggerResult.error); 80 | return; 81 | } 82 | 83 | // if the user understandably doesn't want to wait for results, return right away 84 | if (!wait) { 85 | resolve(triggerResult); 86 | return; 87 | } 88 | 89 | // if this condition doesn't pass - we got a problem 90 | if (triggerResult.data) { 91 | // assemble an array of queueIds 92 | const queueIds = triggerResult.data.reduce( 93 | (accumulator, current) => [ 94 | ...accumulator, 95 | ...(!current.id ? [] : [current.id]), 96 | ], 97 | [], 98 | ); 99 | 100 | // if this condition doesn't pass - we got a problem 101 | if (queueIds.length) { 102 | if (!verbose) { 103 | console.log('\n'); 104 | } 105 | 106 | const auditResults = await fetchAndWaitForLighthouseAudits({ 107 | apiToken, 108 | queueIds, 109 | timeout, 110 | verbose, 111 | }); 112 | 113 | // if output directory is specified write the results to disk 114 | if (outputDirectoryPath) { 115 | writeResults({ 116 | outputDirectory: outputDirectoryPath, 117 | results: auditResults, 118 | }); 119 | } 120 | 121 | if (slackWebhookUrl) { 122 | await slackNotify({ 123 | author, 124 | branch, 125 | pr, 126 | results: auditResults, 127 | sha, 128 | slackWebhookUrl, 129 | verbose, 130 | }); 131 | } 132 | 133 | if (prCommentEnabled && prCommentUrl && prCommentAccessToken) { 134 | await postPrComment({ 135 | isGitHubAction, 136 | isLocalAudit, 137 | isOrb, 138 | prCommentAccessToken, 139 | prCommentSaveOld, 140 | prCommentUrl, 141 | results: auditResults, 142 | verbose, 143 | }); 144 | } 145 | 146 | logResults({ 147 | isGitHubAction, 148 | isLocalAudit, 149 | isOrb, 150 | results: auditResults, 151 | }); 152 | 153 | // success 154 | resolve({ 155 | code: SUCCESS_CODE_GENERIC, 156 | data: auditResults, 157 | }); 158 | return; 159 | } 160 | } 161 | 162 | const errorMessage = 'Failed to retrieve results.'; 163 | if (verbose) { 164 | console.log(`${NAME}:`, errorMessage); 165 | } 166 | 167 | reject( 168 | new LighthouseCheckError(errorMessage, { 169 | code: ERROR_NO_RESULTS, 170 | }), 171 | ); 172 | } else { 173 | const lighthouseAudits = await localLighthouse({ 174 | awsAccessKeyId, 175 | awsBucket, 176 | awsRegion, 177 | awsSecretAccessKey, 178 | emulatedFormFactor, 179 | extraHeaders, 180 | locale, 181 | maxRetries, 182 | maxWaitForLoad, 183 | outputDirectory: outputDirectoryPath, 184 | overridesJsonFile: 185 | overridesJsonFile && path.resolve(overridesJsonFile), 186 | throttling, 187 | throttlingMethod, 188 | urls, 189 | verbose, 190 | }); 191 | 192 | if (!lighthouseAudits.length) { 193 | reject( 194 | new LighthouseCheckError('Something went wrong - no results.', { 195 | code: ERROR_NO_RESULTS, 196 | }), 197 | ); 198 | } else { 199 | if (slackWebhookUrl) { 200 | await slackNotify({ 201 | author, 202 | branch, 203 | pr, 204 | results: lighthouseAudits, 205 | sha, 206 | slackWebhookUrl, 207 | verbose, 208 | }); 209 | } 210 | 211 | if (prCommentEnabled && prCommentUrl && prCommentAccessToken) { 212 | await postPrComment({ 213 | isGitHubAction, 214 | isLocalAudit, 215 | isOrb, 216 | prCommentAccessToken, 217 | prCommentSaveOld, 218 | prCommentUrl, 219 | results: lighthouseAudits, 220 | verbose, 221 | }); 222 | } 223 | 224 | logResults({ 225 | isGitHubAction, 226 | isLocalAudit, 227 | isOrb, 228 | results: lighthouseAudits, 229 | }); 230 | 231 | // success 232 | resolve({ 233 | code: SUCCESS_CODE_GENERIC, 234 | data: lighthouseAudits, 235 | }); 236 | } 237 | } 238 | } catch (error) { 239 | if (verbose) { 240 | console.log(`${NAME}:\n`, error); 241 | } 242 | 243 | reject(error); 244 | } 245 | }); 246 | -------------------------------------------------------------------------------- /src/lighthouseConfig.js: -------------------------------------------------------------------------------- 1 | // https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/config/constants.js 2 | /** 3 | * Adjustments needed for DevTools network throttling to simulate 4 | * more realistic network conditions. 5 | * @see https://crbug.com/721112 6 | * @see https://docs.google.com/document/d/10lfVdS1iDWCRKQXPfbxEn4Or99D64mvNlugP1AQuFlE/edit 7 | */ 8 | const DEVTOOLS_RTT_ADJUSTMENT_FACTOR = 3.75; 9 | const DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR = 0.9; 10 | 11 | export const throttling = { 12 | DEVTOOLS_RTT_ADJUSTMENT_FACTOR, 13 | DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR, 14 | // These values align with WebPageTest's definition of "Fast 3G" 15 | // But offer similar charateristics to roughly the 75th percentile of 4G connections. 16 | mobileSlow4G: { 17 | rttMs: 150, 18 | throughputKbps: 1.6 * 1024, 19 | requestLatencyMs: 150 * DEVTOOLS_RTT_ADJUSTMENT_FACTOR, 20 | downloadThroughputKbps: 1.6 * 1024 * DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR, 21 | uploadThroughputKbps: 750 * DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR, 22 | cpuSlowdownMultiplier: 4, 23 | }, 24 | // These values partially align with WebPageTest's definition of "Regular 3G". 25 | // These values are meant to roughly align with Chrome UX report's 3G definition which are based 26 | // on HTTP RTT of 300-1400ms and downlink throughput of <700kbps. 27 | mobileRegular3G: { 28 | rttMs: 300, 29 | throughputKbps: 700, 30 | requestLatencyMs: 300 * DEVTOOLS_RTT_ADJUSTMENT_FACTOR, 31 | downloadThroughputKbps: 700 * DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR, 32 | uploadThroughputKbps: 700 * DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR, 33 | cpuSlowdownMultiplier: 4, 34 | }, 35 | // Using a "broadband" connection type 36 | // Corresponds to "Dense 4G 25th percentile" in https://docs.google.com/document/d/1Ft1Bnq9-t4jK5egLSOc28IL4TvR-Tt0se_1faTA4KTY/edit#heading=h.bb7nfy2x9e5v 37 | desktopDense4G: { 38 | rttMs: 40, 39 | throughputKbps: 10 * 1024, 40 | cpuSlowdownMultiplier: 1, 41 | requestLatencyMs: 0, // 0 means unset 42 | downloadThroughputKbps: 0, 43 | uploadThroughputKbps: 0, 44 | }, 45 | }; 46 | 47 | /** 48 | * @type {Required} 49 | */ 50 | const MOTOG4_EMULATION_METRICS = { 51 | mobile: true, 52 | width: 360, 53 | height: 640, 54 | // Moto G4 is really 3, but a higher value here works against 55 | // our perf recommendations. 56 | // https://github.com/GoogleChrome/lighthouse/issues/10741#issuecomment-626903508 57 | deviceScaleFactor: 2.625, 58 | disabled: false, 59 | }; 60 | 61 | /** 62 | * Desktop metrics adapted from emulated_devices/module.json 63 | * @type {Required} 64 | */ 65 | const DESKTOP_EMULATION_METRICS = { 66 | mobile: false, 67 | width: 1350, 68 | height: 940, 69 | deviceScaleFactor: 1, 70 | disabled: false, 71 | }; 72 | 73 | const screenEmulationMetrics = { 74 | mobile: MOTOG4_EMULATION_METRICS, 75 | desktop: DESKTOP_EMULATION_METRICS, 76 | }; 77 | 78 | // eslint-disable-next-line max-len 79 | const MOTOG4_USERAGENT = 80 | 'Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4420.0 Mobile Safari/537.36 Chrome-Lighthouse'; 81 | // eslint-disable-next-line max-len 82 | const DESKTOP_USERAGENT = 83 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4420.0 Safari/537.36 Chrome-Lighthouse'; 84 | 85 | const userAgents = { 86 | mobile: MOTOG4_USERAGENT, 87 | desktop: DESKTOP_USERAGENT, 88 | }; 89 | 90 | // https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/config/lr-desktop-config.js 91 | export const desktop = { 92 | extends: 'lighthouse:default', 93 | settings: { 94 | maxWaitForFcp: 15 * 1000, 95 | maxWaitForLoad: 35 * 1000, 96 | formFactor: 'desktop', 97 | throttling: throttling.desktopDense4G, 98 | screenEmulation: screenEmulationMetrics.desktop, 99 | emulatedUserAgent: userAgents.desktop, 100 | // Skip the h2 audit so it doesn't lie to us. See https://github.com/GoogleChrome/lighthouse/issues/6539 101 | skipAudits: ['uses-http2'], 102 | }, 103 | }; 104 | 105 | // https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/config/lr-mobile-config.js 106 | export const mobile = { 107 | extends: 'lighthouse:default', 108 | settings: { 109 | maxWaitForFcp: 15 * 1000, 110 | maxWaitForLoad: 35 * 1000, 111 | // lighthouse:default is mobile by default 112 | // Skip the h2 audit so it doesn't lie to us. See https://github.com/GoogleChrome/lighthouse/issues/6539 113 | skipAudits: ['uses-http2'], 114 | }, 115 | }; 116 | -------------------------------------------------------------------------------- /src/lighthouseConfig.test.js: -------------------------------------------------------------------------------- 1 | import * as config from './lighthouseConfig'; 2 | 3 | describe('config', () => { 4 | it('should match snapshot', () => { 5 | expect(config).toMatchSnapshot(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/lighthouseOptions.js: -------------------------------------------------------------------------------- 1 | export default { 2 | chromeFlags: [ 3 | '--disable-dev-shm-usage', 4 | '--disable-gpu', 5 | '--headless', 6 | '--no-sandbox', 7 | '--ignore-certificate-errors', 8 | ], 9 | ...(!process.env.LOG_LEVEL 10 | ? {} 11 | : { 12 | logLevel: process.env.LOG_LEVEL, 13 | }), 14 | }; 15 | -------------------------------------------------------------------------------- /src/lighthouseOptions.test.js: -------------------------------------------------------------------------------- 1 | import options from './lighthouseOptions'; 2 | 3 | describe('options', () => { 4 | it('should match snapshot', () => { 5 | expect(options).toMatchSnapshot(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/localLighthouse.js: -------------------------------------------------------------------------------- 1 | import lighthousePersist from '@foo-software/lighthouse-persist'; 2 | import fs from 'fs'; 3 | import get from 'lodash.get'; 4 | import { NAME } from './constants'; 5 | import { delay } from './helpers/utils'; 6 | import writeResults from './helpers/writeResults'; 7 | import { desktop, mobile, throttling } from './lighthouseConfig'; 8 | import options from './lighthouseOptions'; 9 | 10 | const defaultLighthouseConfigs = { 11 | desktop, 12 | mobile, 13 | }; 14 | 15 | const getScoresFromFloat = (scores) => 16 | Object.keys(scores).reduce( 17 | (accumulator, current) => ({ 18 | ...accumulator, 19 | ...(typeof scores[current] === 'number' && { 20 | [current]: Math.floor(scores[current] * 100), 21 | }), 22 | }), 23 | {}, 24 | ); 25 | 26 | export const localLighthouse = async ({ 27 | awsAccessKeyId, 28 | awsBucket, 29 | awsRegion, 30 | awsSecretAccessKey, 31 | emulatedFormFactor, 32 | extraHeaders, 33 | locale, 34 | maxWaitForLoad, 35 | outputDirectory, 36 | overrides, 37 | throttling: throttlingParam, 38 | throttlingMethod, 39 | url, 40 | }) => { 41 | // if desktop device, and no throttling param specified, use the 42 | // appropriate throttling 43 | const throttlingOverride = 44 | emulatedFormFactor === 'desktop' && !throttlingParam 45 | ? 'desktopDense4G' 46 | : throttlingParam; 47 | 48 | const lighthouseDefaultConfig = defaultLighthouseConfigs[emulatedFormFactor]; 49 | 50 | // the default config combined with overriding query params 51 | const fullConfig = { 52 | ...lighthouseDefaultConfig, 53 | settings: { 54 | ...lighthouseDefaultConfig.settings, 55 | ...(!maxWaitForLoad 56 | ? {} 57 | : { 58 | maxWaitForLoad, 59 | }), 60 | ...(!throttlingMethod 61 | ? {} 62 | : { 63 | throttlingMethod, 64 | }), 65 | ...(!throttlingOverride || !throttling[throttlingOverride] 66 | ? {} 67 | : { 68 | throttling: throttling[throttlingOverride], 69 | }), 70 | ...(!emulatedFormFactor 71 | ? {} 72 | : { 73 | emulatedFormFactor, 74 | }), 75 | ...(!extraHeaders 76 | ? {} 77 | : { 78 | extraHeaders, 79 | }), 80 | // if we wanted translations (holy!) 81 | // locale: 'ja', 82 | ...(!locale 83 | ? {} 84 | : { 85 | locale, 86 | }), 87 | }, 88 | ...(!overrides || !overrides.config 89 | ? {} 90 | : { 91 | ...overrides.config, 92 | }), 93 | }; 94 | 95 | const { localReport, report, result } = await lighthousePersist({ 96 | awsAccessKeyId, 97 | awsBucket, 98 | awsRegion, 99 | awsSecretAccessKey, 100 | config: fullConfig, 101 | options: { 102 | ...options, 103 | ...(!overrides || !overrides.options 104 | ? {} 105 | : { 106 | ...overrides.options, 107 | }), 108 | }, 109 | outputDirectory, 110 | url, 111 | }); 112 | 113 | const scores = getScoresFromFloat({ 114 | accessibility: get(result, 'categories.accessibility.score'), 115 | bestPractices: get(result, 'categories["best-practices"].score'), 116 | performance: get(result, 'categories.performance.score'), 117 | progressiveWebApp: get(result, 'categories.pwa.score'), 118 | seo: get(result, 'categories.seo.score'), 119 | }); 120 | 121 | return { 122 | url, 123 | localReport, 124 | report, 125 | emulatedFormFactor, 126 | runtimeError: get(result, 'runtimeError.message'), 127 | scores, 128 | }; 129 | }; 130 | 131 | export const getLocalLighthouseResultsWithRetries = async ({ 132 | auditConfig, 133 | localLighthousePromise = localLighthouse, 134 | maxRetries = 0, 135 | retries = 0, 136 | verbose = false, 137 | }) => { 138 | let lighthouseAuditResult; 139 | try { 140 | lighthouseAuditResult = await localLighthousePromise(auditConfig); 141 | if (lighthouseAuditResult.runtimeError) { 142 | throw Error(lighthouseAuditResult.runtimeError); 143 | } 144 | if (maxRetries && retries) { 145 | console.log(`Succeeded on retry #${retries}.`); 146 | } 147 | return lighthouseAuditResult; 148 | } catch (error) { 149 | if (retries >= maxRetries) { 150 | if (maxRetries) { 151 | console.log(`Max retries of ${maxRetries} exhausted... failing now.`); 152 | } 153 | throw error; 154 | } else { 155 | if (verbose) { 156 | console.log( 157 | `${NAME}: Error below caught on retry ${retries} of ${maxRetries}.`, 158 | error, 159 | 'Trying again...', 160 | ); 161 | } else { 162 | console.log( 163 | `Error caught on retry ${retries} of ${maxRetries}.`, 164 | 'Trying again...', 165 | ); 166 | } 167 | 168 | return getLocalLighthouseResultsWithRetries({ 169 | auditConfig, 170 | localLighthousePromise, 171 | maxRetries, 172 | retries: retries + 1, 173 | }); 174 | } 175 | } 176 | }; 177 | 178 | export default async ({ 179 | awsAccessKeyId, 180 | awsBucket, 181 | awsRegion, 182 | awsSecretAccessKey, 183 | emulatedFormFactor, 184 | extraHeaders, 185 | locale, 186 | overridesJsonFile, 187 | maxRetries = 0, 188 | maxWaitForLoad, 189 | outputDirectory, 190 | throttling, 191 | throttlingMethod, 192 | urls, 193 | verbose, 194 | }) => { 195 | // check for overrides config or options 196 | let overrides; 197 | if (overridesJsonFile) { 198 | const overridesJsonString = fs.readFileSync(overridesJsonFile).toString(); 199 | const overridesJson = JSON.parse(overridesJsonString); 200 | 201 | if (overridesJson.config || overridesJson.options) { 202 | overrides = overridesJson; 203 | } 204 | } 205 | 206 | // a list of audit configurations 207 | const auditConfigs = []; 208 | 209 | // collect all audit configs 210 | for (const url of urls) { 211 | const options = { 212 | awsAccessKeyId, 213 | awsBucket, 214 | awsRegion, 215 | awsSecretAccessKey, 216 | emulatedFormFactor, 217 | extraHeaders, 218 | locale, 219 | maxWaitForLoad, 220 | outputDirectory, 221 | overrides, 222 | throttling, 223 | throttlingMethod, 224 | url, 225 | verbose, 226 | }; 227 | 228 | if (options.emulatedFormFactor !== 'all') { 229 | auditConfigs.push(options); 230 | } else { 231 | // establish two audits for all device types 232 | auditConfigs.push({ 233 | ...options, 234 | emulatedFormFactor: 'desktop', 235 | }); 236 | auditConfigs.push({ 237 | ...options, 238 | emulatedFormFactor: 'mobile', 239 | }); 240 | } 241 | } 242 | 243 | const auditResults = []; 244 | let index = 1; 245 | 246 | // for each audit config, run the audit 247 | for (const auditConfig of auditConfigs) { 248 | if (verbose) { 249 | console.log( 250 | `${NAME}: Auditing ${auditConfig.emulatedFormFactor} (${index}/${auditConfigs.length}): ${auditConfig.url}`, 251 | ); 252 | } 253 | 254 | // 1 second delay to allow time of previous Chrome process end 255 | await delay(1000); 256 | 257 | const lighthouseAuditResult = await getLocalLighthouseResultsWithRetries({ 258 | auditConfig, 259 | maxRetries, 260 | retries: 0, 261 | verbose, 262 | }); 263 | auditResults.push(lighthouseAuditResult); 264 | index++; 265 | } 266 | 267 | // if outputDirectory is specified write the results to disk 268 | if (outputDirectory) { 269 | writeResults({ 270 | outputDirectory, 271 | results: auditResults, 272 | }); 273 | } 274 | 275 | return auditResults; 276 | }; 277 | -------------------------------------------------------------------------------- /src/localLighthouse.test.js: -------------------------------------------------------------------------------- 1 | import { getLocalLighthouseResultsWithRetries } from './localLighthouse'; 2 | 3 | describe('getLocalLighthouseResultsWithRetries', () => { 4 | it('should retry based on params and throw when all retries have been exhausted', async () => { 5 | const localLighthousePromise = jest.fn(async () => { 6 | throw Error('uh ohhhhh'); 7 | }); 8 | 9 | await expect( 10 | getLocalLighthouseResultsWithRetries({ 11 | auditConfig: {}, 12 | localLighthousePromise, 13 | maxRetries: 3, 14 | retries: 0, 15 | }), 16 | ).rejects.toHaveProperty('message', 'uh ohhhhh'); 17 | 18 | expect(localLighthousePromise).toHaveBeenCalledTimes(4); 19 | }); 20 | 21 | it('should retry based on params and return data when a retry succeeds', async () => { 22 | const localLighthousePromise = jest 23 | .fn(() => 'default') 24 | .mockImplementationOnce(async () => { 25 | throw Error('uh ohhhhh'); 26 | }) 27 | .mockImplementationOnce(async () => { 28 | throw Error('uh ohhhhh'); 29 | }) 30 | .mockImplementationOnce(async () => { 31 | return { data: true }; 32 | }); 33 | 34 | await expect( 35 | getLocalLighthouseResultsWithRetries({ 36 | auditConfig: {}, 37 | localLighthousePromise, 38 | maxRetries: 3, 39 | retries: 0, 40 | }), 41 | ).resolves.toEqual({ data: true }); 42 | 43 | expect(localLighthousePromise).toHaveBeenCalledTimes(3); 44 | }); 45 | 46 | it(`should function as usual when 'maxRetries' is not defined`, async () => { 47 | const localLighthousePromise = jest.fn(async () => { 48 | return { data: true }; 49 | }); 50 | 51 | await expect( 52 | getLocalLighthouseResultsWithRetries({ 53 | auditConfig: {}, 54 | localLighthousePromise, 55 | }), 56 | ).resolves.toEqual({ data: true }); 57 | 58 | expect(localLighthousePromise).toHaveBeenCalledTimes(1); 59 | }); 60 | 61 | it(`should function as usual when 'maxRetries' is not defined and an error is thrown`, async () => { 62 | const localLighthousePromise = jest.fn(async () => { 63 | throw Error('uh ohhhhh'); 64 | }); 65 | 66 | await expect( 67 | getLocalLighthouseResultsWithRetries({ 68 | auditConfig: {}, 69 | localLighthousePromise, 70 | }), 71 | ).rejects.toHaveProperty('message', 'uh ohhhhh'); 72 | 73 | expect(localLighthousePromise).toHaveBeenCalledTimes(1); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/logResults.js: -------------------------------------------------------------------------------- 1 | import { table, getBorderCharacters } from 'table'; 2 | import getLighthouseAuditTitlesByKey from './helpers/getLighthouseAuditTitlesByKey'; 3 | 4 | // config for the `table` module (for console logging a table) 5 | const tableConfig = { 6 | border: getBorderCharacters('ramac'), 7 | }; 8 | 9 | export default ({ isGitHubAction, isLocalAudit, isOrb, results }) => { 10 | // header 11 | const headerTable = [['Lighthouse Audit']]; 12 | const headerTableConfig = { 13 | ...tableConfig, 14 | columns: { 15 | 0: { 16 | paddingLeft: 29, 17 | paddingRight: 29, 18 | }, 19 | }, 20 | }; 21 | 22 | // log the header 23 | console.log('\n'); 24 | console.log(table(headerTable, headerTableConfig)); 25 | 26 | // log results 27 | results.forEach((result) => { 28 | console.log(`URL: ${result.url}`); 29 | 30 | if (result.emulatedFormFactor) { 31 | console.log(`Device: ${result.emulatedFormFactor}`); 32 | } 33 | 34 | if (result.report) { 35 | console.log(`Report: ${result.report}`); 36 | } 37 | 38 | if (result.localReport) { 39 | console.log(`Local Report: ${result.localReport}`); 40 | } 41 | 42 | const tableData = [ 43 | getLighthouseAuditTitlesByKey(Object.keys(result.scores)), 44 | Object.values(result.scores), 45 | ]; 46 | console.log('\n'); 47 | if (result.runtimeError) { 48 | console.log(`Lighthouse runtime error: ${result.runtimeError}`); 49 | } else { 50 | console.log(table(tableData, tableConfig)); 51 | } 52 | console.log('\n'); 53 | }); 54 | 55 | // if we have a local audit, plug Automated Lighthouse Check 56 | if (isLocalAudit) { 57 | let message = 'Not what you expected? Are your scores flaky?'; 58 | 59 | // depending on how the user is running this module - provide a respective link 60 | if (isGitHubAction) { 61 | message += 62 | ' GitHub runners could be the cause. Try running on Foo instead\n\n'; 63 | message += 64 | 'https://www.foo.software/docs/lighthouse-check-github-action/examples#running-on-foo-and-saving-results'; 65 | } else if (isOrb) { 66 | message += 67 | ' CircleCI runners could be the cause. Try running on Foo instead\n\n'; 68 | message += 69 | 'https://github.com/foo-software/lighthouse-check-orb#usage-foo-api'; 70 | } else { 71 | message += ' Try running on Foo instead\n\n'; 72 | message += 73 | 'https://www.foo.software/lighthouse\n' + 74 | 'https://github.com/foo-software/lighthouse-check#foos-automated-lighthouse-check-api-usage'; 75 | } 76 | 77 | // plug 78 | const plugTable = [[message]]; 79 | const plugTableConfig = { 80 | ...tableConfig, 81 | }; 82 | 83 | // log the plug 84 | console.log(table(plugTable, plugTableConfig)); 85 | console.log('\n'); 86 | } 87 | }; 88 | -------------------------------------------------------------------------------- /src/postPrComment.js: -------------------------------------------------------------------------------- 1 | import LighthouseCheckError from './LighthouseCheckError'; 2 | import { NAME } from './constants'; 3 | import { ERROR_UNEXPECTED_RESPONSE } from './errorCodes'; 4 | import fetch from './fetch'; 5 | import getLighthouseScoreColor from './helpers/getLighthouseScoreColor'; 6 | import lighthouseAuditTitles from './lighthouseAuditTitles'; 7 | 8 | const getBadge = ({ title, score = 0 }) => 9 | `![](https://img.shields.io/badge/${title}-${score}-${getLighthouseScoreColor( 10 | { 11 | isHex: false, 12 | score, 13 | }, 14 | )}?style=flat-square) `; 15 | 16 | export default async ({ 17 | isGitHubAction, 18 | isLocalAudit, 19 | isOrb, 20 | prCommentAccessToken, 21 | prCommentSaveOld, 22 | prCommentUrl, 23 | results, 24 | verbose, 25 | }) => { 26 | try { 27 | let markdown = ''; 28 | 29 | // we'll create a way to uniquely identify a comment so that we don't edit 30 | // the wrong one 31 | const commentIds = []; 32 | 33 | results.forEach((result) => { 34 | commentIds.push( 35 | result.id || `${result.emulatedFormFactor}:${result.url}`, 36 | ); 37 | 38 | // badges 39 | Object.keys(result.scores).forEach((current) => { 40 | markdown += getBadge({ 41 | title: lighthouseAuditTitles[current].replace(/ /g, '%20'), 42 | score: result.scores[current], 43 | }); 44 | }); 45 | 46 | // error 47 | if (result.runtimeError) { 48 | markdown += `**Lighthouse runtime error**: ${result.runtimeError}\n\n`; 49 | } 50 | 51 | // table header 52 | markdown += `\n| Device ${!result.report ? '' : `| Report `}| URL |\n`; 53 | markdown += `|--${!result.report ? '' : `|--`}|--|\n`; 54 | 55 | // the emulatedformfactor 56 | markdown += `| ${result.emulatedFormFactor} `; 57 | 58 | // if we have a URL for the full report 59 | if (result.report) { 60 | markdown += `| [report](${result.report}) `; 61 | } 62 | 63 | // the url 64 | markdown += `| ${result.url} |\n\n`; 65 | }); 66 | 67 | if (isLocalAudit) { 68 | markdown += 'Not what you expected? Are your scores flaky? '; 69 | 70 | if (isGitHubAction) { 71 | markdown += '**GitHub runners could be the cause.**\n'; 72 | } else if (isOrb) { 73 | markdown += '**CircleCI runners could be the cause.**\n'; 74 | } 75 | 76 | markdown += `[Try running on Foo instead]`; 77 | 78 | if (isGitHubAction) { 79 | markdown += 80 | '(https://www.foo.software/docs/lighthouse-check-github-action/examples#running-on-foo-and-saving-results)\n'; 81 | } else if (isOrb) { 82 | markdown += 83 | '(https://github.com/foo-software/lighthouse-check-orb#usage-foo-api)\n'; 84 | } else { 85 | markdown += 86 | '(https://github.com/foo-software/lighthouse-check#foos-automated-lighthouse-check-api-usage)\n'; 87 | } 88 | } 89 | 90 | // create an identifier within the comment when searching comments 91 | // in the future 92 | const commentIdentifierPrefix = ''; 93 | const commentIdentifier = `\n`; 96 | markdown += commentIdentifierPrefix + commentIdentifier; 97 | 98 | // establish existing comment 99 | let existingComment; 100 | 101 | // if we aren't saving old comments 102 | if (!prCommentSaveOld) { 103 | // get existing comments to see if we've already posted scores 104 | const existingCommentsResult = await fetch(prCommentUrl, { 105 | method: 'get', 106 | headers: { 107 | 'content-type': 'application/json', 108 | authorization: `token ${prCommentAccessToken}`, 109 | }, 110 | }); 111 | const existingCommentsJsonResult = await existingCommentsResult.json(); 112 | 113 | if ( 114 | Array.isArray(existingCommentsJsonResult) && 115 | existingCommentsJsonResult.length 116 | ) { 117 | existingComment = existingCommentsJsonResult.find((current) => { 118 | const hasLighthouseComment = current.body.includes( 119 | commentIdentifierPrefix, 120 | ); 121 | if (!hasLighthouseComment) { 122 | return false; 123 | } 124 | 125 | // check to see if this comment matches the current result set 126 | const [, commentIdsFromExistingCommentString] = 127 | current.body.split('COMMENT_ID'); 128 | 129 | if (!commentIdsFromExistingCommentString) { 130 | return false; 131 | } 132 | 133 | const commentIdsFromExistingComment = JSON.parse( 134 | commentIdsFromExistingCommentString, 135 | ); 136 | 137 | // if one has more results than the other then we are definitely different 138 | if (commentIdsFromExistingComment.length !== commentIds.length) { 139 | return false; 140 | } 141 | 142 | // if any result id is not found in the other then we have a diff 143 | for (const commentId of commentIds) { 144 | if (!commentIdsFromExistingComment.includes(commentId)) { 145 | return false; 146 | } 147 | } 148 | 149 | return true; 150 | }); 151 | } 152 | } 153 | 154 | // create or update the comment with scores 155 | const shouldUpdate = existingComment && existingComment.id; 156 | const url = !shouldUpdate 157 | ? prCommentUrl 158 | : `${prCommentUrl}/${existingComment.id}`; 159 | 160 | const result = await fetch(url, { 161 | method: !shouldUpdate ? 'post' : 'put', 162 | body: JSON.stringify({ 163 | ...(shouldUpdate 164 | ? {} 165 | : { 166 | event: 'COMMENT', 167 | }), 168 | body: markdown, 169 | }), 170 | headers: { 171 | 'content-type': 'application/json', 172 | authorization: `token ${prCommentAccessToken}`, 173 | }, 174 | }); 175 | const jsonResult = await result.json(); 176 | 177 | if (!jsonResult.id) { 178 | throw new LighthouseCheckError( 179 | jsonResult.message || 'something went wrong', 180 | { 181 | code: ERROR_UNEXPECTED_RESPONSE, 182 | data: jsonResult, 183 | }, 184 | ); 185 | } 186 | } catch (error) { 187 | if (verbose) { 188 | console.log(`${NAME}:`, error); 189 | } 190 | 191 | // we still need to kill the process 192 | throw error; 193 | } 194 | }; 195 | -------------------------------------------------------------------------------- /src/slackNotify.js: -------------------------------------------------------------------------------- 1 | import { IncomingWebhook } from '@slack/webhook'; 2 | import { NAME } from './constants'; 3 | import getLighthouseScoreColor from './helpers/getLighthouseScoreColor'; 4 | import lighthouseAuditTitles from './lighthouseAuditTitles'; 5 | 6 | export default async ({ 7 | author, 8 | branch, 9 | pr: prParam, 10 | results, 11 | sha, 12 | slackWebhookUrl, 13 | verbose, 14 | }) => { 15 | try { 16 | const webhook = new IncomingWebhook(slackWebhookUrl); 17 | 18 | // sometimes we get weird input 19 | const pr = 20 | typeof prParam !== 'string' || prParam === 'true' ? undefined : prParam; 21 | 22 | for (const result of results) { 23 | // link the report if we have it 24 | let text = !result.report 25 | ? 'Lighthouse audit' 26 | : `<${result.report}|Lighthouse audit>`; 27 | 28 | // if we have a branch 29 | if (branch) { 30 | const branchText = !pr ? branch : `<${pr}|${branch}>`; 31 | text = `${text} change made in \`${branchText}\`.`; 32 | } 33 | 34 | let footer; 35 | if (author) { 36 | footer = `by ${author}`; 37 | 38 | if (sha) { 39 | footer = `${footer} in ${sha.slice(0, 10)}`; 40 | } 41 | } 42 | 43 | await webhook.send({ 44 | text: `${result.url} (${result.emulatedFormFactor})`, 45 | attachments: [ 46 | { 47 | color: '#2091fa', 48 | text, 49 | thumb_url: 50 | 'https://s3.amazonaws.com/foo.software/images/logos/lighthouse.png', 51 | ...(!footer 52 | ? {} 53 | : { 54 | footer, 55 | }), 56 | }, 57 | ...(!result.runtimeError 58 | ? [] 59 | : [ 60 | { 61 | color: '#f74531', 62 | text: `*Lighthouse runtime error*: ${result.runtimeError}`, 63 | short: true, 64 | }, 65 | ]), 66 | ...Object.keys(result.scores).map((current) => ({ 67 | color: getLighthouseScoreColor({ 68 | isHex: true, 69 | score: result.scores[current], 70 | }), 71 | text: `*${lighthouseAuditTitles[current]}*: ${result.scores[current]}`, 72 | short: true, 73 | })), 74 | ], 75 | }); 76 | } 77 | } catch (error) { 78 | if (verbose) { 79 | console.log(`${NAME}:`, error); 80 | } 81 | 82 | // we still need to kill the process 83 | throw error; 84 | } 85 | }; 86 | -------------------------------------------------------------------------------- /src/triggerLighthouse.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash.get'; 2 | import fetch from './fetch'; 3 | import LighthouseCheckError from './LighthouseCheckError'; 4 | import { 5 | API_PAGES_PATH, 6 | API_QUEUE_ITEMS_PATH, 7 | API_URL, 8 | DEFAULT_TAG, 9 | NAME, 10 | SOURCE_GITHUB_ACTION, 11 | SOURCE_CIRCLECI_ORB, 12 | SUCCESS_CODE_GENERIC, 13 | TRIGGER_TYPE 14 | } from './constants'; 15 | import { 16 | ERROR_GENERIC, 17 | ERROR_NO_RESULTS, 18 | ERROR_NO_URLS, 19 | ERROR_UNAUTHORIZED, 20 | ERROR_QUEUE_MAX_USED_DAY 21 | } from './errorCodes'; 22 | 23 | export default async ({ 24 | apiToken, 25 | device, 26 | isGitHubAction, 27 | isOrb, 28 | tag, 29 | urls = [], 30 | verbose = true 31 | }) => { 32 | try { 33 | let apiTokens = urls; 34 | 35 | // if urls was not provided - run on all pages 36 | if (!Array.isArray(urls) || !urls.length) { 37 | if (verbose) { 38 | console.log(`${NAME}:`, 'Fetching URLs from account.'); 39 | } 40 | 41 | const pagesResponse = await fetch(`${API_URL}${API_PAGES_PATH}`, { 42 | method: 'get', 43 | headers: { 44 | Authorization: apiToken, 45 | 'Content-Type': 'application/json' 46 | } 47 | }); 48 | const pagesJson = await pagesResponse.json(); 49 | 50 | if (pagesJson.status >= 400) { 51 | const errorMessage = `Account wasn't found for the provided API token.`; 52 | if (verbose) { 53 | console.log(`${NAME}:`, errorMessage); 54 | } 55 | 56 | throw new LighthouseCheckError(errorMessage, { 57 | code: ERROR_UNAUTHORIZED 58 | }); 59 | } 60 | 61 | const pages = get(pagesJson, 'data.page', []); 62 | if (!pages.length) { 63 | const errorMessage = 'No URLs were found for this account.'; 64 | 65 | if (verbose) { 66 | console.log(`${NAME}:`, errorMessage); 67 | } 68 | 69 | throw new LighthouseCheckError(errorMessage, { 70 | code: ERROR_NO_URLS 71 | }); 72 | } 73 | 74 | apiTokens = pages.map(current => current.apiToken); 75 | } 76 | 77 | if (verbose) { 78 | console.log(`${NAME}:`, 'Enqueueing Lighthouse audits.'); 79 | } 80 | 81 | // pass in the source of the queued item for tracking 82 | let source = 'api'; 83 | if (isGitHubAction) { 84 | source = SOURCE_GITHUB_ACTION; 85 | } else if (isOrb) { 86 | source = SOURCE_CIRCLECI_ORB; 87 | } 88 | 89 | // enqueue urls for Lighthouse audits 90 | const queueItemsResponse = await fetch( 91 | `${API_URL}${API_QUEUE_ITEMS_PATH}`, 92 | { 93 | method: 'post', 94 | headers: { 95 | Authorization: apiToken, 96 | 'Content-Type': 'application/json' 97 | }, 98 | body: JSON.stringify({ 99 | device, 100 | tag: tag || DEFAULT_TAG, 101 | pages: apiTokens.join(), 102 | source, 103 | type: TRIGGER_TYPE 104 | }) 105 | } 106 | ); 107 | const queueItemsJson = await queueItemsResponse.json(); 108 | const queue = get(queueItemsJson, 'data.queue'); 109 | 110 | // if no results 111 | if (!queue.results.length) { 112 | const errorMessage = 'No results.'; 113 | if (verbose) { 114 | console.log(`${NAME}:`, errorMessage); 115 | } 116 | 117 | throw new LighthouseCheckError(errorMessage, { 118 | code: ERROR_NO_RESULTS 119 | }); 120 | } 121 | 122 | // if the API responded with error/s, set a message to be used later 123 | let apiErrorMessage; 124 | if (queue.errors) { 125 | apiErrorMessage = `${NAME}: Below is the API response for attempted URLs as an array.`; 126 | } 127 | 128 | // if all urls failed to be enqueued... 129 | if (queue.errors === queue.results.length) { 130 | const errorCode = queue.results[0].code; 131 | const errorMessage = 132 | errorCode === ERROR_QUEUE_MAX_USED_DAY 133 | ? queue.results[0].message 134 | : 'All URLs failed to be enqueued.'; 135 | 136 | if (verbose) { 137 | console.log(`${NAME}:`, errorMessage); 138 | 139 | if (apiErrorMessage) { 140 | console.log(apiErrorMessage, queue.results); 141 | } 142 | } 143 | 144 | throw new LighthouseCheckError(errorMessage, { 145 | code: errorCode, 146 | data: queue.results 147 | }); 148 | } 149 | 150 | // if only some urls succeeded to be enqueued... 151 | const successResultLength = queue.results.length - queue.errors; 152 | 153 | const message = 154 | successResultLength < queue.results.length 155 | ? `Only ${ 156 | successResultLength > 1 ? 'some' : 'one' 157 | } of your account URLs were enqueued. Typically this occurs when daily limit has been met for a given URL. Check your account limits online.` 158 | : `${queue.results.length} ${ 159 | queue.results.length > 1 ? 'URLs' : 'URL' 160 | } successfully enqueued for Lighthouse. Visit dashboard for results.`; 161 | 162 | if (verbose) { 163 | console.log(`${NAME}:`, message); 164 | 165 | if (apiErrorMessage) { 166 | console.log(apiErrorMessage, queue.results); 167 | } 168 | } 169 | 170 | // success 171 | return { 172 | code: SUCCESS_CODE_GENERIC, 173 | data: queue.results, 174 | message 175 | }; 176 | } catch (error) { 177 | const result = { 178 | code: error.code || ERROR_GENERIC, 179 | error 180 | }; 181 | 182 | // if an error occurred but we still have data (typically if only some URLs failed) 183 | if (error.data) { 184 | result.data = error.data; 185 | } 186 | 187 | return result; 188 | } 189 | }; 190 | -------------------------------------------------------------------------------- /src/triggerLighthouse.test.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import triggerLighthouse from './triggerLighthouse'; 3 | 4 | const mockResponse = { 5 | status: 200, 6 | data: {}, 7 | }; 8 | 9 | const mockPageResponse = { 10 | ...mockResponse, 11 | data: { 12 | page: [], 13 | }, 14 | }; 15 | 16 | const mockFetchPagesResponse = { 17 | json: () => ({ 18 | ...mockPageResponse, 19 | data: { 20 | page: [ 21 | { 22 | _id: 'abc', 23 | apiToken: 'abc123', 24 | }, 25 | { 26 | _id: 'def', 27 | apiToken: 'def456', 28 | }, 29 | ], 30 | }, 31 | }), 32 | }; 33 | 34 | const mockFetchPagesEmptyResponse = { 35 | json: () => mockPageResponse, 36 | }; 37 | 38 | const mockFetchPagesUnauthorizedResponse = { 39 | json: () => ({ 40 | status: 401, 41 | }), 42 | }; 43 | 44 | const mockQueueItemsResponse = { 45 | ...mockResponse, 46 | data: { 47 | queue: { 48 | results: [], 49 | errors: 0, 50 | }, 51 | }, 52 | }; 53 | 54 | const mockFetchQueueItemsEmptyResponse = { 55 | json: () => ({ 56 | ...mockQueueItemsResponse, 57 | }), 58 | }; 59 | 60 | const mockFetchQueueItemsSuccessResponse = { 61 | json: () => ({ 62 | ...mockQueueItemsResponse, 63 | data: { 64 | queue: { 65 | ...mockQueueItemsResponse.data.queue, 66 | results: [ 67 | { 68 | code: 'SUCCESS_QUEUE_ADD', 69 | status: 200, 70 | }, 71 | { 72 | code: 'SUCCESS_QUEUE_ADD', 73 | status: 200, 74 | }, 75 | ], 76 | }, 77 | }, 78 | }), 79 | }; 80 | 81 | const mockFetchQueueItemsFailResponse = { 82 | json: () => ({ 83 | ...mockQueueItemsResponse, 84 | data: { 85 | queue: { 86 | ...mockQueueItemsResponse.data.queue, 87 | results: [ 88 | { 89 | code: 'SOME_ERROR', 90 | message: 'some error message', 91 | status: 401, 92 | }, 93 | { 94 | code: 'SOME_ERROR', 95 | message: 'some error message', 96 | status: 401, 97 | }, 98 | ], 99 | errors: 2, 100 | }, 101 | }, 102 | }), 103 | }; 104 | 105 | const mockFetchQueueItemsFailMaxReachedResponse = { 106 | json: () => ({ 107 | ...mockQueueItemsResponse, 108 | data: { 109 | queue: { 110 | ...mockQueueItemsResponse.data.queue, 111 | results: [ 112 | { 113 | code: 'ERROR_QUEUE_MAX_USED_DAY', 114 | message: 'Max limit of 5 triggers reached.', 115 | status: 401, 116 | }, 117 | { 118 | code: 'ERROR_QUEUE_MAX_USED_DAY', 119 | message: 'Max limit of 5 triggers reached.', 120 | status: 401, 121 | }, 122 | ], 123 | errors: 2, 124 | }, 125 | }, 126 | }), 127 | }; 128 | 129 | const mockFetchQueueItemsMixedResponse = { 130 | json: () => ({ 131 | ...mockQueueItemsResponse, 132 | data: { 133 | queue: { 134 | ...mockQueueItemsResponse.data.queue, 135 | results: [ 136 | { 137 | code: 'ERROR_QUEUE_MAX_USED_DAY', 138 | message: 'Max limit of 5 triggers reached.', 139 | status: 401, 140 | }, 141 | { 142 | code: 'ERROR_QUEUE_MAX_USED_DAY', 143 | message: 'Max limit of 5 triggers reached.', 144 | status: 401, 145 | }, 146 | { 147 | code: 'SUCCESS_QUEUE_ADD', 148 | status: 200, 149 | }, 150 | ], 151 | errors: 2, 152 | }, 153 | }, 154 | }), 155 | }; 156 | 157 | const mockParams = { 158 | apiToken: 'abc123', 159 | }; 160 | 161 | jest.mock('node-fetch', () => ({ 162 | __esModule: true, 163 | default: jest.fn(), 164 | })); 165 | 166 | describe('triggerLighthouse()', () => { 167 | describe('on success', () => { 168 | it('should return an expected response payload', async () => { 169 | fetch 170 | .mockReset() 171 | .mockReturnValueOnce(mockFetchPagesResponse) 172 | .mockReturnValueOnce(mockFetchQueueItemsSuccessResponse); 173 | 174 | const response = await triggerLighthouse(mockParams); 175 | expect(response).toMatchSnapshot(); 176 | }); 177 | 178 | it('should return an expected response payload when some URLs failed to queue but some succeeded', async () => { 179 | fetch 180 | .mockReset() 181 | .mockReturnValueOnce(mockFetchPagesResponse) 182 | .mockReturnValueOnce(mockFetchQueueItemsMixedResponse); 183 | 184 | const response = await triggerLighthouse(mockParams); 185 | expect(response).toMatchSnapshot(); 186 | }); 187 | }); 188 | 189 | describe('on fail', () => { 190 | it('should return an expected response payload when api key is invalid', async () => { 191 | fetch 192 | .mockReset() 193 | .mockReturnValueOnce(mockFetchPagesUnauthorizedResponse) 194 | .mockReturnValueOnce(mockFetchQueueItemsSuccessResponse); 195 | 196 | const response = await triggerLighthouse(mockParams); 197 | expect(response).toMatchSnapshot(); 198 | }); 199 | 200 | it('should return an expected response payload when no pages are found', async () => { 201 | fetch 202 | .mockReset() 203 | .mockReturnValueOnce(mockFetchPagesEmptyResponse) 204 | .mockReturnValueOnce(mockFetchQueueItemsSuccessResponse); 205 | 206 | const response = await triggerLighthouse(mockParams); 207 | expect(response).toMatchSnapshot(); 208 | }); 209 | 210 | it('should return an expected response payload when no queue results are returned', async () => { 211 | fetch 212 | .mockReset() 213 | .mockReturnValueOnce(mockFetchPagesResponse) 214 | .mockReturnValueOnce(mockFetchQueueItemsEmptyResponse); 215 | 216 | const response = await triggerLighthouse(mockParams); 217 | expect(response).toMatchSnapshot(); 218 | }); 219 | 220 | it('should return an expected response payload when all URLs failed to queue', async () => { 221 | fetch 222 | .mockReset() 223 | .mockReturnValueOnce(mockFetchPagesResponse) 224 | .mockReturnValueOnce(mockFetchQueueItemsFailResponse); 225 | 226 | const response = await triggerLighthouse(mockParams); 227 | expect(response).toMatchSnapshot(); 228 | }); 229 | 230 | it('should return an expected response payload when all URLs failed to queue due to max limit reached', async () => { 231 | fetch 232 | .mockReset() 233 | .mockReturnValueOnce(mockFetchPagesResponse) 234 | .mockReturnValueOnce(mockFetchQueueItemsFailMaxReachedResponse); 235 | 236 | const response = await triggerLighthouse(mockParams); 237 | expect(response).toMatchSnapshot(); 238 | }); 239 | }); 240 | }); 241 | -------------------------------------------------------------------------------- /src/validateStatus.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import LighthouseCheckError from './LighthouseCheckError'; 4 | import { NAME, NAME_RESULTS_JSON_FILE } from './constants'; 5 | import { ERROR_INVALID } from './errorCodes'; 6 | 7 | const getScoreFailMessage = ({ name, url, minScore, score }) => { 8 | // if inputs are not specified - assume we shouldn't fail 9 | if (typeof minScore === 'undefined' || typeof score === 'undefined') { 10 | return []; 11 | } 12 | 13 | if (Number(score) < Number(minScore)) { 14 | return [ 15 | `${url}: ${name}: minimum score: ${minScore}, actual score: ${score}`, 16 | ]; 17 | } 18 | 19 | return []; 20 | }; 21 | 22 | const getFailureMessages = ({ 23 | minAccessibilityScore, 24 | minBestPracticesScore, 25 | minPerformanceScore, 26 | minProgressiveWebAppScore, 27 | minSeoScore, 28 | results, 29 | }) => 30 | results.reduce( 31 | (accumulator, current) => [ 32 | ...accumulator, 33 | ...getScoreFailMessage({ 34 | name: 'Accessibility', 35 | minScore: minAccessibilityScore, 36 | score: current.scores.accessibility, 37 | ...current, 38 | }), 39 | ...getScoreFailMessage({ 40 | name: 'Best Practices', 41 | minScore: minBestPracticesScore, 42 | score: current.scores.bestPractices, 43 | ...current, 44 | }), 45 | ...getScoreFailMessage({ 46 | name: 'Performance', 47 | minScore: minPerformanceScore, 48 | score: current.scores.performance, 49 | ...current, 50 | }), 51 | ...getScoreFailMessage({ 52 | name: 'Progressive Web App', 53 | minScore: minProgressiveWebAppScore, 54 | score: current.scores.progressiveWebApp, 55 | ...current, 56 | }), 57 | ...getScoreFailMessage({ 58 | name: 'SEO', 59 | minScore: minSeoScore, 60 | score: current.scores.seo, 61 | ...current, 62 | }), 63 | ], 64 | [], 65 | ); 66 | 67 | export default async ({ 68 | minAccessibilityScore, 69 | minBestPracticesScore, 70 | minPerformanceScore, 71 | minProgressiveWebAppScore, 72 | minSeoScore, 73 | outputDirectory, 74 | results, 75 | verbose, 76 | }) => { 77 | let resultsJson = results; 78 | 79 | if (outputDirectory && !resultsJson) { 80 | const outputDirectoryPath = path.resolve(outputDirectory); 81 | const resultsJsonFile = `${outputDirectoryPath}/${NAME_RESULTS_JSON_FILE}`; 82 | const resultsJsonString = fs.readFileSync(resultsJsonFile).toString(); 83 | resultsJson = JSON.parse(resultsJsonString); 84 | } 85 | 86 | const failures = getFailureMessages({ 87 | minAccessibilityScore, 88 | minBestPracticesScore, 89 | minPerformanceScore, 90 | minProgressiveWebAppScore, 91 | minSeoScore, 92 | results: resultsJson, 93 | }); 94 | 95 | // if we have scores that were below the minimum requirement 96 | if (failures.length) { 97 | // comma-separate error messages and remove the last comma 98 | const failureMessage = failures.join('\n'); 99 | throw new LighthouseCheckError( 100 | `Minimum score requirements failed:\n${failureMessage}`, 101 | { 102 | code: ERROR_INVALID, 103 | }, 104 | ); 105 | } 106 | 107 | if (verbose) { 108 | console.log(`${NAME}:`, 'Scores passed minimum requirement ✅'); 109 | } 110 | 111 | return true; 112 | }; 113 | --------------------------------------------------------------------------------