├── .env.example ├── .eslintrc.json ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE │ └── pull_request_template.md └── workflows │ ├── labeler.yml │ ├── release-please.yml │ └── test.yml ├── .gitignore ├── .nvmrc ├── .prettierrc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── cypress.config.js ├── cypress ├── e2e │ └── e2e.cy.js ├── plugins │ └── index.js └── support │ └── e2e.js ├── example ├── contact.html ├── index.html ├── route1 │ └── index.html └── route2 │ └── index.html ├── jest.config.cjs ├── manifest.yml ├── netlify.toml ├── package.json ├── release.config.js ├── renovate.json ├── src ├── e2e │ ├── fail-threshold-onpostbuild.test.js │ ├── fail-threshold-onsuccess.test.js │ ├── fixture │ │ ├── results-not-found.json │ │ ├── results.json │ │ └── utils.js │ ├── lib │ │ ├── format-mock-log.js │ │ ├── format-mock-log.test.js │ │ └── reset-env.js │ ├── mocks │ │ ├── chrome-launcher.js │ │ ├── console-error.js │ │ ├── console-log.js │ │ ├── lighthouse.js │ │ └── puppeteer.js │ ├── not-found-onpostbuild.test.js │ ├── not-found-onsuccess.test.js │ ├── settings-locale.test.js │ ├── settings-preset.test.js │ ├── success-onpostbuild.test.js │ └── success-onsuccess.test.js ├── format.js ├── format.test.js ├── index.js ├── index.test.js ├── lib │ ├── get-configuration │ │ ├── get-configuration.test.js │ │ └── index.js │ ├── get-serve-path │ │ ├── get-serve-path.test.js │ │ └── index.js │ ├── get-server │ │ ├── get-server.test.js │ │ └── index.js │ ├── get-settings │ │ ├── get-settings.test.js │ │ └── index.js │ ├── get-utils │ │ └── index.js │ ├── persist-results │ │ └── index.js │ ├── prefix-string │ │ └── index.js │ ├── process-results │ │ └── index.js │ ├── run-audit-with-server │ │ └── index.js │ ├── run-audit-with-url │ │ └── index.js │ └── run-event │ │ ├── helpers.js │ │ ├── helpers.test.js │ │ └── index.js ├── replacements.js ├── replacements.test.js └── run-lighthouse.js ├── test └── setup.js └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | # Audits 2 | AUDITS=[{"url":"https://www.example.com","thresholds":{"performance":0.5}},{"serveDir":""},{"serveDir":"route1"},{"path":"route2"}, {path: "contact.html"}] 3 | # Ignored when a url is configured for an audit 4 | PUBLISH_DIR=FULL_PATH_TO_LOCAL_BUILD_DIRECTORY 5 | # JSON string of thresholds to enforce 6 | THRESHOLDS={"performance":0.9,"accessibility":0.9,"best-practices":0.9,"seo":0.9,"pwa":0.9} 7 | # SETTINGS 8 | SETTINGS={"locale":"en", "preset":"desktop"} 9 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true, 5 | "jest": true 6 | }, 7 | "extends": ["eslint:recommended", "plugin:import/recommended"], 8 | "globals": { 9 | "cy": true 10 | }, 11 | 12 | "parserOptions": { 13 | "sourceType": "module", 14 | "ecmaVersion": "latest" 15 | }, 16 | "rules": { 17 | "import/extensions": ["error", "always"], 18 | "import/order": ["error", { "newlines-between": "always" }] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @netlify/product-eng-pod-app-experience 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | * **Please check if the PR fulfills these requirements** 2 | - [ ] The commit message follows our guidelines 3 | - [ ] Tests for the changes have been added (for bug fixes / features) 4 | - [ ] Docs have been added / updated (for bug fixes / features) 5 | 6 | 7 | * **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...) 8 | 9 | 10 | 11 | * **What is the current behavior?** (You can also link to an open issue here) 12 | 13 | 14 | 15 | * **What is the new behavior (if this is a feature change)?** 16 | 17 | 18 | 19 | * **Does this PR introduce a breaking change?** (What changes might users need to make in their application due to this PR?) 20 | 21 | 22 | 23 | * **Other information**: 24 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Label PR 2 | on: 3 | pull_request: 4 | types: [opened, edited] 5 | 6 | jobs: 7 | label-pr: 8 | if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: erezrokah/pr-labeler-action@v1.1.0 12 | if: startsWith(github.event.pull_request.title, 'fix') 13 | with: 14 | token: '${{ secrets.GITHUB_TOKEN }}' 15 | label: 'type: bug' 16 | - uses: erezrokah/pr-labeler-action@v1.1.0 17 | if: startsWith(github.event.pull_request.title, 'chore') || startsWith(github.event.pull_request.title, 'ci:') 18 | with: 19 | token: '${{ secrets.GITHUB_TOKEN }}' 20 | label: 'type: chore' 21 | - uses: erezrokah/pr-labeler-action@v1.1.0 22 | if: startsWith(github.event.pull_request.title, 'feat') 23 | with: 24 | token: '${{ secrets.GITHUB_TOKEN }}' 25 | label: 'type: feature' 26 | - uses: erezrokah/pr-labeler-action@v1.1.0 27 | if: startsWith(github.event.pull_request.title, 'security') 28 | with: 29 | token: '${{ secrets.GITHUB_TOKEN }}' 30 | label: 'type: security' 31 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: release-please 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | release-please: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: navikt/github-app-token-generator@a3831f44404199df32d8f39f7c0ad9bb8fa18b1c 11 | id: get-token 12 | with: 13 | private-key: ${{ secrets.TOKENS_PRIVATE_KEY }} 14 | app-id: ${{ secrets.TOKENS_APP_ID }} 15 | 16 | - uses: GoogleCloudPlatform/release-please-action@v3 17 | id: release 18 | with: 19 | token: ${{ steps.get-token.outputs.token }} 20 | release-type: node 21 | package-name: '@netlify/plugin-lighthouse' 22 | - uses: actions/checkout@v4 23 | if: ${{ steps.release.outputs.releases_created }} 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version-file: '.nvmrc' 27 | cache: yarn 28 | check-latest: true 29 | registry-url: 'https://registry.npmjs.org' 30 | if: ${{ steps.release.outputs.releases_created }} 31 | - run: npm publish 32 | if: ${{ steps.release.outputs.releases_created }} 33 | env: 34 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 35 | - uses: netlify/submit-build-plugin-action@v1 36 | if: ${{ steps.release.outputs.releases_created }} 37 | with: 38 | github-token: ${{ steps.get-token.outputs.token }} 39 | 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | 10 | pull_request: 11 | types: [opened, synchronize, reopened] 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | node-version: [18.x, 20.x, 21.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | check-latest: true 27 | - name: Get yarn cache directory path 28 | id: yarn-cache-dir-path 29 | run: echo "::set-output name=dir::$(yarn cache dir)" 30 | - uses: actions/cache@v4 31 | with: 32 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 33 | key: ${{ runner.os }}-yarn-${{ hashFiles(format('{0}{1}', github.workspace, '/yarn.lock')) }} 34 | restore-keys: | 35 | ${{ runner.os }}-yarn- 36 | - name: log versions 37 | run: node --version && npm --version && yarn --version 38 | - name: install dependencies 39 | run: yarn --frozen-lockfile 40 | - name: run linter 41 | run: yarn lint 42 | - name: check formatting 43 | run: yarn format:ci 44 | - name: run tests 45 | run: yarn test 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .vscode 4 | yarn-error.log 5 | .DS_Store 6 | 7 | # Local Netlify folder 8 | .netlify 9 | reports 10 | coverage 11 | cypress/videos 12 | cypress/screenshots 13 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "useTabs": false, 8 | "overrides": [ 9 | { 10 | "files": "*.json", 11 | "options": { "printWidth": 200 } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [3.2.0](https://github.com/netlify/netlify-plugin-lighthouse/compare/v3.1.0...v3.2.0) (2022-08-01) 2 | 3 | 4 | ### Features 5 | 6 | * feat: reduce summary sent to only id and score ([#448](https://github.com/netlify/netlify-plugin-lighthouse/issues/448)) ([969cc58](https://github.com/netlify/netlify-plugin-lighthouse/commit/969cc589c33f53925ea26d47ae31a7d3152c58c0)) 7 | 8 | ## [6.0.1](https://github.com/netlify/netlify-plugin-lighthouse/compare/v6.0.0...v6.0.1) (2024-10-21) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * skip is-crawlable audit when running onSuccess against DEPLOY_URL ([#621](https://github.com/netlify/netlify-plugin-lighthouse/issues/621)) ([4be7b46](https://github.com/netlify/netlify-plugin-lighthouse/commit/4be7b464f1d48bd818488d97f2d24b093ccf31c7)) 14 | 15 | ## [6.0.0](https://github.com/netlify/netlify-plugin-lighthouse/compare/v5.0.0...v6.0.0) (2024-01-30) 16 | 17 | 18 | ### ⚠ BREAKING CHANGES 19 | 20 | * mark compatible with Node v20 and up ([#612](https://github.com/netlify/netlify-plugin-lighthouse/issues/612)) 21 | 22 | ### Features 23 | 24 | * mark compatible with Node v20 and up ([#612](https://github.com/netlify/netlify-plugin-lighthouse/issues/612)) ([0681a72](https://github.com/netlify/netlify-plugin-lighthouse/commit/0681a7289f7e951d1c375027c7ee7a3ef9dae447)) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * remove typo ([#611](https://github.com/netlify/netlify-plugin-lighthouse/issues/611)) ([4f40958](https://github.com/netlify/netlify-plugin-lighthouse/commit/4f40958669851002aece0d004ef86a971ba99567)) 30 | 31 | ## [5.0.0](https://github.com/netlify/netlify-plugin-lighthouse/compare/v4.1.1...v5.0.0) (2023-07-13) 32 | 33 | 34 | ### ⚠ BREAKING CHANGES 35 | 36 | * Run plugin on live deploy URL by default ([#588](https://github.com/netlify/netlify-plugin-lighthouse/issues/588)) 37 | 38 | ### Features 39 | 40 | * Run plugin on live deploy URL by default ([#588](https://github.com/netlify/netlify-plugin-lighthouse/issues/588)) ([1116f78](https://github.com/netlify/netlify-plugin-lighthouse/commit/1116f782aefe3fac65f02724d39c0dd1a52da872)) 41 | 42 | ## [4.1.1](https://github.com/netlify/netlify-plugin-lighthouse/compare/v4.1.0...v4.1.1) (2023-04-21) 43 | 44 | 45 | ### Bug Fixes 46 | 47 | * Fix chrome-launcher error preventing reports being generated ([#573](https://github.com/netlify/netlify-plugin-lighthouse/issues/573)) ([ae6e28e](https://github.com/netlify/netlify-plugin-lighthouse/commit/ae6e28e9be9311328f7b4cb6a893db1b01ac7e37)) 48 | 49 | ## [4.1.0](https://github.com/netlify/netlify-plugin-lighthouse/compare/v4.0.7...v4.1.0) (2023-04-13) 50 | 51 | 52 | ### Features 53 | 54 | * Allow plugin to run onSuccess with toml setting or env var LIGHTHOUSE_RUN_ON_SUCCESS ([#570](https://github.com/netlify/netlify-plugin-lighthouse/issues/570)) ([7de67f4](https://github.com/netlify/netlify-plugin-lighthouse/commit/7de67f4418a7062401fbdf04daee521ab7a9c08a)) 55 | 56 | ## [4.0.7](https://github.com/netlify/netlify-plugin-lighthouse/compare/v4.0.6...v4.0.7) (2023-01-25) 57 | 58 | 59 | ### Bug Fixes 60 | 61 | * **deps:** Revert "fix(deps): Upgrade puppeteer using cache utils ([#540](https://github.com/netlify/netlify-plugin-lighthouse/issues/540))" ([#544](https://github.com/netlify/netlify-plugin-lighthouse/issues/544)) ([7ea7fb9](https://github.com/netlify/netlify-plugin-lighthouse/commit/7ea7fb96d06181b5f88598c57e7d523089ef2b7d)) 62 | 63 | ## [4.0.6](https://github.com/netlify/netlify-plugin-lighthouse/compare/v4.0.5...v4.0.6) (2023-01-24) 64 | 65 | 66 | ### Bug Fixes 67 | 68 | * **deps:** Upgrade puppeteer using cache utils ([#540](https://github.com/netlify/netlify-plugin-lighthouse/issues/540)) ([f831eb1](https://github.com/netlify/netlify-plugin-lighthouse/commit/f831eb1ead7e0705e432ea5d7c6b1168a5db83c6)) 69 | 70 | ### Miscellaneous Chores 71 | 72 | * **chore:** Internal refactor to use ES modules ([#533](https://github.com/netlify/netlify-plugin-lighthouse/pull/533)) ([bbb55c9](https://github.com/netlify/netlify-plugin-lighthouse/commit/bbb55c9decbe3923add6ea6008d2be3ba03fd6f2)) 73 | 74 | ## [4.0.5](https://github.com/netlify/netlify-plugin-lighthouse/compare/v4.0.4...v4.0.5) (2023-01-16) 75 | 76 | 77 | ### Bug Fixes 78 | 79 | * **deps:** Revert "fix(deps) upgrade puppeteer to v 19.5.2 ([#532](https://github.com/netlify/netlify-plugin-lighthouse/issues/532))" ([#536](https://github.com/netlify/netlify-plugin-lighthouse/issues/536)) ([a319044](https://github.com/netlify/netlify-plugin-lighthouse/commit/a319044517c49d0f60d4f330ccba2756643bb929)) 80 | 81 | ## [4.0.4](https://github.com/netlify/netlify-plugin-lighthouse/compare/v4.0.3...v4.0.4) (2023-01-16) 82 | 83 | ### Bug Fixes 84 | 85 | * **deps:** update dependency puppeteer to v19.5.2 ([#532](https://github.com/netlify/netlify-plugin-lighthouse/pull/532)) ([fa216b2](https://github.com/netlify/netlify-plugin-lighthouse/commit/fa216b249cbf4a7c7128c8fa323639a73a1246b2)) 86 | ### Miscellaneous Chores 87 | 88 | * **deps:** update dependency prettier to v2.8.2 ([#530](https://github.com/netlify/netlify-plugin-lighthouse/issues/530)) ([2adadfe](https://github.com/netlify/netlify-plugin-lighthouse/commit/2adadfed5476c3bc73156894c1200caa383ed036)) 89 | 90 | ## [4.0.3](https://github.com/netlify/netlify-plugin-lighthouse/compare/v4.0.2...v4.0.3) (2022-11-17) 91 | 92 | 93 | ### Bug Fixes 94 | 95 | * **deps:** update dependency lighthouse to v9.6.8 ([#512](https://github.com/netlify/netlify-plugin-lighthouse/issues/512)) ([1edb983](https://github.com/netlify/netlify-plugin-lighthouse/commit/1edb9832df2ee413e6eb97a06c45cce4a985c5a2)) 96 | 97 | ## [4.0.2](https://github.com/netlify/netlify-plugin-lighthouse/compare/v4.0.1...v4.0.2) (2022-10-27) 98 | 99 | 100 | ### Bug Fixes 101 | 102 | * Surface runtime errors in Deploy Log and Deploy Summary ([#505](https://github.com/netlify/netlify-plugin-lighthouse/issues/505)) ([77ccef3](https://github.com/netlify/netlify-plugin-lighthouse/commit/77ccef381d1311a8f42cd881b3b28c1aee762cdf)) 103 | 104 | ## [4.0.1](https://github.com/netlify/netlify-plugin-lighthouse/compare/v4.0.0...v4.0.1) (2022-10-10) 105 | 106 | 107 | ### Bug Fixes 108 | 109 | * **deps:** update dependency express to v4.18.2 ([#489](https://github.com/netlify/netlify-plugin-lighthouse/issues/489)) ([5afd383](https://github.com/netlify/netlify-plugin-lighthouse/commit/5afd383e7fa2f98992f8cbb53f50a1abf02c26a6)) 110 | * **deps:** update dependency puppeteer to v18.2.1 ([#491](https://github.com/netlify/netlify-plugin-lighthouse/issues/491)) ([eeb38de](https://github.com/netlify/netlify-plugin-lighthouse/commit/eeb38ded8ad88735b9b0a9e44f78f51e049a269c)) 111 | 112 | ## [4.0.0](https://github.com/netlify/netlify-plugin-lighthouse/compare/v3.7.1...v4.0.0) (2022-10-07) 113 | 114 | 115 | ### ⚠ BREAKING CHANGES 116 | 117 | * The `path` audit input option no longer affects the served directory for an audit. Use `serveDir` instead. Use `path` to specify the sub directory or `html` file within the served directory that should be audited. 118 | 119 | ### Features 120 | 121 | * Allow running lighthouse on other pages available in publish folder ([#487](https://github.com/netlify/netlify-plugin-lighthouse/issues/487)) ([ea0856b](https://github.com/netlify/netlify-plugin-lighthouse/commit/ea0856b0980576942a0862b69d5b140a9b8025b3)) 122 | 123 | 124 | ### Bug Fixes 125 | 126 | * **deps:** update dependency dotenv to v16.0.3 ([#480](https://github.com/netlify/netlify-plugin-lighthouse/issues/480)) ([c204252](https://github.com/netlify/netlify-plugin-lighthouse/commit/c204252a18f63181381b6b0f7c5e5b34b5c9560e)) 127 | 128 | ## [3.7.1](https://github.com/netlify/netlify-plugin-lighthouse/compare/v3.7.0...v3.7.1) (2022-10-04) 129 | 130 | 131 | ### Bug Fixes 132 | 133 | * Reduce scope of 'installable' test ([#483](https://github.com/netlify/netlify-plugin-lighthouse/issues/483)) ([d88fd09](https://github.com/netlify/netlify-plugin-lighthouse/commit/d88fd09fe2135d935e7e9b4e5d39e04bfb207991)) 134 | 135 | ## [3.7.0](https://github.com/netlify/netlify-plugin-lighthouse/compare/v3.6.0...v3.7.0) (2022-10-03) 136 | 137 | 138 | ### Features 139 | 140 | * send reports on threshold failure ([#479](https://github.com/netlify/netlify-plugin-lighthouse/issues/479)) ([2001e7e](https://github.com/netlify/netlify-plugin-lighthouse/commit/2001e7e1ac15a2c875cb78fdb66ee8b44b3af4ab)) 141 | 142 | ## [3.6.0](https://github.com/netlify/netlify-plugin-lighthouse/compare/v3.5.0...v3.6.0) (2022-09-30) 143 | 144 | 145 | ### Features 146 | 147 | * adds reporting around installable status and config settings ([#476](https://github.com/netlify/netlify-plugin-lighthouse/issues/476)) ([37ddec7](https://github.com/netlify/netlify-plugin-lighthouse/commit/37ddec7614497022a180f0c3c2c45643ac841754)) 148 | 149 | ## [3.5.0](https://github.com/netlify/netlify-plugin-lighthouse/compare/v3.4.1...v3.5.0) (2022-09-29) 150 | 151 | 152 | ### Features 153 | 154 | * Add initial support for Lighthouse settings ([#474](https://github.com/netlify/netlify-plugin-lighthouse/issues/474)) ([587f9d6](https://github.com/netlify/netlify-plugin-lighthouse/commit/587f9d6276def4017b9d430b596cd407911be3e5)) 155 | 156 | 157 | ### Bug Fixes 158 | 159 | * adds score rounding to avoid floating point errors ([#473](https://github.com/netlify/netlify-plugin-lighthouse/issues/473)) ([8b2f4cf](https://github.com/netlify/netlify-plugin-lighthouse/commit/8b2f4cfdf3d0197b731acd5dd7e86c3ed8ac9ff2)) 160 | * **deps:** update dependency dotenv to v16.0.2 ([#462](https://github.com/netlify/netlify-plugin-lighthouse/issues/462)) ([d76b6a6](https://github.com/netlify/netlify-plugin-lighthouse/commit/d76b6a611948778b2c5b158d51e5bd1217ee951d)) 161 | * **deps:** update dependency lighthouse to v9.6.7 ([#463](https://github.com/netlify/netlify-plugin-lighthouse/issues/463)) ([43dfe6d](https://github.com/netlify/netlify-plugin-lighthouse/commit/43dfe6db2d05316183ca3e00a71b136e1ca6da2f)) 162 | * **deps:** update dependency puppeteer to v18 ([#472](https://github.com/netlify/netlify-plugin-lighthouse/issues/472)) ([bf1c432](https://github.com/netlify/netlify-plugin-lighthouse/commit/bf1c432db1f4269ca0f2bed828a2cbf8c3256127)) 163 | 164 | ## [3.4.1](https://github.com/netlify/netlify-plugin-lighthouse/compare/v3.4.0...v3.4.1) (2022-09-21) 165 | 166 | 167 | ### Bug Fixes 168 | 169 | * run replacements function before HTML minification ([#469](https://github.com/netlify/netlify-plugin-lighthouse/issues/469)) ([1e74d86](https://github.com/netlify/netlify-plugin-lighthouse/commit/1e74d86bc0d18a084ea6802e3e4b92b427386b72)) 170 | 171 | ## [3.4.0](https://github.com/netlify/netlify-plugin-lighthouse/compare/v3.3.0...v3.4.0) (2022-09-21) 172 | 173 | 174 | ### Features 175 | 176 | * Add support for theme matching and scroll reporting via `postMessage` ([#461](https://github.com/netlify/netlify-plugin-lighthouse/issues/461)) ([42822bc](https://github.com/netlify/netlify-plugin-lighthouse/commit/42822bca390d90f2573fc98ff9c4c33adf2c697d)) 177 | 178 | ## [3.3.0](https://github.com/netlify/netlify-plugin-lighthouse/compare/v3.2.1...v3.3.0) (2022-08-26) 179 | 180 | 181 | ### Features 182 | 183 | * inject theme-querying snippets to each report ([#455](https://github.com/netlify/netlify-plugin-lighthouse/issues/455)) ([646a06d](https://github.com/netlify/netlify-plugin-lighthouse/commit/646a06d2098377421fa667f8be69cbf85f73684b)) 184 | 185 | 186 | ### Bug Fixes 187 | 188 | * **deps:** update dependency lighthouse to v9.6.5 ([#447](https://github.com/netlify/netlify-plugin-lighthouse/issues/447)) ([72fe792](https://github.com/netlify/netlify-plugin-lighthouse/commit/72fe792abb8e84c4d2b9521a3a544203148b09b6)) 189 | * **deps:** update dependency lighthouse to v9.6.6 ([#456](https://github.com/netlify/netlify-plugin-lighthouse/issues/456)) ([194b212](https://github.com/netlify/netlify-plugin-lighthouse/commit/194b212db2cb138a6a685bc558527e0abfb60fa6)) 190 | * **deps:** update dependency puppeteer to v16 ([#451](https://github.com/netlify/netlify-plugin-lighthouse/issues/451)) ([a0b315e](https://github.com/netlify/netlify-plugin-lighthouse/commit/a0b315e29af70eae5d367d2326d05975f675a769)) 191 | * **deps:** update dependency puppeteer to v16.2.0 ([#457](https://github.com/netlify/netlify-plugin-lighthouse/issues/457)) ([19c9ee8](https://github.com/netlify/netlify-plugin-lighthouse/commit/19c9ee8fa5e2b596791f7ed12d1f40b5a682085d)) 192 | 193 | ## [3.2.1](https://github.com/netlify/netlify-plugin-lighthouse/compare/v3.2.0...v3.2.1) (2022-08-09) 194 | 195 | 196 | ### Bug Fixes 197 | 198 | * account for missing summary ([#452](https://github.com/netlify/netlify-plugin-lighthouse/issues/452)) ([e926936](https://github.com/netlify/netlify-plugin-lighthouse/commit/e9269364bbd59dc30f05f2a525aac845cdc8c89d)) 199 | 200 | ## [3.1.0](https://github.com/netlify/netlify-plugin-lighthouse/compare/v3.0.1...v3.1.0) (2022-07-27) 201 | 202 | 203 | ### Features 204 | 205 | * extract the full html report ([#440](https://github.com/netlify/netlify-plugin-lighthouse/issues/440)) ([0395dac](https://github.com/netlify/netlify-plugin-lighthouse/commit/0395dacf006d64a8cc8933c677e53bccc1653b43)) 206 | 207 | 208 | ### Bug Fixes 209 | 210 | * **deps:** update dependency puppeteer to v15.4.2 ([088882f](https://github.com/netlify/netlify-plugin-lighthouse/commit/088882fac2ac9c401e0a4c042403b411c683349f)) 211 | * **deps:** update dependency puppeteer to v15.5.0 ([#446](https://github.com/netlify/netlify-plugin-lighthouse/issues/446)) ([5e5ef31](https://github.com/netlify/netlify-plugin-lighthouse/commit/5e5ef3170593e80d4c10be703d1f17cd2a3d8b8b)) 212 | 213 | ## [3.0.1](https://github.com/netlify/netlify-plugin-lighthouse/compare/v3.0.0...v3.0.1) (2022-07-12) 214 | 215 | 216 | ### Bug Fixes 217 | 218 | * **deps:** update dependency dotenv to v16 ([#376](https://github.com/netlify/netlify-plugin-lighthouse/issues/376)) ([aff5309](https://github.com/netlify/netlify-plugin-lighthouse/commit/aff5309fd410a7abfd6a6409bba32a110e7964c0)) 219 | * **deps:** update dependency puppeteer to v15 ([#436](https://github.com/netlify/netlify-plugin-lighthouse/issues/436)) ([15118d0](https://github.com/netlify/netlify-plugin-lighthouse/commit/15118d0a03e82900284132bcd0d1b55046301bd7)) 220 | * specify node version `>=14.15 <18` ([#439](https://github.com/netlify/netlify-plugin-lighthouse/issues/439)) ([30cb14d](https://github.com/netlify/netlify-plugin-lighthouse/commit/30cb14d1ad721f15e48cefbdf23dc81bf6aa2ca9)) 221 | 222 | ## [3.0.0](https://github.com/netlify/netlify-plugin-lighthouse/compare/v2.1.3...v3.0.0) (2022-07-07) 223 | 224 | 225 | ### Features 226 | 227 | * upgrade Lighthouse and node ([#422](https://github.com/netlify/netlify-plugin-lighthouse/issues/422)) ([be93d56](https://github.com/netlify/netlify-plugin-lighthouse/commit/be93d56b5172774f3096d4aa97069d9e75d36832)) 228 | 229 | ### ⚠ BREAKING CHANGES 230 | 231 | * **deps:** update dependency lighthouse to v9 (#422) 232 | * drop support for Node 12. add support for node 16 (#422) 233 | 234 | ### Bug Fixes 235 | 236 | * **deps:** update dependency express to v4.17.3 ([4458347](https://github.com/netlify/netlify-plugin-lighthouse/commit/4458347c583f22abef7703c7124124d6a5392a95)) 237 | * **deps:** update dependency puppeteer to v13.1.2 ([af97807](https://github.com/netlify/netlify-plugin-lighthouse/commit/af978071179a152df6e02298350f6b2680c482df)) 238 | * **deps:** update dependency puppeteer to v13.1.3 ([73459cb](https://github.com/netlify/netlify-plugin-lighthouse/commit/73459cb6adce80d80162310da180af155f39a4b3)) 239 | * **deps:** update dependency puppeteer to v13.3.1 ([5a61a72](https://github.com/netlify/netlify-plugin-lighthouse/commit/5a61a7280d63b0925f854e451b8b6eceb9ab8d8b)) 240 | * **deps:** update dependency puppeteer to v13.4.0 ([efab3d9](https://github.com/netlify/netlify-plugin-lighthouse/commit/efab3d9107d72ce01b6ad7d0328c0f40261dafa8)) 241 | * **deps:** update dependency puppeteer to v13.4.1 ([16cdb32](https://github.com/netlify/netlify-plugin-lighthouse/commit/16cdb32d77d36f8e25aebdb2147035e3d240106e)) 242 | * **deps:** update dependency puppeteer to v13.5.1 ([d900737](https://github.com/netlify/netlify-plugin-lighthouse/commit/d90073704a6b1937ac5244636479e1358fcc73ba)) 243 | * **deps:** update dependency puppeteer to v13.5.2 ([c0f07fb](https://github.com/netlify/netlify-plugin-lighthouse/commit/c0f07fbc38f7bf415e3befac026d2cb741d432cb)) 244 | * update changelog to reflect already released ([#430](https://github.com/netlify/netlify-plugin-lighthouse/issues/430)) ([5825fdb](https://github.com/netlify/netlify-plugin-lighthouse/commit/5825fdbb3584a494dbe08b30bbe83e91bcfdb33f)) 245 | 246 | ### [2.1.3](https://github.com/netlify/netlify-plugin-lighthouse/compare/v2.1.2...v2.1.3) (2022-01-10) 247 | 248 | 249 | ### Bug Fixes 250 | 251 | * **deps:** update dependency chalk to v4.1.2 ([b4c1baf](https://github.com/netlify/netlify-plugin-lighthouse/commit/b4c1baf3ddb298fc9053fc30b10ab95bd724b98d)) 252 | * **deps:** update dependency chrome-launcher to ^0.15.0 ([a04050c](https://github.com/netlify/netlify-plugin-lighthouse/commit/a04050c5ed853fa06ffb580f7e299149b312597d)) 253 | * **deps:** update dependency chrome-launcher to v0.14.2 ([96f4f87](https://github.com/netlify/netlify-plugin-lighthouse/commit/96f4f8723bd57a48cbc780890aa92749471c77a3)) 254 | * **deps:** update dependency express to v4.17.2 ([94a9737](https://github.com/netlify/netlify-plugin-lighthouse/commit/94a973766852ca44ad80eb62327bd7f90ead80a1)) 255 | * **deps:** update dependency lighthouse to v8.2.0 ([7820eee](https://github.com/netlify/netlify-plugin-lighthouse/commit/7820eee8e86c40b49ab28fd7ba8ac8f8135a8e9e)) 256 | * **deps:** update dependency lighthouse to v8.3.0 ([e18ba36](https://github.com/netlify/netlify-plugin-lighthouse/commit/e18ba3605541a3b8112e4bb66e947ace47f7fbad)) 257 | * **deps:** update dependency lighthouse to v8.5.1 ([12316ad](https://github.com/netlify/netlify-plugin-lighthouse/commit/12316ad61a88460e2cb1f134050de3d3a93f6322)) 258 | * **deps:** update dependency lighthouse to v8.6.0 ([56a440f](https://github.com/netlify/netlify-plugin-lighthouse/commit/56a440f934999c0dd66432e0d5224ff879795184)) 259 | * **deps:** update dependency puppeteer to v13 ([#342](https://github.com/netlify/netlify-plugin-lighthouse/issues/342)) ([1b44c45](https://github.com/netlify/netlify-plugin-lighthouse/commit/1b44c45cb7686689e29756f379640a5937bad215)) 260 | 261 | ### [2.1.2](https://www.github.com/netlify/netlify-plugin-lighthouse/compare/v2.1.1...v2.1.2) (2021-06-13) 262 | 263 | 264 | ### Bug Fixes 265 | 266 | * replace http-server by express to enable gzip compression ([#222](https://www.github.com/netlify/netlify-plugin-lighthouse/issues/222)) ([a1962e5](https://www.github.com/netlify/netlify-plugin-lighthouse/commit/a1962e57de1a13c98436646355f72019f4beca79)) 267 | 268 | ### [2.1.1](https://www.github.com/netlify/netlify-plugin-lighthouse/compare/v2.1.0...v2.1.1) (2021-06-07) 269 | 270 | 271 | ### Bug Fixes 272 | 273 | * **deps:** update dependency chalk to v4.1.1 ([a7ef976](https://www.github.com/netlify/netlify-plugin-lighthouse/commit/a7ef976d9a211be51cd6f02702b712d52127a18b)) 274 | * **deps:** update dependency chrome-launcher to ^0.14.0 ([ac5599a](https://www.github.com/netlify/netlify-plugin-lighthouse/commit/ac5599a10463ef04b76c7d1c530cf404deaa510a)) 275 | * **deps:** update dependency dotenv to v10 ([#206](https://www.github.com/netlify/netlify-plugin-lighthouse/issues/206)) ([5fe5ce8](https://www.github.com/netlify/netlify-plugin-lighthouse/commit/5fe5ce8a893c9f5224fbbe9b219d8e1217c2c38b)) 276 | * **deps:** update dependency dotenv to v9 ([#195](https://www.github.com/netlify/netlify-plugin-lighthouse/issues/195)) ([da1fdca](https://www.github.com/netlify/netlify-plugin-lighthouse/commit/da1fdca91778a4b00b008b9af465cd7260c413dd)) 277 | * **deps:** update dependency dotenv to v9.0.2 ([c10486b](https://www.github.com/netlify/netlify-plugin-lighthouse/commit/c10486bb5561181c23efeb6c96df1a24f0b08938)) 278 | * **deps:** update dependency lighthouse to v7.4.0 ([852f93a](https://www.github.com/netlify/netlify-plugin-lighthouse/commit/852f93a182cce99dd8d3386638c860901807ff4c)) 279 | * **deps:** update dependency lighthouse to v7.5.0 ([a620593](https://www.github.com/netlify/netlify-plugin-lighthouse/commit/a62059372805284660e9db143f8d81919c0a3239)) 280 | * **deps:** update dependency lighthouse to v8 ([#216](https://www.github.com/netlify/netlify-plugin-lighthouse/issues/216)) ([5481656](https://www.github.com/netlify/netlify-plugin-lighthouse/commit/5481656bd50e11068012a872926733564d4ac5ca)) 281 | * **deps:** update dependency puppeteer to v10 ([#217](https://www.github.com/netlify/netlify-plugin-lighthouse/issues/217)) ([0a163bb](https://www.github.com/netlify/netlify-plugin-lighthouse/commit/0a163bbd066f0702cfa72720a5a27be0808af44d)) 282 | * **deps:** update dependency puppeteer to v9 ([#185](https://www.github.com/netlify/netlify-plugin-lighthouse/issues/185)) ([272ed72](https://www.github.com/netlify/netlify-plugin-lighthouse/commit/272ed7267374059eb4c6e516f4f3050d00438390)) 283 | 284 | ## [2.1.0](https://www.github.com/netlify/netlify-plugin-lighthouse/compare/v2.0.0...v2.1.0) (2021-04-21) 285 | 286 | 287 | ### Features 288 | 289 | * allow publishing the generated html repo ([#180](https://www.github.com/netlify/netlify-plugin-lighthouse/issues/180)) ([53886d5](https://www.github.com/netlify/netlify-plugin-lighthouse/commit/53886d592ac017f44e7959954b7ab2bcbd517b10)) 290 | 291 | ## [2.0.0](https://www.github.com/netlify/netlify-plugin-lighthouse/compare/v1.4.3...v2.0.0) (2021-03-03) 292 | 293 | 294 | ### ⚠ BREAKING CHANGES 295 | 296 | * **deps:** update dependency lighthouse to v7 (#134) 297 | * drop support for Node 10 (#165) 298 | 299 | ### Bug Fixes 300 | 301 | * **deps:** update dependency lighthouse to v7 ([#134](https://www.github.com/netlify/netlify-plugin-lighthouse/issues/134)) ([9ee4580](https://www.github.com/netlify/netlify-plugin-lighthouse/commit/9ee4580368ec16a9f9895e9038d3301acbe582fe)) 302 | 303 | 304 | ### Miscellaneous Chores 305 | 306 | * drop support for Node 10 ([#165](https://www.github.com/netlify/netlify-plugin-lighthouse/issues/165)) ([f750f58](https://www.github.com/netlify/netlify-plugin-lighthouse/commit/f750f5895c5993b75520a0c83ef5e85277479287)) 307 | 308 | ### [1.4.3](https://www.github.com/netlify/netlify-plugin-lighthouse/compare/v1.4.2...v1.4.3) (2021-03-01) 309 | 310 | 311 | ### Bug Fixes 312 | 313 | * **deps:** update dependency puppeteer to v7 ([#153](https://www.github.com/netlify/netlify-plugin-lighthouse/issues/153)) ([9da372f](https://www.github.com/netlify/netlify-plugin-lighthouse/commit/9da372f9b0a69f111a0036c57210f1e3bf8297eb)) 314 | * **deps:** update dependency puppeteer to v8 ([#162](https://www.github.com/netlify/netlify-plugin-lighthouse/issues/162)) ([d5668d0](https://www.github.com/netlify/netlify-plugin-lighthouse/commit/d5668d08a8ed756846477a5d6fb00b31df31677d)) 315 | * **docs:** align readme with plugins installation flow ([fe5d80e](https://www.github.com/netlify/netlify-plugin-lighthouse/commit/fe5d80eb3ccaa1760ada523778be2d0c626e19cf)) 316 | * **docs:** update local dev instructions ([#151](https://www.github.com/netlify/netlify-plugin-lighthouse/issues/151)) ([3841d96](https://www.github.com/netlify/netlify-plugin-lighthouse/commit/3841d960f55e215392fbacd739f2fdc4708ec2ab)) 317 | 318 | ## [1.4.2](https://github.com/netlify/netlify-plugin-lighthouse/compare/v1.4.1...v1.4.2) (2020-09-10) 319 | 320 | 321 | ### Bug Fixes 322 | 323 | * report relevant error on invalid jsons ([#80](https://github.com/netlify/netlify-plugin-lighthouse/issues/80)) ([7417e91](https://github.com/netlify/netlify-plugin-lighthouse/commit/7417e9170ba9865fb63ea1a34878b21bd5245232)) 324 | 325 | ## [1.4.1](https://github.com/netlify/netlify-plugin-lighthouse/compare/v1.4.0...v1.4.1) (2020-09-03) 326 | 327 | 328 | ### Bug Fixes 329 | 330 | * use onPostBuild instead of onSuccess ([#75](https://github.com/netlify/netlify-plugin-lighthouse/issues/75)) ([e998d06](https://github.com/netlify/netlify-plugin-lighthouse/commit/e998d06c6e2e3041001a8fd70f4b1716c8d86c90)) 331 | 332 | # [1.4.0](https://github.com/netlify/netlify-plugin-lighthouse/compare/v1.3.2...v1.4.0) (2020-07-22) 333 | 334 | 335 | ### Bug Fixes 336 | 337 | * **deps:** update dependency puppeteer to v5.2.1 ([#54](https://github.com/netlify/netlify-plugin-lighthouse/issues/54)) ([176049b](https://github.com/netlify/netlify-plugin-lighthouse/commit/176049ba7b45d10c82c3e9afb4fdd86204ac82a4)) 338 | 339 | 340 | ### Features 341 | 342 | * list relevant audits on category failure ([#56](https://github.com/netlify/netlify-plugin-lighthouse/issues/56)) ([6b9a854](https://github.com/netlify/netlify-plugin-lighthouse/commit/6b9a854189f489b65ebf63d384ca0d82bbe56ce5)) 343 | 344 | ## [1.3.2](https://github.com/netlify/netlify-plugin-lighthouse/compare/v1.3.1...v1.3.2) (2020-07-13) 345 | 346 | 347 | ### Bug Fixes 348 | 349 | * only set env.DEBUG_COLORS='true' for the plugin ([#51](https://github.com/netlify/netlify-plugin-lighthouse/issues/51)) ([b2ace7e](https://github.com/netlify/netlify-plugin-lighthouse/commit/b2ace7e15ce36084414d96dd0a7abb7a75948b54)) 350 | 351 | ## [1.3.1](https://github.com/netlify/netlify-plugin-lighthouse/compare/v1.3.0...v1.3.1) (2020-07-13) 352 | 353 | 354 | ### Bug Fixes 355 | 356 | * **lighthouse:** hide logs timestamp when running in tty ([#50](https://github.com/netlify/netlify-plugin-lighthouse/issues/50)) ([b2187ca](https://github.com/netlify/netlify-plugin-lighthouse/commit/b2187cac4413f993a8b27a47c49b0dfe48f9f044)) 357 | 358 | # [1.3.0](https://github.com/netlify/netlify-plugin-lighthouse/compare/v1.2.2...v1.3.0) (2020-07-12) 359 | 360 | 361 | ### Features 362 | 363 | * support multiple audits urls and paths ([#45](https://github.com/netlify/netlify-plugin-lighthouse/issues/45)) ([74dd46d](https://github.com/netlify/netlify-plugin-lighthouse/commit/74dd46d5e36bb168e7b1bdb600d23a8deee93cdc)) 364 | 365 | ## [1.2.2](https://github.com/netlify/netlify-plugin-lighthouse/compare/v1.2.1...v1.2.2) (2020-07-05) 366 | 367 | 368 | ### Bug Fixes 369 | 370 | * **deps:** update dependency lighthouse to v6.1.0 ([#34](https://github.com/netlify/netlify-plugin-lighthouse/issues/34)) ([b12f94e](https://github.com/netlify/netlify-plugin-lighthouse/commit/b12f94ebea0f2a6bc430289f453e5351621fdf30)) 371 | * **deps:** update dependency puppeteer to v4.0.1 ([#35](https://github.com/netlify/netlify-plugin-lighthouse/issues/35)) ([191c086](https://github.com/netlify/netlify-plugin-lighthouse/commit/191c086dd9ef33d17ed81554fc3b3e33cb75e9d2)) 372 | * **deps:** update dependency puppeteer to v5 ([#39](https://github.com/netlify/netlify-plugin-lighthouse/issues/39)) ([a1dbd9a](https://github.com/netlify/netlify-plugin-lighthouse/commit/a1dbd9a497402e205bc788ff1c847820935a02e4)) 373 | 374 | ## [1.2.1](https://github.com/netlify/netlify-plugin-lighthouse/compare/v1.2.0...v1.2.1) (2020-06-21) 375 | 376 | 377 | ### Bug Fixes 378 | 379 | * **logger:** pass colors=true to debug module ([#30](https://github.com/netlify/netlify-plugin-lighthouse/issues/30)) ([d54f4ae](https://github.com/netlify/netlify-plugin-lighthouse/commit/d54f4ae2bed3aef12110ac627b56cadb6a6ff28d)) 380 | 381 | # [1.2.0](https://github.com/netlify/netlify-plugin-lighthouse/compare/v1.1.0...v1.2.0) (2020-06-21) 382 | 383 | 384 | ### Bug Fixes 385 | 386 | * **deps:** update dependency puppeteer to v4 ([#27](https://github.com/netlify/netlify-plugin-lighthouse/issues/27)) ([86670f2](https://github.com/netlify/netlify-plugin-lighthouse/commit/86670f2eb11d101774f3b1feba727244138b347e)) 387 | 388 | 389 | ### Features 390 | 391 | * enable lighthouse logging ([#29](https://github.com/netlify/netlify-plugin-lighthouse/issues/29)) ([9225cdd](https://github.com/netlify/netlify-plugin-lighthouse/commit/9225cddcbc366ae18ded5b7e5f2d50d7a0ae5e09)) 392 | 393 | # [1.1.0](https://github.com/netlify/netlify-plugin-lighthouse/compare/v1.0.1...v1.1.0) (2020-06-11) 394 | 395 | 396 | ### Features 397 | 398 | * add overview messaging ([#20](https://github.com/netlify/netlify-plugin-lighthouse/issues/20)) ([be94a37](https://github.com/netlify/netlify-plugin-lighthouse/commit/be94a37ea9460cd32582a5ce5e64af8ea8663eca)) 399 | 400 | ## [1.0.1](https://github.com/netlify/netlify-plugin-lighthouse/compare/v1.0.0...v1.0.1) (2020-06-11) 401 | 402 | 403 | ### Bug Fixes 404 | 405 | * report error to failBuild ([#19](https://github.com/netlify/netlify-plugin-lighthouse/issues/19)) ([334a282](https://github.com/netlify/netlify-plugin-lighthouse/commit/334a282244bcd1b733ff9004d0c738585dddf6df)) 406 | * **package.json:** add bugs and repository fields ([#16](https://github.com/netlify/netlify-plugin-lighthouse/issues/16)) ([03d957d](https://github.com/netlify/netlify-plugin-lighthouse/commit/03d957dcf4345103c79400a18801b7e9d7e7b019)) 407 | 408 | # 1.0.0 (2020-06-10) 409 | 410 | 411 | ### Bug Fixes 412 | 413 | * stringify summary ([#13](https://github.com/netlify/netlify-plugin-lighthouse/issues/13)) ([cbe937a](https://github.com/netlify/netlify-plugin-lighthouse/commit/cbe937a7a77f92e915eca5c122a2cba7597d24ed)) 414 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions and bug reports to the project are not being accepted at this time. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019-2020 Netlify 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Netlify Plugin Lighthouse 2 | 3 | > [!IMPORTANT] 4 | > Contributions and bug reports to the project are not being accepted at this time. 5 | 6 | A Netlify plugin to generate a Lighthouse report for every deploy 7 | 8 | ## Installation options 9 | 10 | You can install the plugin for your site using your `netlify.toml` file or the Netlify UI. 11 | 12 | For the most customization options, we recommend installing the Lighthouse plugin with a `netlify.toml` file. 13 | 14 | `netlify.toml` file-based installation allows you to: 15 | 16 | - [Run Lighthouse audits for different site paths, such as the contact page and site home page](#lighthouse-plugin-configuration-options) 17 | - [Run Lighthouse audits for a desktop device](#run-lighthouse-audits-for-desktop) 18 | - [Generate Lighthouse results in a language other than English](#generate-lighthouse-results-in-other-languages) 19 | 20 | ### Install plugin through the Netlify UI 21 | 22 | For UI-based installation, you can install this plugin from the [Integrations Hub](https://www.netlify.com/integrations/lighthouse/), the [Plugins directory](https://app.netlify.com/plugins), or through this [direct installation link](https://app.netlify.com/plugins/@netlify/plugin-lighthouse/install). 23 | 24 | ### Install plugin with a `netlify.toml` file 25 | 26 | To install the plugin manually: 27 | 28 | From your project's base directory, use npm, yarn, or any other Node.js package manager to add the plugin to `devDependencies` in `package.json`. 29 | 30 | ```bash 31 | npm install -D @netlify/plugin-lighthouse 32 | ``` 33 | 34 | Then add the plugin to your `netlify.toml` configuration file: 35 | 36 | ```toml 37 | [[plugins]] 38 | package = "@netlify/plugin-lighthouse" 39 | 40 | # optional, deploy the lighthouse report to a path under your site 41 | [plugins.inputs.audits] 42 | output_path = "reports/lighthouse.html" 43 | ``` 44 | 45 | The lighthouse scores are automatically printed to the **Deploy log** in the Netlify UI. For example: 46 | 47 | ``` 48 | 2:35:07 PM: ──────────────────────────────────────────────────────────────── 49 | 2:35:07 PM: @netlify/plugin-lighthouse (onSuccess event) 50 | 2:35:07 PM: ──────────────────────────────────────────────────────────────── 51 | 2:35:07 PM: 52 | 2:35:07 PM: Serving and scanning site from directory dist 53 | 54 | ... 55 | 56 | 2:35:17 PM: { 57 | 2:35:17 PM: results: [ 58 | 2:35:17 PM: { title: 'Performance', score: 0.91, id: 'performance' }, 59 | 2:35:17 PM: { title: 'Accessibility', score: 0.93, id: 'accessibility' }, 60 | 2:35:17 PM: { title: 'Best Practices', score: 0.93, id: 'best-practices' }, 61 | 2:35:17 PM: { title: 'SEO', score: 0.81, id: 'seo' }, 62 | 2:35:17 PM: { title: 'Progressive Web App', score: 0.4, id: 'pwa' } 63 | 2:35:17 PM: ] 64 | 2:35:17 PM: } 65 | ``` 66 | 67 | ## Lighthouse plugin configuration options 68 | 69 | To customize how Lighthouse runs audits, you can make changes to the `netlify.toml` file. 70 | 71 | By default, the plugin will run after your build is deployed on the live deploy permalink, inspecting the home path `/`. 72 | You can add additional configuration and/or inspect a different path, or multiple additional paths by adding configuration in the `netlify.toml` file: 73 | 74 | ```toml 75 | [[plugins]] 76 | package = "@netlify/plugin-lighthouse" 77 | 78 | # Set minimum thresholds for each report area 79 | [plugins.inputs.thresholds] 80 | performance = 0.9 81 | 82 | # to audit a path other than / 83 | # route1 audit will use the top level thresholds 84 | [[plugins.inputs.audits]] 85 | path = "route1" 86 | 87 | # you can optionally specify an output_path per audit, relative to the path, where HTML report output will be saved 88 | output_path = "reports/route1.html" 89 | 90 | # to audit a specific absolute url 91 | [[plugins.inputs.audits]] 92 | url = "https://www.example.com" 93 | 94 | # you can specify thresholds per audit 95 | [plugins.inputs.audits.thresholds] 96 | performance = 0.8 97 | 98 | ``` 99 | 100 | #### Fail a deploy based on score thresholds 101 | 102 | By default, the lighthouse plugin will run _after_ your deploy has been successful, auditing the live deploy content. 103 | 104 | To run the plugin _before_ the deploy is live, use the `fail_deploy_on_score_thresholds` input to instead run during the `onPostBuild` event. 105 | This will statically serve your build output folder, and audit the `index.html` (or other file if specified as below). Please note that sites or site paths using SSR/ISR (server-side rendering or Incremental Static Regeneration) cannot be served and audited in this way. 106 | 107 | Using this configuration, if minimum threshold scores are supplied and not met, the deploy will fail. Set the threshold based on `performance`, `accessibility`, `best-practices`, `seo`, or `pwa`. 108 | 109 | ```toml 110 | [[plugins]] 111 | package = "@netlify/plugin-lighthouse" 112 | 113 | # Set the plugin to run prior to deploy, failing the build if minimum thresholds aren't set 114 | [plugins.inputs] 115 | fail_deploy_on_score_thresholds = "true" 116 | 117 | # Set minimum thresholds for each report area 118 | [plugins.inputs.thresholds] 119 | performance = 0.9 120 | accessibility = 0.7 121 | 122 | # to audit an HTML file other than index.html in the build directory 123 | [[plugins.inputs.audits]] 124 | path = "contact.html" 125 | 126 | # to audit an HTML file other than index.html in a sub path of the build directory 127 | [[plugins.inputs.audits]] 128 | path = "pages/contact.html" 129 | 130 | # to serve only a sub directory of the build directory for an audit 131 | # pages/index.html will be audited, and files outside of this directory will not be served 132 | [[plugins.inputs.audits]] 133 | serveDir = "pages" 134 | ``` 135 | 136 | ### Run Lighthouse audits for desktop 137 | 138 | By default, Lighthouse takes a mobile-first performance testing approach and runs audits for the mobile device experience. You can optionally run Lighthouse audits for the desktop experience by including `preset = "desktop"` in your `netlify.toml` file: 139 | 140 | ```toml 141 | [[plugins]] 142 | package = "@netlify/plugin-lighthouse" 143 | 144 | [plugins.inputs.settings] 145 | preset = "desktop" # Optionally run Lighthouse using a desktop configuration 146 | ``` 147 | 148 | Updates to `netlify.toml` will take effect for new builds. 149 | 150 | To return to running Lighthouse audits for the mobile experience, just remove the line `preset = "desktop"`. New builds will run Lighthouse for the mobile experience. 151 | 152 | ### Generate Lighthouse results in other languages 153 | 154 | By default, Lighthouse results are generated in English. To return Lighthouse results in other languages, include the language code from any Lighthouse-supported locale in your `netlify.toml` file. 155 | 156 | For the latest Lighthouse supported locales or language codes, check out this [official Lighthouse code](https://github.com/GoogleChrome/lighthouse/blob/da3c865d698abc9365fa7bb087a08ce8c89b0a05/types/lhr/settings.d.ts#L9). 157 | 158 | Updates to `netlify.toml` will take effect for new builds. 159 | 160 | #### Example to generate Lighthouse results in Spanish 161 | 162 | ```toml 163 | [[plugins]] 164 | package = "@netlify/plugin-lighthouse" 165 | 166 | [plugins.inputs.settings] 167 | locale = "es" # generates Lighthouse reports in Español 168 | ``` 169 | 170 | ### Run Lighthouse Locally 171 | 172 | Fork and clone this repo. 173 | 174 | Create a `.env` file based on the [example](.env.example) and run 175 | 176 | ```bash 177 | yarn install 178 | yarn local 179 | ``` 180 | 181 | ## Preview Lighthouse results within the Netlify UI 182 | 183 | The Netlify UI allows you to view Lighthouse scores for each of your builds on your site's Deploy Details page with a much richer format. 184 | 185 | You'll need to first install the [Lighthouse build plugin](https://app.netlify.com/plugins/@netlify/plugin-lighthouse/install) on your site. 186 | 187 | Deploy view with Lighthouse visualizations 188 | 189 | If you have multiple audits (e.g. multiple paths) defined in your build, we will display a roll-up of the average Lighthouse scores for all the current build's audits plus the results for each individual audit. 190 | 191 | Deploy details with multiple audit Lighthouse results 192 | 193 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | import plugin from './cypress/plugins/index.js'; 4 | 5 | export default defineConfig({ 6 | e2e: { 7 | setupNodeEvents(on, config) { 8 | return plugin(on, config); 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /cypress/e2e/e2e.cy.js: -------------------------------------------------------------------------------- 1 | describe('Generates Lighthouse reports', () => { 2 | it('Verify report on root path', () => { 3 | cy.visit('/reports/lighthouse.html'); 4 | cy.contains('Performance'); 5 | }); 6 | 7 | it('Verify report on route1', () => { 8 | cy.visit('/route1/reports/route1.html'); 9 | cy.contains('Performance'); 10 | }); 11 | 12 | it('Verify report on route2', () => { 13 | cy.visit('/route2/reports/lighthouse.html'); 14 | cy.contains('Performance'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | export default () => {}; 2 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netlify/netlify-plugin-lighthouse/e0670645ca0055e9d5213cc17aa6493850be7ebf/cypress/support/e2e.js -------------------------------------------------------------------------------- /example/contact.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 |

