| // Copyright 2023 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import type {Chrome} from '../../../extension-api/ExtensionAPI.js'; |
| import * as Platform from '../../core/platform/platform.js'; |
| import * as SDK from '../../core/sdk/sdk.js'; |
| import type * as Protocol from '../../generated/protocol.js'; |
| import * as Bindings from '../../models/bindings/bindings.js'; |
| import * as Trace from '../../models/trace/trace.js'; |
| import * as Workspace from '../../models/workspace/workspace.js'; |
| import {createTarget} from '../../testing/EnvironmentHelpers.js'; |
| import {TestPlugin} from '../../testing/LanguagePluginHelpers.js'; |
| import { |
| describeWithMockConnection, |
| } from '../../testing/MockConnection.js'; |
| import {MockProtocolBackend} from '../../testing/MockScopeChain.js'; |
| import {encodeSourceMap} from '../../testing/SourceMapEncoder.js'; |
| import {loadBasicSourceMapExample} from '../../testing/SourceMapHelpers.js'; |
| import { |
| makeMockRendererHandlerData, |
| makeMockSamplesHandlerData, |
| makeProfileCall, |
| } from '../../testing/TraceHelpers.js'; |
| import {TraceLoader} from '../../testing/TraceLoader.js'; |
| |
| import * as TraceSourceMapsResolver from './trace_source_maps_resolver.js'; |
| |
| const {SourceMapsResolver, SourceMappingsUpdated} = TraceSourceMapsResolver; |
| |
| const {urlString} = Platform.DevToolsPath; |
| const MINIFIED_FUNCTION_NAME = 'minified'; |
| const AUTHORED_FUNCTION_NAME = 'someFunction'; |
| |
| export async function loadCodeLocationResolvingScenario(): Promise<{ |
| authoredScriptURL: string, |
| genScriptURL: string, |
| scriptId: Protocol.Runtime.ScriptId, |
| ignoreListedURL: string, |
| contentScriptURL: string, |
| contentScriptId: Protocol.Runtime.ScriptId, |
| }> { |
| const target = createTarget(); |
| |
| const targetManager = SDK.TargetManager.TargetManager.instance(); |
| const workspace = Workspace.Workspace.WorkspaceImpl.instance({forceNew: true}); |
| const resourceMapping = new Bindings.ResourceMapping.ResourceMapping(targetManager, workspace); |
| const ignoreListManager = Workspace.IgnoreListManager.IgnoreListManager.instance({forceNew: true}); |
| const debuggerWorkspaceBinding = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance({ |
| forceNew: true, |
| resourceMapping, |
| targetManager, |
| ignoreListManager, |
| workspace, |
| }); |
| |
| const backend = new MockProtocolBackend(); |
| |
| // The following mock data creates a source mapping from two authored |
| // scripts to a single complied script. One of the sources |
| // (ignored.ts) is marked as ignore listed in the source map. |
| const sourceRoot = 'http://example.com'; |
| const scriptInfo = { |
| url: `${sourceRoot}/test.out.js`, |
| content: 'function f(x) {\n console.log(x);\n}\nfunction ignore(y){\n console.log(y);\n}', |
| }; |
| const authoredScriptName = 'test.ts'; |
| const ignoredScriptName = 'ignored.ts'; |
| const authoredScriptURL = `${sourceRoot}/${authoredScriptName}`; |
| const ignoreListedScriptURL = `${sourceRoot}/${ignoredScriptName}`; |
| const sourceMap = encodeSourceMap( |
| [ |
| `0:9 => ${authoredScriptName}:0:1`, |
| `1:0 => ${authoredScriptName}:4:0`, |
| `1:2 => ${authoredScriptName}:4:2`, |
| `2:0 => ${authoredScriptName}:2:0`, |
| `3:0 => ${ignoredScriptName}:3:0`, |
| ], |
| sourceRoot); |
| sourceMap.sources = [authoredScriptURL, ignoreListedScriptURL]; |
| sourceMap.ignoreList = [1]; |
| const sourceMapInfo = { |
| url: `${scriptInfo.url}.map`, |
| content: sourceMap, |
| }; |
| |
| // The following mock data creates content script |
| const contentScriptInfo = { |
| url: `${sourceRoot}/content-script.js`, |
| content: 'console.log("content script loaded");', |
| isContentScript: true, |
| }; |
| |
| // Load mock data in devtools |
| const [, , script, , contentScript] = await Promise.all([ |
| debuggerWorkspaceBinding.waitForUISourceCodeAdded(urlString`${authoredScriptURL}`, target), |
| debuggerWorkspaceBinding.waitForUISourceCodeAdded(urlString`${ignoreListedScriptURL}`, target), |
| backend.addScript(target, scriptInfo, sourceMapInfo), |
| debuggerWorkspaceBinding.waitForUISourceCodeAdded(urlString`${contentScriptInfo.url}`, target), |
| backend.addScript(target, contentScriptInfo, null), |
| ]); |
| |
| return { |
| authoredScriptURL, |
| scriptId: script.scriptId, |
| genScriptURL: scriptInfo.url, |
| ignoreListedURL: ignoreListedScriptURL, |
| contentScriptURL: contentScriptInfo.url, |
| contentScriptId: contentScript.scriptId, |
| }; |
| } |
| |
| function parsedTraceFromProfileCalls(profileCalls: Trace.Types.Events.SyntheticProfileCall[]): |
| Trace.TraceModel.ParsedTrace { |
| const workersData: Trace.Handlers.ModelHandlers.Workers.WorkersData = { |
| workerSessionIdEvents: [], |
| workerIdByThread: new Map(), |
| workerURLById: new Map(), |
| }; |
| // This only includes data used in the SourceMapsResolver |
| const data = { |
| Samples: makeMockSamplesHandlerData(profileCalls), |
| Workers: workersData, |
| Renderer: makeMockRendererHandlerData(profileCalls), |
| NetworkRequests: |
| {entityMappings: {entityByEvent: new Map(), eventsByEntity: new Map(), createdEntityCache: new Map()}}, |
| Meta: {mainFrameURL: 'https://example.com', navigationsByNavigationId: new Map()}, |
| } as Trace.Handlers.Types.HandlerData; |
| |
| return {data} as Trace.TraceModel.ParsedTrace; |
| } |
| |
| describeWithMockConnection('SourceMapsResolver', () => { |
| describe('function name resolving', () => { |
| let target: SDK.Target.Target; |
| let script: SDK.Script.Script; |
| let parsedTrace: Trace.TraceModel.ParsedTrace; |
| let profileCallForNameResolving: Trace.Types.Events.SyntheticProfileCall; |
| |
| beforeEach(async function() { |
| target = createTarget(); |
| script = (await loadBasicSourceMapExample(target)).script; |
| |
| profileCallForNameResolving = |
| makeProfileCall('function', 10, 100, Trace.Types.Events.ProcessID(1), Trace.Types.Events.ThreadID(1)); |
| |
| profileCallForNameResolving.callFrame = { |
| columnNumber: 51, |
| functionName: 'minified', |
| lineNumber: 0, |
| scriptId: script.scriptId, |
| url: 'file://gen.js', |
| }; |
| parsedTrace = parsedTraceFromProfileCalls([profileCallForNameResolving]); |
| }); |
| |
| it('renames nodes from the profile models when the corresponding scripts and source maps have loaded', |
| async function() { |
| const resolver = new SourceMapsResolver(parsedTrace); |
| |
| // Test the node's name is minified before the script and source maps load. |
| assert.strictEqual( |
| Trace.Handlers.ModelHandlers.Samples.getProfileCallFunctionName( |
| parsedTrace.data.Samples, profileCallForNameResolving), |
| MINIFIED_FUNCTION_NAME); |
| |
| await resolver.install(); |
| |
| // Now that the script and source map have loaded, test that the model has been automatically |
| // reparsed to resolve function names. |
| assert.strictEqual( |
| Trace.Handlers.ModelHandlers.Samples.getProfileCallFunctionName( |
| parsedTrace.data.Samples, profileCallForNameResolving), |
| AUTHORED_FUNCTION_NAME); |
| |
| // Ensure we populate the cache |
| assert.strictEqual( |
| SourceMapsResolver.resolvedCodeLocationForEntry(profileCallForNameResolving)?.name, |
| AUTHORED_FUNCTION_NAME); |
| }); |
| |
| it('resolves function names using a plugin when available', async () => { |
| const PLUGIN_FUNCTION_NAME = 'PLUGIN_FUNCTION_NAME'; |
| class Plugin extends TestPlugin { |
| constructor() { |
| super('InstrumentationBreakpoints'); |
| } |
| |
| override getFunctionInfo(_rawLocation: Chrome.DevTools.RawLocation): |
| Promise<{frames: Chrome.DevTools.FunctionInfo[], missingSymbolFiles?: string[]|undefined}> { |
| return Promise.resolve({frames: [{name: PLUGIN_FUNCTION_NAME}]}); |
| } |
| override handleScript(_: SDK.Script.Script) { |
| return true; |
| } |
| } |
| |
| const {pluginManager} = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance(); |
| pluginManager.addPlugin(new Plugin()); |
| const resolver = new SourceMapsResolver(parsedTrace); |
| await resolver.install(); |
| assert.strictEqual( |
| Trace.Handlers.ModelHandlers.Samples.getProfileCallFunctionName( |
| parsedTrace.data.Samples, profileCallForNameResolving), |
| PLUGIN_FUNCTION_NAME); |
| }); |
| }); |
| describe('code location resolving', () => { |
| it('correctly stores url mappings using source maps', async () => { |
| const {authoredScriptURL, genScriptURL, scriptId} = await loadCodeLocationResolvingScenario(); |
| const profileCallWithMappings = |
| makeProfileCall('function', 10, 100, Trace.Types.Events.ProcessID(1), Trace.Types.Events.ThreadID(1)); |
| const LINE_NUMBER = 0; |
| const COLUMN_NUMBER = 9; |
| profileCallWithMappings.callFrame = { |
| lineNumber: LINE_NUMBER, |
| columnNumber: COLUMN_NUMBER, |
| functionName: 'minified', |
| scriptId, |
| url: genScriptURL, |
| }; |
| |
| const profileCallWithNoMappings = |
| makeProfileCall('function', 10, 100, Trace.Types.Events.ProcessID(1), Trace.Types.Events.ThreadID(1)); |
| profileCallWithNoMappings.callFrame = { |
| // Purposefully pick a location for which there is no mapping |
| // in the source map we just added. |
| lineNumber: 0, |
| columnNumber: 1, |
| functionName: 'minified', |
| scriptId, |
| url: authoredScriptURL, |
| }; |
| |
| // For a profile call with mappings, it must return the mapped script. |
| const parsedTraceWithMappings = parsedTraceFromProfileCalls([profileCallWithMappings]); |
| const mapperWithMappings = new Trace.EntityMapper.EntityMapper(parsedTraceWithMappings); |
| let resolver = new SourceMapsResolver(parsedTraceWithMappings, mapperWithMappings); |
| await resolver.install(); |
| let sourceMappedURL = SourceMapsResolver.resolvedURLForEntry(parsedTraceWithMappings, profileCallWithMappings); |
| assert.strictEqual(sourceMappedURL, authoredScriptURL); |
| |
| // For a profile call without mappings, it must return the original URL |
| const parsedTraceWithoutMappings = parsedTraceFromProfileCalls([profileCallWithNoMappings]); |
| const mapperWithoutMappings = new Trace.EntityMapper.EntityMapper(parsedTraceWithoutMappings); |
| resolver = new SourceMapsResolver(parsedTraceWithoutMappings, mapperWithoutMappings); |
| await resolver.install(); |
| sourceMappedURL = SourceMapsResolver.resolvedURLForEntry(parsedTraceWithoutMappings, profileCallWithNoMappings); |
| assert.strictEqual(sourceMappedURL, genScriptURL); |
| }); |
| }); |
| describe('unnecessary work detection', () => { |
| it('does not dispatch a SourceMappingsUpdated event if relevant mappings were not updated', async function() { |
| const parsedTrace = await TraceLoader.traceEngine(this, 'user-timings.json.gz'); |
| const listener = sinon.spy(); |
| |
| const sourceMapsResolver = new SourceMapsResolver(parsedTrace); |
| sourceMapsResolver.addEventListener(SourceMappingsUpdated.eventName, listener); |
| await sourceMapsResolver.install(); |
| sinon.assert.notCalled(listener); |
| }); |
| }); |
| describe('updating entity mapping', () => { |
| it('correctly updates mapping for event that maps to a script', async function() { |
| const {scriptId} = await loadCodeLocationResolvingScenario(); |
| |
| const profileCall = |
| makeProfileCall('function', 10, 100, Trace.Types.Events.ProcessID(1), Trace.Types.Events.ThreadID(1)); |
| const LINE_NUMBER = 0; |
| const COLUMN_NUMBER = 9; |
| profileCall.callFrame = { |
| lineNumber: LINE_NUMBER, |
| columnNumber: COLUMN_NUMBER, |
| functionName: 'minified', |
| scriptId, |
| url: 'http://example-domain.com/', |
| }; |
| const profileCallUnmapped = |
| makeProfileCall('function', 10, 100, Trace.Types.Events.ProcessID(1), Trace.Types.Events.ThreadID(1)); |
| profileCallUnmapped.callFrame = { |
| lineNumber: 2, |
| columnNumber: 0, |
| functionName: '', |
| scriptId, |
| url: 'http://example-domain.com/', |
| }; |
| |
| const parsedTrace = parsedTraceFromProfileCalls([profileCall, profileCallUnmapped]); |
| const mapper = new Trace.EntityMapper.EntityMapper(parsedTrace); |
| |
| const testEntity = { |
| name: 'example-domain.com', |
| company: 'example-domain.com', |
| category: '', |
| categories: [], |
| domains: ['example-domain.com'], |
| averageExecutionTime: 0, |
| totalExecutionTime: 0, |
| totalOccurrences: 0, |
| isUnrecognized: true, |
| }; |
| // Set a fake entity for this event that should get overridden. Initially |
| // both traces are mapped together, after the sourcemap that should change |
| mapper.mappings().entityByEvent.set(profileCall, testEntity); |
| mapper.mappings().entityByEvent.set(profileCallUnmapped, testEntity); |
| mapper.mappings().eventsByEntity.set(testEntity, [profileCall, profileCallUnmapped]); |
| mapper.mappings().createdEntityCache.set('example-domain.com', testEntity); |
| |
| const resolver = new SourceMapsResolver(parsedTrace, mapper); |
| // This should update the entities |
| await resolver.install(); |
| const afterEntityOfEvent = mapper.entityForEvent(profileCall); |
| const expected = { |
| name: 'example.com', |
| company: 'example.com', |
| category: '', |
| categories: [], |
| domains: ['example.com'], |
| averageExecutionTime: 0, |
| totalExecutionTime: 0, |
| totalOccurrences: 0, |
| isUnrecognized: true, |
| }; |
| assert.exists(afterEntityOfEvent); |
| assert.deepEqual(afterEntityOfEvent, expected); |
| // The mapped event should now map to its new entity. |
| const gotEventsMapped = mapper.eventsForEntity(afterEntityOfEvent) ?? []; |
| assert.deepEqual(gotEventsMapped, [profileCall]); |
| |
| // The unmapped event should keep its original entity. |
| const gotEventsUnmapped = mapper.eventsForEntity(testEntity) ?? []; |
| assert.deepEqual(gotEventsUnmapped, [profileCallUnmapped]); |
| const gotUnmappedEntity = mapper.entityForEvent(profileCallUnmapped); |
| assert.deepEqual(gotUnmappedEntity, testEntity); |
| }); |
| }); |
| }); |