lighthouse 8.3.0-dev.20210907 → 8.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/changelog.md +1 -1
  2. package/dist/report/standalone.js +196 -0
  3. package/jest.config.js +0 -4
  4. package/lighthouse-cli/cli-flags.js +6 -14
  5. package/lighthouse-cli/run.js +1 -1
  6. package/lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js +3 -9
  7. package/lighthouse-cli/test/smokehouse/report-assert.js +16 -36
  8. package/lighthouse-core/audits/large-javascript-libraries.js +166 -0
  9. package/lighthouse-core/audits/seo/http-status-code.js +6 -2
  10. package/lighthouse-core/audits/viewport.js +1 -2
  11. package/lighthouse-core/config/config-helpers.js +75 -9
  12. package/lighthouse-core/config/config.js +50 -6
  13. package/lighthouse-core/config/constants.js +0 -1
  14. package/lighthouse-core/config/default-config.js +0 -3
  15. package/lighthouse-core/config/experimental-config.js +8 -0
  16. package/lighthouse-core/config/metrics-to-audits.js +0 -1
  17. package/lighthouse-core/fraggle-rock/config/config.js +4 -3
  18. package/lighthouse-core/fraggle-rock/config/default-config.js +1 -3
  19. package/lighthouse-core/fraggle-rock/config/validation.js +0 -201
  20. package/lighthouse-core/fraggle-rock/gather/base-artifacts.js +0 -4
  21. package/lighthouse-core/fraggle-rock/gather/navigation-runner.js +8 -16
  22. package/lighthouse-core/fraggle-rock/gather/runner-helpers.js +0 -4
  23. package/lighthouse-core/fraggle-rock/gather/session.js +5 -30
  24. package/lighthouse-core/gather/driver/navigation.js +6 -7
  25. package/lighthouse-core/gather/driver/wait-for-condition.js +0 -27
  26. package/lighthouse-core/gather/fetcher.js +7 -17
  27. package/lighthouse-core/gather/gather-runner.js +0 -1
  28. package/lighthouse-core/gather/gatherers/seo/robots-txt.js +6 -2
  29. package/lighthouse-core/gather/gatherers/seo/tap-targets.js +2 -2
  30. package/lighthouse-core/gather/gatherers/stacks.js +1 -1
  31. package/lighthouse-core/lib/asset-saver.js +2 -2
  32. package/lighthouse-core/lib/cdt/SDK.js +0 -1
  33. package/lighthouse-core/lib/csp-evaluator.js +4 -17
  34. package/{report/generator → lighthouse-core/lib}/file-namer.js +0 -0
  35. package/lighthouse-core/lib/i18n/README.md +1 -1
  36. package/lighthouse-core/lib/i18n/i18n.js +7 -7
  37. package/lighthouse-core/lib/i18n/locales/ar-XB.json +18 -0
  38. package/lighthouse-core/lib/i18n/locales/ar.json +18 -0
  39. package/lighthouse-core/lib/i18n/locales/bg.json +18 -0
  40. package/lighthouse-core/lib/i18n/locales/ca.json +18 -0
  41. package/lighthouse-core/lib/i18n/locales/cs.json +18 -0
  42. package/lighthouse-core/lib/i18n/locales/da.json +18 -0
  43. package/lighthouse-core/lib/i18n/locales/de.json +18 -0
  44. package/lighthouse-core/lib/i18n/locales/el.json +18 -0
  45. package/lighthouse-core/lib/i18n/locales/en-GB.json +18 -0
  46. package/lighthouse-core/lib/i18n/locales/en-US.json +18 -18
  47. package/lighthouse-core/lib/i18n/locales/en-XA.json +18 -0
  48. package/lighthouse-core/lib/i18n/locales/en-XL.json +18 -18
  49. package/lighthouse-core/lib/i18n/locales/es-419.json +18 -0
  50. package/lighthouse-core/lib/i18n/locales/es.json +18 -0
  51. package/lighthouse-core/lib/i18n/locales/fi.json +18 -0
  52. package/lighthouse-core/lib/i18n/locales/fil.json +18 -0
  53. package/lighthouse-core/lib/i18n/locales/fr.json +18 -0
  54. package/lighthouse-core/lib/i18n/locales/he.json +18 -0
  55. package/lighthouse-core/lib/i18n/locales/hi.json +18 -0
  56. package/lighthouse-core/lib/i18n/locales/hr.json +18 -0
  57. package/lighthouse-core/lib/i18n/locales/hu.json +18 -0
  58. package/lighthouse-core/lib/i18n/locales/id.json +18 -0
  59. package/lighthouse-core/lib/i18n/locales/it.json +18 -0
  60. package/lighthouse-core/lib/i18n/locales/ja.json +18 -0
  61. package/lighthouse-core/lib/i18n/locales/ko.json +18 -0
  62. package/lighthouse-core/lib/i18n/locales/lt.json +18 -0
  63. package/lighthouse-core/lib/i18n/locales/lv.json +18 -0
  64. package/lighthouse-core/lib/i18n/locales/nl.json +18 -0
  65. package/lighthouse-core/lib/i18n/locales/no.json +18 -0
  66. package/lighthouse-core/lib/i18n/locales/pl.json +18 -0
  67. package/lighthouse-core/lib/i18n/locales/pt-PT.json +18 -0
  68. package/lighthouse-core/lib/i18n/locales/pt.json +18 -0
  69. package/lighthouse-core/lib/i18n/locales/ro.json +18 -0
  70. package/lighthouse-core/lib/i18n/locales/ru.json +18 -0
  71. package/lighthouse-core/lib/i18n/locales/sk.json +18 -0
  72. package/lighthouse-core/lib/i18n/locales/sl.json +18 -0
  73. package/lighthouse-core/lib/i18n/locales/sr-Latn.json +18 -0
  74. package/lighthouse-core/lib/i18n/locales/sr.json +18 -0
  75. package/lighthouse-core/lib/i18n/locales/sv.json +18 -0
  76. package/lighthouse-core/lib/i18n/locales/ta.json +18 -0
  77. package/lighthouse-core/lib/i18n/locales/te.json +18 -0
  78. package/lighthouse-core/lib/i18n/locales/th.json +18 -0
  79. package/lighthouse-core/lib/i18n/locales/tr.json +18 -0
  80. package/lighthouse-core/lib/i18n/locales/uk.json +18 -0
  81. package/lighthouse-core/lib/i18n/locales/vi.json +18 -0
  82. package/lighthouse-core/lib/i18n/locales/zh-HK.json +18 -0
  83. package/lighthouse-core/lib/i18n/locales/zh-TW.json +18 -0
  84. package/lighthouse-core/lib/i18n/locales/zh.json +18 -0
  85. package/lighthouse-core/lib/large-javascript-libraries/bundlephobia-database.json +62 -0
  86. package/lighthouse-core/lib/large-javascript-libraries/library-suggestions.js +20 -0
  87. package/lighthouse-core/lib/network-request.js +1 -3
  88. package/lighthouse-core/lib/traces/pwmetrics-events.js +2 -2
  89. package/lighthouse-core/lib/url-shim.js +1 -7
  90. package/lighthouse-core/runner.js +1 -4
  91. package/lighthouse-core/util-commonjs.js +2 -2
  92. package/now.json +1 -1
  93. package/package.json +13 -20
  94. package/report/README.md +2 -2
  95. package/report/assets/standalone-template.html +1 -0
  96. package/report/assets/styles.css +27 -23
  97. package/report/assets/templates.html +40 -37
  98. package/report/clients/bundle.js +0 -1
  99. package/report/{package.json → clients/package.json} +0 -0
  100. package/report/clients/psi.js +3 -13
  101. package/report/clients/standalone.js +1 -1
  102. package/report/{types/buffer.d.ts → clients/treemap.js} +13 -11
  103. package/report/renderer/category-renderer.js +2 -2
  104. package/report/renderer/components.js +48 -46
  105. package/report/renderer/crc-details-renderer.js +16 -16
  106. package/report/renderer/details-renderer.js +1 -1
  107. package/report/renderer/dom.js +0 -17
  108. package/report/renderer/element-screenshot-renderer.js +9 -9
  109. package/{flow-report/test/setup/global-setup.ts → report/renderer/file-namer.js} +2 -5
  110. package/report/renderer/logger.js +4 -4
  111. package/report/renderer/package.json +4 -0
  112. package/report/renderer/report-renderer.js +11 -4
  113. package/report/renderer/report-ui-features.js +656 -43
  114. package/report/renderer/text-encoding.js +1 -1
  115. package/report/renderer/util.js +2 -2
  116. package/report/{generator/report-assets.js → report-assets.js} +3 -9
  117. package/report/{generator/report-generator.js → report-generator.js} +9 -37
  118. package/report/test/clients/package.json +4 -0
  119. package/report/test/clients/psi-test.js +1 -3
  120. package/report/test/renderer/category-renderer-test.js +3 -8
  121. package/report/test/renderer/components-test.js +0 -35
  122. package/report/test/renderer/crc-details-renderer-test.js +4 -5
  123. package/report/test/renderer/details-renderer-test.js +1 -3
  124. package/report/test/renderer/dom-test.js +6 -8
  125. package/report/test/renderer/i18n-test.js +0 -1
  126. package/report/test/renderer/package.json +4 -0
  127. package/report/test/renderer/performance-category-renderer-test.js +27 -30
  128. package/report/test/renderer/pwa-category-renderer-test.js +1 -2
  129. package/report/test/renderer/report-renderer-test.js +0 -1
  130. package/report/test/renderer/report-ui-features-test.js +28 -20
  131. package/report/test/renderer/snippet-renderer-test.js +0 -1
  132. package/report/test/renderer/text-encoding-test.js +0 -2
  133. package/report/test/renderer/util-test.js +0 -1
  134. package/report/test/{generator/report-generator-test.js → report-generator-test.js} +7 -9
  135. package/third-party/chromium-synchronization/installability-errors-test.js +27 -28
  136. package/third-party/snyk/snapshot.json +0 -2
  137. package/tsconfig.json +5 -24
  138. package/types/artifacts.d.ts +18 -5
  139. package/types/{lhr/audit-details.d.ts → audit-details.d.ts} +3 -18
  140. package/types/audit.d.ts +58 -7
  141. package/types/{lhr/budget.d.ts → budget.d.ts} +0 -0
  142. package/types/bundle-phobia-cli/index.d.ts +40 -0
  143. package/types/config.d.ts +8 -3
  144. package/types/externs.d.ts +119 -5
  145. package/types/gatherer.d.ts +2 -3
  146. package/types/global-lh.d.ts +14 -16
  147. package/{report/types/report-result.d.ts → types/html-renderer.d.ts} +17 -8
  148. package/types/{lhr/i18n.d.ts → i18n.d.ts} +25 -2
  149. package/types/{lhr/lhr.d.ts → lhr.d.ts} +9 -41
  150. package/types/smokehouse.d.ts +3 -3
  151. package/types/{lhr/treemap.d.ts → treemap.d.ts} +3 -3
  152. package/flow-report/assets/standalone-flow-template.html +0 -38
  153. package/flow-report/assets/styles.css +0 -310
  154. package/flow-report/jest.config.js +0 -24
  155. package/flow-report/src/app.tsx +0 -40
  156. package/flow-report/src/common.tsx +0 -72
  157. package/flow-report/src/icons.tsx +0 -96
  158. package/flow-report/src/sidebar/flow.tsx +0 -72
  159. package/flow-report/src/sidebar/sidebar.tsx +0 -89
  160. package/flow-report/src/summary/summary.tsx +0 -178
  161. package/flow-report/src/util.ts +0 -160
  162. package/flow-report/src/wrappers/gauge.tsx +0 -40
  163. package/flow-report/src/wrappers/report-renderer.tsx +0 -46
  164. package/flow-report/src/wrappers/report.tsx +0 -71
  165. package/flow-report/standalone-flow.tsx +0 -23
  166. package/flow-report/test/app-test.tsx +0 -48
  167. package/flow-report/test/common-test.tsx +0 -131
  168. package/flow-report/test/setup/env-setup.ts +0 -25
  169. package/flow-report/test/sidebar/flow-test.tsx +0 -71
  170. package/flow-report/test/sidebar/sidebar-test.tsx +0 -53
  171. package/flow-report/test/summary/summary-test.tsx +0 -132
  172. package/flow-report/test/util-test.tsx +0 -109
  173. package/flow-report/tsconfig.json +0 -39
  174. package/flow-report/types/flow-report.d.ts +0 -27
  175. package/lighthouse-core/audits/lcp-lazy-loaded.js +0 -83
  176. package/report/.eslintrc.cjs +0 -33
  177. package/report/generator/README.md +0 -9
  178. package/report/generator/flow-report-assets.js +0 -20
  179. package/report/generator/package.json +0 -4
  180. package/report/generator/tsconfig.json +0 -28
  181. package/report/renderer/drop-down-menu.js +0 -215
  182. package/report/renderer/features-util.js +0 -26
  183. package/report/renderer/open-tab.js +0 -137
  184. package/report/renderer/topbar-features.js +0 -309
  185. package/report/test/generator/file-namer-test.js +0 -28
  186. package/report/test/generator/package.json +0 -4
  187. package/report/test-assets/faux-psi-template.html +0 -126
  188. package/report/test-assets/faux-psi.js +0 -92
  189. package/report/tsconfig.json +0 -39
  190. package/report/types/augment-dom.d.ts +0 -24
  191. package/report/types/html-renderer.d.ts +0 -28
  192. package/types/lhr/audit-result.d.ts +0 -59
  193. package/types/lhr/flow.d.ts +0 -16
  194. package/types/lhr/settings.d.ts +0 -105
  195. package/types/lhr/tsconfig.json +0 -24
  196. package/types/query-selector.d.ts +0 -36
