// 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 { assert } from 'chai';
import sinon from 'sinon';
import * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.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, describeWithEnvironment } from '../../testing/EnvironmentHelpers.js';
import { TestPlugin } from '../../testing/LanguagePluginHelpers.js';
import { MockDebuggerBackend } 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() {
    const backend = new MockDebuggerBackend();
    const target = backend.createTarget();
    const { debuggerWorkspaceBinding, targetManager, workspace } = backend.universe;
    sinon.stub(SDK.TargetManager.TargetManager, 'instance').returns(targetManager);
    sinon.stub(Workspace.Workspace.WorkspaceImpl, 'instance').returns(workspace);
    sinon.stub(Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding, 'instance').returns(debuggerWorkspaceBinding);
    // 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),
    ]);
    await target.model(SDK.DebuggerModel.DebuggerModel)?.sourceMapManager().waitForSourceMapsProcessedForTest();
    return {
        authoredScriptURL,
        scriptId: script.scriptId,
        genScriptURL: scriptInfo.url,
        ignoreListedURL: ignoreListedScriptURL,
        contentScriptURL: contentScriptInfo.url,
        contentScriptId: contentScript.scriptId,
    };
}
function parsedTraceFromProfileCalls(profileCalls) {
    const 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() },
    };
    return { data };
}
describeWithEnvironment('SourceMapsResolver', () => {
    describe('function name resolving', () => {
        let target;
        let script;
        let parsedTrace;
        let profileCallForNameResolving;
        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');
                }
                getFunctionInfo(_rawLocation) {
                    return Promise.resolve({ frames: [{ name: PLUGIN_FUNCTION_NAME }] });
                }
                handleScript(_) {
                    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);
        });
    });
});
//# sourceMappingURL=SourceMapsResolver.test.js.map