| // 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 * as SDK from '../core/sdk/sdk.js'; |
| import type * as Protocol from '../generated/protocol.js'; |
| import * as Trace 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, Trace.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, { |
| parsedTrace: Trace.Handlers.Types.ParsedTrace, |
| insights: Trace.Insights.Types.TraceInsightSets | null, |
| metadata: Trace.Types.File.MetaData | null, |
| model: Trace.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. |
| **/ |
| static setTestTimeout(context: Mocha.Context|Mocha.Suite): void { |
| // Some traces take a long time to process, especially on our CQ machines. |
| // The trace that takes the longest on my Mac M1 Pro is ~3s (yahoo-news.json.gz). |
| // In CQ, that same trace takes ~10s (linux), ~7.5s (mac), ~11.5s (windows). |
| if (context.timeout() > 0) { |
| context.timeout(Math.max(context.timeout(), 30000)); |
| } |
| } |
| |
| /** |
| * 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<Trace.Types.File.Contents> { |
| if (context) { |
| 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(`../panels/timeline/fixtures/traces/${name}`, import.meta.url); |
| |
| const contents = await loadTraceFileFromURL(urlForTest); |
| fileContentsCache.set(name, contents); |
| return contents; |
| } |
| |
| /** |
| * Load an array of raw events from the trace file. |
| **/ |
| static async rawEvents(context: Mocha.Context|Mocha.Suite|null, name: string): |
| Promise<readonly Trace.Types.Events.Event[]> { |
| const contents = await TraceLoader.fixtureContents(context, name); |
| |
| const events = 'traceEvents' in contents ? contents.traceEvents : contents; |
| return events; |
| } |
| |
| /** |
| * Load the metadata from a trace file (throws if not present). |
| **/ |
| static async metadata(context: Mocha.Context|Mocha.Suite|null, name: string): Promise<Trace.Types.File.MetaData> { |
| const contents = await TraceLoader.fixtureContents(context, name); |
| |
| const metadata = 'metadata' in contents ? contents.metadata : null; |
| if (!metadata) { |
| throw new Error('expected metadata but found none'); |
| } |
| |
| return metadata; |
| } |
| |
| /** |
| * Load an array of raw events from the trace file. |
| * Will default to typing those events using the types from Trace Engine, 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. Processing a trace can easily |
| * takes up longer than the 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. See TraceLoader.setTestTimeout. |
| * |
| * @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: Trace.Types.Configuration.Configuration = Trace.Types.Configuration.defaults()): Promise<{ |
| parsedTrace: Trace.Handlers.Types.ParsedTrace, |
| insights: Trace.Insights.Types.TraceInsightSets|null, |
| metadata: Trace.Types.File.MetaData|null, |
| }> { |
| if (context) { |
| TraceLoader.setTestTimeout(context); |
| } |
| // 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 = Trace.Types.Configuration.configToCacheKey(config); |
| |
| const fromCache = traceEngineCache.get(name)?.get(configCacheKey); |
| |
| // If we have results from the cache, we use those to ensure we keep the |
| // tests speedy and don't re-parse trace files over and over again. |
| if (fromCache) { |
| await wrapInTimeout(context, () => { |
| const syntheticEventsManager = fromCache.model.syntheticTraceEventsManager(0); |
| if (!syntheticEventsManager) { |
| throw new Error('Cached trace engine result did not have a synthetic events manager instance'); |
| } |
| Trace.Helpers.SyntheticEvents.SyntheticEventsManager.activate(syntheticEventsManager); |
| TraceLoader.initTraceBoundsManager(fromCache.parsedTrace); |
| Timeline.ModificationsManager.ModificationsManager.reset(); |
| Timeline.ModificationsManager.ModificationsManager.initAndActivateModificationsManager(fromCache.model, 0); |
| }, 4_000, 'Initializing state for cached trace'); |
| return {parsedTrace: fromCache.parsedTrace, insights: fromCache.insights, metadata: fromCache.metadata}; |
| } |
| |
| const fileContents = await wrapInTimeout(context, async () => { |
| return await TraceLoader.fixtureContents(context, name); |
| }, 15_000, `Loading fixtureContents for ${name}`); |
| |
| const parsedTraceData = await wrapInTimeout(context, async () => { |
| return await TraceLoader.executeTraceEngineOnFileContents( |
| fileContents, /* emulate fresh recording */ false, config); |
| }, 15_000, `Executing traceEngine for ${name}`); |
| |
| const cacheByName = traceEngineCache.get(name) ?? new Map<string, { |
| parsedTrace: Trace.Handlers.Types.ParsedTrace, |
| insights: Trace.Insights.Types.TraceInsightSets | null, |
| metadata: Trace.Types.File.MetaData | null, |
| model: Trace.TraceModel.Model, |
| }>(); |
| cacheByName.set(configCacheKey, parsedTraceData); |
| traceEngineCache.set(name, cacheByName); |
| |
| TraceLoader.initTraceBoundsManager(parsedTraceData.parsedTrace); |
| await wrapInTimeout(context, () => { |
| Timeline.ModificationsManager.ModificationsManager.reset(); |
| Timeline.ModificationsManager.ModificationsManager.initAndActivateModificationsManager(parsedTraceData.model, 0); |
| }, 5_000, `Creating modification manager for ${name}`); |
| return { |
| parsedTrace: parsedTraceData.parsedTrace, |
| insights: parsedTraceData.insights, |
| metadata: parsedTraceData.metadata, |
| }; |
| } |
| |
| /** |
| * 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: Trace.Handlers.Types.ParsedTrace): void { |
| TraceBounds.TraceBounds.BoundsManager |
| .instance({ |
| forceNew: true, |
| }) |
| .resetWithNewBounds(data.Meta.traceBounds); |
| } |
| |
| static async executeTraceEngineOnFileContents( |
| contents: Trace.Types.File.Contents, emulateFreshRecording = false, |
| traceEngineConfig?: Trace.Types.Configuration.Configuration): Promise<{ |
| model: Trace.TraceModel.Model, |
| metadata: Trace.Types.File.MetaData, |
| parsedTrace: Trace.Handlers.Types.ParsedTrace, |
| insights: Trace.Insights.Types.TraceInsightSets|null, |
| }> { |
| const events = 'traceEvents' in contents ? contents.traceEvents : contents; |
| const metadata = 'metadata' in contents ? contents.metadata : {}; |
| return await new Promise((resolve, reject) => { |
| const model = Trace.TraceModel.Model.createWithAllHandlers(traceEngineConfig); |
| model.addEventListener(Trace.TraceModel.ModelUpdateEvent.eventName, (event: Event) => { |
| const {data} = event as Trace.TraceModel.ModelUpdateEvent; |
| |
| // When we receive the final update from the model, update the recording |
| // state back to waiting. |
| if (Trace.TraceModel.isModelUpdateDataComplete(data)) { |
| const metadata = model.metadata(0); |
| const parsedTrace = model.parsedTrace(0); |
| const insights = model.traceInsights(0); |
| if (metadata && parsedTrace) { |
| resolve({ |
| model, |
| metadata, |
| parsedTrace, |
| insights, |
| }); |
| } else { |
| reject(new Error('Unable to load trace')); |
| } |
| } |
| }); |
| |
| void model |
| .parse(events, { |
| metadata, |
| isFreshRecording: emulateFreshRecording, |
| async resolveSourceMap(params) { |
| const {sourceUrl, sourceMapUrl, cachedRawSourceMap} = params; |
| |
| if (cachedRawSourceMap) { |
| return new SDK.SourceMap.SourceMap(sourceUrl, sourceMapUrl, cachedRawSourceMap); |
| } |
| |
| if (sourceMapUrl.startsWith('data:')) { |
| const rawSourceMap = await (await fetch(sourceMapUrl)).json(); |
| return new SDK.SourceMap.SourceMap(sourceUrl, sourceMapUrl, rawSourceMap); |
| } |
| |
| return null; |
| }, |
| }) |
| .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<Trace.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?.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 Trace.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')); |
| } |
| |
| export async function fetchFixture(url: URL): Promise<string> { |
| 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?.includes('gzip'); |
| let buffer = await response.arrayBuffer(); |
| if (isGzipEncoded) { |
| buffer = await decodeGzipBuffer(buffer); |
| } |
| const decoder = new TextDecoder('utf-8'); |
| const contents = decoder.decode(buffer); |
| return contents; |
| } |
| |
| /** |
| * Wraps an async Promise with a timeout. We use this to break down and |
| * instrument `TraceLoader` to understand on CQ where timeouts occur. |
| * |
| * @param asyncPromise The Promise representing the async operation to be timed. |
| * @param timeoutMs The timeout in milliseconds. |
| * @param stepName An identifier for the step (for logging). |
| * @returns A promise that resolves with the operation's result, or rejects if it times out. |
| */ |
| async function wrapInTimeout<T>( |
| mochaContext: Mocha.Context|Mocha.Suite|null, callback: () => Promise<T>| T, timeoutMs: number, |
| stepName: string): Promise<T> { |
| const timeout = Promise.withResolvers<void>(); |
| const timeoutId = setTimeout(() => { |
| let testTitle = '(unknown test)'; |
| if (mochaContext) { |
| if (isMochaContext(mochaContext)) { |
| testTitle = mochaContext.currentTest?.fullTitle() ?? testTitle; |
| } else { |
| testTitle = mochaContext.fullTitle(); |
| } |
| } |
| console.error(`TraceLoader: [${stepName}]: took longer than ${timeoutMs}ms in test "${testTitle}"`); |
| timeout.reject(new Error(`Timeout for TraceLoader: '${stepName}' after ${timeoutMs}ms.`)); |
| }, timeoutMs); |
| |
| // Race the original promise against the timeout promise |
| try { |
| const cbResult = await Promise.race([callback(), timeout.promise]); |
| timeout.resolve(); |
| return cbResult as T; |
| } finally { |
| // Clear the timeout if the original promise resolves/rejects, |
| // or if the timeout promise wins the race. |
| clearTimeout(timeoutId); |
| } |
| } |
| |
| function isMochaContext(arg: unknown): arg is Mocha.Context { |
| return typeof arg === 'object' && arg !== null && 'currentTest' in arg; |
| } |