Contact

11 | 12 | 13 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 |

Example

11 | 12 | 13 | -------------------------------------------------------------------------------- /example/route1/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 |

Example Route 1

11 | 12 | 13 | -------------------------------------------------------------------------------- /example/route2/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 |

Example Route 2

11 | 12 | 13 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | testMatch: ['**/__tests__/**/*.+(ts|js)', '**/?(*.)+(spec|test).+(ts|js)'], 4 | setupFiles: ['/test/setup.js'], 5 | transform: {}, 6 | collectCoverageFrom: ['**/*.js'], 7 | }; 8 | -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | name: netlify-plugin-lighthouse 2 | inputs: 3 | - name: audits 4 | required: false 5 | description: A list of audits to perform. Each list item is an object with either a url/path to scan and an optional thresholds mapping. 6 | 7 | # Deprecated, use audits 8 | - name: audit_url 9 | required: false 10 | description: Url of the site to audit, defaults to scanning the current built version of the site 11 | 12 | - name: thresholds 13 | required: false 14 | description: Key value mapping of thresholds that will fail the build when not passed. 15 | 16 | - name: output_path 17 | required: false 18 | description: Path to save the generated HTML Lighthouse report 19 | 20 | - name: settings 21 | required: false 22 | description: Lighthouse-specific settings, used to modify reporting criteria 23 | 24 | - name: fail_deploy_on_score_thresholds 25 | required: false 26 | description: Fail deploy if minimum threshold scores are not met 27 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "echo 'no op'" 3 | publish = "example" 4 | 5 | [build.environment] 6 | NODE_VERSION = "20" 7 | 8 | [[plugins]] 9 | package = "./src/index.js" 10 | 11 | [plugins.inputs] 12 | output_path = "reports/lighthouse.html" 13 | 14 | # Note: Required for our Cypress smoke tests 15 | fail_deploy_on_score_thresholds = "true" 16 | 17 | [plugins.inputs.thresholds] 18 | performance = 0.9 19 | 20 | [[plugins.inputs.audits]] 21 | output_path = "reports/route1.html" 22 | serveDir = "route1" 23 | [[plugins.inputs.audits]] 24 | serveDir = "route2" 25 | [[plugins.inputs.audits]] 26 | serveDir = "" 27 | 28 | [[plugins]] 29 | package = "netlify-plugin-cypress" 30 | # do not run tests after deploy 31 | [plugins.inputs] 32 | enable = false 33 | # run tests after build 34 | [plugins.inputs.postBuild] 35 | enable = true 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@netlify/plugin-lighthouse", 3 | "version": "6.0.1", 4 | "description": "Netlify Plugin to run Lighthouse on each build", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "local": "node -e 'import(\"./src/index.js\").then(index => index.default()).then(events => events.onSuccess());'", 8 | "local-onpostbuild": "node -e 'import(\"./src/index.js\").then(index => index.default({fail_deploy_on_score_thresholds: \"true\"})).then(events => events.onPostBuild());'", 9 | "lint": "eslint 'src/**/*.js'", 10 | "format": "prettier --write 'src/**/*.js'", 11 | "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --collect-coverage --maxWorkers=1", 12 | "format:ci": "prettier --check 'src/**/*.js'" 13 | }, 14 | "keywords": [ 15 | "netlify", 16 | "netlify-plugin", 17 | "lighthouse" 18 | ], 19 | "files": [ 20 | "manifest.yml", 21 | "src" 22 | ], 23 | "author": "netlify-labs", 24 | "license": "MIT", 25 | "dependencies": { 26 | "chalk": "^4.1.0", 27 | "chrome-launcher": "^0.15.0", 28 | "compression": "^1.7.4", 29 | "dotenv": "^16.0.0", 30 | "express": "^4.17.1", 31 | "html-minifier": "^4.0.0", 32 | "lighthouse": "^9.6.3", 33 | "puppeteer": "^18.0.0" 34 | }, 35 | "engines": { 36 | "node": ">=18.14.0" 37 | }, 38 | "type": "module", 39 | "repository": { 40 | "type": "git", 41 | "url": "git+https://github.com/netlify/netlify-plugin-lighthouse.git" 42 | }, 43 | "bugs": { 44 | "url": "https://github.com/netlify/netlify-plugin-lighthouse/issues" 45 | }, 46 | "devDependencies": { 47 | "@commitlint/cli": "^17.0.0", 48 | "@commitlint/config-conventional": "^17.0.0", 49 | "cypress": "^12.0.0", 50 | "eslint": "^8.32.0", 51 | "eslint-plugin-cypress": "^2.11.2", 52 | "eslint-plugin-import": "^2.27.4", 53 | "husky": "^8.0.1", 54 | "jest": "^29.0.0", 55 | "netlify-plugin-cypress": "^2.2.1", 56 | "prettier": "^2.0.0", 57 | "strip-ansi": "^7.0.1" 58 | }, 59 | "husky": { 60 | "hooks": { 61 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | '@semantic-release/commit-analyzer', 4 | '@semantic-release/release-notes-generator', 5 | '@semantic-release/changelog', 6 | '@semantic-release/github', 7 | '@semantic-release/git', 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>netlify/renovate-config:default"], 3 | "ignorePresets": [":prHourlyLimit2"], 4 | "semanticCommits": true, 5 | "dependencyDashboard": true, 6 | "schedule": ["before 9am on Friday"], 7 | "labels": ["area: dependencies"], 8 | "stabilityDays": 3, 9 | "prCreation": "not-pending", 10 | "postUpdateOptions": ["yarnDedupeHighest"] 11 | } 12 | -------------------------------------------------------------------------------- /src/e2e/fail-threshold-onpostbuild.test.js: -------------------------------------------------------------------------------- 1 | import stripAnsi from 'strip-ansi'; 2 | 3 | import mockResult from './fixture/results.json'; 4 | import mockUtils from './fixture/utils.js'; 5 | import mockConsoleLog from './mocks/console-log.js'; 6 | import mockConsoleError from './mocks/console-error.js'; 7 | import mockLighthouse from './mocks/lighthouse.js'; 8 | import mockPuppeteer from './mocks/puppeteer.js'; 9 | import mockChromeLauncher from './mocks/chrome-launcher.js'; 10 | import resetEnv from './lib/reset-env.js'; 11 | import formatMockLog from './lib/format-mock-log.js'; 12 | 13 | mockConsoleLog(); 14 | mockConsoleError(); 15 | mockLighthouse(mockResult); 16 | mockPuppeteer(); 17 | mockChromeLauncher(); 18 | 19 | const lighthousePlugin = (await import('../index.js')).default; 20 | 21 | describe('lighthousePlugin with failed threshold run (onPostBuild)', () => { 22 | beforeEach(() => { 23 | resetEnv(); 24 | jest.clearAllMocks(); 25 | process.env.PUBLISH_DIR = 'example'; 26 | process.env.THRESHOLDS = JSON.stringify({ 27 | performance: 1, 28 | accessibility: 1, 29 | 'best-practices': 1, 30 | seo: 1, 31 | pwa: 1, 32 | }); 33 | }); 34 | 35 | it('should output expected log content', async () => { 36 | const logs = [ 37 | 'Generating Lighthouse report. This may take a minute…', 38 | 'Running Lighthouse on example/', 39 | 'Serving and scanning site from directory example', 40 | 'Lighthouse scores for example/', 41 | '- Performance: 100', 42 | '- Accessibility: 100', 43 | '- Best Practices: 100', 44 | '- SEO: 91', 45 | '- PWA: 30', 46 | ]; 47 | 48 | await lighthousePlugin({ 49 | fail_deploy_on_score_thresholds: 'true', 50 | }).onPostBuild({ utils: mockUtils }); 51 | expect(formatMockLog(console.log.mock.calls)).toEqual(logs); 52 | }); 53 | 54 | it('should not output expected success payload', async () => { 55 | await lighthousePlugin({ 56 | fail_deploy_on_score_thresholds: 'true', 57 | }).onPostBuild({ utils: mockUtils }); 58 | expect(mockUtils.status.show).not.toHaveBeenCalledWith(); 59 | }); 60 | 61 | it('should output expected error', async () => { 62 | const error = [ 63 | 'Expected category SEO to be greater or equal to 1 but got 0.91', 64 | " 'Document does not have a meta description' received a score of 0", 65 | 'Expected category PWA to be greater or equal to 1 but got 0.3', 66 | " 'Web app manifest or service worker do not meet the installability requirements' received a score of 0", 67 | " 'Does not register a service worker that controls page and `start_url`' received a score of 0", 68 | " 'Is not configured for a custom splash screen' received a score of 0", 69 | " 'Does not set a theme color for the address bar.' received a score of 0", 70 | " 'Does not provide a valid `apple-touch-icon`' received a score of 0", 71 | " 'Manifest doesn't have a maskable icon' received a score of 0", 72 | ]; 73 | 74 | await lighthousePlugin({ 75 | fail_deploy_on_score_thresholds: 'true', 76 | }).onPostBuild({ utils: mockUtils }); 77 | const resultError = console.error.mock.calls[0][0]; 78 | expect(stripAnsi(resultError).split('\n').filter(Boolean)).toEqual(error); 79 | }); 80 | 81 | it('should call the expected fail event', async () => { 82 | const message = [ 83 | 'Failed with error:', 84 | 'Expected category SEO to be greater or equal to 1 but got 0.91', 85 | 'Expected category PWA to be greater or equal to 1 but got 0.3', 86 | ]; 87 | const payload = { 88 | errorMetadata: [ 89 | { 90 | details: { 91 | formFactor: 'mobile', 92 | installable: false, 93 | locale: 'en-US', 94 | }, 95 | path: 'example/', 96 | report: '

