Repository: treosh/lighthouse-plugin-field-performance Branch: main Commit: fc2a2b50194a Files: 24 Total size: 39.1 KB Directory structure: gitextract__0o96pqn/ ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .prettierrc ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package.json ├── results/ │ └── .gitkeep ├── src/ │ ├── audits/ │ │ ├── field-cls-origin.js │ │ ├── field-cls.js │ │ ├── field-fcp-origin.js │ │ ├── field-fcp.js │ │ ├── field-fid-origin.js │ │ ├── field-fid.js │ │ ├── field-lcp-origin.js │ │ └── field-lcp.js │ ├── index.js │ └── utils/ │ ├── audit-helpers.js │ └── run-psi.js ├── test/ │ ├── index.js │ ├── snapshots/ │ │ └── test/ │ │ ├── index.js.md │ │ └── index.js.snap │ └── types.d.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Use node 12 uses: actions/setup-node@v2 with: node-version: '12' - name: Install, link plugin locally, and test run: | yarn install yarn link yarn link lighthouse-plugin-field-performance yarn test ================================================ FILE: .gitignore ================================================ node_modules results/* !results/.gitkeep ================================================ FILE: .prettierrc ================================================ { "singleQuote": true, "semi": false, "printWidth": 120 } ================================================ FILE: CONTRIBUTING.md ================================================ # How to Contribute First of all, thank you for your interest in `lighthouse-plugin-field-performance`! We'd love to accept your patches and contributions! #### 1. Install dependencies ```bash yarn install ``` #### 2. Run plugin ```bash yarn install # install deps yarn link # create a global link for lighthouse-plugin-field-performance yarn link lighthouse-plugin-field-performance # add symlink to test the plugin locally yarn mobile-run https://www.apple.com/ # test plugin with a real PSI API response yarn desktop-run https://www.booking.com/ yarn mobile-run https://treo.sh/ # empty response yarn desktop-run https://treo.sh/ # just origin ``` `lighthouse-plugin-field-performance` folder is made of symlinks for a simple local testing. #### 3. Improve the plugin Write your patch. Improve the plugin to help capture Field Performance. Helpful links: - [Plugin docs](https://github.com/GoogleChrome/lighthouse/blob/master/docs/plugins.md) - [PageSpeed Insights](https://developers.google.com/speed/pagespeed/insights) - [PSI API](https://developers.google.com/speed/docs/insights/v5/get-started) #### 4. Tests and linters Coding style is fully defined in [.prettierrc](./.prettierrc). We use [JSDoc](http://usejsdoc.org/) with [TypeScript](https://github.com/Microsoft/TypeScript/wiki/JSDoc-support-in-JavaScript) for linting and annotations. ```bash # https://github.com/GoogleChrome/lighthouse/issues/9050#issuecomment-495678706 yarn link && yarn link lighthouse-plugin-field-performance # install plugin locally yarn test # run all linters && tests yarn tsc -p . # run typescript checks yarn ava test/index.js # run just AVA tests yarn ava test/index.js -u # update AVA snapshots PSI_TOKEN=... yarn ava test/index.js # run AVA with PSI_TOKEN ``` ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) Treo.sh (https://treo.sh/) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # lighthouse-plugin-field-performance > Lighthouse plugin that adds field data to your report. It uses real-user data from Chrome UX Report and Core Web Vitals logic to estimate the score. [An example report for developers.google.com](https://googlechrome.github.io/lighthouse/viewer/?gist=d9072ab8ccb30622deab48e6d5ee229c): Lighthouse Field Performance Plugin

This plugin adds Core Web Vitals values to your Lighthouse report. The Field Performance category includes real-user data provided by [Chrome UX Report](https://developers.google.com/web/tools/chrome-user-experience-report/). It's similar to the field section in [PageSpeed Insights](https://developers.google.com/speed/pagespeed/insights/). The scoring algorithm weighs values for Largest Contentful Paint (LCP), First Input Delay (FID), and Cumulative Layout Shift (CLS). It uses the Core Web Vitals assessment logic that sets 1 if metric is good, 0 if metric is poor, and a value from 0 to 1 if it's between. (_Note_: FCP and the origin values do not affect the score, [see the source](./src/index.js)) Check out the parity between Field & Lab performance on mobile: Field & lab performance on mobile And on desktop: Field & lab performance on desktop Sometimes field data is missing because a URL doesn't have enough anonymous traffic. In this case, the lab data is the only available measurement. ## Install Requires Node.js `12+` and Lighthouse `8+`. ```bash $ npm install lighthouse lighthouse-plugin-field-performance ``` ## Usage Use the plugin with [Lighthouse CLI](https://github.com/GoogleChrome/lighthouse): ```bash $ npx lighthouse https://www.apple.com/ --plugins=lighthouse-plugin-field-performance ``` To run more requests, provide your [PageSpeed Insights token](https://developers.google.com/speed/docs/insights/v5/get-started) using a custom config: ```bash $ npx lighthouse https://www.apple.com/ --config-path=./config.js ``` `config.js` ```js module.exports = { extends: 'lighthouse:default', plugins: ['lighthouse-plugin-field-performance'], settings: { psiToken: 'YOUR_REAL_TOKEN', }, } ``` ## Credits Sponsored by [Treo.sh - Page speed monitoring made simple](https://treo.sh). [![](https://github.com/treosh/lighthouse-plugin-field-performance/workflows/CI/badge.svg)](https://github.com/treosh/lighthouse-plugin-field-performance/actions?workflow=CI) [![](https://img.shields.io/npm/v/lighthouse-plugin-field-performance.svg)](https://npmjs.org/package/lighthouse-plugin-field-performance) [![](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) ================================================ FILE: package.json ================================================ { "name": "lighthouse-plugin-field-performance", "version": "3.0.0", "description": "Lighthouse plugin that shows real-user data (field data) from Chrome UX Report", "repository": "https://github.com/treosh/lighthouse-plugin-field-performance", "author": "Aleksey Kulikov , Artem Denysov ", "license": "MIT", "engines": { "node": ">=10" }, "main": "src/index.js", "files": [ "src" ], "keywords": [ "lighthouse", "lighthouse plugin", "chrome user experience report", "crux", "chrome ux report", "real user monitoring", "first contentful paint", "first input delay" ], "scripts": { "test": "prettier -c src/** test/** package.json README.md && tsc -p . && ava", "mobile-run": "lighthouse --plugins=lighthouse-plugin-field-performance --view --chrome-flags='--headless' --quiet --only-categories=performance,lighthouse-plugin-field-performance --output-path=./results/mobile.html", "desktop-run": "lighthouse --plugins=lighthouse-plugin-field-performance --view --preset=desktop --chrome-flags='--headless' --quiet --only-categories=performance,lighthouse-plugin-field-performance --output-path=./results/desktop.html" }, "ava": { "snapshotDir": "test/snapshots", "files": [ "test/index.js" ], "timeout": "30s" }, "dependencies": { "lodash": "^4.17.21", "node-fetch": "^2.6.1" }, "peerDependencies": { "lighthouse": "8 - 11" }, "devDependencies": { "@types/lodash": "^4.14.172", "@types/node": "16.7.10", "@types/node-fetch": "^2.5.12", "ava": "^3.15.0", "lighthouse": "^8.3.0", "prettier": "^2.3.2", "typescript": "^4.4.2" } } ================================================ FILE: results/.gitkeep ================================================ ================================================ FILE: src/audits/field-cls-origin.js ================================================ const { Audit } = require('lighthouse') const { getLoadingExperience, createNotApplicableResult, createValueResult, createErrorResult, isResultsInField, } = require('../utils/audit-helpers') module.exports = class FieldClsOriginAudit extends Audit { static get meta() { return { id: 'field-cls-origin', title: 'Cumulative Layout Shift (Origin)', description: 'Cumulative Layout Shift (CLS) measures visual stability, and it helps quantify how often users experience unexpected layout shifts. The value is 75th percentile of the origin traffic. [Learn more about CLS](https://web.dev/cls/)', scoreDisplayMode: 'numeric', requiredArtifacts: ['URL', 'settings'], } } /** @param {Object} artifacts @param {Object} context */ static async audit(artifacts, context) { try { const ole = await getLoadingExperience(artifacts, context, false) if (!isResultsInField(ole)) return createNotApplicableResult(FieldClsOriginAudit.meta.title) return createValueResult(ole.metrics.CUMULATIVE_LAYOUT_SHIFT_SCORE, 'cls') } catch (err) { return createErrorResult(err) } } } ================================================ FILE: src/audits/field-cls.js ================================================ const { Audit } = require('lighthouse') const { getLoadingExperience, createNotApplicableResult, createValueResult, createErrorResult, isResultsInField, } = require('../utils/audit-helpers') module.exports = class FieldClsAudit extends Audit { static get meta() { return { id: 'field-cls', title: 'Cumulative Layout Shift (URL)', description: `Cumulative Layout Shift (CLS) measures the sum of all individual layout shift scores for every unexpected layout shift that occurs during the entire lifespan of the page. A low CLS (75th percentile) helps ensure that the page is delightful. [Learn more about CLS](https://web.dev/cls/)`, scoreDisplayMode: 'numeric', requiredArtifacts: ['URL', 'settings'], } } /** @param {Object} artifacts @param {Object} context */ static async audit(artifacts, context) { try { const le = await getLoadingExperience(artifacts, context) if (!isResultsInField(le)) return createNotApplicableResult(FieldClsAudit.meta.title) return createValueResult(le.metrics.CUMULATIVE_LAYOUT_SHIFT_SCORE, 'cls') } catch (err) { return createErrorResult(err) } } } ================================================ FILE: src/audits/field-fcp-origin.js ================================================ const { Audit } = require('lighthouse') const { getLoadingExperience, createNotApplicableResult, createValueResult, createErrorResult, isResultsInField, } = require('../utils/audit-helpers') module.exports = class FieldFcpOriginAudit extends Audit { static get meta() { return { id: 'field-fcp-origin', title: 'First Contentful Paint (Origin)', description: 'First Contentful Paint (FCP) marks the first time in the page load timeline where the user can see anything on the screen. The value is 75th percentile of the origin traffic. [Learn more about FCP](https://web.dev/fcp/)', scoreDisplayMode: 'numeric', requiredArtifacts: ['URL', 'settings'], } } /** @param {Object} artifacts @param {Object} context */ static async audit(artifacts, context) { try { const ole = await getLoadingExperience(artifacts, context, false) if (!isResultsInField(ole)) return createNotApplicableResult(FieldFcpOriginAudit.meta.title) return createValueResult(ole.metrics.FIRST_CONTENTFUL_PAINT_MS, 'fcp') } catch (err) { return createErrorResult(err) } } } ================================================ FILE: src/audits/field-fcp.js ================================================ const { Audit } = require('lighthouse') const { getLoadingExperience, createNotApplicableResult, createValueResult, createErrorResult, isResultsInField, } = require('../utils/audit-helpers') module.exports = class FieldFcpAudit extends Audit { static get meta() { return { id: 'field-fcp', title: 'First Contentful Paint (URL)', description: 'First Contentful Paint (FCP) marks the first time in the page load timeline where the user can see anything on the screen. A fast FCP (75th percentile) helps reassure the user that something is happening. [Learn more about FCP](https://web.dev/fcp/)', scoreDisplayMode: 'numeric', requiredArtifacts: ['URL', 'settings'], } } /** @param {Object} artifacts @param {Object} context */ static async audit(artifacts, context) { try { const le = await getLoadingExperience(artifacts, context) if (!isResultsInField(le)) return createNotApplicableResult(FieldFcpAudit.meta.title) return createValueResult(le.metrics.FIRST_CONTENTFUL_PAINT_MS, 'fcp') } catch (err) { return createErrorResult(err) } } } ================================================ FILE: src/audits/field-fid-origin.js ================================================ const { Audit } = require('lighthouse') const { getLoadingExperience, createNotApplicableResult, createValueResult, createErrorResult, isResultsInField, } = require('../utils/audit-helpers') module.exports = class FieldFidOriginAudit extends Audit { static get meta() { return { id: 'field-fid-origin', title: 'First Input Delay (Origin)', description: `First Input Delay (FID) quantifies the experience users feel when trying to interact with unresponsive pages. The value is 75th percentile of the origin traffic. [Learn more about FID](https://web.dev/fid/)`, scoreDisplayMode: 'numeric', requiredArtifacts: ['URL', 'settings'], } } /** @param {Object} artifacts @param {Object} context */ static async audit(artifacts, context) { try { const ole = await getLoadingExperience(artifacts, context, false) if (!isResultsInField(ole)) return createNotApplicableResult(FieldFidOriginAudit.meta.title) return createValueResult(ole.metrics.FIRST_INPUT_DELAY_MS, 'fid') } catch (err) { return createErrorResult(err) } } } ================================================ FILE: src/audits/field-fid.js ================================================ const { Audit } = require('lighthouse') const { getLoadingExperience, createNotApplicableResult, createValueResult, createErrorResult, isResultsInField, } = require('../utils/audit-helpers') module.exports = class FieldFidAudit extends Audit { static get meta() { return { id: 'field-fid', title: 'First Input Delay (URL)', description: 'First Input Delay (FID) quantifies the experience users feel when trying to interact with unresponsive pages. A fast FID (75th percentile) helps ensure that the page is usable. [Learn more about FID](https://web.dev/fid/)', scoreDisplayMode: 'numeric', requiredArtifacts: ['URL', 'settings'], } } /** @param {Object} artifacts @param {Object} context */ static async audit(artifacts, context) { try { const le = await getLoadingExperience(artifacts, context) if (!isResultsInField(le)) return createNotApplicableResult(FieldFidAudit.meta.title) return createValueResult(le.metrics.FIRST_INPUT_DELAY_MS, 'fid') } catch (err) { return createErrorResult(err) } } } ================================================ FILE: src/audits/field-lcp-origin.js ================================================ const { Audit } = require('lighthouse') const { getLoadingExperience, createNotApplicableResult, createValueResult, createErrorResult, isResultsInField, } = require('../utils/audit-helpers') module.exports = class FieldLcpOriginAudit extends Audit { static get meta() { return { id: 'field-lcp-origin', title: 'Largest Contentful Paint (Origin)', description: `Largest Contentful Paint (LCP) marks the time in the page load timeline when the page's main content has likely loaded. The value is 75th percentile of the origin traffic. [Learn more about LCP](https://web.dev/lcp/)`, scoreDisplayMode: 'numeric', requiredArtifacts: ['URL', 'settings'], } } /** @param {Object} artifacts @param {Object} context */ static async audit(artifacts, context) { try { const ole = await getLoadingExperience(artifacts, context, false) if (!isResultsInField(ole)) return createNotApplicableResult(FieldLcpOriginAudit.meta.title) return createValueResult(ole.metrics.LARGEST_CONTENTFUL_PAINT_MS, 'lcp') } catch (err) { return createErrorResult(err) } } } ================================================ FILE: src/audits/field-lcp.js ================================================ const { Audit } = require('lighthouse') const { getLoadingExperience, createNotApplicableResult, createValueResult, createErrorResult, isResultsInField, } = require('../utils/audit-helpers') module.exports = class FieldLcpAudit extends Audit { static get meta() { return { id: 'field-lcp', title: 'Largest Contentful Paint (URL)', description: `Largest Contentful Paint (LCP) reports the render time of the largest content element that is visible within the viewport. A fast LCP (75th percentile) helps reassure the user that the page is useful. [Learn more about LCP](https://web.dev/lcp/)`, scoreDisplayMode: 'numeric', requiredArtifacts: ['URL', 'settings'], } } /** @param {Object} artifacts @param {Object} context */ static async audit(artifacts, context) { try { const le = await getLoadingExperience(artifacts, context) if (!isResultsInField(le)) return createNotApplicableResult(FieldLcpAudit.meta.title) return createValueResult(le.metrics.LARGEST_CONTENTFUL_PAINT_MS, 'lcp') } catch (err) { return createErrorResult(err) } } } ================================================ FILE: src/index.js ================================================ module.exports = { audits: [ { path: 'lighthouse-plugin-field-performance/src/audits/field-fcp.js' }, { path: 'lighthouse-plugin-field-performance/src/audits/field-lcp.js' }, { path: 'lighthouse-plugin-field-performance/src/audits/field-fid.js' }, { path: 'lighthouse-plugin-field-performance/src/audits/field-cls.js' }, { path: 'lighthouse-plugin-field-performance/src/audits/field-fcp-origin.js' }, { path: 'lighthouse-plugin-field-performance/src/audits/field-lcp-origin.js' }, { path: 'lighthouse-plugin-field-performance/src/audits/field-fid-origin.js' }, { path: 'lighthouse-plugin-field-performance/src/audits/field-cls-origin.js' }, ], groups: { page: { title: 'Page summary', }, origin: { title: 'Origin summary', }, }, category: { title: 'Field Performance', description: 'These metrics show the performance of the page over the past 30 days. Data is collected anonymously in for real-world Chrome users and provided by Chrome UX Report. [Learn More](https://developers.google.com/web/tools/chrome-user-experience-report/)', auditRefs: [ // CWVs are weighted equally { id: 'field-fcp', weight: 0, group: 'page' }, { id: 'field-lcp', weight: 1, group: 'page' }, { id: 'field-fid', weight: 1, group: 'page' }, { id: 'field-cls', weight: 1, group: 'page' }, { id: 'field-fcp-origin', weight: 0, group: 'origin' }, { id: 'field-lcp-origin', weight: 0, group: 'origin' }, { id: 'field-fid-origin', weight: 0, group: 'origin' }, { id: 'field-cls-origin', weight: 0, group: 'origin' }, ], }, } ================================================ FILE: src/utils/audit-helpers.js ================================================ const { round } = require('lodash') const { Audit } = require('lighthouse') const { runPsi } = require('./run-psi') /** * @typedef {{ good: number, poor: number }} Range * @typedef {'fcp' | 'lcp' | 'fid' | 'cls'} Metric * @typedef {{ percentile: number, distributions: { min: number, max: number, proportion: number}[] }} MetricValue * @typedef {{ id: string, overall_category: string, initial_url: string metrics: { FIRST_INPUT_DELAY_MS: MetricValue, FIRST_CONTENTFUL_PAINT_MS: MetricValue, LARGEST_CONTENTFUL_PAINT_MS: MetricValue, CUMULATIVE_LAYOUT_SHIFT_SCORE: MetricValue } }} LoadingExperience */ // cache PSI requests const requests = new Map() /** * Cache results and parse crux data. * * @param {any} artifacts * @param {any} context * @param {boolean} [isUrl] * @return {Promise} */ exports.getLoadingExperience = async (artifacts, context, isUrl = true) => { const psiToken = context.settings.psiToken || null const strategy = artifacts.settings.formFactor === 'desktop' ? 'desktop' : 'mobile' const prefix = isUrl ? 'url' : 'origin' const { href, origin } = new URL(artifacts.URL.finalUrl) const url = `${prefix}:${href}` const key = url + strategy if (!requests.has(key)) { requests.set(key, runPsi({ url, strategy, psiToken })) } const json = await requests.get(key) if (json.error) throw new Error(JSON.stringify(json.error)) // check, that URL response is not for origin if (isUrl) { const hasUrlExperience = json.loadingExperience && json.loadingExperience.id !== origin return hasUrlExperience ? json.loadingExperience : null } return json.loadingExperience } /** * Estimate value and create numeric results * * @param {MetricValue} metricValue * @param {Metric} metric * @return {Object} */ exports.createValueResult = (metricValue, metric) => { const numericValue = normalizeMetricValue(metric, metricValue.percentile) return { numericValue, score: estimateMetricScore(getMetricRange(metric), numericValue), numericUnit: getMetricNumericUnit(metric), displayValue: formatMetric(metric, numericValue), details: createDistributionsTable(metricValue, metric), } } /** * Create result when data does not exist. * * @param {string} title */ exports.createNotApplicableResult = (title) => { return { score: null, notApplicable: true, explanation: `The Chrome User Experience Report does not have sufficient real-world ${title} data for this page.`, } } /** * Create error result. * * @param {Error} err */ exports.createErrorResult = (err) => { console.log(err) return { score: null, errorMessage: err.toString(), } } /** * Checks if loading experience exists in field * * @param {LoadingExperience} le */ exports.isResultsInField = (le) => { return !!le && Boolean(Object.values(le.metrics || {}).length) } /** * @param {MetricValue} metricValue * @param {Metric} metric */ function createDistributionsTable({ distributions }, metric) { const headings = [ { key: 'category', itemType: 'text', text: 'Category' }, { key: 'distribution', itemType: 'text', text: 'Percent of traffic' }, ] const items = distributions.map(({ min, max, proportion }, index) => { const item = {} const normMin = formatMetric(metric, normalizeMetricValue(metric, min)) const normMax = formatMetric(metric, normalizeMetricValue(metric, max)) if (min === 0) { item.category = `Good (faster than ${normMax})` } else if (max && min === distributions[index - 1].max) { item.category = `Needs improvement (from ${normMin} to ${normMax})` } else { item.category = `Poor (longer than ${normMin})` } item.distribution = `${round(proportion * 100, 1)} %` return item }) return Audit.makeTableDetails(headings, items) } /** * Recommended ranks (https://web.dev/metrics/): * * FCP: Fast < 1.0 s, Slow > 3.0 s * LCP: Fast < 2.5 s, Slow > 4.0 s * FID: Fast < 100 ms, Slow > 300 ms * CLS: Fast < 0.10, Slow > 0.25 * * @param {Metric} metric * @return {Range} */ function getMetricRange(metric) { switch (metric) { case 'fcp': return { good: 1000, poor: 3000 } case 'lcp': return { good: 2500, poor: 4000 } case 'fid': return { good: 100, poor: 150 } case 'cls': return { good: 0.1, poor: 0.25 } default: throw new Error(`Invalid metric range: ${metric}`) } } /** * Based on a precise drawing: * https://twitter.com/JohnMu/status/1395798952570724352 * * @param {Range} range * @param {number} value */ function estimateMetricScore({ good, poor }, value) { if (value <= good) return 1 if (value > poor) return 0 const linearScore = round((poor - value) / (poor - good), 2) return linearScore } /** @param {Metric} metric, @param {number} value */ function formatMetric(metric, value) { switch (metric) { case 'fcp': case 'lcp': return round(value / 1000, 1).toFixed(1) + ' s' case 'fid': return round(round(value / 10) * 10) + ' ms' case 'cls': return value === 0 ? '0' : value === 0.1 ? '0.10' : round(value, 3).toString() default: throw new Error(`Invalid metric format: ${metric}`) } } /** @param {Metric} metric @param {number} value */ function normalizeMetricValue(metric, value) { return metric === 'cls' ? value / 100 : value } /** @param {Metric} metric */ function getMetricNumericUnit(metric) { return metric === 'cls' ? 'unitless' : 'millisecond' } ================================================ FILE: src/utils/run-psi.js ================================================ const { default: fetch } = require('node-fetch') const { stringify } = require('querystring') // config const runPagespeedUrl = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed' const retryDelay = 3000 const maxRetries = 3 // expose module.exports = { runPsi } /** * Run PSI API. * * `url:` or `origin:` prefixes uses to get data quickly. * https://github.com/GoogleChrome/lighthouse/issues/1453#issuecomment-530163997 * https://developers.google.com/speed/docs/insights/v5/reference/pagespeedapi/runpagespeed * * @param {{url: string, strategy: string, category?: string, psiToken?: string}} opts * @param {number} [retryCounter] * @return {Promise} */ async function runPsi(opts, retryCounter = 0) { const { url, category, strategy, psiToken } = opts const params = { url, strategy, ...(category ? { category } : {}), ...(psiToken ? { key: psiToken } : {}) } const strParams = stringify(params) const res = await fetch(runPagespeedUrl + '?' + strParams) if (res.status === 200) return res.json() const isJson = res.headers ? (res.headers.get('content-type') || '').includes('application/json') : false if (!isJson) { const text = await res.text() console.error('invalid PSI API response: status=%s text=%s', res.status, text) return retry() } else { const { error } = /** @type {any} */ (await res.json()) console.error('invalid PSI API response: status=%s error=%j', res.status, error) if (error.code === 429) { console.log('error (%s): Too Many Requests', error.code) return retry() } else if (error.code === 400 || error.code === 500) { return { error } } else { throw new Error(`unknown response (${res.status}): ${error.message}`) } } /** * Retry PSI execution. * * @return {Promise} */ async function retry() { if (retryCounter >= maxRetries) throw new Error(`maximum retries reached: ${retryCounter}`) console.log('wait %sms and retry', retryDelay) await new Promise((resolve) => setTimeout(resolve, retryDelay)) return runPsi(opts, retryCounter + 1) } } ================================================ FILE: test/index.js ================================================ const { serial } = require('ava') const { omit, isNumber, isString } = require('lodash') const chromeLauncher = require('chrome-launcher') const lighthouse = require('lighthouse') const psiToken = process.env.PSI_TOKEN || '' serial.only('Measure field perf for site in CruX', async (t) => { const { audits, categories } = await runLighthouse('https://example.com/') const category = categories['lighthouse-plugin-field-performance'] checkResponse('field-fcp') checkResponse('field-lcp') checkResponse('field-fid') checkResponse('field-cls') checkResponse('field-fcp-origin') checkResponse('field-lcp-origin') checkResponse('field-fid-origin') checkResponse('field-cls-origin') console.log('field performance score: %s', category.score) t.snapshot(omit(category, ['score'])) /** @param {string} auditName */ function checkResponse(auditName) { const audit = audits[auditName] t.snapshot(omit(audit, ['details', 'displayValue', 'numericValue', 'score']), `check ${auditName}`) t.true(isNumber(audit.score) && audit.score >= 0 && audit.score <= 1) t.true(isNumber(audit.numericValue)) t.true(isString(audit.displayValue)) console.log('%s: %s/%s – %s', auditName, audit.numericValue, audit.displayValue, audit.score) t.snapshot( { ...audit.details, items: audit.details.items.map(/** @param {object} item */ (item) => omit(item, ['distribution'])), }, `details ${auditName}` ) } }) serial('Measure field perf for site site not in CruX', async (t) => { const { audits, categories } = await runLighthouse('https://alekseykulikov.com/') t.snapshot(audits['field-fcp']) t.snapshot(audits['field-lcp']) t.snapshot(audits['field-fid']) t.snapshot(audits['field-cls']) t.snapshot(audits['field-fcp-origin']) t.snapshot(audits['field-lcp-origin']) t.snapshot(audits['field-fid-origin']) t.snapshot(audits['field-cls-origin']) t.snapshot(categories['lighthouse-plugin-field-performance']) }) /** @param {string} url */ async function runLighthouse(url) { const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless', '--enable-logging', '--no-sandbox'] }) const flags = { port: chrome.port, onlyCategories: ['lighthouse-plugin-field-performance'], } const config = { extends: 'lighthouse:default', plugins: ['lighthouse-plugin-field-performance'], settings: psiToken ? { psiToken } : {}, } const res = await lighthouse(url, flags, config) await chrome.kill() return res.lhr } ================================================ FILE: test/snapshots/test/index.js.md ================================================ # Snapshot report for `test/index.js` The actual snapshot is saved in `index.js.snap`. Generated by [AVA](https://avajs.dev). ## Measure field perf for site in CruX > check field-fcp { description: 'First Contentful Paint (FCP) marks the first time in the page load timeline where the user can see anything on the screen. A fast FCP (75th percentile) helps reassure the user that something is happening. [Learn more about FCP](https://web.dev/fcp/)', errorMessage: undefined, explanation: undefined, id: 'field-fcp', numericUnit: 'millisecond', scoreDisplayMode: 'numeric', title: 'First Contentful Paint (URL)', warnings: undefined, } > details field-fcp { headings: [ { itemType: 'text', key: 'category', text: 'Category', }, { itemType: 'text', key: 'distribution', text: 'Percent of traffic', }, ], items: [ { category: 'Good (faster than 1.8 s)', }, { category: 'Needs improvement (from 1.8 s to 3.0 s)', }, { category: 'Poor (longer than 3.0 s)', }, ], summary: undefined, type: 'table', } > check field-lcp { description: 'Largest Contentful Paint (LCP) reports the render time of the largest content element that is visible within the viewport. A fast LCP (75th percentile) helps reassure the user that the page is useful. [Learn more about LCP](https://web.dev/lcp/)', errorMessage: undefined, explanation: undefined, id: 'field-lcp', numericUnit: 'millisecond', scoreDisplayMode: 'numeric', title: 'Largest Contentful Paint (URL)', warnings: undefined, } > details field-lcp { headings: [ { itemType: 'text', key: 'category', text: 'Category', }, { itemType: 'text', key: 'distribution', text: 'Percent of traffic', }, ], items: [ { category: 'Good (faster than 2.5 s)', }, { category: 'Needs improvement (from 2.5 s to 4.0 s)', }, { category: 'Poor (longer than 4.0 s)', }, ], summary: undefined, type: 'table', } > check field-fid { description: 'First Input Delay (FID) quantifies the experience users feel when trying to interact with unresponsive pages. A fast FID (75th percentile) helps ensure that the page is usable. [Learn more about FID](https://web.dev/fid/)', errorMessage: undefined, explanation: undefined, id: 'field-fid', numericUnit: 'millisecond', scoreDisplayMode: 'numeric', title: 'First Input Delay (URL)', warnings: undefined, } > details field-fid { headings: [ { itemType: 'text', key: 'category', text: 'Category', }, { itemType: 'text', key: 'distribution', text: 'Percent of traffic', }, ], items: [ { category: 'Good (faster than 100 ms)', }, { category: 'Needs improvement (from 100 ms to 300 ms)', }, { category: 'Poor (longer than 300 ms)', }, ], summary: undefined, type: 'table', } > check field-cls { description: 'Cumulative Layout Shift (CLS) measures the sum of all individual layout shift scores for every unexpected layout shift that occurs during the entire lifespan of the page. A low CLS (75th percentile) helps ensure that the page is delightful. [Learn more about CLS](https://web.dev/cls/)', errorMessage: undefined, explanation: undefined, id: 'field-cls', numericUnit: 'unitless', scoreDisplayMode: 'numeric', title: 'Cumulative Layout Shift (URL)', warnings: undefined, } > details field-cls { headings: [ { itemType: 'text', key: 'category', text: 'Category', }, { itemType: 'text', key: 'distribution', text: 'Percent of traffic', }, ], items: [ { category: 'Good (faster than 0.10)', }, { category: 'Needs improvement (from 0.10 to 0.25)', }, { category: 'Poor (longer than 0.25)', }, ], summary: undefined, type: 'table', } > check field-fcp-origin { description: 'First Contentful Paint (FCP) marks the first time in the page load timeline where the user can see anything on the screen. The value is 75th percentile of the origin traffic. [Learn more about FCP](https://web.dev/fcp/)', errorMessage: undefined, explanation: undefined, id: 'field-fcp-origin', numericUnit: 'millisecond', scoreDisplayMode: 'numeric', title: 'First Contentful Paint (Origin)', warnings: undefined, } > details field-fcp-origin { headings: [ { itemType: 'text', key: 'category', text: 'Category', }, { itemType: 'text', key: 'distribution', text: 'Percent of traffic', }, ], items: [ { category: 'Good (faster than 1.8 s)', }, { category: 'Needs improvement (from 1.8 s to 3.0 s)', }, { category: 'Poor (longer than 3.0 s)', }, ], summary: undefined, type: 'table', } > check field-lcp-origin { description: 'Largest Contentful Paint (LCP) marks the time in the page load timeline when the page\'s main content has likely loaded. The value is 75th percentile of the origin traffic. [Learn more about LCP](https://web.dev/lcp/)', errorMessage: undefined, explanation: undefined, id: 'field-lcp-origin', numericUnit: 'millisecond', scoreDisplayMode: 'numeric', title: 'Largest Contentful Paint (Origin)', warnings: undefined, } > details field-lcp-origin { headings: [ { itemType: 'text', key: 'category', text: 'Category', }, { itemType: 'text', key: 'distribution', text: 'Percent of traffic', }, ], items: [ { category: 'Good (faster than 2.5 s)', }, { category: 'Needs improvement (from 2.5 s to 4.0 s)', }, { category: 'Poor (longer than 4.0 s)', }, ], summary: undefined, type: 'table', } > check field-fid-origin { description: 'First Input Delay (FID) quantifies the experience users feel when trying to interact with unresponsive pages. The value is 75th percentile of the origin traffic. [Learn more about FID](https://web.dev/fid/)', errorMessage: undefined, explanation: undefined, id: 'field-fid-origin', numericUnit: 'millisecond', scoreDisplayMode: 'numeric', title: 'First Input Delay (Origin)', warnings: undefined, } > details field-fid-origin { headings: [ { itemType: 'text', key: 'category', text: 'Category', }, { itemType: 'text', key: 'distribution', text: 'Percent of traffic', }, ], items: [ { category: 'Good (faster than 100 ms)', }, { category: 'Needs improvement (from 100 ms to 300 ms)', }, { category: 'Poor (longer than 300 ms)', }, ], summary: undefined, type: 'table', } > check field-cls-origin { description: 'Cumulative Layout Shift (CLS) measures visual stability, and it helps quantify how often users experience unexpected layout shifts. The value is 75th percentile of the origin traffic. [Learn more about CLS](https://web.dev/cls/)', errorMessage: undefined, explanation: undefined, id: 'field-cls-origin', numericUnit: 'unitless', scoreDisplayMode: 'numeric', title: 'Cumulative Layout Shift (Origin)', warnings: undefined, } > details field-cls-origin { headings: [ { itemType: 'text', key: 'category', text: 'Category', }, { itemType: 'text', key: 'distribution', text: 'Percent of traffic', }, ], items: [ { category: 'Good (faster than 0.10)', }, { category: 'Needs improvement (from 0.10 to 0.25)', }, { category: 'Poor (longer than 0.25)', }, ], summary: undefined, type: 'table', } > Snapshot 17 { auditRefs: [ { group: 'lighthouse-plugin-field-performance-page', id: 'field-fcp', weight: 0, }, { group: 'lighthouse-plugin-field-performance-page', id: 'field-lcp', weight: 1, }, { group: 'lighthouse-plugin-field-performance-page', id: 'field-fid', weight: 1, }, { group: 'lighthouse-plugin-field-performance-page', id: 'field-cls', weight: 1, }, { group: 'lighthouse-plugin-field-performance-origin', id: 'field-fcp-origin', weight: 0, }, { group: 'lighthouse-plugin-field-performance-origin', id: 'field-lcp-origin', weight: 0, }, { group: 'lighthouse-plugin-field-performance-origin', id: 'field-fid-origin', weight: 0, }, { group: 'lighthouse-plugin-field-performance-origin', id: 'field-cls-origin', weight: 0, }, ], description: 'These metrics show the performance of the page over the past 30 days. Data is collected anonymously in for real-world Chrome users and provided by Chrome UX Report. [Learn More](https://developers.google.com/web/tools/chrome-user-experience-report/)', id: 'lighthouse-plugin-field-performance', title: 'Field Performance', } ================================================ FILE: test/types.d.ts ================================================ declare module 'lighthouse' declare module 'lighthouse/lighthouse-core/scoring' ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "noEmit": true, "moduleResolution": "node", "module": "commonjs", "target": "es2018", "allowJs": true, "checkJs": true, "strict": true, "strictNullChecks": true, "resolveJsonModule": true, "noUnusedLocals": true, "noUnusedParameters": true, "useUnknownInCatchVariables": false }, "include": ["src", "test"] }