[
  {
    "path": ".editorconfig",
    "content": "# editorconfig.org\n# For Visual Studio code use this extension to enforce below rules:\n# https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig\n# Other IDEs maye have built in editorconfig support, or their own extensions\n#\n# The similar .ecrc file is used by an editorconfig GitHub action to catch those\n# that don't use this.\nroot = true\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "content": "name: Lint Code Base\npermissions:\n  contents: read\non:\n  pull_request:\n  push:\n    branches:\n      - main\n  workflow_dispatch:\njobs:\n  lint:\n    name: Lint Code Base\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout Code\n        uses: actions/checkout@v6\n      - name: NPM install\n        run: npm install\n      - name: Run Prettier\n        run: npm run format:check\n      - name: Run ESlint\n        run: npm run lint\n"
  },
  {
    "path": ".github/workflows/tests.yml",
    "content": "name: Run tests\npermissions:\n  contents: read\non:\n  pull_request:\n  push:\n    branches:\n      - main\n  workflow_dispatch:\njobs:\n  unit-tests:\n    name: Run unit tests\n    # Doesn't require anything special so let's use ubuntu as more available\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout Code\n        uses: actions/checkout@v6\n      - name: NPM install\n        run: npm install\n      - name: Build\n        run: npm run build\n      - name: Run unit tests\n        run: npm run test:unit\n  chrome-tests:\n    name: Run Chrome e2e tests\n    # Runs best on macos for CI as linux requires extra chrome flags\n    runs-on: macos-latest\n    steps:\n      - name: Checkout Code\n        uses: actions/checkout@v6\n      - name: NPM install\n        run: npm install\n      - name: Build\n        run: npm run build\n      - name: Run server\n        run: npm run test:server &\n      - name: Run e2e tests for chrome\n        run: npm run test:e2e -- --browsers=chrome\n  firefox-tests:\n    name: Run Firefox e2e tests\n    # Runs best on macos for CI as linux requires extra setup\n    runs-on: macos-latest\n    steps:\n      - name: Checkout Code\n        uses: actions/checkout@v6\n      - name: NPM install\n        run: npm install\n      - name: Build\n        run: npm run build\n      - name: Run server\n        run: npm run test:server &\n      - name: Run e2e tests for firefox\n        run: npm run test:e2e -- --browsers=firefox\n  safari-tests:\n    name: Run Safari e2e tests\n    # Requires macos\n    runs-on: macos-latest\n    steps:\n      - name: Checkout Code\n        uses: actions/checkout@v6\n      - name: NPM install\n        run: npm install\n      - name: Build\n        run: npm run build\n      - name: Run server\n        run: npm run test:server &\n      - name: Run e2e tests for safari\n        run: npm run test:e2e -- --browsers=safari\n"
  },
  {
    "path": ".gitignore",
    "content": ".DS_Store\n.vscode\nnode_modules\n\n# Log files\n*.log\n\n# Generated TypeScript files and build data\ntsconfig.tsbuildinfo\n\n# Dist files\ndist\n"
  },
  {
    "path": ".husky/.gitignore",
    "content": "_\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "lint-staged\n\ngrep -r \"\\.only(\" test/e2e \\\n  && echo \"ERROR: found .only() use in test\" && exit 1\n\ngrep -r \"browser\\.debug(\" test/e2e \\\n  && echo \"ERROR: found browser.debug() use in test\" && exit 1\n\nexit 0\n"
  },
  {
    "path": ".nvmrc",
    "content": "lts/Hydrogen\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n### v5.1.0 (2025-07-31)\n\n- Register `visibility-change` early ([#637](https://github.com/GoogleChrome/web-vitals/pull/637))\n- Only finalize LCP on user events (`isTrusted=true`) ([#635](https://github.com/GoogleChrome/web-vitals/pull/635))\n- Fallback to default `getSelector` if custom function is null or undefined ([#634](https://github.com/GoogleChrome/web-vitals/pull/634))\n\n### v5.0.3 (2025-06-11)\n\n- Remove visibilitychange event listeners when no longer required ([#627](https://github.com/GoogleChrome/web-vitals/pull/627))\n\n### v5.0.2 (2025-05-29)\n\n- Handle layout shifts with no sources ([#623](https://github.com/GoogleChrome/web-vitals/pull/623))\n\n### v5.0.1 (2025-05-13)\n\n- Fix missing FCP and LCP for prerendered pages ([#621](https://github.com/GoogleChrome/web-vitals/pull/621))\n\n### v5.0.0 (2025-05-07)\n\n[!NOTE]\nSee the [upgrading to v5](/docs/upgrading-to-v5.md) guide for a complete list of all API changes in version 5.\n\n- **[BREAKING]** Remove the deprecated `onFID()` function ([#519](https://github.com/GoogleChrome/web-vitals/pull/519))\n- **[BREAKING]** Change browser support policy to Baseline Widely available ([#525](https://github.com/GoogleChrome/web-vitals/pull/525))\n- **[BREAKING]** Sort the classes that appear in attribution selectors to reduce cardinality ([#518](https://github.com/GoogleChrome/web-vitals/pull/518))\n- Extend INP attribution with extra LoAF information: longest script and buckets ([#592](https://github.com/GoogleChrome/web-vitals/pull/592))\n- Add support for generating custom targets in the attribution build ([#585](https://github.com/GoogleChrome/web-vitals/pull/585))\n- Support multiple calls to `onINP()` with different config options ([#583](https://github.com/GoogleChrome/web-vitals/pull/583))\n- Use visibility-state performance entries ([#612](https://github.com/GoogleChrome/web-vitals/pull/612))\n- Ensure idle callbacks don't run twice ([#541](https://github.com/GoogleChrome/web-vitals/pull/541)) and ([#548](https://github.com/GoogleChrome/web-vitals/pull/548))\n- Cap `nextPaintTime` at `processingStart` ([#540](https://github.com/GoogleChrome/web-vitals/pull/540)) and ([#546](https://github.com/GoogleChrome/web-vitals/pull/546))\n- Cap INP breakdowns to INP duration ([#528](https://github.com/GoogleChrome/web-vitals/pull/528))\n- Cap LCP load duration to LCP time ([#527](https://github.com/GoogleChrome/web-vitals/pull/527))\n\n### v4.2.4 (2024-10-22)\n\n- Fix memory leak in registering new event listeners on every keydown and click ([#554](https://github.com/GoogleChrome/web-vitals/pull/554))\n\n### v4.2.3 (2024-08-06)\n\n- Fix missing LoAF entries in INP attribution ([#512](https://github.com/GoogleChrome/web-vitals/pull/512))\n\n### v4.2.2 (2024-07-17)\n\n- Fix interaction count after bfcache restore ([#505](https://github.com/GoogleChrome/web-vitals/pull/505))\n\n### v4.2.1 (2024-06-30)\n\n- Fix compatibility issues with TypeScript v5.5 ([#497](https://github.com/GoogleChrome/web-vitals/pull/497))\n\n### v4.2.0 (2024-06-20)\n\n- Refactor INP attribution code to fix errors on Windows 10 ([#495](https://github.com/GoogleChrome/web-vitals/pull/495))\n\n### v4.1.1 (2024-06-10)\n\n- Fix pending LoAF cleanup logic ([#493](https://github.com/GoogleChrome/web-vitals/pull/493))\n\n### v4.1.0 (2024-06-06)\n\n- Move the support check to the top of the onINP() function ([#490](https://github.com/GoogleChrome/web-vitals/pull/490))\n- Fix missing LoAF attribution when entries are dispatched before event entries ([#487](https://github.com/GoogleChrome/web-vitals/pull/487))\n\n### v4.0.1 (2024-05-21)\n\n- Add the `ReportCallback` type back but deprecate it ([#483](https://github.com/GoogleChrome/web-vitals/pull/483))\n\n### v4.0.0 (2024-05-13)\n\n[!NOTE]\nSee the [upgrading to v4](/docs/upgrading-to-v4.md) guide for a complete list of all API changes in version 4.\n\n- **[BREAKING]** Update types to support more generic usage ([#471](https://github.com/GoogleChrome/web-vitals/pull/471))\n- **[BREAKING]** Split `waitingDuration` to make it easier to understand redirect delays ([#458](https://github.com/GoogleChrome/web-vitals/pull/458))\n- **[BREAKING]** Rename `TTFBAttribution` fields from `*Time` to `*Duration` ([#453](https://github.com/GoogleChrome/web-vitals/pull/453))\n- **[BREAKING]** Rename `resourceLoadTime` to `resourceLoadDuration` in LCP attribution ([#450](https://github.com/GoogleChrome/web-vitals/pull/450))\n- **[BREAKING]** Add INP breakdown timings and LoAF attribution ([#442](https://github.com/GoogleChrome/web-vitals/pull/442))\n- **[BREAKING]** Deprecate `onFID()` and remove previously deprecated APIs ([#435](https://github.com/GoogleChrome/web-vitals/pull/435))\n- Expose the target element in INP attribution ([#479](https://github.com/GoogleChrome/web-vitals/pull/479))\n- Save INP target after interactions to reduce null values when removed from the DOM ([#477](https://github.com/GoogleChrome/web-vitals/pull/477))\n- Cap TTFB in attribution ([#440](https://github.com/GoogleChrome/web-vitals/pull/440))\n- Fix `reportAllChanges` behavior for LCP when library is loaded late ([#468](https://github.com/GoogleChrome/web-vitals/pull/468))\n\n### v3.5.2 (2024-01-25)\n\n- Pick the first non-null `target` for INP attribution ([#421](https://github.com/GoogleChrome/web-vitals/pull/421))\n\n### v3.5.1 (2023-12-27)\n\n- Add extra guard for `PerformanceEventTiming` not existing ([#403](https://github.com/GoogleChrome/web-vitals/pull/403))\n\n### v3.5.0 (2023-09-28)\n\n- Run `onLCP` callback in separate task ([#386](https://github.com/GoogleChrome/web-vitals/pull/386))\n- Fix INP durationThreshold bug when set to 0 ([#372](https://github.com/GoogleChrome/web-vitals/pull/372))\n- Prevent FID entries being emitted as INP for non-supporting browsers ([#368](https://github.com/GoogleChrome/web-vitals/pull/368))\n\n### v3.4.0 (2023-07-11)\n\n- Make `bindReporter` generic over metric type ([#359](https://github.com/GoogleChrome/web-vitals/pull/359))\n- Update INP status in README ([#362](https://github.com/GoogleChrome/web-vitals/pull/362))\n- Fix Metric types for better TypeScript support ([#356](https://github.com/GoogleChrome/web-vitals/pull/356))\n- Fix selector for SVGs for attribution build ([#354](https://github.com/GoogleChrome/web-vitals/pull/354))\n\n### v3.3.2 (2023-05-29)\n\n- Fix attribution types ([#348](https://github.com/GoogleChrome/web-vitals/pull/348))\n- Safe access navigation entry type ([#290](https://github.com/GoogleChrome/web-vitals/pull/290))\n\n### v3.3.1 (2023-04-04)\n\n- Export metric rating thresholds in attribution build as well.\n\n### v3.3.0 (2023-03-09)\n\n- Export metric rating thresholds, add explicit `MetricRatingThresholds` type ([#323](https://github.com/GoogleChrome/web-vitals/pull/323))\n- Trim classname selector ([#328](https://github.com/GoogleChrome/web-vitals/pull/328))\n- Add link to CrUX versus RUM blog post ([#327](https://github.com/GoogleChrome/web-vitals/pull/327))\n- Prevent LCP being reported for hidden prerendered pages ([#326](https://github.com/GoogleChrome/web-vitals/pull/326))\n- Add Server Timing information to docs ([#324](https://github.com/GoogleChrome/web-vitals/pull/324))\n- Fix link in `onINP()` thresholds comment ([#318](https://github.com/GoogleChrome/web-vitals/pull/318))\n- Update web.dev link for `onINP()` ([#307](https://github.com/GoogleChrome/web-vitals/pull/307))\n- Add a note about when to load the library ([#305](https://github.com/GoogleChrome/web-vitals/pull/305))\n\n### v3.2.0\n\n- Version number skipped\n\n### v3.1.1 (2023-01-10)\n\n- Defer CLS logic until after `onFCP()` callback ([#297](https://github.com/GoogleChrome/web-vitals/pull/297))\n\n### v3.1.0 (2022-11-15)\n\n- Add support for `'restore'` as a `navigationType` ([#284](https://github.com/GoogleChrome/web-vitals/pull/284))\n- Report initial CLS value when `reportAllChanges` is true ([#283](https://github.com/GoogleChrome/web-vitals/pull/283))\n- Defer all observers until after activation ([#282](https://github.com/GoogleChrome/web-vitals/pull/282))\n- Ignore TTFB for loads where responseStart is zero ([#281](https://github.com/GoogleChrome/web-vitals/pull/281))\n- Defer execution of observer callbacks ([#278](https://github.com/GoogleChrome/web-vitals/pull/278))\n\n### v3.0.4 (2022-10-18)\n\n- Clamp LCP and FCP to 0 for prerendered pages ([#270](https://github.com/GoogleChrome/web-vitals/pull/270))\n\n### v3.0.3 (2022-10-04)\n\n- Ensure `attribution` object is always present in attribution build ([#265](https://github.com/GoogleChrome/web-vitals/pull/265))\n\n### v3.0.2 (2022-09-14)\n\n- Set an explicit unpkg dist file ([#261](https://github.com/GoogleChrome/web-vitals/pull/261))\n\n### v3.0.1 (2022-08-31)\n\n- Use the cjs extension for all UMD builds ([#257](https://github.com/GoogleChrome/web-vitals/pull/257))\n\n### v3.0.0 (2022-08-24)\n\n- **[BREAKING]** Add a config object param to all metric functions ([#225](https://github.com/GoogleChrome/web-vitals/pull/225))\n- **[BREAKING]** Report TTFB after a bfcache restore ([#220](https://github.com/GoogleChrome/web-vitals/pull/220))\n- **[BREAKING]** Only include last LCP entry in metric entries ([#218](https://github.com/GoogleChrome/web-vitals/pull/218))\n- Update the metric ID prefix for v3 ([#251](https://github.com/GoogleChrome/web-vitals/pull/251))\n- Move the Navigation Timing API polyfill to the base+polyfill build ([#248](https://github.com/GoogleChrome/web-vitals/pull/248))\n- Add a metric rating property ([#246](https://github.com/GoogleChrome/web-vitals/pull/246))\n- Add deprecation notices for base+polyfill builds ([#242](https://github.com/GoogleChrome/web-vitals/pull/242))\n- Add a new attribution build for debugging issues in the field ([#237](https://github.com/GoogleChrome/web-vitals/pull/237), [#244](https://github.com/GoogleChrome/web-vitals/pull/244))\n- Add support for prerendered pages ([#233](https://github.com/GoogleChrome/web-vitals/pull/233))\n- Rename the `ReportHandler` type to `ReportCallback`, with alias for back-compat ([#225](https://github.com/GoogleChrome/web-vitals/pull/225), [#227](https://github.com/GoogleChrome/web-vitals/pull/227))\n- Add support for the new INP metric ([#221](https://github.com/GoogleChrome/web-vitals/pull/221), [#232](https://github.com/GoogleChrome/web-vitals/pull/232))\n- Rename `getXXX()` functions to `onXXX()` ([#222](https://github.com/GoogleChrome/web-vitals/pull/222))\n- Add a `navigationType` property to the Metric object ([#219](https://github.com/GoogleChrome/web-vitals/pull/219))\n\n### v2.1.4 (2022-01-20)\n\n- Prevent TTFB from reporting after bfcache restore ([#201](https://github.com/GoogleChrome/web-vitals/pull/201))\n\n### v2.1.3 (2022-01-06)\n\n- Only call report if LCP occurs before first hidden ([#197](https://github.com/GoogleChrome/web-vitals/pull/197))\n\n### v2.1.2 (2021-10-11)\n\n- Ensure reported TTFB values are less than the current page time ([#187](https://github.com/GoogleChrome/web-vitals/pull/187))\n\n### v2.1.1 (2021-10-06)\n\n- Add feature detects to support Opera mini in extreme data saver mode ([#186](https://github.com/GoogleChrome/web-vitals/pull/186))\n\n### v2.1.0 (2021-07-01)\n\n- Add batch reporting support and guidance ([#166](https://github.com/GoogleChrome/web-vitals/pull/166))\n\n### v2.0.1 (2021-06-02)\n\n- Detect getEntriesByName support before calling ([#158](https://github.com/GoogleChrome/web-vitals/pull/158))\n\n### v2.0.0 (2021-06-01)\n\n- **[BREAKING]** Update CLS to max session window 5s cap 1s gap ([#148](https://github.com/GoogleChrome/web-vitals/pull/148))\n- Ensure CLS is only reported if page was visible ([#149](https://github.com/GoogleChrome/web-vitals/pull/149))\n- Only report CLS when FCP is reported ([#154](https://github.com/GoogleChrome/web-vitals/pull/154))\n- Update the unique ID version prefix ([#157](https://github.com/GoogleChrome/web-vitals/pull/157))\n\n### v1.1.2 (2021-05-05)\n\n- Ignore negative TTFB values in Firefox ([#147](https://github.com/GoogleChrome/web-vitals/pull/147))\n- Add workaround for Safari FCP bug ([#145](https://github.com/GoogleChrome/web-vitals/pull/145))\n- Add more extensive FID feature detect ([#143](https://github.com/GoogleChrome/web-vitals/pull/143))\n\n### v1.1.1 (2021-03-13)\n\n- Remove use of legacy API to detect Firefox ([#128](https://github.com/GoogleChrome/web-vitals/pull/128))\n\n### v1.1.0 (2021-01-13)\n\n- Fix incorrect UMD config for base+polyfill script ([#117](https://github.com/GoogleChrome/web-vitals/pull/117))\n- Fix missing getter in polyfill ([#114](https://github.com/GoogleChrome/web-vitals/pull/114))\n- Add support for Set in place of WeakSet for IE11 compat ([#110](https://github.com/GoogleChrome/web-vitals/pull/110))\n\n### v1.0.1 (2020-11-16)\n\n- Fix missing `typings` declaration ([#90](https://github.com/GoogleChrome/web-vitals/pull/90))\n\n### v1.0.0 (2020-11-16)\n\n- **[BREAKING]** Add support for reporting metrics on back/forward cache restore ([#87](https://github.com/GoogleChrome/web-vitals/pull/87))\n- **[BREAKING]** Remove the `isFinal` flag from the Metric interface ([#86](https://github.com/GoogleChrome/web-vitals/pull/86))\n- Remove the scroll listener to stop LCP observing ([#85](https://github.com/GoogleChrome/web-vitals/pull/85))\n\n### v0.2.4 (2020-07-23)\n\n- Remove the unload listener ([#68](https://github.com/GoogleChrome/web-vitals/pull/68))\n\n### v0.2.3 (2020-06-26)\n\n- Ensure reports only occur if a PO was created ([#58](https://github.com/GoogleChrome/web-vitals/pull/58))\n\n### v0.2.2 (2020-05-12)\n\n- Remove package `type` field ([#35](https://github.com/GoogleChrome/web-vitals/pull/35))\n\n### v0.2.1 (2020-05-06)\n\n- Ensure all modules are pure modules ([#23](https://github.com/GoogleChrome/web-vitals/pull/23))\n- Ensure proper TypeScript exports and config ([#22](https://github.com/GoogleChrome/web-vitals/pull/22))\n\n### v0.2.0 (2020-05-03)\n\n- Initial public release\n\n### v0.1.0 (2020-04-24)\n\n- Initial pre-release\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Google Open Source Community Guidelines\n\nAt Google, we recognize and celebrate the creativity and collaboration of open\nsource contributors and the diversity of skills, experiences, cultures, and\nopinions they bring to the projects and communities they participate in.\n\nEvery one of Google's open source projects and communities are inclusive\nenvironments, based on treating all individuals respectfully, regardless of\ngender identity and expression, sexual orientation, disabilities,\nneurodiversity, physical appearance, body size, ethnicity, nationality, race,\nage, religion, or similar personal characteristic.\n\nWe value diverse opinions, but we value respectful behavior more.\n\nRespectful behavior includes:\n\n- Being considerate, kind, constructive, and helpful.\n- Not engaging in demeaning, discriminatory, harassing, hateful, sexualized, or\n  physically threatening behavior, speech, and imagery.\n- Not engaging in unwanted physical contact.\n\nSome Google open source projects [may adopt][] an explicit project code of\nconduct, which may have additional detailed expectations for participants. Most\nof those projects will use our [modified Contributor Covenant][].\n\n[may adopt]: https://opensource.google/docs/releasing/preparing/#conduct\n[modified contributor covenant]: https://opensource.google/docs/releasing/template/CODE_OF_CONDUCT/\n\n## Resolve peacefully\n\nWe do not believe that all conflict is necessarily bad; healthy debate and\ndisagreement often yields positive results. However, it is never okay to be\ndisrespectful.\n\nIf you see someone behaving disrespectfully, you are encouraged to address the\nbehavior directly with those involved. Many issues can be resolved quickly and\neasily, and this gives people more control over the outcome of their dispute.\nIf you are unable to resolve the matter for any reason, or if the behavior is\nthreatening or harassing, report it. We are dedicated to providing an\nenvironment where participants feel welcome and safe.\n\n## Reporting problems\n\nSome Google open source projects may adopt a project-specific code of conduct.\nIn those cases, a Google employee will be identified as the Project Steward,\nwho will receive and handle reports of code of conduct violations. In the event\nthat a project hasn’t identified a Project Steward, you can report problems by\nemailing opensource@google.com.\n\nWe will investigate every complaint, but you may not receive a direct response.\nWe will use our discretion in determining when and how to follow up on reported\nincidents, which may range from not taking action to permanent expulsion from\nthe project and project-sponsored spaces. We will notify the accused of the\nreport and provide them an opportunity to discuss it before any action is\ntaken. The identity of the reporter will be omitted from the details of the\nreport supplied to the accused. In potentially harmful situations, such as\nongoing harassment or threats to anyone's safety, we may take action without\nnotice.\n\n_This document was adapted from the [IndieWeb Code of Conduct][] and can also\nbe found at <https://opensource.google/conduct/>._\n\n[indieweb code of conduct]: https://indieweb.org/code-of-conduct\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# How to Contribute\n\nWe'd love to accept your patches and contributions to this project. There are\njust a few small guidelines you need to follow.\n\n## Contributor License Agreement\n\nContributions to this project must be accompanied by a Contributor License\nAgreement. You (or your employer) retain the copyright to your contribution;\nthis simply gives us permission to use and redistribute your contributions as\npart of the project. Head over to <https://cla.developers.google.com/> to see\nyour current agreements on file or to sign a new one.\n\nYou generally only need to submit a CLA once, so if you've already submitted one\n(even if it was for a different project), you probably don't need to do it\nagain.\n\n## Code reviews\n\nAll submissions, including submissions by project members, require review. We\nuse GitHub pull requests for this purpose. Consult\n[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more\ninformation on using pull requests.\n\n## Testing\n\nTo test the full suite run `npm run test`.\n\nTo test a subset of browsers or metrics, run the following in separate terminals:\n\n- `npm run watch`\n- `npm run test:server`\n- `npm run test:e2e -- --browsers=chrome --metrics=TTFB`\n\nThe last command can be replaced as you see fit and include comma, separated values. For example:\n\n- `npm run test:e2e -- --browsers=chrome,firefox --metrics=TTFB,LCP`\n\nTo run an individual test, change `it('test name')` to `it.only('test name')`.\n\nYou can also add `await browser.debug()` lines to the individual test files to pause execution, and press `CTRL+C` in the command line to continue the tests.\n\nSee the https://webdriver.io/ for more information.\n\n## Community Guidelines\n\nThis project follows [Google's Open Source Community\nGuidelines](https://opensource.google/conduct/).\n"
  },
  {
    "path": "LICENSE",
    "content": "\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright 2020 Google LLC\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       https://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "# `web-vitals`\n\n- [Overview](#overview)\n- [Install and load the library](#installation)\n  - [From npm](#import-web-vitals-from-npm)\n  - [From a CDN](#load-web-vitals-from-a-cdn)\n- [Usage](#usage)\n  - [Basic usage](#basic-usage)\n  - [Report the value on every change](#report-the-value-on-every-change)\n  - [Report only the delta of changes](#report-only-the-delta-of-changes)\n  - [Send the results to an analytics endpoint](#send-the-results-to-an-analytics-endpoint)\n  - [Send the results to Google Analytics](#send-the-results-to-google-analytics)\n  - [Send the results to Google Tag Manager](#send-the-results-to-google-tag-manager)\n  - [Send attribution data](#send-attribution-data)\n  - [Batch multiple reports together](#batch-multiple-reports-together)\n- [Build options](#build-options)\n  - [Which build is right for you?](#which-build-is-right-for-you)\n- [API](#api)\n  - [Types](#types)\n  - [Functions](#functions)\n  - [Rating Thresholds](#rating-thresholds)\n  - [Attribution](#attribution)\n- [Browser Support](#browser-support)\n- [Limitations](#limitations)\n- [Development](#development)\n- [Integrations](#integrations)\n- [License](#license)\n\n## Overview\n\nThe `web-vitals` library is a tiny (~2K, brotli'd), modular library for measuring all the [Web Vitals](https://web.dev/articles/vitals) metrics on real users, in a way that accurately matches how they're measured by Chrome and reported to other Google tools (e.g. [Chrome User Experience Report](https://developers.google.com/web/tools/chrome-user-experience-report), [Page Speed Insights](https://developers.google.com/speed/pagespeed/insights/), [Search Console's Speed Report](https://webmasters.googleblog.com/2019/11/search-console-speed-report.html)).\n\nThe library supports all of the [Core Web Vitals](https://web.dev/articles/vitals#core_web_vitals) as well as a number of other metrics that are useful in diagnosing [real-user](https://web.dev/articles/user-centric-performance-metrics) performance issues.\n\n### Core Web Vitals\n\n- [Cumulative Layout Shift (CLS)](https://web.dev/articles/cls)\n- [Interaction to Next Paint (INP)](https://web.dev/articles/inp)\n- [Largest Contentful Paint (LCP)](https://web.dev/articles/lcp)\n\n### Other metrics\n\n- [First Contentful Paint (FCP)](https://web.dev/articles/fcp)\n- [Time to First Byte (TTFB)](https://web.dev/articles/ttfb)\n\n<a name=\"installation\"></a>\n<a name=\"load-the-library\"></a>\n\n## Install and load the library\n\n<a name=\"import-web-vitals-from-npm\"></a>\n\nThe `web-vitals` library uses the `buffered` flag for [PerformanceObserver](https://developer.mozilla.org/docs/Web/API/PerformanceObserver/observe), allowing it to access performance entries that occurred before the library was loaded.\n\nThis means you do not need to load this library early in order to get accurate performance data. In general, this library should be deferred until after other user-impacting code has loaded.\n\n### From npm\n\nYou can install this library from npm by running:\n\n```sh\nnpm install web-vitals\n```\n\n> [!NOTE]\n> If you're not using npm, you can still load `web-vitals` via `<script>` tags from a CDN like [unpkg.com](https://unpkg.com). See the [load `web-vitals` from a CDN](#load-web-vitals-from-a-cdn) usage example below for details.\n\nThere are a few different builds of the `web-vitals` library, and how you load the library depends on which build you want to use.\n\nFor details on the difference between the builds, see <a href=\"#which-build-is-right-for-you\">which build is right for you</a>.\n\n**1. The \"standard\" build**\n\nTo load the \"standard\" build, import modules from the `web-vitals` package in your application code (as you would with any npm package and node-based build tool):\n\n```js\nimport {onLCP, onINP, onCLS} from 'web-vitals';\n```\n\n<a name=\"attribution-build\"></a>\n\n**2. The \"attribution\" build**\n\nMeasuring the Web Vitals scores for your real users is a great first step toward optimizing the user experience. But if your scores aren't _good_, the next step is to understand why they're not good and work to improve them.\n\nThe \"attribution\" build helps you do that by including additional diagnostic information with each metric to help you identify the root cause of poor performance as well as prioritize the most important things to fix.\n\nThe \"attribution\" build is slightly larger than the \"standard\" build (by about 1.5K, brotli'd), so while the code size is still small, it's only recommended if you're actually using these features.\n\nTo load the \"attribution\" build, change any `import` statements that reference `web-vitals` to `web-vitals/attribution`:\n\n```diff\nimport {onLCP, onINP, onCLS} from 'web-vitals';\nimport {onLCP, onINP, onCLS} from 'web-vitals/attribution';\n```\n\nUsage for each of the imported function is identical to the standard build, but when importing from the attribution build, the [metric](#metric) objects will contain an additional [`attribution`](#attribution) property.\n\nSee [Send attribution data](#send-attribution-data) for usage examples, and the [`attribution` reference](#attribution) for details on what values are added for each metric.\n\n<a name=\"load-web-vitals-from-a-cdn\"></a>\n\n### From a CDN\n\nThe recommended way to use the `web-vitals` package is to install it from npm and integrate it into your build process. However, if you're not using npm, it's still possible to use `web-vitals` by requesting it from a CDN that serves npm package files.\n\nThe following examples show how to load `web-vitals` from [unpkg.com](https://unpkg.com/browse/web-vitals/). It is also possible to load this from [jsDelivr](https://www.jsdelivr.com/package/npm/web-vitals), and [cdnjs](https://cdnjs.com/libraries/web-vitals).\n\n_**Important!** The [unpkg.com](https://unpkg.com), [jsDelivr](https://www.jsdelivr.com/), and [cdnjs](https://cdnjs.com) CDNs are shown here for example purposes only. `unpkg.com`, `jsDelivr`, and `cdnjs` are not affiliated with Google, and there are no guarantees that loading the library from those CDNs will continue to work in the future. Self-hosting the built files rather than loading from the CDN is better for security, reliability, and performance reasons._\n\n**Load the \"standard\" build** _(using a module script)_\n\n```html\n<!-- Append the `?module` param to load the module version of `web-vitals` -->\n<script type=\"module\">\n  import {onCLS, onINP, onLCP} from 'https://unpkg.com/web-vitals@5?module';\n\n  onCLS(console.log);\n  onINP(console.log);\n  onLCP(console.log);\n</script>\n```\n\nNote: When the web-vitals code is isolated from the application code in this way, there is less need to depend on dynamic imports so this code uses a regular `import` line.\n\n**Load the \"standard\" build** _(using a classic script)_\n\n```html\n<script>\n  (function () {\n    var script = document.createElement('script');\n    script.src = 'https://unpkg.com/web-vitals@5/dist/web-vitals.iife.js';\n    script.onload = function () {\n      // When loading `web-vitals` using a classic script, all the public\n      // methods can be found on the `webVitals` global namespace.\n      webVitals.onCLS(console.log);\n      webVitals.onINP(console.log);\n      webVitals.onLCP(console.log);\n    };\n    document.head.appendChild(script);\n  })();\n</script>\n```\n\n**Load the \"attribution\" build** _(using a module script)_\n\n```html\n<!-- Append the `?module` param to load the module version of `web-vitals` -->\n<script type=\"module\">\n  import {\n    onCLS,\n    onINP,\n    onLCP,\n  } from 'https://unpkg.com/web-vitals@5/dist/web-vitals.attribution.js?module';\n\n  onCLS(console.log);\n  onINP(console.log);\n  onLCP(console.log);\n</script>\n```\n\n**Load the \"attribution\" build** _(using a classic script)_\n\n```html\n<script>\n  (function () {\n    var script = document.createElement('script');\n    script.src =\n      'https://unpkg.com/web-vitals@5/dist/web-vitals.attribution.iife.js';\n    script.onload = function () {\n      // When loading `web-vitals` using a classic script, all the public\n      // methods can be found on the `webVitals` global namespace.\n      webVitals.onCLS(console.log);\n      webVitals.onINP(console.log);\n      webVitals.onLCP(console.log);\n    };\n    document.head.appendChild(script);\n  })();\n</script>\n```\n\n## Usage\n\n### Basic usage\n\nEach of the Web Vitals metrics is exposed as a single function that takes a `callback` function that will be called any time the metric value is available and ready to be reported.\n\nThe following example measures each of the Core Web Vitals metrics and logs the result to the console once its value is ready to report.\n\n_(The examples below import the \"standard\" build, but they will work with the \"attribution\" build as well.)_\n\n```js\nimport {onCLS, onINP, onLCP} from 'web-vitals';\n\nonCLS(console.log);\nonINP(console.log);\nonLCP(console.log);\n```\n\nNote that some of these metrics will not report until the user has interacted with the page, switched tabs, or the page starts to unload. If you don't see the values logged to the console immediately, try reloading the page (with [preserve log](https://developer.chrome.com/docs/devtools/console/reference/#persist) enabled) or switching tabs and then switching back.\n\nAlso, in some cases a metric callback may never be called:\n\n- INP is not reported if the user never interacts with the page.\n- CLS, FCP, and LCP are not reported if the page was loaded in the background.\n\nIn other cases, a metric callback may be called more than once:\n\n- CLS and INP should be reported any time the [page's `visibilityState` changes to hidden](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden).\n- All metrics are reported again (with the above exceptions) after a page is restored from the [back/forward cache](https://web.dev/articles/bfcache).\n\n> [!WARNING]\n> Do not call any of the Web Vitals functions (e.g. `onCLS()`, `onINP()`, `onLCP()`) more than once per page load. Each of these functions creates a `PerformanceObserver` instance and registers event listeners for the lifetime of the page. While the overhead of calling these functions once is negligible, calling them repeatedly on the same page may eventually result in a memory leak.\n\n### Report the value on every change\n\nIn most cases, you only want the `callback` function to be called when the metric is ready to be reported. However, it is possible to report every change (e.g. each larger layout shift as it happens) by setting `reportAllChanges` to `true` in the optional, [configuration object](#reportopts) (second parameter).\n\n> [!IMPORTANT] > `reportAllChanges` only reports when the **metric changes**, not for each **input to the metric**. For example, a new layout shift that does not increase the CLS metric will not be reported even with `reportAllChanges` set to `true` because the CLS metric has not changed. Similarly, for INP, each interaction is not reported even with `reportAllChanges` set to `true`—just when an interaction causes an increase to INP.\n\nThis can be useful when debugging, but in general using `reportAllChanges` is not needed (or recommended) for measuring these metrics in production.\n\n```js\nimport {onCLS} from 'web-vitals';\n\n// Logs CLS as the value changes.\nonCLS(console.log, {reportAllChanges: true});\n```\n\n### Report only the delta of changes\n\nSome analytics providers allow you to update the value of a metric, even after you've already sent it to their servers (overwriting the previously-sent value with the same `id`).\n\nOther analytics providers, however, do not allow this, so instead of reporting the new value, you need to report only the delta (the difference between the current value and the last-reported value). You can then compute the total value by summing all metric deltas sent with the same ID.\n\nThe following example shows how to use the `id` and `delta` properties:\n\n```js\nimport {onCLS, onINP, onLCP} from 'web-vitals';\n\nfunction logDelta({name, id, delta}) {\n  console.log(`${name} matching ID ${id} changed by ${delta}`);\n}\n\nonCLS(logDelta);\nonINP(logDelta);\nonLCP(logDelta);\n```\n\n> [!NOTE]\n> The first time the `callback` function is called, its `value` and `delta` properties will be the same.\n\nIn addition to using the `id` field to group multiple deltas for the same metric, it can also be used to differentiate different metrics reported on the same page. For example, after a back/forward cache restore, a new metric object is created with a new `id` (since back/forward cache restores are considered separate page visits).\n\n### Send the results to an analytics endpoint\n\nThe following example measures each of the Core Web Vitals metrics and reports them to a hypothetical `/analytics` endpoint, as soon as each is ready to be sent.\n\nThe `sendToAnalytics()` function uses the [`navigator.sendBeacon()`](https://developer.mozilla.org/docs/Web/API/Navigator/sendBeacon) method, which is widely available across browsers, and supports sending data as the page is being unloaded.\n\n```js\nimport {onCLS, onINP, onLCP} from 'web-vitals';\n\nfunction sendToAnalytics(metric) {\n  const body = JSON.stringify({\n    name: metric.name,\n    value: metric.value,\n    id: metric.id,\n\n    // Include additional data as needed...\n  });\n\n  // Use `navigator.sendBeacon()` to send the data, which supports\n  // sending while the page is unloading.\n  navigator.sendBeacon('/analytics', body);\n}\n\nonCLS(sendToAnalytics);\nonINP(sendToAnalytics);\nonLCP(sendToAnalytics);\n```\n\n### Send the results to Google Analytics\n\nGoogle Analytics does not support reporting metric distributions in any of its built-in reports; however, if you set a unique event parameter value (in this case, the metric_id, as shown in the example below) on every metric instance that you send to Google Analytics, you can create a report yourself by first getting the data via the [Google Analytics Data API](https://developers.google.com/analytics/devguides/reporting/data/v1) or via [BigQuery export](https://support.google.com/analytics/answer/9358801) and then visualizing it any charting library you choose.\n\n[Google Analytics 4](https://support.google.com/analytics/answer/10089681) introduces a new Event model allowing custom parameters instead of a fixed category, action, and label. It also supports non-integer values, making it easier to measure Web Vitals metrics compared to previous versions.\n\n```js\nimport {onCLS, onINP, onLCP} from 'web-vitals';\n\nfunction sendToGoogleAnalytics({name, delta, value, id}) {\n  // Assumes the global `gtag()` function exists, see:\n  // https://developers.google.com/analytics/devguides/collection/ga4\n  gtag('event', name, {\n    // Built-in params:\n    value: delta, // Use `delta` so the value can be summed.\n    // Custom params:\n    metric_id: id, // Needed to aggregate events.\n    metric_value: value, // Optional.\n    metric_delta: delta, // Optional.\n\n    // OPTIONAL: any additional params or debug info here.\n    // See: https://web.dev/articles/debug-performance-in-the-field\n    // metric_rating: 'good' | 'needs-improvement' | 'poor',\n    // debug_info: '...',\n    // ...\n  });\n}\n\nonCLS(sendToGoogleAnalytics);\nonINP(sendToGoogleAnalytics);\nonLCP(sendToGoogleAnalytics);\n```\n\nFor details on how to query this data in [BigQuery](https://cloud.google.com/bigquery), or visualise it in [Looker Studio](https://lookerstudio.google.com/), see [Measure and debug performance with Google Analytics 4 and BigQuery](https://web.dev/articles/vitals-ga4).\n\n### Send the results to Google Tag Manager\n\nWhile `web-vitals` can be called directly from Google Tag Manager, using a pre-defined custom template makes this considerably easier. Some recommended templates include:\n\n- [Core Web Vitals](https://tagmanager.google.com/gallery/#/owners/gtm-templates-simo-ahava/templates/core-web-vitals) by [Simo Ahava](https://www.simoahava.com/). See [Track Core Web Vitals in GA4 with Google Tag Manager](https://www.simoahava.com/analytics/track-core-web-vitals-in-ga4-with-google-tag-manager/) for usage and installation instructions.\n- [Web Vitals Template for Google Tag Manager](https://github.com/google-marketing-solutions/web-vitals-gtm-template) by The Google Marketing Solutions team. See the [README](https://github.com/google-marketing-solutions/web-vitals-gtm-template?tab=readme-ov-file#web-vitals-template-for-google-tag-manager) for usage and installation instructions.\n\n### Send attribution data\n\nWhen using the [attribution build](#attribution-build), you can send additional data to help you debug _why_ the metric values are the way they are.\n\nThis example sends an additional `debug_target` param to Google Analytics, corresponding to the element most associated with each metric.\n\n```js\nimport {onCLS, onINP, onLCP} from 'web-vitals/attribution';\n\nfunction sendToGoogleAnalytics({name, delta, value, id, attribution}) {\n  const eventParams = {\n    // Built-in params:\n    value: delta, // Use `delta` so the value can be summed.\n    // Custom params:\n    metric_id: id, // Needed to aggregate events.\n    metric_value: value, // Optional.\n    metric_delta: delta, // Optional.\n  };\n\n  switch (name) {\n    case 'CLS':\n      eventParams.debug_target = attribution.largestShiftTarget;\n      break;\n    case 'INP':\n      eventParams.debug_target = attribution.interactionTarget;\n      break;\n    case 'LCP':\n      eventParams.debug_target = attribution.target;\n      break;\n  }\n\n  // Assumes the global `gtag()` function exists, see:\n  // https://developers.google.com/analytics/devguides/collection/ga4\n  gtag('event', name, eventParams);\n}\n\nonCLS(sendToGoogleAnalytics);\nonINP(sendToGoogleAnalytics);\nonLCP(sendToGoogleAnalytics);\n```\n\n> [!NOTE]\n> This example relies on custom [event parameters](https://support.google.com/analytics/answer/11396839) in Google Analytics 4.\n\nSee [Debug performance in the field](https://web.dev/articles/debug-performance-in-the-field) for more information and examples.\n\n### Batch multiple reports together\n\nRather than reporting each individual Web Vitals metric separately, you can minimize your network usage by batching multiple metric reports together in a single network request.\n\nHowever, since not all Web Vitals metrics become available at the same time, and since not all metrics are reported on every page, you cannot simply defer reporting until all metrics are available.\n\nInstead, you should keep a queue of all metrics that were reported and flush the queue whenever the page is backgrounded or unloaded:\n\n```js\nimport {onCLS, onINP, onLCP} from 'web-vitals';\n\nconst queue = new Set();\nfunction addToQueue(metric) {\n  queue.add(metric);\n}\n\nfunction flushQueue() {\n  if (queue.size > 0) {\n    // Replace with whatever serialization method you prefer.\n    // Note: JSON.stringify will likely include more data than you need.\n    const body = JSON.stringify([...queue]);\n\n    // Use `navigator.sendBeacon()` to send the data, which supports\n    // sending while the page is unloading.\n    navigator.sendBeacon('/analytics', body);\n\n    queue.clear();\n  }\n}\n\nonCLS(addToQueue);\nonINP(addToQueue);\nonLCP(addToQueue);\n\n// Report all available metrics whenever the page is backgrounded or unloaded.\naddEventListener('visibilitychange', () => {\n  if (document.visibilityState === 'hidden') {\n    flushQueue();\n  }\n});\n```\n\n> [!NOTE]\n> See [the Page Lifecycle guide](https://developers.google.com/web/updates/2018/07/page-lifecycle-api#legacy-lifecycle-apis-to-avoid) for an explanation of why `visibilitychange` is recommended over events like `beforeunload` and `unload`.\n\n<a name=\"bundle-versions\"></a>\n\n## Build options\n\nThe `web-vitals` package includes both \"standard\" and \"attribution\" builds, as well as different formats of each to allow developers to choose the format that best meets their needs or integrates with their architecture.\n\nThe following table lists all the builds distributed with the `web-vitals` package on npm.\n\n<table>\n  <tr>\n    <td width=\"35%\">\n      <strong>Filename</strong> <em>(all within <code>dist/*</code>)</em>\n    </td>\n    <td><strong>Export</strong></td>\n    <td><strong>Description</strong></td>\n  </tr>\n  <tr>\n    <td><code>web-vitals.js</code></td>\n    <td><code>pkg.module</code></td>\n    <td>\n      <p>An ES module bundle of all metric functions, without any attribution features.</p>\n      This is the \"standard\" build and is the simplest way to consume this library out of the box.\n    </td>\n  </tr>\n  <tr>\n    <td><code>web-vitals.umd.cjs</code></td>\n    <td><code>pkg.main</code></td>\n    <td>\n      A UMD version of the <code>web-vitals.js</code> bundle (exposed on the <code>self.webVitals.*</code> namespace).\n    </td>\n  </tr>\n  <tr>\n    <td><code>web-vitals.iife.js</code></td>\n    <td>--</td>\n    <td>\n      An IIFE version of the <code>web-vitals.js</code> bundle (exposed on the <code>self.webVitals.*</code> namespace).\n    </td>\n  </tr>\n  <tr>\n    <td><code>web-vitals.attribution.js</code></td>\n    <td>--</td>\n    <td>\n      An ES module version of all metric functions that includes <a href=\"#attribution-build\">attribution</a> features.\n    </td>\n  </tr>\n    <tr>\n    <td><code>web-vitals.attribution.umd.cjs</code></td>\n    <td>--</td>\n    <td>\n      A UMD version of the <code>web-vitals.attribution.js</code> build (exposed on the <code>self.webVitals.*</code> namespace).\n    </td>\n  </tr>\n  </tr>\n    <tr>\n    <td><code>web-vitals.attribution.iife.js</code></td>\n    <td>--</td>\n    <td>\n      An IIFE version of the <code>web-vitals.attribution.js</code> build (exposed on the <code>self.webVitals.*</code> namespace).\n    </td>\n  </tr>\n</table>\n\n<a name=\"which-build-is-right-for-you\"></a>\n\n### Which build is right for you?\n\nMost developers will generally want to use \"standard\" build (via either the ES module or UMD version, depending on your bundler/build system), as it's the easiest to use out of the box and integrate into existing tools.\n\nHowever, if you'd like to collect additional debug information to help you diagnose performance bottlenecks based on real-user issues, use the [\"attribution\" build](#attribution-build).\n\nFor guidance on how to collect and use real-user data to debug performance issues, see [Debug performance in the field](https://web.dev/debug-performance-in-the-field/).\n\n## API\n\n### Types:\n\n#### `Metric`\n\nAll metrics types inherit from the following base interface:\n\n```ts\ninterface Metric {\n  /**\n   * The name of the metric (in acronym form).\n   */\n  name: 'CLS' | 'FCP' | 'INP' | 'LCP' | 'TTFB';\n\n  /**\n   * The current value of the metric.\n   */\n  value: number;\n\n  /**\n   * The rating as to whether the metric value is within the \"good\",\n   * \"needs improvement\", or \"poor\" thresholds of the metric.\n   */\n  rating: 'good' | 'needs-improvement' | 'poor';\n\n  /**\n   * The delta between the current value and the last-reported value.\n   * On the first report, `delta` and `value` will always be the same.\n   */\n  delta: number;\n\n  /**\n   * A unique ID representing this particular metric instance. This ID can\n   * be used by an analytics tool to dedupe multiple values sent for the same\n   * metric instance, or to group multiple deltas together and calculate a\n   * total. It can also be used to differentiate multiple different metric\n   * instances sent from the same page, which can happen if the page is\n   * restored from the back/forward cache (in that case new metrics object\n   * get created).\n   */\n  id: string;\n\n  /**\n   * Any performance entries relevant to the metric value calculation.\n   * The array may also be empty if the metric value was not based on any\n   * entries (e.g. a CLS value of 0 given no layout shifts).\n   */\n  entries: PerformanceEntry[];\n\n  /**\n   * The type of navigation.\n   *\n   * This will be the value returned by the Navigation Timing API (or\n   * `undefined` if the browser doesn't support that API), with the following\n   * exceptions:\n   * - 'back-forward-cache': for pages that are restored from the bfcache.\n   * - 'back_forward' is renamed to 'back-forward' for consistency.\n   * - 'prerender': for pages that were prerendered.\n   * - 'restore': for pages that were discarded by the browser and then\n   * restored by the user.\n   */\n  navigationType:\n    | 'navigate'\n    | 'reload'\n    | 'back-forward'\n    | 'back-forward-cache'\n    | 'prerender'\n    | 'restore';\n}\n```\n\nMetric-specific subclasses:\n\n##### `CLSMetric`\n\n```ts\ninterface CLSMetric extends Metric {\n  name: 'CLS';\n  entries: LayoutShift[];\n}\n```\n\n##### `FCPMetric`\n\n```ts\ninterface FCPMetric extends Metric {\n  name: 'FCP';\n  entries: PerformancePaintTiming[];\n}\n```\n\n##### `INPMetric`\n\n```ts\ninterface INPMetric extends Metric {\n  name: 'INP';\n  entries: PerformanceEventTiming[];\n}\n```\n\n##### `LCPMetric`\n\n```ts\ninterface LCPMetric extends Metric {\n  name: 'LCP';\n  entries: LargestContentfulPaint[];\n}\n```\n\n##### `TTFBMetric`\n\n```ts\ninterface TTFBMetric extends Metric {\n  name: 'TTFB';\n  entries: PerformanceNavigationTiming[];\n}\n```\n\n#### `MetricRatingThresholds`\n\nThe thresholds of metric's \"good\", \"needs improvement\", and \"poor\" ratings.\n\n- Metric values up to and including [0] are rated \"good\"\n- Metric values up to and including [1] are rated \"needs improvement\"\n- Metric values above [1] are \"poor\"\n\n| Metric value    | Rating              |\n| --------------- | ------------------- |\n| ≦ [0]           | \"good\"              |\n| > [0] and ≦ [1] | \"needs improvement\" |\n| > [1]           | \"poor\"              |\n\n```ts\ntype MetricRatingThresholds = [number, number];\n```\n\n_See also [Rating Thresholds](#rating-thresholds)._\n\n#### `ReportOpts`\n\n```ts\ninterface ReportOpts {\n  reportAllChanges?: boolean;\n}\n```\n\nMetric-specific subclasses:\n\n##### `INPReportOpts`\n\n```ts\ninterface INPReportOpts extends ReportOpts {\n  durationThreshold?: number;\n}\n```\n\n#### `AttributionReportOpts`\n\nA subclass of `ReportOpts` used for each metric function exported in the [attribution build](#attribution).\n\n```ts\ninterface AttributionReportOpts extends ReportOpts {\n  generateTarget?: (el: Node | null) => string | null | undefined;\n}\n```\n\nMetric-specific subclasses:\n\n##### `INPAttributionReportOpts`\n\n```ts\ninterface INPAttributionReportOpts extends AttributionReportOpts {\n  durationThreshold?: number;\n}\n```\n\n#### `LoadState`\n\nThe `LoadState` type is used in several of the metric [attribution objects](#attribution).\n\n```ts\n/**\n * The loading state of the document. Note: this value is similar to\n * `document.readyState` but it subdivides the \"interactive\" state into the\n * time before and after the DOMContentLoaded event fires.\n *\n * State descriptions:\n * - `loading`: the initial document response has not yet been fully downloaded\n *   and parsed. This is equivalent to the corresponding `readyState` value.\n * - `dom-interactive`: the document has been fully loaded and parsed, but\n *   scripts may not have yet finished loading and executing.\n * - `dom-content-loaded`: the document is fully loaded and parsed, and all\n *   scripts (except `async` scripts) have loaded and finished executing.\n * - `complete`: the document and all of its sub-resources have finished\n *   loading. This is equivalent to the corresponding `readyState` value.\n */\ntype LoadState =\n  | 'loading'\n  | 'dom-interactive'\n  | 'dom-content-loaded'\n  | 'complete';\n```\n\n### Functions:\n\n#### `onCLS()`\n\n```ts\nfunction onCLS(callback: (metric: CLSMetric) => void, opts?: ReportOpts): void;\n```\n\nCalculates the [CLS](https://web.dev/articles/cls) value for the current page and calls the `callback` function once the value is ready to be reported, along with all `layout-shift` performance entries that were used in the metric value calculation. The reported value is a [double](https://heycam.github.io/webidl/#idl-double) (corresponding to a [layout shift score](https://web.dev/articles/cls#layout_shift_score)).\n\n> [!IMPORTANT]\n> CLS should be continually monitored for changes throughout the entire lifespan of a page—including if the user returns to the page after it's been hidden/backgrounded. However, since browsers often [will not fire additional callbacks once the user has backgrounded a page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden), `callback` is always called when the page's visibility state changes to hidden. As a result, the `callback` function might be called multiple times during the same page load (see [Reporting only the delta of changes](#report-only-the-delta-of-changes) for how to manage this).\n\nIf the `reportAllChanges` [configuration option](#reportopts) is set to `true`, the `callback` function will be called as soon as the value is initially determined as well as any time the value changes throughout the page lifespan (though [not necessarily for every layout shift](#report-the-value-on-every-change)). Note that regardless of whether `reportAllChanges` is used, the final reported value will be the same.\n\n#### `onFCP()`\n\n```ts\nfunction onFCP(callback: (metric: FCPMetric) => void, opts?: ReportOpts): void;\n```\n\nCalculates the [FCP](https://web.dev/articles/fcp) value for the current page and calls the `callback` function once the value is ready, along with the relevant `paint` performance entry used to determine the value. The reported value is a [`DOMHighResTimeStamp`](https://developer.mozilla.org/docs/Web/API/DOMHighResTimeStamp).\n\n#### `onINP()`\n\n```ts\nfunction onINP(\n  callback: (metric: INPMetric) => void,\n  opts?: INPReportOpts,\n): void;\n```\n\nCalculates the [INP](https://web.dev/articles/inp) value for the current page and calls the `callback` function once the value is ready, along with the `event` performance entries reported for that interaction. The reported value is a [`DOMHighResTimeStamp`](https://developer.mozilla.org/docs/Web/API/DOMHighResTimeStamp).\n\n> [!IMPORTANT]\n> INP should be continually monitored for changes throughout the entire lifespan of a page—including if the user returns to the page after it's been hidden/backgrounded. However, since browsers often [will not fire additional callbacks once the user has backgrounded a page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden), `callback` is always called when the page's visibility state changes to hidden. As a result, the `callback` function might be called multiple times during the same page load (see [Reporting only the delta of changes](#report-only-the-delta-of-changes) for how to manage this).\n\nA custom `durationThreshold` [configuration option](#reportopts) can optionally be passed to control the minimum duration filter for `event-timing`. Events which are faster than this threshold are not reported. Note that the `first-input` entry is always observed, regardless of duration, to ensure you always have some INP score. The default threshold, after the library is initialized, is `40` milliseconds (the `event-timing` default of `104` milliseconds applies to all events emitted before the library is initialised). This default threshold of `40` is chosen to strike a balance between usefulness and performance. Running this callback for any interaction that spans just one or two frames is likely not worth the insight that could be gained.\n\nIf the `reportAllChanges` [configuration option](#reportopts) is set to `true`, the `callback` function will be called as soon as the value is initially determined as well as any time the value changes throughout the page lifespan (though [not necessarily for every interaction](#report-the-value-on-every-change)). Note that regardless of whether `reportAllChanges` is used, the final reported value will be the same.\n\n#### `onLCP()`\n\n```ts\nfunction onLCP(callback: (metric: LCPMetric) => void, opts?: ReportOpts): void;\n```\n\nCalculates the [LCP](https://web.dev/articles/lcp) value for the current page and calls the `callback` function once the value is ready (along with the relevant `largest-contentful-paint` performance entry used to determine the value). The reported value is a [`DOMHighResTimeStamp`](https://developer.mozilla.org/docs/Web/API/DOMHighResTimeStamp).\n\nIf the `reportAllChanges` [configuration option](#reportopts) is set to `true`, the `callback` function will be called any time a new `largest-contentful-paint` performance entry is dispatched, or once the final value of the metric has been determined. Note that regardless of whether `reportAllChanges` is used, the final reported value will be the same.\n\n#### `onTTFB()`\n\n```ts\nfunction onTTFB(\n  callback: (metric: TTFBMetric) => void,\n  opts?: ReportOpts,\n): void;\n```\n\nCalculates the [TTFB](https://web.dev/articles/ttfb) value for the current page and calls the `callback` function once the page has loaded, along with the relevant `navigation` performance entry used to determine the value. The reported value is a [`DOMHighResTimeStamp`](https://developer.mozilla.org/docs/Web/API/DOMHighResTimeStamp).\n\nNote, this function waits until after the page is loaded to call `callback` in order to ensure all properties of the `navigation` entry are populated. This is useful if you want to report on other metrics exposed by the [Navigation Timing API](https://w3c.github.io/navigation-timing/).\n\nFor example, the TTFB metric starts from the page's [time origin](https://www.w3.org/TR/hr-time-2/#sec-time-origin), which means it includes time spent on DNS lookup, connection negotiation, network latency, and server processing time.\n\n```js\nimport {onTTFB} from 'web-vitals';\n\nonTTFB((metric) => {\n  // Calculate the request time by subtracting from TTFB\n  // everything that happened prior to the request starting.\n  const requestTime = metric.value - metric.entries[0].requestStart;\n  console.log('Request time:', requestTime);\n});\n```\n\n> [!NOTE]\n> Browsers that do not support `navigation` entries will fall back to using `performance.timing` (with the timestamps converted from epoch time to [`DOMHighResTimeStamp`](https://developer.mozilla.org/docs/Web/API/DOMHighResTimeStamp)). This ensures code referencing these values (like in the example above) will work the same in all browsers.\n\n### Rating Thresholds:\n\nThe thresholds of each metric's \"good\", \"needs improvement\", and \"poor\" ratings are available as [`MetricRatingThresholds`](#metricratingthresholds).\n\nExample:\n\n```ts\nimport {CLSThresholds, INPThresholds, LCPThresholds} from 'web-vitals';\n\nconsole.log(CLSThresholds); // [ 0.1, 0.25 ]\nconsole.log(INPThresholds); // [ 200, 500 ]\nconsole.log(LCPThresholds); // [ 2500, 4000 ]\n```\n\n> [!NOTE]\n> It's typically not necessary (or recommended) to manually calculate metric value ratings using these thresholds. Use the [`Metric['rating']`](#metric) instead.\n\n### Attribution:\n\nIn the [attribution build](#attribution-build) each of the metric functions has two primary differences from their standard build counterparts:\n\n1. Their callback is invoked with a `MetricWithAttribution` objects instead of a `Metric` object. Each `MetricWithAttribution` extends the `Metric` object and adds an additional `attribution` object, which contains potentially-helpful debugging information that can be sent along with the metric values for the current page visit in order to help identify issues happening to real-users in the field.\n\n2. They accept an `AttributionReportOpts` objects instead of a `ReportOpts` object. The `AttributionReportOpts` object supports an additional, optional, `generateTarget()` function that lets developers customize how DOM elements are stringified for reporting purposes. When passed, the return value of the `generateTarget()` function will be used for any \"target\" properties in the following attribution objects: [`CLSAttribution`](#CLSAttribution), [`INPAttribution`](#INPAttribution), and [`LCPAttribution`](#LCPAttribution). If `null` or `undefined` is returned by the `generateTarget()` function, or no function is given, then the default selector function will be used.\n\n   ```ts\n   interface AttributionReportOpts extends ReportOpts {\n     generateTarget?: (el: Node | null) => string | null | undefined;\n   }\n   ```\n\n   For example, if a web page has unique `data-name` attribute on many elements, you may prefer to use those over the built-in selector-style strings that are generated by default.\n\n   ```js\n   function customGenerateTarget(el) {\n     if (el.dataset.name) {\n       return el.dataset.name;\n     }\n\n     // Otherwise use default selector function\n   }\n\n   onLCP(sendToAnalytics, {generateTarget: customGenerateTarget});\n   ```\n\nThe next sections document the shape of the `attribution` object for each of the metrics:\n\n#### `CLSAttribution`\n\n```ts\ninterface CLSAttribution {\n  /**\n   * By default, a selector identifying the first element (in document order)\n   * that shifted when the single largest layout shift that contributed to the\n   * page's CLS score occurred. If the `generateTarget` configuration option\n   * was passed, then this will instead be the return value of that function,\n   * falling back to the default if that returns null or undefined.\n   */\n  largestShiftTarget?: string;\n  /**\n   * The time when the single largest layout shift contributing to the page's\n   * CLS score occurred.\n   */\n  largestShiftTime?: DOMHighResTimeStamp;\n  /**\n   * The layout shift score of the single largest layout shift contributing to\n   * the page's CLS score.\n   */\n  largestShiftValue?: number;\n  /**\n   * The `LayoutShiftEntry` representing the single largest layout shift\n   * contributing to the page's CLS score. (Useful when you need more than just\n   * `largestShiftTarget`, `largestShiftTime`, and `largestShiftValue`).\n   */\n  largestShiftEntry?: LayoutShift;\n  /**\n   * The first element source (in document order) among the `sources` list\n   * of the `largestShiftEntry` object. (Also useful when you need more than\n   * just `largestShiftTarget`, `largestShiftTime`, and `largestShiftValue`).\n   */\n  largestShiftSource?: LayoutShiftAttribution;\n  /**\n   * The loading state of the document at the time when the largest layout\n   * shift contribution to the page's CLS score occurred (see `LoadState`\n   * for details).\n   */\n  loadState?: LoadState;\n}\n```\n\n#### `FCPAttribution`\n\n```ts\ninterface FCPAttribution {\n  /**\n   * The time from when the user initiates loading the page until when the\n   * browser receives the first byte of the response (a.k.a. TTFB).\n   */\n  timeToFirstByte: number;\n  /**\n   * The delta between TTFB and the first contentful paint (FCP).\n   */\n  firstByteToFCP: number;\n  /**\n   * The loading state of the document at the time when FCP `occurred (see\n   * `LoadState` for details). Ideally, documents can paint before they finish\n   * loading (e.g. the `loading` or `dom-interactive` phases).\n   */\n  loadState: LoadState;\n  /**\n   * The `PerformancePaintTiming` entry corresponding to FCP.\n   */\n  fcpEntry?: PerformancePaintTiming;\n  /**\n   * The `navigation` entry of the current page, which is useful for diagnosing\n   * general page load issues. This can be used to access `serverTiming` for example:\n   * navigationEntry?.serverTiming\n   */\n  navigationEntry?: PerformanceNavigationTiming;\n}\n```\n\n#### `INPAttribution`\n\n```ts\ninterface INPAttribution {\n  /**\n   * By default, a selector identifying the element that the user first\n   * interacted with as part of the frame where the INP candidate interaction\n   * occurred. If this value is an empty string, that generally means the\n   * element was removed from the DOM after the interaction. If the\n   * `generateTarget` configuration option was passed, then this will instead\n   * be the return value of that function, falling back to the default if that\n   * returns null or undefined.\n   */\n  interactionTarget: string;\n  /**\n   * The time when the user first interacted during the frame where the INP\n   * candidate interaction occurred (if more than one interaction occurred\n   * within the frame, only the first time is reported).\n   */\n  interactionTime: DOMHighResTimeStamp;\n  /**\n   * The type of interaction, based on the event type of the `event` entry\n   * that corresponds to the interaction (i.e. the first `event` entry\n   * containing an `interactionId` dispatched in a given animation frame).\n   * For \"pointerdown\", \"pointerup\", or \"click\" events this will be \"pointer\",\n   * and for \"keydown\" or \"keyup\" events this will be \"keyboard\".\n   */\n  interactionType: 'pointer' | 'keyboard';\n  /**\n   * The best-guess timestamp of the next paint after the interaction.\n   * In general, this timestamp is the same as the `startTime + duration` of\n   * the event timing entry. However, since duration values are rounded to the\n   * nearest 8ms (and can be rounded down), this value is clamped to always be\n   * reported after the processing times.\n   */\n  nextPaintTime: DOMHighResTimeStamp;\n  /**\n   * An array of Event Timing entries that were processed within the same\n   * animation frame as the INP candidate interaction.\n   * Note this is capped to a max of 5 entries (the first 4 + the last one).\n   */\n  processedEventEntries: PerformanceEventTiming[];\n  /**\n   * The time from when the user interacted with the page until when the\n   * browser was first able to start processing event listeners for that\n   * interaction. This time captures the delay before event processing can\n   * begin due to the main thread being busy with other work.\n   */\n  inputDelay: number;\n  /**\n   * The time from when the first event listener started running in response to\n   * the user interaction until when all event listener processing has finished.\n   */\n  processingDuration: number;\n  /**\n   * The time from when the browser finished processing all event listeners for\n   * the user interaction until the next frame is presented on the screen and\n   * visible to the user. This time includes work on the main thread (such as\n   * `requestAnimationFrame()` callbacks, `ResizeObserver` and\n   * `IntersectionObserver` callbacks, and style/layout calculation) as well\n   * as off-main-thread work (such as compositor, GPU, and raster work).\n   */\n  presentationDelay: number;\n  /**\n   * The loading state of the document at the time when the interaction\n   * corresponding to INP occurred (see `LoadState` for details). If the\n   * interaction occurred while the document was loading and executing script\n   * (e.g. usually in the `dom-interactive` phase) it can result in long delays.\n   */\n  loadState: LoadState;\n  /**\n   * If the browser supports the Long Animation Frame API, this array will\n   * include any `long-animation-frame` entries that intersect with the INP\n   * candidate interaction's `startTime` and the `processingEnd` time of the\n   * last event processed within that animation frame. If the browser does not\n   * support the Long Animation Frame API or no `long-animation-frame` entries\n   * are detected, this array will be empty.\n   */\n  longAnimationFrameEntries: PerformanceLongAnimationFrameTiming[];\n  /**\n   * Summary information about the longest script entry intersecting the INP\n   * duration. Note, only script entries above 5 milliseconds are reported by\n   * the Long Animation Frame API.\n   */\n  longestScript?: INPLongestScriptSummary;\n  /**\n   * The total duration of Long Animation Frame scripts that intersect the INP\n   * duration excluding any forced style and layout (that is included in\n   * totalStyleAndLayout). Note, this is limited to scripts > 5 milliseconds.\n   */\n  totalScriptDuration?: number;\n  /**\n   * The total style and layout duration from any Long Animation Frames\n   * intersecting the INP interaction. This includes any end-of-frame style and\n   * layout duration + any forced style and layout duration.\n   */\n  totalStyleAndLayoutDuration?: number;\n  /**\n   * The off main-thread presentation delay from the end of the last Long\n   * Animation Frame (where available) until the INP end point.\n   */\n  totalPaintDuration?: number;\n  /**\n   * The total unattributed time not included in any of the previous totals.\n   * This includes scripts < 5 milliseconds and other timings not attributed\n   * by Long Animation Frame (including when a frame is < 50ms and so has no\n   * Long Animation Frame).\n   * When no Long Animation Frames are present this will be undefined, rather\n   * than everything being unattributed to make it clearer when it's expected\n   * to be small.\n   */\n  totalUnattributedDuration?: number;\n}\n```\n\n#### `INPLongestScriptSummary`\n\n```ts\ninterface INPLongestScriptSummary {\n  /**\n   * The longest Long Animation Frame script entry that intersects the INP\n   * interaction.\n   */\n  entry: PerformanceScriptTiming;\n  /**\n   * The INP subpart where the longest script ran.\n   */\n  subpart: 'input-delay' | 'processing-duration' | 'presentation-delay';\n  /**\n   * The amount of time the longest script intersected the INP duration.\n   */\n  intersectingDuration: number;\n}\n```\n\n#### `LCPAttribution`\n\n```ts\ninterface LCPAttribution {\n  /**\n   * By default, a selector identifying the element corresponding to the\n   * largest contentful paint for the page. If the `generateTarget`\n   * configuration option was passed, then this will instead be the return\n   * value of that function, falling back to the default if that returns null\n   * or undefined.\n   */\n  target?: string;\n  /**\n   * The URL (if applicable) of the LCP image resource. If the LCP element\n   * is a text node, this value will not be set.\n   */\n  url?: string;\n  /**\n   * The time from when the user initiates loading the page until when the\n   * browser receives the first byte of the response (a.k.a. TTFB). See\n   * [Optimize LCP](https://web.dev/articles/optimize-lcp) for details.\n   */\n  timeToFirstByte: number;\n  /**\n   * The delta between TTFB and when the browser starts loading the LCP\n   * resource (if there is one, otherwise 0). See [Optimize\n   * LCP](https://web.dev/articles/optimize-lcp) for details.\n   */\n  resourceLoadDelay: number;\n  /**\n   * The total time it takes to load the LCP resource itself (if there is one,\n   * otherwise 0). See [Optimize LCP](https://web.dev/articles/optimize-lcp) for\n   * details.\n   */\n  resourceLoadDuration: number;\n  /**\n   * The delta between when the LCP resource finishes loading until the LCP\n   * element is fully rendered. See [Optimize\n   * LCP](https://web.dev/articles/optimize-lcp) for details.\n   */\n  elementRenderDelay: number;\n  /**\n   * The `navigation` entry of the current page, which is useful for diagnosing\n   * general page load issues. This can be used to access `serverTiming` for example:\n   * navigationEntry?.serverTiming\n   */\n  navigationEntry?: PerformanceNavigationTiming;\n  /**\n   * The `resource` entry for the LCP resource (if applicable), which is useful\n   * for diagnosing resource load issues.\n   */\n  lcpResourceEntry?: PerformanceResourceTiming;\n  /**\n   * The `LargestContentfulPaint` entry corresponding to LCP.\n   */\n  lcpEntry?: LargestContentfulPaint;\n}\n```\n\n#### `TTFBAttribution`\n\n```ts\ninterface TTFBAttribution {\n  /**\n   * The total time from when the user initiates loading the page to when the\n   * page starts to handle the request. Large values here are typically due\n   * to HTTP redirects, though other browser processing contributes to this\n   * duration as well (so even without redirect it's generally not zero).\n   */\n  waitingDuration: number;\n  /**\n   * The total time spent checking the HTTP cache for a match. For navigations\n   * handled via service worker, this duration usually includes service worker\n   * start-up time as well as time processing `fetch` event listeners, with\n   * some exceptions, see: https://github.com/w3c/navigation-timing/issues/199\n   */\n  cacheDuration: number;\n  /**\n   * The total time to resolve the DNS for the requested domain.\n   */\n  dnsDuration: number;\n  /**\n   * The total time to create the connection to the requested domain.\n   */\n  connectionDuration: number;\n  /**\n   * The total time from when the request was sent until the first byte of the\n   * response was received. This includes network time as well as server\n   * processing time.\n   */\n  requestDuration: number;\n  /**\n   * The `navigation` entry of the current page, which is useful for diagnosing\n   * general page load issues. This can be used to access `serverTiming` for\n   * example: navigationEntry?.serverTiming\n   */\n  navigationEntry?: PerformanceNavigationTiming;\n}\n```\n\n## Browser Support\n\nThe `web-vitals` code is tested in Chrome, Firefox, and Safari. In addition, all JavaScript features used in the code are part of ([Baseline Widely Available](https://web.dev/baseline)), and thus should run without error in all versions of these browsers released within the last 30 months.\n\nHowever, some of the APIs required to capture these metrics (notable CLS) are currently only available in some browsers. The latest browser support for each function is as follows:\n\n- `onCLS()`: Chromium\n- `onFCP()`: Chromium, Firefox, Safari\n- `onINP()`: Chromium, Firefox, Safari\n- `onLCP()`: Chromium, Firefox, Safari\n- `onTTFB()`: Chromium, Firefox, Safari\n\n## Limitations\n\nThe `web-vitals` library is primarily a wrapper around the Web APIs that measure the Web Vitals metrics, which means the limitations of those APIs will mostly apply to this library as well. More details on these limitations is available in [this blog post](https://web.dev/articles/crux-and-rum-differences).\n\nThe primary limitation of these APIs is they have no visibility into `<iframe>` content (not even same-origin iframes), which means pages that make use of iframes will likely see a difference between the data measured by this library and the data available in the Chrome User Experience Report (which does include iframe content).\n\nFor same-origin iframes, it's possible to use the `web-vitals` library to measure metrics, but it's tricky because it requires the developer to add the library to every frame and `postMessage()` the results to the parent frame for aggregation.\n\n> [!NOTE]\n> Given the lack of iframe support, the `onCLS()` function technically measures [DCLS](https://github.com/wicg/layout-instability#cumulative-scores) (Document Cumulative Layout Shift) rather than CLS, if the page includes iframes).\n\n## Development\n\n### Building the code\n\nThe `web-vitals` source code is written in TypeScript. To transpile the code and build the production bundles, run the following command.\n\n```sh\nnpm run build\n```\n\nTo build the code and watch for changes, run:\n\n```sh\nnpm run watch\n```\n\n### Running the tests\n\nThe `web-vitals` code is tested in real browsers using [webdriver.io](https://webdriver.io/). Use the following command to run the tests:\n\n```sh\nnpm test\n```\n\nTo test any of the APIs manually, you can start the test server\n\n```sh\nnpm run test:server\n```\n\nThen navigate to `http://localhost:9090/test/<view>`, where `<view>` is the basename of one the templates under [/test/views/](/test/views/).\n\nYou'll likely want to combine this with `npm run watch` to ensure any changes you make are transpiled and rebuilt.\n\n## Integrations\n\n- [**Web Vitals Connector**](https://goo.gle/web-vitals-connector): Data Studio connector to create dashboards from [Web Vitals data captured in BigQuery](https://web.dev/articles/vitals-ga4).\n- [**Core Web Vitals Custom Tag template**](https://www.simoahava.com/custom-templates/core-web-vitals/): Custom GTM template tag to [add measurement handlers](https://www.simoahava.com/analytics/track-core-web-vitals-in-ga4-with-google-tag-manager/) for all Core Web Vitals metrics.\n- [**`web-vitals-reporter`**](https://github.com/treosh/web-vitals-reporter): JavaScript library to batch `callback` functions and send data with a single request.\n\n## License\n\n[Apache 2.0](/LICENSE)\n"
  },
  {
    "path": "attribution.d.ts",
    "content": "/*\n Copyright 2022 Google LLC\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     https://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n*/\n\nexport * from './dist/modules/attribution/index.js';\n"
  },
  {
    "path": "attribution.js",
    "content": "/*\n Copyright 2022 Google LLC\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     https://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n*/\n\n// Creates the `web-vitals/attribution` import in node-based bundlers.\n// This will not be needed when export maps are widely supported.\nexport * from './dist/web-vitals.attribution.js';\n"
  },
  {
    "path": "docs/upgrading-to-v4.md",
    "content": "# Upgrading to v4\n\nThis document lists the full set of changes between version 3 and version 4 that are relevant to anyone wanting to upgrade to the new version. This document groups changes into \"breaking changes\", \"new features\", and \"deprecations\" across both the \"standard\" and \"attribution\" builds (see [build options](/#build-options) for details).\n\n## ❌ Breaking changes\n\n### Standard build\n\n#### General\n\n- **Removed** the \"base+polyfill\" build, which includes the FID polyfill and the Navigation Timing polyfill supporting legacy Safari browsers ([#435](https://github.com/GoogleChrome/web-vitals/pull/435)).\n- **Removed** all `getXXX()` functions that were deprecated in v3 ([#435](https://github.com/GoogleChrome/web-vitals/pull/435)).\n\n#### `INPMetric`\n\n- **Changed** `entries` to only include entries with matching `interactionId` that were processed within the same animation frame. Previously it included all entries with matching `interactionId` values, which could include entries not impacting INP ([#442](https://github.com/GoogleChrome/web-vitals/pull/442)).\n\n### Attribution build\n\n#### `INPAttribution`\n\n- **Renamed** `eventTarget` to `interactionTarget` ([#442](https://github.com/GoogleChrome/web-vitals/pull/442)).\n- **Renamed** `eventTime` to `interactionTime` ([#442](https://github.com/GoogleChrome/web-vitals/pull/442)).\n- **Renamed** `eventType` to `interactionType`. Also this property will now always be either \"pointer\" or \"keyboard\" ([#442](https://github.com/GoogleChrome/web-vitals/pull/442)).\n- **Removed** `eventEntry` in favor of the new `processedEventEntries` array (see below), which includes all `event` entries processed within the same animation frame as the INP candidate interaction ([#442](https://github.com/GoogleChrome/web-vitals/pull/442)).\n\n#### `LCPAttribution`\n\n- **Renamed** `resourceLoadTime` to `resourceLoadDuration` ([#450](https://github.com/GoogleChrome/web-vitals/pull/450)).\n\n#### `TTFBAttribution`\n\n- **Renamed** `waitingTime` to `waitingDuration`, and also split out the portion of this duration spent checking the HTTP cache, see `cacheDuration` in the [new features](#-new-features) section below ([#453](https://github.com/GoogleChrome/web-vitals/pull/453), [#458](https://github.com/GoogleChrome/web-vitals/pull/458)).\n- **Renamed** `dnsTime` to `dnsDuration` ([#453](https://github.com/GoogleChrome/web-vitals/pull/453)).\n- **Renamed** `connectionTime` to `connectionDuration` ([#453](https://github.com/GoogleChrome/web-vitals/pull/453)).\n- **Renamed** `requestTime` to `requestDuration` ([#453](https://github.com/GoogleChrome/web-vitals/pull/453)).\n\n## 🚀 New features\n\n### Standard build\n\nNo new features were introduced into the \"standard\" build, outside of the breaking changes mentioned above.\n\n### Attribution build\n\n#### `INPAttribution`\n\n- **Added** `nextPaintTime`, which marks the timestamp of the next paint after the interaction ([#442](https://github.com/GoogleChrome/web-vitals/pull/442)).\n- **Added** `inputDelay`, which measures the time from when the user interacted with the page until when the browser was first able to start processing event listeners for that interaction. ([#442](https://github.com/GoogleChrome/web-vitals/pull/442)).\n- **Added** `processingDuration`, which measures the time from when the first event listener started running in response to the user interaction until when all event listener processing has finished ([#442](https://github.com/GoogleChrome/web-vitals/pull/442)).\n- **Added** `presentationDelay`, which measures the time from when the browser finished processing all event listeners for the user interaction until the next frame is presented on the screen and visible to the user. ([#442](https://github.com/GoogleChrome/web-vitals/pull/442)).\n- **Added** `processedEventEntries`, an array of `event` entries that were processed within the same animation frame as the INP candidate interaction ([#442](https://github.com/GoogleChrome/web-vitals/pull/442)).\n- **Added** `longAnimationFrameEntries`, which includes any `long-animation-frame` entries that overlap with the INP candidate interaction ([#442](https://github.com/GoogleChrome/web-vitals/pull/442)).\n- **Added** `interactionTargetElement` ([#479](https://github.com/GoogleChrome/web-vitals/pull/479)).\n\n#### `TTFBAttribution`\n\n- **Added** `cacheDuration`, which marks the total time spent checking the HTTP cache for a match ([#458](https://github.com/GoogleChrome/web-vitals/pull/458)).\n\n## ⚠️ Deprecations\n\n### Standard and attribution builds\n\n- The `onFID()` function [has been deprecated](https://web.dev/blog/inp-cwv-launch#fid_deprecation_timeline). Developers should use `onINP()` instead ([#435](https://github.com/GoogleChrome/web-vitals/pull/435)).\n- The `ReportCallback` type has been deprecated in favor of explicit callback types for each metric function ([#483](https://github.com/GoogleChrome/web-vitals/pull/483)).\n\n_All deprecated APIs will be removed in the next major version._\n"
  },
  {
    "path": "docs/upgrading-to-v5.md",
    "content": "# Upgrading to v5\n\nThis document lists the full set of changes between version 4 and version 5 that are relevant to anyone wanting to upgrade to the new version. This document groups changes into \"breaking changes\", \"new features\", and \"deprecations\" across both the \"standard\" and \"attribution\" builds (see [build options](/#build-options) for details).\n\n## ❌ Breaking changes\n\n### Standard build\n\n#### General\n\n- **Removed** First Input Delay (FID) support and `onFID()` [as deprecated in v4](./upgrading-to-v4.md#%EF%B8%8F-deprecations). FID has been replaced by INP and this [removal was previously advertised in that announcement post](https://web.dev/blog/inp-cwv-launch#fid_deprecation_timeline). ([#519](https://github.com/GoogleChrome/web-vitals/pull/519))\n- **Changed** the browser support policy to [Baseline Widely available](https://web.dev/baseline) browser support. ([#525](https://github.com/GoogleChrome/web-vitals/pull/525))\n\n##### More details on the Baseline Widely available change:\n\nAll of the [builds](README#build-options) in `web-vitals` v5 use only [Baseline Widely available](https://web.dev/baseline) APIs, which means they should run without error in all browsers released in the last few years. Note that the Core Web Vitals metrics are only available in modern browsers, so legacy browser support is unnecessary for this library.\n\nIf your site needs to support legacy browsers, you can still use the `web-vitals` library without causing errors in those browsers by adhering to the following recommendations:\n\nWe recommend loading the `web-vitals` library in a separate script file from your site's main application bundle(s), either via `<script type=\"module\">` and `import` statements or via your bundler's code splitting feature (for example, [rollup](https://rollupjs.org/tutorial/#code-splitting), [esbuild](https://esbuild.github.io/api/#splitting), and [webpack](https://webpack.js.org/guides/code-splitting/)). This ensures that any errors encountered while loading the library do not impact other code on your site.\n\nIf you do choose to include the `web-vitals` library code in your main application bundle—and you also need to support very old browsers—it's critical that you configure your bundler to transpile the `web-vitals` code along with the rest of you application JavaScript. This is important because most bundlers do not transpile `node_modules` by default.\n\n### Attribution build\n\n#### `INPAttribution`, `LCPAttribution`, and `CLSAttribution`\n\n- **Changed** to sort the classes that appear in attribution selectors to reduce cardinality. While not a breaking change in the API, this may result in a short term difference in reports based on the selector during the change over from v4 to v5, but longer term should result in few selectors that are more easily grouped in reporting. ([#518](https://github.com/GoogleChrome/web-vitals/pull/518))\n- **Changed** `LCPAttribution.element` to `LCPAttribution.target` as part of ([#585](https://github.com/GoogleChrome/web-vitals/pull/585)) for consistency.\n- **Removed** `INPAttribution.interactionTargetElement` by default. If needed this can be generated as part of support for generating custom targets in the attribution build ([#585](https://github.com/GoogleChrome/web-vitals/pull/585))\n\n## 🚀 New features\n\n- **Added** support for generating custom targets in the attribution build ([#585](https://github.com/GoogleChrome/web-vitals/pull/585))\n- **Added** extended INP attribution with extra LoAF information: longest script and buckets ([#592](https://github.com/GoogleChrome/web-vitals/pull/592))\n\n## ⚠️ Deprecations\n\nThere were no deprecations in v5.\n"
  },
  {
    "path": "eslint.config.js",
    "content": "import {defineConfig} from 'eslint/config';\nimport globals from 'globals';\nimport tsParser from '@typescript-eslint/parser';\nimport path from 'node:path';\nimport {fileURLToPath} from 'node:url';\nimport js from '@eslint/js';\nimport {FlatCompat} from '@eslint/eslintrc';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\nconst compat = new FlatCompat({\n  baseDirectory: __dirname,\n  recommendedConfig: js.configs.recommended,\n  allConfig: js.configs.all,\n});\n\nexport default defineConfig([\n  {\n    ignores: [\n      '**/.DS_Store',\n      '**/.vscode/',\n      '**/node_modules/',\n      '**/*.log',\n      '**/tsconfig.tsbuildinfo',\n      '**/dist/',\n    ],\n  },\n  {\n    languageOptions: {\n      globals: {\n        ...globals.browser,\n        ...globals.node,\n        ...globals.mocha,\n      },\n\n      ecmaVersion: 'latest',\n      sourceType: 'module',\n    },\n  },\n  {\n    files: ['**/wdio.conf.js'],\n    extends: compat.extends('eslint:recommended'),\n\n    rules: {\n      'max-len': 'off',\n    },\n  },\n  {\n    files: ['test/e2e/*.js'],\n    extends: compat.extends('eslint:recommended'),\n\n    languageOptions: {\n      globals: {\n        $: false,\n        browser: false,\n        __toSafeObject: false,\n      },\n    },\n\n    rules: {\n      'comma-dangle': ['error', 'always-multiline'],\n      indent: ['error', 2],\n      'no-invalid-this': 'off',\n\n      'max-len': [\n        2,\n        {\n          ignorePattern: '^\\\\s*import|= require\\\\(|^\\\\s*it\\\\(|^\\\\s*describe\\\\(',\n          ignoreUrls: true,\n        },\n      ],\n    },\n  },\n  {\n    files: ['src/**/*.ts'],\n    extends: compat.extends('plugin:@typescript-eslint/recommended'),\n\n    languageOptions: {\n      parser: tsParser,\n      ecmaVersion: 2018,\n      sourceType: 'module',\n    },\n\n    rules: {\n      '@typescript-eslint/no-non-null-assertion': 'off',\n      '@typescript-eslint/no-use-before-define': 'off',\n      '@typescript-eslint/explicit-function-return-type': 'off',\n      '@typescript-eslint/explicit-module-boundary-types': 'off',\n      '@typescript-eslint/ban-ts-comment': 'off',\n      'comma-dangle': ['error', 'always-multiline'],\n      indent: ['error', 2],\n      'no-dupe-class-members': 'off',\n      'prefer-spread': 'off',\n    },\n  },\n]);\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"web-vitals\",\n  \"version\": \"5.1.0\",\n  \"description\": \"Easily measure performance metrics in JavaScript\",\n  \"type\": \"module\",\n  \"typings\": \"dist/modules/index.d.ts\",\n  \"main\": \"dist/web-vitals.umd.cjs\",\n  \"module\": \"dist/web-vitals.js\",\n  \"unpkg\": \"dist/web-vitals.iife.js\",\n  \"exports\": {\n    \".\": {\n      \"types\": \"./dist/modules/index.d.ts\",\n      \"require\": \"./dist/web-vitals.umd.cjs\",\n      \"default\": \"./dist/web-vitals.js\"\n    },\n    \"./attribution\": {\n      \"types\": \"./dist/modules/attribution/index.d.ts\",\n      \"require\": \"./dist/web-vitals.attribution.umd.cjs\",\n      \"default\": \"./dist/web-vitals.attribution.js\"\n    },\n    \"./attribution.js\": {\n      \"types\": \"./dist/modules/attribution/index.d.ts\",\n      \"require\": \"./dist/web-vitals.attribution.umd.cjs\",\n      \"default\": \"./dist/web-vitals.attribution.js\"\n    },\n    \"./onCLS.js\": {\n      \"types\": \"./dist/modules/onCLS.d.ts\",\n      \"default\": \"./dist/modules/onCLS.js\"\n    },\n    \"./onFCP.js\": {\n      \"types\": \"./dist/modules/onFCP.d.ts\",\n      \"default\": \"./dist/modules/onFCP.js\"\n    },\n    \"./onINP.js\": {\n      \"types\": \"./dist/modules/onINP.d.ts\",\n      \"default\": \"./dist/modules/onINP.js\"\n    },\n    \"./onLCP.js\": {\n      \"types\": \"./dist/modules/onLCP.d.ts\",\n      \"default\": \"./dist/modules/onLCP.js\"\n    },\n    \"./onTTFB.js\": {\n      \"types\": \"./dist/modules/onTTFB.d.ts\",\n      \"default\": \"./dist/modules/onTTFB.js\"\n    },\n    \"./attribution/onCLS.js\": {\n      \"types\": \"./dist/modules/attribution/onCLS.d.ts\",\n      \"default\": \"./dist/modules/attribution/onCLS.js\"\n    },\n    \"./attribution/onFCP.js\": {\n      \"types\": \"./dist/modules/attribution/onFCP.d.ts\",\n      \"default\": \"./dist/modules/attribution/onFCP.js\"\n    },\n    \"./attribution/onINP.js\": {\n      \"types\": \"./dist/modules/attribution/onINP.d.ts\",\n      \"default\": \"./dist/modules/attribution/onINP.js\"\n    },\n    \"./attribution/onLCP.js\": {\n      \"types\": \"./dist/modules/attribution/onLCP.d.ts\",\n      \"default\": \"./dist/modules/attribution/onLCP.js\"\n    },\n    \"./attribution/onTTFB.js\": {\n      \"types\": \"./dist/modules/attribution/onTTFB.d.ts\",\n      \"default\": \"./dist/modules/attribution/onTTFB.js\"\n    }\n  },\n  \"files\": [\n    \"attribution.js\",\n    \"attribution.d.ts\",\n    \"dist\",\n    \"src\"\n  ],\n  \"scripts\": {\n    \"build\": \"run-s clean build:ts build:js\",\n    \"build:ts\": \"tsc -b\",\n    \"build:js\": \"rollup -c\",\n    \"clean\": \"rm -rf dist tsconfig.tsbuildinfo\",\n    \"dev\": \"run-p watch test:server\",\n    \"format\": \"prettier \\\"**/*.{cjs,css,html,js,json,md,ts,yml,yaml}\\\" --write --ignore-path .gitignore\",\n    \"format:check\": \"prettier \\\"**/*.{cjs,css,html,js,json,html,md,ts,yml,yaml}\\\" --check --ignore-path .gitignore\",\n    \"lint\": \"eslint \\\"*.js\\\" \\\"src/**/*.ts\\\" \\\"test/**/*.js\\\"\",\n    \"lint:fix\": \"eslint --fix \\\"*.js\\\" \\\"src/**/*.ts\\\" \\\"test/**/*.js\\\"\",\n    \"postversion\": \"git push --follow-tags\",\n    \"release:major\": \"npm version major -m 'Release v%s' && npm publish\",\n    \"release:minor\": \"npm version minor -m 'Release v%s' && npm publish\",\n    \"release:patch\": \"npm version patch -m 'Release v%s' && npm publish\",\n    \"release:alpha\": \"npm version prerelease --preid=alpha -m 'Release v%s' && npm publish --tag next\",\n    \"release:beta\": \"npm version prerelease --preid=beta -m 'Release v%s' && npm publish --tag next\",\n    \"release:rc\": \"npm version prerelease --preid=rc -m 'Release v%s' && npm publish --tag next\",\n    \"test\": \"npm-run-all build test:unit -p -r test:e2e test:server\",\n    \"test:e2e\": \"wdio\",\n    \"test:server\": \"node test/server.js\",\n    \"test:unit\": \"node --test test/unit/*test.js\",\n    \"start\": \"run-s build:ts test:server watch\",\n    \"watch\": \"run-p watch:*\",\n    \"watch:ts\": \"tsc -b -w\",\n    \"watch:js\": \"rollup -c -w\",\n    \"version\": \"run-s build\",\n    \"prepare\": \"husky\"\n  },\n  \"keywords\": [\n    \"crux\",\n    \"performance\",\n    \"metrics\",\n    \"Core Web Vitals\",\n    \"CLS\",\n    \"FCP\",\n    \"INP\",\n    \"LCP\",\n    \"TTFB\"\n  ],\n  \"author\": {\n    \"name\": \"Philip Walton\",\n    \"email\": \"philip@philipwalton.com\",\n    \"url\": \"http://philipwalton.com\"\n  },\n  \"license\": \"Apache-2.0\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/GoogleChrome/web-vitals.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/GoogleChrome/web-vitals/issues\"\n  },\n  \"husky\": {\n    \"hooks\": {\n      \"pre-commit\": \"npm run lint\"\n    }\n  },\n  \"prettier\": {\n    \"arrowParens\": \"always\",\n    \"bracketSpacing\": false,\n    \"quoteProps\": \"preserve\",\n    \"singleQuote\": true\n  },\n  \"devDependencies\": {\n    \"@babel/core\": \"^7.28.5\",\n    \"@babel/preset-env\": \"^7.28.5\",\n    \"@rollup/plugin-babel\": \"^6.1.0\",\n    \"@rollup/plugin-terser\": \"^0.4.4\",\n    \"@typescript-eslint/eslint-plugin\": \"^8.52.0\",\n    \"@typescript-eslint/parser\": \"^8.52.0\",\n    \"@wdio/cli\": \"^9.23.0\",\n    \"@wdio/local-runner\": \"^9.25.0\",\n    \"@wdio/mocha-framework\": \"^9.25.0\",\n    \"@wdio/spec-reporter\": \"^9.25.0\",\n    \"eslint\": \"^9.39.2\",\n    \"fs-extra\": \"^11.3.3\",\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.7\",\n    \"npm-run-all\": \"^4.1.5\",\n    \"nunjucks\": \"^3.2.4\",\n    \"prettier\": \"^3.7.4\",\n    \"rollup\": \"^4.55.1\",\n    \"typescript\": \"^5.9.3\",\n    \"yargs\": \"^18.0.0\"\n  },\n  \"lint-staged\": {\n    \"**/*.{js,ts}\": \"eslint --fix\",\n    \"**/*.{cjs,css,html,js,json,html,md,ts,yml,yaml}\": \"prettier --write --ignore-path .gitignore\"\n  }\n}\n"
  },
  {
    "path": "rollup.config.js",
    "content": "/*\n Copyright 2020 Google LLC\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     https://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n*/\n\nimport babel from '@rollup/plugin-babel';\nimport terser from '@rollup/plugin-terser';\n\nconst configurePlugins = ({module}) => {\n  return [\n    babel({\n      babelHelpers: 'bundled',\n      presets: [\n        [\n          '@babel/preset-env',\n          {\n            bugfixes: true,\n            targets: 'baseline widely available',\n          },\n        ],\n      ],\n    }),\n    terser({\n      module,\n      compress: true,\n      mangle: {\n        properties: {\n          // Any object properties beginning with the '_' character will be\n          // mangled. Use this prefix for any object properties that are not\n          // part of the public API and do that not match an existing build-in\n          // API names (e.g. `.id` or `.entries`).\n          regex: /^_/,\n        },\n      },\n    }),\n  ];\n};\n\nconst configs = [\n  {\n    input: 'dist/modules/index.js',\n    output: {\n      format: 'esm',\n      file: './dist/web-vitals.js',\n    },\n    plugins: configurePlugins({module: true}),\n  },\n  {\n    input: 'dist/modules/index.js',\n    output: {\n      format: 'umd',\n      file: `./dist/web-vitals.umd.cjs`,\n      name: 'webVitals',\n    },\n    plugins: configurePlugins({module: false}),\n  },\n  {\n    input: 'dist/modules/index.js',\n    output: {\n      format: 'iife',\n      file: './dist/web-vitals.iife.js',\n      name: 'webVitals',\n    },\n    plugins: configurePlugins({module: false}),\n  },\n  {\n    input: 'dist/modules/attribution/index.js',\n    output: {\n      format: 'esm',\n      file: './dist/web-vitals.attribution.js',\n    },\n    plugins: configurePlugins({module: true}),\n  },\n  {\n    input: 'dist/modules/attribution/index.js',\n    output: {\n      format: 'umd',\n      file: `./dist/web-vitals.attribution.umd.cjs`,\n      name: 'webVitals',\n    },\n    plugins: configurePlugins({module: false}),\n  },\n  {\n    input: 'dist/modules/attribution/index.js',\n    output: {\n      format: 'iife',\n      file: './dist/web-vitals.attribution.iife.js',\n      name: 'webVitals',\n    },\n    plugins: configurePlugins({module: false}),\n  },\n];\n\nexport default configs;\n"
  },
  {
    "path": "src/attribution/index.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nexport {onCLS} from './onCLS.js';\nexport {onFCP} from './onFCP.js';\nexport {onINP} from './onINP.js';\nexport {onLCP} from './onLCP.js';\nexport {onTTFB} from './onTTFB.js';\n\nexport {CLSThresholds} from '../onCLS.js';\nexport {FCPThresholds} from '../onFCP.js';\nexport {INPThresholds} from '../onINP.js';\nexport {LCPThresholds} from '../onLCP.js';\nexport {TTFBThresholds} from '../onTTFB.js';\n\nexport * from '../types.js';\n"
  },
  {
    "path": "src/attribution/onCLS.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {LayoutShiftManager} from '../lib/LayoutShiftManager.js';\nimport {getLoadState} from '../lib/getLoadState.js';\nimport {getSelector} from '../lib/getSelector.js';\nimport {initUnique} from '../lib/initUnique.js';\nimport {onCLS as unattributedOnCLS} from '../onCLS.js';\nimport {\n  CLSAttribution,\n  CLSMetric,\n  CLSMetricWithAttribution,\n  AttributionReportOpts,\n} from '../types.js';\n\nconst getLargestLayoutShiftEntry = (entries: LayoutShift[]) => {\n  return entries.reduce((a, b) => (a.value > b.value ? a : b));\n};\n\nconst getLargestLayoutShiftSource = (sources: LayoutShiftAttribution[]) => {\n  return sources.find((s) => s.node?.nodeType === 1) || sources[0];\n};\n\n/**\n * Calculates the [CLS](https://web.dev/articles/cls) value for the current page and\n * calls the `callback` function once the value is ready to be reported, along\n * with all `layout-shift` performance entries that were used in the metric\n * value calculation. The reported value is a `double` (corresponding to a\n * [layout shift score](https://web.dev/articles/cls#layout_shift_score)).\n *\n * If the `reportAllChanges` configuration option is set to `true`, the\n * `callback` function will be called as soon as the value is initially\n * determined as well as any time the value changes throughout the page\n * lifespan.\n *\n * _**Important:** CLS should be continually monitored for changes throughout\n * the entire lifespan of a page—including if the user returns to the page after\n * it's been hidden/backgrounded. However, since browsers often [will not fire\n * additional callbacks once the user has backgrounded a\n * page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden),\n * `callback` is always called when the page's visibility state changes to\n * hidden. As a result, the `callback` function might be called multiple times\n * during the same page load._\n */\nexport const onCLS = (\n  onReport: (metric: CLSMetricWithAttribution) => void,\n  opts: AttributionReportOpts = {},\n) => {\n  // Clone the opts object to ensure it's unique, so we can initialize a\n  // single instance of the `LayoutShiftManager` class that's shared only with\n  // this function invocation and the `unattributedOnCLS()` invocation below\n  // (which is passed the same `opts` object).\n  opts = Object.assign({}, opts);\n\n  const layoutShiftManager = initUnique(opts, LayoutShiftManager);\n  const layoutShiftTargetMap: WeakMap<LayoutShiftAttribution, string> =\n    new WeakMap();\n\n  layoutShiftManager._onAfterProcessingUnexpectedShift = (\n    entry: LayoutShift,\n  ) => {\n    if (entry?.sources?.length) {\n      const largestSource = getLargestLayoutShiftSource(entry.sources);\n      const node = largestSource?.node;\n      if (node) {\n        const customTarget = opts.generateTarget?.(node) ?? getSelector(node);\n        layoutShiftTargetMap.set(largestSource, customTarget);\n      }\n    }\n  };\n\n  const attributeCLS = (metric: CLSMetric): CLSMetricWithAttribution => {\n    // Use an empty object if no other attribution has been set.\n    let attribution: CLSAttribution = {};\n\n    if (metric.entries.length) {\n      const largestEntry = getLargestLayoutShiftEntry(metric.entries);\n      if (largestEntry?.sources?.length) {\n        const largestSource = getLargestLayoutShiftSource(largestEntry.sources);\n        if (largestSource) {\n          attribution = {\n            largestShiftTarget: layoutShiftTargetMap.get(largestSource),\n            largestShiftTime: largestEntry.startTime,\n            largestShiftValue: largestEntry.value,\n            largestShiftSource: largestSource,\n            largestShiftEntry: largestEntry,\n            loadState: getLoadState(largestEntry.startTime),\n          };\n        }\n      }\n    }\n\n    // Use `Object.assign()` to ensure the original metric object is returned.\n    const metricWithAttribution: CLSMetricWithAttribution = Object.assign(\n      metric,\n      {attribution},\n    );\n    return metricWithAttribution;\n  };\n\n  unattributedOnCLS((metric: CLSMetric) => {\n    const metricWithAttribution = attributeCLS(metric);\n    onReport(metricWithAttribution);\n  }, opts);\n};\n"
  },
  {
    "path": "src/attribution/onFCP.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {getBFCacheRestoreTime} from '../lib/bfcache.js';\nimport {getLoadState} from '../lib/getLoadState.js';\nimport {getNavigationEntry} from '../lib/getNavigationEntry.js';\nimport {onFCP as unattributedOnFCP} from '../onFCP.js';\nimport {\n  FCPAttribution,\n  FCPMetric,\n  FCPMetricWithAttribution,\n  AttributionReportOpts,\n} from '../types.js';\n\nconst attributeFCP = (metric: FCPMetric): FCPMetricWithAttribution => {\n  // Use a default object if no other attribution has been set.\n  let attribution: FCPAttribution = {\n    timeToFirstByte: 0,\n    firstByteToFCP: metric.value,\n    loadState: getLoadState(getBFCacheRestoreTime()),\n  };\n\n  if (metric.entries.length) {\n    const navigationEntry = getNavigationEntry();\n    const fcpEntry = metric.entries.at(-1);\n\n    if (navigationEntry) {\n      const activationStart = navigationEntry.activationStart || 0;\n      const ttfb = Math.max(0, navigationEntry.responseStart - activationStart);\n\n      attribution = {\n        timeToFirstByte: ttfb,\n        firstByteToFCP: metric.value - ttfb,\n        loadState: getLoadState(metric.entries[0].startTime),\n        navigationEntry,\n        fcpEntry,\n      };\n    }\n  }\n\n  // Use `Object.assign()` to ensure the original metric object is returned.\n  const metricWithAttribution: FCPMetricWithAttribution = Object.assign(\n    metric,\n    {attribution},\n  );\n  return metricWithAttribution;\n};\n\n/**\n * Calculates the [FCP](https://web.dev/articles/fcp) value for the current page and\n * calls the `callback` function once the value is ready, along with the\n * relevant `paint` performance entry used to determine the value. The reported\n * value is a `DOMHighResTimeStamp`.\n */\nexport const onFCP = (\n  onReport: (metric: FCPMetricWithAttribution) => void,\n  opts: AttributionReportOpts = {},\n) => {\n  unattributedOnFCP((metric: FCPMetric) => {\n    const metricWithAttribution = attributeFCP(metric);\n    onReport(metricWithAttribution);\n  }, opts);\n};\n"
  },
  {
    "path": "src/attribution/onINP.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {getLoadState} from '../lib/getLoadState.js';\nimport {getSelector} from '../lib/getSelector.js';\nimport {initUnique} from '../lib/initUnique.js';\nimport {InteractionManager, Interaction} from '../lib/InteractionManager.js';\nimport {observe} from '../lib/observe.js';\nimport {whenIdleOrHidden} from '../lib/whenIdleOrHidden.js';\nimport {onINP as unattributedOnINP} from '../onINP.js';\nimport {\n  INPAttribution,\n  INPAttributionReportOpts,\n  INPMetric,\n  INPMetricWithAttribution,\n  INPLongestScriptSummary,\n} from '../types.js';\n\ninterface pendingEntriesGroup {\n  startTime: DOMHighResTimeStamp;\n  processingStart: DOMHighResTimeStamp;\n  processingEnd: DOMHighResTimeStamp;\n  renderTime: DOMHighResTimeStamp;\n  entries: PerformanceEventTiming[];\n}\n\n// The maximum number of previous frames for which data is kept.\n// Storing data about previous frames is necessary to handle cases where event\n// and LoAF entries are dispatched out of order, and so a buffer of previous\n// frame data is needed to determine various bits of INP attribution once all\n// the frame-related data has come in.\n// In most cases this out-of-order data is only off by a frame or two, so\n// keeping the most recent 10 should be more than sufficient.\nconst MAX_PENDING_FRAMES = 10;\n\n/**\n * Calculates the [INP](https://web.dev/articles/inp) value for the current\n * page and calls the `callback` function once the value is ready, along with\n * the `event` performance entries reported for that interaction. The reported\n * value is a `DOMHighResTimeStamp`.\n *\n * A custom `durationThreshold` configuration option can optionally be passed\n * to control what `event-timing` entries are considered for INP reporting. The\n * default threshold is `40`, which means INP scores of less than 40 will not\n * be reported. To avoid reporting no interactions in these cases, the library\n * will fall back to the input delay of the first interaction. Note that this\n * will not affect your 75th percentile INP value unless that value is also\n * less than 40 (well below the recommended\n * [good](https://web.dev/articles/inp#what_is_a_good_inp_score) threshold).\n *\n * If the `reportAllChanges` configuration option is set to `true`, the\n * `callback` function will be called as soon as the value is initially\n * determined as well as any time the value changes throughout the page\n * lifespan.\n *\n * _**Important:** INP should be continually monitored for changes throughout\n * the entire lifespan of a page—including if the user returns to the page after\n * it has been hidden/backgrounded. However, since browsers often [will not fire\n * additional callbacks once the user has backgrounded a\n * page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden),\n * `callback` is always called when the page's visibility state changes to\n * hidden. As a result, the `callback` function might be called multiple times\n * during the same page load._\n */\nexport const onINP = (\n  onReport: (metric: INPMetricWithAttribution) => void,\n  opts: INPAttributionReportOpts = {},\n) => {\n  // Clone the opts object to ensure it's unique, so we can initialize a\n  // single instance of the `InteractionManager` class that's shared only with\n  // this function invocation and the `unattributedOnINP()` invocation below\n  // (which is passed the same `opts` object).\n  opts = Object.assign({}, opts);\n\n  const interactionManager = initUnique(opts, InteractionManager);\n\n  // A list of LoAF entries that have been dispatched and could potentially\n  // intersect with the INP candidate interaction. Note that periodically this\n  // list is cleaned up and entries that are known to not match INP are removed.\n  let pendingLoAFs: PerformanceLongAnimationFrameTiming[] = [];\n\n  // An array of groups of all the event timing entries that occurred within a\n  // particular frame. Note that periodically this array is cleaned up and entries\n  // that are known to not match INP are removed.\n  let pendingEntriesGroups: pendingEntriesGroup[] = [];\n\n  // The `processingEnd` time of most recently-processed event, chronologically.\n  let latestProcessingEnd: number = 0;\n\n  // A WeakMap to look up the event-timing-entries group of a given entry.\n  // Note that this only maps from \"important\" entries: either the first input or\n  // those with an `interactionId`.\n  const entryToEntriesGroupMap: WeakMap<\n    PerformanceEventTiming,\n    pendingEntriesGroup\n  > = new WeakMap();\n\n  // A mapping of interactionIds to the target Node.\n  const interactionTargetMap: WeakMap<Interaction, string> = new WeakMap();\n\n  // A boolean flag indicating whether or not a cleanup task has been queued.\n  let cleanupPending = false;\n\n  /**\n   * Adds new LoAF entries to the `pendingLoAFs` list.\n   */\n  const handleLoAFEntries = (\n    entries: PerformanceLongAnimationFrameTiming[],\n  ) => {\n    pendingLoAFs = pendingLoAFs.concat(entries);\n    queueCleanup();\n  };\n\n  const saveInteractionTarget = (interaction: Interaction) => {\n    if (!interactionTargetMap.get(interaction)) {\n      const node = interaction.entries[0].target;\n      if (node) {\n        const customTarget = opts.generateTarget?.(node) ?? getSelector(node);\n        interactionTargetMap.set(interaction, customTarget);\n      }\n    }\n  };\n\n  /**\n   * Groups entries that were presented within the same animation frame by\n   * a common `renderTime`. This function works by referencing\n   * `pendingEntriesGroups` and using an existing render time if one is found\n   * (otherwise creating a new one). This function also adds all interaction\n   * entries to an `entryToRenderTimeMap` WeakMap so that the \"grouped\" entries\n   * can be looked up later.\n   */\n  const groupEntriesByRenderTime = (entry: PerformanceEventTiming) => {\n    const renderTime = entry.startTime + entry.duration;\n    let group;\n\n    // Update `latestProcessingEnd` to correspond to the `processingEnd`\n    // value of the most recently dispatched `event` entry.\n    latestProcessingEnd = Math.max(latestProcessingEnd, entry.processingEnd);\n\n    // Iterate over all previous render times in reverse order to find a match.\n    // Go in reverse since the most likely match will be at the end.\n    for (let i = pendingEntriesGroups.length - 1; i >= 0; i--) {\n      const potentialGroup = pendingEntriesGroups[i];\n\n      // If a group's render time is within 8ms of the entry's render time,\n      // assume they were part of the same frame and add it to the group.\n      if (Math.abs(renderTime - potentialGroup.renderTime) <= 8) {\n        group = potentialGroup;\n        group.startTime = Math.min(entry.startTime, group.startTime);\n        group.processingStart = Math.min(\n          entry.processingStart,\n          group.processingStart,\n        );\n        group.processingEnd = Math.max(\n          entry.processingEnd,\n          group.processingEnd,\n        );\n        // For some frames there can be many event entries. In this case the\n        // value of including all the processed entries versus the memory use\n        // becomes questionable (see also https://crbug.com/484342204).\n        // So limit to 5 (the first 4 + the last one) to cap the memory of\n        // keeping the heavy PerformanceEventEntry objects around.\n        if (group.entries.length >= 5) {\n          group.entries.length = 5; // Shouldn't ever happen but let's be safe\n          group.entries[4] = entry;\n        } else {\n          group.entries.push(entry);\n        }\n\n        break;\n      }\n    }\n\n    // If there was no matching group, assume this is a new frame.\n    if (!group) {\n      group = {\n        startTime: entry.startTime,\n        processingStart: entry.processingStart,\n        processingEnd: entry.processingEnd,\n        renderTime,\n        entries: [entry],\n      };\n\n      pendingEntriesGroups.push(group);\n    }\n\n    // Store the grouped render time for this entry for reference later.\n    if (entry.interactionId || entry.entryType === 'first-input') {\n      entryToEntriesGroupMap.set(entry, group);\n    }\n\n    queueCleanup();\n  };\n\n  const queueCleanup = () => {\n    // Queue cleanup of entries that are not part of any INP candidates.\n    if (!cleanupPending) {\n      whenIdleOrHidden(cleanupEntries);\n      cleanupPending = true;\n    }\n  };\n\n  const cleanupEntries = () => {\n    // Create a set of entries groups that are part of the longest\n    // interactions (for faster lookup below).\n    const longestInteractionGroups = new Set(\n      interactionManager._longestInteractionList.map((i) => {\n        return entryToEntriesGroupMap.get(i.entries[0]);\n      }),\n    );\n\n    // Clean up the `pendingEntriesGroups` list so it doesn't grow endlessly.\n    // Keep any groups that:\n    // 1) Correspond to one of the current longest interactions, OR\n    // 2) Are part of one of the most recent set of frames (which is\n    //    determined by checking if the index in the group is within\n    //    `MAX_PENDING_FRAMES` of the group's length).\n    const minIndexToKeep = pendingEntriesGroups.length - MAX_PENDING_FRAMES;\n    pendingEntriesGroups = pendingEntriesGroups.filter((group, i) => {\n      // Check index first because it's faster.\n      return i >= minIndexToKeep || longestInteractionGroups.has(group);\n    });\n\n    // Create a set of LoAF entries that intersect with entries in the newly\n    // cleaned up `pendingEntriesGroups` (for faster lookup below).\n    const intersectingLoAFs: Set<PerformanceLongAnimationFrameTiming> =\n      new Set();\n\n    for (const group of pendingEntriesGroups) {\n      const loafs = getIntersectingLoAFs(group.startTime, group.processingEnd);\n      for (const loaf of loafs) {\n        intersectingLoAFs.add(loaf);\n      }\n    }\n\n    // Clean up the `pendingLoAFs` list so it doesn't grow endlessly.\n    // Keep all LoAFs that either:\n    // 1) Intersect with one of the above pending entries groups, OR\n    // 2) Occurred more recently than the most recently process event entry.\n    pendingLoAFs = pendingLoAFs.filter((loaf) => {\n      return (\n        // Compare times first because it's faster.\n        loaf.startTime > latestProcessingEnd || intersectingLoAFs.has(loaf)\n      );\n    });\n\n    cleanupPending = false;\n  };\n\n  interactionManager._onBeforeProcessingEntry = groupEntriesByRenderTime;\n  interactionManager._onAfterProcessingINPCandidate = saveInteractionTarget;\n\n  const getIntersectingLoAFs = (\n    start: DOMHighResTimeStamp,\n    end: DOMHighResTimeStamp,\n  ) => {\n    const intersectingLoAFs: PerformanceLongAnimationFrameTiming[] = [];\n\n    for (const loaf of pendingLoAFs) {\n      // If the LoAF ends before the given start time, ignore it.\n      if (loaf.startTime + loaf.duration < start) continue;\n\n      // If the LoAF starts after the given end time, ignore it and all\n      // subsequent pending LoAFs (because they're in time order).\n      if (loaf.startTime > end) break;\n\n      // Still here? If so this LoAF intersects with the interaction.\n      intersectingLoAFs.push(loaf);\n    }\n    return intersectingLoAFs;\n  };\n\n  const attributeLoAFDetails = (attribution: INPAttribution) => {\n    // If there is no LoAF data then nothing further to attribute\n    if (!attribution.longAnimationFrameEntries?.length) {\n      return;\n    }\n\n    const interactionTime = attribution.interactionTime;\n    const inputDelay = attribution.inputDelay;\n    const processingDuration = attribution.processingDuration;\n\n    // Stats across all LoAF entries and scripts.\n    let totalScriptDuration = 0;\n    let totalStyleAndLayoutDuration = 0;\n    let totalPaintDuration = 0;\n    let longestScriptDuration = 0;\n    let longestScriptEntry: PerformanceScriptTiming | undefined;\n    let longestScriptSubpart: INPLongestScriptSummary['subpart'] | undefined;\n\n    for (const loafEntry of attribution.longAnimationFrameEntries) {\n      totalStyleAndLayoutDuration =\n        totalStyleAndLayoutDuration +\n        loafEntry.startTime +\n        loafEntry.duration -\n        loafEntry.styleAndLayoutStart;\n\n      for (const script of loafEntry.scripts) {\n        const scriptEndTime = script.startTime + script.duration;\n        if (scriptEndTime < interactionTime) {\n          continue;\n        }\n        const intersectingScriptDuration =\n          scriptEndTime - Math.max(interactionTime, script.startTime);\n        // Since forcedStyleAndLayoutDuration doesn't provide timestamps, we\n        // apportion the total based on the intersectingScriptDuration. Not\n        // correct depending on when it occurred, but the best we can do.\n        const intersectingForceStyleAndLayoutDuration = script.duration\n          ? (intersectingScriptDuration / script.duration) *\n            script.forcedStyleAndLayoutDuration\n          : 0;\n        // For scripts we exclude forcedStyleAndLayout (same as DevTools does\n        // in its summary totals) and instead include that in\n        // totalStyleAndLayoutDuration\n        totalScriptDuration +=\n          intersectingScriptDuration - intersectingForceStyleAndLayoutDuration;\n        totalStyleAndLayoutDuration += intersectingForceStyleAndLayoutDuration;\n\n        if (intersectingScriptDuration > longestScriptDuration) {\n          // Set the subpart this occurred in.\n          longestScriptSubpart =\n            script.startTime < interactionTime + inputDelay\n              ? 'input-delay'\n              : script.startTime >=\n                  interactionTime + inputDelay + processingDuration\n                ? 'presentation-delay'\n                : 'processing-duration';\n\n          longestScriptEntry = script;\n          longestScriptDuration = intersectingScriptDuration;\n        }\n      }\n    }\n\n    // Calculate the totalPaintDuration from the last LoAF after\n    // presentationDelay starts (where available)\n    const lastLoAF = attribution.longAnimationFrameEntries.at(-1);\n    const lastLoAFEndTime = lastLoAF\n      ? lastLoAF.startTime + lastLoAF.duration\n      : 0;\n    if (lastLoAFEndTime >= interactionTime + inputDelay + processingDuration) {\n      totalPaintDuration = attribution.nextPaintTime - lastLoAFEndTime;\n    }\n\n    if (longestScriptEntry && longestScriptSubpart) {\n      attribution.longestScript = {\n        entry: longestScriptEntry,\n        subpart: longestScriptSubpart,\n        intersectingDuration: longestScriptDuration,\n      };\n    }\n    attribution.totalScriptDuration = totalScriptDuration;\n    attribution.totalStyleAndLayoutDuration = totalStyleAndLayoutDuration;\n    attribution.totalPaintDuration = totalPaintDuration;\n    attribution.totalUnattributedDuration =\n      attribution.nextPaintTime -\n      interactionTime -\n      totalScriptDuration -\n      totalStyleAndLayoutDuration -\n      totalPaintDuration;\n  };\n\n  const attributeINP = (metric: INPMetric): INPMetricWithAttribution => {\n    const firstEntry = metric.entries[0];\n    const group = entryToEntriesGroupMap.get(firstEntry)!;\n\n    const processingStart = firstEntry.processingStart;\n\n    // Due to the fact that durations can be rounded down to the nearest 8ms,\n    // we have to clamp `nextPaintTime` so it doesn't appear to occur before\n    // processing starts. Note: we can't use `processingEnd` since processing\n    // can extend beyond the event duration in some cases (see next comment).\n    const nextPaintTime = Math.max(\n      firstEntry.startTime + firstEntry.duration,\n      processingStart,\n    );\n\n    // For the purposes of attribution, clamp `processingEnd` to `nextPaintTime`,\n    // so processing is never reported as taking longer than INP (which can\n    // happen via the web APIs in the case of sync modals, e.g. `alert()`).\n    // See: https://github.com/GoogleChrome/web-vitals/issues/492\n    const processingEnd = Math.min(group.processingEnd, nextPaintTime);\n\n    // Sort the entries in processing time order.\n    const processedEventEntries = group.entries.sort((a, b) => {\n      return a.processingStart - b.processingStart;\n    });\n\n    const longAnimationFrameEntries: PerformanceLongAnimationFrameTiming[] =\n      getIntersectingLoAFs(firstEntry.startTime, processingEnd);\n\n    const interaction = interactionManager._longestInteractionMap.get(\n      firstEntry.interactionId,\n    );\n\n    const attribution: INPAttribution = {\n      // TS flags the next line because `interactionTargetMap.get()` might\n      // return `undefined`, but we ignore this assuming the user knows what\n      // they are doing.\n      interactionTarget: interactionTargetMap.get(interaction!)!,\n      interactionType: firstEntry.name.startsWith('key')\n        ? 'keyboard'\n        : 'pointer',\n      interactionTime: firstEntry.startTime,\n      nextPaintTime: nextPaintTime,\n      processedEventEntries: processedEventEntries,\n      longAnimationFrameEntries: longAnimationFrameEntries,\n      inputDelay: processingStart - firstEntry.startTime,\n      processingDuration: processingEnd - processingStart,\n      presentationDelay: nextPaintTime - processingEnd,\n      loadState: getLoadState(firstEntry.startTime),\n      longestScript: undefined,\n      totalScriptDuration: undefined,\n      totalStyleAndLayoutDuration: undefined,\n      totalPaintDuration: undefined,\n      totalUnattributedDuration: undefined,\n    };\n\n    attributeLoAFDetails(attribution);\n\n    // Use `Object.assign()` to ensure the original metric object is returned.\n    const metricWithAttribution: INPMetricWithAttribution = Object.assign(\n      metric,\n      {attribution},\n    );\n    return metricWithAttribution;\n  };\n\n  // Start observing LoAF entries for attribution.\n  observe('long-animation-frame', handleLoAFEntries);\n\n  unattributedOnINP((metric: INPMetric) => {\n    const metricWithAttribution = attributeINP(metric);\n    onReport(metricWithAttribution);\n  }, opts);\n};\n"
  },
  {
    "path": "src/attribution/onLCP.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {getNavigationEntry} from '../lib/getNavigationEntry.js';\nimport {getSelector} from '../lib/getSelector.js';\nimport {initUnique} from '../lib/initUnique.js';\nimport {LCPEntryManager} from '../lib/LCPEntryManager.js';\nimport {onLCP as unattributedOnLCP} from '../onLCP.js';\nimport {\n  LCPAttribution,\n  LCPMetric,\n  LCPMetricWithAttribution,\n  AttributionReportOpts,\n} from '../types.js';\n\n/**\n * Calculates the [LCP](https://web.dev/articles/lcp) value for the current page and\n * calls the `callback` function once the value is ready (along with the\n * relevant `largest-contentful-paint` performance entry used to determine the\n * value). The reported value is a `DOMHighResTimeStamp`.\n *\n * If the `reportAllChanges` configuration option is set to `true`, the\n * `callback` function will be called any time a new `largest-contentful-paint`\n * performance entry is dispatched, or once the final value of the metric has\n * been determined.\n */\nexport const onLCP = (\n  onReport: (metric: LCPMetricWithAttribution) => void,\n  opts: AttributionReportOpts = {},\n) => {\n  // Clone the opts object to ensure it's unique, so we can initialize a\n  // single instance of the `LCPEntryManager` class that's shared only with\n  // this function invocation and the `unattributedOnLCP()` invocation below\n  // (which is passed the same `opts` object).\n  opts = Object.assign({}, opts);\n\n  const lcpEntryManager = initUnique(opts, LCPEntryManager);\n  const lcpTargetMap: WeakMap<LargestContentfulPaint, string> = new WeakMap();\n\n  lcpEntryManager._onBeforeProcessingEntry = (\n    entry: LargestContentfulPaint,\n  ) => {\n    const node = entry.element;\n    if (node) {\n      const customTarget = opts.generateTarget?.(node) ?? getSelector(node);\n      lcpTargetMap.set(entry, customTarget);\n    } else if (entry.id) {\n      // Use the LargestContentfulPaint.id property when the element has been\n      // removed from the DOM (and so node is null), but still has an ID.\n      lcpTargetMap.set(entry, `#${entry.id}`);\n    }\n  };\n\n  const attributeLCP = (metric: LCPMetric): LCPMetricWithAttribution => {\n    // Use a default object if no other attribution has been set.\n    let attribution: LCPAttribution = {\n      timeToFirstByte: 0,\n      resourceLoadDelay: 0,\n      resourceLoadDuration: 0,\n      elementRenderDelay: metric.value,\n    };\n\n    if (metric.entries.length) {\n      // The `metric.entries.length` check ensures there will be an entry.\n      const lcpEntry = metric.entries.at(-1)!;\n      const lcpResourceEntry =\n        lcpEntry.url &&\n        performance\n          .getEntriesByType('resource')\n          .find((e) => e.name === lcpEntry.url);\n\n      attribution.target = lcpTargetMap.get(lcpEntry);\n      attribution.lcpEntry = lcpEntry;\n      // Only attribute the URL and resource entry if they exist.\n      if (lcpEntry.url) {\n        attribution.url = lcpEntry.url;\n      }\n      if (lcpResourceEntry) {\n        attribution.lcpResourceEntry = lcpResourceEntry;\n      }\n\n      // Get subparts from navigation entry. Do this last as occasionally\n      // Safari seems to fail to find a navigation entry.\n      const navigationEntry = getNavigationEntry();\n      if (navigationEntry) {\n        const activationStart = navigationEntry.activationStart || 0;\n\n        const ttfb = Math.max(\n          0,\n          navigationEntry.responseStart - activationStart,\n        );\n\n        const lcpRequestStart = Math.max(\n          ttfb,\n          // Prefer `requestStart` (if TOA is set), otherwise use `startTime`.\n          lcpResourceEntry\n            ? (lcpResourceEntry.requestStart || lcpResourceEntry.startTime) -\n                activationStart\n            : 0,\n        );\n        const lcpResponseEnd = Math.min(\n          // Cap at LCP time (videos continue downloading after LCP for example)\n          metric.value,\n          Math.max(\n            lcpRequestStart,\n            lcpResourceEntry\n              ? lcpResourceEntry.responseEnd - activationStart\n              : 0,\n          ),\n        );\n\n        attribution = {\n          ...attribution,\n          timeToFirstByte: ttfb,\n          resourceLoadDelay: lcpRequestStart - ttfb,\n          resourceLoadDuration: lcpResponseEnd - lcpRequestStart,\n          elementRenderDelay: metric.value - lcpResponseEnd,\n          navigationEntry,\n        };\n      }\n    }\n\n    // Use `Object.assign()` to ensure the original metric object is returned.\n    const metricWithAttribution: LCPMetricWithAttribution = Object.assign(\n      metric,\n      {attribution},\n    );\n    return metricWithAttribution;\n  };\n\n  unattributedOnLCP((metric: LCPMetric) => {\n    const metricWithAttribution = attributeLCP(metric);\n    onReport(metricWithAttribution);\n  }, opts);\n};\n"
  },
  {
    "path": "src/attribution/onTTFB.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {onTTFB as unattributedOnTTFB} from '../onTTFB.js';\nimport {\n  TTFBMetric,\n  TTFBMetricWithAttribution,\n  AttributionReportOpts,\n  TTFBAttribution,\n} from '../types.js';\n\nconst attributeTTFB = (metric: TTFBMetric): TTFBMetricWithAttribution => {\n  // Use a default object if no other attribution has been set.\n  let attribution: TTFBAttribution = {\n    waitingDuration: 0,\n    cacheDuration: 0,\n    dnsDuration: 0,\n    connectionDuration: 0,\n    requestDuration: 0,\n  };\n\n  if (metric.entries.length) {\n    const navigationEntry = metric.entries[0];\n    const activationStart = navigationEntry.activationStart || 0;\n\n    // Measure from workerStart or fetchStart so any service worker startup\n    // time is included in cacheDuration (which also includes other sw time\n    // anyway, that cannot be accurately split out cross-browser).\n    const waitEnd = Math.max(\n      (navigationEntry.workerStart || navigationEntry.fetchStart) -\n        activationStart,\n      0,\n    );\n    const dnsStart = Math.max(\n      navigationEntry.domainLookupStart - activationStart,\n      0,\n    );\n    const connectStart = Math.max(\n      navigationEntry.connectStart - activationStart,\n      0,\n    );\n    const connectEnd = Math.max(\n      navigationEntry.connectEnd - activationStart,\n      0,\n    );\n\n    attribution = {\n      waitingDuration: waitEnd,\n      cacheDuration: dnsStart - waitEnd,\n      // dnsEnd usually equals connectStart but use connectStart over dnsEnd\n      // for dnsDuration in case there ever is a gap.\n      dnsDuration: connectStart - dnsStart,\n      connectionDuration: connectEnd - connectStart,\n      // There is often a gap between connectEnd and requestStart. Attribute\n      // that to requestDuration so connectionDuration remains 0 for\n      // service worker controlled requests were connectStart and connectEnd\n      // are the same.\n      requestDuration: metric.value - connectEnd,\n      navigationEntry: navigationEntry,\n    };\n  }\n\n  // Use `Object.assign()` to ensure the original metric object is returned.\n  const metricWithAttribution: TTFBMetricWithAttribution = Object.assign(\n    metric,\n    {attribution},\n  );\n  return metricWithAttribution;\n};\n\n/**\n * Calculates the [TTFB](https://web.dev/articles/ttfb) value for the\n * current page and calls the `callback` function once the page has loaded,\n * along with the relevant `navigation` performance entry used to determine the\n * value. The reported value is a `DOMHighResTimeStamp`.\n *\n * Note, this function waits until after the page is loaded to call `callback`\n * in order to ensure all properties of the `navigation` entry are populated.\n * This is useful if you want to report on other metrics exposed by the\n * [Navigation Timing API](https://w3c.github.io/navigation-timing/). For\n * example, the TTFB metric starts from the page's [time\n * origin](https://www.w3.org/TR/hr-time-2/#sec-time-origin), which means it\n * includes time spent on DNS lookup, connection negotiation, network latency,\n * and server processing time.\n */\nexport const onTTFB = (\n  onReport: (metric: TTFBMetricWithAttribution) => void,\n  opts: AttributionReportOpts = {},\n) => {\n  unattributedOnTTFB((metric: TTFBMetric) => {\n    const metricWithAttribution = attributeTTFB(metric);\n    onReport(metricWithAttribution);\n  }, opts);\n};\n"
  },
  {
    "path": "src/index.ts",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nexport {onCLS, CLSThresholds} from './onCLS.js';\nexport {onFCP, FCPThresholds} from './onFCP.js';\nexport {onINP, INPThresholds} from './onINP.js';\nexport {onLCP, LCPThresholds} from './onLCP.js';\nexport {onTTFB, TTFBThresholds} from './onTTFB.js';\n\nexport * from './types.js';\n"
  },
  {
    "path": "src/lib/InteractionManager.ts",
    "content": "/*\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {getInteractionCount} from './polyfills/interactionCountPolyfill.js';\n\nexport interface Interaction {\n  _latency: number;\n  // While the `id` and `entries` properties are also internal and could be\n  // mangled by prefixing with an underscore, since they correspond to public\n  // symbols there is no need to mangle them as the library will compress\n  // better if we reuse the existing names.\n  id: number;\n  entries: PerformanceEventTiming[];\n}\n\n// To prevent unnecessary memory usage on pages with lots of interactions,\n// store at most 10 of the longest interactions to consider as INP candidates.\nconst MAX_INTERACTIONS_TO_CONSIDER = 10;\n\n// Used to store the interaction count after a bfcache restore, since p98\n// interaction latencies should only consider the current navigation.\nlet prevInteractionCount = 0;\n\n/**\n * Returns the interaction count since the last bfcache restore (or for the\n * full page lifecycle if there were no bfcache restores).\n */\nconst getInteractionCountForNavigation = () => {\n  return getInteractionCount() - prevInteractionCount;\n};\n\nexport class InteractionManager {\n  /**\n   * A list of longest interactions on the page (by latency) sorted so the\n   * longest one is first. The list is at most MAX_INTERACTIONS_TO_CONSIDER\n   * long.\n   */\n  _longestInteractionList: Interaction[] = [];\n\n  /**\n   * A mapping of longest interactions by their interaction ID.\n   * This is used for faster lookup.\n   */\n  _longestInteractionMap: Map<number, Interaction> = new Map();\n\n  _onBeforeProcessingEntry?: (entry: PerformanceEventTiming) => void;\n\n  _onAfterProcessingINPCandidate?: (interaction: Interaction) => void;\n\n  _resetInteractions() {\n    prevInteractionCount = getInteractionCount();\n    this._longestInteractionList.length = 0;\n    this._longestInteractionMap.clear();\n  }\n\n  /**\n   * Returns the estimated p98 longest interaction based on the stored\n   * interaction candidates and the interaction count for the current page.\n   */\n  _estimateP98LongestInteraction() {\n    const candidateInteractionIndex = Math.min(\n      this._longestInteractionList.length - 1,\n      Math.floor(getInteractionCountForNavigation() / 50),\n    );\n\n    return this._longestInteractionList[candidateInteractionIndex];\n  }\n\n  /**\n   * Takes a performance entry and adds it to the list of worst interactions\n   * if its duration is long enough to make it among the worst. If the\n   * entry is part of an existing interaction, it is merged and the latency\n   * and entries list is updated as needed.\n   */\n  _processEntry(entry: PerformanceEventTiming) {\n    this._onBeforeProcessingEntry?.(entry);\n\n    // Skip further processing for entries that cannot be INP candidates.\n    if (!(entry.interactionId || entry.entryType === 'first-input')) return;\n\n    // The least-long of the 10 longest interactions.\n    const minLongestInteraction = this._longestInteractionList.at(-1);\n\n    let interaction = this._longestInteractionMap.get(entry.interactionId!);\n\n    // Only process the entry if it's possibly one of the ten longest,\n    // or if it's part of an existing interaction.\n    if (\n      interaction ||\n      this._longestInteractionList.length < MAX_INTERACTIONS_TO_CONSIDER ||\n      // If the above conditions are false, `minLongestInteraction` will be set.\n      entry.duration > minLongestInteraction!._latency\n    ) {\n      // If the interaction already exists, update it. Otherwise create one.\n      if (interaction) {\n        // If the new entry has a longer duration, replace the old entries,\n        // otherwise add to the array.\n        if (entry.duration > interaction._latency) {\n          interaction.entries = [entry];\n          interaction._latency = entry.duration;\n        } else if (\n          entry.duration === interaction._latency &&\n          entry.startTime === interaction.entries[0].startTime\n        ) {\n          interaction.entries.push(entry);\n        }\n      } else {\n        interaction = {\n          id: entry.interactionId!,\n          entries: [entry],\n          _latency: entry.duration,\n        };\n        this._longestInteractionMap.set(interaction.id, interaction);\n        this._longestInteractionList.push(interaction);\n      }\n\n      // Sort the entries by latency (descending) and keep only the top ten.\n      this._longestInteractionList.sort((a, b) => b._latency - a._latency);\n      if (this._longestInteractionList.length > MAX_INTERACTIONS_TO_CONSIDER) {\n        const removedInteractions = this._longestInteractionList.splice(\n          MAX_INTERACTIONS_TO_CONSIDER,\n        );\n\n        for (const interaction of removedInteractions) {\n          this._longestInteractionMap.delete(interaction.id);\n        }\n      }\n\n      // Call any post-processing on the interaction\n      this._onAfterProcessingINPCandidate?.(interaction);\n    }\n  }\n}\n"
  },
  {
    "path": "src/lib/LCPEntryManager.ts",
    "content": "/*\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nexport class LCPEntryManager {\n  _onBeforeProcessingEntry?: (entry: LargestContentfulPaint) => void;\n\n  _processEntry(entry: LargestContentfulPaint) {\n    this._onBeforeProcessingEntry?.(entry);\n  }\n}\n"
  },
  {
    "path": "src/lib/LayoutShiftManager.ts",
    "content": "/*\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nexport class LayoutShiftManager {\n  _onAfterProcessingUnexpectedShift?: (entry: LayoutShift) => void;\n\n  _sessionValue = 0;\n  _sessionEntries: LayoutShift[] = [];\n\n  _processEntry(entry: LayoutShift) {\n    // Only count layout shifts without recent user input.\n    if (entry.hadRecentInput) return;\n\n    const firstSessionEntry = this._sessionEntries[0];\n    const lastSessionEntry = this._sessionEntries.at(-1);\n\n    // If the entry occurred less than 1 second after the previous entry\n    // and less than 5 seconds after the first entry in the session,\n    // include the entry in the current session. Otherwise, start a new\n    // session.\n    if (\n      this._sessionValue &&\n      firstSessionEntry &&\n      lastSessionEntry &&\n      entry.startTime - lastSessionEntry.startTime < 1000 &&\n      entry.startTime - firstSessionEntry.startTime < 5000\n    ) {\n      this._sessionValue += entry.value;\n      this._sessionEntries.push(entry);\n    } else {\n      this._sessionValue = entry.value;\n      this._sessionEntries = [entry];\n    }\n\n    this._onAfterProcessingUnexpectedShift?.(entry);\n  }\n}\n"
  },
  {
    "path": "src/lib/bfcache.ts",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\ninterface onBFCacheRestoreCallback {\n  (event: PageTransitionEvent): void;\n}\n\nlet bfcacheRestoreTime = -1;\n\nexport const getBFCacheRestoreTime = () => bfcacheRestoreTime;\n\nexport const onBFCacheRestore = (cb: onBFCacheRestoreCallback) => {\n  addEventListener(\n    'pageshow',\n    (event) => {\n      if (event.persisted) {\n        bfcacheRestoreTime = event.timeStamp;\n        cb(event);\n      }\n    },\n    true,\n  );\n};\n"
  },
  {
    "path": "src/lib/bindReporter.ts",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {MetricType, MetricRatingThresholds} from '../types.js';\n\nconst getRating = (\n  value: number,\n  thresholds: MetricRatingThresholds,\n): MetricType['rating'] => {\n  if (value > thresholds[1]) {\n    return 'poor';\n  }\n  if (value > thresholds[0]) {\n    return 'needs-improvement';\n  }\n  return 'good';\n};\n\nexport const bindReporter = <MetricName extends MetricType['name']>(\n  callback: (metric: Extract<MetricType, {name: MetricName}>) => void,\n  metric: Extract<MetricType, {name: MetricName}>,\n  thresholds: MetricRatingThresholds,\n  reportAllChanges?: boolean,\n) => {\n  let prevValue: number;\n  let delta: number;\n  return (forceReport?: boolean) => {\n    if (metric.value >= 0) {\n      if (forceReport || reportAllChanges) {\n        delta = metric.value - (prevValue ?? 0);\n\n        // Report the metric if there's a non-zero delta or if no previous\n        // value exists (which can happen in the case of the document becoming\n        // hidden when the metric value is 0).\n        // See: https://github.com/GoogleChrome/web-vitals/issues/14\n        if (delta || prevValue === undefined) {\n          prevValue = metric.value;\n          metric.delta = delta;\n          metric.rating = getRating(metric.value, thresholds);\n          callback(metric);\n        }\n      }\n    }\n  };\n};\n"
  },
  {
    "path": "src/lib/doubleRAF.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nexport const doubleRAF = (cb: () => unknown) => {\n  requestAnimationFrame(() => requestAnimationFrame(() => cb()));\n};\n"
  },
  {
    "path": "src/lib/generateUniqueID.ts",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Performantly generate a unique, 30-char string by combining a version\n * number, the current timestamp with a 13-digit number integer.\n * @return {string}\n */\nexport const generateUniqueID = () => {\n  return `v5-${Date.now()}-${Math.floor(Math.random() * (9e12 - 1)) + 1e12}`;\n};\n"
  },
  {
    "path": "src/lib/getActivationStart.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {getNavigationEntry} from './getNavigationEntry.js';\n\nexport const getActivationStart = (): number => {\n  const navEntry = getNavigationEntry();\n  return navEntry?.activationStart ?? 0;\n};\n"
  },
  {
    "path": "src/lib/getLoadState.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {getNavigationEntry} from './getNavigationEntry.js';\nimport {LoadState} from '../types.js';\n\nexport const getLoadState = (timestamp: number): LoadState => {\n  if (document.readyState === 'loading') {\n    // If the `readyState` is 'loading' there's no need to look at timestamps\n    // since the timestamp has to be the current time or earlier.\n    return 'loading';\n  } else {\n    const navigationEntry = getNavigationEntry();\n    if (navigationEntry) {\n      if (timestamp < navigationEntry.domInteractive) {\n        return 'loading';\n      } else if (\n        navigationEntry.domContentLoadedEventStart === 0 ||\n        timestamp < navigationEntry.domContentLoadedEventStart\n      ) {\n        // If the `domContentLoadedEventStart` timestamp has not yet been\n        // set, or if the given timestamp is less than that value.\n        return 'dom-interactive';\n      } else if (\n        navigationEntry.domComplete === 0 ||\n        timestamp < navigationEntry.domComplete\n      ) {\n        // If the `domComplete` timestamp has not yet been\n        // set, or if the given timestamp is less than that value.\n        return 'dom-content-loaded';\n      }\n    }\n  }\n  // If any of the above fail, default to loaded. This could really only\n  // happy if the browser doesn't support the performance timeline, which\n  // most likely means this code would never run anyway.\n  return 'complete';\n};\n"
  },
  {
    "path": "src/lib/getNavigationEntry.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nexport const getNavigationEntry = (): PerformanceNavigationTiming | void => {\n  const navigationEntry = performance.getEntriesByType('navigation')[0];\n\n  // Check to ensure the `responseStart` property is present and valid.\n  // In some cases a zero value is reported by the browser (for\n  // privacy/security reasons), and in other cases (bugs) the value is\n  // negative or is larger than the current page time. Ignore these cases:\n  // - https://github.com/GoogleChrome/web-vitals/issues/137\n  // - https://github.com/GoogleChrome/web-vitals/issues/162\n  // - https://github.com/GoogleChrome/web-vitals/issues/275\n  if (\n    navigationEntry &&\n    navigationEntry.responseStart > 0 &&\n    navigationEntry.responseStart < performance.now()\n  ) {\n    return navigationEntry;\n  }\n};\n"
  },
  {
    "path": "src/lib/getSelector.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst getName = (node: Node) => {\n  const name = node.nodeName;\n  return node.nodeType === 1\n    ? name.toLowerCase()\n    : name.toUpperCase().replace(/^#/, '');\n};\n\nconst MAX_LEN = 100;\n\nexport const getSelector = (node: Node | null) => {\n  let sel = '';\n\n  try {\n    while (node?.nodeType !== 9) {\n      const el: Element = node as Element;\n      const part = el.id\n        ? '#' + el.id\n        : [getName(el), ...Array.from(el.classList).sort()].join('.');\n      if (sel.length + part.length > MAX_LEN - 1) {\n        return sel || part;\n      }\n      sel = sel ? part + '>' + sel : part;\n      if (el.id) {\n        break;\n      }\n      node = el.parentNode;\n    }\n  } catch {\n    // Do nothing...\n  }\n  return sel;\n};\n"
  },
  {
    "path": "src/lib/getVisibilityWatcher.ts",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {onBFCacheRestore} from './bfcache.js';\nimport {getActivationStart} from './getActivationStart.js';\n\nlet firstHiddenTime = -1;\nconst onHiddenFunctions: Set<() => void> = new Set();\n\nconst initHiddenTime = () => {\n  // If the document is hidden when this code runs, assume it was always\n  // hidden and the page was loaded in the background, with the one exception\n  // that visibility state is always 'hidden' during prerendering, so we have\n  // to ignore that case until prerendering finishes (see: `prerenderingchange`\n  // event logic below).\n  return document.visibilityState === 'hidden' && !document.prerendering\n    ? 0\n    : Infinity;\n};\n\nconst onVisibilityUpdate = (event: Event) => {\n  // Handle changes to hidden state\n  if (document.visibilityState === 'hidden') {\n    if (event.type === 'visibilitychange') {\n      for (const onHiddenFunction of onHiddenFunctions) {\n        onHiddenFunction();\n      }\n    }\n\n    // If the document is 'hidden' and no previous hidden timestamp has been\n    // set (so is infinity), update it based on the current event data.\n    if (!isFinite(firstHiddenTime)) {\n      // If the event is a 'visibilitychange' event, it means the page was\n      // visible prior to this change, so the event timestamp is the first\n      // hidden time.\n      // However, if the event is not a 'visibilitychange' event, then it must\n      // be a 'prerenderingchange' event, and the fact that the document is\n      // still 'hidden' from the above check means the tab was activated\n      // in a background state and so has always been hidden.\n      firstHiddenTime = event.type === 'visibilitychange' ? event.timeStamp : 0;\n\n      // We no longer need the `prerenderingchange` event listener now we've\n      // set an initial init time so remove that\n      // (we'll keep the visibilitychange one for onHiddenFunction above)\n      removeEventListener('prerenderingchange', onVisibilityUpdate, true);\n    }\n  }\n};\n\nexport const getVisibilityWatcher = () => {\n  if (firstHiddenTime < 0) {\n    // Check if we have a previous hidden `visibility-state` performance entry.\n    const activationStart = getActivationStart();\n    /* eslint-disable indent */\n    const firstVisibilityStateHiddenTime = !document.prerendering\n      ? globalThis.performance\n          .getEntriesByType('visibility-state')\n          .find((e) => e.name === 'hidden' && e.startTime >= activationStart)\n          ?.startTime\n      : undefined;\n    /* eslint-enable indent */\n\n    // Prefer that, but if it's not available and the document is hidden when\n    // this code runs, assume it was hidden since navigation start. This isn't\n    // a perfect heuristic, but it's the best we can do until the\n    // `visibility-state` performance entry becomes available in all browsers.\n    firstHiddenTime = firstVisibilityStateHiddenTime ?? initHiddenTime();\n\n    // Listen for visibility changes so we can handle things like bfcache\n    // restores and/or prerender without having to examine individual\n    // timestamps in detail and also for onHidden function calls.\n    addEventListener('visibilitychange', onVisibilityUpdate, true);\n    // IMPORTANT: when a page is prerendering, its `visibilityState` is\n    // 'hidden', so in order to account for cases where this module checks for\n    // visibility during prerendering, an additional check after prerendering\n    // completes is also required.\n    addEventListener('prerenderingchange', onVisibilityUpdate, true);\n\n    // Reset the time on bfcache restores.\n    onBFCacheRestore(() => {\n      // Schedule a task in order to track the `visibilityState` once it's\n      // had an opportunity to change to visible in all browsers.\n      // https://bugs.chromium.org/p/chromium/issues/detail?id=1133363\n      setTimeout(() => {\n        firstHiddenTime = initHiddenTime();\n      });\n    });\n  }\n  return {\n    get firstHiddenTime() {\n      return firstHiddenTime;\n    },\n    onHidden(cb: () => void) {\n      onHiddenFunctions.add(cb);\n    },\n  };\n};\n"
  },
  {
    "path": "src/lib/initMetric.ts",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {getBFCacheRestoreTime} from './bfcache.js';\nimport {generateUniqueID} from './generateUniqueID.js';\nimport {getActivationStart} from './getActivationStart.js';\nimport {getNavigationEntry} from './getNavigationEntry.js';\nimport {MetricType} from '../types.js';\n\nexport const initMetric = <MetricName extends MetricType['name']>(\n  name: MetricName,\n  value: number = -1,\n) => {\n  const navEntry = getNavigationEntry();\n  let navigationType: MetricType['navigationType'] = 'navigate';\n\n  if (getBFCacheRestoreTime() >= 0) {\n    navigationType = 'back-forward-cache';\n  } else if (navEntry) {\n    if (document.prerendering || getActivationStart() > 0) {\n      navigationType = 'prerender';\n    } else if (document.wasDiscarded) {\n      navigationType = 'restore';\n    } else if (navEntry.type) {\n      navigationType = navEntry.type.replace(\n        /_/g,\n        '-',\n      ) as MetricType['navigationType'];\n    }\n  }\n\n  // Use `entries` type specific for the metric.\n  const entries: Extract<MetricType, {name: MetricName}>['entries'] = [];\n\n  return {\n    name,\n    value,\n    rating: 'good' as const, // If needed, will be updated when reported. `const` to keep the type from widening to `string`.\n    delta: 0,\n    entries,\n    id: generateUniqueID(),\n    navigationType,\n  };\n};\n"
  },
  {
    "path": "src/lib/initUnique.ts",
    "content": "/*\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst instanceMap: WeakMap<object, unknown> = new WeakMap();\n\n/**\n * A function that accepts and identity object and a class object and returns\n * either a new instance of that class or an existing instance, if the\n * identity object was previously used.\n */\nexport function initUnique<T>(identityObj: object, ClassObj: new () => T): T {\n  if (!instanceMap.get(identityObj)) {\n    instanceMap.set(identityObj, new ClassObj());\n  }\n  return instanceMap.get(identityObj)! as T;\n}\n"
  },
  {
    "path": "src/lib/observe.ts",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\ninterface PerformanceEntryMap {\n  'event': PerformanceEventTiming[];\n  'first-input': PerformanceEventTiming[];\n  'layout-shift': LayoutShift[];\n  'largest-contentful-paint': LargestContentfulPaint[];\n  'long-animation-frame': PerformanceLongAnimationFrameTiming[];\n  'paint': PerformancePaintTiming[];\n  'navigation': PerformanceNavigationTiming[];\n  'resource': PerformanceResourceTiming[];\n}\n\n/**\n * Takes a performance entry type and a callback function, and creates a\n * `PerformanceObserver` instance that will observe the specified entry type\n * with buffering enabled and call the callback _for each entry_.\n *\n * This function also feature-detects entry support and wraps the logic in a\n * try/catch to avoid errors in unsupporting browsers.\n */\nexport const observe = <K extends keyof PerformanceEntryMap>(\n  type: K,\n  callback: (entries: PerformanceEntryMap[K]) => void,\n  opts: PerformanceObserverInit = {},\n): PerformanceObserver | undefined => {\n  try {\n    if (PerformanceObserver.supportedEntryTypes.includes(type)) {\n      const po = new PerformanceObserver((list) => {\n        // Delay by a microtask to workaround a bug in Safari where the\n        // callback is invoked immediately, rather than in a separate task.\n        // See: https://github.com/GoogleChrome/web-vitals/issues/277\n        queueMicrotask(() => {\n          callback(list.getEntries() as PerformanceEntryMap[K]);\n        });\n      });\n      po.observe({type, buffered: true, ...opts});\n      return po;\n    }\n  } catch {\n    // Do nothing.\n  }\n  return;\n};\n"
  },
  {
    "path": "src/lib/polyfills/getFirstHiddenTimePolyfill.ts",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nlet firstHiddenTime = document.visibilityState === 'hidden' ? 0 : Infinity;\n\nconst onVisibilityChange = (event: Event) => {\n  if (document.visibilityState === 'hidden') {\n    firstHiddenTime = event.timeStamp;\n    removeEventListener('visibilitychange', onVisibilityChange, true);\n  }\n};\n\n// Note: do not add event listeners unconditionally (outside of polyfills).\naddEventListener('visibilitychange', onVisibilityChange, true);\n\nexport const getFirstHiddenTime = () => firstHiddenTime;\n"
  },
  {
    "path": "src/lib/polyfills/interactionCountPolyfill.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {observe} from '../observe.js';\n\ndeclare global {\n  interface Performance {\n    readonly interactionCount: number;\n  }\n}\n\nlet interactionCountEstimate = 0;\nlet minKnownInteractionId = Infinity;\nlet maxKnownInteractionId = 0;\n\nconst updateEstimate = (entries: PerformanceEventTiming[]) => {\n  for (const entry of entries) {\n    if (entry.interactionId) {\n      minKnownInteractionId = Math.min(\n        minKnownInteractionId,\n        entry.interactionId,\n      );\n      maxKnownInteractionId = Math.max(\n        maxKnownInteractionId,\n        entry.interactionId,\n      );\n\n      interactionCountEstimate = maxKnownInteractionId\n        ? (maxKnownInteractionId - minKnownInteractionId) / 7 + 1\n        : 0;\n    }\n  }\n};\n\nlet po: PerformanceObserver | undefined;\n\n/**\n * Returns the `interactionCount` value using the native API (if available)\n * or the polyfill estimate in this module.\n */\nexport const getInteractionCount = () => {\n  return po ? interactionCountEstimate : (performance.interactionCount ?? 0);\n};\n\n/**\n * Feature detects native support or initializes the polyfill if needed.\n */\nexport const initInteractionCountPolyfill = () => {\n  if ('interactionCount' in performance || po) return;\n\n  po = observe('event', updateEstimate, {\n    type: 'event',\n    buffered: true,\n    durationThreshold: 0,\n  } as PerformanceObserverInit);\n};\n"
  },
  {
    "path": "src/lib/runOnce.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nexport const runOnce = (cb: () => void) => {\n  let called = false;\n  return () => {\n    if (!called) {\n      cb();\n      called = true;\n    }\n  };\n};\n"
  },
  {
    "path": "src/lib/whenActivated.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nexport const whenActivated = (callback: () => void) => {\n  if (document.prerendering) {\n    addEventListener('prerenderingchange', () => callback(), true);\n  } else {\n    callback();\n  }\n};\n"
  },
  {
    "path": "src/lib/whenIdleOrHidden.ts",
    "content": "/*\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {runOnce} from './runOnce.js';\n\n/**\n * Runs the passed callback during the next idle period, or immediately\n * if the browser's visibility state is (or becomes) hidden.\n */\nexport const whenIdleOrHidden = (cb: () => void) => {\n  const rIC = globalThis.requestIdleCallback || setTimeout;\n  const cIC = globalThis.cancelIdleCallback || clearTimeout;\n\n  // If the document is hidden, run the callback immediately, otherwise\n  // race an idle callback with the next `visibilitychange` event.\n  if (document.visibilityState === 'hidden') {\n    cb();\n  } else {\n    const wrappedCb = runOnce(cb);\n\n    let idleHandle = -1;\n    const onHidden = () => {\n      cIC(idleHandle);\n      wrappedCb();\n    };\n\n    addEventListener('visibilitychange', onHidden, {once: true, capture: true});\n    idleHandle = rIC(() => {\n      removeEventListener('visibilitychange', onHidden, {capture: true});\n      wrappedCb();\n    });\n  }\n};\n"
  },
  {
    "path": "src/onCLS.ts",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {onBFCacheRestore} from './lib/bfcache.js';\nimport {bindReporter} from './lib/bindReporter.js';\nimport {doubleRAF} from './lib/doubleRAF.js';\nimport {initMetric} from './lib/initMetric.js';\nimport {initUnique} from './lib/initUnique.js';\nimport {LayoutShiftManager} from './lib/LayoutShiftManager.js';\nimport {observe} from './lib/observe.js';\nimport {runOnce} from './lib/runOnce.js';\nimport {onFCP} from './onFCP.js';\nimport {getVisibilityWatcher} from './lib/getVisibilityWatcher.js';\nimport {CLSMetric, MetricRatingThresholds, ReportOpts} from './types.js';\n\n/** Thresholds for CLS. See https://web.dev/articles/cls#what_is_a_good_cls_score */\nexport const CLSThresholds: MetricRatingThresholds = [0.1, 0.25];\n\n/**\n * Calculates the [CLS](https://web.dev/articles/cls) value for the current page and\n * calls the `callback` function once the value is ready to be reported, along\n * with all `layout-shift` performance entries that were used in the metric\n * value calculation. The reported value is a `double` (corresponding to a\n * [layout shift score](https://web.dev/articles/cls#layout_shift_score)).\n *\n * If the `reportAllChanges` configuration option is set to `true`, the\n * `callback` function will be called as soon as the value is initially\n * determined as well as any time the value changes throughout the page\n * lifespan.\n *\n * _**Important:** CLS should be continually monitored for changes throughout\n * the entire lifespan of a page—including if the user returns to the page after\n * it's been hidden/backgrounded. However, since browsers often [will not fire\n * additional callbacks once the user has backgrounded a\n * page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden),\n * `callback` is always called when the page's visibility state changes to\n * hidden. As a result, the `callback` function might be called multiple times\n * during the same page load._\n */\nexport const onCLS = (\n  onReport: (metric: CLSMetric) => void,\n  opts: ReportOpts = {},\n) => {\n  const visibilityWatcher = getVisibilityWatcher();\n  // Start monitoring FCP so we can only report CLS if FCP is also reported.\n  // Note: this is done to match the current behavior of CrUX.\n  onFCP(\n    runOnce(() => {\n      let metric = initMetric('CLS', 0);\n      let report: ReturnType<typeof bindReporter>;\n\n      const layoutShiftManager = initUnique(opts, LayoutShiftManager);\n\n      const handleEntries = (entries: LayoutShift[]) => {\n        for (const entry of entries) {\n          layoutShiftManager._processEntry(entry);\n        }\n\n        // If the current session value is larger than the current CLS value,\n        // update CLS and the entries contributing to it.\n        if (layoutShiftManager._sessionValue > metric.value) {\n          metric.value = layoutShiftManager._sessionValue;\n          metric.entries = layoutShiftManager._sessionEntries;\n          report();\n        }\n      };\n\n      const po = observe('layout-shift', handleEntries);\n      if (po) {\n        report = bindReporter(\n          onReport,\n          metric,\n          CLSThresholds,\n          opts!.reportAllChanges,\n        );\n\n        visibilityWatcher.onHidden(() => {\n          handleEntries(po.takeRecords() as CLSMetric['entries']);\n          report(true);\n        });\n\n        // Only report after a bfcache restore if the `PerformanceObserver`\n        // successfully registered.\n        onBFCacheRestore(() => {\n          layoutShiftManager._sessionValue = 0;\n          metric = initMetric('CLS', 0);\n          report = bindReporter(\n            onReport,\n            metric,\n            CLSThresholds,\n            opts!.reportAllChanges,\n          );\n\n          doubleRAF(() => report());\n        });\n\n        // Queue a task to report (if nothing else triggers a report first).\n        // This allows CLS to be reported as soon as FCP fires when\n        // `reportAllChanges` is true.\n        setTimeout(report);\n      }\n    }),\n  );\n};\n"
  },
  {
    "path": "src/onFCP.ts",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {onBFCacheRestore} from './lib/bfcache.js';\nimport {bindReporter} from './lib/bindReporter.js';\nimport {doubleRAF} from './lib/doubleRAF.js';\nimport {getActivationStart} from './lib/getActivationStart.js';\nimport {getVisibilityWatcher} from './lib/getVisibilityWatcher.js';\nimport {initMetric} from './lib/initMetric.js';\nimport {observe} from './lib/observe.js';\nimport {whenActivated} from './lib/whenActivated.js';\nimport {FCPMetric, MetricRatingThresholds, ReportOpts} from './types.js';\n\n/** Thresholds for FCP. See https://web.dev/articles/fcp#what_is_a_good_fcp_score */\nexport const FCPThresholds: MetricRatingThresholds = [1800, 3000];\n\n/**\n * Calculates the [FCP](https://web.dev/articles/fcp) value for the current page and\n * calls the `callback` function once the value is ready, along with the\n * relevant `paint` performance entry used to determine the value. The reported\n * value is a `DOMHighResTimeStamp`.\n */\nexport const onFCP = (\n  onReport: (metric: FCPMetric) => void,\n  opts: ReportOpts = {},\n) => {\n  whenActivated(() => {\n    const visibilityWatcher = getVisibilityWatcher();\n    let metric = initMetric('FCP');\n    let report: ReturnType<typeof bindReporter>;\n\n    const handleEntries = (entries: FCPMetric['entries']) => {\n      for (const entry of entries) {\n        if (entry.name === 'first-contentful-paint') {\n          po!.disconnect();\n\n          // Only report if the page wasn't hidden prior to the first paint.\n          if (entry.startTime < visibilityWatcher.firstHiddenTime) {\n            // The activationStart reference is used because FCP should be\n            // relative to page activation rather than navigation start if the\n            // page was prerendered. But in cases where `activationStart` occurs\n            // after the FCP, this time should be clamped at 0.\n            metric.value = Math.max(entry.startTime - getActivationStart(), 0);\n            metric.entries.push(entry);\n            report(true);\n          }\n        }\n      }\n    };\n\n    const po = observe('paint', handleEntries);\n\n    if (po) {\n      report = bindReporter(\n        onReport,\n        metric,\n        FCPThresholds,\n        opts!.reportAllChanges,\n      );\n\n      // Only report after a bfcache restore if the `PerformanceObserver`\n      // successfully registered or the `paint` entry exists.\n      onBFCacheRestore((event) => {\n        metric = initMetric('FCP');\n        report = bindReporter(\n          onReport,\n          metric,\n          FCPThresholds,\n          opts!.reportAllChanges,\n        );\n\n        doubleRAF(() => {\n          metric.value = performance.now() - event.timeStamp;\n          report(true);\n        });\n      });\n    }\n  });\n};\n"
  },
  {
    "path": "src/onINP.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {onBFCacheRestore} from './lib/bfcache.js';\nimport {bindReporter} from './lib/bindReporter.js';\nimport {initMetric} from './lib/initMetric.js';\nimport {initUnique} from './lib/initUnique.js';\nimport {InteractionManager} from './lib/InteractionManager.js';\nimport {observe} from './lib/observe.js';\nimport {initInteractionCountPolyfill} from './lib/polyfills/interactionCountPolyfill.js';\nimport {whenActivated} from './lib/whenActivated.js';\nimport {getVisibilityWatcher} from './lib/getVisibilityWatcher.js';\nimport {whenIdleOrHidden} from './lib/whenIdleOrHidden.js';\n\nimport {INPMetric, MetricRatingThresholds, INPReportOpts} from './types.js';\n\n/** Thresholds for INP. See https://web.dev/articles/inp#what_is_a_good_inp_score */\nexport const INPThresholds: MetricRatingThresholds = [200, 500];\n\n// The default `durationThreshold` used across this library for observing\n// `event` entries via PerformanceObserver.\nconst DEFAULT_DURATION_THRESHOLD = 40;\n\n/**\n * Calculates the [INP](https://web.dev/articles/inp) value for the current\n * page and calls the `callback` function once the value is ready, along with\n * the `event` performance entries reported for that interaction. The reported\n * value is a `DOMHighResTimeStamp`.\n *\n * A custom `durationThreshold` configuration option can optionally be passed\n * to control what `event-timing` entries are considered for INP reporting. The\n * default threshold is `40`, which means INP scores of less than 40 will not\n * be reported. To avoid reporting no interactions in these cases, the library\n * will fall back to the input delay of the first interaction. Note that this\n * will not affect your 75th percentile INP value unless that value is also\n * less than 40 (well below the recommended\n * [good](https://web.dev/articles/inp#what_is_a_good_inp_score) threshold).\n *\n * If the `reportAllChanges` configuration option is set to `true`, the\n * `callback` function will be called as soon as the value is initially\n * determined as well as any time the value changes throughout the page\n * lifespan.\n *\n * _**Important:** INP should be continually monitored for changes throughout\n * the entire lifespan of a page—including if the user returns to the page after\n * it's been hidden/backgrounded. However, since browsers often [will not fire\n * additional callbacks once the user has backgrounded a\n * page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden),\n * `callback` is always called when the page's visibility state changes to\n * hidden. As a result, the `callback` function might be called multiple times\n * during the same page load._\n */\nexport const onINP = (\n  onReport: (metric: INPMetric) => void,\n  opts: INPReportOpts = {},\n) => {\n  // Return if the browser doesn't support all APIs needed to measure INP.\n  if (\n    !(\n      globalThis.PerformanceEventTiming &&\n      'interactionId' in PerformanceEventTiming.prototype\n    )\n  ) {\n    return;\n  }\n\n  const visibilityWatcher = getVisibilityWatcher();\n\n  whenActivated(() => {\n    // TODO(philipwalton): remove once the polyfill is no longer needed.\n    initInteractionCountPolyfill();\n\n    let metric = initMetric('INP');\n    let report: ReturnType<typeof bindReporter>;\n\n    const interactionManager = initUnique(opts, InteractionManager);\n\n    const handleEntries = (entries: INPMetric['entries']) => {\n      // Queue the `handleEntries()` callback in the next idle task.\n      // This is needed to increase the chances that all event entries that\n      // occurred between the user interaction and the next paint\n      // have been dispatched. Note: there is currently an experiment\n      // running in Chrome (EventTimingKeypressAndCompositionInteractionId)\n      // 123+ that if rolled out fully may make this no longer necessary.\n      whenIdleOrHidden(() => {\n        for (const entry of entries) {\n          interactionManager._processEntry(entry);\n        }\n\n        const inp = interactionManager._estimateP98LongestInteraction();\n\n        if (inp && inp._latency !== metric.value) {\n          metric.value = inp._latency;\n          metric.entries = inp.entries;\n          report();\n        }\n      });\n    };\n\n    const po = observe('event', handleEntries, {\n      // Event Timing entries have their durations rounded to the nearest 8ms,\n      // so a duration of 40ms would be any event that spans 2.5 or more frames\n      // at 60Hz. This threshold is chosen to strike a balance between usefulness\n      // and performance. Running this callback for any interaction that spans\n      // just one or two frames is likely not worth the insight that could be\n      // gained.\n      durationThreshold: opts.durationThreshold ?? DEFAULT_DURATION_THRESHOLD,\n    });\n\n    report = bindReporter(\n      onReport,\n      metric,\n      INPThresholds,\n      opts.reportAllChanges,\n    );\n\n    if (po) {\n      // Also observe entries of type `first-input`. This is useful in cases\n      // where the first interaction is less than the `durationThreshold`.\n      po.observe({type: 'first-input', buffered: true});\n\n      visibilityWatcher.onHidden(() => {\n        handleEntries(po.takeRecords() as INPMetric['entries']);\n        report(true);\n      });\n\n      // Only report after a bfcache restore if the `PerformanceObserver`\n      // successfully registered.\n      onBFCacheRestore(() => {\n        interactionManager._resetInteractions();\n\n        metric = initMetric('INP');\n        report = bindReporter(\n          onReport,\n          metric,\n          INPThresholds,\n          opts.reportAllChanges,\n        );\n      });\n    }\n  });\n};\n"
  },
  {
    "path": "src/onLCP.ts",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {LCPEntryManager} from './lib/LCPEntryManager.js';\nimport {onBFCacheRestore} from './lib/bfcache.js';\nimport {bindReporter} from './lib/bindReporter.js';\nimport {doubleRAF} from './lib/doubleRAF.js';\nimport {getActivationStart} from './lib/getActivationStart.js';\nimport {getVisibilityWatcher} from './lib/getVisibilityWatcher.js';\nimport {initMetric} from './lib/initMetric.js';\nimport {initUnique} from './lib/initUnique.js';\nimport {observe} from './lib/observe.js';\nimport {runOnce} from './lib/runOnce.js';\nimport {whenActivated} from './lib/whenActivated.js';\nimport {whenIdleOrHidden} from './lib/whenIdleOrHidden.js';\nimport {LCPMetric, MetricRatingThresholds, ReportOpts} from './types.js';\n\n/** Thresholds for LCP. See https://web.dev/articles/lcp#what_is_a_good_lcp_score */\nexport const LCPThresholds: MetricRatingThresholds = [2500, 4000];\n\n/**\n * Calculates the [LCP](https://web.dev/articles/lcp) value for the current page and\n * calls the `callback` function once the value is ready (along with the\n * relevant `largest-contentful-paint` performance entry used to determine the\n * value). The reported value is a `DOMHighResTimeStamp`.\n *\n * If the `reportAllChanges` configuration option is set to `true`, the\n * `callback` function will be called any time a new `largest-contentful-paint`\n * performance entry is dispatched, or once the final value of the metric has\n * been determined.\n */\nexport const onLCP = (\n  onReport: (metric: LCPMetric) => void,\n  opts: ReportOpts = {},\n) => {\n  whenActivated(() => {\n    const visibilityWatcher = getVisibilityWatcher();\n    let metric = initMetric('LCP');\n    let report: ReturnType<typeof bindReporter>;\n\n    const lcpEntryManager = initUnique(opts, LCPEntryManager);\n\n    const handleEntries = (entries: LCPMetric['entries']) => {\n      // If reportAllChanges is set then call this function for each entry,\n      // otherwise only consider the last one.\n      if (!opts!.reportAllChanges) {\n        entries = entries.slice(-1);\n      }\n\n      for (const entry of entries) {\n        lcpEntryManager._processEntry(entry);\n\n        // Only report if the page wasn't hidden prior to LCP.\n        if (entry.startTime < visibilityWatcher.firstHiddenTime) {\n          // The startTime attribute returns the value of the renderTime if it is\n          // not 0, and the value of the loadTime otherwise. The activationStart\n          // reference is used because LCP should be relative to page activation\n          // rather than navigation start if the page was prerendered. But in cases\n          // where `activationStart` occurs after the LCP, this time should be\n          // clamped at 0.\n          metric.value = Math.max(entry.startTime - getActivationStart(), 0);\n          metric.entries = [entry];\n          report();\n        }\n      }\n    };\n\n    const po = observe('largest-contentful-paint', handleEntries);\n\n    if (po) {\n      report = bindReporter(\n        onReport,\n        metric,\n        LCPThresholds,\n        opts!.reportAllChanges,\n      );\n\n      // Ensure this logic only runs once, since it can be triggered from\n      // any of three different event listeners below.\n      const stopListening = runOnce(() => {\n        handleEntries(po!.takeRecords() as LCPMetric['entries']);\n        po!.disconnect();\n        report(true);\n      });\n\n      // Need a separate wrapper to ensure the `runOnce` function above is\n      // common for all three functions\n      const stopListeningWrapper = (event: Event) => {\n        if (event.isTrusted) {\n          // Wrap the listener in an idle callback so it's run in a separate\n          // task to reduce potential INP impact.\n          // https://github.com/GoogleChrome/web-vitals/issues/383\n          whenIdleOrHidden(stopListening);\n          removeEventListener(event.type, stopListeningWrapper, {\n            capture: true,\n          });\n        }\n      };\n\n      // Stop listening after input or visibilitychange.\n      // Note: while scrolling is an input that stops LCP observation, it's\n      // unreliable since it can be programmatically generated.\n      // See: https://github.com/GoogleChrome/web-vitals/issues/75\n      for (const type of ['keydown', 'click', 'visibilitychange']) {\n        addEventListener(type, stopListeningWrapper, {\n          capture: true,\n        });\n      }\n\n      // Only report after a bfcache restore if the `PerformanceObserver`\n      // successfully registered.\n      onBFCacheRestore((event) => {\n        metric = initMetric('LCP');\n        report = bindReporter(\n          onReport,\n          metric,\n          LCPThresholds,\n          opts!.reportAllChanges,\n        );\n\n        doubleRAF(() => {\n          metric.value = performance.now() - event.timeStamp;\n          report(true);\n        });\n      });\n    }\n  });\n};\n"
  },
  {
    "path": "src/onTTFB.ts",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {bindReporter} from './lib/bindReporter.js';\nimport {initMetric} from './lib/initMetric.js';\nimport {onBFCacheRestore} from './lib/bfcache.js';\nimport {getNavigationEntry} from './lib/getNavigationEntry.js';\nimport {MetricRatingThresholds, ReportOpts, TTFBMetric} from './types.js';\nimport {getActivationStart} from './lib/getActivationStart.js';\nimport {whenActivated} from './lib/whenActivated.js';\n\n/** Thresholds for TTFB. See https://web.dev/articles/ttfb#what_is_a_good_ttfb_score */\nexport const TTFBThresholds: MetricRatingThresholds = [800, 1800];\n\n/**\n * Runs in the next task after the page is done loading and/or prerendering.\n * @param callback\n */\nconst whenReady = (callback: () => void) => {\n  if (document.prerendering) {\n    whenActivated(() => whenReady(callback));\n  } else if (document.readyState !== 'complete') {\n    addEventListener('load', () => whenReady(callback), true);\n  } else {\n    // Queue a task so the callback runs after `loadEventEnd`.\n    setTimeout(callback);\n  }\n};\n\n/**\n * Calculates the [TTFB](https://web.dev/articles/ttfb) value for the\n * current page and calls the `callback` function once the page has loaded,\n * along with the relevant `navigation` performance entry used to determine the\n * value. The reported value is a `DOMHighResTimeStamp`.\n *\n * Note, this function waits until after the page is loaded to call `callback`\n * in order to ensure all properties of the `navigation` entry are populated.\n * This is useful if you want to report on other metrics exposed by the\n * [Navigation Timing API](https://w3c.github.io/navigation-timing/). For\n * example, the TTFB metric starts from the page's [time\n * origin](https://www.w3.org/TR/hr-time-2/#sec-time-origin), which means it\n * includes time spent on DNS lookup, connection negotiation, network latency,\n * and server processing time.\n */\nexport const onTTFB = (\n  onReport: (metric: TTFBMetric) => void,\n  opts: ReportOpts = {},\n) => {\n  let metric = initMetric('TTFB');\n  let report = bindReporter(\n    onReport,\n    metric,\n    TTFBThresholds,\n    opts.reportAllChanges,\n  );\n\n  whenReady(() => {\n    const navigationEntry = getNavigationEntry();\n\n    if (navigationEntry) {\n      // The activationStart reference is used because TTFB should be\n      // relative to page activation rather than navigation start if the\n      // page was prerendered. But in cases where `activationStart` occurs\n      // after the first byte is received, this time should be clamped at 0.\n      metric.value = Math.max(\n        navigationEntry.responseStart - getActivationStart(),\n        0,\n      );\n\n      metric.entries = [navigationEntry];\n      report(true);\n\n      // Only report TTFB after bfcache restores if a `navigation` entry\n      // was reported for the initial load.\n      onBFCacheRestore(() => {\n        metric = initMetric('TTFB', 0);\n        report = bindReporter(\n          onReport,\n          metric,\n          TTFBThresholds,\n          opts!.reportAllChanges,\n        );\n\n        report(true);\n      });\n    }\n  });\n};\n"
  },
  {
    "path": "src/types/base.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {CLSMetric, CLSMetricWithAttribution} from './cls.js';\nimport type {FCPMetric, FCPMetricWithAttribution} from './fcp.js';\nimport type {INPMetric, INPMetricWithAttribution} from './inp.js';\nimport type {LCPMetric, LCPMetricWithAttribution} from './lcp.js';\nimport type {TTFBMetric, TTFBMetricWithAttribution} from './ttfb.js';\n\nexport interface Metric {\n  /**\n   * The name of the metric (in acronym form).\n   */\n  name: 'CLS' | 'FCP' | 'INP' | 'LCP' | 'TTFB';\n\n  /**\n   * The current value of the metric.\n   */\n  value: number;\n\n  /**\n   * The rating as to whether the metric value is within the \"good\",\n   * \"needs improvement\", or \"poor\" thresholds of the metric.\n   */\n  rating: 'good' | 'needs-improvement' | 'poor';\n\n  /**\n   * The delta between the current value and the last-reported value.\n   * On the first report, `delta` and `value` will always be the same.\n   */\n  delta: number;\n\n  /**\n   * A unique ID representing this particular metric instance. This ID can\n   * be used by an analytics tool to dedupe multiple values sent for the same\n   * metric instance, or to group multiple deltas together and calculate a\n   * total. It can also be used to differentiate multiple different metric\n   * instances sent from the same page, which can happen if the page is\n   * restored from the back/forward cache (in that case new metrics object\n   * get created).\n   */\n  id: string;\n\n  /**\n   * Any performance entries relevant to the metric value calculation.\n   * The array may also be empty if the metric value was not based on any\n   * entries (e.g. a CLS value of 0 given no layout shifts).\n   */\n  entries: PerformanceEntry[];\n\n  /**\n   * The type of navigation.\n   *\n   * This will be the value returned by the Navigation Timing API (or\n   * `undefined` if the browser doesn't support that API), with the following\n   * exceptions:\n   * - 'back-forward-cache': for pages that are restored from the bfcache.\n   * - 'back_forward' is renamed to 'back-forward' for consistency.\n   * - 'prerender': for pages that were prerendered.\n   * - 'restore': for pages that were discarded by the browser and then\n   * restored by the user.\n   */\n  navigationType:\n    | 'navigate'\n    | 'reload'\n    | 'back-forward'\n    | 'back-forward-cache'\n    | 'prerender'\n    | 'restore';\n}\n\n/** The union of supported metric types. */\nexport type MetricType =\n  | CLSMetric\n  | FCPMetric\n  | INPMetric\n  | LCPMetric\n  | TTFBMetric;\n\n/** The union of supported metric attribution types. */\nexport type MetricWithAttribution =\n  | CLSMetricWithAttribution\n  | FCPMetricWithAttribution\n  | INPMetricWithAttribution\n  | LCPMetricWithAttribution\n  | TTFBMetricWithAttribution;\n\n/**\n * The thresholds of metric's \"good\", \"needs improvement\", and \"poor\" ratings.\n *\n * - Metric values up to and including [0] are rated \"good\"\n * - Metric values up to and including [1] are rated \"needs improvement\"\n * - Metric values above [1] are \"poor\"\n *\n * | Metric value    | Rating              |\n * | --------------- | ------------------- |\n * | ≦ [0]           | \"good\"              |\n * | > [0] and ≦ [1] | \"needs improvement\" |\n * | > [1]           | \"poor\"              |\n */\nexport type MetricRatingThresholds = [number, number];\n\n/**\n * @deprecated Use metric-specific function types instead, such as:\n * `(metric: LCPMetric) => void`. If a single callback type is needed for\n * multiple metrics, use `(metric: MetricType) => void`.\n */\nexport interface ReportCallback {\n  (metric: MetricType): void;\n}\n\nexport interface ReportOpts {\n  reportAllChanges?: boolean;\n}\n\nexport interface AttributionReportOpts extends ReportOpts {\n  generateTarget?: (el: Node | null) => string | undefined;\n}\n\n/**\n * The loading state of the document. Note: this value is similar to\n * `document.readyState` but it subdivides the \"interactive\" state into the\n * time before and after the DOMContentLoaded event fires.\n *\n * State descriptions:\n * - `loading`: the initial document response has not yet been fully downloaded\n *   and parsed. This is equivalent to the corresponding `readyState` value.\n * - `dom-interactive`: the document has been fully loaded and parsed, but\n *   scripts may not have yet finished loading and executing.\n * - `dom-content-loaded`: the document is fully loaded and parsed, and all\n *   scripts (except `async` scripts) have loaded and finished executing.\n * - `complete`: the document and all of its sub-resources have finished\n *   loading. This is equivalent to the corresponding `readyState` value.\n */\nexport type LoadState =\n  | 'loading'\n  | 'dom-interactive'\n  | 'dom-content-loaded'\n  | 'complete';\n"
  },
  {
    "path": "src/types/cls.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {LoadState, Metric} from './base.js';\n\n/**\n * A CLS-specific version of the Metric object.\n */\nexport interface CLSMetric extends Metric {\n  name: 'CLS';\n  entries: LayoutShift[];\n}\n\n/**\n * An object containing potentially-helpful debugging information that\n * can be sent along with the CLS value for the current page visit in order\n * to help identify issues happening to real-users in the field.\n */\nexport interface CLSAttribution {\n  /**\n   * By default, a selector identifying the first element (in document order)\n   * that shifted when the single largest layout shift that contributed to the\n   * page's CLS score occurred. If the `generateTarget` configuration option\n   * was passed, then this will instead be the return value of that function,\n   * falling back to the default if that returns null or undefined.\n   */\n  largestShiftTarget?: string;\n  /**\n   * The time when the single largest layout shift contributing to the page's\n   * CLS score occurred.\n   */\n  largestShiftTime?: DOMHighResTimeStamp;\n  /**\n   * The layout shift score of the single largest layout shift contributing to\n   * the page's CLS score.\n   */\n  largestShiftValue?: number;\n  /**\n   * The `LayoutShiftEntry` representing the single largest layout shift\n   * contributing to the page's CLS score. (Useful when you need more than just\n   * `largestShiftTarget`, `largestShiftTime`, and `largestShiftValue`).\n   */\n  largestShiftEntry?: LayoutShift;\n  /**\n   * The first element source (in document order) among the `sources` list\n   * of the `largestShiftEntry` object. (Also useful when you need more than\n   * just `largestShiftTarget`, `largestShiftTime`, and `largestShiftValue`).\n   */\n  largestShiftSource?: LayoutShiftAttribution;\n  /**\n   * The loading state of the document at the time when the largest layout\n   * shift contribution to the page's CLS score occurred (see `LoadState`\n   * for details).\n   */\n  loadState?: LoadState;\n}\n\n/**\n * A CLS-specific version of the Metric object with attribution.\n */\nexport interface CLSMetricWithAttribution extends CLSMetric {\n  attribution: CLSAttribution;\n}\n"
  },
  {
    "path": "src/types/fcp.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {LoadState, Metric} from './base.js';\n\n/**\n * An FCP-specific version of the Metric object.\n */\nexport interface FCPMetric extends Metric {\n  name: 'FCP';\n  entries: PerformancePaintTiming[];\n}\n\n/**\n * An object containing potentially-helpful debugging information that\n * can be sent along with the FCP value for the current page visit in order\n * to help identify issues happening to real-users in the field.\n */\nexport interface FCPAttribution {\n  /**\n   * The time from when the user initiates loading the page until when the\n   * browser receives the first byte of the response (a.k.a. TTFB).\n   */\n  timeToFirstByte: number;\n  /**\n   * The delta between TTFB and the first contentful paint (FCP).\n   */\n  firstByteToFCP: number;\n  /**\n   * The loading state of the document at the time when FCP `occurred (see\n   * `LoadState` for details). Ideally, documents can paint before they finish\n   * loading (e.g. the `loading` or `dom-interactive` phases).\n   */\n  loadState: LoadState;\n  /**\n   * The `PerformancePaintTiming` entry corresponding to FCP.\n   */\n  fcpEntry?: PerformancePaintTiming;\n  /**\n   * The `navigation` entry of the current page, which is useful for diagnosing\n   * general page load issues. This can be used to access `serverTiming` for example:\n   * navigationEntry?.serverTiming\n   */\n  navigationEntry?: PerformanceNavigationTiming;\n}\n\n/**\n * An FCP-specific version of the Metric object with attribution.\n */\nexport interface FCPMetricWithAttribution extends FCPMetric {\n  attribution: FCPAttribution;\n}\n"
  },
  {
    "path": "src/types/inp.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n  LoadState,\n  Metric,\n  ReportOpts,\n  AttributionReportOpts,\n} from './base.js';\n\nexport interface INPReportOpts extends ReportOpts {\n  durationThreshold?: number;\n}\n\nexport interface INPAttributionReportOpts extends AttributionReportOpts {\n  durationThreshold?: number;\n}\n\n/**\n * An INP-specific version of the Metric object.\n */\nexport interface INPMetric extends Metric {\n  name: 'INP';\n  entries: PerformanceEventTiming[];\n}\n\nexport interface INPLongestScriptSummary {\n  /**\n   * The longest Long Animation Frame script entry that intersects the INP\n   * interaction.\n   */\n  entry: PerformanceScriptTiming;\n  /**\n   * The INP subpart where the longest script ran.\n   */\n  subpart: 'input-delay' | 'processing-duration' | 'presentation-delay';\n  /**\n   * The amount of time the longest script intersected the INP duration.\n   */\n  intersectingDuration: number;\n}\n\n/**\n * An object containing potentially-helpful debugging information that\n * can be sent along with the INP value for the current page visit in order\n * to help identify issues happening to real-users in the field.\n */\nexport interface INPAttribution {\n  /**\n   * By default, a selector identifying the element that the user first\n   * interacted with as part of the frame where the INP candidate interaction\n   * occurred. If this value is an empty string, that generally means the\n   * element was removed from the DOM after the interaction. If the\n   * `generateTarget` configuration option was passed, then this will instead\n   * be the return value of that function, falling back to the default if that\n   * returns null or undefined.\n   */\n  interactionTarget: string;\n  /**\n   * The time when the user first interacted during the frame where the INP\n   * candidate interaction occurred (if more than one interaction occurred\n   * within the frame, only the first time is reported).\n   */\n  interactionTime: DOMHighResTimeStamp;\n  /**\n   * The type of interaction, based on the event type of the `event` entry\n   * that corresponds to the interaction (i.e. the first `event` entry\n   * containing an `interactionId` dispatched in a given animation frame).\n   * For \"pointerdown\", \"pointerup\", or \"click\" events this will be \"pointer\",\n   * and for \"keydown\" or \"keyup\" events this will be \"keyboard\".\n   */\n  interactionType: 'pointer' | 'keyboard';\n  /**\n   * The best-guess timestamp of the next paint after the interaction.\n   * In general, this timestamp is the same as the `startTime + duration` of\n   * the event timing entry. However, since duration values are rounded to the\n   * nearest 8ms (and can be rounded down), this value is clamped to always be\n   * reported after the processing times.\n   */\n  nextPaintTime: DOMHighResTimeStamp;\n  /**\n   * An array of Event Timing entries that were processed within the same\n   * animation frame as the INP candidate interaction. Note this is capped to\n   * a max of 5 entries (the first 4 + the last one) to conserve memory.\n   */\n  processedEventEntries: PerformanceEventTiming[];\n  /**\n   * The time from when the user interacted with the page until when the\n   * browser was first able to start processing event listeners for that\n   * interaction. This time captures the delay before event processing can\n   * begin due to the main thread being busy with other work.\n   */\n  inputDelay: number;\n  /**\n   * The time from when the first event listener started running in response to\n   * the user interaction until when all event listener processing has finished.\n   */\n  processingDuration: number;\n  /**\n   * The time from when the browser finished processing all event listeners for\n   * the user interaction until the next frame is presented on the screen and\n   * visible to the user. This time includes work on the main thread (such as\n   * `requestAnimationFrame()` callbacks, `ResizeObserver` and\n   * `IntersectionObserver` callbacks, and style/layout calculation) as well\n   * as off-main-thread work (such as compositor, GPU, and raster work).\n   */\n  presentationDelay: number;\n  /**\n   * The loading state of the document at the time when the interaction\n   * corresponding to INP occurred (see `LoadState` for details). If the\n   * interaction occurred while the document was loading and executing script\n   * (e.g. usually in the `dom-interactive` phase) it can result in long delays.\n   */\n  loadState: LoadState;\n  /**\n   * If the browser supports the Long Animation Frame API, this array will\n   * include any `long-animation-frame` entries that intersect with the INP\n   * candidate interaction's `startTime` and the `processingEnd` time of the\n   * last event processed within that animation frame. If the browser does not\n   * support the Long Animation Frame API or no `long-animation-frame` entries\n   * are detected, this array will be empty.\n   */\n  longAnimationFrameEntries: PerformanceLongAnimationFrameTiming[];\n  /**\n   * Summary information about the longest script entry intersecting the INP\n   * duration. Note, only script entries above 5 milliseconds are reported by\n   * the Long Animation Frame API.\n   */\n  longestScript?: INPLongestScriptSummary;\n  /**\n   * The total duration of Long Animation Frame scripts that intersect the INP\n   * duration excluding any forced style and layout (that is included in\n   * totalStyleAndLayout). Note, this is limited to scripts > 5 milliseconds.\n   */\n  totalScriptDuration?: number;\n  /**\n   * The total style and layout duration from any Long Animation Frames\n   * intersecting the INP interaction. This includes any end-of-frame style and\n   * layout duration + any forced style and layout duration.\n   */\n  totalStyleAndLayoutDuration?: number;\n  /**\n   * The off main-thread presentation delay from the end of the last Long\n   * Animation Frame (where available) until the INP end point.\n   */\n  totalPaintDuration?: number;\n  /**\n   * The total unattributed time not included in any of the previous totals.\n   * This includes scripts < 5 milliseconds and other timings not attributed\n   * by Long Animation Frame (including when a frame is < 50ms and so has no\n   * Long Animation Frame).\n   * When no Long Animation Frames are present this will be undefined, rather\n   * than everything being unattributed to make it clearer when it's expected\n   * to be small.\n   */\n  totalUnattributedDuration?: number;\n}\n\n/**\n * An INP-specific version of the Metric object with attribution.\n */\nexport interface INPMetricWithAttribution extends INPMetric {\n  attribution: INPAttribution;\n}\n"
  },
  {
    "path": "src/types/lcp.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {Metric} from './base.js';\n\n/**\n * An LCP-specific version of the Metric object.\n */\nexport interface LCPMetric extends Metric {\n  name: 'LCP';\n  entries: LargestContentfulPaint[];\n}\n\n/**\n * An object containing potentially-helpful debugging information that\n * can be sent along with the LCP value for the current page visit in order\n * to help identify issues happening to real-users in the field.\n */\nexport interface LCPAttribution {\n  /**\n   * By default, a selector identifying the element corresponding to the\n   * largest contentful paint for the page. If the `generateTarget`\n   * configuration option was passed, then this will instead be the return\n   * value of that function, falling back to the default if that returns null\n   * or undefined.\n   */\n  target?: string;\n  /**\n   * The URL (if applicable) of the LCP image resource. If the LCP element\n   * is a text node, this value will not be set.\n   */\n  url?: string;\n  /**\n   * The time from when the user initiates loading the page until when the\n   * browser receives the first byte of the response (a.k.a. TTFB). See\n   * [Optimize LCP](https://web.dev/articles/optimize-lcp) for details.\n   */\n  timeToFirstByte: number;\n  /**\n   * The delta between TTFB and when the browser starts loading the LCP\n   * resource (if there is one, otherwise 0). See [Optimize\n   * LCP](https://web.dev/articles/optimize-lcp) for details.\n   */\n  resourceLoadDelay: number;\n  /**\n   * The total time it takes to load the LCP resource itself (if there is one,\n   * otherwise 0). See [Optimize LCP](https://web.dev/articles/optimize-lcp) for\n   * details.\n   */\n  resourceLoadDuration: number;\n  /**\n   * The delta between when the LCP resource finishes loading until the LCP\n   * element is fully rendered. See [Optimize\n   * LCP](https://web.dev/articles/optimize-lcp) for details.\n   */\n  elementRenderDelay: number;\n  /**\n   * The `navigation` entry of the current page, which is useful for diagnosing\n   * general page load issues. This can be used to access `serverTiming` for example:\n   * navigationEntry?.serverTiming\n   */\n  navigationEntry?: PerformanceNavigationTiming;\n  /**\n   * The `resource` entry for the LCP resource (if applicable), which is useful\n   * for diagnosing resource load issues.\n   */\n  lcpResourceEntry?: PerformanceResourceTiming;\n  /**\n   * The `LargestContentfulPaint` entry corresponding to LCP.\n   */\n  lcpEntry?: LargestContentfulPaint;\n}\n\n/**\n * An LCP-specific version of the Metric object with attribution.\n */\nexport interface LCPMetricWithAttribution extends LCPMetric {\n  attribution: LCPAttribution;\n}\n"
  },
  {
    "path": "src/types/ttfb.ts",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {Metric} from './base.js';\n\n/**\n * A TTFB-specific version of the Metric object.\n */\nexport interface TTFBMetric extends Metric {\n  name: 'TTFB';\n  entries: PerformanceNavigationTiming[];\n}\n\n/**\n * An object containing potentially-helpful debugging information that\n * can be sent along with the TTFB value for the current page visit in order\n * to help identify issues happening to real-users in the field.\n *\n * NOTE: these values are primarily useful for page loads not handled via\n * service worker, as browsers differ in what they report when service worker\n * is involved, see: https://github.com/w3c/navigation-timing/issues/199\n */\nexport interface TTFBAttribution {\n  /**\n   * The total time from when the user initiates loading the page to when the\n   * page starts to handle the request. Large values here are typically due\n   * to HTTP redirects, though other browser processing contributes to this\n   * duration as well (so even without redirect it's generally not zero).\n   */\n  waitingDuration: number;\n  /**\n   * The total time spent checking the HTTP cache for a match. For navigations\n   * handled via service worker, this duration usually includes service worker\n   * start-up time as well as time processing `fetch` event listeners, with\n   * some exceptions, see: https://github.com/w3c/navigation-timing/issues/199\n   */\n  cacheDuration: number;\n  /**\n   * The total time to resolve the DNS for the requested domain.\n   */\n  dnsDuration: number;\n  /**\n   * The total time to create the connection to the requested domain.\n   */\n  connectionDuration: number;\n  /**\n   * The total time from when the request was sent until the first byte of the\n   * response was received. This includes network time as well as server\n   * processing time.\n   */\n  requestDuration: number;\n  /**\n   * The `navigation` entry of the current page, which is useful for diagnosing\n   * general page load issues. This can be used to access `serverTiming` for\n   * example: navigationEntry?.serverTiming\n   */\n  navigationEntry?: PerformanceNavigationTiming;\n}\n\n/**\n * A TTFB-specific version of the Metric object with attribution.\n */\nexport interface TTFBMetricWithAttribution extends TTFBMetric {\n  attribution: TTFBAttribution;\n}\n"
  },
  {
    "path": "src/types.ts",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nexport * from './types/base.js';\n\nexport * from './types/cls.js';\nexport * from './types/fcp.js';\nexport * from './types/inp.js';\nexport * from './types/lcp.js';\nexport * from './types/ttfb.js';\n\n// --------------------------------------------------------------------------\n// Everything below is modifications to built-in modules.\n// --------------------------------------------------------------------------\n\ninterface PerformanceEntryMap {\n  navigation: PerformanceNavigationTiming;\n  resource: PerformanceResourceTiming;\n  paint: PerformancePaintTiming;\n}\n\n// Update built-in types to be more accurate.\ndeclare global {\n  interface Document {\n    // https://wicg.github.io/nav-speculation/prerendering.html#document-prerendering\n    prerendering?: boolean;\n    // https://wicg.github.io/page-lifecycle/#sec-api\n    wasDiscarded?: boolean;\n  }\n\n  interface Performance {\n    getEntriesByType<K extends keyof PerformanceEntryMap>(\n      type: K,\n    ): PerformanceEntryMap[K][];\n  }\n\n  // https://w3c.github.io/event-timing/#sec-modifications-perf-timeline\n  interface PerformanceObserverInit {\n    durationThreshold?: number;\n  }\n\n  // https://wicg.github.io/nav-speculation/prerendering.html#performance-navigation-timing-extension\n  interface PerformanceNavigationTiming {\n    activationStart?: number;\n  }\n\n  // https://wicg.github.io/event-timing/#sec-performance-event-timing\n  interface PerformanceEventTiming extends PerformanceEntry {\n    duration: DOMHighResTimeStamp;\n    readonly interactionId: number;\n  }\n\n  // https://wicg.github.io/layout-instability/#sec-layout-shift-attribution\n  interface LayoutShiftAttribution {\n    node: Node | null;\n    previousRect: DOMRectReadOnly;\n    currentRect: DOMRectReadOnly;\n  }\n\n  // https://wicg.github.io/layout-instability/#sec-layout-shift\n  interface LayoutShift extends PerformanceEntry {\n    value: number;\n    sources: LayoutShiftAttribution[];\n    hadRecentInput: boolean;\n  }\n\n  // https://w3c.github.io/largest-contentful-paint/#sec-largest-contentful-paint-interface\n  interface LargestContentfulPaint extends PerformanceEntry {\n    readonly renderTime: DOMHighResTimeStamp;\n    readonly loadTime: DOMHighResTimeStamp;\n    readonly size: number;\n    readonly id: string;\n    readonly url: string;\n    readonly element: Element | null;\n  }\n\n  // https://w3c.github.io/long-animation-frame/#sec-PerformanceLongAnimationFrameTiming\n  export type ScriptInvokerType =\n    | 'classic-script'\n    | 'module-script'\n    | 'event-listener'\n    | 'user-callback'\n    | 'resolve-promise'\n    | 'reject-promise';\n\n  // https://w3c.github.io/long-animation-frame/#sec-PerformanceLongAnimationFrameTiming\n  export type ScriptWindowAttribution =\n    | 'self'\n    | 'descendant'\n    | 'ancestor'\n    | 'same-page'\n    | 'other';\n\n  // https://w3c.github.io/long-animation-frame/#sec-PerformanceLongAnimationFrameTiming\n  interface PerformanceScriptTiming extends PerformanceEntry {\n    /* Overloading PerformanceEntry */\n    readonly startTime: DOMHighResTimeStamp;\n    readonly duration: DOMHighResTimeStamp;\n    readonly name: string;\n    readonly entryType: string;\n\n    readonly invokerType: ScriptInvokerType;\n    readonly invoker: string;\n    readonly executionStart: DOMHighResTimeStamp;\n    readonly sourceURL: string;\n    readonly sourceFunctionName: string;\n    readonly sourceCharPosition: number;\n    readonly pauseDuration: DOMHighResTimeStamp;\n    readonly forcedStyleAndLayoutDuration: DOMHighResTimeStamp;\n    readonly window?: Window;\n    readonly windowAttribution: ScriptWindowAttribution;\n  }\n\n  // https://w3c.github.io/long-animation-frame/#sec-PerformanceLongAnimationFrameTiming\n  interface PerformanceLongAnimationFrameTiming extends PerformanceEntry {\n    readonly startTime: DOMHighResTimeStamp;\n    readonly duration: DOMHighResTimeStamp;\n    readonly name: string;\n    readonly entryType: string;\n    readonly renderStart: DOMHighResTimeStamp;\n    readonly styleAndLayoutStart: DOMHighResTimeStamp;\n    readonly blockingDuration: DOMHighResTimeStamp;\n    readonly firstUIEventTimestamp: DOMHighResTimeStamp;\n    readonly scripts: PerformanceScriptTiming[];\n  }\n}\n"
  },
  {
    "path": "test/css/styles.css",
    "content": "body {\n  background-color: yellow;\n}\n"
  },
  {
    "path": "test/e2e/onCLS-test.js",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport assert from 'assert';\nimport {beaconCountIs, clearBeacons, getBeacons} from '../utils/beacons.js';\nimport {browserSupportsEntry} from '../utils/browserSupportsEntry.js';\nimport {firstContentfulPaint} from '../utils/firstContentfulPaint.js';\nimport {imagesPainted} from '../utils/imagesPainted.js';\nimport {navigateTo} from '../utils/navigateTo.js';\nimport {nextFrame} from '../utils/nextFrame.js';\nimport {stubForwardBack} from '../utils/stubForwardBack.js';\nimport {stubVisibilityChange} from '../utils/stubVisibilityChange.js';\n\nlet marginTop = 0;\n\ndescribe('onCLS()', async function () {\n  // Retry all tests in this suite up to 2 times.\n  this.retries(2);\n\n  let browserSupportsCLS;\n  let browserSupportsPrerender;\n  before(async function () {\n    browserSupportsCLS = await browserSupportsEntry('layout-shift');\n    browserSupportsPrerender = await browser.execute(() => {\n      return 'onprerenderingchange' in document;\n    });\n\n    // Set a standard screen size so thresholds are the same\n    browser.setWindowSize(1280, 1024);\n  });\n\n  beforeEach(async function () {\n    await navigateTo('about:blank');\n    await clearBeacons();\n    marginTop = 0;\n  });\n\n  it('reports the correct value on visibility hidden after shifts (reportAllChanges === false)', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    await navigateTo('/test/cls');\n\n    // Wait until all images are loaded and rendered, then change to hidden.\n    await imagesPainted();\n    await stubVisibilityChange('hidden');\n\n    await beaconCountIs(1);\n\n    const [cls] = await getBeacons();\n    assert(cls.value > 0);\n    assert(cls.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls.name, 'CLS');\n    assert.strictEqual(cls.value, cls.delta);\n    assert.strictEqual(cls.rating, 'good');\n    assert.strictEqual(cls.entries.length, 2);\n    assert.match(cls.navigationType, /navigate|reload/);\n  });\n\n  it('reports the correct value on visibility hidden after shifts (reportAllChanges === true)', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    await navigateTo('/test/cls?reportAllChanges=1');\n\n    // Wait until all images are loaded and rendered, then change to hidden.\n    await imagesPainted();\n    await stubVisibilityChange('hidden');\n\n    await beaconCountIs(3);\n\n    const [cls1, cls2, cls3] = await getBeacons();\n\n    assert.strictEqual(cls1.value, 0);\n    assert(cls1.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls1.name, 'CLS');\n    assert.strictEqual(cls1.value, cls1.delta);\n    assert.strictEqual(cls1.rating, 'good');\n    assert.strictEqual(cls1.entries.length, 0);\n    assert.match(cls1.navigationType, /navigate|reload/);\n\n    assert.strictEqual(cls2.value, cls1.value + cls2.delta);\n    assert.strictEqual(cls2.id, cls1.id);\n    assert.strictEqual(cls2.name, 'CLS');\n    assert.strictEqual(cls2.rating, 'good');\n    assert.strictEqual(cls2.entries.length, 1);\n    assert.match(cls2.navigationType, /navigate|reload/);\n\n    assert.strictEqual(cls3.value, cls2.value + cls3.delta);\n    assert.strictEqual(cls3.id, cls2.id);\n    assert.strictEqual(cls3.name, 'CLS');\n    assert.strictEqual(cls3.rating, 'good');\n    assert.strictEqual(cls3.entries.length, 2);\n    assert.match(cls3.navigationType, /navigate|reload/);\n  });\n\n  it('reports the correct value on page unload after shifts (reportAllChanges === false)', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    await navigateTo('/test/cls');\n\n    // Wait until all images are loaded and rendered, then change to hidden.\n    await imagesPainted();\n    await navigateTo('about:blank');\n\n    await beaconCountIs(1);\n\n    const [cls] = await getBeacons();\n    assert(cls.value > 0);\n    assert(cls.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls.name, 'CLS');\n    assert.strictEqual(cls.value, cls.delta);\n    assert.strictEqual(cls.rating, 'good');\n    assert.strictEqual(cls.entries.length, 2);\n    assert.match(cls.navigationType, /navigate|reload/);\n  });\n\n  it('reports the correct value even if loaded late (reportAllChanges === false)', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    await navigateTo(`/test/cls?lazyLoad=1`, {readyState: 'complete'});\n\n    // Wait until all images are loaded and rendered, then change to hidden.\n    await imagesPainted();\n    await stubVisibilityChange('hidden');\n\n    await beaconCountIs(1);\n\n    const [cls] = await getBeacons();\n    assert(cls.value > 0);\n    assert(cls.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls.name, 'CLS');\n    assert.strictEqual(cls.value, cls.delta);\n    assert.strictEqual(cls.rating, 'good');\n    assert.strictEqual(cls.entries.length, 2);\n    assert.match(cls.navigationType, /navigate|reload/);\n  });\n\n  it('reports the correct value even if loaded late (reportAllChanges === true)', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    await navigateTo(`/test/cls?lazyLoad=1&reportAllChanges=1`, {\n      readyState: 'complete',\n    });\n\n    // Wait until all images are loaded and rendered, then change to hidden.\n    await imagesPainted();\n    await stubVisibilityChange('hidden');\n\n    // Two shifts should have happened, but since the library loads after\n    // the shifts are done, there should only be a single report.\n    await beaconCountIs(1);\n\n    const [cls] = await getBeacons();\n    assert(cls.value > 0);\n    assert(cls.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls.name, 'CLS');\n    assert.strictEqual(cls.value, cls.delta);\n    assert.strictEqual(cls.rating, 'good');\n    assert.strictEqual(cls.entries.length, 2);\n    assert.match(cls.navigationType, /navigate|reload/);\n  });\n\n  it('resets the session after timeout or gap elapses', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    await navigateTo('/test/cls');\n\n    // Wait until all images are loaded and rendered.\n    await imagesPainted();\n    await browser.pause(1000);\n\n    await stubVisibilityChange('hidden');\n    await beaconCountIs(1);\n\n    const [cls1] = await getBeacons();\n\n    assert(cls1.value > 0);\n    assert(cls1.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls1.name, 'CLS');\n    assert.strictEqual(cls1.value, cls1.delta);\n    assert.strictEqual(cls1.rating, 'good');\n    assert.strictEqual(cls1.entries.length, 2);\n    assert.match(cls1.navigationType, /navigate|reload/);\n\n    await browser.pause(1000);\n    await stubVisibilityChange('visible');\n    await clearBeacons();\n\n    // Force 2 layout shifts, totaling 0.5.\n    await browser.executeAsync((done) => {\n      document.body.style.overflow = 'hidden'; // Prevent scroll bars.\n      document.querySelector('main').style.left = '25vmax';\n      setTimeout(() => {\n        document.querySelector('main').style.left = '0px';\n        done();\n      }, 50);\n    });\n\n    await stubVisibilityChange('hidden');\n    await beaconCountIs(1);\n\n    const [cls2] = await getBeacons();\n\n    // The value should be exactly 0.5, but round just in case.\n    assert.strictEqual(Math.round(cls2.value * 100) / 100, 0.5);\n    assert.strictEqual(cls2.name, 'CLS');\n    assert.strictEqual(cls2.value, cls1.value + cls2.delta);\n    assert.strictEqual(cls2.rating, 'poor');\n    assert.strictEqual(cls2.entries.length, 2);\n    assert.match(cls2.navigationType, /navigate|reload/);\n    assert.match(cls2.id, /^v5-\\d+-\\d+$/);\n\n    await browser.pause(1000);\n    await stubVisibilityChange('visible');\n    await clearBeacons();\n\n    // Force 4 separate layout shifts, totaling 1.5.\n    await browser.executeAsync((done) => {\n      document.querySelector('main').style.left = '25vmax';\n      setTimeout(() => {\n        document.querySelector('main').style.left = '0px';\n        setTimeout(() => {\n          document.querySelector('main').style.left = '50vmax';\n          setTimeout(() => {\n            document.querySelector('main').style.left = '0px';\n            done();\n          }, 50);\n        }, 50);\n      }, 50);\n    });\n\n    await stubVisibilityChange('hidden');\n    await beaconCountIs(1);\n\n    const [cls3] = await getBeacons();\n\n    // The value should be exactly 1.5, but round just in case.\n    assert.strictEqual(Math.round(cls3.value * 100) / 100, 1.5);\n    assert.strictEqual(cls3.name, 'CLS');\n    assert.strictEqual(cls3.value, cls2.value + cls3.delta);\n    assert.strictEqual(cls3.rating, 'poor');\n    assert.strictEqual(cls3.entries.length, 4);\n    assert.match(cls3.navigationType, /navigate|reload/);\n    assert.match(cls3.id, /^v5-\\d+-\\d+$/);\n\n    await browser.pause(1000);\n    await stubVisibilityChange('visible');\n    await clearBeacons();\n\n    // Force 2 layout shifts, totalling 1.0 (less than the previous max).\n    await browser.executeAsync((done) => {\n      document.querySelector('main').style.left = '50vmax';\n      setTimeout(() => {\n        document.querySelector('main').style.left = '0px';\n        done();\n      }, 50);\n    });\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    const beacons = await getBeacons();\n    assert.strictEqual(beacons.length, 0);\n  });\n\n  it('does not report if the browser does not support CLS', async function () {\n    if (browserSupportsCLS) this.skip();\n\n    await navigateTo('/test/cls');\n\n    // Wait until all images are loaded and rendered, then change to hidden.\n    await imagesPainted();\n    await stubVisibilityChange('hidden');\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    await navigateTo('about:blank');\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    const beacons = await getBeacons();\n    assert.strictEqual(beacons.length, 0);\n  });\n\n  it('reports no new values on visibility hidden after shifts (reportAllChanges === true)', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    await navigateTo('/test/cls?reportAllChanges=1');\n\n    // Beacons should be sent as soon as layout shifts occur, wait for them.\n    await beaconCountIs(3);\n\n    const [cls1, cls2, cls3] = await getBeacons();\n\n    assert.strictEqual(cls1.value, 0);\n    assert(cls1.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls1.name, 'CLS');\n    assert.strictEqual(cls1.value, cls1.delta);\n    assert.strictEqual(cls1.rating, 'good');\n    assert.strictEqual(cls1.entries.length, 0);\n    assert.match(cls1.navigationType, /navigate|reload/);\n\n    assert(cls2.value > 0);\n    assert.strictEqual(cls2.name, 'CLS');\n    assert.strictEqual(cls2.id, cls1.id);\n    assert.strictEqual(cls2.value, cls1.delta + cls2.delta);\n    assert.strictEqual(cls2.rating, 'good');\n    assert.strictEqual(cls2.entries.length, 1);\n    assert.match(cls2.navigationType, /navigate|reload/);\n\n    assert(cls3.value >= cls2.value);\n    assert.strictEqual(cls3.name, 'CLS');\n    assert.strictEqual(cls3.id, cls2.id);\n    assert.strictEqual(cls3.value, cls2.value + cls3.delta);\n    assert.strictEqual(cls3.rating, 'good');\n    assert.strictEqual(cls3.entries.length, 2);\n    assert.match(cls3.navigationType, /navigate|reload/);\n\n    await clearBeacons();\n    await stubVisibilityChange('hidden');\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    const beacons = await getBeacons();\n    assert.strictEqual(beacons.length, 0);\n  });\n\n  it('does not report if the value has not changed (reportAllChanges === true)', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    await navigateTo('/test/cls?reportAllChanges=1');\n\n    // Beacons should be sent as soon as layout shifts occur, wait for them.\n    await beaconCountIs(3);\n\n    const [cls1, cls2, cls3] = await getBeacons();\n\n    assert.strictEqual(cls1.value, 0);\n    assert(cls1.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls1.name, 'CLS');\n    assert.strictEqual(cls1.value, cls1.delta);\n    assert.strictEqual(cls1.rating, 'good');\n    assert.strictEqual(cls1.entries.length, 0);\n    assert.match(cls1.navigationType, /navigate|reload/);\n\n    assert(cls2.value > 0);\n    assert.strictEqual(cls2.name, 'CLS');\n    assert.strictEqual(cls2.id, cls1.id);\n    assert.strictEqual(cls2.value, cls1.delta + cls2.delta);\n    assert.strictEqual(cls2.rating, 'good');\n    assert.strictEqual(cls2.entries.length, 1);\n    assert.match(cls2.navigationType, /navigate|reload/);\n\n    assert(cls3.value >= cls2.value);\n    assert.strictEqual(cls3.name, 'CLS');\n    assert.strictEqual(cls3.id, cls2.id);\n    assert.strictEqual(cls3.value, cls2.value + cls3.delta);\n    assert.strictEqual(cls3.rating, 'good');\n    assert.strictEqual(cls3.entries.length, 2);\n    assert.match(cls3.navigationType, /navigate|reload/);\n\n    // Unload the page after no new shifts have occurred.\n    await clearBeacons();\n    await navigateTo('about:blank');\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    const beacons = await getBeacons();\n    assert.strictEqual(beacons.length, 0);\n  });\n\n  it('continues reporting after visibilitychange (reportAllChanges === false)', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    await navigateTo(`/test/cls`);\n\n    // Wait until all images are loaded and rendered, then change to hidden.\n    await imagesPainted();\n    await stubVisibilityChange('hidden');\n\n    await beaconCountIs(1);\n\n    const [cls1] = await getBeacons();\n\n    assert(cls1.value > 0);\n    assert(cls1.delta > 0);\n    assert(cls1.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls1.name, 'CLS');\n    assert.strictEqual(cls1.value, cls1.delta);\n    assert.strictEqual(cls1.rating, 'good');\n    assert.strictEqual(cls1.entries.length, 2);\n    assert.match(cls1.navigationType, /navigate|reload/);\n\n    await clearBeacons();\n    await stubVisibilityChange('visible');\n\n    // Wait for a frame to be painted.\n    await browser.executeAsync((done) => requestAnimationFrame(done));\n\n    await triggerLayoutShift();\n\n    await clearBeacons();\n    await stubVisibilityChange('hidden');\n\n    await beaconCountIs(1);\n\n    const [cls2] = await getBeacons();\n    assert(cls2.value >= cls1.value);\n    assert.strictEqual(cls2.name, 'CLS');\n    assert.strictEqual(cls2.id, cls1.id);\n    assert.strictEqual(cls2.value, cls1.value + cls2.delta);\n    assert.strictEqual(cls2.entries.length, 3);\n    assert.match(cls2.navigationType, /navigate|reload/);\n  });\n\n  it('continues reporting after visibilitychange (reportAllChanges === true)', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    await navigateTo(`/test/cls?reportAllChanges=1`);\n    await beaconCountIs(3);\n\n    const [cls1, cls2, cls3] = await getBeacons();\n\n    assert.strictEqual(cls1.value, 0);\n    assert(cls1.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls1.name, 'CLS');\n    assert.strictEqual(cls1.value, cls1.delta);\n    assert.strictEqual(cls1.rating, 'good');\n    assert.strictEqual(cls1.entries.length, 0);\n    assert.match(cls1.navigationType, /navigate|reload/);\n\n    assert(cls2.value > 0);\n    assert.strictEqual(cls2.name, 'CLS');\n    assert.strictEqual(cls2.id, cls1.id);\n    assert.strictEqual(cls2.value, cls1.delta + cls2.delta);\n    assert.strictEqual(cls2.rating, 'good');\n    assert.strictEqual(cls2.entries.length, 1);\n    assert.match(cls2.navigationType, /navigate|reload/);\n\n    assert(cls3.value >= cls2.value);\n    assert.strictEqual(cls3.name, 'CLS');\n    assert.strictEqual(cls3.id, cls2.id);\n    assert.strictEqual(cls3.value, cls2.value + cls3.delta);\n    assert.strictEqual(cls3.rating, 'good');\n    assert.strictEqual(cls3.entries.length, 2);\n    assert.match(cls3.navigationType, /navigate|reload/);\n\n    // Unload the page after no new shifts have occurred.\n    await clearBeacons();\n    await stubVisibilityChange('hidden');\n    await stubVisibilityChange('visible');\n\n    // Wait for a frame to be painted.\n    await browser.executeAsync((done) => requestAnimationFrame(done));\n\n    await triggerLayoutShift();\n\n    await beaconCountIs(1);\n    const [cls4] = await getBeacons();\n\n    assert(cls4.value > cls3.value);\n    assert.strictEqual(cls4.name, 'CLS');\n    assert.strictEqual(cls4.id, cls3.id);\n    assert.strictEqual(cls4.value, cls3.value + cls4.delta);\n    assert.strictEqual(cls4.rating, 'good');\n    assert.strictEqual(cls4.entries.length, 3);\n    assert.match(cls4.navigationType, /navigate|reload/);\n  });\n\n  it('continues reporting after bfcache restore (reportAllChanges === false)', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    await navigateTo(`/test/cls`);\n\n    // Wait until all images are loaded and rendered, then go forward & back.\n    await imagesPainted();\n\n    await stubForwardBack();\n    await beaconCountIs(1);\n\n    const [cls1] = await getBeacons();\n\n    assert(cls1.value > 0);\n    assert(cls1.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls1.delta, cls1.value);\n    assert.strictEqual(cls1.name, 'CLS');\n    assert.strictEqual(cls1.value, cls1.delta);\n    assert.strictEqual(cls1.rating, 'good');\n    assert.strictEqual(cls1.entries.length, 2);\n    assert.match(cls1.navigationType, /navigate|reload/);\n\n    await clearBeacons();\n    await triggerLayoutShift();\n\n    await stubForwardBack();\n    await beaconCountIs(1);\n\n    const [cls2] = await getBeacons();\n\n    assert(cls2.value > 0);\n    assert(cls2.id.match(/^v5-\\d+-\\d+$/));\n    assert(cls2.id !== cls1.id);\n\n    assert.strictEqual(cls2.name, 'CLS');\n    assert.strictEqual(cls2.value, cls2.delta);\n    assert.strictEqual(cls2.rating, 'good');\n    assert.strictEqual(cls2.entries.length, 1);\n    assert.strictEqual(cls2.navigationType, 'back-forward-cache');\n\n    await clearBeacons();\n    await triggerLayoutShift();\n\n    await stubVisibilityChange('hidden');\n    await beaconCountIs(1);\n\n    const [cls3] = await getBeacons();\n\n    assert(cls3.value > 0);\n    assert(cls3.id.match(/^v5-\\d+-\\d+$/));\n    assert(cls3.id !== cls2.id);\n\n    assert.strictEqual(cls3.name, 'CLS');\n    assert.strictEqual(cls3.value, cls3.delta);\n    assert.strictEqual(cls3.rating, 'good');\n    assert.strictEqual(cls3.entries.length, 1);\n    assert.strictEqual(cls3.navigationType, 'back-forward-cache');\n  });\n\n  it('continues reporting after bfcache restore (reportAllChanges === true)', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    await navigateTo(`/test/cls?reportAllChanges=1`);\n    await beaconCountIs(3);\n\n    const [cls1, cls2, cls3] = await getBeacons();\n\n    assert.strictEqual(cls1.value, 0);\n    assert(cls1.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls1.name, 'CLS');\n    assert.strictEqual(cls1.value, cls1.delta);\n    assert.strictEqual(cls1.rating, 'good');\n    assert.strictEqual(cls1.entries.length, 0);\n    assert.match(cls1.navigationType, /navigate|reload/);\n\n    assert(cls2.value > 0);\n    assert.strictEqual(cls2.name, 'CLS');\n    assert.strictEqual(cls2.id, cls1.id);\n    assert.strictEqual(cls2.value, cls1.delta + cls2.delta);\n    assert.strictEqual(cls2.rating, 'good');\n    assert.strictEqual(cls2.entries.length, 1);\n    assert.match(cls2.navigationType, /navigate|reload/);\n\n    assert(cls3.value >= cls2.value);\n    assert.strictEqual(cls3.name, 'CLS');\n    assert.strictEqual(cls3.id, cls2.id);\n    assert.strictEqual(cls3.value, cls2.value + cls3.delta);\n    assert.strictEqual(cls3.rating, 'good');\n    assert.strictEqual(cls3.entries.length, 2);\n    assert.match(cls3.navigationType, /navigate|reload/);\n\n    await clearBeacons();\n    await stubForwardBack();\n\n    // Wait for a frame to be painted.\n    await browser.executeAsync((done) => requestAnimationFrame(done));\n\n    await triggerLayoutShift();\n\n    await beaconCountIs(2);\n    const [cls4, cls5] = await getBeacons();\n\n    assert.strictEqual(cls4.value, 0);\n    assert(cls4.id.match(/^v5-\\d+-\\d+$/));\n    assert(cls4.id !== cls3.id);\n    assert.strictEqual(cls4.name, 'CLS');\n    assert.strictEqual(cls4.value, cls4.delta);\n    assert.strictEqual(cls4.rating, 'good');\n    assert.strictEqual(cls4.entries.length, 0);\n    assert.strictEqual(cls4.navigationType, 'back-forward-cache');\n\n    assert(cls5.value > 0);\n    assert.strictEqual(cls5.id, cls4.id);\n    assert.strictEqual(cls5.name, 'CLS');\n    assert.strictEqual(cls5.value, cls4.delta + cls5.delta);\n    assert.strictEqual(cls5.rating, 'good');\n    assert.strictEqual(cls5.entries.length, 1);\n    assert.strictEqual(cls5.navigationType, 'back-forward-cache');\n  });\n\n  it('reports zero if no layout shifts occurred on first visibility hidden (reportAllChanges === false)', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    await navigateTo(`/test/cls?noLayoutShifts=1`, {readyState: 'complete'});\n\n    // Wait until the page is loaded and content is visible before hiding.\n    await firstContentfulPaint();\n    await stubVisibilityChange('hidden');\n\n    const [cls] = await getBeacons();\n    assert(cls.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls.name, 'CLS');\n    assert.strictEqual(cls.value, 0);\n    assert.strictEqual(cls.delta, 0);\n    assert.strictEqual(cls.rating, 'good');\n    assert.strictEqual(cls.entries.length, 0);\n    assert.match(cls.navigationType, /navigate|reload/);\n  });\n\n  it('reports zero if no layout shifts occurred on first visibility hidden (reportAllChanges === true)', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    await navigateTo(`/test/cls?reportAllChanges=1&noLayoutShifts=1`, {\n      readyState: 'complete',\n    });\n\n    // Wait until the page is loaded and content is visible before hiding.\n    await firstContentfulPaint();\n    await stubVisibilityChange('hidden');\n\n    await beaconCountIs(1);\n\n    const [cls] = await getBeacons();\n    assert(cls.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls.name, 'CLS');\n    assert.strictEqual(cls.value, 0);\n    assert.strictEqual(cls.delta, 0);\n    assert.strictEqual(cls.rating, 'good');\n    assert.strictEqual(cls.entries.length, 0);\n    assert.match(cls.navigationType, /navigate|reload/);\n  });\n\n  it('reports zero if no layout shifts occurred on page unload (reportAllChanges === false)', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    // Wait until the page is loaded before navigating away.\n    await navigateTo(`/test/cls?noLayoutShifts=1`, {readyState: 'complete'});\n\n    await navigateTo('about:blank');\n\n    await beaconCountIs(1);\n\n    const [cls] = await getBeacons();\n    assert(cls.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls.name, 'CLS');\n    assert.strictEqual(cls.value, 0);\n    assert.strictEqual(cls.delta, 0);\n    assert.strictEqual(cls.rating, 'good');\n    assert.strictEqual(cls.entries.length, 0);\n    assert.match(cls.navigationType, /navigate|reload/);\n  });\n\n  it('reports zero if no layout shifts occurred on page unload (reportAllChanges === true)', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    // Wait until the page is loaded before navigating away.\n    await navigateTo(`/test/cls?noLayoutShifts=1&reportAllChanges=1`, {\n      readyState: 'complete',\n    });\n\n    // Wait until the page is loaded and content is visible before leaving.\n    await firstContentfulPaint();\n    await navigateTo('about:blank');\n\n    await beaconCountIs(1);\n\n    const [cls] = await getBeacons();\n    assert(cls.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls.name, 'CLS');\n    assert.strictEqual(cls.value, 0);\n    assert.strictEqual(cls.delta, 0);\n    assert.strictEqual(cls.rating, 'good');\n    assert.strictEqual(cls.entries.length, 0);\n    assert.match(cls.navigationType, /navigate|reload/);\n  });\n\n  it('does not report if the document was hidden at page load time', async function () {\n    await navigateTo('/test/cls?hidden=1');\n\n    await stubVisibilityChange('visible');\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    const beacons = await getBeacons();\n    assert.strictEqual(beacons.length, 0);\n  });\n\n  it('reports if the page is restored from bfcache even when the document was hidden at page load time', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    await navigateTo('/test/cls?hidden=1', {readyState: 'complete'});\n\n    await stubForwardBack();\n\n    // Give it time for beacons to come in\n    await browser.pause(2000);\n\n    // clear any beacons from page load.\n    await clearBeacons();\n\n    await triggerLayoutShift();\n\n    await stubVisibilityChange('hidden');\n    await beaconCountIs(1);\n\n    const [cls] = await getBeacons();\n\n    assert(cls.value > 0);\n    assert(cls.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls.name, 'CLS');\n    assert.strictEqual(cls.delta, cls.value);\n    assert.strictEqual(cls.rating, 'good');\n    assert.strictEqual(cls.entries.length, 1);\n    assert.strictEqual(cls.navigationType, 'back-forward-cache');\n  });\n\n  it('reports prerender as nav type and excludes shifts that happen in prerender state', async function () {\n    if (!browserSupportsCLS) this.skip();\n    if (!browserSupportsPrerender) this.skip();\n\n    await navigateTo('/test/cls?prerender=1');\n\n    // Wait a bit to allow the prerender to happen\n    // and all loading shifts to complete\n    await browser.pause(2000);\n\n    const prerenderLink = await $('#prerender-link');\n    await prerenderLink.click();\n\n    await beaconCountIs(1);\n    await clearBeacons();\n\n    // Wait until all images are loaded and rendered, then change to hidden.\n    await imagesPainted();\n    await stubVisibilityChange('hidden');\n\n    await beaconCountIs(1);\n    const [cls] = await getBeacons();\n\n    assert.strictEqual(cls.value, 0);\n    assert(cls.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls.name, 'CLS');\n    assert.strictEqual(cls.value, cls.delta);\n    assert.strictEqual(cls.rating, 'good');\n    assert.strictEqual(cls.entries.length, 0);\n    assert.strictEqual(cls.navigationType, 'prerender');\n  });\n\n  it('reports restore as nav type for wasDiscarded', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    await navigateTo('/test/cls?wasDiscarded=1');\n\n    // Wait until all images are loaded and rendered, then change to hidden.\n    await imagesPainted();\n    await stubVisibilityChange('hidden');\n\n    await beaconCountIs(1);\n    const [cls] = await getBeacons();\n\n    assert(cls.value > 0);\n    assert(cls.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls.name, 'CLS');\n    assert.strictEqual(cls.value, cls.delta);\n    assert.strictEqual(cls.rating, 'good');\n    assert.strictEqual(cls.entries.length, 2);\n    assert.strictEqual(cls.navigationType, 'restore');\n  });\n\n  it('works when calling the function twice with different options', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    await navigateTo('/test/cls?doubleCall=1&reportAllChanges2=1');\n\n    // Wait until all images are loaded and rendered.\n    await imagesPainted();\n\n    await beaconCountIs(3, {instance: 2});\n\n    const [cls2_1, cls2_2, cls2_3] = await getBeacons();\n\n    assert.strictEqual(cls2_1.value, 0);\n    assert(cls2_1.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls2_1.name, 'CLS');\n    assert.strictEqual(cls2_1.value, cls2_1.delta);\n    assert.strictEqual(cls2_1.rating, 'good');\n    assert.strictEqual(cls2_1.entries.length, 0);\n    assert.match(cls2_1.navigationType, /navigate|reload/);\n\n    assert.strictEqual(cls2_2.value, cls2_1.value + cls2_2.delta);\n    assert.strictEqual(cls2_2.id, cls2_1.id);\n    assert.strictEqual(cls2_2.name, 'CLS');\n    assert.strictEqual(cls2_2.rating, 'good');\n    assert.strictEqual(cls2_2.entries.length, 1);\n    assert.match(cls2_2.navigationType, /navigate|reload/);\n\n    assert.strictEqual(cls2_3.value, cls2_2.value + cls2_3.delta);\n    assert.strictEqual(cls2_3.id, cls2_2.id);\n    assert.strictEqual(cls2_3.name, 'CLS');\n    assert.strictEqual(cls2_3.rating, 'good');\n    assert.strictEqual(cls2_3.entries.length, 2);\n    assert.match(cls2_3.navigationType, /navigate|reload/);\n\n    assert.strictEqual((await getBeacons({instance: 1})).length, 0);\n\n    await stubVisibilityChange('hidden');\n\n    await beaconCountIs(1, {instance: 1});\n\n    const [cls1_1] = await getBeacons();\n\n    assert(cls1_1.id.match(/^v5-\\d+-\\d+$/));\n    assert(cls1_1.id !== cls2_3.id);\n    assert.strictEqual(cls1_1.value, cls2_3.value);\n    assert.strictEqual(cls1_1.delta, cls2_3.value);\n    assert.strictEqual(cls1_1.name, cls2_3.name);\n    assert.strictEqual(cls1_1.rating, cls2_3.rating);\n    assert.deepEqual(cls1_1.entries, cls2_3.entries);\n    assert.strictEqual(cls1_1.navigationType, cls2_3.navigationType);\n  });\n\n  it('reports on batch reporting using document.visibilitychange', async function () {\n    if (!browserSupportsCLS) this.skip();\n\n    await navigateTo('/test/cls?batchReporting=1');\n\n    // Wait until all images are loaded and rendered, then change to hidden.\n    await imagesPainted();\n    await hideAndReshowPage();\n\n    // The test sends a beacon on both document visibility changes\n    await beaconCountIs(1);\n    const [cls] = await getBeacons();\n\n    assert(cls.value > 0);\n    assert(cls.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(cls.name, 'CLS');\n    assert.strictEqual(cls.value, cls.delta);\n    assert.strictEqual(cls.rating, 'good');\n    assert.strictEqual(cls.entries.length, 2);\n    assert.match(cls.navigationType, /navigate|reload/);\n  });\n\n  describe('attribution', function () {\n    it('includes attribution data on the metric object', async function () {\n      if (!browserSupportsCLS) this.skip();\n\n      await navigateTo('/test/cls?attribution=1&delayDCL=2000');\n\n      // Wait until all images are loaded and rendered, then change to hidden.\n      await imagesPainted();\n      await stubVisibilityChange('hidden');\n\n      await beaconCountIs(1);\n\n      const [cls] = await getBeacons();\n      assert(cls.value > 0);\n      assert(cls.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(cls.name, 'CLS');\n      assert.strictEqual(cls.value, cls.delta);\n      assert.strictEqual(cls.rating, 'good');\n      assert.strictEqual(cls.entries.length, 2);\n      assert.match(cls.navigationType, /navigate|reload/);\n\n      const {largestShiftEntry, largestShiftSource} = getAttribution(\n        cls.entries,\n      );\n\n      assert.deepEqual(cls.attribution.largestShiftEntry, largestShiftEntry);\n      assert.deepEqual(cls.attribution.largestShiftSource, largestShiftSource);\n\n      assert.equal(cls.attribution.largestShiftValue, largestShiftEntry.value);\n      assert.equal(cls.attribution.largestShiftTarget, '#p3');\n      assert.equal(\n        cls.attribution.largestShiftTime,\n        largestShiftEntry.startTime,\n      );\n\n      // The first shift (before the second image loads) is the largest.\n      assert.match(\n        cls.attribution.loadState,\n        /^dom-(interactive|content-loaded)$/,\n      );\n    });\n\n    it('supports generating a custom target', async function () {\n      if (!browserSupportsCLS) this.skip();\n\n      await navigateTo('/test/cls?attribution=1&generateTarget=1');\n\n      // Wait until all images are loaded and rendered, then change to hidden.\n      await imagesPainted();\n      await stubVisibilityChange('hidden');\n\n      await beaconCountIs(1);\n\n      const [cls] = await getBeacons();\n      assert(cls.value > 0);\n      assert(cls.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(cls.name, 'CLS');\n      assert.strictEqual(cls.value, cls.delta);\n      assert.strictEqual(cls.rating, 'good');\n      assert.strictEqual(cls.entries.length, 2);\n      assert.match(cls.navigationType, /navigate|reload/);\n\n      assert.equal(\n        cls.attribution.largestShiftTarget,\n        'secondary-image-wrapper',\n      );\n    });\n\n    it('supports generating a custom target with default fallback', async function () {\n      if (!browserSupportsCLS) this.skip();\n\n      await navigateTo('/test/cls?attribution=1&img2Hidden=1&generateTarget=1');\n\n      // Wait until all images are loaded and rendered, then change to hidden.\n      await imagesPainted();\n      await stubVisibilityChange('hidden');\n\n      await beaconCountIs(1);\n\n      const [cls] = await getBeacons();\n      assert(cls.value > 0);\n      assert(cls.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(cls.name, 'CLS');\n      assert.strictEqual(cls.value, cls.delta);\n      assert.strictEqual(cls.rating, 'good');\n      assert.strictEqual(cls.entries.length, 1);\n      assert.match(cls.navigationType, /navigate|reload/);\n\n      assert.equal(cls.attribution.largestShiftTarget, '#p4');\n    });\n\n    it('supports multiple calls with different custom target generation functions', async function () {\n      if (!browserSupportsCLS) this.skip();\n\n      await navigateTo(\n        '/test/cls?attribution=1&doubleCall=1&generateTarget2=1',\n      );\n\n      // Wait until all images are loaded and rendered, then change to hidden.\n      await imagesPainted();\n      await stubVisibilityChange('hidden');\n\n      await beaconCountIs(1, {instance: 1});\n      await beaconCountIs(1, {instance: 2});\n\n      const [cls1] = await getBeacons({instance: 1});\n      assert(cls1.value > 0);\n      assert(cls1.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(cls1.name, 'CLS');\n      assert.strictEqual(cls1.value, cls1.delta);\n      assert.strictEqual(cls1.rating, 'good');\n      assert.strictEqual(cls1.entries.length, 2);\n      assert.match(cls1.navigationType, /navigate|reload/);\n\n      assert.equal(cls1.attribution.largestShiftTarget, '#p3');\n\n      const [cls2] = await getBeacons({instance: 2});\n      assert.strictEqual(cls2.name, cls1.name);\n      assert.strictEqual(cls2.value, cls1.value);\n      assert.strictEqual(cls2.delta, cls1.delta);\n      assert.strictEqual(cls2.rating, cls1.rating);\n      assert.deepEqual(cls2.entries, cls1.entries);\n      assert(cls2.id !== cls1.id);\n\n      assert.equal(\n        cls2.attribution.largestShiftTarget,\n        'secondary-image-wrapper',\n      );\n    });\n\n    it('reports whether the largest shift was before or after load', async function () {\n      if (!browserSupportsCLS) this.skip();\n\n      await navigateTo(`/test/cls?attribution=1&noLayoutShifts=1`, {\n        readyState: 'complete',\n      });\n\n      // Wait until the page is loaded and content is visible before triggering\n      // a layout shift.\n      await firstContentfulPaint();\n\n      await triggerLayoutShift();\n      await stubVisibilityChange('hidden');\n\n      await beaconCountIs(1);\n      const [cls] = await getBeacons();\n\n      assert(cls.value > 0);\n      assert(cls.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(cls.name, 'CLS');\n      assert.strictEqual(cls.value, cls.delta);\n      assert.strictEqual(cls.rating, 'good');\n      assert.strictEqual(cls.entries.length, 1);\n      assert.match(cls.navigationType, /navigate|reload/);\n\n      const {largestShiftEntry, largestShiftSource} = getAttribution(\n        cls.entries,\n      );\n\n      assert.deepEqual(cls.attribution.largestShiftEntry, largestShiftEntry);\n      assert.deepEqual(cls.attribution.largestShiftSource, largestShiftSource);\n\n      assert.equal(cls.attribution.largestShiftValue, largestShiftEntry.value);\n      assert.equal(cls.attribution.largestShiftTarget, 'html>body>main>h1');\n      assert.equal(\n        cls.attribution.largestShiftTime,\n        largestShiftEntry.startTime,\n      );\n\n      // The first shift (before the second image loads) is the largest.\n      assert.equal(cls.attribution.loadState, 'complete');\n    });\n\n    it('reports an empty object when no shifts', async function () {\n      if (!browserSupportsCLS) this.skip();\n\n      await navigateTo(`/test/cls?attribution=1&noLayoutShifts=1`, {\n        readyState: 'complete',\n      });\n\n      // Wait until the page is loaded and content is visible hiding.\n      await firstContentfulPaint();\n      await stubVisibilityChange('hidden');\n\n      await beaconCountIs(1);\n      const [cls] = await getBeacons();\n\n      assert.strictEqual(cls.value, 0);\n      assert(cls.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(cls.name, 'CLS');\n      assert.strictEqual(cls.value, cls.delta);\n      assert.strictEqual(cls.rating, 'good');\n      assert.strictEqual(cls.entries.length, 0);\n      assert.match(cls.navigationType, /navigate|reload/);\n\n      assert.deepEqual(cls.attribution, {});\n    });\n  });\n});\n\n/**\n * Adds\n * @return {void}\n */\nasync function triggerLayoutShift() {\n  await browser.execute((marginTop) => {\n    document.querySelector('h1').style.marginTop = marginTop + 'em';\n  }, ++marginTop);\n  // Wait for a frame to be painted to ensure shifts are finished painting.\n  await nextFrame();\n}\n\n/**\n *\n * @param {Array} entries\n * @return {Object}\n */\nfunction getAttribution(entries) {\n  let largestShiftEntry;\n  for (const entry of entries) {\n    if (!largestShiftEntry || entry.value > largestShiftEntry.value) {\n      largestShiftEntry = entry;\n    }\n  }\n\n  const largestShiftSource = largestShiftEntry.sources.find((source) => {\n    return source.node !== '[object Text]';\n  });\n\n  return {largestShiftEntry, largestShiftSource};\n}\n\nconst hideAndReshowPage = async () => {\n  // Switch to new tab and back to change visibility state.\n  // New tabs on Safari in webdriver.io are flakey, so minimize/maximize\n  // instead, but it's kind of distracting so use tab switch for others.\n  if (browser.capabilities.browserName !== 'Safari') {\n    const handle1 = await browser.getWindowHandle();\n    await browser.newWindow('https://example.com');\n    await browser.pause(500);\n    await browser.closeWindow();\n    await browser.switchToWindow(handle1);\n  } else {\n    await browser.minimizeWindow();\n    await browser.pause(500);\n    await browser.maximizeWindow();\n  }\n};\n"
  },
  {
    "path": "test/e2e/onFCP-test.js",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport assert from 'assert';\nimport {beaconCountIs, clearBeacons, getBeacons} from '../utils/beacons.js';\nimport {browserSupportsEntry} from '../utils/browserSupportsEntry.js';\nimport {navigateTo} from '../utils/navigateTo.js';\nimport {stubForwardBack} from '../utils/stubForwardBack.js';\nimport {stubVisibilityChange} from '../utils/stubVisibilityChange.js';\nimport {webVitalsLoaded} from '../utils/webVitalsLoaded.js';\n\ndescribe('onFCP()', async function () {\n  // Retry all tests in this suite up to 2 times.\n  this.retries(2);\n\n  let browserSupportsFCP;\n  let browserSupportsPrerender;\n  before(async function () {\n    browserSupportsFCP = await browserSupportsEntry('paint');\n    browserSupportsPrerender = await browser.execute(() => {\n      return 'onprerenderingchange' in document;\n    });\n  });\n\n  beforeEach(async function () {\n    await navigateTo('about:blank');\n    await clearBeacons();\n  });\n\n  it('reports the correct value after the first paint', async function () {\n    if (!browserSupportsFCP) this.skip();\n\n    await navigateTo('/test/fcp');\n\n    await beaconCountIs(1);\n\n    const [fcp] = await getBeacons();\n    assert(fcp.value >= 0);\n    assert(fcp.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(fcp.name, 'FCP');\n    assert.strictEqual(fcp.value, fcp.delta);\n    assert.strictEqual(fcp.rating, 'good');\n    assert.strictEqual(fcp.entries.length, 1);\n    assert.match(fcp.navigationType, /navigate|reload/);\n  });\n\n  it('reports the correct value when loaded late', async function () {\n    if (!browserSupportsFCP) this.skip();\n\n    await navigateTo('/test/fcp?lazyLoad=1');\n\n    await beaconCountIs(1);\n\n    const [fcp] = await getBeacons();\n    assert(fcp.value >= 0);\n    assert(fcp.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(fcp.name, 'FCP');\n    assert.strictEqual(fcp.value, fcp.delta);\n    assert.strictEqual(fcp.rating, 'good');\n    assert.strictEqual(fcp.entries.length, 1);\n    assert.match(fcp.navigationType, /navigate|reload/);\n  });\n\n  it('accounts for time prerendering the page', async function () {\n    if (!browserSupportsFCP) this.skip();\n    if (!browserSupportsPrerender) this.skip();\n\n    await navigateTo('/test/fcp?prerender=1');\n\n    await beaconCountIs(1);\n    await clearBeacons();\n\n    // Wait a bit to allow the prerender to happen\n    await browser.pause(1000);\n\n    const prerenderLink = await $('#prerender-link');\n    await prerenderLink.click();\n\n    await beaconCountIs(1);\n    const [fcp] = await getBeacons();\n\n    const activationStart = await browser.execute(() => {\n      return performance.getEntriesByType('navigation')[0].activationStart;\n    });\n\n    assert(fcp.value >= 0);\n    assert(fcp.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(fcp.name, 'FCP');\n    assert.strictEqual(fcp.value, fcp.delta);\n    assert.strictEqual(fcp.rating, 'good');\n    assert.strictEqual(fcp.entries.length, 1);\n    assert.strictEqual(fcp.entries[0].startTime - activationStart, fcp.value);\n    assert.strictEqual(fcp.navigationType, 'prerender');\n  });\n\n  it('does not report if the browser does not support FCP (including bfcache restores)', async function () {\n    if (browserSupportsFCP) this.skip();\n\n    await navigateTo('/test/fcp');\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    const loadBeacons = await getBeacons();\n    assert.strictEqual(loadBeacons.length, 0);\n\n    await clearBeacons();\n    await stubForwardBack();\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    const bfcacheRestoreBeacons = await getBeacons();\n    assert.strictEqual(bfcacheRestoreBeacons.length, 0);\n  });\n\n  it('does not report if the document was hidden at page load time', async function () {\n    if (!browserSupportsFCP) this.skip();\n\n    await navigateTo('/test/fcp?hidden=1', {readyState: 'complete'});\n\n    await stubVisibilityChange('visible');\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    const beacons = await getBeacons();\n    assert.strictEqual(beacons.length, 0);\n  });\n\n  it('does not report if the document changes to hidden before the first entry', async function () {\n    if (!browserSupportsFCP) this.skip();\n\n    await navigateTo('/test/fcp?invisible=1', {readyState: 'interactive'});\n\n    await stubVisibilityChange('hidden');\n    await webVitalsLoaded();\n    await stubVisibilityChange('visible');\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    const beacons = await getBeacons();\n    assert.strictEqual(beacons.length, 0);\n  });\n\n  it('reports after a render delay before the page changes to hidden', async function () {\n    if (!browserSupportsFCP) this.skip();\n\n    await navigateTo('/test/fcp?renderBlocking=2000');\n\n    // Change to hidden after the first render.\n    await browser.pause(2500);\n    await stubVisibilityChange('hidden');\n\n    const [fcp] = await getBeacons();\n    assert(fcp.value >= 0);\n    assert(fcp.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(fcp.name, 'FCP');\n    assert.strictEqual(fcp.value, fcp.delta);\n    assert.strictEqual(fcp.rating, 'needs-improvement');\n    assert.strictEqual(fcp.entries.length, 1);\n    assert.match(fcp.navigationType, /navigate|reload/);\n  });\n\n  it('reports if the page is restored from bfcache', async function () {\n    if (!browserSupportsFCP) this.skip();\n\n    await navigateTo('/test/fcp');\n\n    await beaconCountIs(1);\n\n    const [fcp1] = await getBeacons();\n    assert(fcp1.value >= 0);\n    assert(fcp1.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(fcp1.name, 'FCP');\n    assert.strictEqual(fcp1.value, fcp1.delta);\n    assert.strictEqual(fcp1.rating, 'good');\n    assert.strictEqual(fcp1.entries.length, 1);\n    assert.match(fcp1.navigationType, /navigate|reload/);\n\n    await clearBeacons();\n    await stubForwardBack();\n\n    await beaconCountIs(1);\n\n    const [fcp2] = await getBeacons();\n    assert(fcp2.value >= 0);\n    assert(fcp2.id.match(/^v5-\\d+-\\d+$/));\n    assert(fcp2.id !== fcp1.id);\n    assert.strictEqual(fcp2.name, 'FCP');\n    assert.strictEqual(fcp2.value, fcp2.delta);\n    assert.strictEqual(fcp2.rating, 'good');\n    assert.strictEqual(fcp2.entries.length, 0);\n    assert.strictEqual(fcp2.navigationType, 'back-forward-cache');\n\n    await clearBeacons();\n    await stubForwardBack();\n\n    await beaconCountIs(1);\n\n    const [fcp3] = await getBeacons();\n    assert(fcp3.value >= 0);\n    assert(fcp3.id.match(/^v5-\\d+-\\d+$/));\n    assert(fcp3.id !== fcp2.id);\n    assert.strictEqual(fcp3.name, 'FCP');\n    assert.strictEqual(fcp3.value, fcp3.delta);\n    assert.strictEqual(fcp3.rating, 'good');\n    assert.strictEqual(fcp3.entries.length, 0);\n    assert.strictEqual(fcp3.navigationType, 'back-forward-cache');\n  });\n\n  it('reports if the page is restored from bfcache even when the document was hidden at page load time', async function () {\n    if (!browserSupportsFCP) this.skip();\n\n    await navigateTo('/test/fcp?hidden=1', {readyState: 'interactive'});\n\n    await stubVisibilityChange('visible');\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    const beacons = await getBeacons();\n    assert.strictEqual(beacons.length, 0);\n\n    await stubForwardBack();\n\n    await beaconCountIs(1);\n\n    const [fcp1] = await getBeacons();\n    assert(fcp1.value >= 0);\n    assert(fcp1.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(fcp1.name, 'FCP');\n    assert.strictEqual(fcp1.value, fcp1.delta);\n    assert.strictEqual(fcp1.rating, 'good');\n    assert.strictEqual(fcp1.entries.length, 0);\n    assert.strictEqual(fcp1.navigationType, 'back-forward-cache');\n\n    await clearBeacons();\n    await stubForwardBack();\n\n    await beaconCountIs(1);\n\n    const [fcp2] = await getBeacons();\n    assert(fcp2.value >= 0);\n    assert(fcp2.id.match(/^v5-\\d+-\\d+$/));\n    assert(fcp2.id !== fcp1.id);\n    assert.strictEqual(fcp2.name, 'FCP');\n    assert.strictEqual(fcp2.value, fcp2.delta);\n    assert.strictEqual(fcp2.rating, 'good');\n    assert.strictEqual(fcp2.entries.length, 0);\n    assert.strictEqual(fcp2.navigationType, 'back-forward-cache');\n  });\n\n  it('reports restore as nav type for wasDiscarded', async function () {\n    if (!browserSupportsFCP) this.skip();\n\n    await navigateTo('/test/fcp?wasDiscarded=1');\n\n    await beaconCountIs(1);\n\n    const [fcp] = await getBeacons();\n    assert(fcp.value >= 0);\n    assert(fcp.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(fcp.name, 'FCP');\n    assert.strictEqual(fcp.value, fcp.delta);\n    assert.strictEqual(fcp.rating, 'good');\n    assert.strictEqual(fcp.entries.length, 1);\n    assert.strictEqual(fcp.navigationType, 'restore');\n  });\n\n  it('works when calling the function twice with different options', async function () {\n    if (!browserSupportsFCP) this.skip();\n\n    await navigateTo('/test/fcp?doubleCall=1&reportAllChanges2=1');\n\n    await beaconCountIs(1, {instance: 1});\n    await beaconCountIs(1, {instance: 2});\n\n    const [fcp1] = await getBeacons({instance: 1});\n    const [fcp2] = await getBeacons({instance: 2});\n\n    assert(fcp1.value >= 0);\n    assert(fcp1.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(fcp1.name, 'FCP');\n    assert.strictEqual(fcp1.value, fcp1.delta);\n    assert.strictEqual(fcp1.rating, 'good');\n    assert.strictEqual(fcp1.entries.length, 1);\n    assert.match(fcp1.navigationType, /navigate|reload/);\n\n    assert(fcp2.id.match(/^v5-\\d+-\\d+$/));\n    assert(fcp2.id !== fcp1.id);\n    assert.strictEqual(fcp2.value, fcp1.value);\n    assert.strictEqual(fcp2.delta, fcp1.delta);\n    assert.strictEqual(fcp2.name, fcp1.name);\n    assert.strictEqual(fcp2.rating, fcp1.rating);\n    assert.deepEqual(fcp2.entries, fcp1.entries);\n    assert.strictEqual(fcp2.navigationType, fcp1.navigationType);\n  });\n\n  describe('attribution', function () {\n    it('includes attribution data on the metric object', async function () {\n      if (!browserSupportsFCP) this.skip();\n\n      await navigateTo('/test/fcp?attribution=1', {readyState: 'complete'});\n\n      await beaconCountIs(1);\n\n      const navEntry = await browser.execute(() => {\n        return performance.getEntriesByType('navigation')[0].toJSON();\n      });\n      const fcpEntry = await browser.execute(() => {\n        return performance\n          .getEntriesByName('first-contentful-paint')[0]\n          .toJSON();\n      });\n\n      const [fcp] = await getBeacons();\n\n      assert(fcp.value >= 0);\n      assert(fcp.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(fcp.name, 'FCP');\n      assert.strictEqual(fcp.value, fcp.delta);\n      assert.strictEqual(fcp.rating, 'good');\n      assert.strictEqual(fcp.entries.length, 1);\n      assert.match(fcp.navigationType, /navigate|reload/);\n\n      assert.equal(fcp.attribution.timeToFirstByte, navEntry.responseStart);\n      assert.equal(\n        fcp.attribution.firstByteToFCP,\n        fcp.value - navEntry.responseStart,\n      );\n      assert.match(\n        fcp.attribution.loadState,\n        /^(loading|dom-(interactive|content-loaded)|complete)$/,\n      );\n\n      assert.deepEqual(fcp.attribution.fcpEntry, fcpEntry);\n\n      // When FCP is reported, not all values on the NavigationTiming entry\n      // are finalized, so just check some keys that should be set before FCP.\n      const {navigationEntry: attributionNavEntry} = fcp.attribution;\n      assert.equal(attributionNavEntry.startTime, navEntry.startTime);\n      assert.equal(attributionNavEntry.fetchStart, navEntry.fetchStart);\n      assert.equal(attributionNavEntry.requestStart, navEntry.requestStart);\n      assert.equal(attributionNavEntry.responseStart, navEntry.responseStart);\n    });\n\n    it('accounts for time prerendering the page', async function () {\n      if (!browserSupportsFCP) this.skip();\n      if (!browserSupportsPrerender) this.skip();\n\n      await navigateTo(`/test/fcp?attribution=1&prerender=1`, {\n        readyState: 'complete',\n      });\n\n      await beaconCountIs(1);\n      await clearBeacons();\n\n      // Wait a bit to allow the prerender to happen\n      await browser.pause(1000);\n\n      const prerenderLink = await $('#prerender-link');\n      await prerenderLink.click();\n\n      await beaconCountIs(1);\n\n      const navEntry = await browser.execute(() => {\n        return __toSafeObject(performance.getEntriesByType('navigation')[0]);\n      });\n      const fcpEntry = await browser.execute(() => {\n        return __toSafeObject(\n          performance.getEntriesByName('first-contentful-paint')[0],\n        );\n      });\n\n      const [fcp] = await getBeacons();\n      assert(fcp.value >= 0);\n      assert(fcp.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(fcp.name, 'FCP');\n      assert.strictEqual(fcp.value, fcp.delta);\n      assert.strictEqual(fcp.rating, 'good');\n      assert.strictEqual(fcp.entries.length, 1);\n      assert.strictEqual(fcp.navigationType, 'prerender');\n\n      assert.equal(\n        fcp.attribution.timeToFirstByte,\n        Math.max(0, navEntry.responseStart - navEntry.activationStart),\n      );\n      assert.equal(\n        fcp.attribution.firstByteToFCP,\n        fcp.value -\n          Math.max(0, navEntry.responseStart - navEntry.activationStart),\n      );\n\n      assert.deepEqual(fcp.attribution.fcpEntry, fcpEntry);\n\n      // When FCP is reported, not all values on the NavigationTiming entry\n      // are finalized, so just check some keys that should be set before FCP.\n      const {navigationEntry: attributionNavEntry} = fcp.attribution;\n      assert.equal(attributionNavEntry.startTime, navEntry.startTime);\n      assert.equal(attributionNavEntry.fetchStart, navEntry.fetchStart);\n      assert.equal(attributionNavEntry.requestStart, navEntry.requestStart);\n      assert.equal(attributionNavEntry.responseStart, navEntry.responseStart);\n    });\n\n    it('reports after a bfcache restore', async function () {\n      if (!browserSupportsFCP) this.skip();\n\n      await navigateTo('/test/fcp?attribution=1', {readyState: 'complete'});\n\n      await beaconCountIs(1);\n\n      await clearBeacons();\n\n      await stubForwardBack();\n\n      await beaconCountIs(1);\n\n      const [fcp] = await getBeacons();\n      assert(fcp.value >= 0);\n      assert(fcp.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(fcp.name, 'FCP');\n      assert.strictEqual(fcp.value, fcp.delta);\n      assert.strictEqual(fcp.rating, 'good');\n      assert.strictEqual(fcp.entries.length, 0);\n      assert.strictEqual(fcp.navigationType, 'back-forward-cache');\n\n      assert.equal(fcp.attribution.timeToFirstByte, 0);\n      assert.equal(fcp.attribution.firstByteToFCP, fcp.value);\n      assert.equal(fcp.attribution.loadState, 'complete');\n      assert.equal(fcp.attribution.navigationEntry, undefined);\n    });\n  });\n});\n"
  },
  {
    "path": "test/e2e/onINP-test.js",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport assert from 'assert';\nimport {assertIsCloseTo} from '../utils/assertIsCloseTo.js';\nimport {beaconCountIs, clearBeacons, getBeacons} from '../utils/beacons.js';\nimport {browserSupportsEntry} from '../utils/browserSupportsEntry.js';\nimport {firstContentfulPaint} from '../utils/firstContentfulPaint.js';\nimport {navigateTo} from '../utils/navigateTo.js';\nimport {nextFrame} from '../utils/nextFrame.js';\nimport {stubForwardBack} from '../utils/stubForwardBack.js';\nimport {stubVisibilityChange} from '../utils/stubVisibilityChange.js';\nimport {waitUntilIdle} from '../utils/waitUntilIdle.js';\nimport {webVitalsLoaded} from '../utils/webVitalsLoaded.js';\n\nconst ROUNDING_ERROR = 8;\n\ndescribe('onINP()', async function () {\n  // Retry all tests in this suite up to 2 times.\n  this.retries(2);\n\n  let browserSupportsINP;\n  let browserSupportsLoAF;\n  let browserSupportsPrerender;\n  before(async function () {\n    browserSupportsINP = await browserSupportsEntry('event');\n    browserSupportsLoAF = await browserSupportsEntry('long-animation-frame');\n    browserSupportsPrerender = await browser.execute(() => {\n      return 'onprerenderingchange' in document;\n    });\n  });\n\n  beforeEach(async function () {\n    await navigateTo('about:blank');\n    await clearBeacons();\n  });\n\n  it('reports the correct value on visibility hidden after interactions (reportAllChanges === false)', async function () {\n    if (!browserSupportsINP) this.skip();\n\n    await navigateTo('/test/inp?click=100', {readyState: 'interactive'});\n\n    // Wait until the library is loaded\n    await webVitalsLoaded();\n\n    const h1 = await $('h1');\n    await simulateUserLikeClick(h1);\n\n    // Ensure the interaction completes.\n    await nextFrame();\n    // Give INP a chance to report\n    await waitUntilIdle();\n\n    await stubVisibilityChange('hidden');\n\n    await beaconCountIs(1);\n\n    const [inp] = await getBeacons();\n    assert(inp.value >= 0);\n    assert(inp.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(inp.name, 'INP');\n    assert.strictEqual(inp.value, inp.delta);\n    assert.strictEqual(inp.rating, 'good');\n    assert(containsEntry(inp.entries, 'click', '[object HTMLHeadingElement]'));\n    assert(allEntriesPresentTogether(inp.entries));\n    assert.match(inp.navigationType, /navigate|reload/);\n  });\n\n  it('reports the correct value on visibility hidden after interactions (reportAllChanges === true)', async function () {\n    if (!browserSupportsINP) this.skip();\n\n    await navigateTo('/test/inp?click=100&reportAllChanges=1', {\n      readyState: 'interactive',\n    });\n\n    const h1 = await $('h1');\n    await simulateUserLikeClick(h1);\n\n    await beaconCountIs(1);\n\n    const [inp] = await getBeacons();\n    assert(inp.value >= 0);\n    assert(inp.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(inp.name, 'INP');\n    assert.strictEqual(inp.value, inp.delta);\n    // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n    if (browser.capabilities.browserName !== 'Safari') {\n      assert.strictEqual(inp.rating, 'good');\n    }\n    assert(containsEntry(inp.entries, 'click', '[object HTMLHeadingElement]'));\n    assert(allEntriesPresentTogether(inp.entries));\n    assert.match(inp.navigationType, /navigate|reload/);\n  });\n\n  it('reports the correct value when script is loaded late (reportAllChanges === false)', async function () {\n    if (!browserSupportsINP) this.skip();\n\n    await navigateTo('/test/inp?click=150&loadAfterInput=1');\n\n    // Wait until the first contentful paint to make sure the\n    // heading is there.\n    await firstContentfulPaint();\n\n    const h1 = await $('h1');\n    await simulateUserLikeClick(h1);\n\n    // Wait until the library is loaded\n    await webVitalsLoaded();\n\n    // Ensure the interaction completes.\n    await nextFrame();\n    // Give INP a chance to report\n    await waitUntilIdle();\n\n    // Safari doesn't emit an entry immediately when no paint\n    // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n    // So need to give it a moment to make sure the entry was emitted.\n    if (browser.capabilities.browserName === 'Safari') {\n      await browser.pause(1000);\n    }\n\n    await stubVisibilityChange('hidden');\n\n    await beaconCountIs(1);\n\n    const [inp] = await getBeacons();\n    assert(inp.value >= 0);\n    assert(inp.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(inp.name, 'INP');\n    assert.strictEqual(inp.value, inp.delta);\n    // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n    if (browser.capabilities.browserName !== 'Safari') {\n      assert.strictEqual(inp.rating, 'good');\n    }\n    assert(containsEntry(inp.entries, 'click', '[object HTMLHeadingElement]'));\n    assert(allEntriesPresentTogether(inp.entries));\n    assert.match(inp.navigationType, /navigate|reload/);\n  });\n\n  it('reports the correct value when loaded late (reportAllChanges === true)', async function () {\n    if (!browserSupportsINP) this.skip();\n\n    // Don't await the `interactive` ready state because DCL is delayed until\n    // after user input.\n    await navigateTo('/test/inp?click=150&reportAllChanges=1&loadAfterInput=1');\n\n    const h1 = await $('h1');\n    await simulateUserLikeClick(h1);\n\n    await beaconCountIs(1);\n\n    const [inp] = await getBeacons();\n    assert(inp.value >= 0);\n    assert(inp.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(inp.name, 'INP');\n    assert.strictEqual(inp.value, inp.delta);\n    // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n    if (browser.capabilities.browserName !== 'Safari') {\n      assert.strictEqual(inp.rating, 'good');\n    }\n    assert(containsEntry(inp.entries, 'click', '[object HTMLHeadingElement]'));\n    assert(allEntriesPresentTogether(inp.entries));\n    assert.match(inp.navigationType, /navigate|reload/);\n  });\n\n  it('reports the correct value on page unload after interactions (reportAllChanges === false)', async function () {\n    if (!browserSupportsINP) this.skip();\n\n    await navigateTo('/test/inp?click=100', {readyState: 'interactive'});\n\n    const h1 = await $('h1');\n    await simulateUserLikeClick(h1);\n\n    // Safari doesn't emit an entry immediately when no paint\n    // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n    // So need to give it a moment to make sure the entry was emitted.\n    if (browser.capabilities.browserName === 'Safari') {\n      await browser.pause(1000);\n    }\n\n    await navigateTo('about:blank', {readyState: 'interactive'});\n\n    await beaconCountIs(1);\n\n    const [inp] = await getBeacons();\n    assert(inp.value >= 0);\n    assert(inp.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(inp.name, 'INP');\n    assert.strictEqual(inp.value, inp.delta);\n    // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n    if (browser.capabilities.browserName !== 'Safari') {\n      assert.strictEqual(inp.rating, 'good');\n    }\n    assert(containsEntry(inp.entries, 'click', '[object HTMLHeadingElement]'));\n    assert(allEntriesPresentTogether(inp.entries));\n    assert.match(inp.navigationType, /navigate|reload/);\n  });\n\n  it('reports the correct value on page unload after interactions (reportAllChanges === true)', async function () {\n    if (!browserSupportsINP) this.skip();\n\n    await navigateTo('/test/inp?click=100&reportAllChanges=1', {\n      readyState: 'interactive',\n    });\n\n    const h1 = await $('h1');\n    await simulateUserLikeClick(h1);\n\n    // Safari doesn't emit an entry immediately when no paint\n    // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n    // So need to give it a moment to make sure the entry was emitted.\n    if (browser.capabilities.browserName === 'Safari') {\n      await browser.pause(1000);\n    }\n\n    await navigateTo('about:blank');\n\n    await beaconCountIs(1);\n\n    const [inp] = await getBeacons();\n    assert(inp.value >= 0);\n    assert(inp.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(inp.name, 'INP');\n    assert.strictEqual(inp.value, inp.delta);\n    // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n    if (browser.capabilities.browserName !== 'Safari') {\n      assert.strictEqual(inp.rating, 'good');\n    }\n    assert(containsEntry(inp.entries, 'click', '[object HTMLHeadingElement]'));\n    assert(allEntriesPresentTogether(inp.entries));\n    assert.match(inp.navigationType, /navigate|reload/);\n  });\n\n  it('reports approx p98 interaction when 50+ interactions (reportAllChanges === false)', async function () {\n    if (!browserSupportsINP) this.skip();\n\n    await navigateTo('/test/inp?click=60&pointerdown=600', {\n      readyState: 'interactive',\n    });\n\n    const h1 = await $('h1');\n    await simulateUserLikeClick(h1);\n\n    await setBlockingTime('pointerdown', 400);\n    await simulateUserLikeClick(h1);\n\n    await setBlockingTime('pointerdown', 100);\n    await simulateUserLikeClick(h1);\n\n    await setBlockingTime('pointerdown', 0);\n\n    // Ensure the interaction completes.\n    await nextFrame();\n    // Give INP a chance to report\n    await waitUntilIdle();\n\n    // Safari doesn't emit an entry immediately when no paint\n    // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n    // So need to give it a moment to make sure the entry was emitted.\n    if (browser.capabilities.browserName === 'Safari') {\n      await browser.pause(1000);\n    }\n\n    await stubVisibilityChange('hidden');\n    await beaconCountIs(1);\n\n    const [inp1] = await getBeacons();\n    assert(inp1.value >= 600); // Initial pointerdown blocking time.\n    assert(allEntriesPresentTogether(inp1.entries));\n    assert.strictEqual(inp1.rating, 'poor');\n\n    await clearBeacons();\n    await stubVisibilityChange('visible');\n\n    let count = 3;\n    while (count < 50) {\n      await h1.click(); // Use .click() because it's faster.\n      count++;\n      // Ensure the interaction completes.\n      await nextFrame();\n    }\n\n    // Give INP a chance to report\n    await waitUntilIdle();\n\n    // Safari doesn't emit an entry immediately when no paint\n    // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n    // So need to give it a moment to make sure the entry was emitted.\n    if (browser.capabilities.browserName === 'Safari') {\n      await browser.pause(1000);\n    }\n\n    await stubVisibilityChange('hidden');\n    await beaconCountIs(1);\n\n    const [inp2] = await getBeacons();\n    assert(inp2.value >= 400); // 2nd-highest pointerdown blocking time.\n    assert(inp2.value < inp1.value); // Should have gone down.\n    assert(allEntriesPresentTogether(inp2.entries));\n    assert.strictEqual(inp2.rating, 'needs-improvement');\n\n    await clearBeacons();\n    await stubVisibilityChange('visible');\n\n    while (count < 100) {\n      await h1.click(); // Use .click() because it's faster.\n      count++;\n      // Ensure the interaction completes.\n      await nextFrame();\n    }\n\n    // Give INP a chance to report\n    await waitUntilIdle();\n\n    // Safari doesn't emit an entry immediately when no paint\n    // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n    // So need to give it a moment to make sure the entry was emitted.\n    if (browser.capabilities.browserName === 'Safari') {\n      await browser.pause(1000);\n    }\n\n    await stubVisibilityChange('hidden');\n    await beaconCountIs(1);\n\n    const [inp3] = await getBeacons();\n    assert(inp3.value >= 100); // 2nd-highest pointerdown blocking time.\n    assert(inp3.value < inp2.value); // Should have gone down.\n    assert(allEntriesPresentTogether(inp3.entries));\n    assert.strictEqual(inp3.rating, 'good');\n  });\n\n  it('reports approx p98 interaction when 50+ interactions (reportAllChanges === true)', async function () {\n    if (!browserSupportsINP) this.skip();\n\n    await navigateTo('/test/inp?click=60&pointerdown=600&reportAllChanges=1', {\n      readyState: 'interactive',\n    });\n\n    const h1 = await $('h1');\n    await simulateUserLikeClick(h1);\n\n    await setBlockingTime('pointerdown', 400);\n    await simulateUserLikeClick(h1);\n\n    await setBlockingTime('pointerdown', 100);\n    await simulateUserLikeClick(h1);\n\n    await setBlockingTime('pointerdown', 0);\n\n    let count = 3;\n    while (count < 100) {\n      await h1.click(); // Use .click() because it's faster.\n      count++;\n      // Ensure the interaction completes.\n      await nextFrame();\n    }\n\n    // Safari doesn't emit an entry immediately when no paint\n    // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n    // So need to give it a moment to make sure the entry was emitted.\n    if (browser.capabilities.browserName === 'Safari') {\n      await browser.pause(1000);\n    }\n\n    await beaconCountIs(3);\n\n    const [inp1, inp2, inp3] = await getBeacons();\n    assert(inp1.value >= 600); // Initial pointerdown blocking time.\n    assert(inp2.value >= 400); // Initial pointerdown blocking time.\n    assert(inp2.value < inp1.value); // Should have gone down.\n    assert(inp3.value >= 100); // 2nd-highest pointerdown blocking time.\n    assert(inp3.value < inp2.value); // Should have gone down.\n    assert(allEntriesPresentTogether(inp1.entries));\n    assert(allEntriesPresentTogether(inp2.entries));\n    assert(allEntriesPresentTogether(inp3.entries));\n    assert.strictEqual(inp1.rating, 'poor');\n    assert.strictEqual(inp2.rating, 'needs-improvement');\n    assert.strictEqual(inp3.rating, 'good');\n  });\n\n  it('reports a new interaction after bfcache restore', async function () {\n    if (!browserSupportsINP) this.skip();\n\n    await navigateTo('/test/inp?click=150');\n\n    // Wait until the library is loaded and the first paint occurs\n    await webVitalsLoaded();\n    await firstContentfulPaint();\n\n    const h1 = await $('h1');\n    await simulateUserLikeClick(h1);\n\n    // Ensure the interaction completes.\n    await nextFrame();\n    // Give INP a chance to report\n    await waitUntilIdle();\n\n    await stubForwardBack();\n    await beaconCountIs(1);\n\n    const [inp1] = await getBeacons();\n    assert(inp1.value >= 0);\n    assert(inp1.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(inp1.name, 'INP');\n    assert.strictEqual(inp1.value, inp1.delta);\n    assert.strictEqual(inp1.rating, 'good');\n    assert(containsEntry(inp1.entries, 'click', '[object HTMLHeadingElement]'));\n    assert(allEntriesPresentTogether(inp1.entries));\n    assert.match(inp1.navigationType, /navigate|reload/);\n\n    await clearBeacons();\n\n    await setBlockingTime('click', 0);\n    await setBlockingTime('keydown', 50);\n\n    const textarea = await $('#textarea');\n    await textarea.click();\n\n    await browser.keys(['a', 'b', 'c']);\n\n    // Ensure the interaction completes.\n    await nextFrame();\n    // Give INP a chance to report\n    await waitUntilIdle();\n\n    // Safari doesn't emit an entry immediately when no paint\n    // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n    // So need to give it a moment to make sure the entry was emitted.\n    if (browser.capabilities.browserName === 'Safari') {\n      await browser.pause(1000);\n    }\n\n    await stubForwardBack();\n    await beaconCountIs(1);\n\n    const [inp2] = await getBeacons();\n\n    assert(inp2.value >= 0);\n    assert(inp2.id.match(/^v5-\\d+-\\d+$/));\n    assert(inp1.id !== inp2.id);\n    assert.strictEqual(inp2.name, 'INP');\n    assert.strictEqual(inp2.value, inp2.delta);\n    assert.strictEqual(inp2.rating, 'good');\n    // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n    if (browser.capabilities.browserName === 'Safari') {\n      assert(\n        containsEntry(inp2.entries, 'keydown', '[object HTMLTextAreaElement]'),\n      );\n    }\n    assert(allEntriesPresentTogether(inp1.entries));\n    assert(inp2.entries[0].startTime > inp1.entries[0].startTime);\n    assert.strictEqual(inp2.navigationType, 'back-forward-cache');\n\n    await stubForwardBack();\n\n    await setBlockingTime('keydown', 0);\n    await setBlockingTime('pointerdown', 300);\n\n    const button = await $('button');\n    await button.click();\n\n    // Ensure the interaction completes.\n    await nextFrame();\n    // Give INP a chance to report\n    await waitUntilIdle();\n\n    // Safari doesn't emit an entry immediately when no paint\n    // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n    // So need to give it a moment to make sure the entry was emitted.\n    if (browser.capabilities.browserName === 'Safari') {\n      await browser.pause(1000);\n    }\n\n    await stubVisibilityChange('hidden');\n    await beaconCountIs(1);\n\n    const [inp3] = await getBeacons();\n    assert(inp3.value >= 0);\n    assert(inp3.id.match(/^v5-\\d+-\\d+$/));\n    assert(inp1.id !== inp3.id);\n    assert.strictEqual(inp3.name, 'INP');\n    assert.strictEqual(inp3.value, inp3.delta);\n    // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n    if (browser.capabilities.browserName !== 'Safari') {\n      assert.strictEqual(inp3.rating, 'needs-improvement');\n      assert(\n        containsEntry(\n          inp3.entries,\n          'pointerdown',\n          '[object HTMLButtonElement]',\n        ),\n      );\n      assert(allEntriesPresentTogether(inp3.entries));\n      assert(inp3.entries[0].startTime > inp2.entries[0].startTime);\n    }\n    assert.strictEqual(inp3.navigationType, 'back-forward-cache');\n  });\n\n  it('does not report if there were no interactions', async function () {\n    if (!browserSupportsINP) this.skip();\n\n    await navigateTo('/test/inp', {readyState: 'interactive'});\n\n    await stubVisibilityChange('hidden');\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    const beacons = await getBeacons();\n    assert.strictEqual(beacons.length, 0);\n  });\n\n  it('reports prerender as nav type for prerender', async function () {\n    if (!browserSupportsINP) this.skip();\n    if (!browserSupportsPrerender) this.skip();\n\n    await navigateTo('/test/inp?click=150&prerender=1');\n\n    await webVitalsLoaded();\n    await firstContentfulPaint();\n\n    let h1 = await $('h1');\n    await simulateUserLikeClick(h1);\n\n    // Wait a bit to allow the prerender to happen\n    await browser.pause(500);\n\n    const prerenderLink = await $('#prerender-link');\n    await prerenderLink.click();\n\n    await beaconCountIs(1);\n    await clearBeacons();\n    await webVitalsLoaded();\n\n    h1 = await $('h1');\n    await simulateUserLikeClick(h1);\n\n    // Ensure the interaction completes.\n    await nextFrame();\n    // Give INP a chance to report\n    await waitUntilIdle();\n\n    await stubVisibilityChange('hidden');\n\n    await beaconCountIs(1);\n\n    const [inp] = await getBeacons();\n    assert(inp.value >= 0);\n    assert(inp.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(inp.name, 'INP');\n    assert.strictEqual(inp.value, inp.delta);\n    assert.strictEqual(inp.rating, 'good');\n    assert(containsEntry(inp.entries, 'click', '[object HTMLHeadingElement]'));\n    assert(allEntriesPresentTogether(inp.entries));\n    assert.strictEqual(inp.navigationType, 'prerender');\n  });\n\n  it('reports restore as nav type for wasDiscarded', async function () {\n    if (!browserSupportsINP) this.skip();\n\n    await navigateTo('/test/inp?click=100&wasDiscarded=1', {\n      readyState: 'interactive',\n    });\n\n    const h1 = await $('h1');\n    await simulateUserLikeClick(h1);\n\n    // Ensure the interaction completes.\n    await nextFrame();\n    // Give INP a chance to report\n    await waitUntilIdle();\n\n    // Safari doesn't emit an entry immediately when no paint\n    // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n    // So need to give it a moment to make sure the entry was emitted.\n    if (browser.capabilities.browserName === 'Safari') {\n      await browser.pause(1000);\n    }\n\n    await stubVisibilityChange('hidden');\n\n    await beaconCountIs(1);\n\n    const [inp] = await getBeacons();\n    assert(inp.value >= 0);\n    assert(inp.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(inp.name, 'INP');\n    assert.strictEqual(inp.value, inp.delta);\n    assert.strictEqual(inp.rating, 'good');\n    assert(containsEntry(inp.entries, 'click', '[object HTMLHeadingElement]'));\n    assert(allEntriesPresentTogether(inp.entries));\n    assert.strictEqual(inp.navigationType, 'restore');\n  });\n\n  it('works when calling the function twice with different options', async function () {\n    if (!browserSupportsINP) this.skip();\n\n    await navigateTo(\n      '/test/inp?click=100&keydown=220&doubleCall=1&reportAllChanges2=1',\n      {readyState: 'interactive'},\n    );\n\n    const textarea = await $('#textarea');\n    simulateUserLikeClick(textarea);\n\n    await beaconCountIs(1, {instance: 2});\n\n    const [inp2_1] = await getBeacons({instance: 2});\n\n    assert(inp2_1.value > 100 - 8);\n    assert(inp2_1.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(inp2_1.name, 'INP');\n    assert.strictEqual(inp2_1.value, inp2_1.delta);\n    assert.strictEqual(inp2_1.rating, 'good');\n    assert(\n      containsEntry(inp2_1.entries, 'click', '[object HTMLTextAreaElement]'),\n    );\n    assert(allEntriesValid(inp2_1.entries));\n    assert.match(inp2_1.navigationType, /navigate|reload/);\n\n    // Assert no beacons for instance 1 were received.\n    assert.strictEqual((await getBeacons({instance: 1})).length, 0);\n\n    await browser.keys(['a']);\n\n    await beaconCountIs(2, {instance: 2});\n\n    const [, inp2_2] = await getBeacons({instance: 2});\n\n    assert.strictEqual(inp2_2.id, inp2_1.id);\n    assert.strictEqual(inp2_2.name, 'INP');\n    assert.strictEqual(inp2_2.value, inp2_2.delta + inp2_1.delta);\n    assert.strictEqual(inp2_2.delta, inp2_2.value - inp2_1.delta);\n    // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n    if (browser.capabilities.browserName !== 'Safari') {\n      assert.strictEqual(inp2_2.rating, 'needs-improvement');\n      assert(\n        containsEntry(\n          inp2_2.entries,\n          'keydown',\n          '[object HTMLTextAreaElement]',\n        ),\n      );\n    }\n    assert(allEntriesValid(inp2_2.entries));\n    assert.match(inp2_2.navigationType, /navigate|reload/);\n\n    await stubVisibilityChange('hidden');\n    await beaconCountIs(1, {instance: 1});\n\n    const [inp1] = await getBeacons({instance: 1});\n    assert(inp1.id.match(/^v5-\\d+-\\d+$/));\n    assert(inp1.id !== inp2_1.id);\n\n    assert(inp1.id.match(/^v5-\\d+-\\d+$/));\n    assert(inp1.id !== inp2_2.id);\n    assert.strictEqual(inp1.value, inp2_2.value);\n    assert.strictEqual(inp1.delta, inp2_2.value);\n    assert.strictEqual(inp1.name, inp2_2.name);\n    assert.strictEqual(inp1.rating, inp2_2.rating);\n    assert.deepEqual(inp1.entries, inp2_2.entries);\n    assert.strictEqual(inp1.navigationType, inp2_2.navigationType);\n  });\n\n  it('reports on batch reporting using document.visibilitychange', async function () {\n    if (!browserSupportsINP) this.skip();\n\n    await navigateTo('/test/inp?click=100&batchReporting=1', {\n      readyState: 'interactive',\n    });\n\n    // Wait until the library is loaded\n    await webVitalsLoaded();\n\n    const h1 = await $('h1');\n    await simulateUserLikeClick(h1);\n\n    // Ensure the interaction completes.\n    await nextFrame();\n    // Give INP a chance to report\n    await waitUntilIdle();\n\n    await hideAndReshowPage();\n\n    await beaconCountIs(1);\n    const [inp] = await getBeacons();\n\n    assert(inp.value >= 0);\n    assert(inp.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(inp.name, 'INP');\n    assert.strictEqual(inp.value, inp.delta);\n    assert.strictEqual(inp.rating, 'good');\n    assert(containsEntry(inp.entries, 'click', '[object HTMLHeadingElement]'));\n    assert(allEntriesPresentTogether(inp.entries));\n    assert.match(inp.navigationType, /navigate|reload/);\n  });\n\n  describe('attribution', function () {\n    it('includes attribution data on the metric object', async function () {\n      if (!browserSupportsINP) this.skip();\n\n      await navigateTo('/test/inp?click=100&attribution=1', {\n        readyState: 'complete',\n      });\n\n      // Wait until the library is loaded and the first paint occurs to ensure\n      // The 40ms event duration is set\n      await webVitalsLoaded();\n      await firstContentfulPaint();\n\n      const h1 = await $('h1');\n      await simulateUserLikeClick(h1);\n\n      // Ensure the interaction completes.\n      await nextFrame();\n      // Give INP a chance to report\n      await waitUntilIdle();\n\n      await stubVisibilityChange('hidden');\n\n      await beaconCountIs(1);\n\n      const [inp1] = await getBeacons();\n\n      assert(inp1.value >= 100 - ROUNDING_ERROR);\n      assert(inp1.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(inp1.name, 'INP');\n      assert.strictEqual(inp1.value, inp1.delta);\n      assert.strictEqual(inp1.rating, 'good');\n      assert(\n        containsEntry(inp1.entries, 'click', '[object HTMLHeadingElement]'),\n      );\n      assert(allEntriesPresentTogether(inp1.entries));\n      assert.match(inp1.navigationType, /navigate|reload/);\n\n      assert.equal(inp1.attribution.interactionTarget, 'html>body>main>h1');\n      assert.equal(inp1.attribution.interactionType, 'pointer');\n      assert.equal(inp1.attribution.interactionTime, inp1.entries[0].startTime);\n      assert.equal(inp1.attribution.loadState, 'complete');\n      assert(allEntriesPresentTogether(inp1.attribution.processedEventEntries));\n\n      // Assert that the reported `nextPaintTime` estimate is not more than 8ms\n      // different from `startTime+duration` in the Event Timing API.\n      assert(\n        inp1.attribution.nextPaintTime -\n          (inp1.entries[0].startTime + inp1.entries[0].duration) <=\n          8,\n      );\n      // Assert that `nextPaintTime` is after processing ends.\n      assert(\n        inp1.attribution.nextPaintTime >=\n          inp1.attribution.interactionTime +\n            (inp1.attribution.inputDelay + inp1.attribution.processingDuration),\n      );\n      // Assert that the INP subpart durations adds up to the total duration\n      // with a tolerance of 1 for rounding error issues\n      assertIsCloseTo(\n        inp1.attribution.nextPaintTime - inp1.attribution.interactionTime,\n        inp1.attribution.inputDelay +\n          inp1.attribution.processingDuration +\n          inp1.attribution.presentationDelay,\n        1,\n      );\n\n      // Assert that the INP subparts timestamps match the values in\n      // the `processedEventEntries` array\n      // with a tolerance of 1 for rounding error issues\n      const sortedEntries1 = inp1.attribution.processedEventEntries.sort(\n        (a, b) => {\n          return a.processingStart - b.processingStart;\n        },\n      );\n      assertIsCloseTo(\n        inp1.attribution.interactionTime + inp1.attribution.inputDelay,\n        sortedEntries1[0].processingStart,\n        1,\n      );\n      assertIsCloseTo(\n        inp1.attribution.interactionTime +\n          inp1.attribution.inputDelay +\n          inp1.attribution.processingDuration,\n        sortedEntries1.at(-1).processingEnd,\n        1,\n      );\n      assertIsCloseTo(\n        inp1.attribution.nextPaintTime - inp1.attribution.presentationDelay,\n        sortedEntries1.at(-1).processingEnd,\n        1,\n      );\n\n      await clearBeacons();\n\n      await stubVisibilityChange('visible');\n      await setBlockingTime('keydown', 300);\n\n      const textarea = await $('#textarea');\n      await textarea.click();\n      await browser.keys(['x']);\n\n      // Ensure the interaction completes.\n      await nextFrame();\n      // Give INP a chance to report\n      await waitUntilIdle();\n\n      await stubVisibilityChange('hidden');\n      await beaconCountIs(1);\n\n      const [inp2] = await getBeacons();\n\n      assert(inp2.value >= 300 - ROUNDING_ERROR);\n      assert(inp2.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(inp2.name, 'INP');\n      assert.strictEqual(inp2.value, inp1.value + inp2.delta);\n      assert.strictEqual(inp2.rating, 'needs-improvement');\n      assert(allEntriesPresentTogether(inp2.entries));\n      assert.match(inp2.navigationType, /navigate|reload/);\n\n      assert.equal(inp2.attribution.interactionTarget, '#textarea');\n      assert.equal(inp2.attribution.interactionType, 'keyboard');\n      assert.equal(inp2.attribution.interactionTime, inp2.entries[0].startTime);\n      assert.equal(inp2.attribution.loadState, 'complete');\n      assert(allEntriesPresentTogether(inp2.attribution.processedEventEntries));\n      assert(\n        containsEntry(\n          inp2.attribution.processedEventEntries,\n          'keydown',\n          '[object HTMLTextAreaElement]',\n        ),\n      );\n\n      // Assert that the reported `nextPaintTime` estimate is not more than 8ms\n      // different from `startTime+duration` in the Event Timing API.\n      assert(\n        inp2.attribution.nextPaintTime -\n          (inp2.entries[0].startTime + inp2.entries[0].duration) <=\n          8,\n      );\n      // Assert that `nextPaintTime` is after processing ends.\n      assert(\n        inp2.attribution.nextPaintTime >=\n          inp2.attribution.interactionTime +\n            (inp2.attribution.inputDelay + inp2.attribution.processingDuration),\n      );\n      // Assert that the INP subpart durations adds up to the total duration.\n      assert.equal(\n        inp2.attribution.nextPaintTime - inp2.attribution.interactionTime,\n        inp2.attribution.inputDelay +\n          inp2.attribution.processingDuration +\n          inp2.attribution.presentationDelay,\n      );\n\n      // Assert that the INP subparts timestamps match the values in\n      // the `processedEventEntries` array.\n      const sortedEntries2 = inp2.attribution.processedEventEntries.sort(\n        (a, b) => {\n          return a.processingStart - b.processingStart;\n        },\n      );\n      assert.equal(\n        inp2.attribution.interactionTime + inp2.attribution.inputDelay,\n        sortedEntries2[0].processingStart,\n      );\n      assert.equal(\n        inp2.attribution.interactionTime +\n          inp2.attribution.inputDelay +\n          inp2.attribution.processingDuration,\n        sortedEntries2.at(-1).processingEnd,\n      );\n      assert.equal(\n        inp2.attribution.nextPaintTime - inp2.attribution.presentationDelay,\n        sortedEntries2.at(-1).processingEnd,\n      );\n    });\n\n    it('limits processedEventEntries to 5', async function () {\n      if (!browserSupportsINP) this.skip();\n\n      await navigateTo('/test/inp?attribution=1&pointerdown=100');\n\n      // Wait until the library is loaded and the first paint occurs to ensure\n      // The 40ms event duration is set\n      await webVitalsLoaded();\n      await firstContentfulPaint();\n\n      const textarea = await $('#textarea');\n\n      // A click should register pointerdown, mousedown, mouseup, pointerup,\n      // click and some pointerover, pointerenter, pointerleave events\n      // (at least in Chrome and Firefox)\n      // Which is more than 5 and so enough to test with!\n      await textarea.click();\n\n      // Ensure the interaction completes.\n      await nextFrame();\n      // Give INP a chance to report\n      await waitUntilIdle();\n\n      await stubForwardBack();\n      await beaconCountIs(1);\n\n      const [inp] = await getBeacons();\n\n      assert(inp.value >= 0);\n      assert(inp.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(inp.name, 'INP');\n      assert.strictEqual(inp.value, inp.delta);\n      assert(allEntriesPresentTogether(inp.entries));\n      assert(inp.attribution.processedEventEntries.length > 1);\n      assert(inp.attribution.processedEventEntries.length <= 5);\n    });\n\n    it('supports generating a custom target', async function () {\n      if (!browserSupportsINP) this.skip();\n\n      await navigateTo('/test/inp?click=100&attribution=1&generateTarget=1', {\n        readyState: 'complete',\n      });\n\n      const h1 = await $('h1');\n      await simulateUserLikeClick(h1);\n\n      // Ensure the interaction completes.\n      await nextFrame();\n      // Give INP a chance to report\n      await waitUntilIdle();\n\n      await stubVisibilityChange('hidden');\n\n      await beaconCountIs(1);\n\n      const [inp1] = await getBeacons();\n\n      assert(inp1.value >= 100 - ROUNDING_ERROR);\n      assert(inp1.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(inp1.name, 'INP');\n      assert.strictEqual(inp1.value, inp1.delta);\n      assert.strictEqual(inp1.rating, 'good');\n      assert(\n        containsEntry(inp1.entries, 'click', '[object HTMLHeadingElement]'),\n      );\n      assert(allEntriesPresentTogether(inp1.entries));\n      assert.match(inp1.navigationType, /navigate|reload/);\n\n      assert.equal(inp1.attribution.interactionTarget, 'main-heading');\n    });\n\n    it('supports generating a custom target with fallback', async function () {\n      if (!browserSupportsINP) this.skip();\n\n      await navigateTo('/test/inp?click=100&attribution=1&generateTarget=1', {\n        readyState: 'complete',\n      });\n\n      const label1 = await $('#label1>code');\n      await simulateUserLikeClick(label1);\n\n      // Ensure the interaction completes.\n      await nextFrame();\n      // Give INP a chance to report\n      await waitUntilIdle();\n\n      await stubVisibilityChange('hidden');\n\n      await beaconCountIs(1);\n\n      const [inp1] = await getBeacons();\n\n      assert.equal(inp1.attribution.interactionTarget, '#label1>code');\n    });\n\n    it('supports multiple calls with different custom target generation functions', async function () {\n      if (!browserSupportsINP) this.skip();\n\n      await navigateTo(\n        '/test/inp?click=150&attribution=1&doubleCall=1&generateTarget2=1' +\n          '&reportAllChanges=1&reportAllChanges2=1',\n      );\n\n      // Wait until the library is loaded and the first paint occurs to ensure\n      // The 40ms event duration is set\n      await webVitalsLoaded();\n      await firstContentfulPaint();\n\n      const h1 = await $('h1');\n      await simulateUserLikeClick(h1);\n\n      await beaconCountIs(1, {instance: 1});\n      await beaconCountIs(1, {instance: 2});\n\n      const [inp1] = await getBeacons({instance: 1});\n\n      assert(inp1.value >= 100 - ROUNDING_ERROR);\n      assert(inp1.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(inp1.name, 'INP');\n      assert.strictEqual(inp1.value, inp1.delta);\n      // See Safari bug - https://bugs.webkit.org/show_bug.cgi?id=305251\n      if (browser.capabilities.browserName !== 'Safari') {\n        assert.strictEqual(inp1.rating, 'good');\n      }\n      assert(\n        containsEntry(inp1.entries, 'click', '[object HTMLHeadingElement]'),\n      );\n      assert(allEntriesPresentTogether(inp1.entries));\n      assert.match(inp1.navigationType, /navigate|reload/);\n\n      assert.equal(inp1.attribution.interactionTarget, 'html>body>main>h1');\n\n      const [inp2] = await getBeacons({instance: 2});\n\n      assert.strictEqual(inp2.name, inp1.name);\n      assert.strictEqual(inp2.value, inp1.value);\n      assert.strictEqual(inp2.delta, inp1.delta);\n      assert.strictEqual(inp2.rating, inp1.rating);\n      assert.strictEqual(inp2.navigationType, inp1.navigationType);\n      assert.deepEqual(inp2.entries, inp1.entries);\n      assert(inp2.id !== inp1.id);\n\n      assert.equal(inp2.attribution.interactionTarget, 'main-heading');\n    });\n\n    it('reports the domReadyState when input occurred', async function () {\n      if (!browserSupportsINP) this.skip();\n\n      await navigateTo(\n        '/test/inp?attribution=1&reportAllChanges=1&click=150&delayDCL=1000',\n      );\n\n      // Click on the <h1>.\n      const h1 = await $('h1');\n      await h1.click();\n\n      await webVitalsLoaded();\n\n      await stubVisibilityChange('visible');\n      await beaconCountIs(1);\n\n      const [inp1] = await getBeacons();\n      assert.equal(inp1.attribution.loadState, 'dom-interactive');\n\n      await clearBeacons();\n\n      await navigateTo(\n        '/test/inp' +\n          '?attribution=1&reportAllChanges=1&click=150&delayResponse=2000',\n      );\n\n      // Wait a bit to ensure the page elements are available.\n      await browser.pause(1000);\n\n      // Click on the <button>.\n      const reset = await $('#reset');\n      await reset.click();\n\n      await beaconCountIs(1);\n\n      const [inp2] = await getBeacons();\n      assert.equal(inp2.attribution.loadState, 'loading');\n    });\n\n    // TODO: remove this test once the following bug is fixed:\n    // https://bugs.chromium.org/p/chromium/issues/detail?id=1367329\n    it('reports the interaction target from any entry where target is defined', async function () {\n      if (!browserSupportsINP) this.skip();\n\n      await navigateTo('/test/inp?attribution=1&mouseup=100&click=50', {\n        readyState: 'interactive',\n      });\n\n      const h1 = await $('h1');\n      await simulateUserLikeClick(h1);\n\n      // Ensure the interaction completes.\n      await nextFrame();\n      // Give INP a chance to report\n      await waitUntilIdle();\n\n      await stubVisibilityChange('hidden');\n      await beaconCountIs(1);\n\n      const [inp1] = await getBeacons();\n\n      assert.equal(inp1.attribution.interactionType, 'pointer');\n      // The event target should match the h1, even if the `pointerup`\n      // entry doesn't contain a target.\n      // See: https://bugs.chromium.org/p/chromium/issues/detail?id=1367329\n      assert.equal(inp1.attribution.interactionTarget, 'html>body>main>h1');\n    });\n\n    it('reports the interaction target when target is removed from the DOM', async function () {\n      if (!browserSupportsINP) this.skip();\n\n      await navigateTo('/test/inp?attribution=1&mouseup=100&click=50', {\n        readyState: 'interactive',\n      });\n\n      const button = await $('#reset');\n      await simulateUserLikeClick(button);\n\n      // Ensure the interaction completes.\n      await nextFrame();\n      // Give INP a chance to report\n      await waitUntilIdle();\n\n      // Remove the element after the interaction.\n      await browser.execute('document.querySelector(\"#reset\").remove()');\n\n      await stubVisibilityChange('hidden');\n      await beaconCountIs(1);\n\n      const [inp] = await getBeacons();\n\n      assert.equal(inp.attribution.interactionType, 'pointer');\n      assert.equal(inp.attribution.interactionTarget, '#reset');\n    });\n\n    it('includes LoAF entries if the browser supports it', async function () {\n      if (!browserSupportsLoAF) this.skip();\n\n      await navigateTo('/test/inp?attribution=1&pointerdown=100', {\n        readyState: 'interactive',\n      });\n\n      // Click on the <textarea>.\n      const textarea = await $('#textarea');\n      await textarea.click();\n\n      // Ensure the interaction completes.\n      await nextFrame();\n      // Give INP a chance to report\n      await waitUntilIdle();\n\n      await stubVisibilityChange('hidden');\n      await beaconCountIs(1);\n\n      const [inp1] = await getBeacons();\n      assert(inp1.attribution.longAnimationFrameEntries.length > 0);\n      assert.equal(\n        inp1.attribution.longestScript.entry.invokerType,\n        'event-listener',\n      );\n      assert.equal(\n        inp1.attribution.longestScript.entry.invoker,\n        'DOMWindow.onpointerdown',\n      );\n      assert.equal(\n        inp1.attribution.longestScript.subpart,\n        'processing-duration',\n      );\n      assertIsCloseTo(\n        inp1.attribution.longestScript.intersectingDuration,\n        100,\n        10,\n      );\n      assert(inp1.attribution.totalScriptDuration > 0);\n      assert(inp1.attribution.totalStyleAndLayoutDuration >= 0);\n      assert(inp1.attribution.totalPaintDuration >= 0);\n      assert(inp1.attribution.totalUnattributedDuration >= 0);\n      assertIsCloseTo(\n        inp1.value,\n        inp1.attribution.totalScriptDuration +\n          inp1.attribution.totalStyleAndLayoutDuration +\n          inp1.attribution.totalPaintDuration +\n          inp1.attribution.totalUnattributedDuration,\n        0.1,\n      );\n    });\n  });\n});\n\nconst containsEntry = (entries, name, target) => {\n  return entries.findIndex((e) => e.name === name && e.target === target) > -1;\n};\n\nconst allEntriesValid = (entries) => {\n  const renderTimes = entries\n    .map((e) => e.startTime + e.duration)\n    .sort((a, b) => a - b);\n\n  const allEntriesHaveSameRenderTimes =\n    renderTimes.at(-1) - renderTimes.at(0) === 0;\n\n  const entryData = entries.map((e) => JSON.stringify(e));\n\n  const allEntriesAreUnique = entryData.length === new Set(entryData).size;\n\n  return allEntriesHaveSameRenderTimes && allEntriesAreUnique;\n};\n\nconst allEntriesPresentTogether = (entries) => {\n  const renderTimes = entries\n    .map((e) => e.startTime + e.duration)\n    .sort((a, b) => a - b);\n\n  return renderTimes.at(-1) - renderTimes.at(0) <= 8;\n};\n\nconst setBlockingTime = async (event, value) => {\n  const input = await $(`#${event}-blocking-time`);\n  await input.setValue(value);\n};\n\nconst simulateUserLikeClick = async (element) => {\n  await browser\n    .action('pointer')\n    .move({x: 0, y: 0, origin: element})\n    .down({button: 0}) // left button\n    .pause(50)\n    .up({button: 0})\n    .perform();\n};\n\nconst hideAndReshowPage = async () => {\n  // Switch to new tab and back to change visibility state.\n  // New tabs on Safari in webdriver.io are flakey, so minimize/maximize\n  // instead, but it's kind of distracting so use tab switch for others.\n  if (browser.capabilities.browserName !== 'Safari') {\n    const handle1 = await browser.getWindowHandle();\n    await browser.newWindow('https://example.com');\n    await browser.pause(500);\n    await browser.closeWindow();\n    await browser.switchToWindow(handle1);\n  } else {\n    await browser.minimizeWindow();\n    await browser.pause(500);\n    await browser.maximizeWindow();\n  }\n};\n"
  },
  {
    "path": "test/e2e/onLCP-test.js",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport assert from 'assert';\nimport {beaconCountIs, clearBeacons, getBeacons} from '../utils/beacons.js';\nimport {browserSupportsEntry} from '../utils/browserSupportsEntry.js';\nimport {firstContentfulPaint} from '../utils/firstContentfulPaint.js';\nimport {imagesPainted} from '../utils/imagesPainted.js';\nimport {navigateTo} from '../utils/navigateTo.js';\nimport {nextFrame} from '../utils/nextFrame.js';\nimport {stubForwardBack} from '../utils/stubForwardBack.js';\nimport {stubVisibilityChange} from '../utils/stubVisibilityChange.js';\nimport {webVitalsLoaded} from '../utils/webVitalsLoaded.js';\n\ndescribe('onLCP()', async function () {\n  // Retry all tests in this suite up to 2 times.\n  this.retries(2);\n\n  let browserSupportsLCP;\n  let browserSupportsVisibilityState;\n  let browserSupportsPrerender;\n  before(async function () {\n    browserSupportsLCP = await browserSupportsEntry('largest-contentful-paint');\n    browserSupportsVisibilityState =\n      await browserSupportsEntry('visibility-state');\n    browserSupportsPrerender = await browser.execute(() => {\n      return 'onprerenderingchange' in document;\n    });\n  });\n\n  beforeEach(async function () {\n    await navigateTo('about:blank');\n    await clearBeacons();\n  });\n\n  it('reports the correct value on hidden (reportAllChanges === false)', async function () {\n    if (!browserSupportsLCP) this.skip();\n\n    await navigateTo('/test/lcp');\n\n    // Wait until all images are loaded and fully rendered.\n    await imagesPainted();\n\n    // Load a new page to trigger the hidden state.\n    await navigateTo('about:blank');\n\n    await beaconCountIs(1);\n    assertStandardReportsAreCorrect(await getBeacons());\n  });\n\n  it('reports the correct value on hidden (reportAllChanges === true)', async function () {\n    if (!browserSupportsLCP) this.skip();\n\n    await navigateTo('/test/lcp?reportAllChanges=1');\n\n    // Wait until all images are loaded and fully rendered.\n    await imagesPainted();\n\n    // Load a new page to trigger the hidden state.\n    await navigateTo('about:blank');\n\n    await beaconCountIs(2);\n    assertFullReportsAreCorrect(await getBeacons());\n  });\n\n  it('reports the correct value on input (reportAllChanges === false)', async function () {\n    if (!browserSupportsLCP) this.skip();\n\n    await navigateTo('/test/lcp');\n\n    // Wait until all images are loaded and fully rendered.\n    await imagesPainted();\n\n    // Click on the h1 to finalize LCP.\n    const h1 = await $('h1');\n    await h1.click();\n\n    await beaconCountIs(1);\n    assertStandardReportsAreCorrect(await getBeacons());\n  });\n\n  it('reports the correct value on input (reportAllChanges === true)', async function () {\n    if (!browserSupportsLCP) this.skip();\n\n    await navigateTo('/test/lcp?reportAllChanges=1');\n\n    // Wait until all images are loaded and fully rendered.\n    await imagesPainted();\n\n    await beaconCountIs(2);\n    assertFullReportsAreCorrect(await getBeacons());\n  });\n\n  it('reports the correct value when loaded late (reportAllChanges === false)', async function () {\n    if (!browserSupportsLCP) this.skip();\n\n    await navigateTo('/test/lcp?lazyLoad=1');\n\n    // Wait until all images are loaded and fully rendered.\n    await imagesPainted();\n\n    // Click on the h1 to finalize LCP.\n    const h1 = await $('h1');\n    await h1.click();\n\n    await beaconCountIs(1);\n    assertStandardReportsAreCorrect(await getBeacons());\n  });\n\n  it('reports the correct value when loaded late (reportAllChanges === true)', async function () {\n    if (!browserSupportsLCP) this.skip();\n\n    await navigateTo('/test/lcp?lazyLoad=1&reportAllChanges=1');\n\n    // Wait until all images are loaded and fully rendered.\n    await imagesPainted();\n\n    // Safari has an LCP buffer bug - https://bugs.webkit.org/show_bug.cgi?id=305256\n    if (browser.capabilities.browserName !== 'Safari') {\n      await beaconCountIs(2);\n      const beacons = await getBeacons();\n      // Firefox sometimes sends <p>, then <h1>\n      // so grab last two\n      await browser.pause(500);\n\n      assert(beacons.length >= 2);\n      const lcp1 = beacons.at(-2);\n      const lcp2 = beacons.at(-1);\n\n      assert(lcp1.value > 0);\n      assert(lcp1.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(lcp1.name, 'LCP');\n      assert.strictEqual(lcp1.value, lcp1.delta);\n      assert.strictEqual(lcp1.rating, 'good');\n      assert.strictEqual(lcp1.entries.length, 1);\n      assert.strictEqual(lcp1.navigationType, 'navigate');\n\n      assert(lcp2.value > 500); // Greater than the image load delay.\n      assert(lcp2.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(lcp2.name, 'LCP');\n      assert(lcp2.value > lcp2.delta);\n      assert.strictEqual(lcp2.rating, 'good');\n      assert.strictEqual(lcp2.entries.length, 1);\n      assert.strictEqual(lcp2.navigationType, 'navigate');\n    } else {\n      await beaconCountIs(1);\n      const beacons = await getBeacons();\n\n      assert(beacons.length >= 1);\n      const lcp2 = beacons.at(-1);\n\n      assert(lcp2.value > 500); // Greater than the image load delay.\n      assert(lcp2.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(lcp2.name, 'LCP');\n      // assert(lcp2.value > lcp2.delta);\n      assert.strictEqual(lcp2.rating, 'good');\n      assert.strictEqual(lcp2.entries.length, 1);\n      assert.strictEqual(lcp2.navigationType, 'navigate');\n    }\n  });\n\n  it('accounts for time prerendering the page', async function () {\n    if (!browserSupportsLCP) this.skip();\n    if (!browserSupportsPrerender) this.skip();\n\n    await navigateTo('/test/lcp?prerender=1');\n\n    // Wait until all images are loaded and fully rendered.\n    await imagesPainted();\n\n    // Wait until web-vitals is loaded\n    await webVitalsLoaded();\n\n    // Click on the h1 to finalize LCP.\n    const h1 = await $('h1');\n    await h1.click();\n\n    await beaconCountIs(1);\n    await clearBeacons();\n\n    // Wait a bit to allow the prerender to happen\n    await browser.pause(1000);\n\n    const prerenderLink = await $('#prerender-link');\n    await prerenderLink.click();\n\n    // Wait a bit for the navigation to start\n    await browser.pause(500);\n\n    // Wait until all images are loaded and fully rendered.\n    await imagesPainted();\n\n    const activationStart = await browser.execute(() => {\n      return performance.getEntriesByType('navigation')[0].activationStart;\n    });\n\n    // Load a new page to trigger the hidden state.\n    await navigateTo('about:blank');\n\n    await beaconCountIs(1);\n\n    const [lcp] = await getBeacons();\n    assert.strictEqual(lcp.rating, 'good');\n    assert.strictEqual(lcp.entries[0].startTime - activationStart, lcp.value);\n    assert.strictEqual(lcp.navigationType, 'prerender');\n    await clearBeacons();\n  });\n\n  it('does not report if the browser does not support LCP (including bfcache restores)', async function () {\n    if (browserSupportsLCP) this.skip();\n\n    await navigateTo('/test/lcp');\n\n    // Wait until all images are loaded and fully rendered.\n    await imagesPainted();\n\n    // Click on the h1 to finalize LCP.\n    const h1 = await $('h1');\n    await h1.click();\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    assert.strictEqual((await getBeacons()).length, 0);\n\n    await clearBeacons();\n    await stubForwardBack();\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    assert.strictEqual((await getBeacons()).length, 0);\n  });\n\n  it('does not report if the document was hidden at page load time', async function () {\n    if (!browserSupportsLCP) this.skip();\n\n    await navigateTo('/test/lcp?hidden=1', {readyState: 'interactive'});\n\n    await stubVisibilityChange('visible');\n\n    // Click on the h1 to finalize LCP.\n    const h1 = await $('h1');\n    await h1.click();\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    const beacons = await getBeacons();\n    assert.strictEqual(beacons.length, 0);\n  });\n\n  it('does not report if hidden before library loaded and visibility-state supported', async function () {\n    if (!browserSupportsLCP) this.skip();\n    if (!browserSupportsVisibilityState) this.skip();\n\n    // Don't load the library until we click\n    await navigateTo('/test/lcp?loadAfterInput=1&renderBlocking=400');\n\n    // Immediately switch tab\n    await hideAndReshowPage();\n\n    // Click on the h1 to load the library\n    const h1 = await $('h1');\n    await h1.click();\n\n    // Wait until web-vitals is loaded\n    await webVitalsLoaded();\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    // Click on the h1 again now it's loaded to trigger LCP\n    await h1.click();\n\n    const beacons = await getBeacons();\n    assert.strictEqual(beacons.length, 0);\n  });\n\n  it('does report if hidden before library loaded and visibility-state not supported', async function () {\n    if (!browserSupportsLCP) this.skip();\n    if (browserSupportsVisibilityState) this.skip();\n\n    // Don't load the library until we click\n    await navigateTo('/test/lcp?loadAfterInput=1&renderBlocking=400');\n    await hideAndReshowPage();\n\n    // Click on the h1 to load the library\n    const h1 = await $('h1');\n    await h1.click();\n\n    // Wait until web-vitals is loaded\n    await webVitalsLoaded();\n\n    // Click on the h1 again now it's loaded to trigger LCP\n    await h1.click();\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    await beaconCountIs(1);\n    assertStandardReportsAreCorrect(await getBeacons());\n  });\n\n  it('does not report if page hidden initially and onLCP loaded on visibility', async function () {\n    if (!browserSupportsLCP) this.skip();\n    if (!browserSupportsVisibilityState) this.skip();\n\n    // Don't load the library until we reshow the page\n    await navigateTo('/test/lcp?hidden=1&registerOnVisibilityChange=1');\n\n    // Wait until web-vitals is loaded\n    await webVitalsLoaded();\n\n    // Wait a frame to ensure the onLCP call is registered\n    await nextFrame();\n\n    await stubVisibilityChange('visible');\n\n    // Wait a frame to ensure the H1 is painted\n    await nextFrame();\n\n    // Click on the h1 to finalize LCP\n    const h1 = await $('h1');\n    await h1.click();\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    const beacons = await getBeacons();\n    assert.strictEqual(beacons.length, 0);\n  });\n\n  it('does not report if the document changes to hidden before the first render', async function () {\n    if (!browserSupportsLCP) this.skip();\n\n    await navigateTo('/test/lcp?renderBlocking=1000');\n\n    await hideAndReshowPage();\n\n    // Click on the h1 to finalize LCP.\n    const h1 = await $('h1');\n    await h1.click();\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    const beacons = await getBeacons();\n    assert.strictEqual(beacons.length, 0);\n  });\n\n  it('reports after a render delay before the page changes to hidden', async function () {\n    if (!browserSupportsLCP) this.skip();\n\n    await navigateTo('/test/lcp?renderBlocking=3000');\n\n    // Change to hidden after the first render.\n    await browser.pause(3500);\n    await hideAndReshowPage();\n\n    const [lcp1] = await getBeacons();\n\n    assert(lcp1.value > 3000);\n    assert.strictEqual(lcp1.name, 'LCP');\n    assert.strictEqual(lcp1.value, lcp1.delta);\n    assert.strictEqual(lcp1.rating, 'needs-improvement');\n    assert.strictEqual(lcp1.entries.length, 1);\n    assert.strictEqual(lcp1.entries[0].element, '[object HTMLImageElement]');\n    assert.match(lcp1.navigationType, /navigate|reload/);\n  });\n\n  it('stops reporting after the document changes to hidden (reportAllChanges === false)', async function () {\n    if (!browserSupportsLCP) this.skip();\n\n    await navigateTo('/test/lcp?imgDelay=0&imgHidden=1', {\n      readyState: 'interactive',\n    });\n\n    // Wait until the library is loaded and the first paint occurs to ensure\n    // that an LCP entry can be dispatched prior to the document changing to\n    // hidden.\n    await webVitalsLoaded();\n    await firstContentfulPaint();\n\n    await hideAndReshowPage();\n\n    await browser.execute(() => {\n      document.querySelector('img').hidden = false;\n    });\n\n    // Click on the h1 to finalize LCP.\n    const h1 = await $('h1');\n    await h1.click();\n\n    // Wait a bit to ensure no additional beacons were sent.\n    await browser.pause(1000);\n\n    await beaconCountIs(1);\n\n    const [lcp1] = await getBeacons();\n\n    assert(lcp1.value > 0);\n    assert.strictEqual(lcp1.name, 'LCP');\n    assert.strictEqual(lcp1.value, lcp1.delta);\n    assert.strictEqual(lcp1.rating, 'good');\n    assert.strictEqual(lcp1.entries.length, 1);\n    // See Firefox bug - https://bugzilla.mozilla.org/show_bug.cgi?id=1977827\n    if (browser.capabilities.browserName !== 'firefox') {\n      assert.strictEqual(\n        lcp1.entries[0].element,\n        '[object HTMLHeadingElement]',\n      );\n    }\n    assert.match(lcp1.navigationType, /navigate|reload/);\n  });\n\n  it('stops reporting after the document changes to hidden (reportAllChanges === true)', async function () {\n    if (!browserSupportsLCP) this.skip();\n\n    await navigateTo('/test/lcp?reportAllChanges=1&imgDelay=0&imgHidden=1');\n\n    await beaconCountIs(1);\n    // Firefox sometimes sends a <p> and then <h1> beacon, so grab last one\n    await browser.pause(1000);\n    let beacons = await getBeacons();\n    const lcp = beacons.at(-1);\n\n    assert(lcp.value > 0);\n    assert.strictEqual(lcp.name, 'LCP');\n    assert.strictEqual(lcp.value, lcp.delta);\n    assert.strictEqual(lcp.rating, 'good');\n    assert.strictEqual(lcp.entries.length, 1);\n    // See Firefox bug - https://bugzilla.mozilla.org/show_bug.cgi?id=1977827\n    if (browser.capabilities.browserName !== 'firefox') {\n      assert.strictEqual(lcp.entries[0].element, '[object HTMLHeadingElement]');\n    }\n    assert.match(lcp.navigationType, /navigate|reload/);\n\n    await clearBeacons();\n    await hideAndReshowPage();\n\n    await browser.execute(() => {\n      document.querySelector('img').hidden = false;\n    });\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    beacons = await getBeacons();\n    assert.strictEqual(beacons.length, 0);\n  });\n\n  it('reports if the page is restored from bfcache', async function () {\n    if (!browserSupportsLCP) this.skip();\n\n    await navigateTo('/test/lcp');\n\n    // Wait until all images are loaded and fully rendered.\n    await imagesPainted();\n\n    const h1 = await $('h1');\n    await h1.click();\n    await beaconCountIs(1);\n\n    assertStandardReportsAreCorrect(await getBeacons());\n    await clearBeacons();\n\n    await stubForwardBack();\n    await beaconCountIs(1);\n\n    const [lcp1] = await getBeacons();\n\n    assert(lcp1.value > 0);\n    assert(lcp1.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(lcp1.name, 'LCP');\n    assert.strictEqual(lcp1.value, lcp1.delta);\n    assert.strictEqual(lcp1.rating, 'good');\n    assert.strictEqual(lcp1.entries.length, 0);\n    assert.strictEqual(lcp1.navigationType, 'back-forward-cache');\n\n    await clearBeacons();\n    await stubForwardBack();\n    await beaconCountIs(1);\n\n    const [lcp2] = await getBeacons();\n\n    assert(lcp2.value > 0);\n    assert(lcp2.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(lcp2.name, 'LCP');\n    assert.strictEqual(lcp2.value, lcp2.delta);\n    assert.strictEqual(lcp2.rating, 'good');\n    assert.strictEqual(lcp2.entries.length, 0);\n    assert.strictEqual(lcp2.navigationType, 'back-forward-cache');\n  });\n\n  it('reports if the page is restored from bfcache even when the document was hidden at page load time', async function () {\n    if (!browserSupportsLCP) this.skip();\n\n    await navigateTo('/test/lcp?hidden=1', {readyState: 'interactive'});\n\n    await stubVisibilityChange('visible');\n\n    // Click on the h1 to finalize LCP.\n    const h1 = await $('h1');\n    await h1.click();\n\n    // Wait a bit to ensure no beacons were sent.\n    await browser.pause(1000);\n\n    const beacons = await getBeacons();\n    assert.strictEqual(beacons.length, 0);\n\n    await stubForwardBack();\n    await beaconCountIs(1);\n\n    const [lcp1] = await getBeacons();\n\n    assert(lcp1.value > 0);\n    assert(lcp1.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(lcp1.name, 'LCP');\n    assert.strictEqual(lcp1.value, lcp1.delta);\n    assert.strictEqual(lcp1.rating, 'good');\n    assert.strictEqual(lcp1.entries.length, 0);\n    assert.strictEqual(lcp1.navigationType, 'back-forward-cache');\n\n    await clearBeacons();\n    await stubForwardBack();\n    await beaconCountIs(1);\n\n    const [lcp2] = await getBeacons();\n\n    assert(lcp2.value > 0);\n    assert(lcp2.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(lcp2.name, 'LCP');\n    assert.strictEqual(lcp2.value, lcp2.delta);\n    assert.strictEqual(lcp2.rating, 'good');\n    assert.strictEqual(lcp2.entries.length, 0);\n    assert.strictEqual(lcp2.navigationType, 'back-forward-cache');\n  });\n\n  it('reports restore as nav type for wasDiscarded', async function () {\n    if (!browserSupportsLCP) this.skip();\n\n    await navigateTo('/test/lcp?wasDiscarded=1');\n\n    // Wait until all images are loaded and fully rendered.\n    await imagesPainted();\n\n    // Load a new page to trigger the hidden state.\n    await navigateTo('about:blank');\n\n    await beaconCountIs(1);\n\n    const [lcp] = await getBeacons();\n\n    assert(lcp.value > 0);\n    assert(lcp.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(lcp.name, 'LCP');\n    assert.strictEqual(lcp.value, lcp.delta);\n    assert.strictEqual(lcp.rating, 'good');\n    assert.strictEqual(lcp.entries.length, 1);\n    assert.strictEqual(lcp.navigationType, 'restore');\n  });\n\n  it('works when calling the function twice with different options', async function () {\n    if (!browserSupportsLCP) this.skip();\n\n    await navigateTo('/test/lcp?doubleCall=1&reportAllChanges2=1');\n\n    await beaconCountIs(2, {instance: 2});\n\n    const beacons2 = await getBeacons({instance: 2});\n    assertFullReportsAreCorrect(beacons2);\n\n    assert.strictEqual((await getBeacons({instance: 1})).length, 0);\n\n    // Load a new page to trigger the hidden state.\n    await navigateTo('about:blank');\n\n    await beaconCountIs(1, {instance: 1});\n\n    const beacons1 = await getBeacons({instance: 1});\n    assertStandardReportsAreCorrect(beacons1);\n\n    assert(beacons1[0].id !== beacons2[0].id);\n    assert(beacons1[0].id !== beacons2[1].id);\n    assert.deepEqual(beacons1[0].entries, beacons2[1].entries);\n  });\n\n  it('reports on batch reporting using document.visibilitychange', async function () {\n    if (!browserSupportsLCP) this.skip();\n\n    await navigateTo('/test/lcp?batchReporting=1');\n\n    // Wait until all images are loaded and fully rendered.\n    await imagesPainted();\n\n    await hideAndReshowPage();\n\n    await beaconCountIs(1);\n    const [lcp] = await getBeacons();\n    assertStandardReportsAreCorrect([lcp]);\n  });\n\n  describe('attribution', function () {\n    it('includes attribution data on the metric object', async function () {\n      if (!browserSupportsLCP) this.skip();\n\n      await navigateTo('/test/lcp?attribution=1');\n\n      // Wait until all images are loaded and fully rendered.\n      await imagesPainted();\n\n      const navEntry = await browser.execute(() => {\n        return __toSafeObject(performance.getEntriesByType('navigation')[0]);\n      });\n\n      const lcpResEntry = await browser.execute(() => {\n        return performance\n          .getEntriesByType('resource')\n          .find((e) => e.name.includes('square.png'))\n          .toJSON();\n      });\n\n      // Load a new page to trigger the hidden state.\n      await navigateTo('about:blank');\n\n      await beaconCountIs(1);\n\n      const [lcp] = await getBeacons();\n      assertStandardReportsAreCorrect([lcp]);\n\n      assert(lcp.attribution.url.endsWith('/test/img/square.png?delay=500'));\n      assert.equal(lcp.attribution.target, 'html>body>main>p>img.bar.foo');\n      assert.equal(\n        lcp.attribution.timeToFirstByte +\n          lcp.attribution.resourceLoadDelay +\n          lcp.attribution.resourceLoadDuration +\n          lcp.attribution.elementRenderDelay,\n        lcp.value,\n      );\n\n      assert.deepEqual(lcp.attribution.navigationEntry, navEntry);\n      assert.deepEqual(lcp.attribution.lcpResourceEntry, lcpResEntry);\n      assert.deepEqual(lcp.attribution.lcpEntry, lcp.entries.slice(-1)[0]);\n    });\n\n    it('supports generating a custom target', async function () {\n      if (!browserSupportsLCP) this.skip();\n\n      await navigateTo('/test/lcp?attribution=1&generateTarget=1');\n\n      // Wait until all images are loaded and fully rendered.\n      await imagesPainted();\n\n      // Load a new page to trigger the hidden state.\n      await navigateTo('about:blank');\n\n      await beaconCountIs(1);\n\n      const [lcp] = await getBeacons();\n      assertStandardReportsAreCorrect([lcp]);\n\n      assert.equal(lcp.attribution.target, 'main-image');\n    });\n\n    it('supports generating a custom target with fallback', async function () {\n      if (!browserSupportsLCP) this.skip();\n\n      await navigateTo('/test/lcp?attribution=1&imgHidden=1&generateTarget=1');\n\n      // Wait until all images are loaded and fully rendered.\n      await imagesPainted();\n\n      // Load a new page to trigger the hidden state.\n      await navigateTo('about:blank');\n\n      await beaconCountIs(1);\n\n      const [lcp] = await getBeacons();\n\n      assert.equal(lcp.attribution.target, 'html>body>main>h1');\n    });\n\n    it('supports multiple calls with different custom target generation functions', async function () {\n      if (!browserSupportsLCP) this.skip();\n\n      await navigateTo(\n        '/test/lcp?attribution=1&doubleCall=1&generateTarget2=1',\n      );\n\n      // Wait until all images are loaded and fully rendered.\n      await imagesPainted();\n\n      // Load a new page to trigger the hidden state.\n      await navigateTo('about:blank');\n\n      await beaconCountIs(1, {instance: 1});\n      await beaconCountIs(1, {instance: 2});\n\n      const [lcp1] = await getBeacons({instance: 1});\n      assertStandardReportsAreCorrect([lcp1]);\n\n      assert.equal(lcp1.attribution.target, 'html>body>main>p>img.bar.foo');\n\n      const [lcp2] = await getBeacons({instance: 2});\n      assertStandardReportsAreCorrect([lcp2]);\n\n      assert.equal(lcp2.attribution.target, 'main-image');\n    });\n\n    it('handles image resources with incomplete timing data', async function () {\n      if (!browserSupportsLCP) this.skip();\n\n      // TODO - this whole test is flakey in Safari. Need to find out why.\n      if (browser.capabilities.browserName === 'Safari') this.skip();\n\n      await navigateTo('/test/lcp?attribution=1');\n\n      // Wait until all images are loaded and fully rendered.\n      await imagesPainted();\n\n      const navEntry = await browser.execute(() => {\n        return __toSafeObject(performance.getEntriesByType('navigation')[0]);\n      });\n\n      const lcpResEntry = await browser.execute(() => {\n        const entry = performance\n          .getEntriesByType('resource')\n          .find((e) => e.name.includes('square.png'));\n\n        // Stub an entry with no `requestStart` data.\n        Object.defineProperty(entry, 'requestStart', {\n          value: 0,\n          enumerable: true,\n        });\n\n        return __toSafeObject(entry);\n      });\n\n      // Load a new page to trigger the hidden state.\n      await navigateTo('about:blank');\n\n      await beaconCountIs(1);\n\n      const [lcp] = await getBeacons();\n\n      assertStandardReportsAreCorrect([lcp]);\n\n      assert(lcp.attribution.url.endsWith('/test/img/square.png?delay=500'));\n      assert.equal(lcp.attribution.target, 'html>body>main>p>img.bar.foo');\n\n      // Specifically check that resourceLoadDelay falls back to `startTime`.\n      assert.equal(\n        lcp.attribution.resourceLoadDelay,\n        lcpResEntry.startTime - navEntry.responseStart,\n      );\n\n      assert.equal(\n        lcp.attribution.timeToFirstByte +\n          lcp.attribution.resourceLoadDelay +\n          lcp.attribution.resourceLoadDuration +\n          lcp.attribution.elementRenderDelay,\n        lcp.value,\n      );\n\n      assert.deepEqual(lcp.attribution.navigationEntry, navEntry);\n      assert.deepEqual(lcp.attribution.lcpResourceEntry, lcpResEntry);\n      assert.deepEqual(lcp.attribution.lcpEntry, lcp.entries.slice(-1)[0]);\n    });\n\n    it('accounts for time prerendering the page', async function () {\n      if (!browserSupportsLCP) this.skip();\n      if (!browserSupportsPrerender) this.skip();\n\n      await navigateTo('/test/lcp?attribution=1&prerender=1');\n\n      // Wait until web-vitals is loaded\n      await webVitalsLoaded();\n\n      // Click on the h1 to finalize LCP.\n      const h1 = await $('h1');\n      await h1.click();\n\n      await beaconCountIs(1);\n      await clearBeacons();\n\n      // Wait a bit to allow the prerender to happen\n      await browser.pause(1000);\n\n      const prerenderLink = await $('#prerender-link');\n      await prerenderLink.click();\n\n      // Wait a bit for the navigation to start\n      await browser.pause(500);\n\n      // Wait until all images are loaded and fully rendered.\n      await imagesPainted();\n\n      const navEntry = await browser.execute(() => {\n        return __toSafeObject(performance.getEntriesByType('navigation')[0]);\n      });\n\n      const lcpResEntry = await browser.execute(() => {\n        return __toSafeObject(\n          performance\n            .getEntriesByType('resource')\n            .find((e) => e.name.includes('square.png')),\n        );\n      });\n\n      // Load a new page to trigger the hidden state.\n      await navigateTo('about:blank');\n\n      await beaconCountIs(1);\n\n      const [lcp] = await getBeacons();\n\n      assert(lcp.attribution.url.endsWith('/test/img/square.png?delay=500'));\n      assert.equal(lcp.navigationType, 'prerender');\n      assert.equal(lcp.attribution.target, 'html>body>main>p>img.bar.foo');\n\n      // Assert each individual LCP subpart accounts for `activationStart`\n      assert.equal(\n        lcp.attribution.timeToFirstByte,\n        Math.max(0, navEntry.responseStart - navEntry.activationStart),\n      );\n\n      assert.equal(\n        lcp.attribution.resourceLoadDelay,\n        Math.max(0, lcpResEntry.requestStart - navEntry.activationStart) -\n          Math.max(0, navEntry.responseStart - navEntry.activationStart),\n      );\n\n      assert.equal(\n        lcp.attribution.resourceLoadDuration,\n        Math.max(0, lcpResEntry.responseEnd - navEntry.activationStart) -\n          Math.max(0, lcpResEntry.requestStart - navEntry.activationStart),\n      );\n\n      assert.equal(\n        lcp.attribution.elementRenderDelay,\n        Math.max(0, lcp.entries[0].startTime - navEntry.activationStart) -\n          Math.max(0, lcpResEntry.responseEnd - navEntry.activationStart),\n      );\n\n      // Assert that they combine to equal LCP.\n      assert.equal(\n        lcp.attribution.timeToFirstByte +\n          lcp.attribution.resourceLoadDelay +\n          lcp.attribution.resourceLoadDuration +\n          lcp.attribution.elementRenderDelay,\n        lcp.value,\n      );\n\n      assert.deepEqual(lcp.attribution.navigationEntry, navEntry);\n      assert.deepEqual(lcp.attribution.lcpResourceEntry, lcpResEntry);\n      assert.deepEqual(lcp.attribution.lcpEntry, lcp.entries.at(-1));\n    });\n\n    it('handles cases where there is no LCP resource', async function () {\n      if (!browserSupportsLCP) this.skip();\n\n      await navigateTo('/test/lcp?attribution=1&imgHidden=1', {\n        readyState: 'complete',\n      });\n\n      const navEntry = await browser.execute(() => {\n        return __toSafeObject(performance.getEntriesByType('navigation')[0]);\n      });\n\n      // Load a new page to trigger the hidden state.\n      await navigateTo('about:blank');\n\n      await beaconCountIs(1);\n\n      const [lcp] = await getBeacons();\n\n      assert.equal(lcp.attribution.url, undefined);\n      assert.equal(lcp.attribution.target, 'html>body>main>h1');\n      assert.equal(lcp.attribution.resourceLoadDelay, 0);\n      assert.equal(lcp.attribution.resourceLoadDuration, 0);\n      assert.equal(\n        lcp.attribution.timeToFirstByte +\n          lcp.attribution.resourceLoadDelay +\n          lcp.attribution.resourceLoadDuration +\n          lcp.attribution.elementRenderDelay,\n        lcp.value,\n      );\n\n      assert.deepEqual(lcp.attribution.navigationEntry, navEntry);\n      assert.equal(lcp.attribution.lcpResourceEntry, undefined);\n\n      // Deep equal won't work since some of the properties are removed before\n      // sending to /collect, so just compare some.\n      const lcpEntry = lcp.entries.slice(-1)[0];\n      assert.equal(lcp.attribution.lcpEntry.element, lcpEntry.element);\n      assert.equal(lcp.attribution.lcpEntry.size, lcpEntry.size);\n      assert.equal(lcp.attribution.lcpEntry.startTime, lcpEntry.startTime);\n    });\n\n    it('reports after a bfcache restore', async function () {\n      if (!browserSupportsLCP) this.skip();\n\n      await navigateTo('/test/lcp?attribution=1');\n\n      // Wait until all images are loaded and fully rendered.\n      await imagesPainted();\n\n      const h1 = await $('h1');\n      await h1.click();\n      await beaconCountIs(1);\n\n      assertStandardReportsAreCorrect(await getBeacons());\n      await clearBeacons();\n\n      await stubForwardBack();\n      await beaconCountIs(1);\n\n      const [lcp2] = await getBeacons();\n\n      assert(lcp2.value > 0);\n      assert(lcp2.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(lcp2.name, 'LCP');\n      assert.strictEqual(lcp2.value, lcp2.delta);\n      assert.strictEqual(lcp2.entries.length, 0);\n      assert.strictEqual(lcp2.navigationType, 'back-forward-cache');\n\n      assert.equal(lcp2.attribution.target, undefined);\n      assert.equal(lcp2.attribution.timeToFirstByte, 0);\n      assert.equal(lcp2.attribution.resourceLoadDelay, 0);\n      assert.equal(lcp2.attribution.resourceLoadDuration, 0);\n      assert.equal(lcp2.attribution.elementRenderDelay, lcp2.value);\n      assert.equal(lcp2.attribution.navigationEntry, undefined);\n      assert.equal(lcp2.attribution.lcpResourceEntry, undefined);\n      assert.equal(lcp2.attribution.lcpEntry, undefined);\n    });\n\n    it('uses entry.id as fallback when element is removed from DOM', async function () {\n      if (!browserSupportsLCP) this.skip();\n\n      // Navigate with removeElement=1 to remove the LCP image from DOM\n      // before web-vitals library loads, testing the entry.id fallback.\n      await navigateTo('/test/lcp?attribution=1&removeElement=1');\n\n      // Wait until all images are loaded and fully rendered.\n      await imagesPainted();\n\n      // Wait until web-vitals is loaded\n      await webVitalsLoaded();\n\n      // Load a new page to trigger the hidden state.\n      await navigateTo('about:blank');\n\n      await beaconCountIs(1);\n\n      const [lcp] = await getBeacons();\n\n      assert(lcp.value > 500);\n      assert(lcp.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(lcp.name, 'LCP');\n      assert.strictEqual(lcp.rating, 'good');\n      assert.strictEqual(lcp.entries.length, 1);\n\n      assert.equal(lcp.attribution.target, '#lcp-image');\n    });\n  });\n});\n\nconst assertStandardReportsAreCorrect = (beacons) => {\n  const [lcp] = beacons;\n\n  assert(lcp.value > 500); // Greater than the image load delay.\n  assert(lcp.id.match(/^v5-\\d+-\\d+$/));\n  assert.strictEqual(lcp.name, 'LCP');\n  assert.strictEqual(lcp.value, lcp.delta);\n  assert.strictEqual(lcp.rating, 'good');\n  assert.strictEqual(lcp.entries.length, 1);\n  assert.match(lcp.navigationType, /navigate|reload/);\n};\n\nconst assertFullReportsAreCorrect = (beacons) => {\n  // Firefox sometimes sends <p>, then <h1>\n  // so grab last two\n  assert(beacons.length >= 2);\n  const lcp1 = beacons.at(-2);\n  const lcp2 = beacons.at(-1);\n\n  assert(lcp1.value < 500); // Less than the image load delay.\n  assert(lcp1.id.match(/^v5-\\d+-\\d+$/));\n  assert.strictEqual(lcp1.name, 'LCP');\n  assert.strictEqual(lcp1.value, lcp1.delta);\n  assert.strictEqual(lcp1.rating, 'good');\n  assert.strictEqual(lcp1.entries.length, 1);\n  assert.match(lcp1.navigationType, /navigate|reload/);\n\n  assert(lcp2.value > 500); // Greater than the image load delay.\n  assert.strictEqual(lcp2.value, lcp1.value + lcp2.delta);\n  assert.strictEqual(lcp2.name, 'LCP');\n  assert.strictEqual(lcp2.id, lcp1.id);\n  assert.strictEqual(lcp2.rating, 'good');\n  assert.strictEqual(lcp2.entries.length, 1);\n  assert(lcp2.entries[0].startTime > lcp1.entries[0].startTime);\n  assert.match(lcp2.navigationType, /navigate|reload/);\n};\n\nconst hideAndReshowPage = async () => {\n  // Switch to new tab and back to change visibility state.\n  // New tabs on Safari in webdriver.io are flakey, so minimize/maximize\n  // instead, but it's kind of distracting so use tab switch for others.\n  if (browser.capabilities.browserName !== 'Safari') {\n    const handle1 = await browser.getWindowHandle();\n    await browser.newWindow('https://example.com');\n    await browser.pause(500);\n    await browser.closeWindow();\n    await browser.switchToWindow(handle1);\n  } else {\n    await browser.minimizeWindow();\n    await browser.pause(500);\n    await browser.maximizeWindow();\n  }\n};\n"
  },
  {
    "path": "test/e2e/onTTFB-test.js",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport assert from 'assert';\nimport {beaconCountIs, clearBeacons, getBeacons} from '../utils/beacons.js';\nimport {navigateTo} from '../utils/navigateTo.js';\nimport {stubForwardBack} from '../utils/stubForwardBack.js';\n\n/**\n * Accepts a PerformanceNavigationTimingEntry (or shim) and asserts that it\n * has all the expected properties.\n * @param {Object} entry\n */\nfunction assertValidEntry(entry) {\n  const timingProps = [\n    'connectEnd',\n    'connectStart',\n    'domComplete',\n    'domContentLoadedEventEnd',\n    'domContentLoadedEventStart',\n    'domInteractive',\n    'domainLookupEnd',\n    'domainLookupStart',\n    'fetchStart',\n    'loadEventEnd',\n    'loadEventStart',\n    'redirectEnd',\n    'redirectStart',\n    'requestStart',\n    'responseEnd',\n    'responseStart',\n    'secureConnectionStart',\n    'startTime',\n    'unloadEventEnd',\n    'unloadEventStart',\n  ];\n\n  assert.strictEqual(entry.entryType, 'navigation');\n  for (const timingProp of timingProps) {\n    assert(entry[timingProp] >= 0);\n  }\n}\n\ndescribe('onTTFB()', async function () {\n  // Retry all tests in this suite up to 2 times.\n  this.retries(2);\n\n  let browserSupportsPrerender;\n  before(async function () {\n    browserSupportsPrerender = await browser.execute(() => {\n      return 'onprerenderingchange' in document;\n    });\n  });\n  beforeEach(async function () {\n    // In Safari when navigating to 'about:blank' between tests the\n    // Navigation Timing data is consistently negative, so the tests fail.\n    if (browser.capabilities.browserName !== 'Safari') {\n      await navigateTo('about:blank');\n    }\n    await clearBeacons();\n  });\n\n  it('reports the correct value when run during page load', async function () {\n    await navigateTo('/test/ttfb');\n\n    const ttfb = await getTTFBBeacon();\n\n    assert(ttfb.value >= 0);\n    assert(ttfb.value >= ttfb.entries[0].requestStart);\n    assert(ttfb.value <= ttfb.entries[0].loadEventEnd);\n    assert(ttfb.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(ttfb.name, 'TTFB');\n    assert.strictEqual(ttfb.value, ttfb.delta);\n    assert.strictEqual(ttfb.rating, 'good');\n    assert.strictEqual(ttfb.navigationType, 'navigate');\n    assert.strictEqual(ttfb.entries.length, 1);\n\n    assertValidEntry(ttfb.entries[0]);\n  });\n\n  it('reports the correct value event when loaded late', async function () {\n    await navigateTo('/test/ttfb?lazyLoad=1');\n\n    const ttfb = await getTTFBBeacon();\n\n    assert(ttfb.value >= 0);\n    assert(ttfb.value >= ttfb.entries[0].requestStart);\n    assert(ttfb.value <= ttfb.entries[0].loadEventEnd);\n    assert(ttfb.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(ttfb.name, 'TTFB');\n    assert.strictEqual(ttfb.value, ttfb.delta);\n    assert.strictEqual(ttfb.rating, 'good');\n    assert.strictEqual(ttfb.navigationType, 'navigate');\n    assert.strictEqual(ttfb.entries.length, 1);\n\n    assertValidEntry(ttfb.entries[0]);\n  });\n\n  it('reports the correct value when the response is delayed', async function () {\n    await navigateTo('/test/ttfb?delay=1000');\n\n    const ttfb = await getTTFBBeacon();\n\n    assert(ttfb.value >= 1000);\n    assert(ttfb.value >= ttfb.entries[0].requestStart);\n    assert(ttfb.value <= ttfb.entries[0].loadEventEnd);\n    assert(ttfb.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(ttfb.name, 'TTFB');\n    assert.strictEqual(ttfb.value, ttfb.delta);\n    assert.strictEqual(ttfb.rating, 'needs-improvement');\n    assert.strictEqual(ttfb.navigationType, 'navigate');\n    assert.strictEqual(ttfb.entries.length, 1);\n\n    assertValidEntry(ttfb.entries[0]);\n  });\n\n  it('accounts for time prerendering the page', async function () {\n    if (!browserSupportsPrerender) this.skip();\n\n    await navigateTo('/test/ttfb?prerender=1');\n\n    await getTTFBBeacon();\n    await clearBeacons();\n\n    // Wait a bit to allow the prerender to happen\n    await browser.pause(1000);\n\n    const prerenderLink = await $('#prerender-link');\n    await prerenderLink.click();\n\n    const ttfb = await getTTFBBeacon();\n\n    assert(ttfb.value >= 0);\n    assert.strictEqual(ttfb.value, ttfb.delta);\n    assert.strictEqual(ttfb.rating, 'good');\n    assert.strictEqual(ttfb.entries.length, 1);\n    assert.strictEqual(ttfb.navigationType, 'prerender');\n    assert.strictEqual(\n      ttfb.value,\n      Math.max(\n        0,\n        ttfb.entries[0].responseStart - ttfb.entries[0].activationStart,\n      ),\n    );\n\n    assertValidEntry(ttfb.entries[0]);\n  });\n\n  it('reports the correct value when run while prerendering', async function () {\n    if (!browserSupportsPrerender) this.skip();\n\n    await navigateTo('/test/ttfb?prerender=1&imgDelay=1000');\n\n    await getTTFBBeacon();\n    await clearBeacons();\n\n    // Wait a bit to allow the prerender to happen\n    await browser.pause(1000);\n\n    const prerenderLink = await $('#prerender-link');\n    await prerenderLink.click();\n\n    const ttfb = await getTTFBBeacon();\n\n    // Assert that prerendering finished after responseStart.\n    assert(ttfb.entries[0].activationStart >= ttfb.entries[0].responseStart);\n\n    assert(ttfb.value >= 0);\n    assert.strictEqual(ttfb.value, ttfb.delta);\n    assert.strictEqual(ttfb.rating, 'good');\n    assert.strictEqual(ttfb.entries.length, 1);\n    assert.strictEqual(ttfb.navigationType, 'prerender');\n    assert.strictEqual(\n      ttfb.value,\n      Math.max(\n        0,\n        ttfb.entries[0].responseStart - ttfb.entries[0].activationStart,\n      ),\n    );\n\n    assertValidEntry(ttfb.entries[0]);\n  });\n\n  it('reports after a bfcache restore', async function () {\n    await navigateTo('/test/ttfb');\n\n    const ttfb1 = await getTTFBBeacon();\n\n    assert(ttfb1.value >= 0);\n    assert(ttfb1.value >= ttfb1.entries[0].requestStart);\n    assert(ttfb1.value <= ttfb1.entries[0].loadEventEnd);\n    assert(ttfb1.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(ttfb1.name, 'TTFB');\n    assert.strictEqual(ttfb1.rating, 'good');\n    assert.strictEqual(ttfb1.value, ttfb1.delta);\n    assert.strictEqual(ttfb1.navigationType, 'navigate');\n    assert.strictEqual(ttfb1.entries.length, 1);\n\n    assertValidEntry(ttfb1.entries[0]);\n\n    await clearBeacons();\n    await stubForwardBack();\n\n    const ttfb2 = await getTTFBBeacon();\n\n    assert(ttfb2.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(ttfb2.value, 0);\n    assert.strictEqual(ttfb2.name, 'TTFB');\n    assert.strictEqual(ttfb2.value, ttfb2.delta);\n    assert.strictEqual(ttfb2.rating, 'good');\n    assert.strictEqual(ttfb2.navigationType, 'back-forward-cache');\n    assert.strictEqual(ttfb2.entries.length, 0);\n  });\n\n  it('ignores navigations with invalid responseStart timestamps', async function () {\n    for (const rs of [-1, 0, 1e12]) {\n      await navigateTo(`/test/ttfb?responseStart=${rs}`, {\n        readyState: 'complete',\n      });\n\n      // Wait a bit to ensure no beacons were sent.\n      await browser.pause(1000);\n\n      const loadBeacons = await getBeacons();\n      assert.strictEqual(loadBeacons.length, 0);\n\n      // Test back-forward navigations to ensure they're not sent either\n      // in these situations.\n      await stubForwardBack();\n\n      // Wait a bit to ensure no beacons were sent.\n      await browser.pause(1000);\n\n      const bfcacheBeacons = await getBeacons();\n      assert.strictEqual(bfcacheBeacons.length, 0);\n    }\n  });\n\n  it('reports restore as nav type for wasDiscarded', async function () {\n    await navigateTo('/test/ttfb?wasDiscarded=1');\n\n    const ttfb = await getTTFBBeacon();\n\n    assert(ttfb.value >= 0);\n    assert(ttfb.value >= ttfb.entries[0].requestStart);\n    assert(ttfb.value <= ttfb.entries[0].loadEventEnd);\n    assert(ttfb.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(ttfb.name, 'TTFB');\n    assert.strictEqual(ttfb.value, ttfb.delta);\n    assert.strictEqual(ttfb.rating, 'good');\n    assert.strictEqual(ttfb.navigationType, 'restore');\n    assert.strictEqual(ttfb.entries.length, 1);\n\n    assertValidEntry(ttfb.entries[0]);\n  });\n\n  it('works when calling the function twice with different options', async function () {\n    await navigateTo('/test/ttfb?doubleCall=1&reportAllChanges2=1');\n\n    await beaconCountIs(1, {instance: 1});\n    await beaconCountIs(1, {instance: 2});\n\n    const [ttfb1] = await getBeacons({instance: 1});\n    const [ttfb2] = await getBeacons({instance: 2});\n\n    assert(ttfb1.value >= 0);\n    assert(ttfb1.value >= ttfb1.entries[0].requestStart);\n    assert(ttfb1.value <= ttfb1.entries[0].loadEventEnd);\n    assert(ttfb1.id.match(/^v5-\\d+-\\d+$/));\n    assert.strictEqual(ttfb1.name, 'TTFB');\n    assert.strictEqual(ttfb1.value, ttfb1.delta);\n    assert.strictEqual(ttfb1.rating, 'good');\n    assert.strictEqual(ttfb1.navigationType, 'navigate');\n    assert.strictEqual(ttfb1.entries.length, 1);\n    assertValidEntry(ttfb1.entries[0]);\n\n    assert(ttfb2.id.match(/^v5-\\d+-\\d+$/));\n    assert(ttfb2.id !== ttfb1.id);\n    assert.strictEqual(ttfb2.value, ttfb1.value);\n    assert.strictEqual(ttfb2.delta, ttfb1.delta);\n    assert.strictEqual(ttfb2.name, ttfb1.name);\n    assert.strictEqual(ttfb2.rating, ttfb1.rating);\n    assert.deepEqual(ttfb2.entries, ttfb1.entries);\n    assert.strictEqual(ttfb2.navigationType, ttfb1.navigationType);\n  });\n\n  describe('attribution', function () {\n    it('includes attribution data on the metric object', async function () {\n      await navigateTo('/test/ttfb?attribution=1');\n\n      const ttfb = await getTTFBBeacon();\n\n      assert(ttfb.value >= 0);\n      assert(ttfb.value >= ttfb.entries[0].requestStart);\n      assert(ttfb.value <= ttfb.entries[0].loadEventEnd);\n      assert(ttfb.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(ttfb.name, 'TTFB');\n      assert.strictEqual(ttfb.value, ttfb.delta);\n      assert.strictEqual(ttfb.rating, 'good');\n      assert.strictEqual(ttfb.navigationType, 'navigate');\n      assert.strictEqual(ttfb.entries.length, 1);\n\n      assertValidEntry(ttfb.entries[0]);\n\n      const navEntry = ttfb.entries[0];\n      assert.strictEqual(\n        ttfb.attribution.waitingDuration,\n        navEntry.workerStart || navEntry.fetchStart,\n      );\n      assert.strictEqual(\n        ttfb.attribution.cacheDuration,\n        navEntry.domainLookupStart -\n          (navEntry.workerStart || navEntry.fetchStart),\n      );\n      assert.strictEqual(\n        ttfb.attribution.dnsDuration,\n        navEntry.connectStart - navEntry.domainLookupStart,\n      );\n      assert.strictEqual(\n        ttfb.attribution.connectionDuration,\n        navEntry.connectEnd - navEntry.connectStart,\n      );\n      assert.strictEqual(\n        ttfb.attribution.requestDuration,\n        navEntry.responseStart - navEntry.connectEnd,\n      );\n\n      assert.deepEqual(ttfb.attribution.navigationEntry, navEntry);\n    });\n\n    it('accounts for time prerendering the page', async function () {\n      if (!browserSupportsPrerender) this.skip();\n\n      await navigateTo('/test/ttfb?attribution=1&prerender=1');\n\n      await getTTFBBeacon();\n      await clearBeacons();\n\n      // Wait a bit to allow the prerender to happen\n      await browser.pause(1000);\n\n      const prerenderLink = await $('#prerender-link');\n      await prerenderLink.click();\n\n      const ttfb = await getTTFBBeacon();\n\n      const activationStart = await browser.execute(() => {\n        return performance.getEntriesByType('navigation')[0].activationStart;\n      });\n\n      assert(ttfb.value >= 0);\n      assert.strictEqual(ttfb.value, ttfb.delta);\n      assert.strictEqual(ttfb.rating, 'good');\n      assert.strictEqual(ttfb.entries.length, 1);\n      assert.strictEqual(ttfb.navigationType, 'prerender');\n      assert.strictEqual(\n        ttfb.value,\n        Math.max(0, ttfb.entries[0].responseStart - activationStart),\n      );\n\n      assertValidEntry(ttfb.entries[0]);\n\n      const navEntry = ttfb.entries[0];\n      assert.strictEqual(\n        ttfb.attribution.waitingDuration,\n        Math.max(\n          0,\n          (navEntry.workerStart || navEntry.fetchStart) - activationStart,\n        ),\n      );\n      assert.strictEqual(\n        ttfb.attribution.cacheDuration,\n        Math.max(0, navEntry.domainLookupStart - activationStart) -\n          Math.max(\n            0,\n            (navEntry.workerStart || navEntry.fetchStart) - activationStart,\n          ),\n      );\n      assert.strictEqual(\n        ttfb.attribution.dnsDuration,\n        Math.max(0, navEntry.connectStart - activationStart) -\n          Math.max(0, navEntry.domainLookupStart - activationStart),\n      );\n      assert.strictEqual(\n        ttfb.attribution.connectionDuration,\n        Math.max(0, navEntry.connectEnd - activationStart) -\n          Math.max(0, navEntry.connectStart - activationStart),\n      );\n      assert.strictEqual(\n        ttfb.attribution.requestDuration,\n        Math.max(0, navEntry.responseStart - activationStart) -\n          Math.max(0, navEntry.connectEnd - activationStart),\n      );\n\n      assert.deepEqual(ttfb.attribution.navigationEntry, navEntry);\n    });\n\n    it('reports after a bfcache restore', async function () {\n      await navigateTo('/test/ttfb?attribution=1');\n\n      await getTTFBBeacon();\n\n      await clearBeacons();\n      await stubForwardBack();\n\n      await beaconCountIs(1);\n\n      const ttfb = await getTTFBBeacon();\n\n      assert(ttfb.value >= 0);\n      assert(ttfb.id.match(/^v5-\\d+-\\d+$/));\n      assert.strictEqual(ttfb.name, 'TTFB');\n      assert.strictEqual(ttfb.value, ttfb.delta);\n      assert.strictEqual(ttfb.rating, 'good');\n      assert.strictEqual(ttfb.navigationType, 'back-forward-cache');\n      assert.strictEqual(ttfb.entries.length, 0);\n\n      assert.strictEqual(ttfb.attribution.waitingDuration, 0);\n      assert.strictEqual(ttfb.attribution.cacheDuration, 0);\n      assert.strictEqual(ttfb.attribution.dnsDuration, 0);\n      assert.strictEqual(ttfb.attribution.connectionDuration, 0);\n      assert.strictEqual(ttfb.attribution.requestDuration, 0);\n      assert.strictEqual(ttfb.attribution.navigationEntry, undefined);\n    });\n\n    it('reports the correct value for Early Hints', async function () {\n      await navigateTo('/test/ttfb?earlyHintsDelay=50&attribution=1');\n\n      const ttfb = await getTTFBBeacon();\n\n      if ('finalResponseHeadersStart' in ttfb.attribution.navigationEntry) {\n        assert.strictEqual(\n          ttfb.value,\n          ttfb.attribution.navigationEntry.responseStart,\n        );\n        assert.strictEqual(\n          ttfb.value,\n          ttfb.attribution.navigationEntry.firstInterimResponseStart,\n        );\n        assert(\n          ttfb.value <\n            ttfb.attribution.navigationEntry.finalResponseHeadersStart,\n        );\n      } else {\n        assert.strictEqual(\n          ttfb.value,\n          ttfb.attribution.navigationEntry.responseStart,\n        );\n      }\n    });\n  });\n});\n\nconst getTTFBBeacon = async () => {\n  await beaconCountIs(1);\n  const [ttfb] = await getBeacons();\n  return ttfb;\n};\n"
  },
  {
    "path": "test/script/async.js",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconsole.log('async script executed!', performance.now());\n"
  },
  {
    "path": "test/script/defer.js",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconsole.log('defer script executed!', performance.now());\n"
  },
  {
    "path": "test/server.js",
    "content": "/*\n Copyright 2019 Google Inc. All Rights Reserved.\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     https://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n*/\n\nimport http from 'node:http';\nimport fs from 'fs-extra';\nimport path from 'node:path';\nimport nunjucks from 'nunjucks';\n\nconst BEACON_FILE = 'test/beacons.log';\n\nconst MIME_TYPES = {\n  '.js': 'text/javascript',\n  '.cjs': 'text/javascript',\n  '.css': 'text/css',\n  '.png': 'image/png',\n};\n\nnunjucks.configure('./test/views/', {noCache: true});\n\nfunction readBody(req) {\n  return new Promise((resolve) => {\n    const chunks = [];\n    req.on('data', (chunk) => chunks.push(chunk));\n    req.on('error', (err) => reject(err));\n    req.on('end', () => resolve(Buffer.concat(chunks).toString()));\n  });\n}\n\nfunction sleep(ms) {\n  return new Promise((resolve) => setTimeout(resolve, Number(ms)));\n}\n\nconst server = http.createServer(async (req, res) => {\n  const url = new URL(req.url, 'http://localhost');\n  const query = Object.fromEntries(url.searchParams);\n\n  res.setHeader('Cache-Control', 'no-cache');\n  res.setHeader('Access-Control-Allow-Origin', '*');\n\n  if (query.delay) {\n    await sleep(query.delay);\n  }\n\n  if (query.earlyHintsDelay) {\n    res.writeEarlyHints({'link': '</styles.css>; rel=preload; as=style'});\n    await sleep(query.earlyHintsDelay);\n  }\n\n  // POST /collect - analytics beacon endpoint\n  if (req.method === 'POST' && url.pathname === '/collect') {\n    const body = await readBody(req);\n    // Uncomment to log the metric when manually testing.\n    console.log(JSON.stringify(JSON.parse(body), null, 2));\n    console.log('-'.repeat(80));\n    fs.appendFileSync(BEACON_FILE, body + '\\n');\n    res.end();\n    return;\n  }\n\n  // GET /test/:view - render nunjucks template\n  const viewMatch = url.pathname.match(/^\\/test\\/([^/]+)$/);\n  if (req.method === 'GET' && viewMatch) {\n    const view = viewMatch[1];\n    const modulePath = query.attribution\n      ? '/dist/web-vitals.attribution.js'\n      : '/dist/web-vitals.js';\n\n    const data = {\n      ...query,\n      queryString: url.searchParams.toString(),\n      modulePath,\n    };\n\n    try {\n      const content = nunjucks.render(`${view}.njk`, data);\n      res.setHeader('Content-Type', 'text/html');\n\n      if (query.delayResponse) {\n        res.write(content + '\\n');\n        setTimeout(() => {\n          res.write('</body></html>');\n          res.end();\n        }, Number(query.delayResponse));\n      } else {\n        res.end(content);\n      }\n    } catch (error) {\n      console.error(error.stack);\n      res.writeHead(500);\n      res.end(error.stack);\n    }\n    return;\n  }\n\n  // Static file serving\n  const root = process.cwd();\n  const filePath = path.join(root, url.pathname);\n  // Check if filePath is within root\n  if (!filePath.startsWith(root)) {\n    res.writeHead(403);\n    res.end('Forbidden');\n    return;\n  }\n  const ext = path.extname(filePath);\n  const contentType = MIME_TYPES[ext] || 'application/octet-stream';\n\n  fs.readFile(filePath, (err, data) => {\n    if (err) {\n      res.writeHead(404);\n      res.end('Not found');\n      return;\n    }\n    res.writeHead(200, {'Content-Type': contentType});\n    res.end(data);\n  });\n});\n\nconst port = process.env.PORT || 9090;\nserver.listen(port, () => {\n  fs.mkdirSync(path.dirname(BEACON_FILE), {recursive: true});\n  fs.appendFileSync(BEACON_FILE, '');\n  console.log(`Server running:\\nhttp://localhost:${port}`);\n});\n"
  },
  {
    "path": "test/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"allowJs\": true,\n    \"composite\": true,\n    \"declaration\": true,\n    \"lib\": [\"es2017\", \"DOM\"],\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"node\",\n    \"noFallthroughCasesInSwitch\": true,\n    \"noImplicitReturns\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"preserveConstEnums\": true,\n    \"rootDir\": \"./\",\n    \"strict\": true,\n    \"target\": \"esnext\",\n    \"types\": [\"node\", \"webdriverio\"]\n  },\n  \"include\": [\"**/*.js\"],\n  \"exclude\": []\n}\n"
  },
  {
    "path": "test/unit/attribution-test.js",
    "content": "import {describe, it} from 'node:test';\nimport assert from 'assert';\nimport {\n  onCLS,\n  onFCP,\n  onINP,\n  onLCP,\n  onTTFB,\n  CLSThresholds,\n  FCPThresholds,\n  INPThresholds,\n  LCPThresholds,\n  TTFBThresholds,\n} from 'web-vitals/attribution';\n\ndescribe('index', () => {\n  it('exports Web Vitals metrics functions', () => {\n    [onCLS, onFCP, onINP, onLCP, onTTFB].forEach((onFn) =>\n      assert(typeof onFn === 'function'),\n    );\n  });\n\n  it('exports Web Vitals metric thresholds', () => {\n    assert.deepEqual(CLSThresholds, [0.1, 0.25]);\n    assert.deepEqual(FCPThresholds, [1800, 3000]);\n    assert.deepEqual(INPThresholds, [200, 500]);\n    assert.deepEqual(LCPThresholds, [2500, 4000]);\n    assert.deepEqual(TTFBThresholds, [800, 1800]);\n  });\n});\n"
  },
  {
    "path": "test/unit/bindReporter-test.js",
    "content": "import {describe, it} from 'node:test';\nimport assert from 'assert';\nimport {bindReporter} from '../../dist/modules/lib/bindReporter.js';\n\ndescribe('bindReporter', () => {\n  describe('rating classification', () => {\n    const thresholds = [0.1, 0.25]; // CLS thresholds as example\n\n    it('should return \"good\" for values below first threshold', () => {\n      const metric = {\n        name: 'CLS',\n        value: 0.05,\n        entries: [],\n      };\n\n      const reporter = bindReporter(() => {}, metric, thresholds, true);\n\n      reporter(true);\n      assert.equal(metric.rating, 'good');\n    });\n\n    it('should return \"needs-improvement\" for values between thresholds', () => {\n      const metric = {\n        name: 'CLS',\n        value: 0.15,\n        entries: [],\n      };\n\n      const reporter = bindReporter(() => {}, metric, thresholds, true);\n\n      reporter(true);\n      assert.equal(metric.rating, 'needs-improvement');\n    });\n\n    it('should return \"poor\" for values above second threshold', () => {\n      const metric = {\n        name: 'CLS',\n        value: 0.3,\n        entries: [],\n      };\n\n      const reporter = bindReporter(() => {}, metric, thresholds, true);\n\n      reporter(true);\n      assert.equal(metric.rating, 'poor');\n    });\n\n    it('should handle boundary values correctly', () => {\n      const metricAt1stThreshold = {\n        name: 'CLS',\n        value: 0.101,\n        entries: [],\n      };\n\n      const reporter1 = bindReporter(\n        () => {},\n        metricAt1stThreshold,\n        thresholds,\n        true,\n      );\n\n      reporter1(true);\n      assert.equal(metricAt1stThreshold.rating, 'needs-improvement');\n\n      const metricAt2ndThreshold = {\n        name: 'CLS',\n        value: 0.251,\n        entries: [],\n      };\n\n      const reporter2 = bindReporter(\n        () => {},\n        metricAt2ndThreshold,\n        thresholds,\n        true,\n      );\n\n      reporter2(true);\n      assert.equal(metricAt2ndThreshold.rating, 'poor');\n    });\n  });\n\n  describe('state management', () => {\n    it('should calculate delta correctly between reports', () => {\n      const metric = {\n        name: 'CLS',\n        value: 0.1,\n        entries: [],\n      };\n      const reports = [];\n\n      const reporter = bindReporter(\n        (m) => reports.push({...m}),\n        metric,\n        [0.1, 0.25],\n        true,\n      );\n\n      reporter(true);\n      assert.equal(reports[0].delta, 0.1);\n\n      metric.value = 0.15;\n      reporter(true);\n      // To avoid 0.049999999 values\n      let fixDelta = Number(reports[1].delta.toFixed(2));\n      assert.equal(fixDelta, 0.05);\n    });\n\n    describe('reportAllChanges behavior', () => {\n      it('should report all changes when reportAllChanges is true', () => {\n        const metric = {\n          name: 'CLS',\n          value: 0.1,\n          entries: [],\n        };\n        const reports = [];\n\n        const reporter = bindReporter(\n          (m) => reports.push({...m}),\n          metric,\n          [0.1, 0.25],\n          true,\n        );\n\n        reporter();\n        metric.value = 0.15;\n        reporter();\n        metric.value = 0.2;\n        reporter();\n\n        assert.equal(reports.length, 3);\n        assert.equal(reports[0].value, 0.1);\n        assert.equal(reports[1].value, 0.15);\n        assert.equal(reports[2].value, 0.2);\n      });\n\n      it('should not report when value does not change', () => {\n        const metric = {\n          name: 'CLS',\n          value: 0.1,\n          entries: [],\n        };\n        const reports = [];\n\n        const reporter = bindReporter(\n          (m) => reports.push({...m}),\n          metric,\n          [0.1, 0.25],\n          true,\n        );\n\n        reporter();\n        reporter();\n        reporter();\n\n        assert.equal(reports.length, 1);\n        assert.equal(reports[0].value, 0.1);\n      });\n\n      it('should only report on forceReport when reportAllChanges is false', () => {\n        const metric = {\n          name: 'CLS',\n          value: 0.1,\n          entries: [],\n        };\n        const reports = [];\n\n        const reporter = bindReporter(\n          (m) => reports.push({...m}),\n          metric,\n          [0.1, 0.25],\n          false,\n        );\n\n        reporter();\n        metric.value = 0.15;\n        reporter();\n        reporter(true);\n\n        assert.equal(reports.length, 1);\n        assert.equal(reports[0].value, 0.15);\n      });\n    });\n  });\n\n  describe('special values handling', () => {\n    it('should not report negative values', () => {\n      const metric = {\n        name: 'CLS',\n        value: -1,\n        entries: [],\n      };\n      const reports = [];\n\n      const reporter = bindReporter(\n        (m) => reports.push({...m}),\n        metric,\n        [0.1, 0.25],\n        true,\n      );\n\n      reporter(true);\n      assert.equal(reports.length, 0);\n    });\n\n    it('should handle zero values correctly', () => {\n      const metric = {\n        name: 'CLS',\n        value: 0,\n        entries: [],\n      };\n      const reports = [];\n\n      const reporter = bindReporter(\n        (m) => reports.push({...m}),\n        metric,\n        [0.1, 0.25],\n        true,\n      );\n\n      reporter(true);\n      assert.equal(reports.length, 1);\n      assert.equal(reports[0].value, 0);\n      assert.equal(reports[0].delta, 0);\n      assert.equal(reports[0].rating, 'good');\n    });\n\n    describe('first report behavior', () => {\n      it('should always report first value even with zero delta', () => {\n        const metric = {\n          name: 'CLS',\n          value: 0,\n          entries: [],\n        };\n        const reports = [];\n\n        const reporter = bindReporter(\n          (m) => reports.push({...m}),\n          metric,\n          [0.1, 0.25],\n          false,\n        );\n\n        reporter(true);\n        assert.equal(reports.length, 1);\n        assert.equal(reports[0].delta, 0);\n      });\n\n      it('should calculate correct delta for first non-zero value', () => {\n        const metric = {\n          name: 'CLS',\n          value: 0.1,\n          entries: [],\n        };\n        const reports = [];\n\n        const reporter = bindReporter(\n          (m) => reports.push({...m}),\n          metric,\n          [0.1, 0.25],\n          true,\n        );\n\n        reporter(true);\n        assert.equal(reports[0].delta, 0.1);\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "test/unit/index-test.js",
    "content": "import {describe, it} from 'node:test';\nimport assert from 'assert';\nimport {\n  onCLS,\n  onFCP,\n  onINP,\n  onLCP,\n  onTTFB,\n  CLSThresholds,\n  FCPThresholds,\n  INPThresholds,\n  LCPThresholds,\n  TTFBThresholds,\n} from 'web-vitals';\n\ndescribe('index', () => {\n  it('exports Web Vitals metrics functions', () => {\n    [onCLS, onFCP, onINP, onLCP, onTTFB].forEach((onFn) =>\n      assert(typeof onFn === 'function'),\n    );\n  });\n\n  it('exports Web Vitals metric thresholds', () => {\n    assert.deepEqual(CLSThresholds, [0.1, 0.25]);\n    assert.deepEqual(FCPThresholds, [1800, 3000]);\n    assert.deepEqual(INPThresholds, [200, 500]);\n    assert.deepEqual(LCPThresholds, [2500, 4000]);\n    assert.deepEqual(TTFBThresholds, [800, 1800]);\n  });\n});\n"
  },
  {
    "path": "test/utils/assertIsCloseTo.js",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport assert from 'assert';\n\n/**\n * Returns if two numbers are within a maxDelta of each other\n * @return Bool\n */\nexport function assertIsCloseTo(actual, expected, maxDelta) {\n  return assert.ok(Math.abs(actual - expected) <= maxDelta);\n}\n"
  },
  {
    "path": "test/utils/beacons.js",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport fs from 'fs-extra';\n\nconst BEACON_FILE = './test/beacons.log';\n\n/**\n * Runs a webdriverio waitUntil command, ending once the specified number of\n * beacons haven been received (optionally matching the passed `opts` object).\n */\nexport async function beaconCountIs(count, opts = {}) {\n  await browser.waitUntil(async () => {\n    const beacons = await getBeacons(opts);\n\n    return beacons.length === count;\n  });\n}\n\n/**\n * Returns an array of beacons matching the passed `opts` object. If no\n * `opts` are specified, the default is to return all beacon matching\n * the most recently-received metric ID.\n */\nexport async function getBeacons(opts = {}) {\n  const json = await fs.readFile(BEACON_FILE, 'utf-8');\n  const allBeacons = json.trim().split('\\n').filter(Boolean).map(JSON.parse);\n\n  if (allBeacons.length) {\n    const lastBeacon = allBeacons.findLast((beacon) => {\n      if (opts.instance) {\n        return opts.instance === beacon.instance;\n      }\n      return true;\n    });\n\n    if (lastBeacon) {\n      return allBeacons.filter((beacon) => {\n        if (beacon.id === lastBeacon.id) {\n          if (opts.instance) {\n            return opts.instance === beacon.instance;\n          }\n          return true;\n        }\n        return false;\n      });\n    }\n  }\n  return [];\n}\n\n/**\n * Clears the array of beacons on the page.\n */\nexport async function clearBeacons() {\n  await fs.truncate(BEACON_FILE);\n}\n"
  },
  {
    "path": "test/utils/browserSupportsEntry.js",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Returns true if the browser supports using PerformanceObserver and the\n * passed entry type.\n * @param {string} type The performance entry type.\n * @return {boolean}\n */\nexport function browserSupportsEntry(type) {\n  return browser.execute((type) => {\n    // More extensive feature detect needed for Firefox due to:\n    // https://github.com/GoogleChrome/web-vitals/issues/142\n    if (type === 'first-input' && !('PerformanceEventTiming' in self)) {\n      return false;\n    }\n\n    if (\n      type === 'event' &&\n      self.PerformanceEventTiming &&\n      !('interactionId' in PerformanceEventTiming.prototype)\n    ) {\n      return false;\n    }\n\n    return self.PerformanceObserver?.supportedEntryTypes?.includes(type);\n  }, type);\n}\n"
  },
  {
    "path": "test/utils/domReadyState.js",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Returns a promise that resolves once the browser window has loaded, all\n * load callbacks have finished executing, and any pending `__readyPromises`\n * have settled.\n * @return {Promise<void>}\n */\nexport function domReadyState(state) {\n  return browser.executeAsync(async (state, done) => {\n    await new Promise((resolve) => {\n      if (document.readyState === 'complete' || document.readyState === state) {\n        resolve();\n      } else {\n        document.addEventListener('readystatechange', () => {\n          if (\n            document.readyState === state ||\n            document.readyState === 'complete'\n          ) {\n            resolve();\n          }\n        });\n      }\n    });\n    if (state !== 'loading' && self.__readyPromises) {\n      await Promise.all(self.__readyPromises);\n    }\n    // Queue a task so this resolves after any event callback run.\n    setTimeout(done, 0);\n  }, state);\n}\n"
  },
  {
    "path": "test/utils/firstContentfulPaint.js",
    "content": "/*\n * Copyright 2023 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Returns a promise that resolves once the browser window has painted\n * the first frame.\n * @return {Promise<void>}\n */\nexport function firstContentfulPaint() {\n  return browser.executeAsync(async (done) => {\n    if (PerformanceObserver.supportedEntryTypes.includes('paint')) {\n      new PerformanceObserver(() => {\n        done();\n      }).observe({\n        type: 'paint',\n        buffered: true,\n      });\n    } else {\n      done();\n    }\n  });\n}\n"
  },
  {
    "path": "test/utils/imagesPainted.js",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Returns a promise that resolves once the browser window has loaded and all\n * the images in the document have decoded and rendered.\n * @return {Promise<void>}\n */\nexport function imagesPainted() {\n  return browser.executeAsync(async (done) => {\n    // Await `DOMContentLoaded` to ensure all elements are in the DOM.\n    await new Promise((resolve) => {\n      if (document.readyState === 'loading') {\n        addEventListener('DOMContentLoaded', resolve);\n      } else {\n        resolve();\n      }\n    });\n\n    // Use element timing if available, otherwise fall back to load+raf.\n    if (PerformanceObserver.supportedEntryTypes.includes('element')) {\n      const nodes = new Set([\n        ...document.querySelectorAll('[elementtiming]:not([hidden])'),\n      ]);\n\n      new PerformanceObserver((list) => {\n        for (const entry of list.getEntries()) {\n          if (nodes.has(entry.element)) {\n            nodes.delete(entry.element);\n          }\n        }\n        if (nodes.size === 0) {\n          // Queue a task so this resolves after other callbacks have run.\n          setTimeout(() => done(nodes), 0);\n        }\n      }).observe({type: 'element', buffered: true});\n    } else {\n      await new Promise((resolve) => {\n        if (document.readyState !== 'complete') {\n          addEventListener('load', resolve);\n        } else {\n          resolve();\n        }\n      });\n      requestAnimationFrame(() => {\n        requestAnimationFrame(() => {\n          done();\n        });\n      });\n    }\n  });\n}\n"
  },
  {
    "path": "test/utils/navigateTo.js",
    "content": "/*\n * Copyright 2023 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {domReadyState} from './domReadyState.js';\n\n/**\n * Returns a promise that resolves once the browser has navigated to the\n * passed URL path, optionally waiting until a specific DOM ready state.\n * @return {Promise<void>}\n */\nexport async function navigateTo(urlPath, opts) {\n  await browser.url(urlPath);\n\n  // In Firefox and Safari, if the global PageLoadStrategy is set to \"none\",\n  // then it's possible that `browser.url()` will return before the navigation\n  // has started and the old page will still be around, so we have to\n  // manually wait until the URL matches the passed URL. Note that this can\n  // still fail if the prior test navigated to a page with the same URL.\n  if (browser.capabilities.browserName !== 'chrome') {\n    await browser.waitUntil(\n      async () => {\n        // Get the URL from the browser and webdriver to ensure the page has\n        // actually started to load.\n        const url = await browser.execute(() => location.href);\n\n        return url.endsWith(urlPath);\n      },\n      {interval: 50},\n    );\n  }\n\n  if (opts?.readyState) {\n    await domReadyState(opts.readyState);\n  }\n}\n"
  },
  {
    "path": "test/utils/nextFrame.js",
    "content": "/*\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Returns a promise that resolves once the browser has run the next\n * animation frame.\n * @return {Promise<void>}\n */\nexport function nextFrame() {\n  return browser.executeAsync((done) => {\n    requestAnimationFrame(() => {\n      requestAnimationFrame(() => {\n        done();\n      });\n    });\n  });\n}\n"
  },
  {
    "path": "test/utils/stubForwardBack.js",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Overrides the document's `visibilityState` property, sets the body's hidden\n * attribute (to prevent painting) and dispatches a `visibilitychange` event.\n * @return {Promise<void>}\n */\nexport function stubForwardBack(visibilityStateAfterRestore) {\n  return browser.executeAsync((visibilityStateAfterRestore, done) => {\n    self.__stubForwardBack(visibilityStateAfterRestore).then(done);\n  }, visibilityStateAfterRestore);\n}\n"
  },
  {
    "path": "test/utils/stubVisibilityChange.js",
    "content": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Overrides the document's `visibilityState` property, sets the body's hidden\n * attribute (to prevent painting) and dispatches a `visibilitychange` event.\n * @return {Promise<void>}\n */\nexport function stubVisibilityChange(visibilityState) {\n  return browser.execute((visibilityState) => {\n    self.__stubVisibilityChange(visibilityState);\n  }, visibilityState);\n}\n"
  },
  {
    "path": "test/utils/waitUntilIdle.js",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Returns a promise that resolves once the browser has run a rIC\n * (or a setTimeout for non-supporting browsers).\n * @return {Promise<void>}\n */\nexport function waitUntilIdle() {\n  return browser.executeAsync((done) => {\n    if ('requestIdleCallback' in self) {\n      requestIdleCallback(() => {\n        done();\n      });\n    } else {\n      setTimeout(() => {\n        done();\n      });\n    }\n  });\n}\n"
  },
  {
    "path": "test/utils/webVitalsLoaded.js",
    "content": "/*\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Returns a promise that resolves once the web-vitals library has been\n * loaded via the `__testImport()` function.\n * @return {Promise<void>}\n */\nexport function webVitalsLoaded() {\n  return browser.waitUntil(async () => {\n    const isLoaded = await browser.execute(() => self.__isWebVitalsLoaded);\n    return isLoaded === true;\n  });\n}\n"
  },
  {
    "path": "test/views/cls.njk",
    "content": "<!--\n Copyright 2020 Google LLC\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     https://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n{% extends 'layout.njk' %}\n\n{% block content %}\n  <h1 elementtiming=\"main-heading\">CLS Test</h1>\n  {% if noLayoutShifts %}\n    <p id=\"p1\">This text does not shift.</p>\n  {% else %}\n    <p id=\"p2\">\n      <img id=\"img1\" elementtiming=\"main-image\" {% if imgHidden %}hidden{% endif %} src=\"/test/img/square.png?delay=500\" alt=\"Gray square\" />\n      [text node contents]\n    </p>\n    <p id=\"p3\" data-target=\"secondary-image-wrapper\"><img id=\"img3\" elementtiming=\"secondary-image\" {% if img2Hidden %}hidden{% endif %} src=\"/test/img/square.png?delay=1000\" alt=\"Gray square\" /></p>\n    <p id=\"p4\">Text below the images that will get pushed down.</p>\n  {% endif %}\n\n  <p id=\"p5\"><a id=\"navigate-away\" href=\"https://example.com\">Navigate away</a></p>\n  {% if prerender %}<p id=\"p6\"><a id=\"prerender-link\" href=\"/test/cls{% if queryString %}?{{ queryString | safe }}{% endif %}\">Prerender link</a></p>{% endif %}\n\n  <script type=\"module\">\n    const {onCLS} = await __testImport('{{ modulePath }}');\n\n    const queue = new Set();\n    function addToQueue(metric) {\n      queue.add(metric);\n    }\n\n    onCLS((cls) => {\n      cls.instance = 1;\n\n      // Log for easier manual testing.\n      console.log('CLS:', cls);\n\n      if (self.__batchReporting) {\n        console.log('Adding to queue');\n        addToQueue(cls);\n      } else {\n        // Test sending the metric to an analytics endpoint.\n        navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(cls)));\n      }\n    }, {\n      reportAllChanges: self.__reportAllChanges,\n      generateTarget: self.__generateTarget && ((el) => el.dataset.target),\n    });\n\n    if (self.__batchReporting) {\n      document.addEventListener('visibilitychange', () => {\n        if (document.visibilityState === 'hidden') {\n          for (const cls of queue) {\n            console.log('document.addEventListener');\n            navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(cls)));\n          }\n          queue.clear();\n        }\n      });\n    }\n\n    if (self.__doubleCall) {\n      onCLS((cls) => {\n        cls.instance = 2;\n\n        // Log for easier manual testing.\n        console.log('CLS2:', cls);\n\n        // Test sending the metric to an analytics endpoint.\n        navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(cls)));\n      }, {\n        reportAllChanges: self.__reportAllChanges2,\n        generateTarget: self.__generateTarget2 && ((el) => el.dataset.target),\n      });\n    }\n  </script>\n  {% if prerender %}\n  <script type=\"speculationrules\">\n  {\n    \"prerender\": [\n      {\n        \"urls\": [\"/test/cls{% if queryString %}?{{ queryString | safe }}{% endif %}\"]\n      }\n    ]\n  }\n  </script>\n{% endif %}\n{% endblock %}\n"
  },
  {
    "path": "test/views/fcp.njk",
    "content": "<!--\n Copyright 2020 Google LLC\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     https://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n{% extends 'layout.njk' %}\n\n{% block content %}\n  <h1 elementtiming=\"main-heading\">FCP Test</h1>\n  <p>\n    {% if not imgDelay %}\n      {% set imgDelay = 500 %}\n    {% endif %}\n    <img elementtiming=\"main-image\" {% if imgHidden %}hidden{% endif %} src=\"/test/img/square.png?delay={{ imgDelay }}\">\n  </p>\n  <p>Text below the image</p>\n\n  <p><a id=\"navigate-away\" href=\"https://example.com\">Navigate away</a></p>\n  {% if prerender %}<p><a id=\"prerender-link\" href=\"/test/fcp{% if queryString %}?{{ queryString | safe }}{% endif %}\">Prerender link</a></p>{% endif %}\n\n  <script type=\"module\">\n    const {onFCP} = await __testImport('{{ modulePath }}');\n\n    onFCP((fcp) => {\n      fcp.instance = 1;\n\n      // Log for easier manual testing.\n      console.log('FCP:', fcp);\n\n      // Test sending the metric to an analytics endpoint.\n      navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(fcp)));\n    }, {\n      reportAllChanges: self.__reportAllChanges,\n    });\n\n    if (self.__doubleCall) {\n      onFCP((fcp) => {\n        fcp.instance = 2;\n\n        // Log for easier manual testing.\n        console.log('FCP2:', fcp);\n\n        // Test sending the metric to an analytics endpoint.\n        navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(fcp)));\n      }, {\n        reportAllChanges: self.__reportAllChanges2,\n      });\n    }\n  </script>\n  {% if prerender %}\n  <script type=\"speculationrules\">\n  {\n    \"prerender\": [\n      {\n        \"urls\": [\"/test/fcp{% if queryString %}?{{ queryString | safe }}{% endif %}\"]\n      }\n    ]\n  }\n  </script>\n{% endif %}\n{% endblock %}\n"
  },
  {
    "path": "test/views/inp.njk",
    "content": "<!--\n Copyright 2022 Google LLC\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     https://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n{% extends 'layout.njk' %}\n\n{% block content %}\n  <h1 elementtiming=\"main-heading\" data-target=\"main-heading\">INP Test</h1>\n  <p>\n    <label id=\"label1\"><input type=\"number\" value=\"0\" id=\"mousedown-blocking-time\">\n    <code>mousedown</code> blocking time</label>\n  </p>\n  <p>\n    <label><input type=\"number\" value=\"0\" id=\"mouseup-blocking-time\">\n    <code>mouseup</code> blocking time</label>\n  </p>\n  <p>\n    <label><input type=\"number\" value=\"0\" id=\"pointerdown-blocking-time\">\n    <code>pointerdown</code> blocking time</label>\n  </p>\n  <p>\n    <label><input type=\"number\" value=\"0\" id=\"pointerup-blocking-time\">\n    <code>pointerup</code> blocking time</label>\n  </p>\n  <p>\n    <label><input type=\"number\" value=\"0\" id=\"keydown-blocking-time\">\n    <code>keydown</code> blocking time</label>\n  </p>\n  <p>\n    <label><input type=\"number\" value=\"0\" id=\"keyup-blocking-time\">\n    <code>keyup</code> blocking time</label>\n  </p>\n  <p>\n    <label><input type=\"number\" value=\"0\" id=\"click-blocking-time\">\n    <code>click</code> blocking time</label>\n  </p>\n  <p>\n    <button id=\"reset\">Reset blocking time to zero</button>\n  </p>\n\n  <p>\n    <textarea id=\"textarea\" style=\"width:40em;height:5em\"></textarea>\n  </p>\n\n  <script>\n    // Set the blocking values based on query params if present.\n    const params = new URLSearchParams(location.search);\n    for (const [key, value] of params) {\n      const el = document.getElementById(`${key}-blocking-time`);\n      if (el?.nodeName.toLowerCase() === 'input') {\n        el.value = value;\n      }\n    }\n\n    function block(event) {\n      const blockingTime = Number(document.getElementById(`${event.type}-blocking-time`).value);\n      const startTime = performance.now();\n      while (performance.now() < startTime + blockingTime) {\n        // Block...\n      }\n    }\n\n    function onInput(event) {\n      const input = event.target;\n      const eventName = input.id.slice(0, input.id.indexOf('-'));\n      if (input.value > 0) {\n        addEventListener(eventName, block, true);\n      } else {\n        removeEventListener(eventName, block, true);\n      }\n    }\n\n    function addBlockingListeners() {\n      const eventNames = [\n        'mousedown',\n        'mouseup',\n        'pointerdown',\n        'pointerup',\n        'keydown',\n        'keyup',\n        'click',\n      ];\n      for (const eventName of eventNames) {\n        const input = document.getElementById(`${eventName}-blocking-time`);\n        input.addEventListener('input', onInput, true);\n        if (input.value > 0) {\n          addEventListener(eventName, block, true);\n        }\n      }\n    }\n\n    document.getElementById('reset').addEventListener('click', () => {\n      [...document.querySelectorAll('label>input')].forEach((n) => n.value = 0);\n    });\n\n    addBlockingListeners();\n  </script>\n\n  <p><a id=\"navigate-away\" href=\"https://example.com\">Navigate away</a></p>\n  {% if prerender %}<p><a id=\"prerender-link\" href=\"/test/inp{% if queryString %}?{{ queryString | safe }}{% endif %}\">Prerender link</a></p>{% endif %}\n\n  <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin nec porta orci, ac sagittis augue. Nullam orci tellus, suscipit sed magna id, mattis iaculis ex. Etiam felis lectus, accumsan eu magna lacinia, lobortis tempus lacus. Donec nulla metus, blandit eget ullamcorper in, placerat eu massa. Curabitur vitae elementum orci, ac tincidunt neque. Maecenas accumsan odio sit amet arcu elementum, non vestibulum enim finibus. Phasellus malesuada lacinia suscipit. Cras ac gravida urna. In et mauris non tellus pretium ultrices. Fusce mattis a risus at tincidunt. Donec ac fringilla magna, nec suscipit lectus. Sed risus massa, rutrum ut leo quis, tempor dapibus dui. Proin in mauris non risus maximus tincidunt quis a mauris.</p>\n\n  <script type=\"module\">\n    const {onINP} = await __testImport('{{ modulePath }}');\n\n    const queue = new Set();\n    function addToQueue(metric) {\n      queue.add(metric);\n    }\n\n    onINP((inp) => {\n      inp.instance = 1;\n\n      // Log for easier manual testing.\n      console.log('INP:', inp);\n\n      if (self.__batchReporting) {\n        console.log('Adding to queue');\n        addToQueue(inp);\n      } else {\n        // Test sending the metric to an analytics endpoint.\n        navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(inp)));\n      }\n    }, {\n      reportAllChanges: self.__reportAllChanges,\n      durationThreshold: self.__durationThreshold,\n      generateTarget: self.__generateTarget && ((el) => el?.dataset?.target),\n    });\n\n    if (self.__batchReporting) {\n      document.addEventListener('visibilitychange', () => {\n        if (document.visibilityState === 'hidden') {\n          for (const inp of queue) {\n            console.log('document.addEventListener');\n            navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(inp)));\n          }\n          queue.clear();\n        }\n      });\n    }\n\n    if (self.__doubleCall) {\n      onINP((inp) => {\n        inp.instance = 2;\n\n        // Log for easier manual testing.\n        console.log('INP2:', inp);\n\n        // Test sending the metric to an analytics endpoint.\n        navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(inp)));\n      }, {\n        reportAllChanges: self.__reportAllChanges2,\n        durationThreshold: self.__durationThreshold2,\n        generateTarget: self.__generateTarget2 && ((el) => el?.dataset?.target),\n      });\n    }\n  </script>\n  {% if prerender %}\n  <script type=\"speculationrules\">\n  {\n    \"prerender\": [\n      {\n        \"urls\": [\"/test/inp{% if queryString %}?{{ queryString | safe }}{% endif %}\"]\n      }\n    ]\n  }\n  </script>\n{% endif %}\n{% endblock %}\n"
  },
  {
    "path": "test/views/layout.njk",
    "content": "<!DOCTYPE html>\n<!--\n Copyright 2020 Google LLC\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     https://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n<html lang=\"en\" {% if invisible or hidden %}hidden{% endif %}>\n<head>\n  <meta charset=\"utf-8\">\n  <title>Web Vitals Test</title>\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <script>\n    (function() {\n      /**\n       * @param {string} visibilityState\n       * @return {void}\n       */\n      self.__stubVisibilityState = (visibilityState) => {\n        if (visibilityState === 'hidden') {\n          Object.defineProperty(document, 'visibilityState', {\n            value: visibilityState,\n            configurable: true,\n          });\n          document.documentElement.hidden = true;\n        } else {\n          delete document.visibilityState;\n          document.documentElement.hidden = false;\n        }\n      }\n\n      /**\n       * @param {string} visibilityState\n       * @return {void}\n       */\n      self.__stubVisibilityChange = (visibilityState) => {\n        self.__stubVisibilityState(visibilityState);\n        document.dispatchEvent(new Event('visibilitychange'));\n      }\n\n      /**\n       * @param {string} visibilityStateAfterRestore\n       * @return {Promise<void>}\n       */\n      self.__stubForwardBack = (visibilityStateAfterRestore) => {\n        return new Promise((resolve) => {\n          self.dispatchEvent(new PageTransitionEvent('pagehide', {\n            persisted: true,\n          }));\n          self.__stubVisibilityChange('hidden');\n          requestAnimationFrame(() => {\n            requestAnimationFrame(() => {\n              if (visibilityStateAfterRestore !== 'hidden') {\n                self.__stubVisibilityChange('visible');\n              }\n              self.dispatchEvent(new PageTransitionEvent('pageshow', {\n                persisted: true,\n              }));\n              resolve();\n            });\n          });\n        });\n      }\n\n      /**\n       * @return {Promise<void>}\n       */\n      self.__stubWasDiscarded = () => {\n        return new Promise((resolve) => {\n          // Only stub if the page isn't actually discarded.\n          if (!document.wasDiscarded) {\n            Object.defineProperty(document, 'wasDiscarded', {\n              value: true,\n              configurable: true,\n            });\n          }\n        });\n      }\n\n      /**\n       * @return {Promise<void>}\n       */\n      self.__afterLoad = new Promise((resolve) => {\n        if (document.readyState === 'complete') {\n          resolve();\n        } else {\n          addEventListener('load', resolve);\n        }\n      });\n\n      /**\n       * @return {Promise<void>}\n       */\n      self.__afterElementsRendered = new Promise((resolve) => {\n        addEventListener('DOMContentLoaded', () => {\n          if (PerformanceObserver.supportedEntryTypes.includes('element')) {\n            const nodes = new Set([...document.querySelectorAll('[elementtiming]:not([hidden])')]);\n            if (nodes.size === 0 || !PerformanceObserver.supportedEntryTypes.includes('element')) {\n              resolve();\n            }\n            new PerformanceObserver((list) => {\n              for (const entry of list.getEntries()) {\n                if (nodes.has(entry.element)) {\n                  nodes.delete(entry.element);\n                }\n              }\n              if (nodes.size === 0) {\n                resolve();\n              }\n            }).observe({type: 'element', buffered: true});\n          } else {\n            self.__afterLoad.then(() => {\n              requestAnimationFrame(() => {\n                requestAnimationFrame(() => {\n                  resolve();\n                });\n              });\n            });\n          }\n        }, true);\n      });\n\n      /**\n       * @return {Promise<void>}\n       */\n      self.__afterFirstInput = new Promise((resolve) => {\n        new PerformanceObserver(resolve).observe({type: 'first-input', buffered: true});\n      });\n\n      // Uncomment to stub running in a browser that doesn't support performance APIs\n      // (e.g. some version of Opera support this).\n      // delete self.performance;\n\n      const params = new URL(location.href).searchParams;\n\n      function infer(param) {\n        const val = params.get(param);\n\n        if (val) {\n          if (val.match(/^\\d+$/)) {\n            return Number(val);\n          } else if (val.match(/^(true|false)$/)) {\n            return val === 'false' ? false : true;\n          }\n          return val;\n        }\n      }\n\n      self.__reportAllChanges = Boolean(infer('reportAllChanges'));\n      self.__durationThreshold = infer('durationThreshold');\n      self.__generateTarget = infer('generateTarget');\n\n      self.__doubleCall = Boolean(infer('doubleCall'));\n      self.__reportAllChanges2 = Boolean(infer('reportAllChanges2'));\n      self.__durationThreshold2 = infer('durationThreshold2');\n      self.__generateTarget2 = infer('generateTarget2');\n\n      self.__lazyLoad = Boolean(infer('lazyLoad'));\n      self.__loadAfterInput = Boolean(infer('loadAfterInput'));\n      self.__registerOnVisibilityChange = Boolean(infer('registerOnVisibilityChange'));\n      self.__batchReporting = Boolean(infer('batchReporting'));\n      self.__removeElement = Boolean(infer('removeElement'));\n\n      if (params.has('hidden')) {\n        // Stub the page being loaded in the hidden state, but defer to the\n        // native state if the `visibilitychange` event fires.\n        Object.defineProperty(document, 'visibilityState', {\n          value: 'hidden',\n          configurable: true,\n        });\n        // We need to overload getEntriesByType to return an initial hidden\n        // state for prerender.\n        // We should also really add entries on visibiltychange, but we\n        // only use initial entries, so no need.\n        const originalGetEntriesByType =\n          window.performance.getEntriesByType.bind(performance);\n\n        window.performance.getEntriesByType = function(type) {\n          const entries = originalGetEntriesByType(type);\n          if (type === 'visibility-state' && entries.length > 0) {\n            const modifiedEntries = [...entries];\n            modifiedEntries[0] = {\n              name: 'hidden',\n              entryType: 'visibility-state',\n              startTime: 0,\n              duration: 0\n            };\n            return modifiedEntries;\n          }\n          return entries;\n        };\n        addEventListener('visibilitychange', (event) => {\n          if (event.isTrusted) {\n            delete document.visibilityState;\n          }\n        }, true);\n      }\n\n      if (params.has('wasDiscarded')) {\n        self.__stubWasDiscarded();\n      }\n\n      // Push to this promise list if you need to wait for some condition in a test.\n      self.__readyPromises = [];\n\n      // If `__lazyLoad` is set, wait to import until after load and first paint.\n      if (self.__lazyLoad) {\n        self.__readyPromises.push(self.__afterLoad, self.__afterElementsRendered);\n      }\n\n      if (self.__loadAfterInput) {\n        self.__readyPromises.push(self.__afterFirstInput);\n      }\n\n      // Import a module and automatically add that to the list of ready promises.\n      self.__testImport = async (modulePath) => {\n        await Promise.all(self.__readyPromises);\n\n        const importPromise = import(modulePath);\n        self.__readyPromises.push(importPromise);\n\n        importPromise.then(() => {\n          self.__isWebVitalsLoaded = true;\n        });\n\n        return await importPromise;\n      };\n\n      self.__toSafeObject = (oldObj) =>  {\n        if (oldObj === null || typeof oldObj !== 'object') {\n          return oldObj;\n        } else if (oldObj instanceof EventTarget) {\n          return oldObj.toString();\n        }\n        const newObj = {};\n        for (let key in oldObj) {\n          const value = oldObj[key];\n          if (typeof value === 'function') continue;\n\n          newObj[key] = Array.isArray(value)\n            ? value.map(__toSafeObject)\n            : __toSafeObject(value);\n        }\n        return newObj;\n      };\n\n    }());\n  </script>\n  {% block head %}{% endblock %}\n  {% if renderBlocking %}\n    <link rel=\"stylesheet\" href=\"/test/css/styles.css?delay={{ renderBlocking }}\">\n  {% endif %}\n  <style>\n    * {\n      box-sizing: border-box;\n    }\n    *[hidden] {\n      visibility: hidden;\n    }\n    body {\n      font: 1em/1.5 sans-serif;\n      margin: 0;\n    }\n    main {\n      border: 1px solid transparent; /* Prevent margin collapsing */\n      min-height: 100vh;\n      padding: 0 1em;\n      position: relative;\n      width: 100%;\n    }\n  </style>\n</head>\n<body>\n  <main>\n    {% block content %}{% endblock %}\n  </main>\n  {% if delayDCL %}\n    <script defer src=\"/test/script/defer.js?delay={{ delayDCL }}\"></script>\n  {% endif %}\n\n  {% if delayLoad %}\n    <script async src=\"/test/script/async.js?delay={{ delayLoad }}\"></script>\n  {% endif %}\n"
  },
  {
    "path": "test/views/lcp.njk",
    "content": "<!--\n Copyright 2020 Google LLC\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     https://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n{% extends 'layout.njk' %}\n\n{% block content %}\n  <h1 elementtiming=\"main-heading\">LCP Test</h1>\n  <p>\n    {% if not imgDelay %}\n      {% set imgDelay = 500 %}\n    {% endif %}\n    <img {% if removeElement %}id=\"lcp-image\"{% endif %} class=\"foo bar\" elementtiming=\"main-image\" data-target=\"main-image\" {% if imgHidden %}hidden{% endif %} src=\"/test/img/square.png?delay={{ imgDelay }}\">\n  </p>\n  <p>Text below the image</p>\n\n  <p><a id=\"navigate-away\" href=\"https://example.com\">Navigate away</a></p>\n  {% if prerender %}<p><a id=\"prerender-link\" href=\"/test/lcp{% if queryString %}?{{ queryString | safe }}{% endif %}\">Prerender link</a></p>{% endif %}\n\n  <!-- Include a tall element to ensure scrolling is possible. -->\n  <div style=\"height: 100vh\"></div>\n\n  <footer>Text below the full-height element.</footer>\n\n  <script type=\"module\">\n    // Remove the LCP element before loading web-vitals if requested.\n    // This tests the entry.id fallback when element is no longer in DOM.\n    if (self.__removeElement) {\n      await self.__afterElementsRendered;\n        // Give Safari a chance to register image LCP\n        await new Promise(resolve => {\n          setTimeout(resolve, 0);\n        });\n      const img = document.getElementById('lcp-image');\n      if (img) {\n        img.remove();\n      }\n    }\n\n    const {onLCP} = await __testImport('{{ modulePath }}');\n\n    const queue = new Set();\n    function addToQueue(metric) {\n      queue.add(metric);\n    }\n\n    function registerLCP() {\n      onLCP((lcp) => {\n        lcp.instance = 1;\n\n        // Log for easier manual testing.\n        console.log('LCP:', lcp);\n\n        if (self.__batchReporting) {\n          console.log('Adding to queue');\n          addToQueue(lcp);\n        } else {\n          // Test sending the metric to an analytics endpoint.\n          navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(lcp)));\n        }\n      }, {\n        reportAllChanges: self.__reportAllChanges,\n        generateTarget: self.__generateTarget && ((el) => el.dataset.target),\n      });\n    }\n\n    if (self.__registerOnVisibilityChange) {\n      document.addEventListener('visibilitychange', () => {\n        console.log('Got a visibilitychange event', document.visibilityState);\n        if (document.visibilityState === 'visible') {\n          registerLCP()\n        }\n      });\n    } else {\n      registerLCP()\n    }\n\n    if (self.__batchReporting) {\n      document.addEventListener('visibilitychange', () => {\n        if (document.visibilityState === 'hidden') {\n          for (const lcp of queue) {\n            console.log('document.addEventListener');\n            navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(lcp)));\n          }\n          queue.clear();\n        }\n      });\n    }\n\n    if (self.__doubleCall) {\n      onLCP((lcp) => {\n        lcp.instance = 2;\n\n        // Log for easier manual testing.\n        console.log('LCP2:', lcp);\n\n        // Test sending the metric to an analytics endpoint.\n        navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(lcp)));\n      }, {\n        reportAllChanges: self.__reportAllChanges2,\n        generateTarget: self.__generateTarget2 && ((el) => el.dataset.target),\n      });\n    }\n  </script>\n  {% if prerender %}\n  <script type=\"speculationrules\">\n  {\n    \"prerender\": [\n      {\n        \"urls\": [\"/test/lcp{% if queryString %}?{{ queryString | safe }}{% endif %}\"]\n      }\n    ]\n  }\n  </script>\n{% endif %}\n{% endblock %}\n"
  },
  {
    "path": "test/views/ttfb.njk",
    "content": "<!--\n Copyright 2020 Google LLC\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     https://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n{% extends 'layout.njk' %}\n\n{% block content %}\n  <h1 elementtiming=\"main-heading\">TTFB Test</h1>\n  <p>\n    <img elementtiming=\"main-image\" {% if imgHidden %}hidden{% endif %} src=\"/test/img/square.png?delay={{ imgDelay }}\">\n  </p>\n\n  <p>Text below the image</p>\n\n  <p><a id=\"navigate-away\" href=\"https://example.com\">Navigate away</a></p>\n  {% if prerender %}<p><a id=\"prerender-link\" href=\"/test/ttfb{% if queryString %}?{{ queryString | safe }}{% endif %}\">Prerender link</a></p>{% endif %}\n\n  <script>\n    // Set the blocking values based on query params if present.\n    const params = new URLSearchParams(location.search);\n\n    if (params.has('responseStart')) {\n      const navEntry = performance.getEntriesByType('navigation')[0];\n      Object.defineProperty(navEntry, 'responseStart', {\n        value: Number(params.get('responseStart')),\n      });\n    }\n  </script>\n\n<script type=\"module\">\n  const {onTTFB} = await __testImport('{{ modulePath }}');\n\n  onTTFB((ttfb) => {\n    ttfb.instance = 1;\n\n    // Log for easier manual testing.\n    console.log('TTFB:', ttfb);\n\n    // Test sending the metric to an analytics endpoint.\n    navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(ttfb)));\n  }, {\n    reportAllChanges: self.__reportAllChanges,\n  });\n\n  if (self.__doubleCall) {\n    onTTFB((ttfb) => {\n      ttfb.instance = 2;\n\n      // Log for easier manual testing.\n      console.log('TTFB2:', ttfb);\n\n      // Test sending the metric to an analytics endpoint.\n      navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(ttfb)));\n    }, {\n      reportAllChanges: self.__reportAllChanges2,\n    });\n  }\n</script>\n  {% if prerender %}\n  <script type=\"speculationrules\">\n  {\n    \"prerender\": [\n      {\n        \"urls\": [\"/test/ttfb{% if queryString %}?{{ queryString | safe }}{% endif %}\"]\n      }\n    ]\n  }\n  </script>\n{% endif %}\n{% endblock %}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"declaration\": true,\n    \"lib\": [\"esnext\", \"DOM\"],\n    \"module\": \"nodenext\",\n    \"moduleResolution\": \"nodenext\",\n    \"noFallthroughCasesInSwitch\": true,\n    \"noImplicitReturns\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"outDir\": \"./dist/modules\",\n    \"preserveConstEnums\": true,\n    \"rootDir\": \"./src\",\n    \"strict\": true,\n    \"target\": \"esnext\",\n    \"tsBuildInfoFile\": \"./tsconfig.tsbuildinfo\"\n  },\n  \"include\": [\"src/**/*.ts\"],\n  \"exclude\": []\n}\n"
  },
  {
    "path": "wdio.conf.js",
    "content": "/*\n Copyright 2020 Google LLC\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     https://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n*/\n\nimport yargs from 'yargs/yargs';\nimport {hideBin} from 'yargs/helpers';\n\nconst argv = yargs(hideBin(process.argv)).parse();\n\n// Allow running tests for a comma-delimited set of metrics via `--metrics=TTFB,LCP`.\nconst metrics = argv.metrics ? argv.metrics.toUpperCase().split(',') : ['*'];\n\n// Allow running tests for a comma-delimited set of browsers via `--browsers=chrome,safari`.\nconst browsers = argv.browsers\n  ? argv.browsers.toLowerCase().split(',')\n  : ['chrome', 'firefox', 'safari'];\n\nexport const config = {\n  //\n  // ====================\n  // Runner Configuration\n  // ====================\n  // WebdriverIO supports running e2e tests as well as unit and component tests.\n  runner: 'local',\n  //\n  // ==================\n  // Specify Test Files\n  // ==================\n  // Define which test specs should run. The pattern is relative to the directory\n  // of the configuration file being run.\n  //\n  // The specs are defined as an array of spec files (optionally using wildcards\n  // that will be expanded). The test for each spec file will be run in a separate\n  // worker process. In order to have a group of spec files run in the same worker\n  // process simply enclose them in an array within the specs array.\n  //\n  // If you are calling `wdio` from an NPM script (see https://docs.npmjs.com/cli/run-script),\n  // then the current working directory is where your `package.json` resides, so `wdio`\n  // will be called from there.\n  //\n  specs: metrics.map((metric) => `test/e2e/on${metric}-test.js`),\n  // Patterns to exclude.\n  exclude: [\n    // 'path/to/excluded/files'\n  ],\n  //\n  // ============\n  // Capabilities\n  // ============\n  // Define your capabilities here. WebdriverIO can run multiple capabilities at the same\n  // time. Depending on the number of capabilities, WebdriverIO launches several test\n  // sessions. Within your capabilities you can overwrite the spec and exclude options in\n  // order to group specific specs to a specific capability.\n  //\n  // First, you can define how many instances should be started at the same time. Let's\n  // say you have 3 different capabilities (Chrome, Firefox, and Safari) and you have\n  // set maxInstances to 1; wdio will spawn 3 processes. Therefore, if you have 10 spec\n  // files and you set maxInstances to 10, all spec files will get tested at the same time\n  // and 30 processes will get spawned. The property handles how many capabilities\n  // from the same test should run tests.\n  //\n  maxInstances: 1,\n  //\n  // If you have trouble getting all important capabilities together, check out the\n  // Sauce Labs platform configurator - a great tool to configure your capabilities:\n  // https://saucelabs.com/platform/platform-configurator\n  //\n  capabilities: browsers.map((browserName) => {\n    const capability = {\n      browserName: browserName,\n      maxInstances: 1,\n      pageLoadStrategy: 'none',\n      'wdio:enforceWebDriverClassic': true,\n    };\n    if (browserName === 'chrome') {\n      capability['goog:chromeOptions'] = {\n        excludeSwitches: ['enable-automation'],\n        // Can remove next line after puppeteer 21.2.1 lands\n        args: ['disable-search-engine-choice-screen'],\n        // Uncomment to test on Chrome Canary.\n        // binary: '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',\n      };\n    }\n    if (browserName === 'firefox') {\n      capability['moz:firefoxOptions'] = {\n        args: [],\n        prefs: {\n          // Uncomment to disable interactionid on Firefox nightly if unstable\n          // 'dom.performance.event_timing.enable_interactionid': false,\n        },\n        // Uncomment to test on Firefox Nightly (now with INP support)\n        // CI uses Nightly but local testing will use production without this\n        // binary: '/Applications/Firefox Nightly.app/Contents/MacOS/firefox',\n      };\n    }\n    return capability;\n  }),\n  //\n  // ===================\n  // Test Configurations\n  // ===================\n  // Define all options that are relevant for the WebdriverIO instance here\n  //\n  // Level of logging verbosity: trace | debug | info | warn | error | silent\n  logLevel: 'warn',\n  //\n  // Set specific log levels per logger\n  // loggers:\n  // - webdriver, webdriverio\n  // - @wdio/browserstack-service, @wdio/devtools-service, @wdio/sauce-service\n  // - @wdio/mocha-framework, @wdio/jasmine-framework\n  // - @wdio/local-runner\n  // - @wdio/sumologic-reporter\n  // - @wdio/cli, @wdio/config, @wdio/utils\n  // Level of logging verbosity: trace | debug | info | warn | error | silent\n  // logLevels: {\n  //     webdriver: 'info',\n  //     '@wdio/appium-service': 'info'\n  // },\n  //\n  // If you only want to run your tests until a specific amount of tests have failed use\n  // bail (default is 0 - don't bail, run all tests).\n  bail: 0,\n  //\n  // Set a base URL in order to shorten url command calls. If your `url` parameter starts\n  // with `/`, the base url gets prepended, not including the path portion of your baseUrl.\n  // If your `url` parameter starts without a scheme or `/` (like `some/path`), the base url\n  // gets prepended directly.\n  baseUrl: 'http://localhost:9090',\n  //\n  // Default timeout for all waitFor* commands.\n  waitforTimeout: 10000,\n  //\n  // Default timeout in milliseconds for request\n  // if browser driver or grid doesn't send response\n  connectionRetryTimeout: 120000,\n  //\n  // Default request retries count\n  connectionRetryCount: 3,\n  //\n  // Test runner services\n  // Services take over a specific job you don't want to take care of. They enhance\n  // your test setup with almost no effort. Unlike plugins, they don't add new\n  // commands. Instead, they hook themselves up into the test process.\n  // services: [],\n  //\n  // Framework you want to run your specs with.\n  // The following are supported: Mocha, Jasmine, and Cucumber\n  // see also: https://webdriver.io/docs/frameworks\n  //\n  // Make sure you have the wdio adapter package for the specific framework installed\n  // before running any tests.\n  framework: 'mocha',\n\n  //\n  // The number of times to retry the entire specfile when it fails as a whole\n  // specFileRetries: 1,\n  //\n  // Delay in seconds between the spec file retry attempts\n  // specFileRetriesDelay: 0,\n  //\n  // Whether or not retried spec files should be retried immediately or deferred to the end of the queue\n  // specFileRetriesDeferred: false,\n  //\n  // Test reporter for stdout.\n  // The only one supported by default is 'dot'\n  // see also: https://webdriver.io/docs/dot-reporter\n  reporters: ['spec'],\n\n  // Options to be passed to Mocha.\n  // See the full list at http://mochajs.org/\n  mochaOpts: {\n    ui: 'bdd',\n    timeout: 60000,\n  },\n\n  //\n  // =====\n  // Hooks\n  // =====\n  // WebdriverIO provides several hooks you can use to interfere with the test process in order to enhance\n  // it and to build services around it. You can either apply a single function or an array of\n  // methods to it. If one of them returns with a promise, WebdriverIO will wait until that promise got\n  // resolved to continue.\n  /**\n   * Gets executed once before all workers get launched.\n   * @param {object} config wdio configuration object\n   * @param {Array.<Object>} capabilities list of capabilities details\n   */\n  // onPrepare: function (config, capabilities) {\n  // },\n  /**\n   * Gets executed before a worker process is spawned and can be used to initialize specific service\n   * for that worker as well as modify runtime environments in an async fashion.\n   * @param  {string} cid      capability id (e.g 0-0)\n   * @param  {object} caps     object containing capabilities for session that will be spawn in the worker\n   * @param  {object} specs    specs to be run in the worker process\n   * @param  {object} args     object that will be merged with the main configuration once worker is initialized\n   * @param  {object} execArgv list of string arguments passed to the worker process\n   */\n  // onWorkerStart: function (cid, caps, specs, args, execArgv) {\n  // },\n  /**\n   * Gets executed just after a worker process has exited.\n   * @param  {string} cid      capability id (e.g 0-0)\n   * @param  {number} exitCode 0 - success, 1 - fail\n   * @param  {object} specs    specs to be run in the worker process\n   * @param  {number} retries  number of retries used\n   */\n  // onWorkerEnd: function (cid, exitCode, specs, retries) {\n  // },\n  /**\n   * Gets executed just before initialising the webdriver session and test framework. It allows you\n   * to manipulate configurations depending on the capability or spec.\n   * @param {object} config wdio configuration object\n   * @param {Array.<Object>} capabilities list of capabilities details\n   * @param {Array.<String>} specs List of spec file paths that are to be run\n   * @param {string} cid worker id (e.g. 0-0)\n   */\n  // beforeSession: function (config, capabilities, specs, cid) {\n  // },\n  /**\n   * Gets executed before test execution begins. At this point you can access to all global\n   * variables like `browser`. It is the perfect place to define custom commands.\n   * @param {Array.<Object>} capabilities list of capabilities details\n   * @param {Array.<String>} specs        List of spec file paths that are to be run\n   * @param {object}         browser      instance of created browser/device session\n   */\n  // before: function (capabilities, specs) {\n  // },\n  /**\n   * Runs before a WebdriverIO command gets executed.\n   * @param {string} commandName hook command name\n   * @param {Array} args arguments that command would receive\n   */\n  // beforeCommand: function (commandName, args) {\n  // },\n  /**\n   * Hook that gets executed before the suite starts\n   * @param {object} suite suite details\n   */\n  // beforeSuite: function (suite) {\n  // },\n  /**\n   * Function to be executed before a test (in Mocha/Jasmine) starts.\n   */\n  // beforeTest: function (test, context) {\n  // },\n  /**\n   * Hook that gets executed _before_ a hook within the suite starts (e.g. runs before calling\n   * beforeEach in Mocha)\n   */\n  // beforeHook: function (test, context, hookName) {\n  // },\n  /**\n   * Hook that gets executed _after_ a hook within the suite starts (e.g. runs after calling\n   * afterEach in Mocha)\n   */\n  // afterHook: function (test, context, { error, result, duration, passed, retries }, hookName) {\n  // },\n  /**\n   * Function to be executed after a test (in Mocha/Jasmine only)\n   * @param {object}  test             test object\n   * @param {object}  context          scope object the test was executed with\n   * @param {Error}   result.error     error object in case the test fails, otherwise `undefined`\n   * @param {*}       result.result    return object of test function\n   * @param {number}  result.duration  duration of test\n   * @param {boolean} result.passed    true if test has passed, otherwise false\n   * @param {object}  result.retries   information about spec related retries, e.g. `{ attempts: 0, limit: 0 }`\n   */\n  // afterTest: function(test, context, { error, result, duration, passed, retries }) {\n  // },\n\n  /**\n   * Hook that gets executed after the suite has ended\n   * @param {object} suite suite details\n   */\n  // afterSuite: function (suite) {\n  // },\n  /**\n   * Runs after a WebdriverIO command gets executed\n   * @param {string} commandName hook command name\n   * @param {Array} args arguments that command would receive\n   * @param {number} result 0 - command success, 1 - command error\n   * @param {object} error error object if any\n   */\n  // afterCommand: function (commandName, args, result, error) {\n  // },\n  /**\n   * Gets executed after all tests are done. You still have access to all global variables from\n   * the test.\n   * @param {number} result 0 - test pass, 1 - test fail\n   * @param {Array.<Object>} capabilities list of capabilities details\n   * @param {Array.<String>} specs List of spec file paths that ran\n   */\n  // after: function (result, capabilities, specs) {\n  // },\n  /**\n   * Gets executed right after terminating the webdriver session.\n   * @param {object} config wdio configuration object\n   * @param {Array.<Object>} capabilities list of capabilities details\n   * @param {Array.<String>} specs List of spec file paths that ran\n   */\n  // afterSession: function (config, capabilities, specs) {\n  // },\n  /**\n   * Gets executed after all workers got shut down and the process is about to exit. An error\n   * thrown in the onComplete hook will result in the test run failing.\n   * @param {object} exitCode 0 - success, 1 - fail\n   * @param {object} config wdio configuration object\n   * @param {Array.<Object>} capabilities list of capabilities details\n   * @param {<Object>} results object containing test results\n   */\n  // onComplete: function(exitCode, config, capabilities, results) {\n  // },\n  /**\n   * Gets executed when a refresh happens.\n   * @param {string} oldSessionId session ID of the old session\n   * @param {string} newSessionId session ID of the new session\n   */\n  // onReload: function(oldSessionId, newSessionId) {\n  // }\n  /**\n   * Hook that gets executed before a WebdriverIO assertion happens.\n   * @param {object} params information about the assertion to be executed\n   */\n  // beforeAssertion: function(params) {\n  // }\n  /**\n   * Hook that gets executed after a WebdriverIO assertion happened.\n   * @param {object} params information about the assertion that was executed, including its results\n   */\n  // afterAssertion: function(params) {\n  // }\n};\n"
  }
]