chrome-devtools-frontend
Version:
Chrome DevTools UI
547 lines (482 loc) • 24.1 kB
text/typescript
// Copyright 2022 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 Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import type * as Protocol from '../../generated/protocol.js';
import {createTarget} from '../../testing/EnvironmentHelpers.js';
import {describeWithMockConnection} from '../../testing/MockConnection.js';
import {MockProtocolBackend} from '../../testing/MockScopeChain.js';
import {encodeSourceMap} from '../../testing/SourceMapEncoder.js';
import * as TextUtils from '../text_utils/text_utils.js';
import * as Workspace from '../workspace/workspace.js';
import * as Bindings from './bindings.js';
const {urlString} = Platform.DevToolsPath;
describeWithMockConnection('CompilerScriptMapping', () => {
let backend: MockProtocolBackend;
let debuggerWorkspaceBinding: Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding;
let workspace: Workspace.Workspace.WorkspaceImpl;
beforeEach(() => {
const targetManager = SDK.TargetManager.TargetManager.instance();
workspace = Workspace.Workspace.WorkspaceImpl.instance({forceNew: true});
const resourceMapping = new Bindings.ResourceMapping.ResourceMapping(targetManager, workspace);
debuggerWorkspaceBinding = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance(
{forceNew: true, resourceMapping, targetManager});
backend = new MockProtocolBackend();
Bindings.IgnoreListManager.IgnoreListManager.instance({forceNew: true, debuggerWorkspaceBinding});
});
const waitForUISourceCodeAdded =
(url: string, target: SDK.Target.Target): Promise<Workspace.UISourceCode.UISourceCode> =>
debuggerWorkspaceBinding.waitForUISourceCodeAdded(urlString`${url}`, target);
const waitForUISourceCodeRemoved = (uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> =>
new Promise(resolve => {
const {eventType, listener} =
workspace.addEventListener(Workspace.Workspace.Events.UISourceCodeRemoved, event => {
if (event.data === uiSourceCode) {
workspace.removeEventListener(eventType, listener);
resolve();
}
});
});
it('creates UISourceCodes with the correct content type', async () => {
const target = createTarget();
const sourceRoot = 'http://example.com';
const sources = ['foo.js', 'bar.ts', 'baz.jsx'];
const scriptInfo = {url: `${sourceRoot}/bundle.js`, content: '1;\n'};
const sourceMapInfo = {url: `${scriptInfo.url}.map`, content: {version: 3, mappings: '', sourceRoot, sources}};
await Promise.all([
...sources.map(name => waitForUISourceCodeAdded(`${sourceRoot}/${name}`, target).then(uiSourceCode => {
assert.isTrue(uiSourceCode.contentType().isFromSourceMap());
assert.isTrue(uiSourceCode.contentType().isScript());
})),
backend.addScript(target, scriptInfo, sourceMapInfo),
]);
});
it('removes webpack hashes from display names', async () => {
const target = createTarget();
const sourceRoot = 'http://example.com';
const sources = ['foo.js?a1b2', 'two%20words.ts?c3d4', '?e5f6'];
const scriptInfo = {url: `${sourceRoot}/bundle.js`, content: '1;\n'};
const sourceMapInfo = {url: `${scriptInfo.url}.map`, content: {version: 3, mappings: '', sourceRoot, sources}};
const namesPromise = Promise.all(
sources.map(
name =>
waitForUISourceCodeAdded(`${sourceRoot}/${name}`, target).then(uiSourceCode => uiSourceCode.name())),
);
await backend.addScript(target, scriptInfo, sourceMapInfo);
assert.deepEqual(await namesPromise, ['foo.js', 'two words.ts', '?e5f6']);
});
it('creates UISourceCodes with the correct media type', async () => {
const target = createTarget();
const sourceRoot = 'http://example.com';
const scriptInfo = {
url: `${sourceRoot}/bundle.js`,
content: 'foo();\nbar();\nbaz();\n',
};
const sourceMapInfo = {
url: `${scriptInfo.url}.map`,
content: encodeSourceMap(['0:0 => foo.js:0:0', '1:0 => bar.ts:0:0', '2:0 => baz.jsx:0:0'], sourceRoot),
};
const [fooUISourceCode, barUISourceCode, bazUISourceCode] = await Promise.all([
waitForUISourceCodeAdded(`${sourceRoot}/foo.js`, target),
waitForUISourceCodeAdded(`${sourceRoot}/bar.ts`, target),
waitForUISourceCodeAdded(`${sourceRoot}/baz.jsx`, target),
backend.addScript(target, scriptInfo, sourceMapInfo),
]);
assert.strictEqual(fooUISourceCode.mimeType(), 'text/javascript');
assert.strictEqual(barUISourceCode.mimeType(), 'text/typescript');
assert.strictEqual(bazUISourceCode.mimeType(), 'text/jsx');
});
it('creates UISourceCodes with the correct content and metadata', async () => {
const target = createTarget();
const sourceRoot = 'http://example.com';
const sourceContent = 'const x = 1; console.log(x)';
const scriptInfo = {
url: `${sourceRoot}/script.min.js`,
content: 'console.log(1);',
};
const sourceMapInfo = {
url: `${scriptInfo.url}.map`,
content: {version: 1, mappings: '', sources: ['script.js'], sourcesContent: [sourceContent], sourceRoot},
};
const [uiSourceCode] = await Promise.all([
waitForUISourceCodeAdded(`${sourceRoot}/script.js`, target),
backend.addScript(target, scriptInfo, sourceMapInfo),
]);
const metadata = await uiSourceCode.requestMetadata();
assert.strictEqual(metadata?.contentSize, sourceContent.length);
const content = await uiSourceCode.requestContentData();
assert.instanceOf(content, TextUtils.ContentData.ContentData);
assert.strictEqual(content.text, sourceContent);
});
it('creates separate UISourceCodes for separate targets', async () => {
// Create a main target and a worker child target.
const mainTarget = createTarget({
id: 'main' as Protocol.Target.TargetID,
type: SDK.Target.Type.FRAME,
});
const workerTarget = createTarget({
id: 'worker' as Protocol.Target.TargetID,
type: SDK.Target.Type.ServiceWorker,
parentTarget: mainTarget,
});
const sourceRoot = 'http://example.com';
const scriptInfo = {
url: `${sourceRoot}/script.min.js`,
content: 'console.log(1);',
};
const sourceMapInfo = {
url: `${scriptInfo.url}.map`,
content: encodeSourceMap(['0:0 => script.js:0:0'], sourceRoot),
};
// Register the same script for both targets, and wait until the `CompilerScriptMapping`
// adds a UISourceCode for the `script.js` that is listed in the source map for each of
// the two targets.
const [mainUISourceCode, mainScript, workerUISourceCode, workerScript] = await Promise.all([
waitForUISourceCodeAdded(`${sourceRoot}/script.js`, mainTarget),
backend.addScript(mainTarget, scriptInfo, sourceMapInfo),
waitForUISourceCodeAdded(`${sourceRoot}/script.js`, workerTarget),
backend.addScript(workerTarget, scriptInfo, sourceMapInfo),
]);
assert.notStrictEqual(mainUISourceCode, workerUISourceCode);
for (const {script, uiSourceCode} of
[{script: mainScript, uiSourceCode: mainUISourceCode},
{script: workerScript, uiSourceCode: workerUISourceCode}]) {
const rawLocations = await debuggerWorkspaceBinding.uiLocationToRawLocations(uiSourceCode, 0, 0);
assert.lengthOf(rawLocations, 1);
const [rawLocation] = rawLocations;
assert.strictEqual(rawLocation.script(), script);
const uiLocation = await debuggerWorkspaceBinding.rawLocationToUILocation(rawLocation);
assert.strictEqual(uiLocation!.uiSourceCode, uiSourceCode);
}
});
it('creates separate UISourceCodes for content scripts', async () => {
// By default content scripts are ignore listed, which will prevent processing the
// source map. We need to disable that option.
Bindings.IgnoreListManager.IgnoreListManager.instance().unIgnoreListContentScripts();
const target = createTarget();
const sourceRoot = 'http://example.com';
const scriptInfo = {
url: `${sourceRoot}/script.min.js`,
content: 'console.log(1);',
};
const sourceMapInfo = {
url: `${scriptInfo.url}.map`,
content: encodeSourceMap(['0:0 => script.js:0:0'], sourceRoot),
};
// Register `script.min.js` as regular script first.
const regularScriptInfo = {...scriptInfo, isContentScript: false};
const [regularUISourceCode, regularScript] = await Promise.all([
waitForUISourceCodeAdded(`${sourceRoot}/script.js`, target),
backend.addScript(target, regularScriptInfo, sourceMapInfo),
]);
// Now register the same `script.min.js` as content script.
const contentScriptInfo = {...scriptInfo, isContentScript: true};
const [contentUISourceCode, contentScript] = await Promise.all([
waitForUISourceCodeAdded(`${sourceRoot}/script.js`, target),
backend.addScript(target, contentScriptInfo, sourceMapInfo),
]);
assert.notStrictEqual(regularUISourceCode, contentUISourceCode);
for (const {script, uiSourceCode} of
[{script: regularScript, uiSourceCode: regularUISourceCode},
{script: contentScript, uiSourceCode: contentUISourceCode}]) {
const rawLocations = await debuggerWorkspaceBinding.uiLocationToRawLocations(uiSourceCode, 0, 0);
assert.lengthOf(rawLocations, 1);
const [rawLocation] = rawLocations;
assert.strictEqual(rawLocation.script(), script);
const uiLocation = await debuggerWorkspaceBinding.rawLocationToUILocation(rawLocation);
assert.strictEqual(uiLocation!.uiSourceCode, uiSourceCode);
}
});
it('correctly marks known 3rdparty UISourceCodes', async () => {
const target = createTarget();
const sourceRoot = 'http://example.com';
const scriptInfo = {
url: `${sourceRoot}/bundle.js`,
content: '1;\n',
};
const sourceMapInfo = {
url: `${scriptInfo.url}.map`,
content: {
version: 3,
mappings: '',
sourceRoot,
sources: ['app.ts', 'lib.ts'],
ignoreList: [1],
},
};
await Promise.all([
waitForUISourceCodeAdded(`${sourceRoot}/app.ts`, target).then(uiSourceCode => {
assert.isFalse(uiSourceCode.isKnownThirdParty(), '`app.ts` is not a known 3rdparty script');
}),
waitForUISourceCodeAdded(`${sourceRoot}/lib.ts`, target).then(uiSourceCode => {
assert.isTrue(uiSourceCode.isKnownThirdParty(), '`lib.ts` is a known 3rdparty script');
}),
backend.addScript(target, scriptInfo, sourceMapInfo),
]);
});
it('correctly maps to inline <script>s with `//# sourceURL` annotations', async () => {
const target = createTarget();
const sourceRoot = 'http://example.com';
const scriptInfo = {
url: `${sourceRoot}/test.out.js`,
content: 'function f(x) {\n console.log(x);\n}\n',
startLine: 4,
startOffset: 12,
hasSourceURL: true,
};
const sourceMapInfo = {
url: `${scriptInfo.url}.map`,
content: encodeSourceMap(
[
'0:0 => test.ts:0:0',
'1:0 => test.ts:1:0',
'1:2 => test.ts:1:2',
'2:0 => test.ts:2:0',
],
sourceRoot),
};
const [uiSourceCode, script] = await Promise.all([
waitForUISourceCodeAdded(`${sourceRoot}/test.ts`, target),
backend.addScript(target, scriptInfo, sourceMapInfo),
]);
const rawLocations = await debuggerWorkspaceBinding.uiLocationToRawLocations(uiSourceCode, 1, 2);
assert.lengthOf(rawLocations, 1);
const [rawLocation] = rawLocations;
assert.strictEqual(rawLocation.script(), script);
assert.strictEqual(rawLocation.lineNumber, 1);
assert.strictEqual(rawLocation.columnNumber, 2);
const uiLocation = await debuggerWorkspaceBinding.rawLocationToUILocation(rawLocation);
assert.strictEqual(uiLocation!.uiSourceCode, uiSourceCode);
assert.strictEqual(uiLocation!.lineNumber, 1);
assert.strictEqual(uiLocation!.columnNumber, 2);
});
it('correctly removes UISourceCodes when detaching a sourcemap', async () => {
const target = createTarget();
const sourceRoot = 'http://example.com';
const scriptInfo = {
url: `${sourceRoot}/test.out.js`,
content: '1\n2\n',
};
const sourceMapInfo = {
url: `${scriptInfo.url}.map`,
content: encodeSourceMap(
[
'0:0 => a.ts:0:0',
'1:0 => b.ts:1:0',
],
sourceRoot),
};
const [, , script] = await Promise.all([
waitForUISourceCodeAdded(`${sourceRoot}/a.ts`, target),
waitForUISourceCodeAdded(`${sourceRoot}/b.ts`, target),
backend.addScript(target, scriptInfo, sourceMapInfo),
]);
script.debuggerModel.sourceMapManager().detachSourceMap(script);
assert.isNull(
workspace.uiSourceCodeForURL(urlString`${`${sourceRoot}/a.ts`}`), '`a.ts` should not be around anymore');
assert.isNull(
workspace.uiSourceCodeForURL(urlString`${`${sourceRoot}/b.ts`}`), '`b.ts` should not be around anymore');
});
it('correctly reports source-mapped lines', async () => {
const target = createTarget();
const sourceRoot = 'http://example.com';
const scriptInfo = {
url: `${sourceRoot}/test.out.js`,
content: 'function f(x) {\n console.log(x);\n}\n',
};
const sourceMapInfo = {
url: `${scriptInfo.url}.map`,
content: encodeSourceMap(
[
'0:9 => test.ts:0:1',
'1:0 => test.ts:4:0',
'1:2 => test.ts:4:2',
'2:0 => test.ts:2:0',
],
sourceRoot),
};
const [uiSourceCode] = await Promise.all([
waitForUISourceCodeAdded(`${sourceRoot}/test.ts`, target),
backend.addScript(target, scriptInfo, sourceMapInfo),
]);
const mappedLines = await debuggerWorkspaceBinding.getMappedLines(uiSourceCode);
assert.deepEqual(mappedLines, new Set([0, 2, 4]));
});
describe('supports modern Web development workflows', () => {
it('supports webpack code splitting', async () => {
// This is basically the "Shared code with webpack entry point code-splitting" scenario
// outlined in http://go/devtools-source-identities, where two routes (`route1.ts` and
// `route2.ts`) share some common code (`shared.ts`), and webpack is configured to spit
// out a dedicated bundle for each route (`route1.js` and `route2.js`). The demo can be
// found at https://devtools-source-identities.glitch.me/webpack-code-split/ for further
// reference.
const target = createTarget();
const sourceRoot = 'webpack:///src';
// Load the script and source map for the first route.
const route1ScriptInfo = {
url: 'http://example.com/route1.js',
content: 'function f(x){}\nf(1)',
};
const route1SourceMapInfo = {
url: `${route1ScriptInfo.url}.map`,
content: encodeSourceMap(['0:0 => shared.ts:0:0', '1:0 => route1.ts:0:0'], sourceRoot),
};
const [route1UISourceCode, firstSharedUISourceCode, route1Script] = await Promise.all([
waitForUISourceCodeAdded(`${sourceRoot}/route1.ts`, target),
waitForUISourceCodeAdded(`${sourceRoot}/shared.ts`, target),
backend.addScript(target, route1ScriptInfo, route1SourceMapInfo),
]);
// Both `route1.ts` and `shared.ts` are referred to only by `route1.js` at this point.
assert.deepEqual(await debuggerWorkspaceBinding.uiLocationToRawLocations(route1UISourceCode, 0), [
route1Script.debuggerModel.createRawLocation(route1Script, 1, 0),
]);
assert.deepEqual(await debuggerWorkspaceBinding.uiLocationToRawLocations(firstSharedUISourceCode, 0), [
route1Script.debuggerModel.createRawLocation(route1Script, 0, 0),
]);
// Load the script and source map for the second route. At this point a new `shared.ts` should
// appear, replacing the original `shared.ts` UISourceCode.
const route2ScriptInfo = {
url: 'http://example.com/route2.js',
content: 'function f(x){}\nf(2)',
};
const route2SourceMapInfo = {
url: `${route2ScriptInfo.url}.map`,
content: encodeSourceMap(['0:0 => shared.ts:0:0', '1:0 => route2.ts:0:0'], sourceRoot),
};
const [route2UISourceCode, secondSharedUISourceCode, route2Script] = await Promise.all([
waitForUISourceCodeAdded(`${sourceRoot}/route2.ts`, target),
waitForUISourceCodeAdded(`${sourceRoot}/shared.ts`, target),
backend.addScript(target, route2ScriptInfo, route2SourceMapInfo),
waitForUISourceCodeRemoved(firstSharedUISourceCode),
]);
// Now `route1.ts` is provided exclusively by `route1.js`...
const route1UILocation = route1UISourceCode.uiLocation(0, 0);
const route1Locations = await debuggerWorkspaceBinding.uiLocationToRawLocations(
route1UILocation.uiSourceCode, route1UILocation.lineNumber, route1UILocation.columnNumber);
assert.lengthOf(route1Locations, 1);
const [route1Location] = route1Locations;
assert.strictEqual(route1Location.script(), route1Script);
assert.deepEqual(await debuggerWorkspaceBinding.rawLocationToUILocation(route1Location), route1UILocation);
// ...and `route2.ts` is provided exclusively by `route2.js`...
const route2UILocation = route2UISourceCode.uiLocation(0, 0);
const route2Locations = await debuggerWorkspaceBinding.uiLocationToRawLocations(
route2UILocation.uiSourceCode, route2UILocation.lineNumber, route2UILocation.columnNumber);
assert.lengthOf(route2Locations, 1);
const [route2Location] = route2Locations;
assert.strictEqual(route2Location.script(), route2Script);
assert.deepEqual(await debuggerWorkspaceBinding.rawLocationToUILocation(route2Location), route2UILocation);
// ...but `shared.ts` is provided by both `route1.js` and `route2.js`.
const sharedUILocation = secondSharedUISourceCode.uiLocation(0, 0);
const sharedLocations = await debuggerWorkspaceBinding.uiLocationToRawLocations(
sharedUILocation.uiSourceCode, sharedUILocation.lineNumber, sharedUILocation.columnNumber);
assert.sameMembers(sharedLocations.map(location => location.script()), [route1Script, route2Script]);
for (const location of sharedLocations) {
assert.deepEqual(await debuggerWorkspaceBinding.rawLocationToUILocation(location), sharedUILocation);
}
});
it('supports webpack hot module replacement', async () => {
// This simulates the webpack HMR machinery, where originally a `bundle.js` is served,
// which includes embedded authored code for `lib.js` and `app.js`, both of which map
// to `bundle.js`. Later an update script is sent that replaces `app.js` with a newer
// version, while sending the same authored code for `lib.js` (presumably because the
// devserver figured the file might have changed). Now the initial `app.js` should be
// removed and `bundle.js` will have un-mapped locations for the `app.js` part. The
// new `app.js` will point to the update script. `lib.js` remains unchanged.
//
// This is a generalization of https://crbug.com/1403362 and http://crbug.com/1403432,
// which both present special cases of the general stale mapping problem.
const target = createTarget();
const sourceRoot = 'webpack:///src';
// Load the original bundle.
const originalScriptInfo = {
url: 'http://example.com/bundle.js',
content: 'const f = console.log;\nf("Hello from the original bundle");',
};
const originalSourceMapInfo = {
url: `${originalScriptInfo.url}.map`,
content: encodeSourceMap(
[
'0:0 => lib.js:0:0',
'lib.js: const f = console.log;',
'1:0 => app.js:0:0',
'app.js: f("Hello from the original bundle")',
],
sourceRoot),
};
const [originalLibUISourceCode, originalAppUISourceCode, originalScript] = await Promise.all([
waitForUISourceCodeAdded(`${sourceRoot}/lib.js`, target),
waitForUISourceCodeAdded(`${sourceRoot}/app.js`, target),
backend.addScript(target, originalScriptInfo, originalSourceMapInfo),
]);
// Initially the original `bundle.js` maps to the original `app.js` and `lib.js`.
assert.deepEqual(
await debuggerWorkspaceBinding.rawLocationToUILocation(
originalScript.debuggerModel.createRawLocation(originalScript, 0, 0)),
originalLibUISourceCode.uiLocation(0, 0));
assert.deepEqual(
await debuggerWorkspaceBinding.rawLocationToUILocation(
originalScript.debuggerModel.createRawLocation(originalScript, 1, 0)),
originalAppUISourceCode.uiLocation(0, 0));
// Inject the HMR update script.
const updateScriptInfo = {
url: 'http://example.com/hot.update.1234.js',
content: 'f("Hello from the update");',
};
const updateSourceMapInfo = {
url: `${updateScriptInfo.url}.map`,
content: encodeSourceMap(
[
'0:0 => app.js:0:0',
'lib.js: const f = console.log;',
'app.js: f("Hello from the update")',
],
sourceRoot),
};
const [updateAppUISourceCode, , updateScript] = await Promise.all([
waitForUISourceCodeAdded(`${sourceRoot}/app.js`, target),
// The original `app.js` should disappear as part of the HMR update.
waitForUISourceCodeRemoved(originalAppUISourceCode),
backend.addScript(target, updateScriptInfo, updateSourceMapInfo),
]);
// Now we have a new `app.js`...
assert.notStrictEqual(updateAppUISourceCode, originalAppUISourceCode);
assert.isEmpty(await debuggerWorkspaceBinding.uiLocationToRawLocations(originalAppUISourceCode, 0, 0));
assert.deepEqual(await debuggerWorkspaceBinding.uiLocationToRawLocations(updateAppUISourceCode, 0, 0), [
updateScript.debuggerModel.createRawLocation(updateScript, 0, 0),
]);
// ...and the `app.js` mapping of the `bundle.js` is now gone...
const {uiSourceCode} = (await debuggerWorkspaceBinding.rawLocationToUILocation(
originalScript.debuggerModel.createRawLocation(originalScript, 1, 0)))!;
assert.notStrictEqual(uiSourceCode, originalAppUISourceCode);
assert.notStrictEqual(uiSourceCode, updateAppUISourceCode);
// ...while the `lib.js` mapping of `bundle.js` is still intact (because it
// was the same content).
assert.deepEqual(
await debuggerWorkspaceBinding.rawLocationToUILocation(
originalScript.debuggerModel.createRawLocation(originalScript, 0, 0)),
originalLibUISourceCode.uiLocation(0, 0));
});
});
it('assumes UTF-8 encoding for source files embedded in source maps', async () => {
const target = createTarget();
const sourceRoot = 'http://example.com';
const sourceContent = 'console.log("Ahoj světe!");';
const scriptInfo = {
url: `${sourceRoot}/script.min.js`,
content: sourceContent,
};
const sourceMapInfo = {
url: `${scriptInfo.url}.map`,
content: {version: 3, mappings: '', sources: ['script.js'], sourcesContent: [sourceContent], sourceRoot},
};
const [uiSourceCode] = await Promise.all([
waitForUISourceCodeAdded(`${sourceRoot}/script.js`, target),
backend.addScript(target, scriptInfo, sourceMapInfo),
]);
const metadata = await uiSourceCode.requestMetadata();
assert.notStrictEqual(metadata?.contentSize, sourceContent.length);
const sourceUTF8 = new TextEncoder().encode(sourceContent);
assert.strictEqual(metadata?.contentSize, sourceUTF8.length);
});
});