@@ -16,6 +16,8 @@
16
16
  */
17
17
  'use strict';
18
18
 
19
+ /* eslint-env browser */
20
+
19
21
  /**
20
22
  * @fileoverview Adds tools button, print, and other dynamic functionality to
21
23
  * the report.
@@ -23,12 +25,10 @@
23
25
 
24
26
  /** @typedef {import('./dom').DOM} DOM */
25
27
 
28
+ import {getFilenamePrefix} from './file-namer.js';
26
29
  import {ElementScreenshotRenderer} from './element-screenshot-renderer.js';
27
- import {toggleDarkTheme} from './features-util.js';
28
- import {openTreemap} from './open-tab.js';
29
- import {TopbarFeatures} from './topbar-features.js';
30
+ import {TextEncoding} from './text-encoding.js';
30
31
  import {Util} from './util.js';
31
- import {getFilenamePrefix} from '../generator/file-namer.js';
32
32
 
33
33
  /**
34
34
  * @param {HTMLTableElement} tableEl
@@ -37,6 +37,16 @@ import {getFilenamePrefix} from '../generator/file-namer.js';
37
37
  function getTableRows(tableEl) {
38
38
  return Array.from(tableEl.tBodies[0].rows);
39
39
  }
40
+
41
+ function getAppsOrigin() {
42
+ const isVercel = window.location.host.endsWith('.vercel.app');
43
+ const isDev = new URLSearchParams(window.location.search).has('dev');
44
+
45
+ if (isVercel) return `https://${window.location.host}/gh-pages`;
46
+ if (isDev) return 'http://localhost:8000';
47
+ return 'https://googlechrome.github.io/lighthouse';
48
+ }
49
+
40
50
  export class ReportUIFeatures {
41
51
  /**
42
52
  * @param {DOM} dom
@@ -48,24 +58,48 @@ export class ReportUIFeatures {
48
58
  this._dom = dom;
49
59
  /** @type {Document} */
