blob: 7aea16d5f68f0b9f90b7269d9e2ecf73032eeed7 [file] [log] [blame]
Blink Reformat4c46d092018-04-07 15:32:37 +00001// Copyright 2016 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/**
6 * @implements {SDK.SDKModelObserver<!SDK.ServiceWorkerManager>}
7 * @unrestricted
8 */
9Audits2.Audits2Panel = class extends UI.Panel {
10 constructor() {
11 super('audits2');
12 this.registerRequiredCSS('audits2/lighthouse/report-styles.css');
13 this.registerRequiredCSS('audits2/audits2Panel.css');
14
15 this._protocolService = new Audits2.ProtocolService();
16
17 this._renderToolbar();
18
19 this._auditResultsElement = this.contentElement.createChild('div', 'audits2-results-container');
20 this._dropTarget = new UI.DropTarget(
21 this.contentElement, [UI.DropTarget.Type.File], Common.UIString('Drop audit file here'),
22 this._handleDrop.bind(this));
23
24 for (const preset of Audits2.Audits2Panel.Presets)
25 preset.setting.addChangeListener(this._refreshDialogUI.bind(this));
26
27 this._showLandingPage();
28 SDK.targetManager.observeModels(SDK.ServiceWorkerManager, this);
29 SDK.targetManager.addEventListener(SDK.TargetManager.Events.InspectedURLChanged, this._refreshDialogUI, this);
30 }
31
32 /**
33 * @override
34 * @param {!SDK.ServiceWorkerManager} serviceWorkerManager
35 */
36 modelAdded(serviceWorkerManager) {
37 if (this._manager)
38 return;
39
40 this._manager = serviceWorkerManager;
41 this._serviceWorkerListeners = [
42 this._manager.addEventListener(SDK.ServiceWorkerManager.Events.RegistrationUpdated, this._refreshDialogUI, this),
43 this._manager.addEventListener(SDK.ServiceWorkerManager.Events.RegistrationDeleted, this._refreshDialogUI, this),
44 ];
45
46 this._refreshDialogUI();
47 }
48
49 /**
50 * @override
51 * @param {!SDK.ServiceWorkerManager} serviceWorkerManager
52 */
53 modelRemoved(serviceWorkerManager) {
54 if (!this._manager || this._manager !== serviceWorkerManager)
55 return;
56
57 Common.EventTarget.removeEventListeners(this._serviceWorkerListeners);
58 this._manager = null;
59 this._serviceWorkerListeners = null;
60 this._refreshDialogUI();
61 }
62
63 /**
64 * @return {boolean}
65 */
66 _hasActiveServiceWorker() {
67 if (!this._manager)
68 return false;
69
70 const mainTarget = SDK.targetManager.mainTarget();
71 if (!mainTarget)
72 return false;
73
74 const inspectedURL = mainTarget.inspectedURL().asParsedURL();
75 const inspectedOrigin = inspectedURL && inspectedURL.securityOrigin();
76 for (const registration of this._manager.registrations().values()) {
77 if (registration.securityOrigin !== inspectedOrigin)
78 continue;
79
80 for (const version of registration.versions.values()) {
81 if (version.controlledClients.length > 1)
82 return true;
83 }
84 }
85
86 return false;
87 }
88
89 /**
90 * @return {boolean}
91 */
92 _hasAtLeastOneCategory() {
93 return Audits2.Audits2Panel.Presets.some(preset => preset.setting.get());
94 }
95
96 /**
97 * @return {?string}
98 */
99 _unauditablePageMessage() {
100 if (!this._manager)
101 return null;
102
103 const mainTarget = SDK.targetManager.mainTarget();
104 const inspectedURL = mainTarget && mainTarget.inspectedURL();
105 if (inspectedURL && !/^(http|chrome-extension)/.test(inspectedURL)) {
106 return Common.UIString(
107 'Can only audit HTTP/HTTPS pages and Chrome extensions. ' +
108 'Navigate to a different page to start an audit.');
109 }
110
111 // Audits don't work on most undockable targets (extension popup pages, remote debugging, etc).
112 // However, the tests run in a content shell which is not dockable yet audits just fine,
113 // so disable this check when under test.
114 if (!Host.isUnderTest() && !Runtime.queryParam('can_dock'))
115 return Common.UIString('Can only audit tabs. Navigate to this page in a separate tab to start an audit.');
116
117 return null;
118 }
119
120 _refreshDialogUI() {
121 if (!this._dialog)
122 return;
123
124 const hasActiveServiceWorker = this._hasActiveServiceWorker();
125 const hasAtLeastOneCategory = this._hasAtLeastOneCategory();
126 const unauditablePageMessage = this._unauditablePageMessage();
127 const isDisabled = hasActiveServiceWorker || !hasAtLeastOneCategory || !!unauditablePageMessage;
128
129 let helpText = '';
130 if (hasActiveServiceWorker) {
131 helpText = Common.UIString(
132 'Multiple tabs are being controlled by the same service worker. ' +
133 'Close your other tabs on the same origin to audit this page.');
134 } else if (!hasAtLeastOneCategory) {
135 helpText = Common.UIString('At least one category must be selected.');
136 } else if (unauditablePageMessage) {
137 helpText = unauditablePageMessage;
138 }
139
140 this._dialog.setHelpText(helpText);
141 this._dialog.setStartEnabled(!isDisabled);
142 }
143
144 _refreshToolbarUI() {
145 this._downloadButton.setEnabled(this._reportSelector.hasCurrentSelection());
146 this._clearButton.setEnabled(this._reportSelector.hasItems());
147 }
148
149 _clearAll() {
150 this._reportSelector.clearAll();
151 this._showLandingPage();
152 this._refreshToolbarUI();
153 }
154
155 _downloadSelected() {
156 this._reportSelector.downloadSelected();
157 }
158
159 _renderToolbar() {
160 const toolbar = new UI.Toolbar('', this.element);
161
162 this._newButton = new UI.ToolbarButton(Common.UIString('Perform an audit\u2026'), 'largeicon-add');
163 toolbar.appendToolbarItem(this._newButton);
164 this._newButton.addEventListener(UI.ToolbarButton.Events.Click, this._showDialog.bind(this));
165
166 this._downloadButton = new UI.ToolbarButton(Common.UIString('Download report'), 'largeicon-download');
167 toolbar.appendToolbarItem(this._downloadButton);
168 this._downloadButton.addEventListener(UI.ToolbarButton.Events.Click, this._downloadSelected.bind(this));
169
170 toolbar.appendSeparator();
171
172 this._reportSelector = new Audits2.ReportSelector();
173 toolbar.appendToolbarItem(this._reportSelector.comboBox());
174
175 this._clearButton = new UI.ToolbarButton(Common.UIString('Clear all'), 'largeicon-clear');
176 toolbar.appendToolbarItem(this._clearButton);
177 this._clearButton.addEventListener(UI.ToolbarButton.Events.Click, this._clearAll.bind(this));
178
179 toolbar.appendSeparator();
180
181 toolbar.appendText(ls`Emulation: `);
182 for (const runtimeSetting of Audits2.Audits2Panel.RuntimeSettings) {
183 const control = new UI.ToolbarSettingComboBox(runtimeSetting.options, runtimeSetting.setting);
184 control.element.title = runtimeSetting.description;
185 toolbar.appendToolbarItem(control);
186 }
187
188 this._refreshToolbarUI();
189 }
190
191 _showLandingPage() {
192 if (this._reportSelector.hasCurrentSelection())
193 return;
194
195 this._auditResultsElement.removeChildren();
196 const landingPage = this._auditResultsElement.createChild('div', 'vbox audits2-landing-page');
197 const landingCenter = landingPage.createChild('div', 'vbox audits2-landing-center');
198 landingCenter.createChild('div', 'audits2-logo');
199 const text = landingCenter.createChild('div', 'audits2-landing-text');
200 text.createChild('span', 'audits2-landing-bold-text').textContent = Common.UIString('Audits');
201 text.createChild('span').textContent = Common.UIString(
202 ' help you identify and fix common problems that affect' +
203 ' your site\'s performance, accessibility, and user experience. ');
204 const link = text.createChild('span', 'link');
205 link.textContent = Common.UIString('Learn more');
206 link.addEventListener(
207 'click', () => InspectorFrontendHost.openInNewTab('https://developers.google.com/web/tools/lighthouse/'));
208
209 const newButton = UI.createTextButton(
210 Common.UIString('Perform an audit\u2026'), this._showDialog.bind(this), '', true /* primary */);
211 landingCenter.appendChild(newButton);
212 this.setDefaultFocusedElement(newButton);
213 this._refreshToolbarUI();
214 }
215
216 _showDialog() {
217 this._dialog = new Audits2.Audits2Dialog(result => this._buildReportUI(result), this._protocolService);
218 this._dialog.render(this._auditResultsElement);
219 this._refreshDialogUI();
220 }
221
222 _hideDialog() {
223 if (!this._dialog)
224 return;
225
226 this._dialog.hide();
227 delete this._dialog;
228 }
229
230 /**
231 * @param {!ReportRenderer.ReportJSON} lighthouseResult
232 */
233 _buildReportUI(lighthouseResult) {
234 if (lighthouseResult === null)
235 return;
236
237 const optionElement =
238 new Audits2.ReportSelector.Item(lighthouseResult, this._auditResultsElement, this._showLandingPage.bind(this));
239 this._reportSelector.prepend(optionElement);
240 this._hideDialog();
241 this._refreshToolbarUI();
242 }
243
244 /**
245 * @param {!DataTransfer} dataTransfer
246 */
247 _handleDrop(dataTransfer) {
248 const items = dataTransfer.items;
249 if (!items.length)
250 return;
251 const item = items[0];
252 if (item.kind === 'file') {
253 const entry = items[0].webkitGetAsEntry();
254 if (!entry.isFile)
255 return;
256 entry.file(file => {
257 const reader = new FileReader();
258 reader.onload = () => this._loadedFromFile(/** @type {string} */ (reader.result));
259 reader.readAsText(file);
260 });
261 }
262 }
263
264 /**
265 * @param {string} profile
266 */
267 _loadedFromFile(profile) {
268 const data = JSON.parse(profile);
269 if (!data['lighthouseVersion'])
270 return;
271 this._buildReportUI(/** @type {!ReportRenderer.ReportJSON} */ (data));
272 }
273};
274
275/**
276 * @override
277 */
278Audits2.Audits2Panel.ReportRenderer = class extends ReportRenderer {
279 /**
280 * Provides empty element for left nav
281 * @override
282 * @returns {!DocumentFragment}
283 */
284 _renderReportNav() {
285 return createDocumentFragment();
286 }
287
288 /**
289 * @param {!ReportRenderer.ReportJSON} report
290 * @override
291 * @return {!DocumentFragment}
292 */
293 _renderReportHeader(report) {
294 return createDocumentFragment();
295 }
296};
297
298class ReportUIFeatures {
299 /**
300 * @param {!ReportRenderer.ReportJSON} report
301 */
302 initFeatures(report) {
303 }
304}
305
306/** @typedef {{type: string, setting: !Common.Setting, configID: string, title: string, description: string}} */
307Audits2.Audits2Panel.Preset;
308
309/** @type {!Array.<!Audits2.Audits2Panel.Preset>} */
310Audits2.Audits2Panel.Presets = [
311 // configID maps to Lighthouse's Object.keys(config.categories)[0] value
312 {
313 setting: Common.settings.createSetting('audits2.cat_perf', true),
314 configID: 'performance',
315 title: 'Performance',
316 description: 'How long does this app take to show content and become usable'
317 },
318 {
319 setting: Common.settings.createSetting('audits2.cat_pwa', true),
320 configID: 'pwa',
321 title: 'Progressive Web App',
322 description: 'Does this page meet the standard of a Progressive Web App'
323 },
324 {
325 setting: Common.settings.createSetting('audits2.cat_best_practices', true),
326 configID: 'best-practices',
327 title: 'Best practices',
328 description: 'Does this page follow best practices for modern web development'
329 },
330 {
331 setting: Common.settings.createSetting('audits2.cat_a11y', true),
332 configID: 'accessibility',
333 title: 'Accessibility',
334 description: 'Is this page usable by people with disabilities or impairments'
335 },
336 {
337 setting: Common.settings.createSetting('audits2.cat_seo', true),
338 configID: 'seo',
339 title: 'SEO',
340 description: 'Is this page optimized for search engine results ranking'
341 },
342];
343
344/** @typedef {{setting: !Common.Setting, description: string, setFlags: function(!Object, string), options: !Array}} */
345Audits2.Audits2Panel.RuntimeSetting;
346
347/** @type {!Array.<!Audits2.Audits2Panel.RuntimeSetting>} */
348Audits2.Audits2Panel.RuntimeSettings = [
349 {
350 setting: Common.settings.createSetting('audits2.device_type', 'mobile'),
351 description: Common.UIString('Apply mobile emulation during auditing'),
352 setFlags: (flags, value) => {
353 flags.disableDeviceEmulation = value === 'desktop';
354 },
355 options: [
356 {label: Common.UIString('Mobile'), value: 'mobile'},
357 {label: Common.UIString('Desktop'), value: 'desktop'},
358 ],
359 },
360 {
361 setting: Common.settings.createSetting('audits2.throttling', 'default'),
362 description: Common.UIString('Apply network and CPU throttling during performance auditing'),
363 setFlags: (flags, value) => {
364 flags.disableNetworkThrottling = value === 'off';
365 flags.disableCpuThrottling = value === 'off';
366 },
367 options: [
368 {label: Common.UIString('3G w/ CPU slowdown'), value: 'default'},
369 {label: Common.UIString('No throttling'), value: 'off'},
370 ],
371 },
372 {
373 setting: Common.settings.createSetting('audits2.storage_reset', 'on'),
374 description: Common.UIString('Reset storage (localStorage, IndexedDB, etc) to a clean baseline before auditing'),
375 setFlags: (flags, value) => {
376 flags.disableStorageReset = value === 'off';
377 },
378 options: [
379 {label: Common.UIString('Clear storage'), value: 'on'},
380 {label: Common.UIString('Preserve storage'), value: 'off'},
381 ],
382 },
383];
384
385Audits2.ReportSelector = class {
386 constructor() {
387 this._emptyItem = null;
388 this._comboBox = new UI.ToolbarComboBox(this._handleChange.bind(this), 'audits2-report');
389 this._comboBox.setMaxWidth(180);
390 this._comboBox.setMinWidth(140);
391 this._itemByOptionElement = new Map();
392 this._setPlaceholderState();
393 }
394
395 _setPlaceholderState() {
396 this._comboBox.setEnabled(false);
397 this._emptyItem = createElement('option');
398 this._emptyItem.label = Common.UIString('(no reports)');
399 this._comboBox.selectElement().appendChild(this._emptyItem);
400 this._comboBox.select(this._emptyItem);
401 }
402
403 /**
404 * @param {!Event} event
405 */
406 _handleChange(event) {
407 const item = this._selectedItem();
408 if (item)
409 item.select();
410 }
411
412 /**
413 * @return {!Audits2.ReportSelector.Item}
414 */
415 _selectedItem() {
416 const option = this._comboBox.selectedOption();
417 return this._itemByOptionElement.get(option);
418 }
419
420 /**
421 * @return {boolean}
422 */
423 hasCurrentSelection() {
424 return !!this._selectedItem();
425 }
426
427 /**
428 * @return {boolean}
429 */
430 hasItems() {
431 return this._itemByOptionElement.size > 0;
432 }
433
434 /**
435 * @return {!UI.ToolbarComboBox}
436 */
437 comboBox() {
438 return this._comboBox;
439 }
440
441 /**
442 * @param {!Audits2.ReportSelector.Item} item
443 */
444 prepend(item) {
445 if (this._emptyItem) {
446 this._emptyItem.remove();
447 delete this._emptyItem;
448 }
449
450 const optionEl = item.optionElement();
451 const selectEl = this._comboBox.selectElement();
452
453 this._itemByOptionElement.set(optionEl, item);
454 selectEl.insertBefore(optionEl, selectEl.firstElementChild);
455 this._comboBox.setEnabled(true);
456 this._comboBox.select(optionEl);
457 item.select();
458 }
459
460 clearAll() {
461 for (const elem of this._comboBox.options()) {
462 this._itemByOptionElement.get(elem).delete();
463 this._itemByOptionElement.delete(elem);
464 }
465
466 this._setPlaceholderState();
467 }
468
469 downloadSelected() {
470 const item = this._selectedItem();
471 item.download();
472 }
473};
474
475Audits2.ReportSelector.Item = class {
476 /**
477 * @param {!ReportRenderer.ReportJSON} lighthouseResult
478 * @param {!Element} resultsView
479 * @param {function()} showLandingCallback
480 */
481 constructor(lighthouseResult, resultsView, showLandingCallback) {
482 this._lighthouseResult = lighthouseResult;
483 this._resultsView = resultsView;
484 this._showLandingCallback = showLandingCallback;
485 /** @type {?Element} */
486 this._reportContainer = null;
487
488
489 const url = new Common.ParsedURL(lighthouseResult.url);
490 const timestamp = lighthouseResult.generatedTime;
491 this._element = createElement('option');
492 this._element.label = `${new Date(timestamp).toLocaleTimeString()} - ${url.domain()}`;
493 }
494
495 select() {
496 this._renderReport();
497 }
498
499 /**
500 * @return {!Element}
501 */
502 optionElement() {
503 return this._element;
504 }
505
506 delete() {
507 if (this._element)
508 this._element.remove();
509 this._showLandingCallback();
510 }
511
512 download() {
513 const url = new Common.ParsedURL(this._lighthouseResult.url).domain();
514 const timestamp = this._lighthouseResult.generatedTime;
515 const fileName = `${url}-${new Date(timestamp).toISO8601Compact()}.json`;
516 Workspace.fileManager.save(fileName, JSON.stringify(this._lighthouseResult), true);
517 }
518
519 _renderReport() {
520 this._resultsView.removeChildren();
521 if (this._reportContainer) {
522 this._resultsView.appendChild(this._reportContainer);
523 return;
524 }
525
526 this._reportContainer = this._resultsView.createChild('div', 'lh-vars lh-root lh-devtools');
527
528 const dom = new DOM(/** @type {!Document} */ (this._resultsView.ownerDocument));
529 const detailsRenderer = new Audits2.DetailsRenderer(dom);
530 const categoryRenderer = new Audits2.CategoryRenderer(dom, detailsRenderer);
531 categoryRenderer.setTraceArtifact(this._lighthouseResult);
532 const renderer = new Audits2.Audits2Panel.ReportRenderer(dom, categoryRenderer);
533
534 const templatesHTML = Runtime.cachedResources['audits2/lighthouse/templates.html'];
535 const templatesDOM = new DOMParser().parseFromString(templatesHTML, 'text/html');
536 if (!templatesDOM)
537 return;
538
539 renderer.setTemplateContext(templatesDOM);
540 renderer.renderReport(this._lighthouseResult, this._reportContainer);
541 }
542};
543
544Audits2.CategoryRenderer = class extends CategoryRenderer {
545 /**
546 * @override
547 * @param {!DOM} dom
548 * @param {!DetailsRenderer} detailsRenderer
549 */
550 constructor(dom, detailsRenderer) {
551 super(dom, detailsRenderer);
552 this._defaultPassTrace = null;
553 }
554
555 /**
556 * @param {!ReportRenderer.ReportJSON} lhr
557 */
558 setTraceArtifact(lhr) {
559 if (!lhr.artifacts || !lhr.artifacts.traces || !lhr.artifacts.traces.defaultPass)
560 return;
561 this._defaultPassTrace = lhr.artifacts.traces.defaultPass;
562 }
563
564 /**
565 * @override
566 * @param {!ReportRenderer.CategoryJSON} category
567 * @param {!Object<string, !ReportRenderer.GroupJSON>} groups
568 * @return {!Element}
569 */
570 renderPerformanceCategory(category, groups) {
571 const defaultPassTrace = this._defaultPassTrace;
572 const element = super.renderPerformanceCategory(category, groups);
573 if (!defaultPassTrace)
574 return element;
575
576 const timelineButton = UI.createTextButton(Common.UIString('View Trace'), onViewTraceClick, 'view-trace');
577 element.querySelector('.lh-audit-group').prepend(timelineButton);
578 return element;
579
580 async function onViewTraceClick() {
581 await UI.inspectorView.showPanel('timeline');
582 Timeline.TimelinePanel.instance().loadFromEvents(defaultPassTrace.traceEvents);
583 }
584 }
585};
586
587Audits2.DetailsRenderer = class extends DetailsRenderer {
588 /**
589 * @param {!DOM} dom
590 */
591 constructor(dom) {
592 super(dom);
593 this._onMainFrameNavigatedPromise = null;
594 }
595
596 /**
597 * @override
598 * @param {!DetailsRenderer.NodeDetailsJSON} item
599 * @return {!Element}
600 */
601 renderNode(item) {
602 const element = super.renderNode(item);
603 this._replaceWithDeferredNodeBlock(element, item);
604 return element;
605 }
606
607 /**
608 * @param {!Element} origElement
609 * @param {!DetailsRenderer.NodeDetailsJSON} detailsItem
610 */
611 async _replaceWithDeferredNodeBlock(origElement, detailsItem) {
612 const mainTarget = SDK.targetManager.mainTarget();
613 if (!this._onMainFrameNavigatedPromise) {
614 const resourceTreeModel = mainTarget.model(SDK.ResourceTreeModel);
615 this._onMainFrameNavigatedPromise = resourceTreeModel.once(SDK.ResourceTreeModel.Events.MainFrameNavigated);
616 }
617
618 await this._onMainFrameNavigatedPromise;
619
620 const domModel = mainTarget.model(SDK.DOMModel);
621 if (!detailsItem.path)
622 return;
623
624 const nodeId = await domModel.pushNodeByPathToFrontend(detailsItem.path);
625
626 if (!nodeId)
627 return;
628 const node = domModel.nodeForId(nodeId);
629 if (!node)
630 return;
631
632 const element =
633 await Common.Linkifier.linkify(node, /** @type {!Common.Linkifier.Options} */ ({title: detailsItem.snippet}));
634 origElement.title = '';
635 origElement.textContent = '';
636 origElement.appendChild(element);
637 }
638};