blob: 66555ffdd9d7f47a6ac9b0917f14d141e02192f3 [file]
// Copyright 2023 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 type * as Protocol from '../generated/protocol.js';
import * as TraceEngine from '../models/trace/trace.js';
import * as Timeline from '../panels/timeline/timeline.js';
import * as TraceBounds from '../services/trace_bounds/trace_bounds.js';
// We maintain two caches:
// 1. The file contents JSON.parsed for a given trace file.
// 2. The trace engine models for a given file (used by the traceEngine function)
// Both the file contents and the model data are not expected to change during
// the lifetime of an instance of DevTools, so they are safe to cache and
// re-use across tests to avoid extra time spent loading and parsing the same
// inputs.
// In the future once the data layer migration is complete, we can hopefully
// simplify this into one method that loads the new engine and none of the old
// ones.
const fileContentsCache = new Map<string, TraceEngine.Types.File.Contents>();
// The new engine cache is a map of maps of:
// trace file name => trace engine configuration => trace data
//
// The first map is a Map of string (which is the name of the trace file) to a
// new map, where the key is the trace engine configuration stringified.
// This ensures that we cache as much as we can, but if you load the same trace
// file with different trace engine configurations, we will not use the cache
// and will reparse. This is required as some of the settings and experiments
// change if events are kept and dropped.
const traceEngineCache = new Map<string, Map<string, {
traceData: TraceEngine.Handlers.Types.TraceParseData,
insights: TraceEngine.Insights.Types.TraceInsightData | null,
model: TraceEngine.TraceModel.Model,
}>>();
export interface TraceEngineLoaderOptions {
initTraceBounds: boolean;
}
/**
* Loads trace files defined as fixtures in front_end/panels/timeline/fixtures/traces.
*
* Will automatically cache the results to save time processing the same trace
* multiple times in a run of the test suite.
**/
export class TraceLoader {
/**
* Parsing some trace files easily takes up more than our default Mocha timeout
* which is 2seconds. So for most tests that include parsing a trace, we have to
* increase the timeout. We use this function to ensure we set a consistent
* timeout across all trace model tests.
* The context might be null when we only render a component example.
**/
static setTestTimeout(context: Mocha.Context|Mocha.Suite|null): void {
if (!context || context.timeout() >= 10_000) {
return;
}
context?.timeout(10_000);
}
/**
* Loads a trace file into memory and returns its contents after
* JSON.parse-ing them
*
**/
static async fixtureContents(context: Mocha.Context|Mocha.Suite|null, name: string):
Promise<TraceEngine.Types.File.Contents> {
TraceLoader.setTestTimeout(context);
const cached = fileContentsCache.get(name);
if (cached) {
return cached;
}
// Required URLs differ across the component server and the unit tests, so try both.
const urlForTest = new URL(`../front_end/panels/timeline/fixtures/traces/${name}`, window.location.origin);
const urlForComponentExample =
new URL(`../front_end/panels/timeline/fixtures/traces/${name}`, window.location.origin);
if (window.location.pathname.includes('ui/components/docs') ||
window.location.pathname.includes('ui\\components\\docs')) {
const contents = await loadTraceFileFromURL(urlForComponentExample);
fileContentsCache.set(name, contents);
return contents;
}
const contents = await loadTraceFileFromURL(urlForTest);
fileContentsCache.set(name, contents);
return contents;
}
/**
* Load an array of raw events from the trace file.
* Will default to typing those events using the types from TraceEngine, but
* can be overriden by passing the legacy EventPayload type as the generic.
**/
static async rawEvents(context: Mocha.Context|Mocha.Suite|null, name: string):
Promise<readonly TraceEngine.Types.TraceEvents.TraceEventData[]> {
const contents = await TraceLoader.fixtureContents(context, name);
const events = 'traceEvents' in contents ? contents.traceEvents : contents;
TraceEngine.Helpers.SyntheticEvents.SyntheticEventsManager.initAndActivate(events);
return events;
}
/**
* Load an array of raw events from the trace file.
* Will default to typing those events using the types from TraceEngine, but
* can be overriden by passing the legacy EventPayload type as the generic.
**/
static async rawCPUProfile(context: Mocha.Context|Mocha.Suite|null, name: string):
Promise<Protocol.Profiler.Profile> {
const contents = await TraceLoader.fixtureContents(context, name) as unknown as Protocol.Profiler.Profile;
return contents;
}
/**
* Executes only the new trace engine on the fixture and returns the resulting parsed data.
*
* @param context The Mocha test context. |allModelsFromFile| function easily
* takes up more than our default Mocha timeout, which is 2s. So we have to
* increase this test's timeout. It might be null when we only render a
* component example.
*
* @param file The name of the trace file to be loaded.
* The trace file should be in ../panels/timeline/fixtures/traces folder.
*
* @param options Additional trace options.
* @param options.initTraceBounds (defaults to `true`) after the trace is
* loaded, the TraceBounds manager will automatically be initialised using
* the bounds from the trace.
*
* @param config The config the new trace engine should run with. Optional,
* will fall back to the Default config if not provided.
*/
static async traceEngine(
context: Mocha.Context|Mocha.Suite|null, name: string,
config: TraceEngine.Types.Configuration.Configuration = TraceEngine.Types.Configuration.defaults()): Promise<{
traceData: TraceEngine.Handlers.Types.TraceParseData,
insights: TraceEngine.Insights.Types.TraceInsightData|null,
}> {
// Force the TraceBounds to be reset to empty. This ensures that in
// tests where we are using the new engine data we don't accidentally
// rely on the fact that a previous test has set the BoundsManager.
TraceBounds.TraceBounds.BoundsManager.instance({forceNew: true});
const configCacheKey = TraceEngine.Types.Configuration.configToCacheKey(config);
const fromCache = traceEngineCache.get(name)?.get(configCacheKey);
if (fromCache) {
TraceLoader.initTraceBoundsManager(fromCache.traceData);
Timeline.ModificationsManager.ModificationsManager.initAndActivateModificationsManager(fromCache.model, 0);
// This init step is usually done in model.parse(), but as we loaded from
// the cache here, we manually run it.
// The SyntheticEventsManager caches instances based on the rawEvents()
// array, so we can safely do this even if we have already created an
// instance for this trace before - the old one will be re-used, rather
// than creating a new one.
const rawEvents = fromCache.model.rawTraceEvents();
if (rawEvents) {
TraceEngine.Helpers.SyntheticEvents.SyntheticEventsManager.initAndActivate(
rawEvents,
);
}
return {traceData: fromCache.traceData, insights: fromCache.insights};
}
const fileContents = await TraceLoader.fixtureContents(context, name);
const traceEngineData =
await TraceLoader.executeTraceEngineOnFileContents(fileContents, /* emulate fresh recording */ false, config);
const cacheByName = traceEngineCache.get(name) || new Map<string, {
traceData: TraceEngine.Handlers.Types.TraceParseData,
insights: TraceEngine.Insights.Types.TraceInsightData | null,
model: TraceEngine.TraceModel.Model,
}>();
cacheByName.set(configCacheKey, traceEngineData);
traceEngineCache.set(name, cacheByName);
TraceLoader.initTraceBoundsManager(traceEngineData.traceData);
Timeline.ModificationsManager.ModificationsManager.initAndActivateModificationsManager(traceEngineData.model, 0);
return {
traceData: traceEngineData.traceData,
insights: traceEngineData.insights,
};
}
/**
* Initialise the BoundsManager with the bounds from a trace.
* This isn't always required, but some of our code - particularly at the UI
* level - rely on this being set. This is always set in the actual panel, but
* parsing a trace in a test does not automatically set it.
**/
static initTraceBoundsManager(data: TraceEngine.Handlers.Types.TraceParseData): void {
TraceBounds.TraceBounds.BoundsManager
.instance({
forceNew: true,
})
.resetWithNewBounds(data.Meta.traceBounds);
}
static async executeTraceEngineOnFileContents(
contents: TraceEngine.Types.File.Contents, emulateFreshRecording = false,
traceEngineConfig?: TraceEngine.Types.Configuration.Configuration): Promise<{
model: TraceEngine.TraceModel.Model,
metadata: TraceEngine.Types.File.MetaData,
traceData: TraceEngine.Handlers.Types.TraceParseData,
insights: TraceEngine.Insights.Types.TraceInsightData|null,
}> {
const events = 'traceEvents' in contents ? contents.traceEvents : contents;
const metadata = 'metadata' in contents ? contents.metadata : {};
return new Promise((resolve, reject) => {
const model = TraceEngine.TraceModel.Model.createWithAllHandlers(traceEngineConfig);
model.addEventListener(TraceEngine.TraceModel.ModelUpdateEvent.eventName, (event: Event) => {
const {data} = event as TraceEngine.TraceModel.ModelUpdateEvent;
// When we receive the final update from the model, update the recording
// state back to waiting.
if (TraceEngine.TraceModel.isModelUpdateDataComplete(data)) {
const metadata = model.metadata(0);
const traceData = model.traceParsedData(0);
const insights = model.traceInsights(0);
if (metadata && traceData) {
resolve({
model,
metadata,
traceData,
insights,
});
} else {
reject(new Error('Unable to load trace'));
}
}
});
void model.parse(events, {metadata, isFreshRecording: emulateFreshRecording}).catch(e => console.error(e));
});
}
}
// Below this point are private methods used in the TraceLoader class. These
// are purposefully not exported, you should use one of the static methods
// defined above.
async function loadTraceFileFromURL(url: URL): Promise<TraceEngine.Types.File.Contents> {
const response = await fetch(url);
if (response.status !== 200) {
throw new Error(`Unable to load ${url}`);
}
const contentType = response.headers.get('content-type');
const isGzipEncoded = contentType !== null && contentType.includes('gzip');
let buffer = await response.arrayBuffer();
if (isGzipEncoded) {
buffer = await decodeGzipBuffer(buffer);
}
const decoder = new TextDecoder('utf-8');
const contents = JSON.parse(decoder.decode(buffer)) as TraceEngine.Types.File.Contents;
return contents;
}
interface CompressionStream extends ReadableWritablePair<Uint8Array, Uint8Array> {}
interface DecompressionStream extends ReadableWritablePair<Uint8Array, Uint8Array> {}
declare const CompressionStream: {
prototype: CompressionStream,
new (type: string): CompressionStream,
};
declare const DecompressionStream: {
prototype: DecompressionStream,
new (type: string): DecompressionStream,
};
function codec(buffer: ArrayBuffer, codecStream: CompressionStream|DecompressionStream): Promise<ArrayBuffer> {
const {readable, writable} = new TransformStream();
const codecReadable = readable.pipeThrough(codecStream);
const writer = writable.getWriter();
void writer.write(buffer);
void writer.close();
// Wrap in a response for convenience.
const response = new Response(codecReadable);
return response.arrayBuffer();
}
function decodeGzipBuffer(buffer: ArrayBuffer): Promise<ArrayBuffer> {
return codec(buffer, new DecompressionStream('gzip'));
}