[
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\non: push\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v2\n      - name: Use node 12\n        uses: actions/setup-node@v2\n        with:\n          node-version: '12'\n      - name: Install, link plugin locally, and test\n        run: |\n          yarn install\n          yarn link\n          yarn link lighthouse-plugin-field-performance\n          yarn test\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules\nresults/*\n!results/.gitkeep"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"singleQuote\": true,\n  \"semi\": false,\n  \"printWidth\": 120\n}\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# How to Contribute\n\nFirst of all, thank you for your interest in `lighthouse-plugin-field-performance`!\nWe'd love to accept your patches and contributions!\n\n#### 1. Install dependencies\n\n```bash\nyarn install\n```\n\n#### 2. Run plugin\n\n```bash\nyarn install # install deps\nyarn link # create a global link for lighthouse-plugin-field-performance\nyarn link lighthouse-plugin-field-performance # add symlink to test the plugin locally\n\nyarn mobile-run https://www.apple.com/ # test plugin with a real PSI API response\nyarn desktop-run https://www.booking.com/\n\nyarn mobile-run https://treo.sh/ # empty response\nyarn desktop-run https://treo.sh/ # just origin\n```\n\n`lighthouse-plugin-field-performance` folder is made of symlinks for a simple local testing.\n\n#### 3. Improve the plugin\n\nWrite your patch. Improve the plugin to help capture Field Performance.\n\nHelpful links:\n\n- [Plugin docs](https://github.com/GoogleChrome/lighthouse/blob/master/docs/plugins.md)\n- [PageSpeed Insights](https://developers.google.com/speed/pagespeed/insights)\n- [PSI API](https://developers.google.com/speed/docs/insights/v5/get-started)\n\n#### 4. Tests and linters\n\nCoding style is fully defined in [.prettierrc](./.prettierrc).\nWe use [JSDoc](http://usejsdoc.org/) with [TypeScript](https://github.com/Microsoft/TypeScript/wiki/JSDoc-support-in-JavaScript) for linting and annotations.\n\n```bash\n# https://github.com/GoogleChrome/lighthouse/issues/9050#issuecomment-495678706\nyarn link && yarn link lighthouse-plugin-field-performance # install plugin locally\n\nyarn test # run all linters && tests\nyarn tsc -p . # run typescript checks\nyarn ava test/index.js # run just AVA tests\nyarn ava test/index.js -u # update AVA snapshots\nPSI_TOKEN=... yarn ava test/index.js # run AVA with PSI_TOKEN\n```\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) Treo.sh <info@treo.sh> (https://treo.sh/)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# lighthouse-plugin-field-performance\n\n> 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.\n\n[An example report for developers.google.com](https://googlechrome.github.io/lighthouse/viewer/?gist=d9072ab8ccb30622deab48e6d5ee229c):\n\n<a href=\"https://googlechrome.github.io/lighthouse/viewer/?gist=d9072ab8ccb30622deab48e6d5ee229c\">\n  <img width=\"1162\" alt=\"Lighthouse Field Performance Plugin\" src=\"https://user-images.githubusercontent.com/158189/83353335-27499180-a352-11ea-8ee5-059582117a14.png\">\n</a>\n\n<br />\n<br />\n\nThis 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/).\n\nThe 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))\n\nCheck out the parity between Field & Lab performance on mobile:\n\n<img width=\"973\" alt=\"Field & lab performance on mobile\" src=\"https://user-images.githubusercontent.com/158189/83353215-31b75b80-a351-11ea-801e-07f5a2b73e51.png\">\n\nAnd on desktop:\n\n<img width=\"972\" alt=\"Field & lab performance on desktop\" src=\"https://user-images.githubusercontent.com/158189/83353212-2ebc6b00-a351-11ea-9cf8-6a04a5f0f903.png\">\n\nSometimes field data is missing because a URL doesn't have enough anonymous traffic. In this case, the lab data is the only available measurement.\n\n## Install\n\nRequires Node.js `12+` and Lighthouse `8+`.\n\n```bash\n$ npm install lighthouse lighthouse-plugin-field-performance\n```\n\n## Usage\n\nUse the plugin with [Lighthouse CLI](https://github.com/GoogleChrome/lighthouse):\n\n```bash\n$ npx lighthouse https://www.apple.com/ --plugins=lighthouse-plugin-field-performance\n```\n\nTo run more requests, provide your [PageSpeed Insights token](https://developers.google.com/speed/docs/insights/v5/get-started) using a custom config:\n\n```bash\n$ npx lighthouse https://www.apple.com/ --config-path=./config.js\n```\n\n`config.js`\n\n```js\nmodule.exports = {\n  extends: 'lighthouse:default',\n  plugins: ['lighthouse-plugin-field-performance'],\n  settings: {\n    psiToken: 'YOUR_REAL_TOKEN',\n  },\n}\n```\n\n## Credits\n\nSponsored by [Treo.sh - Page speed monitoring made simple](https://treo.sh).\n\n[![](https://github.com/treosh/lighthouse-plugin-field-performance/workflows/CI/badge.svg)](https://github.com/treosh/lighthouse-plugin-field-performance/actions?workflow=CI)\n[![](https://img.shields.io/npm/v/lighthouse-plugin-field-performance.svg)](https://npmjs.org/package/lighthouse-plugin-field-performance)\n[![](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"lighthouse-plugin-field-performance\",\n  \"version\": \"3.0.0\",\n  \"description\": \"Lighthouse plugin that shows real-user data (field data) from Chrome UX Report\",\n  \"repository\": \"https://github.com/treosh/lighthouse-plugin-field-performance\",\n  \"author\": \"Aleksey Kulikov <alekseykulikov@me.com>, Artem Denysov <denysov.artem@gmail.com>\",\n  \"license\": \"MIT\",\n  \"engines\": {\n    \"node\": \">=10\"\n  },\n  \"main\": \"src/index.js\",\n  \"files\": [\n    \"src\"\n  ],\n  \"keywords\": [\n    \"lighthouse\",\n    \"lighthouse plugin\",\n    \"chrome user experience report\",\n    \"crux\",\n    \"chrome ux report\",\n    \"real user monitoring\",\n    \"first contentful paint\",\n    \"first input delay\"\n  ],\n  \"scripts\": {\n    \"test\": \"prettier -c src/** test/** package.json README.md && tsc -p . && ava\",\n    \"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\",\n    \"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\"\n  },\n  \"ava\": {\n    \"snapshotDir\": \"test/snapshots\",\n    \"files\": [\n      \"test/index.js\"\n    ],\n    \"timeout\": \"30s\"\n  },\n  \"dependencies\": {\n    \"lodash\": \"^4.17.21\",\n    \"node-fetch\": \"^2.6.1\"\n  },\n  \"peerDependencies\": {\n    \"lighthouse\": \"8 - 11\"\n  },\n  \"devDependencies\": {\n    \"@types/lodash\": \"^4.14.172\",\n    \"@types/node\": \"16.7.10\",\n    \"@types/node-fetch\": \"^2.5.12\",\n    \"ava\": \"^3.15.0\",\n    \"lighthouse\": \"^8.3.0\",\n    \"prettier\": \"^2.3.2\",\n    \"typescript\": \"^4.4.2\"\n  }\n}\n"
  },
  {
    "path": "results/.gitkeep",
    "content": ""
  },
  {
    "path": "src/audits/field-cls-origin.js",
    "content": "const { Audit } = require('lighthouse')\nconst {\n  getLoadingExperience,\n  createNotApplicableResult,\n  createValueResult,\n  createErrorResult,\n  isResultsInField,\n} = require('../utils/audit-helpers')\n\nmodule.exports = class FieldClsOriginAudit extends Audit {\n  static get meta() {\n    return {\n      id: 'field-cls-origin',\n      title: 'Cumulative Layout Shift (Origin)',\n      description:\n        '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/)',\n      scoreDisplayMode: 'numeric',\n      requiredArtifacts: ['URL', 'settings'],\n    }\n  }\n\n  /** @param {Object} artifacts @param {Object} context */\n  static async audit(artifacts, context) {\n    try {\n      const ole = await getLoadingExperience(artifacts, context, false)\n      if (!isResultsInField(ole)) return createNotApplicableResult(FieldClsOriginAudit.meta.title)\n      return createValueResult(ole.metrics.CUMULATIVE_LAYOUT_SHIFT_SCORE, 'cls')\n    } catch (err) {\n      return createErrorResult(err)\n    }\n  }\n}\n"
  },
  {
    "path": "src/audits/field-cls.js",
    "content": "const { Audit } = require('lighthouse')\nconst {\n  getLoadingExperience,\n  createNotApplicableResult,\n  createValueResult,\n  createErrorResult,\n  isResultsInField,\n} = require('../utils/audit-helpers')\n\nmodule.exports = class FieldClsAudit extends Audit {\n  static get meta() {\n    return {\n      id: 'field-cls',\n      title: 'Cumulative Layout Shift (URL)',\n      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/)`,\n      scoreDisplayMode: 'numeric',\n      requiredArtifacts: ['URL', 'settings'],\n    }\n  }\n\n  /** @param {Object} artifacts @param {Object} context */\n  static async audit(artifacts, context) {\n    try {\n      const le = await getLoadingExperience(artifacts, context)\n      if (!isResultsInField(le)) return createNotApplicableResult(FieldClsAudit.meta.title)\n      return createValueResult(le.metrics.CUMULATIVE_LAYOUT_SHIFT_SCORE, 'cls')\n    } catch (err) {\n      return createErrorResult(err)\n    }\n  }\n}\n"
  },
  {
    "path": "src/audits/field-fcp-origin.js",
    "content": "const { Audit } = require('lighthouse')\nconst {\n  getLoadingExperience,\n  createNotApplicableResult,\n  createValueResult,\n  createErrorResult,\n  isResultsInField,\n} = require('../utils/audit-helpers')\n\nmodule.exports = class FieldFcpOriginAudit extends Audit {\n  static get meta() {\n    return {\n      id: 'field-fcp-origin',\n      title: 'First Contentful Paint (Origin)',\n      description:\n        '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/)',\n      scoreDisplayMode: 'numeric',\n      requiredArtifacts: ['URL', 'settings'],\n    }\n  }\n\n  /** @param {Object} artifacts @param {Object} context */\n  static async audit(artifacts, context) {\n    try {\n      const ole = await getLoadingExperience(artifacts, context, false)\n      if (!isResultsInField(ole)) return createNotApplicableResult(FieldFcpOriginAudit.meta.title)\n      return createValueResult(ole.metrics.FIRST_CONTENTFUL_PAINT_MS, 'fcp')\n    } catch (err) {\n      return createErrorResult(err)\n    }\n  }\n}\n"
  },
  {
    "path": "src/audits/field-fcp.js",
    "content": "const { Audit } = require('lighthouse')\nconst {\n  getLoadingExperience,\n  createNotApplicableResult,\n  createValueResult,\n  createErrorResult,\n  isResultsInField,\n} = require('../utils/audit-helpers')\n\nmodule.exports = class FieldFcpAudit extends Audit {\n  static get meta() {\n    return {\n      id: 'field-fcp',\n      title: 'First Contentful Paint (URL)',\n      description:\n        '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/)',\n      scoreDisplayMode: 'numeric',\n      requiredArtifacts: ['URL', 'settings'],\n    }\n  }\n\n  /** @param {Object} artifacts @param {Object} context */\n  static async audit(artifacts, context) {\n    try {\n      const le = await getLoadingExperience(artifacts, context)\n      if (!isResultsInField(le)) return createNotApplicableResult(FieldFcpAudit.meta.title)\n      return createValueResult(le.metrics.FIRST_CONTENTFUL_PAINT_MS, 'fcp')\n    } catch (err) {\n      return createErrorResult(err)\n    }\n  }\n}\n"
  },
  {
    "path": "src/audits/field-fid-origin.js",
    "content": "const { Audit } = require('lighthouse')\nconst {\n  getLoadingExperience,\n  createNotApplicableResult,\n  createValueResult,\n  createErrorResult,\n  isResultsInField,\n} = require('../utils/audit-helpers')\n\nmodule.exports = class FieldFidOriginAudit extends Audit {\n  static get meta() {\n    return {\n      id: 'field-fid-origin',\n      title: 'First Input Delay (Origin)',\n      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/)`,\n      scoreDisplayMode: 'numeric',\n      requiredArtifacts: ['URL', 'settings'],\n    }\n  }\n\n  /** @param {Object} artifacts @param {Object} context */\n  static async audit(artifacts, context) {\n    try {\n      const ole = await getLoadingExperience(artifacts, context, false)\n      if (!isResultsInField(ole)) return createNotApplicableResult(FieldFidOriginAudit.meta.title)\n      return createValueResult(ole.metrics.FIRST_INPUT_DELAY_MS, 'fid')\n    } catch (err) {\n      return createErrorResult(err)\n    }\n  }\n}\n"
  },
  {
    "path": "src/audits/field-fid.js",
    "content": "const { Audit } = require('lighthouse')\nconst {\n  getLoadingExperience,\n  createNotApplicableResult,\n  createValueResult,\n  createErrorResult,\n  isResultsInField,\n} = require('../utils/audit-helpers')\n\nmodule.exports = class FieldFidAudit extends Audit {\n  static get meta() {\n    return {\n      id: 'field-fid',\n      title: 'First Input Delay (URL)',\n      description:\n        '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/)',\n      scoreDisplayMode: 'numeric',\n      requiredArtifacts: ['URL', 'settings'],\n    }\n  }\n\n  /** @param {Object} artifacts @param {Object} context */\n  static async audit(artifacts, context) {\n    try {\n      const le = await getLoadingExperience(artifacts, context)\n      if (!isResultsInField(le)) return createNotApplicableResult(FieldFidAudit.meta.title)\n      return createValueResult(le.metrics.FIRST_INPUT_DELAY_MS, 'fid')\n    } catch (err) {\n      return createErrorResult(err)\n    }\n  }\n}\n"
  },
  {
    "path": "src/audits/field-lcp-origin.js",
    "content": "const { Audit } = require('lighthouse')\nconst {\n  getLoadingExperience,\n  createNotApplicableResult,\n  createValueResult,\n  createErrorResult,\n  isResultsInField,\n} = require('../utils/audit-helpers')\n\nmodule.exports = class FieldLcpOriginAudit extends Audit {\n  static get meta() {\n    return {\n      id: 'field-lcp-origin',\n      title: 'Largest Contentful Paint (Origin)',\n      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/)`,\n      scoreDisplayMode: 'numeric',\n      requiredArtifacts: ['URL', 'settings'],\n    }\n  }\n\n  /** @param {Object} artifacts @param {Object} context */\n  static async audit(artifacts, context) {\n    try {\n      const ole = await getLoadingExperience(artifacts, context, false)\n      if (!isResultsInField(ole)) return createNotApplicableResult(FieldLcpOriginAudit.meta.title)\n      return createValueResult(ole.metrics.LARGEST_CONTENTFUL_PAINT_MS, 'lcp')\n    } catch (err) {\n      return createErrorResult(err)\n    }\n  }\n}\n"
  },
  {
    "path": "src/audits/field-lcp.js",
    "content": "const { Audit } = require('lighthouse')\nconst {\n  getLoadingExperience,\n  createNotApplicableResult,\n  createValueResult,\n  createErrorResult,\n  isResultsInField,\n} = require('../utils/audit-helpers')\n\nmodule.exports = class FieldLcpAudit extends Audit {\n  static get meta() {\n    return {\n      id: 'field-lcp',\n      title: 'Largest Contentful Paint (URL)',\n      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/)`,\n      scoreDisplayMode: 'numeric',\n      requiredArtifacts: ['URL', 'settings'],\n    }\n  }\n\n  /** @param {Object} artifacts @param {Object} context */\n  static async audit(artifacts, context) {\n    try {\n      const le = await getLoadingExperience(artifacts, context)\n      if (!isResultsInField(le)) return createNotApplicableResult(FieldLcpAudit.meta.title)\n      return createValueResult(le.metrics.LARGEST_CONTENTFUL_PAINT_MS, 'lcp')\n    } catch (err) {\n      return createErrorResult(err)\n    }\n  }\n}\n"
  },
  {
    "path": "src/index.js",
    "content": "module.exports = {\n  audits: [\n    { path: 'lighthouse-plugin-field-performance/src/audits/field-fcp.js' },\n    { path: 'lighthouse-plugin-field-performance/src/audits/field-lcp.js' },\n    { path: 'lighthouse-plugin-field-performance/src/audits/field-fid.js' },\n    { path: 'lighthouse-plugin-field-performance/src/audits/field-cls.js' },\n    { path: 'lighthouse-plugin-field-performance/src/audits/field-fcp-origin.js' },\n    { path: 'lighthouse-plugin-field-performance/src/audits/field-lcp-origin.js' },\n    { path: 'lighthouse-plugin-field-performance/src/audits/field-fid-origin.js' },\n    { path: 'lighthouse-plugin-field-performance/src/audits/field-cls-origin.js' },\n  ],\n  groups: {\n    page: {\n      title: 'Page summary',\n    },\n    origin: {\n      title: 'Origin summary',\n    },\n  },\n  category: {\n    title: 'Field Performance',\n    description:\n      '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/)',\n    auditRefs: [\n      // CWVs are weighted equally\n      { id: 'field-fcp', weight: 0, group: 'page' },\n      { id: 'field-lcp', weight: 1, group: 'page' },\n      { id: 'field-fid', weight: 1, group: 'page' },\n      { id: 'field-cls', weight: 1, group: 'page' },\n      { id: 'field-fcp-origin', weight: 0, group: 'origin' },\n      { id: 'field-lcp-origin', weight: 0, group: 'origin' },\n      { id: 'field-fid-origin', weight: 0, group: 'origin' },\n      { id: 'field-cls-origin', weight: 0, group: 'origin' },\n    ],\n  },\n}\n"
  },
  {
    "path": "src/utils/audit-helpers.js",
    "content": "const { round } = require('lodash')\nconst { Audit } = require('lighthouse')\nconst { runPsi } = require('./run-psi')\n\n/**\n * @typedef {{ good: number, poor: number }} Range\n * @typedef {'fcp' | 'lcp' | 'fid' | 'cls'} Metric\n * @typedef {{ percentile: number, distributions: { min: number, max: number, proportion: number}[] }} MetricValue\n * @typedef {{ id: string, overall_category: string, initial_url: string\n               metrics: { FIRST_INPUT_DELAY_MS: MetricValue, FIRST_CONTENTFUL_PAINT_MS: MetricValue, LARGEST_CONTENTFUL_PAINT_MS: MetricValue, CUMULATIVE_LAYOUT_SHIFT_SCORE: MetricValue } }} LoadingExperience\n */\n\n// cache PSI requests\n\nconst requests = new Map()\n\n/**\n * Cache results and parse crux data.\n *\n * @param {any} artifacts\n * @param {any} context\n * @param {boolean} [isUrl]\n * @return {Promise<LoadingExperience>}\n */\n\nexports.getLoadingExperience = async (artifacts, context, isUrl = true) => {\n  const psiToken = context.settings.psiToken || null\n  const strategy = artifacts.settings.formFactor === 'desktop' ? 'desktop' : 'mobile'\n  const prefix = isUrl ? 'url' : 'origin'\n  const { href, origin } = new URL(artifacts.URL.finalUrl)\n  const url = `${prefix}:${href}`\n  const key = url + strategy\n  if (!requests.has(key)) {\n    requests.set(key, runPsi({ url, strategy, psiToken }))\n  }\n  const json = await requests.get(key)\n  if (json.error) throw new Error(JSON.stringify(json.error))\n  // check, that URL response is not for origin\n  if (isUrl) {\n    const hasUrlExperience = json.loadingExperience && json.loadingExperience.id !== origin\n    return hasUrlExperience ? json.loadingExperience : null\n  }\n  return json.loadingExperience\n}\n\n/**\n * Estimate value and create numeric results\n *\n * @param {MetricValue} metricValue\n * @param {Metric} metric\n * @return {Object}\n */\n\nexports.createValueResult = (metricValue, metric) => {\n  const numericValue = normalizeMetricValue(metric, metricValue.percentile)\n  return {\n    numericValue,\n    score: estimateMetricScore(getMetricRange(metric), numericValue),\n    numericUnit: getMetricNumericUnit(metric),\n    displayValue: formatMetric(metric, numericValue),\n    details: createDistributionsTable(metricValue, metric),\n  }\n}\n\n/**\n * Create result when data does not exist.\n *\n * @param {string} title\n */\n\nexports.createNotApplicableResult = (title) => {\n  return {\n    score: null,\n    notApplicable: true,\n    explanation: `The Chrome User Experience Report \n          does not have sufficient real-world ${title} data for this page.`,\n  }\n}\n\n/**\n * Create error result.\n *\n * @param {Error} err\n */\n\nexports.createErrorResult = (err) => {\n  console.log(err)\n  return {\n    score: null,\n    errorMessage: err.toString(),\n  }\n}\n\n/**\n * Checks if loading experience exists in field\n *\n * @param {LoadingExperience} le\n */\n\nexports.isResultsInField = (le) => {\n  return !!le && Boolean(Object.values(le.metrics || {}).length)\n}\n\n/**\n * @param {MetricValue} metricValue\n * @param {Metric} metric\n */\n\nfunction createDistributionsTable({ distributions }, metric) {\n  const headings = [\n    { key: 'category', itemType: 'text', text: 'Category' },\n    { key: 'distribution', itemType: 'text', text: 'Percent of traffic' },\n  ]\n  const items = distributions.map(({ min, max, proportion }, index) => {\n    const item = {}\n    const normMin = formatMetric(metric, normalizeMetricValue(metric, min))\n    const normMax = formatMetric(metric, normalizeMetricValue(metric, max))\n\n    if (min === 0) {\n      item.category = `Good (faster than ${normMax})`\n    } else if (max && min === distributions[index - 1].max) {\n      item.category = `Needs improvement (from ${normMin} to ${normMax})`\n    } else {\n      item.category = `Poor (longer than ${normMin})`\n    }\n\n    item.distribution = `${round(proportion * 100, 1)} %`\n\n    return item\n  })\n\n  return Audit.makeTableDetails(headings, items)\n}\n\n/**\n * Recommended ranks (https://web.dev/metrics/):\n *\n * FCP: Fast < 1.0 s,   Slow > 3.0 s\n * LCP: Fast < 2.5 s,   Slow > 4.0 s\n * FID: Fast < 100 ms,  Slow > 300 ms\n * CLS: Fast < 0.10,    Slow > 0.25\n *\n * @param {Metric} metric\n * @return {Range}\n */\n\nfunction getMetricRange(metric) {\n  switch (metric) {\n    case 'fcp':\n      return { good: 1000, poor: 3000 }\n    case 'lcp':\n      return { good: 2500, poor: 4000 }\n    case 'fid':\n      return { good: 100, poor: 150 }\n    case 'cls':\n      return { good: 0.1, poor: 0.25 }\n    default:\n      throw new Error(`Invalid metric range: ${metric}`)\n  }\n}\n\n/**\n * Based on a precise drawing:\n * https://twitter.com/JohnMu/status/1395798952570724352\n *\n * @param {Range} range\n * @param {number} value\n */\n\nfunction estimateMetricScore({ good, poor }, value) {\n  if (value <= good) return 1\n  if (value > poor) return 0\n  const linearScore = round((poor - value) / (poor - good), 2)\n  return linearScore\n}\n\n/** @param {Metric} metric, @param {number} value */\nfunction formatMetric(metric, value) {\n  switch (metric) {\n    case 'fcp':\n    case 'lcp':\n      return round(value / 1000, 1).toFixed(1) + ' s'\n    case 'fid':\n      return round(round(value / 10) * 10) + ' ms'\n    case 'cls':\n      return value === 0 ? '0' : value === 0.1 ? '0.10' : round(value, 3).toString()\n    default:\n      throw new Error(`Invalid metric format: ${metric}`)\n  }\n}\n\n/** @param {Metric} metric @param {number} value */\nfunction normalizeMetricValue(metric, value) {\n  return metric === 'cls' ? value / 100 : value\n}\n\n/** @param {Metric} metric */\nfunction getMetricNumericUnit(metric) {\n  return metric === 'cls' ? 'unitless' : 'millisecond'\n}\n"
  },
  {
    "path": "src/utils/run-psi.js",
    "content": "const { default: fetch } = require('node-fetch')\nconst { stringify } = require('querystring')\n\n// config\n\nconst runPagespeedUrl = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed'\nconst retryDelay = 3000\nconst maxRetries = 3\n\n// expose\n\nmodule.exports = { runPsi }\n\n/**\n * Run PSI API.\n *\n * `url:` or `origin:` prefixes uses to get data quickly.\n * https://github.com/GoogleChrome/lighthouse/issues/1453#issuecomment-530163997\n * https://developers.google.com/speed/docs/insights/v5/reference/pagespeedapi/runpagespeed\n *\n * @param {{url: string, strategy: string, category?: string, psiToken?: string}} opts\n * @param {number} [retryCounter]\n * @return {Promise<any>}\n */\n\nasync function runPsi(opts, retryCounter = 0) {\n  const { url, category, strategy, psiToken } = opts\n  const params = { url, strategy, ...(category ? { category } : {}), ...(psiToken ? { key: psiToken } : {}) }\n  const strParams = stringify(params)\n  const res = await fetch(runPagespeedUrl + '?' + strParams)\n  if (res.status === 200) return res.json()\n\n  const isJson = res.headers ? (res.headers.get('content-type') || '').includes('application/json') : false\n  if (!isJson) {\n    const text = await res.text()\n    console.error('invalid PSI API response: status=%s text=%s', res.status, text)\n    return retry()\n  } else {\n    const { error } = /** @type {any} */ (await res.json())\n    console.error('invalid PSI API response: status=%s error=%j', res.status, error)\n    if (error.code === 429) {\n      console.log('error (%s): Too Many Requests', error.code)\n      return retry()\n    } else if (error.code === 400 || error.code === 500) {\n      return { error }\n    } else {\n      throw new Error(`unknown response (${res.status}): ${error.message}`)\n    }\n  }\n\n  /**\n   * Retry PSI execution.\n   *\n   * @return {Promise<Object>}\n   */\n\n  async function retry() {\n    if (retryCounter >= maxRetries) throw new Error(`maximum retries reached: ${retryCounter}`)\n    console.log('wait %sms and retry', retryDelay)\n    await new Promise((resolve) => setTimeout(resolve, retryDelay))\n    return runPsi(opts, retryCounter + 1)\n  }\n}\n"
  },
  {
    "path": "test/index.js",
    "content": "const { serial } = require('ava')\nconst { omit, isNumber, isString } = require('lodash')\nconst chromeLauncher = require('chrome-launcher')\nconst lighthouse = require('lighthouse')\nconst psiToken = process.env.PSI_TOKEN || ''\n\nserial.only('Measure field perf for site in CruX', async (t) => {\n  const { audits, categories } = await runLighthouse('https://example.com/')\n  const category = categories['lighthouse-plugin-field-performance']\n  checkResponse('field-fcp')\n  checkResponse('field-lcp')\n  checkResponse('field-fid')\n  checkResponse('field-cls')\n  checkResponse('field-fcp-origin')\n  checkResponse('field-lcp-origin')\n  checkResponse('field-fid-origin')\n  checkResponse('field-cls-origin')\n\n  console.log('field performance score: %s', category.score)\n  t.snapshot(omit(category, ['score']))\n\n  /** @param {string} auditName */\n  function checkResponse(auditName) {\n    const audit = audits[auditName]\n    t.snapshot(omit(audit, ['details', 'displayValue', 'numericValue', 'score']), `check ${auditName}`)\n    t.true(isNumber(audit.score) && audit.score >= 0 && audit.score <= 1)\n    t.true(isNumber(audit.numericValue))\n    t.true(isString(audit.displayValue))\n    console.log('%s: %s/%s – %s', auditName, audit.numericValue, audit.displayValue, audit.score)\n    t.snapshot(\n      {\n        ...audit.details,\n        items: audit.details.items.map(/** @param {object} item */ (item) => omit(item, ['distribution'])),\n      },\n      `details ${auditName}`\n    )\n  }\n})\n\nserial('Measure field perf for site site not in CruX', async (t) => {\n  const { audits, categories } = await runLighthouse('https://alekseykulikov.com/')\n  t.snapshot(audits['field-fcp'])\n  t.snapshot(audits['field-lcp'])\n  t.snapshot(audits['field-fid'])\n  t.snapshot(audits['field-cls'])\n  t.snapshot(audits['field-fcp-origin'])\n  t.snapshot(audits['field-lcp-origin'])\n  t.snapshot(audits['field-fid-origin'])\n  t.snapshot(audits['field-cls-origin'])\n  t.snapshot(categories['lighthouse-plugin-field-performance'])\n})\n\n/** @param {string} url */\nasync function runLighthouse(url) {\n  const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless', '--enable-logging', '--no-sandbox'] })\n  const flags = {\n    port: chrome.port,\n    onlyCategories: ['lighthouse-plugin-field-performance'],\n  }\n  const config = {\n    extends: 'lighthouse:default',\n    plugins: ['lighthouse-plugin-field-performance'],\n    settings: psiToken ? { psiToken } : {},\n  }\n  const res = await lighthouse(url, flags, config)\n  await chrome.kill()\n  return res.lhr\n}\n"
  },
  {
    "path": "test/snapshots/test/index.js.md",
    "content": "# Snapshot report for `test/index.js`\n\nThe actual snapshot is saved in `index.js.snap`.\n\nGenerated by [AVA](https://avajs.dev).\n\n## Measure field perf for site in CruX\n\n> check field-fcp\n\n    {\n      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/)',\n      errorMessage: undefined,\n      explanation: undefined,\n      id: 'field-fcp',\n      numericUnit: 'millisecond',\n      scoreDisplayMode: 'numeric',\n      title: 'First Contentful Paint (URL)',\n      warnings: undefined,\n    }\n\n> details field-fcp\n\n    {\n      headings: [\n        {\n          itemType: 'text',\n          key: 'category',\n          text: 'Category',\n        },\n        {\n          itemType: 'text',\n          key: 'distribution',\n          text: 'Percent of traffic',\n        },\n      ],\n      items: [\n        {\n          category: 'Good (faster than 1.8 s)',\n        },\n        {\n          category: 'Needs improvement (from 1.8 s to 3.0 s)',\n        },\n        {\n          category: 'Poor (longer than 3.0 s)',\n        },\n      ],\n      summary: undefined,\n      type: 'table',\n    }\n\n> check field-lcp\n\n    {\n      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/)',\n      errorMessage: undefined,\n      explanation: undefined,\n      id: 'field-lcp',\n      numericUnit: 'millisecond',\n      scoreDisplayMode: 'numeric',\n      title: 'Largest Contentful Paint (URL)',\n      warnings: undefined,\n    }\n\n> details field-lcp\n\n    {\n      headings: [\n        {\n          itemType: 'text',\n          key: 'category',\n          text: 'Category',\n        },\n        {\n          itemType: 'text',\n          key: 'distribution',\n          text: 'Percent of traffic',\n        },\n      ],\n      items: [\n        {\n          category: 'Good (faster than 2.5 s)',\n        },\n        {\n          category: 'Needs improvement (from 2.5 s to 4.0 s)',\n        },\n        {\n          category: 'Poor (longer than 4.0 s)',\n        },\n      ],\n      summary: undefined,\n      type: 'table',\n    }\n\n> check field-fid\n\n    {\n      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/)',\n      errorMessage: undefined,\n      explanation: undefined,\n      id: 'field-fid',\n      numericUnit: 'millisecond',\n      scoreDisplayMode: 'numeric',\n      title: 'First Input Delay (URL)',\n      warnings: undefined,\n    }\n\n> details field-fid\n\n    {\n      headings: [\n        {\n          itemType: 'text',\n          key: 'category',\n          text: 'Category',\n        },\n        {\n          itemType: 'text',\n          key: 'distribution',\n          text: 'Percent of traffic',\n        },\n      ],\n      items: [\n        {\n          category: 'Good (faster than 100 ms)',\n        },\n        {\n          category: 'Needs improvement (from 100 ms to 300 ms)',\n        },\n        {\n          category: 'Poor (longer than 300 ms)',\n        },\n      ],\n      summary: undefined,\n      type: 'table',\n    }\n\n> check field-cls\n\n    {\n      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/)',\n      errorMessage: undefined,\n      explanation: undefined,\n      id: 'field-cls',\n      numericUnit: 'unitless',\n      scoreDisplayMode: 'numeric',\n      title: 'Cumulative Layout Shift (URL)',\n      warnings: undefined,\n    }\n\n> details field-cls\n\n    {\n      headings: [\n        {\n          itemType: 'text',\n          key: 'category',\n          text: 'Category',\n        },\n        {\n          itemType: 'text',\n          key: 'distribution',\n          text: 'Percent of traffic',\n        },\n      ],\n      items: [\n        {\n          category: 'Good (faster than 0.10)',\n        },\n        {\n          category: 'Needs improvement (from 0.10 to 0.25)',\n        },\n        {\n          category: 'Poor (longer than 0.25)',\n        },\n      ],\n      summary: undefined,\n      type: 'table',\n    }\n\n> check field-fcp-origin\n\n    {\n      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/)',\n      errorMessage: undefined,\n      explanation: undefined,\n      id: 'field-fcp-origin',\n      numericUnit: 'millisecond',\n      scoreDisplayMode: 'numeric',\n      title: 'First Contentful Paint (Origin)',\n      warnings: undefined,\n    }\n\n> details field-fcp-origin\n\n    {\n      headings: [\n        {\n          itemType: 'text',\n          key: 'category',\n          text: 'Category',\n        },\n        {\n          itemType: 'text',\n          key: 'distribution',\n          text: 'Percent of traffic',\n        },\n      ],\n      items: [\n        {\n          category: 'Good (faster than 1.8 s)',\n        },\n        {\n          category: 'Needs improvement (from 1.8 s to 3.0 s)',\n        },\n        {\n          category: 'Poor (longer than 3.0 s)',\n        },\n      ],\n      summary: undefined,\n      type: 'table',\n    }\n\n> check field-lcp-origin\n\n    {\n      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/)',\n      errorMessage: undefined,\n      explanation: undefined,\n      id: 'field-lcp-origin',\n      numericUnit: 'millisecond',\n      scoreDisplayMode: 'numeric',\n      title: 'Largest Contentful Paint (Origin)',\n      warnings: undefined,\n    }\n\n> details field-lcp-origin\n\n    {\n      headings: [\n        {\n          itemType: 'text',\n          key: 'category',\n          text: 'Category',\n        },\n        {\n          itemType: 'text',\n          key: 'distribution',\n          text: 'Percent of traffic',\n        },\n      ],\n      items: [\n        {\n          category: 'Good (faster than 2.5 s)',\n        },\n        {\n          category: 'Needs improvement (from 2.5 s to 4.0 s)',\n        },\n        {\n          category: 'Poor (longer than 4.0 s)',\n        },\n      ],\n      summary: undefined,\n      type: 'table',\n    }\n\n> check field-fid-origin\n\n    {\n      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/)',\n      errorMessage: undefined,\n      explanation: undefined,\n      id: 'field-fid-origin',\n      numericUnit: 'millisecond',\n      scoreDisplayMode: 'numeric',\n      title: 'First Input Delay (Origin)',\n      warnings: undefined,\n    }\n\n> details field-fid-origin\n\n    {\n      headings: [\n        {\n          itemType: 'text',\n          key: 'category',\n          text: 'Category',\n        },\n        {\n          itemType: 'text',\n          key: 'distribution',\n          text: 'Percent of traffic',\n        },\n      ],\n      items: [\n        {\n          category: 'Good (faster than 100 ms)',\n        },\n        {\n          category: 'Needs improvement (from 100 ms to 300 ms)',\n        },\n        {\n          category: 'Poor (longer than 300 ms)',\n        },\n      ],\n      summary: undefined,\n      type: 'table',\n    }\n\n> check field-cls-origin\n\n    {\n      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/)',\n      errorMessage: undefined,\n      explanation: undefined,\n      id: 'field-cls-origin',\n      numericUnit: 'unitless',\n      scoreDisplayMode: 'numeric',\n      title: 'Cumulative Layout Shift (Origin)',\n      warnings: undefined,\n    }\n\n> details field-cls-origin\n\n    {\n      headings: [\n        {\n          itemType: 'text',\n          key: 'category',\n          text: 'Category',\n        },\n        {\n          itemType: 'text',\n          key: 'distribution',\n          text: 'Percent of traffic',\n        },\n      ],\n      items: [\n        {\n          category: 'Good (faster than 0.10)',\n        },\n        {\n          category: 'Needs improvement (from 0.10 to 0.25)',\n        },\n        {\n          category: 'Poor (longer than 0.25)',\n        },\n      ],\n      summary: undefined,\n      type: 'table',\n    }\n\n> Snapshot 17\n\n    {\n      auditRefs: [\n        {\n          group: 'lighthouse-plugin-field-performance-page',\n          id: 'field-fcp',\n          weight: 0,\n        },\n        {\n          group: 'lighthouse-plugin-field-performance-page',\n          id: 'field-lcp',\n          weight: 1,\n        },\n        {\n          group: 'lighthouse-plugin-field-performance-page',\n          id: 'field-fid',\n          weight: 1,\n        },\n        {\n          group: 'lighthouse-plugin-field-performance-page',\n          id: 'field-cls',\n          weight: 1,\n        },\n        {\n          group: 'lighthouse-plugin-field-performance-origin',\n          id: 'field-fcp-origin',\n          weight: 0,\n        },\n        {\n          group: 'lighthouse-plugin-field-performance-origin',\n          id: 'field-lcp-origin',\n          weight: 0,\n        },\n        {\n          group: 'lighthouse-plugin-field-performance-origin',\n          id: 'field-fid-origin',\n          weight: 0,\n        },\n        {\n          group: 'lighthouse-plugin-field-performance-origin',\n          id: 'field-cls-origin',\n          weight: 0,\n        },\n      ],\n      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/)',\n      id: 'lighthouse-plugin-field-performance',\n      title: 'Field Performance',\n    }\n"
  },
  {
    "path": "test/types.d.ts",
    "content": "declare module 'lighthouse'\ndeclare module 'lighthouse/lighthouse-core/scoring'\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"noEmit\": true,\n    \"moduleResolution\": \"node\",\n    \"module\": \"commonjs\",\n    \"target\": \"es2018\",\n    \"allowJs\": true,\n    \"checkJs\": true,\n    \"strict\": true,\n    \"strictNullChecks\": true,\n    \"resolveJsonModule\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"useUnknownInCatchVariables\": false\n  },\n  \"include\": [\"src\", \"test\"]\n}\n"
  }
]