chrome-devtools-frontend
Version:
Chrome DevTools UI
1,337 lines (1,207 loc) • 180 kB
JavaScript
/**
* @license
* Copyright 2017 The Lighthouse Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
/* globals self, URL */
/** @typedef {import('./i18n')} I18n */
const ELLIPSIS = '\u2026';
const NBSP = '\xa0';
const PASS_THRESHOLD = 0.9;
const SCREENSHOT_PREFIX = 'data:image/jpeg;base64,';
const RATINGS = {
PASS: {label: 'pass', minScore: PASS_THRESHOLD},
AVERAGE: {label: 'average', minScore: 0.5},
FAIL: {label: 'fail'},
ERROR: {label: 'error'},
};
// 25 most used tld plus one domains (aka public suffixes) from http archive.
// @see https://github.com/GoogleChrome/lighthouse/pull/5065#discussion_r191926212
// The canonical list is https://publicsuffix.org/learn/ but we're only using subset to conserve bytes
const listOfTlds = [
'com', 'co', 'gov', 'edu', 'ac', 'org', 'go', 'gob', 'or', 'net', 'in', 'ne', 'nic', 'gouv',
'web', 'spb', 'blog', 'jus', 'kiev', 'mil', 'wi', 'qc', 'ca', 'bel', 'on',
];
class Util {
static get PASS_THRESHOLD() {
return PASS_THRESHOLD;
}
static get MS_DISPLAY_VALUE() {
return `%10d${NBSP}ms`;
}
/**
* Returns a new LHR that's reshaped for slightly better ergonomics within the report rendereer.
* Also, sets up the localized UI strings used within renderer and makes changes to old LHRs to be
* compatible with current renderer.
* The LHR passed in is not mutated.
* TODO(team): we all agree the LHR shape change is technical debt we should fix
* @param {LH.Result} result
* @return {LH.ReportResult}
*/
static prepareReportResult(result) {
// If any mutations happen to the report within the renderers, we want the original object untouched
const clone = /** @type {LH.ReportResult} */ (JSON.parse(JSON.stringify(result)));
// If LHR is older (≤3.0.3), it has no locale setting. Set default.
if (!clone.configSettings.locale) {
clone.configSettings.locale = 'en';
}
if (!clone.configSettings.formFactor) {
// @ts-expect-error fallback handling for emulatedFormFactor
clone.configSettings.formFactor = clone.configSettings.emulatedFormFactor;
}
for (const audit of Object.values(clone.audits)) {
// Turn 'not-applicable' (LHR <4.0) and 'not_applicable' (older proto versions)
// into 'notApplicable' (LHR ≥4.0).
// @ts-expect-error tsc rightly flags that these values shouldn't occur.
// eslint-disable-next-line max-len
if (audit.scoreDisplayMode === 'not_applicable' || audit.scoreDisplayMode === 'not-applicable') {
audit.scoreDisplayMode = 'notApplicable';
}
if (audit.details) {
// Turn `auditDetails.type` of undefined (LHR <4.2) and 'diagnostic' (LHR <5.0)
// into 'debugdata' (LHR ≥5.0).
// @ts-expect-error tsc rightly flags that these values shouldn't occur.
if (audit.details.type === undefined || audit.details.type === 'diagnostic') {
// @ts-expect-error details is of type never.
audit.details.type = 'debugdata';
}
// Add the jpg data URL prefix to filmstrip screenshots without them (LHR <5.0).
if (audit.details.type === 'filmstrip') {
for (const screenshot of audit.details.items) {
if (!screenshot.data.startsWith(SCREENSHOT_PREFIX)) {
screenshot.data = SCREENSHOT_PREFIX + screenshot.data;
}
}
}
}
}
// For convenience, smoosh all AuditResults into their auditRef (which has just weight & group)
if (typeof clone.categories !== 'object') throw new Error('No categories provided.');
for (const category of Object.values(clone.categories)) {
category.auditRefs.forEach(auditRef => {
const result = clone.audits[auditRef.id];
auditRef.result = result;
// attach the stackpacks to the auditRef object
if (clone.stackPacks) {
clone.stackPacks.forEach(pack => {
if (pack.descriptions[auditRef.id]) {
auditRef.stackPacks = auditRef.stackPacks || [];
auditRef.stackPacks.push({
title: pack.title,
iconDataURL: pack.iconDataURL,
description: pack.descriptions[auditRef.id],
});
}
});
}
});
}
return clone;
}
/**
* Used to determine if the "passed" for the purposes of showing up in the "failed" or "passed"
* sections of the report.
*
* @param {{score: (number|null), scoreDisplayMode: string}} audit
* @return {boolean}
*/
static showAsPassed(audit) {
switch (audit.scoreDisplayMode) {
case 'manual':
case 'notApplicable':
return true;
case 'error':
case 'informative':
return false;
case 'numeric':
case 'binary':
default:
return Number(audit.score) >= RATINGS.PASS.minScore;
}
}
/**
* Convert a score to a rating label.
* @param {number|null} score
* @param {string=} scoreDisplayMode
* @return {string}
*/
static calculateRating(score, scoreDisplayMode) {
// Handle edge cases first, manual and not applicable receive 'pass', errored audits receive 'error'
if (scoreDisplayMode === 'manual' || scoreDisplayMode === 'notApplicable') {
return RATINGS.PASS.label;
} else if (scoreDisplayMode === 'error') {
return RATINGS.ERROR.label;
} else if (score === null) {
return RATINGS.FAIL.label;
}
// At this point, we're rating a standard binary/numeric audit
let rating = RATINGS.FAIL.label;
if (score >= RATINGS.PASS.minScore) {
rating = RATINGS.PASS.label;
} else if (score >= RATINGS.AVERAGE.minScore) {
rating = RATINGS.AVERAGE.label;
}
return rating;
}
/**
* Split a string by markdown code spans (enclosed in `backticks`), splitting
* into segments that were enclosed in backticks (marked as `isCode === true`)
* and those that outside the backticks (`isCode === false`).
* @param {string} text
* @return {Array<{isCode: true, text: string}|{isCode: false, text: string}>}
*/
static splitMarkdownCodeSpans(text) {
/** @type {Array<{isCode: true, text: string}|{isCode: false, text: string}>} */
const segments = [];
// Split on backticked code spans.
const parts = text.split(/`(.*?)`/g);
for (let i = 0; i < parts.length; i ++) {
const text = parts[i];
// Empty strings are an artifact of splitting, not meaningful.
if (!text) continue;
// Alternates between plain text and code segments.
const isCode = i % 2 !== 0;
segments.push({
isCode,
text,
});
}
return segments;
}
/**
* Split a string on markdown links (e.g. [some link](https://...)) into
* segments of plain text that weren't part of a link (marked as
* `isLink === false`), and segments with text content and a URL that did make
* up a link (marked as `isLink === true`).
* @param {string} text
* @return {Array<{isLink: true, text: string, linkHref: string}|{isLink: false, text: string}>}
*/
static splitMarkdownLink(text) {
/** @type {Array<{isLink: true, text: string, linkHref: string}|{isLink: false, text: string}>} */
const segments = [];
const parts = text.split(/\[([^\]]+?)\]\((https?:\/\/.*?)\)/g);
while (parts.length) {
// Shift off the same number of elements as the pre-split and capture groups.
const [preambleText, linkText, linkHref] = parts.splice(0, 3);
if (preambleText) { // Skip empty text as it's an artifact of splitting, not meaningful.
segments.push({
isLink: false,
text: preambleText,
});
}
// Append link if there are any.
if (linkText && linkHref) {
segments.push({
isLink: true,
text: linkText,
linkHref,
});
}
}
return segments;
}
/**
* @param {URL} parsedUrl
* @param {{numPathParts?: number, preserveQuery?: boolean, preserveHost?: boolean}=} options
* @return {string}
*/
static getURLDisplayName(parsedUrl, options) {
// Closure optional properties aren't optional in tsc, so fallback needs undefined values.
options = options || {numPathParts: undefined, preserveQuery: undefined,
preserveHost: undefined};
const numPathParts = options.numPathParts !== undefined ? options.numPathParts : 2;
const preserveQuery = options.preserveQuery !== undefined ? options.preserveQuery : true;
const preserveHost = options.preserveHost || false;
let name;
if (parsedUrl.protocol === 'about:' || parsedUrl.protocol === 'data:') {
// Handle 'about:*' and 'data:*' URLs specially since they have no path.
name = parsedUrl.href;
} else {
name = parsedUrl.pathname;
const parts = name.split('/').filter(part => part.length);
if (numPathParts && parts.length > numPathParts) {
name = ELLIPSIS + parts.slice(-1 * numPathParts).join('/');
}
if (preserveHost) {
name = `${parsedUrl.host}/${name.replace(/^\//, '')}`;
}
if (preserveQuery) {
name = `${name}${parsedUrl.search}`;
}
}
const MAX_LENGTH = 64;
// Always elide hexadecimal hash
name = name.replace(/([a-f0-9]{7})[a-f0-9]{13}[a-f0-9]*/g, `$1${ELLIPSIS}`);
// Also elide other hash-like mixed-case strings
name = name.replace(/([a-zA-Z0-9-_]{9})(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])[a-zA-Z0-9-_]{10,}/g,
`$1${ELLIPSIS}`);
// Also elide long number sequences
name = name.replace(/(\d{3})\d{6,}/g, `$1${ELLIPSIS}`);
// Merge any adjacent ellipses
name = name.replace(/\u2026+/g, ELLIPSIS);
// Elide query params first
if (name.length > MAX_LENGTH && name.includes('?')) {
// Try to leave the first query parameter intact
name = name.replace(/\?([^=]*)(=)?.*/, `?$1$2${ELLIPSIS}`);
// Remove it all if it's still too long
if (name.length > MAX_LENGTH) {
name = name.replace(/\?.*/, `?${ELLIPSIS}`);
}
}
// Elide too long names next
if (name.length > MAX_LENGTH) {
const dotIndex = name.lastIndexOf('.');
if (dotIndex >= 0) {
name = name.slice(0, MAX_LENGTH - 1 - (name.length - dotIndex)) +
// Show file extension
`${ELLIPSIS}${name.slice(dotIndex)}`;
} else {
name = name.slice(0, MAX_LENGTH - 1) + ELLIPSIS;
}
}
return name;
}
/**
* Split a URL into a file, hostname and origin for easy display.
* @param {string} url
* @return {{file: string, hostname: string, origin: string}}
*/
static parseURL(url) {
const parsedUrl = new URL(url);
return {
file: Util.getURLDisplayName(parsedUrl),
hostname: parsedUrl.hostname,
origin: parsedUrl.origin,
};
}
/**
* @param {string|URL} value
* @return {!URL}
*/
static createOrReturnURL(value) {
if (value instanceof URL) {
return value;
}
return new URL(value);
}
/**
* Gets the tld of a domain
*
* @param {string} hostname
* @return {string} tld
*/
static getTld(hostname) {
const tlds = hostname.split('.').slice(-2);
if (!listOfTlds.includes(tlds[0])) {
return `.${tlds[tlds.length - 1]}`;
}
return `.${tlds.join('.')}`;
}
/**
* Returns a primary domain for provided hostname (e.g. www.example.com -> example.com).
* @param {string|URL} url hostname or URL object
* @returns {string}
*/
static getRootDomain(url) {
const hostname = Util.createOrReturnURL(url).hostname;
const tld = Util.getTld(hostname);
// tld is .com or .co.uk which means we means that length is 1 to big
// .com => 2 & .co.uk => 3
const splitTld = tld.split('.');
// get TLD + root domain
return hostname.split('.').slice(-splitTld.length).join('.');
}
/**
* @param {LH.Config.Settings} settings
* @return {!Array<{name: string, description: string}>}
*/
static getEnvironmentDisplayValues(settings) {
const emulationDesc = Util.getEmulationDescriptions(settings);
return [
{
name: Util.i18n.strings.runtimeSettingsDevice,
description: emulationDesc.deviceEmulation,
},
{
name: Util.i18n.strings.runtimeSettingsNetworkThrottling,
description: emulationDesc.networkThrottling,
},
{
name: Util.i18n.strings.runtimeSettingsCPUThrottling,
description: emulationDesc.cpuThrottling,
},
];
}
/**
* @param {LH.Config.Settings} settings
* @return {{deviceEmulation: string, networkThrottling: string, cpuThrottling: string}}
*/
static getEmulationDescriptions(settings) {
let cpuThrottling;
let networkThrottling;
const throttling = settings.throttling;
switch (settings.throttlingMethod) {
case 'provided':
cpuThrottling = Util.i18n.strings.throttlingProvided;
networkThrottling = Util.i18n.strings.throttlingProvided;
break;
case 'devtools': {
const {cpuSlowdownMultiplier, requestLatencyMs} = throttling;
cpuThrottling = `${Util.i18n.formatNumber(cpuSlowdownMultiplier)}x slowdown (DevTools)`;
networkThrottling = `${Util.i18n.formatNumber(requestLatencyMs)}${NBSP}ms HTTP RTT, ` +
`${Util.i18n.formatNumber(throttling.downloadThroughputKbps)}${NBSP}Kbps down, ` +
`${Util.i18n.formatNumber(throttling.uploadThroughputKbps)}${NBSP}Kbps up (DevTools)`;
break;
}
case 'simulate': {
const {cpuSlowdownMultiplier, rttMs, throughputKbps} = throttling;
cpuThrottling = `${Util.i18n.formatNumber(cpuSlowdownMultiplier)}x slowdown (Simulated)`;
networkThrottling = `${Util.i18n.formatNumber(rttMs)}${NBSP}ms TCP RTT, ` +
`${Util.i18n.formatNumber(throughputKbps)}${NBSP}Kbps throughput (Simulated)`;
break;
}
default:
cpuThrottling = Util.i18n.strings.runtimeUnknown;
networkThrottling = Util.i18n.strings.runtimeUnknown;
}
// TODO(paulirish): revise Runtime Settings strings: https://github.com/GoogleChrome/lighthouse/pull/11796
const deviceEmulation = {
mobile: Util.i18n.strings.runtimeMobileEmulation,
desktop: Util.i18n.strings.runtimeDesktopEmulation,
}[settings.formFactor] || Util.i18n.strings.runtimeNoEmulation;
return {
deviceEmulation,
cpuThrottling,
networkThrottling,
};
}
/**
* Returns only lines that are near a message, or the first few lines if there are
* no line messages.
* @param {LH.Audit.Details.SnippetValue['lines']} lines
* @param {LH.Audit.Details.SnippetValue['lineMessages']} lineMessages
* @param {number} surroundingLineCount Number of lines to include before and after
* the message. If this is e.g. 2 this function might return 5 lines.
*/
static filterRelevantLines(lines, lineMessages, surroundingLineCount) {
if (lineMessages.length === 0) {
// no lines with messages, just return the first bunch of lines
return lines.slice(0, surroundingLineCount * 2 + 1);
}
const minGapSize = 3;
const lineNumbersToKeep = new Set();
// Sort messages so we can check lineNumbersToKeep to see how big the gap to
// the previous line is.
lineMessages = lineMessages.sort((a, b) => (a.lineNumber || 0) - (b.lineNumber || 0));
lineMessages.forEach(({lineNumber}) => {
let firstSurroundingLineNumber = lineNumber - surroundingLineCount;
let lastSurroundingLineNumber = lineNumber + surroundingLineCount;
while (firstSurroundingLineNumber < 1) {
// make sure we still show (surroundingLineCount * 2 + 1) lines in total
firstSurroundingLineNumber++;
lastSurroundingLineNumber++;
}
// If only a few lines would be omitted normally then we prefer to include
// extra lines to avoid the tiny gap
if (lineNumbersToKeep.has(firstSurroundingLineNumber - minGapSize - 1)) {
firstSurroundingLineNumber -= minGapSize;
}
for (let i = firstSurroundingLineNumber; i <= lastSurroundingLineNumber; i++) {
const surroundingLineNumber = i;
lineNumbersToKeep.add(surroundingLineNumber);
}
});
return lines.filter(line => lineNumbersToKeep.has(line.lineNumber));
}
/**
* @param {string} categoryId
*/
static isPluginCategory(categoryId) {
return categoryId.startsWith('lighthouse-plugin-');
}
}
/**
* Some parts of the report renderer require data found on the LHR. Instead of wiring it
* through, we have this global.
* @type {LH.ReportResult | null}
*/
Util.reportJson = null;
/**
* An always-increasing counter for making unique SVG ID suffixes.
*/
Util.getUniqueSuffix = (() => {
let svgSuffix = 0;
return function() {
return svgSuffix++;
};
})();
/** @type {I18n} */
// @ts-expect-error: Is set in report renderer.
Util.i18n = null;
/**
* Report-renderer-specific strings.
*/
Util.UIStrings = {
/** Disclaimer shown to users below the metric values (First Contentful Paint, Time to Interactive, etc) to warn them that the numbers they see will likely change slightly the next time they run Lighthouse. */
varianceDisclaimer: 'Values are estimated and may vary. The [performance score is calculated](https://web.dev/performance-scoring/) directly from these metrics.',
/** Text link pointing to an interactive calculator that explains Lighthouse scoring. The link text should be fairly short. */
calculatorLink: 'See calculator.',
/** Column heading label for the listing of opportunity audits. Each audit title represents an opportunity. There are only 2 columns, so no strict character limit. */
opportunityResourceColumnLabel: 'Opportunity',
/** Column heading label for the estimated page load savings of opportunity audits. Estimated Savings is the total amount of time (in seconds) that Lighthouse computed could be reduced from the total page load time, if the suggested action is taken. There are only 2 columns, so no strict character limit. */
opportunitySavingsColumnLabel: 'Estimated Savings',
/** An error string displayed next to a particular audit when it has errored, but not provided any specific error message. */
errorMissingAuditInfo: 'Report error: no audit information',
/** A label, shown next to an audit title or metric title, indicating that there was an error computing it. The user can hover on the label to reveal a tooltip with the extended error message. Translation should be short (< 20 characters). */
errorLabel: 'Error!',
/** This label is shown above a bulleted list of warnings. It is shown directly below an audit that produced warnings. Warnings describe situations the user should be aware of, as Lighthouse was unable to complete all the work required on this audit. For example, The 'Unable to decode image (biglogo.jpg)' warning may show up below an image encoding audit. */
warningHeader: 'Warnings: ',
/** Section heading shown above a list of passed audits that contain warnings. Audits under this section do not negatively impact the score, but Lighthouse has generated some potentially actionable suggestions that should be reviewed. This section is expanded by default and displays after the failing audits. */
warningAuditsGroupTitle: 'Passed audits but with warnings',
/** Section heading shown above a list of audits that are passing. 'Passed' here refers to a passing grade. This section is collapsed by default, as the user should be focusing on the failed audits instead. Users can click this heading to reveal the list. */
passedAuditsGroupTitle: 'Passed audits',
/** Section heading shown above a list of audits that do not apply to the page. For example, if an audit is 'Are images optimized?', but the page has no images on it, the audit will be marked as not applicable. This is neither passing or failing. This section is collapsed by default, as the user should be focusing on the failed audits instead. Users can click this heading to reveal the list. */
notApplicableAuditsGroupTitle: 'Not applicable',
/** Section heading shown above a list of audits that were not computed by Lighthouse. They serve as a list of suggestions for the user to go and manually check. For example, Lighthouse can't automate testing cross-browser compatibility, so that is listed within this section, so the user is reminded to test it themselves. This section is collapsed by default, as the user should be focusing on the failed audits instead. Users can click this heading to reveal the list. */
manualAuditsGroupTitle: 'Additional items to manually check',
/** Label shown preceding any important warnings that may have invalidated the entire report. For example, if the user has Chrome extensions installed, they may add enough performance overhead that Lighthouse's performance metrics are unreliable. If shown, this will be displayed at the top of the report UI. */
toplevelWarningsMessage: 'There were issues affecting this run of Lighthouse:',
/** String of text shown in a graphical representation of the flow of network requests for the web page. This label represents the initial network request that fetches an HTML page. This navigation may be redirected (eg. Initial navigation to http://example.com redirects to https://www.example.com). */
crcInitialNavigation: 'Initial Navigation',
/** Label of value shown in the summary of critical request chains. Refers to the total amount of time (milliseconds) of the longest critical path chain/sequence of network requests. Example value: 2310 ms */
crcLongestDurationLabel: 'Maximum critical path latency:',
/** Label for button that shows all lines of the snippet when clicked */
snippetExpandButtonLabel: 'Expand snippet',
/** Label for button that only shows a few lines of the snippet when clicked */
snippetCollapseButtonLabel: 'Collapse snippet',
/** Explanation shown to users below performance results to inform them that the test was done with a 4G network connection and to warn them that the numbers they see will likely change slightly the next time they run Lighthouse. 'Lighthouse' becomes link text to additional documentation. */
lsPerformanceCategoryDescription: '[Lighthouse](https://developers.google.com/web/tools/lighthouse/) analysis of the current page on an emulated mobile network. Values are estimated and may vary.',
/** Title of the lab data section of the Performance category. Within this section are various speed metrics which quantify the pageload performance into values presented in seconds and milliseconds. "Lab" is an abbreviated form of "laboratory", and refers to the fact that the data is from a controlled test of a website, not measurements from real users visiting that site. */
labDataTitle: 'Lab Data',
/** This label is for a checkbox above a table of items loaded by a web page. The checkbox is used to show or hide third-party (or "3rd-party") resources in the table, where "third-party resources" refers to items loaded by a web page from URLs that aren't controlled by the owner of the web page. */
thirdPartyResourcesLabel: 'Show 3rd-party resources',
/** Option in a dropdown menu that opens a small, summary report in a print dialog. */
dropdownPrintSummary: 'Print Summary',
/** Option in a dropdown menu that opens a full Lighthouse report in a print dialog. */
dropdownPrintExpanded: 'Print Expanded',
/** Option in a dropdown menu that copies the Lighthouse JSON object to the system clipboard. */
dropdownCopyJSON: 'Copy JSON',
/** Option in a dropdown menu that saves the Lighthouse report HTML locally to the system as a '.html' file. */
dropdownSaveHTML: 'Save as HTML',
/** Option in a dropdown menu that saves the Lighthouse JSON object to the local system as a '.json' file. */
dropdownSaveJSON: 'Save as JSON',
/** Option in a dropdown menu that opens the current report in the Lighthouse Viewer Application. */
dropdownViewer: 'Open in Viewer',
/** Option in a dropdown menu that saves the current report as a new Github Gist. */
dropdownSaveGist: 'Save as Gist',
/** Option in a dropdown menu that toggles the themeing of the report between Light(default) and Dark themes. */
dropdownDarkTheme: 'Toggle Dark Theme',
/** Title of the Runtime settings table in a Lighthouse report. Runtime settings are the environment configurations that a specific report used at auditing time. */
runtimeSettingsTitle: 'Runtime Settings',
/** Label for a row in a table that shows the URL that was audited during a Lighthouse run. */
runtimeSettingsUrl: 'URL',
/** Label for a row in a table that shows the time at which a Lighthouse run was conducted; formatted as a timestamp, e.g. Jan 1, 1970 12:00 AM UTC. */
runtimeSettingsFetchTime: 'Fetch Time',
/** Label for a row in a table that describes the kind of device that was emulated for the Lighthouse run. Example values for row elements: 'No Emulation', 'Emulated Desktop', etc. */
runtimeSettingsDevice: 'Device',
/** Label for a row in a table that describes the network throttling conditions that were used during a Lighthouse run, if any. */
runtimeSettingsNetworkThrottling: 'Network throttling',
/** Label for a row in a table that describes the CPU throttling conditions that were used during a Lighthouse run, if any.*/
runtimeSettingsCPUThrottling: 'CPU throttling',
/** Label for a row in a table that shows in what tool Lighthouse is being run (e.g. The lighthouse CLI, Chrome DevTools, Lightrider, WebPageTest, etc). */
runtimeSettingsChannel: 'Channel',
/** Label for a row in a table that shows the User Agent that was detected on the Host machine that ran Lighthouse. */
runtimeSettingsUA: 'User agent (host)',
/** Label for a row in a table that shows the User Agent that was used to send out all network requests during the Lighthouse run. */
runtimeSettingsUANetwork: 'User agent (network)',
/** Label for a row in a table that shows the estimated CPU power of the machine running Lighthouse. Example row values: 532, 1492, 783. */
runtimeSettingsBenchmark: 'CPU/Memory Power',
/** Label for a row in a table that shows the version of the Axe library used. Example row values: 2.1.0, 3.2.3 */
runtimeSettingsAxeVersion: 'Axe version',
/** Label for button to create an issue against the Lighthouse Github project. */
footerIssue: 'File an issue',
/** Descriptive explanation for emulation setting when no device emulation is set. */
runtimeNoEmulation: 'No emulation',
/** Descriptive explanation for emulation setting when emulating a Moto G4 mobile device. */
runtimeMobileEmulation: 'Emulated Moto G4',
/** Descriptive explanation for emulation setting when emulating a generic desktop form factor, as opposed to a mobile-device like form factor. */
runtimeDesktopEmulation: 'Emulated Desktop',
/** Descriptive explanation for a runtime setting that is set to an unknown value. */
runtimeUnknown: 'Unknown',
/** Descriptive explanation for environment throttling that was provided by the runtime environment instead of provided by Lighthouse throttling. */
throttlingProvided: 'Provided by environment',
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = Util;
} else {
self.Util = Util;
}
;
/**
* @license
* Copyright 2017 The Lighthouse Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
/* globals URL self Util */
/** @typedef {HTMLElementTagNameMap & {[id: string]: HTMLElement}} HTMLElementByTagName */
class DOM {
/**
* @param {Document} document
*/
constructor(document) {
/** @type {Document} */
this._document = document;
/** @type {string} */
this._lighthouseChannel = 'unknown';
}
/**
* @template {string} T
* @param {T} name
* @param {string=} className
* @param {Object<string, (string|undefined)>=} attrs Attribute key/val pairs.
* Note: if an attribute key has an undefined value, this method does not
* set the attribute on the node.
* @return {HTMLElementByTagName[T]}
*/
createElement(name, className, attrs = {}) {
const element = this._document.createElement(name);
if (className) {
element.className = className;
}
Object.keys(attrs).forEach(key => {
const value = attrs[key];
if (typeof value !== 'undefined') {
element.setAttribute(key, value);
}
});
return element;
}
/**
* @param {string} namespaceURI
* @param {string} name
* @param {string=} className
* @param {Object<string, (string|undefined)>=} attrs Attribute key/val pairs.
* Note: if an attribute key has an undefined value, this method does not
* set the attribute on the node.
* @return {Element}
*/
createElementNS(namespaceURI, name, className, attrs = {}) {
const element = this._document.createElementNS(namespaceURI, name);
if (className) {
element.className = className;
}
Object.keys(attrs).forEach(key => {
const value = attrs[key];
if (typeof value !== 'undefined') {
element.setAttribute(key, value);
}
});
return element;
}
/**
* @return {!DocumentFragment}
*/
createFragment() {
return this._document.createDocumentFragment();
}
/**
* @template {string} T
* @param {Element} parentElem
* @param {T} elementName
* @param {string=} className
* @param {Object<string, (string|undefined)>=} attrs Attribute key/val pairs.
* Note: if an attribute key has an undefined value, this method does not
* set the attribute on the node.
* @return {HTMLElementByTagName[T]}
*/
createChildOf(parentElem, elementName, className, attrs) {
const element = this.createElement(elementName, className, attrs);
parentElem.appendChild(element);
return element;
}
/**
* @param {string} selector
* @param {ParentNode} context
* @return {!DocumentFragment} A clone of the template content.
* @throws {Error}
*/
cloneTemplate(selector, context) {
const template = /** @type {?HTMLTemplateElement} */ (context.querySelector(selector));
if (!template) {
throw new Error(`Template not found: template${selector}`);
}
const clone = this._document.importNode(template.content, true);
// Prevent duplicate styles in the DOM. After a template has been stamped
// for the first time, remove the clone's styles so they're not re-added.
if (template.hasAttribute('data-stamped')) {
this.findAll('style', clone).forEach(style => style.remove());
}
template.setAttribute('data-stamped', 'true');
return clone;
}
/**
* Resets the "stamped" state of the templates.
*/
resetTemplates() {
this.findAll('template[data-stamped]', this._document).forEach(t => {
t.removeAttribute('data-stamped');
});
}
/**
* @param {string} text
* @return {Element}
*/
convertMarkdownLinkSnippets(text) {
const element = this.createElement('span');
for (const segment of Util.splitMarkdownLink(text)) {
if (!segment.isLink) {
// Plain text segment.
element.appendChild(this._document.createTextNode(segment.text));
continue;
}
// Otherwise, append any links found.
const url = new URL(segment.linkHref);
const DOCS_ORIGINS = ['https://developers.google.com', 'https://web.dev'];
if (DOCS_ORIGINS.includes(url.origin)) {
url.searchParams.set('utm_source', 'lighthouse');
url.searchParams.set('utm_medium', this._lighthouseChannel);
}
const a = this.createElement('a');
a.rel = 'noopener';
a.target = '_blank';
a.textContent = segment.text;
a.href = url.href;
element.appendChild(a);
}
return element;
}
/**
* @param {string} markdownText
* @return {Element}
*/
convertMarkdownCodeSnippets(markdownText) {
const element = this.createElement('span');
for (const segment of Util.splitMarkdownCodeSpans(markdownText)) {
if (segment.isCode) {
const pre = this.createElement('code');
pre.textContent = segment.text;
element.appendChild(pre);
} else {
element.appendChild(this._document.createTextNode(segment.text));
}
}
return element;
}
/**
* The channel to use for UTM data when rendering links to the documentation.
* @param {string} lighthouseChannel
*/
setLighthouseChannel(lighthouseChannel) {
this._lighthouseChannel = lighthouseChannel;
}
/**
* @return {Document}
*/
document() {
return this._document;
}
/**
* TODO(paulirish): import and conditionally apply the DevTools frontend subclasses instead of this
* @return {boolean}
*/
isDevTools() {
return !!this._document.querySelector('.lh-devtools');
}
/**
* Guaranteed context.querySelector. Always returns an element or throws if
* nothing matches query.
* @template {string} T
* @param {T} query
* @param {ParentNode} context
*/
find(query, context) {
const result = context.querySelector(query);
if (result === null) {
throw new Error(`query ${query} not found`);
}
return result;
}
/**
* Helper for context.querySelectorAll. Returns an Array instead of a NodeList.
* @template {string} T
* @param {T} query
* @param {ParentNode} context
*/
findAll(query, context) {
const elements = Array.from(context.querySelectorAll(query));
return elements;
}
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = DOM;
} else {
self.DOM = DOM;
}
;
/*
Details Element Polyfill 2.4.0
Copyright © 2019 Javan Makhmali
*/
(function() {
"use strict";
var element = document.createElement("details");
var elementIsNative = typeof HTMLDetailsElement != "undefined" && element instanceof HTMLDetailsElement;
var support = {
open: "open" in element || elementIsNative,
toggle: "ontoggle" in element
};
var styles = '\ndetails, summary {\n display: block;\n}\ndetails:not([open]) > *:not(summary) {\n display: none;\n}\nsummary::before {\n content: "►";\n padding-right: 0.3rem;\n font-size: 0.6rem;\n cursor: default;\n}\n[open] > summary::before {\n content: "▼";\n}\n';
var _ref = [], forEach = _ref.forEach, slice = _ref.slice;
if (!support.open) {
polyfillStyles();
polyfillProperties();
polyfillToggle();
polyfillAccessibility();
}
if (support.open && !support.toggle) {
polyfillToggleEvent();
}
function polyfillStyles() {
document.head.insertAdjacentHTML("afterbegin", "<style>" + styles + "</style>");
}
function polyfillProperties() {
var prototype = document.createElement("details").constructor.prototype;
var setAttribute = prototype.setAttribute, removeAttribute = prototype.removeAttribute;
var open = Object.getOwnPropertyDescriptor(prototype, "open");
Object.defineProperties(prototype, {
open: {
get: function get() {
if (this.tagName == "DETAILS") {
return this.hasAttribute("open");
} else {
if (open && open.get) {
return open.get.call(this);
}
}
},
set: function set(value) {
if (this.tagName == "DETAILS") {
return value ? this.setAttribute("open", "") : this.removeAttribute("open");
} else {
if (open && open.set) {
return open.set.call(this, value);
}
}
}
},
setAttribute: {
value: function value(name, _value) {
var _this = this;
var call = function call() {
return setAttribute.call(_this, name, _value);
};
if (name == "open" && this.tagName == "DETAILS") {
var wasOpen = this.hasAttribute("open");
var result = call();
if (!wasOpen) {
var summary = this.querySelector("summary");
if (summary) summary.setAttribute("aria-expanded", true);
triggerToggle(this);
}
return result;
}
return call();
}
},
removeAttribute: {
value: function value(name) {
var _this2 = this;
var call = function call() {
return removeAttribute.call(_this2, name);
};
if (name == "open" && this.tagName == "DETAILS") {
var wasOpen = this.hasAttribute("open");
var result = call();
if (wasOpen) {
var summary = this.querySelector("summary");
if (summary) summary.setAttribute("aria-expanded", false);
triggerToggle(this);
}
return result;
}
return call();
}
}
});
}
function polyfillToggle() {
onTogglingTrigger(function(element) {
element.hasAttribute("open") ? element.removeAttribute("open") : element.setAttribute("open", "");
});
}
function polyfillToggleEvent() {
if (window.MutationObserver) {
new MutationObserver(function(mutations) {
forEach.call(mutations, function(mutation) {
var target = mutation.target, attributeName = mutation.attributeName;
if (target.tagName == "DETAILS" && attributeName == "open") {
triggerToggle(target);
}
});
}).observe(document.documentElement, {
attributes: true,
subtree: true
});
} else {
onTogglingTrigger(function(element) {
var wasOpen = element.getAttribute("open");
setTimeout(function() {
var isOpen = element.getAttribute("open");
if (wasOpen != isOpen) {
triggerToggle(element);
}
}, 1);
});
}
}
function polyfillAccessibility() {
setAccessibilityAttributes(document);
if (window.MutationObserver) {
new MutationObserver(function(mutations) {
forEach.call(mutations, function(mutation) {
forEach.call(mutation.addedNodes, setAccessibilityAttributes);
});
}).observe(document.documentElement, {
subtree: true,
childList: true
});
} else {
document.addEventListener("DOMNodeInserted", function(event) {
setAccessibilityAttributes(event.target);
});
}
}
function setAccessibilityAttributes(root) {
findElementsWithTagName(root, "SUMMARY").forEach(function(summary) {
var details = findClosestElementWithTagName(summary, "DETAILS");
summary.setAttribute("aria-expanded", details.hasAttribute("open"));
if (!summary.hasAttribute("tabindex")) summary.setAttribute("tabindex", "0");
if (!summary.hasAttribute("role")) summary.setAttribute("role", "button");
});
}
function eventIsSignificant(event) {
return !(event.defaultPrevented || event.ctrlKey || event.metaKey || event.shiftKey || event.target.isContentEditable);
}
function onTogglingTrigger(callback) {
addEventListener("click", function(event) {
if (eventIsSignificant(event)) {
if (event.which <= 1) {
var element = findClosestElementWithTagName(event.target, "SUMMARY");
if (element && element.parentNode && element.parentNode.tagName == "DETAILS") {
callback(element.parentNode);
}
}
}
}, false);
addEventListener("keydown", function(event) {
if (eventIsSignificant(event)) {
if (event.keyCode == 13 || event.keyCode == 32) {
var element = findClosestElementWithTagName(event.target, "SUMMARY");
if (element && element.parentNode && element.parentNode.tagName == "DETAILS") {
callback(element.parentNode);
event.preventDefault();
}
}
}
}, false);
}
function triggerToggle(element) {
var event = document.createEvent("Event");
event.initEvent("toggle", false, false);
element.dispatchEvent(event);
}
function findElementsWithTagName(root, tagName) {
return (root.tagName == tagName ? [ root ] : []).concat(typeof root.getElementsByTagName == "function" ? slice.call(root.getElementsByTagName(tagName)) : []);
}
function findClosestElementWithTagName(element, tagName) {
if (typeof element.closest == "function") {
return element.closest(tagName);
} else {
while (element) {
if (element.tagName == tagName) {
return element;
} else {
element = element.parentNode;
}
}
}
}
})();
;
/**
* @license
* Copyright 2017 The Lighthouse Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
/* globals self CriticalRequestChainRenderer SnippetRenderer ElementScreenshotRenderer Util URL */
/** @typedef {import('./dom.js')} DOM */
// Convenience types for localized AuditDetails.
/** @typedef {LH.FormattedIcu<LH.Audit.Details>} AuditDetails */
/** @typedef {LH.FormattedIcu<LH.Audit.Details.Opportunity>} OpportunityTable */
/** @typedef {LH.FormattedIcu<LH.Audit.Details.Table>} Table */
/** @typedef {LH.FormattedIcu<LH.Audit.Details.TableItem>} TableItem */
/** @typedef {LH.FormattedIcu<LH.Audit.Details.ItemValue>} TableItemValue */
const URL_PREFIXES = ['http://', 'https://', 'data:'];
class DetailsRenderer {
/**
* @param {DOM} dom
* @param {{fullPageScreenshot?: LH.Artifacts.FullPageScreenshot}} [options]
*/
constructor(dom, options = {}) {
this._dom = dom;
this._fullPageScreenshot = options.fullPageScreenshot;
/** @type {ParentNode} */
this._templateContext; // eslint-disable-line no-unused-expressions
}
/**
* @param {ParentNode} context
*/
setTemplateContext(context) {
this._templateContext = context;
}
/**
* @param {AuditDetails} details
* @return {Element|null}
*/
render(details) {
switch (details.type) {
case 'filmstrip':
return this._renderFilmstrip(details);
case 'list':
return this._renderList(details);
case 'table':
return this._renderTable(details);
case 'criticalrequestchain':
return CriticalRequestChainRenderer.render(this._dom, this._templateContext, details, this);
case 'opportunity':
return this._renderTable(details);
// Internal-only details, not for rendering.
case 'screenshot':
case 'debugdata':
case 'full-page-screenshot':
return null;
default: {
// @ts-expect-error tsc thinks this is unreachable, but be forward compatible
// with new unexpected detail types.
return this._renderUnknown(details.type, details);
}
}
}
/**
* @param {{value: number, granularity?: number}} details
* @return {Element}
*/
_renderBytes(details) {
// TODO: handle displayUnit once we have something other than 'kb'
// Note that 'kb' is historical and actually represents KiB.
const value = Util.i18n.formatBytesToKiB(details.value, details.granularity);
const textEl = this._renderText(value);
textEl.title = Util.i18n.formatBytes(details.value);
return textEl;
}
/**
* @param {{value: number, granularity?: number, displayUnit?: string}} details
* @return {Element}
*/
_renderMilliseconds(details) {
let value = Util.i18n.formatMilliseconds(details.value, details.granularity);
if (details.displayUnit === 'duration') {
value = Util.i18n.formatDuration(details.value);
}
return this._renderText(value);
}
/**
* @param {string} text
* @return {HTMLElement}
*/
renderTextURL(text) {
const url = text;
let displayedPath;
let displayedHost;
let title;
try {
const parsed = Util.parseURL(url);
displayedPath = parsed.file === '/' ? parsed.origin : parsed.file;
displayedHost = parsed.file === '/' || parsed.hostname === '' ? '' : `(${parsed.hostname})`;
title = url;
} catch (e) {
displayedPath = url;
}
const element = this._dom.createElement('div', 'lh-text__url');
element.appendChild(this._renderLink({text: displayedPath, url}));
if (displayedHost) {
const hostElem = this._renderText(displayedHost);
hostElem.classList.add('lh-text__url-host');
element.appendChild(hostElem);
}
if (title) {
element.title = url;
// set the url on the element's dataset which we use to check 3rd party origins
element.dataset.url = url;
}
return element;
}
/**
* @param {{text: string, url: string}} details
* @return {HTMLElement}
*/
_renderLink(details) {
const allowedProtocols = ['https:', 'http:'];
let url;
try {
url = new URL(details.url);
} catch (_) {}
if (!url || !allowedProtocols.includes(url.protocol)) {
// Fall back to just the link text if invalid or protocol not allowed.
const element = this._renderText(details.text);
element.classList.add('lh-link');
return element;
}
const a = this._dom.createElement('a');
a.rel = 'noopener';
a.target = '_blank';
a.textContent = details.text;
a.href = url.href;
a.classList.add('lh-link');
return a;
}
/**
* @param {string} text
* @return {HTMLDivElement}
*/
_renderText(text) {
const element = this._dom.createElement('div', 'lh-text');
element.textContent = text;
return element;
}
/**
* @param {{value: number, granularity?: number}} details
* @return {Element}
*/
_renderNumeric(details) {
const value = Util.i18n.formatNumber(details.value, details.granularity);
const element = this._dom.createElement('div', 'lh-numeric');
element.textContent = value;
return element;
}
/**
* Create small thumbnail with scaled down image asset.
* @param {string} details
* @return {Element}
*/
_renderThumbnail(details) {
const element = this._dom.createElement('img', 'lh-thumbnail');
const strValue = details;
element.src = strValue;
element.title = strValue;
element.alt = '';
return element;
}
/**
* @param {string} type
* @param {*} value
*/
_renderUnknown(type, value) {
// eslint-disable-next-line no-console
console.error(`Unknown details type: ${type}`, value);
const element = this._dom.createElement('details', 'lh-unknown');
this._dom.createChildOf(element, 'summary').textContent =
`We don't know how to render audit details of type \`${type}\`. ` +
'The Lighthouse version that collected this data is likely newer than the Lighthouse ' +
'version of the report renderer. Expand for the raw JSON.';
this._dom.createChildOf(element, 'pre').textContent = JSON.stringify(value, null, 2);
return element;
}
/**
* Render a details item value for embedding in a table. Renders the value
* based on the heading's valueType, unless the value itself has a `type`
* property to override it.
* @param {TableItemValue} value
* @param {LH.Audit.Details.OpportunityColumnHeading} heading
* @return {Element|null}
*/
_renderTableValue(value, heading) {
if (value === undefined || value === null) {
return null;
}
// First deal with the possible object forms of value.
if (typeof value === 'object') {
// The value's type overrides the heading's for this column.
switch (value.type) {
case 'code': {
return this._renderCode(value.value);
}
case 'link': {
return this._renderLink(value);
}
case 'node': {
return this.renderNode(value);
}
case 'numeric': {
return this._renderNumeric(value);
}
case 'source-location': {
return this.renderSourceLocation(value);
}
case 'url': {
return this.renderTextURL(value.value);
}
default: {
return this._renderUnknown(value.type, value);
}
}
}
// Next, deal with primitives.
switch (heading.valueType) {
case 'bytes': {
const numValue = Number(value);
return this._renderBytes({value: numValue, granularity: heading.granularity});
}
case 'code': {
const strValue = String(value);