Lighthouse Report (mock)

', 97 | summary: { 98 | accessibility: 100, 99 | 'best-practices': 100, 100 | performance: 100, 101 | pwa: 30, 102 | seo: 91, 103 | }, 104 | }, 105 | ], 106 | }; 107 | 108 | await lighthousePlugin({ 109 | fail_deploy_on_score_thresholds: 'true', 110 | }).onPostBuild({ utils: mockUtils }); 111 | const [resultMessage, resultPayload] = 112 | mockUtils.build.failBuild.mock.calls[0]; 113 | 114 | expect(stripAnsi(resultMessage).split('\n').filter(Boolean)).toEqual( 115 | message, 116 | ); 117 | expect(resultPayload).toEqual(payload); 118 | 119 | // This should only be called onSuccess 120 | expect(mockUtils.build.failPlugin).not.toHaveBeenCalled(); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /src/e2e/fail-threshold-onsuccess.test.js: -------------------------------------------------------------------------------- 1 | import stripAnsi from 'strip-ansi'; 2 | 3 | import mockResult from './fixture/results.json'; 4 | import mockUtils from './fixture/utils.js'; 5 | import mockConsoleLog from './mocks/console-log.js'; 6 | import mockConsoleError from './mocks/console-error.js'; 7 | import mockLighthouse from './mocks/lighthouse.js'; 8 | import mockPuppeteer from './mocks/puppeteer.js'; 9 | import mockChromeLauncher from './mocks/chrome-launcher.js'; 10 | import resetEnv from './lib/reset-env.js'; 11 | import formatMockLog from './lib/format-mock-log.js'; 12 | 13 | mockConsoleLog(); 14 | mockConsoleError(); 15 | mockLighthouse(mockResult); 16 | mockPuppeteer(); 17 | mockChromeLauncher(); 18 | 19 | const lighthousePlugin = (await import('../index.js')).default; 20 | 21 | describe('lighthousePlugin with failed threshold run (onSuccess)', () => { 22 | beforeEach(() => { 23 | resetEnv(); 24 | jest.clearAllMocks(); 25 | process.env.DEPLOY_URL = 'https://www.netlify.com'; 26 | process.env.THRESHOLDS = JSON.stringify({ 27 | performance: 1, 28 | accessibility: 1, 29 | 'best-practices': 1, 30 | seo: 1, 31 | pwa: 1, 32 | }); 33 | }); 34 | 35 | it('should output expected log content', async () => { 36 | const logs = [ 37 | 'Generating Lighthouse report. This may take a minute…', 38 | 'Running Lighthouse on /', 39 | 'Lighthouse scores for /', 40 | '- Performance: 100', 41 | '- Accessibility: 100', 42 | '- Best Practices: 100', 43 | '- SEO: 91', 44 | '- PWA: 30', 45 | ]; 46 | 47 | await lighthousePlugin().onSuccess({ utils: mockUtils }); 48 | expect(formatMockLog(console.log.mock.calls)).toEqual(logs); 49 | }); 50 | 51 | it('should not output expected success payload', async () => { 52 | await lighthousePlugin().onSuccess({ utils: mockUtils }); 53 | expect(mockUtils.status.show).not.toHaveBeenCalledWith(); 54 | }); 55 | 56 | it('should output expected error', async () => { 57 | const error = [ 58 | "Error for url 'https://www.netlify.com':", 59 | 'Expected category SEO to be greater or equal to 1 but got 0.91', 60 | " 'Document does not have a meta description' received a score of 0", 61 | 'Expected category PWA to be greater or equal to 1 but got 0.3', 62 | " 'Web app manifest or service worker do not meet the installability requirements' received a score of 0", 63 | " 'Does not register a service worker that controls page and `start_url`' received a score of 0", 64 | " 'Is not configured for a custom splash screen' received a score of 0", 65 | " 'Does not set a theme color for the address bar.' received a score of 0", 66 | " 'Does not provide a valid `apple-touch-icon`' received a score of 0", 67 | " 'Manifest doesn't have a maskable icon' received a score of 0", 68 | ]; 69 | 70 | await lighthousePlugin().onSuccess({ utils: mockUtils }); 71 | const resultError = console.error.mock.calls[0][0]; 72 | expect(stripAnsi(resultError).split('\n').filter(Boolean)).toEqual(error); 73 | }); 74 | 75 | it('should call the expected fail event', async () => { 76 | const message = [ 77 | 'Failed with error:', 78 | "Error for url 'https://www.netlify.com':", 79 | 'Expected category SEO to be greater or equal to 1 but got 0.91', 80 | 'Expected category PWA to be greater or equal to 1 but got 0.3', 81 | ]; 82 | const payload = { 83 | errorMetadata: [ 84 | { 85 | details: { 86 | formFactor: 'mobile', 87 | installable: false, 88 | locale: 'en-US', 89 | }, 90 | path: '/', 91 | report: '