50
60
  this._document = this._dom.document();
51
- this._topbar = new TopbarFeatures(this, dom);
61
+ /** @type {DropDown} */
62
+ this._dropDown = new DropDown(this._dom);
63
+ /** @type {boolean} */
64
+ this._copyAttempt = false;
65
+ /** @type {HTMLElement} */
66
+ this.topbarEl; // eslint-disable-line no-unused-expressions
67
+ /** @type {HTMLElement} */
68
+ this.scoreScaleEl; // eslint-disable-line no-unused-expressions
69
+ /** @type {HTMLElement} */
70
+ this.stickyHeaderEl; // eslint-disable-line no-unused-expressions
71
+ /** @type {HTMLElement} */
72
+ this.highlightEl; // eslint-disable-line no-unused-expressions
52
73
 
53
74
  this.onMediaQueryChange = this.onMediaQueryChange.bind(this);
75
+ this.onCopy = this.onCopy.bind(this);
76
+ this.onDropDownMenuClick = this.onDropDownMenuClick.bind(this);
77
+ this.onKeyUp = this.onKeyUp.bind(this);
78
+ this.collapseAllDetails = this.collapseAllDetails.bind(this);
79
+ this.expandAllDetails = this.expandAllDetails.bind(this);
80
+ this._toggleDarkTheme = this._toggleDarkTheme.bind(this);
81
+ this._updateStickyHeaderOnScroll = this._updateStickyHeaderOnScroll.bind(this);
54
82
  }
55
83
 
56
84
  /**
57
85
  * Adds tools button, print, and other functionality to the report. The method
58
86
  * should be called whenever the report needs to be re-rendered.
59
- * @param {LH.Result} lhr
87
+ * @param {LH.Result} report
60
88
  */
