chrome-devtools-frontend
Version:
Chrome DevTools UI
543 lines (454 loc) • 21.1 kB
text/typescript
// 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]);
}
}