UNPKG

chrome-devtools-frontend

Version:
543 lines (454 loc) • 21.1 kB
// Copyright 2016 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as Common from '../../core/common/common.js'; import * as Host from '../../core/host/host.js'; import * as i18n from '../../core/i18n/i18n.js'; import * as Root from '../../core/root/root.js'; import * as SDK from '../../core/sdk/sdk.js'; import * as EmulationModel from '../../models/emulation/emulation.js'; import * as UI from '../../ui/legacy/legacy.js'; import * as Emulation from '../emulation/emulation.js'; import type { AuditProgressChangedEvent, PageAuditabilityChangedEvent, PageWarningsChangedEvent} from './LighthouseController.js'; import {Events, LighthouseController} from './LighthouseController.js'; import lighthousePanelStyles from './lighthousePanel.css.js'; import type {LighthouseRun} from './LighthouseProtocolService.js'; import {ProtocolService} from './LighthouseProtocolService.js'; import type {ReportJSON, RunnerResultArtifacts} from './LighthouseReporterTypes.js'; import * as LighthouseReport from '../../third_party/lighthouse/report/report.js'; import {LighthouseReportRenderer, LighthouseReportUIFeatures} from './LighthouseReportRenderer.js'; import {Item, ReportSelector} from './LighthouseReportSelector.js'; import {StartView} from './LighthouseStartView.js'; import {StartViewFR} from './LighthouseStartViewFR.js'; import {StatusView} from './LighthouseStatusView.js'; import {TimespanView} from './LighthouseTimespanView.js'; const UIStrings = { /** *@description Text that appears when user drag and drop something (for example, a file) in Lighthouse Panel */ dropLighthouseJsonHere: 'Drop `Lighthouse` JSON here', /** *@description Tooltip text that appears when hovering over the largeicon add button in the Lighthouse Panel */ performAnAudit: 'Perform an audit…', /** *@description Text to clear everything */ clearAll: 'Clear all', /** *@description Tooltip text that appears when hovering over the largeicon settings gear in show settings pane setting in start view of the audits panel */ lighthouseSettings: '`Lighthouse` settings', /** *@description Status header in the Lighthouse panel */ printing: 'Printing', /** *@description Status text in the Lighthouse panel */ thePrintPopupWindowIsOpenPlease: 'The print popup window is open. Please close it to continue.', /** *@description Text in Lighthouse Panel */ cancelling: 'Cancelling', }; const str_ = i18n.i18n.registerUIStrings('panels/lighthouse/LighthousePanel.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); let lighthousePanelInstace: LighthousePanel; type Nullable<T> = T|null; export class LighthousePanel extends UI.Panel.Panel { private readonly protocolService: ProtocolService; private readonly controller: LighthouseController; private readonly startView: StartView; private readonly statusView: StatusView; private readonly timespanView: TimespanView|null; private warningText: Nullable<string>; private unauditableExplanation: Nullable<string>; private readonly cachedRenderedReports: Map<ReportJSON, HTMLElement>; private readonly dropTarget: UI.DropTarget.DropTarget; private readonly auditResultsElement: HTMLElement; private clearButton!: UI.Toolbar.ToolbarButton; private newButton!: UI.Toolbar.ToolbarButton; private reportSelector!: ReportSelector; private settingsPane!: UI.Widget.Widget; private rightToolbar!: UI.Toolbar.Toolbar; private showSettingsPaneSetting!: Common.Settings.Setting<boolean>; private stateBefore?: { emulation: { type: EmulationModel.DeviceModeModel.Type, enabled: boolean, outlineEnabled: boolean, toolbarControlsEnabled: boolean, scale: number, device: EmulationModel.EmulatedDevices.EmulatedDevice|null, mode: EmulationModel.EmulatedDevices.Mode|null, }, network: {conditions: SDK.NetworkManager.Conditions}, }; private isLHAttached?: boolean; private currentLighthouseRun?: LighthouseRun; private constructor() { super('lighthouse'); this.protocolService = new ProtocolService(); this.controller = new LighthouseController(this.protocolService); if (Root.Runtime.experiments.isEnabled('lighthousePanelFR')) { this.startView = new StartViewFR(this.controller); this.timespanView = new TimespanView(this.controller); } else { this.startView = new StartView(this.controller); this.timespanView = null; } this.statusView = new StatusView(this.controller); this.warningText = null; this.unauditableExplanation = null; this.cachedRenderedReports = new Map(); this.dropTarget = new UI.DropTarget.DropTarget( this.contentElement, [UI.DropTarget.Type.File], i18nString(UIStrings.dropLighthouseJsonHere), this.handleDrop.bind(this)); this.controller.addEventListener(Events.PageAuditabilityChanged, this.refreshStartAuditUI.bind(this)); this.controller.addEventListener(Events.PageWarningsChanged, this.refreshWarningsUI.bind(this)); this.controller.addEventListener(Events.AuditProgressChanged, this.refreshStatusUI.bind(this)); this.controller.addEventListener(Events.RequestLighthouseTimespanStart, this.onLighthouseTimespanStart.bind(this)); this.controller.addEventListener(Events.RequestLighthouseTimespanEnd, this.onLighthouseTimespanEnd.bind(this)); this.controller.addEventListener(Events.RequestLighthouseStart, this.onLighthouseStart.bind(this)); this.controller.addEventListener(Events.RequestLighthouseCancel, this.onLighthouseCancel.bind(this)); this.renderToolbar(); this.auditResultsElement = this.contentElement.createChild('div', 'lighthouse-results-container'); this.renderStartView(); this.controller.recomputePageAuditability(); } static instance(opts = {forceNew: null}): LighthousePanel { const {forceNew} = opts; if (!lighthousePanelInstace || forceNew) { lighthousePanelInstace = new LighthousePanel(); } return lighthousePanelInstace; } static getEvents(): typeof Events { return Events; } private async onLighthouseTimespanStart(): Promise<void> { this.timespanView?.show(this.contentElement); await this.startLighthouse(); this.timespanView?.ready(); } private async onLighthouseTimespanEnd(): Promise<void> { this.timespanView?.hide(); await this.collectLighthouseResults(); } private async onLighthouseStart(): Promise<void> { await this.startLighthouse(); await this.collectLighthouseResults(); } private async onLighthouseCancel(): Promise<void> { this.timespanView?.hide(); void this.cancelLighthouse(); } private refreshWarningsUI(evt: Common.EventTarget.EventTargetEvent<PageWarningsChangedEvent>): void { // PageWarningsChanged fires multiple times during an audit, which we want to ignore. if (this.isLHAttached) { return; } this.warningText = evt.data.warning; this.startView.setWarningText(evt.data.warning); } private refreshStartAuditUI(evt: Common.EventTarget.EventTargetEvent<PageAuditabilityChangedEvent>): void { // PageAuditabilityChanged fires multiple times during an audit, which we want to ignore. if (this.isLHAttached) { return; } this.startView.refresh(); this.unauditableExplanation = evt.data.helpText; this.startView.setUnauditableExplanation(evt.data.helpText); this.startView.setStartButtonEnabled(!evt.data.helpText); } private refreshStatusUI(evt: Common.EventTarget.EventTargetEvent<AuditProgressChangedEvent>): void { this.statusView.updateStatus(evt.data.message); } private refreshToolbarUI(): void { this.clearButton.setEnabled(this.reportSelector.hasItems()); } private clearAll(): void { this.reportSelector.clearAll(); this.renderStartView(); this.refreshToolbarUI(); } private renderToolbar(): void { const lighthouseToolbarContainer = this.element.createChild('div', 'lighthouse-toolbar-container'); const toolbar = new UI.Toolbar.Toolbar('', lighthouseToolbarContainer); this.newButton = new UI.Toolbar.ToolbarButton(i18nString(UIStrings.performAnAudit), 'largeicon-add'); toolbar.appendToolbarItem(this.newButton); this.newButton.addEventListener(UI.Toolbar.ToolbarButton.Events.Click, this.renderStartView.bind(this)); toolbar.appendSeparator(); this.reportSelector = new ReportSelector(() => this.renderStartView()); toolbar.appendToolbarItem(this.reportSelector.comboBox()); this.clearButton = new UI.Toolbar.ToolbarButton(i18nString(UIStrings.clearAll), 'largeicon-clear'); toolbar.appendToolbarItem(this.clearButton); this.clearButton.addEventListener(UI.Toolbar.ToolbarButton.Events.Click, this.clearAll.bind(this)); this.settingsPane = new UI.Widget.HBox(); this.settingsPane.show(this.contentElement); this.settingsPane.element.classList.add('lighthouse-settings-pane'); this.settingsPane.element.appendChild(this.startView.settingsToolbar().element); this.showSettingsPaneSetting = Common.Settings.Settings.instance().createSetting( 'lighthouseShowSettingsToolbar', false, Common.Settings.SettingStorageType.Synced); this.rightToolbar = new UI.Toolbar.Toolbar('', lighthouseToolbarContainer); this.rightToolbar.appendSeparator(); this.rightToolbar.appendToolbarItem(new UI.Toolbar.ToolbarSettingToggle( this.showSettingsPaneSetting, 'largeicon-settings-gear', i18nString(UIStrings.lighthouseSettings))); this.showSettingsPaneSetting.addChangeListener(this.updateSettingsPaneVisibility.bind(this)); this.updateSettingsPaneVisibility(); this.refreshToolbarUI(); } private updateSettingsPaneVisibility(): void { this.settingsPane.element.classList.toggle('hidden', !this.showSettingsPaneSetting.get()); } private toggleSettingsDisplay(show: boolean): void { this.rightToolbar.element.classList.toggle('hidden', !show); this.settingsPane.element.classList.toggle('hidden', !show); this.updateSettingsPaneVisibility(); } private renderStartView(): void { this.auditResultsElement.removeChildren(); this.statusView.hide(); this.reportSelector.selectNewReport(); this.contentElement.classList.toggle('in-progress', false); this.startView.show(this.contentElement); this.toggleSettingsDisplay(true); this.startView.setUnauditableExplanation(this.unauditableExplanation); this.startView.setStartButtonEnabled(!this.unauditableExplanation); if (!this.unauditableExplanation) { this.startView.focusStartButton(); } this.startView.setWarningText(this.warningText); this.newButton.setEnabled(false); this.refreshToolbarUI(); this.setDefaultFocusedChild(this.startView); } private renderStatusView(inspectedURL: string): void { this.contentElement.classList.toggle('in-progress', true); this.statusView.setInspectedURL(inspectedURL); this.statusView.show(this.contentElement); } private beforePrint(): void { this.statusView.show(this.contentElement); this.statusView.toggleCancelButton(false); this.statusView.renderText(i18nString(UIStrings.printing), i18nString(UIStrings.thePrintPopupWindowIsOpenPlease)); } private afterPrint(): void { this.statusView.hide(); this.statusView.toggleCancelButton(true); } private renderReport(lighthouseResult: ReportJSON, artifacts?: RunnerResultArtifacts): void { this.toggleSettingsDisplay(false); this.contentElement.classList.toggle('in-progress', false); this.startView.hideWidget(); this.statusView.hide(); this.auditResultsElement.removeChildren(); this.newButton.setEnabled(true); this.refreshToolbarUI(); const cachedRenderedReport = this.cachedRenderedReports.get(lighthouseResult); if (cachedRenderedReport) { this.auditResultsElement.appendChild(cachedRenderedReport); return; } const reportContainer = this.auditResultsElement.createChild('div', 'lh-vars lh-root lh-devtools'); // @ts-ignore Expose LHR on DOM for e2e tests reportContainer._lighthouseResultForTesting = lighthouseResult; const dom = new LighthouseReport.DOM(this.auditResultsElement.ownerDocument as Document, reportContainer); const renderer = new LighthouseReportRenderer(dom) as LighthouseReport.ReportRenderer; const el = renderer.renderReport(lighthouseResult, reportContainer); // Linkifying requires the target be loaded. Do not block the report // from rendering, as this is just an embellishment and the main target // could take awhile to load. void this.waitForMainTargetLoad().then(() => { void LighthouseReportRenderer.linkifyNodeDetails(el); void LighthouseReportRenderer.linkifySourceLocationDetails(el); }); LighthouseReportRenderer.handleDarkMode(el); // eslint-disable-next-line @typescript-eslint/no-explicit-any const features = new LighthouseReportUIFeatures(dom) as any; features.setBeforePrint(this.beforePrint.bind(this)); features.setAfterPrint(this.afterPrint.bind(this)); LighthouseReportRenderer.addViewTraceButton(el, features, artifacts); features.initFeatures(lighthouseResult); this.cachedRenderedReports.set(lighthouseResult, reportContainer); } private async waitForMainTargetLoad(): Promise<void> { const mainTarget = SDK.TargetManager.TargetManager.instance().mainTarget(); if (!mainTarget) { return; } const resourceTreeModel = mainTarget.model(SDK.ResourceTreeModel.ResourceTreeModel); if (!resourceTreeModel) { return; } await resourceTreeModel.once(SDK.ResourceTreeModel.Events.Load); } private buildReportUI(lighthouseResult: ReportJSON, artifacts?: RunnerResultArtifacts): void { if (lighthouseResult === null) { return; } const optionElement = new Item( lighthouseResult, () => this.renderReport(lighthouseResult, artifacts), this.renderStartView.bind(this)); this.reportSelector.prepend(optionElement); this.refreshToolbarUI(); this.renderReport(lighthouseResult); } private handleDrop(dataTransfer: DataTransfer): void { const items = dataTransfer.items; if (!items.length) { return; } const item = items[0]; if (item.kind === 'file') { const file = items[0].getAsFile(); if (!file) { return; } const reader = new FileReader(); reader.onload = (): void => this.loadedFromFile(reader.result as string); reader.readAsText(file); } } private loadedFromFile(report: string): void { const data = JSON.parse(report); if (!data['lighthouseVersion']) { return; } this.buildReportUI(data as ReportJSON); } private async startLighthouse(): Promise<void> { Host.userMetrics.actionTaken(Host.UserMetrics.Action.LighthouseStarted); try { const inspectedURL = await this.controller.getInspectedURL({force: true}); const categoryIDs = this.controller.getCategoryIDs(); const flags = this.controller.getFlags(); this.currentLighthouseRun = {inspectedURL, categoryIDs, flags}; await this.setupEmulationAndProtocolConnection(); if (flags.mode === 'timespan') { await this.protocolService.startTimespan(this.currentLighthouseRun); } } catch (err) { await this.restoreEmulationAndProtocolConnection(); if (err instanceof Error) { this.statusView.renderBugReport(err); } } } private async collectLighthouseResults(): Promise<void> { try { if (!this.currentLighthouseRun) { throw new Error('Lighthouse is not started'); } this.renderStatusView(this.currentLighthouseRun.inspectedURL); const lighthouseResponse = await this.protocolService.collectLighthouseResults(this.currentLighthouseRun); if (lighthouseResponse && lighthouseResponse.fatal) { const error = new Error(lighthouseResponse.message); error.stack = lighthouseResponse.stack; throw error; } if (!lighthouseResponse) { throw new Error('Auditing failed to produce a result'); } Host.userMetrics.actionTaken(Host.UserMetrics.Action.LighthouseFinished); await this.restoreEmulationAndProtocolConnection(); this.buildReportUI(lighthouseResponse.lhr, lighthouseResponse.artifacts); // Give focus to the new audit button when completed this.newButton.element.focus(); } catch (err) { await this.restoreEmulationAndProtocolConnection(); if (err instanceof Error) { this.statusView.renderBugReport(err); } } finally { this.currentLighthouseRun = undefined; } } private async cancelLighthouse(): Promise<void> { this.currentLighthouseRun = undefined; this.statusView.updateStatus(i18nString(UIStrings.cancelling)); await this.restoreEmulationAndProtocolConnection(); this.renderStartView(); } /** * We set the device emulation on the DevTools-side for two reasons: * 1. To workaround some odd device metrics emulation bugs like occuluding viewports * 2. To get the attractive device outline * * We also set flags.internalDisableDeviceScreenEmulation = true to let LH only apply UA emulation */ private async setupEmulationAndProtocolConnection(): Promise<void> { const flags = this.controller.getFlags(); const emulationModel = EmulationModel.DeviceModeModel.DeviceModeModel.instance(); this.stateBefore = { emulation: { type: emulationModel.type(), enabled: emulationModel.enabledSetting().get(), outlineEnabled: emulationModel.deviceOutlineSetting().get(), toolbarControlsEnabled: emulationModel.toolbarControlsEnabledSetting().get(), scale: emulationModel.scaleSetting().get(), device: emulationModel.device(), mode: emulationModel.mode(), }, network: {conditions: SDK.NetworkManager.MultitargetNetworkManager.instance().networkConditions()}, }; emulationModel.toolbarControlsEnabledSetting().set(false); if ('emulatedFormFactor' in flags && flags.emulatedFormFactor === 'desktop') { emulationModel.enabledSetting().set(false); emulationModel.emulate(EmulationModel.DeviceModeModel.Type.None, null, null); } else if (flags.emulatedFormFactor === 'mobile') { emulationModel.enabledSetting().set(true); emulationModel.deviceOutlineSetting().set(true); for (const device of EmulationModel.EmulatedDevices.EmulatedDevicesList.instance().standard()) { if (device.title === 'Moto G4') { emulationModel.emulate(EmulationModel.DeviceModeModel.Type.Device, device, device.modes[0], 1); } } } await this.protocolService.attach(); this.isLHAttached = true; } private async restoreEmulationAndProtocolConnection(): Promise<void> { if (!this.isLHAttached) { return; } this.isLHAttached = false; await this.protocolService.detach(); if (this.stateBefore) { const emulationModel = EmulationModel.DeviceModeModel.DeviceModeModel.instance(); // Detaching a session after overriding device metrics will prevent other sessions from overriding device metrics in the future. // A workaround is to call "Emulation.clearDeviceMetricOverride" which is the result of the next line. // https://bugs.chromium.org/p/chromium/issues/detail?id=1337089 emulationModel.emulate(EmulationModel.DeviceModeModel.Type.None, null, null); const {type, enabled, outlineEnabled, toolbarControlsEnabled, scale, device, mode} = this.stateBefore.emulation; emulationModel.enabledSetting().set(enabled); emulationModel.deviceOutlineSetting().set(outlineEnabled); emulationModel.toolbarControlsEnabledSetting().set(toolbarControlsEnabled); // `emulate` will ignore the `scale` parameter for responsive emulation. // In this case we can just set it here. if (type === EmulationModel.DeviceModeModel.Type.Responsive) { emulationModel.scaleSetting().set(scale); } emulationModel.emulate(type, device, mode, scale); SDK.NetworkManager.MultitargetNetworkManager.instance().setNetworkConditions(this.stateBefore.network.conditions); delete this.stateBefore; } Emulation.InspectedPagePlaceholder.InspectedPagePlaceholder.instance().update(true); const mainTarget = SDK.TargetManager.TargetManager.instance().mainTarget(); if (!mainTarget) { return; } const resourceTreeModel = mainTarget.model(SDK.ResourceTreeModel.ResourceTreeModel); if (!resourceTreeModel) { return; } // reload to reset the page state const inspectedURL = await this.controller.getInspectedURL(); await resourceTreeModel.navigate(inspectedURL); } wasShown(): void { super.wasShown(); this.registerCSSFiles([lighthousePanelStyles]); } }