Lighthouse Report (mock)

', 92 | summary: { 93 | accessibility: 100, 94 | 'best-practices': 100, 95 | performance: 100, 96 | pwa: 30, 97 | seo: 91, 98 | }, 99 | }, 100 | ], 101 | }; 102 | 103 | await lighthousePlugin().onSuccess({ utils: mockUtils }); 104 | const [resultMessage, resultPayload] = 105 | mockUtils.build.failPlugin.mock.calls[0]; 106 | 107 | expect(stripAnsi(resultMessage).split('\n').filter(Boolean)).toEqual( 108 | message, 109 | ); 110 | expect(resultPayload).toEqual(payload); 111 | 112 | // This should only be called onPostBuild 113 | expect(mockUtils.build.failBuild).not.toHaveBeenCalled(); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/e2e/fixture/utils.js: -------------------------------------------------------------------------------- 1 | const mockUtils = { 2 | build: { 3 | failPlugin: jest.fn(), 4 | failBuild: jest.fn(), 5 | }, 6 | status: { 7 | show: jest.fn(), 8 | }, 9 | }; 10 | 11 | export default mockUtils; 12 | -------------------------------------------------------------------------------- /src/e2e/lib/format-mock-log.js: -------------------------------------------------------------------------------- 1 | import stripAnsi from 'strip-ansi'; 2 | 3 | const formatMockLog = (log) => { 4 | return log.flat().map(stripAnsi); 5 | }; 6 | 7 | export default formatMockLog; 8 | -------------------------------------------------------------------------------- /src/e2e/lib/format-mock-log.test.js: -------------------------------------------------------------------------------- 1 | import formatMockLog from './format-mock-log.js'; 2 | 3 | describe('formatMockLog', () => { 4 | it('should return an array of strings', () => { 5 | const log = [['one'], ['two'], ['three']]; 6 | const result = formatMockLog(log); 7 | expect(result).toEqual(['one', 'two', 'three']); 8 | }); 9 | 10 | it('should strip ANSI characters', () => { 11 | const log = [ 12 | ['\u001b[31mone\u001b[39m'], 13 | ['\u001b[32mtwo\u001b[39m'], 14 | ['\u001b[33mthree\u001b[39m'], 15 | ]; 16 | const result = formatMockLog(log); 17 | expect(result).toEqual(['one', 'two', 'three']); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/e2e/lib/reset-env.js: -------------------------------------------------------------------------------- 1 | const resetEnv = () => { 2 | delete process.env.OUTPUT_PATH; 3 | delete process.env.PUBLISH_DIR; 4 | delete process.env.SETTINGS; 5 | delete process.env.THRESHOLDS; 6 | delete process.env.URL; 7 | delete process.env.AUDIT_URL; 8 | process.env.AUDITS = null; 9 | }; 10 | 11 | export default resetEnv; 12 | -------------------------------------------------------------------------------- /src/e2e/mocks/chrome-launcher.js: -------------------------------------------------------------------------------- 1 | const chromeLauncher = () => 2 | jest.unstable_mockModule('chrome-launcher', () => { 3 | return { 4 | default: { 5 | launch: () => 6 | Promise.resolve({ port: 49920, kill: () => Promise.resolve() }), 7 | }, 8 | }; 9 | }); 10 | 11 | export default chromeLauncher; 12 | -------------------------------------------------------------------------------- /src/e2e/mocks/console-error.js: -------------------------------------------------------------------------------- 1 | const consoleError = () => jest.spyOn(console, 'error').mockImplementation(); 2 | 3 | export default consoleError; 4 | -------------------------------------------------------------------------------- /src/e2e/mocks/console-log.js: -------------------------------------------------------------------------------- 1 | const consoleLog = () => jest.spyOn(console, 'log').mockImplementation(); 2 | 3 | export default consoleLog; 4 | -------------------------------------------------------------------------------- /src/e2e/mocks/lighthouse.js: -------------------------------------------------------------------------------- 1 | const lighthouse = (mockResult) => 2 | jest.unstable_mockModule('lighthouse', () => { 3 | return { 4 | default: () => Promise.resolve(mockResult), 5 | }; 6 | }); 7 | 8 | export default lighthouse; 9 | -------------------------------------------------------------------------------- /src/e2e/mocks/puppeteer.js: -------------------------------------------------------------------------------- 1 | const puppeteer = () => 2 | jest.unstable_mockModule('puppeteer', () => { 3 | return { 4 | default: { 5 | createBrowserFetcher: () => ({ 6 | localRevisions: () => Promise.resolve(['123']), 7 | revisionInfo: () => Promise.resolve({ executablePath: 'path' }), 8 | }), 9 | }, 10 | }; 11 | }); 12 | 13 | export default puppeteer; 14 | -------------------------------------------------------------------------------- /src/e2e/not-found-onpostbuild.test.js: -------------------------------------------------------------------------------- 1 | import mockResult from './fixture/results-not-found.json'; 2 | import mockUtils from './fixture/utils.js'; 3 | import mockConsoleLog from './mocks/console-log.js'; 4 | import mockConsoleError from './mocks/console-error.js'; 5 | import mockLighthouse from './mocks/lighthouse.js'; 6 | import mockPuppeteer from './mocks/puppeteer.js'; 7 | import mockChromeLauncher from './mocks/chrome-launcher.js'; 8 | import resetEnv from './lib/reset-env.js'; 9 | import formatMockLog from './lib/format-mock-log.js'; 10 | 11 | mockConsoleLog(); 12 | mockLighthouse(mockResult); 13 | mockPuppeteer(); 14 | mockChromeLauncher(); 15 | 16 | const lighthousePlugin = (await import('../index.js')).default; 17 | 18 | describe('lighthousePlugin with single not-found run (onPostBuild)', () => { 19 | beforeEach(() => { 20 | resetEnv(); 21 | jest.clearAllMocks(); 22 | process.env.PUBLISH_DIR = 'example'; 23 | process.env.AUDITS = JSON.stringify([{ path: 'this-page-does-not-exist' }]); 24 | }); 25 | 26 | it('should output expected log content', async () => { 27 | const logs = [ 28 | 'Generating Lighthouse report. This may take a minute…', 29 | 'Running Lighthouse on example/this-page-does-not-exist', 30 | 'Serving and scanning site from directory example', 31 | 'ERRORED_DOCUMENT_REQUEST', 32 | 'Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests. (Status code: 404)', 33 | ]; 34 | 35 | await lighthousePlugin({ 36 | fail_deploy_on_score_thresholds: 'true', 37 | }).onPostBuild({ utils: mockUtils }); 38 | expect(formatMockLog(console.log.mock.calls)).toEqual(logs); 39 | }); 40 | 41 | it('should output expected payload', async () => { 42 | const payload = { 43 | extraData: [ 44 | { 45 | details: { 46 | formFactor: 'mobile', 47 | installable: false, 48 | locale: 'en-US', 49 | }, 50 | report: '

Lighthouse Report (mock)

', 51 | }, 52 | ], 53 | summary: 54 | "Error testing 'example/this-page-does-not-exist': Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests. (Status code: 404)", 55 | }; 56 | 57 | await lighthousePlugin({ 58 | fail_deploy_on_score_thresholds: 'true', 59 | }).onPostBuild({ utils: mockUtils }); 60 | expect(mockUtils.status.show).toHaveBeenCalledWith(payload); 61 | }); 62 | 63 | it('should not output errors, or call fail events', async () => { 64 | mockConsoleError(); 65 | 66 | await lighthousePlugin({ 67 | fail_deploy_on_score_thresholds: 'true', 68 | }).onPostBuild({ utils: mockUtils }); 69 | expect(console.error).not.toHaveBeenCalled(); 70 | expect(mockUtils.build.failBuild).not.toHaveBeenCalled(); 71 | expect(mockUtils.build.failPlugin).not.toHaveBeenCalled(); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/e2e/not-found-onsuccess.test.js: -------------------------------------------------------------------------------- 1 | import mockResult from './fixture/results-not-found.json'; 2 | import mockUtils from './fixture/utils.js'; 3 | import mockConsoleLog from './mocks/console-log.js'; 4 | import mockConsoleError from './mocks/console-error.js'; 5 | import mockLighthouse from './mocks/lighthouse.js'; 6 | import mockPuppeteer from './mocks/puppeteer.js'; 7 | import mockChromeLauncher from './mocks/chrome-launcher.js'; 8 | import resetEnv from './lib/reset-env.js'; 9 | import formatMockLog from './lib/format-mock-log.js'; 10 | 11 | mockConsoleLog(); 12 | mockLighthouse(mockResult); 13 | mockPuppeteer(); 14 | mockChromeLauncher(); 15 | 16 | const lighthousePlugin = (await import('../index.js')).default; 17 | 18 | describe('lighthousePlugin with single not-found run (onSuccess)', () => { 19 | beforeEach(() => { 20 | resetEnv(); 21 | jest.clearAllMocks(); 22 | process.env.DEPLOY_URL = 'https://www.netlify.com'; 23 | process.env.AUDITS = JSON.stringify([{ path: 'this-page-does-not-exist' }]); 24 | }); 25 | 26 | it('should output expected log content', async () => { 27 | const logs = [ 28 | 'Generating Lighthouse report. This may take a minute…', 29 | 'Running Lighthouse on /this-page-does-not-exist', 30 | 'ERRORED_DOCUMENT_REQUEST', 31 | 'Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests. (Status code: 404)', 32 | ]; 33 | 34 | await lighthousePlugin().onSuccess({ utils: mockUtils }); 35 | expect(formatMockLog(console.log.mock.calls)).toEqual(logs); 36 | }); 37 | 38 | it('should output expected payload', async () => { 39 | const payload = { 40 | extraData: [ 41 | { 42 | details: { 43 | formFactor: 'mobile', 44 | installable: false, 45 | locale: 'en-US', 46 | }, 47 | report: '

Lighthouse Report (mock)

', 48 | }, 49 | ], 50 | summary: 51 | "Error testing '/this-page-does-not-exist': Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests. (Status code: 404)", 52 | }; 53 | 54 | await lighthousePlugin().onSuccess({ utils: mockUtils }); 55 | expect(mockUtils.status.show).toHaveBeenCalledWith(payload); 56 | }); 57 | 58 | it('should not output errors, or call fail events', async () => { 59 | mockConsoleError(); 60 | 61 | await lighthousePlugin().onSuccess({ utils: mockUtils }); 62 | expect(console.error).not.toHaveBeenCalled(); 63 | expect(mockUtils.build.failBuild).not.toHaveBeenCalled(); 64 | expect(mockUtils.build.failPlugin).not.toHaveBeenCalled(); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/e2e/settings-locale.test.js: -------------------------------------------------------------------------------- 1 | import mockResult from './fixture/results.json'; 2 | import mockUtils from './fixture/utils.js'; 3 | import mockConsoleLog from './mocks/console-log.js'; 4 | import mockConsoleError from './mocks/console-error.js'; 5 | import mockLighthouse from './mocks/lighthouse.js'; 6 | import mockPuppeteer from './mocks/puppeteer.js'; 7 | import mockChromeLauncher from './mocks/chrome-launcher.js'; 8 | import resetEnv from './lib/reset-env.js'; 9 | import formatMockLog from './lib/format-mock-log.js'; 10 | 11 | const modifiedResult = mockResult; 12 | modifiedResult.lhr.categories.performance.title = 'Rendimiento'; 13 | modifiedResult.lhr.categories.accessibility.title = 'Accesibilidad'; 14 | modifiedResult.lhr.categories['best-practices'].title = 15 | 'Prácticas recomendadas'; 16 | modifiedResult.lhr.categories.seo.title = 'SEO'; 17 | modifiedResult.lhr.categories.pwa.title = 'PWA'; 18 | modifiedResult.lhr.configSettings.locale = 'es'; 19 | 20 | mockConsoleLog(); 21 | mockLighthouse(modifiedResult); 22 | mockPuppeteer(); 23 | mockChromeLauncher(); 24 | 25 | const lighthousePlugin = (await import('../index.js')).default; 26 | 27 | describe('lighthousePlugin with custom locale', () => { 28 | beforeEach(() => { 29 | resetEnv(); 30 | jest.clearAllMocks(); 31 | process.env.PUBLISH_DIR = 'example'; 32 | process.env.SETTINGS = JSON.stringify({ locale: 'es' }); 33 | process.env.DEPLOY_URL = 'https://www.netlify.com'; 34 | }); 35 | 36 | it('should output expected log content', async () => { 37 | const logs = [ 38 | 'Generating Lighthouse report. This may take a minute…', 39 | 'Running Lighthouse on / using the “es” locale', 40 | 'Lighthouse scores for /', 41 | '- Rendimiento: 100', 42 | '- Accesibilidad: 100', 43 | '- Prácticas recomendadas: 100', 44 | '- SEO: 91', 45 | '- PWA: 30', 46 | ]; 47 | 48 | await lighthousePlugin().onSuccess({ utils: mockUtils }); 49 | expect(formatMockLog(console.log.mock.calls)).toEqual(logs); 50 | }); 51 | 52 | it('should output expected payload', async () => { 53 | const payload = { 54 | extraData: [ 55 | { 56 | details: { 57 | formFactor: 'mobile', 58 | installable: false, 59 | locale: 'es', 60 | }, 61 | path: '/', 62 | report: '

Lighthouse Report (mock)

', 63 | summary: { 64 | accessibility: 100, 65 | 'best-practices': 100, 66 | performance: 100, 67 | pwa: 30, 68 | seo: 91, 69 | }, 70 | }, 71 | ], 72 | summary: 73 | "Summary for path '/': Rendimiento: 100, Accesibilidad: 100, Prácticas recomendadas: 100, SEO: 91, PWA: 30", 74 | }; 75 | 76 | await lighthousePlugin().onSuccess({ utils: mockUtils }); 77 | expect(mockUtils.status.show).toHaveBeenCalledWith(payload); 78 | }); 79 | 80 | it('should not output errors, or call fail events', async () => { 81 | mockConsoleError(); 82 | 83 | await lighthousePlugin().onSuccess({ utils: mockUtils }); 84 | expect(console.error).not.toHaveBeenCalled(); 85 | expect(mockUtils.build.failBuild).not.toHaveBeenCalled(); 86 | expect(mockUtils.build.failPlugin).not.toHaveBeenCalled(); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /src/e2e/settings-preset.test.js: -------------------------------------------------------------------------------- 1 | import mockResult from './fixture/results.json'; 2 | import mockUtils from './fixture/utils.js'; 3 | import mockConsoleLog from './mocks/console-log.js'; 4 | import mockConsoleError from './mocks/console-error.js'; 5 | import mockLighthouse from './mocks/lighthouse.js'; 6 | import mockPuppeteer from './mocks/puppeteer.js'; 7 | import mockChromeLauncher from './mocks/chrome-launcher.js'; 8 | import resetEnv from './lib/reset-env.js'; 9 | import formatMockLog from './lib/format-mock-log.js'; 10 | 11 | const modifiedResult = mockResult; 12 | modifiedResult.lhr.configSettings.formFactor = 'desktop'; 13 | 14 | mockConsoleLog(); 15 | mockLighthouse(modifiedResult); 16 | mockPuppeteer(); 17 | mockChromeLauncher(); 18 | 19 | const lighthousePlugin = (await import('../index.js')).default; 20 | 21 | describe('lighthousePlugin with custom device preset', () => { 22 | beforeEach(() => { 23 | resetEnv(); 24 | jest.clearAllMocks(); 25 | process.env.PUBLISH_DIR = 'example'; 26 | process.env.SETTINGS = JSON.stringify({ preset: 'desktop' }); 27 | process.env.DEPLOY_URL = 'https://www.netlify.com'; 28 | }); 29 | 30 | it('should output expected log content', async () => { 31 | const logs = [ 32 | 'Generating Lighthouse report. This may take a minute…', 33 | 'Running Lighthouse on / using the “desktop” preset', 34 | 'Lighthouse scores for /', 35 | '- Performance: 100', 36 | '- Accessibility: 100', 37 | '- Best Practices: 100', 38 | '- SEO: 91', 39 | '- PWA: 30', 40 | ]; 41 | await lighthousePlugin().onSuccess({ utils: mockUtils }); 42 | expect(formatMockLog(console.log.mock.calls)).toEqual(logs); 43 | }); 44 | 45 | it('should output expected payload', async () => { 46 | const payload = { 47 | extraData: [ 48 | { 49 | details: { 50 | formFactor: 'desktop', 51 | installable: false, 52 | locale: 'en-US', 53 | }, 54 | path: '/', 55 | report: '

Lighthouse Report (mock)

', 56 | summary: { 57 | accessibility: 100, 58 | 'best-practices': 100, 59 | performance: 100, 60 | pwa: 30, 61 | seo: 91, 62 | }, 63 | }, 64 | ], 65 | summary: 66 | "Summary for path '/': Performance: 100, Accessibility: 100, Best Practices: 100, SEO: 91, PWA: 30", 67 | }; 68 | 69 | await lighthousePlugin().onSuccess({ utils: mockUtils }); 70 | expect(mockUtils.status.show).toHaveBeenCalledWith(payload); 71 | }); 72 | 73 | it('should not output errors, or call fail events', async () => { 74 | mockConsoleError(); 75 | 76 | await lighthousePlugin().onSuccess({ utils: mockUtils }); 77 | expect(console.error).not.toHaveBeenCalled(); 78 | expect(mockUtils.build.failBuild).not.toHaveBeenCalled(); 79 | expect(mockUtils.build.failPlugin).not.toHaveBeenCalled(); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/e2e/success-onpostbuild.test.js: -------------------------------------------------------------------------------- 1 | import mockResult from './fixture/results.json'; 2 | import mockUtils from './fixture/utils.js'; 3 | import mockConsoleLog from './mocks/console-log.js'; 4 | import mockConsoleError from './mocks/console-error.js'; 5 | import mockLighthouse from './mocks/lighthouse.js'; 6 | import mockPuppeteer from './mocks/puppeteer.js'; 7 | import mockChromeLauncher from './mocks/chrome-launcher.js'; 8 | import resetEnv from './lib/reset-env.js'; 9 | import formatMockLog from './lib/format-mock-log.js'; 10 | 11 | mockConsoleLog(); 12 | mockLighthouse(mockResult); 13 | mockPuppeteer(); 14 | mockChromeLauncher(); 15 | 16 | const lighthousePlugin = (await import('../index.js')).default; 17 | 18 | describe('lighthousePlugin with single report per run (onPostBuild)', () => { 19 | beforeEach(() => { 20 | resetEnv(); 21 | jest.clearAllMocks(); 22 | process.env.PUBLISH_DIR = 'example'; 23 | }); 24 | 25 | it('should output expected log content', async () => { 26 | const logs = [ 27 | 'Generating Lighthouse report. This may take a minute…', 28 | 'Running Lighthouse on example/', 29 | 'Serving and scanning site from directory example', 30 | 'Lighthouse scores for example/', 31 | '- Performance: 100', 32 | '- Accessibility: 100', 33 | '- Best Practices: 100', 34 | '- SEO: 91', 35 | '- PWA: 30', 36 | ]; 37 | await lighthousePlugin({ 38 | fail_deploy_on_score_thresholds: 'true', 39 | }).onPostBuild({ utils: mockUtils }); 40 | expect(formatMockLog(console.log.mock.calls)).toEqual(logs); 41 | }); 42 | 43 | it('should output expected payload', async () => { 44 | const payload = { 45 | extraData: [ 46 | { 47 | details: { 48 | formFactor: 'mobile', 49 | installable: false, 50 | locale: 'en-US', 51 | }, 52 | path: 'example/', 53 | report: '

Lighthouse Report (mock)

', 54 | summary: { 55 | accessibility: 100, 56 | 'best-practices': 100, 57 | performance: 100, 58 | pwa: 30, 59 | seo: 91, 60 | }, 61 | }, 62 | ], 63 | summary: 64 | "Summary for path 'example/': Performance: 100, Accessibility: 100, Best Practices: 100, SEO: 91, PWA: 30", 65 | }; 66 | 67 | await lighthousePlugin({ 68 | fail_deploy_on_score_thresholds: 'true', 69 | }).onPostBuild({ utils: mockUtils }); 70 | expect(mockUtils.status.show).toHaveBeenCalledWith(payload); 71 | }); 72 | 73 | it('should not output errors, or call fail events', async () => { 74 | mockConsoleError(); 75 | 76 | await lighthousePlugin({ 77 | fail_deploy_on_score_thresholds: 'true', 78 | }).onPostBuild({ utils: mockUtils }); 79 | expect(console.error).not.toHaveBeenCalled(); 80 | expect(mockUtils.build.failBuild).not.toHaveBeenCalled(); 81 | expect(mockUtils.build.failPlugin).not.toHaveBeenCalled(); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/e2e/success-onsuccess.test.js: -------------------------------------------------------------------------------- 1 | import mockResult from './fixture/results.json'; 2 | import mockUtils from './fixture/utils.js'; 3 | import mockConsoleLog from './mocks/console-log.js'; 4 | import mockConsoleError from './mocks/console-error.js'; 5 | import mockLighthouse from './mocks/lighthouse.js'; 6 | import mockPuppeteer from './mocks/puppeteer.js'; 7 | import mockChromeLauncher from './mocks/chrome-launcher.js'; 8 | import resetEnv from './lib/reset-env.js'; 9 | import formatMockLog from './lib/format-mock-log.js'; 10 | 11 | mockConsoleLog(); 12 | mockLighthouse(mockResult); 13 | mockPuppeteer(); 14 | mockChromeLauncher(); 15 | 16 | const lighthousePlugin = (await import('../index.js')).default; 17 | 18 | describe('lighthousePlugin with single report per run (onSuccess)', () => { 19 | beforeEach(() => { 20 | resetEnv(); 21 | jest.clearAllMocks(); 22 | process.env.DEPLOY_URL = 'https://www.netlify.com'; 23 | }); 24 | 25 | it('should output expected log content', async () => { 26 | const logs = [ 27 | 'Generating Lighthouse report. This may take a minute…', 28 | 'Running Lighthouse on /', 29 | 'Lighthouse scores for /', 30 | '- Performance: 100', 31 | '- Accessibility: 100', 32 | '- Best Practices: 100', 33 | '- SEO: 91', 34 | '- PWA: 30', 35 | ]; 36 | await lighthousePlugin().onSuccess({ utils: mockUtils }); 37 | expect(formatMockLog(console.log.mock.calls)).toEqual(logs); 38 | }); 39 | 40 | it('should output expected payload', async () => { 41 | const payload = { 42 | extraData: [ 43 | { 44 | details: { 45 | formFactor: 'mobile', 46 | installable: false, 47 | locale: 'en-US', 48 | }, 49 | path: '/', 50 | report: '

Lighthouse Report (mock)

', 51 | summary: { 52 | accessibility: 100, 53 | 'best-practices': 100, 54 | performance: 100, 55 | pwa: 30, 56 | seo: 91, 57 | }, 58 | }, 59 | ], 60 | summary: 61 | "Summary for path '/': Performance: 100, Accessibility: 100, Best Practices: 100, SEO: 91, PWA: 30", 62 | }; 63 | 64 | await lighthousePlugin().onSuccess({ utils: mockUtils }); 65 | expect(mockUtils.status.show).toHaveBeenCalledWith(payload); 66 | }); 67 | 68 | it('should not output errors, or call fail events', async () => { 69 | mockConsoleError(); 70 | 71 | await lighthousePlugin().onSuccess({ utils: mockUtils }); 72 | expect(console.error).not.toHaveBeenCalled(); 73 | expect(mockUtils.build.failBuild).not.toHaveBeenCalled(); 74 | expect(mockUtils.build.failPlugin).not.toHaveBeenCalled(); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/format.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { minify } from 'html-minifier'; 3 | 4 | import makeReplacements from './replacements.js'; 5 | 6 | export const belowThreshold = (id, expected, categories) => { 7 | const category = categories.find((c) => c.id === id); 8 | if (!category) { 9 | console.warn(`Could not find category ${chalk.yellow(id)}`); 10 | } 11 | const actual = category ? category.score : Number.MAX_SAFE_INTEGER; 12 | return actual < expected; 13 | }; 14 | 15 | export const getError = (id, expected, categories, audits) => { 16 | const category = categories.find((c) => c.id === id); 17 | 18 | const categoryError = `Expected category ${chalk.cyan( 19 | category.title, 20 | )} to be greater or equal to ${chalk.green(expected)} but got ${chalk.red( 21 | category.score !== null ? category.score : 'unknown', 22 | )}`; 23 | 24 | const categoryAudits = category.auditRefs 25 | .filter(({ weight, id }) => weight > 0 && audits[id].score < 1) 26 | .map((ref) => { 27 | const audit = audits[ref.id]; 28 | return ` '${chalk.cyan( 29 | audit.title, 30 | )}' received a score of ${chalk.yellow(audit.score)}`; 31 | }) 32 | .join('\n'); 33 | 34 | return { message: categoryError, details: categoryAudits }; 35 | }; 36 | 37 | export const formatShortSummary = (categories) => { 38 | return categories 39 | .map(({ title, score }) => `${title}: ${Math.round(score * 100)}`) 40 | .join(', '); 41 | }; 42 | 43 | export const formatResults = ({ results, thresholds }) => { 44 | const runtimeError = results.lhr.runtimeError; 45 | 46 | const categories = Object.values(results.lhr.categories).map( 47 | ({ title, score, id, auditRefs }) => ({ title, score, id, auditRefs }), 48 | ); 49 | 50 | const categoriesBelowThreshold = Object.entries(thresholds).filter( 51 | ([id, expected]) => belowThreshold(id, expected, categories), 52 | ); 53 | 54 | const errors = categoriesBelowThreshold.map(([id, expected]) => 55 | getError(id, expected, categories, results.lhr.audits), 56 | ); 57 | 58 | const summary = categories.map(({ title, score, id }) => ({ 59 | title, 60 | score, 61 | id, 62 | ...(thresholds[id] ? { threshold: thresholds[id] } : {}), 63 | })); 64 | 65 | const shortSummary = formatShortSummary(categories); 66 | 67 | const formattedReport = makeReplacements(results.report); 68 | 69 | // Pull some additional details to pass to App 70 | const { formFactor, locale } = results.lhr.configSettings; 71 | const installable = results.lhr.audits['installable-manifest']?.score === 1; 72 | const details = { installable, formFactor, locale }; 73 | 74 | const report = minify(formattedReport, { 75 | removeAttributeQuotes: true, 76 | collapseWhitespace: true, 77 | removeRedundantAttributes: true, 78 | removeOptionalTags: true, 79 | removeEmptyElements: true, 80 | minifyCSS: true, 81 | minifyJS: true, 82 | }); 83 | 84 | return { summary, shortSummary, details, report, errors, runtimeError }; 85 | }; 86 | -------------------------------------------------------------------------------- /src/format.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | belowThreshold, 3 | getError, 4 | formatShortSummary, 5 | formatResults, 6 | } from './format.js'; 7 | 8 | // Strip ANSI color codes from strings, as they make CI sad. 9 | const stripAnsiCodes = (str) => 10 | str.replace( 11 | // eslint-disable-next-line no-control-regex 12 | /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, 13 | '', 14 | ); 15 | 16 | describe('format', () => { 17 | const getCategories = ({ score }) => [ 18 | { 19 | title: 'Performance', 20 | score, 21 | id: 'performance', 22 | auditRefs: [ 23 | { weight: 1, id: 'is-crawlable' }, 24 | { weight: 1, id: 'robots-txt' }, 25 | { weight: 1, id: 'tap-targets' }, 26 | ], 27 | }, 28 | ]; 29 | const audits = { 30 | 'is-crawlable': { 31 | id: 'is-crawlable', 32 | title: 'Page isn’t blocked from indexing', 33 | description: 34 | "Search engines are unable to include your pages in search results if they don't have permission to crawl them. [Learn more](https://web.dev/is-crawable/).", 35 | score: 1, 36 | }, 37 | 'robots-txt': { 38 | id: 'robots-txt', 39 | title: 'robots.txt is valid', 40 | description: 41 | 'If your robots.txt file is malformed, crawlers may not be able to understand how you want your website to be crawled or indexed. [Learn more](https://web.dev/robots-txt/).', 42 | score: 0, 43 | }, 44 | 'tap-targets': { 45 | id: 'tap-targets', 46 | title: 'Tap targets are sized appropriately', 47 | description: 48 | 'Interactive elements like buttons and links should be large enough (48x48px), and have enough space around them, to be easy enough to tap without overlapping onto other elements. [Learn more](https://web.dev/tap-targets/).', 49 | score: 0.5, 50 | }, 51 | }; 52 | 53 | const formattedError = { 54 | details: 55 | " 'robots.txt is valid' received a score of 0\n" + 56 | " 'Tap targets are sized appropriately' received a score of 0.5", 57 | message: 58 | 'Expected category Performance to be greater or equal to 1 but got 0.5', 59 | }; 60 | 61 | describe('belowThreshold', () => { 62 | const categories = [ 63 | { title: 'Performance', score: 0.9, id: 'performance' }, 64 | { title: 'Accessibility', score: 0.8, id: 'accessibility' }, 65 | ]; 66 | 67 | it('returns false when the score is above the threshold', () => { 68 | expect(belowThreshold('performance', 0.8, categories)).toBe(false); 69 | }); 70 | 71 | it('returns false when the category is not found', () => { 72 | console.warn = jest.fn(); 73 | const result = belowThreshold('seo', 0.8, categories); 74 | expect(console.warn).toHaveBeenCalled(); 75 | expect(result).toBe(false); 76 | }); 77 | 78 | it('returns true when the score is below the threshold', () => { 79 | expect(belowThreshold('performance', 1, categories)).toBe(true); 80 | }); 81 | }); 82 | 83 | describe('getError', () => { 84 | it('returns an expected error message and list of details with valid score', () => { 85 | const errorMessage = getError( 86 | 'performance', 87 | 1, 88 | getCategories({ score: 0.5 }), 89 | audits, 90 | ); 91 | expect(stripAnsiCodes(errorMessage.details)).toEqual( 92 | formattedError.details, 93 | ); 94 | expect(stripAnsiCodes(errorMessage.message)).toEqual( 95 | formattedError.message, 96 | ); 97 | }); 98 | 99 | it('returns an expected error message and list of details without valid score', () => { 100 | const errorMessage = getError( 101 | 'performance', 102 | 1, 103 | getCategories({ score: null }), 104 | audits, 105 | ); 106 | expect(stripAnsiCodes(errorMessage.message)).toContain( 107 | 'to be greater or equal to 1 but got unknown', 108 | ); 109 | }); 110 | }); 111 | 112 | describe('formatShortSummary', () => { 113 | const categories = [ 114 | { title: 'Performance', score: 1, id: 'performance' }, 115 | { title: 'Accessibility', score: 0.9, id: 'accessibility' }, 116 | { title: 'Best Practices', score: 0.8, id: 'best-practices' }, 117 | { title: 'SEO', score: 0.7, id: 'seo' }, 118 | { title: 'PWA', score: 0.6, id: 'pwa' }, 119 | ]; 120 | 121 | it('should return a shortSummary containing scores if available', () => { 122 | const shortSummary = formatShortSummary(categories); 123 | expect(shortSummary).toEqual( 124 | 'Performance: 100, Accessibility: 90, Best Practices: 80, SEO: 70, PWA: 60', 125 | ); 126 | }); 127 | }); 128 | 129 | describe('formatResults', () => { 130 | const getResults = () => ({ 131 | lhr: { 132 | lighthouseVersion: '9.6.3', 133 | requestedUrl: 'http://localhost:5100/404.html', 134 | finalUrl: 'http://localhost:5100/404.html', 135 | audits, 136 | configSettings: {}, 137 | categories: getCategories({ score: 0.5 }), 138 | }, 139 | artifacts: {}, 140 | report: '\n' + 'Hi\n', 141 | }); 142 | 143 | it('should return formatted results', () => { 144 | expect(formatResults({ results: getResults(), thresholds: {} })).toEqual({ 145 | details: { 146 | formFactor: undefined, 147 | installable: false, 148 | locale: undefined, 149 | }, 150 | errors: [], 151 | report: 'Hi', 152 | shortSummary: 'Performance: 50', 153 | summary: [{ id: 'performance', score: 0.5, title: 'Performance' }], 154 | }); 155 | }); 156 | 157 | it('should return formatted results with passing thresholds', () => { 158 | const thresholds = { 159 | performance: 0.1, 160 | }; 161 | const formattedResults = formatResults({ 162 | results: getResults(), 163 | thresholds, 164 | }); 165 | expect(formattedResults.errors).toEqual([]); 166 | expect(formattedResults.summary).toEqual([ 167 | { 168 | id: 'performance', 169 | score: 0.5, 170 | title: 'Performance', 171 | threshold: 0.1, 172 | }, 173 | ]); 174 | }); 175 | 176 | it('should return formatted results with failing thresholds', () => { 177 | const thresholds = { 178 | performance: 1, 179 | }; 180 | const formattedResults = formatResults({ 181 | results: getResults(), 182 | thresholds, 183 | }); 184 | expect(stripAnsiCodes(formattedResults.errors[0].message)).toEqual( 185 | formattedError.message, 186 | ); 187 | expect(stripAnsiCodes(formattedResults.errors[0].details)).toEqual( 188 | formattedError.details, 189 | ); 190 | expect(formattedResults.summary).toEqual([ 191 | { 192 | id: 'performance', 193 | score: 0.5, 194 | title: 'Performance', 195 | threshold: 1, 196 | }, 197 | ]); 198 | }); 199 | 200 | it('should use supplied config settings and data to populate `details`', () => { 201 | const results = getResults(); 202 | results.lhr.configSettings = { 203 | locale: 'es', 204 | formFactor: 'desktop', 205 | }; 206 | results.lhr.audits['installable-manifest'] = { 207 | id: 'installable-manifest', 208 | score: 1, 209 | }; 210 | 211 | const formattedResults = formatResults({ results, thresholds: {} }); 212 | expect(formattedResults.details).toEqual({ 213 | formFactor: 'desktop', 214 | installable: true, 215 | locale: 'es', 216 | }); 217 | }); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | 3 | import runEvent from './lib/run-event/index.js'; 4 | import getUtils from './lib/get-utils/index.js'; 5 | 6 | dotenv.config(); 7 | 8 | export default function lighthousePlugin(inputs) { 9 | // Run onSuccess by default, unless inputs specify we should fail_deploy_on_score_thresholds 10 | const defaultEvent = 11 | inputs?.fail_deploy_on_score_thresholds === 'true' 12 | ? 'onPostBuild' 13 | : 'onSuccess'; 14 | 15 | if (defaultEvent === 'onSuccess') { 16 | return { 17 | onSuccess: async ({ constants, utils, inputs } = {}) => { 18 | // Mock the required `utils` functions if running locally 19 | const { failPlugin, show } = getUtils({ utils }); 20 | 21 | await runEvent({ 22 | event: 'onSuccess', 23 | constants, 24 | inputs, 25 | onComplete: show, 26 | onFail: failPlugin, 27 | }); 28 | }, 29 | }; 30 | } else { 31 | return { 32 | onPostBuild: async ({ constants, utils, inputs } = {}) => { 33 | // Mock the required `utils` functions if running locally 34 | const { failBuild, show } = getUtils({ utils }); 35 | 36 | await runEvent({ 37 | event: 'onPostBuild', 38 | constants, 39 | inputs, 40 | onComplete: show, 41 | onFail: failBuild, 42 | }); 43 | }, 44 | }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import lighthousePlugin from './index.js'; 2 | 3 | describe('lighthousePlugin plugin events', () => { 4 | describe('onPostBuild', () => { 5 | it('should return only the expected event function', async () => { 6 | const events = lighthousePlugin({ 7 | fail_deploy_on_score_thresholds: 'true', 8 | }); 9 | expect(events).toEqual({ 10 | onPostBuild: expect.any(Function), 11 | }); 12 | }); 13 | }); 14 | 15 | describe('onSuccess', () => { 16 | it('should return only the expected event function', async () => { 17 | const events = lighthousePlugin(); 18 | expect(events).toEqual({ 19 | onSuccess: expect.any(Function), 20 | }); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/lib/get-configuration/get-configuration.test.js: -------------------------------------------------------------------------------- 1 | jest.spyOn(console, 'warn').mockImplementation(() => {}); 2 | jest.unstable_mockModule('chalk', () => { 3 | return { 4 | default: { 5 | green: (m) => m, 6 | yellow: (m) => m, 7 | red: (m) => m, 8 | }, 9 | }; 10 | }); 11 | 12 | const getConfiguration = (await import('./index.js')).default; 13 | 14 | describe('config', () => { 15 | beforeEach(() => { 16 | delete process.env.PUBLISH_DIR; 17 | delete process.env.AUDIT_URL; 18 | delete process.env.THRESHOLDS; 19 | delete process.env.AUDITS; 20 | jest.clearAllMocks(); 21 | }); 22 | it('should empty config when constants, inputs are undefined', () => { 23 | const config = getConfiguration(); 24 | 25 | expect(config).toEqual({ 26 | auditConfigs: [ 27 | { 28 | serveDir: undefined, 29 | url: undefined, 30 | thresholds: {}, 31 | }, 32 | ], 33 | }); 34 | }); 35 | 36 | it('should return config from process.env when constants, inputs are undefined', () => { 37 | process.env.PUBLISH_DIR = 'PUBLISH_DIR'; 38 | process.env.AUDIT_URL = 'AUDIT_URL'; 39 | process.env.THRESHOLDS = JSON.stringify({ performance: 0.9 }); 40 | const config = getConfiguration(); 41 | 42 | expect(config).toEqual({ 43 | auditConfigs: [ 44 | { 45 | serveDir: 'PUBLISH_DIR', 46 | url: 'AUDIT_URL', 47 | thresholds: { performance: 0.9 }, 48 | }, 49 | ], 50 | }); 51 | }); 52 | 53 | it('should return config from process.env.AUDITS', () => { 54 | process.env.PUBLISH_DIR = 'PUBLISH_DIR'; 55 | process.env.AUDITS = JSON.stringify([ 56 | { url: 'https://www.test.com', thresholds: { performance: 0.9 } }, 57 | { serveDir: 'route1', thresholds: { seo: 0.9 } }, 58 | ]); 59 | const config = getConfiguration(); 60 | 61 | expect(config).toEqual({ 62 | auditConfigs: [ 63 | { 64 | url: 'https://www.test.com', 65 | thresholds: { performance: 0.9 }, 66 | serveDir: 'PUBLISH_DIR', 67 | }, 68 | { serveDir: 'PUBLISH_DIR/route1', thresholds: { seo: 0.9 } }, 69 | ], 70 | }); 71 | }); 72 | 73 | it('should print deprecated warning when using audit_url', () => { 74 | const constants = {}; 75 | const inputs = { audit_url: 'url' }; 76 | 77 | getConfiguration({ constants, inputs }); 78 | 79 | expect(console.warn).toHaveBeenCalledTimes(1); 80 | 81 | expect(console.warn).toHaveBeenCalledWith( 82 | 'inputs.audit_url is deprecated, please use inputs.audits', 83 | ); 84 | }); 85 | 86 | it('should return config from constants and inputs', () => { 87 | const constants = { PUBLISH_DIR: 'PUBLISH_DIR' }; 88 | const inputs = { 89 | audit_url: 'url', 90 | thresholds: { seo: 1 }, 91 | output_path: 'reports/lighthouse.html', 92 | }; 93 | const config = getConfiguration({ constants, inputs }); 94 | 95 | expect(config).toEqual({ 96 | auditConfigs: [ 97 | { 98 | serveDir: 'PUBLISH_DIR', 99 | url: 'url', 100 | thresholds: { seo: 1 }, 101 | output_path: 'reports/lighthouse.html', 102 | }, 103 | ], 104 | }); 105 | }); 106 | 107 | it('should append audits serveDir to PUBLISH_DIR', () => { 108 | const constants = { PUBLISH_DIR: 'PUBLISH_DIR' }; 109 | const inputs = { 110 | audits: [{ serveDir: 'route1', thresholds: { seo: 1 } }], 111 | }; 112 | const config = getConfiguration({ constants, inputs }); 113 | 114 | expect(config).toEqual({ 115 | auditConfigs: [ 116 | { serveDir: 'PUBLISH_DIR/route1', thresholds: { seo: 1 } }, 117 | ], 118 | }); 119 | }); 120 | 121 | it('should use default thresholds when no audit thresholds is configured', () => { 122 | const constants = { PUBLISH_DIR: 'PUBLISH_DIR' }; 123 | const inputs = { 124 | thresholds: { performance: 1 }, 125 | audits: [ 126 | { serveDir: 'route1', thresholds: { seo: 1 } }, 127 | { serveDir: 'route2' }, 128 | ], 129 | }; 130 | const config = getConfiguration({ constants, inputs }); 131 | 132 | expect(config).toEqual({ 133 | auditConfigs: [ 134 | { serveDir: 'PUBLISH_DIR/route1', thresholds: { seo: 1 } }, 135 | { serveDir: 'PUBLISH_DIR/route2', thresholds: { performance: 1 } }, 136 | ], 137 | }); 138 | }); 139 | 140 | it('should throw error on serveDir path traversal', () => { 141 | const constants = { PUBLISH_DIR: 'PUBLISH_DIR' }; 142 | const inputs = { 143 | thresholds: { performance: 1 }, 144 | audits: [{ serveDir: '../' }], 145 | }; 146 | 147 | expect(() => getConfiguration({ constants, inputs })).toThrow( 148 | new Error( 149 | 'resolved path for ../ is outside publish directory PUBLISH_DIR', 150 | ), 151 | ); 152 | }); 153 | 154 | it('should treat audit serveDir as relative path', () => { 155 | const constants = { PUBLISH_DIR: 'PUBLISH_DIR' }; 156 | const inputs = { 157 | audits: [{ serveDir: '/a/b' }], 158 | }; 159 | 160 | const config = getConfiguration({ constants, inputs }); 161 | 162 | expect(config).toEqual({ 163 | auditConfigs: [{ serveDir: 'PUBLISH_DIR/a/b', thresholds: {} }], 164 | }); 165 | }); 166 | 167 | it('should throw error on invalid thresholds json input', () => { 168 | const constants = { THRESHOLDS: 'PUBLISH_DIR' }; 169 | const inputs = { 170 | thresholds: 'invalid_json', 171 | audits: [{}], 172 | }; 173 | 174 | expect(() => getConfiguration({ constants, inputs })).toThrow( 175 | /Invalid JSON for 'thresholds' input/, 176 | ); 177 | }); 178 | 179 | it('should throw error on invalid audits json input', () => { 180 | const constants = { THRESHOLDS: 'PUBLISH_DIR' }; 181 | const inputs = { 182 | thresholds: { performance: 1 }, 183 | audits: 'invalid_json', 184 | }; 185 | 186 | expect(() => getConfiguration({ constants, inputs })).toThrow( 187 | /Invalid JSON for 'audits' input/, 188 | ); 189 | }); 190 | 191 | it('should use specific audit output_path when configured', () => { 192 | const constants = { PUBLISH_DIR: 'PUBLISH_DIR' }; 193 | const inputs = { 194 | output_path: 'reports/lighthouse.html', 195 | audits: [ 196 | { serveDir: 'route1' }, 197 | { serveDir: 'route2', output_path: 'reports/route2.html' }, 198 | ], 199 | }; 200 | const config = getConfiguration({ constants, inputs }); 201 | 202 | expect(config).toEqual({ 203 | auditConfigs: [ 204 | { 205 | serveDir: 'PUBLISH_DIR/route1', 206 | output_path: 'reports/lighthouse.html', 207 | thresholds: {}, 208 | }, 209 | { 210 | serveDir: 'PUBLISH_DIR/route2', 211 | output_path: 'reports/route2.html', 212 | thresholds: {}, 213 | }, 214 | ], 215 | }); 216 | }); 217 | }); 218 | -------------------------------------------------------------------------------- /src/lib/get-configuration/index.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | import getServePath from '../get-serve-path/index.js'; 4 | 5 | const getConfiguration = ({ constants, inputs, deployUrl } = {}) => { 6 | const useDeployUrl = !!deployUrl; 7 | 8 | const serveDir = 9 | (constants && constants.PUBLISH_DIR) || process.env.PUBLISH_DIR; 10 | 11 | const auditUrl = (inputs && inputs.audit_url) || process.env.AUDIT_URL; 12 | 13 | if (auditUrl) { 14 | console.warn( 15 | `${chalk.yellow( 16 | 'inputs.audit_url', 17 | )} is deprecated, please use ${chalk.green('inputs.audits')}`, 18 | ); 19 | } 20 | 21 | const output_path = (inputs && inputs.output_path) || process.env.OUTPUT_PATH; 22 | 23 | let thresholds = 24 | (inputs && inputs.thresholds) || process.env.THRESHOLDS || {}; 25 | 26 | if (typeof thresholds === 'string') { 27 | try { 28 | thresholds = JSON.parse(thresholds); 29 | } catch (e) { 30 | throw new Error(`Invalid JSON for 'thresholds' input: ${e.message}`); 31 | } 32 | } 33 | 34 | let audits = (inputs && inputs.audits) || process.env.AUDITS; 35 | if (typeof audits === 'string') { 36 | try { 37 | audits = JSON.parse(audits); 38 | } catch (e) { 39 | throw new Error(`Invalid JSON for 'audits' input: ${e.message}`); 40 | } 41 | } 42 | 43 | let auditConfigs = []; 44 | 45 | /** TODO: Simplify this… 46 | * When using a deployUrl (testing against the live deployed site), we don't 47 | * need to serve the site, so we can skip the serveDir and output_path 48 | */ 49 | 50 | if (!Array.isArray(audits)) { 51 | auditConfigs = [ 52 | { 53 | serveDir: useDeployUrl ? undefined : serveDir, 54 | url: useDeployUrl ? deployUrl : auditUrl, 55 | thresholds, 56 | output_path: useDeployUrl ? undefined : output_path, 57 | }, 58 | ]; 59 | } else { 60 | auditConfigs = audits.map((a) => { 61 | return { 62 | ...a, 63 | thresholds: a.thresholds || thresholds, 64 | output_path: useDeployUrl ? undefined : a.output_path || output_path, 65 | url: useDeployUrl ? deployUrl : a.url, 66 | serveDir: useDeployUrl 67 | ? undefined 68 | : getServePath(serveDir, a.serveDir ?? ''), 69 | }; 70 | }); 71 | } 72 | 73 | return { auditConfigs }; 74 | }; 75 | 76 | export default getConfiguration; 77 | -------------------------------------------------------------------------------- /src/lib/get-serve-path/get-serve-path.test.js: -------------------------------------------------------------------------------- 1 | import getServePath from './index.js'; 2 | 3 | describe('getServePath', () => { 4 | it('returns undefined for dir thats not a string', () => { 5 | expect(getServePath(2)).toEqual(undefined); 6 | }); 7 | 8 | it('returns undefined for subdir thats not a string', () => { 9 | expect(getServePath(2, 2)).toEqual(undefined); 10 | }); 11 | 12 | it('returns joined path for serveDir', () => { 13 | expect(getServePath('example', 'path')).toEqual('example/path'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/lib/get-serve-path/index.js: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | 3 | import chalk from 'chalk'; 4 | 5 | const getServePath = (dir, subDir) => { 6 | if (typeof subDir !== 'string' || typeof dir !== 'string') { 7 | return undefined; 8 | } 9 | 10 | const resolvedPath = join(dir, subDir); 11 | if (!resolvedPath.startsWith(dir)) { 12 | throw new Error( 13 | chalk.red( 14 | `resolved path for ${chalk.red( 15 | subDir, 16 | )} is outside publish directory ${chalk.red(dir)}`, 17 | ), 18 | ); 19 | } 20 | 21 | return resolvedPath; 22 | }; 23 | 24 | export default getServePath; 25 | -------------------------------------------------------------------------------- /src/lib/get-server/get-server.test.js: -------------------------------------------------------------------------------- 1 | jest.unstable_mockModule('chalk', () => { 2 | return { 3 | default: { 4 | magenta: (m) => m, 5 | }, 6 | }; 7 | }); 8 | 9 | const mockedExpress = () => ({ 10 | use: jest.fn(), 11 | listen: jest.fn(), 12 | }); 13 | const mockStatic = jest.fn(); 14 | Object.defineProperty(mockedExpress, 'static', { value: mockStatic }); 15 | 16 | jest.unstable_mockModule('express', () => { 17 | return { 18 | default: mockedExpress, 19 | }; 20 | }); 21 | const getServer = (await import('./index.js')).default; 22 | 23 | describe('getServer', () => { 24 | beforeEach(() => { 25 | jest.clearAllMocks(); 26 | }); 27 | 28 | it('returns a mock server if audit URL is defined', () => { 29 | const mockListen = jest.fn(); 30 | console.log = jest.fn(); 31 | 32 | const { server } = getServer({ auditUrl: '/' }); 33 | expect(server.listen).toBeDefined(); 34 | server.listen(mockListen); 35 | expect(mockListen).toHaveBeenCalled(); 36 | expect(console.log.mock.calls[0][0]).toEqual('Scanning url /'); 37 | 38 | expect(server.close).toBeDefined(); 39 | expect(server.close()).toBeUndefined(); 40 | expect(server.url).toEqual('/'); 41 | }); 42 | 43 | it('throws an error if no audit URL and no serveDir', () => { 44 | expect(() => getServer({})).toThrow('Empty publish dir'); 45 | }); 46 | 47 | it('returns an express server if no audit URL and a serveDir', () => { 48 | const { server } = getServer({ serveDir: 'dir' }); 49 | expect(mockStatic).toHaveBeenCalled(); 50 | 51 | // Check we log when we start serving directory 52 | server.listen(jest.fn()); 53 | expect(console.log.mock.calls[0][0]).toEqual( 54 | 'Serving and scanning site from directory dir', 55 | ); 56 | 57 | expect(server.url).toEqual('http://localhost:5100'); 58 | 59 | // Check close method closes the given instance 60 | const close = jest.fn(); 61 | server.close({ close }); 62 | expect(close).toHaveBeenCalled(); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/lib/get-server/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import compression from 'compression'; 3 | import chalk from 'chalk'; 4 | 5 | const getServer = ({ serveDir, auditUrl }) => { 6 | if (auditUrl) { 7 | // return a mock server for readability 8 | const server = { 9 | listen: async (func) => { 10 | console.log(`Scanning url ${chalk.magenta(auditUrl)}`); 11 | await func(); 12 | }, 13 | close: () => undefined, 14 | url: auditUrl, 15 | }; 16 | return { server }; 17 | } 18 | 19 | if (!serveDir) { 20 | throw new Error('Empty publish dir'); 21 | } 22 | 23 | const app = express(); 24 | app.use(compression()); 25 | app.use(express.static(serveDir)); 26 | 27 | const port = 5100; 28 | const host = 'localhost'; 29 | 30 | const server = { 31 | listen: (func) => { 32 | console.log( 33 | `Serving and scanning site from directory ${chalk.magenta(serveDir)}`, 34 | ); 35 | return app.listen(port, host, func); 36 | }, 37 | close: (instance) => instance.close(), 38 | url: `http://${host}:${port}`, 39 | }; 40 | return { server }; 41 | }; 42 | 43 | export default getServer; 44 | -------------------------------------------------------------------------------- /src/lib/get-settings/get-settings.test.js: -------------------------------------------------------------------------------- 1 | import getSettings from './index.js'; 2 | 3 | describe('replacements', () => { 4 | it('should return the default config with no settings set', () => { 5 | const derivedSettings = getSettings(); 6 | expect(derivedSettings.extends).toEqual('lighthouse:default'); 7 | expect(derivedSettings.settings).toEqual({}); 8 | }); 9 | 10 | it('should return a template config with preset set to desktop', () => { 11 | const derivedSettings = getSettings({ preset: 'desktop' }); 12 | expect(derivedSettings.extends).toEqual('lighthouse:default'); 13 | expect(derivedSettings.settings.formFactor).toEqual('desktop'); 14 | }); 15 | 16 | it('should add a locale if set', () => { 17 | const derivedSettings = getSettings({ locale: 'es' }); 18 | expect(derivedSettings.settings.locale).toEqual('es'); 19 | }); 20 | 21 | it('should use values from process.env.SETTINGS', () => { 22 | process.env.SETTINGS = JSON.stringify({ 23 | preset: 'desktop', 24 | locale: 'ar', 25 | }); 26 | const derivedSettings = getSettings(); 27 | expect(derivedSettings.settings.formFactor).toEqual('desktop'); 28 | expect(derivedSettings.settings.locale).toEqual('ar'); 29 | }); 30 | 31 | it('should prefer values from input over process.env.SETTINGS', () => { 32 | process.env.SETTINGS = JSON.stringify({ 33 | locale: 'ar', 34 | }); 35 | const derivedSettings = getSettings({ locale: 'es' }); 36 | expect(derivedSettings.settings.locale).toEqual('es'); 37 | }); 38 | 39 | it('should skip is-crawlable audit when using deployUrl', () => { 40 | const derivedSettings = getSettings({}, true); 41 | expect(derivedSettings.settings.skipAudits).toEqual(['is-crawlable']); 42 | }); 43 | 44 | it('should error with incorrect syntax for process.env.SETTINGS', () => { 45 | process.env.SETTINGS = 'not json'; 46 | expect(getSettings).toThrow(/Invalid JSON/); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/lib/get-settings/index.js: -------------------------------------------------------------------------------- 1 | import desktopConfig from 'lighthouse/lighthouse-core/config/desktop-config.js'; 2 | import fullConfig from 'lighthouse/lighthouse-core/config/full-config.js'; 3 | 4 | /* 5 | * Check for settings added in `.env` file and merge with input settings 6 | * specified in `netlify.toml` 7 | */ 8 | const mergeSettingsSources = (inputSettings = {}) => { 9 | let envSettings = {}; 10 | if (typeof process.env.SETTINGS === 'string') { 11 | try { 12 | envSettings = JSON.parse(process.env.SETTINGS); 13 | } catch (e) { 14 | throw new Error(`Invalid JSON for 'settings' input: ${e.message}`); 15 | } 16 | } 17 | 18 | // Shallow merge of input and env settings, with input taking priority. 19 | // Review the need for a deep merge if/when more complex settings are added. 20 | return Object.assign({}, envSettings, inputSettings); 21 | }; 22 | 23 | const getSettings = (inputSettings, isUsingDeployUrl) => { 24 | const settings = mergeSettingsSources(inputSettings); 25 | 26 | // Set a base-level config based on the preset input value 27 | // (desktop is currently the only supported option) 28 | const derivedSettings = 29 | settings.preset === 'desktop' ? desktopConfig : fullConfig; 30 | 31 | // The following are added to the `settings` object of the selected base config 32 | // We add individually to avoid passing anything unexpected to Lighthouse. 33 | if (settings.locale) { 34 | derivedSettings.settings.locale = settings.locale; 35 | } 36 | 37 | // If we are running against the Netlify deploy URL, the injected x-robots-tag will always cause the audit to fail, 38 | // likely producing a false positive, so we skip in this case 39 | if (isUsingDeployUrl) { 40 | derivedSettings.settings.skipAudits = ['is-crawlable']; 41 | } 42 | 43 | return derivedSettings; 44 | }; 45 | 46 | export default getSettings; 47 | -------------------------------------------------------------------------------- /src/lib/get-utils/index.js: -------------------------------------------------------------------------------- 1 | // This function checks to see if we're running within the Netlify Build system, 2 | // and if so, we use the util functions. If not, we're likely running locally 3 | // so fall back using console.log to emulate the output. 4 | 5 | const getUtils = ({ utils }) => { 6 | // If available, fails the Netlify build with the supplied message 7 | // https://docs.netlify.com/integrations/build-plugins/create-plugins/#error-reporting 8 | const failBuild = 9 | utils?.build?.failBuild || 10 | ((message, { error } = {}) => { 11 | console.log('\n--- utils.build.failBuild() ---'); 12 | console.error(message, error && error.message); 13 | console.log('-------------------------------\n'); 14 | process.exitCode = 1; 15 | }); 16 | 17 | // If available, fails the Netlify build with the supplied message 18 | // https://docs.netlify.com/integrations/build-plugins/create-plugins/#error-reporting 19 | const failPlugin = 20 | utils?.build?.failPlugin || 21 | ((message, { error } = {}) => { 22 | console.log('\n--- utils.build.failPlugin() ---'); 23 | console.error(message, error && error.message); 24 | console.log('--------------------------------\n'); 25 | process.exitCode = 1; 26 | }); 27 | 28 | // If available, displays the summary in the Netlify UI Deploy Summary section 29 | // https://docs.netlify.com/integrations/build-plugins/create-plugins/#logging 30 | const show = 31 | utils?.status?.show || 32 | (({ summary }) => { 33 | console.log('\n--- utils.status.show() ---'); 34 | console.log(summary); 35 | console.log('---------------------------\n'); 36 | }); 37 | 38 | return { failBuild, failPlugin, show }; 39 | }; 40 | 41 | export default getUtils; 42 | -------------------------------------------------------------------------------- /src/lib/persist-results/index.js: -------------------------------------------------------------------------------- 1 | import { dirname } from 'path'; 2 | import { mkdir, writeFile } from 'fs/promises'; 3 | 4 | const persistResults = async ({ report, path }) => { 5 | await mkdir(dirname(path), { recursive: true }); 6 | await writeFile(path, report); 7 | }; 8 | 9 | export default persistResults; 10 | -------------------------------------------------------------------------------- /src/lib/prefix-string/index.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | const prefixString = ({ path, url, str }) => { 4 | if (path) { 5 | return `\n${chalk.red('Error')} for directory '${chalk.cyan( 6 | path, 7 | )}':\n${str}`; 8 | } else if (url) { 9 | return `\n${chalk.red('Error')} for url '${chalk.cyan(url)}':\n${str}`; 10 | } else { 11 | return `\n${str}`; 12 | } 13 | }; 14 | 15 | export default prefixString; 16 | -------------------------------------------------------------------------------- /src/lib/process-results/index.js: -------------------------------------------------------------------------------- 1 | import prefixString from '../prefix-string/index.js'; 2 | 3 | const processResults = ({ data, errors }) => { 4 | const err = {}; 5 | if (errors.length > 0) { 6 | const error = errors.reduce( 7 | (acc, { path, url, errors }) => { 8 | const message = prefixString({ 9 | path, 10 | url, 11 | str: errors.map((e) => e.message).join('\n'), 12 | }); 13 | const details = prefixString({ 14 | path, 15 | url, 16 | str: errors.map((e) => `${e.message}\n${e.details}`).join('\n'), 17 | }); 18 | 19 | return { 20 | message: `${acc.message}\n${message}`, 21 | details: `${acc.details}\n${details}`, 22 | }; 23 | }, 24 | { 25 | message: '', 26 | details: '', 27 | }, 28 | ); 29 | err.message = error.message; 30 | err.details = error.details; 31 | } 32 | const reports = []; 33 | return { 34 | error: err, 35 | summary: data 36 | .map( 37 | ({ 38 | path, 39 | url, 40 | summary, 41 | shortSummary, 42 | details, 43 | report, 44 | runtimeError, 45 | }) => { 46 | const obj = { report, details }; 47 | 48 | if (!runtimeError && summary) { 49 | obj.summary = summary.reduce((acc, item) => { 50 | acc[item.id] = Math.round(item.score * 100); 51 | return acc; 52 | }, {}); 53 | } 54 | 55 | if (runtimeError) { 56 | reports.push(obj); 57 | return `Error testing '${path || url}': ${runtimeError.message}`; 58 | } 59 | 60 | if (path) { 61 | obj.path = path; 62 | reports.push(obj); 63 | return `Summary for path '${path}': ${shortSummary}`; 64 | } 65 | if (url) { 66 | obj.url = url; 67 | reports.push(obj); 68 | return `Summary for url '${url}': ${shortSummary}`; 69 | } 70 | return `${shortSummary}`; 71 | }, 72 | ) 73 | .join('\n'), 74 | extraData: reports, 75 | }; 76 | }; 77 | 78 | export default processResults; 79 | -------------------------------------------------------------------------------- /src/lib/run-audit-with-server/index.js: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | 3 | import { formatResults } from '../../format.js'; 4 | import { runLighthouse, getBrowserPath } from '../../run-lighthouse.js'; 5 | import persistResults from '../persist-results/index.js'; 6 | import getServer from '../get-server/index.js'; 7 | 8 | const runAuditWithServer = async ({ 9 | serveDir, 10 | path = '', 11 | url, 12 | thresholds, 13 | output_path, 14 | settings, 15 | }) => { 16 | try { 17 | const { server } = getServer({ serveDir: serveDir, auditUrl: url }); 18 | 19 | const browserPath = await getBrowserPath(); 20 | 21 | const { error, results } = await new Promise((resolve) => { 22 | const instance = server.listen(async () => { 23 | try { 24 | const fullPath = path ? `${server.url}/${path}` : server.url; 25 | const results = await runLighthouse(browserPath, fullPath, settings); 26 | resolve({ error: false, results }); 27 | } catch (error) { 28 | resolve({ error }); 29 | } finally { 30 | server.close(instance); 31 | } 32 | }); 33 | }); 34 | 35 | if (error) { 36 | return { error }; 37 | } else { 38 | const { summary, shortSummary, details, report, errors, runtimeError } = 39 | formatResults({ 40 | results, 41 | thresholds, 42 | }); 43 | 44 | if (output_path) { 45 | await persistResults({ report, path: join(serveDir, output_path) }); 46 | } 47 | 48 | return { 49 | summary, 50 | shortSummary, 51 | details, 52 | report, 53 | errors, 54 | runtimeError, 55 | }; 56 | } 57 | } catch (error) { 58 | console.warn(error); 59 | return { error }; 60 | } 61 | }; 62 | 63 | export default runAuditWithServer; 64 | -------------------------------------------------------------------------------- /src/lib/run-audit-with-url/index.js: -------------------------------------------------------------------------------- 1 | import { formatResults } from '../../format.js'; 2 | import { runLighthouse, getBrowserPath } from '../../run-lighthouse.js'; 3 | 4 | const runAuditWithUrl = async ({ path = '', url, thresholds, settings }) => { 5 | try { 6 | const browserPath = await getBrowserPath(); 7 | 8 | const getResults = async () => { 9 | const fullPath = path ? `${url}/${path}` : url; 10 | const results = await runLighthouse(browserPath, fullPath, settings); 11 | 12 | try { 13 | return { results }; 14 | } catch (error) { 15 | return new Error({ error }); 16 | } 17 | }; 18 | 19 | const { error, results } = await getResults(); 20 | 21 | if (error) { 22 | return { error }; 23 | } else { 24 | const { summary, shortSummary, details, report, errors, runtimeError } = 25 | formatResults({ 26 | results, 27 | thresholds, 28 | }); 29 | 30 | return { 31 | summary, 32 | shortSummary, 33 | details, 34 | report, 35 | errors, 36 | runtimeError, 37 | }; 38 | } 39 | } catch (error) { 40 | return { error }; 41 | } 42 | }; 43 | 44 | export default runAuditWithUrl; 45 | -------------------------------------------------------------------------------- /src/lib/run-event/helpers.js: -------------------------------------------------------------------------------- 1 | export const formatStartMessage = ({ count, path, formFactor, locale }) => { 2 | const message = ['Running Lighthouse on', path]; 3 | 4 | // Build a list of settings used for this run. 5 | const settings = []; 6 | if (locale) { 7 | settings.push(`the “${locale}” locale`); 8 | } 9 | if (formFactor === 'desktop') { 10 | settings.push('the “desktop” preset'); 11 | } 12 | if (settings.length) { 13 | message.push(`using ${settings.join(' and ')}`); 14 | } 15 | 16 | if (count?.total > 1) { 17 | message.push(`(${count.i}/${count.total})`); 18 | } 19 | 20 | return message.join(' '); 21 | }; 22 | -------------------------------------------------------------------------------- /src/lib/run-event/helpers.test.js: -------------------------------------------------------------------------------- 1 | import { formatStartMessage } from './helpers.js'; 2 | 3 | describe('formatStartMessage', () => { 4 | it('should format a message using only the path', () => { 5 | const result = formatStartMessage({ path: 'https://example.com/path' }); 6 | expect(result).toEqual('Running Lighthouse on https://example.com/path'); 7 | }); 8 | 9 | it('should format a message using only the path and count', () => { 10 | const result = formatStartMessage({ 11 | count: { i: 1, total: 2 }, 12 | path: 'https://example.com/path', 13 | }); 14 | expect(result).toEqual( 15 | 'Running Lighthouse on https://example.com/path (1/2)', 16 | ); 17 | }); 18 | 19 | it('should format a message using a single feature', () => { 20 | const result = formatStartMessage({ 21 | path: 'https://example.com/path', 22 | formFactor: 'desktop', 23 | }); 24 | expect(result).toEqual( 25 | 'Running Lighthouse on https://example.com/path using the “desktop” preset', 26 | ); 27 | }); 28 | 29 | it('should format a message using multiple features', () => { 30 | const result = formatStartMessage({ 31 | path: 'https://example.com/path', 32 | formFactor: 'desktop', 33 | locale: 'de', 34 | }); 35 | expect(result).toEqual( 36 | 'Running Lighthouse on https://example.com/path using the “de” locale and the “desktop” preset', 37 | ); 38 | }); 39 | 40 | it('should format a message using all available inputs', () => { 41 | const result = formatStartMessage({ 42 | count: { i: 1, total: 2 }, 43 | path: 'https://example.com/path', 44 | formFactor: 'desktop', 45 | locale: 'es', 46 | }); 47 | expect(result).toEqual( 48 | 'Running Lighthouse on https://example.com/path using the “es” locale and the “desktop” preset (1/2)', 49 | ); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/lib/run-event/index.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | import getSettings from '../../lib/get-settings/index.js'; 4 | import processResults from '../../lib/process-results/index.js'; 5 | import runAuditWithUrl from '../../lib/run-audit-with-url/index.js'; 6 | import runAuditWithServer from '../../lib/run-audit-with-server/index.js'; 7 | import getConfiguration from '../get-configuration/index.js'; 8 | 9 | import { formatStartMessage } from './helpers.js'; 10 | 11 | const runEvent = async ({ 12 | event, 13 | constants, 14 | inputs, 15 | onComplete, 16 | onFail, 17 | } = {}) => { 18 | const isOnSuccess = event === 'onSuccess'; 19 | 20 | const deployUrl = process.env.DEPLOY_URL; 21 | 22 | // If we don't have the deploy URL to test against, we can't run Lighthouse onSuccess. 23 | // If running locally, ensure you have a DEPLOY_URL set in your .env file 24 | // e.g., `DEPLOY_URL=https://www.netlify.com/` 25 | if (isOnSuccess && !deployUrl) { 26 | console.log( 27 | chalk.red('DEPLOY_URL not available, skipping Lighthouse Plugin'), 28 | ); 29 | return; 30 | } 31 | 32 | // Generate the config for each report we'll be running. 33 | // For onSuccess, we pass a deployUrl 34 | // For onPostBuild, we don't pass a deployUrl 35 | const { auditConfigs } = getConfiguration({ 36 | constants, 37 | inputs, 38 | deployUrl: isOnSuccess ? deployUrl : undefined, 39 | }); 40 | 41 | console.log( 42 | `Generating Lighthouse report${ 43 | auditConfigs.length > 1 ? 's' : '' 44 | }. This may take a minute…`, 45 | ); 46 | 47 | let errorMetadata = []; 48 | 49 | try { 50 | const settings = getSettings(inputs?.settings, isOnSuccess); 51 | 52 | const allErrors = []; 53 | const data = []; 54 | 55 | let i = 0; 56 | for (const auditConfig of auditConfigs) { 57 | i++; 58 | 59 | const { serveDir, path, url, thresholds, output_path } = auditConfig; 60 | const fullPath = [serveDir, path].join('/'); 61 | 62 | const startMessage = formatStartMessage({ 63 | count: { i, total: auditConfigs.length }, 64 | path: fullPath, 65 | formFactor: settings?.settings.formFactor, 66 | locale: settings?.settings.locale, 67 | }); 68 | 69 | console.log(startMessage); 70 | 71 | const runner = isOnSuccess ? runAuditWithUrl : runAuditWithServer; 72 | const { errors, summary, shortSummary, details, report, runtimeError } = 73 | await runner({ 74 | serveDir, 75 | path, 76 | url, 77 | thresholds, 78 | output_path, 79 | settings, 80 | }); 81 | 82 | if (summary && !runtimeError) { 83 | console.log(chalk.cyan.bold(`Lighthouse scores for ${fullPath}`)); 84 | summary.map((item) => { 85 | console.log(`- ${item.title}: ${Math.floor(item.score * 100)}`); 86 | }); 87 | } 88 | 89 | if (runtimeError) { 90 | console.log(chalk.red.bold(runtimeError.code)); 91 | console.log(chalk.red(runtimeError.message)); 92 | } 93 | 94 | if (Array.isArray(errors) && errors.length > 0) { 95 | allErrors.push({ serveDir, url, errors }); 96 | } 97 | 98 | data.push({ 99 | path: fullPath, 100 | url, 101 | summary, 102 | shortSummary, 103 | details, 104 | report, 105 | runtimeError, 106 | }); 107 | } 108 | 109 | const { error, summary, extraData } = processResults({ 110 | data, 111 | errors: allErrors, 112 | }); 113 | 114 | errorMetadata.push(...extraData); 115 | 116 | if (error && Object.keys(error).length !== 0) { 117 | throw error; 118 | } 119 | 120 | onComplete({ summary, extraData }); 121 | } catch (error) { 122 | if (error.details) { 123 | console.error(error.details); 124 | onFail(`${chalk.red('Failed with error:\n')}${error.message}`, { 125 | errorMetadata, 126 | }); 127 | } else { 128 | onFail(`${chalk.red('Failed with error:\n')}`, { 129 | error, 130 | errorMetadata, 131 | }); 132 | } 133 | } 134 | }; 135 | 136 | export default runEvent; 137 | -------------------------------------------------------------------------------- /src/replacements.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Adds postMessage functionality for iframe communication 3 | * 1. We first check if the message origin is on our expected list. 4 | * 2. Next we listen for a message to tell us which theme the user is using in 5 | * the Netlify UI, and we toggle classes so the report matches. 6 | * 3. Finally we set up an intersection observer to send a message to the parent 7 | * window when the report footer is in view (triggers an Amplitude event to 8 | * log the report as been "viewed in full"). 9 | */ 10 | const enablePostMessageCommunication = { 11 | source: ``, 12 | replacement: ``, 45 | }; 46 | 47 | const replacements = [enablePostMessageCommunication]; 48 | 49 | const makeReplacements = (str) => { 50 | return replacements.reduce((acc, { source, replacement }) => { 51 | return acc.replace(source, replacement); 52 | }, str); 53 | }; 54 | 55 | export default makeReplacements; 56 | -------------------------------------------------------------------------------- /src/replacements.test.js: -------------------------------------------------------------------------------- 1 | import makeReplacements from './replacements.js'; 2 | 3 | describe('replacements', () => { 4 | it('should make enablePostMessageCommunication replacement', () => { 5 | const data = ''; 6 | const replacedContent = makeReplacements(data); 7 | expect(replacedContent).toContain(''); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/run-lighthouse.js: -------------------------------------------------------------------------------- 1 | import puppeteer from 'puppeteer'; 2 | import lighthouse from 'lighthouse'; 3 | import log from 'lighthouse-logger'; 4 | import chromeLauncher from 'chrome-launcher'; 5 | 6 | export const getBrowserPath = async () => { 7 | const browserFetcher = puppeteer.createBrowserFetcher(); 8 | const revisions = await browserFetcher.localRevisions(); 9 | if (revisions.length <= 0) { 10 | throw new Error('Could not find local browser'); 11 | } 12 | const info = await browserFetcher.revisionInfo(revisions[0]); 13 | return info.executablePath; 14 | }; 15 | 16 | export const runLighthouse = async (browserPath, url, settings) => { 17 | let chrome; 18 | try { 19 | const logLevel = 'error'; 20 | log.setLevel(logLevel); 21 | chrome = await chromeLauncher.launch({ 22 | chromePath: browserPath, 23 | chromeFlags: [ 24 | '--headless', 25 | '--no-sandbox', 26 | '--disable-gpu', 27 | '--disable-dev-shm-usage', 28 | ], 29 | logLevel, 30 | }); 31 | const results = await lighthouse( 32 | url, 33 | { 34 | port: chrome.port, 35 | output: 'html', 36 | logLevel, 37 | }, 38 | settings, 39 | ); 40 | return results; 41 | } finally { 42 | if (chrome) { 43 | await chrome.kill(); 44 | } 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | 3 | global.jest = jest; 4 | --------------------------------------------------------------------------------