lighthouse
Version:
Automated auditing, performance metrics, and best practices for the web.
262 lines (238 loc) • 10.3 kB
JavaScript
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import log from 'lighthouse-logger';
import * as i18n from '../lib/i18n/i18n.js';
import * as TraceEngine from '../lib/trace-engine.js';
import {makeComputedArtifact} from './computed-artifact.js';
import {CumulativeLayoutShift} from './metrics/cumulative-layout-shift.js';
import {ProcessedTrace} from './processed-trace.js';
import SDK from '../lib/cdt/SDK.js';
import * as LH from '../../types/lh.js';
/**
* @fileoverview Processes trace with the shared trace engine.
*/
class TraceEngineResult {
/**
* @param {LH.TraceEvent[]} _traceEvents
* @param {LH.Audit.Context['settings']} settings
* @param {LH.Artifacts['SourceMaps']} SourceMaps
* @return {Promise<LH.Artifacts.TraceEngineResult>}
*/
static async runTraceEngine(_traceEvents, settings, SourceMaps) {
const processor = new TraceEngine.TraceProcessor(TraceEngine.TraceHandlers);
const traceEvents =
/** @type {import('@paulirish/trace_engine').Types.Events.Event[]} */ (_traceEvents);
const lanternSettings = {};
if (settings.throttlingMethod) lanternSettings.throttlingMethod = settings.throttlingMethod;
if (settings.throttling) lanternSettings.throttling = settings.throttling;
if (settings.precomputedLanternData) {
lanternSettings.precomputedLanternData = settings.precomputedLanternData;
}
// SyntheticEventsManager must be initialized prior to processor.parse().
TraceEngine.Helpers.SyntheticEvents.SyntheticEventsManager.createAndActivate(traceEvents);
await processor.parse(traceEvents, {
logger: {
start(id) {
const logId = `lh:computed:TraceEngineResult:${id}`;
log.time({msg: `Trace Engine ${id}`, id: logId});
},
end(id) {
const logId = `lh:computed:TraceEngineResult:${id}`;
log.timeEnd({msg: `Trace Engine ${id}`, id: logId});
},
},
lanternSettings,
async resolveSourceMap(params) {
const sourceMap = SourceMaps.find(sm => sm.scriptId === params.scriptId);
if (!sourceMap || !sourceMap.map) {
return null;
}
const compiledUrl = sourceMap.scriptUrl || 'compiled.js';
const mapUrl = sourceMap.sourceMapUrl || 'compiled.js.map';
return new SDK.SourceMap(compiledUrl, mapUrl, sourceMap.map);
},
});
if (!processor.parsedTrace) throw new Error('No data');
if (!processor.insights) throw new Error('No insights');
this.localizeInsights(processor.insights);
return {parsedTrace: processor.parsedTrace, insights: processor.insights};
}
/**
* Adapts the given DevTools function that returns a localized string to one
* that returns a LH.IcuMessage.
*
* @template {any[]} Args
* @template {import('../lib/trace-engine.js').DevToolsIcuMessage} Ret
* @param {ReturnType<i18n.createIcuMessageFn>} str_
* @param {(...args: Args) => Ret} fn
* @return {(...args: Args) => LH.IcuMessage}
*/
static localizeFunction(str_, fn) {
return (...args) => this.localize(str_, fn(...args));
}
/**
* Converts the input parameters given to `i18nString` usages in DevTools to a
* LH.IcuMessage.
*
* @param {ReturnType<i18n.createIcuMessageFn>} str_
* @param {import('../lib/trace-engine.js').DevToolsIcuMessage} traceEngineI18nObject
* @return {LH.IcuMessage}
*/
static localize(str_, traceEngineI18nObject) {
/** @type {Record<string, string|number>|undefined} */
let values;
if (traceEngineI18nObject.values) {
values = {};
for (const [key, value] of Object.entries(traceEngineI18nObject.values)) {
if (value && typeof value === 'object' && '__i18nBytes' in value) {
values[key] = value.__i18nBytes;
// TODO: use an actual byte formatter. Right now, this shows the exact number of bytes.
} else if (value && typeof value === 'object' && '__i18nMillis' in value) {
values[key] = `${value.__i18nMillis} ms`;
// TODO: use an actual time formatter.
} else if (value && typeof value === 'object' && 'i18nId' in value) {
// TODO: add support for str_ values to be IcuMessage. For now, we translate it here.
// This means that locale swapping won't work for this portion of the IcuMessage.
// @ts-expect-error
values[key] = str_(value.i18nId, value.values).formattedDefault;
} else {
values[key] = value;
}
}
}
return str_(traceEngineI18nObject.i18nId, values);
}
/**
* Recursively finds all DevToolsIcuMessage objects and replaces them with LH.IcuMessage.
*
* @param {ReturnType<i18n.createIcuMessageFn>} str_
* @param {object} object
*/
static localizeObject(str_, object) {
/**
* Execute `cb(traceEngineI18nObject)` on every i18n object, recursively. The cb return
* value replaces traceEngineI18nObject.
* @param {any} obj
* @param {(traceEngineI18nObject: {i18nId: string, values?: {}}) => LH.IcuMessage} cb
* @param {Set<object>} seen
*/
function recursiveReplaceLocalizableStrings(obj, cb, seen) {
if (seen.has(seen)) {
return;
}
seen.add(obj);
if (obj instanceof Map) {
for (const [key, value] of obj) {
if (value && typeof value === 'object' && 'i18nId' in value) {
obj.set(key, cb(value));
} else {
recursiveReplaceLocalizableStrings(value, cb, seen);
}
}
} else if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
Object.keys(obj).forEach(key => {
const value = obj[key];
if (value && typeof value === 'object' && 'i18nId' in value) {
obj[key] = cb(value);
} else {
recursiveReplaceLocalizableStrings(value, cb, seen);
}
});
} else if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
const value = obj[i];
if (value && typeof value === 'object' && 'i18nId' in value) {
obj[i] = cb(value);
} else {
recursiveReplaceLocalizableStrings(value, cb, seen);
}
}
}
}
// Pass `{i18nId: string, values?: {}}` through Lighthouse's i18n pipeline.
// This is equivalent to if we directly did `str_(UIStrings.whatever, ...)`
recursiveReplaceLocalizableStrings(object, (traceEngineI18nObject) => {
let values = traceEngineI18nObject.values;
if (values) {
values = structuredClone(values);
for (const [key, value] of Object.entries(values)) {
if (value && typeof value === 'object' && '__i18nBytes' in value) {
// @ts-expect-error
values[key] = value.__i18nBytes;
// TODO: use an actual byte formatter. Right now, this shows the exact number of bytes.
} else if (value && typeof value === 'object' && '__i18nMillis' in value) {
// @ts-expect-error
values[key] = `${value.__i18nMillis} ms`;
// TODO: use an actual time formatter.
} else if (value && typeof value === 'object' && 'i18nId' in value) {
// TODO: add support for str_ values to be IcuMessage.
// @ts-expect-error
values[key] = str_(value.i18nId, value.values).formattedDefault;
}
}
}
return str_(traceEngineI18nObject.i18nId, values);
}, new Set());
}
/**
* @param {import('@paulirish/trace_engine/models/trace/insights/types.js').TraceInsightSets} insightSets
*/
static localizeInsights(insightSets) {
for (const insightSet of insightSets.values()) {
for (const [name, model] of Object.entries(insightSet.model)) {
if (model instanceof Error) {
continue;
}
/** @type {Record<string, string>} */
let traceEngineUIStrings;
if (name in TraceEngine.Insights.Models) {
const nameAsKey = /** @type {keyof typeof TraceEngine.Insights.Models} */ (name);
traceEngineUIStrings = TraceEngine.Insights.Models[nameAsKey].UIStrings;
} else {
throw new Error(`insight missing UIStrings: ${name}`);
}
const key = `node_modules/@paulirish/trace_engine/models/trace/insights/${name}.js`;
const str_ = i18n.createIcuMessageFn(key, traceEngineUIStrings);
this.localizeObject(str_, model);
}
}
}
/**
* @param {{trace: LH.Trace, settings: LH.Audit.Context['settings'], SourceMaps: LH.Artifacts['SourceMaps']}} data
* @param {LH.Artifacts.ComputedContext} context
* @return {Promise<LH.Artifacts.TraceEngineResult>}
*/
static async compute_(data, context) {
// In CumulativeLayoutShift.getLayoutShiftEvents we handle a bug in Chrome layout shift
// trace events re: changing the viewport emulation resulting in incorrectly set `had_recent_input`.
// Below, the same logic is applied to set those problem events' `had_recent_input` to false, so that
// the trace engine will count them.
// The trace events are copied-on-write, so the original trace remains unmodified.
const processedTrace = await ProcessedTrace.request(data.trace, context);
const layoutShiftEvents = new Set(
CumulativeLayoutShift.getLayoutShiftEvents(processedTrace).map(e => e.event));
// Avoid modifying the input array.
const traceEvents = [...data.trace.traceEvents];
for (let i = 0; i < traceEvents.length; i++) {
let event = traceEvents[i];
if (event.name !== 'LayoutShift') continue;
if (!event.args.data) continue;
const isConsidered = layoutShiftEvents.has(event);
if (event.args.data.had_recent_input && isConsidered) {
event = JSON.parse(JSON.stringify(event));
// @ts-expect-error impossible for data to be missing.
event.args.data.had_recent_input = false;
traceEvents[i] = event;
}
}
const result =
await TraceEngineResult.runTraceEngine(traceEvents, data.settings, data.SourceMaps);
return result;
}
}
const TraceEngineResultComputed =
makeComputedArtifact(TraceEngineResult, ['trace', 'settings', 'SourceMaps']);
export {TraceEngineResultComputed as TraceEngineResult};