61
- initFeatures(lhr) {
62
- this.json = lhr;
89
+ initFeatures(report) {
90
+ this.json = report;
63
91
 
64
- this._topbar.enable(lhr);
65
- this._topbar.resetUIState();
66
92
  this._setupMediaQueryListeners();
93
+ this._dropDown.setup(this.onDropDownMenuClick);
67
94
  this._setupThirdPartyFilter();
68
95
  this._setupElementScreenshotOverlay(this._dom.find('.lh-container', this._document));
96
+ this._setUpCollapseDetailsAfterPrinting();
97
+ this._resetUIState();
98
+ this._document.addEventListener('keyup', this.onKeyUp);
99
+ this._document.addEventListener('copy', this.onCopy);
100
+
101
+ const topbarLogo = this._dom.find('.lh-topbar__logo', this._document);
102
+ topbarLogo.addEventListener('click', () => this._toggleDarkTheme());
69
103
 
70
104
  let turnOffTheLights = false;
71
105
  // Do not query the system preferences for DevTools - DevTools should only apply dark theme
@@ -78,7 +112,7 @@ export class ReportUIFeatures {
78
112
  // To get fireworks you need 100 scores in all core categories, except PWA (because going the PWA route is discretionary).
79
113
  const fireworksRequiredCategoryIds = ['performance', 'accessibility', 'best-practices', 'seo'];
80
114
  const scoresAll100 = fireworksRequiredCategoryIds.every(id => {
81
- const cat = lhr.categories[id];
115
+ const cat = report.categories[id];
82
116
  return cat && cat.score === 1;
83
117
  });
84
118
  if (scoresAll100) {
@@ -87,12 +121,32 @@ export class ReportUIFeatures {
87
121
  }
88
122
 
89
123
  if (turnOffTheLights) {
90
- toggleDarkTheme(this._dom, true);
124
+ this._toggleDarkTheme(true);
125
+ }
126
+
127
+ // There is only a sticky header when at least 2 categories are present.
128
+ if (Object.keys(this.json.categories).length >= 2) {
129
+ this._setupStickyHeaderElements();
130
+ const containerEl = this._dom.find('.lh-container', this._document);
131
+ const elToAddScrollListener = this._getScrollParent(containerEl);
132
+ elToAddScrollListener.addEventListener('scroll', this._updateStickyHeaderOnScroll);
133
+
134
+ // Use ResizeObserver where available.
135
+ // TODO: there is an issue with incorrect position numbers and, as a result, performance
136
+ // issues due to layout thrashing.
137
+ // See https://github.com/GoogleChrome/lighthouse/pull/9023/files#r288822287 for details.
138
+ // For now, limit to DevTools.
139
+ if (this._dom.isDevTools()) {
140
+ const resizeObserver = new window.ResizeObserver(this._updateStickyHeaderOnScroll);
141
+ resizeObserver.observe(containerEl);
142
+ } else {
143
+ window.addEventListener('resize', this._updateStickyHeaderOnScroll);
144
+ }
91
145
  }
92
146
 
93
147
  // Show the metric descriptions by default when there is an error.
94
- const hasMetricError = lhr.categories.performance && lhr.categories.performance.auditRefs
95
- .some(audit => Boolean(audit.group === 'metrics' && lhr.audits[audit.id].errorMessage));
148
+ const hasMetricError = report.categories.performance && report.categories.performance.auditRefs
149
+ .some(audit => Boolean(audit.group === 'metrics' && report.audits[audit.id].errorMessage));
96
150
  if (hasMetricError) {
97
151
  const toggleInputEl = this._dom.find('input.lh-metrics-toggle__input', this._document);
98
152
  toggleInputEl.checked = true;
@@ -104,7 +158,7 @@ export class ReportUIFeatures {
104
158
  this.addButton({
105
159
  text: Util.i18n.strings.viewTreemapLabel,
106
160
  icon: 'treemap',
107
- onClick: () => openTreemap(this.json),
161
+ onClick: () => ReportUIFeatures.openTreemap(this.json),
108
162
  });
109
163
  }
110
164
 
@@ -112,12 +166,18 @@ export class ReportUIFeatures {
112
166
  for (const node of this._dom.findAll('[data-i18n]', this._dom.document())) {
113
167
  // These strings are guaranteed to (at least) have a default English string in Util.UIStrings,
114
168
  // so this cannot be undefined as long as `report-ui-features.data-i18n` test passes.
115
- const i18nKey = node.getAttribute('data-i18n');
116
- const i18nAttr = /** @type {keyof typeof Util.i18n.strings} */ (i18nKey);
169
+ const i18nAttr = /** @type {keyof LH.I18NRendererStrings} */ (node.getAttribute('data-i18n'));
117
170
  node.textContent = Util.i18n.strings[i18nAttr];
118
171
  }
119
172
  }
120
173
 
174
+ /**
175
+ * @param {ParentNode} _
176
+ */
177
+ setTemplateContext(_) {
178
+ // Removed, exists until usage is removed from CDT.
179
+ }
180
+
121
181
  /**
122
182
  * @param {{container?: Element, text: string, icon?: string, onClick: () => void}} opts
123
183
  */
@@ -136,8 +196,8 @@ export class ReportUIFeatures {
136
196
  'lh-button',
137
197
  ];
138
198
  if (opts.icon) {
139
- classes.push('lh-report-icon');
140
- classes.push(`lh-report-icon--${opts.icon}`);
199
+ classes.push('report-icon');
200
+ classes.push(`report-icon--${opts.icon}`);
141
201
  }
142
202
  const buttonEl = this._dom.createChildOf(buttonsEl, 'button', classes.join(' '));
143
203
  buttonEl.textContent = opts.text;
@@ -146,30 +206,44 @@ export class ReportUIFeatures {
146
206
  }
147
207
 
148
208
  /**
149
- * Returns the html that recreates this report.
150
- * @return {string}
209
+ * Finds the first scrollable ancestor of `element`. Falls back to the document.
210
+ * @param {Element} element
211
+ * @return {Node}
151
212
  */
152
- getReportHtml() {
153
- this._topbar.resetUIState();
154
- return this._document.documentElement.outerHTML;
155
- }
213
+ _getScrollParent(element) {
214
+ const {overflowY} = window.getComputedStyle(element);
215
+ const isScrollable = overflowY !== 'visible' && overflowY !== 'hidden';
156
216
 
157
- /**
158
- * Save json as a gist. Unimplemented in base UI features.
159
- */
160
- saveAsGist() {
161
- // TODO ?
162
- throw new Error('Cannot save as gist from base report');
217
+ if (isScrollable) {
218
+ return element;
219
+ }
220
+
221
+ if (element.parentElement) {
222
+ return this._getScrollParent(element.parentElement);
223
+ }
224
+
225
+ return document;
163
226
  }
164
227
 
165
228
  _enableFireworks() {
166
229
  const scoresContainer = this._dom.find('.lh-scores-container', this._document);
167
- scoresContainer.classList.add('lh-score100');
230
+ scoresContainer.classList.add('score100');
168
231
  scoresContainer.addEventListener('click', _ => {
169
- scoresContainer.classList.toggle('lh-fireworks-paused');
232
+ scoresContainer.classList.toggle('fireworks-paused');
170
233
  });
171
234
  }
172
235
 
236
+ /**
237
+ * Fires a custom DOM event on target.
238
+ * @param {string} name Name of the event.
239
+ * @param {Node=} target DOM node to fire the event on.
240
+ * @param {*=} detail Custom data to include.
241
+ */
242
+ _fireEventOn(name, target = this._document, detail) {
243
+ const event = new CustomEvent(name, detail ? {detail} : undefined);
244
+ target.dispatchEvent(event);
245
+ }
246
+
173
247
  _setupMediaQueryListeners() {
174
248
  const mediaQuery = self.matchMedia('(max-width: 500px)');
175
249
  mediaQuery.addListener(this.onMediaQueryChange);
@@ -177,15 +251,6 @@ export class ReportUIFeatures {
177
251
  this.onMediaQueryChange(mediaQuery);
178
252
  }
179
253
 
180
- /**
181
- * Resets the state of page before capturing the page for export.
182
- * When the user opens the exported HTML page, certain UI elements should
183
- * be in their closed state (not opened) and the templates should be unstamped.
184
- */
185
- _resetUIState() {
186
- this._topbar.resetUIState();
187
- }
188
-
189
254
  /**
190
255
  * Handle media query change events.
191
256
  * @param {MediaQueryList|MediaQueryListEvent} mql
@@ -326,9 +391,311 @@ export class ReportUIFeatures {
326
391
  return thirdPartyRows;
327
392
  }
328
393
 
394
+ _setupStickyHeaderElements() {
395
+ this.topbarEl = this._dom.find('div.lh-topbar', this._document);
396
+ this.scoreScaleEl = this._dom.find('div.lh-scorescale', this._document);
397
+ this.stickyHeaderEl = this._dom.find('div.lh-sticky-header', this._document);
398
+
399
+ // Highlighter will be absolutely positioned at first gauge, then transformed on scroll.
400
+ this.highlightEl = this._dom.createChildOf(this.stickyHeaderEl, 'div', 'lh-highlighter');
401
+ }
402
+
403
+ /**
404
+ * Handle copy events.
405
+ * @param {ClipboardEvent} e
406
+ */
407
+ onCopy(e) {
408
+ // Only handle copy button presses (e.g. ignore the user copying page text).
409
+ if (this._copyAttempt && e.clipboardData) {
410
+ // We want to write our own data to the clipboard, not the user's text selection.
411
+ e.preventDefault();
412
+ e.clipboardData.setData('text/plain', JSON.stringify(this.json, null, 2));
413
+
414
+ this._fireEventOn('lh-log', this._document, {
415
+ cmd: 'log', msg: 'Report JSON copied to clipboard',
416
+ });
417
+ }
418
+
419
+ this._copyAttempt = false;
420
+ }
421
+
422
+ /**
423
+ * Copies the report JSON to the clipboard (if supported by the browser).
424
+ */
425
+ onCopyButtonClick() {
426
+ this._fireEventOn('lh-analytics', this._document, {
427
+ cmd: 'send',
428
+ fields: {hitType: 'event', eventCategory: 'report', eventAction: 'copy'},
429
+ });
430
+
431
+ try {
432
+ if (this._document.queryCommandSupported('copy')) {
433
+ this._copyAttempt = true;
434
+
435
+ // Note: In Safari 10.0.1, execCommand('copy') returns true if there's
436
+ // a valid text selection on the page. See http://caniuse.com/#feat=clipboard.
437
+ if (!this._document.execCommand('copy')) {
438
+ this._copyAttempt = false; // Prevent event handler from seeing this as a copy attempt.
439
+
440
+ this._fireEventOn('lh-log', this._document, {
441
+ cmd: 'warn', msg: 'Your browser does not support copy to clipboard.',
442
+ });
443
+ }
444
+ }
445
+ } catch (/** @type {Error} */ e) {
446
+ this._copyAttempt = false;
447
+ this._fireEventOn('lh-log', this._document, {cmd: 'log', msg: e.message});
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Resets the state of page before capturing the page for export.
453
+ * When the user opens the exported HTML page, certain UI elements should
454
+ * be in their closed state (not opened) and the templates should be unstamped.
455
+ */
456
+ _resetUIState() {
457
+ this._dropDown.close();
458
+ }
459
+
460
+ /**
461
+ * Handler for tool button.
462
+ * @param {Event} e
463
+ */
464
+ onDropDownMenuClick(e) {
465
+ e.preventDefault();
466
+
467
+ const el = /** @type {?Element} */ (e.target);
468
+
469
+ if (!el || !el.hasAttribute('data-action')) {
470
+ return;
471
+ }
472
+
473
+ switch (el.getAttribute('data-action')) {
474
+ case 'copy':
475
+ this.onCopyButtonClick();
476
+ break;
477
+ case 'print-summary':
478
+ this.collapseAllDetails();
479
+ this._print();
480
+ break;
481
+ case 'print-expanded':
482
+ this.expandAllDetails();
483
+ this._print();
484
+ break;
485
+ case 'save-json': {
486
+ const jsonStr = JSON.stringify(this.json, null, 2);
487
+ this._saveFile(new Blob([jsonStr], {type: 'application/json'}));
488
+ break;
489
+ }
490
+ case 'save-html': {
491
+ const htmlStr = this.getReportHtml();
492
+ try {
493
+ this._saveFile(new Blob([htmlStr], {type: 'text/html'}));
494
+ } catch (/** @type {Error} */ e) {
495
+ this._fireEventOn('lh-log', this._document, {
496
+ cmd: 'error', msg: 'Could not export as HTML. ' + e.message,
497
+ });
498
+ }
499
+ break;
500
+ }
501
+ case 'open-viewer': {
502
+ // DevTools cannot send data with postMessage, and we only want to use the URL fragment
503
+ // approach for viewer when needed, so check the environment and choose accordingly.
504
+ if (this._dom.isDevTools()) {
505
+ ReportUIFeatures.openViewer(this.json);
506
+ } else {
507
+ const windowName = 'viewer-' + ReportUIFeatures.computeWindowNameSuffix(this.json);
508
+ const url = getAppsOrigin() + '/viewer/';
509
+ ReportUIFeatures.openTabWithUrlData({lhr: this.json}, url, windowName);
510
+ }
511
+ break;
512
+ }
513
+ case 'save-gist': {
514
+ this.saveAsGist();
515
+ break;
516
+ }
517
+ case 'toggle-dark': {
518
+ this._toggleDarkTheme();
519
+ break;
520
+ }
521
+ }
522
+
523
+ this._dropDown.close();
524
+ }
525
+
526
+ _print() {
527
+ self.print();
528
+ }
529
+
530
+ /**
531
+ * Keyup handler for the document.
532
+ * @param {KeyboardEvent} e
533
+ */
534
+ onKeyUp(e) {
535
+ // Ctrl+P - Expands audit details when user prints via keyboard shortcut.
536
+ if ((e.ctrlKey || e.metaKey) && e.keyCode === 80) {
537
+ this._dropDown.close();
538
+ }
539
+ }
540
+
541
+ /**
542
+ * The popup's window.name is keyed by version+url+fetchTime, so we reuse/select tabs correctly.
543
+ * @param {LH.Result} json
544
+ * @protected
545
+ */
546
+ static computeWindowNameSuffix(json) {
547
+ // @ts-ignore - If this is a v2 LHR, use old `generatedTime`.
548
+ const fallbackFetchTime = /** @type {string} */ (json.generatedTime);
549
+ const fetchTime = json.fetchTime || fallbackFetchTime;
550
+ return `${json.lighthouseVersion}-${json.requestedUrl}-${fetchTime}`;
551
+ }
552
+
553
+ /**
554
+ * Opens a new tab to the online viewer and sends the local page's JSON results
555
+ * to the online viewer using URL.fragment
556
+ * @param {LH.Result} json
557
+ * @protected
558
+ */
559
+ static openViewer(json) {
560
+ const windowName = 'viewer-' + this.computeWindowNameSuffix(json);
561
+ const url = getAppsOrigin() + '/viewer/';
562
+ ReportUIFeatures.openTabWithUrlData({lhr: json}, url, windowName);
563
+ }
564
+
565
+ /**
566
+ * Opens a new tab to the treemap app and sends the JSON results using URL.fragment
567
+ * @param {LH.Result} json
568
+ */
569
+ static openTreemap(json) {
570
+ const treemapData = json.audits['script-treemap-data'].details;
571
+ if (!treemapData) {
572
+ throw new Error('no script treemap data found');
573
+ }
574
+
575
+ /** @type {LH.Treemap.Options} */
576
+ const treemapOptions = {
577
+ lhr: {
578
+ requestedUrl: json.requestedUrl,
579
+ finalUrl: json.finalUrl,
580
+ audits: {
581
+ 'script-treemap-data': json.audits['script-treemap-data'],
582
+ },
583
+ configSettings: {
584
+ locale: json.configSettings.locale,
585
+ },
586
+ },
587
+ };
588
+ const url = getAppsOrigin() + '/treemap/';
589
+ const windowName = 'treemap-' + this.computeWindowNameSuffix(json);
590
+
591
+ ReportUIFeatures.openTabWithUrlData(treemapOptions, url, windowName);
592
+ }
593
+
594
+ /**
595
+ * Opens a new tab to an external page and sends data using postMessage.
596
+ * @param {{lhr: LH.Result} | LH.Treemap.Options} data
597
+ * @param {string} url
598
+ * @param {string} windowName
599
+ * @protected
600
+ */
601
+ static openTabAndSendData(data, url, windowName) {
602
+ const origin = new URL(url).origin;
603
+ // Chrome doesn't allow us to immediately postMessage to a popup right
604
+ // after it's created. Normally, we could also listen for the popup window's
605
+ // load event, however it is cross-domain and won't fire. Instead, listen
606
+ // for a message from the target app saying "I'm open".
607
+ window.addEventListener('message', function msgHandler(messageEvent) {
608
+ if (messageEvent.origin !== origin) {
609
+ return;
610
+ }
611
+ if (popup && messageEvent.data.opened) {
612
+ popup.postMessage(data, origin);
613
+ window.removeEventListener('message', msgHandler);
614
+ }
615
+ });
616
+
617
+ const popup = window.open(url, windowName);
618
+ }
619
+
620
+ /**
621
+ * Opens a new tab to an external page and sends data via base64 encoded url params.
622
+ * @param {{lhr: LH.Result} | LH.Treemap.Options} data
623
+ * @param {string} url_
624
+ * @param {string} windowName
625
+ * @protected
626
+ */
627
+ static async openTabWithUrlData(data, url_, windowName) {
628
+ const url = new URL(url_);
629
+ const gzip = Boolean(window.CompressionStream);
630
+ url.hash = await TextEncoding.toBase64(JSON.stringify(data), {
631
+ gzip,
632
+ });
633
+ if (gzip) url.searchParams.set('gzip', '1');
634
+ window.open(url.toString(), windowName);
635
+ }
636
+
637
+ /**
638
+ * Expands all audit `<details>`.
639
+ * Ideally, a print stylesheet could take care of this, but CSS has no way to
640
+ * open a `<details>` element.
641
+ */
642
+ expandAllDetails() {
643
+ const details = this._dom.findAll('.lh-categories details', this._document);
644
+ details.map(detail => detail.open = true);
645
+ }
646
+
647
+ /**
648
+ * Collapses all audit `<details>`.
649
+ * open a `<details>` element.
650
+ */
651
+ collapseAllDetails() {
652
+ const details = this._dom.findAll('.lh-categories details', this._document);
653
+ details.map(detail => detail.open = false);
654
+ }
655
+
656
+ /**
657
+ * Sets up listeners to collapse audit `<details>` when the user closes the
658
+ * print dialog, all `<details>` are collapsed.
659
+ */
660
+ _setUpCollapseDetailsAfterPrinting() {
661
+ // FF and IE implement these old events.
662
+ if ('onbeforeprint' in self) {
663
+ self.addEventListener('afterprint', this.collapseAllDetails);
664
+ } else {
665
+ // Note: FF implements both window.onbeforeprint and media listeners. However,
666
+ // it doesn't matchMedia doesn't fire when matching 'print'.
667
+ self.matchMedia('print').addListener(mql => {
668
+ if (mql.matches) {
669
+ this.expandAllDetails();
670
+ } else {
671
+ this.collapseAllDetails();
672
+ }
673
+ });
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Returns the html that recreates this report.
679
+ * @return {string}
680
+ * @protected
681
+ */
682
+ getReportHtml() {
683
+ this._resetUIState();
684
+ return this._document.documentElement.outerHTML;
685
+ }
686
+
687
+ /**
688
+ * Save json as a gist. Unimplemented in base UI features.
689
+ * @protected
690
+ */
691
+ saveAsGist() {
692
+ throw new Error('Cannot save as gist from base report');
693
+ }
694
+
329
695
  /**
330
696
  * Downloads a file (blob) using a[download].
331
697
  * @param {Blob|File} blob The file to save.
698
+ * @private
332
699
  */
333
700
  _saveFile(blob) {
334
701
  const filename = getFilenamePrefix({
@@ -346,6 +713,252 @@ export class ReportUIFeatures {
346
713
 
347
714
  // cleanup.
348
715
  this._document.body.removeChild(a);
349
- setTimeout(() => URL.revokeObjectURL(a.href), 500);
716
+ setTimeout(_ => URL.revokeObjectURL(a.href), 500);
717
+ }
718
+
719
+ /**
720
+ * @private
721
+ * @param {boolean} [force]
722
+ */
723
+ _toggleDarkTheme(force) {
724
+ const el = this._dom.find('.lh-vars', this._document);
725
+ // This seems unnecessary, but in DevTools, passing "undefined" as the second
726
+ // parameter acts like passing "false".
727
+ // https://github.com/ChromeDevTools/devtools-frontend/blob/dd6a6d4153647c2a4203c327c595692c5e0a4256/front_end/dom_extension/DOMExtension.js#L809-L819
728
+ if (typeof force === 'undefined') {
729
+ el.classList.toggle('dark');
730
+ } else {
731
+ el.classList.toggle('dark', force);
732
+ }
733
+ }
734
+
735
+ _updateStickyHeaderOnScroll() {
736
+ // Show sticky header when the score scale begins to go underneath the topbar.
737
+ const topbarBottom = this.topbarEl.getBoundingClientRect().bottom;
738
+ const scoreScaleTop = this.scoreScaleEl.getBoundingClientRect().top;
739
+ const showStickyHeader = topbarBottom >= scoreScaleTop;
740
+
741
+ // Highlight mini gauge when section is in view.
742
+ // In view = the last category that starts above the middle of the window.
743
+ const categoryEls = Array.from(this._document.querySelectorAll('.lh-category'));
744
+ const categoriesAboveTheMiddle =
745
+ categoryEls.filter(el => el.getBoundingClientRect().top - window.innerHeight / 2 < 0);
746
+ const highlightIndex =
747
+ categoriesAboveTheMiddle.length > 0 ? categoriesAboveTheMiddle.length - 1 : 0;
748
+
749
+ // Category order matches gauge order in sticky header.
750
+ const gaugeWrapperEls = this.stickyHeaderEl.querySelectorAll('.lh-gauge__wrapper');
751
+ const gaugeToHighlight = gaugeWrapperEls[highlightIndex];
752
+ const origin = gaugeWrapperEls[0].getBoundingClientRect().left;
753
+ const offset = gaugeToHighlight.getBoundingClientRect().left - origin;
754
+
755
+ // Mutate at end to avoid layout thrashing.
756
+ this.highlightEl.style.transform = `translate(${offset}px)`;
757
+ this.stickyHeaderEl.classList.toggle('lh-sticky-header--visible', showStickyHeader);
758
+ }
759
+ }
760
+
761
+ class DropDown {
762
+ /**
763
+ * @param {DOM} dom
764
+ */
765
+ constructor(dom) {
766
+ /** @type {DOM} */
767
+ this._dom = dom;
768
+ /** @type {HTMLElement} */
769
+ this._toggleEl; // eslint-disable-line no-unused-expressions
770
+ /** @type {HTMLElement} */
771
+ this._menuEl; // eslint-disable-line no-unused-expressions
772
+
773
+ this.onDocumentKeyDown = this.onDocumentKeyDown.bind(this);
774
+ this.onToggleClick = this.onToggleClick.bind(this);
775
+ this.onToggleKeydown = this.onToggleKeydown.bind(this);
776
+ this.onMenuFocusOut = this.onMenuFocusOut.bind(this);
777
+ this.onMenuKeydown = this.onMenuKeydown.bind(this);
778
+
779
+ this._getNextMenuItem = this._getNextMenuItem.bind(this);
780
+ this._getNextSelectableNode = this._getNextSelectableNode.bind(this);
781
+ this._getPreviousMenuItem = this._getPreviousMenuItem.bind(this);
782
+ }
783
+
784
+ /**
785
+ * @param {function(MouseEvent): any} menuClickHandler
786
+ */
787
+ setup(menuClickHandler) {
788
+ this._toggleEl = this._dom.find('button.lh-tools__button', this._dom.document());
789
+ this._toggleEl.addEventListener('click', this.onToggleClick);
790
+ this._toggleEl.addEventListener('keydown', this.onToggleKeydown);
791
+
792
+ this._menuEl = this._dom.find('div.lh-tools__dropdown', this._dom.document());
793
+ this._menuEl.addEventListener('keydown', this.onMenuKeydown);
794
+ this._menuEl.addEventListener('click', menuClickHandler);
795
+ }
796
+
797
+ close() {
798
+ this._toggleEl.classList.remove('active');
799
+ this._toggleEl.setAttribute('aria-expanded', 'false');
800
+ if (this._menuEl.contains(this._dom.document().activeElement)) {
801
+ // Refocus on the tools button if the drop down last had focus
802
+ this._toggleEl.focus();
803
+ }
804
+ this._menuEl.removeEventListener('focusout', this.onMenuFocusOut);
805
+ this._dom.document().removeEventListener('keydown', this.onDocumentKeyDown);
806
+ }
807
+
808
+ /**
809
+ * @param {HTMLElement} firstFocusElement
810
+ */
811
+ open(firstFocusElement) {
812
+ if (this._toggleEl.classList.contains('active')) {
813
+ // If the drop down is already open focus on the element
814
+ firstFocusElement.focus();
815
+ } else {
816
+ // Wait for drop down transition to complete so options are focusable.
817
+ this._menuEl.addEventListener('transitionend', () => {
818
+ firstFocusElement.focus();
819
+ }, {once: true});
820
+ }
821
+
822
+ this._toggleEl.classList.add('active');
823
+ this._toggleEl.setAttribute('aria-expanded', 'true');
824
+ this._menuEl.addEventListener('focusout', this.onMenuFocusOut);
825
+ this._dom.document().addEventListener('keydown', this.onDocumentKeyDown);
826
+ }
827
+
828
+ /**
829
+ * Click handler for tools button.
830
+ * @param {Event} e
831
+ */
832
+ onToggleClick(e) {
833
+ e.preventDefault();
834
+ e.stopImmediatePropagation();
835
+
836
+ if (this._toggleEl.classList.contains('active')) {
837
+ this.close();
838
+ } else {
839
+ this.open(this._getNextMenuItem());
840
+ }
841
+ }
842
+
843
+ /**
844
+ * Handler for tool button.
845
+ * @param {KeyboardEvent} e
846
+ */
847
+ onToggleKeydown(e) {
848
+ switch (e.code) {
849
+ case 'ArrowUp':
850
+ e.preventDefault();
851
+ this.open(this._getPreviousMenuItem());
852
+ break;
853
+ case 'ArrowDown':
854
+ case 'Enter':
855
+ case ' ':
856
+ e.preventDefault();
857
+ this.open(this._getNextMenuItem());
858
+ break;
859
+ default:
860
+ // no op
861
+ }
862
+ }
863
+
864
+ /**
865
+ * Handler for tool DropDown.
866
+ * @param {KeyboardEvent} e
867
+ */
868
+ onMenuKeydown(e) {
869
+ const el = /** @type {?HTMLElement} */ (e.target);
870
+
871
+ switch (e.code) {
872
+ case 'ArrowUp':
873
+ e.preventDefault();
874
+ this._getPreviousMenuItem(el).focus();
875
+ break;
876
+ case 'ArrowDown':
877
+ e.preventDefault();
878
+ this._getNextMenuItem(el).focus();
879
+ break;
880
+ case 'Home':
881
+ e.preventDefault();
882
+ this._getNextMenuItem().focus();
883
+ break;
884
+ case 'End':
885
+ e.preventDefault();
886
+ this._getPreviousMenuItem().focus();
887
+ break;
888
+ default:
889
+ // no op
890
+ }
891
+ }
892
+
893
+ /**
894
+ * Keydown handler for the document.
895
+ * @param {KeyboardEvent} e
896
+ */
897
+ onDocumentKeyDown(e) {
898
+ if (e.keyCode === 27) { // ESC
899
+ this.close();
900
+ }
901
+ }
902
+
903
+ /**
904
+ * Focus out handler for the drop down menu.
905
+ * @param {FocusEvent} e
906
+ */
907
+ onMenuFocusOut(e) {
908
+ const focusedEl = /** @type {?HTMLElement} */ (e.relatedTarget);
909
+
910
+ if (!this._menuEl.contains(focusedEl)) {
911
+ this.close();
912
+ }
913
+ }
914
+
915
+ /**
916
+ * @param {Array<Node>} allNodes
917
+ * @param {?HTMLElement=} startNode
918
+ * @return {HTMLElement}
919
+ */
920
+ _getNextSelectableNode(allNodes, startNode) {
921
+ const nodes = allNodes.filter(/** @return {node is HTMLElement} */ (node) => {
922
+ if (!(node instanceof HTMLElement)) {
923
+ return false;
924
+ }
925
+
926
+ // 'Save as Gist' option may be disabled.
927
+ if (node.hasAttribute('disabled')) {
928
+ return false;
929
+ }
930
+
931
+ // 'Save as Gist' option may have display none.
932
+ if (window.getComputedStyle(node).display === 'none') {
933
+ return false;
934
+ }
935
+
936
+ return true;
937
+ });
938
+
939
+ let nextIndex = startNode ? (nodes.indexOf(startNode) + 1) : 0;
940
+ if (nextIndex >= nodes.length) {
941
+ nextIndex = 0;
942
+ }
943
+
944
+ return nodes[nextIndex];
945
+ }
946
+
947
+ /**
948
+ * @param {?HTMLElement=} startEl
949
+ * @return {HTMLElement}
950
+ */
951
+ _getNextMenuItem(startEl) {
952
+ const nodes = Array.from(this._menuEl.childNodes);
953
+ return this._getNextSelectableNode(nodes, startEl);
954
+ }
955
+
956
+ /**
957
+ * @param {?HTMLElement=} startEl
958
+ * @return {HTMLElement}
959
+ */
960
+ _getPreviousMenuItem(startEl) {
961
+ const nodes = Array.from(this._menuEl.childNodes).reverse();
962
+ return this._getNextSelectableNode(nodes, startEl);
350
963
  }
351
964
  }