chrome-devtools-frontend
Version:
Chrome DevTools UI
1,065 lines (911 loc) • 48 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 type {Chrome} from '../../../extension-api/ExtensionAPI.js';
import * as Common from '../../core/common/common.js';
import * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Protocol from '../../generated/protocol.js';
import {createTarget, expectConsoleLogs} from '../../testing/EnvironmentHelpers.js';
import {
describeWithDevtoolsExtension,
getExtensionOrigin,
} from '../../testing/ExtensionHelpers.js';
import {MockProtocolBackend} from '../../testing/MockScopeChain.js';
import {addChildFrame, FRAME_URL, getMainFrame} from '../../testing/ResourceTreeHelpers.js';
import {encodeSourceMap} from '../../testing/SourceMapEncoder.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as Bindings from '../bindings/bindings.js';
import * as Extensions from '../extensions/extensions.js';
import type * as HAR from '../har/har.js';
import * as Logs from '../logs/logs.js';
import * as TextUtils from '../text_utils/text_utils.js';
import * as Workspace from '../workspace/workspace.js';
const {urlString} = Platform.DevToolsPath;
describeWithDevtoolsExtension('Extensions', {}, context => {
it('are initialized after the target is initialized and navigated to a non-privileged URL', async () => {
// This check is a proxy for verifying that the extension has been initialized. Outside of the test the extension
// API is available as soon as the extension page is loaded, which we don't do in the test.
assert.isUndefined(context.chrome.devtools);
const addExtensionStub = sinon.stub(Extensions.ExtensionServer.ExtensionServer.instance(), 'addExtension');
createTarget().setInspectedURL(urlString`http://example.com`);
sinon.assert.calledOnceWithExactly(addExtensionStub, context.extensionDescriptor);
});
it('are not initialized before the target is initialized and navigated to a non-privileged URL', async () => {
// This check is a proxy for verifying that the extension has been initialized. Outside of the test the extension
// API is available as soon as the extension page is loaded, which we don't do in the test.
assert.isUndefined(context.chrome.devtools);
const addExtensionStub = sinon.stub(Extensions.ExtensionServer.ExtensionServer.instance(), 'addExtension');
createTarget().setInspectedURL(urlString`chrome://version`);
sinon.assert.notCalled(addExtensionStub);
});
it('defers loading extensions until after navigation from a privileged to a non-privileged host', async () => {
const addExtensionSpy = sinon.spy(Extensions.ExtensionServer.ExtensionServer.instance(), 'addExtension');
const target = createTarget({type: SDK.Target.Type.FRAME});
target.setInspectedURL(urlString`chrome://abcdef`);
assert.isTrue(addExtensionSpy.notCalled, 'addExtension not called');
target.setInspectedURL(allowedUrl);
assert.isTrue(addExtensionSpy.calledOnce, 'addExtension called once');
assert.isTrue(addExtensionSpy.returned(true), 'addExtension returned true');
});
it('only returns page resources for allowed targets', async () => {
const urls = ['http://example.com', 'chrome://version'] as Platform.DevToolsPath.UrlString[];
const targets = urls.map(async url => {
const target = createTarget({url});
const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel);
assert.isNotNull(resourceTreeModel);
await resourceTreeModel.once(SDK.ResourceTreeModel.Events.CachedResourcesLoaded);
target.setInspectedURL(url);
resourceTreeModel.mainFrame?.addResource(new SDK.Resource.Resource(
resourceTreeModel, null, url, url, null, null, Common.ResourceType.resourceTypes.Document, 'application/text',
null, null));
return target;
});
await Promise.all(targets);
const resources =
await new Promise<Chrome.DevTools.Resource[]>(r => context.chrome.devtools!.inspectedWindow.getResources(r));
assert.deepEqual(resources.map(r => r.url), ['https://example.com/', 'http://example.com']);
});
describe('Resource', () => {
let target: SDK.Target.Target;
let project: Bindings.ContentProviderBasedProject.ContentProviderBasedProject;
beforeEach(() => {
target = createTarget();
const inspectedUrl = urlString`https://www.example.com/`;
target.setInspectedURL(inspectedUrl);
project = new Bindings.ContentProviderBasedProject.ContentProviderBasedProject(
Workspace.Workspace.WorkspaceImpl.instance(), target.id(), Workspace.Workspace.projectTypes.Network, '',
false /* isServiceProject */);
const targetManager = target.targetManager();
const resourceMapping =
new Bindings.ResourceMapping.ResourceMapping(targetManager, Workspace.Workspace.WorkspaceImpl.instance());
Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance(
{forceNew: true, resourceMapping, targetManager});
});
describe('setFunctionRangesForScript', () => {
const validFunctionRanges = [{start: {line: 0, column: 0}, end: {line: 10, column: 1}, name: 'foo'}];
it('correctly calls DebuggerWorkspaceBindings.setFunctionRanges via Resource.setFunctionRangesForScript API',
async () => {
// create a mock uiSourceCode for the sourceMap script
const scriptUrl = urlString`https://example.com/foo.js.map/foo.js`;
project.addUISourceCode(
new Workspace.UISourceCode.UISourceCode(
project, scriptUrl, Common.ResourceType.resourceTypes.SourceMapScript),
);
const uiSourceCode = project.uiSourceCodeForURL(scriptUrl);
assert.exists(uiSourceCode);
assert.exists(context.chrome.devtools);
const resources = await new Promise<Chrome.DevTools.Resource[]>(
r => context.chrome.devtools?.inspectedWindow.getResources(r));
const nonSourceMapScripts = resources.filter(r => r.type !== 'sm-script');
const sourceMapScripts = resources.filter(r => r.type === 'sm-script');
const workspaceBindingSetFunctionRangesStub =
sinon.stub(Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance(), 'setFunctionRanges');
// The assert.throws() helper does not work with async/await, hence the manual try catch
let didThrow = false;
try {
// Should throw if called with a non-sourceMap script
await nonSourceMapScripts[0].setFunctionRangesForScript(validFunctionRanges);
} catch (e) {
didThrow = true;
assertIsStatus(e);
assert.strictEqual(e.code, 'E_BADARG');
}
assert.isTrue(didThrow, 'SetFunctionRangesForScript did not throw an error as expected.');
try {
// Should throw if called with invalid/empty ranges
await sourceMapScripts[0].setFunctionRangesForScript([/** empty ranges */]);
} catch (e) {
didThrow = true;
assertIsStatus(e);
assert.strictEqual(e.code, 'E_BADARG');
}
assert.isTrue(didThrow, 'SetFunctionRangesForScript did not throw an error as expected.');
sinon.assert.notCalled(workspaceBindingSetFunctionRangesStub);
await sourceMapScripts[0].setFunctionRangesForScript(validFunctionRanges);
sinon.assert.calledOnceWithExactly(workspaceBindingSetFunctionRangesStub, uiSourceCode, validFunctionRanges);
});
});
it('returns the buildId', async () => {
const stubScript = sinon.createStubInstance(SDK.Script.Script);
// @ts-expect-error
stubScript.buildId = 'my-build-id';
sinon.stub(Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance(), 'scriptsForUISourceCode')
.returns([stubScript]);
project.addUISourceCode(
new Workspace.UISourceCode.UISourceCode(
project, urlString`http://example.com/index.js`, Common.ResourceType.resourceTypes.Script),
);
const resources =
await new Promise<Chrome.DevTools.Resource[]>(r => context.chrome.devtools?.inspectedWindow.getResources(r));
assert.strictEqual(resources[0].url, 'http://example.com/index.js');
assert.strictEqual(resources[0].buildId, 'my-build-id');
});
});
});
describeWithDevtoolsExtension('Extensions', {}, context => {
expectConsoleLogs({
warn: ['evaluate: the main frame is not yet available'],
error: ['Extension server error: Object not found: <top>'],
});
beforeEach(() => {
createTarget().setInspectedURL(urlString`http://example.com`);
});
it('can register a recorder extension for export', async () => {
class RecorderPlugin {
async stringify(recording: object) {
return JSON.stringify(recording);
}
async stringifyStep(step: object) {
return JSON.stringify(step);
}
}
const extensionPlugin = new RecorderPlugin();
await context.chrome.devtools?.recorder.registerRecorderExtensionPlugin(extensionPlugin, 'Test', 'text/javascript');
const manager = Extensions.RecorderPluginManager.RecorderPluginManager.instance();
assert.lengthOf(manager.plugins(), 1);
const plugin = manager.plugins()[0];
const result = await plugin.stringify({
name: 'test',
steps: [],
});
const stepResult = await plugin.stringifyStep({
type: 'scroll',
});
assert.lengthOf(manager.plugins(), 1);
assert.strictEqual(manager.plugins()[0].getMediaType(), 'text/javascript');
assert.strictEqual(manager.plugins()[0].getName(), 'Test');
assert.deepEqual(manager.plugins()[0].getCapabilities(), ['export']);
assert.deepEqual(result, '{"name":"test","steps":[]}');
assert.deepEqual(stepResult, '{"type":"scroll"}');
await context.chrome.devtools?.recorder.unregisterRecorderExtensionPlugin(extensionPlugin);
});
it('can register a recorder extension for replay', async () => {
class RecorderPlugin {
replay(_recording: object) {
return;
}
}
const extensionPlugin = new RecorderPlugin();
await context.chrome.devtools?.recorder.registerRecorderExtensionPlugin(extensionPlugin, 'Replay');
const manager = Extensions.RecorderPluginManager.RecorderPluginManager.instance();
assert.lengthOf(manager.plugins(), 1);
const plugin = manager.plugins()[0];
await plugin.replay({
name: 'test',
steps: [],
});
assert.lengthOf(manager.plugins(), 1);
assert.deepEqual(manager.plugins()[0].getCapabilities(), ['replay']);
assert.strictEqual(manager.plugins()[0].getName(), 'Replay');
await context.chrome.devtools?.recorder.unregisterRecorderExtensionPlugin(extensionPlugin);
});
it('can create and show a panel for Recorder', async () => {
const panel = await new Promise<Chrome.DevTools.ExtensionPanel>(
resolve => context.chrome.devtools?.panels.create('Test', 'test.png', 'test.html', resolve));
class RecorderPlugin {
replay(_recording: object) {
panel?.show();
}
}
const extensionPlugin = new RecorderPlugin();
await context.chrome.devtools?.recorder.registerRecorderExtensionPlugin(extensionPlugin, 'Replay');
const manager = Extensions.RecorderPluginManager.RecorderPluginManager.instance();
assert.lengthOf(manager.plugins(), 1);
const plugin = manager.plugins()[0];
const stub = sinon.stub(UI.InspectorView.InspectorView.instance(), 'showPanel').callsFake(() => Promise.resolve());
await plugin.replay({
name: 'test',
steps: [],
});
sinon.assert.called(stub);
await context.chrome.devtools?.recorder.unregisterRecorderExtensionPlugin(extensionPlugin);
});
it('can create and show a view for Recorder', async () => {
const view = await context.chrome.devtools?.recorder.createView('Test', 'test.html');
class RecorderPlugin {
replay(_recording: object) {
view?.show();
}
}
const extensionPlugin = new RecorderPlugin();
await context.chrome.devtools?.recorder.registerRecorderExtensionPlugin(extensionPlugin, 'Replay');
const manager = Extensions.RecorderPluginManager.RecorderPluginManager.instance();
assert.lengthOf(manager.plugins(), 1);
assert.lengthOf(manager.views(), 1);
const plugin = manager.plugins()[0];
const onceShowRequested = manager.once(Extensions.RecorderPluginManager.Events.SHOW_VIEW_REQUESTED);
await plugin.replay({
name: 'test',
steps: [],
});
const viewDescriptor = await onceShowRequested;
assert.deepEqual(viewDescriptor.title, 'Test');
await context.chrome.devtools?.recorder.unregisterRecorderExtensionPlugin(extensionPlugin);
});
it('can not show a view for Recorder without using the replay trigger', async () => {
const view = await context.chrome.devtools?.recorder.createView('Test', 'test.html');
class RecorderPlugin {
replay(_recording: object) {
}
stringify(recording: object) {
return JSON.stringify(recording);
}
}
const extensionPlugin = new RecorderPlugin();
await context.chrome.devtools?.recorder.registerRecorderExtensionPlugin(extensionPlugin, 'Replay');
const manager = Extensions.RecorderPluginManager.RecorderPluginManager.instance();
assert.lengthOf(manager.plugins(), 1);
assert.lengthOf(manager.views(), 1);
const events: object[] = [];
manager.addEventListener(Extensions.RecorderPluginManager.Events.SHOW_VIEW_REQUESTED, event => {
events.push(event);
});
view?.show();
// Sending inspectedWindow.eval should flush the message queue and make sure
// that the ShowViewRequested command was not actually dispatched.
await new Promise(resolve => context.chrome.devtools?.inspectedWindow.eval('1', undefined, resolve));
assert.deepEqual(events, []);
await context.chrome.devtools?.recorder.unregisterRecorderExtensionPlugin(extensionPlugin);
});
it('can dispatch hide and show events', async () => {
const view = await context.chrome.devtools?.recorder.createView('Test', 'test.html');
const onShownCalled = sinon.promise();
const onShown = () => onShownCalled.resolve(true);
const onHiddenCalled = sinon.promise();
const onHidden = () => onHiddenCalled.resolve(true);
view?.onHidden.addListener(onHidden);
view?.onShown.addListener(onShown);
class RecorderPlugin {
replay(_recording: object) {
view?.show();
}
}
const extensionPlugin = new RecorderPlugin();
await context.chrome.devtools?.recorder.registerRecorderExtensionPlugin(extensionPlugin, 'Replay');
const manager = Extensions.RecorderPluginManager.RecorderPluginManager.instance();
const plugin = manager.plugins()[0];
const onceShowRequested = manager.once(Extensions.RecorderPluginManager.Events.SHOW_VIEW_REQUESTED);
await plugin.replay({
name: 'test',
steps: [],
});
const viewDescriptor = await onceShowRequested;
assert.deepEqual(viewDescriptor.title, 'Test');
const descriptor = manager.getViewDescriptor(viewDescriptor.id);
descriptor?.onShown();
await onShownCalled;
descriptor?.onHidden();
await onHiddenCalled;
await context.chrome.devtools?.recorder.unregisterRecorderExtensionPlugin(extensionPlugin);
});
it('reload only the main toplevel frame', async () => {
const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
assert.isNotNull(target);
const secondTarget = createTarget();
const secondResourceTreeModel = secondTarget.model(SDK.ResourceTreeModel.ResourceTreeModel);
assert.isNotNull(secondResourceTreeModel);
const secondReloadStub = sinon.stub(secondResourceTreeModel, 'reloadPage');
const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel);
assert.isNotNull(resourceTreeModel);
const reloadStub = sinon.stub(resourceTreeModel, 'reloadPage');
const reloadPromise = new Promise(resolve => reloadStub.callsFake(resolve));
context.chrome.devtools!.inspectedWindow.reload();
await reloadPromise;
sinon.assert.calledOnce(reloadStub);
sinon.assert.notCalled(secondReloadStub);
});
it('correcly installs blocked extensions after navigation', async () => {
const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
assert.isOk(target);
target.setInspectedURL(urlString`chrome://version`);
const extensionServer = Extensions.ExtensionServer.ExtensionServer.instance();
const addExtensionSpy = sinon.spy(extensionServer, 'addExtension');
assert.isUndefined(extensionServer.addExtension({
startPage: 'about:blank',
name: 'ext',
exposeExperimentalAPIs: false,
}));
target.setInspectedURL(urlString`http://example.com`);
assert.deepEqual(addExtensionSpy.returnValues, [undefined, true]);
});
it('correcly reenables extensions after navigation', async () => {
const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
assert.isOk(target);
const extensionServer = Extensions.ExtensionServer.ExtensionServer.instance();
assert.isTrue(extensionServer.isEnabledForTest);
target.setInspectedURL(urlString`chrome://version`);
assert.isFalse(extensionServer.isEnabledForTest);
target.setInspectedURL(urlString`http://example.com`);
assert.isTrue(extensionServer.isEnabledForTest);
});
});
const allowedUrl = FRAME_URL;
const blockedUrl = urlString`http://web.dev`;
const hostsPolicy = {
runtimeAllowedHosts: [allowedUrl],
runtimeBlockedHosts: [allowedUrl, blockedUrl],
};
function waitForFunction<T>(fn: () => T): Promise<T> {
return new Promise<T>(r => {
const check = () => {
const result = fn();
if (result) {
r(result);
} else {
setTimeout(check);
}
};
check();
});
}
describeWithDevtoolsExtension('Runtime hosts policy', {hostsPolicy}, context => {
expectConsoleLogs({error: ['Extension server error: Operation failed: Permission denied']});
for (const protocol of ['devtools', 'chrome', 'chrome-untrusted', 'chrome-error', 'chrome-search']) {
it(`blocks API calls on blocked protocols: ${protocol}`, async () => {
assert.isUndefined(context.chrome.devtools);
const target = createTarget({type: SDK.Target.Type.FRAME});
const addExtensionStub = sinon.stub(Extensions.ExtensionServer.ExtensionServer.instance(), 'addExtension');
target.setInspectedURL(urlString`${`${protocol}://foo`}`);
sinon.assert.notCalled(addExtensionStub);
assert.isUndefined(context.chrome.devtools);
});
}
it('blocks API calls on blocked hosts', async () => {
assert.isUndefined(context.chrome.devtools);
const target = createTarget({type: SDK.Target.Type.FRAME});
const addExtensionStub = sinon.spy(Extensions.ExtensionServer.ExtensionServer.instance(), 'addExtension');
target.setInspectedURL(blockedUrl);
assert.isTrue(addExtensionStub.alwaysReturned(undefined));
assert.isUndefined(context.chrome.devtools);
});
it('allows API calls on allowlisted hosts', async () => {
const target = createTarget({type: SDK.Target.Type.FRAME});
target.setInspectedURL(allowedUrl);
{
const result = await new Promise<object>(cb => context.chrome.devtools?.network.getHAR(cb));
assert.hasAnyKeys(result, ['entries']);
}
});
it('allows API calls on non-blocked hosts', async () => {
const target = createTarget({type: SDK.Target.Type.FRAME});
target.setInspectedURL(urlString`http://example.com2`);
{
const result = await new Promise<object>(cb => context.chrome.devtools?.network.getHAR(cb));
assert.hasAnyKeys(result, ['entries']);
}
});
it('defers loading extensions until after navigation from a blocked to an allowed host', async () => {
const addExtensionSpy = sinon.spy(Extensions.ExtensionServer.ExtensionServer.instance(), 'addExtension');
const target = createTarget({type: SDK.Target.Type.FRAME});
target.setInspectedURL(blockedUrl);
assert.isTrue(addExtensionSpy.calledOnce, 'addExtension called once');
assert.deepEqual(addExtensionSpy.returnValues, [undefined]);
target.setInspectedURL(allowedUrl);
assert.isTrue(addExtensionSpy.calledTwice, 'addExtension called twice');
assert.deepEqual(addExtensionSpy.returnValues, [undefined, true]);
});
it('does not include blocked hosts in the HAR entries', async () => {
Logs.NetworkLog.NetworkLog.instance();
const target = createTarget({type: SDK.Target.Type.FRAME});
target.setInspectedURL(urlString`http://example.com2`);
const networkManager = target.model(SDK.NetworkManager.NetworkManager);
assert.exists(networkManager);
const frameId = 'frame-id' as Protocol.Page.FrameId;
createRequest(networkManager, frameId, 'blocked-url-request-id' as Protocol.Network.RequestId, blockedUrl);
createRequest(networkManager, frameId, 'allowed-url-request-id' as Protocol.Network.RequestId, allowedUrl);
{
const result = await new Promise<object>(cb => context.chrome.devtools?.network.getHAR(cb)) as HAR.Log.LogDTO;
assert.exists(result.entries.find(e => e.request.url === allowedUrl));
assert.notExists(result.entries.find(e => e.request.url === blockedUrl));
}
});
async function setUpFrame(
name: string, url: Platform.DevToolsPath.UrlString, parentFrame?: SDK.ResourceTreeModel.ResourceTreeFrame,
executionContextOrigin?: Platform.DevToolsPath.UrlString) {
const parentTarget = parentFrame?.resourceTreeModel()?.target();
const target = createTarget({id: `${name}-target-id` as Protocol.Target.TargetID, parentTarget});
const frame = parentFrame ? await addChildFrame(target, {url}) : getMainFrame(target, {url});
if (executionContextOrigin) {
executionContextOrigin = urlString`${new URL(executionContextOrigin).origin}`;
const parentRuntimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
assert.exists(parentRuntimeModel);
parentRuntimeModel.executionContextCreated({
id: 0 as Protocol.Runtime.ExecutionContextId,
origin: executionContextOrigin,
name: executionContextOrigin,
uniqueId: executionContextOrigin,
auxData: {frameId: frame.id, isDefault: true},
});
}
return frame;
}
it('blocks evaluation on blocked subframes', async () => {
assert.isUndefined(context.chrome.devtools);
const parentFrameUrl = allowedUrl;
const childFrameUrl = blockedUrl;
const parentFrame = await setUpFrame('parent', parentFrameUrl);
await setUpFrame('child', childFrameUrl, parentFrame);
const result = await new Promise<{result: unknown, error?: {details: unknown[]}}>(
r => context.chrome.devtools?.inspectedWindow.eval(
'4', {frameURL: childFrameUrl}, (result, error) => r({result, error})));
assert.deepEqual(result.error?.details, ['Permission denied']);
});
it('doesn\'t block evaluation on blocked sub-executioncontexts with useContentScriptContext', async () => {
assert.isUndefined(context.chrome.devtools);
const parentFrameUrl = allowedUrl;
const childFrameUrl = urlString`${`${allowedUrl}/2`}`;
const childExeContextOrigin = blockedUrl;
const parentFrame = await setUpFrame('parent', parentFrameUrl, undefined, parentFrameUrl);
const childFrame = await setUpFrame('child', childFrameUrl, parentFrame, childExeContextOrigin);
// Create a fake content script execution context, i.e., a non-default context with the extension's (== window's)
// origin.
const runtimeModel = childFrame.resourceTreeModel()?.target().model(SDK.RuntimeModel.RuntimeModel);
assert.exists(runtimeModel);
runtimeModel.executionContextCreated({
id: 1 as Protocol.Runtime.ExecutionContextId,
origin: window.location.origin,
name: window.location.origin,
uniqueId: window.location.origin,
auxData: {frameId: childFrame.id, isDefault: false},
});
const contentScriptExecutionContext = runtimeModel.executionContext(1);
assert.exists(contentScriptExecutionContext);
sinon.stub(contentScriptExecutionContext, 'evaluate').returns(Promise.resolve({
object: SDK.RemoteObject.RemoteObject.fromLocalObject(4),
}));
const result = await new Promise<{result: unknown, error?: {details: unknown[]}}>(
r => context.chrome.devtools?.inspectedWindow.eval(
'4', {frameURL: childFrameUrl, useContentScriptContext: true}, (result, error) => r({result, error})));
assert.deepEqual(result.result, 4);
});
it('blocks evaluation on blocked sub-executioncontexts with explicit scriptExecutionContextOrigin', async () => {
assert.isUndefined(context.chrome.devtools);
const parentFrameUrl = allowedUrl;
const childFrameUrl = urlString`${`${allowedUrl}/2`}`;
const parentFrame = await setUpFrame('parent', parentFrameUrl, undefined, parentFrameUrl);
const childFrame = await setUpFrame('child', childFrameUrl, parentFrame, parentFrameUrl);
// Create a non-default context with a blocked origin.
const childExeContextOrigin = blockedUrl;
const runtimeModel = childFrame.resourceTreeModel()?.target().model(SDK.RuntimeModel.RuntimeModel);
assert.exists(runtimeModel);
runtimeModel.executionContextCreated({
id: 1 as Protocol.Runtime.ExecutionContextId,
origin: childExeContextOrigin,
name: childExeContextOrigin,
uniqueId: childExeContextOrigin,
auxData: {frameId: childFrame.id, isDefault: false},
});
const result = await new Promise<{result: unknown, error?: {details: unknown[]}}>(
r => context.chrome.devtools?.inspectedWindow.eval(
// The typings don't match the implementation, so we need to cast to any here to make ts happy.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
'4', {frameURL: childFrameUrl, scriptExecutionContext: childExeContextOrigin} as any,
(result, error) => r({result, error})));
assert.deepEqual(result.error?.details, ['Permission denied']);
});
it('blocks evaluation on blocked sub-executioncontexts', async () => {
assert.isUndefined(context.chrome.devtools);
const parentFrameUrl = allowedUrl;
const childFrameUrl = urlString`${`${allowedUrl}/2`}`;
const childExeContextOrigin = blockedUrl;
const parentFrame = await setUpFrame('parent', parentFrameUrl, undefined, parentFrameUrl);
await setUpFrame('child', childFrameUrl, parentFrame, childExeContextOrigin);
const result = await new Promise<{result: unknown, error?: {details: unknown[]}}>(
r => context.chrome.devtools?.inspectedWindow.eval(
'4', {frameURL: childFrameUrl}, (result, error) => r({result, error})));
assert.deepEqual(result.error?.details, ['Permission denied']);
});
async function createUISourceCode(
project: Bindings.ContentProviderBasedProject.ContentProviderBasedProject, url: Platform.DevToolsPath.UrlString) {
const mimeType = 'text/html';
const dataProvider = () =>
Promise.resolve(new TextUtils.ContentData.ContentData('content', /* isBase64 */ false, mimeType));
project.addUISourceCodeWithProvider(
new Workspace.UISourceCode.UISourceCode(project, url, Common.ResourceType.resourceTypes.Document),
new TextUtils.StaticContentProvider.StaticContentProvider(
url, Common.ResourceType.resourceTypes.Document, dataProvider),
null, mimeType);
await project.uiSourceCodeForURL(url)?.requestContentData();
}
it('blocks getting resource contents on blocked urls', async () => {
const target = createTarget({id: 'target' as Protocol.Target.TargetID});
target.setInspectedURL(allowedUrl);
sinon.stub(Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding, 'instance')
.returns(sinon.createStubInstance(
Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding, {scriptsForUISourceCode: []}));
const project = new Bindings.ContentProviderBasedProject.ContentProviderBasedProject(
Workspace.Workspace.WorkspaceImpl.instance(), target.id(), Workspace.Workspace.projectTypes.Network, '',
false /* isServiceProject */);
await createUISourceCode(project, blockedUrl);
await createUISourceCode(project, allowedUrl);
assert.exists(context.chrome.devtools);
const resources =
await new Promise<Chrome.DevTools.Resource[]>(r => context.chrome.devtools?.inspectedWindow.getResources(r));
assert.deepEqual(resources.map(r => r.url), [allowedUrl]);
const resourceContents = await Promise.all(resources.map(
resource => new Promise<{url: string, content?: string, encoding?: string}>(
r => resource.getContent((content, encoding) => r({url: resource.url, content, encoding})))));
assert.deepEqual(resourceContents, [
{url: allowedUrl, content: 'content', encoding: ''},
]);
});
function createRequest(
networkManager: SDK.NetworkManager.NetworkManager, frameId: Protocol.Page.FrameId,
requestId: Protocol.Network.RequestId, url: Platform.DevToolsPath.UrlString): void {
const request = SDK.NetworkRequest.NetworkRequest.create(requestId, url, url, frameId, null, null, undefined);
const dataProvider = () =>
Promise.resolve(new TextUtils.ContentData.ContentData('content', false, request.mimeType));
request.setContentDataProvider(dataProvider);
networkManager.dispatchEventToListeners(SDK.NetworkManager.Events.RequestStarted, {request, originalRequest: null});
request.finished = true;
networkManager.dispatchEventToListeners(SDK.NetworkManager.Events.RequestFinished, request);
}
it('does not include blocked hosts in onRequestFinished event listener', async () => {
const frameId = 'frame-id' as Protocol.Page.FrameId;
const target = createTarget({id: 'target' as Protocol.Target.TargetID});
target.setInspectedURL(allowedUrl);
const requests: HAR.Log.EntryDTO[] = [];
// onRequestFinished returns a type of Request. However in actual fact, the returned object contains HAR data
// which result type mismatch due to the Request type not containing the respective fields in HAR.Log.EntryDTO.
// Therefore, cast through unknown to resolve this.
// TODO: (crbug.com/1482763) Update Request type to match HAR.Log.EntryDTO
context.chrome.devtools?.network.onRequestFinished.addListener(
r => requests.push(r as unknown as HAR.Log.EntryDTO));
await waitForFunction(
() => Extensions.ExtensionServer.ExtensionServer.instance().hasSubscribers(
Extensions.ExtensionAPI.PrivateAPI.Events.NetworkRequestFinished));
const networkManager = target.model(SDK.NetworkManager.NetworkManager);
assert.exists(networkManager);
createRequest(networkManager, frameId, 'blocked-url-request-id' as Protocol.Network.RequestId, blockedUrl);
createRequest(networkManager, frameId, 'allowed-url-request-id' as Protocol.Network.RequestId, allowedUrl);
await waitForFunction(() => requests.length >= 1);
assert.lengthOf(requests, 1);
assert.exists(requests.find(e => e.request.url === allowedUrl));
assert.notExists(requests.find(e => e.request.url === blockedUrl));
});
it('blocks setting resource contents on blocked urls', async () => {
const target = createTarget({id: 'target' as Protocol.Target.TargetID});
target.setInspectedURL(allowedUrl);
sinon.stub(Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding, 'instance')
.returns(sinon.createStubInstance(
Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding, {scriptsForUISourceCode: []}));
const project = new Bindings.ContentProviderBasedProject.ContentProviderBasedProject(
Workspace.Workspace.WorkspaceImpl.instance(), target.id(), Workspace.Workspace.projectTypes.Network, '',
false /* isServiceProject */);
await createUISourceCode(project, blockedUrl);
await createUISourceCode(project, allowedUrl);
assert.exists(context.chrome.devtools);
const resources =
await new Promise<Chrome.DevTools.Resource[]>(r => context.chrome.devtools?.inspectedWindow.getResources(r));
assert.deepEqual(resources.map(r => r.url), [allowedUrl]);
assert.deepEqual(project.uiSourceCodeForURL(allowedUrl)?.content(), 'content');
assert.deepEqual(project.uiSourceCodeForURL(blockedUrl)?.content(), 'content');
const responses = await Promise.all(resources.map(
resource => new Promise<Object|undefined>(r => resource.setContent('modified', true, r)))) as
Array<undefined|{code: string, details: string[]}>;
assert.deepEqual(responses.map(response => response?.code), ['OK']);
assert.deepEqual(responses.map(response => response?.details), [[]]);
assert.deepEqual(project.uiSourceCodeForURL(allowedUrl)?.content(), 'modified');
assert.deepEqual(project.uiSourceCodeForURL(blockedUrl)?.content(), 'content');
});
});
describe('ExtensionServer', () => {
it('can correctly expand resource paths', async () => {
// Ideally this would be a chrome-extension://, but that doesn't work with URL in chrome headless.
const extensionOrigin = urlString`chrome://abcdef`;
const almostOrigin = urlString`${`${extensionOrigin}/`}`;
const expectation = urlString`${`${extensionOrigin}/foo`}`;
assert.isUndefined(
Extensions.ExtensionServer.ExtensionServer.expandResourcePath(extensionOrigin, 'http://example.com/foo'));
assert.strictEqual(
expectation, Extensions.ExtensionServer.ExtensionServer.expandResourcePath(extensionOrigin, expectation));
assert.strictEqual(
expectation, Extensions.ExtensionServer.ExtensionServer.expandResourcePath(extensionOrigin, '/foo'));
assert.strictEqual(
expectation, Extensions.ExtensionServer.ExtensionServer.expandResourcePath(extensionOrigin, 'foo'));
assert.isUndefined(
Extensions.ExtensionServer.ExtensionServer.expandResourcePath(almostOrigin, 'http://example.com/foo'));
assert.strictEqual(
expectation, Extensions.ExtensionServer.ExtensionServer.expandResourcePath(almostOrigin, expectation));
assert.strictEqual(
expectation, Extensions.ExtensionServer.ExtensionServer.expandResourcePath(almostOrigin, '/foo'));
assert.strictEqual(expectation, Extensions.ExtensionServer.ExtensionServer.expandResourcePath(almostOrigin, 'foo'));
});
it('cannot inspect chrome webstore URLs', () => {
const blockedUrls = [
'http://chrome.google.com/webstore',
'https://chrome.google.com./webstore',
'http://chrome.google.com/webstore',
'https://chrome.google.com./webstore',
'http://chrome.google.com/webstore/foo',
'https://chrome.google.com./webstore/foo',
'http://chrome.google.com/webstore/foo',
'https://chrome.google.com./webstore/foo',
'http://chromewebstore.google.com/',
'https://chromewebstore.google.com./',
'http://chromewebstore.google.com/',
'https://chromewebstore.google.com./',
'http://chromewebstore.google.com/foo',
'https://chromewebstore.google.com./foo',
'http://chromewebstore.google.com/foo',
'https://chromewebstore.google.com./foo',
];
const allowedUrls = [
'http://chrome.google.com/webstor',
'https://chrome.google.com./webstor',
'http://chrome.google.com/webstor',
'https://chrome.google.com./webstor',
'http://chrome.google.com/',
'https://chrome.google.com./',
'http://chrome.google.com/',
'https://chrome.google.com./',
'http://google.com/webstore',
'https://google.com./webstore',
'http://google.com/webstore',
'https://google.com./webstore',
'http://chromewebstor.google.com/',
'https://chromewebstor.google.com./',
'http://chromewebstor.google.com/',
'https://chromewebstor.google.com./',
];
for (const url of blockedUrls as Platform.DevToolsPath.UrlString[]) {
assert.isFalse(Extensions.ExtensionServer.ExtensionServer.canInspectURL(url), url);
}
for (const url of allowedUrls as Platform.DevToolsPath.UrlString[]) {
assert.isTrue(Extensions.ExtensionServer.ExtensionServer.canInspectURL(url), url);
}
});
it('cannot inspect non-HTTP URL schemes', () => {
const blockedUrls = [
'devtools://devtools/bundled/front_end/devtools_app.html',
'devtools://devtools/anything',
'chrome://extensions',
'chrome-untrusted://extensions',
'chrome-error://crash',
'chrome-search://foo/bar',
];
for (const url of blockedUrls as Platform.DevToolsPath.UrlString[]) {
assert.isFalse(Extensions.ExtensionServer.ExtensionServer.canInspectURL(url), url);
}
});
});
function assertIsStatus<T>(value: T|Extensions.ExtensionServer.Record):
asserts value is Extensions.ExtensionServer.Record {
if (value && typeof value === 'object' && 'code' in value) {
assert.isTrue(value.code === 'OK' || Boolean(value.isError), `Value ${value} is not a status code`);
} else {
assert.fail(`Value ${value} is not a status code`);
}
}
describeWithDevtoolsExtension('Wasm extension API', {}, context => {
let stopId: unknown;
beforeEach(() => {
const target = createTarget();
target.setInspectedURL(urlString`http://example.com`);
const targetManager = target.targetManager();
const resourceMapping =
new Bindings.ResourceMapping.ResourceMapping(targetManager, Workspace.Workspace.WorkspaceImpl.instance());
Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance(
{forceNew: true, resourceMapping, targetManager});
const callFrame = sinon.createStubInstance(SDK.DebuggerModel.CallFrame);
callFrame.debuggerModel = new SDK.DebuggerModel.DebuggerModel(target);
sinon.stub(callFrame, 'id').get(() => '0' as Protocol.Debugger.CallFrameId);
sinon.stub(callFrame.debuggerModel.agent, 'invoke_evaluateOnCallFrame')
.returns(
Promise.resolve({result: {type: Protocol.Runtime.RemoteObjectType.Undefined}, getError: () => undefined}));
stopId = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance().pluginManager.stopIdForCallFrame(
callFrame);
});
function captureError(expectedMessage: string): sinon.SinonStub {
const original = console.error;
return sinon.stub(console, 'error').callsFake((message, ...args) => {
if (expectedMessage !== message) {
original(message, ...args);
}
});
}
it('getWasmGlobal does not block on invalid indices', async () => {
const log = captureError('Extension server error: Invalid argument global: No global with index 0');
const result = await context.chrome.devtools?.languageServices.getWasmGlobal(0, stopId);
assertIsStatus(result);
sinon.assert.calledOnce(log);
assert.strictEqual(result.code, 'E_BADARG');
assert.strictEqual(result.details[0], 'global');
});
it('getWasmLocal does not block on invalid indices', async () => {
const log = captureError('Extension server error: Invalid argument local: No local with index 0');
const result = await context.chrome.devtools?.languageServices.getWasmLocal(0, stopId);
assertIsStatus(result);
sinon.assert.calledOnce(log);
assert.strictEqual(result.code, 'E_BADARG');
assert.strictEqual(result.details[0], 'local');
});
it('getWasmOp does not block on invalid indices', async () => {
const log = captureError('Extension server error: Invalid argument op: No operand with index 0');
const result = await context.chrome.devtools?.languageServices.getWasmOp(0, stopId);
assertIsStatus(result);
sinon.assert.calledOnce(log);
assert.strictEqual(result.code, 'E_BADARG');
assert.strictEqual(result.details[0], 'op');
});
});
class StubLanguageExtension implements Chrome.DevTools.LanguageExtensionPlugin {
async addRawModule(): Promise<string[]|{missingSymbolFiles: string[]}> {
return [];
}
async sourceLocationToRawLocation(): Promise<Chrome.DevTools.RawLocationRange[]> {
return [];
}
async rawLocationToSourceLocation(): Promise<Chrome.DevTools.SourceLocation[]> {
return [];
}
async getScopeInfo(): Promise<Chrome.DevTools.ScopeInfo> {
throw new Error('Method not implemented.');
}
async listVariablesInScope(): Promise<Chrome.DevTools.Variable[]> {
return [];
}
async removeRawModule(): Promise<void> {
}
async getFunctionInfo(): Promise<{frames: Chrome.DevTools.FunctionInfo[], missingSymbolFiles: string[]}|
{missingSymbolFiles: string[]}|{frames: Chrome.DevTools.FunctionInfo[]}> {
return {frames: []};
}
async getInlinedFunctionRanges(): Promise<Chrome.DevTools.RawLocationRange[]> {
return [];
}
async getInlinedCalleesRanges(): Promise<Chrome.DevTools.RawLocationRange[]> {
return [];
}
async getMappedLines(): Promise<number[]|undefined> {
return undefined;
}
async evaluate(): Promise<Chrome.DevTools.RemoteObject|Chrome.DevTools.ForeignObject|null> {
return null;
}
async getProperties(): Promise<Chrome.DevTools.PropertyDescriptor[]> {
return [];
}
async releaseObject(): Promise<void> {
}
}
describeWithDevtoolsExtension('Language Extension API', {}, context => {
it('reports loaded resources', async () => {
const target = createTarget();
target.setInspectedURL(urlString`http://example.com`);
const pageResourceLoader =
SDK.PageResourceLoader.PageResourceLoader.instance({forceNew: true, loadOverride: null, maxConcurrentLoads: 1});
const spy = sinon.spy(pageResourceLoader, 'resourceLoadedThroughExtension');
await context.chrome.devtools?.languageServices.reportResourceLoad('test.dwo', {success: true, size: 10});
sinon.assert.calledOnce(spy);
assert.strictEqual(pageResourceLoader.getNumberOfResources().resources, 1);
const resource = spy.args[0][0];
const extensionId = getExtensionOrigin();
const expectedInitiator = {target: null, frameId: null, initiatorUrl: urlString`${extensionId}`, extensionId};
const expectedResource = {
url: urlString`test.dwo`,
initiator: expectedInitiator,
success: true,
size: 10,
duration: null,
errorMessage: undefined,
};
assert.deepEqual(resource, expectedResource);
});
});
for (const allowFileAccess of [true, false]) {
describeWithDevtoolsExtension(
`Language Extension API with {allowFileAccess: ${allowFileAccess}}`, {allowFileAccess}, context => {
let target: SDK.Target.Target;
beforeEach(() => {
target = createTarget();
const targetManager = target.targetManager();
const workspace = Workspace.Workspace.WorkspaceImpl.instance();
const resourceMapping = new Bindings.ResourceMapping.ResourceMapping(targetManager, workspace);
target.setInspectedURL(urlString`http://example.com`);
const debuggerWorkspaceBinding = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance(
{forceNew: true, targetManager, resourceMapping});
Bindings.IgnoreListManager.IgnoreListManager.instance({forceNew: true, debuggerWorkspaceBinding});
});
it('passes allowFileAccess to the LanguageExtensionEndpoint', async () => {
const endpointSpy =
sinon.spy(Extensions.LanguageExtensionEndpoint.LanguageExtensionEndpoint.prototype, 'handleScript');
const plugin = new StubLanguageExtension();
await context.chrome.devtools?.languageServices.registerLanguageExtensionPlugin(plugin, 'plugin', {
language: Protocol.Debugger.ScriptLanguage.JavaScript,
symbol_types: [Protocol.Debugger.DebugSymbolsType.SourceMap],
});
const debuggerModel = target.model(SDK.DebuggerModel.DebuggerModel);
assert.isOk(debuggerModel);
debuggerModel.parsedScriptSource(
'0' as Protocol.Runtime.ScriptId, urlString`file:///source/url`, 0, 0, 100, 100, 0, '', {}, false,
'file:///source/url.map', false, false, 200, true, null, null,
Protocol.Debugger.ScriptLanguage.JavaScript, [{
type: Protocol.Debugger.DebugSymbolsType.SourceMap,
externalURL: 'file:///source/url.map',
}],
null, null);
sinon.assert.calledOnce(endpointSpy);
assert.strictEqual(
(endpointSpy.thisValues[0] as Extensions.LanguageExtensionEndpoint.LanguageExtensionEndpoint)
.allowFileAccess,
allowFileAccess);
});
});
}
describeWithDevtoolsExtension('validate attachSourceMapURL ', {}, context => {
it('correctly attaches a source map to a registered script', async () => {
const sourceRoot = 'http://example.com';
const scriptName = 'script.ts';
const scriptInfo = {
url: urlString`${sourceRoot}/script.js`,
content: 'function f(x) { console.log(x); } function ignore(y){ console.log(y); }',
};
const sourceMap = encodeSourceMap(
[
`0:9 => ${scriptName}:0:1`,
`1:0 => ${scriptName}:4:0`,
`1:2 => ${scriptName}:4:2`,
`2:0 => ${scriptName}:2:0`,
],
sourceRoot);
const sourceMapString = {
version: 3,
names: ['f', 'console', 'log', 'ignore'],
sources: [scriptInfo.url],
mappings: sourceMap.mappings,
file: `${scriptInfo.url}.map`,
};
const target = createTarget({type: SDK.Target.Type.FRAME});
const targetManager = target.targetManager();
const workspace = Workspace.Workspace.WorkspaceImpl.instance();
const resourceMapping = new Bindings.ResourceMapping.ResourceMapping(targetManager, workspace);
const debuggerWorkspaceBinding = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance(
{forceNew: false, resourceMapping, targetManager});
const backend = new MockProtocolBackend();
Bindings.IgnoreListManager.IgnoreListManager.instance({forceNew: false, debuggerWorkspaceBinding});
// Before any script is registered, there shouldn't be any uiSourceCodes.
assert.isNull(Workspace.Workspace.WorkspaceImpl.instance().uiSourceCodeForURL(scriptInfo.url));
// Create promise to await the uiSourceCode given the url and its target.
const uiSourceCodePromise = debuggerWorkspaceBinding.waitForUISourceCodeAdded(scriptInfo.url, target);
// Register the script.
const currentScript = await backend.addScript(target, scriptInfo, null);
// Await the promise for sourceCode to be added.
await uiSourceCodePromise;
assert.exists(context.chrome.devtools);
const resources = await new Promise<Chrome.DevTools.Resource[]>(r => {
context.chrome.devtools?.inspectedWindow.getResources(r);
});
// Validate that resource is registered.
assert.isTrue(resources && resources.length > 0);
// Script should not have a source map url attached yet.
assert.notExists(currentScript.sourceMapURL);
// Call attachSourceMapURL with encoded source map as a dataURL
const scriptResource = resources.find(item => item.url === scriptInfo.url.toString());
const encodedSourceMap = `data:text/plain;base64,${btoa(JSON.stringify(sourceMapString))}`;
await scriptResource?.attachSourceMapURL(encodedSourceMap);
// Validate that the script has the sourcemap dataURL attached.
assert.deepEqual(currentScript.sourceMapURL, encodedSourceMap);
});
});