blob: f9d670ce5fbc2b8cc29eedebe5497b0d8348f86d [file] [log] [blame]
// Copyright (c) 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.
/**
* @implements {SDK.SDKModelObserver<!SDK.ServiceWorkerManager>}
* @unrestricted
*/
Audits2.Audits2Panel = class extends UI.PanelWithSidebar {
constructor() {
super('audits2');
this.registerRequiredCSS('audits2/audits2Panel.css');
this.registerRequiredCSS('audits2/lighthouse/report-styles.css');
this._protocolService = new Audits2.ProtocolService();
this._protocolService.registerStatusCallback(msg => this._updateStatus(Common.UIString(msg)));
var toolbar = new UI.Toolbar('', this.panelSidebarElement());
var newButton = new UI.ToolbarButton(Common.UIString('New audit\u2026'), 'largeicon-add');
toolbar.appendToolbarItem(newButton);
newButton.addEventListener(UI.ToolbarButton.Events.Click, this._showLauncherUI.bind(this));
var deleteButton = new UI.ToolbarButton(Common.UIString('Delete audit'), 'largeicon-delete');
toolbar.appendToolbarItem(deleteButton);
deleteButton.addEventListener(UI.ToolbarButton.Events.Click, this._deleteSelected.bind(this));
toolbar.appendSeparator();
var clearButton = new UI.ToolbarButton(Common.UIString('Clear all'), 'largeicon-clear');
toolbar.appendToolbarItem(clearButton);
clearButton.addEventListener(UI.ToolbarButton.Events.Click, this._clearAll.bind(this));
this._treeOutline = new UI.TreeOutlineInShadow();
this._treeOutline.registerRequiredCSS('audits2/lighthouse/report-styles.css');
this._treeOutline.registerRequiredCSS('audits2/audits2Tree.css');
this.panelSidebarElement().appendChild(this._treeOutline.element);
this._dropTarget = new UI.DropTarget(
this.contentElement, [UI.DropTarget.Types.Files], Common.UIString('Drop audit file here'),
this._handleDrop.bind(this));
for (var preset of Audits2.Audits2Panel.Presets)
preset.setting.addChangeListener(this._updateStartButtonEnabled.bind(this));
this._showLandingPage();
SDK.targetManager.observeModels(SDK.ServiceWorkerManager, this);
}
/**
* @override
* @param {!SDK.ServiceWorkerManager} serviceWorkerManager
*/
modelAdded(serviceWorkerManager) {
if (this._manager)
return;
this._manager = serviceWorkerManager;
this._serviceWorkerListeners = [
this._manager.addEventListener(
SDK.ServiceWorkerManager.Events.RegistrationUpdated, this._updateStartButtonEnabled, this),
this._manager.addEventListener(
SDK.ServiceWorkerManager.Events.RegistrationDeleted, this._updateStartButtonEnabled, this),
];
this._updateStartButtonEnabled();
}
/**
* @override
* @param {!SDK.ServiceWorkerManager} serviceWorkerManager
*/
modelRemoved(serviceWorkerManager) {
if (!this._manager || this._manager !== serviceWorkerManager)
return;
Common.EventTarget.removeEventListeners(this._serviceWorkerListeners);
this._manager = null;
this._serviceWorkerListeners = null;
this._updateStartButtonEnabled();
}
/**
* @return {boolean}
*/
_hasActiveServiceWorker() {
if (!this._manager)
return false;
var inspectedURL = SDK.targetManager.mainTarget().inspectedURL().asParsedURL();
var inspectedOrigin = inspectedURL && inspectedURL.securityOrigin();
for (var registration of this._manager.registrations().values()) {
if (registration.securityOrigin !== inspectedOrigin)
continue;
for (var version of registration.versions.values()) {
if (version.controlledClients.length > 1)
return true;
}
}
return false;
}
/**
* @return {boolean}
*/
_hasAtLeastOneCategory() {
return Audits2.Audits2Panel.Presets.some(preset => preset.setting.get());
}
_updateStartButtonEnabled() {
var hasActiveServiceWorker = this._hasActiveServiceWorker();
var hasAtLeastOneCategory = this._hasAtLeastOneCategory();
var isDisabled = hasActiveServiceWorker || !hasAtLeastOneCategory;
if (this._dialogHelpText && hasActiveServiceWorker) {
this._dialogHelpText.textContent = Common.UIString(
'Multiple tabs are being controlled by the same service worker. ' +
'Close your other tabs on the same origin to audit this page.');
}
if (this._dialogHelpText && !hasAtLeastOneCategory)
this._dialogHelpText.textContent = Common.UIString('At least one category must be selected.');
if (this._dialogHelpText)
this._dialogHelpText.classList.toggle('hidden', !isDisabled);
if (this._startButton)
this._startButton.disabled = isDisabled;
}
_clearAll() {
this._treeOutline.removeChildren();
this._showLandingPage();
}
_deleteSelected() {
var selection = this._treeOutline.selectedTreeElement;
if (selection)
selection._deleteItem();
}
_showLandingPage() {
if (this._treeOutline.rootElement().childCount())
return;
this.mainElement().removeChildren();
var landingPage = this.mainElement().createChild('div', 'vbox audits2-landing-page');
var landingCenter = landingPage.createChild('div', 'vbox audits2-landing-center');
landingCenter.createChild('div', 'audits2-logo');
var text = landingCenter.createChild('div', 'audits2-landing-text');
text.createChild('span', 'audits2-landing-bold-text').textContent = Common.UIString('Audits');
text.createChild('span').textContent = Common.UIString(
' help you identify and fix common problems that affect' +
' your site\'s performance, accessibility, and user experience. ');
var link = text.createChild('span', 'link');
link.textContent = Common.UIString('Learn more');
link.addEventListener(
'click', () => InspectorFrontendHost.openInNewTab('https://developers.google.com/web/tools/lighthouse/'));
var newButton = UI.createTextButton(
Common.UIString('Perform an audit\u2026'), this._showLauncherUI.bind(this), '', true /* primary */);
landingCenter.appendChild(newButton);
this.setDefaultFocusedElement(newButton);
}
_showLauncherUI() {
this._dialog = new UI.Dialog();
this._dialog.setOutsideClickCallback(event => event.consume(true));
var root = UI.createShadowRootWithCoreStyles(this._dialog.contentElement, 'audits2/audits2Dialog.css');
var auditsViewElement = root.createChild('div', 'audits2-view');
var closeButton = auditsViewElement.createChild('div', 'dialog-close-button', 'dt-close-button');
closeButton.addEventListener('click', () => this._cancelAndClose());
var uiElement = auditsViewElement.createChild('div');
var headerElement = uiElement.createChild('header');
this._headerTitleElement = headerElement.createChild('p');
this._headerTitleElement.textContent = Common.UIString('Audits to perform');
uiElement.appendChild(headerElement);
this._auditSelectorForm = uiElement.createChild('form', 'audits2-form');
for (var preset of Audits2.Audits2Panel.Presets) {
preset.setting.setTitle(preset.title);
var checkbox = new UI.ToolbarSettingCheckbox(preset.setting);
var row = this._auditSelectorForm.createChild('div', 'vbox audits2-launcher-row');
row.appendChild(checkbox.element);
row.createChild('span', 'audits2-launcher-description dimmed').textContent = preset.description;
}
this._statusView = this._createStatusView(uiElement);
this._dialogHelpText = uiElement.createChild('div', 'audits2-dialog-help-text');
var buttonsRow = uiElement.createChild('div', 'audits2-dialog-buttons hbox');
this._startButton =
UI.createTextButton(Common.UIString('Run audit'), this._start.bind(this), '', true /* primary */);
this._updateStartButtonEnabled();
buttonsRow.appendChild(this._startButton);
this._cancelButton = UI.createTextButton(Common.UIString('Cancel'), this._cancel.bind(this));
buttonsRow.appendChild(this._cancelButton);
this._dialog.setSizeBehavior(UI.GlassPane.SizeBehavior.SetExactWidthMaxHeight);
this._dialog.setMaxContentSize(new UI.Size(500, 400));
this._dialog.show(this.mainElement());
auditsViewElement.tabIndex = 0;
auditsViewElement.focus();
}
/**
* @param {!Element} launcherUIElement
* @return {!Element}
*/
_createStatusView(launcherUIElement) {
var statusView = launcherUIElement.createChild('div', 'audits2-status vbox hidden');
this._statusIcon = statusView.createChild('div', 'icon');
this._statusElement = statusView.createChild('div');
this._updateStatus(Common.UIString('Loading...'));
return statusView;
}
/**
* @return {!Promise<undefined>}
*/
_updateInspectedURL() {
var mainTarget = SDK.targetManager.mainTarget();
var runtimeModel = mainTarget.model(SDK.RuntimeModel);
var executionContext = runtimeModel && runtimeModel.defaultExecutionContext();
this._inspectedURL = mainTarget.inspectedURL();
if (!executionContext)
return Promise.resolve();
return new Promise(resolve => {
executionContext.evaluate('window.location.href', 'audits', false, false, true, false, false, (object, err) => {
if (!err && object) {
this._inspectedURL = object.value;
object.release();
}
resolve();
});
});
}
_start() {
var emulationModel = self.singleton(Emulation.DeviceModeModel);
this._emulationEnabledBefore = emulationModel.enabledSetting().get();
this._emulationOutlineEnabledBefore = emulationModel.deviceOutlineSetting().get();
emulationModel.enabledSetting().set(true);
emulationModel.deviceOutlineSetting().set(true);
emulationModel.toolbarControlsEnabledSetting().set(false);
for (var device of Emulation.EmulatedDevicesList.instance().standard()) {
if (device.title === 'Nexus 5X')
emulationModel.emulate(Emulation.DeviceModeModel.Type.Device, device, device.modes[0], 1);
}
this._dialog.setCloseOnEscape(false);
var categoryIDs = [];
for (var preset of Audits2.Audits2Panel.Presets) {
if (preset.setting.get())
categoryIDs.push(preset.configID);
}
return Promise.resolve()
.then(_ => this._updateInspectedURL())
.then(_ => this._protocolService.attach())
.then(_ => {
this._auditRunning = true;
this._updateButton();
this._updateStatus(Common.UIString('Loading\u2026'));
})
.then(_ => this._protocolService.startLighthouse(this._inspectedURL, categoryIDs))
.then(lighthouseResult => {
if (lighthouseResult && lighthouseResult.fatal) {
const error = new Error(lighthouseResult.message);
error.stack = lighthouseResult.stack;
throw error;
}
return this._stopAndReattach().then(() => this._buildReportUI(lighthouseResult));
})
.catch(err => {
if (err instanceof Error)
this._renderBugReport(err);
});
}
_hideDialog() {
if (!this._dialog)
return;
this._dialog.hide();
var emulationModel = self.singleton(Emulation.DeviceModeModel);
emulationModel.enabledSetting().set(this._emulationEnabledBefore);
emulationModel.deviceOutlineSetting().set(this._emulationOutlineEnabledBefore);
emulationModel.toolbarControlsEnabledSetting().set(true);
delete this._dialog;
delete this._statusView;
delete this._statusIcon;
delete this._statusElement;
delete this._startButton;
delete this._cancelButton;
delete this._auditSelectorForm;
delete this._headerTitleElement;
delete this._emulationEnabledBefore;
delete this._emulationOutlineEnabledBefore;
}
_cancelAndClose() {
this._cancel();
this._hideDialog();
}
_cancel() {
if (this._auditRunning) {
this._updateStatus(Common.UIString('Cancelling\u2026'));
this._stopAndReattach();
} else {
this._hideDialog();
}
}
_updateButton() {
if (!this._dialog)
return;
this._startButton.classList.toggle('hidden', this._auditRunning);
this._startButton.disabled = this._auditRunning;
this._statusView.classList.toggle('hidden', !this._auditRunning);
this._auditSelectorForm.classList.toggle('hidden', this._auditRunning);
if (this._auditRunning)
this._headerTitleElement.textContent = Common.UIString('Auditing your web page \u2026');
else
this._headerTitleElement.textContent = Common.UIString('Audits to perform');
}
/**
* @param {string} statusMessage
*/
_updateStatus(statusMessage) {
if (!this._dialog)
return;
this._statusElement.textContent = statusMessage;
}
/**
* @return {!Promise<undefined>}
*/
async _stopAndReattach() {
await this._protocolService.detach();
Emulation.InspectedPagePlaceholder.instance().update(true);
var resourceTreeModel = SDK.targetManager.mainTarget().model(SDK.ResourceTreeModel);
// reload to reset the page state
await resourceTreeModel.navigate(this._inspectedURL);
this._auditRunning = false;
this._updateButton();
}
/**
* @param {!ReportRenderer.ReportJSON} lighthouseResult
*/
_buildReportUI(lighthouseResult) {
if (lighthouseResult === null) {
this._updateStatus(Common.UIString('Auditing failed.'));
return;
}
var treeElement =
new Audits2.Audits2Panel.TreeElement(lighthouseResult, this.mainElement(), this._showLandingPage.bind(this));
this._treeOutline.appendChild(treeElement);
treeElement._populate();
treeElement.select();
this._hideDialog();
}
/**
* @param {!Error} err
*/
_renderBugReport(err) {
console.error(err);
this._statusElement.textContent = '';
this._statusIcon.classList.add('error');
this._statusElement.createTextChild(Common.UIString('Ah, sorry! We ran into an error: '));
this._statusElement.createChild('em').createTextChild(err.message);
this._createBugReportLink(err, this._statusElement);
}
/**
* @param {!Error} err
* @param {!Element} parentElem
*/
_createBugReportLink(err, parentElem) {
var baseURI = 'https://github.com/GoogleChrome/lighthouse/issues/new?';
var title = encodeURI('title=DevTools Error: ' + err.message.substring(0, 60));
var issueBody = `
**Initial URL**: ${this._inspectedURL}
**Chrome Version**: ${navigator.userAgent.match(/Chrome\/(\S+)/)[1]}
**Error Message**: ${err.message}
**Stack Trace**:
\`\`\`
${err.stack}
\`\`\`
`;
var body = '&body=' + encodeURI(issueBody.trim());
var reportErrorEl = parentElem.createChild('a', 'audits2-link audits2-report-error');
reportErrorEl.href = baseURI + title + body;
reportErrorEl.textContent = Common.UIString('Report this bug');
reportErrorEl.target = '_blank';
}
/**
* @param {!DataTransfer} dataTransfer
*/
_handleDrop(dataTransfer) {
var items = dataTransfer.items;
if (!items.length)
return;
var item = items[0];
if (item.kind === 'file') {
var entry = items[0].webkitGetAsEntry();
if (!entry.isFile)
return;
entry.file(file => {
var reader = new FileReader();
reader.onload = () => this._loadedFromFile(/** @type {string} */ (reader.result));
reader.readAsText(file);
});
}
}
/**
* @param {string} profile
*/
_loadedFromFile(profile) {
var data = JSON.parse(profile);
if (!data['lighthouseVersion'])
return;
this._buildReportUI(/** @type {!ReportRenderer.ReportJSON} */ (data));
}
};
/**
* @override
*/
Audits2.Audits2Panel.ReportRenderer = class extends ReportRenderer {
/**
* Provides empty element for left nav
* @override
* @returns {!DocumentFragment}
*/
_renderReportNav() {
return createDocumentFragment();
}
/**
* @param {!ReportRenderer.ReportJSON} report
* @override
* @return {!DocumentFragment}
*/
_renderReportHeader(report) {
return createDocumentFragment();
}
};
class ReportUIFeatures {
/**
* @param {!ReportRenderer.ReportJSON} report
*/
initFeatures(report) {
}
}
/** @typedef {{setting: !Common.Setting, configID: string, title: string, description: string}} */
Audits2.Audits2Panel.Preset;
/** @type {!Array.<!Audits2.Audits2Panel.Preset>} */
Audits2.Audits2Panel.Presets = [
// configID maps to Lighthouse's Object.keys(config.categories)[0] value
{
setting: Common.settings.createSetting('audits2.cat_pwa', true),
configID: 'pwa',
title: 'Progressive Web App',
description: 'Does this page meet the standard of a Progressive Web App'
},
{
setting: Common.settings.createSetting('audits2.cat_perf', true),
configID: 'performance',
title: 'Performance',
description: 'How long does this app take to show content and become usable'
},
{
setting: Common.settings.createSetting('audits2.cat_best_practices', true),
configID: 'best-practices',
title: 'Best practices',
description: 'Does this page follow best practices for modern web development'
},
{
setting: Common.settings.createSetting('audits2.cat_a11y', true),
configID: 'accessibility',
title: 'Accessibility',
description: 'Is this page usable by people with disabilities or impairments'
},
];
Audits2.ProtocolService = class extends Common.Object {
constructor() {
super();
/** @type {?Protocol.InspectorBackend.Connection} */
this._rawConnection = null;
/** @type {?Services.ServiceManager.Service} */
this._backend = null;
/** @type {?Promise} */
this._backendPromise = null;
/** @type {?function(string)} */
this._status = null;
}
/**
* @return {!Promise<undefined>}
*/
attach() {
return SDK.targetManager.interceptMainConnection(this._dispatchProtocolMessage.bind(this)).then(rawConnection => {
this._rawConnection = rawConnection;
this._rawConnection.sendMessage(
JSON.stringify({id: 0, method: 'Input.setIgnoreInputEvents', params: {ignore: true}}));
});
}
/**
* @param {string} inspectedURL
* @param {!Array<string>} categoryIDs
* @return {!Promise<!ReportRenderer.ReportJSON>}
*/
startLighthouse(inspectedURL, categoryIDs) {
return this._send('start', {url: inspectedURL, categoryIDs});
}
/**
* @return {!Promise<!Object|undefined>}
*/
detach() {
return Promise.resolve().then(() => this._send('stop')).then(() => this._backend.dispose()).then(() => {
delete this._backend;
delete this._backendPromise;
return this._rawConnection.disconnect();
});
}
/**
* @param {function (string): undefined} callback
*/
registerStatusCallback(callback) {
this._status = callback;
}
/**
* @param {string} message
*/
_dispatchProtocolMessage(message) {
this._send('dispatchProtocolMessage', {message: message});
}
_initWorker() {
this._backendPromise =
Services.serviceManager.createAppService('audits2_worker', 'Audits2Service', false).then(backend => {
if (this._backend)
return;
this._backend = backend;
this._backend.on('statusUpdate', result => this._status(result.message));
this._backend.on('sendProtocolMessage', result => this._sendProtocolMessage(result.message));
});
}
/**
* @param {string} message
*/
_sendProtocolMessage(message) {
this._rawConnection.sendMessage(message);
}
/**
* @param {string} method
* @param {!Object=} params
* @return {!Promise<!ReportRenderer.ReportJSON>}
*/
_send(method, params) {
if (!this._backendPromise)
this._initWorker();
return this._backendPromise.then(_ => this._backend.send(method, params));
}
};
Audits2.Audits2Panel.TreeElement = class extends UI.TreeElement {
/**
* @param {!ReportRenderer.ReportJSON} lighthouseResult
* @param {!Element} resultsView
* @param {function()} showLandingCallback
*/
constructor(lighthouseResult, resultsView, showLandingCallback) {
super('', false);
this._lighthouseResult = lighthouseResult;
this._resultsView = resultsView;
this._showLandingCallback = showLandingCallback;
/** @type {?Element} */
this._reportContainer = null;
var url = new Common.ParsedURL(lighthouseResult.url);
var timestamp = lighthouseResult.generatedTime;
var titleElement = this.titleElement();
titleElement.classList.add('audits2-report-tree-item');
titleElement.createChild('div').textContent = url.domain();
titleElement.createChild('span', 'dimmed').textContent = new Date(timestamp).toLocaleString();
this.listItemElement.addEventListener('contextmenu', this._handleContextMenuEvent.bind(this), false);
}
_populate() {
for (var category of this._lighthouseResult.reportCategories) {
var treeElement = new Audits2.Audits2Panel.TreeSubElement(category.id, category.name, category.score);
this.appendChild(treeElement);
}
}
/**
* @override
* @param {boolean=} selectedByUser
* @return {boolean}
*/
onselect(selectedByUser) {
this._renderReport();
return true;
}
/**
* @override
*/
ondelete() {
this._deleteItem();
return true;
}
_deleteItem() {
this.treeOutline.removeChild(this);
this._showLandingCallback();
}
/**
* @param {!Event} event
*/
_handleContextMenuEvent(event) {
var contextMenu = new UI.ContextMenu(event);
contextMenu.appendItem(Common.UIString('Save as\u2026'), () => {
var url = new Common.ParsedURL(this._lighthouseResult.url).domain();
var timestamp = this._lighthouseResult.generatedTime;
var fileName = `${url}-${new Date(timestamp).toISO8601Compact()}.json`;
Workspace.fileManager.save(fileName, JSON.stringify(this._lighthouseResult), true);
});
contextMenu.appendItem(Common.UIString('Delete'), () => this._deleteItem());
contextMenu.show();
}
/**
* @override
*/
onunbind() {
if (this._reportContainer && this._reportContainer.parentElement)
this._reportContainer.remove();
}
_renderReport() {
this._resultsView.removeChildren();
if (this._reportContainer) {
this._resultsView.appendChild(this._reportContainer);
return;
}
this._reportContainer = this._resultsView.createChild('div', 'report-container lh-vars lh-root lh-devtools');
var dom = new DOM(/** @type {!Document} */ (this._resultsView.ownerDocument));
var detailsRenderer = new Audits2.DetailsRenderer(dom);
var categoryRenderer = new CategoryRenderer(dom, detailsRenderer);
var renderer = new Audits2.Audits2Panel.ReportRenderer(dom, categoryRenderer);
var templatesHTML = Runtime.cachedResources['audits2/lighthouse/templates.html'];
var templatesDOM = new DOMParser().parseFromString(templatesHTML, 'text/html');
if (!templatesDOM)
return;
renderer.setTemplateContext(templatesDOM);
renderer.renderReport(this._lighthouseResult, this._reportContainer);
}
};
Audits2.Audits2Panel.TreeSubElement = class extends UI.TreeElement {
/**
* @param {string} id
* @param {string} name
* @param {number} score
*/
constructor(id, name, score) {
super('');
this._id = id;
this.listItemElement.textContent = name;
var label = Util.calculateRating(score);
var subtitleElement = this.listItemElement.createChild('span', 'lh-vars audits2-tree-subtitle-' + label);
subtitleElement.textContent = String(Math.round(score));
}
/**
* @override
* @return {boolean}
*/
onselect() {
this.parent._renderReport();
var node = this.parent._resultsView.querySelector('.lh-category[id=' + this._id + ']');
if (node) {
node.scrollIntoView(true);
return true;
}
return false;
}
};
Audits2.DetailsRenderer = class extends DetailsRenderer {
/**
* @param {!DOM} dom
*/
constructor(dom) {
super(dom);
this._onMainFrameNavigatedPromise = null;
}
/**
* @override
* @param {!DetailsRenderer.NodeDetailsJSON} item
* @return {!Element}
*/
renderNode(item) {
var element = super.renderNode(item);
this._replaceWithDeferredNodeBlock(element, item);
return element;
}
/**
* @param {!Element} origElement
* @param {!DetailsRenderer.NodeDetailsJSON} detailsItem
*/
async _replaceWithDeferredNodeBlock(origElement, detailsItem) {
var mainTarget = SDK.targetManager.mainTarget();
if (!this._onMainFrameNavigatedPromise) {
var resourceTreeModel = mainTarget.model(SDK.ResourceTreeModel);
this._onMainFrameNavigatedPromise = resourceTreeModel.once(SDK.ResourceTreeModel.Events.MainFrameNavigated);
}
await this._onMainFrameNavigatedPromise;
var domModel = mainTarget.model(SDK.DOMModel);
if (!detailsItem.path)
return;
var nodeId = await domModel.pushNodeByPathToFrontend(detailsItem.path);
if (!nodeId)
return;
var node = domModel.nodeForId(nodeId);
if (!node)
return;
var element = Components.DOMPresentationUtils.linkifyNodeReference(node, undefined, detailsItem.snippet);
origElement.title = '';
origElement.textContent = '';
origElement.appendChild(element);
}
};