blob: 87fc3a73978e18e7ce5dcc75f55a426230db5c2a [file] [log] [blame]
/**
* @license Copyright 2017 Google Inc. 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 */
const ELLIPSIS = '\u2026';
const NBSP = '\xa0';
const PASS_THRESHOLD = 0.75;
const RATINGS = {
PASS: {label: 'pass', minScore: PASS_THRESHOLD},
AVERAGE: {label: 'average', minScore: 0.45},
FAIL: {label: 'fail'},
ERROR: {label: 'error'},
};
class Util {
static get PASS_THRESHOLD() {
return PASS_THRESHOLD;
}
static get MS_DISPLAY_VALUE() {
return `%10d${NBSP}ms`;
}
/**
* @param {string|Array<string|number>=} displayValue
* @return {string}
*/
static formatDisplayValue(displayValue) {
if (typeof displayValue === 'string') return displayValue;
if (!displayValue) return '';
const replacementRegex = /%([0-9]*(\.[0-9]+)?d|s)/;
const template = /** @type {string} */ (displayValue[0]);
if (typeof template !== 'string') {
// First value should always be the format string, but we don't want to fail to build
// a report, return a placeholder.
return 'UNKNOWN';
}
let output = template;
for (const replacement of displayValue.slice(1)) {
if (!replacementRegex.test(output)) {
// eslint-disable-next-line no-console
console.warn('Too many replacements given');
break;
}
output = output.replace(replacementRegex, match => {
const granularity = Number(match.match(/[0-9.]+/)) || 1;
return match === '%s' ?
replacement.toLocaleString() :
(Math.round(Number(replacement) / granularity) * granularity).toLocaleString();
});
}
if (replacementRegex.test(output)) {
// eslint-disable-next-line no-console
console.warn('Not enough replacements given');
}
return output;
}
/**
* 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 'not-applicable':
return true;
case 'error':
case 'informative':
return false;
case 'numeric':
case 'binary':
default:
// Numeric audits that are within PASS_THRESHOLD will still show up with failing.
// For opportunities, we want to have them show up with other failing for contrast.
// For diagnostics, we sort by score so they'll be lowest priority.
return Number(audit.score) === 1;
}
}
/**
* 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 === 'not-applicable') {
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;
}
/**
* Format number.
* @param {number} number
* @param {number=} granularity Number of decimal places to include. Defaults to 0.1.
* @return {string}
*/
static formatNumber(number, granularity = 0.1) {
const coarseValue = Math.round(number / granularity) * granularity;
return coarseValue.toLocaleString();
}
/**
* @param {number} size
* @param {number=} granularity Controls how coarse the displayed value is, defaults to .01
* @return {string}
*/
static formatBytesToKB(size, granularity = 0.1) {
const kbs = (Math.round(size / 1024 / granularity) * granularity).toLocaleString();
return `${kbs}${NBSP}KB`;
}
/**
* @param {number} ms
* @param {number=} granularity Controls how coarse the displayed value is, defaults to 10
* @return {string}
*/
static formatMilliseconds(ms, granularity = 10) {
const coarseTime = Math.round(ms / granularity) * granularity;
return `${coarseTime.toLocaleString()}${NBSP}ms`;
}
/**
* @param {number} ms
* @param {number=} granularity Controls how coarse the displayed value is, defaults to 0.1
* @return {string}
*/
static formatSeconds(ms, granularity = 0.1) {
const coarseTime = Math.round(ms / 1000 / granularity) * granularity;
return `${coarseTime.toLocaleString()}${NBSP}s`;
}
/**
* Format time.
* @param {string} date
* @return {string}
*/
static formatDateTime(date) {
const options = {
month: 'short', day: 'numeric', year: 'numeric',
hour: 'numeric', minute: 'numeric', timeZoneName: 'short',
};
let formatter = new Intl.DateTimeFormat('en-US', options);
// Force UTC if runtime timezone could not be detected.
// See https://github.com/GoogleChrome/lighthouse/issues/1056
const tz = formatter.resolvedOptions().timeZone;
if (!tz || tz.toLowerCase() === 'etc/unknown') {
options.timeZone = 'UTC';
formatter = new Intl.DateTimeFormat('en-US', options);
}
return formatter.format(new Date(date));
}
/**
* Converts a time in milliseconds into a duration string, i.e. `1d 2h 13m 52s`
* @param {number} timeInMilliseconds
* @return {string}
*/
static formatDuration(timeInMilliseconds) {
let timeInSeconds = timeInMilliseconds / 1000;
if (Math.round(timeInSeconds) === 0) {
return 'None';
}
/** @type {Array<string>} */
const parts = [];
const unitLabels = /** @type {Object<string, number>} */ ({
d: 60 * 60 * 24,
h: 60 * 60,
m: 60,
s: 1,
});
Object.keys(unitLabels).forEach(label => {
const unit = unitLabels[label];
const numberOfUnits = Math.floor(timeInSeconds / unit);
if (numberOfUnits > 0) {
timeInSeconds -= numberOfUnits * unit;
parts.push(`${numberOfUnits}\xa0${label}`);
}
});
return parts.join(' ');
}
/**
* @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 {number} startTime
* @param {number} endTime
* @return {string}
*/
static chainDuration(startTime, endTime) {
return Util.formatNumber((endTime - startTime) * 1000);
}
/**
* @param {LH.Config.Settings} settings
* @return {Array<{name: string, description: string}>}
*/
static getEnvironmentDisplayValues(settings) {
const emulationDesc = Util.getEmulationDescriptions(settings);
return [
{
name: 'Device',
description: emulationDesc.deviceEmulation,
},
{
name: 'Network throttling',
description: emulationDesc.networkThrottling,
},
{
name: 'CPU throttling',
description: emulationDesc.cpuThrottling,
},
];
}
/**
* @param {LH.Config.Settings} settings
* @return {{deviceEmulation: string, networkThrottling: string, cpuThrottling: string, summary: string}}
*/
static getEmulationDescriptions(settings) {
let cpuThrottling;
let networkThrottling;
let summary;
const throttling = settings.throttling;
switch (settings.throttlingMethod) {
case 'provided':
cpuThrottling = 'Provided by environment';
networkThrottling = 'Provided by environment';
summary = 'No throttling applied';
break;
case 'devtools': {
const {cpuSlowdownMultiplier, requestLatencyMs} = throttling;
cpuThrottling = `${Util.formatNumber(cpuSlowdownMultiplier)}x slowdown (DevTools)`;
networkThrottling = `${Util.formatNumber(requestLatencyMs)}${NBSP}ms HTTP RTT, ` +
`${Util.formatNumber(throttling.downloadThroughputKbps)}${NBSP}Kbps down, ` +
`${Util.formatNumber(throttling.uploadThroughputKbps)}${NBSP}Kbps up (DevTools)`;
summary = 'Throttled Fast 3G network';
break;
}
case 'simulate': {
const {cpuSlowdownMultiplier, rttMs, throughputKbps} = throttling;
cpuThrottling = `${Util.formatNumber(cpuSlowdownMultiplier)}x slowdown (Simulated)`;
networkThrottling = `${Util.formatNumber(rttMs)}${NBSP}ms TCP RTT, ` +
`${Util.formatNumber(throughputKbps)}${NBSP}Kbps throughput (Simulated)`;
summary = 'Simulated Fast 3G network';
break;
}
default:
cpuThrottling = 'Unknown';
networkThrottling = 'Unknown';
summary = 'Unknown';
}
const deviceEmulation = settings.disableDeviceEmulation ? 'No emulation' : 'Emulated Nexus 5X';
return {
deviceEmulation,
cpuThrottling,
networkThrottling,
summary: `${deviceEmulation}, ${summary}`,
};
}
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = Util;
} else {
self.Util = Util;
}