[test] Move more of `front_end/panels/` tests next to the source.
DISABLE_THIRD_PARTY_CHECK=Moving unit tests.
Bug: b:325903709, b:323795674, b:326214132
Change-Id: I70aced8ca3f7024f3a3a6b191e3451fb8c7674c9
Doc: http://go/chrome-devtools:move-unit-tests-design
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/5309878
Reviewed-by: Simon Zünd <[email protected]>
Commit-Queue: Benedikt Meurer <[email protected]>
Auto-Submit: Benedikt Meurer <[email protected]>
diff --git a/front_end/panels/explain/BUILD.gn b/front_end/panels/explain/BUILD.gn
index e20979e..552d089 100644
--- a/front_end/panels/explain/BUILD.gn
+++ b/front_end/panels/explain/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../scripts/build/ninja/devtools_module.gni")
import("../../../scripts/build/ninja/generate_css.gni")
+import("../../../third_party/typescript/typescript.gni")
import("../visibility.gni")
generate_css("css_files") {
@@ -44,7 +45,6 @@
visibility = [
":*",
"../../../test/interactions/*",
- "../../../test/unittests/*",
"../../../test/unittests/front_end/panels/emulation/*",
"../../entrypoints/*",
"../../legacy_test_runner/*",
@@ -68,3 +68,18 @@
visibility = [ "../../entrypoints/*" ]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [
+ "InsightProvider.test.ts",
+ "PromptBuilder.test.ts",
+ "components/ConsoleInsight.test.ts",
+ ]
+
+ deps = [
+ ":bundle",
+ "../../../test/unittests/front_end/helpers",
+ ]
+}
diff --git a/front_end/panels/explain/InsightProvider.test.ts b/front_end/panels/explain/InsightProvider.test.ts
new file mode 100644
index 0000000..17b0ee2
--- /dev/null
+++ b/front_end/panels/explain/InsightProvider.test.ts
@@ -0,0 +1,196 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Host from '../../core/host/host.js';
+import * as Root from '../../core/root/root.js';
+
+import * as Explain from './explain.js';
+
+const {assert} = chai;
+
+const TEST_MODEL_ID = 'testModelId';
+
+describe('InsightProvider', () => {
+ it('adds no model temperature if there is no aidaTemperature query param', () => {
+ const stub = sinon.stub(Root.Runtime.Runtime, 'queryParam');
+ stub.withArgs('aidaTemperature').returns(null);
+ const request = Explain.InsightProvider.buildApiRequest('foo');
+ assert.deepStrictEqual(request, {
+ input: 'foo',
+ client: 'CHROME_DEVTOOLS',
+ });
+ stub.restore();
+ });
+
+ it('adds a model temperature', () => {
+ const stub = sinon.stub(Root.Runtime.Runtime, 'queryParam');
+ stub.withArgs('aidaTemperature').returns('0.5');
+ const request = Explain.InsightProvider.buildApiRequest('foo');
+ assert.deepStrictEqual(request, {
+ input: 'foo',
+ client: 'CHROME_DEVTOOLS',
+ options: {
+ temperature: 0.5,
+ },
+ });
+ stub.restore();
+ });
+
+ it('adds a model temperature of 0', () => {
+ const stub = sinon.stub(Root.Runtime.Runtime, 'queryParam');
+ stub.withArgs('aidaTemperature').returns('0');
+ const request = Explain.InsightProvider.buildApiRequest('foo');
+ assert.deepStrictEqual(request, {
+ input: 'foo',
+ client: 'CHROME_DEVTOOLS',
+ options: {
+ temperature: 0,
+ },
+ });
+ stub.restore();
+ });
+
+ it('adds no model temperature if the aidaTemperature query param cannot be parsed into a float', () => {
+ const stub = sinon.stub(Root.Runtime.Runtime, 'queryParam');
+ stub.withArgs('aidaTemperature').returns('not a number');
+ const request = Explain.InsightProvider.buildApiRequest('foo');
+ assert.deepStrictEqual(request, {
+ input: 'foo',
+ client: 'CHROME_DEVTOOLS',
+ });
+ stub.restore();
+ });
+
+ it('adds no model id if there is no aidaModelId query param', () => {
+ const stub = sinon.stub(Root.Runtime.Runtime, 'queryParam');
+ stub.withArgs('aidaModelId').returns(null);
+ const request = Explain.InsightProvider.buildApiRequest('foo');
+ assert.deepStrictEqual(request, {
+ input: 'foo',
+ client: 'CHROME_DEVTOOLS',
+ });
+ stub.restore();
+ });
+
+ it('adds a model id', () => {
+ const stub = sinon.stub(Root.Runtime.Runtime, 'queryParam');
+ stub.withArgs('aidaModelId').returns(TEST_MODEL_ID);
+ const request = Explain.InsightProvider.buildApiRequest('foo');
+ assert.deepStrictEqual(request, {
+ input: 'foo',
+ client: 'CHROME_DEVTOOLS',
+ options: {
+ model_id: TEST_MODEL_ID,
+ },
+ });
+ stub.restore();
+ });
+
+ it('adds a model id and temperature', () => {
+ const stub = sinon.stub(Root.Runtime.Runtime, 'queryParam');
+ stub.withArgs('aidaModelId').returns(TEST_MODEL_ID);
+ stub.withArgs('aidaTemperature').returns('0.5');
+ const request = Explain.InsightProvider.buildApiRequest('foo');
+ assert.deepStrictEqual(request, {
+ input: 'foo',
+ client: 'CHROME_DEVTOOLS',
+ options: {
+ model_id: TEST_MODEL_ID,
+ temperature: 0.5,
+ },
+ });
+ stub.restore();
+ });
+
+ async function getAllResults(provider: Explain.InsightProvider): Promise<string[]> {
+ const results = [];
+ for await (const result of provider.getInsights('foo')) {
+ results.push(result);
+ }
+ return results;
+ }
+
+ it('handles chunked response', async () => {
+ sinon.stub(Host.InspectorFrontendHost.InspectorFrontendHostInstance, 'doAidaConversation')
+ .callsFake(async (_, streamId, callback) => {
+ const response = JSON.stringify([
+ {textChunk: {text: 'hello '}},
+ {textChunk: {text: 'brave '}},
+ {textChunk: {text: 'new world!'}},
+ ]);
+ for (const chunk of response.split(',')) {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ Host.ResourceLoader.streamWrite(streamId, chunk);
+ }
+ callback({statusCode: 200});
+ });
+
+ const provider = new Explain.InsightProvider();
+ const results = await getAllResults(provider);
+ assert.deepStrictEqual(results, ['hello ', 'hello brave ', 'hello brave new world!']);
+ });
+
+ it('handles subsequent code chunks', async () => {
+ sinon.stub(Host.InspectorFrontendHost.InspectorFrontendHostInstance, 'doAidaConversation')
+ .callsFake(async (_, streamId, callback) => {
+ const response = JSON.stringify([
+ {textChunk: {text: 'hello '}},
+ {codeChunk: {code: 'brave '}},
+ {codeChunk: {code: 'new World()'}},
+ ]);
+ for (const chunk of response.split(',')) {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ Host.ResourceLoader.streamWrite(streamId, chunk);
+ }
+ callback({statusCode: 200});
+ });
+
+ const provider = new Explain.InsightProvider();
+ const results = await getAllResults(provider);
+ assert.deepStrictEqual(
+ results, ['hello ', 'hello \n`````\nbrave \n`````\n', 'hello \n`````\nbrave new World()\n`````\n']);
+ });
+
+ it('throws a readable error on 403', async () => {
+ sinon.stub(Host.InspectorFrontendHost.InspectorFrontendHostInstance, 'doAidaConversation').callsArgWith(2, {
+ 'statusCode': 403,
+ });
+ const provider = new Explain.InsightProvider();
+ try {
+ await getAllResults(provider);
+ expect.fail('provider.getInsights did not throw');
+ } catch (err) {
+ expect(err.message).equals('Server responded: permission denied');
+ }
+ });
+
+ it('throws an error for other codes', async () => {
+ sinon.stub(Host.InspectorFrontendHost.InspectorFrontendHostInstance, 'doAidaConversation').callsArgWith(2, {
+ 'statusCode': 418,
+ });
+ const provider = new Explain.InsightProvider();
+ try {
+ await getAllResults(provider);
+ expect.fail('provider.getInsights did not throw');
+ } catch (err) {
+ expect(err.message).equals('Request failed: {"statusCode":418}');
+ }
+ });
+
+ it('throws an error with all details for other failures', async () => {
+ sinon.stub(Host.InspectorFrontendHost.InspectorFrontendHostInstance, 'doAidaConversation').callsArgWith(2, {
+ 'error': 'Cannot get OAuth credentials',
+ 'detail': '{\'@type\': \'type.googleapis.com/google.rpc.DebugInfo\', \'detail\': \'DETAILS\'}',
+ });
+ const provider = new Explain.InsightProvider();
+ try {
+ await getAllResults(provider);
+ expect.fail('provider.getInsights did not throw');
+ } catch (err) {
+ expect(err.message)
+ .equals(
+ 'Cannot send request: Cannot get OAuth credentials {\'@type\': \'type.googleapis.com/google.rpc.DebugInfo\', \'detail\': \'DETAILS\'}');
+ }
+ });
+});
diff --git a/front_end/panels/explain/PromptBuilder.test.ts b/front_end/panels/explain/PromptBuilder.test.ts
new file mode 100644
index 0000000..63e538e
--- /dev/null
+++ b/front_end/panels/explain/PromptBuilder.test.ts
@@ -0,0 +1,388 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {
+ createConsoleViewMessageWithStubDeps,
+ createStackTrace,
+} from '../../../test/unittests/front_end/helpers/ConsoleHelpers.js';
+import {createTarget, describeWithLocale} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import {
+ createContentProviderUISourceCode,
+ createFakeScriptMapping,
+} from '../../../test/unittests/front_end/helpers/UISourceCodeHelpers.js';
+import type * as Platform from '../../core/platform/platform.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import * as Protocol from '../../generated/protocol.js';
+import * as Bindings from '../../models/bindings/bindings.js';
+import * as Logs from '../../models/logs/logs.js';
+import * as Workspace from '../../models/workspace/workspace.js';
+
+import * as Explain from './explain.js';
+
+const {assert} = chai;
+
+describeWithLocale('PromptBuilder', () => {
+ describe('allowHeader', () => {
+ it('disallows cookie headers', () => {
+ assert(!Explain.allowHeader({name: 'Cookie', value: ''}));
+ assert(!Explain.allowHeader({name: 'cookiE', value: ''}));
+ assert(!Explain.allowHeader({name: 'cookie', value: ''}));
+ assert(!Explain.allowHeader({name: 'set-cookie', value: ''}));
+ assert(!Explain.allowHeader({name: 'Set-cOokie', value: ''}));
+ });
+
+ it('disallows authorization headers', () => {
+ assert(!Explain.allowHeader({name: 'AuthoRization', value: ''}));
+ assert(!Explain.allowHeader({name: 'authorization', value: ''}));
+ });
+
+ it('disallows custom headers', () => {
+ assert(!Explain.allowHeader({name: 'X-smth', value: ''}));
+ assert(!Explain.allowHeader({name: 'X-', value: ''}));
+ assert(!Explain.allowHeader({name: 'x-smth', value: ''}));
+ assert(!Explain.allowHeader({name: 'x-', value: ''}));
+ });
+ });
+
+ const NETWORK_REQUEST = {
+ url() {
+ return 'https://example.com' as Platform.DevToolsPath.UrlString;
+ },
+ requestHeaders() {
+ return [{
+ name: 'Origin',
+ value: 'https://example.com',
+ }];
+ },
+ statusCode: 404,
+ statusText: 'Not found',
+ responseHeaders: [{
+ name: 'Origin',
+ value: 'https://example.com',
+ }],
+ } as SDK.NetworkRequest.NetworkRequest;
+
+ describe('format formatNetworkRequest', () => {
+ it('formats a network request', () => {
+ assert.strictEqual(Explain.formatNetworkRequest(NETWORK_REQUEST), `Request: https://example.com
+
+Request headers:
+Origin: https://example.com
+
+Response headers:
+Origin: https://example.com
+
+Response status: 404 Not found`);
+ });
+ });
+
+ describe('formatRelatedCode', () => {
+ it('formats a single line code', () => {
+ assert.strictEqual(
+ Explain.formatRelatedCode(
+ {
+ text: '12345678901234567890',
+ columnNumber: 10,
+ lineNumber: 0,
+ },
+ /* maxLength=*/ 5),
+ '89012');
+ assert.strictEqual(
+ Explain.formatRelatedCode(
+ {
+ text: '12345678901234567890',
+ columnNumber: 10,
+ lineNumber: 0,
+ },
+ /* maxLength=*/ 6),
+ '890123');
+ assert.strictEqual(
+ Explain.formatRelatedCode(
+ {
+ text: '12345678901234567890',
+ columnNumber: 10,
+ lineNumber: 0,
+ },
+ /* maxLength=*/ 30),
+ '12345678901234567890');
+ });
+
+ it('formats a multiline code', () => {
+ assert.strictEqual(
+ Explain.formatRelatedCode(
+ {
+ text: '123\n456\n789\n123\n456\n789\n',
+ columnNumber: 1,
+ lineNumber: 1,
+ },
+ /* maxLength=*/ 5),
+ '456');
+ assert.strictEqual(
+ Explain.formatRelatedCode(
+ {
+ text: '123\n456\n789\n123\n456\n789\n',
+ columnNumber: 1,
+ lineNumber: 1,
+ },
+ /* maxLength=*/ 10),
+ '456\n789\n123');
+ assert.strictEqual(
+ Explain.formatRelatedCode(
+ {
+ text: '123\n456\n789\n123\n456\n789\n',
+ columnNumber: 1,
+ lineNumber: 1,
+ },
+ /* maxLength=*/ 16),
+ '123\n456\n789\n123');
+ });
+
+ it('uses indentation to select blocks or functions', () => {
+ // Somewhat realistic code
+ const text = `import something;
+import anotherthing;
+
+const x = 1;
+function f1() {
+ // a
+
+ // b
+}
+
+function bigger() {
+ // x
+ if (true) {
+ // y
+
+ // zzzzzz
+ }
+
+ let y = x + 2;
+
+ if (false) {
+ // a
+
+ f1();
+ if (x == x) {
+ // z
+ }
+ }
+}
+
+export const y = "";
+`;
+ assert.strictEqual(
+ Explain.formatRelatedCode({text, columnNumber: 4, lineNumber: 11}, /* maxLength=*/ 233),
+ ' // x\n if (true) {\n // y\n\n // zzzzzz\n }\n\n let y = x + 2;\n\n if (false) {\n // a\n\n f1();\n if (x == x) {\n // z\n }\n }',
+ );
+ assert.strictEqual(
+ Explain.formatRelatedCode({text, columnNumber: 4, lineNumber: 11}, /* maxLength=*/ 232),
+ ' // x\n if (true) {\n // y\n\n // zzzzzz\n }\n\n let y = x + 2;',
+ );
+ assert.strictEqual(
+ Explain.formatRelatedCode({text, columnNumber: 4, lineNumber: 11}, /* maxLength=*/ 600),
+ text.trim(),
+ );
+ assert.strictEqual(
+ Explain.formatRelatedCode({text, columnNumber: 4, lineNumber: 11}, /* maxLength=*/ 50),
+ ' // x\n if (true) {\n // y\n\n // zzzzzz\n }',
+ );
+ assert.strictEqual(
+ Explain.formatRelatedCode({text, columnNumber: 4, lineNumber: 11}, /* maxLength=*/ 40),
+ ' // x',
+ );
+ assert.strictEqual(
+ Explain.formatRelatedCode({text, columnNumber: 4, lineNumber: 18}, /* maxLength=*/ 50),
+ ' let y = x + 2;',
+ );
+ });
+ });
+
+ it('Extracts expected whitespace from beginnings of lines', () => {
+ assert.strictEqual(Explain.lineWhitespace(' a'), ' ');
+ assert.strictEqual(Explain.lineWhitespace('a'), '');
+ assert.strictEqual(Explain.lineWhitespace(' '), null);
+ assert.strictEqual(Explain.lineWhitespace(''), null);
+ assert.strictEqual(Explain.lineWhitespace('\t\ta'), '\t\t');
+ });
+
+ describeWithMockConnection('buildPrompt', () => {
+ let target: SDK.Target.Target;
+ let debuggerWorkspaceBinding: Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding;
+
+ beforeEach(() => {
+ target = createTarget();
+ const targetManager = target.targetManager();
+ const workspace = Workspace.Workspace.WorkspaceImpl.instance();
+ const resourceMapping = new Bindings.ResourceMapping.ResourceMapping(targetManager, workspace);
+ debuggerWorkspaceBinding = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance(
+ {forceNew: true, resourceMapping, targetManager});
+ });
+
+ const PREAMBLE = 'Why does browser show an error';
+ const RELATED_CODE_PREFIX = 'For the following code in my web app';
+ const RELATED_NETWORK_REQUEST_PREFIX = 'For the following network request in my web app';
+
+ it('builds a simple prompt', async () => {
+ const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
+ const messageDetails = {
+ type: Protocol.Runtime.ConsoleAPICalledEventType.Log,
+ };
+ const ERROR_MESSAGE = 'kaboom!';
+ const rawMessage = new SDK.ConsoleModel.ConsoleMessage(
+ runtimeModel, SDK.ConsoleModel.FrontendMessageSource.ConsoleAPI, /* level */ null, ERROR_MESSAGE,
+ messageDetails);
+ const {message} = createConsoleViewMessageWithStubDeps(rawMessage);
+ const promptBuilder = new Explain.PromptBuilder(message);
+ const {prompt, sources} = await promptBuilder.buildPrompt();
+ assert.strictEqual(prompt, [
+ PREAMBLE,
+ ERROR_MESSAGE,
+ ].join('\n'));
+ assert.deepStrictEqual(sources, [{type: 'message', value: ERROR_MESSAGE}]);
+ });
+
+ it('builds a prompt with related code', async () => {
+ const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
+ const SCRIPT_ID = '1' as Protocol.Runtime.ScriptId;
+ const LINE_NUMBER = 42;
+ const URL = 'http://example.com/script.js' as Platform.DevToolsPath.UrlString;
+ const stackTrace = createStackTrace([
+ `${SCRIPT_ID}::userNestedFunction::${URL}::${LINE_NUMBER}::15`,
+ `${SCRIPT_ID}::userFunction::http://example.com/script.js::10::2`,
+ `${SCRIPT_ID}::entry::http://example.com/app.js::25::10`,
+ ]);
+ const messageDetails = {
+ type: Protocol.Runtime.ConsoleAPICalledEventType.Log,
+ stackTrace,
+ };
+ const RELATED_CODE = `${'\n'.repeat(LINE_NUMBER)}console.error('kaboom!')`;
+ const {uiSourceCode, project} =
+ createContentProviderUISourceCode({url: URL, mimeType: 'text/javascript', content: RELATED_CODE});
+ const debuggerModel = target.model(SDK.DebuggerModel.DebuggerModel);
+ assertNotNullOrUndefined(debuggerModel);
+ const mapping = createFakeScriptMapping(debuggerModel, uiSourceCode, LINE_NUMBER, SCRIPT_ID);
+ debuggerWorkspaceBinding.addSourceMapping(mapping);
+ const ERROR_MESSAGE = 'kaboom!';
+ const rawMessage = new SDK.ConsoleModel.ConsoleMessage(
+ runtimeModel, SDK.ConsoleModel.FrontendMessageSource.ConsoleAPI, /* level */ null, ERROR_MESSAGE,
+ messageDetails);
+ const {message} = createConsoleViewMessageWithStubDeps(rawMessage);
+ const promptBuilder = new Explain.PromptBuilder(message);
+ const {prompt, sources} = await promptBuilder.buildPrompt();
+ assert.strictEqual(prompt, [
+ PREAMBLE,
+ ERROR_MESSAGE,
+ RELATED_CODE_PREFIX,
+ '',
+ '```',
+ RELATED_CODE.trim(),
+ '```',
+ ].join('\n'));
+
+ assert.deepStrictEqual(
+ sources, [{type: 'message', value: ERROR_MESSAGE}, {type: 'relatedCode', value: RELATED_CODE.trim()}]);
+
+ Workspace.Workspace.WorkspaceImpl.instance().removeProject(project);
+ Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance().removeSourceMapping(mapping);
+ });
+
+ it('builds a prompt with related code and stacktrace', async () => {
+ const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
+ const SCRIPT_ID = '1' as Protocol.Runtime.ScriptId;
+ const LINE_NUMBER = 42;
+ const URL = 'http://example.com/script.js' as Platform.DevToolsPath.UrlString;
+ const stackTrace = createStackTrace([
+ `${SCRIPT_ID}::userNestedFunction::${URL}::${LINE_NUMBER}::15`,
+ `${SCRIPT_ID}::userFunction::http://example.com/script.js::10::2`,
+ `${SCRIPT_ID}::entry::http://example.com/app.js::25::10`,
+ ]);
+ // Linkifier is mocked in this test, therefore, no link text after @.
+ const STACK_TRACE = ['userNestedFunction @ ', 'userFunction @ ', 'entry @'].join('\n');
+ const messageDetails = {
+ type: Protocol.Runtime.ConsoleAPICalledEventType.Log,
+ stackTrace,
+ };
+ const RELATED_CODE = `${'\n'.repeat(LINE_NUMBER)}console.error('kaboom!')`;
+ const {uiSourceCode, project} =
+ createContentProviderUISourceCode({url: URL, mimeType: 'text/javascript', content: RELATED_CODE});
+ const debuggerModel = target.model(SDK.DebuggerModel.DebuggerModel);
+ assertNotNullOrUndefined(debuggerModel);
+ const mapping = createFakeScriptMapping(debuggerModel, uiSourceCode, LINE_NUMBER, SCRIPT_ID);
+ debuggerWorkspaceBinding.addSourceMapping(mapping);
+ const ERROR_MESSAGE = 'kaboom!';
+ const rawMessage = new SDK.ConsoleModel.ConsoleMessage(
+ runtimeModel, SDK.ConsoleModel.FrontendMessageSource.ConsoleAPI, Protocol.Log.LogEntryLevel.Error,
+ ERROR_MESSAGE, messageDetails);
+ const {message} = createConsoleViewMessageWithStubDeps(rawMessage);
+ const promptBuilder = new Explain.PromptBuilder(message);
+ const {prompt, sources} = await promptBuilder.buildPrompt();
+ assert.strictEqual(prompt, [
+ PREAMBLE,
+ ERROR_MESSAGE,
+ STACK_TRACE,
+ RELATED_CODE_PREFIX,
+ '',
+ '```',
+ RELATED_CODE.trim(),
+ '```',
+ ].join('\n'));
+
+ assert.deepStrictEqual(sources, [
+ {type: 'message', value: ERROR_MESSAGE},
+ {type: 'stacktrace', value: STACK_TRACE},
+ {type: 'relatedCode', value: RELATED_CODE.trim()},
+ ]);
+
+ Workspace.Workspace.WorkspaceImpl.instance().removeProject(project);
+ Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance().removeSourceMapping(mapping);
+ });
+
+ it('builds a prompt with related request', async () => {
+ const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
+ const REQUEST_ID = '29.1' as Protocol.Network.RequestId;
+ const messageDetails = {
+ type: Protocol.Runtime.ConsoleAPICalledEventType.Log,
+ affectedResources: {
+ requestId: REQUEST_ID,
+ },
+ };
+ sinon.stub(Logs.NetworkLog.NetworkLog.instance(), 'requestsForId').withArgs(REQUEST_ID).returns([
+ NETWORK_REQUEST,
+ ]);
+ const RELATED_REQUEST = [
+ 'Request: https://example.com',
+ '',
+ 'Request headers:',
+ 'Origin: https://example.com',
+ '',
+ 'Response headers:',
+ 'Origin: https://example.com',
+ '',
+ 'Response status: 404 Not found',
+ ].join('\n');
+ const ERROR_MESSAGE = 'kaboom!';
+ const rawMessage = new SDK.ConsoleModel.ConsoleMessage(
+ runtimeModel, SDK.ConsoleModel.FrontendMessageSource.ConsoleAPI, /* level */ null, ERROR_MESSAGE,
+ messageDetails);
+ const {message} = createConsoleViewMessageWithStubDeps(rawMessage);
+ const promptBuilder = new Explain.PromptBuilder(message);
+ const {prompt, sources} = await promptBuilder.buildPrompt();
+ assert.strictEqual(prompt, [
+ PREAMBLE,
+ ERROR_MESSAGE,
+ RELATED_NETWORK_REQUEST_PREFIX,
+ '',
+ '```',
+ RELATED_REQUEST,
+ '```',
+ ].join('\n'));
+
+ assert.deepStrictEqual(
+ sources, [{type: 'message', value: ERROR_MESSAGE}, {type: 'networkRequest', value: RELATED_REQUEST}]);
+ });
+ });
+});
diff --git a/front_end/panels/explain/components/ConsoleInsight.test.ts b/front_end/panels/explain/components/ConsoleInsight.test.ts
new file mode 100644
index 0000000..c32e84c
--- /dev/null
+++ b/front_end/panels/explain/components/ConsoleInsight.test.ts
@@ -0,0 +1,140 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Explain from '../explain.js';
+import type * as Marked from '../../../third_party/marked/marked.js';
+import {dispatchClickEvent, renderElementIntoDOM} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {describeWithLocale} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+
+const {assert} = chai;
+
+describeWithLocale('ConsoleInsight', () => {
+ describe('Markdown renderer', () => {
+ it('renders link as an x-link', () => {
+ const renderer = new Explain.MarkdownRenderer();
+ const result =
+ renderer.renderToken({type: 'link', text: 'learn more', href: 'exampleLink'} as Marked.Marked.Token);
+ assert((result.values[0] as HTMLElement).tagName === 'X-LINK');
+ });
+ it('renders images as an x-link', () => {
+ const renderer = new Explain.MarkdownRenderer();
+ const result =
+ renderer.renderToken({type: 'image', text: 'learn more', href: 'exampleLink'} as Marked.Marked.Token);
+ assert((result.values[0] as HTMLElement).tagName === 'X-LINK');
+ });
+ it('renders headers as a strong element', () => {
+ const renderer = new Explain.MarkdownRenderer();
+ const result = renderer.renderToken({type: 'heading', text: 'learn more'} as Marked.Marked.Token);
+ assert(result.strings.join('').includes('<strong>'));
+ });
+ it('renders unsupported tokens', () => {
+ const renderer = new Explain.MarkdownRenderer();
+ const result = renderer.renderToken({type: 'html', raw: '<!DOCTYPE html>'} as Marked.Marked.Token);
+ assert(result.values.join('').includes('<!DOCTYPE html>'));
+ });
+ });
+
+ describe('ConsoleInsight', () => {
+ function getTestInsightProvider() {
+ return {
+ async *
+ getInsights() {
+ yield 'test';
+ },
+ };
+ }
+
+ function getTestPromptBuilder() {
+ return {
+ async buildPrompt() {
+ return {
+ prompt: '',
+ sources: [
+ {
+ type: Explain.SourceType.MESSAGE,
+ value: 'error message',
+ },
+ ],
+ };
+ },
+ };
+ }
+
+ async function drainMicroTasks() {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ }
+
+ it('shows the consent flow for signed-in users', async () => {
+ const component = new Explain.ConsoleInsight(getTestPromptBuilder(), getTestInsightProvider(), '', {
+ isSyncActive: true,
+ accountEmail: 'some-email',
+ });
+ renderElementIntoDOM(component);
+ await drainMicroTasks();
+ // Consent button is present.
+ assert(component.shadowRoot!.querySelector('.consent-button'));
+ });
+
+ it('consent can be accepted', async () => {
+ const component = new Explain.ConsoleInsight(getTestPromptBuilder(), getTestInsightProvider(), '', {
+ isSyncActive: true,
+ accountEmail: 'some-email',
+ });
+ renderElementIntoDOM(component);
+ await drainMicroTasks();
+ dispatchClickEvent(component.shadowRoot!.querySelector('.consent-button')!, {
+ bubbles: true,
+ composed: true,
+ });
+ // Expected to be rendered in the next task.
+ await new Promise(resolve => setTimeout(resolve, 0));
+ // Rating buttons are shown.
+ assert(component.shadowRoot!.querySelector('.rating'));
+ });
+
+ it('report if the user is not logged in', async () => {
+ const component = new Explain.ConsoleInsight(getTestPromptBuilder(), getTestInsightProvider(), '', {
+ isSyncActive: false,
+ });
+ renderElementIntoDOM(component);
+ await drainMicroTasks();
+ const content = component.shadowRoot!.querySelector('main')!.innerText.trim();
+ assert.strictEqual(
+ content, 'This feature is only available if you are signed into Chrome with your Google account.');
+ });
+
+ it('report if the sync is not enabled', async () => {
+ const component = new Explain.ConsoleInsight(getTestPromptBuilder(), getTestInsightProvider(), '', {
+ isSyncActive: false,
+ accountEmail: 'some-email',
+ });
+ renderElementIntoDOM(component);
+ await drainMicroTasks();
+ const content = component.shadowRoot!.querySelector('main')!.innerText.trim();
+ assert.strictEqual(content, 'This feature is only available if you have Chrome sync turned on.');
+ });
+
+ it('report if the navigator is offline', async () => {
+ const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator')!;
+ Object.defineProperty(globalThis, 'navigator', {
+ get() {
+ return {onLine: false};
+ },
+ });
+
+ try {
+ const component = new Explain.ConsoleInsight(getTestPromptBuilder(), getTestInsightProvider(), '', {
+ isSyncActive: false,
+ accountEmail: 'some-email',
+ });
+ renderElementIntoDOM(component);
+ await drainMicroTasks();
+ const content = component.shadowRoot!.querySelector('main')!.innerText.trim();
+ assert.strictEqual(content, 'Internet connection is currently not available.');
+ } finally {
+ Object.defineProperty(globalThis, 'navigator', navigatorDescriptor);
+ }
+ });
+ });
+});
diff --git a/front_end/panels/issues/BUILD.gn b/front_end/panels/issues/BUILD.gn
index c6d6a81..d6b4e8e 100644
--- a/front_end/panels/issues/BUILD.gn
+++ b/front_end/panels/issues/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../scripts/build/ninja/devtools_module.gni")
import("../../../scripts/build/ninja/generate_css.gni")
+import("../../../third_party/typescript/typescript.gni")
import("../visibility.gni")
generate_css("css_files") {
@@ -93,3 +94,20 @@
visibility = [ "../../entrypoints/*" ]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [
+ "IssueAggregator.test.ts",
+ "IssueView.test.ts",
+ ]
+
+ deps = [
+ ":bundle",
+ "../../../test/unittests/front_end/helpers",
+ "../../core/common:bundle",
+ "../../core/host:bundle",
+ "../../core/sdk:bundle",
+ ]
+}
diff --git a/front_end/panels/issues/IssueAggregator.test.ts b/front_end/panels/issues/IssueAggregator.test.ts
new file mode 100644
index 0000000..ab45974
--- /dev/null
+++ b/front_end/panels/issues/IssueAggregator.test.ts
@@ -0,0 +1,363 @@
+// Copyright 2020 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 {
+ createFakeSetting,
+ createTarget,
+ describeWithEnvironment,
+} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import {MockIssuesManager} from '../../../test/unittests/front_end/helpers/MockIssuesManager.js';
+import {StubIssue} from '../../../test/unittests/front_end/helpers/StubIssue.js';
+import type * as Common from '../../core/common/common.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import * as Protocol from '../../generated/protocol.js';
+import * as IssuesManager from '../../models/issues_manager/issues_manager.js';
+
+import * as Issues from './issues.js';
+
+const {assert} = chai;
+
+describeWithEnvironment('AggregatedIssue', () => {
+ const aggregationKey = 'key' as unknown as Issues.IssueAggregator.AggregationKey;
+ it('deduplicates network requests across issues', () => {
+ const issue1 = StubIssue.createFromRequestIds(['id1', 'id2']);
+ const issue2 = StubIssue.createFromRequestIds(['id1']);
+
+ const aggregatedIssue = new Issues.IssueAggregator.AggregatedIssue('code', aggregationKey);
+ aggregatedIssue.addInstance(issue1);
+ aggregatedIssue.addInstance(issue2);
+
+ const actualRequestIds = [...aggregatedIssue.requests()].map(r => r.requestId).sort();
+ assert.deepStrictEqual(actualRequestIds, ['id1', 'id2']);
+ });
+
+ it('deduplicates affected cookies across issues', () => {
+ const issue1 = StubIssue.createFromCookieNames(['cookie1']);
+ const issue2 = StubIssue.createFromCookieNames(['cookie2']);
+ const issue3 = StubIssue.createFromCookieNames(['cookie1', 'cookie2']);
+
+ const aggregatedIssue = new Issues.IssueAggregator.AggregatedIssue('code', aggregationKey);
+ aggregatedIssue.addInstance(issue1);
+ aggregatedIssue.addInstance(issue2);
+ aggregatedIssue.addInstance(issue3);
+
+ const actualCookieNames = [...aggregatedIssue.cookies()].map(c => c.name).sort();
+ assert.deepStrictEqual(actualCookieNames, ['cookie1', 'cookie2']);
+ });
+});
+
+function createModel() {
+ const target = createTarget();
+ const model = target.model(SDK.IssuesModel.IssuesModel);
+ assertNotNullOrUndefined(model);
+ return model;
+}
+
+describeWithMockConnection('IssueAggregator', () => {
+ it('deduplicates issues with the same code', () => {
+ const issue1 = StubIssue.createFromRequestIds(['id1']);
+ const issue2 = StubIssue.createFromRequestIds(['id2']);
+
+ const model = createModel();
+ const mockManager = new MockIssuesManager([]) as unknown as IssuesManager.IssuesManager.IssuesManager;
+ const aggregator = new Issues.IssueAggregator.IssueAggregator(mockManager);
+ mockManager.dispatchEventToListeners(
+ IssuesManager.IssuesManager.Events.IssueAdded, {issuesModel: model, issue: issue1});
+ mockManager.dispatchEventToListeners(
+ IssuesManager.IssuesManager.Events.IssueAdded, {issuesModel: model, issue: issue2});
+
+ const issues = Array.from(aggregator.aggregatedIssues());
+ assert.strictEqual(issues.length, 1);
+ const requestIds = [...issues[0].requests()].map(r => r.requestId).sort();
+ assert.deepStrictEqual(requestIds, ['id1', 'id2']);
+ });
+
+ it('deduplicates issues with the same code added before its creation', () => {
+ const issue1 = StubIssue.createFromRequestIds(['id1']);
+ const issue2 = StubIssue.createFromRequestIds(['id2']);
+ const issue1b = StubIssue.createFromRequestIds(['id1']); // Duplicate id.
+ const issue3 = StubIssue.createFromRequestIds(['id3']);
+
+ const model = createModel();
+ const mockManager =
+ new MockIssuesManager([issue1b, issue3]) as unknown as IssuesManager.IssuesManager.IssuesManager;
+ const aggregator = new Issues.IssueAggregator.IssueAggregator(mockManager);
+ mockManager.dispatchEventToListeners(
+ IssuesManager.IssuesManager.Events.IssueAdded, {issuesModel: model, issue: issue1});
+ mockManager.dispatchEventToListeners(
+ IssuesManager.IssuesManager.Events.IssueAdded, {issuesModel: model, issue: issue2});
+
+ const issues = Array.from(aggregator.aggregatedIssues());
+ assert.strictEqual(issues.length, 1);
+ const requestIds = [...issues[0].requests()].map(r => r.requestId).sort();
+ assert.deepStrictEqual(requestIds, ['id1', 'id2', 'id3']);
+ });
+
+ it('keeps issues with different codes separate', () => {
+ const issue1 = new StubIssue('codeA', ['id1'], []);
+ const issue2 = new StubIssue('codeB', ['id1'], []);
+ const issue1b = new StubIssue('codeC', ['id1'], []);
+ const issue3 = new StubIssue('codeA', ['id1'], []);
+
+ const model = createModel();
+ const mockManager =
+ new MockIssuesManager([issue1b, issue3]) as unknown as IssuesManager.IssuesManager.IssuesManager;
+ const aggregator = new Issues.IssueAggregator.IssueAggregator(mockManager);
+ mockManager.dispatchEventToListeners(
+ IssuesManager.IssuesManager.Events.IssueAdded, {issuesModel: model, issue: issue1});
+ mockManager.dispatchEventToListeners(
+ IssuesManager.IssuesManager.Events.IssueAdded, {issuesModel: model, issue: issue2});
+
+ const issues = Array.from(aggregator.aggregatedIssues());
+ assert.strictEqual(issues.length, 3);
+ const issueCodes = issues.map(r => r.aggregationKey().toString()).sort((a, b) => a.localeCompare(b));
+ assert.deepStrictEqual(issueCodes, ['codeA', 'codeB', 'codeC']);
+ });
+
+ describe('aggregates issue kind', () => {
+ it('for a single issue', () => {
+ const issues = StubIssue.createFromIssueKinds([IssuesManager.Issue.IssueKind.Improvement]);
+
+ const mockManager = new MockIssuesManager(issues) as unknown as IssuesManager.IssuesManager.IssuesManager;
+ const aggregator = new Issues.IssueAggregator.IssueAggregator(mockManager);
+
+ const aggregatedIssues = Array.from(aggregator.aggregatedIssues());
+ assert.strictEqual(aggregatedIssues.length, 1);
+ const aggregatedIssue = aggregatedIssues[0];
+ assert.strictEqual(aggregatedIssue.getKind(), IssuesManager.Issue.IssueKind.Improvement);
+ });
+
+ it('for issues of two different kinds', () => {
+ const issues = StubIssue.createFromIssueKinds([
+ IssuesManager.Issue.IssueKind.Improvement,
+ IssuesManager.Issue.IssueKind.BreakingChange,
+ IssuesManager.Issue.IssueKind.Improvement,
+ ]);
+
+ const mockManager = new MockIssuesManager(issues) as unknown as IssuesManager.IssuesManager.IssuesManager;
+ const aggregator = new Issues.IssueAggregator.IssueAggregator(mockManager);
+
+ const aggregatedIssues = Array.from(aggregator.aggregatedIssues());
+ assert.strictEqual(aggregatedIssues.length, 1);
+ const aggregatedIssue = aggregatedIssues[0];
+ assert.strictEqual(aggregatedIssue.getKind(), IssuesManager.Issue.IssueKind.BreakingChange);
+ });
+
+ it('for issues of three different kinds', () => {
+ const issues = StubIssue.createFromIssueKinds([
+ IssuesManager.Issue.IssueKind.BreakingChange,
+ IssuesManager.Issue.IssueKind.PageError,
+ IssuesManager.Issue.IssueKind.Improvement,
+ ]);
+
+ const mockManager = new MockIssuesManager(issues) as unknown as IssuesManager.IssuesManager.IssuesManager;
+ const aggregator = new Issues.IssueAggregator.IssueAggregator(mockManager);
+
+ const aggregatedIssues = Array.from(aggregator.aggregatedIssues());
+ assert.strictEqual(aggregatedIssues.length, 1);
+ const aggregatedIssue = aggregatedIssues[0];
+ assert.strictEqual(aggregatedIssue.getKind(), IssuesManager.Issue.IssueKind.PageError);
+ });
+ });
+});
+
+describeWithMockConnection('IssueAggregator', () => {
+ it('aggregates heavy ad issues correctly', () => {
+ const model = createModel();
+ const details1 = {
+ resolution: Protocol.Audits.HeavyAdResolutionStatus.HeavyAdBlocked,
+ reason: Protocol.Audits.HeavyAdReason.CpuPeakLimit,
+ frame: {frameId: 'main' as Protocol.Page.FrameId},
+ };
+ const issue1 = new IssuesManager.HeavyAdIssue.HeavyAdIssue(details1, model);
+ const details2 = {
+ resolution: Protocol.Audits.HeavyAdResolutionStatus.HeavyAdWarning,
+ reason: Protocol.Audits.HeavyAdReason.NetworkTotalLimit,
+ frame: {frameId: 'main' as Protocol.Page.FrameId},
+ };
+ const issue2 = new IssuesManager.HeavyAdIssue.HeavyAdIssue(details2, model);
+
+ const mockManager = new MockIssuesManager([]) as unknown as IssuesManager.IssuesManager.IssuesManager;
+ const aggregator = new Issues.IssueAggregator.IssueAggregator(mockManager);
+ mockManager.dispatchEventToListeners(
+ IssuesManager.IssuesManager.Events.IssueAdded, {issuesModel: model, issue: issue1});
+ mockManager.dispatchEventToListeners(
+ IssuesManager.IssuesManager.Events.IssueAdded, {issuesModel: model, issue: issue2});
+
+ const issues = Array.from(aggregator.aggregatedIssues());
+ assert.strictEqual(issues.length, 1);
+ const resolutions = [...issues[0].getHeavyAdIssues()].map(r => r.details().resolution).sort();
+ assert.deepStrictEqual(resolutions, [
+ Protocol.Audits.HeavyAdResolutionStatus.HeavyAdBlocked,
+ Protocol.Audits.HeavyAdResolutionStatus.HeavyAdWarning,
+ ]);
+ });
+
+ const scriptId1 = '1' as Protocol.Runtime.ScriptId;
+
+ describe('IssueAggregator', () => {
+ it('aggregates affected locations correctly', () => {
+ const model = createModel();
+ const issue1 = StubIssue.createFromAffectedLocations([{url: 'foo', lineNumber: 1, columnNumber: 1}]);
+ const issue2 = StubIssue.createFromAffectedLocations([
+ {url: 'foo', lineNumber: 1, columnNumber: 1},
+ {url: 'foo', lineNumber: 1, columnNumber: 12},
+ ]);
+ const issue3 = StubIssue.createFromAffectedLocations([
+ {url: 'bar', lineNumber: 1, columnNumber: 1},
+ {url: 'baz', lineNumber: 1, columnNumber: 1},
+ ]);
+ const issue4 = StubIssue.createFromAffectedLocations([
+ {url: 'bar', lineNumber: 1, columnNumber: 1, scriptId: scriptId1},
+ {url: 'foo', lineNumber: 2, columnNumber: 1},
+ ]);
+
+ const mockManager = new MockIssuesManager([]) as unknown as IssuesManager.IssuesManager.IssuesManager;
+ const aggregator = new Issues.IssueAggregator.IssueAggregator(mockManager);
+ for (const issue of [issue1, issue2, issue3, issue4]) {
+ mockManager.dispatchEventToListeners(
+ IssuesManager.IssuesManager.Events.IssueAdded, {issuesModel: model, issue: issue});
+ }
+
+ const issues = Array.from(aggregator.aggregatedIssues());
+ assert.strictEqual(issues.length, 1);
+ const locations = [...issues[0].sources()].sort((x, y) => JSON.stringify(x).localeCompare(JSON.stringify(y)));
+ assert.deepStrictEqual(locations, [
+ {url: 'bar', lineNumber: 1, columnNumber: 1, scriptId: scriptId1},
+ {url: 'bar', lineNumber: 1, columnNumber: 1},
+ {url: 'baz', lineNumber: 1, columnNumber: 1},
+ {url: 'foo', lineNumber: 1, columnNumber: 1},
+ {url: 'foo', lineNumber: 1, columnNumber: 12},
+ {url: 'foo', lineNumber: 2, columnNumber: 1},
+ ]);
+ });
+ });
+});
+
+describeWithMockConnection('IssueAggregator', () => {
+ let hideIssueByCodeSetting: Common.Settings.Setting<IssuesManager.IssuesManager.HideIssueMenuSetting>;
+ let showThirdPartyIssuesSetting: Common.Settings.Setting<boolean>;
+ let issuesManager: IssuesManager.IssuesManager.IssuesManager;
+ let model: SDK.IssuesModel.IssuesModel;
+ let aggregator: Issues.IssueAggregator.IssueAggregator;
+
+ beforeEach(() => {
+ hideIssueByCodeSetting =
+ createFakeSetting('hide by code', ({} as IssuesManager.IssuesManager.HideIssueMenuSetting));
+ showThirdPartyIssuesSetting = createFakeSetting('third party flag', false);
+ issuesManager = new IssuesManager.IssuesManager.IssuesManager(showThirdPartyIssuesSetting, hideIssueByCodeSetting);
+ const target = createTarget();
+ model = target.model(SDK.IssuesModel.IssuesModel) as SDK.IssuesModel.IssuesModel;
+ aggregator = new Issues.IssueAggregator.IssueAggregator(issuesManager);
+ });
+
+ it('aggregates hidden issues correctly', () => {
+ const issues = [
+ new StubIssue('HiddenStubIssue1', [], []),
+ new StubIssue('HiddenStubIssue2', [], []),
+ new StubIssue('UnhiddenStubIssue1', [], []),
+ new StubIssue('UnhiddenStubIssue2', [], []),
+ ];
+
+ hideIssueByCodeSetting.set({
+ 'HiddenStubIssue1': IssuesManager.IssuesManager.IssueStatus.Hidden,
+ 'HiddenStubIssue2': IssuesManager.IssuesManager.IssueStatus.Hidden,
+ });
+
+ for (const issue of issues) {
+ issuesManager.addIssue(model, issue);
+ }
+ assert.strictEqual(aggregator.numberOfAggregatedIssues(), 2);
+ assert.strictEqual(aggregator.numberOfHiddenAggregatedIssues(), 2);
+ });
+
+ it('aggregates hidden issues correctly on updating settings', () => {
+ const issues = [
+ new StubIssue('HiddenStubIssue1', [], []),
+ new StubIssue('HiddenStubIssue2', [], []),
+ new StubIssue('UnhiddenStubIssue1', [], []),
+ new StubIssue('UnhiddenStubIssue2', [], []),
+ ];
+
+ for (const issue of issues) {
+ issuesManager.addIssue(model, issue);
+ }
+
+ hideIssueByCodeSetting.set({
+ 'HiddenStubIssue1': IssuesManager.IssuesManager.IssueStatus.Hidden,
+ });
+ assert.strictEqual(aggregator.numberOfAggregatedIssues(), 3);
+ assert.strictEqual(aggregator.numberOfHiddenAggregatedIssues(), 1);
+
+ hideIssueByCodeSetting.set({
+ 'HiddenStubIssue1': IssuesManager.IssuesManager.IssueStatus.Hidden,
+ 'HiddenStubIssue2': IssuesManager.IssuesManager.IssueStatus.Hidden,
+ });
+ assert.strictEqual(aggregator.numberOfAggregatedIssues(), 2);
+ assert.strictEqual(aggregator.numberOfHiddenAggregatedIssues(), 2);
+ });
+
+ it('aggregates hidden issues correctly when issues get unhidden', () => {
+ const issues = [
+ new StubIssue('HiddenStubIssue1', [], []),
+ new StubIssue('HiddenStubIssue2', [], []),
+ new StubIssue('UnhiddenStubIssue1', [], []),
+ new StubIssue('UnhiddenStubIssue2', [], []),
+ ];
+
+ hideIssueByCodeSetting.set({
+ 'HiddenStubIssue1': IssuesManager.IssuesManager.IssueStatus.Hidden,
+ 'HiddenStubIssue2': IssuesManager.IssuesManager.IssueStatus.Hidden,
+ 'UnhiddenStubIssue1': IssuesManager.IssuesManager.IssueStatus.Hidden,
+ 'UnhiddenStubIssue2': IssuesManager.IssuesManager.IssueStatus.Hidden,
+ });
+
+ for (const issue of issues) {
+ issuesManager.addIssue(model, issue);
+ }
+
+ assert.strictEqual(aggregator.numberOfHiddenAggregatedIssues(), 4);
+ assert.strictEqual(aggregator.numberOfAggregatedIssues(), 0);
+
+ hideIssueByCodeSetting.set({
+ 'HiddenStubIssue1': IssuesManager.IssuesManager.IssueStatus.Hidden,
+ 'HiddenStubIssue2': IssuesManager.IssuesManager.IssueStatus.Hidden,
+ 'UnhiddenStubIssue1': IssuesManager.IssuesManager.IssueStatus.Unhidden,
+ 'UnhiddenStubIssue2': IssuesManager.IssuesManager.IssueStatus.Hidden,
+ });
+
+ assert.strictEqual(aggregator.numberOfAggregatedIssues(), 1);
+ assert.strictEqual(aggregator.numberOfHiddenAggregatedIssues(), 3);
+ });
+
+ it('aggregates hidden issues correctly when all issues get unhidden', () => {
+ const issues = [
+ new StubIssue('HiddenStubIssue1', [], []),
+ new StubIssue('HiddenStubIssue2', [], []),
+ new StubIssue('UnhiddenStubIssue1', [], []),
+ new StubIssue('UnhiddenStubIssue2', [], []),
+ ];
+
+ hideIssueByCodeSetting.set({
+ 'HiddenStubIssue1': IssuesManager.IssuesManager.IssueStatus.Hidden,
+ 'HiddenStubIssue2': IssuesManager.IssuesManager.IssueStatus.Hidden,
+ 'UnhiddenStubIssue1': IssuesManager.IssuesManager.IssueStatus.Hidden,
+ 'UnhiddenStubIssue2': IssuesManager.IssuesManager.IssueStatus.Hidden,
+ });
+
+ for (const issue of issues) {
+ issuesManager.addIssue(model, issue);
+ }
+
+ assert.strictEqual(aggregator.numberOfHiddenAggregatedIssues(), 4);
+ assert.strictEqual(aggregator.numberOfAggregatedIssues(), 0);
+
+ issuesManager.unhideAllIssues();
+
+ assert.strictEqual(aggregator.numberOfAggregatedIssues(), 4);
+ assert.strictEqual(aggregator.numberOfHiddenAggregatedIssues(), 0);
+ });
+});
diff --git a/front_end/panels/issues/IssueView.test.ts b/front_end/panels/issues/IssueView.test.ts
new file mode 100644
index 0000000..10367eb
--- /dev/null
+++ b/front_end/panels/issues/IssueView.test.ts
@@ -0,0 +1,77 @@
+// 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 {describeWithRealConnection} from '../../../test/unittests/front_end/helpers/RealConnection.js';
+import {StubIssue} from '../../../test/unittests/front_end/helpers/StubIssue.js';
+import {recordedMetricsContain} from '../../../test/unittests/front_end/helpers/UserMetricsHelpers.js';
+import * as Host from '../../core/host/host.js';
+import * as UI from '../../ui/legacy/legacy.js';
+
+import * as Issues from './issues.js';
+
+const {assert} = chai;
+
+describeWithRealConnection('IssueView', () => {
+ it('records metrics when an issue is expanded', () => {
+ const aggregationKey = 'key' as unknown as Issues.IssueAggregator.AggregationKey;
+ const issue = StubIssue.createFromRequestIds(['id1', 'id2']);
+ const aggregatedIssue = new Issues.IssueAggregator.AggregatedIssue('code', aggregationKey);
+ aggregatedIssue.addInstance(issue);
+ const view = new Issues.IssueView.IssueView(aggregatedIssue, {title: 'Mock issue', links: [], markdown: []});
+ const treeOutline =
+ new UI.TreeOutline.TreeOutline(); // TreeElements need to be part of a TreeOutline to be expandable.
+ treeOutline.appendChild(view);
+
+ view.expand();
+
+ assert.isTrue(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.IssuesPanelIssueExpanded,
+ Host.UserMetrics.IssueExpanded.Other));
+ view.clear();
+ });
+
+ it('records metrics when a SameSite Cookie issue is expanded', () => {
+ const aggregationKey = 'key' as unknown as Issues.IssueAggregator.AggregationKey;
+ const issue = StubIssue.createCookieIssue('CookieIssue::WarnSameSiteUnspecifiedLaxAllowUnsafe::ReadCookie');
+ const aggregatedIssue = new Issues.IssueAggregator.AggregatedIssue(
+ 'CookieIssue::WarnSameSiteUnspecifiedLaxAllowUnsafe::ReadCookie', aggregationKey);
+ aggregatedIssue.addInstance(issue);
+ const view = new Issues.IssueView.IssueView(aggregatedIssue, {title: 'Mock Cookie Issue', links: [], markdown: []});
+ const treeOutline =
+ new UI.TreeOutline.TreeOutline(); // TreeElements need to be part of a TreeOutline to be expandable.
+ treeOutline.appendChild(view);
+
+ view.expand();
+
+ assert.isTrue(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.IssuesPanelIssueExpanded,
+ Host.UserMetrics.IssueExpanded.SameSiteCookie));
+ assert.isFalse(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.IssuesPanelIssueExpanded,
+ Host.UserMetrics.IssueExpanded.GenericCookie));
+ view.clear();
+ });
+
+ it('records metrics when a ThirdPartyPhaseout Cookie issue is expanded', () => {
+ const aggregationKey = 'key' as unknown as Issues.IssueAggregator.AggregationKey;
+ const issue = StubIssue.createCookieIssue('CookieIssue::WarnThirdPartyPhaseout::ReadCookie');
+ const aggregatedIssue =
+ new Issues.IssueAggregator.AggregatedIssue('CookieIssue::WarnThirdPartyPhaseout::ReadCookie', aggregationKey);
+ aggregatedIssue.addInstance(issue);
+ const view = new Issues.IssueView.IssueView(aggregatedIssue, {title: 'Mock Cookie Issue', links: [], markdown: []});
+ const treeOutline =
+ new UI.TreeOutline.TreeOutline(); // TreeElements need to be part of a TreeOutline to be expandable.
+ treeOutline.appendChild(view);
+
+ view.expand();
+
+ assert.isTrue(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.IssuesPanelIssueExpanded,
+ Host.UserMetrics.IssueExpanded.ThirdPartyPhaseoutCookie));
+ assert.isFalse(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.IssuesPanelIssueExpanded,
+ Host.UserMetrics.IssueExpanded.GenericCookie));
+ view.clear();
+ });
+});
diff --git a/front_end/panels/layers/BUILD.gn b/front_end/panels/layers/BUILD.gn
index 720f126..c365fe5 100644
--- a/front_end/panels/layers/BUILD.gn
+++ b/front_end/panels/layers/BUILD.gn
@@ -4,6 +4,7 @@
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../scripts/build/ninja/devtools_module.gni")
+import("../../../third_party/typescript/typescript.gni")
import("../visibility.gni")
devtools_module("layers") {
@@ -49,3 +50,14 @@
visibility = [ "../../entrypoints/*" ]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [ "LayersPanel.test.ts" ]
+
+ deps = [
+ ":bundle",
+ "../../../test/unittests/front_end/helpers",
+ ]
+}
diff --git a/front_end/panels/layers/LayersPanel.test.ts b/front_end/panels/layers/LayersPanel.test.ts
new file mode 100644
index 0000000..6dcd36c
--- /dev/null
+++ b/front_end/panels/layers/LayersPanel.test.ts
@@ -0,0 +1,70 @@
+// 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.
+
+const {assert} = chai;
+
+import * as Layers from './layers.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import * as UI from '../../ui/legacy/legacy.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import {createTarget, stubNoopSettings} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+
+describeWithMockConnection('LayersPanel', () => {
+ beforeEach(async () => {
+ const actionRegistryInstance = UI.ActionRegistry.ActionRegistry.instance({forceNew: true});
+ UI.ShortcutRegistry.ShortcutRegistry.instance({forceNew: true, actionRegistry: actionRegistryInstance});
+ stubNoopSettings();
+ });
+
+ const tests = (targetFactory: () => SDK.Target.Target) => {
+ let target: SDK.Target.Target;
+
+ beforeEach(async () => {
+ target = targetFactory();
+ });
+
+ it('udpates 3d view when layer painted', async () => {
+ const panel = Layers.LayersPanel.LayersPanel.instance({forceNew: true});
+ const layerTreeModel = target.model(Layers.LayerTreeModel.LayerTreeModel);
+ assertNotNullOrUndefined(layerTreeModel);
+ const updateLayerSnapshot = sinon.stub(panel.layers3DView, 'updateLayerSnapshot');
+ const LAYER = {id: () => 'TEST_LAYER'} as Layers.LayerTreeModel.AgentLayer;
+ layerTreeModel.dispatchEventToListeners(Layers.LayerTreeModel.Events.LayerPainted, LAYER);
+ assert.isTrue(updateLayerSnapshot.calledOnceWith(LAYER));
+ });
+ };
+
+ describe('without tab taget', () => tests(() => createTarget()));
+ describe('with tab taget', () => tests(() => {
+ const tabTarget = createTarget({type: SDK.Target.Type.Tab});
+ createTarget({parentTarget: tabTarget, subtype: 'prerender'});
+ return createTarget({parentTarget: tabTarget});
+ }));
+
+ it('can handle scope switches', async () => {
+ const tabTarget = createTarget({type: SDK.Target.Type.Tab});
+ const prerenderTarget = createTarget({parentTarget: tabTarget, subtype: 'prerender'});
+ const primaryTarget = createTarget({parentTarget: tabTarget});
+
+ const panel = Layers.LayersPanel.LayersPanel.instance({forceNew: true});
+ const primaryLayerTreeModel = primaryTarget.model(Layers.LayerTreeModel.LayerTreeModel);
+ assertNotNullOrUndefined(primaryLayerTreeModel);
+ const prerenderLayerTreeModel = prerenderTarget.model(Layers.LayerTreeModel.LayerTreeModel);
+ assertNotNullOrUndefined(prerenderLayerTreeModel);
+ const updateLayerSnapshot = sinon.stub(panel.layers3DView, 'updateLayerSnapshot');
+
+ const LAYER_1 = {id: () => 'TEST_LAYER_1'} as Layers.LayerTreeModel.AgentLayer;
+ const LAYER_2 = {id: () => 'TEST_LAYER_2'} as Layers.LayerTreeModel.AgentLayer;
+ primaryLayerTreeModel.dispatchEventToListeners(Layers.LayerTreeModel.Events.LayerPainted, LAYER_1);
+ prerenderLayerTreeModel.dispatchEventToListeners(Layers.LayerTreeModel.Events.LayerPainted, LAYER_2);
+ assert.isTrue(updateLayerSnapshot.calledOnceWith(LAYER_1));
+
+ updateLayerSnapshot.reset();
+ SDK.TargetManager.TargetManager.instance().setScopeTarget(prerenderTarget);
+ primaryLayerTreeModel.dispatchEventToListeners(Layers.LayerTreeModel.Events.LayerPainted, LAYER_1);
+ prerenderLayerTreeModel.dispatchEventToListeners(Layers.LayerTreeModel.Events.LayerPainted, LAYER_2);
+ assert.isTrue(updateLayerSnapshot.calledOnceWith(LAYER_2));
+ });
+});
diff --git a/front_end/panels/lighthouse/BUILD.gn b/front_end/panels/lighthouse/BUILD.gn
index 292f5c6..6b374c3 100644
--- a/front_end/panels/lighthouse/BUILD.gn
+++ b/front_end/panels/lighthouse/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../scripts/build/ninja/devtools_module.gni")
import("../../../scripts/build/ninja/generate_css.gni")
+import("../../../third_party/typescript/typescript.gni")
import("../visibility.gni")
generate_css("css_files") {
@@ -59,7 +60,6 @@
visibility = [
":*",
"../../../test/unittests/front_end/entrypoints/missing_entrypoints/*",
- "../../../test/unittests/front_end/panels/lighthouse/*",
"../../entrypoints/*",
]
@@ -77,3 +77,19 @@
visibility = [ "../../entrypoints/*" ]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [
+ "LighthouseController.test.ts",
+ "LighthousePanel.test.ts",
+ "LighthouseProtocolService.test.ts",
+ "LighthouseReportRenderer.test.ts",
+ ]
+
+ deps = [
+ ":bundle",
+ "../../../test/unittests/front_end/helpers",
+ ]
+}
diff --git a/front_end/panels/lighthouse/LighthouseController.test.ts b/front_end/panels/lighthouse/LighthouseController.test.ts
new file mode 100644
index 0000000..0efbe42
--- /dev/null
+++ b/front_end/panels/lighthouse/LighthouseController.test.ts
@@ -0,0 +1,43 @@
+// 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 {createTarget, stubNoopSettings} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+
+import type * as LighthouseModule from './lighthouse.js';
+
+describeWithMockConnection('LighthouseController', () => {
+ const tests = (targetFactory: () => SDK.Target.Target) => {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ let Lighthouse: typeof LighthouseModule;
+ let target: SDK.Target.Target;
+
+ beforeEach(async () => {
+ stubNoopSettings();
+ Lighthouse = await import('./lighthouse.js');
+ target = targetFactory();
+ });
+
+ it('updates page auditability on service worker registraion', async () => {
+ const controller = new Lighthouse.LighthouseController.LighthouseController(
+ sinon.createStubInstance(Lighthouse.LighthouseProtocolService.ProtocolService));
+ const serviceWorkerManager = target.model(SDK.ServiceWorkerManager.ServiceWorkerManager);
+ assertNotNullOrUndefined(serviceWorkerManager);
+ const pageAuditabilityChange = controller.once(Lighthouse.LighthouseController.Events.PageAuditabilityChanged);
+ serviceWorkerManager.dispatchEventToListeners(
+ SDK.ServiceWorkerManager.Events.RegistrationUpdated,
+ {} as SDK.ServiceWorkerManager.ServiceWorkerRegistration);
+ await pageAuditabilityChange;
+ });
+ };
+
+ describe('without tab target', () => tests(createTarget));
+ describe('with tab target', () => tests(() => {
+ const tabTarget = createTarget({type: SDK.Target.Type.Tab});
+ createTarget({parentTarget: tabTarget, subtype: 'prerender'});
+ return createTarget({parentTarget: tabTarget});
+ }));
+});
diff --git a/front_end/panels/lighthouse/LighthousePanel.test.ts b/front_end/panels/lighthouse/LighthousePanel.test.ts
new file mode 100644
index 0000000..5be35f1
--- /dev/null
+++ b/front_end/panels/lighthouse/LighthousePanel.test.ts
@@ -0,0 +1,99 @@
+// 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 {createTarget, stubNoopSettings} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import type * as Common from '../../core/common/common.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import type * as Protocol from '../../generated/protocol.js';
+
+import type * as LighthouseModule from './lighthouse.js';
+
+describeWithMockConnection('LighthousePanel', () => {
+ const tests = (targetFactory: () => SDK.Target.Target) => {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ let Lighthouse: typeof LighthouseModule;
+ let target: SDK.Target.Target;
+ let resourceTreeModelNavigate: sinon.SinonStub;
+ let protocolService: LighthouseModule.LighthouseProtocolService.ProtocolService;
+ let controller: LighthouseModule.LighthouseController.LighthouseController;
+
+ const URL = 'http://example.com';
+ const LH_REPORT = {
+ lhr: {
+ finalDisplayedUrl: URL,
+ configSettings: {},
+ audits: {},
+ categories: {_: {auditRefs: [], id: ''}},
+ lighthouseVersion: '',
+ userAgent: '',
+ fetchTime: 0,
+ environment: {benchmarkIndex: 0},
+ i18n: {rendererFormattedStrings: {}},
+ },
+ } as unknown as LighthouseModule.LighthouseReporterTypes.RunnerResult;
+
+ beforeEach(async () => {
+ Lighthouse = await import('./lighthouse.js');
+ target = targetFactory();
+ sinon.stub(target.pageAgent(), 'invoke_getNavigationHistory').resolves({
+ currentIndex: 0,
+ entries: [{url: URL}],
+ getError: () => null,
+ } as unknown as Protocol.Page.GetNavigationHistoryResponse);
+
+ const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel);
+ assertNotNullOrUndefined(resourceTreeModel);
+ resourceTreeModelNavigate = sinon.stub(resourceTreeModel, 'navigate').resolves();
+ sinon.stub(resourceTreeModel, 'addEventListener')
+ .callThrough()
+ .withArgs(SDK.ResourceTreeModel.Events.Load, sinon.match.any)
+ .callsArgWithAsync(1, {resourceTreeModel, loadTime: 0})
+ .returns({} as Common.EventTarget.EventDescriptor);
+
+ protocolService = new Lighthouse.LighthouseProtocolService.ProtocolService();
+ sinon.stub(protocolService, 'attach').resolves();
+ sinon.stub(protocolService, 'detach').resolves();
+ sinon.stub(protocolService, 'collectLighthouseResults').resolves(LH_REPORT);
+
+ controller = new Lighthouse.LighthouseController.LighthouseController(protocolService);
+
+ stubNoopSettings();
+ });
+
+ // Failing due to StartView not finding settings title.
+ it.skip('[crbug.com/326214132] restores the original URL when done', async () => {
+ const instance =
+ Lighthouse.LighthousePanel.LighthousePanel.instance({forceNew: true, protocolService, controller});
+ void instance.handleCompleteRun();
+
+ await new Promise<void>(resolve => resourceTreeModelNavigate.withArgs(URL).callsFake(() => {
+ resolve();
+ return Promise.resolve();
+ }));
+ });
+
+ // Failing due to StartView not finding settings title.
+ it.skip('[crbug.com/326214132] waits for main taget to load before linkifying', async () => {
+ const instance =
+ Lighthouse.LighthousePanel.LighthousePanel.instance({forceNew: true, protocolService, controller});
+ void instance.handleCompleteRun();
+
+ await new Promise<void>(
+ resolve => sinon.stub(Lighthouse.LighthouseReportRenderer.LighthouseReportRenderer, 'linkifyNodeDetails')
+ .callsFake((_: Element) => {
+ resolve();
+ return Promise.resolve();
+ }));
+ });
+ };
+
+ describe('without tab taget', () => tests(() => createTarget()));
+ describe('with tab taget', () => tests(() => {
+ const tabTarget = createTarget({type: SDK.Target.Type.Tab});
+ createTarget({parentTarget: tabTarget, subtype: 'prerender'});
+ return createTarget({parentTarget: tabTarget});
+ }));
+});
diff --git a/front_end/panels/lighthouse/LighthouseProtocolService.test.ts b/front_end/panels/lighthouse/LighthouseProtocolService.test.ts
new file mode 100644
index 0000000..a68bbaa
--- /dev/null
+++ b/front_end/panels/lighthouse/LighthouseProtocolService.test.ts
@@ -0,0 +1,99 @@
+// 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 {createTarget} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection, dispatchEvent} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import type * as ProtocolClient from '../../core/protocol_client/protocol_client.js';
+import * as SDK from '../../core/sdk/sdk.js';
+
+import type * as LighthouseModule from './lighthouse.js';
+
+const {assert} = chai;
+
+describeWithMockConnection('LighthouseProtocolService', () => {
+ const attachDetach = (targetFactory: () => {rootTarget: SDK.Target.Target, primaryTarget: SDK.Target.Target}) => {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ let Lighthouse: typeof LighthouseModule;
+ let primaryTarget: SDK.Target.Target;
+ let rootTarget: SDK.Target.Target;
+ let suspendAllTargets: sinon.SinonStub;
+ let resumeAllTargets: sinon.SinonStub;
+ let createParallelConnection: sinon.SinonStub;
+ const FRAME = {
+ id: 'main',
+ loaderId: 'test',
+ url: 'http://example.com',
+ securityOrigin: 'http://example.com',
+ mimeType: 'text/html',
+ };
+
+ beforeEach(async () => {
+ Lighthouse = await import('./lighthouse.js');
+ const targets = targetFactory();
+ primaryTarget = targets.primaryTarget;
+ rootTarget = targets.rootTarget;
+
+ const targetManager = SDK.TargetManager.TargetManager.instance();
+
+ suspendAllTargets = sinon.stub(targetManager, 'suspendAllTargets').resolves();
+ resumeAllTargets = sinon.stub(targetManager, 'resumeAllTargets').resolves();
+ SDK.ChildTargetManager.ChildTargetManager.install();
+ const childTargetManager = primaryTarget.model(SDK.ChildTargetManager.ChildTargetManager);
+ assertNotNullOrUndefined(childTargetManager);
+
+ sinon.stub(childTargetManager, 'getParentTargetId').resolves(primaryTarget.targetInfo()?.targetId);
+ if (rootTarget === primaryTarget) {
+ createParallelConnection = sinon.stub(childTargetManager, 'createParallelConnection').resolves({
+ connection: {disconnect: () => {}} as ProtocolClient.InspectorBackend.Connection,
+ sessionId: 'foo',
+ });
+ } else {
+ const rootChildTargetManager = rootTarget.model(SDK.ChildTargetManager.ChildTargetManager);
+ assertNotNullOrUndefined(rootChildTargetManager);
+ sinon.stub(rootChildTargetManager, 'getParentTargetId').resolves(rootTarget.targetInfo()?.targetId);
+ createParallelConnection = sinon.stub(rootChildTargetManager, 'createParallelConnection').resolves({
+ connection: {disconnect: () => {}} as ProtocolClient.InspectorBackend.Connection,
+ sessionId: 'foo',
+ });
+ }
+ dispatchEvent(primaryTarget, 'Page.frameNavigated', {frame: FRAME});
+ });
+
+ it('suspends all targets', async () => {
+ const service = new Lighthouse.LighthouseProtocolService.ProtocolService();
+ await service.attach();
+ assert.isTrue(suspendAllTargets.calledOnce);
+ });
+
+ it('creates a parallel connection', async () => {
+ const service = new Lighthouse.LighthouseProtocolService.ProtocolService();
+ await service.attach();
+ assert.isTrue(createParallelConnection.calledOnce);
+ });
+
+ it('resumes all targets', async () => {
+ const service = new Lighthouse.LighthouseProtocolService.ProtocolService();
+ await service.attach();
+ await service.detach();
+ assert.isTrue(resumeAllTargets.calledOnce);
+ });
+ };
+
+ describe('attach/detach without tab taget', () => attachDetach(() => {
+ const target = createTarget();
+ return {
+ rootTarget: target,
+ primaryTarget: target,
+ };
+ }));
+ describe('attach/detach with tab taget', () => attachDetach(() => {
+ const tabTarget = createTarget({type: SDK.Target.Type.Tab});
+ createTarget({parentTarget: tabTarget, subtype: 'prerender'});
+ return {
+ rootTarget: tabTarget,
+ primaryTarget: createTarget({parentTarget: tabTarget}),
+ };
+ }));
+});
diff --git a/front_end/panels/lighthouse/LighthouseReportRenderer.test.ts b/front_end/panels/lighthouse/LighthouseReportRenderer.test.ts
new file mode 100644
index 0000000..65c0048
--- /dev/null
+++ b/front_end/panels/lighthouse/LighthouseReportRenderer.test.ts
@@ -0,0 +1,133 @@
+// 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 {createTarget} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import * as Common from '../../core/common/common.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import type * as Protocol from '../../generated/protocol.js';
+import * as UI from '../../ui/legacy/legacy.js';
+
+import type * as LighthouseModule from './lighthouse.js';
+
+const {assert} = chai;
+
+describeWithMockConnection('LighthouseReportRenderer', () => {
+ const linkifyNodeDetails = (targetFactory: () => SDK.Target.Target) => {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ let Lighthouse: typeof LighthouseModule;
+ let target: SDK.Target.Target;
+ let sourceElement: HTMLElement;
+ let linkElement: HTMLElement;
+ const PATH = 'TEST_PATH';
+ const NODE_ID = 42 as Protocol.DOM.NodeId;
+ const NODE = {id: NODE_ID} as SDK.DOMModel.DOMNode;
+ const SNIPPET = 'SNIPPET';
+ const LH_NODE_HTML = (path: string, snippet: string) =>
+ `<div class="lh-node" data-path="${path}" data-snippet="${snippet}"></div>`;
+ beforeEach(async () => {
+ Lighthouse = await import('./lighthouse.js');
+ target = targetFactory();
+ linkElement = document.createElement('div');
+ linkElement.textContent = 'link';
+ sourceElement = document.createElement('div');
+ });
+
+ it('resolves node and calls linkifier', async () => {
+ sourceElement.innerHTML = LH_NODE_HTML(PATH, SNIPPET);
+
+ const domModel = target.model(SDK.DOMModel.DOMModel);
+ assertNotNullOrUndefined(domModel);
+ sinon.stub(domModel, 'pushNodeByPathToFrontend').withArgs(PATH).returns(Promise.resolve(NODE_ID));
+ sinon.stub(domModel, 'nodeForId').withArgs(NODE_ID).returns(NODE);
+ sinon.stub(Common.Linkifier.Linkifier, 'linkify')
+ .withArgs(NODE, {tooltip: SNIPPET, preventKeyboardFocus: undefined})
+ .returns(Promise.resolve(linkElement));
+
+ await Lighthouse.LighthouseReportRenderer.LighthouseReportRenderer.linkifyNodeDetails(sourceElement);
+
+ assert.include([...sourceElement.firstChild?.childNodes || []], linkElement);
+ });
+
+ it('handles multiple nodes', async () => {
+ const domModel = target.model(SDK.DOMModel.DOMModel);
+ assertNotNullOrUndefined(domModel);
+ const pushNodeByPathToFrontend = sinon.stub(domModel, 'pushNodeByPathToFrontend');
+ const nodeForId = sinon.stub(domModel, 'nodeForId');
+ const linkify = sinon.stub(Common.Linkifier.Linkifier, 'linkify');
+ const NUM_NODES = 3;
+ for (let i = 1; i <= NUM_NODES; ++i) {
+ sourceElement.innerHTML += LH_NODE_HTML(PATH + i, SNIPPET + i);
+
+ const nodeId = i as Protocol.DOM.NodeId;
+ const node = {id: nodeId} as SDK.DOMModel.DOMNode;
+ pushNodeByPathToFrontend.withArgs(PATH + i).returns(Promise.resolve(nodeId));
+ nodeForId.withArgs(nodeId).returns(node);
+ linkify.withArgs(node, {tooltip: SNIPPET + i, preventKeyboardFocus: undefined})
+ .returns(Promise.resolve(document.createTextNode(`link${i}`)));
+ }
+
+ await Lighthouse.LighthouseReportRenderer.LighthouseReportRenderer.linkifyNodeDetails(sourceElement);
+
+ assert.strictEqual(sourceElement.childNodes.length, NUM_NODES);
+ assert.deepStrictEqual([...sourceElement.childNodes].map(n => n.textContent), ['link1', 'link2', 'link3']);
+ });
+
+ it('resets tooltip', async () => {
+ sourceElement.innerHTML = LH_NODE_HTML(PATH, SNIPPET);
+
+ const domModel = target.model(SDK.DOMModel.DOMModel);
+ assertNotNullOrUndefined(domModel);
+ sinon.stub(domModel, 'pushNodeByPathToFrontend').returns(Promise.resolve(NODE_ID));
+ sinon.stub(domModel, 'nodeForId').returns(NODE);
+ sinon.stub(Common.Linkifier.Linkifier, 'linkify').returns(Promise.resolve(linkElement));
+ const installTooltip = sinon.spy(UI.Tooltip.Tooltip, 'install');
+
+ await Lighthouse.LighthouseReportRenderer.LighthouseReportRenderer.linkifyNodeDetails(sourceElement);
+
+ assert.isTrue(installTooltip.calledOnceWith(sourceElement.firstChild as HTMLElement, ''));
+ });
+
+ it('only keeps link and screenshot', async () => {
+ sourceElement.innerHTML = LH_NODE_HTML(PATH, SNIPPET);
+ assertNotNullOrUndefined(sourceElement.firstElementChild);
+ sourceElement.firstElementChild.innerHTML = 'foo<div class="lh-element-screenshot"></div>bar';
+
+ const domModel = target.model(SDK.DOMModel.DOMModel);
+ assertNotNullOrUndefined(domModel);
+ sinon.stub(domModel, 'pushNodeByPathToFrontend').returns(Promise.resolve(NODE_ID));
+ sinon.stub(domModel, 'nodeForId').returns(NODE);
+ sinon.stub(Common.Linkifier.Linkifier, 'linkify').returns(Promise.resolve(linkElement));
+
+ await Lighthouse.LighthouseReportRenderer.LighthouseReportRenderer.linkifyNodeDetails(sourceElement);
+
+ assert.strictEqual(
+ sourceElement.firstElementChild.innerHTML, '<div class="lh-element-screenshot"></div><div>link</div>');
+ });
+
+ it('skips malformed nodes', async () => {
+ const originalHtml = [
+ LH_NODE_HTML('', SNIPPET),
+ LH_NODE_HTML('UNKNOWN_PATH', SNIPPET),
+ LH_NODE_HTML('PATH_WIHTOUT_NODE', SNIPPET),
+ ].join('');
+ const domModel = target.model(SDK.DOMModel.DOMModel);
+ assertNotNullOrUndefined(domModel);
+ sinon.stub(domModel, 'pushNodeByPathToFrontend').withArgs('PATH_WIHTOUT_NODE').returns(Promise.resolve(NODE_ID));
+ sourceElement.innerHTML = originalHtml;
+
+ await Lighthouse.LighthouseReportRenderer.LighthouseReportRenderer.linkifyNodeDetails(sourceElement);
+
+ assert.strictEqual(sourceElement.innerHTML, originalHtml);
+ });
+ };
+
+ describe('linkifyNodeDetails without tab taget', () => linkifyNodeDetails(() => createTarget()));
+ describe('linkifyNodeDetails with tab taget', () => linkifyNodeDetails(() => {
+ const tabTarget = createTarget({type: SDK.Target.Type.Tab});
+ createTarget({parentTarget: tabTarget, subtype: 'prerender'});
+ return createTarget({parentTarget: tabTarget});
+ }));
+});
diff --git a/front_end/panels/linear_memory_inspector/BUILD.gn b/front_end/panels/linear_memory_inspector/BUILD.gn
index d69b772..a957aa0 100644
--- a/front_end/panels/linear_memory_inspector/BUILD.gn
+++ b/front_end/panels/linear_memory_inspector/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../scripts/build/ninja/devtools_module.gni")
import("../../../scripts/build/ninja/generate_css.gni")
+import("../../../third_party/typescript/typescript.gni")
import("../visibility.gni")
devtools_module("linear_memory_inspector") {
@@ -53,3 +54,18 @@
visibility = [ "../../entrypoints/*" ]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [
+ "LinearMemoryInspectorController.test.ts",
+ "LinearMemoryInspectorPane.test.ts",
+ ]
+
+ deps = [
+ ":bundle",
+ "../../../test/unittests/front_end/helpers",
+ "./components:bundle",
+ ]
+}
diff --git a/front_end/panels/linear_memory_inspector/LinearMemoryInspectorController.test.ts b/front_end/panels/linear_memory_inspector/LinearMemoryInspectorController.test.ts
new file mode 100644
index 0000000..5a1b264
--- /dev/null
+++ b/front_end/panels/linear_memory_inspector/LinearMemoryInspectorController.test.ts
@@ -0,0 +1,208 @@
+// Copyright (c) 2020 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 {describeWithEnvironment} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import * as SDK from '../../core/sdk/sdk.js';
+
+import * as LinearMemoryInspectorComponents from './components/components.js';
+import * as LinearMemoryInspector from './linear_memory_inspector.js';
+
+const {assert} = chai;
+const {LinearMemoryInspectorController} = LinearMemoryInspector;
+const {ValueInterpreterDisplayUtils} = LinearMemoryInspectorComponents;
+
+class MockRemoteObject extends SDK.RemoteObject.LocalJSONObject {
+ private objSubtype?: string;
+
+ constructor(array: ArrayBuffer) {
+ super(array);
+ }
+
+ override arrayBufferByteLength() {
+ return this.value.byteLength;
+ }
+
+ override get subtype(): string|undefined {
+ return 'arraybuffer';
+ }
+}
+
+function createWrapper(array: Uint8Array) {
+ const mockRemoteObj = new MockRemoteObject(array.buffer);
+ const mockRemoteArrayBuffer = new SDK.RemoteObject.RemoteArrayBuffer(mockRemoteObj);
+ return new LinearMemoryInspectorController.RemoteArrayBufferWrapper(mockRemoteArrayBuffer);
+}
+
+describeWithEnvironment('LinearMemoryInspectorController', () => {
+ it('throws an error on an invalid (out-of-bounds) memory range request', async () => {
+ const array = new Uint8Array([2, 4, 6, 2, 4]);
+ const wrapper = createWrapper(array);
+ try {
+ await LinearMemoryInspectorController.LinearMemoryInspectorController.getMemoryRange(wrapper, 10, 20);
+ throw new Error('Function did now throw.');
+ } catch (e) {
+ const error = e as Error;
+ assert.strictEqual(error.message, 'Requested range is out of bounds.');
+ }
+ });
+
+ it('throws an error on an invalid memory range request', async () => {
+ const array = new Uint8Array([2, 4, 6, 2, 4]);
+ const wrapper = createWrapper(array);
+ try {
+ await LinearMemoryInspectorController.LinearMemoryInspectorController.getMemoryRange(wrapper, 20, 10);
+ throw new Error('Function did now throw.');
+ } catch (e) {
+ const error = e as Error;
+ assert.strictEqual(error.message, 'Requested range is out of bounds.');
+ }
+ });
+
+ it('can pull updated data on memory range request', async () => {
+ const array = new Uint8Array([2, 4, 6, 2, 4]);
+ const wrapper = createWrapper(array);
+ const valuesBefore =
+ await LinearMemoryInspectorController.LinearMemoryInspectorController.getMemoryRange(wrapper, 0, array.length);
+
+ assert.strictEqual(valuesBefore.length, array.length);
+ for (let i = 0; i < array.length; ++i) {
+ assert.strictEqual(valuesBefore[i], array[i]);
+ }
+
+ const changedIndex = 0;
+ const changedValue = 10;
+ array[changedIndex] = changedValue;
+ const valuesAfter =
+ await LinearMemoryInspectorController.LinearMemoryInspectorController.getMemoryRange(wrapper, 0, array.length);
+
+ assert.strictEqual(valuesAfter.length, valuesBefore.length);
+ for (let i = 0; i < valuesBefore.length; ++i) {
+ if (i === changedIndex) {
+ assert.strictEqual(valuesAfter[i], changedValue);
+ } else {
+ assert.strictEqual(valuesAfter[i], valuesBefore[i]);
+ }
+ }
+ });
+
+ it('triggers saving and loading of settings on settings changed event', () => {
+ const instance = LinearMemoryInspectorController.LinearMemoryInspectorController.instance();
+
+ const valueTypes =
+ new Set([ValueInterpreterDisplayUtils.ValueType.Int16, ValueInterpreterDisplayUtils.ValueType.Float32]);
+ const valueTypeModes = new Map(
+ [[ValueInterpreterDisplayUtils.ValueType.Int16, ValueInterpreterDisplayUtils.ValueTypeMode.Hexadecimal]]);
+ const settings = {
+ valueTypes,
+ modes: valueTypeModes,
+ endianness: ValueInterpreterDisplayUtils.Endianness.Little,
+ };
+ const defaultSettings = instance.loadSettings();
+ instance.saveSettings(settings);
+
+ assert.notDeepEqual(defaultSettings, settings);
+
+ const actualSettings = instance.loadSettings();
+ assert.deepEqual(actualSettings, settings);
+ });
+
+ it('returns undefined when error happens in evaluateExpression', async () => {
+ const errorText = 'This is a test error';
+ const callFrame = {
+ evaluate: ({}) => {
+ return new Promise(resolve => {
+ resolve({error: errorText} as SDK.RuntimeModel.EvaluationResult);
+ });
+ },
+ } as SDK.DebuggerModel.CallFrame;
+ const stub = sinon.stub(console, 'error');
+ const instance = LinearMemoryInspectorController.LinearMemoryInspectorController.instance();
+ const expressionName = 'myCar';
+ const result = await instance.evaluateExpression(callFrame, expressionName);
+ assert.strictEqual(result, undefined);
+ assert.isTrue(stub.calledOnceWithExactly(
+ `Tried to evaluate the expression '${expressionName}' but got an error: ${errorText}`));
+ });
+
+ it('returns undefined when exceptionDetails is set on the result of evaluateExpression', async () => {
+ const exceptionText = 'This is a test exception\'s detail text';
+ const callFrame = {
+ evaluate: ({}) => {
+ return new Promise(resolve => {
+ resolve({
+ object: {type: 'object'} as SDK.RemoteObject.RemoteObject,
+ exceptionDetails: {text: exceptionText},
+ } as SDK.RuntimeModel.EvaluationResult);
+ });
+ },
+ } as SDK.DebuggerModel.CallFrame;
+ const stub = sinon.stub(console, 'error');
+ const instance = LinearMemoryInspectorController.LinearMemoryInspectorController.instance();
+ const expressionName = 'myCar.manufacturer';
+ const result = await instance.evaluateExpression(callFrame, expressionName);
+ assert.strictEqual(result, undefined);
+ assert.isTrue(stub.calledOnceWithExactly(
+ `Tried to evaluate the expression '${expressionName}' but got an exception: ${exceptionText}`));
+ });
+
+ it('returns RemoteObject when no exception happens in evaluateExpression', async () => {
+ const expectedObj = {type: 'object'} as SDK.RemoteObject.RemoteObject;
+ const callFrame = {
+ evaluate: ({}) => {
+ return new Promise(resolve => {
+ resolve({
+ object: expectedObj,
+ } as SDK.RuntimeModel.EvaluationResult);
+ });
+ },
+ } as SDK.DebuggerModel.CallFrame;
+ const instance = LinearMemoryInspectorController.LinearMemoryInspectorController.instance();
+ const result = await instance.evaluateExpression(callFrame, 'myCar.manufacturer');
+ assert.deepEqual(result, expectedObj);
+ });
+
+ it('removes the provided highlightInfo when it is stored in the Controller', () => {
+ const highlightInfo = {startAddress: 0, size: 16, name: 'myNumbers', type: 'int[]'} as
+ LinearMemoryInspectorComponents.LinearMemoryViewerUtils.HighlightInfo;
+ const bufferId = 'someBufferId';
+ const instance = LinearMemoryInspectorController.LinearMemoryInspectorController.instance();
+
+ instance.setHighlightInfo(bufferId, highlightInfo);
+ assert.deepEqual(instance.getHighlightInfo(bufferId), highlightInfo);
+
+ instance.removeHighlight(bufferId, highlightInfo);
+ assert.deepEqual(instance.getHighlightInfo(bufferId), undefined);
+ });
+
+ it('does not change the stored highlight when the provided highlightInfo does not match', () => {
+ const highlightInfo = {startAddress: 0, size: 16, name: 'myNumbers', type: 'int[]'} as
+ LinearMemoryInspectorComponents.LinearMemoryViewerUtils.HighlightInfo;
+ const differentHighlightInfo = {startAddress: 20, size: 50, name: 'myBytes', type: 'bool[]'} as
+ LinearMemoryInspectorComponents.LinearMemoryViewerUtils.HighlightInfo;
+ const bufferId = 'someBufferId';
+ const instance = LinearMemoryInspectorController.LinearMemoryInspectorController.instance();
+
+ instance.setHighlightInfo(bufferId, highlightInfo);
+ assert.deepEqual(instance.getHighlightInfo(bufferId), highlightInfo);
+
+ instance.removeHighlight(bufferId, differentHighlightInfo);
+ assert.deepEqual(instance.getHighlightInfo(bufferId), highlightInfo);
+ });
+});
+
+describe('RemoteArrayBufferWrapper', () => {
+ it('correctly wraps the remote object', async () => {
+ const array = new Uint8Array([2, 4, 6, 2, 4]);
+ const wrapper = createWrapper(array);
+
+ assert.strictEqual(wrapper.length(), array.length);
+
+ const extractedArray = await wrapper.getRange(0, 3);
+ assert.lengthOf(extractedArray, 3);
+
+ for (let i = 0; i < 3; ++i) {
+ assert.deepEqual(array[i], extractedArray[i]);
+ }
+ });
+});
diff --git a/front_end/panels/linear_memory_inspector/LinearMemoryInspectorPane.test.ts b/front_end/panels/linear_memory_inspector/LinearMemoryInspectorPane.test.ts
new file mode 100644
index 0000000..03c252f
--- /dev/null
+++ b/front_end/panels/linear_memory_inspector/LinearMemoryInspectorPane.test.ts
@@ -0,0 +1,46 @@
+// Copyright (c) 2020 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 {describeWithEnvironment} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+
+import * as LinearMemoryInspector from './linear_memory_inspector.js';
+
+function createArray() {
+ const array = [];
+ for (let i = 0; i < 100; ++i) {
+ array.push(i);
+ }
+ return new Uint8Array(array);
+}
+
+describeWithEnvironment('LinearMemoryInspectorPane', () => {
+ class Uint8Wrapper {
+ private array: Uint8Array;
+
+ constructor(array: Uint8Array) {
+ this.array = array;
+ }
+
+ getRange(start: number, end: number): Promise<Uint8Array> {
+ return Promise.resolve(this.array.slice(start, end));
+ }
+ length(): number {
+ return this.array.length;
+ }
+ }
+
+ it('can be created', () => {
+ const instance = LinearMemoryInspector.LinearMemoryInspectorPane.LinearMemoryInspectorPane.instance();
+ const arrayWrapper = new Uint8Wrapper(createArray());
+ const scriptId = 'scriptId';
+ const title = 'Test Title';
+ instance.create(scriptId, title, arrayWrapper, 10);
+
+ const tabbedPane = instance.contentElement.querySelector('.tabbed-pane');
+ assertNotNullOrUndefined(tabbedPane);
+ const inspector = tabbedPane.querySelector('devtools-linear-memory-inspector-inspector');
+ assert.notInstanceOf(inspector, HTMLSpanElement);
+ });
+});
diff --git a/front_end/panels/linear_memory_inspector/components/BUILD.gn b/front_end/panels/linear_memory_inspector/components/BUILD.gn
index 15de848..d15fe13 100644
--- a/front_end/panels/linear_memory_inspector/components/BUILD.gn
+++ b/front_end/panels/linear_memory_inspector/components/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../../scripts/build/ninja/devtools_module.gni")
import("../../../../scripts/build/ninja/generate_css.gni")
+import("../../../../third_party/typescript/typescript.gni")
import("../../visibility.gni")
generate_css("css_files") {
@@ -64,3 +65,22 @@
visibility += devtools_panels_visibility
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [
+ "LinearMemoryHighlightChipList.test.ts",
+ "LinearMemoryInspector.test.ts",
+ "LinearMemoryNavigator.test.ts",
+ "LinearMemoryValueInterpreter.test.ts",
+ "LinearMemoryViewer.test.ts",
+ "ValueInterpreterDisplay.test.ts",
+ "ValueInterpreterSettings.test.ts",
+ ]
+
+ deps = [
+ ":bundle",
+ "../../../../test/unittests/front_end/helpers",
+ ]
+}
diff --git a/front_end/panels/linear_memory_inspector/components/LinearMemoryHighlightChipList.test.ts b/front_end/panels/linear_memory_inspector/components/LinearMemoryHighlightChipList.test.ts
new file mode 100644
index 0000000..18fc0d6
--- /dev/null
+++ b/front_end/panels/linear_memory_inspector/components/LinearMemoryHighlightChipList.test.ts
@@ -0,0 +1,138 @@
+// Copyright 2020 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 {
+ assertElement,
+ assertShadowRoot,
+ getElementWithinComponent,
+ getEventPromise,
+ renderElementIntoDOM,
+} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {describeWithLocale} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+
+import * as LinearMemoryInspectorComponents from './components.js';
+
+const {assert} = chai;
+
+export const HIGHLIGHT_CHIP = '.highlight-chip';
+export const HIGHLIGHT_PILL_JUMP_BUTTON_SELECTOR = '.jump-to-highlight-button';
+export const HIGHLIGHT_PILL_VARIABLE_NAME = HIGHLIGHT_PILL_JUMP_BUTTON_SELECTOR + ' .value';
+export const HIGHLIGHT_ROW_REMOVE_BUTTON_SELECTOR = '.delete-highlight-button';
+
+describeWithLocale('LinearMemoryInspectorHighlightChipList', () => {
+ let component: LinearMemoryInspectorComponents.LinearMemoryHighlightChipList.LinearMemoryHighlightChipList;
+
+ beforeEach(renderHighlightRow);
+
+ function renderHighlightRow() {
+ component = new LinearMemoryInspectorComponents.LinearMemoryHighlightChipList.LinearMemoryHighlightChipList();
+ renderElementIntoDOM(component);
+ const highlightInfo = {
+ startAddress: 10,
+ size: 8,
+ type: 'double',
+ name: 'myNumber',
+ };
+ component.data = {
+ highlightInfos: [
+ highlightInfo,
+ ],
+ };
+ }
+
+ it('renders a highlight chip button', () => {
+ const shadowRoot = component.shadowRoot;
+ assertShadowRoot(shadowRoot);
+ const button = shadowRoot.querySelector(HIGHLIGHT_PILL_JUMP_BUTTON_SELECTOR);
+ assertElement(button, HTMLButtonElement);
+ const expressionName = shadowRoot.querySelector(HIGHLIGHT_PILL_VARIABLE_NAME);
+ assertElement(expressionName, HTMLSpanElement);
+ assert.strictEqual(expressionName.innerText, 'myNumber');
+ });
+
+ it('focuses a highlight chip button', async () => {
+ const shadowRoot = component.shadowRoot;
+ assertShadowRoot(shadowRoot);
+ const chip = shadowRoot.querySelector(HIGHLIGHT_CHIP);
+ assertElement(chip, HTMLDivElement);
+ assert.isTrue(!chip.classList.contains('focused'));
+
+ const highlightedMemory = {
+ startAddress: 10,
+ size: 8,
+ type: 'double',
+ name: 'myNumber',
+ };
+ const data = {
+ highlightInfos: [highlightedMemory],
+ focusedMemoryHighlight: highlightedMemory,
+ } as LinearMemoryInspectorComponents.LinearMemoryHighlightChipList.LinearMemoryHighlightChipListData;
+ component.data = data;
+ assert.isTrue(chip.classList.contains('focused'));
+ });
+
+ it('renders multiple chips', () => {
+ const shadowRoot = component.shadowRoot;
+ const highlightInfos = [
+ {
+ startAddress: 10,
+ size: 8,
+ type: 'double',
+ name: 'myNumber',
+ },
+ {
+ startAddress: 20,
+ size: 4,
+ type: 'int',
+ name: 'myInt',
+ },
+ ];
+ component.data = {
+ highlightInfos: highlightInfos,
+ };
+ assertShadowRoot(shadowRoot);
+ const chips = shadowRoot.querySelectorAll(HIGHLIGHT_CHIP);
+ assert.strictEqual(chips.length, highlightInfos.length);
+ });
+
+ it('sends event when clicking on jump to highlighted memory', async () => {
+ const eventPromise =
+ getEventPromise<LinearMemoryInspectorComponents.LinearMemoryHighlightChipList.JumpToHighlightedMemoryEvent>(
+ component,
+ LinearMemoryInspectorComponents.LinearMemoryHighlightChipList.JumpToHighlightedMemoryEvent.eventName);
+
+ const shadowRoot = component.shadowRoot;
+ assertShadowRoot(shadowRoot);
+ const button = shadowRoot.querySelector(HIGHLIGHT_PILL_JUMP_BUTTON_SELECTOR);
+ assertElement(button, HTMLButtonElement);
+ button.click();
+
+ assert.isNotNull(await eventPromise);
+ });
+
+ it('sends event when clicking on delete highlight chip', async () => {
+ const eventPromise =
+ getEventPromise<LinearMemoryInspectorComponents.LinearMemoryHighlightChipList.DeleteMemoryHighlightEvent>(
+ component,
+ LinearMemoryInspectorComponents.LinearMemoryHighlightChipList.DeleteMemoryHighlightEvent.eventName);
+
+ const shadowRoot = component.shadowRoot;
+ assertShadowRoot(shadowRoot);
+ const button = shadowRoot.querySelector(HIGHLIGHT_ROW_REMOVE_BUTTON_SELECTOR);
+ assertElement(button, HTMLButtonElement);
+ button.click();
+
+ assert.isNotNull(await eventPromise);
+ });
+
+ it('shows tooltip on jump to highlighted memory button', () => {
+ const button = getElementWithinComponent(component, HIGHLIGHT_PILL_JUMP_BUTTON_SELECTOR, HTMLButtonElement);
+ assert.strictEqual(button.title, 'Jump to this memory');
+ });
+
+ it('shows tooltip on delete highlight button', () => {
+ const button = getElementWithinComponent(component, HIGHLIGHT_ROW_REMOVE_BUTTON_SELECTOR, HTMLButtonElement);
+ assert.strictEqual(button.title, 'Stop highlighting this memory');
+ });
+});
diff --git a/front_end/panels/linear_memory_inspector/components/LinearMemoryInspector.test.ts b/front_end/panels/linear_memory_inspector/components/LinearMemoryInspector.test.ts
new file mode 100644
index 0000000..bc30366
--- /dev/null
+++ b/front_end/panels/linear_memory_inspector/components/LinearMemoryInspector.test.ts
@@ -0,0 +1,411 @@
+// Copyright 2020 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.
+
+/* eslint-disable rulesdir/es_modules_import */
+
+import {
+ dispatchClickEvent,
+ getElementsWithinComponent,
+ getElementWithinComponent,
+ getEventPromise,
+ renderElementIntoDOM,
+} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {describeWithLocale} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+
+import * as LinearMemoryInspectorComponents from './components.js';
+import {
+ NAVIGATOR_ADDRESS_SELECTOR,
+ NAVIGATOR_HISTORY_BUTTON_SELECTOR,
+ NAVIGATOR_PAGE_BUTTON_SELECTOR,
+} from './LinearMemoryNavigator.test.js';
+import {ENDIANNESS_SELECTOR} from './LinearMemoryValueInterpreter.test.js';
+import {VIEWER_BYTE_CELL_SELECTOR} from './LinearMemoryViewer.test.js';
+import {DISPLAY_JUMP_TO_POINTER_BUTTON_SELECTOR} from './ValueInterpreterDisplay.test.js';
+
+const {assert} = chai;
+
+const NAVIGATOR_SELECTOR = 'devtools-linear-memory-inspector-navigator';
+const VIEWER_SELECTOR = 'devtools-linear-memory-inspector-viewer';
+const INTERPRETER_SELECTOR = 'devtools-linear-memory-inspector-interpreter';
+
+describeWithLocale('LinearMemoryInspector', () => {
+ function getViewer(component: LinearMemoryInspectorComponents.LinearMemoryInspector.LinearMemoryInspector) {
+ return getElementWithinComponent(
+ component, VIEWER_SELECTOR, LinearMemoryInspectorComponents.LinearMemoryViewer.LinearMemoryViewer);
+ }
+
+ function getNavigator(component: LinearMemoryInspectorComponents.LinearMemoryInspector.LinearMemoryInspector) {
+ return getElementWithinComponent(
+ component, NAVIGATOR_SELECTOR, LinearMemoryInspectorComponents.LinearMemoryNavigator.LinearMemoryNavigator);
+ }
+
+ function getValueInterpreter(component: LinearMemoryInspectorComponents.LinearMemoryInspector.LinearMemoryInspector) {
+ return getElementWithinComponent(
+ component, INTERPRETER_SELECTOR,
+ LinearMemoryInspectorComponents.LinearMemoryValueInterpreter.LinearMemoryValueInterpreter);
+ }
+
+ function setUpComponent() {
+ const component = new LinearMemoryInspectorComponents.LinearMemoryInspector.LinearMemoryInspector();
+
+ const flexWrapper = document.createElement('div');
+ flexWrapper.style.width = '500px';
+ flexWrapper.style.height = '500px';
+ flexWrapper.style.display = 'flex';
+ flexWrapper.appendChild(component);
+ renderElementIntoDOM(flexWrapper);
+
+ const size = 1000;
+ const memory = [];
+ for (let i = 0; i < size; ++i) {
+ memory[i] = i;
+ }
+ const data = {
+ memory: new Uint8Array(memory),
+ address: 20,
+ memoryOffset: 0,
+ outerMemoryLength: memory.length,
+ endianness: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Little,
+ valueTypes: new Set<LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType>(
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.getDefaultValueTypeMapping().keys()),
+ };
+ component.data = data;
+
+ return {component, data};
+ }
+
+ function triggerAddressChangedEvent(
+ component: LinearMemoryInspectorComponents.LinearMemoryInspector.LinearMemoryInspector, address: string,
+ mode: LinearMemoryInspectorComponents.LinearMemoryNavigator.Mode) {
+ const navigator = getNavigator(component);
+ const changeEvent =
+ new LinearMemoryInspectorComponents.LinearMemoryNavigator.AddressInputChangedEvent(address, mode);
+ navigator.dispatchEvent(changeEvent);
+ }
+
+ function assertUpdatesInNavigator(
+ navigator: LinearMemoryInspectorComponents.LinearMemoryNavigator.LinearMemoryNavigator, expectedAddress: string,
+ expectedTooltip: string) {
+ const address = getElementWithinComponent(navigator, NAVIGATOR_ADDRESS_SELECTOR, HTMLInputElement);
+ const addressValue = address.value;
+ assert.strictEqual(addressValue, expectedAddress);
+ assert.strictEqual(address.title, expectedTooltip);
+ }
+
+ it('renders the navigator component', () => {
+ const {component} = setUpComponent();
+ const navigator = getNavigator(component);
+ assert.isNotNull(navigator);
+ });
+
+ it('renders the viewer component', () => {
+ const {component} = setUpComponent();
+ const viewer = getViewer(component);
+ assert.isNotNull(viewer);
+ });
+
+ it('renders the interpreter component', () => {
+ const {component} = setUpComponent();
+ const interpreter = getValueInterpreter(component);
+ assert.isNotNull(interpreter);
+ });
+
+ it('only saves history entries if addresses differ', async () => {
+ const {component, data} = setUpComponent();
+ // Set the address to zero to avoid the LMI to jump around in terms of addresses
+ // before the LMI is completely rendered (it requires two rendering processes,
+ // meanwhile our test might have already started).
+ data.address = 0;
+ component.data = data;
+
+ const navigator = getNavigator(component);
+ const buttons = getElementsWithinComponent(navigator, NAVIGATOR_HISTORY_BUTTON_SELECTOR, HTMLButtonElement);
+ const [backwardButton] = buttons;
+
+ const viewer = getViewer(component);
+ const byteCells = getElementsWithinComponent(viewer, VIEWER_BYTE_CELL_SELECTOR, HTMLSpanElement);
+
+ const byteIndices = [2, 1, 1, 2];
+ const expectedHistory = [2, 1, 2];
+
+ for (const index of byteIndices) {
+ const byteSelectedPromise =
+ getEventPromise<LinearMemoryInspectorComponents.LinearMemoryViewer.ByteSelectedEvent>(viewer, 'byteselected');
+ dispatchClickEvent(byteCells[index]);
+ await byteSelectedPromise;
+ }
+
+ const navigatorAddress = getElementWithinComponent(navigator, NAVIGATOR_ADDRESS_SELECTOR, HTMLInputElement);
+ for (const index of expectedHistory) {
+ assert.strictEqual(parseInt(navigatorAddress.value, 16), index);
+ dispatchClickEvent(backwardButton);
+ }
+ });
+
+ it('can navigate addresses back and forth in history', async () => {
+ const {component, data: {address}} = setUpComponent();
+
+ const navigator = getNavigator(component);
+ const buttons = getElementsWithinComponent(navigator, NAVIGATOR_HISTORY_BUTTON_SELECTOR, HTMLButtonElement);
+ const [backwardButton, forwardButton] = buttons;
+
+ const viewer = getViewer(component);
+ const byteCells = getElementsWithinComponent(viewer, VIEWER_BYTE_CELL_SELECTOR, HTMLSpanElement);
+
+ const visitedByteValue = [address];
+ const historyLength = Math.min(byteCells.length, 10);
+
+ for (let i = 1; i < historyLength; ++i) {
+ const byteSelectedPromise =
+ getEventPromise<LinearMemoryInspectorComponents.LinearMemoryViewer.ByteSelectedEvent>(viewer, 'byteselected');
+ dispatchClickEvent(byteCells[i]);
+ const byteSelectedEvent = await byteSelectedPromise;
+ visitedByteValue.push(byteSelectedEvent.data);
+ }
+
+ for (let i = historyLength - 1; i >= 0; --i) {
+ const currentByteValue =
+ getElementWithinComponent(viewer, VIEWER_BYTE_CELL_SELECTOR + '.selected', HTMLSpanElement);
+ assert.strictEqual(parseInt(currentByteValue.innerText, 16), visitedByteValue[i]);
+ dispatchClickEvent(backwardButton);
+ }
+
+ for (let i = 0; i < historyLength; ++i) {
+ const currentByteValue =
+ getElementWithinComponent(viewer, VIEWER_BYTE_CELL_SELECTOR + '.selected', HTMLSpanElement);
+ assert.strictEqual(parseInt(currentByteValue.innerText, 16), visitedByteValue[i]);
+
+ dispatchClickEvent(forwardButton);
+ }
+ });
+
+ it('can turn the page back and forth', () => {
+ const {component} = setUpComponent();
+ const navigator = getNavigator(component);
+ const buttons = getElementsWithinComponent(navigator, NAVIGATOR_PAGE_BUTTON_SELECTOR, HTMLButtonElement);
+ const [backwardButton, forwardButton] = buttons;
+
+ const address = getElementWithinComponent(navigator, NAVIGATOR_ADDRESS_SELECTOR, HTMLInputElement);
+ const addressBefore = parseInt(address.value, 16);
+
+ const viewer = getViewer(component);
+ const bytesShown = getElementsWithinComponent(viewer, VIEWER_BYTE_CELL_SELECTOR, HTMLSpanElement);
+ const numBytesPerPage = bytesShown.length;
+
+ dispatchClickEvent(forwardButton);
+ let addressAfter = parseInt(address.value, 16);
+ let expectedAddressAfter = addressBefore + numBytesPerPage;
+ assert.strictEqual(addressAfter, expectedAddressAfter);
+
+ dispatchClickEvent(backwardButton);
+ addressAfter = parseInt(address.value, 16);
+ expectedAddressAfter -= numBytesPerPage;
+ assert.strictEqual(addressAfter, Math.max(0, expectedAddressAfter));
+ });
+
+ it('synchronizes selected addresses in navigator and viewer', () => {
+ const {component, data} = setUpComponent();
+ const navigator = getNavigator(component);
+
+ const address = getElementWithinComponent(navigator, NAVIGATOR_ADDRESS_SELECTOR, HTMLInputElement);
+ const viewer = getViewer(component);
+ const selectedByte = getElementWithinComponent(viewer, VIEWER_BYTE_CELL_SELECTOR + '.selected', HTMLSpanElement);
+
+ const actualByteValue = parseInt(selectedByte.innerText, 16);
+ const expectedByteValue = data.memory[parseInt(address.value, 16)];
+ assert.strictEqual(actualByteValue, expectedByteValue);
+ });
+
+ it('can change endianness settings on event', () => {
+ const {component} = setUpComponent();
+ const interpreter = getValueInterpreter(component);
+ const select = getElementWithinComponent(interpreter, ENDIANNESS_SELECTOR, HTMLSelectElement);
+ assert.deepEqual(select.value, LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Little);
+
+ const endianSetting = LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Big;
+ const event =
+ new LinearMemoryInspectorComponents.LinearMemoryValueInterpreter.EndiannessChangedEvent(endianSetting);
+ interpreter.dispatchEvent(event);
+
+ assert.deepEqual(select.value, event.data);
+ });
+
+ it('updates current address if user triggers a jumptopointeraddress event', () => {
+ const {component, data} = setUpComponent();
+ data.valueTypes = new Set([LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Pointer32]);
+ data.memory = new Uint8Array([2, 0, 0, 0]);
+ data.outerMemoryLength = data.memory.length;
+ data.address = 0;
+ data.endianness = LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Little;
+ component.data = data;
+
+ const interpreter = getValueInterpreter(component);
+ const display = getElementWithinComponent(
+ interpreter, 'devtools-linear-memory-inspector-interpreter-display',
+ LinearMemoryInspectorComponents.ValueInterpreterDisplay.ValueInterpreterDisplay);
+ const button = getElementWithinComponent(display, DISPLAY_JUMP_TO_POINTER_BUTTON_SELECTOR, HTMLButtonElement);
+ dispatchClickEvent(button);
+
+ const navigator = getNavigator(component);
+ const selectedByte = getElementWithinComponent(navigator, NAVIGATOR_ADDRESS_SELECTOR, HTMLInputElement);
+
+ const actualSelectedByte = parseInt(selectedByte.value, 16);
+ const expectedSelectedByte = new DataView(data.memory.buffer).getUint32(0, true);
+ assert.strictEqual(actualSelectedByte, expectedSelectedByte);
+ });
+
+ it('leaves the navigator address as inputted by user on edit event', () => {
+ const {component} = setUpComponent();
+ const navigator = getNavigator(component);
+ triggerAddressChangedEvent(component, '2', LinearMemoryInspectorComponents.LinearMemoryNavigator.Mode.Edit);
+ assertUpdatesInNavigator(navigator, '2', 'Enter address');
+ });
+
+ it('changes navigator address (to hex) on valid user submit event', () => {
+ const {component} = setUpComponent();
+ const navigator = getNavigator(component);
+ triggerAddressChangedEvent(component, '2', LinearMemoryInspectorComponents.LinearMemoryNavigator.Mode.Submitted);
+ assertUpdatesInNavigator(navigator, '0x00000002', 'Enter address');
+ });
+
+ it('leaves the navigator address as inputted by user on invalid edit event', () => {
+ const {component} = setUpComponent();
+ const navigator = getNavigator(component);
+ triggerAddressChangedEvent(component, '-2', LinearMemoryInspectorComponents.LinearMemoryNavigator.Mode.Edit);
+ assertUpdatesInNavigator(navigator, '-2', 'Address has to be a number between 0x00000000 and 0x000003E8');
+ });
+
+ it('leaves the navigator address as inputted by user on invalid submit event', () => {
+ const {component} = setUpComponent();
+ const navigator = getNavigator(component);
+ triggerAddressChangedEvent(component, '-2', LinearMemoryInspectorComponents.LinearMemoryNavigator.Mode.Submitted);
+ assertUpdatesInNavigator(navigator, '-2', 'Address has to be a number between 0x00000000 and 0x000003E8');
+ });
+
+ it('triggers MemoryRequestEvent on refresh', async () => {
+ const {component, data} = setUpComponent();
+ const navigator = getNavigator(component);
+ const viewer = getViewer(component);
+
+ const bytes = getElementsWithinComponent(viewer, VIEWER_BYTE_CELL_SELECTOR, HTMLSpanElement);
+ const numBytesPerPage = bytes.length;
+
+ const eventPromise = getEventPromise<LinearMemoryInspectorComponents.LinearMemoryInspector.MemoryRequestEvent>(
+ component, 'memoryrequest');
+ navigator.dispatchEvent(new LinearMemoryInspectorComponents.LinearMemoryNavigator.RefreshRequestedEvent());
+ const event = await eventPromise;
+ const {start, end, address} = event.data;
+
+ assert.strictEqual(address, data.address);
+ assert.isAbove(end, start);
+ assert.strictEqual(numBytesPerPage, end - start);
+ });
+
+ it('triggers event on address change when byte is selected', async () => {
+ const {component, data} = setUpComponent();
+ const eventPromise = getEventPromise<LinearMemoryInspectorComponents.LinearMemoryInspector.AddressChangedEvent>(
+ component, 'addresschanged');
+ const viewer = getViewer(component);
+ const bytes = getElementsWithinComponent(viewer, VIEWER_BYTE_CELL_SELECTOR, HTMLSpanElement);
+ const numBytesPerPage = bytes.length;
+ const pageNumber = data.address / numBytesPerPage;
+ const addressOfFirstByte = pageNumber * numBytesPerPage + 1;
+ dispatchClickEvent(bytes[1]);
+ const event = await eventPromise;
+ assert.strictEqual(event.data, addressOfFirstByte);
+ });
+
+ it('triggers event on address change when data is set', async () => {
+ const {component, data} = setUpComponent();
+ const eventPromise = getEventPromise<LinearMemoryInspectorComponents.LinearMemoryInspector.AddressChangedEvent>(
+ component, 'addresschanged');
+ data.address = 10;
+ component.data = data;
+ const event = await eventPromise;
+ assert.strictEqual(event.data, data.address);
+ });
+
+ it('triggers event on settings changed when value type is changed', async () => {
+ const {component} = setUpComponent();
+ const interpreter = getValueInterpreter(component);
+ const eventPromise = getEventPromise<LinearMemoryInspectorComponents.LinearMemoryInspector.SettingsChangedEvent>(
+ component, 'settingschanged');
+ const valueType = LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int16;
+ interpreter.dispatchEvent(
+ new LinearMemoryInspectorComponents.LinearMemoryValueInterpreter.ValueTypeToggledEvent(valueType, false));
+ const event = await eventPromise;
+ assert.isTrue(event.data.valueTypes.size > 1);
+ assert.isFalse(event.data.valueTypes.has(valueType));
+ });
+
+ it('triggers event on settings changed when value type mode is changed', async () => {
+ const {component} = setUpComponent();
+ const interpreter = getValueInterpreter(component);
+ const eventPromise = getEventPromise<LinearMemoryInspectorComponents.LinearMemoryInspector.SettingsChangedEvent>(
+ component, 'settingschanged');
+ const valueType = LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int16;
+ const valueTypeMode = LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Hexadecimal;
+ interpreter.dispatchEvent(new LinearMemoryInspectorComponents.ValueInterpreterDisplay.ValueTypeModeChangedEvent(
+ valueType, valueTypeMode));
+ const event = await eventPromise;
+ assert.isTrue(event.data.valueTypes.has(valueType));
+ assert.strictEqual(event.data.modes.get(valueType), valueTypeMode);
+ });
+
+ it('triggers event on settings changed when endianness is changed', async () => {
+ const {component} = setUpComponent();
+ const interpreter = getValueInterpreter(component);
+ const eventPromise = getEventPromise<LinearMemoryInspectorComponents.LinearMemoryInspector.SettingsChangedEvent>(
+ component, 'settingschanged');
+ const endianness = LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Big;
+ interpreter.dispatchEvent(
+ new LinearMemoryInspectorComponents.LinearMemoryValueInterpreter.EndiannessChangedEvent(endianness));
+ const event = await eventPromise;
+ assert.strictEqual(event.data.endianness, endianness);
+ });
+
+ it('formats a hexadecimal number', () => {
+ const number = 23;
+ assert.strictEqual(
+ LinearMemoryInspectorComponents.LinearMemoryInspectorUtils.toHexString({number, pad: 0, prefix: false}), '17');
+ });
+
+ it('formats a hexadecimal number and adds padding', () => {
+ const number = 23;
+ assert.strictEqual(
+ LinearMemoryInspectorComponents.LinearMemoryInspectorUtils.toHexString({number, pad: 5, prefix: false}),
+ '00017');
+ });
+
+ it('formats a hexadecimal number and adds prefix', () => {
+ const number = 23;
+ assert.strictEqual(
+ LinearMemoryInspectorComponents.LinearMemoryInspectorUtils.toHexString({number, pad: 5, prefix: true}),
+ '0x00017');
+ });
+
+ it('can parse a valid hexadecimal address', () => {
+ const address = '0xa';
+ const parsedAddress = LinearMemoryInspectorComponents.LinearMemoryInspectorUtils.parseAddress(address);
+ assert.strictEqual(parsedAddress, 10);
+ });
+
+ it('can parse a valid decimal address', () => {
+ const address = '20';
+ const parsedAddress = LinearMemoryInspectorComponents.LinearMemoryInspectorUtils.parseAddress(address);
+ assert.strictEqual(parsedAddress, 20);
+ });
+
+ it('returns undefined on parsing invalid address', () => {
+ const address = '20a';
+ const parsedAddress = LinearMemoryInspectorComponents.LinearMemoryInspectorUtils.parseAddress(address);
+ assert.strictEqual(parsedAddress, undefined);
+ });
+
+ it('returns undefined on parsing negative address', () => {
+ const address = '-20';
+ const parsedAddress = LinearMemoryInspectorComponents.LinearMemoryInspectorUtils.parseAddress(address);
+ assert.strictEqual(parsedAddress, undefined);
+ });
+});
diff --git a/front_end/panels/linear_memory_inspector/components/LinearMemoryNavigator.test.ts b/front_end/panels/linear_memory_inspector/components/LinearMemoryNavigator.test.ts
new file mode 100644
index 0000000..2d33b2d
--- /dev/null
+++ b/front_end/panels/linear_memory_inspector/components/LinearMemoryNavigator.test.ts
@@ -0,0 +1,196 @@
+// Copyright 2020 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 {
+ assertElement,
+ assertElements,
+ assertShadowRoot,
+ getElementsWithinComponent,
+ getElementWithinComponent,
+ getEventPromise,
+ renderElementIntoDOM,
+} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {describeWithLocale} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+
+import * as LinearMemoryInspectorComponents from './components.js';
+
+const {assert} = chai;
+
+export const NAVIGATOR_ADDRESS_SELECTOR = '[data-input]';
+export const NAVIGATOR_PAGE_BUTTON_SELECTOR = '[data-button=pagenavigation]';
+export const NAVIGATOR_HISTORY_BUTTON_SELECTOR = '[data-button=historynavigation]';
+export const NAVIGATOR_REFRESH_BUTTON_SELECTOR = '[data-button=refreshrequested]';
+
+describeWithLocale('LinearMemoryNavigator', () => {
+ let component: LinearMemoryInspectorComponents.LinearMemoryNavigator.LinearMemoryNavigator;
+
+ beforeEach(renderNavigator);
+
+ function renderNavigator() {
+ component = new LinearMemoryInspectorComponents.LinearMemoryNavigator.LinearMemoryNavigator();
+ renderElementIntoDOM(component);
+
+ component.data = {
+ address: '20',
+ valid: true,
+ mode: LinearMemoryInspectorComponents.LinearMemoryNavigator.Mode.Submitted,
+ error: undefined,
+ canGoBackInHistory: true,
+ canGoForwardInHistory: true,
+ };
+ }
+
+ async function assertNavigationEvents(eventType: string) {
+ const shadowRoot = component.shadowRoot;
+ assertShadowRoot(shadowRoot);
+ const pageNavigationButtons = shadowRoot.querySelectorAll(`[data-button=${eventType}]`);
+ assertElements(pageNavigationButtons, HTMLButtonElement);
+ assert.lengthOf(pageNavigationButtons, 2);
+
+ const navigation = [];
+ for (const button of pageNavigationButtons) {
+ const eventPromise = getEventPromise<LinearMemoryInspectorComponents.LinearMemoryNavigator.PageNavigationEvent>(
+ component, eventType);
+ button.click();
+ const event = await eventPromise;
+ navigation.push(event.data);
+ }
+
+ assert.deepEqual(navigation, [
+ LinearMemoryInspectorComponents.LinearMemoryNavigator.Navigation.Backward,
+ LinearMemoryInspectorComponents.LinearMemoryNavigator.Navigation.Forward,
+ ]);
+ }
+
+ it('renders navigator address', () => {
+ const shadowRoot = component.shadowRoot;
+ assertShadowRoot(shadowRoot);
+ const input = shadowRoot.querySelector(NAVIGATOR_ADDRESS_SELECTOR);
+ assertElement(input, HTMLInputElement);
+ assert.strictEqual(input.value, '20');
+ });
+
+ it('re-renders address on address change', () => {
+ component.data = {
+ address: '16',
+ valid: true,
+ mode: LinearMemoryInspectorComponents.LinearMemoryNavigator.Mode.Submitted,
+ error: undefined,
+ canGoBackInHistory: false,
+ canGoForwardInHistory: false,
+ };
+
+ const shadowRoot = component.shadowRoot;
+ assertShadowRoot(shadowRoot);
+ const input = shadowRoot.querySelector(NAVIGATOR_ADDRESS_SELECTOR);
+ assertElement(input, HTMLInputElement);
+ assert.strictEqual(input.value, '16');
+ });
+
+ it('sends event when clicking on refresh', async () => {
+ const eventPromise = getEventPromise<LinearMemoryInspectorComponents.LinearMemoryNavigator.RefreshRequestedEvent>(
+ component, 'refreshrequested');
+
+ const shadowRoot = component.shadowRoot;
+ assertShadowRoot(shadowRoot);
+ const refreshButton = shadowRoot.querySelector(NAVIGATOR_REFRESH_BUTTON_SELECTOR);
+ assertElement(refreshButton, HTMLButtonElement);
+ refreshButton.click();
+
+ assert.isNotNull(await eventPromise);
+ });
+
+ it('sends events when clicking previous and next page', async () => {
+ await assertNavigationEvents('historynavigation');
+ });
+
+ it('sends events when clicking undo and redo', async () => {
+ await assertNavigationEvents('pagenavigation');
+ });
+
+ it('disables the previous and next page buttons if specified as not navigatable', () => {
+ component.data = {
+ address: '0',
+ valid: true,
+ mode: LinearMemoryInspectorComponents.LinearMemoryNavigator.Mode.Submitted,
+ error: undefined,
+ canGoBackInHistory: false,
+ canGoForwardInHistory: false,
+ };
+
+ const buttons = getElementsWithinComponent(component, NAVIGATOR_HISTORY_BUTTON_SELECTOR, HTMLButtonElement);
+ assert.lengthOf(buttons, 2);
+ const historyBack = buttons[0];
+ const historyForward = buttons[1];
+
+ assert.isTrue(historyBack.disabled);
+ assert.isTrue(historyForward.disabled);
+ });
+
+ it('shows tooltip on hovering over address', () => {
+ const input = getElementWithinComponent(component, NAVIGATOR_ADDRESS_SELECTOR, HTMLInputElement);
+ assert.strictEqual(input.title, 'Enter address');
+ });
+
+ it('shows tooltip with error and selects all text on submitting invalid address input', () => {
+ const error = 'Address is invalid';
+ const invalidAddress = '60';
+ component.data = {
+ address: invalidAddress,
+ valid: false,
+ mode: LinearMemoryInspectorComponents.LinearMemoryNavigator.Mode.InvalidSubmit,
+ error,
+ canGoBackInHistory: false,
+ canGoForwardInHistory: false,
+ };
+ const input = getElementWithinComponent(component, NAVIGATOR_ADDRESS_SELECTOR, HTMLInputElement);
+ assert.strictEqual(input.title, error);
+ assert.isNotNull(input.selectionStart);
+ assert.isNotNull(input.selectionEnd);
+ if (input.selectionEnd !== null && input.selectionStart !== null) {
+ const selectionLength = input.selectionEnd - input.selectionStart;
+ assert.strictEqual(selectionLength, invalidAddress.length);
+ }
+ });
+
+ it('shows tooltip with invalid address on hovering over address', () => {
+ const error = 'Address is invalid';
+ component.data = {
+ address: '60',
+ valid: false,
+ mode: LinearMemoryInspectorComponents.LinearMemoryNavigator.Mode.Edit,
+ error,
+ canGoBackInHistory: false,
+ canGoForwardInHistory: false,
+ };
+ const input = getElementWithinComponent(component, NAVIGATOR_ADDRESS_SELECTOR, HTMLInputElement);
+ assert.strictEqual(input.title, error);
+ });
+
+ it('shows tooltip on page navigation buttons', () => {
+ const buttons = getElementsWithinComponent(component, NAVIGATOR_PAGE_BUTTON_SELECTOR, HTMLButtonElement);
+ assert.lengthOf(buttons, 2);
+ const pageBack = buttons[0];
+ const pageForward = buttons[1];
+
+ assert.strictEqual(pageBack.title, 'Previous page');
+ assert.strictEqual(pageForward.title, 'Next page');
+ });
+
+ it('shows tooltip on history navigation buttons', () => {
+ const buttons = getElementsWithinComponent(component, NAVIGATOR_HISTORY_BUTTON_SELECTOR, HTMLButtonElement);
+ assert.lengthOf(buttons, 2);
+ const historyBack = buttons[0];
+ const historyForward = buttons[1];
+
+ assert.strictEqual(historyBack.title, 'Go back in address history');
+ assert.strictEqual(historyForward.title, 'Go forward in address history');
+ });
+
+ it('shows tooltip on refresh button', () => {
+ const refreshButton = getElementWithinComponent(component, NAVIGATOR_REFRESH_BUTTON_SELECTOR, HTMLButtonElement);
+
+ assert.strictEqual(refreshButton.title, 'Refresh');
+ });
+});
diff --git a/front_end/panels/linear_memory_inspector/components/LinearMemoryValueInterpreter.test.ts b/front_end/panels/linear_memory_inspector/components/LinearMemoryValueInterpreter.test.ts
new file mode 100644
index 0000000..361a617
--- /dev/null
+++ b/front_end/panels/linear_memory_inspector/components/LinearMemoryValueInterpreter.test.ts
@@ -0,0 +1,119 @@
+// Copyright 2020 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 {
+ getElementWithinComponent,
+ getEventPromise,
+ renderElementIntoDOM,
+} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {describeWithLocale} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+
+import * as LinearMemoryInspectorComponents from './components.js';
+
+const {assert} = chai;
+
+const DISPLAY_SELECTOR = 'devtools-linear-memory-inspector-interpreter-display';
+const SETTINGS_SELECTOR = 'devtools-linear-memory-inspector-interpreter-settings';
+const TOOLBAR_SELECTOR = '.settings-toolbar';
+export const ENDIANNESS_SELECTOR = '[data-endianness]';
+
+function assertSettingsRenders(component: HTMLElement) {
+ const settings = getElementWithinComponent(
+ component, SETTINGS_SELECTOR, LinearMemoryInspectorComponents.ValueInterpreterSettings.ValueInterpreterSettings);
+ assert.isNotNull(settings);
+}
+
+function assertDisplayRenders(component: HTMLElement) {
+ const display = getElementWithinComponent(
+ component, DISPLAY_SELECTOR, LinearMemoryInspectorComponents.ValueInterpreterDisplay.ValueInterpreterDisplay);
+ assert.isNotNull(display);
+}
+
+function clickSettingsButton(
+ component: LinearMemoryInspectorComponents.LinearMemoryValueInterpreter.LinearMemoryValueInterpreter) {
+ const settingsButton = getElementWithinComponent(component, '[data-settings]', HTMLButtonElement);
+ settingsButton.click();
+}
+
+describeWithLocale('LinearMemoryValueInterpreter', () => {
+ function setUpComponent() {
+ const buffer = new Uint8Array([34, 234, 12, 3]).buffer;
+ const component = new LinearMemoryInspectorComponents.LinearMemoryValueInterpreter.LinearMemoryValueInterpreter();
+ component.data = {
+ value: buffer,
+ endianness: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Little,
+ valueTypes: new Set([LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int8]),
+ memoryLength: buffer.byteLength,
+ };
+ renderElementIntoDOM(component);
+ return component;
+ }
+
+ it('renders the settings toolbar', () => {
+ const component = setUpComponent();
+ const settingsToolbar = getElementWithinComponent(component, TOOLBAR_SELECTOR, HTMLDivElement);
+ assert.isNotNull(settingsToolbar);
+ });
+
+ it('renders value display as default', () => {
+ const component = setUpComponent();
+ assertDisplayRenders(component);
+ });
+
+ it('switches between value display and value settings', () => {
+ const component = setUpComponent();
+ assertDisplayRenders(component);
+
+ clickSettingsButton(component);
+
+ assertSettingsRenders(component);
+ });
+
+ it('listens on TypeToggleEvents', async () => {
+ const component = setUpComponent();
+ clickSettingsButton(component);
+
+ const settings = getElementWithinComponent(
+ component, SETTINGS_SELECTOR,
+ LinearMemoryInspectorComponents.ValueInterpreterSettings.ValueInterpreterSettings);
+ const eventPromise =
+ getEventPromise<LinearMemoryInspectorComponents.LinearMemoryValueInterpreter.ValueTypeToggledEvent>(
+ component, 'valuetypetoggled');
+ const expectedType = LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Float64;
+ const expectedChecked = true;
+ const typeToggleEvent =
+ new LinearMemoryInspectorComponents.ValueInterpreterSettings.TypeToggleEvent(expectedType, expectedChecked);
+ settings.dispatchEvent(typeToggleEvent);
+
+ const event = await eventPromise;
+ assert.strictEqual(event.data.type, expectedType);
+ assert.strictEqual(event.data.checked, expectedChecked);
+ });
+
+ it('renders the endianness options', () => {
+ const component = setUpComponent();
+ const input = getElementWithinComponent(component, ENDIANNESS_SELECTOR, HTMLSelectElement);
+ assert.deepEqual(input.value, LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Little);
+ const options = input.querySelectorAll('option');
+ const endiannessSettings = Array.from(options).map(option => option.value);
+ assert.deepEqual(endiannessSettings, [
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Little,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Big,
+ ]);
+ });
+
+ it('triggers an event on changing endianness', async () => {
+ const component = setUpComponent();
+ const input = getElementWithinComponent(component, ENDIANNESS_SELECTOR, HTMLSelectElement);
+
+ const eventPromise =
+ getEventPromise<LinearMemoryInspectorComponents.LinearMemoryValueInterpreter.EndiannessChangedEvent>(
+ component, 'endiannesschanged');
+ const changeEvent = new Event('change');
+ input.dispatchEvent(changeEvent);
+
+ const event = await eventPromise;
+ assert.deepEqual(event.data, LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Little);
+ });
+});
diff --git a/front_end/panels/linear_memory_inspector/components/LinearMemoryViewer.test.ts b/front_end/panels/linear_memory_inspector/components/LinearMemoryViewer.test.ts
new file mode 100644
index 0000000..1957549
--- /dev/null
+++ b/front_end/panels/linear_memory_inspector/components/LinearMemoryViewer.test.ts
@@ -0,0 +1,401 @@
+// Copyright 2020 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 {
+ assertElement,
+ assertElements,
+ assertShadowRoot,
+ getElementsWithinComponent,
+ getElementWithinComponent,
+ getEventPromise,
+ renderElementIntoDOM,
+} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+
+import * as LinearMemoryInspectorComponents from './components.js';
+
+const {assert} = chai;
+
+const NUM_BYTES_PER_GROUP = 4;
+export const VIEWER_BYTE_CELL_SELECTOR = '.byte-cell';
+export const VIEWER_TEXT_CELL_SELECTOR = '.text-cell';
+export const VIEWER_ROW_SELECTOR = '.row';
+export const VIEWER_ADDRESS_SELECTOR = '.address';
+
+describe('LinearMemoryViewer', () => {
+ async function setUpComponent() {
+ const component = createComponent();
+ const data = createComponentData();
+ component.data = data;
+
+ const event =
+ await getEventPromise<LinearMemoryInspectorComponents.LinearMemoryViewer.ResizeEvent>(component, 'resize');
+ const numBytesPerPage = event.data;
+ assert.isAbove(numBytesPerPage, 4);
+
+ return {component, data};
+ }
+
+ async function setUpComponentWithHighlightInfo() {
+ const component = createComponent();
+ const data = createComponentData();
+ const highlightInfo: LinearMemoryInspectorComponents.LinearMemoryViewerUtils.HighlightInfo = {
+ startAddress: 2,
+ size: 21, // A large enough odd number so that the highlight spans mulitple rows.
+ type: 'bool[]',
+ };
+ const dataWithHighlightInfo = {
+ ...data,
+ highlightInfo: highlightInfo,
+ };
+
+ const eventPromise =
+ getEventPromise<LinearMemoryInspectorComponents.LinearMemoryViewer.ResizeEvent>(component, 'resize');
+ component.data = dataWithHighlightInfo;
+ const event = await eventPromise;
+ const numBytesPerPage = event.data;
+ assert.isAbove(numBytesPerPage, 4);
+
+ return {component, dataWithHighlightInfo};
+ }
+
+ function createComponent() {
+ const component = new LinearMemoryInspectorComponents.LinearMemoryViewer.LinearMemoryViewer();
+ const flexWrapper = document.createElement('div');
+ flexWrapper.style.width = '500px';
+ flexWrapper.style.height = '500px';
+ flexWrapper.style.display = 'flex';
+ flexWrapper.appendChild(component);
+ renderElementIntoDOM(flexWrapper);
+ return component;
+ }
+
+ function createComponentData() {
+ const memory = [];
+ for (let i = 0; i < 1000; ++i) {
+ memory.push(i);
+ }
+
+ const data = {
+ memory: new Uint8Array(memory),
+ address: 2,
+ memoryOffset: 0,
+ focus: true,
+ };
+
+ return data;
+ }
+
+ function getCellsPerRow(
+ component: LinearMemoryInspectorComponents.LinearMemoryViewer.LinearMemoryViewer, cellSelector: string) {
+ assertShadowRoot(component.shadowRoot);
+ const row = component.shadowRoot.querySelector(VIEWER_ROW_SELECTOR);
+ assertElement(row, HTMLDivElement);
+ const cellsPerRow = row.querySelectorAll(cellSelector);
+ assert.isNotEmpty(cellsPerRow);
+ assertElements(cellsPerRow, HTMLSpanElement);
+ return cellsPerRow;
+ }
+
+ function assertSelectedCellIsHighlighted(
+ component: LinearMemoryInspectorComponents.LinearMemoryViewer.LinearMemoryViewer, cellSelector: string,
+ index: number) {
+ assertShadowRoot(component.shadowRoot);
+ const selectedCells = component.shadowRoot.querySelectorAll(cellSelector + '.selected');
+ assert.lengthOf(selectedCells, 1);
+ assertElements(selectedCells, HTMLSpanElement);
+ const selectedCell = selectedCells[0];
+
+ const allCells = getCellsPerRow(component, cellSelector);
+ assert.isAtLeast(allCells.length, index);
+ const cellAtAddress = allCells[index];
+
+ assert.strictEqual(selectedCell, cellAtAddress);
+ }
+
+ async function assertEventTriggeredOnArrowNavigation(
+ component: LinearMemoryInspectorComponents.LinearMemoryViewer.LinearMemoryViewer, code: string,
+ expectedAddress: number) {
+ const eventPromise = getEventPromise<LinearMemoryInspectorComponents.LinearMemoryViewer.ByteSelectedEvent>(
+ component, 'byteselected');
+ const view = getElementWithinComponent(component, '.view', HTMLDivElement);
+ view.dispatchEvent(new KeyboardEvent('keydown', {'code': code}));
+ const event = await eventPromise;
+ assert.strictEqual(event.data, expectedAddress);
+ }
+
+ it('correctly renders bytes given a memory offset greater than zero', () => {
+ const data = createComponentData();
+ data.memoryOffset = 1;
+ assert.isAbove(data.address, data.memoryOffset);
+ const component = new LinearMemoryInspectorComponents.LinearMemoryViewer.LinearMemoryViewer();
+ component.data = data;
+ renderElementIntoDOM(component);
+
+ const selectedByte = getElementWithinComponent(component, VIEWER_BYTE_CELL_SELECTOR + '.selected', HTMLSpanElement);
+ const selectedValue = parseInt(selectedByte.innerText, 16);
+ assert.strictEqual(selectedValue, data.memory[data.address - data.memoryOffset]);
+ });
+
+ it('triggers an event on resize', async () => {
+ const data = createComponentData();
+ const component = new LinearMemoryInspectorComponents.LinearMemoryViewer.LinearMemoryViewer();
+ component.data = data;
+
+ const thinWrapper = document.createElement('div');
+ thinWrapper.style.width = '100px';
+ thinWrapper.style.height = '100px';
+ thinWrapper.style.display = 'flex';
+ thinWrapper.appendChild(component);
+ renderElementIntoDOM(thinWrapper);
+
+ const eventPromise =
+ getEventPromise<LinearMemoryInspectorComponents.LinearMemoryViewer.ResizeEvent>(component, 'resize');
+ thinWrapper.style.width = '800px';
+
+ assert.isNotNull(await eventPromise);
+ });
+
+ it('renders one address per row', async () => {
+ const {component} = await setUpComponent();
+ assertShadowRoot(component.shadowRoot);
+ const rows = component.shadowRoot.querySelectorAll(VIEWER_ROW_SELECTOR);
+ const addresses = component.shadowRoot.querySelectorAll(VIEWER_ADDRESS_SELECTOR);
+ assert.isNotEmpty(rows);
+ assert.strictEqual(rows.length, addresses.length);
+ });
+
+ it('renders addresses depending on the bytes per row', async () => {
+ const {component, data} = await setUpComponent();
+ const bytesPerRow = getCellsPerRow(component, VIEWER_BYTE_CELL_SELECTOR);
+ const numBytesPerRow = bytesPerRow.length;
+
+ assertShadowRoot(component.shadowRoot);
+ const addresses = component.shadowRoot.querySelectorAll(VIEWER_ADDRESS_SELECTOR);
+ assert.isNotEmpty(addresses);
+
+ for (let i = 0, currentAddress = data.memoryOffset; i < addresses.length; currentAddress += numBytesPerRow, ++i) {
+ const addressElement = addresses[i];
+ assertElement(addressElement, HTMLSpanElement);
+
+ const hex = currentAddress.toString(16).toUpperCase().padStart(8, '0');
+ assert.strictEqual(addressElement.innerText, hex);
+ }
+ });
+
+ it('renders unsplittable byte group', () => {
+ const thinWrapper = document.createElement('div');
+ thinWrapper.style.width = '10px';
+
+ const component = new LinearMemoryInspectorComponents.LinearMemoryViewer.LinearMemoryViewer();
+ component.data = createComponentData();
+ thinWrapper.appendChild(component);
+ renderElementIntoDOM(thinWrapper);
+ const bytesPerRow = getCellsPerRow(component, VIEWER_BYTE_CELL_SELECTOR);
+ assert.strictEqual(bytesPerRow.length, NUM_BYTES_PER_GROUP);
+ });
+
+ it('renders byte values corresponding to memory set', async () => {
+ const {component, data} = await setUpComponent();
+ assertShadowRoot(component.shadowRoot);
+ const bytes = component.shadowRoot.querySelectorAll(VIEWER_BYTE_CELL_SELECTOR);
+ assertElements(bytes, HTMLSpanElement);
+
+ const memory = data.memory;
+ const bytesPerPage = bytes.length;
+ const memoryStartAddress = Math.floor(data.address / bytesPerPage) * bytesPerPage;
+ assert.isAtMost(bytes.length, memory.length);
+ for (let i = 0; i < bytes.length; ++i) {
+ const hex = memory[memoryStartAddress + i].toString(16).toUpperCase().padStart(2, '0');
+ assert.strictEqual(bytes[i].innerText, hex);
+ }
+ });
+
+ it('triggers an event on selecting a byte value', async () => {
+ const {component, data} = await setUpComponent();
+ assertShadowRoot(component.shadowRoot);
+
+ const byte = component.shadowRoot.querySelector(VIEWER_BYTE_CELL_SELECTOR);
+ assertElement(byte, HTMLSpanElement);
+
+ const eventPromise = getEventPromise<LinearMemoryInspectorComponents.LinearMemoryViewer.ByteSelectedEvent>(
+ component, 'byteselected');
+ byte.click();
+ const {data: address} = await eventPromise;
+ assert.strictEqual(address, data.memoryOffset);
+ });
+
+ it('renders as many ascii values as byte values in a row', async () => {
+ const {component} = await setUpComponent();
+ const bytes = getCellsPerRow(component, VIEWER_BYTE_CELL_SELECTOR);
+ const ascii = getCellsPerRow(component, VIEWER_TEXT_CELL_SELECTOR);
+
+ assert.strictEqual(bytes.length, ascii.length);
+ });
+
+ it('renders ascii values corresponding to bytes', async () => {
+ const {component} = await setUpComponent();
+ assertShadowRoot(component.shadowRoot);
+
+ const asciiValues = component.shadowRoot.querySelectorAll(VIEWER_TEXT_CELL_SELECTOR);
+ const byteValues = component.shadowRoot.querySelectorAll(VIEWER_BYTE_CELL_SELECTOR);
+ assertElements(asciiValues, HTMLSpanElement);
+ assertElements(byteValues, HTMLSpanElement);
+ assert.strictEqual(byteValues.length, asciiValues.length);
+
+ const smallestPrintableAscii = 20;
+ const largestPrintableAscii = 127;
+
+ for (let i = 0; i < byteValues.length; ++i) {
+ const byteValue = parseInt(byteValues[i].innerText, 16);
+ const asciiText = asciiValues[i].innerText;
+ if (byteValue < smallestPrintableAscii || byteValue > largestPrintableAscii) {
+ assert.strictEqual(asciiText, '.');
+ } else {
+ assert.strictEqual(asciiText, String.fromCharCode(byteValue).trim());
+ }
+ }
+ });
+
+ it('triggers an event on selecting an ascii value', async () => {
+ const {component, data} = await setUpComponent();
+ assertShadowRoot(component.shadowRoot);
+
+ const asciiCell = component.shadowRoot.querySelector(VIEWER_TEXT_CELL_SELECTOR);
+ assertElement(asciiCell, HTMLSpanElement);
+
+ const eventPromise = getEventPromise<LinearMemoryInspectorComponents.LinearMemoryViewer.ByteSelectedEvent>(
+ component, 'byteselected');
+ asciiCell.click();
+ const {data: address} = await eventPromise;
+ assert.strictEqual(address, data.memoryOffset);
+ });
+
+ it('highlights selected byte value on setting an address', () => {
+ const component = new LinearMemoryInspectorComponents.LinearMemoryViewer.LinearMemoryViewer();
+ const memory = new Uint8Array([2, 3, 5, 3]);
+ const address = 2;
+
+ renderElementIntoDOM(component);
+ component.data = {
+ memory,
+ address,
+ memoryOffset: 0,
+ focus: true,
+ };
+
+ assertSelectedCellIsHighlighted(component, VIEWER_BYTE_CELL_SELECTOR, address);
+ assertSelectedCellIsHighlighted(component, VIEWER_TEXT_CELL_SELECTOR, address);
+ assertSelectedCellIsHighlighted(component, VIEWER_ADDRESS_SELECTOR, 0);
+ });
+
+ it('triggers an event on arrow down', async () => {
+ const {component, data} = await setUpComponent();
+ const addressBefore = data.address;
+ const expectedAddress = addressBefore - 1;
+ await assertEventTriggeredOnArrowNavigation(component, 'ArrowLeft', expectedAddress);
+ });
+
+ it('triggers an event on arrow right', async () => {
+ const {component, data} = await setUpComponent();
+ const addressBefore = data.address;
+ const expectedAddress = addressBefore + 1;
+ await assertEventTriggeredOnArrowNavigation(component, 'ArrowRight', expectedAddress);
+ });
+
+ it('triggers an event on arrow down', async () => {
+ const {component, data} = await setUpComponent();
+ const addressBefore = data.address;
+
+ const bytesPerRow = getCellsPerRow(component, VIEWER_BYTE_CELL_SELECTOR);
+ const numBytesPerRow = bytesPerRow.length;
+ const expectedAddress = addressBefore + numBytesPerRow;
+ await assertEventTriggeredOnArrowNavigation(component, 'ArrowDown', expectedAddress);
+ });
+
+ it('triggers an event on arrow up', async () => {
+ const {component, data} = await setUpComponent();
+ const addressBefore = data.address;
+
+ const bytesPerRow = getCellsPerRow(component, VIEWER_BYTE_CELL_SELECTOR);
+ const numBytesPerRow = bytesPerRow.length;
+ const expectedAddress = addressBefore - numBytesPerRow;
+ await assertEventTriggeredOnArrowNavigation(component, 'ArrowUp', expectedAddress);
+ });
+
+ it('triggers an event on page down', async () => {
+ const {component, data} = await setUpComponent();
+ const addressBefore = data.address;
+
+ const bytes = getElementsWithinComponent(component, VIEWER_BYTE_CELL_SELECTOR, HTMLSpanElement);
+ const numBytesPerPage = bytes.length;
+ const expectedAddress = addressBefore + numBytesPerPage;
+ await assertEventTriggeredOnArrowNavigation(component, 'PageDown', expectedAddress);
+ });
+
+ it('triggers an event on page down', async () => {
+ const {component, data} = await setUpComponent();
+ const addressBefore = data.address;
+
+ const bytes = getElementsWithinComponent(component, VIEWER_BYTE_CELL_SELECTOR, HTMLSpanElement);
+ const numBytesPerPage = bytes.length;
+ const expectedAddress = addressBefore - numBytesPerPage;
+ await assertEventTriggeredOnArrowNavigation(component, 'PageUp', expectedAddress);
+ });
+
+ it('does not highlight any bytes when no highlight info set', async () => {
+ const {component} = await setUpComponent();
+ const byteCells = getElementsWithinComponent(component, '.byte-cell.highlight-area', HTMLSpanElement);
+ const textCells = getElementsWithinComponent(component, '.text-cell.highlight-area', HTMLSpanElement);
+
+ assert.strictEqual(byteCells.length, 0);
+ assert.strictEqual(textCells.length, 0);
+ });
+
+ it('highlights correct number of bytes when highlight info set', async () => {
+ const {component, dataWithHighlightInfo} = await setUpComponentWithHighlightInfo();
+ const byteCells = getElementsWithinComponent(component, '.byte-cell.highlight-area', HTMLSpanElement);
+ const textCells = getElementsWithinComponent(component, '.text-cell.highlight-area', HTMLSpanElement);
+
+ assert.strictEqual(byteCells.length, dataWithHighlightInfo.highlightInfo.size);
+ assert.strictEqual(textCells.length, dataWithHighlightInfo.highlightInfo.size);
+ });
+
+ it('highlights byte cells at correct positions when highlight info set', async () => {
+ const {component, dataWithHighlightInfo} = await setUpComponentWithHighlightInfo();
+ const byteCells = getElementsWithinComponent(component, '.byte-cell.highlight-area', HTMLSpanElement);
+
+ for (let i = 0; i < byteCells.length; ++i) {
+ const selectedValue = parseInt(byteCells[i].innerText, 16);
+ const index = dataWithHighlightInfo.highlightInfo.startAddress - dataWithHighlightInfo.memoryOffset + i;
+ assert.strictEqual(selectedValue, dataWithHighlightInfo.memory[index]);
+ }
+ });
+
+ it('focuses highlighted byte cells when focusedMemoryHighlight provided', async () => {
+ const {component, dataWithHighlightInfo} = await setUpComponentWithHighlightInfo();
+ const dataWithFocusedMemoryHighlight = {
+ ...dataWithHighlightInfo,
+ focusedMemoryHighlight: dataWithHighlightInfo.highlightInfo,
+ };
+ component.data = dataWithFocusedMemoryHighlight;
+ const byteCells = getElementsWithinComponent(component, '.byte-cell.focused', HTMLSpanElement);
+
+ for (let i = 0; i < byteCells.length; ++i) {
+ const selectedValue = parseInt(byteCells[i].innerText, 16);
+ const index = dataWithHighlightInfo.highlightInfo.startAddress - dataWithHighlightInfo.memoryOffset + i;
+ assert.strictEqual(selectedValue, dataWithHighlightInfo.memory[index]);
+ }
+ });
+
+ it('does not focus highlighted byte cells when no focusedMemoryHighlight provided', async () => {
+ const {component, dataWithHighlightInfo} = await setUpComponentWithHighlightInfo();
+ const dataWithFocusedMemoryHighlight = {
+ ...dataWithHighlightInfo,
+ focusedMemoryHighlight: dataWithHighlightInfo.highlightInfo,
+ };
+ component.data = dataWithFocusedMemoryHighlight;
+ const byteCells = getElementsWithinComponent(component, '.byte-cell.focused', HTMLSpanElement);
+ assert.isEmpty(byteCells);
+ });
+});
diff --git a/front_end/panels/linear_memory_inspector/components/ValueInterpreterDisplay.test.ts b/front_end/panels/linear_memory_inspector/components/ValueInterpreterDisplay.test.ts
new file mode 100644
index 0000000..81cc750
--- /dev/null
+++ b/front_end/panels/linear_memory_inspector/components/ValueInterpreterDisplay.test.ts
@@ -0,0 +1,452 @@
+// Copyright 2020 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 {
+ dispatchClickEvent,
+ getElementsWithinComponent,
+ getElementWithinComponent,
+ getEventPromise,
+ renderElementIntoDOM,
+} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {describeWithLocale} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+
+import * as LinearMemoryInspectorComponents from './components.js';
+
+export const DISPLAY_JUMP_TO_POINTER_BUTTON_SELECTOR = '[data-jump]';
+
+const {assert} = chai;
+
+describeWithLocale('ValueInterpreterDisplay', () => {
+ const combinationsForNumbers = [
+ {endianness: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Little, signed: true},
+ {endianness: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Little, signed: false},
+ {endianness: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Big, signed: false},
+ {endianness: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Big, signed: true},
+ ];
+
+ function testNumberFormatCombinations(
+ baseData: {
+ buffer: ArrayBuffer,
+ type: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType,
+ mode: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode,
+ },
+ combinations: Array<
+ {endianness: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness, signed: boolean}>) {
+ const expectedIntValue = 20;
+ const expectedFloatValue = -234.03;
+ for (let i = 0; i < combinations.length; ++i) {
+ const {endianness, signed} = combinations[i];
+ let expectedValue;
+ const isLittleEndian =
+ endianness === LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Little;
+ const view = new DataView(baseData.buffer);
+ switch (baseData.type) {
+ case LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int8:
+ expectedValue = signed ? -expectedIntValue : expectedIntValue;
+ signed ? view.setInt8(0, expectedValue) : view.setInt8(0, expectedValue);
+ break;
+ case LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int16:
+ expectedValue = signed ? -expectedIntValue : expectedIntValue;
+ signed ? view.setInt16(0, expectedValue, isLittleEndian) : view.setUint16(0, expectedValue, isLittleEndian);
+ break;
+ case LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int32:
+ expectedValue = signed ? -expectedIntValue : expectedIntValue;
+ signed ? view.setInt32(0, expectedValue, isLittleEndian) : view.setUint32(0, expectedValue, isLittleEndian);
+ break;
+ case LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int64:
+ expectedValue = signed ? -expectedIntValue : expectedIntValue;
+ signed ? view.setBigInt64(0, BigInt(expectedValue), isLittleEndian) :
+ view.setBigUint64(0, BigInt(expectedValue), isLittleEndian);
+ break;
+ case LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Float32:
+ expectedValue = expectedFloatValue;
+ view.setFloat32(0, expectedValue, isLittleEndian);
+ break;
+ case LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Float64:
+ expectedValue = expectedFloatValue;
+ view.setFloat64(0, expectedValue, isLittleEndian);
+ break;
+ case LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Pointer32:
+ expectedValue = '0x' + expectedIntValue.toString(16);
+ view.setInt32(0, expectedIntValue, isLittleEndian);
+ break;
+ case LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Pointer64:
+ expectedValue = '0x' + expectedIntValue.toString(16);
+ view.setBigUint64(0, BigInt(expectedIntValue), isLittleEndian);
+ break;
+ default:
+ throw new Error(`Unknown type ${baseData.type}`);
+ }
+ const actualValue =
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.format({...baseData, ...combinations[i]});
+ assert.strictEqual(actualValue, expectedValue.toString());
+ }
+ }
+
+ it('correctly formats signed/unsigned and endianness for Integer 8-bit (decimal)', () => {
+ const formatData = {
+ buffer: new ArrayBuffer(1),
+ type: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int8,
+ mode: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Decimal,
+ };
+ testNumberFormatCombinations(formatData, combinationsForNumbers);
+ });
+
+ it('correctly formats signed/unsigned and endianness for Integer 16-bit (decimal)', () => {
+ const formatData = {
+ buffer: new ArrayBuffer(2),
+ type: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int16,
+ mode: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Decimal,
+ };
+
+ testNumberFormatCombinations(formatData, combinationsForNumbers);
+ });
+
+ it('correctly formats signed/unsigned and endianness for Integer 32-bit (decimal)', () => {
+ const formatData = {
+ buffer: new ArrayBuffer(4),
+ type: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int32,
+ mode: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Decimal,
+ };
+
+ testNumberFormatCombinations(formatData, combinationsForNumbers);
+ });
+
+ it('correctly formats signed/unsigned and endianness for Integer 64-bit (decimal)', () => {
+ const formatData = {
+ buffer: new ArrayBuffer(8),
+ type: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int64,
+ mode: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Decimal,
+ };
+
+ testNumberFormatCombinations(formatData, combinationsForNumbers);
+ });
+
+ it('correctly formats endianness for Float 32-bit (decimal)', () => {
+ const formatData = {
+ buffer: new ArrayBuffer(4),
+ type: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Float32,
+ mode: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Decimal,
+ };
+
+ testNumberFormatCombinations(formatData, combinationsForNumbers);
+ });
+
+ it('correctly formats endianness for Float 64-bit (decimal)', () => {
+ const formatData = {
+ buffer: new ArrayBuffer(8),
+ type: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Float64,
+ mode: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Decimal,
+ };
+
+ testNumberFormatCombinations(formatData, combinationsForNumbers);
+ });
+
+ it('correctly formats endianness for Pointer 32-bit', () => {
+ const formatData = {
+ buffer: new ArrayBuffer(4),
+ type: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Pointer32,
+ mode: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Hexadecimal,
+ };
+
+ testNumberFormatCombinations(formatData, combinationsForNumbers);
+ });
+
+ it('correctly formats endianness for Pointer 64-bit', () => {
+ const formatData = {
+ buffer: new ArrayBuffer(8),
+ type: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Pointer64,
+ mode: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Hexadecimal,
+ };
+
+ testNumberFormatCombinations(formatData, combinationsForNumbers);
+ });
+
+ it('correctly formats floats in decimal mode', () => {
+ const expectedFloat = 341.34;
+ const actualValue = LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.formatFloat(
+ expectedFloat, LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Decimal);
+ assert.strictEqual(actualValue, '341.34');
+ });
+
+ it('correctly formats floats in scientific mode', () => {
+ const expectedFloat = 341.34;
+ const actualValue = LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.formatFloat(
+ expectedFloat, LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Scientific);
+ assert.strictEqual(actualValue, '3.41e+2');
+ });
+
+ it('correctly formats integers in decimal mode', () => {
+ const expectedInteger = 120;
+ const actualValue = LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.formatInteger(
+ expectedInteger, LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Decimal);
+ assert.strictEqual(actualValue, '120');
+ });
+
+ it('correctly formats integers in hexadecimal mode', () => {
+ const expectedInteger = 16;
+ const actualValue = LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.formatInteger(
+ expectedInteger, LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Hexadecimal);
+ assert.strictEqual(actualValue, '0x10');
+ });
+
+ it('returns N/A for negative hex numbers', () => {
+ const negativeInteger = -16;
+ const actualValue = LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.formatInteger(
+ negativeInteger, LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Hexadecimal);
+ assert.strictEqual(actualValue, 'N/A');
+ });
+
+ it('correctly formats integers in octal mode', () => {
+ const expectedInteger = 16;
+ const actualValue = LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.formatInteger(
+ expectedInteger, LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Octal);
+ assert.strictEqual(actualValue, '20');
+ });
+
+ it('returns N/A for negative octal numbers', () => {
+ const expectedInteger = -16;
+ const actualValue = LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.formatInteger(
+ expectedInteger, LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Octal);
+ assert.strictEqual(actualValue, 'N/A');
+ });
+
+ it('renders pointer values in LinearMemoryInspector.ValueInterpreterDisplayUtils.ValueTypes', () => {
+ const component = new LinearMemoryInspectorComponents.ValueInterpreterDisplay.ValueInterpreterDisplay();
+ const array = [1, 132, 172, 71, 43, 12, 12, 66];
+ component.data = {
+ buffer: new Uint8Array(array).buffer,
+ endianness: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Little,
+ valueTypes: new Set([
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Pointer32,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Pointer64,
+ ]),
+ memoryLength: array.length,
+ };
+ renderElementIntoDOM(component);
+
+ const dataValues = getElementsWithinComponent(component, '[data-value]', HTMLDivElement);
+ assert.lengthOf(dataValues, 2);
+
+ const actualValues = Array.from(dataValues).map(x => x.innerText);
+ const expectedValues = ['0x47AC8401', '0x420C0C2B47AC8401'];
+ assert.deepStrictEqual(actualValues, expectedValues);
+ });
+
+ it('renders value in selected LinearMemoryInspector.ValueInterpreterDisplayUtils.ValueTypes', () => {
+ const component = new LinearMemoryInspectorComponents.ValueInterpreterDisplay.ValueInterpreterDisplay();
+ const array = [1, 132, 172, 71];
+ component.data = {
+ buffer: new Uint8Array(array).buffer,
+ endianness: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Little,
+ valueTypes: new Set([
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int16,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Float32,
+ ]),
+ memoryLength: array.length,
+ };
+ renderElementIntoDOM(component);
+
+ const dataValues = getElementsWithinComponent(component, '[data-value]', HTMLSpanElement);
+ assert.lengthOf(dataValues, 3);
+
+ const actualValues = Array.from(dataValues).map(x => x.innerText);
+ const expectedValues = ['33793', '-31743', '88328.01'];
+ assert.deepStrictEqual(actualValues, expectedValues);
+ });
+
+ it('renders only unsigned values for Octal and Hexadecimal representation', () => {
+ const component = new LinearMemoryInspectorComponents.ValueInterpreterDisplay.ValueInterpreterDisplay();
+ const array = [0xC8, 0xC9, 0xCA, 0XCB];
+ component.data = {
+ buffer: new Uint8Array(array).buffer,
+ endianness: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Little,
+ valueTypes: new Set([
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int8,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int16,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int32,
+ ]),
+ valueTypeModes: new Map([
+ [
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int8,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Octal,
+ ],
+ [
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int16,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Hexadecimal,
+ ],
+ [
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int32,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Decimal,
+ ],
+ ]),
+ memoryLength: array.length,
+ };
+ renderElementIntoDOM(component);
+
+ const dataValues = getElementsWithinComponent(component, '[data-value]', HTMLSpanElement);
+ assert.lengthOf(dataValues, 4);
+
+ const actualValues = Array.from(dataValues).map(x => x.innerText);
+ const expectedValues = ['310', '0xC9C8', '3419064776', '-875902520'];
+ assert.deepStrictEqual(actualValues, expectedValues);
+ });
+
+ it('triggers a value changed event on selecting a new mode', async () => {
+ const component = new LinearMemoryInspectorComponents.ValueInterpreterDisplay.ValueInterpreterDisplay();
+ const array = [1, 132, 172, 71];
+ const oldMode = LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Decimal;
+ const newMode = LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueTypeMode.Scientific;
+
+ const mapping = LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.getDefaultValueTypeMapping();
+ mapping.set(LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Float32, oldMode);
+
+ component.data = {
+ buffer: new Uint8Array(array).buffer,
+ endianness: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Little,
+ valueTypes: new Set([
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Float32,
+ ]),
+ valueTypeModes: mapping,
+ memoryLength: array.length,
+ };
+
+ const input = getElementWithinComponent(component, '[data-mode-settings]', HTMLSelectElement);
+ assert.strictEqual(input.value, oldMode);
+ input.value = newMode;
+ const eventPromise =
+ getEventPromise<LinearMemoryInspectorComponents.ValueInterpreterDisplay.ValueTypeModeChangedEvent>(
+ component, 'valuetypemodechanged');
+ const changeEvent = new Event('change');
+ input.dispatchEvent(changeEvent);
+ const event = await eventPromise;
+ assert.deepEqual(
+ event.data,
+ {type: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Float32, mode: newMode});
+ });
+
+ it('triggers an event on jumping to an address from a 32-bit pointer', async () => {
+ const component = new LinearMemoryInspectorComponents.ValueInterpreterDisplay.ValueInterpreterDisplay();
+ const array = [1, 0, 0, 0];
+ component.data = {
+ buffer: new Uint8Array(array).buffer,
+ endianness: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Little,
+ valueTypes: new Set([
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Pointer32,
+ ]),
+ memoryLength: array.length,
+ };
+ renderElementIntoDOM(component);
+
+ const button = getElementWithinComponent(component, DISPLAY_JUMP_TO_POINTER_BUTTON_SELECTOR, HTMLButtonElement);
+ const eventPromise =
+ getEventPromise<LinearMemoryInspectorComponents.ValueInterpreterDisplay.JumpToPointerAddressEvent>(
+ component, 'jumptopointeraddress');
+ dispatchClickEvent(button);
+ const event = await eventPromise;
+ assert.deepEqual(event.data, 1);
+ });
+
+ it('triggers an event on jumping to an address from a 64-bit pointer', async () => {
+ const component = new LinearMemoryInspectorComponents.ValueInterpreterDisplay.ValueInterpreterDisplay();
+ const array = [1, 0, 0, 0, 0, 0, 0, 0];
+ component.data = {
+ buffer: new Uint8Array(array).buffer,
+ endianness: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Little,
+ valueTypes: new Set([
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Pointer64,
+ ]),
+ memoryLength: array.length,
+ };
+ renderElementIntoDOM(component);
+
+ const button = getElementWithinComponent(component, DISPLAY_JUMP_TO_POINTER_BUTTON_SELECTOR, HTMLButtonElement);
+ const eventPromise =
+ getEventPromise<LinearMemoryInspectorComponents.ValueInterpreterDisplay.JumpToPointerAddressEvent>(
+ component, 'jumptopointeraddress');
+ dispatchClickEvent(button);
+ const event = await eventPromise;
+ assert.deepEqual(event.data, 1);
+ });
+
+ it('renders a disabled jump-to-address button if address is invalid', () => {
+ const component = new LinearMemoryInspectorComponents.ValueInterpreterDisplay.ValueInterpreterDisplay();
+ const array = [8, 0, 0, 0, 0, 0, 0, 0];
+ component.data = {
+ buffer: new Uint8Array(array).buffer,
+ endianness: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Little,
+ valueTypes: new Set([
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Pointer32,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Pointer64,
+ ]),
+ memoryLength: array.length,
+ };
+ renderElementIntoDOM(component);
+
+ const buttons = getElementsWithinComponent(component, DISPLAY_JUMP_TO_POINTER_BUTTON_SELECTOR, HTMLButtonElement);
+ assert.lengthOf(buttons, 2);
+ assert.isTrue(buttons[0].disabled);
+ assert.isTrue(buttons[1].disabled);
+ });
+
+ it('selects text in data-value elements if user selects it', () => {
+ // To test the failing case, set .value-type user-select to `none`.
+ // This is necessary as we render the component in isolation, so it doesn't
+ // inherit this property from its parent.
+
+ const component = new LinearMemoryInspectorComponents.ValueInterpreterDisplay.ValueInterpreterDisplay();
+ const array = [1, 132, 172, 71];
+ component.data = {
+ buffer: new Uint8Array(array).buffer,
+ endianness: LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.Endianness.Little,
+ valueTypes: new Set([
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int8,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int16,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Float32,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Pointer32,
+ ]),
+ memoryLength: array.length,
+ };
+ renderElementIntoDOM(component);
+
+ const dataValues = getElementsWithinComponent(component, '.selectable-text', HTMLSpanElement);
+ assert.lengthOf(dataValues, 9);
+
+ const expectedValues = [
+ 'Integer 8-bit',
+ '1',
+ 'Integer 16-bit',
+ '33793',
+ '-31743',
+ 'Float 32-bit',
+ '88328.01',
+ 'Pointer 32-bit',
+ '0x47AC8401',
+ ];
+
+ // Workaround for selecting text (instead of double-clicking it).
+ // We can use a range to specify an element. Range can be converted into
+ // a selection. We then check if the selected text meets our expectations.
+
+ // Continuous part of a document, independent of any visual representation.
+ const range = document.createRange();
+ // Represents user's highlighted text.
+ const selection = document.getSelection();
+
+ for (let i = 0; i < dataValues.length; ++i) {
+ if (selection === null) {
+ assert.fail('Selection is null');
+ }
+ // Set range around the element.
+ range.selectNodeContents(dataValues[i]);
+ // Remove ranges associated with selection.
+ selection?.removeAllRanges();
+ // Select element using range.
+ selection?.addRange(range);
+
+ const text = window.getSelection()?.toString();
+ assert.strictEqual(text, expectedValues[i]);
+ }
+ });
+});
diff --git a/front_end/panels/linear_memory_inspector/components/ValueInterpreterSettings.test.ts b/front_end/panels/linear_memory_inspector/components/ValueInterpreterSettings.test.ts
new file mode 100644
index 0000000..32d621e
--- /dev/null
+++ b/front_end/panels/linear_memory_inspector/components/ValueInterpreterSettings.test.ts
@@ -0,0 +1,103 @@
+// Copyright 2020 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 {
+ assertElement,
+ getElementsWithinComponent,
+ getEventPromise,
+ renderElementIntoDOM,
+} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {describeWithLocale} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+
+import * as LinearMemoryInspectorComponents from './components.js';
+
+const {assert} = chai;
+
+const SETTINGS_INPUT_SELECTOR = '[data-input]';
+const SETTINGS_TITLE_SELECTOR = '[data-title]';
+const SETTINGS_LABEL_SELECTOR = '.type-label';
+
+describeWithLocale('ValueInterpreterSettings', () => {
+ function setUpComponent() {
+ const component = new LinearMemoryInspectorComponents.ValueInterpreterSettings.ValueInterpreterSettings();
+ const data = {
+ valueTypes: new Set([
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int8,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Float64,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Pointer32,
+ ]),
+ };
+ component.data = data;
+ renderElementIntoDOM(component);
+ return {component, data};
+ }
+
+ it('renders all checkboxes', () => {
+ const {component} = setUpComponent();
+ const checkboxes = getElementsWithinComponent(component, SETTINGS_LABEL_SELECTOR, HTMLLabelElement);
+ const checkboxLabels = Array.from(checkboxes, checkbox => checkbox.getAttribute('title'));
+ assert.deepEqual(checkboxLabels, [
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int8,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int16,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int32,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int64,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Float32,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Float64,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Pointer32,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Pointer64,
+ ]);
+ });
+
+ it('triggers an event on checkbox click', async () => {
+ const {component} = setUpComponent();
+ const labels = getElementsWithinComponent(component, SETTINGS_LABEL_SELECTOR, HTMLLabelElement);
+
+ for (const label of labels) {
+ const checkbox = label.querySelector(SETTINGS_INPUT_SELECTOR);
+ assertElement(checkbox, HTMLInputElement);
+ const title = label.querySelector(SETTINGS_TITLE_SELECTOR);
+ assertElement(title, HTMLSpanElement);
+
+ const checked = checkbox.checked;
+
+ const eventPromise = getEventPromise<LinearMemoryInspectorComponents.ValueInterpreterSettings.TypeToggleEvent>(
+ component, 'typetoggle');
+ checkbox.click();
+ const event = await eventPromise;
+
+ assert.strictEqual(`${event.data.type}`, title.innerText);
+ assert.strictEqual(checkbox.checked, !checked);
+ }
+ });
+
+ it('correctly shows checkboxes as checked/unchecked', () => {
+ const {component, data} = setUpComponent();
+ const labels = getElementsWithinComponent(component, SETTINGS_LABEL_SELECTOR, HTMLLabelElement);
+ const elements = Array.from(labels).map(label => {
+ const checkbox = label.querySelector<HTMLInputElement>(SETTINGS_INPUT_SELECTOR);
+ const title = label.querySelector<HTMLSpanElement>(SETTINGS_TITLE_SELECTOR);
+ assertElement(checkbox, HTMLInputElement);
+ assertElement(title, HTMLSpanElement);
+ return {title, checked: checkbox.checked};
+ });
+ assert.isAtLeast(data.valueTypes.size, 1);
+ const checkedTitles = new Set(elements.filter(n => n.checked).map(n => n.title.innerText));
+ const expectedTitles = new Set([...data.valueTypes].map(type => `${type}`));
+ assert.deepEqual(checkedTitles, expectedTitles);
+
+ const uncheckedTitles = new Set(elements.filter(n => !n.checked).map(n => n.title.innerText));
+ const allTypesTitle = [
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int8,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int16,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int32,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Int64,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Float32,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Float64,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Pointer32,
+ LinearMemoryInspectorComponents.ValueInterpreterDisplayUtils.ValueType.Pointer64,
+ ];
+ const expectedUncheckedTitles = new Set(allTypesTitle.filter(title => !expectedTitles.has(title)));
+ assert.deepEqual(uncheckedTitles, expectedUncheckedTitles);
+ });
+});
diff --git a/front_end/panels/media/BUILD.gn b/front_end/panels/media/BUILD.gn
index 684916b..76b5602 100644
--- a/front_end/panels/media/BUILD.gn
+++ b/front_end/panels/media/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../scripts/build/ninja/devtools_module.gni")
import("../../../scripts/build/ninja/generate_css.gni")
+import("../../../third_party/typescript/typescript.gni")
import("../visibility.gni")
generate_css("css_files") {
@@ -73,3 +74,18 @@
visibility = [ "../../entrypoints/*" ]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [
+ "MainView.test.ts",
+ "TickingFlameChartHelpers.test.ts",
+ ]
+
+ deps = [
+ ":bundle",
+ "../../../test/unittests/front_end/helpers",
+ "../../core/sdk:bundle",
+ ]
+}
diff --git a/front_end/panels/media/MainView.test.ts b/front_end/panels/media/MainView.test.ts
new file mode 100644
index 0000000..42abdfa
--- /dev/null
+++ b/front_end/panels/media/MainView.test.ts
@@ -0,0 +1,63 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {createTarget} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import type * as Common from '../../core/common/common.js';
+import type * as Platform from '../../core/platform/platform.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import type * as Protocol from '../../generated/protocol.js';
+import * as Coordinator from '../../ui/components/render_coordinator/render_coordinator.js';
+
+import * as Media from './media.js';
+
+const {assert} = chai;
+
+const PLAYER_ID = 'PLAYER_ID' as Protocol.Media.PlayerId;
+
+describeWithMockConnection('MediaMainView', () => {
+ let target: SDK.Target.Target;
+
+ beforeEach(() => {
+ target = createTarget();
+ });
+
+ const testUiUpdate = <T extends keyof Media.MediaModel.EventTypes>(
+ event: Platform.TypeScriptUtilities.NoUnion<T>, expectedMethod: keyof Media.MainView.PlayerDataDownloadManager,
+ inScope: boolean) => async () => {
+ SDK.TargetManager.TargetManager.instance().setScopeTarget(inScope ? target : null);
+ const downloadStore = new Media.MainView.PlayerDataDownloadManager();
+ const expectedCall = sinon.stub(downloadStore, expectedMethod).returns();
+ const mainView = new Media.MainView.MainView(downloadStore);
+ mainView.markAsRoot();
+ mainView.show(document.body);
+ const model = target.model(Media.MediaModel.MediaModel);
+ assertNotNullOrUndefined(model);
+ model.dispatchEventToListeners(Media.MediaModel.Events.PlayersCreated, [PLAYER_ID]);
+ const field = [{name: 'kResolution', value: '{}', data: {}, stack: [], cause: []}];
+ const data = {playerId: PLAYER_ID, properties: field, events: field, messages: field, errors: field};
+ model.dispatchEventToListeners(
+ event, ...[data] as unknown as Common.EventTarget.EventPayloadToRestParameters<Media.MediaModel.EventTypes, T>);
+ await new Promise(resolve => setTimeout(resolve, 0));
+ assert.strictEqual(expectedCall.called, inScope);
+ await Coordinator.RenderCoordinator.RenderCoordinator.instance().done();
+ mainView.detach();
+ };
+
+ it('reacts to properties on in scope event',
+ testUiUpdate(Media.MediaModel.Events.PlayerPropertiesChanged, 'onProperty', true));
+ it('does not react to properties on out of scope event',
+ testUiUpdate(Media.MediaModel.Events.PlayerPropertiesChanged, 'onProperty', false));
+ it('reacts to event on in scope event', testUiUpdate(Media.MediaModel.Events.PlayerEventsAdded, 'onEvent', true));
+ it('does not react to event on out of scope event',
+ testUiUpdate(Media.MediaModel.Events.PlayerEventsAdded, 'onEvent', false));
+ it('reacts to messages on in scope event',
+ testUiUpdate(Media.MediaModel.Events.PlayerMessagesLogged, 'onMessage', true));
+ it('does not react to messages on out of scope event',
+ testUiUpdate(Media.MediaModel.Events.PlayerMessagesLogged, 'onMessage', false));
+ it('reacts to error on in scope event', testUiUpdate(Media.MediaModel.Events.PlayerErrorsRaised, 'onError', true));
+ it('does not react to error on out of scope event',
+ testUiUpdate(Media.MediaModel.Events.PlayerErrorsRaised, 'onError', false));
+});
diff --git a/front_end/panels/media/TickingFlameChartHelpers.test.ts b/front_end/panels/media/TickingFlameChartHelpers.test.ts
new file mode 100644
index 0000000..cb32754
--- /dev/null
+++ b/front_end/panels/media/TickingFlameChartHelpers.test.ts
@@ -0,0 +1,92 @@
+// Copyright 2020 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 Media from './media.js';
+
+const {assert} = chai;
+
+function assertParameters(
+ bounds: Media.TickingFlameChartHelpers.Bounds, low: number, high: number, min: number, max: number, range: number) {
+ assert.closeTo(bounds.low, low, 0.01);
+ assert.closeTo(bounds.high, high, 0.01);
+ assert.closeTo(bounds.min, min, 0.01);
+ assert.closeTo(bounds.max, max, 0.01);
+ assert.closeTo(bounds.range, range, 0.01);
+}
+
+describe('TickingFlameChartTests', () => {
+ it('checks that the formatter works correctly', () => {
+ assert.strictEqual(Media.TickingFlameChartHelpers.formatMillisecondsToSeconds(901, 0), '1 s');
+ assert.strictEqual(Media.TickingFlameChartHelpers.formatMillisecondsToSeconds(901, 1), '0.9 s');
+ assert.strictEqual(Media.TickingFlameChartHelpers.formatMillisecondsToSeconds(901, 2), '0.9 s');
+ assert.strictEqual(Media.TickingFlameChartHelpers.formatMillisecondsToSeconds(901, 3), '0.901 s');
+
+ assert.strictEqual(Media.TickingFlameChartHelpers.formatMillisecondsToSeconds(89129, 2), '89.13 s');
+ });
+
+ it('checks that the bounds are correct', () => {
+ const bounds = new Media.TickingFlameChartHelpers.Bounds(0, 100, 1000, 100);
+ assertParameters(bounds, 0, 100, 0, 100, 100);
+ });
+
+ it('checks zoom toggle works correctly', () => {
+ const bounds = new Media.TickingFlameChartHelpers.Bounds(0, 1000, 1000, 100);
+ bounds.zoomOut(1, 0.5); // does nothing, because it hasn't been zoomed yet.
+ assertParameters(bounds, 0, 1000, 0, 1000, 1000);
+
+ bounds.zoomIn(1, 0.5); // zooms in 1 tick right in the middle
+ assertParameters(bounds, 45.45, 954.54, 0, 1000, 909.09);
+
+ bounds.zoomOut(1, 0.5); // zooms out 1 tick right in the middle
+ assertParameters(bounds, 0, 1000, 0, 1000, 1000);
+ });
+
+ it('checks zoom different locations works correctly', () => {
+ const bounds = new Media.TickingFlameChartHelpers.Bounds(0, 1000, 1000, 100);
+ bounds.zoomOut(1, 0.5); // does nothing, because its already at max.
+ assertParameters(bounds, 0, 1000, 0, 1000, 1000);
+
+ bounds.zoomIn(1, 0.5); // zooms in 1 tick right in the middle
+ assertParameters(bounds, 45.45, 954.54, 0, 1000, 909.09);
+
+ bounds.zoomOut(1, 0); // zooms out 1 tick on the left edge
+ assertParameters(bounds, 45.45, 1000, 0, 1000, 954.54);
+ });
+
+ it('checks adding to the bounds range', () => {
+ const bounds = new Media.TickingFlameChartHelpers.Bounds(0, 1000, 1000, 100);
+
+ bounds.addMax(10); // Should push up the max because we're zoomed out.
+ assertParameters(bounds, 0, 1010, 0, 1010, 1010);
+
+ bounds.zoomIn(1, 0); // zoom in at the beginning to move away from the live edge
+ assertParameters(bounds, 0, 918.18, 0, 1010, 918.18);
+
+ bounds.addMax(10); // adding to max should not move the view window, only the max size.
+ assertParameters(bounds, 0, 918.18, 0, 1020, 918.18);
+
+ bounds.zoomOut(1, 0);
+ bounds.zoomOut(1, 0); // extra zoom to make sure it's reset.
+ assertParameters(bounds, 0, 1020, 0, 1020, 1020);
+
+ bounds.zoomIn(1, 1); // zoom in on the leading edge now
+ assertParameters(bounds, 92.72, 1020, 0, 1020, 927.28);
+
+ bounds.addMax(10); // it won't scroll because the viewport size is less than the max scroll size
+ assertParameters(bounds, 92.72, 1020, 0, 1030, 927.28);
+
+ bounds.zoomOut(1, 1);
+ bounds.zoomOut(1, 0.5); // extra zoom to make sure it's reset.
+ assertParameters(bounds, 0, 1030, 0, 1030, 1030);
+
+ bounds.addMax(2000); // push bounds way up, so zoom won't push us below the threshold.
+ assertParameters(bounds, 0, 3030, 0, 3030, 3030);
+
+ bounds.zoomIn(1, 1); // zoom in on the leading edge now
+ assertParameters(bounds, 275.45, 3030, 0, 3030, 2754.55);
+
+ bounds.addMax(10); // the viewport range should change now.
+ assertParameters(bounds, 275.45, 3040, 0, 3040, 2764.55);
+ });
+});
diff --git a/front_end/panels/mobile_throttling/BUILD.gn b/front_end/panels/mobile_throttling/BUILD.gn
index 7442c69..69946ac 100644
--- a/front_end/panels/mobile_throttling/BUILD.gn
+++ b/front_end/panels/mobile_throttling/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../scripts/build/ninja/devtools_module.gni")
import("../../../scripts/build/ninja/generate_css.gni")
+import("../../../third_party/typescript/typescript.gni")
import("../visibility.gni")
generate_css("css_files") {
@@ -66,3 +67,15 @@
visibility = [ "../../entrypoints/*" ]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [ "ThrottlingManager.test.ts" ]
+
+ deps = [
+ ":bundle",
+ "../../../test/unittests/front_end/helpers",
+ "../../core/sdk:bundle",
+ ]
+}
diff --git a/front_end/panels/mobile_throttling/ThrottlingManager.test.ts b/front_end/panels/mobile_throttling/ThrottlingManager.test.ts
new file mode 100644
index 0000000..c73c6f9
--- /dev/null
+++ b/front_end/panels/mobile_throttling/ThrottlingManager.test.ts
@@ -0,0 +1,65 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as SDK from '../../core/sdk/sdk.js';
+import * as MobileThrottling from './mobile_throttling.js';
+import {dispatchClickEvent} from '../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {describeWithEnvironment} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+
+const {assert} = chai;
+
+describeWithEnvironment('ThrottlingManager', () => {
+ describe('OfflineToolbarCheckbox', () => {
+ it('has initial checked state which depends on throttling setting', () => {
+ const throttlingManager = MobileThrottling.ThrottlingManager.throttlingManager();
+
+ SDK.NetworkManager.MultitargetNetworkManager.instance().setNetworkConditions(
+ SDK.NetworkManager.OfflineConditions);
+ let checkbox = throttlingManager.createOfflineToolbarCheckbox();
+ assert.isTrue(checkbox.checked());
+
+ SDK.NetworkManager.MultitargetNetworkManager.instance().setNetworkConditions(SDK.NetworkManager.Fast3GConditions);
+ checkbox = throttlingManager.createOfflineToolbarCheckbox();
+ assert.isFalse(checkbox.checked());
+ });
+
+ it('listens to changes in throttling setting', () => {
+ const throttlingManager = MobileThrottling.ThrottlingManager.throttlingManager();
+ const checkbox = throttlingManager.createOfflineToolbarCheckbox();
+ assert.isFalse(checkbox.checked());
+
+ SDK.NetworkManager.MultitargetNetworkManager.instance().setNetworkConditions(
+ SDK.NetworkManager.OfflineConditions);
+ assert.isTrue(checkbox.checked());
+
+ SDK.NetworkManager.MultitargetNetworkManager.instance().setNetworkConditions(
+ SDK.NetworkManager.NoThrottlingConditions);
+ assert.isFalse(checkbox.checked());
+ });
+
+ it('updates setting when checkbox is clicked on', () => {
+ const throttlingManager = MobileThrottling.ThrottlingManager.throttlingManager();
+ const multiTargetNetworkManager = SDK.NetworkManager.MultitargetNetworkManager.instance();
+
+ multiTargetNetworkManager.setNetworkConditions(SDK.NetworkManager.OfflineConditions);
+ const checkbox = throttlingManager.createOfflineToolbarCheckbox();
+ assert.isTrue(checkbox.checked());
+
+ dispatchClickEvent(checkbox.inputElement);
+ assert.isFalse(checkbox.checked());
+ assert.strictEqual(SDK.NetworkManager.NoThrottlingConditions, multiTargetNetworkManager.networkConditions());
+
+ multiTargetNetworkManager.setNetworkConditions(SDK.NetworkManager.Slow3GConditions);
+ assert.isFalse(checkbox.checked());
+
+ dispatchClickEvent(checkbox.inputElement);
+ assert.isTrue(checkbox.checked());
+ assert.strictEqual(SDK.NetworkManager.OfflineConditions, multiTargetNetworkManager.networkConditions());
+
+ dispatchClickEvent(checkbox.inputElement);
+ assert.isFalse(checkbox.checked());
+ assert.strictEqual(SDK.NetworkManager.Slow3GConditions, multiTargetNetworkManager.networkConditions());
+ });
+ });
+});
diff --git a/front_end/panels/network/BUILD.gn b/front_end/panels/network/BUILD.gn
index e9ce592..5a9976e 100644
--- a/front_end/panels/network/BUILD.gn
+++ b/front_end/panels/network/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../scripts/build/ninja/devtools_module.gni")
import("../../../scripts/build/ninja/generate_css.gni")
+import("../../../third_party/typescript/typescript.gni")
import("../visibility.gni")
generate_css("css_files") {
@@ -108,7 +109,6 @@
visibility = [
":*",
"../../../test/unittests/front_end/entrypoints/missing_entrypoints/*",
- "../../../test/unittests/front_end/panels/network/*",
"../../entrypoints/*",
"../../ui/components/request_link_icon/*",
"../application/*",
@@ -131,7 +131,38 @@
]
visibility = [
- "../../../test/unittests/front_end/panels/network/*",
+ ":unittests",
"../../entrypoints/*",
]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [
+ "BlockedURLsPane.test.ts",
+ "NetworkDataGridNode.test.ts",
+ "NetworkItemView.test.ts",
+ "NetworkLogView.test.ts",
+ "NetworkOverview.test.ts",
+ "NetworkPanel.test.ts",
+ "NetworkSearchScope.test.ts",
+ "RequestCookiesView.test.ts",
+ "RequestPayloadView.test.ts",
+ "RequestPreviewView.test.ts",
+ "RequestResponseView.test.ts",
+ ]
+
+ deps = [
+ ":bundle",
+ ":meta",
+ "../../../test/unittests/front_end/helpers",
+ "../../core/platform:bundle",
+ "../../core/sdk:bundle",
+ "../../generated:protocol",
+ "../../models/text_utils:bundle",
+ "../../ui/legacy:bundle",
+ "../../ui/legacy/components/source_frame:bundle",
+ "./components:bundle",
+ ]
+}
diff --git a/front_end/panels/network/BlockedURLsPane.test.ts b/front_end/panels/network/BlockedURLsPane.test.ts
new file mode 100644
index 0000000..be86ea9
--- /dev/null
+++ b/front_end/panels/network/BlockedURLsPane.test.ts
@@ -0,0 +1,53 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {createTarget, registerNoopActions} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import type * as Platform from '../../core/platform/platform.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import * as Logs from '../../models/logs/logs.js';
+
+import * as Network from './network.js';
+
+describeWithMockConnection('BlockedURLsPane', () => {
+ beforeEach(() => {
+ registerNoopActions([
+ 'network.add-network-request-blocking-pattern',
+ 'network.remove-all-network-request-blocking-patterns',
+ ]);
+ });
+
+ describe('update', () => {
+ const updatesOnRequestFinishedEvent = (inScope: boolean) => () => {
+ const target = createTarget();
+ const blockedURLsPane = new Network.BlockedURLsPane.BlockedURLsPane();
+ SDK.TargetManager.TargetManager.instance().setScopeTarget(inScope ? target : null);
+ const networkManager = target.model(SDK.NetworkManager.NetworkManager);
+ assertNotNullOrUndefined(networkManager);
+ const updateStub = sinon.stub(blockedURLsPane, 'update');
+
+ const request = sinon.createStubInstance(SDK.NetworkRequest.NetworkRequest, {
+ wasBlocked: true,
+ url: 'http://example.com' as Platform.DevToolsPath.UrlString,
+ });
+ networkManager.dispatchEventToListeners(SDK.NetworkManager.Events.RequestFinished, request);
+
+ assert.strictEqual(updateStub.calledOnce, inScope);
+ };
+
+ it('is called upon RequestFinished event (when target is in scope)', updatesOnRequestFinishedEvent(true));
+ it('is called upon RequestFinished event (when target is out of scope)', updatesOnRequestFinishedEvent(false));
+
+ it('is called upon Reset event', () => {
+ const blockedURLsPane = new Network.BlockedURLsPane.BlockedURLsPane();
+ const updateStub = sinon.stub(blockedURLsPane, 'update');
+
+ Logs.NetworkLog.NetworkLog.instance().dispatchEventToListeners(
+ Logs.NetworkLog.Events.Reset, {clearIfPreserved: true});
+
+ assert.isTrue(updateStub.calledOnce);
+ });
+ });
+});
diff --git a/front_end/panels/network/NetworkDataGridNode.test.ts b/front_end/panels/network/NetworkDataGridNode.test.ts
new file mode 100644
index 0000000..6741f53
--- /dev/null
+++ b/front_end/panels/network/NetworkDataGridNode.test.ts
@@ -0,0 +1,475 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assertElement} from '../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {describeWithEnvironment} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import * as Common from '../../core/common/common.js';
+import type * as Platform from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import * as Protocol from '../../generated/protocol.js';
+
+import * as Network from './network.js';
+
+describeWithEnvironment('NetworkLogView', () => {
+ it('adds marker to requests with overridden headers', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.statusCode = 200;
+
+ request.setWasIntercepted(true);
+ request.responseHeaders = [{name: 'foo', value: 'overridden'}];
+ request.originalResponseHeaders = [{name: 'foo', value: 'original'}];
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+ networkRequestNode.renderCell(el, 'name');
+ const marker = el.querySelector('.network-override-marker');
+ const tooltip = el.querySelector('[title="Request headers are overridden"]');
+ assertElement(marker, HTMLDivElement);
+ assert.isNotNull(tooltip);
+ });
+
+ it('adds marker to requests with overridden content', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.statusCode = 200;
+
+ request.setWasIntercepted(true);
+ request.hasOverriddenContent = true;
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+ networkRequestNode.renderCell(el, 'name');
+ const marker = el.querySelector('.network-override-marker');
+ const tooltip = el.querySelector('[title="Request content is overridden"]');
+ assertElement(marker, HTMLDivElement);
+ assert.isNotNull(tooltip);
+ });
+
+ it('adds marker to requests with overridden headers and content', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.statusCode = 200;
+
+ request.setWasIntercepted(true);
+ request.hasOverriddenContent = true;
+ request.responseHeaders = [{name: 'foo', value: 'overridden'}];
+ request.originalResponseHeaders = [{name: 'foo', value: 'original'}];
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+ networkRequestNode.renderCell(el, 'name');
+ const marker = el.querySelector('.network-override-marker');
+ const tooltip = el.querySelector('[title="Both request content and headers are overridden"]');
+ assertElement(marker, HTMLDivElement);
+ assert.isNotNull(tooltip);
+ });
+
+ it('does not add marker to unoverridden request', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.statusCode = 200;
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+ networkRequestNode.renderCell(el, 'name');
+ const marker = el.querySelector('.network-override-marker');
+ assert.isNull(marker);
+ });
+
+ it('does not add a marker to requests which are intercepted but not overridden', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.statusCode = 200;
+
+ request.setWasIntercepted(true);
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+ networkRequestNode.renderCell(el, 'name');
+ const marker = el.querySelector('.network-override-marker');
+ assert.isNull(marker);
+ });
+
+ it('adds an error red icon to the left of the failed requests', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.statusCode = 404;
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+
+ networkRequestNode.renderCell(el, 'name');
+ const iconElement = el.querySelector('.icon') as HTMLElement;
+
+ const iconStyle = iconElement.style;
+ const indexOfIconImage = iconStyle.webkitMaskImage.indexOf('Images/') + 7;
+ const iconImage = iconStyle.webkitMaskImage.substring(indexOfIconImage);
+
+ assert.strictEqual('cross-circle-filled.svg")', iconImage);
+
+ const backgroundColorOfIcon = iconStyle.backgroundColor.toString();
+ assert.strictEqual(backgroundColorOfIcon, 'var(--icon-error)');
+ });
+
+ it('show media icon', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'https://www.example.com/test.mp3' as Platform.DevToolsPath.UrlString, '' as Platform.DevToolsPath.UrlString,
+ null, null, null);
+ request.setResourceType(Common.ResourceType.resourceTypes.Media);
+ request.mimeType = 'audio/mpeg';
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+
+ networkRequestNode.renderCell(el, 'name');
+ const iconElement = el.querySelector('.icon') as HTMLElement;
+
+ const iconStyle = iconElement.style;
+ const indexOfIconImage = iconStyle.webkitMaskImage.indexOf('Images/') + 7;
+ const iconImage = iconStyle.webkitMaskImage.substring(indexOfIconImage);
+
+ assert.strictEqual('file-media.svg")', iconImage);
+ });
+
+ it('show wasm icon', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'https://www.example.com/test.wasm' as Platform.DevToolsPath.UrlString, '' as Platform.DevToolsPath.UrlString,
+ null, null, null);
+ request.setResourceType(Common.ResourceType.resourceTypes.Wasm);
+ request.mimeType = 'application/wasm';
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+
+ networkRequestNode.renderCell(el, 'name');
+ const iconElement = el.querySelector('.icon') as HTMLElement;
+
+ const iconStyle = iconElement.style;
+ const indexOfIconImage = iconStyle.webkitMaskImage.indexOf('Images/') + 7;
+ const iconImage = iconStyle.webkitMaskImage.substring(indexOfIconImage);
+
+ assert.strictEqual('file-wasm.svg")', iconImage);
+ });
+
+ it('show websocket icon', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com/ws' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.setResourceType(Common.ResourceType.resourceTypes.WebSocket);
+ request.mimeType = '';
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+
+ networkRequestNode.renderCell(el, 'name');
+ const iconElement = el.querySelector('.icon') as HTMLElement;
+
+ const iconStyle = iconElement.style;
+ const indexOfIconImage = iconStyle.webkitMaskImage.indexOf('Images/') + 7;
+ const iconImage = iconStyle.webkitMaskImage.substring(indexOfIconImage);
+
+ assert.strictEqual('file-websocket.svg")', iconImage);
+ });
+
+ it('shows fetch icon', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'https://www.example.com/test.json?keepalive=false' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.setResourceType(Common.ResourceType.resourceTypes.Fetch);
+ request.mimeType = '';
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+
+ networkRequestNode.renderCell(el, 'name');
+ const iconElement = el.querySelector('.icon') as HTMLElement;
+
+ const iconStyle = iconElement.style;
+ const indexOfIconImage = iconStyle.webkitMaskImage.indexOf('Images/') + 7;
+ const iconImage = iconStyle.webkitMaskImage.substring(indexOfIconImage);
+
+ assert.strictEqual('file-fetch-xhr.svg")', iconImage);
+ });
+
+ it('shows xhr icon', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'https://www.example.com/test.json?keepalive=false' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.setResourceType(Common.ResourceType.resourceTypes.XHR);
+ request.mimeType = 'application/octet-stream';
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+
+ networkRequestNode.renderCell(el, 'name');
+ const iconElement = el.querySelector('.icon') as HTMLElement;
+
+ const iconStyle = iconElement.style;
+ const indexOfIconImage = iconStyle.webkitMaskImage.indexOf('Images/') + 7;
+ const iconImage = iconStyle.webkitMaskImage.substring(indexOfIconImage);
+
+ assert.strictEqual('file-fetch-xhr.svg")', iconImage);
+ });
+
+ it('mime win: show image preview icon for xhr-image', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'https://www.example.com/test.svg' as Platform.DevToolsPath.UrlString, '' as Platform.DevToolsPath.UrlString,
+ null, null, null);
+ request.setResourceType(Common.ResourceType.resourceTypes.XHR);
+ request.mimeType = 'image/svg+xml';
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+
+ networkRequestNode.renderCell(el, 'name');
+ const iconElement = el.querySelector('.icon.image') as HTMLElement;
+ const imagePreview = el.querySelector('.image-network-icon-preview') as HTMLImageElement;
+
+ assert.isTrue(iconElement instanceof HTMLDivElement);
+ assert.isTrue(imagePreview instanceof HTMLImageElement);
+ });
+
+ it('mime win: show document icon for fetch-html', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com/page' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.setResourceType(Common.ResourceType.resourceTypes.Fetch);
+ request.mimeType = 'text/html';
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+
+ networkRequestNode.renderCell(el, 'name');
+ const iconElement = el.querySelector('.icon') as HTMLElement;
+
+ const iconStyle = iconElement.style;
+ const indexOfIconImage = iconStyle.webkitMaskImage.indexOf('Images/') + 7;
+ const iconImage = iconStyle.webkitMaskImage.substring(indexOfIconImage);
+
+ assert.strictEqual('file-document.svg")', iconImage);
+ });
+
+ it('mime win: show generic icon for preflight-text', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'https://www.example.com/api/test' as Platform.DevToolsPath.UrlString, '' as Platform.DevToolsPath.UrlString,
+ null, null, null);
+ request.setResourceType(Common.ResourceType.resourceTypes.Preflight);
+ request.mimeType = 'text/plain';
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+
+ networkRequestNode.renderCell(el, 'name');
+ const iconElement = el.querySelector('.icon') as HTMLElement;
+
+ const iconStyle = iconElement.style;
+ const indexOfIconImage = iconStyle.webkitMaskImage.indexOf('Images/') + 7;
+ const iconImage = iconStyle.webkitMaskImage.substring(indexOfIconImage);
+
+ assert.strictEqual('file-generic.svg")', iconImage);
+ });
+
+ it('mime win: show script icon for other-javascript)', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com/ping' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.setResourceType(Common.ResourceType.resourceTypes.Other);
+ request.mimeType = 'application/javascript';
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+
+ networkRequestNode.renderCell(el, 'name');
+ const iconElement = el.querySelector('.icon') as HTMLElement;
+
+ const iconStyle = iconElement.style;
+ const indexOfIconImage = iconStyle.webkitMaskImage.indexOf('Images/') + 7;
+ const iconImage = iconStyle.webkitMaskImage.substring(indexOfIconImage);
+
+ assert.strictEqual('file-script.svg")', iconImage);
+ });
+
+ it('mime win: shows json icon for fetch-json', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'https://www.example.com/api/list' as Platform.DevToolsPath.UrlString, '' as Platform.DevToolsPath.UrlString,
+ null, null, null);
+ request.setResourceType(Common.ResourceType.resourceTypes.Fetch);
+ request.mimeType = 'application/json';
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+
+ networkRequestNode.renderCell(el, 'name');
+ const iconElement = el.querySelector('.icon') as HTMLElement;
+
+ const iconStyle = iconElement.style;
+ const indexOfIconImage = iconStyle.webkitMaskImage.indexOf('Images/') + 7;
+ const iconImage = iconStyle.webkitMaskImage.substring(indexOfIconImage);
+
+ assert.strictEqual('file-json.svg")', iconImage);
+ });
+
+ it('shows the corresponding status text of a status code', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.statusCode = 305;
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+
+ networkRequestNode.renderCell(el, 'status');
+
+ assert.strictEqual(el.title, '305 Use Proxy');
+ });
+
+ it('populate has-overrides: headers', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.statusCode = 200;
+
+ request.setWasIntercepted(true);
+ request.responseHeaders = [{name: 'foo', value: 'overridden'}];
+ request.originalResponseHeaders = [{name: 'foo', value: 'original'}];
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+ networkRequestNode.renderCell(el, 'has-overrides');
+ const marker = el.innerText;
+ assert.strictEqual(marker, 'headers');
+ });
+
+ it('populate has-overrides: content', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.statusCode = 200;
+
+ request.setWasIntercepted(true);
+ request.hasOverriddenContent = true;
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+ networkRequestNode.renderCell(el, 'has-overrides');
+ const marker = el.innerText;
+ assert.strictEqual(marker, 'content');
+ });
+
+ it('populate has-overrides: content, headers', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.statusCode = 200;
+
+ request.setWasIntercepted(true);
+ request.hasOverriddenContent = true;
+ request.responseHeaders = [{name: 'foo', value: 'overridden'}];
+ request.originalResponseHeaders = [{name: 'foo', value: 'original'}];
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+ networkRequestNode.renderCell(el, 'has-overrides');
+ const marker = el.innerText;
+ assert.strictEqual(marker, 'content, headers');
+ });
+
+ it('populate has-overrides: null', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.statusCode = 200;
+
+ request.setWasIntercepted(false);
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+ networkRequestNode.renderCell(el, 'has-overrides');
+ const marker = el.innerText;
+ assert.strictEqual(marker, '');
+ });
+
+ it('only counts non-blocked response cookies', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.addExtraResponseInfo({
+ responseHeaders:
+ [{name: 'Set-Cookie', value: 'good=123; Path=/; Secure; SameSite=None\nbad=456; Path=/; SameSite=None'}],
+ blockedResponseCookies: [{
+ blockedReasons: [Protocol.Network.SetCookieBlockedReason.SameSiteNoneInsecure],
+ cookie: null,
+ cookieLine: 'bad=456; Path=/; SameSite=None',
+ }],
+ resourceIPAddressSpace: Protocol.Network.IPAddressSpace.Public,
+ statusCode: undefined,
+ cookiePartitionKey: undefined,
+ cookiePartitionKeyOpaque: undefined,
+ exemptedResponseCookies: undefined,
+ });
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+ networkRequestNode.renderCell(el, 'setcookies');
+ assert.strictEqual(el.innerText, '1');
+ });
+
+ it('shows transferred size when the matched ServiceWorker router source is network', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.resourceSize = 4;
+ request.setTransferSize(2);
+ request.statusCode = 200;
+ request.serviceWorkerRouterInfo = {
+ ruleIdMatched: 1,
+ matchedSourceType: Protocol.Network.ServiceWorkerRouterSource.Network,
+ };
+
+ const networkRequestNode = new Network.NetworkDataGridNode.NetworkRequestNode(
+ {} as Network.NetworkDataGridNode.NetworkLogViewInterface, request);
+ const el = document.createElement('div');
+ networkRequestNode.renderCell(el, 'size');
+ assert.strictEqual(el.innerText, '(ServiceWorker router)4\xa0B');
+ const tooltip = el.getAttribute('title')!;
+ const expected = 'Matched to ServiceWorker router#1, 2\xa0B transferred over network, resource size: 4\xa0B';
+ assert.strictEqual(tooltip, expected);
+ });
+});
diff --git a/front_end/panels/network/NetworkItemView.test.ts b/front_end/panels/network/NetworkItemView.test.ts
new file mode 100644
index 0000000..61ad1f7
--- /dev/null
+++ b/front_end/panels/network/NetworkItemView.test.ts
@@ -0,0 +1,144 @@
+// 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 {renderElementIntoDOM} from '../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {
+ deinitializeGlobalVars,
+ describeWithEnvironment,
+} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import {setUpEnvironment} from '../../../test/unittests/front_end/helpers/OverridesHelpers.js';
+import type * as Platform from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import type * as Protocol from '../../generated/protocol.js';
+import type * as IconButton from '../../ui/components/icon_button/icon_button.js';
+import type * as UI from '../../ui/legacy/legacy.js';
+
+import * as NetworkForward from './forward/forward.js';
+import * as Network from './network.js';
+
+function renderNetworkItemView(request?: SDK.NetworkRequest.NetworkRequest): Network.NetworkItemView.NetworkItemView {
+ if (!request) {
+ request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'https://www.example.com/foo.html' as Platform.DevToolsPath.UrlString, '' as Platform.DevToolsPath.UrlString,
+ null, null, null);
+ }
+ const networkItemView =
+ new Network.NetworkItemView.NetworkItemView(request, {} as Network.NetworkTimeCalculator.NetworkTimeCalculator);
+ const div = document.createElement('div');
+ renderElementIntoDOM(div);
+ networkItemView.markAsRoot();
+ networkItemView.show(div);
+ return networkItemView;
+}
+
+function getIconDataInTab(tabs: UI.TabbedPane.TabbedPaneTab[], tabId: string) {
+ const icon = tabs.find(tab => tab.id === tabId)?.['icon'] as IconButton.Icon.Icon | undefined;
+ const iconData = icon?.data as IconButton.Icon.IconWithName;
+
+ return iconData;
+}
+
+describeWithMockConnection('NetworkItemView', () => {
+ beforeEach(() => {
+ setUpEnvironment();
+ });
+
+ afterEach(async () => {
+ await deinitializeGlobalVars();
+ });
+
+ it('reveals header in RequestHeadersView', async () => {
+ const networkItemView = renderNetworkItemView();
+ const headersViewComponent = networkItemView.getHeadersViewComponent();
+ const headersViewComponentSpy = sinon.spy(headersViewComponent, 'revealHeader');
+
+ assert.isTrue(headersViewComponentSpy.notCalled);
+
+ networkItemView.revealHeader(NetworkForward.UIRequestLocation.UIHeaderSection.Response, 'headerName');
+
+ assert.isTrue(
+ headersViewComponentSpy.calledWith(NetworkForward.UIRequestLocation.UIHeaderSection.Response, 'headerName'));
+ networkItemView.detach();
+ });
+});
+
+describeWithEnvironment('NetworkItemView', () => {
+ let request: SDK.NetworkRequest.NetworkRequest;
+ const OVERRIDEN_ICON_NAME = 'small-status-dot';
+
+ beforeEach(async () => {
+ request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.statusCode = 200;
+ });
+
+ it('shows indicator for overriden headers and responses', () => {
+ request.setWasIntercepted(true);
+ request.hasOverriddenContent = true;
+ request.responseHeaders = [{name: 'foo', value: 'overridden'}];
+ request.originalResponseHeaders = [{name: 'foo', value: 'original'}];
+
+ const networkItemView = renderNetworkItemView(request);
+ const headersIcon = getIconDataInTab(networkItemView['tabs'], 'headersComponent');
+ const responseIcon = getIconDataInTab(networkItemView['tabs'], 'response');
+
+ networkItemView.detach();
+
+ assert.strictEqual(headersIcon.iconName, OVERRIDEN_ICON_NAME);
+ assert.strictEqual(responseIcon.iconName, OVERRIDEN_ICON_NAME);
+ });
+
+ it('shows indicator for overriden headers', () => {
+ request.setWasIntercepted(true);
+ request.responseHeaders = [{name: 'foo', value: 'overridden'}];
+ request.originalResponseHeaders = [{name: 'foo', value: 'original'}];
+
+ const networkItemView = renderNetworkItemView(request);
+ const headersIcon = getIconDataInTab(networkItemView['tabs'], 'headersComponent');
+ const responseIcon = getIconDataInTab(networkItemView['tabs'], 'response');
+
+ networkItemView.detach();
+
+ assert.strictEqual(headersIcon.iconName, OVERRIDEN_ICON_NAME);
+ assert.isUndefined(responseIcon);
+ });
+
+ it('shows indicator for overriden content', () => {
+ request.setWasIntercepted(true);
+ request.hasOverriddenContent = true;
+
+ const networkItemView = renderNetworkItemView(request);
+ const headersIcon = getIconDataInTab(networkItemView['tabs'], 'headersComponent');
+ const responseIcon = getIconDataInTab(networkItemView['tabs'], 'response');
+
+ networkItemView.detach();
+
+ assert.isUndefined(headersIcon);
+ assert.strictEqual(responseIcon.iconName, OVERRIDEN_ICON_NAME);
+ });
+
+ it('does not show indicator for unoverriden request', () => {
+ const networkItemView = renderNetworkItemView(request);
+ const headersIcon = getIconDataInTab(networkItemView['tabs'], 'headersComponent');
+ const responseIcon = getIconDataInTab(networkItemView['tabs'], 'response');
+
+ networkItemView.detach();
+
+ assert.isUndefined(headersIcon);
+ assert.isUndefined(responseIcon);
+ });
+
+ it('shows the Response and EventSource tab for text/event-stream requests', () => {
+ request.mimeType = 'text/event-stream';
+ const networkItemView = renderNetworkItemView(request);
+
+ assert.isTrue(networkItemView.hasTab(NetworkForward.UIRequestLocation.UIRequestTabs.EventSource));
+ assert.isTrue(networkItemView.hasTab(NetworkForward.UIRequestLocation.UIRequestTabs.Response));
+
+ networkItemView.detach();
+ });
+});
diff --git a/front_end/panels/network/NetworkLogView.test.ts b/front_end/panels/network/NetworkLogView.test.ts
new file mode 100644
index 0000000..08aefd1
--- /dev/null
+++ b/front_end/panels/network/NetworkLogView.test.ts
@@ -0,0 +1,919 @@
+// 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 {
+ assertElement,
+ assertShadowRoot,
+ dispatchClickEvent,
+ dispatchMouseUpEvent,
+ raf,
+} from '../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {createTarget} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection, dispatchEvent} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import * as Common from '../../core/common/common.js';
+import * as Host from '../../core/host/host.js';
+import * as Platform from '../../core/platform/platform.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import * as Root from '../../core/root/root.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import * as Protocol from '../../generated/protocol.js';
+import * as HAR from '../../models/har/har.js';
+import * as Logs from '../../models/logs/logs.js';
+import * as Workspace from '../../models/workspace/workspace.js';
+import * as Coordinator from '../../ui/components/render_coordinator/render_coordinator.js';
+import * as UI from '../../ui/legacy/legacy.js';
+
+import * as Network from './network.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+describeWithMockConnection('NetworkLogView', () => {
+ const tests = (targetFactory: () => SDK.Target.Target) => {
+ let target: SDK.Target.Target;
+ let networkLogView: Network.NetworkLogView.NetworkLogView;
+ let networkLog: Logs.NetworkLog.NetworkLog;
+
+ beforeEach(() => {
+ const dummyStorage = new Common.Settings.SettingsStorage({});
+
+ for (const settingName of ['network-color-code-resource-types', 'network.group-by-frame']) {
+ Common.Settings.registerSettingExtension({
+ settingName,
+ settingType: Common.Settings.SettingType.BOOLEAN,
+ defaultValue: false,
+ });
+ }
+ Common.Settings.Settings.instance({
+ forceNew: true,
+ syncedStorage: dummyStorage,
+ globalStorage: dummyStorage,
+ localStorage: dummyStorage,
+ });
+ sinon.stub(UI.ShortcutRegistry.ShortcutRegistry, 'instance').returns({
+ shortcutTitleForAction: () => {},
+ shortcutsForAction: () => [],
+ } as unknown as UI.ShortcutRegistry.ShortcutRegistry);
+ networkLog = Logs.NetworkLog.NetworkLog.instance();
+ target = targetFactory();
+ });
+
+ let nextId = 0;
+ function createNetworkRequest(
+ url: string,
+ options: {requestHeaders?: SDK.NetworkRequest.NameValue[], finished?: boolean, target?: SDK.Target.Target}):
+ SDK.NetworkRequest.NetworkRequest {
+ const effectiveTarget = options.target || target;
+ const networkManager = effectiveTarget.model(SDK.NetworkManager.NetworkManager);
+ assertNotNullOrUndefined(networkManager);
+ let request: SDK.NetworkRequest.NetworkRequest|undefined;
+ const onRequestStarted = (event: Common.EventTarget.EventTargetEvent<SDK.NetworkManager.RequestStartedEvent>) => {
+ request = event.data.request;
+ };
+ networkManager.addEventListener(SDK.NetworkManager.Events.RequestStarted, onRequestStarted);
+ dispatchEvent(
+ effectiveTarget, 'Network.requestWillBeSent',
+ {requestId: `request${++nextId}`, loaderId: 'loaderId', request: {url}} as unknown as
+ Protocol.Network.RequestWillBeSentEvent);
+ networkManager.removeEventListener(SDK.NetworkManager.Events.RequestStarted, onRequestStarted);
+ assertNotNullOrUndefined(request);
+ request.requestMethod = 'GET';
+ if (options.requestHeaders) {
+ request.setRequestHeaders(options.requestHeaders);
+ }
+ if (options.finished) {
+ request.finished = true;
+ }
+ return request;
+ }
+
+ function createEnvironment() {
+ const filterBar = new UI.FilterBar.FilterBar('networkPanel', true);
+ networkLogView = createNetworkLogView(filterBar);
+ networkLogView.markAsRoot();
+ networkLogView.show(document.body);
+ const rootNode = networkLogView.columns().dataGrid().rootNode();
+
+ return {rootNode, filterBar, networkLogView};
+ }
+
+ it('generates a valid curl command when some headers don\'t have values', async () => {
+ const request = createNetworkRequest('http://localhost' as Platform.DevToolsPath.UrlString, {
+ requestHeaders: [
+ {name: 'header-with-value', value: 'some value'},
+ {name: 'no-value-header', value: ''},
+ ],
+ });
+ const actual = await Network.NetworkLogView.NetworkLogView.generateCurlCommand(request, 'unix');
+ const expected =
+ 'curl \'http://localhost\' \\\n -H \'header-with-value: some value\' \\\n -H \'no-value-header;\'';
+ assert.strictEqual(actual, expected);
+ });
+
+ // Note this isn't an ideal test as the internal headers are generated rather than explicitly added,
+ // are only added on HTTP/2 and HTTP/3, have a preceeding colon like `:authority` but it still tests
+ // the stripping function.
+ it('generates a valid curl command while stripping internal headers', async () => {
+ const request = createNetworkRequest('http://localhost' as Platform.DevToolsPath.UrlString, {
+ requestHeaders: [
+ {name: 'authority', value: 'www.example.com'},
+ ],
+ });
+ const actual = await Network.NetworkLogView.NetworkLogView.generateCurlCommand(request, 'unix');
+ const expected = 'curl \'http://localhost\'';
+ assert.strictEqual(actual, expected);
+ });
+
+ it('generates a valid curl command when header values contain double quotes', async () => {
+ const request = createNetworkRequest('http://localhost' as Platform.DevToolsPath.UrlString, {
+ requestHeaders: [{name: 'cookie', value: 'eva="Sg4="'}],
+ });
+ assert.strictEqual(
+ await Network.NetworkLogView.NetworkLogView.generateCurlCommand(request, 'unix'),
+ 'curl \'http://localhost\' -H \'cookie: eva=\"Sg4=\"\'',
+ );
+ assert.strictEqual(
+ await Network.NetworkLogView.NetworkLogView.generateCurlCommand(request, 'win'),
+ 'curl "http://localhost" -H ^"cookie: eva=^\\^"Sg4=^\\^"^"',
+ );
+ });
+
+ it('generates a valid curl command when header values contain percentages', async () => {
+ const request = createNetworkRequest('http://localhost' as Platform.DevToolsPath.UrlString, {
+ requestHeaders: [{name: 'cookie', value: 'eva=%22Sg4%3D%22'}],
+ });
+ assert.strictEqual(
+ await Network.NetworkLogView.NetworkLogView.generateCurlCommand(request, 'unix'),
+ 'curl \'http://localhost\' -H \'cookie: eva=%22Sg4%3D%22\'',
+ );
+ assert.strictEqual(
+ await Network.NetworkLogView.NetworkLogView.generateCurlCommand(request, 'win'),
+ 'curl "http://localhost" -H ^"cookie: eva=^%^22Sg4^%^3D^%^22^"',
+ );
+ });
+
+ function createNetworkLogView(filterBar?: UI.FilterBar.FilterBar): Network.NetworkLogView.NetworkLogView {
+ if (!filterBar) {
+ filterBar = {addFilter: () => {}, filterButton: () => ({addEventListener: () => {}}), addDivider: () => {}} as
+ unknown as UI.FilterBar.FilterBar;
+ }
+ return new Network.NetworkLogView.NetworkLogView(
+ filterBar, document.createElement('div'),
+ Common.Settings.Settings.instance().createSetting('networkLogLargeRows', false));
+ }
+
+ const tests = (inScope: boolean) => () => {
+ beforeEach(() => {
+ networkLogView = createNetworkLogView();
+ SDK.TargetManager.TargetManager.instance().setScopeTarget(inScope ? target : null);
+ });
+
+ it('adds dividers on main frame load events', async () => {
+ const addEventDividers = sinon.spy(networkLogView.columns(), 'addEventDividers');
+
+ networkLogView.setRecording(true);
+
+ const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel);
+ assertNotNullOrUndefined(resourceTreeModel);
+ resourceTreeModel.dispatchEventToListeners(SDK.ResourceTreeModel.Events.Load, {resourceTreeModel, loadTime: 5});
+ resourceTreeModel.dispatchEventToListeners(SDK.ResourceTreeModel.Events.DOMContentLoaded, 6);
+ if (inScope) {
+ assert.isTrue(addEventDividers.calledTwice);
+ assert.isTrue(addEventDividers.getCall(0).calledWith([5], 'network-load-divider'));
+ assert.isTrue(addEventDividers.getCall(1).calledWith([6], 'network-dcl-divider'));
+ } else {
+ assert.isFalse(addEventDividers.called);
+ }
+ });
+
+ it('can export all as HAR', async () => {
+ SDK.TargetManager.TargetManager.instance().setScopeTarget(inScope ? target : null);
+ const harWriterWrite = sinon.stub(HAR.Writer.Writer, 'write').resolves();
+ const URL_HOST = 'example.com';
+ target.setInspectedURL(`http://${URL_HOST}/foo` as Platform.DevToolsPath.UrlString);
+ const FILENAME = `${URL_HOST}.har` as Platform.DevToolsPath.RawPathString;
+ const fileManager = Workspace.FileManager.FileManager.instance();
+ const fileManagerSave =
+ sinon.stub(fileManager, 'save').withArgs(FILENAME, '', true).resolves({fileSystemPath: FILENAME});
+ const fileManagerClose = sinon.stub(fileManager, 'close');
+
+ const FINISHED_REQUEST_1 = createNetworkRequest('http://example.com/', {finished: true});
+ const FINISHED_REQUEST_2 = createNetworkRequest('http://example.com/favicon.ico', {finished: true});
+ const UNFINISHED_REQUEST = createNetworkRequest('http://example.com/background.bmp', {finished: false});
+ sinon.stub(Logs.NetworkLog.NetworkLog.instance(), 'requests').returns([
+ FINISHED_REQUEST_1,
+ FINISHED_REQUEST_2,
+ UNFINISHED_REQUEST,
+ ]);
+ await networkLogView.exportAll();
+
+ if (inScope) {
+ assert.isTrue(harWriterWrite.calledOnceWith(
+ sinon.match.any, [FINISHED_REQUEST_1, FINISHED_REQUEST_2], sinon.match.any));
+ assert.isTrue(fileManagerSave.calledOnce);
+ assert.isTrue(fileManagerClose.calledOnce);
+ } else {
+ assert.isFalse(harWriterWrite.called);
+ assert.isFalse(fileManagerSave.called);
+ assert.isFalse(fileManagerClose.called);
+ }
+ });
+
+ it('can import and filter from HAR', async () => {
+ const URL_1 = 'http://example.com/' as Platform.DevToolsPath.UrlString;
+ const URL_2 = 'http://example.com/favicon.ico' as Platform.DevToolsPath.UrlString;
+ function makeHarEntry(url: Platform.DevToolsPath.UrlString) {
+ return {
+ request: {method: 'GET', url: url, headersSize: -1, bodySize: 0},
+ response: {status: 0, content: {'size': 0, 'mimeType': 'x-unknown'}, headersSize: -1, bodySize: -1},
+ startedDateTime: null,
+ time: null,
+ timings: {blocked: null, dns: -1, ssl: -1, connect: -1, send: 0, wait: 0, receive: 0},
+ };
+ }
+ const har = {
+ log: {
+ version: '1.2',
+ creator: {name: 'WebInspector', version: '537.36'},
+ entries: [makeHarEntry(URL_1), makeHarEntry(URL_2)],
+ },
+ };
+ networkLogView.markAsRoot();
+ networkLogView.show(document.body);
+ const blob = new Blob([JSON.stringify(har)], {type: 'text/plain'});
+ const file = new File([blob], 'log.har');
+ await networkLogView.onLoadFromFile(file);
+ await coordinator.done({waitForWork: true});
+
+ const rootNode = networkLogView.columns().dataGrid().rootNode();
+ assert.deepEqual(
+ rootNode.children.map(n => (n as Network.NetworkDataGridNode.NetworkNode).request()?.url()),
+ [URL_1, URL_2]);
+
+ networkLogView.setTextFilterValue('favicon');
+ assert.deepEqual(
+ rootNode.children.map(n => (n as Network.NetworkDataGridNode.NetworkNode).request()?.url()), [URL_2]);
+
+ networkLogView.detach();
+ });
+
+ it('shows summary toolbar with content', () => {
+ target.setInspectedURL('http://example.com/' as Platform.DevToolsPath.UrlString);
+ const request = createNetworkRequest('http://example.com/', {finished: true});
+ request.endTime = 0.669414;
+ request.setIssueTime(0.435136, 0.435136);
+ request.setResourceType(Common.ResourceType.resourceTypes.Document);
+
+ networkLogView.setRecording(true);
+ const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel);
+ assertNotNullOrUndefined(resourceTreeModel);
+ resourceTreeModel.dispatchEventToListeners(
+ SDK.ResourceTreeModel.Events.Load, {resourceTreeModel, loadTime: 0.686191});
+ resourceTreeModel.dispatchEventToListeners(SDK.ResourceTreeModel.Events.DOMContentLoaded, 0.683709);
+ networkLogView.markAsRoot();
+ networkLogView.show(document.body);
+
+ const toolbar = networkLogView.summaryToolbar();
+ const textElements = toolbar.element.shadowRoot?.querySelectorAll('.toolbar-text');
+ assertNotNullOrUndefined(textElements);
+ const textContents = [...textElements].map(item => item.textContent);
+ if (inScope) {
+ assert.deepEqual(textContents, [
+ '1 requests',
+ '0\u00a0B transferred',
+ '0\u00a0B resources',
+ 'Finish: 234\u00a0ms',
+ 'DOMContentLoaded: 249\u00a0ms',
+ 'Load: 251\u00a0ms',
+ ]);
+ } else {
+ assert.strictEqual(textElements.length, 0);
+ }
+ networkLogView.detach();
+ });
+ };
+ describe('in scope', tests(true));
+ describe('out of scope', tests(false));
+
+ const handlesSwitchingScope = (preserveLog: boolean) => async () => {
+ Common.Settings.Settings.instance().moduleSetting('network-log.preserve-log').set(preserveLog);
+ SDK.TargetManager.TargetManager.instance().setScopeTarget(target);
+ const anotherTarget = createTarget();
+ const networkManager = target.model(SDK.NetworkManager.NetworkManager);
+ assertNotNullOrUndefined(networkManager);
+ const request1 = createNetworkRequest('url1', {target});
+ const request2 = createNetworkRequest('url2', {target});
+ const request3 = createNetworkRequest('url3', {target: anotherTarget});
+ networkLogView = createNetworkLogView();
+ networkLogView.markAsRoot();
+ networkLogView.show(document.body);
+ await coordinator.done();
+
+ const rootNode = networkLogView.columns().dataGrid().rootNode();
+ assert.deepEqual(
+ rootNode.children.map(n => (n as Network.NetworkDataGridNode.NetworkNode).request()), [request1, request2]);
+
+ SDK.TargetManager.TargetManager.instance().setScopeTarget(anotherTarget);
+ await coordinator.done();
+ assert.deepEqual(
+ rootNode.children.map(n => (n as Network.NetworkDataGridNode.NetworkNode).request()),
+ preserveLog ? [request1, request2, request3] : [request3]);
+
+ networkLogView.detach();
+ };
+
+ it('replaces requests when switching scope with preserve log off', handlesSwitchingScope(false));
+ it('appends requests when switching scope with preserve log on', handlesSwitchingScope(true));
+
+ it('appends requests on prerender activation with preserve log on', async () => {
+ Common.Settings.Settings.instance().moduleSetting('network-log.preserve-log').set(true);
+ SDK.TargetManager.TargetManager.instance().setScopeTarget(target);
+ const anotherTarget = createTarget();
+ const networkManager = target.model(SDK.NetworkManager.NetworkManager);
+ assertNotNullOrUndefined(networkManager);
+ const request1 = createNetworkRequest('url1', {target});
+ const request2 = createNetworkRequest('url2', {target});
+ const request3 = createNetworkRequest('url3', {target: anotherTarget});
+ networkLogView = createNetworkLogView();
+ networkLogView.markAsRoot();
+ networkLogView.show(document.body);
+ await coordinator.done();
+
+ const rootNode = networkLogView.columns().dataGrid().rootNode();
+ assert.deepEqual(
+ rootNode.children.map(n => (n as Network.NetworkDataGridNode.NetworkNode).request()), [request1, request2]);
+
+ const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel);
+ assertNotNullOrUndefined(resourceTreeModel);
+ const frame = {
+ url: 'http://example.com/',
+ unreachableUrl: () => Platform.DevToolsPath.EmptyUrlString,
+ resourceTreeModel: () => resourceTreeModel,
+ } as SDK.ResourceTreeModel.ResourceTreeFrame;
+ resourceTreeModel.dispatchEventToListeners(
+ SDK.ResourceTreeModel.Events.PrimaryPageChanged,
+ {frame, type: SDK.ResourceTreeModel.PrimaryPageChangeType.Activation});
+ await coordinator.done();
+ assert.deepEqual(
+ rootNode.children.map(n => (n as Network.NetworkDataGridNode.NetworkNode).request()),
+ [request1, request2, request3]);
+
+ networkLogView.detach();
+ });
+
+ it('hide Chrome extension requests from checkbox', async () => {
+ createNetworkRequest('chrome-extension://url1', {target});
+ createNetworkRequest('url2', {target});
+ let rootNode;
+ let filterBar;
+ ({rootNode, filterBar, networkLogView} = createEnvironment());
+ const hideExtCheckbox = getCheckbox(filterBar, 'Hide \'chrome-extension://\' URLs');
+
+ assert.deepEqual(
+ rootNode.children.map(n => (n as Network.NetworkDataGridNode.NetworkNode).request()?.url()),
+ ['chrome-extension://url1' as Platform.DevToolsPath.UrlString, 'url2' as Platform.DevToolsPath.UrlString]);
+
+ clickCheckbox(hideExtCheckbox);
+ assert.deepEqual(
+ rootNode.children.map(n => (n as Network.NetworkDataGridNode.NetworkNode).request()?.url()),
+ ['url2' as Platform.DevToolsPath.UrlString]);
+
+ networkLogView.detach();
+ });
+
+ it('can hide Chrome extension requests from dropdown', async () => {
+ Root.Runtime.experiments.enableForTest(Root.Runtime.ExperimentName.NETWORK_PANEL_FILTER_BAR_REDESIGN);
+ createNetworkRequest('chrome-extension://url1', {target});
+ createNetworkRequest('url2', {target});
+ let rootNode;
+ let filterBar;
+ ({rootNode, filterBar, networkLogView} = createEnvironment());
+
+ assert.deepEqual(
+ rootNode.children.map(n => (n as Network.NetworkDataGridNode.NetworkNode).request()?.url()),
+ ['chrome-extension://url1' as Platform.DevToolsPath.UrlString, 'url2' as Platform.DevToolsPath.UrlString]);
+
+ const dropdown = await openMoreTypesDropdown(filterBar, networkLogView);
+ if (!dropdown) {
+ return;
+ }
+ const softMenu = getSoftMenu();
+ const hideExtensionURL = getDropdownItem(softMenu, 'Hide extension URLs');
+ assert.isFalse(hideExtensionURL.hasAttribute('checked'));
+ dispatchMouseUpEvent(hideExtensionURL);
+ await raf();
+ assert.isTrue(hideExtensionURL.hasAttribute('checked'));
+
+ assert.deepEqual(
+ rootNode.children.map(n => (n as Network.NetworkDataGridNode.NetworkNode).request()?.url()),
+ ['url2' as Platform.DevToolsPath.UrlString]);
+
+ dropdown.discard();
+ networkLogView.detach();
+ });
+
+ it('displays correct count for more filters', async () => {
+ Root.Runtime.experiments.enableForTest(Root.Runtime.ExperimentName.NETWORK_PANEL_FILTER_BAR_REDESIGN);
+ let filterBar;
+ ({filterBar, networkLogView} = createEnvironment());
+ const dropdown = await openMoreTypesDropdown(filterBar, networkLogView);
+ if (!dropdown) {
+ return;
+ }
+
+ assert.strictEqual(getMoreFiltersActiveCount(filterBar), '0');
+ assert.isTrue(getCountAdorner(filterBar)?.classList.contains('hidden'));
+
+ const softMenu = getSoftMenu();
+ await selectMoreFiltersOption(softMenu, 'Hide extension URLs');
+
+ assert.strictEqual(getMoreFiltersActiveCount(filterBar), '1');
+ assert.isFalse(getCountAdorner(filterBar)?.classList.contains('hidden'));
+
+ dropdown.discard();
+ networkLogView.detach();
+ });
+
+ it('can automatically check the `All` option in the `Request Type` when the only type checked becomes unchecked',
+ async () => {
+ Root.Runtime.experiments.enableForTest(Root.Runtime.ExperimentName.NETWORK_PANEL_FILTER_BAR_REDESIGN);
+
+ const dropdown = setupRequestTypesDropdown();
+ const button = dropdown.element().querySelector('.toolbar-button');
+
+ assertElement(button, HTMLElement);
+ dispatchClickEvent(button, {bubbles: true, composed: true});
+ await raf();
+
+ const optionImg = getRequestTypeDropdownOption('Image');
+ const optionImgCheckmark = optionImg?.querySelector('.checkmark') || null;
+ const optionAll = getRequestTypeDropdownOption('All');
+ const optionAllCheckmark = optionAll?.querySelector('.checkmark') || null;
+
+ assertElement(optionImg, HTMLElement);
+ assertElement(optionImgCheckmark, HTMLElement);
+ assertElement(optionAll, HTMLElement);
+ assertElement(optionAllCheckmark, HTMLElement);
+
+ assert.isTrue(optionAll.ariaLabel === 'All, checked');
+ assert.isTrue(optionImg.ariaLabel === 'Image, unchecked');
+ assert.isTrue(window.getComputedStyle(optionAllCheckmark).getPropertyValue('opacity') === '1');
+ assert.isTrue(window.getComputedStyle(optionImgCheckmark).getPropertyValue('opacity') === '0');
+
+ await selectRequestTypesOption('Image');
+
+ assert.isTrue(optionAll.ariaLabel === 'All, unchecked');
+ assert.isTrue(optionImg.ariaLabel === 'Image, checked');
+ assert.isTrue(window.getComputedStyle(optionAllCheckmark).getPropertyValue('opacity') === '0');
+ assert.isTrue(window.getComputedStyle(optionImgCheckmark).getPropertyValue('opacity') === '1');
+
+ await selectRequestTypesOption('Image');
+
+ assert.isTrue(optionAll.ariaLabel === 'All, checked');
+ assert.isTrue(optionImg.ariaLabel === 'Image, unchecked');
+ assert.isTrue(window.getComputedStyle(optionAllCheckmark).getPropertyValue('opacity') === '1');
+ assert.isTrue(window.getComputedStyle(optionImgCheckmark).getPropertyValue('opacity') === '0');
+
+ dropdown.discard();
+ await raf();
+ });
+
+ it('shows correct selected request types count', async () => {
+ Root.Runtime.experiments.enableForTest(Root.Runtime.ExperimentName.NETWORK_PANEL_FILTER_BAR_REDESIGN);
+ const umaCountSpy = sinon.spy(Host.userMetrics, 'resourceTypeFilterNumberOfSelectedChanged');
+ const umaTypeSpy = sinon.spy(Host.userMetrics, 'resourceTypeFilterItemSelected');
+
+ const dropdown = setupRequestTypesDropdown();
+ const button = dropdown.element().querySelector('.toolbar-button');
+ assertElement(button, HTMLElement);
+
+ let countAdorner = button.querySelector('.active-filters-count');
+ assert.isTrue(countAdorner?.classList.contains('hidden'));
+
+ dispatchClickEvent(button, {bubbles: true, composed: true});
+ await raf();
+ await selectRequestTypesOption('Image');
+
+ countAdorner = button.querySelector('.active-filters-count');
+ assert.isFalse(countAdorner?.classList.contains('hidden'));
+ assert.strictEqual(countAdorner?.querySelector('[slot="content"]')?.textContent, '1');
+
+ dropdown.discard();
+ await raf();
+ assert.isTrue(umaCountSpy.calledOnceWith(1));
+ assert.isTrue(umaTypeSpy.calledOnceWith('Image'));
+ });
+
+ it('adjusts request types label dynamically', async () => {
+ Root.Runtime.experiments.enableForTest(Root.Runtime.ExperimentName.NETWORK_PANEL_FILTER_BAR_REDESIGN);
+
+ const dropdown = setupRequestTypesDropdown();
+ const button = dropdown.element().querySelector('.toolbar-button');
+ assertElement(button, HTMLElement);
+
+ let toolbarText = button.querySelector('.toolbar-text')?.textContent;
+ assert.strictEqual(toolbarText, 'Request types');
+
+ dispatchClickEvent(button, {bubbles: true, composed: true});
+ await raf();
+ await selectRequestTypesOption('Image');
+ await selectRequestTypesOption('JavaScript');
+
+ toolbarText = button.querySelector('.toolbar-text')?.textContent;
+ assert.strictEqual(toolbarText, 'JS, Img');
+
+ await selectRequestTypesOption('CSS');
+
+ toolbarText = button.querySelector('.toolbar-text')?.textContent;
+ assert.strictEqual(toolbarText, 'CSS, JS...');
+
+ dropdown.discard();
+ await raf();
+ });
+
+ it('lists selected types in requests types tooltip', async () => {
+ Root.Runtime.experiments.enableForTest(Root.Runtime.ExperimentName.NETWORK_PANEL_FILTER_BAR_REDESIGN);
+ const umaCountSpy = sinon.spy(Host.userMetrics, 'resourceTypeFilterNumberOfSelectedChanged');
+ const umaTypeSpy = sinon.spy(Host.userMetrics, 'resourceTypeFilterItemSelected');
+
+ const dropdown = setupRequestTypesDropdown();
+ const button = dropdown.element().querySelector('.toolbar-button');
+ assertElement(button, HTMLElement);
+
+ let tooltipText = button.title;
+ assert.strictEqual(tooltipText, 'Filter requests by type');
+
+ dispatchClickEvent(button, {bubbles: true, composed: true});
+ await raf();
+ await selectRequestTypesOption('Image');
+ await selectRequestTypesOption('JavaScript');
+
+ tooltipText = button.title;
+ assert.strictEqual(tooltipText, 'Show only JavaScript, Image');
+
+ dropdown.discard();
+ await raf();
+ assert.isTrue(umaCountSpy.calledOnceWith(2));
+ assert.isTrue(umaTypeSpy.calledTwice);
+ assert.isTrue(umaTypeSpy.calledWith('Image'));
+ assert.isTrue(umaTypeSpy.calledWith('JavaScript'));
+ });
+
+ it('updates tooltip to default when request type deselected', async () => {
+ Root.Runtime.experiments.enableForTest(Root.Runtime.ExperimentName.NETWORK_PANEL_FILTER_BAR_REDESIGN);
+
+ const dropdown = setupRequestTypesDropdown();
+ const button = dropdown.element().querySelector('.toolbar-button');
+ assertElement(button, HTMLElement);
+
+ dispatchClickEvent(button, {bubbles: true, composed: true});
+ await raf();
+ await selectRequestTypesOption('Image');
+
+ let tooltipText = button.title;
+ assert.strictEqual(tooltipText, 'Show only Image');
+
+ await selectRequestTypesOption('Image');
+
+ tooltipText = button.title;
+ assert.strictEqual(tooltipText, 'Filter requests by type');
+
+ dropdown.discard();
+ await raf();
+ });
+
+ it('can filter requests with blocked response cookies from checkbox', async () => {
+ const request1 = createNetworkRequest('url1', {target});
+ request1.blockedResponseCookies = () => [{
+ blockedReasons: [Protocol.Network.SetCookieBlockedReason.SameSiteNoneInsecure],
+ cookie: null,
+ cookieLine: 'foo=bar; SameSite=None',
+ }];
+ createNetworkRequest('url2', {target});
+ let rootNode;
+ let filterBar;
+ ({rootNode, filterBar, networkLogView} = createEnvironment());
+ const blockedCookiesCheckbox = getCheckbox(filterBar, 'Show only requests with blocked response cookies');
+ clickCheckbox(blockedCookiesCheckbox);
+ assert.deepEqual(rootNode.children.map(n => (n as Network.NetworkDataGridNode.NetworkNode).request()?.url()), [
+ 'url1' as Platform.DevToolsPath.UrlString,
+ ]);
+
+ networkLogView.detach();
+ });
+
+ it('can filter requests with blocked response cookies from dropdown', async () => {
+ Root.Runtime.experiments.enableForTest(Root.Runtime.ExperimentName.NETWORK_PANEL_FILTER_BAR_REDESIGN);
+ const umaCountSpy = sinon.spy(Host.userMetrics, 'networkPanelMoreFiltersNumberOfSelectedChanged');
+ const umaItemSpy = sinon.spy(Host.userMetrics, 'networkPanelMoreFiltersItemSelected');
+
+ const request1 = createNetworkRequest('url1', {target});
+ request1.blockedResponseCookies = () => [{
+ blockedReasons: [Protocol.Network.SetCookieBlockedReason.SameSiteNoneInsecure],
+ cookie: null,
+ cookieLine: 'foo=bar; SameSite=None',
+ }];
+ createNetworkRequest('url2', {target});
+ let rootNode;
+ let filterBar;
+ ({rootNode, filterBar, networkLogView} = createEnvironment());
+
+ assert.deepEqual(
+ rootNode.children.map(n => (n as Network.NetworkDataGridNode.NetworkNode).request()?.url()),
+ ['url1' as Platform.DevToolsPath.UrlString, 'url2' as Platform.DevToolsPath.UrlString]);
+
+ const dropdown = await openMoreTypesDropdown(filterBar, networkLogView);
+ if (!dropdown) {
+ return;
+ }
+ const softMenu = getSoftMenu();
+ const blockedResponseCookies = getDropdownItem(softMenu, 'Blocked response cookies');
+ assert.isFalse(blockedResponseCookies.hasAttribute('checked'));
+ dispatchMouseUpEvent(blockedResponseCookies);
+ await raf();
+ assert.isTrue(blockedResponseCookies.hasAttribute('checked'));
+
+ assert.deepEqual(rootNode.children.map(n => (n as Network.NetworkDataGridNode.NetworkNode).request()?.url()), [
+ 'url1' as Platform.DevToolsPath.UrlString,
+ ]);
+
+ dropdown.discard();
+ assert.isTrue(umaCountSpy.calledOnceWith(1));
+ assert.isTrue(umaItemSpy.calledOnceWith('Blocked response cookies'));
+ networkLogView.detach();
+ });
+
+ it('lists selected options in more filters tooltip', async () => {
+ Root.Runtime.experiments.enableForTest(Root.Runtime.ExperimentName.NETWORK_PANEL_FILTER_BAR_REDESIGN);
+ const umaCountSpy = sinon.spy(Host.userMetrics, 'networkPanelMoreFiltersNumberOfSelectedChanged');
+ const umaItemSpy = sinon.spy(Host.userMetrics, 'networkPanelMoreFiltersItemSelected');
+ let filterBar;
+ ({filterBar, networkLogView} = createEnvironment());
+
+ const dropdown = await openMoreTypesDropdown(filterBar, networkLogView);
+ assertNotNullOrUndefined(dropdown);
+
+ const button = dropdown.element().querySelector('.toolbar-button');
+ assertElement(button, HTMLElement);
+ assert.strictEqual(button.title, 'Show only/hide requests');
+
+ const softMenu = getSoftMenu();
+ await selectMoreFiltersOption(softMenu, 'Blocked response cookies');
+ await selectMoreFiltersOption(softMenu, 'Hide extension URLs');
+
+ assert.strictEqual(button.title, 'Hide extension URLs, Blocked response cookies');
+
+ dropdown.discard();
+ assert.isTrue(umaCountSpy.calledOnceWith(2));
+ assert.isTrue(umaItemSpy.calledTwice);
+ assert.isTrue(umaItemSpy.calledWith('Hide extension URLs'));
+ assert.isTrue(umaItemSpy.calledWith('Blocked response cookies'));
+ networkLogView.detach();
+ });
+
+ it('updates tooltip to default when more filters option deselected', async () => {
+ Root.Runtime.experiments.enableForTest(Root.Runtime.ExperimentName.NETWORK_PANEL_FILTER_BAR_REDESIGN);
+ let filterBar;
+ ({filterBar, networkLogView} = createEnvironment());
+
+ const dropdown = await openMoreTypesDropdown(filterBar, networkLogView);
+ assertNotNullOrUndefined(dropdown);
+
+ const button = dropdown.element().querySelector('.toolbar-button');
+ assertElement(button, HTMLElement);
+ assert.strictEqual(button.title, 'Show only/hide requests');
+
+ const softMenu = getSoftMenu();
+ await selectMoreFiltersOption(softMenu, 'Blocked response cookies');
+
+ assert.strictEqual(button.title, 'Blocked response cookies');
+
+ await selectMoreFiltersOption(softMenu, 'Blocked response cookies');
+
+ assert.strictEqual(button.title, 'Show only/hide requests');
+
+ dropdown.discard();
+ networkLogView.detach();
+ });
+
+ it('can remove requests', async () => {
+ networkLogView = createNetworkLogView();
+ const request = createNetworkRequest('url1', {target});
+ networkLogView.markAsRoot();
+ networkLogView.show(document.body);
+
+ const rootNode = networkLogView.columns().dataGrid().rootNode();
+ assert.strictEqual(rootNode.children.length, 1);
+
+ networkLog.dispatchEventToListeners(Logs.NetworkLog.Events.RequestRemoved, {request});
+ assert.strictEqual(rootNode.children.length, 0);
+
+ networkLogView.detach();
+ });
+
+ function createOverrideRequests() {
+ const urlNotOverridden = 'url-not-overridden' as Platform.DevToolsPath.UrlString;
+ const urlHeaderOverridden = 'url-header-overridden' as Platform.DevToolsPath.UrlString;
+ const urlContentOverridden = 'url-content-overridden' as Platform.DevToolsPath.UrlString;
+ const urlHeaderAndContentOverridden = 'url-header-und-content-overridden' as Platform.DevToolsPath.UrlString;
+
+ createNetworkRequest(urlNotOverridden, {target});
+ const r2 = createNetworkRequest(urlHeaderOverridden, {target});
+ const r3 = createNetworkRequest(urlContentOverridden, {target});
+ const r4 = createNetworkRequest(urlHeaderAndContentOverridden, {target});
+
+ // set up overrides
+ r2.originalResponseHeaders = [{name: 'content-type', value: 'x'}];
+ r2.responseHeaders = [{name: 'content-type', value: 'overriden'}];
+ r3.hasOverriddenContent = true;
+ r4.originalResponseHeaders = [{name: 'age', value: 'x'}];
+ r4.responseHeaders = [{name: 'age', value: 'overriden'}];
+ r4.hasOverriddenContent = true;
+
+ return {urlNotOverridden, urlHeaderOverridden, urlContentOverridden, urlHeaderAndContentOverridden};
+ }
+
+ it('can apply filter - has-overrides:yes', async () => {
+ const {urlHeaderOverridden, urlContentOverridden, urlHeaderAndContentOverridden} = createOverrideRequests();
+
+ const filterBar = new UI.FilterBar.FilterBar('networkPanel', true);
+ networkLogView = createNetworkLogView(filterBar);
+ networkLogView.setTextFilterValue('has-overrides:yes');
+
+ networkLogView.markAsRoot();
+ networkLogView.show(document.body);
+ const rootNode = networkLogView.columns().dataGrid().rootNode();
+
+ assert.deepEqual(rootNode.children.map(n => (n as Network.NetworkDataGridNode.NetworkNode).request()?.url()), [
+ urlHeaderOverridden,
+ urlContentOverridden,
+ urlHeaderAndContentOverridden,
+ ]);
+
+ networkLogView.detach();
+ });
+
+ it('can apply filter - has-overrides:no', async () => {
+ const {urlNotOverridden} = createOverrideRequests();
+
+ const filterBar = new UI.FilterBar.FilterBar('networkPanel', true);
+ networkLogView = createNetworkLogView(filterBar);
+ networkLogView.setTextFilterValue('has-overrides:no');
+
+ networkLogView.markAsRoot();
+ networkLogView.show(document.body);
+ const rootNode = networkLogView.columns().dataGrid().rootNode();
+
+ assert.deepEqual(rootNode.children.map(n => (n as Network.NetworkDataGridNode.NetworkNode).request()?.url()), [
+ urlNotOverridden,
+ ]);
+
+ networkLogView.detach();
+ });
+
+ it('can apply filter - has-overrides:headers', async () => {
+ const {urlHeaderOverridden, urlHeaderAndContentOverridden} = createOverrideRequests();
+
+ const filterBar = new UI.FilterBar.FilterBar('networkPanel', true);
+ networkLogView = createNetworkLogView(filterBar);
+ networkLogView.setTextFilterValue('has-overrides:headers');
+
+ networkLogView.markAsRoot();
+ networkLogView.show(document.body);
+ const rootNode = networkLogView.columns().dataGrid().rootNode();
+
+ assert.deepEqual(rootNode.children.map(n => (n as Network.NetworkDataGridNode.NetworkNode).request()?.url()), [
+ urlHeaderOverridden,
+ urlHeaderAndContentOverridden,
+ ]);
+
+ networkLogView.detach();
+ });
+
+ it('can apply filter - has-overrides:content', async () => {
+ const {urlContentOverridden, urlHeaderAndContentOverridden} = createOverrideRequests();
+
+ const filterBar = new UI.FilterBar.FilterBar('networkPanel', true);
+ networkLogView = createNetworkLogView(filterBar);
+ networkLogView.setTextFilterValue('has-overrides:content');
+
+ networkLogView.markAsRoot();
+ networkLogView.show(document.body);
+ const rootNode = networkLogView.columns().dataGrid().rootNode();
+
+ assert.deepEqual(rootNode.children.map(n => (n as Network.NetworkDataGridNode.NetworkNode).request()?.url()), [
+ urlContentOverridden,
+ urlHeaderAndContentOverridden,
+ ]);
+
+ networkLogView.detach();
+ });
+
+ it('can apply filter - has-overrides:tent', async () => {
+ const {urlHeaderAndContentOverridden, urlContentOverridden} = createOverrideRequests();
+
+ const filterBar = new UI.FilterBar.FilterBar('networkPanel', true);
+ networkLogView = createNetworkLogView(filterBar);
+ networkLogView.setTextFilterValue('has-overrides:tent'); // partial text
+
+ networkLogView.markAsRoot();
+ networkLogView.show(document.body);
+ const rootNode = networkLogView.columns().dataGrid().rootNode();
+
+ assert.deepEqual(rootNode.children.map(n => (n as Network.NetworkDataGridNode.NetworkNode).request()?.url()), [
+ urlContentOverridden,
+ urlHeaderAndContentOverridden,
+ ]);
+
+ networkLogView.detach();
+ });
+ };
+
+ describe('without tab target', () => tests(createTarget));
+ describe('with tab target', () => tests(() => {
+ const tabTarget = createTarget({type: SDK.Target.Type.Tab});
+ createTarget({parentTarget: tabTarget, subtype: 'prerender'});
+ return createTarget({parentTarget: tabTarget});
+ }));
+});
+
+function clickCheckbox(checkbox: HTMLInputElement) {
+ checkbox.checked = true;
+ const event = new Event('change');
+ checkbox.dispatchEvent(event);
+}
+
+function getCheckbox(filterBar: UI.FilterBar.FilterBar, title: string) {
+ const checkbox =
+ filterBar.element.querySelector(`[title="${title}"] span`)?.shadowRoot?.querySelector('input') || null;
+ assertElement(checkbox, HTMLInputElement);
+ return checkbox;
+}
+
+function getRequestTypeDropdownOption(requestType: string): Element|null {
+ const dropDownVbox = document.querySelector('.vbox')?.shadowRoot?.querySelectorAll('.soft-context-menu-item') || [];
+ const dropdownOptions = Array.from(dropDownVbox);
+ return dropdownOptions.find(el => el.textContent?.includes(requestType)) || null;
+}
+
+async function selectRequestTypesOption(option: string) {
+ const item = getRequestTypeDropdownOption(option);
+ assertElement(item, HTMLElement);
+ dispatchMouseUpEvent(item, {bubbles: true, composed: true});
+ await raf();
+}
+
+async function openMoreTypesDropdown(
+ filterBar: UI.FilterBar.FilterBar, networkLogView: Network.NetworkLogView.NetworkLogView):
+ Promise<Network.NetworkLogView.MoreFiltersDropDownUI|undefined> {
+ const button = filterBar.element.querySelector('[aria-label="Show only/hide requests dropdown"]')
+ ?.querySelector('.toolbar-button');
+ button?.dispatchEvent(new Event('click'));
+ await raf();
+ const dropdown = networkLogView.getMoreFiltersDropdown();
+ return dropdown;
+}
+
+function setupRequestTypesDropdown() {
+ const filterItems = Object.values(Common.ResourceType.resourceCategories).map(category => ({
+ name: category.title(),
+ label: () => category.shortTitle(),
+ title: category.title(),
+ }));
+
+ const setting = Common.Settings.Settings.instance().createSetting('network-resource-type-filters', {all: true});
+ const dropdown = new Network.NetworkLogView.DropDownTypesUI(filterItems, setting);
+ return dropdown;
+}
+
+function getCountAdorner(filterBar: UI.FilterBar.FilterBar): HTMLElement|null {
+ const button = filterBar.element.querySelector('[aria-label="Show only/hide requests dropdown"]')
+ ?.querySelector('.toolbar-button');
+ return button?.querySelector('.active-filters-count') ?? null;
+}
+
+function getMoreFiltersActiveCount(filterBar: UI.FilterBar.FilterBar): string {
+ const countAdorner = getCountAdorner(filterBar);
+ const count = countAdorner?.querySelector('[slot="content"]')?.textContent ?? '';
+ return count;
+}
+
+function getSoftMenu(): HTMLElement {
+ const container = document.querySelector('div[data-devtools-glass-pane]');
+ assertElement(container, HTMLElement);
+ assertShadowRoot(container.shadowRoot);
+ const softMenu = container.shadowRoot.querySelector('.soft-context-menu');
+ assertElement(softMenu, HTMLElement);
+ return softMenu;
+}
+
+function getDropdownItem(softMenu: HTMLElement, label: string) {
+ const item = softMenu?.querySelector(`[aria-label^="${label}"]`);
+ assertElement(item, HTMLElement);
+ return item;
+}
+
+async function selectMoreFiltersOption(softMenu: HTMLElement, option: string) {
+ const item = getDropdownItem(softMenu, option);
+ dispatchMouseUpEvent(item);
+ await raf();
+}
diff --git a/front_end/panels/network/NetworkOverview.test.ts b/front_end/panels/network/NetworkOverview.test.ts
new file mode 100644
index 0000000..593cc13
--- /dev/null
+++ b/front_end/panels/network/NetworkOverview.test.ts
@@ -0,0 +1,63 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {createTarget} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import type * as Common from '../../core/common/common.js';
+import type * as Platform from '../../core/platform/platform.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import * as Coordinator from '../../ui/components/render_coordinator/render_coordinator.js';
+import type * as PerfUI from '../../ui/legacy/components/perf_ui/perf_ui.js';
+
+import * as Network from './network.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+describeWithMockConnection('NetworkOverview', () => {
+ let target: SDK.Target.Target;
+ let networkOverview: Network.NetworkOverview.NetworkOverview;
+
+ beforeEach(() => {
+ networkOverview = new Network.NetworkOverview.NetworkOverview();
+ target = createTarget();
+ });
+
+ const updatesOnEvent = <T extends keyof SDK.ResourceTreeModel.EventTypes>(
+ event: Platform.TypeScriptUtilities.NoUnion<T>, inScope: boolean) => async () => {
+ SDK.TargetManager.TargetManager.instance().setScopeTarget(inScope ? target : null);
+ const calculator = {
+ computePosition: sinon.stub(),
+ setDisplayWidth: sinon.stub(),
+ positionToTime: sinon.stub(),
+ setBounds: sinon.stub(),
+ setNavStartTimes: sinon.stub(),
+ reset: sinon.stub(),
+ formatValue: sinon.stub(),
+ maximumBoundary: sinon.stub(),
+ minimumBoundary: sinon.stub(),
+ zeroTime: sinon.stub(),
+ boundarySpan: sinon.stub(),
+ };
+ networkOverview.setCalculator(
+ calculator as unknown as PerfUI.TimelineOverviewCalculator.TimelineOverviewCalculator);
+ networkOverview.markAsRoot();
+ networkOverview.show(document.body);
+ const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel);
+ assertNotNullOrUndefined(resourceTreeModel);
+ assert.isFalse(calculator.computePosition.called);
+ resourceTreeModel.dispatchEventToListeners(
+ event,
+ ...[{loadTime: 42}] as Common.EventTarget.EventPayloadToRestParameters<SDK.ResourceTreeModel.EventTypes, T>);
+ await coordinator.done();
+ assert.strictEqual(calculator.computePosition.called, inScope);
+ networkOverview.detach();
+ };
+
+ it('updates on in scope load event', updatesOnEvent(SDK.ResourceTreeModel.Events.Load, true));
+ it('does not update on out of scope load event', updatesOnEvent(SDK.ResourceTreeModel.Events.Load, false));
+ it('updates on in scope DOM content load event', updatesOnEvent(SDK.ResourceTreeModel.Events.DOMContentLoaded, true));
+ it('does not update on out of scope DOM content load event',
+ updatesOnEvent(SDK.ResourceTreeModel.Events.DOMContentLoaded, false));
+});
diff --git a/front_end/panels/network/NetworkPanel.test.ts b/front_end/panels/network/NetworkPanel.test.ts
new file mode 100644
index 0000000..7f2ea61
--- /dev/null
+++ b/front_end/panels/network/NetworkPanel.test.ts
@@ -0,0 +1,128 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assertElement, assertShadowRoot} from '../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {createTarget, registerNoopActions} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import * as Common from '../../core/common/common.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import * as Logs from '../../models/logs/logs.js';
+import * as TraceEngine from '../../models/trace/trace.js';
+import * as Coordinator from '../../ui/components/render_coordinator/render_coordinator.js';
+import * as UI from '../../ui/legacy/legacy.js';
+
+import * as Network from './network.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+describeWithMockConnection('NetworkPanel', () => {
+ let target: SDK.Target.Target;
+ let networkPanel: Network.NetworkPanel.NetworkPanel;
+
+ beforeEach(async () => {
+ registerNoopActions(['network.toggle-recording', 'network.clear']);
+
+ target = createTarget();
+ const dummyStorage = new Common.Settings.SettingsStorage({});
+ for (const settingName
+ of ['network-color-code-resource-types', 'network.group-by-frame', 'network-record-film-strip-setting']) {
+ Common.Settings.registerSettingExtension({
+ settingName,
+ settingType: Common.Settings.SettingType.BOOLEAN,
+ defaultValue: false,
+ });
+ }
+ Common.Settings.Settings.instance({
+ forceNew: true,
+ syncedStorage: dummyStorage,
+ globalStorage: dummyStorage,
+ localStorage: dummyStorage,
+ });
+ networkPanel = Network.NetworkPanel.NetworkPanel.instance({forceNew: true, displayScreenshotDelay: 0});
+ networkPanel.markAsRoot();
+ networkPanel.show(document.body);
+ await coordinator.done();
+ });
+
+ afterEach(async () => {
+ await coordinator.done();
+ networkPanel.detach();
+ });
+
+ const tracingTests = (inScope: boolean) => () => {
+ it('starts recording on page reload', async () => {
+ SDK.TargetManager.TargetManager.instance().setScopeTarget(inScope ? target : null);
+ Common.Settings.Settings.instance().moduleSetting('network-record-film-strip-setting').set(true);
+ const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel);
+ assertNotNullOrUndefined(resourceTreeModel);
+ const tracingManager = target.model(TraceEngine.TracingManager.TracingManager);
+ assertNotNullOrUndefined(tracingManager);
+ const tracingStart = sinon.spy(tracingManager, 'start');
+ resourceTreeModel.dispatchEventToListeners(SDK.ResourceTreeModel.Events.WillReloadPage);
+ assert.strictEqual(tracingStart.called, inScope);
+ });
+
+ it('stops recording on page load', async () => {
+ SDK.TargetManager.TargetManager.instance().setScopeTarget(target);
+ Common.Settings.Settings.instance().moduleSetting('network-record-film-strip-setting').set(true);
+ const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel);
+ assertNotNullOrUndefined(resourceTreeModel);
+ const tracingManager = target.model(TraceEngine.TracingManager.TracingManager);
+ assertNotNullOrUndefined(tracingManager);
+ resourceTreeModel.dispatchEventToListeners(SDK.ResourceTreeModel.Events.WillReloadPage);
+ SDK.TargetManager.TargetManager.instance().setScopeTarget(inScope ? target : null);
+
+ const tracingStop = sinon.spy(tracingManager, 'stop');
+ resourceTreeModel.dispatchEventToListeners(SDK.ResourceTreeModel.Events.Load, {resourceTreeModel, loadTime: 42});
+ await new Promise(resolve => setTimeout(resolve, 0));
+ assert.strictEqual(tracingStop.called, inScope);
+ });
+ };
+
+ describe('in scope', tracingTests(true));
+ describe('out of scpe', tracingTests(false));
+});
+
+describeWithMockConnection('NetworkPanel', () => {
+ let networkPanel: Network.NetworkPanel.NetworkPanel;
+
+ beforeEach(async () => {
+ UI.ActionRegistration.maybeRemoveActionExtension('network.toggle-recording');
+ UI.ActionRegistration.maybeRemoveActionExtension('network.clear');
+ await import('./network-meta.js');
+ createTarget();
+ const dummyStorage = new Common.Settings.SettingsStorage({});
+ Common.Settings.Settings.instance({
+ forceNew: true,
+ syncedStorage: dummyStorage,
+ globalStorage: dummyStorage,
+ localStorage: dummyStorage,
+ });
+ const actionRegistryInstance = UI.ActionRegistry.ActionRegistry.instance({forceNew: true});
+ UI.ShortcutRegistry.ShortcutRegistry.instance({forceNew: true, actionRegistry: actionRegistryInstance});
+
+ networkPanel = Network.NetworkPanel.NetworkPanel.instance({forceNew: true, displayScreenshotDelay: 0});
+ networkPanel.markAsRoot();
+ networkPanel.show(document.body);
+ await coordinator.done();
+ });
+
+ afterEach(async () => {
+ await coordinator.done();
+ networkPanel.detach();
+ });
+
+ it('clears network log on button click', async () => {
+ const networkLogResetSpy = sinon.spy(Logs.NetworkLog.NetworkLog.instance(), 'reset');
+ const toolbar = networkPanel.element.querySelector('.network-toolbar-container .toolbar');
+ assertElement(toolbar, HTMLDivElement);
+ assertShadowRoot(toolbar.shadowRoot);
+ const button = toolbar.shadowRoot.querySelector('[aria-label="Clear network log"]');
+ assertElement(button, HTMLButtonElement);
+ button.click();
+ await coordinator.done({waitForWork: true});
+ assert.isTrue(networkLogResetSpy.called);
+ });
+});
diff --git a/front_end/panels/network/NetworkSearchScope.test.ts b/front_end/panels/network/NetworkSearchScope.test.ts
new file mode 100644
index 0000000..a0ba24f
--- /dev/null
+++ b/front_end/panels/network/NetworkSearchScope.test.ts
@@ -0,0 +1,182 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {describeWithLocale} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import * as Common from '../../core/common/common.js';
+import type * as Platform from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import * as Logs from '../../models/logs/logs.js';
+import * as TextUtils from '../../models/text_utils/text_utils.js';
+import * as Workspace from '../../models/workspace/workspace.js';
+import type * as Search from '../search/search.js';
+
+import * as Network from './network.js';
+
+describeWithLocale('NetworkSearchScope', () => {
+ let scope: Network.NetworkSearchScope.NetworkSearchScope;
+
+ beforeEach(() => {
+ const fakeRequest1 = SDK.NetworkRequest.NetworkRequest.createWithoutBackendRequest(
+ 'fakeId1', 'http://example.com/main.js' as Platform.DevToolsPath.UrlString,
+ 'http://example.com/index.html' as Platform.DevToolsPath.UrlString, null);
+ fakeRequest1.setRequestHeaders([{name: 'fooRequestHeader', value: 'value1'}]);
+ fakeRequest1.responseHeaders = [{name: 'fooResponseHeader', value: 'foo value'}];
+ fakeRequest1.setResourceType(Common.ResourceType.resourceTypes.Script);
+ fakeRequest1.mimeType = 'text/javascript';
+ fakeRequest1.setContentDataProvider(
+ async () => new TextUtils.ContentData.ContentData(
+ 'This is the response body of request 1.\nAnd a second line.\n', false, fakeRequest1.mimeType));
+
+ const fakeRequest2 = SDK.NetworkRequest.NetworkRequest.createWithoutBackendRequest(
+ 'fakeId1', 'http://example.com/bundle.min.js' as Platform.DevToolsPath.UrlString,
+ 'http://example.com/index.html' as Platform.DevToolsPath.UrlString, null);
+ fakeRequest2.setRequestHeaders([{name: 'barRequestHeader', value: 'value2'}]);
+ fakeRequest2.responseHeaders = [{name: 'barResponseHeader', value: 'bar value'}];
+ fakeRequest2.setResourceType(Common.ResourceType.resourceTypes.Script);
+ fakeRequest2.mimeType = 'text/javascript';
+ fakeRequest2.setContentDataProvider(
+ async () => new TextUtils.ContentData.ContentData(
+ 'This is the first line.\nAnd another line in the response body of request 2.\n', false,
+ fakeRequest2.mimeType));
+
+ const fakeLog = sinon.createStubInstance(Logs.NetworkLog.NetworkLog, {requests: [fakeRequest1, fakeRequest2]});
+ scope = new Network.NetworkSearchScope.NetworkSearchScope(fakeLog);
+ });
+
+ it('calls finished callback when done', async () => {
+ const searchConfig = new Workspace.SearchConfig.SearchConfig('foo', false, false);
+ const finishedStub = sinon.stub();
+
+ await scope.performSearch(searchConfig, new Common.Progress.Progress(), () => {}, finishedStub);
+
+ assert.isTrue(finishedStub.calledOnceWith(true));
+ });
+
+ it('finds requests by url', async () => {
+ const searchConfig = new Workspace.SearchConfig.SearchConfig('main', false, false);
+ const results: Search.SearchScope.SearchResult[] = [];
+
+ await scope.performSearch(searchConfig, new Common.Progress.Progress(), result => results.push(result), () => {});
+
+ assert.lengthOf(results, 1);
+ assert.strictEqual(results[0].label(), 'main.js');
+ assert.strictEqual(results[0].matchesCount(), 1);
+ assert.strictEqual(results[0].matchLabel(0), 'URL');
+ assert.strictEqual(results[0].matchLineContent(0), 'http://example.com/main.js');
+ });
+
+ it('finds request header names', async () => {
+ const searchConfig = new Workspace.SearchConfig.SearchConfig('fooRequest', false, false);
+ const results: Search.SearchScope.SearchResult[] = [];
+
+ await scope.performSearch(searchConfig, new Common.Progress.Progress(), result => results.push(result), () => {});
+
+ assert.lengthOf(results, 1);
+ assert.strictEqual(results[0].label(), 'main.js');
+ assert.strictEqual(results[0].matchesCount(), 1);
+ assert.strictEqual(results[0].matchLabel(0), 'fooRequestHeader:');
+ assert.strictEqual(results[0].matchLineContent(0), 'value1');
+ });
+
+ it('finds request header values', async () => {
+ const searchConfig = new Workspace.SearchConfig.SearchConfig('value2', false, false);
+ const results: Search.SearchScope.SearchResult[] = [];
+
+ await scope.performSearch(searchConfig, new Common.Progress.Progress(), result => results.push(result), () => {});
+
+ assert.lengthOf(results, 1);
+ assert.strictEqual(results[0].label(), 'bundle.min.js');
+ assert.strictEqual(results[0].matchesCount(), 1);
+ assert.strictEqual(results[0].matchLabel(0), 'barRequestHeader:');
+ assert.strictEqual(results[0].matchLineContent(0), 'value2');
+ });
+
+ it('finds response header names', async () => {
+ const searchConfig = new Workspace.SearchConfig.SearchConfig('barResponse', false, false);
+ const results: Search.SearchScope.SearchResult[] = [];
+
+ await scope.performSearch(searchConfig, new Common.Progress.Progress(), result => results.push(result), () => {});
+
+ assert.lengthOf(results, 1);
+ assert.strictEqual(results[0].label(), 'bundle.min.js');
+ assert.strictEqual(results[0].matchesCount(), 1);
+ assert.strictEqual(results[0].matchLabel(0), 'barResponseHeader:');
+ assert.strictEqual(results[0].matchLineContent(0), 'bar value');
+ });
+
+ it('finds response header values', async () => {
+ const searchConfig = new Workspace.SearchConfig.SearchConfig('foo value', false, false);
+ const results: Search.SearchScope.SearchResult[] = [];
+
+ await scope.performSearch(searchConfig, new Common.Progress.Progress(), result => results.push(result), () => {});
+
+ assert.lengthOf(results, 1);
+ assert.strictEqual(results[0].label(), 'main.js');
+ assert.strictEqual(results[0].matchesCount(), 1);
+ assert.strictEqual(results[0].matchLabel(0), 'fooResponseHeader:');
+ assert.strictEqual(results[0].matchLineContent(0), 'foo value');
+ });
+
+ it('honors "file:" query prefixes to filter requests', async () => {
+ const searchConfig = new Workspace.SearchConfig.SearchConfig('f:main.js value', false, false);
+ const results: Search.SearchScope.SearchResult[] = [];
+
+ await scope.performSearch(searchConfig, new Common.Progress.Progress(), result => results.push(result), () => {});
+
+ assert.lengthOf(results, 1);
+ assert.strictEqual(results[0].label(), 'main.js');
+ assert.strictEqual(results[0].matchesCount(), 2);
+
+ assert.strictEqual(results[0].matchLabel(0), 'fooRequestHeader:');
+ assert.strictEqual(results[0].matchLineContent(0), 'value1');
+ assert.strictEqual(results[0].matchLabel(1), 'fooResponseHeader:');
+ assert.strictEqual(results[0].matchLineContent(1), 'foo value');
+ });
+
+ it('finds matches in response bodies', async () => {
+ const searchConfig = new Workspace.SearchConfig.SearchConfig('response body', false, false);
+ const results: Search.SearchScope.SearchResult[] = [];
+
+ await scope.performSearch(searchConfig, new Common.Progress.Progress(), result => results.push(result), () => {});
+
+ assert.lengthOf(results, 2);
+ assert.strictEqual(results[0].label(), 'bundle.min.js');
+ assert.strictEqual(results[0].matchesCount(), 1);
+ assert.strictEqual(results[0].matchLabel(0), '2');
+ assert.strictEqual(results[0].matchLineContent(0), 'And another line in the response body of request 2.');
+
+ assert.strictEqual(results[1].label(), 'main.js');
+ assert.strictEqual(results[1].matchesCount(), 1);
+ assert.strictEqual(results[1].matchLabel(0), '1');
+ assert.strictEqual(results[1].matchLineContent(0), 'This is the response body of request 1.');
+ });
+
+ it('handles "file:" prefix correctly for response body matches', async () => {
+ const searchConfig = new Workspace.SearchConfig.SearchConfig('f:bundle.min.js response body', false, false);
+ const results: Search.SearchScope.SearchResult[] = [];
+
+ await scope.performSearch(searchConfig, new Common.Progress.Progress(), result => results.push(result), () => {});
+
+ assert.lengthOf(results, 1);
+ assert.strictEqual(results[0].label(), 'bundle.min.js');
+ assert.strictEqual(results[0].matchesCount(), 1);
+ assert.strictEqual(results[0].matchLabel(0), '2');
+ assert.strictEqual(results[0].matchLineContent(0), 'And another line in the response body of request 2.');
+ });
+
+ it('finds matches in response bodies only if all parts of a query match', async () => {
+ const searchConfig = new Workspace.SearchConfig.SearchConfig('"response body""second line"', false, false);
+ const results: Search.SearchScope.SearchResult[] = [];
+
+ await scope.performSearch(searchConfig, new Common.Progress.Progress(), result => results.push(result), () => {});
+
+ assert.lengthOf(results, 1);
+ assert.strictEqual(results[0].label(), 'main.js');
+ assert.strictEqual(results[0].matchesCount(), 2);
+ assert.strictEqual(results[0].matchLabel(0), '1');
+ assert.strictEqual(results[0].matchLineContent(0), 'This is the response body of request 1.');
+ assert.strictEqual(results[0].matchLabel(1), '2');
+ assert.strictEqual(results[0].matchLineContent(1), 'And a second line.');
+ });
+});
diff --git a/front_end/panels/network/RequestCookiesView.test.ts b/front_end/panels/network/RequestCookiesView.test.ts
new file mode 100644
index 0000000..d1bb9cd
--- /dev/null
+++ b/front_end/panels/network/RequestCookiesView.test.ts
@@ -0,0 +1,51 @@
+// 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 {renderElementIntoDOM} from '../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import type * as Platform from '../../core/platform/platform.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import * as Root from '../../core/root/root.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import type * as Protocol from '../../generated/protocol.js';
+
+import * as Network from './network.js';
+
+const {assert} = chai;
+
+function renderCookiesView(request: SDK.NetworkRequest.NetworkRequest): Network.RequestCookiesView.RequestCookiesView {
+ const component = new Network.RequestCookiesView.RequestCookiesView(request);
+ const div = document.createElement('div');
+ renderElementIntoDOM(div);
+ component.markAsRoot();
+ component.show(div);
+ return component;
+}
+
+describeWithMockConnection('RequestCookiesView', () => {
+ beforeEach(() => {
+ Root.Runtime.experiments.register('experimental-cookie-features', '');
+ });
+ it('show a message when request site has cookies in another partition', () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'https://www.example.com/foo.html' as Platform.DevToolsPath.UrlString, '' as Platform.DevToolsPath.UrlString,
+ null, null, null);
+ const component = renderCookiesView(request);
+ const message = component.element.querySelector('.site-has-cookies-in-other-partition');
+ assertNotNullOrUndefined(message);
+ assert.isTrue(message.classList.contains('hidden'));
+ request.addExtraRequestInfo({
+ siteHasCookieInOtherPartition: true,
+ includedRequestCookies: [],
+ blockedRequestCookies: [],
+ connectTiming: {requestTime: 0},
+ requestHeaders: [],
+ } as SDK.NetworkRequest.ExtraRequestInfo);
+ component.willHide();
+ component.wasShown();
+ assert.isFalse(message.classList.contains('hidden'));
+ component.detach();
+ });
+});
diff --git a/front_end/panels/network/RequestPayloadView.test.ts b/front_end/panels/network/RequestPayloadView.test.ts
new file mode 100644
index 0000000..74c1a66
--- /dev/null
+++ b/front_end/panels/network/RequestPayloadView.test.ts
@@ -0,0 +1,13 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Network from './network.js';
+
+describe('RequestPayloadView', () => {
+ it('decodes headers', async () => {
+ const encoded = 'Test+%21%40%23%24%25%5E%26*%28%29_%2B+parameters.';
+ const parameterElement = Network.RequestPayloadView.RequestPayloadView.formatParameter(encoded, '', true);
+ assert.strictEqual(parameterElement.textContent, 'Test !@#$%^&*()_+ parameters.');
+ });
+});
diff --git a/front_end/panels/network/RequestPreviewView.test.ts b/front_end/panels/network/RequestPreviewView.test.ts
new file mode 100644
index 0000000..1132d5a
--- /dev/null
+++ b/front_end/panels/network/RequestPreviewView.test.ts
@@ -0,0 +1,90 @@
+// 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 {renderElementIntoDOM} from '../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {describeWithLocale} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import * as Platform from '../../core/platform/platform.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import type * as Protocol from '../../generated/protocol.js';
+import * as TextUtils from '../../models/text_utils/text_utils.js';
+
+import * as Network from './network.js';
+
+async function contentData(): Promise<TextUtils.ContentData.ContentData> {
+ const content = '<style> p { color: red; }</style><link rel="stylesheet" ref="http://devtools-frontend.test/style">';
+ return new TextUtils.ContentData.ContentData(content, false, 'text/css');
+}
+
+function renderPreviewView(request: SDK.NetworkRequest.NetworkRequest): Network.RequestPreviewView.RequestPreviewView {
+ const component = new Network.RequestPreviewView.RequestPreviewView(request);
+ const div = document.createElement('div');
+ renderElementIntoDOM(div);
+ component.markAsRoot();
+ component.show(div);
+ return component;
+}
+
+describeWithLocale('RequestPreviewView', () => {
+ it('prevents previewed html from making same-site requests', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'http://devtools-frontend.test/content' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.setContentDataProvider(contentData);
+ request.mimeType = Platform.MimeType.MimeType.HTML;
+ const component = renderPreviewView(request);
+ const widget = await component.showPreview();
+ const frame = widget.contentElement.querySelector('iframe');
+ expect(frame).to.be.not.null;
+ expect(frame?.getAttribute('csp')).to.eql('default-src \'none\';img-src data:;style-src \'unsafe-inline\'');
+ component.detach();
+ });
+
+ it('does not add utf-8 charset to the data URL for the HTML preview for already decoded content', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'http://devtools-frontend.test/index.html' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.setContentDataProvider(
+ () => Promise.resolve(new TextUtils.ContentData.ContentData(
+ '<!DOCTYPE html>\n<p>Iñtërnâtiônàlizætiøn☃𝌆</p>', false, 'text/html', 'utf-16')));
+ request.mimeType = Platform.MimeType.MimeType.HTML;
+ request.setCharset('utf-16');
+
+ assert.strictEqual(request.charset(), 'utf-16');
+
+ const component = renderPreviewView(request);
+ const widget = await component.showPreview();
+ const frame = widget.contentElement.querySelector('iframe');
+ assertNotNullOrUndefined(frame);
+
+ assert.notInclude(frame.src, 'charset=utf-8');
+ assert.notInclude(frame.src, ' base64');
+ });
+
+ it('does add the correct charset to the data URL for the HTML preview for base64 content', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'http://devtools-frontend.test/index.html' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ // UTF-16 + base64 encoded "<!DOCTYPE html>\n<p>Iñtërnâtiônàlizætiøn☃𝌆</p>".
+ request.setContentDataProvider(
+ () => Promise.resolve(new TextUtils.ContentData.ContentData(
+ '//48ACEARABPAEMAVABZAFAARQAgAGgAdABtAGwAPgAKADwAcAA+AEkA8QB0AOsAcgBuAOIAdABpAPQAbgDgAGwAaQB6AOYAdABpAPgAbgADJjTYBt88AC8AcAA+AAoA',
+ true, 'text/html', 'utf-16')));
+ request.mimeType = Platform.MimeType.MimeType.HTML;
+ request.setCharset('utf-16');
+
+ assert.strictEqual(request.charset(), 'utf-16');
+
+ const component = renderPreviewView(request);
+ const widget = await component.showPreview();
+ const frame = widget.contentElement.querySelector('iframe');
+ assertNotNullOrUndefined(frame);
+
+ assert.include(frame.src, 'charset=utf-16');
+ assert.include(frame.src, 'base64');
+ });
+});
diff --git a/front_end/panels/network/RequestResponseView.test.ts b/front_end/panels/network/RequestResponseView.test.ts
new file mode 100644
index 0000000..8f2e7e5
--- /dev/null
+++ b/front_end/panels/network/RequestResponseView.test.ts
@@ -0,0 +1,41 @@
+// Copyright 2024 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 {describeWithEnvironment} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import type * 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 TextUtils from '../../models/text_utils/text_utils.js';
+import * as SourceFrame from '../../ui/legacy/components/source_frame/source_frame.js';
+import * as UI from '../../ui/legacy/legacy.js';
+
+import * as Network from './network.js';
+
+describeWithEnvironment('RequestResponseView', () => {
+ it('does show WASM disassembly for WASM module requests', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'http://devtools-frontend.test/module.wasm' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.setContentDataProvider(
+ () => Promise.resolve(new TextUtils.ContentData.ContentData(
+ 'AGFzbQEAAAABBQFgAAF/AwIBAAcHAQNiYXIAAAoGAQQAQQILACQEbmFtZQAQD3Nob3ctd2FzbS0yLndhdAEGAQADYmFyAgMBAAA=',
+ true, 'application/wasm')));
+ request.mimeType = 'application/wasm';
+ request.finished = true;
+
+ const mockedSourceView = new UI.EmptyWidget.EmptyWidget('<disassembled WASM>');
+ const viewStub = sinon.stub(SourceFrame.ResourceSourceFrame.ResourceSourceFrame, 'createSearchableView')
+ .returns(mockedSourceView);
+
+ const component = new Network.RequestResponseView.RequestResponseView(request);
+ component.markAsRoot();
+ const widget = await component.showPreview();
+
+ assert.isTrue(viewStub.calledOnceWithExactly(request, 'application/wasm'));
+ assert.strictEqual(widget, mockedSourceView);
+
+ component.detach();
+ });
+});
diff --git a/front_end/panels/network/components/BUILD.gn b/front_end/panels/network/components/BUILD.gn
index a558eef..b8a4988 100644
--- a/front_end/panels/network/components/BUILD.gn
+++ b/front_end/panels/network/components/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../../scripts/build/ninja/devtools_module.gni")
import("../../../../scripts/build/ninja/generate_css.gni")
+import("../../../../third_party/typescript/typescript.gni")
import("../../visibility.gni")
generate_css("css_files") {
@@ -72,3 +73,20 @@
visibility += devtools_panels_visibility
}
+
+ts_library("unittests") {
+ testonly = true
+ sources = [
+ "HeaderSectionRow.test.ts",
+ "RequestHeaderSection.test.ts",
+ "RequestHeadersView.test.ts",
+ "RequestTrustTokensView.test.ts",
+ "ResponseHeaderSection.test.ts",
+ ]
+
+ deps = [
+ ":bundle",
+ "../../../../test/unittests/front_end/helpers",
+ "../../../core/platform:bundle",
+ ]
+}
diff --git a/front_end/panels/network/components/HeaderSectionRow.test.ts b/front_end/panels/network/components/HeaderSectionRow.test.ts
new file mode 100644
index 0000000..d57d988
--- /dev/null
+++ b/front_end/panels/network/components/HeaderSectionRow.test.ts
@@ -0,0 +1,547 @@
+// 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 {
+ assertElement,
+ assertShadowRoot,
+ dispatchCopyEvent,
+ dispatchInputEvent,
+ dispatchKeyDownEvent,
+ dispatchPasteEvent,
+ getCleanTextContentFromElements,
+ renderElementIntoDOM,
+} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {describeWithEnvironment} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import * as Host from '../../../core/host/host.js';
+import * as Platform from '../../../core/platform/platform.js';
+import * as Protocol from '../../../generated/protocol.js';
+import * as Coordinator from '../../../ui/components/render_coordinator/render_coordinator.js';
+
+import * as NetworkComponents from './components.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+const {assert} = chai;
+
+async function renderHeaderSectionRow(header: NetworkComponents.HeaderSectionRow.HeaderDescriptor): Promise<{
+ component: NetworkComponents.HeaderSectionRow.HeaderSectionRow,
+ nameEditable: HTMLSpanElement | null,
+ valueEditable: HTMLSpanElement | null,
+ scrollIntoViewSpy: sinon.SinonSpy,
+}> {
+ const component = new NetworkComponents.HeaderSectionRow.HeaderSectionRow();
+ const scrollIntoViewSpy = sinon.spy(component, 'scrollIntoView');
+ renderElementIntoDOM(component);
+ assert.isTrue(scrollIntoViewSpy.notCalled);
+ component.data = {header};
+ await coordinator.done();
+ assertShadowRoot(component.shadowRoot);
+
+ let nameEditable: HTMLSpanElement|null = null;
+ const nameEditableComponent = component.shadowRoot.querySelector<NetworkComponents.EditableSpan.EditableSpan>(
+ '.header-name devtools-editable-span');
+ if (nameEditableComponent) {
+ assertElement(nameEditableComponent, HTMLElement);
+ assertShadowRoot(nameEditableComponent.shadowRoot);
+ nameEditable = nameEditableComponent.shadowRoot.querySelector('.editable');
+ assertElement(nameEditable, HTMLSpanElement);
+ }
+
+ let valueEditable: HTMLSpanElement|null = null;
+ const valueEditableComponent = component.shadowRoot.querySelector<NetworkComponents.EditableSpan.EditableSpan>(
+ '.header-value devtools-editable-span');
+ if (valueEditableComponent) {
+ assertElement(valueEditableComponent, HTMLElement);
+ assertShadowRoot(valueEditableComponent.shadowRoot);
+ valueEditable = valueEditableComponent.shadowRoot.querySelector('.editable');
+ assertElement(valueEditable, HTMLSpanElement);
+ }
+
+ return {component, nameEditable, valueEditable, scrollIntoViewSpy};
+}
+
+const hasReloadPrompt = (shadowRoot: ShadowRoot) => {
+ return Boolean(
+ shadowRoot.querySelector('devtools-icon[title="Refresh the page/request for these changes to take effect"]'));
+};
+
+describeWithEnvironment('HeaderSectionRow', () => {
+ it('emits UMA event when a header value is being copied', async () => {
+ const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+ name: Platform.StringUtilities.toLowerCaseString('some-header-name'),
+ value: 'someHeaderValue',
+ };
+ const {component, scrollIntoViewSpy} = await renderHeaderSectionRow(headerData);
+ assertShadowRoot(component.shadowRoot);
+ assert.isTrue(scrollIntoViewSpy.notCalled);
+
+ const spy = sinon.spy(Host.userMetrics, 'actionTaken');
+ const headerValue = component.shadowRoot.querySelector('.header-value');
+ assertElement(headerValue, HTMLElement);
+
+ assert.isTrue(spy.notCalled);
+ dispatchCopyEvent(headerValue);
+ assert.isTrue(spy.calledWith(Host.UserMetrics.Action.NetworkPanelCopyValue));
+ });
+
+ it('renders detailed reason for blocked requests', async () => {
+ const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+ name: Platform.StringUtilities.toLowerCaseString('cross-origin-resource-policy'),
+ value: null,
+ headerNotSet: true,
+ blockedDetails: {
+ explanation: () =>
+ 'To use this resource from a different origin, the server needs to specify a cross-origin resource policy in the response headers:',
+ examples: [
+ {
+ codeSnippet: 'Cross-Origin-Resource-Policy: same-site',
+ comment: () => 'Choose this option if the resource and the document are served from the same site.',
+ },
+ {
+ codeSnippet: 'Cross-Origin-Resource-Policy: cross-origin',
+ comment: () =>
+ 'Only choose this option if an arbitrary website including this resource does not impose a security risk.',
+ },
+ ],
+ link: {url: 'https://web.dev/coop-coep/'},
+ },
+ };
+ const {component} = await renderHeaderSectionRow(headerData);
+ assertShadowRoot(component.shadowRoot);
+
+ const headerName = component.shadowRoot.querySelector('.header-name');
+ assertElement(headerName, HTMLDivElement);
+ const regex = /^\s*not-set\s*cross-origin-resource-policy:\s*$/;
+ assert.isTrue(regex.test(headerName.textContent || ''));
+
+ const headerValue = component.shadowRoot.querySelector('.header-value');
+ assertElement(headerValue, HTMLDivElement);
+ assert.strictEqual(headerValue.textContent?.trim(), '');
+
+ assert.strictEqual(
+ getCleanTextContentFromElements(component.shadowRoot, '.call-to-action')[0],
+ 'To use this resource from a different origin, the server needs to specify a cross-origin ' +
+ 'resource policy in the response headers:Cross-Origin-Resource-Policy: same-siteChoose ' +
+ 'this option if the resource and the document are served from the same site.' +
+ 'Cross-Origin-Resource-Policy: cross-originOnly choose this option if an arbitrary website ' +
+ 'including this resource does not impose a security risk.Learn more',
+ );
+ });
+
+ it('displays decoded "x-client-data"-header', async () => {
+ const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+ name: Platform.StringUtilities.toLowerCaseString('x-client-data'),
+ value: 'CJa2yQEIpLbJAQiTocsB',
+ };
+ const {component} = await renderHeaderSectionRow(headerData);
+ assertShadowRoot(component.shadowRoot);
+
+ const headerName = component.shadowRoot.querySelector('.header-name');
+ assertElement(headerName, HTMLDivElement);
+ assert.strictEqual(headerName.textContent?.trim(), 'x-client-data:');
+
+ const headerValue = component.shadowRoot.querySelector('.header-value');
+ assertElement(headerValue, HTMLDivElement);
+ assert.isTrue(headerValue.classList.contains('flex-columns'));
+
+ assert.isTrue(
+ (getCleanTextContentFromElements(component.shadowRoot, '.header-value')[0]).startsWith('CJa2yQEIpLbJAQiTocsB'));
+
+ assert.strictEqual(
+ getCleanTextContentFromElements(component.shadowRoot, '.header-value code')[0],
+ 'message ClientVariations {// Active client experiment variation IDs.repeated int32 variation_id = [3300118, 3300132, 3330195];\n}',
+ );
+ });
+
+ it('displays info about blocked "Set-Cookie"-headers', async () => {
+ const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+ name: Platform.StringUtilities.toLowerCaseString('set-cookie'),
+ value: 'secure=only; Secure',
+ setCookieBlockedReasons:
+ [Protocol.Network.SetCookieBlockedReason.SecureOnly, Protocol.Network.SetCookieBlockedReason.OverwriteSecure],
+ };
+ const {component} = await renderHeaderSectionRow(headerData);
+ assertShadowRoot(component.shadowRoot);
+
+ const headerName = component.shadowRoot.querySelector('.header-name');
+ assertElement(headerName, HTMLDivElement);
+ assert.strictEqual(headerName.textContent?.trim(), 'set-cookie:');
+
+ const headerValue = component.shadowRoot.querySelector('.header-value');
+ assertElement(headerValue, HTMLDivElement);
+ assert.strictEqual(headerValue.textContent?.trim(), 'secure=only; Secure');
+
+ const icon = component.shadowRoot.querySelector('devtools-icon');
+ assertElement(icon, HTMLElement);
+
+ assert.strictEqual(
+ icon.title,
+ 'This attempt to set a cookie via a Set-Cookie header was blocked because it had the ' +
+ '"Secure" attribute but was not received over a secure connection.\nThis attempt to ' +
+ 'set a cookie via a Set-Cookie header was blocked because it was not sent over a ' +
+ 'secure connection and would have overwritten a cookie with the Secure attribute.');
+ });
+
+ it('can be highlighted', async () => {
+ const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+ name: Platform.StringUtilities.toLowerCaseString('some-header-name'),
+ value: 'someHeaderValue',
+ highlight: true,
+ };
+ const {component, scrollIntoViewSpy} = await renderHeaderSectionRow(headerData);
+ assertShadowRoot(component.shadowRoot);
+ const headerRowElement = component.shadowRoot.querySelector('.row.header-highlight');
+ assertElement(headerRowElement, HTMLDivElement);
+ assert.isTrue(scrollIntoViewSpy.calledOnce);
+ });
+
+ it('allows editing header name and header value', async () => {
+ const originalHeaderName = Platform.StringUtilities.toLowerCaseString('some-header-name');
+ const originalHeaderValue = 'someHeaderValue';
+ const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+ name: originalHeaderName,
+ value: originalHeaderValue,
+ nameEditable: true,
+ valueEditable: true,
+ };
+ const editedHeaderName = 'new-header-name';
+ const editedHeaderValue = 'new value for header';
+
+ const {component, nameEditable, valueEditable} = await renderHeaderSectionRow(headerData);
+ assertShadowRoot(component.shadowRoot);
+
+ let headerValueFromEvent = '';
+ let headerNameFromEvent = '';
+ let headerEditedEventCount = 0;
+ component.addEventListener('headeredited', event => {
+ headerEditedEventCount++;
+ headerValueFromEvent = event.headerValue;
+ headerNameFromEvent = event.headerName;
+ });
+
+ assertElement(nameEditable, HTMLSpanElement);
+ assert.isTrue(hasReloadPrompt(component.shadowRoot));
+ nameEditable.focus();
+ nameEditable.innerText = editedHeaderName;
+ dispatchInputEvent(nameEditable, {inputType: 'insertText', data: editedHeaderName, bubbles: true, composed: true});
+ nameEditable.blur();
+ await coordinator.done();
+
+ assert.strictEqual(headerEditedEventCount, 1);
+ assert.strictEqual(headerNameFromEvent, editedHeaderName);
+ assert.strictEqual(headerValueFromEvent, originalHeaderValue);
+ assert.isTrue(hasReloadPrompt(component.shadowRoot));
+
+ assertElement(valueEditable, HTMLSpanElement);
+ valueEditable.focus();
+ valueEditable.innerText = editedHeaderValue;
+ dispatchInputEvent(
+ valueEditable, {inputType: 'insertText', data: editedHeaderValue, bubbles: true, composed: true});
+ valueEditable.blur();
+ await coordinator.done();
+
+ assert.strictEqual(headerEditedEventCount, 2);
+ assert.strictEqual(headerNameFromEvent, editedHeaderName);
+ assert.strictEqual(headerValueFromEvent, editedHeaderValue);
+ assert.isTrue(hasReloadPrompt(component.shadowRoot));
+
+ nameEditable.focus();
+ nameEditable.innerText = originalHeaderName;
+ dispatchInputEvent(
+ nameEditable, {inputType: 'insertText', data: originalHeaderName, bubbles: true, composed: true});
+ nameEditable.blur();
+ await coordinator.done();
+
+ assert.strictEqual(headerEditedEventCount, 3);
+ assert.strictEqual(headerNameFromEvent, originalHeaderName);
+ assert.strictEqual(headerValueFromEvent, editedHeaderValue);
+ assert.isTrue(hasReloadPrompt(component.shadowRoot));
+
+ valueEditable.focus();
+ valueEditable.innerText = originalHeaderValue;
+ dispatchInputEvent(
+ valueEditable, {inputType: 'insertText', data: originalHeaderValue, bubbles: true, composed: true});
+ valueEditable.blur();
+ await coordinator.done();
+
+ assert.strictEqual(headerEditedEventCount, 4);
+ assert.strictEqual(headerNameFromEvent, originalHeaderName);
+ assert.strictEqual(headerValueFromEvent, originalHeaderValue);
+ assert.isTrue(hasReloadPrompt(component.shadowRoot));
+ });
+
+ it('does not allow setting an emtpy header name', async () => {
+ const headerName = Platform.StringUtilities.toLowerCaseString('some-header-name');
+ const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+ name: headerName,
+ value: 'someHeaderValue',
+ nameEditable: true,
+ valueEditable: true,
+ };
+
+ const {component, nameEditable} = await renderHeaderSectionRow(headerData);
+ assertShadowRoot(component.shadowRoot);
+
+ let headerEditedEventCount = 0;
+ component.addEventListener('headeredited', () => {
+ headerEditedEventCount++;
+ });
+
+ assertElement(nameEditable, HTMLElement);
+ nameEditable.focus();
+ nameEditable.innerText = '';
+ nameEditable.blur();
+
+ assert.strictEqual(headerEditedEventCount, 0);
+ assert.strictEqual(nameEditable.innerText, 'Some-Header-Name');
+ });
+
+ it('resets edited value on escape key', async () => {
+ const originalHeaderValue = 'special chars: \'\"\\.,;!?@_-+/=<>()[]{}|*&^%$#§±`~';
+ const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+ name: Platform.StringUtilities.toLowerCaseString('some-header-name'),
+ value: originalHeaderValue,
+ originalValue: originalHeaderValue,
+ valueEditable: true,
+ };
+
+ const {component, valueEditable} = await renderHeaderSectionRow(headerData);
+ assertShadowRoot(component.shadowRoot);
+
+ let eventCount = 0;
+ component.addEventListener('headeredited', () => {
+ eventCount++;
+ });
+
+ assertElement(valueEditable, HTMLElement);
+ assert.strictEqual(valueEditable.innerText, originalHeaderValue);
+ valueEditable.focus();
+ valueEditable.innerText = 'new value for header';
+ dispatchKeyDownEvent(valueEditable, {key: 'Escape', bubbles: true, composed: true});
+
+ assert.strictEqual(eventCount, 0);
+ assert.strictEqual(valueEditable.innerText, originalHeaderValue);
+ const row = component.shadowRoot.querySelector('.row');
+ assert.isFalse(row?.classList.contains('header-overridden'));
+ });
+
+ it('confirms edited value and exits editing mode on "Enter"-key', async () => {
+ const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+ name: Platform.StringUtilities.toLowerCaseString('some-header-name'),
+ value: 'someHeaderValue',
+ valueEditable: true,
+ };
+ const editedHeaderValue = 'new value for header';
+
+ const {component, valueEditable} = await renderHeaderSectionRow(headerData);
+ assertShadowRoot(component.shadowRoot);
+
+ let headerValueFromEvent = '';
+ let eventCount = 0;
+ component.addEventListener('headeredited', event => {
+ headerValueFromEvent = event.headerValue;
+ eventCount++;
+ });
+
+ assertElement(valueEditable, HTMLElement);
+ valueEditable.focus();
+ valueEditable.innerText = editedHeaderValue;
+ dispatchKeyDownEvent(valueEditable, {key: 'Enter', bubbles: true});
+
+ assert.strictEqual(headerValueFromEvent, editedHeaderValue);
+ assert.strictEqual(eventCount, 1);
+ });
+
+ it('removes formatting for pasted content', async () => {
+ const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+ name: Platform.StringUtilities.toLowerCaseString('some-header-name'),
+ value: 'someHeaderValue',
+ valueEditable: true,
+ };
+
+ const {component, valueEditable} = await renderHeaderSectionRow(headerData);
+ assertShadowRoot(component.shadowRoot);
+
+ let headerValueFromEvent = '';
+ component.addEventListener('headeredited', event => {
+ headerValueFromEvent = event.headerValue;
+ });
+
+ assertElement(valueEditable, HTMLElement);
+ valueEditable.focus();
+ const dt = new DataTransfer();
+ dt.setData('text/plain', 'foo\nbar');
+ dt.setData('text/html', 'This is <b>bold</b>');
+ dispatchPasteEvent(valueEditable, {clipboardData: dt, bubbles: true});
+ valueEditable.blur();
+
+ assert.strictEqual(headerValueFromEvent, 'foo bar');
+ });
+
+ it('adds and removes `header-overridden` class correctly', async () => {
+ const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+ name: Platform.StringUtilities.toLowerCaseString('some-header-name'),
+ value: 'someHeaderValue',
+ originalValue: 'someHeaderValue',
+ valueEditable: true,
+ highlight: true,
+ };
+
+ const {component, valueEditable} = await renderHeaderSectionRow(headerData);
+ assertShadowRoot(component.shadowRoot);
+ assertElement(valueEditable, HTMLElement);
+ const row = component.shadowRoot.querySelector('.row');
+ assert.isFalse(row?.classList.contains('header-overridden'));
+ assert.isTrue(row?.classList.contains('header-highlight'));
+ assert.isFalse(hasReloadPrompt(component.shadowRoot));
+
+ valueEditable.focus();
+ valueEditable.innerText = 'a';
+ dispatchInputEvent(valueEditable, {inputType: 'insertText', data: 'a', bubbles: true, composed: true});
+ await coordinator.done();
+ assert.isTrue(row?.classList.contains('header-overridden'));
+ assert.isFalse(row?.classList.contains('header-highlight'));
+ assert.isTrue(hasReloadPrompt(component.shadowRoot));
+
+ dispatchKeyDownEvent(valueEditable, {key: 'Escape', bubbles: true, composed: true});
+ await coordinator.done();
+ assert.isFalse(component.shadowRoot.querySelector('.row')?.classList.contains('header-overridden'));
+ });
+
+ it('adds and removes `header-overridden` class correctly when editing unset headers', async () => {
+ const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+ name: Platform.StringUtilities.toLowerCaseString('some-header-name'),
+ value: null,
+ originalValue: null,
+ valueEditable: true,
+ };
+
+ const {component, valueEditable} = await renderHeaderSectionRow(headerData);
+ assertShadowRoot(component.shadowRoot);
+ assertElement(valueEditable, HTMLElement);
+ const row = component.shadowRoot.querySelector('.row');
+ assert.isFalse(row?.classList.contains('header-overridden'));
+
+ valueEditable.focus();
+ valueEditable.innerText = 'a';
+ dispatchInputEvent(valueEditable, {inputType: 'insertText', data: 'a', bubbles: true, composed: true});
+ await coordinator.done();
+ assert.isTrue(row?.classList.contains('header-overridden'));
+
+ dispatchKeyDownEvent(valueEditable, {key: 'Escape', bubbles: true, composed: true});
+ await coordinator.done();
+ assert.isFalse(component.shadowRoot.querySelector('.row')?.classList.contains('header-overridden'));
+ });
+
+ it('shows error-icon when header name contains disallowed characters', async () => {
+ const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+ name: Platform.StringUtilities.toLowerCaseString('some-header-name'),
+ value: 'someHeaderValue',
+ originalValue: 'someHeaderValue',
+ nameEditable: true,
+ valueEditable: true,
+ };
+
+ const {component, nameEditable} = await renderHeaderSectionRow(headerData);
+ assertShadowRoot(component.shadowRoot);
+ assertElement(nameEditable, HTMLElement);
+ const row = component.shadowRoot.querySelector('.row');
+ assertElement(row, HTMLDivElement);
+ assert.strictEqual(row.querySelector('devtools-icon.disallowed-characters'), null);
+ assert.isTrue(hasReloadPrompt(component.shadowRoot));
+
+ nameEditable.focus();
+ nameEditable.innerText = '*';
+ dispatchInputEvent(nameEditable, {inputType: 'insertText', data: '*', bubbles: true, composed: true});
+ await coordinator.done();
+ assertElement(row.querySelector('devtools-icon.disallowed-characters'), HTMLElement);
+ assert.isTrue(hasReloadPrompt(component.shadowRoot));
+
+ dispatchKeyDownEvent(nameEditable, {key: 'Escape', bubbles: true, composed: true});
+ await coordinator.done();
+ assert.strictEqual(row.querySelector('devtools-icon.disallowed-characters'), null);
+ assert.isTrue(hasReloadPrompt(component.shadowRoot));
+ });
+
+ it('recoginzes only alphanumeric characters, dashes, and underscores as valid in header names', () => {
+ assert.strictEqual(NetworkComponents.HeaderSectionRow.isValidHeaderName('AlphaNumeric123'), true);
+ assert.strictEqual(NetworkComponents.HeaderSectionRow.isValidHeaderName('Alpha Numeric'), false);
+ assert.strictEqual(NetworkComponents.HeaderSectionRow.isValidHeaderName('AlphaNumeric123!'), false);
+ assert.strictEqual(NetworkComponents.HeaderSectionRow.isValidHeaderName('With-dashes_and_underscores'), true);
+ assert.strictEqual(NetworkComponents.HeaderSectionRow.isValidHeaderName('no*'), false);
+ });
+
+ it('allows removing a header override', async () => {
+ const headerName = Platform.StringUtilities.toLowerCaseString('some-header-name');
+ const headerValue = 'someHeaderValue';
+ const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+ name: headerName,
+ value: headerValue,
+ valueEditable: true,
+ };
+
+ const {component} = await renderHeaderSectionRow(headerData);
+ assertShadowRoot(component.shadowRoot);
+
+ let headerValueFromEvent = '';
+ let headerNameFromEvent = '';
+ let headerRemovedEventCount = 0;
+
+ component.addEventListener('headerremoved', event => {
+ headerRemovedEventCount++;
+ headerValueFromEvent = (event as NetworkComponents.HeaderSectionRow.HeaderRemovedEvent).headerValue;
+ headerNameFromEvent = (event as NetworkComponents.HeaderSectionRow.HeaderRemovedEvent).headerName;
+ });
+
+ const removeHeaderButton = component.shadowRoot.querySelector('.remove-header') as HTMLElement;
+ removeHeaderButton.click();
+
+ assert.strictEqual(headerRemovedEventCount, 1);
+ assert.strictEqual(headerNameFromEvent, headerName);
+ assert.strictEqual(headerValueFromEvent, headerValue);
+ });
+
+ it('removes leading/trailing whitespace when editing header names/values', async () => {
+ const originalHeaderName = Platform.StringUtilities.toLowerCaseString('some-header-name');
+ const originalHeaderValue = 'someHeaderValue';
+ const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+ name: originalHeaderName,
+ value: originalHeaderValue,
+ nameEditable: true,
+ valueEditable: true,
+ };
+ const editedHeaderName = ' new-header-name ';
+ const editedHeaderValue = ' new value for header ';
+
+ const {component, nameEditable, valueEditable} = await renderHeaderSectionRow(headerData);
+ assertShadowRoot(component.shadowRoot);
+
+ let headerValueFromEvent = '';
+ let headerNameFromEvent = '';
+ let headerEditedEventCount = 0;
+ component.addEventListener('headeredited', event => {
+ headerEditedEventCount++;
+ headerValueFromEvent = event.headerValue;
+ headerNameFromEvent = event.headerName;
+ });
+
+ assertElement(nameEditable, HTMLElement);
+ nameEditable.focus();
+ nameEditable.innerText = editedHeaderName;
+ nameEditable.blur();
+
+ assert.strictEqual(headerEditedEventCount, 1);
+ assert.strictEqual(headerNameFromEvent, editedHeaderName.trim());
+ assert.strictEqual(headerValueFromEvent, originalHeaderValue);
+
+ assertElement(valueEditable, HTMLElement);
+ valueEditable.focus();
+ valueEditable.innerText = editedHeaderValue;
+ valueEditable.blur();
+
+ assert.strictEqual(headerEditedEventCount, 2);
+ assert.strictEqual(headerNameFromEvent, editedHeaderName.trim());
+ assert.strictEqual(headerValueFromEvent, editedHeaderValue.trim());
+ });
+});
diff --git a/front_end/panels/network/components/RequestHeaderSection.test.ts b/front_end/panels/network/components/RequestHeaderSection.test.ts
new file mode 100644
index 0000000..ab7ccd7
--- /dev/null
+++ b/front_end/panels/network/components/RequestHeaderSection.test.ts
@@ -0,0 +1,107 @@
+// 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 {
+ assertElement,
+ assertShadowRoot,
+ getCleanTextContentFromElements,
+ renderElementIntoDOM,
+} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {describeWithEnvironment} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import type * as SDK from '../../../core/sdk/sdk.js';
+import * as Coordinator from '../../../ui/components/render_coordinator/render_coordinator.js';
+
+import * as NetworkComponents from './components.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+const {assert} = chai;
+
+async function renderRequestHeaderSection(request: SDK.NetworkRequest.NetworkRequest):
+ Promise<NetworkComponents.RequestHeaderSection.RequestHeaderSection> {
+ const component = new NetworkComponents.RequestHeaderSection.RequestHeaderSection();
+ renderElementIntoDOM(component);
+ component.data = {request};
+ await coordinator.done();
+ assertElement(component, HTMLElement);
+ assertShadowRoot(component.shadowRoot);
+ return component;
+}
+
+describeWithEnvironment('RequestHeaderSection', () => {
+ it('renders provisional headers warning', async () => {
+ const request = {
+ cachedInMemory: () => true,
+ requestHeaders: () =>
+ [{name: ':method', value: 'GET'},
+ {name: 'accept-encoding', value: 'gzip, deflate, br'},
+ {name: 'cache-control', value: 'no-cache'},
+ ],
+ requestHeadersText: () => undefined,
+ } as unknown as SDK.NetworkRequest.NetworkRequest;
+
+ const component = await renderRequestHeaderSection(request);
+ assertShadowRoot(component.shadowRoot);
+
+ assert.strictEqual(
+ getCleanTextContentFromElements(component.shadowRoot, '.call-to-action')[0],
+ 'Provisional headers are shown. Disable cache to see full headers. Learn more',
+ );
+ });
+
+ it('sorts headers alphabetically', async () => {
+ const request = {
+ cachedInMemory: () => true,
+ requestHeaders: () =>
+ [{name: 'Ab', value: 'second'},
+ {name: 'test', value: 'fifth'},
+ {name: 'name', value: 'fourth'},
+ {name: 'abc', value: 'third'},
+ {name: 'aa', value: 'first'},
+ ],
+ requestHeadersText: () => 'placeholderText',
+ } as unknown as SDK.NetworkRequest.NetworkRequest;
+
+ const component = await renderRequestHeaderSection(request);
+ assertShadowRoot(component.shadowRoot);
+
+ const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+ const sorted = Array.from(rows).map(row => {
+ assertShadowRoot(row.shadowRoot);
+ return [
+ row.shadowRoot.querySelector('.header-name')?.textContent?.trim() || '',
+ row.shadowRoot.querySelector('.header-value')?.textContent?.trim() || '',
+ ];
+ });
+ assert.deepStrictEqual(sorted, [
+ ['aa:', 'first'],
+ ['ab:', 'second'],
+ ['abc:', 'third'],
+ ['name:', 'fourth'],
+ ['test:', 'fifth'],
+ ]);
+ });
+
+ it('does not warn about pseudo-headers containing invalid characters', async () => {
+ const request = {
+ cachedInMemory: () => true,
+ requestHeaders: () =>
+ [{name: ':Authority', value: 'www.example.com'},
+ {name: ':Method', value: 'GET'},
+ {name: ':Path', value: '/'},
+ {name: ':Scheme', value: 'https'},
+ ],
+ requestHeadersText: () => 'placeholderText',
+ } as unknown as SDK.NetworkRequest.NetworkRequest;
+
+ const component = await renderRequestHeaderSection(request);
+ assertShadowRoot(component.shadowRoot);
+
+ const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+ for (const row of rows) {
+ assertShadowRoot(row.shadowRoot);
+ assert.isNull(row.shadowRoot.querySelector('.disallowed-characters'));
+ }
+ });
+});
diff --git a/front_end/panels/network/components/RequestHeadersView.test.ts b/front_end/panels/network/components/RequestHeadersView.test.ts
new file mode 100644
index 0000000..3b48b21
--- /dev/null
+++ b/front_end/panels/network/components/RequestHeadersView.test.ts
@@ -0,0 +1,533 @@
+// 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 {
+ assertElement,
+ assertShadowRoot,
+ dispatchClickEvent,
+ dispatchCopyEvent,
+ dispatchKeyDownEvent,
+ getCleanTextContentFromElements,
+ getElementWithinComponent,
+ renderElementIntoDOM,
+} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {
+ deinitializeGlobalVars,
+ describeWithEnvironment,
+} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../../test/unittests/front_end/helpers/MockConnection.js';
+import {
+ createWorkspaceProject,
+ setUpEnvironment,
+} from '../../../../test/unittests/front_end/helpers/OverridesHelpers.js';
+import {createFileSystemUISourceCode} from '../../../../test/unittests/front_end/helpers/UISourceCodeHelpers.js';
+import {
+ recordedMetricsContain,
+ resetRecordedMetrics,
+} from '../../../../test/unittests/front_end/helpers/UserMetricsHelpers.js';
+import * as Common from '../../../core/common/common.js';
+import * as Host from '../../../core/host/host.js';
+import type * as Platform from '../../../core/platform/platform.js';
+import * as SDK from '../../../core/sdk/sdk.js';
+import * as Protocol from '../../../generated/protocol.js';
+import * as Persistence from '../../../models/persistence/persistence.js';
+import * as Coordinator from '../../../ui/components/render_coordinator/render_coordinator.js';
+import * as NetworkForward from '../forward/forward.js';
+
+import * as NetworkComponents from './components.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+const {assert} = chai;
+
+const defaultRequest = {
+ statusCode: 200,
+ statusText: 'OK',
+ requestMethod: 'GET',
+ url: () => 'https://www.example.com/index.html',
+ cachedInMemory: () => true,
+ remoteAddress: () => '199.36.158.100:443',
+ referrerPolicy: () => Protocol.Network.RequestReferrerPolicy.StrictOriginWhenCrossOrigin,
+ sortedResponseHeaders: [
+ {name: 'age', value: '0'},
+ {name: 'cache-control', value: 'max-age=600'},
+ {name: 'content-encoding', value: 'gzip'},
+ {name: 'content-length', value: '661'},
+ ],
+ requestHeadersText: () => '',
+ requestHeaders: () =>
+ [{name: ':method', value: 'GET'}, {name: 'accept-encoding', value: 'gzip, deflate, br'},
+ {name: 'cache-control', value: 'no-cache'}],
+ responseHeadersText: `HTTP/1.1 200 OK
+ age: 0
+ cache-control: max-age=600
+ content-encoding: gzip
+ content-length: 661
+ `,
+ wasBlocked: () => false,
+ blockedResponseCookies: () => [],
+ originalResponseHeaders: [],
+ setCookieHeaders: [],
+ getAssociatedData: () => null,
+ setAssociatedData: () => {},
+} as unknown as SDK.NetworkRequest.NetworkRequest;
+
+async function renderHeadersComponent(request: SDK.NetworkRequest.NetworkRequest) {
+ Object.setPrototypeOf(request, SDK.NetworkRequest.NetworkRequest.prototype);
+ const component = new NetworkComponents.RequestHeadersView.RequestHeadersView(request);
+ renderElementIntoDOM(component);
+ component.wasShown();
+ await coordinator.done({waitForWork: true});
+ return component;
+}
+
+const getTextFromRow = (row: HTMLElement) => {
+ assertShadowRoot(row.shadowRoot);
+ const headerNameElement = row.shadowRoot.querySelector('.header-name');
+ const headerName = headerNameElement?.textContent?.trim() || '';
+ const headerValueElement = row.shadowRoot.querySelector('.header-value');
+ const headerValue = headerValueElement?.textContent?.trim() || '';
+ return [headerName, headerValue];
+};
+
+const getRowsFromCategory = (category: HTMLElement) => {
+ const slot = getElementWithinComponent(category, 'slot', HTMLSlotElement);
+ const section = slot.assignedElements()[0];
+ assertElement(section, HTMLElement);
+ assertShadowRoot(section.shadowRoot);
+ const rows = section.shadowRoot.querySelectorAll('devtools-header-section-row');
+ return Array.from(rows);
+};
+
+const getRowsTextFromCategory = (category: HTMLElement) => {
+ return getRowsFromCategory(category).map(row => getTextFromRow(row));
+};
+
+const getRowHighlightStatus = (container: HTMLElement) => {
+ const rows = getRowsFromCategory(container);
+ return rows.map(row => {
+ const element = row.shadowRoot?.querySelector('.row');
+ return element?.classList.contains('header-highlight') || false;
+ });
+};
+
+describeWithMockConnection('RequestHeadersView', () => {
+ let component: NetworkComponents.RequestHeadersView.RequestHeadersView|null|undefined = null;
+
+ beforeEach(() => {
+ setUpEnvironment();
+ resetRecordedMetrics();
+ });
+
+ afterEach(async () => {
+ component?.remove();
+ await deinitializeGlobalVars();
+ });
+
+ it('renders the General section', async () => {
+ component = await renderHeadersComponent(defaultRequest);
+ assertShadowRoot(component.shadowRoot);
+
+ const generalCategory = component.shadowRoot.querySelector('[aria-label="General"]');
+ assertElement(generalCategory, HTMLElement);
+
+ const names = getCleanTextContentFromElements(generalCategory, '.header-name');
+ assert.deepEqual(names, [
+ 'Request URL:',
+ 'Request Method:',
+ 'Status Code:',
+ 'Remote Address:',
+ 'Referrer Policy:',
+ ]);
+
+ const values = getCleanTextContentFromElements(generalCategory, '.header-value');
+ assert.deepEqual(values, [
+ 'https://www.example.com/index.html',
+ 'GET',
+ '200 OK (from memory cache)',
+ '199.36.158.100:443',
+ 'strict-origin-when-cross-origin',
+ ]);
+ });
+
+ it('status text of a request from cache memory corresponds to the status code', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.statusCode = 200;
+ request.setFromMemoryCache();
+
+ component = await renderHeadersComponent(request);
+ assertShadowRoot(component.shadowRoot);
+
+ const statusCodeSection = component.shadowRoot.querySelector('.status-with-comment');
+ assert.strictEqual('200 OK (from memory cache)', statusCodeSection?.textContent);
+ });
+
+ it('renders request and response headers', async () => {
+ component = await renderHeadersComponent(defaultRequest);
+ assertShadowRoot(component.shadowRoot);
+
+ const responseHeadersCategory = component.shadowRoot.querySelector('[aria-label="Response Headers"]');
+ assertElement(responseHeadersCategory, HTMLElement);
+ assert.deepStrictEqual(
+ getRowsTextFromCategory(responseHeadersCategory),
+ [['age:', '0'], ['cache-control:', 'max-age=600'], ['content-encoding:', 'gzip'], ['content-length:', '661']]);
+
+ const requestHeadersCategory = component.shadowRoot.querySelector('[aria-label="Request Headers"]');
+ assertElement(requestHeadersCategory, HTMLElement);
+ assert.deepStrictEqual(
+ getRowsTextFromCategory(requestHeadersCategory),
+ [[':method:', 'GET'], ['accept-encoding:', 'gzip, deflate, br'], ['cache-control:', 'no-cache']]);
+ });
+
+ it('emits UMA event when a header value is being copied', async () => {
+ component = await renderHeadersComponent(defaultRequest);
+ assertShadowRoot(component.shadowRoot);
+
+ const generalCategory = component.shadowRoot.querySelector('[aria-label="General"]');
+ assertElement(generalCategory, HTMLElement);
+
+ const spy = sinon.spy(Host.userMetrics, 'actionTaken');
+ const headerValue = generalCategory.querySelector('.header-value');
+ assertElement(headerValue, HTMLElement);
+
+ assert.isTrue(spy.notCalled);
+ dispatchCopyEvent(headerValue);
+ assert.isTrue(spy.calledWith(Host.UserMetrics.Action.NetworkPanelCopyValue));
+ });
+
+ it('can switch between source and parsed view', async () => {
+ component = await renderHeadersComponent(defaultRequest);
+ assertShadowRoot(component.shadowRoot);
+
+ const responseHeadersCategory = component.shadowRoot.querySelector('[aria-label="Response Headers"]');
+ assertElement(responseHeadersCategory, HTMLElement);
+
+ // Switch to viewing source view
+ responseHeadersCategory.dispatchEvent(new NetworkComponents.RequestHeadersView.ToggleRawHeadersEvent());
+ await coordinator.done();
+
+ const rawHeadersDiv = responseHeadersCategory.querySelector('.raw-headers');
+ assertElement(rawHeadersDiv, HTMLDivElement);
+ const rawTextContent = rawHeadersDiv.textContent?.replace(/ {2,}/g, '');
+ assert.strictEqual(
+ rawTextContent,
+ 'HTTP/1.1 200 OK\nage: 0\ncache-control: max-age=600\ncontent-encoding: gzip\ncontent-length: 661');
+
+ // Switch to viewing parsed view
+ responseHeadersCategory.dispatchEvent(new NetworkComponents.RequestHeadersView.ToggleRawHeadersEvent());
+ await coordinator.done();
+
+ assert.deepStrictEqual(
+ getRowsTextFromCategory(responseHeadersCategory),
+ [['age:', '0'], ['cache-control:', 'max-age=600'], ['content-encoding:', 'gzip'], ['content-length:', '661']]);
+ });
+
+ it('cuts off long raw headers and shows full content on button click', async () => {
+ const loremIpsum = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
+ incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
+ ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit
+ in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
+ cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`;
+
+ component = await renderHeadersComponent({
+ ...defaultRequest,
+ responseHeadersText: loremIpsum.repeat(10),
+ } as unknown as SDK.NetworkRequest.NetworkRequest);
+ assertShadowRoot(component.shadowRoot);
+
+ const responseHeadersCategory = component.shadowRoot.querySelector('[aria-label="Response Headers"]');
+ assertElement(responseHeadersCategory, HTMLElement);
+
+ // Switch to viewing source view
+ responseHeadersCategory.dispatchEvent(new NetworkComponents.RequestHeadersView.ToggleRawHeadersEvent());
+ await coordinator.done();
+
+ const rawHeadersDiv = responseHeadersCategory.querySelector('.raw-headers');
+ assertElement(rawHeadersDiv, HTMLDivElement);
+ const shortenedRawTextContent = rawHeadersDiv.textContent?.replace(/ {2,}/g, '');
+ assert.strictEqual(shortenedRawTextContent?.length, 2896);
+
+ const showMoreButton = responseHeadersCategory.querySelector('devtools-button');
+ assertElement(showMoreButton, HTMLElement);
+ assert.strictEqual(showMoreButton.textContent, 'Show more');
+ showMoreButton.click();
+ await coordinator.done();
+
+ const noMoreShowMoreButton = responseHeadersCategory.querySelector('devtools-button');
+ assert.isNull(noMoreShowMoreButton);
+
+ const fullRawTextContent = rawHeadersDiv.textContent?.replace(/ {2,}/g, '');
+ assert.strictEqual(fullRawTextContent?.length, 4450);
+ });
+
+ it('re-renders on request headers update', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'https://www.example.com/foo.html' as Platform.DevToolsPath.UrlString, '' as Platform.DevToolsPath.UrlString,
+ null, null, null);
+ request.responseHeaders = [{name: 'originalName', value: 'originalValue'}];
+
+ component = await renderHeadersComponent(request);
+ assertShadowRoot(component.shadowRoot);
+ const responseHeadersCategory = component.shadowRoot.querySelector('[aria-label="Response Headers"]');
+ assertElement(responseHeadersCategory, HTMLElement);
+
+ const spy = sinon.spy(component, 'render');
+ assert.isTrue(spy.notCalled);
+ assert.deepStrictEqual(getRowsTextFromCategory(responseHeadersCategory), [['originalname:', 'originalValue']]);
+
+ request.responseHeaders = [{name: 'updatedName', value: 'updatedValue'}];
+ assert.isTrue(spy.calledOnce);
+ await coordinator.done();
+ assert.deepStrictEqual(getRowsTextFromCategory(responseHeadersCategory), [['updatedname:', 'updatedValue']]);
+ });
+
+ it('can highlight individual response headers', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'https://www.example.com/foo.html' as Platform.DevToolsPath.UrlString, '' as Platform.DevToolsPath.UrlString,
+ null, null, null);
+ request.responseHeaders = [
+ {name: 'foo', value: 'bar'},
+ {name: 'highlightMe', value: 'some value'},
+ {name: 'DevTools', value: 'rock'},
+ ];
+
+ component = await renderHeadersComponent(request);
+ assertShadowRoot(component.shadowRoot);
+
+ const responseHeadersCategory = component.shadowRoot.querySelector('[aria-label="Response Headers"]');
+ assertElement(responseHeadersCategory, HTMLElement);
+ assert.deepStrictEqual(
+ getRowsTextFromCategory(responseHeadersCategory),
+ [['devtools:', 'rock'], ['foo:', 'bar'], ['highlightme:', 'some value']]);
+
+ assert.deepStrictEqual(getRowHighlightStatus(responseHeadersCategory), [false, false, false]);
+ component.revealHeader(NetworkForward.UIRequestLocation.UIHeaderSection.Response, 'HiGhLiGhTmE');
+ await coordinator.done();
+ assert.deepStrictEqual(getRowHighlightStatus(responseHeadersCategory), [false, false, true]);
+ });
+
+ it('can highlight individual request headers', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'https://www.example.com/foo.html' as Platform.DevToolsPath.UrlString, '' as Platform.DevToolsPath.UrlString,
+ null, null, null);
+ request.setRequestHeaders([
+ {name: 'foo', value: 'bar'},
+ {name: 'highlightMe', value: 'some value'},
+ {name: 'DevTools', value: 'rock'},
+ ]);
+
+ component = await renderHeadersComponent(request);
+ assertShadowRoot(component.shadowRoot);
+
+ const requestHeadersCategory = component.shadowRoot.querySelector('[aria-label="Request Headers"]');
+ assertElement(requestHeadersCategory, HTMLElement);
+ assert.deepStrictEqual(
+ getRowsTextFromCategory(requestHeadersCategory),
+ [['devtools:', 'rock'], ['foo:', 'bar'], ['highlightme:', 'some value']]);
+
+ assert.deepStrictEqual(getRowHighlightStatus(requestHeadersCategory), [false, false, false]);
+ component.revealHeader(NetworkForward.UIRequestLocation.UIHeaderSection.Request, 'HiGhLiGhTmE');
+ await coordinator.done();
+ assert.deepStrictEqual(getRowHighlightStatus(requestHeadersCategory), [false, false, true]);
+ });
+
+ it('renders a link to \'.headers\'', async () => {
+ const {project} = createFileSystemUISourceCode({
+ url: 'file:///path/to/overrides/www.example.com/.headers' as Platform.DevToolsPath.UrlString,
+ mimeType: 'text/plain',
+ fileSystemPath: 'file:///path/to/overrides',
+ });
+
+ await Persistence.NetworkPersistenceManager.NetworkPersistenceManager.instance().setProject(project);
+
+ component = await renderHeadersComponent(defaultRequest);
+ assertShadowRoot(component.shadowRoot);
+
+ const responseHeadersCategory = component.shadowRoot.querySelector('[aria-label="Response Headers"]');
+ assertElement(responseHeadersCategory, HTMLElement);
+ assertShadowRoot(responseHeadersCategory.shadowRoot);
+
+ const linkElements = responseHeadersCategory.shadowRoot.querySelectorAll('x-link');
+ assert.strictEqual(linkElements.length, 2);
+
+ assertElement(linkElements[0], HTMLElement);
+ assert.strictEqual(linkElements[0].title, 'https://goo.gle/devtools-override');
+
+ assertElement(linkElements[1], HTMLElement);
+ assert.strictEqual(linkElements[1].textContent?.trim(), Persistence.NetworkPersistenceManager.HEADERS_FILENAME);
+ });
+
+ it('does not render a link to \'.headers\' if a matching \'.headers\' does not exist', async () => {
+ const {project} = createFileSystemUISourceCode({
+ url: 'file:///path/to/overrides/www.mismatch.com/.headers' as Platform.DevToolsPath.UrlString,
+ mimeType: 'text/plain',
+ fileSystemPath: 'file:///path/to/overrides',
+ });
+
+ await Persistence.NetworkPersistenceManager.NetworkPersistenceManager.instance().setProject(project);
+
+ component = await renderHeadersComponent(defaultRequest);
+ assertShadowRoot(component.shadowRoot);
+
+ const responseHeadersCategory = component.shadowRoot.querySelector('[aria-label="Response Headers"]');
+ assertElement(responseHeadersCategory, HTMLElement);
+ assertShadowRoot(responseHeadersCategory.shadowRoot);
+
+ const linkElement = responseHeadersCategory.shadowRoot.querySelector('x-link');
+ assert.isNull(linkElement);
+ });
+
+ it('allows enabling header overrides via buttons located next to each header', async () => {
+ Common.Settings.Settings.instance().moduleSetting('persistence-network-overrides-enabled').set(false);
+
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com/' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.responseHeaders = [
+ {name: 'foo', value: 'bar'},
+ ];
+
+ await createWorkspaceProject('file:///path/to/overrides' as Platform.DevToolsPath.UrlString, [
+ {
+ name: '.headers',
+ path: 'www.example.com/',
+ content: '[]',
+ },
+ ]);
+
+ component = await renderHeadersComponent(request);
+ assertShadowRoot(component.shadowRoot);
+ const responseHeaderSection = component.shadowRoot.querySelector('devtools-response-header-section');
+ assertElement(responseHeaderSection, HTMLElement);
+ assertShadowRoot(responseHeaderSection.shadowRoot);
+ const headerRow = responseHeaderSection.shadowRoot.querySelector('devtools-header-section-row');
+ assertElement(headerRow, HTMLElement);
+ assertShadowRoot(headerRow.shadowRoot);
+
+ const checkRow = (shadowRoot: ShadowRoot, headerName: string, headerValue: string, isEditable: boolean) => {
+ assert.strictEqual(shadowRoot.querySelector('.header-name')?.textContent?.trim(), headerName);
+ const valueEditableComponent =
+ shadowRoot.querySelector<NetworkComponents.EditableSpan.EditableSpan>('.header-value devtools-editable-span');
+ if (isEditable) {
+ assertElement(valueEditableComponent, HTMLElement);
+ assertShadowRoot(valueEditableComponent.shadowRoot);
+ const valueEditable = valueEditableComponent.shadowRoot.querySelector('.editable');
+ assertElement(valueEditable, HTMLSpanElement);
+ assert.strictEqual(valueEditable.textContent?.trim(), headerValue);
+ } else {
+ assert.strictEqual(shadowRoot.querySelector('.header-value')?.textContent?.trim(), headerValue);
+ assert.strictEqual(valueEditableComponent, null);
+ }
+ };
+
+ checkRow(headerRow.shadowRoot, 'foo:', 'bar', false);
+
+ const pencilButton = headerRow.shadowRoot.querySelector('.enable-editing');
+ assertElement(pencilButton, HTMLElement);
+ pencilButton.click();
+ await coordinator.done();
+
+ checkRow(headerRow.shadowRoot, 'foo:', 'bar', true);
+ assert.isTrue(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken,
+ Host.UserMetrics.Action.HeaderOverrideEnableEditingClicked));
+ assert.isTrue(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken,
+ Host.UserMetrics.Action.PersistenceNetworkOverridesEnabled));
+ });
+
+ it('records metrics when a new \'.headers\' file is created', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId, 'https://www.example.com/' as Platform.DevToolsPath.UrlString,
+ '' as Platform.DevToolsPath.UrlString, null, null, null);
+ request.responseHeaders = [
+ {name: 'foo', value: 'bar'},
+ ];
+ await createWorkspaceProject('file:///path/to/overrides' as Platform.DevToolsPath.UrlString, []);
+
+ component = await renderHeadersComponent(request);
+ assertShadowRoot(component.shadowRoot);
+ const responseHeaderSection = component.shadowRoot.querySelector('devtools-response-header-section');
+ assertElement(responseHeaderSection, HTMLElement);
+ assertShadowRoot(responseHeaderSection.shadowRoot);
+ const headerRow = responseHeaderSection.shadowRoot.querySelector('devtools-header-section-row');
+ assertElement(headerRow, HTMLElement);
+ assertShadowRoot(headerRow.shadowRoot);
+
+ const pencilButton = headerRow.shadowRoot.querySelector('.enable-editing');
+ assertElement(pencilButton, HTMLElement);
+
+ assert.isFalse(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken,
+ Host.UserMetrics.Action.HeaderOverrideFileCreated));
+
+ pencilButton.click();
+ await coordinator.done();
+
+ assert.isTrue(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken,
+ Host.UserMetrics.Action.HeaderOverrideFileCreated));
+ });
+});
+
+describeWithEnvironment('RequestHeadersView\'s Category', () => {
+ it('can be opened and closed with right/left arrow keys', async () => {
+ const component = new NetworkComponents.RequestHeadersView.Category();
+ renderElementIntoDOM(component);
+ component.data = {
+ name: 'general',
+ title: 'General' as Common.UIString.LocalizedString,
+ loggingContext: 'details-general',
+ };
+ assertShadowRoot(component.shadowRoot);
+ await coordinator.done();
+
+ const details = getElementWithinComponent(component, 'details', HTMLDetailsElement);
+ const summary = getElementWithinComponent(component, 'summary', HTMLElement);
+
+ assert.isTrue(details.hasAttribute('open'));
+
+ dispatchKeyDownEvent(summary, {key: 'ArrowLeft'});
+ assert.isFalse(details.hasAttribute('open'));
+
+ dispatchKeyDownEvent(summary, {key: 'ArrowDown'});
+ assert.isFalse(details.hasAttribute('open'));
+
+ dispatchKeyDownEvent(summary, {key: 'ArrowLeft'});
+ assert.isFalse(details.hasAttribute('open'));
+
+ dispatchKeyDownEvent(summary, {key: 'ArrowRight'});
+ assert.isTrue(details.hasAttribute('open'));
+
+ dispatchKeyDownEvent(summary, {key: 'ArrowUp'});
+ assert.isTrue(details.hasAttribute('open'));
+ });
+
+ it('dispatches an event when its checkbox is toggled', async () => {
+ let eventCounter = 0;
+ const component = new NetworkComponents.RequestHeadersView.Category();
+ renderElementIntoDOM(component);
+ component.data = {
+ name: 'responseHeaders',
+ title: 'Response Headers' as Common.UIString.LocalizedString,
+ headerCount: 3,
+ checked: false,
+ loggingContext: 'details-response-headers',
+ };
+ assertShadowRoot(component.shadowRoot);
+ await coordinator.done();
+ component.addEventListener(NetworkComponents.RequestHeadersView.ToggleRawHeadersEvent.eventName, () => {
+ eventCounter += 1;
+ });
+ const checkbox = getElementWithinComponent(component, 'input', HTMLInputElement);
+
+ dispatchClickEvent(checkbox);
+ assert.strictEqual(eventCounter, 1);
+ });
+});
diff --git a/front_end/panels/network/components/RequestTrustTokensView.test.ts b/front_end/panels/network/components/RequestTrustTokensView.test.ts
new file mode 100644
index 0000000..6ad5405
--- /dev/null
+++ b/front_end/panels/network/components/RequestTrustTokensView.test.ts
@@ -0,0 +1,83 @@
+// Copyright 2020 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 {
+ getElementsWithinComponent,
+ getElementWithinComponent,
+ renderElementIntoDOM,
+} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {describeWithLocale} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {assertNotNullOrUndefined} from '../../../core/platform/platform.js';
+import type * as SDK from '../../../core/sdk/sdk.js';
+import * as Protocol from '../../../generated/protocol.js';
+
+import * as NetworkComponents from './components.js';
+
+const {assert} = chai;
+
+describeWithLocale('RequestTrustTokensView', () => {
+ const mockId = 'mockId' as Protocol.Network.RequestId;
+
+ const makeRequest =
+ (params?: Protocol.Network.TrustTokenParams, result?: Protocol.Network.TrustTokenOperationDoneEvent) => {
+ return {trustTokenParams: () => params, trustTokenOperationDoneEvent: () => result} as
+ SDK.NetworkRequest.NetworkRequest;
+ };
+
+ const renderRequestTrustTokensView = (request: SDK.NetworkRequest.NetworkRequest) => {
+ const component = new NetworkComponents.RequestTrustTokensView.RequestTrustTokensView(request);
+ renderElementIntoDOM(component);
+ void component.render();
+ return component;
+ };
+
+ it('renders the RefreshPolicy for redemptions', () => {
+ const component = renderRequestTrustTokensView(makeRequest({
+ operation: Protocol.Network.TrustTokenOperationType.Redemption,
+ refreshPolicy: Protocol.Network.TrustTokenParamsRefreshPolicy.UseCached,
+ }));
+
+ const [typeSpan, refreshPolicySpan] =
+ getElementsWithinComponent(component, 'devtools-report-value.code', HTMLElement);
+ assert.strictEqual(typeSpan.textContent, 'Redemption');
+ assert.strictEqual(refreshPolicySpan.textContent, 'UseCached');
+ });
+
+ it('renders all issuers as a list', () => {
+ const expectedIssuers = ['example.org', 'foo.dev', 'bar.com'];
+ const component = renderRequestTrustTokensView(makeRequest({
+ operation: Protocol.Network.TrustTokenOperationType.Signing,
+ issuers: expectedIssuers,
+ } as Protocol.Network.TrustTokenParams));
+
+ const issuerElements = getElementsWithinComponent(component, 'ul.issuers-list > li', HTMLElement);
+ const actualIssuers = [...issuerElements].map(e => e.textContent);
+
+ assert.deepStrictEqual(actualIssuers.sort(), expectedIssuers.sort());
+ });
+
+ it('renders a result section with success status for successful requests', () => {
+ const component = renderRequestTrustTokensView(makeRequest(undefined, {
+ status: Protocol.Network.TrustTokenOperationDoneEventStatus.Ok,
+ type: Protocol.Network.TrustTokenOperationType.Issuance,
+ requestId: mockId,
+ }));
+
+ const simpleText = getElementWithinComponent(component, 'span > strong', HTMLElement);
+ assertNotNullOrUndefined(simpleText);
+ assert.strictEqual(simpleText.textContent, 'Success');
+ });
+
+ it('renders a result section with failure status for failed requests', () => {
+ const component = renderRequestTrustTokensView(makeRequest(undefined, {
+ status: Protocol.Network.TrustTokenOperationDoneEventStatus.BadResponse,
+ type: Protocol.Network.TrustTokenOperationType.Issuance,
+ requestId: mockId,
+ }));
+
+ const simpleText = getElementWithinComponent(component, 'span > strong', HTMLElement);
+ assertNotNullOrUndefined(simpleText);
+ assert.strictEqual(simpleText.textContent, 'Failure');
+ });
+});
diff --git a/front_end/panels/network/components/ResponseHeaderSection.test.ts b/front_end/panels/network/components/ResponseHeaderSection.test.ts
new file mode 100644
index 0000000..70e8912
--- /dev/null
+++ b/front_end/panels/network/components/ResponseHeaderSection.test.ts
@@ -0,0 +1,1316 @@
+// 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 {
+ assertElement,
+ assertShadowRoot,
+ dispatchInputEvent,
+ getCleanTextContentFromElements,
+ renderElementIntoDOM,
+} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {describeWithEnvironment} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {
+ createWorkspaceProject,
+ setUpEnvironment,
+} from '../../../../test/unittests/front_end/helpers/OverridesHelpers.js';
+import {
+ recordedMetricsContain,
+ resetRecordedMetrics,
+} from '../../../../test/unittests/front_end/helpers/UserMetricsHelpers.js';
+import * as Common from '../../../core/common/common.js';
+import * as Host from '../../../core/host/host.js';
+import type * as Platform from '../../../core/platform/platform.js';
+import * as SDK from '../../../core/sdk/sdk.js';
+import * as Protocol from '../../../generated/protocol.js';
+import type * as Persistence from '../../../models/persistence/persistence.js';
+import * as Workspace from '../../../models/workspace/workspace.js';
+import * as Coordinator from '../../../ui/components/render_coordinator/render_coordinator.js';
+import * as NetworkForward from '../forward/forward.js';
+
+import * as NetworkComponents from './components.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+const {assert} = chai;
+
+const enum HeaderAttribute {
+ HeaderName = 'HeaderName',
+ HeaderValue = 'HeaderValue',
+}
+
+async function renderResponseHeaderSection(request: SDK.NetworkRequest.NetworkRequest):
+ Promise<NetworkComponents.ResponseHeaderSection.ResponseHeaderSection> {
+ const component = new NetworkComponents.ResponseHeaderSection.ResponseHeaderSection();
+ renderElementIntoDOM(component);
+ Object.setPrototypeOf(request, SDK.NetworkRequest.NetworkRequest.prototype);
+ component.data = {
+ request,
+ toReveal: {section: NetworkForward.UIRequestLocation.UIHeaderSection.Response, header: 'highlighted-header'},
+ };
+ await coordinator.done();
+ assertElement(component, HTMLElement);
+ assertShadowRoot(component.shadowRoot);
+ return component;
+}
+
+async function editHeaderRow(
+ component: NetworkComponents.ResponseHeaderSection.ResponseHeaderSection, index: number,
+ headerAttribute: HeaderAttribute, newValue: string) {
+ assertShadowRoot(component.shadowRoot);
+ const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+ assert.isTrue(rows.length >= index + 1, 'Trying to edit row with index greater than # of rows.');
+ const row = rows[index];
+ assertShadowRoot(row.shadowRoot);
+ const selector = headerAttribute === HeaderAttribute.HeaderName ? '.header-name' : '.header-value';
+ const editableComponent = row.shadowRoot.querySelector(`${selector} devtools-editable-span`);
+ assertElement(editableComponent, HTMLElement);
+ assertShadowRoot(editableComponent.shadowRoot);
+ const editable = editableComponent.shadowRoot.querySelector('.editable');
+ assertElement(editable, HTMLSpanElement);
+ editable.focus();
+ editable.innerText = newValue;
+ dispatchInputEvent(editable, {inputType: 'insertText', data: newValue, bubbles: true, composed: true});
+ editable.blur();
+ await coordinator.done();
+}
+
+async function removeHeaderRow(
+ component: NetworkComponents.ResponseHeaderSection.ResponseHeaderSection, index: number): Promise<void> {
+ assertShadowRoot(component.shadowRoot);
+ const row = component.shadowRoot.querySelectorAll('devtools-header-section-row')[index];
+ assertElement(row, HTMLElement);
+ assertShadowRoot(row.shadowRoot);
+ const button = row.shadowRoot.querySelector('.remove-header');
+ assertElement(button, HTMLElement);
+ button.click();
+ await coordinator.done();
+}
+
+async function setupHeaderEditing(
+ headerOverridesFileContent: string, actualHeaders: SDK.NetworkRequest.NameValue[],
+ originalHeaders: SDK.NetworkRequest.NameValue[]) {
+ const request = {
+ sortedResponseHeaders: actualHeaders,
+ originalResponseHeaders: originalHeaders,
+ setCookieHeaders: [],
+ blockedResponseCookies: () => [],
+ wasBlocked: () => false,
+ url: () => 'https://www.example.com/',
+ getAssociatedData: () => null,
+ setAssociatedData: () => {},
+ } as unknown as SDK.NetworkRequest.NetworkRequest;
+
+ return setupHeaderEditingWithRequest(headerOverridesFileContent, request);
+}
+
+async function setupHeaderEditingWithRequest(
+ headerOverridesFileContent: string, request: SDK.NetworkRequest.NetworkRequest) {
+ const networkPersistenceManager =
+ await createWorkspaceProject('file:///path/to/overrides' as Platform.DevToolsPath.UrlString, [
+ {
+ name: '.headers',
+ path: 'www.example.com/',
+ content: headerOverridesFileContent,
+ },
+ ]);
+
+ const project = networkPersistenceManager.project();
+ let spy = sinon.spy();
+ if (project) {
+ const uiSourceCode = project.uiSourceCodeForURL(
+ 'file:///path/to/overrides/www.example.com/.headers' as Platform.DevToolsPath.UrlString);
+ if (uiSourceCode) {
+ spy = sinon.spy(uiSourceCode, 'setWorkingCopy');
+ }
+ }
+
+ const component = await renderResponseHeaderSection(request);
+ assertShadowRoot(component.shadowRoot);
+ return {component, spy};
+}
+
+function checkHeaderSectionRow(
+ row: NetworkComponents.HeaderSectionRow.HeaderSectionRow, headerName: string, headerValue: string,
+ isOverride: boolean, isNameEditable: boolean, isValueEditable: boolean, isHighlighted: boolean = false,
+ isDeleted: boolean = false): void {
+ assertShadowRoot(row.shadowRoot);
+ const rowElement = row.shadowRoot.querySelector('.row');
+ assert.strictEqual(rowElement?.classList.contains('header-overridden'), isOverride);
+ assert.strictEqual(rowElement?.classList.contains('header-highlight'), isHighlighted);
+ assert.strictEqual(rowElement?.classList.contains('header-deleted'), isDeleted);
+
+ const nameEditableComponent =
+ row.shadowRoot.querySelector<NetworkComponents.EditableSpan.EditableSpan>('.header-name devtools-editable-span');
+ if (isNameEditable) {
+ assertElement(nameEditableComponent, HTMLElement);
+ assertShadowRoot(nameEditableComponent.shadowRoot);
+ const nameEditable = nameEditableComponent.shadowRoot.querySelector('.editable');
+ assertElement(nameEditable, HTMLSpanElement);
+ const textContent =
+ nameEditable.textContent?.trim() + (row.shadowRoot.querySelector('.header-name')?.textContent || '').trim();
+ assert.strictEqual(textContent, headerName);
+ } else {
+ assert.strictEqual(nameEditableComponent, null);
+ assert.strictEqual(row.shadowRoot.querySelector('.header-name')?.textContent?.trim(), headerName);
+ }
+
+ const valueEditableComponent =
+ row.shadowRoot.querySelector<NetworkComponents.EditableSpan.EditableSpan>('.header-value devtools-editable-span');
+ if (isValueEditable) {
+ assertElement(valueEditableComponent, HTMLElement);
+ assertShadowRoot(valueEditableComponent.shadowRoot);
+ const valueEditable = valueEditableComponent.shadowRoot.querySelector('.editable');
+ assertElement(valueEditable, HTMLSpanElement);
+ assert.strictEqual(valueEditable.textContent?.trim(), headerValue);
+ } else {
+ assert.strictEqual(valueEditableComponent, null);
+ assert.strictEqual(row.shadowRoot.querySelector('.header-value')?.textContent?.trim(), headerValue);
+ }
+}
+
+function isRowFocused(
+ component: NetworkComponents.ResponseHeaderSection.ResponseHeaderSection, rowIndex: number): boolean {
+ assertShadowRoot(component.shadowRoot);
+ const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+ assert.isTrue(rows.length > rowIndex);
+ return Boolean(rows[rowIndex].shadowRoot?.activeElement);
+}
+
+describeWithEnvironment('ResponseHeaderSection', () => {
+ beforeEach(async () => {
+ await setUpEnvironment();
+ resetRecordedMetrics();
+ });
+
+ it('renders detailed reason for blocked requests', async () => {
+ const request = {
+ sortedResponseHeaders: [
+ {name: 'content-length', value: '661'},
+ ],
+ blockedResponseCookies: () => [],
+ wasBlocked: () => true,
+ blockedReason: () => Protocol.Network.BlockedReason.CorpNotSameOriginAfterDefaultedToSameOriginByCoep,
+ originalResponseHeaders: [],
+ setCookieHeaders: [],
+ url: () => 'https://www.example.com/',
+ getAssociatedData: () => null,
+ setAssociatedData: () => {},
+ } as unknown as SDK.NetworkRequest.NetworkRequest;
+
+ const component = await renderResponseHeaderSection(request);
+ assertShadowRoot(component.shadowRoot);
+
+ const row = component.shadowRoot.querySelectorAll('devtools-header-section-row')[1];
+ assertElement(row, HTMLElement);
+ assertShadowRoot(row.shadowRoot);
+
+ const regex = /^\s*not-set\s*cross-origin-resource-policy:\s*$/;
+ assert.isTrue(regex.test(row.shadowRoot.querySelector('.header-name')?.textContent || ''));
+ assert.strictEqual(row.shadowRoot.querySelector('.header-value')?.textContent?.trim(), '');
+ assert.strictEqual(
+ getCleanTextContentFromElements(row.shadowRoot, '.call-to-action')[0],
+ 'To use this resource from a different origin, the server needs to specify a cross-origin ' +
+ 'resource policy in the response headers:Cross-Origin-Resource-Policy: same-siteChoose ' +
+ 'this option if the resource and the document are served from the same site.' +
+ 'Cross-Origin-Resource-Policy: cross-originOnly choose this option if an arbitrary website ' +
+ 'including this resource does not impose a security risk.Learn more',
+ );
+ });
+
+ it('displays info about blocked "Set-Cookie"-headers', async () => {
+ const request = {
+ sortedResponseHeaders: [{name: 'Set-Cookie', value: 'secure=only; Secure'}],
+ blockedResponseCookies: () => [{
+ blockedReasons: ['SecureOnly', 'OverwriteSecure'],
+ cookieLine: 'secure=only; Secure',
+ cookie: null,
+ }],
+ wasBlocked: () => false,
+ originalResponseHeaders: [],
+ setCookieHeaders: [],
+ url: () => 'https://www.example.com/',
+ getAssociatedData: () => null,
+ setAssociatedData: () => {},
+ } as unknown as SDK.NetworkRequest.NetworkRequest;
+
+ const component = await renderResponseHeaderSection(request);
+ assertShadowRoot(component.shadowRoot);
+
+ const row = component.shadowRoot.querySelector('devtools-header-section-row');
+ assertElement(row, HTMLElement);
+ assertShadowRoot(row.shadowRoot);
+
+ assert.strictEqual(row.shadowRoot.querySelector('.header-name')?.textContent?.trim(), 'set-cookie:');
+ assert.strictEqual(row.shadowRoot.querySelector('.header-value')?.textContent?.trim(), 'secure=only; Secure');
+
+ const icon = row.shadowRoot.querySelector('devtools-icon');
+ assertElement(icon, HTMLElement);
+ assert.strictEqual(
+ icon.title,
+ 'This attempt to set a cookie via a Set-Cookie header was blocked because it had the ' +
+ '"Secure" attribute but was not received over a secure connection.\nThis attempt to ' +
+ 'set a cookie via a Set-Cookie header was blocked because it was not sent over a ' +
+ 'secure connection and would have overwritten a cookie with the Secure attribute.');
+ });
+
+ it('marks overridden headers', async () => {
+ const request = {
+ sortedResponseHeaders: [
+ // keep names in alphabetical order
+ {name: 'duplicate-both-no-mismatch', value: 'foo'},
+ {name: 'duplicate-both-no-mismatch', value: 'bar'},
+ {name: 'duplicate-both-with-mismatch', value: 'Chrome'},
+ {name: 'duplicate-both-with-mismatch', value: 'DevTools'},
+ {name: 'duplicate-different-order', value: 'aaa'},
+ {name: 'duplicate-different-order', value: 'bbb'},
+ {name: 'duplicate-in-actual-headers', value: 'first'},
+ {name: 'duplicate-in-actual-headers', value: 'second'},
+ {name: 'duplicate-in-original-headers', value: 'two'},
+ {name: 'duplicate-single-line', value: 'first line, second line'},
+ {name: 'is-in-original-headers', value: 'not an override'},
+ {name: 'not-in-original-headers', value: 'is an override'},
+ {name: 'triplicate', value: '1'},
+ {name: 'triplicate', value: '2'},
+ {name: 'triplicate', value: '2'},
+ {name: 'xyz', value: 'contains \tab'},
+ ],
+ blockedResponseCookies: () => [],
+ wasBlocked: () => false,
+ originalResponseHeaders: [
+ // keep names in alphabetical order
+ {name: 'duplicate-both-no-mismatch', value: 'foo'},
+ {name: 'duplicate-both-no-mismatch', value: 'bar'},
+ {name: 'duplicate-both-with-mismatch', value: 'Chrome'},
+ {name: 'duplicate-both-with-mismatch', value: 'Canary'},
+ {name: 'duplicate-different-order', value: 'bbb'},
+ {name: 'duplicate-different-order', value: 'aaa'},
+ {name: 'duplicate-in-actual-headers', value: 'first'},
+ {name: 'duplicate-in-original-headers', value: 'one'},
+ {name: 'duplicate-in-original-headers', value: 'two'},
+ {name: 'duplicate-single-line', value: 'first line'},
+ {name: 'duplicate-single-line', value: 'second line'},
+ {name: 'is-in-original-headers', value: 'not an override'},
+ {name: 'triplicate', value: '1'},
+ {name: 'triplicate', value: '1'},
+ {name: 'triplicate', value: '2'},
+ {name: 'xyz', value: 'contains \tab'},
+ ],
+ setCookieHeaders: [],
+ url: () => 'https://www.example.com/',
+ getAssociatedData: () => null,
+ setAssociatedData: () => {},
+ } as unknown as SDK.NetworkRequest.NetworkRequest;
+
+ const component = await renderResponseHeaderSection(request);
+ assertShadowRoot(component.shadowRoot);
+ const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+
+ const checkRow = (shadowRoot: ShadowRoot, headerName: string, headerValue: string, isOverride: boolean) => {
+ assert.strictEqual(shadowRoot.querySelector('.header-name')?.textContent?.trim(), headerName);
+ assert.strictEqual(shadowRoot.querySelector('.header-value')?.textContent?.trim(), headerValue);
+ assert.strictEqual(shadowRoot.querySelector('.row')?.classList.contains('header-overridden'), isOverride);
+ };
+
+ assertShadowRoot(rows[0].shadowRoot);
+ checkRow(rows[0].shadowRoot, 'duplicate-both-no-mismatch:', 'foo', false);
+ assertShadowRoot(rows[1].shadowRoot);
+ checkRow(rows[1].shadowRoot, 'duplicate-both-no-mismatch:', 'bar', false);
+ assertShadowRoot(rows[2].shadowRoot);
+ checkRow(rows[2].shadowRoot, 'duplicate-both-with-mismatch:', 'Chrome', true);
+ assertShadowRoot(rows[3].shadowRoot);
+ checkRow(rows[3].shadowRoot, 'duplicate-both-with-mismatch:', 'DevTools', true);
+ assertShadowRoot(rows[4].shadowRoot);
+ checkRow(rows[4].shadowRoot, 'duplicate-different-order:', 'aaa', true);
+ assertShadowRoot(rows[5].shadowRoot);
+ checkRow(rows[5].shadowRoot, 'duplicate-different-order:', 'bbb', true);
+ assertShadowRoot(rows[6].shadowRoot);
+ checkRow(rows[6].shadowRoot, 'duplicate-in-actual-headers:', 'first', true);
+ assertShadowRoot(rows[7].shadowRoot);
+ checkRow(rows[7].shadowRoot, 'duplicate-in-actual-headers:', 'second', true);
+ assertShadowRoot(rows[8].shadowRoot);
+ checkRow(rows[8].shadowRoot, 'duplicate-in-original-headers:', 'two', true);
+ assertShadowRoot(rows[9].shadowRoot);
+ checkRow(rows[9].shadowRoot, 'duplicate-single-line:', 'first line, second line', false);
+ assertShadowRoot(rows[10].shadowRoot);
+ checkRow(rows[10].shadowRoot, 'is-in-original-headers:', 'not an override', false);
+ assertShadowRoot(rows[11].shadowRoot);
+ checkRow(rows[11].shadowRoot, 'not-in-original-headers:', 'is an override', true);
+ assertShadowRoot(rows[12].shadowRoot);
+ checkRow(rows[12].shadowRoot, 'triplicate:', '1', true);
+ assertShadowRoot(rows[13].shadowRoot);
+ checkRow(rows[13].shadowRoot, 'triplicate:', '2', true);
+ assertShadowRoot(rows[14].shadowRoot);
+ checkRow(rows[14].shadowRoot, 'triplicate:', '2', true);
+ assertShadowRoot(rows[15].shadowRoot);
+ checkRow(rows[15].shadowRoot, 'xyz:', 'contains ab', false);
+ });
+
+ it('correctly sets headers as "editable" when matching ".headers" file exists and setting is turned on', async () => {
+ await createWorkspaceProject('file:///path/to/overrides' as Platform.DevToolsPath.UrlString, [
+ {
+ name: '.headers',
+ path: 'www.example.com/',
+ content: `[
+ {
+ "applyTo": "index.html",
+ "headers": [{
+ "name": "server",
+ "value": "overridden server"
+ }]
+ }
+ ]`,
+ },
+ ]);
+
+ const request = {
+ sortedResponseHeaders: [
+ {name: 'cache-control', value: 'max-age=600'},
+ {name: 'server', value: 'overridden server'},
+ ],
+ blockedResponseCookies: () => [],
+ wasBlocked: () => false,
+ originalResponseHeaders: [
+ {name: 'cache-control', value: 'max-age=600'},
+ {name: 'server', value: 'original server'},
+ ],
+ setCookieHeaders: [],
+ url: () => 'https://www.example.com/',
+ getAssociatedData: () => null,
+ setAssociatedData: () => {},
+ } as unknown as SDK.NetworkRequest.NetworkRequest;
+
+ const component = await renderResponseHeaderSection(request);
+ assertShadowRoot(component.shadowRoot);
+ const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+
+ checkHeaderSectionRow(rows[0], 'cache-control:', 'max-age=600', false, false, true);
+ checkHeaderSectionRow(rows[1], 'server:', 'overridden server', true, false, true);
+
+ Common.Settings.Settings.instance().moduleSetting('persistence-network-overrides-enabled').set(false);
+ component.data = {request};
+ await coordinator.done();
+
+ checkHeaderSectionRow(rows[0], 'cache-control:', 'max-age=600', false, false, false);
+ checkHeaderSectionRow(rows[1], 'server:', 'overridden server', true, false, false);
+
+ Common.Settings.Settings.instance().moduleSetting('persistence-network-overrides-enabled').set(true);
+ });
+
+ it('does not set headers as "editable" when matching ".headers" file cannot be parsed correctly', async () => {
+ await createWorkspaceProject('file:///path/to/overrides' as Platform.DevToolsPath.UrlString, [
+ {
+ name: '.headers',
+ path: 'www.example.com/',
+ // 'headers' contains the invalid key 'no-name' and will therefore
+ // cause a parsing error.
+ content: `[
+ {
+ "applyTo": "index.html",
+ "headers": [{
+ "no-name": "server",
+ "value": "overridden server"
+ }]
+ }
+ ]`,
+ },
+ ]);
+
+ const request = {
+ sortedResponseHeaders: [
+ {name: 'cache-control', value: 'max-age=600'},
+ {name: 'server', value: 'overridden server'},
+ ],
+ blockedResponseCookies: () => [],
+ wasBlocked: () => false,
+ originalResponseHeaders: [
+ {name: 'cache-control', value: 'max-age=600'},
+ {name: 'server', value: 'original server'},
+ ],
+ setCookieHeaders: [],
+ url: () => 'https://www.example.com/',
+ getAssociatedData: () => null,
+ setAssociatedData: () => {},
+ } as unknown as SDK.NetworkRequest.NetworkRequest;
+
+ // A console error is emitted when '.headers' cannot be parsed correctly.
+ // We don't need that noise in the test output.
+ sinon.stub(console, 'error');
+
+ const component = await renderResponseHeaderSection(request);
+ assertShadowRoot(component.shadowRoot);
+ const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+
+ assertShadowRoot(rows[0].shadowRoot);
+ checkHeaderSectionRow(rows[0], 'cache-control:', 'max-age=600', false, false, false);
+ assertShadowRoot(rows[1].shadowRoot);
+ checkHeaderSectionRow(rows[1], 'server:', 'overridden server', true, false, false);
+ });
+
+ it('can edit original headers', async () => {
+ const headerOverridesFileContent = `[
+ {
+ "applyTo": "index.html",
+ "headers": [{
+ "name": "server",
+ "value": "overridden server"
+ }]
+ }
+ ]`;
+
+ const actualHeaders = [
+ {name: 'cache-control', value: 'max-age=600'},
+ {name: 'server', value: 'overridden server'},
+ ];
+
+ const originalHeaders = [
+ {name: 'cache-control', value: 'max-age=600'},
+ {name: 'server', value: 'original server'},
+ ];
+
+ const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders);
+ await editHeaderRow(component, 0, HeaderAttribute.HeaderValue, 'max-age=9999');
+
+ const expected = [{
+ applyTo: 'index.html',
+ headers: [
+ {
+ name: 'server',
+ value: 'overridden server',
+ },
+ {
+ name: 'cache-control',
+ value: 'max-age=9999',
+ },
+ ],
+ }];
+ assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2)));
+ assert.isTrue(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken,
+ Host.UserMetrics.Action.HeaderOverrideHeaderEdited));
+ });
+
+ it('can handle tab-character in header value', async () => {
+ const headers = [
+ {name: 'foo', value: 'syn\tax'},
+ ];
+ const {component, spy} = await setupHeaderEditing('[]', headers, headers);
+ assertShadowRoot(component.shadowRoot);
+
+ const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+ assert.strictEqual(rows.length, 1);
+ checkHeaderSectionRow(rows[0], 'foo:', 'syn ax', false, false, true);
+
+ await editHeaderRow(component, 0, HeaderAttribute.HeaderValue, 'syn ax');
+ assert.isTrue(spy.notCalled);
+ checkHeaderSectionRow(rows[0], 'foo:', 'syn ax', false, false, true);
+
+ await editHeaderRow(component, 0, HeaderAttribute.HeaderValue, 'syntax');
+ const expected = [{
+ applyTo: 'index.html',
+ headers: [
+ {
+ name: 'foo',
+ value: 'syntax',
+ },
+ ],
+ }];
+ assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2)));
+ checkHeaderSectionRow(rows[0], 'foo:', 'syntax', true, false, true);
+ });
+
+ it('can edit overridden headers', async () => {
+ const headerOverridesFileContent = `[
+ {
+ "applyTo": "index.html",
+ "headers": [{
+ "name": "server",
+ "value": "overridden server"
+ }]
+ }
+ ]`;
+
+ const actualHeaders = [
+ {name: 'cache-control', value: 'max-age=600'},
+ {name: 'server', value: 'overridden server'},
+ ];
+
+ const originalHeaders = [
+ {name: 'cache-control', value: 'max-age=600'},
+ {name: 'server', value: 'original server'},
+ ];
+
+ const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders);
+ await editHeaderRow(component, 1, HeaderAttribute.HeaderValue, 'edited value');
+
+ const expected = [{
+ applyTo: 'index.html',
+ headers: [
+ {
+ name: 'server',
+ value: 'edited value',
+ },
+ ],
+ }];
+ assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2)));
+ assert.isTrue(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken,
+ Host.UserMetrics.Action.HeaderOverrideHeaderEdited));
+ });
+
+ it('can remove header overrides', async () => {
+ const headerOverridesFileContent = `[
+ {
+ "applyTo": "index.html",
+ "headers": [
+ {
+ "name": "highlighted-header",
+ "value": "overridden highlighted-header"
+ },
+ {
+ "name": "cache-control",
+ "value": "max-age=9999"
+ },
+ {
+ "name": "added",
+ "value": "foo"
+ }
+ ]
+ }
+ ]`;
+
+ const actualHeaders = [
+ {name: 'added', value: 'foo'},
+ {name: 'cache-control', value: 'max-age=9999'},
+ {name: 'highlighted-header', value: 'overridden highlighted-header'},
+ ];
+
+ const originalHeaders = [
+ {name: 'cache-control', value: 'max-age=600'},
+ {name: 'highlighted-header', value: 'original highlighted-header'},
+ ];
+
+ const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders);
+ assertShadowRoot(component.shadowRoot);
+ let rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+ assert.strictEqual(rows.length, 3);
+ checkHeaderSectionRow(rows[0], 'added:', 'foo', true, false, true);
+ checkHeaderSectionRow(rows[1], 'cache-control:', 'max-age=9999', true, false, true);
+ checkHeaderSectionRow(rows[2], 'highlighted-header:', 'overridden highlighted-header', true, false, true, true);
+ await removeHeaderRow(component, 2);
+
+ let expected = [{
+ applyTo: 'index.html',
+ headers: [
+ {
+ name: 'cache-control',
+ value: 'max-age=9999',
+ },
+ {
+ name: 'added',
+ value: 'foo',
+ },
+ ],
+ }];
+ assert.strictEqual(spy.callCount, 1);
+ assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2)));
+ assert.isTrue(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken,
+ Host.UserMetrics.Action.HeaderOverrideHeaderRemoved));
+
+ rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+ assert.strictEqual(rows.length, 3);
+ checkHeaderSectionRow(rows[0], 'added:', 'foo', true, false, true);
+ checkHeaderSectionRow(rows[1], 'cache-control:', 'max-age=9999', true, false, true);
+ checkHeaderSectionRow(
+ rows[2], 'highlighted-header:', 'overridden highlighted-header', true, false, false, true, true);
+
+ spy.resetHistory();
+ await removeHeaderRow(component, 0);
+
+ expected = [{
+ applyTo: 'index.html',
+ headers: [
+ {
+ name: 'cache-control',
+ value: 'max-age=9999',
+ },
+ ],
+ }];
+ assert.strictEqual(spy.callCount, 1);
+ assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2)));
+ rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+ assert.strictEqual(rows.length, 3);
+ checkHeaderSectionRow(rows[0], 'added:', 'foo', true, false, false, false, true);
+ checkHeaderSectionRow(rows[1], 'cache-control:', 'max-age=9999', true, false, true);
+ checkHeaderSectionRow(
+ rows[2], 'highlighted-header:', 'overridden highlighted-header', true, false, false, true, true);
+ });
+
+ it('can remove the last header override', async () => {
+ const headerOverridesFileContent = `[
+ {
+ "applyTo": "index.html",
+ "headers": [
+ {
+ "name": "server",
+ "value": "overridden server"
+ }
+ ]
+ }
+ ]`;
+
+ const actualHeaders = [
+ {name: 'server', value: 'overridden server'},
+ ];
+
+ const originalHeaders = [
+ {name: 'server', value: 'original server'},
+ ];
+
+ const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders);
+ await removeHeaderRow(component, 0);
+
+ const expected: Persistence.NetworkPersistenceManager.HeaderOverride[] = [];
+ assert.strictEqual(spy.callCount, 1);
+ assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2)));
+ assert.isTrue(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken,
+ Host.UserMetrics.Action.HeaderOverrideHeaderRemoved));
+ });
+
+ it('can handle non-breaking spaces when removing header overrides', async () => {
+ const headerOverridesFileContent = `[
+ {
+ "applyTo": "index.html",
+ "headers": [
+ {
+ "name": "added",
+ "value": "space\xa0between"
+ }
+ ]
+ }
+ ]`;
+
+ const actualHeaders = [
+ {name: 'added', value: 'space between'},
+ {name: 'cache-control', value: 'max-age=600'},
+ ];
+
+ const originalHeaders = [
+ {name: 'cache-control', value: 'max-age=600'},
+ ];
+
+ const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders);
+ assertShadowRoot(component.shadowRoot);
+ let rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+ assert.strictEqual(rows.length, 2);
+ checkHeaderSectionRow(rows[0], 'added:', 'space between', true, false, true);
+ checkHeaderSectionRow(rows[1], 'cache-control:', 'max-age=600', false, false, true);
+ await removeHeaderRow(component, 0);
+
+ const expected: Persistence.NetworkPersistenceManager.HeaderOverride[] = [];
+ assert.strictEqual(spy.callCount, 1);
+ assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2)));
+
+ rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+ assert.strictEqual(rows.length, 2);
+ checkHeaderSectionRow(rows[0], 'added:', 'space between', true, false, false, false, true);
+ checkHeaderSectionRow(rows[1], 'cache-control:', 'max-age=600', false, false, true);
+ });
+
+ it('does not generate header overrides which have "applyTo" but empty "headers" array', async () => {
+ const actualHeaders = [
+ {name: 'server', value: 'original server'},
+ ];
+ const {component, spy} = await setupHeaderEditing('[]', actualHeaders, actualHeaders);
+ await editHeaderRow(component, 0, HeaderAttribute.HeaderValue, 'overridden server');
+
+ const expected = [{
+ applyTo: 'index.html',
+ headers: [
+ {
+ name: 'server',
+ value: 'overridden server',
+ },
+ ],
+ }];
+ assert.strictEqual(spy.callCount, 1);
+ assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2)));
+
+ spy.resetHistory();
+ await editHeaderRow(component, 0, HeaderAttribute.HeaderValue, 'original server');
+ assert.strictEqual(spy.callCount, 1);
+ assert.isTrue(spy.calledOnceWith(JSON.stringify([], null, 2)));
+ });
+
+ it('can add headers', async () => {
+ const headerOverridesFileContent = `[
+ {
+ "applyTo": "index.html",
+ "headers": [{
+ "name": "server",
+ "value": "overridden server"
+ }]
+ }
+ ]`;
+ const actualHeaders = [{name: 'server', value: 'overridden server'}];
+ const originalHeaders = [{name: 'server', value: 'original server'}];
+
+ const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders);
+ assertShadowRoot(component.shadowRoot);
+ const addHeaderButton = component.shadowRoot.querySelector('.add-header-button');
+ assertElement(addHeaderButton, HTMLElement);
+ addHeaderButton.click();
+ await coordinator.done();
+
+ let expected = [{
+ applyTo: 'index.html',
+ headers: [
+ {
+ name: 'server',
+ value: 'overridden server',
+ },
+ {
+ name: 'header-name',
+ value: 'header value',
+ },
+ ],
+ }];
+ assert.isTrue(spy.getCall(-1).calledWith(JSON.stringify(expected, null, 2)));
+
+ assert.isTrue(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken,
+ Host.UserMetrics.Action.HeaderOverrideHeaderAdded));
+
+ await editHeaderRow(component, 1, HeaderAttribute.HeaderName, 'foo');
+ expected = [{
+ applyTo: 'index.html',
+ headers: [
+ {
+ name: 'server',
+ value: 'overridden server',
+ },
+ {
+ name: 'foo',
+ value: 'header value',
+ },
+ ],
+ }];
+ assert.isTrue(spy.getCall(-1).calledWith(JSON.stringify(expected, null, 2)));
+
+ await editHeaderRow(component, 1, HeaderAttribute.HeaderValue, 'bar');
+ expected = [{
+ applyTo: 'index.html',
+ headers: [
+ {
+ name: 'server',
+ value: 'overridden server',
+ },
+ {
+ name: 'foo',
+ value: 'bar',
+ },
+ ],
+ }];
+ assert.isTrue(spy.getCall(-1).calledWith(JSON.stringify(expected, null, 2)));
+ });
+
+ it('can remove a newly added header', async () => {
+ const actualHeaders = [
+ {name: 'server', value: 'original server'},
+ ];
+ const {component, spy} = await setupHeaderEditing('[]', actualHeaders, actualHeaders);
+ assertShadowRoot(component.shadowRoot);
+ const addHeaderButton = component.shadowRoot.querySelector('.add-header-button');
+ assertElement(addHeaderButton, HTMLElement);
+ addHeaderButton.click();
+ await coordinator.done();
+
+ const expected = [{
+ applyTo: 'index.html',
+ headers: [
+ {
+ name: 'header-name',
+ value: 'header value',
+ },
+ ],
+ }];
+ assert.isTrue(spy.getCall(-1).calledWith(JSON.stringify(expected, null, 2)));
+ let rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+ assert.strictEqual(rows.length, 2);
+ checkHeaderSectionRow(rows[0], 'server:', 'original server', false, false, true);
+ checkHeaderSectionRow(rows[1], 'header-name:', 'header value', true, true, true);
+
+ spy.resetHistory();
+ await removeHeaderRow(component, 1);
+
+ assert.strictEqual(spy.callCount, 1);
+ assert.isTrue(spy.calledOnceWith(JSON.stringify([], null, 2)));
+ rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+ assert.strictEqual(rows.length, 2);
+ checkHeaderSectionRow(rows[0], 'server:', 'original server', false, false, true);
+ checkHeaderSectionRow(rows[1], 'header-name:', 'header value', true, false, false, false, true);
+ });
+
+ it('renders headers as (not) editable depending on overall overrides setting', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'https://www.example.com/index.html' as Platform.DevToolsPath.UrlString, '' as Platform.DevToolsPath.UrlString,
+ null, null, null);
+ request.responseHeaders = [{name: 'server', value: 'overridden server'}];
+ request.originalResponseHeaders = [{name: 'server', value: 'original server'}];
+
+ const {component} = await setupHeaderEditingWithRequest('[]', request);
+ assertShadowRoot(component.shadowRoot);
+ const addHeaderButton = component.shadowRoot.querySelector('.add-header-button');
+ assertElement(addHeaderButton, HTMLElement);
+ addHeaderButton.click();
+ await coordinator.done();
+
+ let rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+ assert.strictEqual(rows.length, 2);
+ checkHeaderSectionRow(rows[0], 'server:', 'overridden server', true, false, true);
+ checkHeaderSectionRow(rows[1], 'header-name:', 'header value', true, true, true);
+
+ component.remove();
+ Common.Settings.Settings.instance().moduleSetting('persistence-network-overrides-enabled').set(false);
+ const component2 = await renderResponseHeaderSection(request);
+ assertShadowRoot(component2.shadowRoot);
+
+ rows = component2.shadowRoot.querySelectorAll('devtools-header-section-row');
+ assert.strictEqual(rows.length, 2);
+ checkHeaderSectionRow(rows[0], 'server:', 'overridden server', true, false, false);
+ checkHeaderSectionRow(rows[1], 'header-name:', 'header value', true, false, false);
+
+ component2.remove();
+ Common.Settings.Settings.instance().moduleSetting('persistence-network-overrides-enabled').set(true);
+ const component3 = await renderResponseHeaderSection(request);
+ assertShadowRoot(component3.shadowRoot);
+
+ rows = component3.shadowRoot.querySelectorAll('devtools-header-section-row');
+ assert.strictEqual(rows.length, 2);
+ checkHeaderSectionRow(rows[0], 'server:', 'overridden server', true, false, true);
+ checkHeaderSectionRow(rows[1], 'header-name:', 'header value', true, true, true);
+ });
+
+ it('can edit multiple headers', async () => {
+ const headerOverridesFileContent = `[
+ {
+ "applyTo": "index.html",
+ "headers": [{
+ "name": "server",
+ "value": "overridden server"
+ }]
+ }
+ ]`;
+
+ const actualHeaders = [
+ {name: 'cache-control', value: 'max-age=600'},
+ {name: 'server', value: 'overridden server'},
+ ];
+
+ const originalHeaders = [
+ {name: 'cache-control', value: 'max-age=600'},
+ {name: 'server', value: 'original server'},
+ ];
+
+ const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders);
+ await editHeaderRow(component, 0, HeaderAttribute.HeaderValue, 'edited cache-control');
+ await editHeaderRow(component, 1, HeaderAttribute.HeaderValue, 'edited server');
+
+ const expected = [{
+ applyTo: 'index.html',
+ headers: [
+ {
+ name: 'cache-control',
+ value: 'edited cache-control',
+ },
+ {
+ name: 'server',
+ value: 'edited server',
+ },
+ ],
+ }];
+ assert.isTrue(spy.lastCall.calledWith(JSON.stringify(expected, null, 2)));
+ });
+
+ it('can edit multiple headers which have the same name', async () => {
+ const headerOverridesFileContent = '[]';
+
+ const actualHeaders = [
+ {name: 'link', value: 'first value'},
+ {name: 'link', value: 'second value'},
+ ];
+
+ const originalHeaders = [
+ {name: 'link', value: 'first value'},
+ {name: 'link', value: 'second value'},
+ ];
+
+ const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders);
+ await editHeaderRow(component, 0, HeaderAttribute.HeaderValue, 'third value');
+
+ let expected = [{
+ applyTo: 'index.html',
+ headers: [
+ {
+ name: 'link',
+ value: 'third value',
+ },
+ ],
+ }];
+ assert.isTrue(spy.lastCall.calledWith(JSON.stringify(expected, null, 2)));
+
+ await editHeaderRow(component, 1, HeaderAttribute.HeaderValue, 'fourth value');
+ expected = [{
+ applyTo: 'index.html',
+ headers: [
+ {
+ name: 'link',
+ value: 'third value',
+ },
+ {
+ name: 'link',
+ value: 'fourth value',
+ },
+ ],
+ }];
+ assert.isTrue(spy.lastCall.calledWith(JSON.stringify(expected, null, 2)));
+ });
+
+ it('can edit multiple headers which have the same name and which are already overridden', async () => {
+ const headerOverridesFileContent = `[
+ {
+ "applyTo": "index.html",
+ "headers": [
+ {
+ "name": "link",
+ "value": "third value"
+ },
+ {
+ "name": "link",
+ "value": "fourth value"
+ }
+ ]
+ }
+ ]`;
+
+ const actualHeaders = [
+ {name: 'link', value: 'third value'},
+ {name: 'link', value: 'fourth value'},
+ ];
+
+ const originalHeaders = [
+ {name: 'link', value: 'first value'},
+ {name: 'link', value: 'second value'},
+ ];
+
+ const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders);
+ await editHeaderRow(component, 1, HeaderAttribute.HeaderValue, 'fifth value');
+
+ let expected = [{
+ applyTo: 'index.html',
+ headers: [
+ {
+ name: 'link',
+ value: 'third value',
+ },
+ {
+ name: 'link',
+ value: 'fifth value',
+ },
+ ],
+ }];
+ assert.isTrue(spy.lastCall.calledWith(JSON.stringify(expected, null, 2)));
+
+ await editHeaderRow(component, 0, HeaderAttribute.HeaderValue, 'sixth value');
+ expected = [{
+ applyTo: 'index.html',
+ headers: [
+ {
+ name: 'link',
+ value: 'sixth value',
+ },
+ {
+ name: 'link',
+ value: 'fifth value',
+ },
+ ],
+ }];
+ assert.isTrue(spy.lastCall.calledWith(JSON.stringify(expected, null, 2)));
+ });
+
+ it('persists edits to header overrides and resurfaces them upon component (re-)creation', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'https://www.example.com/index.html' as Platform.DevToolsPath.UrlString, '' as Platform.DevToolsPath.UrlString,
+ null, null, null);
+ request.responseHeaders = [{name: 'server', value: 'overridden server'}];
+ request.originalResponseHeaders = [{name: 'server', value: 'original server'}];
+
+ const headerOverridesFileContent = `[
+ {
+ "applyTo": "index.html",
+ "headers": [{
+ "name": "server",
+ "value": "overridden server"
+ }]
+ }
+ ]`;
+
+ const {component, spy} = await setupHeaderEditingWithRequest(headerOverridesFileContent, request);
+ assertShadowRoot(component.shadowRoot);
+ const addHeaderButton = component.shadowRoot.querySelector('.add-header-button');
+ assertElement(addHeaderButton, HTMLElement);
+ addHeaderButton.click();
+ await coordinator.done();
+
+ await editHeaderRow(component, 0, HeaderAttribute.HeaderValue, 'unit test');
+ await editHeaderRow(component, 1, HeaderAttribute.HeaderName, 'foo');
+ await editHeaderRow(component, 1, HeaderAttribute.HeaderValue, 'bar');
+ const expected = [{
+ applyTo: 'index.html',
+ headers: [
+ {
+ name: 'server',
+ value: 'unit test',
+ },
+ {
+ name: 'foo',
+ value: 'bar',
+ },
+ ],
+ }];
+ assert.isTrue(spy.getCall(-1).calledWith(JSON.stringify(expected, null, 2)));
+
+ component.remove();
+ const component2 = await renderResponseHeaderSection(request);
+ assertShadowRoot(component2.shadowRoot);
+
+ const rows = component2.shadowRoot.querySelectorAll('devtools-header-section-row');
+ assert.strictEqual(rows.length, 2);
+ checkHeaderSectionRow(rows[0], 'server:', 'unit test', true, false, true);
+ checkHeaderSectionRow(rows[1], 'foo:', 'bar', true, true, true);
+ });
+
+ it('focuses on newly added header rows on initial render', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'https://www.example.com/index.html' as Platform.DevToolsPath.UrlString, '' as Platform.DevToolsPath.UrlString,
+ null, null, null);
+ request.responseHeaders = [{name: 'server', value: 'overridden server'}];
+ request.originalResponseHeaders = [{name: 'server', value: 'original server'}];
+
+ const headerOverridesFileContent = `[
+ {
+ "applyTo": "index.html",
+ "headers": [{
+ "name": "server",
+ "value": "overridden server"
+ }]
+ }
+ ]`;
+
+ const {component} = await setupHeaderEditingWithRequest(headerOverridesFileContent, request);
+ assertShadowRoot(component.shadowRoot);
+ const addHeaderButton = component.shadowRoot.querySelector('.add-header-button');
+ assertElement(addHeaderButton, HTMLElement);
+ addHeaderButton.click();
+ await coordinator.done();
+ assert.isFalse(isRowFocused(component, 0));
+ assert.isTrue(isRowFocused(component, 1));
+
+ component.remove();
+ const component2 = await renderResponseHeaderSection(request);
+ assertShadowRoot(component2.shadowRoot);
+ assert.isFalse(isRowFocused(component2, 0));
+ assert.isFalse(isRowFocused(component2, 1));
+ });
+
+ it('can handle removal of ".headers" file', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'https://www.example.com/index.html' as Platform.DevToolsPath.UrlString, '' as Platform.DevToolsPath.UrlString,
+ null, null, null);
+ request.responseHeaders = [{name: 'server', value: 'overridden server'}];
+ request.originalResponseHeaders = [{name: 'server', value: 'original server'}];
+
+ const headerOverridesFileContent = `[
+ {
+ "applyTo": "index.html",
+ "headers": [{
+ "name": "server",
+ "value": "overridden server"
+ }]
+ }
+ ]`;
+
+ const {component} = await setupHeaderEditingWithRequest(headerOverridesFileContent, request);
+ assertShadowRoot(component.shadowRoot);
+ let addHeaderButton = component.shadowRoot.querySelector('.add-header-button');
+ assertElement(addHeaderButton, HTMLElement);
+ addHeaderButton.click();
+ await coordinator.done();
+
+ await editHeaderRow(component, 0, HeaderAttribute.HeaderValue, 'unit test');
+
+ sinon.stub(Workspace.Workspace.WorkspaceImpl.instance(), 'uiSourceCodeForURL').callsFake(() => null);
+
+ component.data = {request};
+ await coordinator.done();
+
+ const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+ assert.strictEqual(rows.length, 1);
+ checkHeaderSectionRow(rows[0], 'server:', 'overridden server', true, false, false);
+ addHeaderButton = component.shadowRoot.querySelector('.add-header-button');
+ assert.isNull(addHeaderButton);
+ });
+
+ it('handles rendering and editing \'set-cookie\' headers', async () => {
+ const request = SDK.NetworkRequest.NetworkRequest.create(
+ 'requestId' as Protocol.Network.RequestId,
+ 'https://www.example.com/index.html' as Platform.DevToolsPath.UrlString, '' as Platform.DevToolsPath.UrlString,
+ null, null, null);
+ request.responseHeaders = [
+ {name: 'Cache-Control', value: 'max-age=600'},
+ {name: 'Z-Header', value: 'zzz'},
+ ];
+ request.originalResponseHeaders = [
+ {name: 'Set-Cookie', value: 'bar=original'},
+ {name: 'Set-Cookie', value: 'foo=original'},
+ {name: 'Set-Cookie', value: 'malformed'},
+ {name: 'Cache-Control', value: 'max-age=600'},
+ {name: 'Z-header', value: 'zzz'},
+ ];
+ request.setCookieHeaders = [
+ {name: 'Set-Cookie', value: 'bar=original'},
+ {name: 'Set-Cookie', value: 'foo=overridden'},
+ {name: 'Set-Cookie', value: 'user=12345'},
+ {name: 'Set-Cookie', value: 'malformed'},
+ {name: 'Set-Cookie', value: 'wrong format'},
+ ];
+
+ const headerOverridesFileContent = `[
+ {
+ "applyTo": "index.html",
+ "headers": [
+ {
+ "name": "set-cookie",
+ "value": "foo=overridden"
+ },
+ {
+ "name": "set-cookie",
+ "value": "user=12345"
+ },
+ {
+ "name": "set-cookie",
+ "value": "wrong format"
+ }
+ ]
+ }
+ ]`;
+
+ const {component, spy} = await setupHeaderEditingWithRequest(headerOverridesFileContent, request);
+ assertShadowRoot(component.shadowRoot);
+ const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+ assert.strictEqual(rows.length, 7);
+ assertShadowRoot(rows[0].shadowRoot);
+ checkHeaderSectionRow(rows[0], 'cache-control:', 'max-age=600', false, false, true);
+ assertShadowRoot(rows[1].shadowRoot);
+ checkHeaderSectionRow(rows[1], 'set-cookie:', 'bar=original', false, false, true);
+ assertShadowRoot(rows[2].shadowRoot);
+ checkHeaderSectionRow(rows[2], 'set-cookie:', 'foo=overridden', true, false, true);
+ assertShadowRoot(rows[3].shadowRoot);
+ checkHeaderSectionRow(rows[3], 'set-cookie:', 'user=12345', true, false, true);
+ assertShadowRoot(rows[4].shadowRoot);
+ checkHeaderSectionRow(rows[4], 'set-cookie:', 'malformed', false, false, true);
+ assertShadowRoot(rows[5].shadowRoot);
+ checkHeaderSectionRow(rows[5], 'set-cookie:', 'wrong format', true, false, true);
+ assertShadowRoot(rows[6].shadowRoot);
+ checkHeaderSectionRow(rows[6], 'z-header:', 'zzz', false, false, true);
+
+ await editHeaderRow(component, 2, HeaderAttribute.HeaderValue, 'foo=edited');
+ const expected = [{
+ applyTo: 'index.html',
+ headers: [
+ {
+ name: 'set-cookie',
+ value: 'user=12345',
+ },
+ {
+ name: 'set-cookie',
+ value: 'wrong format',
+ },
+ {
+ name: 'set-cookie',
+ value: 'foo=edited',
+ },
+ ],
+ }];
+ assert.isTrue(spy.getCall(-1).calledWith(JSON.stringify(expected, null, 2)));
+
+ await editHeaderRow(component, 1, HeaderAttribute.HeaderValue, 'bar=edited');
+ expected[0].headers.push({name: 'set-cookie', value: 'bar=edited'});
+ assert.isTrue(spy.getCall(-1).calledWith(JSON.stringify(expected, null, 2)));
+ });
+
+ it('ignores capitalisation of the `set-cookie` header when marking as overridden', async () => {
+ const request = {
+ sortedResponseHeaders: [
+ {name: 'set-cookie', value: 'user=123'},
+ ],
+ blockedResponseCookies: () => [],
+ wasBlocked: () => false,
+ originalResponseHeaders: [
+ {name: 'Set-Cookie', value: 'user=123'},
+ ],
+ setCookieHeaders: [],
+ url: () => 'https://www.example.com/',
+ getAssociatedData: () => null,
+ setAssociatedData: () => {},
+ } as unknown as SDK.NetworkRequest.NetworkRequest;
+
+ const component = await renderResponseHeaderSection(request);
+ assertShadowRoot(component.shadowRoot);
+ const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+
+ assertShadowRoot(rows[0].shadowRoot);
+ assert.strictEqual(rows[0].shadowRoot.querySelector('.header-name')?.textContent?.trim(), 'set-cookie:');
+ assert.strictEqual(rows[0].shadowRoot.querySelector('.header-value')?.textContent?.trim(), 'user=123');
+ assert.strictEqual(rows[0].shadowRoot.querySelector('.row')?.classList.contains('header-overridden'), false);
+ });
+
+ it('does not mark unset headers (which cause the request to be blocked) as overridden', async () => {
+ const request = {
+ sortedResponseHeaders: [
+ {name: 'abc', value: 'def'},
+ ],
+ blockedResponseCookies: () => [],
+ wasBlocked: () => true,
+ blockedReason: () => Protocol.Network.BlockedReason.CoepFrameResourceNeedsCoepHeader,
+ originalResponseHeaders: [
+ {name: 'abc', value: 'def'},
+ ],
+ setCookieHeaders: [],
+ url: () => 'https://www.example.com/',
+ getAssociatedData: () => null,
+ setAssociatedData: () => {},
+ } as unknown as SDK.NetworkRequest.NetworkRequest;
+
+ const component = await renderResponseHeaderSection(request);
+ assertShadowRoot(component.shadowRoot);
+ const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+
+ const checkRow = (shadowRoot: ShadowRoot, headerName: string, headerValue: string, isOverride: boolean) => {
+ assert.deepEqual(getCleanTextContentFromElements(shadowRoot, '.header-name'), [headerName]);
+ assert.strictEqual(shadowRoot.querySelector('.header-value')?.textContent?.trim(), headerValue);
+ assert.strictEqual(shadowRoot.querySelector('.row')?.classList.contains('header-overridden'), isOverride);
+ };
+
+ assertShadowRoot(rows[0].shadowRoot);
+ checkRow(rows[0].shadowRoot, 'abc:', 'def', false);
+ assertShadowRoot(rows[1].shadowRoot);
+ checkRow(rows[1].shadowRoot, 'not-setcross-origin-embedder-policy:', '', false);
+ });
+});
diff --git a/front_end/panels/performance_monitor/BUILD.gn b/front_end/panels/performance_monitor/BUILD.gn
index 8e2dff9..9e443fe 100644
--- a/front_end/panels/performance_monitor/BUILD.gn
+++ b/front_end/panels/performance_monitor/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../scripts/build/ninja/devtools_module.gni")
import("../../../scripts/build/ninja/generate_css.gni")
+import("../../../third_party/typescript/typescript.gni")
import("../visibility.gni")
generate_css("css_files") {
@@ -55,3 +56,14 @@
visibility = [ "../../entrypoints/*" ]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [ "PerformanceMonitor.test.ts" ]
+
+ deps = [
+ ":bundle",
+ "../../../test/unittests/front_end/helpers",
+ ]
+}
diff --git a/front_end/panels/performance_monitor/PerformanceMonitor.test.ts b/front_end/panels/performance_monitor/PerformanceMonitor.test.ts
new file mode 100644
index 0000000..94190da
--- /dev/null
+++ b/front_end/panels/performance_monitor/PerformanceMonitor.test.ts
@@ -0,0 +1,54 @@
+// 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 PerformanceMonitor from './performance_monitor.js';
+import type * as Protocol from '../../generated/protocol.js';
+import * as SDK from '../../core/sdk/sdk.js';
+
+import {createTarget} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+
+describeWithMockConnection('PerformanceMonitor', () => {
+ const tests = (targetFactory: () => SDK.Target.Target) => {
+ let target: SDK.Target.Target;
+ let performanceMonitor: PerformanceMonitor.PerformanceMonitor.PerformanceMonitorImpl;
+
+ beforeEach(() => {
+ target = targetFactory();
+ });
+ afterEach(() => {
+ performanceMonitor.detach();
+ });
+
+ it('updates metrics', async () => {
+ let metrics = {metrics: [{name: 'LayoutCount', value: 42}]} as Protocol.Performance.GetMetricsResponse;
+ let onGetMetrics = () => {};
+ sinon.stub(target.performanceAgent(), 'invoke_getMetrics').callsFake(() => {
+ onGetMetrics();
+ return Promise.resolve(metrics);
+ });
+ performanceMonitor = new PerformanceMonitor.PerformanceMonitor.PerformanceMonitorImpl(0);
+ performanceMonitor.markAsRoot();
+ performanceMonitor.show(document.body);
+ assert.isFalse(
+ [...performanceMonitor.contentElement.querySelectorAll('.perfmon-indicator-value')].some(e => e.textContent));
+ await new Promise<void>(resolve => {
+ onGetMetrics = resolve;
+ });
+ metrics = {metrics: [{name: 'LayoutCount', value: 84}]} as Protocol.Performance.GetMetricsResponse;
+ await new Promise<void>(resolve => {
+ onGetMetrics = resolve;
+ });
+ assert.isTrue(
+ [...performanceMonitor.contentElement.querySelectorAll('.perfmon-indicator-value')].some(e => e.textContent));
+ });
+ };
+
+ describe('without tab target', () => tests(createTarget));
+ describe('with tab target', () => tests(() => {
+ const tabTarget = createTarget({type: SDK.Target.Type.Tab});
+ createTarget({parentTarget: tabTarget, subtype: 'prerender'});
+ return createTarget({parentTarget: tabTarget});
+ }));
+});
diff --git a/front_end/panels/profiler/BUILD.gn b/front_end/panels/profiler/BUILD.gn
index 28404ce..2a93d50 100644
--- a/front_end/panels/profiler/BUILD.gn
+++ b/front_end/panels/profiler/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../scripts/build/ninja/devtools_module.gni")
import("../../../scripts/build/ninja/generate_css.gni")
+import("../../../third_party/typescript/typescript.gni")
import("../visibility.gni")
generate_css("css_files") {
@@ -97,3 +98,18 @@
visibility = [ "../../entrypoints/*" ]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [ "CPUProfileView.test.ts" ]
+
+ deps = [
+ ":bundle",
+ "../../../test/unittests/front_end/helpers",
+ "../../core/common:bundle",
+ "../../core/protocol_client:bundle",
+ "../../core/sdk:bundle",
+ "../../generated:protocol",
+ ]
+}
diff --git a/front_end/panels/profiler/CPUProfileView.test.ts b/front_end/panels/profiler/CPUProfileView.test.ts
new file mode 100644
index 0000000..8e2aee2
--- /dev/null
+++ b/front_end/panels/profiler/CPUProfileView.test.ts
@@ -0,0 +1,38 @@
+// 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.
+
+const {assert} = chai;
+
+import {createTarget} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithRealConnection} from '../../../test/unittests/front_end/helpers/RealConnection.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import type * as Protocol from '../../generated/protocol.js';
+import type * as Profiler from './profiler.js';
+
+describeWithRealConnection('CPUProfileView test', () => {
+ it('reads registered console profile messages from the model', async () => {
+ const Profiler = await import('./profiler.js');
+ const target = createTarget();
+ const model = target.model(SDK.CPUProfilerModel.CPUProfilerModel) as SDK.CPUProfilerModel.CPUProfilerModel;
+ const scriptId = 'bar' as Protocol.Runtime.ScriptId;
+ const lineNumber = 42;
+ const cpuProfile = {
+ nodes: [{
+ id: 1,
+ callFrame: {functionName: 'fun', scriptId, lineNumber, url: 'http://foo', columnNumber: 1},
+ hitCount: 42,
+ }],
+ startTime: 1,
+ endTime: 2,
+ };
+ model.consoleProfileFinished({
+ id: 'foo',
+ location: {scriptId, lineNumber},
+ profile: cpuProfile,
+ });
+ const profileType = new Profiler.CPUProfileView.CPUProfileType();
+ const cpuProfileHeader = profileType.getProfiles()[0] as Profiler.CPUProfileView.CPUProfileHeader;
+ assert.deepEqual(cpuProfileHeader?.cpuProfilerModel?.registeredConsoleProfileMessages[0]?.cpuProfile, cpuProfile);
+ });
+});
diff --git a/front_end/panels/protocol_monitor/BUILD.gn b/front_end/panels/protocol_monitor/BUILD.gn
index e6b79d2..b406259 100644
--- a/front_end/panels/protocol_monitor/BUILD.gn
+++ b/front_end/panels/protocol_monitor/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../scripts/build/ninja/devtools_module.gni")
import("../../../scripts/build/ninja/generate_css.gni")
+import("../../../third_party/typescript/typescript.gni")
import("../visibility.gni")
generate_css("css_files") {
@@ -40,7 +41,6 @@
visibility = [
":*",
"../../../test/unittests/front_end/entrypoints/missing_entrypoints/*",
- "../../../test/unittests/front_end/panels/protocol_monitor/*",
"../../entrypoints/*",
]
@@ -59,3 +59,15 @@
visibility = [ "../../entrypoints/*" ]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [ "ProtocolMonitor.test.ts" ]
+
+ deps = [
+ ":bundle",
+ "../../../test/unittests/front_end/helpers",
+ "./components:bundle",
+ ]
+}
diff --git a/front_end/panels/protocol_monitor/ProtocolMonitor.test.ts b/front_end/panels/protocol_monitor/ProtocolMonitor.test.ts
new file mode 100644
index 0000000..fa7b02f
--- /dev/null
+++ b/front_end/panels/protocol_monitor/ProtocolMonitor.test.ts
@@ -0,0 +1,177 @@
+// 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.
+
+const {assert} = chai;
+import * as ProtocolMonitor from './protocol_monitor.js';
+
+describe('ProtocolMonitor', () => {
+ describe('parseCommandInput', () => {
+ it('parses various JSON formats', async () => {
+ const input = {
+ command: 'Input.dispatchMouseEvent',
+ parameters: {parameter1: 'value1'},
+ };
+ // "command" variations.
+ assert.deepStrictEqual(
+ ProtocolMonitor.ProtocolMonitor.parseCommandInput(JSON.stringify({
+ command: input.command,
+ parameters: input.parameters,
+ })),
+ input);
+ assert.deepStrictEqual(
+ ProtocolMonitor.ProtocolMonitor.parseCommandInput(JSON.stringify({
+ cmd: input.command,
+ parameters: input.parameters,
+ })),
+ input);
+ assert.deepStrictEqual(
+ ProtocolMonitor.ProtocolMonitor.parseCommandInput(JSON.stringify({
+ method: input.command,
+ parameters: input.parameters,
+ })),
+ input);
+
+ // "parameters" variations.
+ assert.deepStrictEqual(
+ ProtocolMonitor.ProtocolMonitor.parseCommandInput(JSON.stringify({
+ command: input.command,
+ params: input.parameters,
+ })),
+ input);
+ assert.deepStrictEqual(
+ ProtocolMonitor.ProtocolMonitor.parseCommandInput(JSON.stringify({
+ cmd: input.command,
+ args: input.parameters,
+ })),
+ input);
+ assert.deepStrictEqual(
+ ProtocolMonitor.ProtocolMonitor.parseCommandInput(JSON.stringify({
+ method: input.command,
+ arguments: input.parameters,
+ })),
+ input);
+ });
+
+ it('parses non-JSON data as a command name', async () => {
+ assert.deepStrictEqual(ProtocolMonitor.ProtocolMonitor.parseCommandInput('Input.dispatchMouseEvent'), {
+ command: 'Input.dispatchMouseEvent',
+ parameters: {},
+ });
+ });
+
+ it('should correctly creates a map of CDP commands with their corresponding metadata', async () => {
+ const domains = [
+ {
+ domain: 'Test',
+ metadata: {
+ 'Test.test': {
+ parameters: [{
+ name: 'test',
+ type: 'test',
+ optional: true,
+ }],
+ description: 'Description1',
+ replyArgs: ['Test1'],
+ },
+ },
+ },
+ {
+ domain: 'Test2',
+ metadata: {
+ 'Test2.test2': {
+ parameters: [{
+ name: 'test2',
+ type: 'test2',
+ optional: true,
+ }],
+ description: 'Description2',
+ replyArgs: ['Test2'],
+ },
+ 'Test2.test3': {
+ parameters: [{
+ name: 'test3',
+ type: 'test3',
+ optional: true,
+ }],
+ description: 'Description3',
+ replyArgs: ['Test3'],
+ },
+ },
+ },
+ ] as Iterable<ProtocolMonitor.ProtocolMonitor.ProtocolDomain>;
+
+ const expectedCommands = new Map();
+ expectedCommands.set('Test.test', {
+ parameters: [{
+ name: 'test',
+ type: 'test',
+ optional: true,
+ }],
+ description: 'Description1',
+ replyArgs: ['Test1'],
+ });
+ expectedCommands.set('Test2.test2', {
+ parameters: [{
+ name: 'test2',
+ type: 'test2',
+ optional: true,
+ }],
+ description: 'Description2',
+ replyArgs: ['Test2'],
+ });
+ expectedCommands.set('Test2.test3', {
+ parameters: [{
+ name: 'test3',
+ type: 'test3',
+ optional: true,
+ }],
+ description: 'Description3',
+ replyArgs: ['Test3'],
+ });
+
+ const metadataByCommand = ProtocolMonitor.ProtocolMonitor.buildProtocolMetadata(domains);
+ assert.deepStrictEqual(metadataByCommand, expectedCommands);
+ });
+ });
+
+ describe('HistoryAutocompleteDataProvider', () => {
+ it('should create completions with no history', async () => {
+ const provider = new ProtocolMonitor.ProtocolMonitor.CommandAutocompleteSuggestionProvider();
+ assert.deepStrictEqual(await provider.buildTextPromptCompletions('test', 'test'), []);
+ });
+
+ it('should build completions in the reverse insertion order', async () => {
+ const provider = new ProtocolMonitor.ProtocolMonitor.CommandAutocompleteSuggestionProvider();
+
+ provider.addEntry('test1');
+ provider.addEntry('test2');
+ provider.addEntry('test3');
+ assert.deepStrictEqual(await provider.buildTextPromptCompletions('test', 'test'), [
+ {text: 'test3'},
+ {text: 'test2'},
+ {text: 'test1'},
+ ]);
+
+ provider.addEntry('test1');
+ assert.deepStrictEqual(await provider.buildTextPromptCompletions('test', 'test'), [
+ {text: 'test1'},
+ {text: 'test3'},
+ {text: 'test2'},
+ ]);
+ });
+
+ it('should limit the number of completions', async () => {
+ const provider = new ProtocolMonitor.ProtocolMonitor.CommandAutocompleteSuggestionProvider(2);
+
+ provider.addEntry('test1');
+ provider.addEntry('test2');
+ provider.addEntry('test3');
+
+ assert.deepStrictEqual(await provider.buildTextPromptCompletions('test', 'test'), [
+ {text: 'test3'},
+ {text: 'test2'},
+ ]);
+ });
+ });
+});
diff --git a/front_end/panels/protocol_monitor/components/BUILD.gn b/front_end/panels/protocol_monitor/components/BUILD.gn
index d476d8a..2d5587f 100644
--- a/front_end/panels/protocol_monitor/components/BUILD.gn
+++ b/front_end/panels/protocol_monitor/components/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../../scripts/build/ninja/devtools_module.gni")
import("../../../../scripts/build/ninja/generate_css.gni")
+import("../../../../third_party/typescript/typescript.gni")
generate_css("css_files") {
sources = [
@@ -44,7 +45,18 @@
visibility = [
":*",
"../:*",
- "../../../../test/unittests/front_end/panels/protocol_monitor/*",
"../../../ui/components/docs/*",
]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [ "JSONEditor.test.ts" ]
+
+ deps = [
+ ":bundle",
+ "../../../../test/unittests/front_end/helpers",
+ "../../../ui/components/suggestion_input:bundle",
+ ]
+}
diff --git a/front_end/panels/protocol_monitor/components/JSONEditor.test.ts b/front_end/panels/protocol_monitor/components/JSONEditor.test.ts
new file mode 100644
index 0000000..5d42e5d
--- /dev/null
+++ b/front_end/panels/protocol_monitor/components/JSONEditor.test.ts
@@ -0,0 +1,1397 @@
+// 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.
+
+const {assert} = chai;
+import * as ProtocolMonitor from '../protocol_monitor.js';
+import {
+ getEventPromise,
+ dispatchKeyDownEvent,
+ dispatchMouseMoveEvent,
+ dispatchClickEvent,
+ renderElementIntoDOM,
+ raf,
+} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import * as ProtocolComponents from './components.js';
+import type * as SuggestionInput from '../../../ui/components/suggestion_input/suggestion_input.js';
+import * as Coordinator from '../../../ui/components/render_coordinator/render_coordinator.js';
+
+import {describeWithEnvironment} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import * as Menus from '../../../ui/components/menus/menus.js';
+import * as Host from '../../../core/host/host.js';
+import * as UI from '../../../ui/legacy/legacy.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+describeWithEnvironment('JSONEditor', () => {
+ const renderJSONEditor = () => {
+ const jsonEditor = new ProtocolComponents.JSONEditor.JSONEditor();
+ jsonEditor.metadataByCommand = new Map();
+ jsonEditor.typesByName = new Map();
+ jsonEditor.enumsByName = new Map();
+ jsonEditor.connectedCallback();
+ renderElementIntoDOM(jsonEditor);
+ return jsonEditor;
+ };
+
+ const populateMetadata = async (jsonEditor: ProtocolComponents.JSONEditor.JSONEditor) => {
+ const mockDomain = [
+ {
+ domain: 'Test',
+ metadata: {
+ 'Test.test': {
+ parameters: [{
+ name: 'test',
+ type: 'string',
+ optional: false,
+ typeRef: 'Test.testRef',
+ }],
+ description: 'Description1.',
+ replyArgs: ['Test1'],
+ },
+ 'Test.test2': {
+ parameters: [{
+ 'optional': true,
+ 'type': 'array',
+ 'name': 'test2',
+ 'typeRef': 'string',
+ }],
+ },
+ 'Test.test3': {
+ parameters: [{
+ 'optional': false,
+ 'type': 'object',
+ 'value': [
+ {
+ 'optional': true,
+ 'type': 'string',
+ 'name': 'param1',
+ },
+ {
+ 'optional': true,
+ 'type': 'string',
+ 'name': 'param2',
+ },
+ ],
+ 'name': 'test3',
+ 'typeRef': 'string',
+ }],
+ },
+ 'Test.test4': {
+ parameters: [{
+ name: 'test',
+ type: 'boolean',
+ optional: false,
+ }],
+ description: 'Description4.',
+ replyArgs: ['Test4'],
+ },
+ 'Test.test5': {
+ parameters: [
+ {
+ name: 'test',
+ type: 'string',
+ optional: true,
+ },
+ {
+ name: 'test2',
+ type: 'string',
+ optional: true,
+ },
+ ],
+ description: 'Description5.',
+ replyArgs: ['Test5'],
+ },
+ 'Test.test6': {
+ parameters: [{
+ name: 'test',
+ type: 'number',
+ optional: true,
+ }],
+ description: 'Description6.',
+ replyArgs: ['Test6'],
+ },
+ 'Test.test7': {
+ parameters: [{
+ name: 'test',
+ type: 'boolean',
+ optional: true,
+ }],
+ description: 'Description7.',
+ replyArgs: ['Test7'],
+ },
+ 'Test.test8': {
+ parameters: [{
+ name: 'test',
+ type: 'number',
+ optional: false,
+ }],
+ description: 'Description8.',
+ replyArgs: ['Test8'],
+ },
+ 'Test.test9': {
+ parameters: [
+ {
+ 'name': 'traceConfig',
+ 'type': 'object',
+ 'optional': false,
+ 'description': '',
+ 'typeRef': 'Tracing.TraceConfig',
+ },
+ ],
+ description: 'Description9.',
+ replyArgs: ['Test9'],
+ },
+ 'Test.test10': {
+ parameters: [
+ {
+ 'name': 'NoTypeRef',
+ 'type': 'object',
+ 'optional': true,
+ 'description': '',
+ 'typeRef': 'NoTypeRef',
+ },
+ ],
+ description: 'Description10.',
+ replyArgs: ['Test10'],
+ },
+ 'Test.test11': {
+ parameters: [{
+ 'optional': false,
+ 'type': 'array',
+ 'name': 'test11',
+ 'typeRef': 'Test.arrayTypeRef',
+ }],
+ },
+ 'Test.test12': {
+ parameters: [{
+ 'optional': true,
+ 'type': 'object',
+ 'value': [
+ {
+ 'optional': false,
+ 'type': 'string',
+ 'name': 'param1',
+ },
+ {
+ 'optional': false,
+ 'type': 'number',
+ 'name': 'param2',
+ },
+ ],
+ 'name': 'test12',
+ 'typeRef': 'Optional.Object',
+ }],
+ },
+ 'Test.test13': {
+ parameters: [{
+ name: 'newTest',
+ type: 'string',
+ optional: false,
+ typeRef: 'Test.newTestRef',
+ }],
+ description: 'Description13.',
+ replyArgs: ['Test13'],
+ },
+ 'Test.test14': {
+ parameters: [
+ {
+ 'name': 'NoTypeRef',
+ 'type': 'object',
+ 'optional': true,
+ 'description': '',
+ },
+ ],
+ description: 'Description14.',
+ replyArgs: ['Test14'],
+ },
+ },
+ },
+ ] as Iterable<ProtocolMonitor.ProtocolMonitor.ProtocolDomain>;
+
+ const metadataByCommand = ProtocolMonitor.ProtocolMonitor.buildProtocolMetadata(mockDomain);
+ jsonEditor.metadataByCommand = metadataByCommand;
+ await jsonEditor.updateComplete;
+ };
+
+ const renderHoveredElement = async (element: Element|null) => {
+ if (element) {
+ const clock = sinon.useFakeTimers();
+ try {
+ dispatchMouseMoveEvent(element, {
+ bubbles: true,
+ composed: true,
+ });
+ clock.tick(300);
+ clock.restore();
+ } finally {
+ clock.restore();
+ }
+ await raf();
+ } else {
+ throw new Error('No parameter has been found');
+ }
+ };
+
+ const renderSuggestionBox = async (
+ command: string, jsonEditor: ProtocolComponents.JSONEditor.JSONEditor,
+ enumsByName?: Map<string, Record<string, string>>) => {
+ jsonEditor.command = command;
+ if (enumsByName) {
+ jsonEditor.enumsByName = enumsByName;
+ }
+ jsonEditor.populateParametersForCommandWithDefaultValues();
+ await jsonEditor.updateComplete;
+
+ const inputs = jsonEditor.renderRoot.querySelectorAll('devtools-suggestion-input');
+ // inputs[0] corresponds to the devtools-suggestion-input of the command
+ const suggestionInput = inputs[1];
+ // Reset the value to empty string because for boolean it will be set to false by default and the correct suggestions will not show
+ suggestionInput.value = '';
+ suggestionInput.focus();
+
+ await suggestionInput.updateComplete;
+ const suggestionBox = suggestionInput.renderRoot.querySelector('devtools-suggestion-box');
+
+ if (!suggestionBox) {
+ throw new Error('No suggestion box shown');
+ }
+ const suggestions = Array.from(suggestionBox.renderRoot.querySelectorAll('li')).map(item => {
+ if (!item.textContent) {
+ throw new Error('No text inside suggestion');
+ }
+ return (item.textContent.replaceAll(/\s/g, ''));
+ });
+ return suggestions;
+ };
+
+ const serializePopupContent = () => {
+ const container = document.body.querySelector<HTMLDivElement>('[data-devtools-glass-pane]');
+ const hintDetailView = container?.shadowRoot?.querySelector('devtools-css-hint-details-view');
+ return hintDetailView?.shadowRoot?.textContent?.replaceAll(/\s/g, '');
+ };
+
+ const renderEditorForCommand = async(command: string, parameters: {[paramName: string]: unknown}): Promise<{
+ inputs: NodeListOf<SuggestionInput.SuggestionInput.SuggestionInput>,
+ displayedCommand: string,
+ jsonEditor: ProtocolComponents.JSONEditor.JSONEditor,
+ }> => {
+ const typesByName = new Map();
+ typesByName.set('string', [
+ {name: 'param1', type: 'string', optional: false, description: 'display a string', typeRef: null},
+ {name: 'param2', type: 'string', optional: false, description: 'displays another string', typeRef: null},
+ ]);
+
+ const jsonEditor = renderJSONEditor();
+
+ await populateMetadata(jsonEditor);
+ jsonEditor.typesByName = typesByName;
+
+ jsonEditor.displayCommand(command, parameters);
+
+ await jsonEditor.updateComplete;
+ const shadowRoot = jsonEditor.renderRoot;
+ const inputs = shadowRoot.querySelectorAll('devtools-suggestion-input');
+ const displayedCommand = jsonEditor.command;
+ return {inputs, displayedCommand, jsonEditor};
+ };
+
+ const renderParamsWithDefaultValues = async (command: string) => {
+ const jsonEditor = renderJSONEditor();
+
+ await populateMetadata(jsonEditor);
+ jsonEditor.command = command;
+ jsonEditor.populateParametersForCommandWithDefaultValues();
+ await jsonEditor.updateComplete;
+
+ const param = jsonEditor.renderRoot.querySelector('[data-paramId]');
+ await renderHoveredElement(param);
+
+ const setDefaultValueButton = jsonEditor.renderRoot.querySelector('devtools-button');
+ if (!setDefaultValueButton) {
+ throw new Error('No button');
+ }
+ dispatchClickEvent(setDefaultValueButton, {
+ bubbles: true,
+ composed: true,
+ });
+
+ await jsonEditor.updateComplete;
+
+ const input = jsonEditor.renderRoot.querySelectorAll('devtools-suggestion-input');
+ const paramInput = input[1];
+
+ if (!paramInput) {
+ throw new Error('No input shown');
+ }
+ return paramInput;
+ };
+
+ const renderWarningIcon = async (command: string, enumsByName?: Map<string, Record<string, string>>) => {
+ const jsonEditor = renderJSONEditor();
+ await populateMetadata(jsonEditor);
+ jsonEditor.command = command;
+ if (enumsByName) {
+ jsonEditor.enumsByName = enumsByName;
+ }
+ jsonEditor.populateParametersForCommandWithDefaultValues();
+ await jsonEditor.updateComplete;
+
+ // inputs[0] corresponds to the devtools-suggestion-input of the command
+ const input = jsonEditor.renderRoot.querySelectorAll('devtools-suggestion-input')[1];
+ if (!input) {
+ throw Error('No editable content displayed');
+ }
+ input.value = 'Not an accepted value';
+ await jsonEditor.updateComplete;
+ input.focus();
+ input.blur();
+ await jsonEditor.updateComplete;
+ const warningIcon = jsonEditor.renderRoot.querySelector('devtools-icon');
+ if (!warningIcon) {
+ throw Error('No icon displayed');
+ }
+ return warningIcon;
+ };
+
+ describe('Binding input bar', () => {
+ it('should show the command written in the input bar inside the editor when parameters are strings with the correct value',
+ async () => {
+ const cdpCommand = {
+ 'command': 'Test.test',
+ 'parameters': {
+ 'test': 'test',
+ },
+ };
+
+ const {command, parameters} = ProtocolMonitor.ProtocolMonitor.parseCommandInput(JSON.stringify(cdpCommand));
+ const {inputs} = await renderEditorForCommand(command, parameters);
+ const parameterRecorderInput = inputs[1];
+ const value = parameterRecorderInput.renderRoot.textContent?.replaceAll(/\s/g, '');
+ const expectedValue = 'test';
+ assert.deepStrictEqual(value, expectedValue);
+ });
+
+ it('should show the command written in the input bar inside the editor when parameters are arrays with the correct value',
+ async () => {
+ const cdpCommand = {
+ 'command': 'Test.test2',
+ 'parameters': {
+ 'test2': ['test'],
+ },
+ };
+ const {command, parameters} = ProtocolMonitor.ProtocolMonitor.parseCommandInput(JSON.stringify(cdpCommand));
+ const {inputs} = await renderEditorForCommand(command, parameters);
+ const parameterRecorderInput = inputs[1];
+ const value = parameterRecorderInput.renderRoot.textContent?.replaceAll(/\s/g, '');
+ const expectedValue = 'test';
+ assert.deepStrictEqual(value, expectedValue);
+ });
+
+ it('should show the command written in the input bar inside the editor when parameters are object with the correct value',
+ async () => {
+ const cdpCommand = {
+ 'command': 'Test.test3',
+ 'parameters': {
+ 'test3': {
+ 'param1': 'test1',
+ 'param2': 'test2',
+ },
+ },
+ };
+
+ const {command, parameters} = ProtocolMonitor.ProtocolMonitor.parseCommandInput(JSON.stringify(cdpCommand));
+ const {inputs} = await renderEditorForCommand(command, parameters);
+ const parameterRecorderInput = inputs[1];
+ const value = parameterRecorderInput.renderRoot.textContent?.replaceAll(/\s/g, '');
+ const expectedValue = 'test1';
+ assert.deepStrictEqual(value, expectedValue);
+ });
+
+ it('should should every parameter of a command as undefined even if some parameters have not been entered inside the input bar',
+ async () => {
+ const cdpCommand = {
+ 'command': 'Test.test5',
+ 'parameters': {
+ 'test': 'test',
+ },
+ };
+ const {command, parameters} = ProtocolMonitor.ProtocolMonitor.parseCommandInput(JSON.stringify(cdpCommand));
+
+ const jsonEditor = renderJSONEditor();
+
+ await populateMetadata(jsonEditor);
+
+ jsonEditor.displayCommand(command, parameters);
+
+ await jsonEditor.updateComplete;
+ const shadowRoot = jsonEditor.renderRoot;
+ const displayedParameters = shadowRoot.querySelectorAll('.parameter');
+ // Two parameters (test and test2) should be displayed because in the metadata, Test.test5 accepts two parameters
+ assert.deepStrictEqual(displayedParameters.length, 2);
+ });
+ it('does not output parameters if the input is invalid json', async () => {
+ const cdpCommand = '"command": "Test.test", "parameters":';
+ const {command, parameters} = ProtocolMonitor.ProtocolMonitor.parseCommandInput(cdpCommand);
+
+ const {inputs} = await renderEditorForCommand(command, parameters);
+
+ assert.deepStrictEqual(inputs.length, Object.keys(parameters).length + 1);
+ });
+
+ it('does not output parameters if the parameters field is not an object', async () => {
+ const cdpCommand = '"command": "test", "parameters": 1234';
+
+ const {command, parameters} = ProtocolMonitor.ProtocolMonitor.parseCommandInput(cdpCommand);
+
+ const {inputs} = await renderEditorForCommand(command, parameters);
+
+ assert.deepStrictEqual(inputs.length, Object.keys(parameters).length + 1);
+ });
+
+ it('does not output parameters if there is no parameter inserted in the input bar', async () => {
+ const cdpCommand = '"command": "test"';
+
+ const {command, parameters} = ProtocolMonitor.ProtocolMonitor.parseCommandInput(cdpCommand);
+
+ const {inputs} = await renderEditorForCommand(command, parameters);
+
+ assert.deepStrictEqual(inputs.length, Object.keys(parameters).length + 1);
+ });
+
+ it('checks that the command input field remains empty when there is no command parameter entered', async () => {
+ const cdpCommand = {
+ 'parameters': {
+ 'test': 'test',
+ },
+ };
+
+ const {command, parameters} = ProtocolMonitor.ProtocolMonitor.parseCommandInput(JSON.stringify(cdpCommand));
+
+ const {displayedCommand} = await renderEditorForCommand(command, parameters);
+
+ assert.deepStrictEqual(displayedCommand, '');
+ });
+
+ it('checks that the command input field remains if the command is not supported', async () => {
+ const cdpCommand = 'dummyCommand';
+
+ const {command, parameters} = ProtocolMonitor.ProtocolMonitor.parseCommandInput(JSON.stringify(cdpCommand));
+ const {displayedCommand} = await renderEditorForCommand(command, parameters);
+
+ assert.deepStrictEqual(displayedCommand, '');
+ });
+ });
+
+ describe('Display command written in editor inside input bar', () => {
+ it('should display the command edited inside the CDP editor into the input bar', async () => {
+ const split = new UI.SplitWidget.SplitWidget(true, false, 'protocol-monitor-split-container', 400);
+ const editorWidget = new ProtocolMonitor.ProtocolMonitor.EditorWidget();
+ const jsonEditor = editorWidget.jsonEditor;
+ jsonEditor.command = 'Test.test';
+ jsonEditor.parameters = [
+ {
+ name: 'test',
+ type: ProtocolComponents.JSONEditor.ParameterType.String,
+ description: 'test',
+ optional: false,
+ value: 'test',
+ },
+ ];
+ const dataGrid = new ProtocolMonitor.ProtocolMonitor.ProtocolMonitorDataGrid(split);
+ split.setMainWidget(dataGrid);
+ split.setSidebarWidget(editorWidget);
+ split.toggleSidebar();
+ await coordinator.done();
+
+ // The first input bar corresponds to the filter bar, so we query the second one which corresponds to the CDP one.
+ const toolbarInput = dataGrid.element.shadowRoot?.querySelectorAll('.toolbar')[1].shadowRoot?.querySelector(
+ '.toolbar-input-prompt');
+ assert.deepStrictEqual(toolbarInput?.innerHTML, '{"command":"Test.test","parameters":{"test":"test"}}');
+ });
+
+ it('should update the selected target inside the input bar', async () => {
+ const split = new UI.SplitWidget.SplitWidget(true, false, 'protocol-monitor-split-container', 400);
+ const editorWidget = new ProtocolMonitor.ProtocolMonitor.EditorWidget();
+ const jsonEditor = editorWidget.jsonEditor;
+ jsonEditor.targetId = 'value2';
+ const dataGrid = new ProtocolMonitor.ProtocolMonitor.ProtocolMonitorDataGrid(split);
+ const selector = dataGrid.selector;
+
+ selector.createOption('Option 1', 'value1');
+ selector.createOption('Option 2', 'value2');
+ selector.createOption('Option 3', 'value3');
+
+ split.setMainWidget(dataGrid);
+ split.setSidebarWidget(editorWidget);
+
+ split.toggleSidebar();
+ await coordinator.done();
+
+ // Should be index 1 because the targetId equals "value2" which corresponds to the index number 1
+ assert.deepStrictEqual(selector.selectedIndex(), 1);
+ });
+
+ // Flaky test.
+ it.skip(
+ '[crbug.com/1484534]: should not display the command into the input bar if the command is empty string',
+ async () => {
+ const split = new UI.SplitWidget.SplitWidget(true, false, 'protocol-monitor-split-container', 400);
+ const editorWidget = new ProtocolMonitor.ProtocolMonitor.EditorWidget();
+ const jsonEditor = editorWidget.jsonEditor;
+ jsonEditor.command = '';
+ const dataGrid = new ProtocolMonitor.ProtocolMonitor.ProtocolMonitorDataGrid(split);
+ split.setMainWidget(dataGrid);
+ split.setSidebarWidget(editorWidget);
+ split.toggleSidebar();
+
+ await coordinator.done();
+
+ // The first input bar corresponds to the filter bar, so we query the second one which corresponds to the CDP one.
+ const toolbarInput = dataGrid.element.shadowRoot?.querySelectorAll('.toolbar')[1].shadowRoot?.querySelector(
+ '.toolbar-input-prompt');
+ assert.deepStrictEqual(toolbarInput?.innerHTML, '');
+ });
+ });
+ describe('Descriptions', () => {
+ it('should show the popup with the correct description for the description of parameters', async () => {
+ const inputParameters = [
+ {
+ type: 'array',
+ optional: false,
+ value: [
+ {name: '0', value: 'value0', optional: true, type: 'string'},
+ {name: '1', value: 'value1', optional: true, type: 'string'},
+ {name: '2', value: 'value2', optional: true, type: 'string'},
+ ],
+ name: 'arrayParam',
+ typeRef: 'string',
+ description: 'test.',
+ },
+ ] as ProtocolComponents.JSONEditor.Parameter[];
+ const jsonEditor = renderJSONEditor();
+
+ jsonEditor.parameters = inputParameters;
+ await jsonEditor.updateComplete;
+ const param = jsonEditor.renderRoot.querySelector('[data-paramId]');
+
+ await renderHoveredElement(param);
+ const popupContent = serializePopupContent();
+ const expectedPopupContent = 'test.Type:arrayLearnMore';
+ assert.deepStrictEqual(popupContent, expectedPopupContent);
+ });
+
+ it('should show the popup with the correct description for the description of command', async () => {
+ const cdpCommand = 'Test.test';
+ const jsonEditor = renderJSONEditor();
+
+ await populateMetadata(jsonEditor);
+ jsonEditor.command = cdpCommand;
+ await jsonEditor.updateComplete;
+
+ const command = jsonEditor.renderRoot.querySelector('.command');
+ await renderHoveredElement(command);
+
+ const popupContent = serializePopupContent();
+
+ const expectedPopupContent = 'Description1.Returns:Test1LearnMore';
+ assert.deepStrictEqual(popupContent, expectedPopupContent);
+ });
+ });
+
+ describe('Suggestion box', () => {
+ it('should display suggestion box with correct suggestions when the parameter is an enum', async () => {
+ const enumsByName = new Map([
+ ['Test.testRef', {'Test': 'test', 'Test1': 'test1', 'Test2': 'test2'}],
+ ]);
+ const command = 'Test.test';
+
+ const jsonEditor = renderJSONEditor();
+
+ await populateMetadata(jsonEditor);
+
+ const suggestions = await renderSuggestionBox(command, jsonEditor, enumsByName);
+ assert.deepStrictEqual(suggestions, ['test', 'test1', 'test2']);
+ });
+
+ it('should display suggestion box with correct suggestions when the parameter is a boolean', async () => {
+ const command = 'Test.test4';
+
+ const jsonEditor = renderJSONEditor();
+
+ await populateMetadata(jsonEditor);
+
+ const suggestions = await renderSuggestionBox(command, jsonEditor);
+
+ assert.deepStrictEqual(suggestions, ['false', 'true']);
+ });
+
+ it('should show the suggestion box for enum parameters nested inside arrays', async () => {
+ const enumsByName = new Map([
+ ['Test.arrayTypeRef', {'Test': 'test', 'Test1': 'test1', 'Test2': 'test2'}],
+ ]);
+ const command = 'Test.test11';
+
+ const jsonEditor = renderJSONEditor();
+
+ await populateMetadata(jsonEditor);
+ jsonEditor.enumsByName = enumsByName;
+ jsonEditor.command = command;
+ jsonEditor.populateParametersForCommandWithDefaultValues();
+
+ await jsonEditor.updateComplete;
+
+ const param = jsonEditor.renderRoot.querySelector('[data-paramId]');
+ await renderHoveredElement(param);
+
+ const addParamButton = jsonEditor.renderRoot.querySelector('devtools-button[title="Add a parameter"]');
+ if (!addParamButton) {
+ throw new Error('No button');
+ }
+ dispatchClickEvent(addParamButton, {
+ bubbles: true,
+ composed: true,
+ });
+
+ await jsonEditor.updateComplete;
+
+ const inputs = jsonEditor.renderRoot.querySelectorAll('devtools-suggestion-input');
+ // inputs[0] corresponds to the devtools-suggestion-input of the command
+ const suggestionInput = inputs[1];
+ // Reset the value to empty string because for boolean it will be set to false by default and the correct suggestions will not show
+ suggestionInput.value = '';
+ suggestionInput.focus();
+
+ await suggestionInput.updateComplete;
+ const suggestionBox = suggestionInput.renderRoot.querySelector('devtools-suggestion-box');
+
+ if (!suggestionBox) {
+ throw new Error('No suggestion box shown');
+ }
+ const suggestions = Array.from(suggestionBox.renderRoot.querySelectorAll('li')).map(item => {
+ if (!item.textContent) {
+ throw new Error('No text inside suggestion');
+ }
+ return (item.textContent.replaceAll(/\s/g, ''));
+ });
+
+ assert.deepStrictEqual(suggestions, ['test', 'test1', 'test2']);
+ });
+
+ it('should update the values inside the suggestion box when the command changes', async () => {
+ const enumsByName = new Map();
+ enumsByName.set('Test.testRef', {'Test': 'test', 'Test1': 'test1', 'Test2': 'test2'});
+ enumsByName.set('Test.newTestRef', {'NewTest': 'newtest', 'NewTest1': 'newtest1', 'NewTest2': 'newtest2'});
+
+ const command = 'Test.test';
+
+ const jsonEditor = renderJSONEditor();
+
+ await populateMetadata(jsonEditor);
+
+ await renderSuggestionBox(command, jsonEditor, enumsByName);
+
+ const newCommand = 'Test.test13';
+
+ const newSuggestions = await renderSuggestionBox(newCommand, jsonEditor, enumsByName);
+
+ assert.deepStrictEqual(newSuggestions, ['newtest', 'newtest1', 'newtest2']);
+ });
+
+ it('should not display suggestion box when the parameter is neither a string or a boolean', async () => {
+ const command = 'Test.test8';
+
+ const jsonEditor = renderJSONEditor();
+
+ await populateMetadata(jsonEditor);
+
+ const suggestions = await renderSuggestionBox(command, jsonEditor);
+
+ assert.deepStrictEqual(suggestions, []);
+ });
+ });
+
+ describe('Display with default values', () => {
+ it('should show <empty_string> inside the placeholder when clicking on plus button for optional string parameter',
+ async () => {
+ const command = 'Test.test5';
+
+ const placeholder = (await renderParamsWithDefaultValues(command)).placeholder;
+
+ const expectedPlaceholder = '<empty_string>';
+
+ assert.deepStrictEqual(placeholder, expectedPlaceholder);
+ });
+
+ it('should show 0 as a value inside input when clicking on plus button for optional number parameter', async () => {
+ const command = 'Test.test6';
+
+ const value = Number((await renderParamsWithDefaultValues(command)).value);
+
+ const expectedValue = 0;
+
+ assert.deepStrictEqual(value, expectedValue);
+ });
+
+ it('should show false as a value inside input when clicking on plus button for optional boolean parameter',
+ async () => {
+ const command = 'Test.test7';
+
+ const value = Boolean((await renderParamsWithDefaultValues(command)).value);
+
+ const expectedValue = false;
+
+ assert.deepStrictEqual(value, expectedValue);
+ });
+
+ it('should show the keys with default values when clicking of plus button for optional object parameters',
+ async () => {
+ const command = 'Test.test12';
+ const typesByName = new Map();
+ typesByName.set('Optional.Object', [
+ {
+ 'optional': false,
+ 'type': 'string',
+ 'name': 'param1',
+ },
+ {
+ 'optional': false,
+ 'type': 'number',
+ 'name': 'param2',
+ },
+ ]);
+ const jsonEditor = renderJSONEditor();
+ jsonEditor.typesByName = typesByName;
+ await populateMetadata(jsonEditor);
+ jsonEditor.command = command;
+ jsonEditor.populateParametersForCommandWithDefaultValues();
+ await jsonEditor.updateComplete;
+
+ const param = jsonEditor.renderRoot.querySelector('[data-paramId]');
+ await renderHoveredElement(param);
+
+ const showDefaultValuesButton =
+ jsonEditor.renderRoot.querySelector('devtools-button[title="Add a parameter"]');
+ if (!showDefaultValuesButton) {
+ throw new Error('No button');
+ }
+
+ dispatchClickEvent(showDefaultValuesButton, {
+ bubbles: true,
+ composed: true,
+ });
+
+ await jsonEditor.updateComplete;
+
+ // The -1 is need to not take into account the input for the command
+ const numberOfInputs = jsonEditor.renderRoot.querySelectorAll('devtools-suggestion-input').length - 1;
+
+ assert.deepStrictEqual(numberOfInputs, 2);
+ });
+
+ });
+
+ describe('Reset to default values', () => {
+ it('should reset the value of keys of object parameter to default value when clicking on clear button',
+ async () => {
+ const cdpCommand = {
+ 'command': 'Test.test3',
+ 'parameters': {
+ 'test3': {
+ 'param1': 'test1',
+ 'param2': 'test2',
+ },
+ },
+ };
+
+ const {command, parameters} = ProtocolMonitor.ProtocolMonitor.parseCommandInput(JSON.stringify(cdpCommand));
+ const {jsonEditor} = await renderEditorForCommand(command, parameters);
+
+ const param = jsonEditor.renderRoot.querySelector('[data-paramId=\'test3\']');
+
+ await renderHoveredElement(param);
+
+ const setDefaultValueButton = jsonEditor.renderRoot.querySelector('devtools-button');
+
+ if (!setDefaultValueButton) {
+ throw new Error('No button');
+ }
+ dispatchClickEvent(setDefaultValueButton, {
+ bubbles: true,
+ composed: true,
+ });
+
+ await jsonEditor.updateComplete;
+
+ const input = jsonEditor.renderRoot.querySelectorAll('devtools-suggestion-input');
+ const values = [input[1].value, input[2].value];
+
+ const expectedValues = ['', ''];
+
+ assert.deepStrictEqual(values, expectedValues);
+ });
+
+ it('should reset the value of array parameter to empty array when clicking on clear button', async () => {
+ const inputParameters = [
+ {
+ type: 'array',
+ optional: false,
+ value: [
+ {name: '0', value: 'value0', optional: true, type: 'string'},
+ {name: '1', value: 'value1', optional: true, type: 'string'},
+ {name: '2', value: 'value2', optional: true, type: 'string'},
+ ],
+ name: 'arrayParam',
+ typeRef: 'string',
+ },
+ ];
+
+ const jsonEditor = renderJSONEditor();
+ jsonEditor.parameters = inputParameters as ProtocolComponents.JSONEditor.Parameter[];
+ await jsonEditor.updateComplete;
+
+ const param = jsonEditor.renderRoot.querySelector('[data-paramId=\'arrayParam\']');
+
+ await renderHoveredElement(param);
+
+ const setDefaultValueButton =
+ jsonEditor.renderRoot.querySelector('devtools-button[title="Reset to default value"]');
+
+ if (!setDefaultValueButton) {
+ throw new Error('No button');
+ }
+ dispatchClickEvent(setDefaultValueButton, {
+ bubbles: true,
+ composed: true,
+ });
+
+ await jsonEditor.updateComplete;
+
+ const value = jsonEditor.parameters[0].value;
+
+ assert.deepStrictEqual(value, []);
+ });
+
+ it('should reset the value of optional object parameter to undefined after clicking on clear button', async () => {
+ const command = 'Test.test12';
+ const typesByName = new Map();
+ typesByName.set('Optional.Object', [
+ {
+ 'optional': false,
+ 'type': 'string',
+ 'name': 'param1',
+ },
+ {
+ 'optional': false,
+ 'type': 'number',
+ 'name': 'param2',
+ },
+ ]);
+ const jsonEditor = renderJSONEditor();
+ jsonEditor.typesByName = typesByName;
+ await populateMetadata(jsonEditor);
+ jsonEditor.command = command;
+ jsonEditor.populateParametersForCommandWithDefaultValues();
+ await jsonEditor.updateComplete;
+
+ const param = jsonEditor.renderRoot.querySelector('[data-paramId]');
+ await renderHoveredElement(param);
+
+ const showDefaultValuesButton = jsonEditor.renderRoot.querySelector('devtools-button[title="Add a parameter"]');
+ if (!showDefaultValuesButton) {
+ throw new Error('No button');
+ }
+
+ dispatchClickEvent(showDefaultValuesButton, {
+ bubbles: true,
+ composed: true,
+ });
+
+ await jsonEditor.updateComplete;
+
+ await renderHoveredElement(param);
+ const clearButton = jsonEditor.renderRoot.querySelector('devtools-button[title="Reset to default value"]');
+
+ if (!clearButton) {
+ throw new Error('No clear button');
+ }
+
+ dispatchClickEvent(clearButton, {
+ bubbles: true,
+ composed: true,
+ });
+
+ await jsonEditor.updateComplete;
+ // The -1 is need to not take into account the input for the command
+ const numberOfInputs = jsonEditor.renderRoot.querySelectorAll('devtools-suggestion-input').length - 1;
+
+ assert.deepStrictEqual(numberOfInputs, 0);
+ });
+ });
+
+ describe('Delete and add for array parameters', () => {
+ it('should delete the specified array parameter by clicking the "Delete" button', async () => {
+ const inputParameters = [
+ {
+ type: 'array',
+ optional: false,
+ value: [
+ {name: '0', value: 'value0', optional: true, type: 'string'},
+ {name: '1', value: 'value1', optional: true, type: 'string'},
+ {name: '2', value: 'value2', optional: true, type: 'string'},
+ ],
+ name: 'arrayParam',
+ typeRef: 'string',
+ },
+
+ ];
+
+ const expectedParams = {
+ arrayParam: ['value1', 'value2'],
+ };
+
+ const jsonEditor = renderJSONEditor();
+ jsonEditor.parameters = inputParameters as ProtocolComponents.JSONEditor.Parameter[];
+ await jsonEditor.updateComplete;
+
+ const shadowRoot = jsonEditor.renderRoot;
+
+ const parameterIndex = 0;
+ const deleteButtons = shadowRoot.querySelectorAll('devtools-button[title="Delete parameter"]');
+ if (deleteButtons.length > parameterIndex) {
+ deleteButtons[parameterIndex].dispatchEvent(new Event('click'));
+ }
+
+ const resultedParams = jsonEditor.getParameters();
+ assert.deepStrictEqual(expectedParams, resultedParams);
+ });
+
+ it('should add parameters when clicking on "Plus" button for array parameters', async () => {
+ const command = 'Test.test2';
+
+ const jsonEditor = renderJSONEditor();
+
+ await populateMetadata(jsonEditor);
+ jsonEditor.command = command;
+ jsonEditor.populateParametersForCommandWithDefaultValues();
+ await jsonEditor.updateComplete;
+
+ const param = jsonEditor.renderRoot.querySelector('[data-paramId]');
+ await renderHoveredElement(param);
+
+ const addParamButton = jsonEditor.renderRoot.querySelector('devtools-button[title="Add a parameter"]');
+ if (!addParamButton) {
+ throw new Error('No button');
+ }
+ dispatchClickEvent(addParamButton, {
+ bubbles: true,
+ composed: true,
+ });
+ dispatchClickEvent(addParamButton, {
+ bubbles: true,
+ composed: true,
+ });
+
+ await jsonEditor.updateComplete;
+
+ // The -1 is need to not take into account the input for the command
+ const numberOfInputs = jsonEditor.renderRoot.querySelectorAll('devtools-suggestion-input').length - 1;
+
+ assert.deepStrictEqual(numberOfInputs, 2);
+ });
+ });
+
+ describe('Send parameters in a correct format', () => {
+ it('should return the parameters in a format understandable by the ProtocolMonitor when sending a command via CTRL + Enter',
+ async () => {
+ const jsonEditor = renderJSONEditor();
+
+ const inputParameters = [
+ {
+ 'optional': true,
+ 'type': 'string',
+ 'value': 'test0',
+ 'name': 'test0',
+ },
+ {
+ 'optional': true,
+ 'type': 'string',
+ 'value': 'test1',
+ 'name': 'test1',
+ },
+ {
+ 'optional': false,
+ 'type': 'string',
+ 'value': 'test2',
+ 'name': 'test2',
+ },
+ {
+ 'optional': true,
+ 'type': 'array',
+ 'value': [
+ {
+ 'optional': true,
+ 'type': 'string',
+ 'value': 'param1Value',
+ 'name': 'param1',
+ },
+ {
+ 'optional': true,
+ 'type': 'string',
+ 'value': 'param2Value',
+ 'name': 'param2',
+ },
+ ],
+ 'name': 'test3',
+ },
+ {
+ 'optional': true,
+ 'type': 'object',
+ 'value': [
+ {
+ 'optional': true,
+ 'type': 'string',
+ 'value': 'param1Value',
+ 'name': 'param1',
+ },
+ {
+ 'optional': true,
+ 'type': 'string',
+ 'value': 'param2Value',
+ 'name': 'param2',
+ },
+ ],
+ 'name': 'test4',
+ },
+ ];
+
+ const expectedParameters = {
+ 'test0': 'test0',
+ 'test1': 'test1',
+ 'test2': 'test2',
+ 'test3': ['param1Value', 'param2Value'],
+ 'test4': {
+ 'param1': 'param1Value',
+ 'param2': 'param2Value',
+ },
+ };
+
+ jsonEditor.parameters = inputParameters as ProtocolComponents.JSONEditor.Parameter[];
+ const responsePromise = getEventPromise(jsonEditor, ProtocolComponents.JSONEditor.SubmitEditorEvent.eventName);
+
+ dispatchKeyDownEvent(jsonEditor, {key: 'Enter', ctrlKey: true, metaKey: true});
+
+ const response = await responsePromise as ProtocolComponents.JSONEditor.SubmitEditorEvent;
+
+ assert.deepStrictEqual(response.data.parameters, expectedParameters);
+ });
+
+ it('should return the parameters in a format understandable by the ProtocolMonitor when sending a command via the send button',
+ async () => {
+ const jsonEditor = renderJSONEditor();
+ jsonEditor.command = 'Test.test';
+ jsonEditor.parameters = [
+ {
+ name: 'testName',
+ type: ProtocolComponents.JSONEditor.ParameterType.String,
+ description: 'test',
+ optional: false,
+ value: 'testValue',
+ },
+ ];
+ await jsonEditor.updateComplete;
+
+ const toolbar = jsonEditor.renderRoot.querySelector('devtools-pm-toolbar');
+ if (!toolbar) {
+ throw Error('No toolbar found !');
+ }
+ const event = new ProtocolComponents.Toolbar.SendCommandEvent();
+ const responsePromise = getEventPromise(jsonEditor, ProtocolComponents.JSONEditor.SubmitEditorEvent.eventName);
+
+ toolbar.dispatchEvent(event);
+ const response = await responsePromise as ProtocolComponents.JSONEditor.SubmitEditorEvent;
+
+ const expectedParameters = {
+ 'testName': 'testValue',
+ };
+
+ assert.deepStrictEqual(response.data.parameters, expectedParameters);
+ });
+ });
+
+ describe('Verify the type of the entered value', () => {
+ it('should show a warning icon if the type of the parameter is number but the entered value is not', async () => {
+ const command = 'Test.test8';
+
+ const warningIcon = await renderWarningIcon(command);
+ assert.isNotNull(warningIcon);
+ });
+ it('should show a warning icon if the type of the parameter is boolean but the entered value is not true or false',
+ async () => {
+ const command = 'Test.test4';
+ const warningIcon = await renderWarningIcon(command);
+ assert.isNotNull(warningIcon);
+ });
+ it('should show a warning icon if the type of the parameter is enum but the entered value is not among the accepted values',
+ async () => {
+ const enumsByName = new Map([
+ ['Test.testRef', {'Test': 'test', 'Test1': 'test1', 'Test2': 'test2'}],
+ ]);
+ const command = 'Test.test';
+ const warningIcon = await renderWarningIcon(command, enumsByName);
+ assert.isNotNull(warningIcon);
+ });
+ });
+
+ it('should not display parameters if a command is unknown', async () => {
+ const cdpCommand = 'Unknown';
+ const jsonEditor = renderJSONEditor();
+
+ await populateMetadata(jsonEditor);
+ jsonEditor.command = cdpCommand;
+ await jsonEditor.updateComplete;
+
+ const inputs = jsonEditor.renderRoot.querySelectorAll('devtools-suggestion-input');
+ const addButtons = jsonEditor.renderRoot.querySelectorAll('devtools-button[title="Add a parameter"]');
+
+ assert.deepStrictEqual(inputs.length, 1);
+ assert.deepStrictEqual(addButtons.length, 0);
+ });
+
+ it('checks that the selection of a target works', async () => {
+ const jsonEditor = renderJSONEditor();
+ await jsonEditor.updateComplete;
+ const targetId = 'target1';
+ const event = new Menus.SelectMenu.SelectMenuItemSelectedEvent('target1');
+
+ const shadowRoot = jsonEditor.renderRoot;
+ const selectMenu = shadowRoot.querySelector('devtools-select-menu');
+ selectMenu?.dispatchEvent(event);
+ const expectedId = jsonEditor.targetId;
+
+ assert.deepStrictEqual(targetId, expectedId);
+ });
+
+ it('should copy the CDP command to clipboard via copy event', async () => {
+ const jsonEditor = renderJSONEditor();
+ jsonEditor.command = 'Test.test';
+ jsonEditor.parameters = [
+ {
+ name: 'test',
+ type: ProtocolComponents.JSONEditor.ParameterType.String,
+ description: 'test',
+ optional: false,
+ value: 'test',
+ },
+ ];
+ await jsonEditor.updateComplete;
+ const isCalled = sinon.promise();
+ const copyText = sinon
+ .stub(
+ Host.InspectorFrontendHost.InspectorFrontendHostInstance,
+ 'copyText',
+ )
+ .callsFake(() => {
+ void isCalled.resolve(true);
+ });
+ const toolbar = jsonEditor.renderRoot.querySelector('devtools-pm-toolbar');
+ if (!toolbar) {
+ throw Error('No toolbar found !');
+ }
+ const event = new ProtocolComponents.Toolbar.CopyCommandEvent();
+ toolbar.dispatchEvent(event);
+ await isCalled;
+
+ assert.isTrue(copyText.calledWith(JSON.stringify({command: 'Test.test', parameters: {'test': 'test'}})));
+ });
+
+ it('should display the correct parameters with a command with array nested inside object', async () => {
+ const command = 'Test.test9';
+ const typesByName = new Map();
+ // This nested object contains every subtype including array
+ typesByName.set('Tracing.TraceConfig', [
+ {
+ 'name': 'recordMode',
+ 'type': 'string',
+ 'optional': true,
+ 'description': 'Controls how the trace buffer stores data.',
+ 'typeRef': null,
+ },
+ {
+ 'name': 'traceBufferSizeInKb',
+ 'type': 'number',
+ 'optional': true,
+ 'description':
+ 'Size of the trace buffer in kilobytes. If not specified or zero is passed, a default value of 200 MB would be used.',
+ 'typeRef': null,
+ },
+ {
+ 'name': 'enableSystrace',
+ 'type': 'boolean',
+ 'optional': true,
+ 'description': 'Turns on system tracing.',
+ 'typeRef': null,
+ },
+ {
+ 'name': 'includedCategories',
+ 'type': 'array',
+ 'optional': true,
+ 'description': 'Included category filters.',
+ 'typeRef': 'string',
+ },
+ {
+ 'name': 'memoryDumpConfig',
+ 'type': 'object',
+ 'optional': true,
+ 'description': 'Configuration for memory dump triggers. Used only when \\"memory-infra\\" category is enabled.',
+ 'typeRef':
+ 'Tracing.MemoryDumpConfig', // This typeref is on purpose not added to show that this param will be treated as a string parameter
+ },
+ ]);
+
+ const jsonEditor = renderJSONEditor();
+
+ await populateMetadata(jsonEditor);
+ jsonEditor.typesByName = typesByName;
+ jsonEditor.command = command;
+ jsonEditor.populateParametersForCommandWithDefaultValues();
+ await jsonEditor.updateComplete;
+ const shadowRoot = jsonEditor.renderRoot;
+ const parameters = shadowRoot.querySelectorAll('.parameter');
+ // This expected value is equal to 6 because there are 5 different parameters inside typesByName + 1
+ // for the name of the parameter (traceConfig)
+ assert.deepStrictEqual(parameters.length, 6);
+ });
+
+ it('should return the parameters in a format understandable by the ProtocolMonitor when sending a command with object parameter that has no typeRef found in map',
+ async () => {
+ const command = 'Test.test10';
+ const typesByName = new Map();
+ // We set the map typesBynames without the key NoTypeRef
+ typesByName.set('Tracing.TraceConfig', [
+ {
+ 'name': 'memoryDumpConfig',
+ 'type': 'object',
+ 'optional': true,
+ 'description':
+ 'Configuration for memory dump triggers. Used only when \\"memory-infra\\" category is enabled.',
+ 'typeRef':
+ 'Tracing.MemoryDumpConfig', // This typeref is on purpose not added to show that this param will be treated as a string parameter
+ },
+ ]);
+
+ const jsonEditor = renderJSONEditor();
+
+ await populateMetadata(jsonEditor);
+ jsonEditor.typesByName = typesByName;
+ jsonEditor.command = command;
+ jsonEditor.populateParametersForCommandWithDefaultValues();
+ await jsonEditor.updateComplete;
+ const shadowRoot = jsonEditor.renderRoot;
+ const parameters = shadowRoot.querySelector('.parameter');
+
+ await renderHoveredElement(parameters);
+
+ const addParamButton = jsonEditor.renderRoot.querySelector('devtools-button[title="Add custom property"]');
+ if (!addParamButton) {
+ throw new Error('No button');
+ }
+ // We click two times to display two parameters with key/value pairs
+ dispatchClickEvent(addParamButton, {
+ bubbles: true,
+ composed: true,
+ });
+ dispatchClickEvent(addParamButton, {
+ bubbles: true,
+ composed: true,
+ });
+
+ await jsonEditor.updateComplete;
+ const editors = shadowRoot.querySelectorAll('devtools-suggestion-input');
+
+ // Editors[0] refers to the command editor, so we start at index 1
+ // We populate the key/value pairs
+ editors[1].value = 'testName1';
+ await jsonEditor.updateComplete;
+ editors[1].focus();
+ editors[1].blur();
+ await jsonEditor.updateComplete;
+
+ editors[2].value = 'testValue1';
+ await jsonEditor.updateComplete;
+ editors[2].focus();
+ editors[2].blur();
+ await jsonEditor.updateComplete;
+
+ editors[3].value = 'testName2';
+ await jsonEditor.updateComplete;
+ editors[3].focus();
+ editors[3].blur();
+ await jsonEditor.updateComplete;
+
+ editors[4].value = 'testValue2';
+ await jsonEditor.updateComplete;
+ editors[4].focus();
+ editors[4].blur();
+ await jsonEditor.updateComplete;
+
+ const responsePromise = getEventPromise(jsonEditor, ProtocolComponents.JSONEditor.SubmitEditorEvent.eventName);
+
+ // We send the command
+ dispatchKeyDownEvent(jsonEditor, {key: 'Enter', ctrlKey: true, metaKey: true});
+
+ const response = await responsePromise as ProtocolComponents.JSONEditor.SubmitEditorEvent;
+
+ const expectedParameters = {
+ 'NoTypeRef': {
+ 'testName1': 'testValue1',
+ 'testName2': 'testValue2',
+ },
+ };
+
+ assert.deepStrictEqual(response.data.parameters, expectedParameters);
+ });
+
+ it('should show the custom editor for an object param that has no type ref', async () => {
+ const command = 'Test.test14';
+ const jsonEditor = renderJSONEditor();
+
+ await populateMetadata(jsonEditor);
+ jsonEditor.command = command;
+ jsonEditor.populateParametersForCommandWithDefaultValues();
+ await jsonEditor.updateComplete;
+ const shadowRoot = jsonEditor.renderRoot;
+ const parameters = shadowRoot.querySelector('.parameter');
+
+ await renderHoveredElement(parameters);
+
+ const addParamButton = jsonEditor.renderRoot.querySelector('devtools-button[title="Add custom property"]');
+ if (!addParamButton) {
+ throw new Error('No button');
+ }
+ // We click two times to display two parameters with key/value pairs
+ dispatchClickEvent(addParamButton, {
+ bubbles: true,
+ composed: true,
+ });
+ dispatchClickEvent(addParamButton, {
+ bubbles: true,
+ composed: true,
+ });
+
+ await jsonEditor.updateComplete;
+ // The -1 is need to not take into account the input for the command
+
+ const numberOfInputs = jsonEditor.renderRoot.querySelectorAll('devtools-suggestion-input').length - 1;
+
+ assert.deepStrictEqual(numberOfInputs, 4);
+ });
+
+ describe('Command suggestion filter', () => {
+ it('filters the commands by substring match', async () => {
+ assert(ProtocolComponents.JSONEditor.suggestionFilter('Test', 'Tes'));
+ assert(ProtocolComponents.JSONEditor.suggestionFilter('Test', 'est'));
+ assert(!ProtocolComponents.JSONEditor.suggestionFilter('Test', 'dest'));
+ });
+ });
+});
diff --git a/front_end/panels/recorder/BUILD.gn b/front_end/panels/recorder/BUILD.gn
index d95a3fc..78fc220 100644
--- a/front_end/panels/recorder/BUILD.gn
+++ b/front_end/panels/recorder/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../scripts/build/ninja/devtools_module.gni")
import("../../../scripts/build/ninja/generate_css.gni")
+import("../../../third_party/typescript/typescript.gni")
generate_css("css_files") {
sources = [ "recorderController.css" ]
@@ -73,3 +74,17 @@
"../../entrypoints/inspector:*",
]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [ "RecorderController.test.ts" ]
+
+ deps = [
+ ":bundle",
+ "../../../test/unittests/front_end/helpers",
+ "../../ui/components/render_coordinator:bundle",
+ "./components:bundle",
+ "./models:bundle",
+ ]
+}
diff --git a/front_end/panels/recorder/RecorderController.test.ts b/front_end/panels/recorder/RecorderController.test.ts
new file mode 100644
index 0000000..eeec488
--- /dev/null
+++ b/front_end/panels/recorder/RecorderController.test.ts
@@ -0,0 +1,481 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/* eslint-disable rulesdir/es_modules_import */
+
+const {assert} = chai;
+
+import {RecorderActions} from './recorder-actions/recorder-actions.js';
+import {RecorderController} from './recorder.js';
+import * as Models from './models/models.js';
+import * as Components from './components/components.js';
+import {
+ describeWithEnvironment,
+ setupActionRegistry,
+} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import * as Coordinator from '../../ui/components/render_coordinator/render_coordinator.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+describeWithEnvironment('RecorderController', () => {
+ setupActionRegistry();
+
+ function makeRecording(): Models.RecordingStorage.StoredRecording {
+ const step = {
+ type: Models.Schema.StepType.Navigate as const,
+ url: 'https://example.com',
+ };
+ const recording = {
+ storageName: 'test',
+ flow: {title: 'test', steps: [step]},
+ };
+ return recording;
+ }
+
+ async function setupController(
+ recording: Models.RecordingStorage.StoredRecording,
+ ): Promise<RecorderController.RecorderController> {
+ const controller = new RecorderController.RecorderController();
+ controller.setCurrentPageForTesting(RecorderController.Pages.RecordingPage);
+ controller.setCurrentRecordingForTesting(recording);
+ controller.connectedCallback();
+ await coordinator.done();
+ return controller;
+ }
+
+ describe('Navigation', () => {
+ it('should return back to the previous page on recordingcancelled event', async () => {
+ const previousPage = RecorderController.Pages.AllRecordingsPage;
+ const controller = new RecorderController.RecorderController();
+ controller.setCurrentPageForTesting(previousPage);
+ controller.setCurrentPageForTesting(
+ RecorderController.Pages.CreateRecordingPage,
+ );
+ controller.connectedCallback();
+ await coordinator.done();
+
+ const createRecordingView = controller.shadowRoot?.querySelector(
+ 'devtools-create-recording-view',
+ );
+ assert.ok(createRecordingView);
+ createRecordingView?.dispatchEvent(
+ new Components.CreateRecordingView.RecordingCancelledEvent(),
+ );
+
+ assert.strictEqual(controller.getCurrentPageForTesting(), previousPage);
+ });
+ });
+
+ describe('StepView', () => {
+ async function dispatchRecordingViewEvent(
+ controller: RecorderController.RecorderController,
+ event: Event,
+ ): Promise<void> {
+ const recordingView = controller.shadowRoot?.querySelector(
+ 'devtools-recording-view',
+ );
+ assert.ok(recordingView);
+ recordingView?.dispatchEvent(event);
+ await coordinator.done();
+ }
+
+ beforeEach(() => {
+ Models.RecordingStorage.RecordingStorage.instance().clearForTest();
+ });
+
+ after(() => {
+ Models.RecordingStorage.RecordingStorage.instance().clearForTest();
+ });
+
+ it('should add a new step after a step', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+
+ await dispatchRecordingViewEvent(
+ controller,
+ new Components.StepView.AddStep(
+ recording.flow.steps[0],
+ Components.StepView.AddStepPosition.AFTER,
+ ),
+ );
+
+ const flow = controller.getUserFlow();
+ assert.deepStrictEqual(flow, {
+ title: 'test',
+ steps: [
+ {
+ type: Models.Schema.StepType.Navigate as const,
+ url: 'https://example.com',
+ },
+ {
+ type: Models.Schema.StepType.WaitForElement as const,
+ selectors: ['body'],
+ },
+ ],
+ });
+ });
+
+ it('should add a new step after a section', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+
+ const sections = controller.getSectionsForTesting();
+ if (!sections) {
+ throw new Error('Controller is missing sections');
+ }
+ assert.lengthOf(sections, 1);
+ await dispatchRecordingViewEvent(
+ controller,
+ new Components.StepView.AddStep(
+ sections[0],
+ Components.StepView.AddStepPosition.AFTER,
+ ),
+ );
+
+ const flow = controller.getUserFlow();
+ assert.deepStrictEqual(flow, {
+ title: 'test',
+ steps: [
+ {
+ type: Models.Schema.StepType.Navigate as const,
+ url: 'https://example.com',
+ },
+ {
+ type: Models.Schema.StepType.WaitForElement as const,
+ selectors: ['body'],
+ },
+ ],
+ });
+ });
+
+ it('should add a new step before a step', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+
+ await dispatchRecordingViewEvent(
+ controller,
+ new Components.StepView.AddStep(
+ recording.flow.steps[0],
+ Components.StepView.AddStepPosition.BEFORE,
+ ),
+ );
+
+ const flow = controller.getUserFlow();
+ assert.deepStrictEqual(flow, {
+ title: 'test',
+ steps: [
+ {
+ type: Models.Schema.StepType.WaitForElement as const,
+ selectors: ['body'],
+ },
+ {
+ type: Models.Schema.StepType.Navigate as const,
+ url: 'https://example.com',
+ },
+ ],
+ });
+ });
+
+ it('should delete a step', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+
+ await dispatchRecordingViewEvent(
+ controller,
+ new Components.StepView.RemoveStep(recording.flow.steps[0]),
+ );
+
+ const flow = controller.getUserFlow();
+ assert.deepStrictEqual(flow, {title: 'test', steps: []});
+ });
+
+ it('should adding a new step before a step with a breakpoint update the breakpoint indexes correctly', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+ const stepIndex = 3;
+
+ await dispatchRecordingViewEvent(
+ controller,
+ new Components.StepView.AddBreakpointEvent(stepIndex),
+ );
+ assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), [
+ stepIndex,
+ ]);
+ await dispatchRecordingViewEvent(
+ controller,
+ new Components.StepView.AddStep(
+ recording.flow.steps[0],
+ Components.StepView.AddStepPosition.BEFORE,
+ ),
+ );
+
+ // Breakpoint index moves to the next index
+ assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), [
+ stepIndex + 1,
+ ]);
+ });
+
+ it('should removing a step before a step with a breakpoint update the breakpoint indexes correctly', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+ const stepIndex = 3;
+
+ await dispatchRecordingViewEvent(
+ controller,
+ new Components.StepView.AddBreakpointEvent(stepIndex),
+ );
+ assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), [
+ stepIndex,
+ ]);
+ await dispatchRecordingViewEvent(
+ controller,
+ new Components.StepView.RemoveStep(recording.flow.steps[0]),
+ );
+
+ // Breakpoint index moves to the previous index
+ assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), [
+ stepIndex - 1,
+ ]);
+ });
+
+ it('should removing a step with a breakpoint remove the breakpoint index as well', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+ const stepIndex = 0;
+
+ await dispatchRecordingViewEvent(
+ controller,
+ new Components.StepView.AddBreakpointEvent(stepIndex),
+ );
+ assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), [
+ stepIndex,
+ ]);
+ await dispatchRecordingViewEvent(
+ controller,
+ new Components.StepView.RemoveStep(recording.flow.steps[stepIndex]),
+ );
+
+ // Breakpoint index is removed
+ assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), []);
+ });
+
+ it('should "add breakpoint" event add a breakpoint', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+ const stepIndex = 1;
+
+ assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), []);
+ await dispatchRecordingViewEvent(
+ controller,
+ new Components.StepView.AddBreakpointEvent(stepIndex),
+ );
+
+ assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), [
+ stepIndex,
+ ]);
+ });
+
+ it('should "remove breakpoint" event remove a breakpoint', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+ const stepIndex = 1;
+
+ await dispatchRecordingViewEvent(
+ controller,
+ new Components.StepView.AddBreakpointEvent(stepIndex),
+ );
+ assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), [
+ stepIndex,
+ ]);
+ await dispatchRecordingViewEvent(
+ controller,
+ new Components.StepView.RemoveBreakpointEvent(stepIndex),
+ );
+
+ assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), []);
+ });
+ });
+
+ describe('Create new recording action', () => {
+ it('should execute action', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+
+ await controller.handleActions(RecorderActions.CreateRecording);
+
+ assert.strictEqual(
+ controller.getCurrentPageForTesting(),
+ RecorderController.Pages.CreateRecordingPage,
+ );
+ });
+
+ it('should not execute action while recording', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+
+ controller.setIsRecordingStateForTesting(true);
+
+ await controller.handleActions(RecorderActions.CreateRecording);
+
+ assert.strictEqual(
+ controller.getCurrentPageForTesting(),
+ RecorderController.Pages.RecordingPage,
+ );
+ });
+
+ it('should not execute action while replaying', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+
+ controller.setRecordingStateForTesting({
+ isPlaying: true,
+ isPausedOnBreakpoint: false,
+ });
+
+ await controller.handleActions(RecorderActions.CreateRecording);
+
+ assert.strictEqual(
+ controller.getCurrentPageForTesting(),
+ RecorderController.Pages.RecordingPage,
+ );
+ });
+ });
+
+ describe('Action is possible', () => {
+ it('should return true for create action when not replaying or recording', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+
+ assert.isTrue(
+ controller.isActionPossible(RecorderActions.CreateRecording),
+ );
+ });
+
+ it('should return false for create action when recording', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+
+ controller.setRecordingStateForTesting({
+ isPlaying: true,
+ isPausedOnBreakpoint: false,
+ });
+
+ assert.isFalse(
+ controller.isActionPossible(RecorderActions.CreateRecording),
+ );
+ });
+
+ it('should return false for create action when replaying', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+
+ controller.setIsRecordingStateForTesting(true);
+
+ assert.isFalse(
+ controller.isActionPossible(RecorderActions.CreateRecording),
+ );
+ });
+
+ it('should return correct value for start/stop action', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+
+ assert.isTrue(
+ controller.isActionPossible(RecorderActions.StartRecording),
+ );
+
+ controller.setRecordingStateForTesting({
+ isPlaying: true,
+ isPausedOnBreakpoint: false,
+ });
+ assert.isFalse(
+ controller.isActionPossible(RecorderActions.StartRecording),
+ );
+ });
+
+ it('should return true for replay action when on the recording page', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+
+ controller.setCurrentPageForTesting(
+ RecorderController.Pages.RecordingPage,
+ );
+
+ assert.isTrue(
+ controller.isActionPossible(RecorderActions.ReplayRecording),
+ );
+ });
+
+ it('should return false for replay action when not on the recording page', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+
+ controller.setCurrentPageForTesting(
+ RecorderController.Pages.AllRecordingsPage,
+ );
+ assert.isFalse(
+ controller.isActionPossible(RecorderActions.ReplayRecording),
+ );
+
+ controller.setCurrentPageForTesting(
+ RecorderController.Pages.CreateRecordingPage,
+ );
+ assert.isFalse(
+ controller.isActionPossible(RecorderActions.ReplayRecording),
+ );
+
+ controller.setCurrentPageForTesting(RecorderController.Pages.StartPage);
+ assert.isFalse(
+ controller.isActionPossible(RecorderActions.ReplayRecording),
+ );
+
+ controller.setRecordingStateForTesting({
+ isPlaying: true,
+ isPausedOnBreakpoint: false,
+ });
+ controller.setCurrentPageForTesting(
+ RecorderController.Pages.RecordingPage,
+ );
+ assert.isFalse(
+ controller.isActionPossible(RecorderActions.ReplayRecording),
+ );
+ });
+
+ it('should true for toggle when on the recording page', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+
+ controller.setCurrentPageForTesting(
+ RecorderController.Pages.RecordingPage,
+ );
+ assert.isTrue(
+ controller.isActionPossible(RecorderActions.ToggleCodeView),
+ );
+ });
+
+ it('should false for toggle when on the recording page', async () => {
+ const recording = makeRecording();
+ const controller = await setupController(recording);
+
+ controller.setCurrentPageForTesting(
+ RecorderController.Pages.AllRecordingsPage,
+ );
+ assert.isFalse(
+ controller.isActionPossible(RecorderActions.ToggleCodeView),
+ );
+
+ controller.setCurrentPageForTesting(RecorderController.Pages.StartPage);
+ assert.isFalse(
+ controller.isActionPossible(RecorderActions.ToggleCodeView),
+ );
+
+ controller.setCurrentPageForTesting(
+ RecorderController.Pages.AllRecordingsPage,
+ );
+ assert.isFalse(
+ controller.isActionPossible(RecorderActions.ToggleCodeView),
+ );
+ });
+ });
+});
diff --git a/front_end/panels/recorder/components/BUILD.gn b/front_end/panels/recorder/components/BUILD.gn
index 47a53b4..71176ca 100644
--- a/front_end/panels/recorder/components/BUILD.gn
+++ b/front_end/panels/recorder/components/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../../scripts/build/ninja/devtools_module.gni")
import("../../../../scripts/build/ninja/generate_css.gni")
+import("../../../../third_party/typescript/typescript.gni")
generate_css("css_files") {
sources = [
@@ -83,3 +84,25 @@
"../../../ui/components/docs/*",
]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [
+ "CreateRecordingView.test.ts",
+ "RecordingListView.test.ts",
+ "RecordingView.test.ts",
+ "ReplayButton.test.ts",
+ "SelectButton.test.ts",
+ "StepEditor.test.ts",
+ "StepView.test.ts",
+ ]
+
+ deps = [
+ ":bundle",
+ "../../../../test/unittests/front_end/helpers",
+ "../../../../test/unittests/front_end/helpers:recorder_helpers",
+ "../../../ui/components/text_editor:bundle",
+ "../models:bundle",
+ ]
+}
diff --git a/front_end/panels/recorder/components/CreateRecordingView.test.ts b/front_end/panels/recorder/components/CreateRecordingView.test.ts
new file mode 100644
index 0000000..309301e
--- /dev/null
+++ b/front_end/panels/recorder/components/CreateRecordingView.test.ts
@@ -0,0 +1,142 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Components from './components.js';
+import * as Models from '../models/models.js';
+import {
+ describeWithEnvironment,
+ setupActionRegistry,
+} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {renderElementIntoDOM} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+
+describeWithEnvironment('CreateRecordingView', () => {
+ setupActionRegistry();
+
+ function createView() {
+ const view = new Components.CreateRecordingView.CreateRecordingView();
+ view.data = {
+ recorderSettings: new Models.RecorderSettings.RecorderSettings(),
+ };
+ renderElementIntoDOM(view, {
+ allowMultipleChildren: true,
+ });
+ return view;
+ }
+
+ it('should render create recording view', async () => {
+ const view = createView();
+ const input = view.shadowRoot?.querySelector(
+ '#user-flow-name',
+ ) as HTMLInputElement;
+ assert.ok(input);
+ const button = view.shadowRoot?.querySelector(
+ 'devtools-control-button',
+ ) as Components.ControlButton.ControlButton;
+ assert.ok(button);
+ const onceClicked = new Promise<Components.CreateRecordingView.RecordingStartedEvent>(
+ resolve => {
+ view.addEventListener('recordingstarted', resolve, {once: true});
+ },
+ );
+ input.value = 'Test';
+ button.dispatchEvent(new Event('click'));
+ const event = await onceClicked;
+ assert.deepEqual(event.name, 'Test');
+ });
+
+ it('should dispatch recordingcancelled event on the close button click', async () => {
+ const view = createView();
+ const onceClicked = new Promise<Components.CreateRecordingView.RecordingCancelledEvent>(
+ resolve => {
+ view.addEventListener('recordingcancelled', resolve, {once: true});
+ },
+ );
+ const closeButton = view.shadowRoot?.querySelector(
+ '[title="Cancel recording"]',
+ ) as HTMLButtonElement;
+
+ closeButton.dispatchEvent(new Event('click'));
+ const event = await onceClicked;
+ assert.instanceOf(
+ event,
+ Components.CreateRecordingView.RecordingCancelledEvent,
+ );
+ });
+
+ it('should generate a default name', async () => {
+ const view = createView();
+ const input = view.shadowRoot?.querySelector(
+ '#user-flow-name',
+ ) as HTMLInputElement;
+ assert.isAtLeast(input.value.length, 'Recording'.length);
+ });
+
+ it('should remember the most recent selector attribute', async () => {
+ let view = createView();
+ let input = view.shadowRoot?.querySelector(
+ '#selector-attribute',
+ ) as HTMLInputElement;
+ assert.ok(input);
+ const button = view.shadowRoot?.querySelector(
+ 'devtools-control-button',
+ ) as Components.ControlButton.ControlButton;
+ assert.ok(button);
+ const onceClicked = new Promise<Components.CreateRecordingView.RecordingStartedEvent>(
+ resolve => {
+ view.addEventListener('recordingstarted', resolve, {once: true});
+ },
+ );
+ input.value = 'data-custom-attribute';
+ button.dispatchEvent(new Event('click'));
+ await onceClicked;
+
+ view = createView();
+ input = view.shadowRoot?.querySelector(
+ '#selector-attribute',
+ ) as HTMLInputElement;
+ assert.ok(input);
+ assert.strictEqual(input.value, 'data-custom-attribute');
+ });
+
+ it('should remember recorded selector types', async () => {
+ let view = createView();
+
+ let checkboxes = view.shadowRoot?.querySelectorAll(
+ '.selector-type input[type=checkbox]',
+ ) as NodeListOf<HTMLInputElement>;
+ assert.strictEqual(checkboxes.length, 5);
+ const button = view.shadowRoot?.querySelector(
+ 'devtools-control-button',
+ ) as Components.ControlButton.ControlButton;
+ assert.ok(button);
+ const onceClicked = new Promise<Components.CreateRecordingView.RecordingStartedEvent>(
+ resolve => {
+ view.addEventListener('recordingstarted', resolve, {once: true});
+ },
+ );
+ checkboxes[0].checked = false;
+ button.dispatchEvent(new Event('click'));
+ const event = await onceClicked;
+
+ assert.deepStrictEqual(event.selectorTypesToRecord, [
+ 'aria',
+ 'text',
+ 'xpath',
+ 'pierce',
+ ]);
+
+ view = createView();
+ checkboxes = view.shadowRoot?.querySelectorAll(
+ '.selector-type input[type=checkbox]',
+ ) as NodeListOf<HTMLInputElement>;
+ assert.strictEqual(checkboxes.length, 5);
+ assert.isFalse(checkboxes[0].checked);
+ assert.isTrue(checkboxes[1].checked);
+ assert.isTrue(checkboxes[2].checked);
+ assert.isTrue(checkboxes[3].checked);
+ assert.isTrue(checkboxes[4].checked);
+ });
+});
diff --git a/front_end/panels/recorder/components/RecordingListView.test.ts b/front_end/panels/recorder/components/RecordingListView.test.ts
new file mode 100644
index 0000000..0752305
--- /dev/null
+++ b/front_end/panels/recorder/components/RecordingListView.test.ts
@@ -0,0 +1,84 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Components from './components.js';
+
+import {
+ describeWithEnvironment,
+ setupActionRegistry,
+} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {
+ dispatchClickEvent,
+ dispatchKeyDownEvent,
+renderElementIntoDOM} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import * as Coordinator from '../../../ui/components/render_coordinator/render_coordinator.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+describeWithEnvironment('RecordingListView', () => {
+ setupActionRegistry();
+
+ it('should open a recording on Enter', async () => {
+ const view = new Components.RecordingListView.RecordingListView();
+ renderElementIntoDOM(view);
+ view.recordings = [{storageName: 'storage-test', name: 'test'}];
+ await coordinator.done();
+ const recording = view.shadowRoot?.querySelector('.row') as HTMLDivElement;
+ assert.ok(recording);
+ const eventSent = new Promise<Components.RecordingListView.OpenRecordingEvent>(
+ resolve => {
+ view.addEventListener('openrecording', resolve, {once: true});
+ },
+ );
+ dispatchKeyDownEvent(recording, {key: 'Enter'});
+ const event = await eventSent;
+ assert.strictEqual(event.storageName, 'storage-test');
+ });
+
+ it('should delete a recording', async () => {
+ const view = new Components.RecordingListView.RecordingListView();
+ renderElementIntoDOM(view);
+ view.recordings = [{storageName: 'storage-test', name: 'test'}];
+ await coordinator.done();
+ const deleteButton = view.shadowRoot?.querySelector(
+ '.delete-recording-button',
+ ) as HTMLButtonElement;
+ assert.ok(deleteButton);
+ const eventSent = new Promise<Components.RecordingListView.DeleteRecordingEvent>(
+ resolve => {
+ view.addEventListener('deleterecording', resolve, {once: true});
+ },
+ );
+ dispatchClickEvent(deleteButton);
+ const event = await eventSent;
+ assert.strictEqual(event.storageName, 'storage-test');
+ });
+
+ it('should not open a recording on Enter on the delete button', async () => {
+ const view = new Components.RecordingListView.RecordingListView();
+ renderElementIntoDOM(view);
+ view.recordings = [{storageName: 'storage-test', name: 'test'}];
+ await coordinator.done();
+ const deleteButton = view.shadowRoot?.querySelector(
+ '.delete-recording-button',
+ ) as HTMLDivElement;
+ assert.ok(deleteButton);
+ let forceResolve: Function|undefined;
+ const eventSent = new Promise<Components.RecordingListView.OpenRecordingEvent>(
+ resolve => {
+ forceResolve = resolve;
+ view.addEventListener('openrecording', resolve, {once: true});
+ },
+ );
+ dispatchKeyDownEvent(deleteButton, {key: 'Enter', bubbles: true});
+ const maybeEvent = await Promise.race([
+ eventSent,
+ new Promise(resolve => queueMicrotask(() => resolve('timeout'))),
+ ]);
+ assert.strictEqual(maybeEvent, 'timeout');
+ forceResolve?.();
+ });
+});
diff --git a/front_end/panels/recorder/components/RecordingView.test.ts b/front_end/panels/recorder/components/RecordingView.test.ts
new file mode 100644
index 0000000..2573a18
--- /dev/null
+++ b/front_end/panels/recorder/components/RecordingView.test.ts
@@ -0,0 +1,244 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from '../models/models.js';
+import * as Components from './components.js';
+import * as Converters from '../converters/converters.js';
+import type * as TextEditor from '../../../ui/components/text_editor/text_editor.js';
+import * as Host from '../../../core/host/host.js';
+
+import {
+ describeWithEnvironment,
+ setupActionRegistry,
+} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {
+ dispatchClickEvent,
+ dispatchMouseOverEvent,
+ getEventPromise,
+renderElementIntoDOM} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import * as Coordinator from '../../../ui/components/render_coordinator/render_coordinator.js';
+import * as Menus from '../../../ui/components/menus/menus.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+describeWithEnvironment('RecordingView', () => {
+ setupActionRegistry();
+
+ const step = {type: Models.Schema.StepType.Scroll as const};
+ const section = {title: 'test', steps: [step], url: 'https://example.com'};
+ const userFlow = {title: 'test', steps: [step]};
+ const recorderSettingsMock = {
+ preferredCopyFormat: Models.ConverterIds.ConverterIds.JSON,
+ } as Models.RecorderSettings.RecorderSettings;
+
+ async function renderView(): Promise<Components.RecordingView.RecordingView> {
+ const view = new Components.RecordingView.RecordingView();
+ recorderSettingsMock.preferredCopyFormat = Models.ConverterIds.ConverterIds.JSON;
+ renderElementIntoDOM(view);
+ view.data = {
+ replayState: {isPlaying: false, isPausedOnBreakpoint: false},
+ isRecording: false,
+ recordingTogglingInProgress: false,
+ recording: userFlow,
+ currentStep: undefined,
+ currentError: undefined,
+ sections: [section],
+ settings: undefined,
+ recorderSettings: recorderSettingsMock,
+ lastReplayResult: undefined,
+ replayAllowed: true,
+ breakpointIndexes: new Set(),
+ builtInConverters: [
+ new Converters.JSONConverter.JSONConverter(' '),
+ new Converters.PuppeteerReplayConverter.PuppeteerReplayConverter(' '),
+ ],
+ extensionConverters: [],
+ replayExtensions: [],
+ };
+ await coordinator.done();
+ return view;
+ }
+
+ async function waitForTextEditor(
+ view: Components.RecordingView.RecordingView,
+ ): Promise<TextEditor.TextEditor.TextEditor> {
+ await getEventPromise(view, 'code-generated');
+ const textEditor = view.shadowRoot?.querySelector('devtools-text-editor');
+ assert.isNotNull(textEditor);
+ return textEditor as TextEditor.TextEditor.TextEditor;
+ }
+
+ function hoverOverScrollStep(
+ view: Components.RecordingView.RecordingView,
+ ): void {
+ const steps = view.shadowRoot?.querySelectorAll('devtools-step-view') || [];
+ assert.lengthOf(steps, 2);
+ dispatchMouseOverEvent(steps[1]);
+ }
+
+ function clickStep(view: Components.RecordingView.RecordingView) {
+ const steps = view.shadowRoot?.querySelectorAll('devtools-step-view') || [];
+ assert.lengthOf(steps, 2);
+ dispatchClickEvent(steps[1]);
+ }
+
+ function dispatchOnStep(
+ view: Components.RecordingView.RecordingView,
+ customEvent: Event,
+ ) {
+ const steps = view.shadowRoot?.querySelectorAll('devtools-step-view') || [];
+ assert.lengthOf(steps, 2);
+ steps[1].dispatchEvent(customEvent);
+ }
+
+ function clickShowCode(view: Components.RecordingView.RecordingView) {
+ const button = view.shadowRoot?.querySelector(
+ '.show-code',
+ ) as HTMLDivElement;
+ assert.ok(button);
+ dispatchClickEvent(button);
+ }
+
+ function clickHideCode(view: Components.RecordingView.RecordingView) {
+ const button = view.shadowRoot?.querySelector(
+ '[title="Hide code"]',
+ ) as HTMLDivElement;
+ assert.ok(button);
+ dispatchClickEvent(button);
+ }
+
+ async function waitForSplitViewToDissappear(
+ view: Components.RecordingView.RecordingView,
+ ): Promise<void> {
+ await getEventPromise(view, 'code-generated');
+ const splitView = view.shadowRoot?.querySelector('devtools-split-view');
+ assert.isNull(splitView);
+ }
+
+ async function changeCodeView(view: Components.RecordingView.RecordingView): Promise<void> {
+ const menu = view.shadowRoot?.querySelector(
+ 'devtools-select-menu',
+ ) as Menus.SelectMenu.SelectMenu;
+ assert.ok(menu);
+
+ const event = new Menus.SelectMenu.SelectMenuItemSelectedEvent(Models.ConverterIds.ConverterIds.Replay);
+ menu.dispatchEvent(event);
+ }
+
+ it('should show code and highlight on hover', async () => {
+ const view = await renderView();
+
+ clickShowCode(view);
+
+ // Click is handled async, therefore, waiting for the text editor.
+ const textEditor = await waitForTextEditor(view);
+ assert.deepStrictEqual(textEditor.editor.state.selection.toJSON(), {
+ ranges: [{anchor: 0, head: 0}],
+ main: 0,
+ });
+
+ hoverOverScrollStep(view);
+ assert.deepStrictEqual(textEditor.editor.state.selection.toJSON(), {
+ ranges: [{anchor: 34, head: 68}],
+ main: 0,
+ });
+ });
+
+ it('should close code', async () => {
+ const view = await renderView();
+
+ clickShowCode(view);
+
+ // Click is handled async, therefore, waiting for the text editor.
+ await waitForTextEditor(view);
+
+ clickHideCode(view);
+
+ // Click is handled async, therefore, waiting for split view to be removed.
+ await waitForSplitViewToDissappear(view);
+ });
+
+ it('should copy the recording to clipboard via copy event', async () => {
+ await renderView();
+ const clipboardData = new DataTransfer();
+ const isCalled = sinon.promise();
+ const copyText = sinon
+ .stub(
+ Host.InspectorFrontendHost.InspectorFrontendHostInstance,
+ 'copyText',
+ )
+ .callsFake(() => {
+ void isCalled.resolve(true);
+ });
+ const event = new ClipboardEvent('copy', {clipboardData, bubbles: true});
+
+ document.body.dispatchEvent(event);
+
+ await isCalled;
+
+ assert.isTrue(
+ copyText.calledWith(JSON.stringify(userFlow, null, 2) + '\n'),
+ );
+ });
+
+ it('should copy a step to clipboard via copy event', async () => {
+ const view = await renderView();
+
+ clickStep(view);
+
+ const clipboardData = new DataTransfer();
+ const isCalled = sinon.promise();
+ const copyText = sinon
+ .stub(
+ Host.InspectorFrontendHost.InspectorFrontendHostInstance,
+ 'copyText',
+ )
+ .callsFake(() => {
+ void isCalled.resolve(true);
+ });
+ const event = new ClipboardEvent('copy', {clipboardData, bubbles: true});
+
+ document.body.dispatchEvent(event);
+
+ await isCalled;
+
+ assert.isTrue(copyText.calledWith(JSON.stringify(step, null, 2) + '\n'));
+ });
+
+ it('should copy a step to clipboard via custom event', async () => {
+ const view = await renderView();
+ const isCalled = sinon.promise();
+ const copyText = sinon
+ .stub(
+ Host.InspectorFrontendHost.InspectorFrontendHostInstance,
+ 'copyText',
+ )
+ .callsFake(() => {
+ void isCalled.resolve(true);
+ });
+ const event = new Components.StepView.CopyStepEvent(step);
+
+ dispatchOnStep(view, event);
+
+ await isCalled;
+
+ assert.isTrue(copyText.calledWith(JSON.stringify(step, null, 2) + '\n'));
+ });
+
+ it('should show code and change preferred copy method', async () => {
+ const view = await renderView();
+
+ clickShowCode(
+ view,
+ );
+
+ await waitForTextEditor(view);
+ await changeCodeView(view);
+ await waitForTextEditor(view);
+
+ assert.notEqual(recorderSettingsMock.preferredCopyFormat, Models.ConverterIds.ConverterIds.JSON);
+ });
+});
diff --git a/front_end/panels/recorder/components/ReplayButton.test.ts b/front_end/panels/recorder/components/ReplayButton.test.ts
new file mode 100644
index 0000000..348d8fe
--- /dev/null
+++ b/front_end/panels/recorder/components/ReplayButton.test.ts
@@ -0,0 +1,114 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as RecorderComponents from './components.js';
+import * as Models from '../models/models.js';
+import {
+ describeWithEnvironment,
+ setupActionRegistry,
+} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import * as Coordinator from '../../../ui/components/render_coordinator/render_coordinator.js';
+
+import {renderElementIntoDOM} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+describeWithEnvironment('ReplayButton', () => {
+ setupActionRegistry();
+
+ let settings: Models.RecorderSettings.RecorderSettings;
+ async function createReplayButton() {
+ settings = new Models.RecorderSettings.RecorderSettings();
+ const component = new RecorderComponents.ReplayButton.ReplayButton();
+ component.data = {settings, replayExtensions: []};
+ renderElementIntoDOM(component);
+ await coordinator.done();
+
+ return component;
+ }
+
+ afterEach(() => {
+ settings.speed = Models.RecordingPlayer.PlayRecordingSpeed.Normal;
+ });
+
+ it('should change the button value when another option is selected in select menu', async () => {
+ const component = await createReplayButton();
+ const selectButton = component.shadowRoot?.querySelector(
+ 'devtools-select-button',
+ );
+ assert.strictEqual(
+ selectButton?.value,
+ Models.RecordingPlayer.PlayRecordingSpeed.Normal,
+ );
+
+ selectButton?.dispatchEvent(
+ new RecorderComponents.SelectButton.SelectButtonClickEvent(
+ Models.RecordingPlayer.PlayRecordingSpeed.Slow,
+ ),
+ );
+ await coordinator.done();
+ assert.strictEqual(
+ selectButton?.value,
+ Models.RecordingPlayer.PlayRecordingSpeed.Slow,
+ );
+ });
+
+ it('should emit startreplayevent on selectbuttonclick event', async () => {
+ const component = await createReplayButton();
+ const onceClicked = new Promise<RecorderComponents.ReplayButton.StartReplayEvent>(
+ resolve => {
+ component.addEventListener('startreplay', resolve, {once: true});
+ },
+ );
+
+ const selectButton = component.shadowRoot?.querySelector(
+ 'devtools-select-button',
+ );
+ selectButton?.dispatchEvent(
+ new RecorderComponents.SelectButton.SelectButtonClickEvent(
+ Models.RecordingPlayer.PlayRecordingSpeed.Slow,
+ ),
+ );
+
+ const event = await onceClicked;
+ assert.deepEqual(
+ event.speed,
+ Models.RecordingPlayer.PlayRecordingSpeed.Slow,
+ );
+ });
+
+ it('should save the changed button when option is selected in select menu', async () => {
+ const component = await createReplayButton();
+ const selectButton = component.shadowRoot?.querySelector(
+ 'devtools-select-button',
+ );
+
+ selectButton?.dispatchEvent(
+ new RecorderComponents.SelectButton.SelectButtonClickEvent(
+ Models.RecordingPlayer.PlayRecordingSpeed.Slow,
+ ),
+ );
+
+ assert.strictEqual(
+ settings.speed,
+ Models.RecordingPlayer.PlayRecordingSpeed.Slow,
+ );
+ });
+
+ it('should load the saved button on initial render', async () => {
+ settings.speed = Models.RecordingPlayer.PlayRecordingSpeed.Slow;
+
+ const component = await createReplayButton();
+
+ const selectButton = component.shadowRoot?.querySelector(
+ 'devtools-select-button',
+ );
+ assert.strictEqual(
+ selectButton?.value,
+ Models.RecordingPlayer.PlayRecordingSpeed.Slow,
+ );
+ });
+});
diff --git a/front_end/panels/recorder/components/SelectButton.test.ts b/front_end/panels/recorder/components/SelectButton.test.ts
new file mode 100644
index 0000000..73c987c
--- /dev/null
+++ b/front_end/panels/recorder/components/SelectButton.test.ts
@@ -0,0 +1,70 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as RecorderComponents from './components.js';
+import * as Menus from '../../../ui/components/menus/menus.js';
+
+import * as Coordinator from '../../../ui/components/render_coordinator/render_coordinator.js';
+
+import {renderElementIntoDOM} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+describe('SelectButton', () => {
+ it('should emit selectbuttonclick event on button click', async () => {
+ const component = new RecorderComponents.SelectButton.SelectButton();
+ component.value = 'item1';
+ component.items = [
+ {value: 'item1', label: () => 'item1-label'},
+ {value: 'item2', label: () => 'item2-label'},
+ ];
+ renderElementIntoDOM(component);
+ await coordinator.done();
+ const onceClicked = new Promise<RecorderComponents.SelectButton.SelectButtonClickEvent>(
+ resolve => {
+ component.addEventListener('selectbuttonclick', resolve, {
+ once: true,
+ });
+ },
+ );
+
+ const button = component.shadowRoot?.querySelector('devtools-button');
+ assert.exists(button);
+ button?.click();
+
+ const event = await onceClicked;
+ assert.strictEqual(event.value, 'item1');
+ });
+
+ it('should emit selectbuttonclick event on item click in select menu', async () => {
+ const component = new RecorderComponents.SelectButton.SelectButton();
+ component.value = 'item1';
+ component.items = [
+ {value: 'item1', label: () => 'item1-label'},
+ {value: 'item2', label: () => 'item2-label'},
+ ];
+ component.connectedCallback();
+ await coordinator.done();
+ const onceClicked = new Promise<RecorderComponents.SelectButton.SelectButtonClickEvent>(
+ resolve => {
+ component.addEventListener('selectbuttonclick', resolve, {
+ once: true,
+ });
+ },
+ );
+
+ const selectMenu = component.shadowRoot?.querySelector(
+ 'devtools-select-menu',
+ );
+ assert.exists(selectMenu);
+ selectMenu?.dispatchEvent(
+ new Menus.SelectMenu.SelectMenuItemSelectedEvent('item1'),
+ );
+
+ const event = await onceClicked;
+ assert.strictEqual(event.value, 'item1');
+ });
+});
diff --git a/front_end/panels/recorder/components/StepEditor.test.ts b/front_end/panels/recorder/components/StepEditor.test.ts
new file mode 100644
index 0000000..2532241
--- /dev/null
+++ b/front_end/panels/recorder/components/StepEditor.test.ts
@@ -0,0 +1,714 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+// eslint-disable-next-line rulesdir/es_modules_import
+import * as EnvironmentHelpers from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import type * as Components from './components.js';
+import * as Models from '../models/models.js';
+// eslint-disable-next-line rulesdir/es_modules_import
+import * as RecorderHelpers from '../../../../test/unittests/front_end/helpers/RecorderHelpers.js';
+import type * as SuggestionInput from '../../../ui/components/suggestion_input/suggestion_input.js';
+
+import {
+ renderElementIntoDOM,
+ getEventPromise,
+ assertElement,
+ dispatchKeyDownEvent,
+} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+
+const {describeWithLocale} = EnvironmentHelpers;
+
+function getStepEditedPromise(editor: Components.StepEditor.StepEditor) {
+ return getEventPromise<Components.StepEditor.StepEditedEvent>(
+ editor,
+ 'stepedited',
+ )
+ .then(({data}) => data);
+}
+
+const triggerMicroTaskQueue = async (n = 1) => {
+ while (n > 0) {
+ --n;
+ await new Promise(resolve => setTimeout(resolve, 0));
+ }
+};
+
+describeWithLocale('StepEditor', () => {
+ async function renderEditor(
+ step: Models.Schema.Step,
+ ): Promise<Components.StepEditor.StepEditor> {
+ const editor = document.createElement('devtools-recorder-step-editor');
+ editor.step = structuredClone(step) as typeof editor.step;
+ renderElementIntoDOM(editor, {});
+ await editor.updateComplete;
+ return editor;
+ }
+
+ function getInputByAttribute(
+ editor: Components.StepEditor.StepEditor,
+ attribute: string,
+ ): SuggestionInput.SuggestionInput.SuggestionInput {
+ const input = editor.renderRoot.querySelector(
+ `.attribute[data-attribute="${attribute}"] devtools-suggestion-input`,
+ );
+ if (!input) {
+ throw new Error(`${attribute} devtools-suggestion-input not found`);
+ }
+ return input as SuggestionInput.SuggestionInput.SuggestionInput;
+ }
+
+ function getAllInputValues(
+ editor: Components.StepEditor.StepEditor,
+ ): string[] {
+ const result = [];
+ const inputs = editor.renderRoot.querySelectorAll(
+ 'devtools-suggestion-input',
+ );
+ for (const input of inputs) {
+ result.push(input.value);
+ }
+ return result;
+ }
+
+ async function addOptionalField(
+ editor: Components.StepEditor.StepEditor,
+ attribute: string,
+ ): Promise<void> {
+ const button = editor.renderRoot.querySelector(
+ `devtools-button.add-row[data-attribute="${attribute}"]`,
+ );
+ assertElement(button, HTMLElement);
+ button.click();
+ await triggerMicroTaskQueue();
+ await editor.updateComplete;
+ }
+
+ async function deleteOptionalField(
+ editor: Components.StepEditor.StepEditor,
+ attribute: string,
+ ): Promise<void> {
+ const button = editor.renderRoot.querySelector(
+ `devtools-button.delete-row[data-attribute="${attribute}"]`,
+ );
+ assertElement(button, HTMLElement);
+ button.click();
+ await triggerMicroTaskQueue();
+ await editor.updateComplete;
+ }
+
+ async function clickFrameLevelButton(
+ editor: Components.StepEditor.StepEditor,
+ className: string,
+ ): Promise<void> {
+ const button = editor.renderRoot.querySelector(
+ `.attribute[data-attribute="frame"] devtools-button${className}`,
+ );
+ assertElement(button, HTMLElement);
+ button.click();
+ await editor.updateComplete;
+ }
+
+ async function clickSelectorLevelButton(
+ editor: Components.StepEditor.StepEditor,
+ path: number[],
+ className: string,
+ ): Promise<void> {
+ const button = editor.renderRoot.querySelector(
+ `[data-selector-path="${path.join('.')}"] devtools-button${className}`,
+ );
+ assertElement(button, HTMLElement);
+ button.click();
+ await editor.updateComplete;
+ }
+
+ /**
+ * Extra button to be able to focus on it in tests to see how
+ * the step editor reacts when the focus moves outside of it.
+ */
+ function createFocusOutsideButton() {
+ const button = document.createElement('button');
+ button.innerText = 'click';
+ renderElementIntoDOM(button, {allowMultipleChildren: true});
+
+ return {
+ focus() {
+ button.focus();
+ },
+ };
+ }
+
+ beforeEach(() => {
+ RecorderHelpers.installMocksForRecordingPlayer();
+ });
+
+ it('should edit step type', async () => {
+ const editor = await renderEditor({
+ type: Models.Schema.StepType.Click,
+ selectors: [['.cls']],
+ offsetX: 1,
+ offsetY: 1,
+ });
+ const step = getStepEditedPromise(editor);
+
+ const input = getInputByAttribute(editor, 'type');
+ input.focus();
+ input.value = 'change';
+ await input.updateComplete;
+
+ input.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: 'Enter',
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ await editor.updateComplete;
+
+ assert.deepStrictEqual(await step, {
+ type: Models.Schema.StepType.Change,
+ selectors: ['.cls'],
+ value: 'Value',
+ });
+ assert.deepStrictEqual(getAllInputValues(editor), [
+ 'change',
+ '.cls',
+ 'Value',
+ ]);
+ });
+
+ it('should edit step type via dropdown', async () => {
+ const editor = await renderEditor({type: Models.Schema.StepType.Scroll});
+ const step = getStepEditedPromise(editor);
+
+ const input = getInputByAttribute(editor, 'type');
+ input.focus();
+ input.value = '';
+ await input.updateComplete;
+
+ // Use the drop down.
+ input.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: 'ArrowDown',
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ input.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: 'Enter',
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ await editor.updateComplete;
+
+ assert.deepStrictEqual(await step, {
+ type: Models.Schema.StepType.Click,
+ selectors: ['.cls'],
+ offsetX: 1,
+ offsetY: 1,
+ });
+ assert.deepStrictEqual(getAllInputValues(editor), [
+ 'click',
+ '.cls',
+ '1',
+ '1',
+ ]);
+ });
+
+ it('should edit other attributes', async () => {
+ const editor = await renderEditor({
+ type: Models.Schema.StepType.CustomStep,
+ name: 'test',
+ parameters: {},
+ });
+ const step = getStepEditedPromise(editor);
+
+ const input = getInputByAttribute(editor, 'parameters');
+ input.focus();
+ input.value = '{"custom":"test"}';
+ await input.updateComplete;
+
+ input.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: 'Enter',
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ await editor.updateComplete;
+
+ assert.deepStrictEqual(await step, {
+ type: Models.Schema.StepType.CustomStep,
+ name: 'test',
+ parameters: {custom: 'test'},
+ });
+ assert.deepStrictEqual(getAllInputValues(editor), [
+ 'customStep',
+ 'test',
+ '{"custom":"test"}',
+ ]);
+ });
+
+ it('should close dropdown on Enter', async () => {
+ const editor = await renderEditor({type: Models.Schema.StepType.Scroll});
+
+ const input = getInputByAttribute(editor, 'type');
+ input.focus();
+ input.value = '';
+ await input.updateComplete;
+
+ const suggestions = input.renderRoot.querySelector(
+ 'devtools-suggestion-box',
+ );
+ if (!suggestions) {
+ throw new Error('Failed to find element');
+ }
+ assert.strictEqual(
+ window.getComputedStyle(suggestions).display,
+ 'block',
+ );
+
+ input.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: 'Enter',
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ assert.strictEqual(
+ window.getComputedStyle(suggestions).display,
+ 'none',
+ );
+ });
+
+ it('should close dropdown on focus elsewhere', async () => {
+ const editor = await renderEditor({type: Models.Schema.StepType.Scroll});
+ const button = createFocusOutsideButton();
+
+ const input = getInputByAttribute(editor, 'type');
+ input.focus();
+ input.value = '';
+ await input.updateComplete;
+
+ const suggestions = input.renderRoot.querySelector(
+ 'devtools-suggestion-box',
+ );
+ if (!suggestions) {
+ throw new Error('Failed to find element');
+ }
+ assert.strictEqual(
+ window.getComputedStyle(suggestions).display,
+ 'block',
+ );
+
+ button.focus();
+ assert.strictEqual(
+ window.getComputedStyle(suggestions).display,
+ 'none',
+ );
+ });
+
+ it('should add optional fields', async () => {
+ const editor = await renderEditor({type: Models.Schema.StepType.Scroll});
+ const step = getStepEditedPromise(editor);
+
+ await addOptionalField(editor, 'x');
+
+ assert.deepStrictEqual(await step, {
+ type: Models.Schema.StepType.Scroll,
+ x: 0,
+ });
+ assert.deepStrictEqual(getAllInputValues(editor), ['scroll', '0']);
+ });
+
+ it('should add the duration field', async () => {
+ const editor = await renderEditor({
+ type: Models.Schema.StepType.Click,
+ offsetX: 1,
+ offsetY: 1,
+ selectors: ['.cls'],
+ });
+ const step = getStepEditedPromise(editor);
+
+ await addOptionalField(editor, 'duration');
+
+ assert.deepStrictEqual(await step, {
+ type: Models.Schema.StepType.Click,
+ offsetX: 1,
+ offsetY: 1,
+ selectors: ['.cls'],
+ duration: 50,
+ });
+ assert.deepStrictEqual(getAllInputValues(editor), [
+ 'click',
+ '.cls',
+ '1',
+ '1',
+ '50',
+ ]);
+ });
+
+ it('should add the parameters field', async () => {
+ const editor = await renderEditor({
+ type: Models.Schema.StepType.WaitForElement,
+ selectors: ['.cls'],
+ });
+ const step = getStepEditedPromise(editor);
+
+ await addOptionalField(editor, 'properties');
+
+ assert.deepStrictEqual(await step, {
+ type: Models.Schema.StepType.WaitForElement,
+ selectors: ['.cls'],
+ properties: {},
+ });
+ assert.deepStrictEqual(getAllInputValues(editor), [
+ 'waitForElement',
+ '.cls',
+ '{}',
+ ]);
+ });
+
+ it('should edit timeout fields', async () => {
+ const editor = await renderEditor({
+ type: Models.Schema.StepType.Navigate,
+ url: 'https://example.com',
+ });
+ const step = getStepEditedPromise(editor);
+
+ await addOptionalField(editor, 'timeout');
+
+ assert.deepStrictEqual(await step, {
+ type: Models.Schema.StepType.Navigate,
+ url: 'https://example.com',
+ timeout: 5000,
+ });
+ assert.deepStrictEqual(getAllInputValues(editor), [
+ 'navigate',
+ 'https://example.com',
+ '5000',
+ ]);
+ });
+
+ it('should delete optional fields', async () => {
+ const editor = await renderEditor({
+ type: Models.Schema.StepType.Scroll,
+ x: 1,
+ });
+ const step = getStepEditedPromise(editor);
+
+ await deleteOptionalField(editor, 'x');
+
+ assert.deepStrictEqual(await step, {type: Models.Schema.StepType.Scroll});
+ assert.deepStrictEqual(getAllInputValues(editor), ['scroll']);
+ });
+
+ it('should add/remove frames', async () => {
+ const editor = await renderEditor({
+ type: Models.Schema.StepType.Scroll,
+ frame: [0],
+ });
+ {
+ const step = getStepEditedPromise(editor);
+
+ await clickFrameLevelButton(editor, '.add-frame');
+
+ assert.deepStrictEqual(await step, {
+ type: Models.Schema.StepType.Scroll,
+ frame: [0, 0],
+ });
+ assert.deepStrictEqual(getAllInputValues(editor), ['scroll', '0', '0']);
+
+ assert.isTrue(
+ editor.shadowRoot?.activeElement?.matches(
+ 'devtools-suggestion-input[data-path="frame.1"]',
+ ),
+ );
+ }
+ {
+ const step = getStepEditedPromise(editor);
+
+ await clickFrameLevelButton(editor, '.remove-frame');
+
+ assert.deepStrictEqual(await step, {
+ type: Models.Schema.StepType.Scroll,
+ frame: [0],
+ });
+ assert.deepStrictEqual(getAllInputValues(editor), ['scroll', '0']);
+
+ assert.isTrue(
+ editor.shadowRoot?.activeElement?.matches(
+ 'devtools-suggestion-input[data-path="frame.0"]',
+ ),
+ );
+ }
+ });
+
+ it('should add/remove selector parts', async () => {
+ const editor = await renderEditor({
+ type: Models.Schema.StepType.Scroll,
+ selectors: [['.part1']],
+ });
+
+ {
+ const step = getStepEditedPromise(editor);
+
+ await clickSelectorLevelButton(editor, [0, 0], '.add-selector-part');
+
+ assert.deepStrictEqual(await step, {
+ type: Models.Schema.StepType.Scroll,
+ selectors: [['.part1', '.cls']],
+ });
+ assert.deepStrictEqual(getAllInputValues(editor), [
+ 'scroll',
+ '.part1',
+ '.cls',
+ ]);
+
+ assert.isTrue(
+ editor.shadowRoot?.activeElement?.matches(
+ 'devtools-suggestion-input[data-path="selectors.0.1"]',
+ ),
+ );
+ }
+
+ {
+ const step = getStepEditedPromise(editor);
+
+ await clickSelectorLevelButton(editor, [0, 0], '.remove-selector-part');
+
+ assert.deepStrictEqual(await step, {
+ type: Models.Schema.StepType.Scroll,
+ selectors: ['.cls'],
+ });
+ assert.deepStrictEqual(getAllInputValues(editor), ['scroll', '.cls']);
+
+ assert.isTrue(
+ editor.shadowRoot?.activeElement?.matches(
+ 'devtools-suggestion-input[data-path="selectors.0.0"]',
+ ),
+ );
+ }
+ });
+
+ it('should add/remove selectors', async () => {
+ const editor = await renderEditor({
+ type: Models.Schema.StepType.Scroll,
+ selectors: [['.part1']],
+ });
+ {
+ const step = getStepEditedPromise(editor);
+
+ await clickSelectorLevelButton(editor, [0], '.add-selector');
+
+ assert.deepStrictEqual(await step, {
+ type: Models.Schema.StepType.Scroll,
+ selectors: ['.part1', '.cls'],
+ });
+ assert.deepStrictEqual(getAllInputValues(editor), [
+ 'scroll',
+ '.part1',
+ '.cls',
+ ]);
+ assert.isTrue(
+ editor.shadowRoot?.activeElement?.matches(
+ 'devtools-suggestion-input[data-path="selectors.1.0"]',
+ ),
+ );
+ }
+ {
+ const step = getStepEditedPromise(editor);
+
+ await clickSelectorLevelButton(editor, [1], '.remove-selector');
+
+ assert.deepStrictEqual(await step, {
+ type: Models.Schema.StepType.Scroll,
+ selectors: ['.part1'],
+ });
+ assert.deepStrictEqual(getAllInputValues(editor), ['scroll', '.part1']);
+ assert.isTrue(
+ editor.shadowRoot?.activeElement?.matches(
+ 'devtools-suggestion-input[data-path="selectors.0.0"]',
+ ),
+ );
+ }
+ });
+
+ it('should become readonly if disabled', async () => {
+ const editor = await renderEditor({
+ type: Models.Schema.StepType.Scroll,
+ selectors: [['.part1']],
+ });
+ editor.disabled = true;
+ await editor.updateComplete;
+
+ for (const input of editor.renderRoot.querySelectorAll(
+ 'devtools-suggestion-input',
+ )) {
+ assert.isTrue(input.disabled);
+ }
+ });
+
+ it('clears text selection when navigating away from devtools-suggestion-input', async () => {
+ const editor = await renderEditor({type: Models.Schema.StepType.Scroll});
+
+ // Clicking on the type devtools-suggestion-input should select the entire text in the field.
+ const input = getInputByAttribute(editor, 'type');
+ input.focus();
+ input.click();
+ assert.strictEqual(window.getSelection()?.toString(), 'scroll');
+
+ // Navigating away should remove the selection.
+ dispatchKeyDownEvent(input, {
+ key: 'Enter',
+ bubbles: true,
+ composed: true,
+ });
+ assert.strictEqual(window.getSelection()?.toString(), '');
+ });
+
+ it('should add an attribute after another\'s deletion', async () => {
+ const editor = await renderEditor({
+ type: Models.Schema.StepType.WaitForElement,
+ selectors: [['.cls']],
+ });
+
+ await addOptionalField(editor, 'operator');
+ await deleteOptionalField(editor, 'operator');
+ const step = getStepEditedPromise(editor);
+ await addOptionalField(editor, 'count');
+
+ assert.deepStrictEqual(await step, {
+ type: Models.Schema.StepType.WaitForElement,
+ selectors: ['.cls'],
+ count: 1,
+ });
+ assert.deepStrictEqual(getAllInputValues(editor), [
+ 'waitForElement',
+ '.cls',
+ '1',
+ ]);
+ });
+
+ it('should edit asserted events', async () => {
+ const editor = await renderEditor({
+ type: Models.Schema.StepType.Navigate,
+ url: 'www.example.com',
+ assertedEvents: [{
+ type: 'navigation' as Models.Schema.AssertedEventType,
+ title: 'Test',
+ url: 'www.example.com',
+ }],
+ });
+
+ const step = getStepEditedPromise(editor);
+
+ const input = getInputByAttribute(editor, 'assertedEvents');
+ input.focus();
+ input.value = 'None';
+ await input.updateComplete;
+
+ input.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: 'Enter',
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ await editor.updateComplete;
+
+ assert.deepStrictEqual(await step, {
+ type: Models.Schema.StepType.Navigate,
+ url: 'www.example.com',
+ assertedEvents: [{
+ type: 'navigation' as Models.Schema.AssertedEventType,
+ title: 'None',
+ url: 'www.example.com',
+ }],
+ });
+ });
+
+ it('should add/remove attribute assertion', async () => {
+ const editor = await renderEditor({
+ type: Models.Schema.StepType.WaitForElement,
+ selectors: ['.part1'],
+ attributes: {
+ a: 'b',
+ },
+ });
+ {
+ const step = getStepEditedPromise(editor);
+
+ editor.renderRoot.querySelectorAll<HTMLElement>('.add-attribute-assertion')[0]?.click();
+
+ assert.deepStrictEqual(await step, {
+ type: Models.Schema.StepType.WaitForElement,
+ selectors: ['.part1'],
+ attributes: {a: 'b', attribute: 'value'},
+ });
+ assert.deepStrictEqual(getAllInputValues(editor), [
+ 'waitForElement',
+ '.part1',
+ 'a',
+ 'b',
+ 'attribute',
+ 'value',
+ ]);
+ }
+ {
+ const step = getStepEditedPromise(editor);
+
+ editor.renderRoot.querySelectorAll<HTMLElement>('.remove-attribute-assertion')[1]?.click();
+
+ assert.deepStrictEqual(await step, {
+ type: Models.Schema.StepType.WaitForElement,
+ selectors: ['.part1'],
+ attributes: {a: 'b'},
+ });
+ assert.deepStrictEqual(getAllInputValues(editor), [
+ 'waitForElement',
+ '.part1',
+ 'a',
+ 'b',
+ ]);
+ }
+ });
+
+ it('should edit attribute assertion', async () => {
+ const editor = await renderEditor({
+ type: Models.Schema.StepType.WaitForElement,
+ selectors: ['.part1'],
+ attributes: {
+ a: 'b',
+ },
+ });
+
+ const step = getStepEditedPromise(editor);
+
+ const input = getInputByAttribute(editor, 'attributes');
+ input.focus();
+ input.value = 'innerText';
+ await input.updateComplete;
+
+ input.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: 'Enter',
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ await editor.updateComplete;
+
+ assert.deepStrictEqual(await step, {
+ type: Models.Schema.StepType.WaitForElement,
+ selectors: ['.part1'],
+ attributes: {
+ innerText: 'b',
+ },
+ });
+ });
+});
diff --git a/front_end/panels/recorder/components/StepView.test.ts b/front_end/panels/recorder/components/StepView.test.ts
new file mode 100644
index 0000000..748c07e
--- /dev/null
+++ b/front_end/panels/recorder/components/StepView.test.ts
@@ -0,0 +1,265 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from '../models/models.js';
+import * as Converters from '../converters/converters.js';
+import * as Components from './components.js';
+import * as Menus from '../../../ui/components/menus/menus.js';
+import type * as Button from '../../../ui/components/buttons/buttons.js';
+import * as Coordinator from '../../../ui/components/render_coordinator/render_coordinator.js';
+
+import {
+ dispatchClickEvent,
+ getEventPromise,
+ renderElementIntoDOM,
+} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {
+ describeWithEnvironment,
+} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+describeWithEnvironment('StepView', () => {
+ const step = {type: Models.Schema.StepType.Scroll as const};
+ const section = {title: 'test', steps: [step], url: 'https://example.com'};
+
+ async function createStepView(
+ opts: Partial<Components.StepView.StepViewData> = {},
+ ): Promise<Components.StepView.StepView> {
+ const view = new Components.StepView.StepView();
+ view.data = {
+ step: opts.step !== undefined ? step : undefined,
+ section: opts.section !== undefined ? section : undefined,
+ state: Components.StepView.State.Default,
+ isEndOfGroup: opts.isEndOfGroup ?? false,
+ isStartOfGroup: opts.isStartOfGroup ?? false,
+ isFirstSection: opts.isFirstSection ?? false,
+ isLastSection: opts.isLastSection ?? false,
+ stepIndex: opts.stepIndex ?? 0,
+ sectionIndex: opts.sectionIndex ?? 0,
+ isRecording: opts.isRecording ?? false,
+ isPlaying: opts.isPlaying ?? false,
+ hasBreakpoint: opts.hasBreakpoint ?? false,
+ removable: opts.removable ?? false,
+ builtInConverters: opts.builtInConverters ||
+ [
+ new Converters.JSONConverter.JSONConverter(' '),
+ ],
+ extensionConverters: opts.extensionConverters || [],
+ isSelected: opts.isSelected ?? false,
+ recorderSettings: new Models.RecorderSettings.RecorderSettings(),
+ };
+ renderElementIntoDOM(view);
+ await coordinator.done();
+ return view;
+ }
+
+ describe('Step and section actions menu', () => {
+ it('should open actions menu', async () => {
+ const view = await createStepView({step});
+ assert.notOk(
+ view.shadowRoot?.querySelector('devtools-menu[has-open-dialog]'),
+ );
+
+ const button = view.shadowRoot?.querySelector(
+ '.step-actions',
+ ) as Button.Button.Button;
+ assert.ok(button);
+
+ dispatchClickEvent(button);
+ await coordinator.done();
+
+ assert.ok(
+ view.shadowRoot?.querySelector('devtools-menu[has-open-dialog]'),
+ );
+ });
+
+ it('should dispatch "AddStep before" events on steps', async () => {
+ const view = await createStepView({step});
+
+ const menu = view.shadowRoot?.querySelector(
+ '.step-actions + devtools-menu',
+ ) as Menus.Menu.Menu;
+ assert.ok(menu);
+ const eventPromise = getEventPromise<Components.StepView.AddStep>(
+ view,
+ 'addstep',
+ );
+ menu.dispatchEvent(
+ new Menus.Menu.MenuItemSelectedEvent('add-step-before'),
+ );
+ const event = await eventPromise;
+
+ assert.strictEqual(event.position, 'before');
+ assert.deepStrictEqual(event.stepOrSection, step);
+ });
+
+ it('should dispatch "AddStep before" events on steps', async () => {
+ const view = await createStepView({section});
+
+ const menu = view.shadowRoot?.querySelector(
+ '.step-actions + devtools-menu',
+ ) as Menus.Menu.Menu;
+ assert.ok(menu);
+ const eventPromise = getEventPromise<Components.StepView.AddStep>(
+ view,
+ 'addstep',
+ );
+ menu.dispatchEvent(
+ new Menus.Menu.MenuItemSelectedEvent('add-step-before'),
+ );
+ const event = await eventPromise;
+
+ assert.strictEqual(event.position, 'before');
+ assert.deepStrictEqual(event.stepOrSection, section);
+ });
+
+ it('should dispatch "AddStep after" events on steps', async () => {
+ const view = await createStepView({step});
+
+ const menu = view.shadowRoot?.querySelector(
+ '.step-actions + devtools-menu',
+ ) as Menus.Menu.Menu;
+ assert.ok(menu);
+ const eventPromise = getEventPromise<Components.StepView.AddStep>(
+ view,
+ 'addstep',
+ );
+ menu.dispatchEvent(
+ new Menus.Menu.MenuItemSelectedEvent('add-step-after'),
+ );
+ const event = await eventPromise;
+
+ assert.strictEqual(event.position, 'after');
+ assert.deepStrictEqual(event.stepOrSection, step);
+ });
+
+ it('should dispatch "Remove steps" events on steps', async () => {
+ const view = await createStepView({step});
+
+ const menu = view.shadowRoot?.querySelector(
+ '.step-actions + devtools-menu',
+ ) as Menus.Menu.Menu;
+ assert.ok(menu);
+ const eventPromise = getEventPromise<Components.StepView.RemoveStep>(
+ view,
+ 'removestep',
+ );
+ menu.dispatchEvent(
+ new Menus.Menu.MenuItemSelectedEvent('remove-step'),
+ );
+ const event = await eventPromise;
+
+ assert.deepStrictEqual(event.step, step);
+ });
+
+ it('should dispatch "Add breakpoint" event on steps', async () => {
+ const view = await createStepView({step});
+
+ const menu = view.shadowRoot?.querySelector(
+ '.step-actions + devtools-menu',
+ ) as Menus.Menu.Menu;
+ assert.ok(menu);
+ const eventPromise = getEventPromise<Components.StepView.AddBreakpointEvent>(
+ view,
+ 'addbreakpoint',
+ );
+ menu.dispatchEvent(
+ new Menus.Menu.MenuItemSelectedEvent('add-breakpoint'),
+ );
+ const event = await eventPromise;
+
+ assert.deepStrictEqual(event.index, 0);
+ });
+
+ it('should dispatch "Remove breakpoint" event on steps', async () => {
+ const view = await createStepView({step});
+
+ const menu = view.shadowRoot?.querySelector(
+ '.step-actions + devtools-menu',
+ ) as Menus.Menu.Menu;
+ assert.ok(menu);
+ const eventPromise = getEventPromise<Components.StepView.AddBreakpointEvent>(
+ view,
+ 'removebreakpoint',
+ );
+ menu.dispatchEvent(
+ new Menus.Menu.MenuItemSelectedEvent('remove-breakpoint'),
+ );
+ const event = await eventPromise;
+
+ assert.deepStrictEqual(event.index, 0);
+ });
+ });
+
+ describe('Breakpoint events', () => {
+ it('should dispatch "Add breakpoint" event on breakpoint icon click if there is not a breakpoint on the step',
+ async () => {
+ const view = await createStepView({step});
+ const breakpointIcon = view.shadowRoot?.querySelector('.breakpoint-icon');
+ const eventPromise = getEventPromise<Components.StepView.AddBreakpointEvent>(
+ view,
+ 'addbreakpoint',
+ );
+ assert.isOk(breakpointIcon);
+
+ breakpointIcon?.dispatchEvent(new Event('click'));
+ const event = await eventPromise;
+
+ assert.deepStrictEqual(event.index, 0);
+ });
+
+ it('should dispatch "Remove breakpoint" event on breakpoint icon click if there already is a breakpoint on the step',
+ async () => {
+ const view = await createStepView({hasBreakpoint: true, step});
+ const breakpointIcon = view.shadowRoot?.querySelector('.breakpoint-icon');
+ const eventPromise = getEventPromise<Components.StepView.RemoveBreakpointEvent>(
+ view,
+ 'removebreakpoint',
+ );
+
+ breakpointIcon?.dispatchEvent(new Event('click'));
+ const event = await eventPromise;
+
+ assert.deepStrictEqual(event.index, 0);
+ });
+ });
+
+ describe('Copy steps', () => {
+ it('should copy a step to clipboard', async () => {
+ const view = await createStepView({step});
+ const menu = view.shadowRoot?.querySelector(
+ '.step-actions + devtools-menu',
+ ) as Menus.Menu.Menu;
+ assert.ok(menu);
+
+ const isCalled = sinon.promise();
+ view.addEventListener(Components.StepView.CopyStepEvent.eventName, () => {
+ void isCalled.resolve(true);
+ });
+ menu.dispatchEvent(
+ new Menus.Menu.MenuItemSelectedEvent('copy-step-as-json'),
+ );
+
+ const called = await isCalled;
+
+ assert.isTrue(called);
+ });
+ });
+
+ describe('Selection', () => {
+ it('should render timeline as selected if isSelected = true', async () => {
+ const view = await createStepView({step, isSelected: true});
+ assert.ok(view);
+ const section = view.shadowRoot?.querySelector(
+ 'devtools-timeline-section',
+ );
+ assert.ok(section);
+ const div = section?.shadowRoot?.querySelector('div');
+ assert.isTrue(div?.classList.contains('is-selected'));
+ });
+ });
+});
diff --git a/front_end/panels/recorder/converters/BUILD.gn b/front_end/panels/recorder/converters/BUILD.gn
index 5147795..8b1f4d1 100644
--- a/front_end/panels/recorder/converters/BUILD.gn
+++ b/front_end/panels/recorder/converters/BUILD.gn
@@ -2,9 +2,9 @@
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
-import(
- "../../../../scripts/build/ninja/devtools_entrypoint.gni")
+import("../../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../../scripts/build/ninja/devtools_module.gni")
+import("../../../../third_party/typescript/typescript.gni")
devtools_module("converters") {
sources = [
@@ -34,8 +34,23 @@
visibility = [
":*",
"../:*",
- "../../../../test/unittests/front_end/panels/recorder/*",
"../../../ui/components/docs/*",
"../components:*",
]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [
+ "LighthouseConverter.test.ts",
+ "PuppeteerConverter.test.ts",
+ "PuppeteerReplayConverter.test.ts",
+ ]
+
+ deps = [
+ ":bundle",
+ "../../../../test/unittests/front_end/helpers",
+ "../models:bundle",
+ ]
+}
diff --git a/front_end/panels/recorder/converters/LighthouseConverter.test.ts b/front_end/panels/recorder/converters/LighthouseConverter.test.ts
new file mode 100644
index 0000000..d90d2b5
--- /dev/null
+++ b/front_end/panels/recorder/converters/LighthouseConverter.test.ts
@@ -0,0 +1,80 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from '../models/models.js';
+import * as Converters from './converters.js';
+
+describe('LighthouseConverter', () => {
+ it('should stringify a flow', async () => {
+ const converter = new Converters.LighthouseConverter.LighthouseConverter(
+ ' ',
+ );
+ const [result, sourceMap] = await converter.stringify({
+ title: 'test',
+ steps: [
+ {type: Models.Schema.StepType.Navigate, url: 'https://example.com'},
+ {type: Models.Schema.StepType.Scroll, selectors: [['.cls']]},
+ ],
+ });
+ const expected = `const fs = require('fs');
+const puppeteer = require('puppeteer'); // v22.0.0 or later
+
+(async () => {
+ const browser = await puppeteer.launch();
+ const page = await browser.newPage();
+ const timeout = 5000;
+ page.setDefaultTimeout(timeout);
+
+ const lhApi = await import('lighthouse'); // v10.0.0 or later
+ const flags = {
+ screenEmulation: {
+ disabled: true
+ }
+ }
+ const config = lhApi.desktopConfig;
+ const lhFlow = await lhApi.startFlow(page, {name: 'test', config, flags});
+ await lhFlow.startNavigation();
+ {
+ const targetPage = page;
+ await targetPage.goto('https://example.com');
+ }
+ await lhFlow.endNavigation();
+ await lhFlow.startTimespan();
+ {
+ const targetPage = page;
+ await puppeteer.Locator.race([
+ targetPage.locator('.cls')
+ ])
+ .setTimeout(timeout)
+ .scroll({ scrollTop: undefined, scrollLeft: undefined});
+ }
+ await lhFlow.endTimespan();
+ const lhFlowReport = await lhFlow.generateReport();
+ fs.writeFileSync(__dirname + '/flow.report.html', lhFlowReport)
+
+ await browser.close();`;
+ const actual = result.substring(0, expected.length);
+ assert.strictEqual(actual, expected, `Unexpected start of generated result:\n${actual}`);
+ assert.deepStrictEqual(sourceMap, [1, 17, 6, 23, 9]);
+ });
+
+ it('should stringify a step', async () => {
+ const converter = new Converters.LighthouseConverter.LighthouseConverter(
+ ' ',
+ );
+ const result = await converter.stringifyStep({
+ type: Models.Schema.StepType.Scroll,
+ });
+ assert.strictEqual(
+ result,
+ `{
+ const targetPage = page;
+ await targetPage.evaluate((x, y) => { window.scroll(x, y); }, undefined, undefined)
+}
+`,
+ );
+ });
+});
diff --git a/front_end/panels/recorder/converters/PuppeteerConverter.test.ts b/front_end/panels/recorder/converters/PuppeteerConverter.test.ts
new file mode 100644
index 0000000..cea11a5
--- /dev/null
+++ b/front_end/panels/recorder/converters/PuppeteerConverter.test.ts
@@ -0,0 +1,58 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from '../models/models.js';
+import * as Converters from './converters.js';
+
+describe('PuppeteerConverter', () => {
+ it('should stringify a flow', async () => {
+ const converter = new Converters.PuppeteerConverter.PuppeteerConverter(
+ ' ',
+ );
+ const [result, sourceMap] = await converter.stringify({
+ title: 'test',
+ steps: [{type: Models.Schema.StepType.Scroll, selectors: [['.cls']]}],
+ });
+ const expected = `const puppeteer = require('puppeteer'); // v22.0.0 or later
+
+(async () => {
+ const browser = await puppeteer.launch();
+ const page = await browser.newPage();
+ const timeout = 5000;
+ page.setDefaultTimeout(timeout);
+
+ {
+ const targetPage = page;
+ await puppeteer.Locator.race([
+ targetPage.locator('.cls')
+ ])
+ .setTimeout(timeout)
+ .scroll({ scrollTop: undefined, scrollLeft: undefined});
+ }
+
+ await browser.close();`;
+ const actual = result.substring(0, expected.length);
+ assert.strictEqual(actual, expected, `Unexpected start of generated result:\n${actual}`);
+ assert.deepStrictEqual(sourceMap, [1, 8, 8]);
+ });
+
+ it('should stringify a step', async () => {
+ const converter = new Converters.PuppeteerConverter.PuppeteerConverter(
+ ' ',
+ );
+ const result = await converter.stringifyStep({
+ type: Models.Schema.StepType.Scroll,
+ });
+ assert.strictEqual(
+ result,
+ `{
+ const targetPage = page;
+ await targetPage.evaluate((x, y) => { window.scroll(x, y); }, undefined, undefined)
+}
+`,
+ );
+ });
+});
diff --git a/front_end/panels/recorder/converters/PuppeteerReplayConverter.test.ts b/front_end/panels/recorder/converters/PuppeteerReplayConverter.test.ts
new file mode 100644
index 0000000..1598976
--- /dev/null
+++ b/front_end/panels/recorder/converters/PuppeteerReplayConverter.test.ts
@@ -0,0 +1,60 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from '../models/models.js';
+import * as Converters from './converters.js';
+
+describe('PuppeteerReplayConverter', () => {
+ it('should stringify a flow', async () => {
+ const converter = new Converters.PuppeteerReplayConverter.PuppeteerReplayConverter(' ');
+ const [result, sourceMap] = await converter.stringify({
+ title: 'test',
+ steps: [{type: Models.Schema.StepType.Scroll, selectors: [['.cls']]}],
+ });
+ assert.strictEqual(
+ result,
+ `import url from 'url';
+import { createRunner } from '@puppeteer/replay';
+
+export async function run(extension) {
+ const runner = await createRunner(extension);
+
+ await runner.runBeforeAllSteps();
+
+ await runner.runStep({
+ type: 'scroll',
+ selectors: [
+ [
+ '.cls'
+ ]
+ ]
+ });
+
+ await runner.runAfterAllSteps();
+}
+
+if (process && import.meta.url === url.pathToFileURL(process.argv[1]).href) {
+ run()
+}
+`,
+ );
+ assert.deepStrictEqual(sourceMap, [1, 8, 8]);
+ });
+
+ it('should stringify a step', async () => {
+ const converter = new Converters.PuppeteerReplayConverter.PuppeteerReplayConverter(' ');
+ const result = await converter.stringifyStep({
+ type: Models.Schema.StepType.Scroll,
+ });
+ assert.strictEqual(
+ result,
+ `await runner.runStep({
+ type: 'scroll'
+});
+`,
+ );
+ });
+});
diff --git a/front_end/panels/recorder/injected/BUILD.gn b/front_end/panels/recorder/injected/BUILD.gn
index e0ab1d4..660ef70 100644
--- a/front_end/panels/recorder/injected/BUILD.gn
+++ b/front_end/panels/recorder/injected/BUILD.gn
@@ -3,8 +3,7 @@
# found in the LICENSE file.
import("../../../../scripts/build/ninja/copy.gni")
-import(
- "../../../../scripts/build/ninja/devtools_entrypoint.gni")
+import("../../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../../scripts/build/ninja/devtools_module.gni")
import("../../../../scripts/build/ninja/node.gni")
diff --git a/front_end/panels/recorder/injected/selectors/BUILD.gn b/front_end/panels/recorder/injected/selectors/BUILD.gn
new file mode 100644
index 0000000..3fe628b
--- /dev/null
+++ b/front_end/panels/recorder/injected/selectors/BUILD.gn
@@ -0,0 +1,13 @@
+# Copyright 2024 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("../../../../../third_party/typescript/typescript.gni")
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [ "CSSSelector.test.ts" ]
+
+ deps = [ ".." ]
+}
diff --git a/front_end/panels/recorder/injected/selectors/CSSSelector.test.ts b/front_end/panels/recorder/injected/selectors/CSSSelector.test.ts
new file mode 100644
index 0000000..964e322
--- /dev/null
+++ b/front_end/panels/recorder/injected/selectors/CSSSelector.test.ts
@@ -0,0 +1,41 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/* eslint-disable rulesdir/es_modules_import */
+
+import {findMinMax} from './CSSSelector.js';
+
+describe('findMinMax', () => {
+ it('should work', () => {
+ const minmax = findMinMax([0, 10], {
+ inc(index: number): number {
+ return index + 1;
+ },
+ valueOf(index: number): number {
+ return index;
+ },
+ gte(value: number, index: number): boolean {
+ return value >= index;
+ },
+ });
+
+ assert.strictEqual(minmax, 9);
+ });
+
+ it('should work, non trivial', () => {
+ const minmax = findMinMax([0, 10], {
+ inc(index: number): number {
+ return index + 1;
+ },
+ valueOf(index: number): number {
+ return index;
+ },
+ gte(value: number, index: number): boolean {
+ return value >= Math.min(index, 5);
+ },
+ });
+
+ assert.strictEqual(minmax, 5);
+ });
+});
diff --git a/front_end/panels/recorder/models/BUILD.gn b/front_end/panels/recorder/models/BUILD.gn
index 795d6bc..27773e0 100644
--- a/front_end/panels/recorder/models/BUILD.gn
+++ b/front_end/panels/recorder/models/BUILD.gn
@@ -2,9 +2,9 @@
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
-import(
- "../../../../scripts/build/ninja/devtools_entrypoint.gni")
+import("../../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../../scripts/build/ninja/devtools_module.gni")
+import("../../../../third_party/typescript/typescript.gni")
devtools_module("recorder") {
sources = [
@@ -32,8 +32,8 @@
"../../../panels/elements:bundle",
"../../../services/puppeteer:bundle",
"../../../third_party/puppeteer:bundle",
- "../../../ui/legacy:bundle",
"../../../third_party/puppeteer-replay:bundle",
+ "../../../ui/legacy:bundle",
"../injected:bundle",
"../util:bundle",
]
@@ -51,8 +51,7 @@
"../*",
"../../../../test/e2e/recorder/*",
"../../../../test/interactions/panels/recorder/*",
- "../../../../test/unittests/front_end/helpers",
- "../../../../test/unittests/front_end/panels/recorder/*",
+ "../../../../test/unittests/front_end/helpers/*",
"../../../ui/components/docs/*",
"../../../ui/components/docs/recorder_injected/*",
"../../entrypoints/main/*",
@@ -64,3 +63,25 @@
"../components/*",
]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [
+ "RecorderSettings.test.ts",
+ "RecorderShorcutHelper.test.ts",
+ "RecordingPlayer.test.ts",
+ "SchemaUtils.test.ts",
+ "ScreenshotUtils.test.ts",
+ "Section.test.ts",
+ "recording-storage.test.ts",
+ "screenshot-storage.test.ts",
+ ]
+
+ deps = [
+ ":bundle",
+ "../../../../test/unittests/front_end/helpers",
+ "../../../../test/unittests/front_end/helpers:recorder_helpers",
+ "../../../generated:protocol",
+ ]
+}
diff --git a/front_end/panels/recorder/models/RecorderSettings.test.ts b/front_end/panels/recorder/models/RecorderSettings.test.ts
new file mode 100644
index 0000000..064bade
--- /dev/null
+++ b/front_end/panels/recorder/models/RecorderSettings.test.ts
@@ -0,0 +1,65 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from './models.js';
+import * as Common from '../../../core/common/common.js';
+import {
+ describeWithEnvironment,
+} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+
+describeWithEnvironment('RecorderSettings', () => {
+ let recorderSettings: Models.RecorderSettings.RecorderSettings;
+
+ beforeEach(() => {
+ recorderSettings = new Models.RecorderSettings.RecorderSettings();
+ });
+
+ it('should have correct default values', async () => {
+ assert.isTrue(recorderSettings.selectorAttribute === '');
+ assert.isTrue(
+ recorderSettings.speed === Models.RecordingPlayer.PlayRecordingSpeed.Normal,
+ );
+ Object.values(Models.Schema.SelectorType).forEach(type => {
+ assert.isTrue(recorderSettings.getSelectorByType(type));
+ });
+ });
+
+ it('should get default Title', async () => {
+ const now = new Date('2022-12-01 15:30');
+ const clock = sinon.useFakeTimers(now.getTime());
+
+ assert.strictEqual(
+ recorderSettings.defaultTitle,
+ `Recording ${now.toLocaleDateString()} at ${now.toLocaleTimeString()}`,
+ );
+ clock.restore();
+ });
+
+ it('should save selector attribute change', () => {
+ const value = 'custom-selector';
+ recorderSettings.selectorAttribute = value;
+ assert.strictEqual(
+ Common.Settings.Settings.instance().settingForTest('recorder-selector-attribute').get(),
+ value,
+ );
+ });
+
+ it('should save speed attribute change', () => {
+ recorderSettings.speed = Models.RecordingPlayer.PlayRecordingSpeed.ExtremelySlow;
+ assert.strictEqual(
+ Common.Settings.Settings.instance().settingForTest('recorder-panel-replay-speed').get(),
+ Models.RecordingPlayer.PlayRecordingSpeed.ExtremelySlow,
+ );
+ });
+
+ it('should save selector type change', () => {
+ const selectorType = Models.Schema.SelectorType.CSS;
+ recorderSettings.setSelectorByType(selectorType, false);
+ assert.isFalse(
+ Common.Settings.Settings.instance().settingForTest(`recorder-${selectorType}-selector-enabled`).get(),
+ );
+ });
+});
diff --git a/front_end/panels/recorder/models/RecorderShorcutHelper.test.ts b/front_end/panels/recorder/models/RecorderShorcutHelper.test.ts
new file mode 100644
index 0000000..8388258
--- /dev/null
+++ b/front_end/panels/recorder/models/RecorderShorcutHelper.test.ts
@@ -0,0 +1,60 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Host from '../../../core/host/host.js';
+import * as Models from './models.js';
+
+describe('RecorderShortcutHelper', () => {
+ function waitFor(time: number) {
+ return new Promise(resolve => setTimeout(resolve, time));
+ }
+
+ function dispatchShortcut() {
+ const event = new KeyboardEvent('keyup', {
+ key: 'E',
+ ctrlKey: Host.Platform.isMac() ? false : true,
+ metaKey: Host.Platform.isMac() ? true : false,
+ bubbles: true,
+ composed: true,
+ });
+
+ document.dispatchEvent(event);
+ }
+
+ it('should wait for timeout', async () => {
+ const time = 10;
+ const helper = new Models.RecorderShortcutHelper.RecorderShortcutHelper(
+ time,
+ );
+ const stub = sinon.stub();
+
+ helper.handleShortcut(stub);
+ await waitFor(time + 10);
+
+ assert.strictEqual(stub.callCount, 1);
+
+ dispatchShortcut();
+
+ assert.strictEqual(stub.callCount, 1);
+ });
+
+ it('should stop on click', async () => {
+ const time = 100;
+ const helper = new Models.RecorderShortcutHelper.RecorderShortcutHelper(
+ time,
+ );
+ const stub = sinon.stub();
+
+ helper.handleShortcut(stub);
+ dispatchShortcut();
+
+ await waitFor(time / 2);
+ assert.strictEqual(stub.callCount, 1);
+
+ await waitFor(time);
+ assert.strictEqual(stub.callCount, 1);
+ });
+});
diff --git a/front_end/panels/recorder/models/RecordingPlayer.test.ts b/front_end/panels/recorder/models/RecordingPlayer.test.ts
new file mode 100644
index 0000000..a80b85c
--- /dev/null
+++ b/front_end/panels/recorder/models/RecordingPlayer.test.ts
@@ -0,0 +1,257 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from './models.js';
+
+import {
+ createCustomStep,
+ installMocksForRecordingPlayer,
+ installMocksForTargetManager,
+} from '../../../../test/unittests/front_end/helpers/RecorderHelpers.js';
+
+describe('RecordingPlayer', () => {
+ let recordingPlayer: Models.RecordingPlayer.RecordingPlayer;
+
+ beforeEach(() => {
+ installMocksForTargetManager();
+ installMocksForRecordingPlayer();
+ });
+
+ afterEach(() => {
+ recordingPlayer.disposeForTesting();
+ });
+
+ it('should emit `Step` event before executing in every step', async () => {
+ recordingPlayer = new Models.RecordingPlayer.RecordingPlayer(
+ {
+ title: 'test',
+ steps: [
+ createCustomStep(),
+ createCustomStep(),
+ createCustomStep(),
+ ],
+ },
+ {
+ speed: Models.RecordingPlayer.PlayRecordingSpeed.Normal,
+ breakpointIndexes: new Set(),
+ },
+ );
+ const stepEventHandlerStub = sinon.stub().callsFake(async ({data: {resolve}}) => {
+ resolve();
+ });
+ recordingPlayer.addEventListener(
+ Models.RecordingPlayer.Events.Step,
+ stepEventHandlerStub,
+ );
+
+ await recordingPlayer.play();
+
+ assert.isTrue(stepEventHandlerStub.getCalls().length === 3);
+ });
+
+ describe('Step by step execution', () => {
+ it('should stop execution before executing a step that has a breakpoint', async () => {
+ recordingPlayer = new Models.RecordingPlayer.RecordingPlayer(
+ {
+ title: 'test',
+ steps: [
+ createCustomStep(),
+ createCustomStep(),
+ createCustomStep(),
+ ],
+ },
+ {
+ speed: Models.RecordingPlayer.PlayRecordingSpeed.Normal,
+ breakpointIndexes: new Set([1]),
+ },
+ );
+ const stepEventHandlerStub = sinon.stub().callsFake(async ({data: {resolve}}) => {
+ resolve();
+ });
+ const stopEventPromise = new Promise<void>(resolve => {
+ recordingPlayer.addEventListener(
+ Models.RecordingPlayer.Events.Stop,
+ () => {
+ resolve();
+ },
+ );
+ });
+ recordingPlayer.addEventListener(
+ Models.RecordingPlayer.Events.Step,
+ stepEventHandlerStub,
+ );
+
+ void recordingPlayer.play();
+ await stopEventPromise;
+
+ assert.strictEqual(stepEventHandlerStub.getCalls().length, 2);
+ });
+
+ it('should `stepOver` execute only the next step after breakpoint and stop', async () => {
+ recordingPlayer = new Models.RecordingPlayer.RecordingPlayer(
+ {
+ title: 'test',
+ steps: [
+ createCustomStep(),
+ createCustomStep(),
+ createCustomStep(),
+ createCustomStep(),
+ ],
+ },
+ {
+ speed: Models.RecordingPlayer.PlayRecordingSpeed.Normal,
+ breakpointIndexes: new Set([1]),
+ },
+ );
+ const stepEventHandlerStub = sinon.stub().callsFake(async ({data: {resolve}}) => {
+ resolve();
+ });
+ let stopEventPromise = new Promise<void>(resolve => {
+ recordingPlayer.addEventListener(
+ Models.RecordingPlayer.Events.Stop,
+ () => {
+ resolve();
+ stopEventPromise = new Promise<void>(nextResolve => {
+ recordingPlayer.addEventListener(
+ Models.RecordingPlayer.Events.Stop,
+ () => {
+ nextResolve();
+ },
+ {once: true},
+ );
+ });
+ },
+ {once: true},
+ );
+ });
+ recordingPlayer.addEventListener(
+ Models.RecordingPlayer.Events.Step,
+ stepEventHandlerStub,
+ );
+
+ void recordingPlayer.play();
+ await stopEventPromise;
+ assert.strictEqual(stepEventHandlerStub.getCalls().length, 2);
+ recordingPlayer.stepOver();
+ await stopEventPromise;
+
+ assert.strictEqual(stepEventHandlerStub.getCalls().length, 3);
+ });
+
+ it('should `continue` execute until the next breakpoint', async () => {
+ recordingPlayer = new Models.RecordingPlayer.RecordingPlayer(
+ {
+ title: 'test',
+ steps: [
+ createCustomStep(),
+ createCustomStep(),
+ createCustomStep(),
+ createCustomStep(),
+ createCustomStep(),
+ ],
+ },
+ {
+ speed: Models.RecordingPlayer.PlayRecordingSpeed.Normal,
+ breakpointIndexes: new Set([1, 3]),
+ },
+ );
+ const stepEventHandlerStub = sinon.stub().callsFake(async ({data: {resolve}}) => {
+ resolve();
+ });
+ let stopEventPromise = new Promise<void>(resolve => {
+ recordingPlayer.addEventListener(
+ Models.RecordingPlayer.Events.Stop,
+ () => {
+ resolve();
+ stopEventPromise = new Promise<void>(nextResolve => {
+ recordingPlayer.addEventListener(
+ Models.RecordingPlayer.Events.Stop,
+ () => {
+ nextResolve();
+ },
+ {once: true},
+ );
+ });
+ },
+ {once: true},
+ );
+ });
+ recordingPlayer.addEventListener(
+ Models.RecordingPlayer.Events.Step,
+ stepEventHandlerStub,
+ );
+
+ void recordingPlayer.play();
+ await stopEventPromise;
+ assert.strictEqual(stepEventHandlerStub.getCalls().length, 2);
+ recordingPlayer.continue();
+ await stopEventPromise;
+
+ assert.strictEqual(stepEventHandlerStub.getCalls().length, 4);
+ });
+
+ it('should `continue` execute until the end if there is no later breakpoints', async () => {
+ recordingPlayer = new Models.RecordingPlayer.RecordingPlayer(
+ {
+ title: 'test',
+ steps: [
+ createCustomStep(),
+ createCustomStep(),
+ createCustomStep(),
+ createCustomStep(),
+ createCustomStep(),
+ ],
+ },
+ {
+ speed: Models.RecordingPlayer.PlayRecordingSpeed.Normal,
+ breakpointIndexes: new Set([1]),
+ },
+ );
+ const stepEventHandlerStub = sinon.stub().callsFake(async ({data: {resolve}}) => {
+ resolve();
+ });
+ let stopEventPromise = new Promise<void>(resolve => {
+ recordingPlayer.addEventListener(
+ Models.RecordingPlayer.Events.Stop,
+ () => {
+ resolve();
+ stopEventPromise = new Promise<void>(nextResolve => {
+ recordingPlayer.addEventListener(
+ Models.RecordingPlayer.Events.Stop,
+ () => {
+ nextResolve();
+ },
+ {once: true},
+ );
+ });
+ },
+ {once: true},
+ );
+ });
+ const doneEventPromise = new Promise<void>(resolve => {
+ recordingPlayer.addEventListener(
+ Models.RecordingPlayer.Events.Done,
+ () => {
+ resolve();
+ },
+ {once: true},
+ );
+ });
+ recordingPlayer.addEventListener(
+ Models.RecordingPlayer.Events.Step,
+ stepEventHandlerStub,
+ );
+
+ void recordingPlayer.play();
+ await stopEventPromise;
+ assert.strictEqual(stepEventHandlerStub.getCalls().length, 2);
+ recordingPlayer.continue();
+ await doneEventPromise;
+
+ assert.strictEqual(stepEventHandlerStub.getCalls().length, 5);
+ });
+ });
+});
diff --git a/front_end/panels/recorder/models/SchemaUtils.test.ts b/front_end/panels/recorder/models/SchemaUtils.test.ts
new file mode 100644
index 0000000..4642478
--- /dev/null
+++ b/front_end/panels/recorder/models/SchemaUtils.test.ts
@@ -0,0 +1,37 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from './models.js';
+
+describe('SchemaUtils', () => {
+ it('should compare step selectors', () => {
+ const {areSelectorsEqual} = Models.SchemaUtils;
+ assert.isTrue(
+ areSelectorsEqual(
+ {type: Models.Schema.StepType.Scroll},
+ {type: Models.Schema.StepType.Scroll},
+ ),
+ );
+ assert.isFalse(
+ areSelectorsEqual(
+ {type: Models.Schema.StepType.Scroll, selectors: [['#id']]},
+ {type: Models.Schema.StepType.Scroll},
+ ),
+ );
+ assert.isTrue(
+ areSelectorsEqual(
+ {type: Models.Schema.StepType.Scroll, selectors: [['#id']]},
+ {type: Models.Schema.StepType.Scroll, selectors: [['#id']]},
+ ),
+ );
+ assert.isFalse(
+ areSelectorsEqual(
+ {type: Models.Schema.StepType.Scroll, selectors: [['#id', '#id2']]},
+ {type: Models.Schema.StepType.Scroll, selectors: [['#id']]},
+ ),
+ );
+ });
+});
diff --git a/front_end/panels/recorder/models/ScreenshotUtils.test.ts b/front_end/panels/recorder/models/ScreenshotUtils.test.ts
new file mode 100644
index 0000000..7403f0e
--- /dev/null
+++ b/front_end/panels/recorder/models/ScreenshotUtils.test.ts
@@ -0,0 +1,76 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from './models.js';
+
+describe('ScreenshotUtils', () => {
+ async function generateImage(
+ width: number,
+ height: number,
+ ): Promise<Models.ScreenshotStorage.Screenshot> {
+ const img = new Image(width, height);
+ const promise = new Promise(resolve => {
+ img.onload = resolve;
+ });
+ img.src = `data:image/svg+xml,%3Csvg viewBox='0 0 ${width} ${
+ height}' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='50' cy='50' r='50'/%3E%3C/svg%3E`;
+ await promise;
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const context = canvas.getContext('2d');
+ if (!context) {
+ throw new Error('Could not create context.');
+ }
+ const bitmap = await createImageBitmap(img, {
+ resizeHeight: height,
+ resizeWidth: width,
+ });
+ context.drawImage(bitmap, 0, 0);
+
+ return canvas.toDataURL('image/png') as Models.ScreenshotStorage.Screenshot;
+ }
+
+ async function getScreenshotDimensions(
+ screenshot: Models.ScreenshotStorage.Screenshot,
+ ): Promise<number[]> {
+ const tmp = new Image();
+ const promise = new Promise(resolve => {
+ tmp.onload = resolve;
+ });
+ tmp.src = screenshot;
+ await promise;
+ return [tmp.width, tmp.height];
+ }
+
+ it('can resize screenshots to be 160px wide and <= 240px high', async () => {
+ const {resizeScreenshot} = Models.ScreenshotUtils;
+ assert.deepStrictEqual(
+ await getScreenshotDimensions(
+ await resizeScreenshot(await generateImage(400, 800)),
+ ),
+ [160, 240],
+ );
+ assert.deepStrictEqual(
+ await getScreenshotDimensions(
+ await resizeScreenshot(await generateImage(800, 400)),
+ ),
+ [160, 80],
+ );
+ assert.deepStrictEqual(
+ await getScreenshotDimensions(
+ await resizeScreenshot(await generateImage(80, 80)),
+ ),
+ [160, 160],
+ );
+ assert.deepStrictEqual(
+ await getScreenshotDimensions(
+ await resizeScreenshot(await generateImage(80, 320)),
+ ),
+ [160, 240],
+ );
+ });
+});
diff --git a/front_end/panels/recorder/models/Section.test.ts b/front_end/panels/recorder/models/Section.test.ts
new file mode 100644
index 0000000..ffff0a1
--- /dev/null
+++ b/front_end/panels/recorder/models/Section.test.ts
@@ -0,0 +1,100 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from './models.js';
+
+describe('Section', () => {
+ describe('buildSections', () => {
+ const buildSections = Models.Section.buildSections;
+
+ function makeStep(): Models.Schema.Step {
+ return {type: Models.Schema.StepType.Scroll};
+ }
+
+ function makeNavigateStep(): Models.Schema.NavigateStep {
+ return {
+ type: Models.Schema.StepType.Navigate,
+ url: 'https://example.com',
+ assertedEvents: [
+ {
+ type: Models.Schema.AssertedEventType.Navigation,
+ url: 'https://example.com',
+ title: 'Test',
+ },
+ ],
+ };
+ }
+
+ function makeStepCausingNavigation(): Models.Schema.Step {
+ return {
+ type: Models.Schema.StepType.Scroll,
+ assertedEvents: [
+ {
+ type: Models.Schema.AssertedEventType.Navigation,
+ url: 'https://example.com',
+ title: 'Test',
+ },
+ ],
+ };
+ }
+
+ it('should build not sections for empty steps', () => {
+ assert.deepStrictEqual(buildSections([]), []);
+ });
+
+ it('should build a current page section for initial steps that do not cause navigation', () => {
+ const step1 = makeStep();
+ const step2 = makeStep();
+ assert.deepStrictEqual(buildSections([step1, step2]), [
+ {title: 'Current page', url: '', steps: [step1, step2]},
+ ]);
+ });
+
+ it('should build a current page section for initial steps that cause navigation', () => {
+ {
+ const step1 = makeNavigateStep();
+ const step2 = makeStep();
+ assert.deepStrictEqual(buildSections([step1, step2]), [
+ {
+ title: 'Test',
+ url: 'https://example.com',
+ steps: [step2],
+ causingStep: step1,
+ },
+ ]);
+ }
+
+ {const step1 = makeStepCausingNavigation(); const step2 = makeStep(); assert.deepStrictEqual(
+ buildSections([step1, step2]),
+ [
+ {title: 'Current page', url: '', steps: [step1]},
+ {title: 'Test', url: 'https://example.com', steps: [step2]},
+ ]);}
+ });
+
+ it('should generate multiple sections', () => {
+ const step1 = makeStep();
+ const step2 = makeNavigateStep();
+ const step3 = makeStep();
+ const step4 = makeStepCausingNavigation();
+ const step5 = makeStep();
+
+ assert.deepStrictEqual(
+ buildSections([step1, step2, step3, step4, step5]),
+ [
+ {title: 'Current page', url: '', steps: [step1, step2]},
+ {
+ title: 'Test',
+ url: 'https://example.com',
+ steps: [step3, step4],
+ causingStep: step2,
+ },
+ {title: 'Test', url: 'https://example.com', steps: [step5]},
+ ],
+ );
+ });
+ });
+});
diff --git a/front_end/panels/recorder/models/recording-storage.test.ts b/front_end/panels/recorder/models/recording-storage.test.ts
new file mode 100644
index 0000000..4067211
--- /dev/null
+++ b/front_end/panels/recorder/models/recording-storage.test.ts
@@ -0,0 +1,67 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Recorder from './models.js';
+import {
+ describeWithEnvironment,
+} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+
+describeWithEnvironment('RecordingStorage', () => {
+ beforeEach(() => {
+ Recorder.RecordingStorage.RecordingStorage.instance().clearForTest();
+ });
+
+ after(() => {
+ Recorder.RecordingStorage.RecordingStorage.instance().clearForTest();
+ });
+
+ class MockIdGenerator {
+ #id = 1;
+ next() {
+ const result = `recording_${this.#id}`;
+ this.#id++;
+ return result;
+ }
+ }
+
+ it('should create and retrieve recordings', async () => {
+ const storage = Recorder.RecordingStorage.RecordingStorage.instance();
+ storage.setIdGeneratorForTest(new MockIdGenerator());
+ const flow1 = {title: 'Test1', steps: []};
+ const flow2 = {title: 'Test2', steps: []};
+ const flow3 = {title: 'Test3', steps: []};
+ assert.deepEqual(await storage.saveRecording(flow1), {
+ storageName: 'recording_1',
+ flow: flow1,
+ });
+ assert.deepEqual(await storage.saveRecording(flow2), {
+ storageName: 'recording_2',
+ flow: flow2,
+ });
+ assert.deepEqual(await storage.getRecordings(), [
+ {storageName: 'recording_1', flow: flow1},
+ {storageName: 'recording_2', flow: flow2},
+ ]);
+ assert.deepEqual(await storage.getRecording('recording_2'), {
+ storageName: 'recording_2',
+ flow: flow2,
+ });
+ assert.deepEqual(await storage.getRecording('recording_3'), undefined);
+ assert.deepEqual(await storage.updateRecording('recording_2', flow3), {
+ storageName: 'recording_2',
+ flow: flow3,
+ });
+ assert.deepEqual(await storage.getRecording('recording_2'), {
+ storageName: 'recording_2',
+ flow: flow3,
+ });
+
+ await storage.deleteRecording('recording_2');
+ assert.deepEqual(await storage.getRecordings(), [
+ {storageName: 'recording_1', flow: flow1},
+ ]);
+ });
+});
diff --git a/front_end/panels/recorder/models/screenshot-storage.test.ts b/front_end/panels/recorder/models/screenshot-storage.test.ts
new file mode 100644
index 0000000..8711ae0
--- /dev/null
+++ b/front_end/panels/recorder/models/screenshot-storage.test.ts
@@ -0,0 +1,161 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Recorder from './models.js';
+import * as Common from '../../../core/common/common.js';
+
+import {
+ describeWithEnvironment,
+} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+
+let instance: Recorder.ScreenshotStorage.ScreenshotStorage;
+
+describeWithEnvironment('ScreenshotStorage', () => {
+ beforeEach(() => {
+ instance = Recorder.ScreenshotStorage.ScreenshotStorage.instance();
+ instance.clear();
+ });
+
+ it('should return null if no screenshot has been stored for the given index', () => {
+ const imageData = instance.getScreenshotForSection('recording-1', 1);
+ assert.isNull(imageData);
+ });
+
+ it('should return the stored image data when a screenshot has been stored for the given index', () => {
+ const imageData = 'data:image/jpeg;base64,...' as Recorder.ScreenshotStorage.Screenshot;
+ instance.storeScreenshotForSection('recording-1', 1, imageData);
+ const retrievedImageData = instance.getScreenshotForSection(
+ 'recording-1',
+ 1,
+ );
+ assert.strictEqual(retrievedImageData, imageData);
+ });
+
+ it('should load previous screenshots from settings', () => {
+ const imageData = 'data:image/jpeg;base64,...' as Recorder.ScreenshotStorage.Screenshot;
+ const setting = Common.Settings.Settings.instance().createSetting<Recorder.ScreenshotStorage.ScreenshotMetaData[]>(
+ 'recorder-screenshots', []);
+ setting.set([{recordingName: 'recording-1', index: 1, data: imageData}]);
+
+ const screenshotStorage = Recorder.ScreenshotStorage.ScreenshotStorage.instance({forceNew: true});
+ const retrievedImageData = screenshotStorage.getScreenshotForSection(
+ 'recording-1',
+ 1,
+ );
+ assert.strictEqual(retrievedImageData, imageData);
+ });
+
+ it('should sync screenshots to settings', () => {
+ const imageData = 'data:image/jpeg;base64,...' as Recorder.ScreenshotStorage.Screenshot;
+ instance.storeScreenshotForSection('recording-1', 1, imageData);
+
+ const setting = Common.Settings.Settings.instance().createSetting<Recorder.ScreenshotStorage.ScreenshotMetaData[]>(
+ 'recorder-screenshots', []);
+ const value = setting.get();
+ assert.strictEqual(value.length, 1);
+ assert.strictEqual(value[0].index, 1);
+ assert.strictEqual(value[0].data, imageData);
+ });
+
+ it('should limit the amount of stored screenshots', () => {
+ const screenshotStorage = Recorder.ScreenshotStorage.ScreenshotStorage.instance({
+ forceNew: true,
+ maxStorageSize: 2,
+ });
+
+ screenshotStorage.storeScreenshotForSection(
+ 'recording-1',
+ 1,
+ '1' as Recorder.ScreenshotStorage.Screenshot,
+ );
+ screenshotStorage.storeScreenshotForSection(
+ 'recording-1',
+ 2,
+ '2' as Recorder.ScreenshotStorage.Screenshot,
+ );
+ screenshotStorage.storeScreenshotForSection(
+ 'recording-1',
+ 3,
+ '3' as Recorder.ScreenshotStorage.Screenshot,
+ );
+
+ const imageData1 = screenshotStorage.getScreenshotForSection(
+ 'recording-1',
+ 1,
+ );
+ const imageData2 = screenshotStorage.getScreenshotForSection(
+ 'recording-1',
+ 2,
+ );
+ const imageData3 = screenshotStorage.getScreenshotForSection(
+ 'recording-1',
+ 3,
+ );
+
+ assert.isNull(imageData1);
+ assert.isNotNull(imageData2);
+ assert.isNotNull(imageData3);
+ });
+
+ it('should drop the oldest screenshots first', () => {
+ const screenshotStorage = Recorder.ScreenshotStorage.ScreenshotStorage.instance({
+ forceNew: true,
+ maxStorageSize: 2,
+ });
+
+ screenshotStorage.storeScreenshotForSection(
+ 'recording-1',
+ 1,
+ '1' as Recorder.ScreenshotStorage.Screenshot,
+ );
+ screenshotStorage.storeScreenshotForSection(
+ 'recording-1',
+ 2,
+ '2' as Recorder.ScreenshotStorage.Screenshot,
+ );
+ screenshotStorage.getScreenshotForSection('recording-1', 1);
+ screenshotStorage.storeScreenshotForSection(
+ 'recording-1',
+ 3,
+ '3' as Recorder.ScreenshotStorage.Screenshot,
+ );
+
+ const imageData1 = screenshotStorage.getScreenshotForSection(
+ 'recording-1',
+ 1,
+ );
+ const imageData2 = screenshotStorage.getScreenshotForSection(
+ 'recording-1',
+ 2,
+ );
+ const imageData3 = screenshotStorage.getScreenshotForSection(
+ 'recording-1',
+ 3,
+ );
+
+ assert.isNotNull(imageData1);
+ assert.isNull(imageData2);
+ assert.isNotNull(imageData3);
+ });
+
+ it('should namespace the screenshots by recording name', () => {
+ const imageData = 'data:image/jpeg;base64,...' as Recorder.ScreenshotStorage.Screenshot;
+
+ instance.storeScreenshotForSection('recording-1', 1, imageData);
+ const storedImageData = instance.getScreenshotForSection('recording-2', 1);
+
+ assert.isNull(storedImageData);
+ });
+
+ it('should delete screenshots by recording name', () => {
+ const imageData = 'data:image/jpeg;base64,...' as Recorder.ScreenshotStorage.Screenshot;
+
+ instance.storeScreenshotForSection('recording-1', 1, imageData);
+ const storedImageData = instance.getScreenshotForSection('recording-2', 1);
+
+ assert.isNull(storedImageData);
+ });
+});
diff --git a/front_end/panels/recorder/util/BUILD.gn b/front_end/panels/recorder/util/BUILD.gn
index e4d30d2..f671140 100644
--- a/front_end/panels/recorder/util/BUILD.gn
+++ b/front_end/panels/recorder/util/BUILD.gn
@@ -2,9 +2,9 @@
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
-import(
- "../../../../scripts/build/ninja/devtools_entrypoint.gni")
+import("../../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../../scripts/build/ninja/devtools_module.gni")
+import("../../../../third_party/typescript/typescript.gni")
devtools_module("util") {
sources = [ "SharedObject.ts" ]
@@ -21,7 +21,15 @@
]
visibility = [
+ ":*",
"../*",
- "../../../../test/unittests/front_end/panels/recorder/*",
]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [ "SharedObject.test.ts" ]
+
+ deps = [ ":bundle" ]
+}
diff --git a/front_end/panels/recorder/util/SharedObject.test.ts b/front_end/panels/recorder/util/SharedObject.test.ts
new file mode 100644
index 0000000..39e1ce8
--- /dev/null
+++ b/front_end/panels/recorder/util/SharedObject.test.ts
@@ -0,0 +1,90 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Util from './util.js';
+
+describe('SharedObject', () => {
+ it('should work', async () => {
+ // The test object
+ const testObject = {value: false};
+
+ const object = new Util.SharedObject.SharedObject(
+ () => {
+ testObject.value = true;
+ return {...testObject};
+ },
+ object => {
+ object.value = false;
+ });
+
+ // No one acquired.
+ assert.isFalse(testObject.value);
+
+ // First acquire.
+ const [object1, release1] = await object.acquire();
+ // Should be created.
+ assert.notStrictEqual(object1, testObject);
+ // Acquired actually occured.
+ assert.isTrue(testObject.value);
+
+ // The second object should be the same.
+ const [object2, release2] = await object.acquire();
+ // Should equal the first acquired object.
+ assert.strictEqual(object2, object1);
+ // Should still be true.
+ assert.isTrue(object1.value);
+
+ // First release (can be in any order).
+ await release1();
+ // Should still be true.
+ assert.isTrue(object1.value);
+
+ // Second release.
+ await release2();
+ assert.isFalse(object1.value);
+ });
+ it('should work with run', async () => {
+ // The test object
+ const testObject = {value: false};
+
+ const object = new Util.SharedObject.SharedObject(
+ () => {
+ testObject.value = true;
+ return {...testObject};
+ },
+ object => {
+ object.value = false;
+ });
+
+ // No one acquired.
+ assert.isFalse(testObject.value);
+
+ let finalObject: {value: boolean}|undefined;
+ const promises = [];
+
+ // First acquire.
+ promises.push(object.run(async object1 => {
+ // Should be created.
+ assert.notStrictEqual(object1, testObject);
+ // Acquired actually occured.
+ assert.isTrue(testObject.value);
+
+ promises.push(object.run(async object2 => {
+ // Should equal the first acquired object.
+ assert.strictEqual(object2, object1);
+ // Should still be true.
+ assert.isTrue(object1.value);
+
+ finalObject = object1;
+ }));
+ }));
+
+ await Promise.all(promises);
+
+ assert.isDefined(finalObject);
+ assert.isFalse(finalObject?.value);
+ });
+});
diff --git a/front_end/panels/screencast/BUILD.gn b/front_end/panels/screencast/BUILD.gn
index b78f790..1bab428 100644
--- a/front_end/panels/screencast/BUILD.gn
+++ b/front_end/panels/screencast/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../scripts/build/ninja/devtools_module.gni")
import("../../../scripts/build/ninja/generate_css.gni")
+import("../../../third_party/typescript/typescript.gni")
import("../visibility.gni")
generate_css("css_files") {
@@ -40,7 +41,6 @@
visibility = [
":*",
"../../../test/unittests/front_end/entrypoints/missing_entrypoints/*",
- "../../../test/unittests/front_end/panels/screencast/*",
"../../entrypoints/*",
]
@@ -58,3 +58,14 @@
visibility = [ "../../entrypoints/*" ]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [ "ScreencastApp.test.ts" ]
+
+ deps = [
+ ":bundle",
+ "../../../test/unittests/front_end/helpers",
+ ]
+}
diff --git a/front_end/panels/screencast/ScreencastApp.test.ts b/front_end/panels/screencast/ScreencastApp.test.ts
new file mode 100644
index 0000000..9f7dc16
--- /dev/null
+++ b/front_end/panels/screencast/ScreencastApp.test.ts
@@ -0,0 +1,33 @@
+// 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 {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import * as Screencast from './screencast.js';
+import {createTarget} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+
+describeWithMockConnection('ScreencastApp', () => {
+ const tests = (targetFactory: () => SDK.Target.Target) => {
+ it('can start casting', async () => {
+ const screencastApp = new Screencast.ScreencastApp.ScreencastApp();
+ screencastApp.presentUI(document);
+ const target = targetFactory();
+ const screenCaptureModel = target.model(SDK.ScreenCaptureModel.ScreenCaptureModel);
+ assertNotNullOrUndefined(screenCaptureModel);
+ await new Promise<void>(
+ resolve => sinon.stub(screenCaptureModel, 'startScreencast').callsFake((..._args: unknown[]) => {
+ resolve();
+ }));
+ screencastApp.rootView?.detach();
+ });
+ };
+
+ describe('without tab target', () => tests(createTarget));
+ describe('with tab target', () => tests(() => {
+ const tabTarget = createTarget({type: SDK.Target.Type.Tab});
+ createTarget({parentTarget: tabTarget, subtype: 'prerender'});
+ return createTarget({parentTarget: tabTarget});
+ }));
+});
diff --git a/front_end/panels/search/BUILD.gn b/front_end/panels/search/BUILD.gn
index f9cb35a..c3678cb 100644
--- a/front_end/panels/search/BUILD.gn
+++ b/front_end/panels/search/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../scripts/build/ninja/devtools_module.gni")
import("../../../scripts/build/ninja/generate_css.gni")
+import("../../../third_party/typescript/typescript.gni")
import("../visibility.gni")
generate_css("css_files") {
@@ -43,7 +44,6 @@
visibility = [
":*",
"../../../test/unittests/front_end/entrypoints/missing_entrypoints/*",
- "../../../test/unittests/front_end/panels/search/*",
"../../entrypoints/*",
"../network/*",
"../sources/*",
@@ -51,3 +51,22 @@
visibility += devtools_panels_visibility
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [
+ "SearchResultsPane.test.ts",
+ "SearchView.test.ts",
+ ]
+
+ deps = [
+ ":bundle",
+ "../../../test/unittests/front_end/helpers",
+ "../../core/common:bundle",
+ "../../core/platform:bundle",
+ "../../models/text_utils:bundle",
+ "../../models/workspace:bundle",
+ "../../ui/legacy:bundle",
+ ]
+}
diff --git a/front_end/panels/search/SearchResultsPane.test.ts b/front_end/panels/search/SearchResultsPane.test.ts
new file mode 100644
index 0000000..91689d4
--- /dev/null
+++ b/front_end/panels/search/SearchResultsPane.test.ts
@@ -0,0 +1,229 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Search from './search.js';
+import * as TextUtils from '../../models/text_utils/text_utils.js';
+import * as Workspace from '../../models/workspace/workspace.js';
+
+import {describeWithLocale} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+
+const {lineSegmentForMatch} = Search.SearchResultsPane;
+
+function r(matchDescriptor: TemplateStringsArray): TextUtils.TextRange.SourceRange {
+ const start = matchDescriptor[0].indexOf('[');
+ const end = matchDescriptor[0].indexOf(')');
+ return new TextUtils.TextRange.SourceRange(start, end - start);
+}
+
+describe('lineSegmentForMatch', () => {
+ it('is a no-op if for short lines with the match close to the start', () => {
+ const lineContent = 'Just a short line';
+ const matchRange = r` [ )`;
+
+ const {lineSegment, matchRange: actualMRange} = lineSegmentForMatch(lineContent, matchRange);
+
+ assert.strictEqual(lineSegment, lineContent);
+ assert.deepEqual(actualMRange, matchRange);
+ });
+
+ it('only shows {prefixLength} characters before the match with an ellipsis', () => {
+ const lineContent = 'Just a somewhat short line';
+ const matchRange = r` [ )`;
+
+ const {lineSegment, matchRange: actualMRange} = lineSegmentForMatch(lineContent, matchRange, {prefixLength: 5});
+
+ assert.strictEqual(lineSegment, '…what short line');
+ assert.deepEqual(actualMRange, r` [ )`);
+ });
+
+ it('only shows {maxLength} characters (excluding prefix ellipsis)', () => {
+ const lineContent = 'A somewhat longer line to demonstrate maxLength';
+ const matchRange = r` [ )`;
+
+ const {lineSegment, matchRange: actualMRange} = lineSegmentForMatch(lineContent, matchRange, {maxLength: 22});
+
+ assert.strictEqual(lineSegment, 'A somewhat longer line');
+ assert.deepEqual(actualMRange, r` [ )`);
+ });
+
+ it('trims whitespace at the beginning of the line', () => {
+ const lineContent = ' A line with whitespace at the beginning';
+ const matchRange = r` [ )`;
+
+ const {lineSegment, matchRange: actualMRange} = lineSegmentForMatch(lineContent, matchRange);
+
+ assert.strictEqual(lineSegment, 'A line with whitespace at the beginning');
+ assert.deepEqual(actualMRange, r` [ )`);
+ });
+
+ it('works with whitespace trimming and {prefixLength}', () => {
+ const lineContent = ' A line with whitespace at the beginning';
+ const matchRange = r` [ )`;
+
+ const {lineSegment, matchRange: actualMRange} = lineSegmentForMatch(lineContent, matchRange, {prefixLength: 5});
+
+ assert.strictEqual(lineSegment, '…pace at the beginning');
+ assert.deepEqual(actualMRange, r` [ )`);
+ });
+
+ it('only trims whitespace until the match starts', () => {
+ const lineContent = ' A line with whitespace at the beginning';
+ const matchRange = r` [ )`;
+
+ const {lineSegment, matchRange: actualMRange} = lineSegmentForMatch(lineContent, matchRange);
+
+ assert.strictEqual(lineSegment, ' A line with whitespace at the beginning');
+ assert.deepEqual(actualMRange, r`[ )`);
+ });
+
+ it('it shortens the range to the end of the segment if the line was truncated (together with the match)', () => {
+ const lineContent = 'A very very very long line with a very long match';
+ const matchRange = r` [ )`;
+
+ const {lineSegment, matchRange: actualMRange} =
+ lineSegmentForMatch(lineContent, matchRange, {prefixLength: 5, maxLength: 15});
+
+ assert.strictEqual(lineSegment, '…very very long ');
+ assert.deepEqual(actualMRange, r` [ )`);
+ });
+});
+
+class FakeSearchResult implements Search.SearchScope.SearchResult {
+ #label: string;
+ #description: string;
+ #matchDescriptors: {lineNumber: number, lineContent: string, matchRange?: TextUtils.TextRange.SourceRange}[];
+
+ constructor(
+ label: string, description: string,
+ matchDescriptors: {lineNumber: number, lineContent: string, matchRange?: TextUtils.TextRange.SourceRange}[]) {
+ this.#label = label;
+ this.#description = description;
+ this.#matchDescriptors = matchDescriptors;
+ }
+
+ label(): string {
+ return this.#label;
+ }
+ description(): string {
+ return this.#description;
+ }
+ matchesCount(): number {
+ return this.#matchDescriptors.length;
+ }
+ matchLabel(index: number): string {
+ return this.#matchDescriptors[index].lineNumber.toString();
+ }
+ matchLineContent(index: number): string {
+ return this.#matchDescriptors[index].lineContent;
+ }
+ matchRevealable(): Object {
+ return {};
+ }
+ matchColumn(index: number): number|undefined {
+ return this.#matchDescriptors[index].matchRange?.offset;
+ }
+ matchLength(index: number): number|undefined {
+ return this.#matchDescriptors[index].matchRange?.length;
+ }
+}
+
+describeWithLocale('SearchResultsPane', () => {
+ it('shows one entry per line with matches when matchColumn/matchLength is NOT present', () => {
+ const searchConfig = new Workspace.SearchConfig.SearchConfig('the', true, false);
+ const resultPane = new Search.SearchResultsPane.SearchResultsPane(searchConfig);
+ resultPane.addSearchResult(new FakeSearchResult('file.txt', 'file.txt', [
+ {lineNumber: 10, lineContent: 'This is the line with multiple "the" matches'},
+ {lineNumber: 15, lineContent: 'This is a line with only one "the" match'},
+ ]));
+
+ resultPane.showAllMatches();
+
+ const matchSpans = resultPane['treeOutline'].shadowRoot.querySelectorAll('.search-match-content');
+ assert.lengthOf(matchSpans, 2);
+ assert.deepEqual(
+ [...matchSpans].map(span => span.textContent),
+ ['This is the line with multiple "the" matches', '…with only one "the" match']);
+ });
+
+ it('shows one entry per match when matchColumn/matchLength is present', () => {
+ const searchConfig = new Workspace.SearchConfig.SearchConfig('the', true, false);
+ const resultPane = new Search.SearchResultsPane.SearchResultsPane(searchConfig);
+ resultPane.addSearchResult(new FakeSearchResult('file.txt', 'file.txt', [
+ {
+ lineNumber: 10,
+ lineContent: 'This is the line with multiple "the" matches',
+ matchRange: r` [ )`,
+ },
+ {
+ lineNumber: 10,
+ lineContent: 'This is the line with multiple "the" matches',
+ matchRange: r` [ )`,
+ },
+ {
+ lineNumber: 15,
+ lineContent: 'This is a line with only one "the" match',
+ matchRange: r` [ )`,
+ },
+ ]));
+
+ resultPane.showAllMatches();
+
+ const matchSpans = resultPane['treeOutline'].shadowRoot.querySelectorAll('.search-match-content');
+ assert.lengthOf(matchSpans, 3);
+ assert.deepEqual([...matchSpans].map(span => span.textContent), [
+ 'This is the line with multiple "the" matches',
+ '… the line with multiple "the" matches',
+ '…is a line with only one "the" match',
+ ]);
+ });
+
+ it('highlights all matches of a line when matchColumn/matchLength is NOT present', () => {
+ const searchConfig = new Workspace.SearchConfig.SearchConfig('the', true, false);
+ const resultPane = new Search.SearchResultsPane.SearchResultsPane(searchConfig);
+ resultPane.addSearchResult(new FakeSearchResult('file.txt', 'file.txt', [
+ {lineNumber: 10, lineContent: 'This is the line with multiple "the" matches'},
+ {lineNumber: 15, lineContent: 'This is a line with only one "the" match'},
+ ]));
+
+ resultPane.showAllMatches();
+
+ const matchSpans = resultPane['treeOutline'].shadowRoot.querySelectorAll('.highlighted-search-result');
+ assert.lengthOf(matchSpans, 3);
+ assert.deepEqual([...matchSpans].map(span => span.textContent), ['the', 'the', 'the']);
+ });
+
+ it('highlights only the specified match when matchColumn/matchLength is present', () => {
+ const searchConfig = new Workspace.SearchConfig.SearchConfig('the', true, false);
+ const resultPane = new Search.SearchResultsPane.SearchResultsPane(searchConfig);
+ resultPane.addSearchResult(new FakeSearchResult('file.txt', 'file.txt', [
+ {
+ lineNumber: 10,
+ lineContent: 'This is the line with multiple "the" matches',
+ matchRange: r` [ )`,
+ },
+ {
+ lineNumber: 10,
+ lineContent: 'This is the line with multiple "the" matches',
+ matchRange: r` [ )`,
+ },
+ {
+ lineNumber: 15,
+ lineContent: 'This is a line with only one "the" match',
+ matchRange: r` [ )`,
+ },
+ ]));
+
+ resultPane.showAllMatches();
+
+ const matchSpans = resultPane['treeOutline'].shadowRoot.querySelectorAll('.search-match-content');
+ assert.lengthOf(matchSpans, 3);
+ assert.deepEqual([...matchSpans].map(span => span.innerHTML), [
+ 'This is <span class="highlighted-search-result">the</span> line with multiple "the" matches',
+ '… the line with multiple "<span class="highlighted-search-result">the</span>" matches',
+ '…is a line with only one "<span class="highlighted-search-result">the</span>" match',
+ ]);
+ });
+});
diff --git a/front_end/panels/search/SearchView.test.ts b/front_end/panels/search/SearchView.test.ts
new file mode 100644
index 0000000..db373ab
--- /dev/null
+++ b/front_end/panels/search/SearchView.test.ts
@@ -0,0 +1,166 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Common from '../../core/common/common.js';
+import * as Platform from '../../core/platform/platform.js';
+import type * as Workspace from '../../models/workspace/workspace.js';
+import * as Search from './search.js';
+import * as UI from '../../ui/legacy/legacy.js';
+
+import {dispatchKeyDownEvent} from '../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {describeWithEnvironment} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+
+interface PerformSearchArgs {
+ searchConfig: Workspace.SearchConfig.SearchConfig;
+ progress: Common.Progress.Progress;
+ searchResultCallback: (arg0: Search.SearchScope.SearchResult) => void;
+ searchFinishedCallback: (arg0: boolean) => void;
+}
+
+class FakeSearchScope implements Search.SearchScope.SearchScope {
+ readonly performSearchCalledPromise: Promise<PerformSearchArgs>;
+ readonly #resolvePerformSearchCalledPromise: (args: PerformSearchArgs) => void;
+
+ constructor() {
+ const {promise, resolve} = Platform.PromiseUtilities.promiseWithResolvers<PerformSearchArgs>();
+ this.performSearchCalledPromise = promise;
+ this.#resolvePerformSearchCalledPromise = resolve;
+ }
+
+ performSearch(
+ searchConfig: Workspace.SearchConfig.SearchConfig, progress: Common.Progress.Progress,
+ searchResultCallback: (arg0: Search.SearchScope.SearchResult) => void,
+ searchFinishedCallback: (arg0: boolean) => void): void|Promise<void> {
+ this.#resolvePerformSearchCalledPromise({searchConfig, progress, searchResultCallback, searchFinishedCallback});
+ }
+
+ performIndexing(progress: Common.Progress.Progress): void {
+ setTimeout(() => progress.done(), 0); // Allow microtasks to run.
+ }
+
+ stopSearch(): void {
+ }
+}
+
+class TestSearchView extends Search.SearchView.SearchView {
+ /**
+ * The throttler with which the base 'SearchView' throttles UI updates.
+ * Exposed here so tests can wait for the updates to finish.
+ */
+ readonly throttler: Common.Throttler.Throttler;
+
+ readonly #scopeCreator: () => Search.SearchScope.SearchScope;
+ /**
+ * `SearchView` resets and lazily re-creates the search results pane for each search.
+ * To provide a fake instance we install a get/set accesssor for the original property
+ * that behaves normally with no override, but returns the mock if one is provided.
+ */
+ #searchResultsPane: Search.SearchResultsPane.SearchResultsPane|null = null;
+ readonly #overrideResultsPane: boolean;
+
+ constructor(
+ scopeCreator: () => Search.SearchScope.SearchScope,
+ searchResultsPane?: Search.SearchResultsPane.SearchResultsPane) {
+ const throttler = new Common.Throttler.Throttler(/* timeoutMs */ 0);
+ super('fake', throttler);
+ this.throttler = throttler;
+ this.#scopeCreator = scopeCreator;
+ this.#searchResultsPane = searchResultsPane ?? null;
+ this.#overrideResultsPane = Boolean(searchResultsPane);
+
+ // Use 'Object.definePrroperty' or TS won't be happy that we replace a prop with an accessor.
+ Object.defineProperty(this, 'searchResultsPane', {
+ get: () => this.#searchResultsPane,
+ set: (pane: Search.SearchResultsPane.SearchResultsPane|null) => {
+ if (!this.#overrideResultsPane) {
+ this.#searchResultsPane = pane;
+ }
+ },
+ });
+ }
+
+ override createScope(): Search.SearchScope.SearchScope {
+ return this.#scopeCreator();
+ }
+
+ /** Fills in the UI elements of the SearchView and hits 'Enter'. */
+ triggerSearch(query: string, matchCase: boolean, isRegex: boolean): void {
+ this.search.value = query;
+ this.matchCaseButton.setToggled(matchCase);
+ this.regexButton.setToggled(isRegex);
+
+ dispatchKeyDownEvent(this.search, {keyCode: UI.KeyboardShortcut.Keys.Enter.code});
+ }
+
+ get currentSearchResultMessage(): string {
+ return this.contentElement.querySelector('.search-message:nth-child(3)')!.textContent ?? '';
+ }
+}
+
+describeWithEnvironment('SearchView', () => {
+ it('calls the search scope with the search config provided by the user via the UI', async () => {
+ const fakeScope = new FakeSearchScope();
+ const searchView = new TestSearchView(() => fakeScope);
+
+ searchView.triggerSearch('a query', true, true);
+
+ const {searchConfig} = await fakeScope.performSearchCalledPromise;
+ assert.strictEqual(searchConfig.query(), 'a query');
+ assert.isFalse(searchConfig.ignoreCase());
+ assert.isTrue(searchConfig.isRegex());
+ });
+
+ it('notifies the user when no search results were found', async () => {
+ const fakeScope = new FakeSearchScope();
+ const searchView = new TestSearchView(() => fakeScope);
+
+ searchView.triggerSearch('a query', true, true);
+
+ const {searchFinishedCallback} = await fakeScope.performSearchCalledPromise;
+ searchFinishedCallback(/* finished */ true);
+
+ assert.strictEqual(searchView.currentSearchResultMessage, 'No matches found.');
+ });
+
+ it('updates the search result message with a count when search results are added', async () => {
+ const fakeScope = new FakeSearchScope();
+ const fakeResultsPane = sinon.createStubInstance(Search.SearchResultsPane.SearchResultsPane);
+ const searchView = new TestSearchView(() => fakeScope, fakeResultsPane);
+
+ searchView.triggerSearch('a query', true, true);
+
+ const {searchResultCallback} = await fakeScope.performSearchCalledPromise;
+
+ searchResultCallback({matchesCount: () => 10} as Search.SearchScope.SearchResult);
+ await searchView.throttler.process?.();
+ assert.strictEqual(searchView.currentSearchResultMessage, 'Found 10 matching lines in 1 file.');
+
+ searchResultCallback({matchesCount: () => 42} as Search.SearchScope.SearchResult);
+ await searchView.throttler.process?.();
+ assert.strictEqual(searchView.currentSearchResultMessage, 'Found 52 matching lines in 2 files.');
+ });
+
+ it('forwards each SearchResult to the results pane', async () => {
+ const fakeScope = new FakeSearchScope();
+ const fakeResultsPane = sinon.createStubInstance(Search.SearchResultsPane.SearchResultsPane);
+ const searchView = new TestSearchView(() => fakeScope, fakeResultsPane);
+
+ searchView.triggerSearch('a query', true, true);
+
+ const {searchResultCallback} = await fakeScope.performSearchCalledPromise;
+
+ const searchResult1 = ({matchesCount: () => 10}) as Search.SearchScope.SearchResult;
+ const searchResult2 = ({matchesCount: () => 42}) as Search.SearchScope.SearchResult;
+
+ searchResultCallback(searchResult1);
+ searchResultCallback(searchResult2);
+ await searchView.throttler.process?.();
+
+ assert.isTrue(fakeResultsPane.addSearchResult.calledTwice);
+ assert.strictEqual(fakeResultsPane.addSearchResult.args[0][0], searchResult1);
+ assert.strictEqual(fakeResultsPane.addSearchResult.args[1][0], searchResult2);
+ });
+});
diff --git a/front_end/panels/security/BUILD.gn b/front_end/panels/security/BUILD.gn
index d059de0..45a3ef6 100644
--- a/front_end/panels/security/BUILD.gn
+++ b/front_end/panels/security/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../scripts/build/ninja/devtools_module.gni")
import("../../../scripts/build/ninja/generate_css.gni")
+import("../../../third_party/typescript/typescript.gni")
import("../visibility.gni")
generate_css("css_files") {
@@ -62,3 +63,17 @@
visibility = [ "../../entrypoints/*" ]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [ "SecurityPanel.test.ts" ]
+
+ deps = [
+ ":bundle",
+ "../../../test/unittests/front_end/helpers",
+ "../../core/platform:bundle",
+ "../../core/sdk:bundle",
+ "../../generated:protocol",
+ ]
+}
diff --git a/front_end/panels/security/SecurityPanel.test.ts b/front_end/panels/security/SecurityPanel.test.ts
new file mode 100644
index 0000000..0cfaf6c
--- /dev/null
+++ b/front_end/panels/security/SecurityPanel.test.ts
@@ -0,0 +1,136 @@
+// 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 {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import * as Protocol from '../../generated/protocol.js';
+import * as Security from './security.js';
+import {assertElement} from '../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {createTarget} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+
+describeWithMockConnection('SecurityPanel', () => {
+ const tests = (targetFactory: () => SDK.Target.Target) => {
+ let target: SDK.Target.Target;
+
+ beforeEach(() => {
+ target = targetFactory();
+ });
+
+ it('updates when security state changes', async () => {
+ const securityPanel = Security.SecurityPanel.SecurityPanel.instance({forceNew: true});
+ const securityModel = target.model(Security.SecurityModel.SecurityModel);
+ assertNotNullOrUndefined(securityModel);
+ const visibleSecurityState = {
+ securityState: Protocol.Security.SecurityState.Insecure,
+ securityStateIssueIds: [],
+ certificateSecurityState: null,
+ } as unknown as Security.SecurityModel.PageVisibleSecurityState;
+ securityModel.dispatchEventToListeners(
+ Security.SecurityModel.Events.VisibleSecurityStateChanged, visibleSecurityState);
+
+ assert.isTrue(securityPanel.mainView.contentElement.querySelector('.security-summary')
+ ?.classList.contains('security-summary-insecure'));
+
+ visibleSecurityState.securityState = Protocol.Security.SecurityState.Secure;
+ securityModel.dispatchEventToListeners(
+ Security.SecurityModel.Events.VisibleSecurityStateChanged, visibleSecurityState);
+
+ assert.isFalse(securityPanel.mainView.contentElement.querySelector('.security-summary')
+ ?.classList.contains('security-summary-insecure'));
+ assert.isTrue(securityPanel.mainView.contentElement.querySelector('.security-summary')
+ ?.classList.contains('security-summary-secure'));
+ });
+ };
+
+ describe('without tab target', () => tests(createTarget));
+ describe('with tab target', () => tests(() => {
+ const tabTarget = createTarget({type: SDK.Target.Type.Tab});
+ createTarget({parentTarget: tabTarget, subtype: 'prerender'});
+ return createTarget({parentTarget: tabTarget});
+ }));
+
+ it('can switch to a different SecurityModel', async () => {
+ const tabTarget = createTarget({type: SDK.Target.Type.Tab});
+ const mainTarget = createTarget({parentTarget: tabTarget});
+ const mainSecurityModel = mainTarget.model(Security.SecurityModel.SecurityModel);
+ assertNotNullOrUndefined(mainSecurityModel);
+ const securityPanel = Security.SecurityPanel.SecurityPanel.instance({forceNew: true});
+
+ // Add the main target to the security panel.
+ securityPanel.modelAdded(mainSecurityModel);
+ const visibleSecurityState = {
+ securityState: Protocol.Security.SecurityState.Insecure,
+ securityStateIssueIds: [],
+ certificateSecurityState: null,
+ } as unknown as Security.SecurityModel.PageVisibleSecurityState;
+ mainSecurityModel.dispatchEventToListeners(
+ Security.SecurityModel.Events.VisibleSecurityStateChanged, visibleSecurityState);
+ assert.isTrue(securityPanel.mainView.contentElement.querySelector('.security-summary')
+ ?.classList.contains('security-summary-insecure'));
+
+ // Switch to the prerender target.
+ const prerenderTarget = createTarget({parentTarget: tabTarget, subtype: 'prerender'});
+ const prerenderSecurityModel = prerenderTarget.model(Security.SecurityModel.SecurityModel);
+ assertNotNullOrUndefined(prerenderSecurityModel);
+ securityPanel.modelAdded(prerenderSecurityModel);
+ securityPanel.modelRemoved(mainSecurityModel);
+
+ // Check that the security panel does not listen to events from the previous target.
+ visibleSecurityState.securityState = Protocol.Security.SecurityState.Secure;
+ mainSecurityModel.dispatchEventToListeners(
+ Security.SecurityModel.Events.VisibleSecurityStateChanged, visibleSecurityState);
+ assert.isTrue(securityPanel.mainView.contentElement.querySelector('.security-summary')
+ ?.classList.contains('security-summary-insecure'));
+
+ // Check that the security panel listens to events from the current target.
+ prerenderSecurityModel.dispatchEventToListeners(
+ Security.SecurityModel.Events.VisibleSecurityStateChanged, visibleSecurityState);
+ assert.isTrue(securityPanel.mainView.contentElement.querySelector('.security-summary')
+ ?.classList.contains('security-summary-secure'));
+
+ // Check that the SecurityPanel listens to any PrimaryPageChanged event
+ const resourceTreeModel = mainTarget.model(SDK.ResourceTreeModel.ResourceTreeModel);
+ assertNotNullOrUndefined(resourceTreeModel);
+ const sidebarTreeClearSpy = sinon.spy(securityPanel.sidebarTree, 'clearOrigins');
+ resourceTreeModel.dispatchEventToListeners(SDK.ResourceTreeModel.Events.PrimaryPageChanged, {
+ frame: {url: 'https://www.example.com'} as SDK.ResourceTreeModel.ResourceTreeFrame,
+ type: SDK.ResourceTreeModel.PrimaryPageChangeType.Navigation,
+ });
+ assert.isTrue(sidebarTreeClearSpy.calledOnce);
+ });
+
+ it('shows \'reload page\' message when no data is available', async () => {
+ const target = createTarget();
+ const securityModel = target.model(Security.SecurityModel.SecurityModel);
+ assertNotNullOrUndefined(securityModel);
+ const securityPanel = Security.SecurityPanel.SecurityPanel.instance({forceNew: true});
+
+ // Check that reload message is visible initially.
+ const reloadMessage = securityPanel.sidebarTree.shadowRoot.querySelector('.security-main-view-reload-message');
+ assertElement(reloadMessage, HTMLLIElement);
+ assert.isFalse(reloadMessage.classList.contains('hidden'));
+
+ // Check that reload message is hidden when there is data to display.
+ const networkManager = securityModel.networkManager();
+ const request = {
+ wasBlocked: () => false,
+ url: () => 'https://www.example.com',
+ securityState: () => Protocol.Security.SecurityState.Secure,
+ securityDetails: () => null,
+ cached: () => false,
+ } as SDK.NetworkRequest.NetworkRequest;
+ networkManager.dispatchEventToListeners(SDK.NetworkManager.Events.RequestFinished, request);
+ assert.isTrue(reloadMessage.classList.contains('hidden'));
+
+ // Check that reload message is hidden after clearing data.
+ const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel);
+ assertNotNullOrUndefined(resourceTreeModel);
+ resourceTreeModel.dispatchEventToListeners(SDK.ResourceTreeModel.Events.PrimaryPageChanged, {
+ frame: {url: 'https://www.example.com'} as SDK.ResourceTreeModel.ResourceTreeFrame,
+ type: SDK.ResourceTreeModel.PrimaryPageChangeType.Navigation,
+ });
+ assert.isFalse(reloadMessage.classList.contains('hidden'));
+ });
+});
diff --git a/front_end/panels/snippets/BUILD.gn b/front_end/panels/snippets/BUILD.gn
index 97e076f..7f6584b 100644
--- a/front_end/panels/snippets/BUILD.gn
+++ b/front_end/panels/snippets/BUILD.gn
@@ -4,6 +4,7 @@
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../scripts/build/ninja/devtools_module.gni")
+import("../../../third_party/typescript/typescript.gni")
import("../visibility.gni")
devtools_module("snippets") {
@@ -40,3 +41,20 @@
visibility += devtools_panels_visibility
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [ "ScriptSnippetFileSystem.test.ts" ]
+
+ deps = [
+ ":bundle",
+ "../../../test/unittests/front_end/helpers",
+ "../../core/common:bundle",
+ "../../core/platform:bundle",
+ "../../core/sdk:bundle",
+ "../../models/persistence:bundle",
+ "../../models/workspace:bundle",
+ "../../ui/legacy:bundle",
+ ]
+}
diff --git a/front_end/panels/snippets/ScriptSnippetFileSystem.test.ts b/front_end/panels/snippets/ScriptSnippetFileSystem.test.ts
new file mode 100644
index 0000000..3c368d0
--- /dev/null
+++ b/front_end/panels/snippets/ScriptSnippetFileSystem.test.ts
@@ -0,0 +1,26 @@
+// 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 Common from '../../core/common/common.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import type * as Persistence from '../../models/persistence/persistence.js';
+import type * as Platform from '../../core/platform/platform.js';
+import * as Workspace from '../../models/workspace/workspace.js';
+import * as UI from '../../ui/legacy/legacy.js';
+import * as Snippets from './snippets.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import {createTarget} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {MockExecutionContext} from '../../../test/unittests/front_end/helpers/MockExecutionContext.js';
+
+describeWithMockConnection('ScriptSnippetFileSystem', () => {
+ it('evaluates snippets with user gesture', async () => {
+ UI.Context.Context.instance().setFlavor(
+ SDK.RuntimeModel.ExecutionContext, new MockExecutionContext(createTarget()));
+ const uiSourceCode = new Workspace.UISourceCode.UISourceCode(
+ {} as Persistence.FileSystemWorkspaceBinding.FileSystem, 'snippet://test.js' as Platform.DevToolsPath.UrlString,
+ Common.ResourceType.resourceTypes.Script);
+ await Snippets.ScriptSnippetFileSystem.evaluateScriptSnippet(uiSourceCode);
+ UI.Context.Context.instance().setFlavor(SDK.RuntimeModel.ExecutionContext, null);
+ });
+});
diff --git a/front_end/panels/sources/BUILD.gn b/front_end/panels/sources/BUILD.gn
index db1a786..dbb2aae 100644
--- a/front_end/panels/sources/BUILD.gn
+++ b/front_end/panels/sources/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../scripts/build/ninja/devtools_module.gni")
import("../../../scripts/build/ninja/generate_css.gni")
+import("../../../third_party/typescript/typescript.gni")
import("../visibility.gni")
generate_css("css_files") {
@@ -137,3 +138,35 @@
visibility = [ "../../entrypoints/*" ]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [
+ "BreakpointEditDialog.test.ts",
+ "CSSPlugin.test.ts",
+ "CoveragePlugin.test.ts",
+ "DebuggerPausedMessage.test.ts",
+ "DebuggerPlugin.test.ts",
+ "FilePathScoreFunction.test.ts",
+ "FilteredUISourceCodeListProvider.test.ts",
+ "NavigatorView.test.ts",
+ "OutlineQuickOpen.test.ts",
+ "ResourceOriginPlugin.test.ts",
+ "SourcesNavigator.test.ts",
+ "SourcesView.test.ts",
+ "TabbedEditorContainer.test.ts",
+ ]
+
+ deps = [
+ ":bundle",
+ "../../../test/unittests/front_end/helpers",
+ "../../core/platform:bundle",
+ "../../core/sdk:bundle",
+ "../../models/bindings:bundle",
+ "../../models/breakpoints:bundle",
+ "../../third_party/codemirror.next:bundle",
+ "../../ui/components/text_editor:bundle",
+ "../../ui/legacy/components/source_frame:bundle",
+ ]
+}
diff --git a/front_end/panels/sources/BreakpointEditDialog.test.ts b/front_end/panels/sources/BreakpointEditDialog.test.ts
new file mode 100644
index 0000000..7cdf9c0
--- /dev/null
+++ b/front_end/panels/sources/BreakpointEditDialog.test.ts
@@ -0,0 +1,128 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {dispatchKeyDownEvent, renderElementIntoDOM} from '../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {describeWithEnvironment} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import type * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js';
+
+import * as Sources from './sources.js';
+
+function setCodeMirrorContent(editor: CodeMirror.EditorView, content: string) {
+ editor.dispatch({
+ changes: {from: 0, to: editor.state.doc.length, insert: content},
+ });
+}
+
+function setBreakpointType(
+ dialog: Sources.BreakpointEditDialog.BreakpointEditDialog, newType: SDK.DebuggerModel.BreakpointType) {
+ const toolbar = dialog.contentElement.querySelector('.toolbar');
+ const selectElement = toolbar!.shadowRoot!.querySelector('select');
+ selectElement!.value = newType;
+ selectElement!.dispatchEvent(new Event('change'));
+}
+
+// Note that we currently don't install a fake RuntimeModel + ExecutionContext for these tests.
+// This means the 'BreakpointEditDialog' won't be able to check whether the
+// condition is a complete JavaScript expression or not and simply assume it is.
+describeWithEnvironment('BreakpointEditDialog', () => {
+ it('reports a committed condition when the Enter key is pressed', async () => {
+ const resultPromise = new Promise<Sources.BreakpointEditDialog.BreakpointEditDialogResult>(resolve => {
+ const dialog = new Sources.BreakpointEditDialog.BreakpointEditDialog(0, '', false, resolve);
+ const {editorForTest: {editor}} = dialog;
+ setCodeMirrorContent(editor, 'x === 5');
+
+ dispatchKeyDownEvent(editor.contentDOM, {key: 'Enter'});
+ });
+
+ const {committed, condition} = await resultPromise;
+ assert.isTrue(committed);
+ assert.strictEqual(condition, 'x === 5');
+ });
+
+ it('does not report a commited condition when the ESC key is pressed', async () => {
+ const resultPromise = new Promise<Sources.BreakpointEditDialog.BreakpointEditDialogResult>(resolve => {
+ const dialog = new Sources.BreakpointEditDialog.BreakpointEditDialog(0, '', false, resolve);
+ const {editorForTest: {editor}} = dialog;
+ setCodeMirrorContent(editor, 'hello');
+
+ dispatchKeyDownEvent(editor.contentDOM, {key: 'Escape'});
+ });
+
+ const {committed} = await resultPromise;
+ assert.isFalse(committed);
+ });
+
+ it('commits condition when close button is clicked', async () => {
+ const resultPromise = new Promise<Sources.BreakpointEditDialog.BreakpointEditDialogResult>(resolve => {
+ const dialog = new Sources.BreakpointEditDialog.BreakpointEditDialog(0, '', false, resolve);
+ const {editorForTest: {editor}} = dialog;
+ setCodeMirrorContent(editor, 'x === 5');
+
+ dialog.contentElement.querySelector('devtools-icon')!.click();
+ });
+
+ const {committed, condition} = await resultPromise;
+ assert.isTrue(committed);
+ assert.strictEqual(condition, 'x === 5');
+ });
+
+ it('leaves the condition as-is for logpoints', async () => {
+ const resultPromise = new Promise<Sources.BreakpointEditDialog.BreakpointEditDialogResult>(resolve => {
+ const dialog = new Sources.BreakpointEditDialog.BreakpointEditDialog(0, '', true, resolve);
+ const {editorForTest: {editor}} = dialog;
+ setCodeMirrorContent(editor, 'x');
+
+ dispatchKeyDownEvent(editor.contentDOM, {key: 'Enter'});
+ });
+
+ const {condition} = await resultPromise;
+ assert.strictEqual(condition, 'x');
+ });
+
+ it('result includes isLogpoint for logpoints', async () => {
+ const resultPromise = new Promise<Sources.BreakpointEditDialog.BreakpointEditDialogResult>(resolve => {
+ const dialog = new Sources.BreakpointEditDialog.BreakpointEditDialog(0, '', true, resolve);
+ const {editorForTest: {editor}} = dialog;
+ setCodeMirrorContent(editor, 'x');
+
+ dispatchKeyDownEvent(editor.contentDOM, {key: 'Enter'});
+ });
+
+ const {isLogpoint} = await resultPromise;
+ assert.isTrue(isLogpoint);
+ });
+
+ it('result includes isLogpoint for conditional breakpoints', async () => {
+ const resultPromise = new Promise<Sources.BreakpointEditDialog.BreakpointEditDialogResult>(resolve => {
+ const dialog = new Sources.BreakpointEditDialog.BreakpointEditDialog(0, '', false, resolve);
+ const {editorForTest: {editor}} = dialog;
+ setCodeMirrorContent(editor, 'x === 5');
+
+ dispatchKeyDownEvent(editor.contentDOM, {key: 'Enter'});
+ });
+
+ const {isLogpoint} = await resultPromise;
+ assert.isFalse(isLogpoint);
+ });
+
+ it('prefills the input with the old condition', async () => {
+ const dialog = new Sources.BreakpointEditDialog.BreakpointEditDialog(0, 'x === 42', false, () => {});
+ const {editorForTest: {editor}} = dialog;
+
+ assert.strictEqual(editor.state.doc.sliceString(0), 'x === 42');
+ });
+
+ it('focuses the editor input field after changing the breakpoint type', async () => {
+ const dialog = new Sources.BreakpointEditDialog.BreakpointEditDialog(0, '', false, () => {});
+ renderElementIntoDOM(dialog.contentElement);
+
+ setBreakpointType(dialog, SDK.DebuggerModel.BreakpointType.LOGPOINT);
+
+ const {editorForTest: {editor}} = dialog;
+ assert.isTrue(editor.hasFocus);
+
+ dialog.contentElement.remove(); // Cleanup.
+ });
+});
diff --git a/front_end/panels/sources/CSSPlugin.test.ts b/front_end/panels/sources/CSSPlugin.test.ts
new file mode 100644
index 0000000..7551e4b
--- /dev/null
+++ b/front_end/panels/sources/CSSPlugin.test.ts
@@ -0,0 +1,95 @@
+// 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 {createTarget} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import * as Common from '../../core/common/common.js';
+import type * 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 Workspace from '../../models/workspace/workspace.js';
+import * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js';
+import * as UI from '../../ui/legacy/legacy.js';
+
+import * as Sources from './sources.js';
+
+const {CSSPlugin} = Sources.CSSPlugin;
+
+describe('CSSPlugin', () => {
+ describe('accepts', () => {
+ it('holds true for documents', () => {
+ const uiSourceCode = sinon.createStubInstance(Workspace.UISourceCode.UISourceCode);
+ uiSourceCode.contentType.returns(Common.ResourceType.resourceTypes.Document);
+ assert.isTrue(CSSPlugin.accepts(uiSourceCode));
+ });
+
+ it('holds true for style sheets', () => {
+ const uiSourceCode = sinon.createStubInstance(Workspace.UISourceCode.UISourceCode);
+ uiSourceCode.contentType.returns(Common.ResourceType.resourceTypes.Stylesheet);
+ assert.isTrue(CSSPlugin.accepts(uiSourceCode));
+ });
+ });
+});
+
+describeWithMockConnection('CSSPlugin', () => {
+ const classNameCompletion = (targetFactory: () => SDK.Target.Target) => {
+ beforeEach(() => {
+ sinon.stub(UI.ShortcutRegistry.ShortcutRegistry, 'instance').returns({
+ shortcutTitleForAction: () => {},
+ shortcutsForAction: () => [],
+ getShortcutListener: () => {},
+ } as unknown as UI.ShortcutRegistry.ShortcutRegistry);
+ targetFactory();
+ });
+
+ type CompletionProvider = (cx: CodeMirror.CompletionContext) => Promise<CodeMirror.CompletionResult|null>;
+ type ExtensionOrFacetProvider = {value: {override: CompletionProvider[]}}|ExtensionOrFacetProvider[]|
+ CodeMirror.Extension;
+ function findAutocompletion(extensions: ExtensionOrFacetProvider): CompletionProvider|null {
+ if ('value' in extensions && extensions.value.override) {
+ return extensions.value.override[0] || null;
+ }
+ if ('length' in extensions) {
+ for (let i = 0; i < extensions.length; ++i) {
+ const result = findAutocompletion(extensions[i]);
+ if (result) {
+ return result;
+ }
+ }
+ }
+ return null;
+ }
+
+ it('suggests CSS class names from the stylesheet', async () => {
+ const URL = 'http://example.com/styles.css' as Platform.DevToolsPath.UrlString;
+ const uiSourceCode = sinon.createStubInstance(Workspace.UISourceCode.UISourceCode);
+ uiSourceCode.url.returns(URL);
+ const plugin = new CSSPlugin(uiSourceCode);
+ const autocompletion = findAutocompletion(plugin.editorExtension());
+ const FROM = 42;
+ sinon.stub(CodeMirror.Tree.prototype, 'resolveInner')
+ .returns({name: 'ClassName', from: FROM} as CodeMirror.SyntaxNode);
+ const STYLESHEET_ID = 'STYLESHEET_ID' as Protocol.CSS.StyleSheetId;
+ sinon.stub(SDK.CSSModel.CSSModel.prototype, 'getStyleSheetIdsForURL').withArgs(URL).returns([STYLESHEET_ID]);
+ const CLASS_NAMES = ['foo', 'bar', 'baz'];
+ sinon.stub(SDK.CSSModel.CSSModel.prototype, 'getClassNames').withArgs(STYLESHEET_ID).resolves(CLASS_NAMES);
+ const completionResult =
+ await autocompletion!({state: {field: () => {}}} as unknown as CodeMirror.CompletionContext);
+ assert.deepStrictEqual(completionResult, {
+ from: FROM,
+ options: [
+ {type: 'constant', label: CLASS_NAMES[0]},
+ {type: 'constant', label: CLASS_NAMES[1]},
+ {type: 'constant', label: CLASS_NAMES[2]},
+ ],
+ });
+ });
+ };
+ describe('class name completion without tab target', () => classNameCompletion(createTarget));
+ describe('class name completion with tab target', () => classNameCompletion(() => {
+ const tabTarget = createTarget({type: SDK.Target.Type.Tab});
+ createTarget({parentTarget: tabTarget, subtype: 'prerender'});
+ return createTarget({parentTarget: tabTarget});
+ }));
+});
diff --git a/front_end/panels/sources/CoveragePlugin.test.ts b/front_end/panels/sources/CoveragePlugin.test.ts
new file mode 100644
index 0000000..1ae9b4e
--- /dev/null
+++ b/front_end/panels/sources/CoveragePlugin.test.ts
@@ -0,0 +1,87 @@
+// 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 {createTarget} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import {createContentProviderUISourceCode} from '../../../test/unittests/front_end/helpers/UISourceCodeHelpers.js';
+import type * 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 Workspace from '../../models/workspace/workspace.js';
+import type * as SourceFrame from '../../ui/legacy/components/source_frame/source_frame.js';
+import * as Coverage from '../coverage/coverage.js';
+
+import * as Sources from './sources.js';
+
+describeWithMockConnection('CoveragePlugin', () => {
+ const tests = (targetFactory: () => SDK.Target.Target) => {
+ let target: SDK.Target.Target;
+ let uiSourceCode: Workspace.UISourceCode.UISourceCode;
+ let model: Coverage.CoverageModel.CoverageModel;
+ let coverageInfo: Coverage.CoverageModel.URLCoverageInfo;
+ const URL = 'test.js' as Platform.DevToolsPath.UrlString;
+
+ beforeEach(() => {
+ target = targetFactory();
+ const workspace = Workspace.Workspace.WorkspaceImpl.instance();
+ const targetManager = SDK.TargetManager.TargetManager.instance();
+ const resourceMapping = new Bindings.ResourceMapping.ResourceMapping(targetManager, workspace);
+ Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance({
+ forceNew: true,
+ resourceMapping,
+ targetManager,
+ });
+ Bindings.CSSWorkspaceBinding.CSSWorkspaceBinding.instance({
+ forceNew: true,
+ resourceMapping,
+ targetManager,
+ });
+
+ model = target.model(Coverage.CoverageModel.CoverageModel) as Coverage.CoverageModel.CoverageModel;
+ coverageInfo = new Coverage.CoverageModel.URLCoverageInfo(URL);
+ coverageInfo.addToSizes(9, 28);
+ sinon.stub(model, 'getCoverageForUrl').withArgs(URL).returns(coverageInfo);
+ ({uiSourceCode} = createContentProviderUISourceCode({url: URL, mimeType: 'text/javascript'}));
+ });
+
+ it('shows stats', async () => {
+ const coveragePlugin =
+ new Sources.CoveragePlugin.CoveragePlugin(uiSourceCode, <SourceFrame.SourceFrame.Transformer>{});
+ const [toolbarItem] = coveragePlugin.rightToolbarItems();
+ assert.strictEqual('Show Details', toolbarItem.element.title);
+ assert.strictEqual(
+ 'Coverage: 32.1%', toolbarItem.element.querySelector('.toolbar-text:not(.hidden)')?.textContent);
+ });
+
+ it('updates stats', async () => {
+ const coveragePlugin =
+ new Sources.CoveragePlugin.CoveragePlugin(uiSourceCode, <SourceFrame.SourceFrame.Transformer>{});
+ const [toolbarItem] = coveragePlugin.rightToolbarItems();
+ assert.strictEqual(
+ 'Coverage: 32.1%', toolbarItem.element.querySelector('.toolbar-text:not(.hidden)')?.textContent);
+
+ coverageInfo.addToSizes(10, 2);
+ assert.strictEqual(
+ 'Coverage: 63.3%', toolbarItem.element.querySelector('.toolbar-text:not(.hidden)')?.textContent);
+ });
+
+ it('resets stats', async () => {
+ const coveragePlugin =
+ new Sources.CoveragePlugin.CoveragePlugin(uiSourceCode, <SourceFrame.SourceFrame.Transformer>{});
+ const [toolbarItem] = coveragePlugin.rightToolbarItems();
+ assert.strictEqual(
+ 'Coverage: 32.1%', toolbarItem.element.querySelector('.toolbar-text:not(.hidden)')?.textContent);
+
+ model.dispatchEventToListeners(Coverage.CoverageModel.Events.CoverageReset);
+ assert.strictEqual('Click to show Coverage Panel', toolbarItem.element.title);
+ assert.strictEqual('Coverage: n/a', toolbarItem.element.querySelector('.toolbar-text:not(.hidden)')?.textContent);
+ });
+ };
+ describe('without tab taget', () => tests(() => createTarget()));
+ describe('with tab taget', () => tests(() => {
+ const tabTarget = createTarget({type: SDK.Target.Type.Tab});
+ createTarget({parentTarget: tabTarget, subtype: 'prerender'});
+ return createTarget({parentTarget: tabTarget});
+ }));
+});
diff --git a/front_end/panels/sources/DebuggerPausedMessage.test.ts b/front_end/panels/sources/DebuggerPausedMessage.test.ts
new file mode 100644
index 0000000..d035168
--- /dev/null
+++ b/front_end/panels/sources/DebuggerPausedMessage.test.ts
@@ -0,0 +1,138 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assertElement} from '../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {describeWithEnvironment} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import * as Protocol from '../../generated/protocol.js';
+import * as Bindings from '../../models/bindings/bindings.js';
+import * as Breakpoints from '../../models/breakpoints/breakpoints.js';
+import * as Workspace from '../../models/workspace/workspace.js';
+
+import * as Sources from './sources.js';
+
+describeWithEnvironment('DebuggerPausedMessage', () => {
+ let debuggerWorkspaceBinding: Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding;
+ let breakpointManager: Breakpoints.BreakpointManager.BreakpointManager;
+ let pausedMessage: Sources.DebuggerPausedMessage.DebuggerPausedMessage;
+
+ beforeEach(() => {
+ const workspace = Workspace.Workspace.WorkspaceImpl.instance();
+ const targetManager = SDK.TargetManager.TargetManager.instance();
+ const resourceMapping = new Bindings.ResourceMapping.ResourceMapping(targetManager, workspace);
+ debuggerWorkspaceBinding = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance({
+ forceNew: true,
+ resourceMapping,
+ targetManager,
+ });
+ breakpointManager = Breakpoints.BreakpointManager.BreakpointManager.instance({
+ forceNew: true,
+ targetManager,
+ workspace,
+ debuggerWorkspaceBinding,
+ });
+ pausedMessage = new Sources.DebuggerPausedMessage.DebuggerPausedMessage();
+ });
+
+ function getPausedMessageFromDOM(): {main: string, sub?: string} {
+ const mainElement = pausedMessage.element().shadowRoot?.querySelector('.status-main') ?? null;
+ assertElement(mainElement, HTMLDivElement);
+ const main = mainElement.textContent;
+ assertNotNullOrUndefined(main);
+ const sub = pausedMessage.element().shadowRoot?.querySelector('.status-sub')?.textContent ?? undefined;
+ return {main, sub};
+ }
+
+ describe('EventDetails pause', () => {
+ const testCases = [
+ {
+ title: 'shows no sub message if aux data is missing',
+ auxData: undefined,
+ expectedSub: undefined,
+ },
+ {
+ title: 'shows no sub message for unknown instrumentation breakpoints',
+ auxData: {
+ eventName: 'instrumentation:somethingrandom123',
+ },
+ expectedSub: undefined,
+ },
+ {
+ title: 'shows the fixed string for untranslated instrumentation breakpoints',
+ auxData: {
+ eventName: 'instrumentation:setTimeout',
+ },
+ expectedSub: 'setTimeout',
+ },
+ {
+ title: 'shows the translated string for translated instrumentation breakpoints',
+ auxData: {
+ eventName: 'instrumentation:requestAnimationFrame',
+ },
+ expectedSub: 'Request Animation Frame',
+ },
+ {
+ title: 'shows no sub message for unknown listener breakpoints',
+ auxData: {
+ eventName: 'listener:somethingrandom123',
+ },
+ expectedSub: undefined,
+ },
+ {
+ title: 'shows the "targetName" as a prefix for listener breakpoints',
+ auxData: {
+ eventName: 'listener:loadstart',
+ targetName: 'xmlhttprequest',
+ },
+ expectedSub: 'xmlhttprequest.loadstart',
+ },
+ {
+ title: 'shows the "targetName" as a prefix for "*" listener breakpoints',
+ auxData: {
+ eventName: 'listener:pointerover',
+ targetName: 'something-random-123',
+ },
+ expectedSub: 'something-random-123.pointerover',
+ },
+ {
+ title: 'extracts the hex code for WebGL errors',
+ auxData: {
+ eventName: 'instrumentation:webglErrorFired',
+ webglErrorName: 'something 0x42 something',
+ },
+ expectedSub: 'WebGL Error Fired (0x42)',
+ },
+ {
+ title: 'shows the WebGL error name',
+ auxData: {
+ eventName: 'instrumentation:webglErrorFired',
+ webglErrorName: 'something went wrong',
+ },
+ expectedSub: 'WebGL Error Fired (something went wrong)',
+ },
+ {
+ title: 'shows the CSP directive text for script blocked errors',
+ auxData: {
+ eventName: 'instrumentation:scriptBlockedByCSP',
+ directiveText: 'script-src "self"',
+ },
+ expectedSub: 'Script blocked due to Content Security Policy directive: script-src "self"',
+ },
+ ];
+
+ for (const {title, auxData, expectedSub} of testCases) {
+ it(title, async () => {
+ const details = new SDK.DebuggerModel.DebuggerPausedDetails(
+ sinon.createStubInstance(SDK.DebuggerModel.DebuggerModel),
+ /* callFrames */[], Protocol.Debugger.PausedEventReason.EventListener, auxData, /* breakpointIds */[]);
+ await pausedMessage.render(details, debuggerWorkspaceBinding, breakpointManager);
+
+ const {main, sub} = getPausedMessageFromDOM();
+ assert.strictEqual(main, 'Paused on event listener');
+ assert.strictEqual(sub, expectedSub);
+ });
+ }
+ });
+});
diff --git a/front_end/panels/sources/DebuggerPlugin.test.ts b/front_end/panels/sources/DebuggerPlugin.test.ts
new file mode 100644
index 0000000..a3850d5
--- /dev/null
+++ b/front_end/panels/sources/DebuggerPlugin.test.ts
@@ -0,0 +1,729 @@
+// 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 {createTarget, describeWithEnvironment} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import {MockProtocolBackend, parseScopeChain} from '../../../test/unittests/front_end/helpers/MockScopeChain.js';
+import type * as Platform from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import * as Protocol from '../../generated/protocol.js';
+import * as Bindings from '../../models/bindings/bindings.js';
+import * as TextUtils from '../../models/text_utils/text_utils.js';
+import * as Workspace from '../../models/workspace/workspace.js';
+import * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js';
+import * as TextEditor from '../../ui/components/text_editor/text_editor.js';
+
+import * as Sources from './sources.js';
+
+describeWithMockConnection('Inline variable view scope helpers', () => {
+ const URL = 'file:///tmp/example.js' as Platform.DevToolsPath.UrlString;
+ let target: SDK.Target.Target;
+ let backend: MockProtocolBackend;
+
+ beforeEach(() => {
+ const workspace = Workspace.Workspace.WorkspaceImpl.instance();
+ const targetManager = SDK.TargetManager.TargetManager.instance();
+ const resourceMapping = new Bindings.ResourceMapping.ResourceMapping(targetManager, workspace);
+ const debuggerWorkspaceBinding = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance({
+ forceNew: true,
+ resourceMapping,
+ targetManager,
+ });
+ Bindings.IgnoreListManager.IgnoreListManager.instance({forceNew: true, debuggerWorkspaceBinding});
+ target = createTarget();
+ backend = new MockProtocolBackend();
+ });
+
+ async function toOffsetWithSourceMap(
+ sourceMap: SDK.SourceMap.SourceMap|undefined, location: SDK.DebuggerModel.Location|null) {
+ if (!location || !sourceMap) {
+ return null;
+ }
+ const entry = sourceMap.findEntry(location.lineNumber, location.columnNumber);
+ if (!entry || !entry.sourceURL) {
+ return null;
+ }
+ const content = sourceMap.embeddedContentByURL(entry.sourceURL);
+ if (!content) {
+ return null;
+ }
+ const text = new TextUtils.Text.Text(content);
+ return text.offsetFromPosition(entry.sourceLineNumber, entry.sourceColumnNumber);
+ }
+
+ async function toOffset(source: string|null, location: SDK.DebuggerModel.Location|null) {
+ if (!location || !source) {
+ return null;
+ }
+ const text = new TextUtils.Text.Text(source);
+ return text.offsetFromPosition(location.lineNumber, location.columnNumber);
+ }
+
+ it('can resolve single scope mappings with source map', async () => {
+ const sourceMapUrl = 'file:///tmp/example.js.min.map';
+ // This example was minified with terser v5.7.0 with following command.
+ // 'terser index.js -m --toplevel -o example.min.js --source-map "url=example.min.js.map,includeSources"'
+ const source = `function o(o,n){console.log(o,n)}o(1,2);\n//# sourceMappingURL=${sourceMapUrl}`;
+ const scopes = ' { }';
+
+ // The original scopes below have to match with how the source map translates the scope, so it
+ // does not align perfectly with the source language scopes. In principle, this test could only
+ // assert that the tests are approximately correct; currently, we assert an exact match.
+ const originalSource = 'function unminified(par1, par2) {\n console.log(par1, par2);\n}\nunminified(1, 2);\n';
+ const originalScopes = ' { \n \n }';
+ const expectedOffsets = parseScopeChain(originalScopes);
+
+ const sourceMapContent = {
+ 'version': 3,
+ 'names': ['unminified', 'par1', 'par2', 'console', 'log'],
+ 'sources': ['index.js'],
+ 'sourcesContent': [originalSource],
+ 'mappings': 'AAAA,SAASA,EAAWC,EAAMC,GACxBC,QAAQC,IAAIH,EAAMC,EACpB,CACAF,EAAW,EAAG',
+ };
+ const sourceMapJson = JSON.stringify(sourceMapContent);
+
+ const scopeObject = backend.createSimpleRemoteObject([{name: 'o', value: 42}, {name: 'n', value: 1}]);
+ const callFrame = await backend.createCallFrame(
+ target, {url: URL, content: source}, scopes, {url: sourceMapUrl, content: sourceMapJson}, [scopeObject]);
+
+ // Get source map for mapping locations to 'editor' offsets.
+ const sourceMap = await callFrame.debuggerModel.sourceMapManager().sourceMapForClientPromise(callFrame.script);
+
+ const scopeMappings =
+ await Sources.DebuggerPlugin.computeScopeMappings(callFrame, l => toOffsetWithSourceMap(sourceMap, l));
+
+ const text = new TextUtils.Text.Text(originalSource);
+ assert.strictEqual(scopeMappings.length, 1);
+ assert.strictEqual(
+ scopeMappings[0].scopeStart,
+ text.offsetFromPosition(expectedOffsets[0].startLine, expectedOffsets[0].startColumn));
+ assert.strictEqual(
+ scopeMappings[0].scopeEnd, text.offsetFromPosition(expectedOffsets[0].endLine, expectedOffsets[0].endColumn));
+ assert.strictEqual(scopeMappings[0].variableMap.get('par1')?.value, 42);
+ assert.strictEqual(scopeMappings[0].variableMap.get('par2')?.value, 1);
+ });
+
+ it('can resolve nested scope mappings with source map', async () => {
+ const sourceMapUrl = 'file:///tmp/example.js.min.map';
+ // This example was minified with terser v5.7.0 with following command.
+ // 'terser index.js -m --toplevel -o example.min.js --source-map "url=example.min.js.map,includeSources"'
+ const source =
+ `function o(o){const n=console.log.bind(console);for(let c=0;c<o;c++)n(c)}o(10);\n//# sourceMappingURL=${
+ sourceMapUrl}`;
+ const scopes =
+ ' { < >} ';
+
+ const originalSource =
+ 'function f(n) {\n const c = console.log.bind(console);\n for (let i = 0; i < n; i++) c(i);\n}\nf(10);\n';
+ const originalScopes =
+ ' { \n \n < >\n }';
+ const expectedOffsets = parseScopeChain(originalScopes);
+
+ const sourceMapContent = {
+ 'version': 3,
+ 'names': ['f', 'n', 'c', 'console', 'log', 'bind', 'i'],
+ 'sources': ['index.js'],
+ 'sourcesContent': [originalSource],
+ 'mappings':
+ 'AAAA,SAASA,EAAEC,GACT,MAAMC,EAAIC,QAAQC,IAAIC,KAAKF,SAC3B,IAAK,IAAIG,EAAI,EAAGA,EAAIL,EAAGK,IAAKJ,EAAEI,EAChC,CACAN,EAAE',
+ };
+ const sourceMapJson = JSON.stringify(sourceMapContent);
+
+ const functionScopeObject = backend.createSimpleRemoteObject([{name: 'o', value: 10}, {name: 'n', value: 1234}]);
+ const forScopeObject = backend.createSimpleRemoteObject([{name: 'c', value: 5}]);
+
+ const callFrame = await backend.createCallFrame(
+ target, {url: URL, content: source}, scopes, {url: sourceMapUrl, content: sourceMapJson},
+ [forScopeObject, functionScopeObject]);
+
+ // Get source map for mapping locations to 'editor' offsets.
+ const sourceMap = await callFrame.debuggerModel.sourceMapManager().sourceMapForClientPromise(callFrame.script);
+
+ const scopeMappings =
+ await Sources.DebuggerPlugin.computeScopeMappings(callFrame, l => toOffsetWithSourceMap(sourceMap, l));
+
+ const text = new TextUtils.Text.Text(originalSource);
+ assert.strictEqual(scopeMappings.length, 2);
+ assert.strictEqual(
+ scopeMappings[0].scopeStart,
+ text.offsetFromPosition(expectedOffsets[0].startLine, expectedOffsets[0].startColumn));
+ assert.strictEqual(
+ scopeMappings[0].scopeEnd, text.offsetFromPosition(expectedOffsets[0].endLine, expectedOffsets[0].endColumn));
+ assert.strictEqual(scopeMappings[0].variableMap.get('i')?.value, 5);
+ assert.strictEqual(scopeMappings[0].variableMap.size, 1);
+ assert.strictEqual(
+ scopeMappings[1].scopeStart,
+ text.offsetFromPosition(expectedOffsets[1].startLine, expectedOffsets[1].startColumn));
+ assert.strictEqual(
+ scopeMappings[1].scopeEnd, text.offsetFromPosition(expectedOffsets[1].endLine, expectedOffsets[1].endColumn));
+ assert.strictEqual(scopeMappings[1].variableMap.get('n')?.value, 10);
+ assert.strictEqual(scopeMappings[1].variableMap.get('c')?.value, 1234);
+ assert.strictEqual(scopeMappings[1].variableMap.size, 2);
+ });
+
+ it('can resolve simple scope mappings', async () => {
+ const source = 'function f(a) { debugger } f(1)';
+ const scopes = ' { }';
+ const expectedOffsets = parseScopeChain(scopes);
+
+ const functionScopeObject = backend.createSimpleRemoteObject([{name: 'a', value: 1}]);
+
+ const callFrame =
+ await backend.createCallFrame(target, {url: URL, content: source}, scopes, null, [functionScopeObject]);
+
+ const scopeMappings = await Sources.DebuggerPlugin.computeScopeMappings(callFrame, l => toOffset(source, l));
+
+ assert.strictEqual(scopeMappings.length, 1);
+ assert.strictEqual(scopeMappings[0].scopeStart, expectedOffsets[0].startColumn);
+ assert.strictEqual(scopeMappings[0].scopeEnd, expectedOffsets[0].endColumn);
+ assert.strictEqual(scopeMappings[0].variableMap.get('a')?.value, 1);
+ assert.strictEqual(scopeMappings[0].variableMap.size, 1);
+ });
+
+ it('can resolve nested scope mappings for block with no variables', async () => {
+ const source = 'function f() { let a = 1; { debugger } } f()';
+ const scopes = ' { < > }';
+ const expectedOffsets = parseScopeChain(scopes);
+
+ const functionScopeObject = backend.createSimpleRemoteObject([{name: 'a', value: 1}]);
+ const blockScopeObject = backend.createSimpleRemoteObject([]);
+
+ const callFrame = await backend.createCallFrame(
+ target, {url: URL, content: source}, scopes, null, [blockScopeObject, functionScopeObject]);
+
+ const scopeMappings = await Sources.DebuggerPlugin.computeScopeMappings(callFrame, l => toOffset(source, l));
+
+ assert.strictEqual(scopeMappings.length, 2);
+ assert.strictEqual(scopeMappings[0].scopeStart, expectedOffsets[0].startColumn);
+ assert.strictEqual(scopeMappings[0].scopeEnd, expectedOffsets[0].endColumn);
+ assert.strictEqual(scopeMappings[0].variableMap.size, 0);
+ assert.strictEqual(scopeMappings[1].scopeStart, expectedOffsets[1].startColumn);
+ assert.strictEqual(scopeMappings[1].scopeEnd, expectedOffsets[1].endColumn);
+ assert.strictEqual(scopeMappings[1].variableMap.get('a')?.value, 1);
+ assert.strictEqual(scopeMappings[1].variableMap.size, 1);
+ });
+
+ it('can resolve nested scope mappings for function with no variables', async () => {
+ const source = 'function f() { console.log("Hi"); { let a = 1; debugger } } f()';
+ const scopes = ' { < > }';
+ const expectedOffsets = parseScopeChain(scopes);
+
+ const functionScopeObject = backend.createSimpleRemoteObject([]);
+ const blockScopeObject = backend.createSimpleRemoteObject([{name: 'a', value: 1}]);
+
+ const callFrame = await backend.createCallFrame(
+ target, {url: URL, content: source}, scopes, null, [blockScopeObject, functionScopeObject]);
+
+ const scopeMappings = await Sources.DebuggerPlugin.computeScopeMappings(callFrame, l => toOffset(source, l));
+
+ assert.strictEqual(scopeMappings.length, 2);
+ assert.strictEqual(scopeMappings[0].scopeStart, expectedOffsets[0].startColumn);
+ assert.strictEqual(scopeMappings[0].scopeEnd, expectedOffsets[0].endColumn);
+ assert.strictEqual(scopeMappings[0].variableMap.size, 1);
+ assert.strictEqual(scopeMappings[0].variableMap.get('a')?.value, 1);
+ assert.strictEqual(scopeMappings[1].scopeStart, expectedOffsets[1].startColumn);
+ assert.strictEqual(scopeMappings[1].scopeEnd, expectedOffsets[1].endColumn);
+ assert.strictEqual(scopeMappings[1].variableMap.size, 0);
+ });
+});
+
+function makeState(doc: string, extensions: CodeMirror.Extension = []) {
+ return CodeMirror.EditorState.create({
+ doc,
+ extensions: [
+ extensions,
+ TextEditor.Config.baseConfiguration(doc),
+ TextEditor.Config.autocompletion.instance(),
+ ],
+ });
+}
+
+describeWithEnvironment('Inline variable view parser', () => {
+ it('parses simple identifier', () => {
+ const state = makeState('c', CodeMirror.javascript.javascriptLanguage);
+ const variables = Sources.DebuggerPlugin.getVariableNamesByLine(state, 0, 1, 1);
+ assert.deepEqual(variables, [{line: 0, from: 0, id: 'c'}]);
+ });
+
+ it('parses simple function', () => {
+ const code = `function f(o) {
+ let a = 1;
+ debugger;
+ }`;
+ const state = makeState(code, CodeMirror.javascript.javascriptLanguage);
+ const variables = Sources.DebuggerPlugin.getVariableNamesByLine(state, 10, code.length, code.indexOf('debugger'));
+ assert.deepEqual(variables, [{line: 0, from: 11, id: 'o'}, {line: 1, from: 26, id: 'a'}]);
+ });
+
+ it('parses patterns', () => {
+ const code = `function f(o) {
+ let {x: a, y: [b, c]} = {x: o, y: [1, 2]};
+ console.log(a + b + c);
+ debugger;
+ }`;
+ const state = makeState(code, CodeMirror.javascript.javascriptLanguage);
+ const variables = Sources.DebuggerPlugin.getVariableNamesByLine(state, 10, code.length, code.indexOf('debugger'));
+ assert.deepEqual(variables, [
+ {line: 0, from: 11, id: 'o'},
+ {line: 1, from: 30, id: 'a'},
+ {line: 1, from: 37, id: 'b'},
+ {line: 1, from: 40, id: 'c'},
+ {line: 1, from: 50, id: 'o'},
+ {line: 2, from: 71, id: 'console'},
+ {line: 2, from: 83, id: 'a'},
+ {line: 2, from: 87, id: 'b'},
+ {line: 2, from: 91, id: 'c'},
+ ]);
+ });
+
+ it('parses function with nested block', () => {
+ const code = `function f(o) {
+ let a = 1;
+ {
+ let a = 2;
+ debugger;
+ }
+ }`;
+ const state = makeState(code, CodeMirror.javascript.javascriptLanguage);
+ const variables = Sources.DebuggerPlugin.getVariableNamesByLine(state, 10, code.length, code.indexOf('debugger'));
+ assert.deepEqual(
+ variables, [{line: 0, from: 11, id: 'o'}, {line: 1, from: 26, id: 'a'}, {line: 3, from: 53, id: 'a'}]);
+ });
+
+ it('parses function variable, ignores shadowing let in sibling block', () => {
+ const code = `function f(o) {
+ let a = 1;
+ {
+ let a = 2;
+ console.log(a);
+ }
+ debugger;
+ }`;
+ const state = makeState(code, CodeMirror.javascript.javascriptLanguage);
+ const variables = Sources.DebuggerPlugin.getVariableNamesByLine(state, 10, code.length, code.indexOf('debugger'));
+ assert.deepEqual(
+ variables, [{line: 0, from: 11, id: 'o'}, {line: 1, from: 26, id: 'a'}, {line: 4, from: 68, id: 'console'}]);
+ });
+
+ it('parses function variable, ignores shadowing const in sibling block', () => {
+ const code = `function f(o) {
+ let a = 1;
+ {
+ const a = 2;
+ console.log(a);
+ }
+ debugger;
+ }`;
+ const state = makeState(code, CodeMirror.javascript.javascriptLanguage);
+ const variables = Sources.DebuggerPlugin.getVariableNamesByLine(state, 10, code.length, code.indexOf('debugger'));
+ assert.deepEqual(
+ variables, [{line: 0, from: 11, id: 'o'}, {line: 1, from: 26, id: 'a'}, {line: 4, from: 70, id: 'console'}]);
+ });
+
+ it('parses function variable, ignores shadowing typed const in sibling block', () => {
+ const code = `function f(o) {
+ let a: number = 1;
+ {
+ const a: number = 2;
+ console.log(a);
+ }
+ debugger;
+ }`;
+ const state = makeState(code, CodeMirror.javascript.javascriptLanguage);
+ const variables = Sources.DebuggerPlugin.getVariableNamesByLine(state, 10, code.length, code.indexOf('debugger'));
+ assert.deepEqual(
+ variables, [{line: 0, from: 11, id: 'o'}, {line: 1, from: 26, id: 'a'}, {line: 4, from: 86, id: 'console'}]);
+ });
+
+ it('parses function variable, reports all vars', () => {
+ const code = `function f(o) {
+ var a = 1;
+ {
+ var a = 2;
+ console.log(a);
+ }
+ debugger;
+ }`;
+ const state = makeState(code, CodeMirror.javascript.javascriptLanguage);
+ const variables = Sources.DebuggerPlugin.getVariableNamesByLine(state, 10, code.length, code.indexOf('debugger'));
+ assert.deepEqual(variables, [
+ {line: 0, from: 11, id: 'o'},
+ {line: 1, from: 26, id: 'a'},
+ {line: 3, from: 53, id: 'a'},
+ {line: 4, from: 68, id: 'console'},
+ {line: 4, from: 80, id: 'a'},
+ ]);
+ });
+
+ it('parses function variable, handles shadowing in doubly nested scopes', () => {
+ const code = `function f() {
+ let a = 1;
+ let b = 2;
+ let c = 3;
+ {
+ let b;
+ {
+ const c = 4;
+ b = 5;
+ console.log(c);
+ }
+ console.log(c);
+ }
+ debugger;
+ }`;
+ const state = makeState(code, CodeMirror.javascript.javascriptLanguage);
+ const variables = Sources.DebuggerPlugin.getVariableNamesByLine(state, 10, code.length, code.indexOf('debugger'));
+ assert.deepEqual(variables, [
+ {line: 1, from: 25, id: 'a'},
+ {line: 2, from: 42, id: 'b'},
+ {line: 3, from: 59, id: 'c'},
+ {line: 9, from: 149, id: 'console'},
+ {line: 11, from: 183, id: 'console'},
+ {line: 11, from: 195, id: 'c'},
+ ]);
+ });
+
+ it('parses function variable, handles shadowing with object pattern', () => {
+ const code = `function f() {
+ let a = 1;
+ {
+ let {x: b, y: a} = {x: 1, y: 2};
+ console.log(a + b);
+ }
+ console.log(a);
+ debugger;
+ }`;
+ const state = makeState(code, CodeMirror.javascript.javascriptLanguage);
+ const variables = Sources.DebuggerPlugin.getVariableNamesByLine(state, 10, code.length, code.indexOf('debugger'));
+ assert.deepEqual(variables, [
+ {line: 1, from: 25, id: 'a'},
+ {line: 4, from: 89, id: 'console'},
+ {line: 6, from: 123, id: 'console'},
+ {line: 6, from: 135, id: 'a'},
+ ]);
+ });
+
+ it('parses function variable, handles shadowing with array pattern', () => {
+ const code = `function f() {
+ let a = 1;
+ {
+ const [b, a] = [1, 2];
+ console.log(a + b);
+ }
+ console.log(a);
+ debugger;
+ }`;
+ const state = makeState(code, CodeMirror.javascript.javascriptLanguage);
+ const variables = Sources.DebuggerPlugin.getVariableNamesByLine(state, 10, code.length, code.indexOf('debugger'));
+ assert.deepEqual(variables, [
+ {line: 1, from: 25, id: 'a'},
+ {line: 4, from: 79, id: 'console'},
+ {line: 6, from: 113, id: 'console'},
+ {line: 6, from: 125, id: 'a'},
+ ]);
+ });
+});
+
+describeWithEnvironment('Inline variable view scope value resolution', () => {
+ it('resolves single variable in single scope', () => {
+ const value42 = {type: Protocol.Runtime.RemoteObjectType.Number, value: 42} as SDK.RemoteObject.RemoteObject;
+ const scopeMappings = [{scopeStart: 0, scopeEnd: 10, variableMap: new Map([['a', value42]])}];
+ const variableNames = [{line: 3, from: 5, id: 'a'}];
+ const valuesByLine = Sources.DebuggerPlugin.getVariableValuesByLine(scopeMappings, variableNames);
+
+ assert.strictEqual(valuesByLine?.size, 1);
+ assert.strictEqual(valuesByLine?.get(3)?.size, 1);
+ assert.strictEqual(valuesByLine?.get(3)?.get('a')?.value, 42);
+ });
+
+ it('resolves shadowed variables', () => {
+ const value1 = {type: Protocol.Runtime.RemoteObjectType.Number, value: 1} as SDK.RemoteObject.RemoteObject;
+ const value2 = {type: Protocol.Runtime.RemoteObjectType.Number, value: 2} as SDK.RemoteObject.RemoteObject;
+ const scopeMappings = [
+ {scopeStart: 10, scopeEnd: 20, variableMap: new Map([['a', value1]])},
+ {scopeStart: 0, scopeEnd: 30, variableMap: new Map([['a', value2]])},
+ ];
+ const variableNames = [
+ {line: 0, from: 5, id: 'a'}, // Falls into the outer scope.
+ {line: 10, from: 15, id: 'a'}, // Inner scope.
+ {line: 20, from: 25, id: 'a'}, // Outer scope.
+ {line: 30, from: 35, id: 'a'}, // Outside of any scope.
+ ];
+ const valuesByLine = Sources.DebuggerPlugin.getVariableValuesByLine(scopeMappings, variableNames);
+
+ assert.strictEqual(valuesByLine?.size, 3);
+ assert.strictEqual(valuesByLine?.get(0)?.size, 1);
+ assert.strictEqual(valuesByLine?.get(0)?.get('a')?.value, 2);
+ assert.strictEqual(valuesByLine?.get(10)?.size, 1);
+ assert.strictEqual(valuesByLine?.get(10)?.get('a')?.value, 1);
+ assert.strictEqual(valuesByLine?.get(20)?.size, 1);
+ assert.strictEqual(valuesByLine?.get(20)?.get('a')?.value, 2);
+ });
+
+ it('resolves multiple variables on the same line', () => {
+ const value1 = {type: Protocol.Runtime.RemoteObjectType.Number, value: 1} as SDK.RemoteObject.RemoteObject;
+ const value2 = {type: Protocol.Runtime.RemoteObjectType.Number, value: 2} as SDK.RemoteObject.RemoteObject;
+ const scopeMappings = [{scopeStart: 10, scopeEnd: 20, variableMap: new Map([['a', value1], ['b', value2]])}];
+ const variableNames = [
+ {line: 10, from: 11, id: 'a'},
+ {line: 10, from: 13, id: 'b'},
+ {line: 10, from: 15, id: 'a'},
+ ];
+ const valuesByLine = Sources.DebuggerPlugin.getVariableValuesByLine(scopeMappings, variableNames);
+
+ assert.strictEqual(valuesByLine?.size, 1);
+ assert.strictEqual(valuesByLine?.get(10)?.size, 2);
+ assert.strictEqual(valuesByLine?.get(10)?.get('a')?.value, 1);
+ assert.strictEqual(valuesByLine?.get(10)?.get('b')?.value, 2);
+ });
+});
+
+describe('DebuggerPlugin', () => {
+ describe('computeExecutionDecorations', () => {
+ const {computeExecutionDecorations} = Sources.DebuggerPlugin;
+ const extensions = [CodeMirror.javascript.javascript()];
+
+ it('correctly returns no decorations when line is outside of the document', () => {
+ const doc = 'console.log("Hello World!");';
+ const state = CodeMirror.EditorState.create({doc, extensions});
+ const decorations = computeExecutionDecorations(state, 1, 0);
+ assert.strictEqual(decorations.size, 0, 'Expected to have no decorations');
+ });
+
+ it('correctly returns line and token decorations', () => {
+ const doc = 'function foo() {\n debugger;\n }';
+ const state = CodeMirror.EditorState.create({doc, extensions});
+ const decorations = computeExecutionDecorations(state, 1, 2);
+ assert.strictEqual(decorations.size, 2, 'Expected to have execution line and token decoration');
+ });
+
+ it('correctly returns line and token decorations even for long documents', () => {
+ const doc = 'console.log("Hello World!");\n'.repeat(10_000);
+ const state = CodeMirror.EditorState.create({doc, extensions});
+ const decorations = computeExecutionDecorations(state, 9_998, 0);
+ assert.strictEqual(decorations.size, 2, 'Expected to have execution line and token decoration');
+ });
+ });
+
+ describe('computePopoverHighlightRange', () => {
+ const {computePopoverHighlightRange} = Sources.DebuggerPlugin;
+
+ it('correctly returns highlight range depending on cursor position and selection', () => {
+ const doc = 'Hello World!';
+ const selection = CodeMirror.EditorSelection.create([
+ CodeMirror.EditorSelection.range(2, 5),
+ ]);
+ const state = CodeMirror.EditorState.create({doc, selection});
+ assert.isNull(computePopoverHighlightRange(state, 'text/plain', 0));
+ assert.deepInclude(computePopoverHighlightRange(state, 'text/plain', 2), {from: 2, to: 5});
+ assert.deepInclude(computePopoverHighlightRange(state, 'text/plain', 5), {from: 2, to: 5});
+ assert.isNull(computePopoverHighlightRange(state, 'text/plain', 10));
+ assert.isNull(computePopoverHighlightRange(state, 'text/plain', doc.length - 1));
+ });
+
+ describe('in JavaScript files', () => {
+ const extensions = [CodeMirror.javascript.javascript()];
+
+ it('correctly returns highlight range for member assignments', () => {
+ const doc = 'obj.foo = 42;';
+ const state = CodeMirror.EditorState.create({doc, extensions});
+
+ assert.deepInclude(computePopoverHighlightRange(state, 'text/javascript', 0), {from: 0, to: 3});
+ assert.deepInclude(computePopoverHighlightRange(state, 'text/javascript', 4), {from: 0, to: 7});
+ });
+
+ it('correctly returns highlight range for member assignments involving `this`', () => {
+ const doc = 'this.x = bar;';
+ const state = CodeMirror.EditorState.create({doc, extensions});
+
+ assert.deepInclude(computePopoverHighlightRange(state, 'text/javascript', 0), {from: 0, to: 4});
+ assert.deepInclude(computePopoverHighlightRange(state, 'text/javascript', 5), {from: 0, to: 6});
+ });
+
+ it('correctly reports function calls as potentially side-effecting', () => {
+ const doc = 'getRandomCoffee().name';
+ const state = CodeMirror.EditorState.create({doc, extensions});
+
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.indexOf('getRandomCoffee')),
+ {containsSideEffects: false},
+ );
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.lastIndexOf('.')),
+ {containsSideEffects: true},
+ );
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.indexOf('name')),
+ {containsSideEffects: true},
+ );
+ });
+
+ it('correctly reports method calls as potentially side-effecting', () => {
+ const doc = 'utils.getRandomCoffee().name';
+ const state = CodeMirror.EditorState.create({doc, extensions});
+
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.indexOf('utils')),
+ {containsSideEffects: false},
+ );
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.indexOf('getRandomCoffee')),
+ {containsSideEffects: false},
+ );
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.lastIndexOf('.')),
+ {containsSideEffects: true},
+ );
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.indexOf('name')),
+ {containsSideEffects: true},
+ );
+ });
+
+ it('correctly reports function calls in property accesses as potentially side-effecting', () => {
+ const doc = 'bar[foo()]';
+ const state = CodeMirror.EditorState.create({doc, extensions});
+
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.indexOf('bar')),
+ {containsSideEffects: false, from: 0, to: 'bar'.length},
+ );
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.indexOf('[')),
+ {containsSideEffects: true, from: 0, to: doc.length},
+ );
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.indexOf(']')),
+ {containsSideEffects: true, from: 0, to: doc.length},
+ );
+ });
+
+ it('correct reports postfix increments in property accesses as potentially side-effecting', () => {
+ const doc = 'a[i++]';
+ const state = CodeMirror.EditorState.create({doc, extensions});
+
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.indexOf('[')),
+ {containsSideEffects: true, from: 0, to: doc.length},
+ );
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.indexOf(']')),
+ {containsSideEffects: true, from: 0, to: doc.length},
+ );
+ });
+
+ it('correct reports postfix decrements in property accesses as potentially side-effecting', () => {
+ const doc = 'a[i--]';
+ const state = CodeMirror.EditorState.create({doc, extensions});
+
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.indexOf('[')),
+ {containsSideEffects: true, from: 0, to: doc.length},
+ );
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.indexOf(']')),
+ {containsSideEffects: true, from: 0, to: doc.length},
+ );
+ });
+
+ it('correct reports prefix increments in property accesses as potentially side-effecting', () => {
+ const doc = 'array[++index]';
+ const state = CodeMirror.EditorState.create({doc, extensions});
+
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.indexOf('[')),
+ {containsSideEffects: true, from: 0, to: doc.length},
+ );
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.indexOf(']')),
+ {containsSideEffects: true, from: 0, to: doc.length},
+ );
+ });
+
+ it('correct reports prefix decrements in property accesses as potentially side-effecting', () => {
+ const doc = 'array[--index]';
+ const state = CodeMirror.EditorState.create({doc, extensions});
+
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.indexOf('[')),
+ {containsSideEffects: true, from: 0, to: doc.length},
+ );
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.indexOf(']')),
+ {containsSideEffects: true, from: 0, to: doc.length},
+ );
+ });
+
+ it('correct reports assignment expressions in property accesses as potentially side-effecting', () => {
+ const doc = 'array[index *= 5]';
+ const state = CodeMirror.EditorState.create({doc, extensions});
+
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.indexOf('[')),
+ {containsSideEffects: true, from: 0, to: doc.length},
+ );
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/javascript', doc.indexOf(']')),
+ {containsSideEffects: true, from: 0, to: doc.length},
+ );
+ });
+ });
+
+ describe('in HTML files', () => {
+ it('correctly returns highlight range for variables in inline <script>s', () => {
+ const doc = `<!DOCTYPE html>
+<script type="text/javascript">
+globalThis.foo = bar + baz;
+</script>`;
+ const extensions = [CodeMirror.html.html()];
+ const state = CodeMirror.EditorState.create({doc, extensions});
+ for (const name of ['bar', 'baz']) {
+ const from = doc.indexOf(name);
+ const to = from + name.length;
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/html', from),
+ {from, to},
+ `did not correct highlight '${name}'`,
+ );
+ }
+ });
+
+ it('correctly returns highlight range for variables in inline event handlers', () => {
+ const doc = `<!DOCTYPE html>
+<button onclick="foo(bar, baz)">Click me!</button>`;
+ const extensions = [CodeMirror.html.html()];
+ const state = CodeMirror.EditorState.create({doc, extensions});
+ for (const name of ['foo', 'bar', 'baz']) {
+ const from = doc.indexOf(name);
+ const to = from + name.length;
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/html', from),
+ {from, to},
+ `did not correct highlight '${name}'`,
+ );
+ }
+ });
+ });
+
+ describe('in TSX files', () => {
+ it('correctly returns highlight range for field accesses', () => {
+ const doc = `function foo(obj: any): number {
+ return obj.x + obj.y;
+}`;
+ const extensions = [CodeMirror.javascript.tsxLanguage];
+ const state = CodeMirror.EditorState.create({doc, extensions});
+ for (const name of ['x', 'y']) {
+ const pos = doc.lastIndexOf(name);
+ const from = pos - 4;
+ const to = pos + name.length;
+ assert.deepInclude(
+ computePopoverHighlightRange(state, 'text/typescript-jsx', pos),
+ {from, to},
+ `did not correct highlight '${name}'`,
+ );
+ }
+ });
+ });
+ });
+});
diff --git a/front_end/panels/sources/FilePathScoreFunction.test.ts b/front_end/panels/sources/FilePathScoreFunction.test.ts
new file mode 100644
index 0000000..4a4ff2c
--- /dev/null
+++ b/front_end/panels/sources/FilePathScoreFunction.test.ts
@@ -0,0 +1,173 @@
+// Copyright 2020 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.
+
+const {assert} = chai;
+
+import * as Sources from './sources.js';
+
+describe('FilePathScoreFunction', () => {
+ describe('score', () => {
+ let filePathScoreFunction: Sources.FilePathScoreFunction.FilePathScoreFunction;
+
+ beforeEach(() => {
+ filePathScoreFunction = new Sources.FilePathScoreFunction.FilePathScoreFunction('App');
+ });
+
+ it('should prefer filename match over path match', () => {
+ const fileMatchScore = filePathScoreFunction.calculateScore('/path/to/App.js', null);
+ const pathMatchScore = filePathScoreFunction.calculateScore('/path/to/App/whatever', null);
+
+ assert.isTrue(fileMatchScore > pathMatchScore);
+ });
+
+ it('should prefer longer partial match', () => {
+ const longMatchScore = filePathScoreFunction.calculateScore('/path/to/App.js', null);
+ const shortMatchScore = filePathScoreFunction.calculateScore('/path/to/Ap.js', null);
+
+ assert.isTrue(shortMatchScore < longMatchScore);
+ });
+
+ it('should prefer consecutive match', () => {
+ const consecutiveMatchScore = filePathScoreFunction.calculateScore('/path/to/App.js', null);
+ const notConsecutiveMatchScore = filePathScoreFunction.calculateScore('path/to/A_p_p.js', null);
+
+ assert.isTrue(consecutiveMatchScore > notConsecutiveMatchScore);
+ });
+
+ it('should prefer path match at start', () => {
+ const pathStartScore = filePathScoreFunction.calculateScore('App/js/file.js', null);
+ const midPathMatchScore = filePathScoreFunction.calculateScore('public/App/js/file.js', null);
+
+ assert.isTrue(pathStartScore > midPathMatchScore);
+ });
+
+ it('should prefer match at word start', () => {
+ const wordStartMatchScore = filePathScoreFunction.calculateScore('/js/App.js', null);
+ const midWordMatchScore = filePathScoreFunction.calculateScore('/js/someApp.js', null);
+
+ assert.isTrue(wordStartMatchScore > midWordMatchScore);
+ });
+
+ it('should prefer caps match', () => {
+ const capsMatchScore = filePathScoreFunction.calculateScore('/js/App.js', null);
+ const noCapsMatchScore = filePathScoreFunction.calculateScore('/js/app.js', null);
+
+ assert.isTrue(capsMatchScore > noCapsMatchScore);
+ });
+
+ it('should prefer shorter path', () => {
+ const shortPathScore = filePathScoreFunction.calculateScore('path/App.js', null);
+ const longerPathScore = filePathScoreFunction.calculateScore('longer/path/App.js', null);
+
+ assert.isTrue(shortPathScore > longerPathScore);
+ });
+
+ it('should highlight matching filename, but not path', () => {
+ const highlightsFullMatch = new Array<number>();
+ const highlightsCamelCase = new Array<number>();
+ const highlightsDash = new Array<number>();
+ const highlightsUnderscore = new Array<number>();
+ const highlightsDot = new Array<number>();
+ const highlightsWhitespace = new Array<number>();
+
+ filePathScoreFunction.calculateScore('App/App.js', highlightsFullMatch);
+ filePathScoreFunction.calculateScore('App/MyApp.js', highlightsCamelCase);
+ filePathScoreFunction.calculateScore('App/My-App.js', highlightsDash);
+ filePathScoreFunction.calculateScore('App/My_App.js', highlightsUnderscore);
+ filePathScoreFunction.calculateScore('App/My.App.js', highlightsDot);
+ filePathScoreFunction.calculateScore('App/My App.js', highlightsWhitespace);
+
+ assert.deepEqual(highlightsFullMatch, [4, 5, 6]);
+ assert.deepEqual(highlightsCamelCase, [6, 7, 8]);
+ assert.deepEqual(highlightsDash, [7, 8, 9]);
+ assert.deepEqual(highlightsUnderscore, [7, 8, 9]);
+ assert.deepEqual(highlightsDot, [7, 8, 9]);
+ assert.deepEqual(highlightsWhitespace, [7, 8, 9]);
+ });
+
+ it('should highlight path when not matching filename', () => {
+ const highlightsConsecutive = new Array<number>();
+ const highlightsNonConsecutive = new Array<number>();
+
+ filePathScoreFunction.calculateScore('public/App/index.js', highlightsConsecutive);
+ filePathScoreFunction.calculateScore('public/A/p/p/index.js', highlightsNonConsecutive);
+
+ assert.deepEqual(highlightsConsecutive, [7, 8, 9]);
+ assert.deepEqual(highlightsNonConsecutive, [7, 9, 11]);
+ });
+
+ it('should highlight non consecutive match correctly', () => {
+ const highlights = new Array<number>();
+
+ filePathScoreFunction.calculateScore('path/A-wesome-pp.js', highlights);
+
+ assert.deepEqual(highlights, [5, 14, 15]);
+ });
+
+ it('should highlight full path match if filename only matches partially', () => {
+ const highlights = new Array<number>();
+
+ filePathScoreFunction.calculateScore('App/someapp.js', highlights);
+
+ assert.deepEqual(highlights, [0, 1, 2]);
+ });
+
+ it('should match highlights and score', () => {
+ // ported from third_party/blink/web_tests/http/tests/devtools/components/file-path-scoring.js
+ const testQueries = [
+ ['textepl', './Source/devtools/front_end/TextEditor.pl'],
+ ['defted', './Source/devtools/front_end/DefaultTextEditor.pl'],
+ ['CMTE', './Source/devtools/front_end/CodeMirrorTextEditor.pl'],
+ ['frocmte', './Source/devtools/front_end/CodeMirrorTextEditor.pl'],
+ ['cmtepl', './Source/devtools/front_end/CodeMirrorTextEditor.pl'],
+ ['setscr', './Source/devtools/front_end/SettingsScreen.pl'],
+ ['cssnfv', './Source/devtools/front_end/CSSNamedFlowView.pl'],
+ ['jssf', './Source/devtools/front_end/JavaScriptSourceFrame.pl'],
+ ['sofrapl', './Source/devtools/front_end/SourceFrame.pl'],
+ ['inspeins', './Source/core/inspector/InspectorInstrumentation.z'],
+ ['froscr', './Source/devtools/front_end/Script.pl'],
+ ['adscon', './Source/devtools/front_end/AdvancedSearchController.pl'],
+ ['execontext', 'execution_context/ExecutionContext.cpp'],
+ ];
+
+ const expectedResults = [
+ [[28, 29, 30, 31, 32, 39, 40], 46551],
+ [[28, 29, 30, 35, 39, 40], 34512],
+ [[28, 32, 38, 42], 28109],
+ [[18, 19, 20, 28, 32, 38, 42], 35533],
+ [[28, 32, 38, 42, 49, 50], 31437],
+ [[28, 29, 30, 36, 37, 38], 35283],
+ [[28, 29, 30, 31, 36, 40], 37841],
+ [[28, 32, 38, 44], 21964],
+ [[28, 29, 34, 35, 36, 40, 41], 37846],
+ [[24, 25, 26, 27, 28, 33, 34, 35], 52174],
+ [[18, 19, 20, 28, 29, 30], 33755],
+ [[28, 29, 36, 42, 43, 44], 33225],
+ [[18, 19, 20, 21, 28, 29, 30, 31, 32, 33], 64986],
+ ];
+
+ for (let i = 0; i < testQueries.length; ++i) {
+ const highlights = new Array<number>();
+ const filePathScoreFunction = new Sources.FilePathScoreFunction.FilePathScoreFunction(testQueries[i][0]);
+ const score = filePathScoreFunction.calculateScore(testQueries[i][1], highlights);
+ assert.strictEqual(score, expectedResults[i][1]);
+ assert.deepEqual(highlights, expectedResults[i][0]);
+ }
+ });
+
+ it('should return correct scores', () => {
+ // ported from third_party/blink/web_tests/http/tests/devtools/components/file-path-scoring.js
+ const filePathScoreFunction = new Sources.FilePathScoreFunction.FilePathScoreFunction('execontext');
+ const score = filePathScoreFunction.calculateScore('execution_context/ExecutionContext.cpp', null);
+
+ const lowerScores = [
+ filePathScoreFunction.calculateScore('testing/NullExecutionContext.cpp', null),
+ filePathScoreFunction.calculateScore('svg/SVGTextRunRenderingContext.cpp', null),
+ ];
+
+ assert.isTrue(score > lowerScores[0]);
+ assert.isTrue(score > lowerScores[1]);
+ });
+ });
+});
diff --git a/front_end/panels/sources/FilteredUISourceCodeListProvider.test.ts b/front_end/panels/sources/FilteredUISourceCodeListProvider.test.ts
new file mode 100644
index 0000000..e6b4978
--- /dev/null
+++ b/front_end/panels/sources/FilteredUISourceCodeListProvider.test.ts
@@ -0,0 +1,152 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Bindings from '../../models/bindings/bindings.js';
+import * as Common from '../../core/common/common.js';
+import type * as Platform from '../../core/platform/platform.js';
+import * as Workspace from '../../models/workspace/workspace.js';
+import * as Root from '../../core/root/root.js';
+import * as Sources from './sources.js';
+import {describeWithEnvironment} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {setUpEnvironment} from '../../../test/unittests/front_end/helpers/OverridesHelpers.js';
+
+const setUpEnvironmentWithUISourceCode =
+ (url: string, resourceType: Common.ResourceType.ResourceType, project?: Workspace.Workspace.Project) => {
+ const {workspace, debuggerWorkspaceBinding} = setUpEnvironment();
+ Bindings.IgnoreListManager.IgnoreListManager.instance({forceNew: false, debuggerWorkspaceBinding});
+
+ if (!project) {
+ project = {id: () => url, type: () => Workspace.Workspace.projectTypes.Network} as Workspace.Workspace.Project;
+ }
+
+ const uiSourceCode =
+ new Workspace.UISourceCode.UISourceCode(project, url as Platform.DevToolsPath.UrlString, resourceType);
+
+ project.uiSourceCodes = () => [uiSourceCode];
+
+ workspace.addProject(project);
+
+ return {workspace, project, uiSourceCode};
+ };
+
+describeWithEnvironment('FilteredUISourceCodeListProvider', () => {
+ before(() => {
+ Root.Runtime.experiments.register(Root.Runtime.ExperimentName.JUST_MY_CODE, '');
+ });
+
+ it('should exclude Fetch requests in the result', () => {
+ const url = 'http://www.example.com/list-fetch.json';
+ const resourceType = Common.ResourceType.resourceTypes.Fetch;
+
+ const {workspace, project} = setUpEnvironmentWithUISourceCode(url, resourceType);
+
+ const filteredUISourceCodeListProvider =
+ new Sources.FilteredUISourceCodeListProvider.FilteredUISourceCodeListProvider();
+ filteredUISourceCodeListProvider.attach();
+
+ const result = filteredUISourceCodeListProvider.itemCount();
+
+ workspace.removeProject(project);
+
+ assert.strictEqual(result, 0);
+ });
+
+ it('should exclude XHR requests in the result', () => {
+ const url = 'http://www.example.com/list-xhr.json';
+ const resourceType = Common.ResourceType.resourceTypes.XHR;
+
+ const {workspace, project} = setUpEnvironmentWithUISourceCode(url, resourceType);
+
+ const filteredUISourceCodeListProvider =
+ new Sources.FilteredUISourceCodeListProvider.FilteredUISourceCodeListProvider();
+ filteredUISourceCodeListProvider.attach();
+
+ const result = filteredUISourceCodeListProvider.itemCount();
+
+ workspace.removeProject(project);
+
+ assert.strictEqual(result, 0);
+ });
+
+ it('should include Document requests in the result', () => {
+ const url = 'http://www.example.com/index.html';
+ const resourceType = Common.ResourceType.resourceTypes.Document;
+
+ const {workspace, project} = setUpEnvironmentWithUISourceCode(url, resourceType);
+
+ const filteredUISourceCodeListProvider =
+ new Sources.FilteredUISourceCodeListProvider.FilteredUISourceCodeListProvider();
+ filteredUISourceCodeListProvider.attach();
+
+ const resultUrl = filteredUISourceCodeListProvider.itemKeyAt(0);
+ const resultCount = filteredUISourceCodeListProvider.itemCount();
+
+ workspace.removeProject(project);
+
+ assert.strictEqual(resultUrl, url);
+ assert.strictEqual(resultCount, 1);
+ });
+
+ it('should exclude ignored script requests in the result', () => {
+ const url = 'http://www.example.com/some-script.js';
+ const resourceType = Common.ResourceType.resourceTypes.Script;
+
+ const {workspace, project, uiSourceCode} = setUpEnvironmentWithUISourceCode(url, resourceType);
+
+ // ignore the uiSourceCode
+ Root.Runtime.experiments.setEnabled(Root.Runtime.ExperimentName.JUST_MY_CODE, true);
+ Bindings.IgnoreListManager.IgnoreListManager.instance().ignoreListUISourceCode(uiSourceCode);
+
+ const filteredUISourceCodeListProvider =
+ new Sources.FilteredUISourceCodeListProvider.FilteredUISourceCodeListProvider();
+ filteredUISourceCodeListProvider.attach();
+
+ const result = filteredUISourceCodeListProvider.itemCount();
+
+ workspace.removeProject(project);
+ Root.Runtime.experiments.setEnabled(Root.Runtime.ExperimentName.JUST_MY_CODE, false);
+
+ assert.strictEqual(result, 0);
+ });
+
+ it('should include Image requests in the result', () => {
+ const url = 'http://www.example.com/img.png';
+ const resourceType = Common.ResourceType.resourceTypes.Image;
+
+ const {workspace, project} = setUpEnvironmentWithUISourceCode(url, resourceType);
+
+ const filteredUISourceCodeListProvider =
+ new Sources.FilteredUISourceCodeListProvider.FilteredUISourceCodeListProvider();
+ filteredUISourceCodeListProvider.attach();
+
+ const resultUrl = filteredUISourceCodeListProvider.itemKeyAt(0);
+ const resultCount = filteredUISourceCodeListProvider.itemCount();
+
+ workspace.removeProject(project);
+
+ assert.strictEqual(resultCount, 1);
+ assert.strictEqual(resultUrl, url);
+ });
+
+ it('should include Script requests in the result', () => {
+ const url = 'http://www.example.com/some-script.js';
+ const resourceType = Common.ResourceType.resourceTypes.Script;
+
+ const {workspace, project} = setUpEnvironmentWithUISourceCode(url, resourceType);
+
+ const filteredUISourceCodeListProvider =
+ new Sources.FilteredUISourceCodeListProvider.FilteredUISourceCodeListProvider();
+ filteredUISourceCodeListProvider.attach();
+
+ const resultUrl = filteredUISourceCodeListProvider.itemKeyAt(0);
+ const resultCount = filteredUISourceCodeListProvider.itemCount();
+
+ workspace.removeProject(project);
+
+ assert.strictEqual(resultCount, 1);
+ assert.strictEqual(resultUrl, url);
+ });
+});
diff --git a/front_end/panels/sources/NavigatorView.test.ts b/front_end/panels/sources/NavigatorView.test.ts
new file mode 100644
index 0000000..fd88ccb
--- /dev/null
+++ b/front_end/panels/sources/NavigatorView.test.ts
@@ -0,0 +1,105 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {createTarget} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import * as Common from '../../core/common/common.js';
+import type * as Platform from '../../core/platform/platform.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import * as Root from '../../core/root/root.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 Breakpoints from '../../models/breakpoints/breakpoints.js';
+import * as Persistence from '../../models/persistence/persistence.js';
+import * as TextUtils from '../../models/text_utils/text_utils.js';
+import * as Workspace from '../../models/workspace/workspace.js';
+import * as UI from '../../ui/legacy/legacy.js';
+
+import * as Sources from './sources.js';
+
+const {assert} = chai;
+
+import {
+ describeWithMockConnection,
+ setMockConnectionResponseHandler,
+} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+
+describeWithMockConnection('NavigatorView', () => {
+ let target: SDK.Target.Target;
+ let workspace: Workspace.Workspace.WorkspaceImpl;
+
+ beforeEach(() => {
+ Root.Runtime.experiments.register(Root.Runtime.ExperimentName.AUTHORED_DEPLOYED_GROUPING, '');
+ Root.Runtime.experiments.register(Root.Runtime.ExperimentName.JUST_MY_CODE, '');
+
+ setMockConnectionResponseHandler('Page.getResourceTree', async () => {
+ return {
+ frameTree: null,
+ };
+ });
+
+ const actionRegistryInstance = UI.ActionRegistry.ActionRegistry.instance({forceNew: true});
+ UI.ShortcutRegistry.ShortcutRegistry.instance({forceNew: true, actionRegistry: actionRegistryInstance});
+ target = createTarget();
+ const targetManager = target.targetManager();
+ targetManager.setScopeTarget(target);
+ workspace = Workspace.Workspace.WorkspaceImpl.instance();
+ const resourceMapping = new Bindings.ResourceMapping.ResourceMapping(targetManager, workspace);
+ Bindings.CSSWorkspaceBinding.CSSWorkspaceBinding.instance({forceNew: true, resourceMapping, targetManager});
+ const debuggerWorkspaceBinding = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance({
+ forceNew: true,
+ resourceMapping,
+ targetManager,
+ });
+ Bindings.IgnoreListManager.IgnoreListManager.instance({forceNew: true, debuggerWorkspaceBinding});
+ const breakpointManager = Breakpoints.BreakpointManager.BreakpointManager.instance(
+ {forceNew: true, targetManager, workspace, debuggerWorkspaceBinding});
+ Persistence.Persistence.PersistenceImpl.instance({forceNew: true, workspace, breakpointManager});
+ Persistence.NetworkPersistenceManager.NetworkPersistenceManager.instance({forceNew: true, workspace});
+ });
+
+ function addResourceAndUISourceCode(
+ url: Platform.DevToolsPath.UrlString, frame: SDK.ResourceTreeModel.ResourceTreeFrame, content: string,
+ mimeType: string, resourceTreeModel: SDK.ResourceTreeModel.ResourceTreeModel) {
+ frame.addResource(new SDK.Resource.Resource(
+ resourceTreeModel, null, url, url, frame.id, null, Common.ResourceType.resourceTypes.Document, 'text/html',
+ null, null));
+ const uiSourceCode = workspace.uiSourceCodeForURL(url) as Workspace.UISourceCode.UISourceCode;
+
+ const projectType = Workspace.Workspace.projectTypes.Network;
+ const project = new Bindings.ContentProviderBasedProject.ContentProviderBasedProject(
+ workspace, 'PROJECT_ID', projectType, 'Test project', false /* isServiceProject*/);
+ Bindings.NetworkProject.NetworkProject.setTargetForProject(project, target);
+ const contentProvider = TextUtils.StaticContentProvider.StaticContentProvider.fromString(
+ url, Common.ResourceType.ResourceType.fromMimeType(mimeType), content);
+ const metadata = new Workspace.UISourceCode.UISourceCodeMetadata(null, null);
+ project.addUISourceCodeWithProvider(uiSourceCode, contentProvider, metadata, mimeType);
+ return {project};
+ }
+
+ it('can discard multiple childless frames', async () => {
+ const url = 'http://example.com/index.html' as Platform.DevToolsPath.UrlString;
+ const mainFrameId = 'main' as Protocol.Page.FrameId;
+ const childFrameId = 'child' as Protocol.Page.FrameId;
+
+ const resourceTreeModel =
+ target.model(SDK.ResourceTreeModel.ResourceTreeModel) as SDK.ResourceTreeModel.ResourceTreeModel;
+ await resourceTreeModel.once(SDK.ResourceTreeModel.Events.CachedResourcesLoaded);
+ resourceTreeModel.frameAttached(mainFrameId, null);
+ const childFrame = resourceTreeModel.frameAttached(childFrameId, mainFrameId);
+ assertNotNullOrUndefined(childFrame);
+ const {project} = addResourceAndUISourceCode(url, childFrame, '', 'text/html', resourceTreeModel);
+
+ const navigatorView = Sources.SourcesNavigator.NetworkNavigatorView.instance({forceNew: true});
+ const children = navigatorView.scriptsTree.rootElement().children();
+ assert.strictEqual(children.length, 1, 'The NavigatorView root node should have 1 child before node removal');
+ assert.strictEqual(children[0].title, 'top');
+
+ // Remove leaf node and assert that node removal propagates up to the root node.
+ project.removeUISourceCode(url);
+ assert.strictEqual(
+ navigatorView.scriptsTree.rootElement().children().length, 0,
+ 'The NavigarorView root node should not have any children after node removal');
+ });
+});
diff --git a/front_end/panels/sources/OutlineQuickOpen.test.ts b/front_end/panels/sources/OutlineQuickOpen.test.ts
new file mode 100644
index 0000000..5691298
--- /dev/null
+++ b/front_end/panels/sources/OutlineQuickOpen.test.ts
@@ -0,0 +1,1056 @@
+// 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.
+
+const {assert} = chai;
+
+import * as Sources from './sources.js';
+import * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js';
+import * as UI from '../../ui/legacy/legacy.js';
+
+describe('OutlineQuickOpen', () => {
+ describe('generates a correct JavaScript outline', () => {
+ function javaScriptOutline(doc: string) {
+ const extensions = [CodeMirror.javascript.javascript()];
+ const state = CodeMirror.EditorState.create({doc, extensions});
+ return Sources.OutlineQuickOpen.outline(state);
+ }
+
+ it('for empty scripts', () => {
+ assert.isEmpty(javaScriptOutline(''));
+ });
+
+ it('for simple function statements', () => {
+ assert.deepEqual(
+ javaScriptOutline('function f() {}'),
+ [
+ {title: 'f', subtitle: '()', lineNumber: 0, columnNumber: 9},
+ ],
+ );
+ assert.deepEqual(
+ javaScriptOutline('function func(param) { return param; }'),
+ [
+ {title: 'func', subtitle: '(param)', lineNumber: 0, columnNumber: 9},
+ ],
+ );
+ assert.deepEqual(
+ javaScriptOutline('function foo(a, b, c) {}'),
+ [
+ {title: 'foo', subtitle: '(a, b, c)', lineNumber: 0, columnNumber: 9},
+ ],
+ );
+ });
+
+ it('for function statements with rest arguments', () => {
+ assert.deepEqual(
+ javaScriptOutline('function func(...rest) {}'),
+ [
+ {title: 'func', subtitle: '(...rest)', lineNumber: 0, columnNumber: 9},
+ ],
+ );
+ assert.deepEqual(
+ javaScriptOutline('function foo(a, b, ...c) {}'),
+ [
+ {title: 'foo', subtitle: '(a, b, ...c)', lineNumber: 0, columnNumber: 9},
+ ],
+ );
+ });
+
+ it('for function statements with pattern parameters', () => {
+ assert.deepEqual(
+ javaScriptOutline(
+ 'function foo({a, b}, c) { return a + b; }\n' +
+ 'function bar(a, [b, [c]]) { return a+b; }'),
+ [
+ {title: 'foo', subtitle: '({‥}, c)', lineNumber: 0, columnNumber: 9},
+ {title: 'bar', subtitle: '(a, [‥])', lineNumber: 1, columnNumber: 9},
+ ],
+ );
+ });
+
+ it('for nested function statements', () => {
+ assert.deepEqual(
+ javaScriptOutline('function foo(){ function bar() {} function baz(a,b ,c) { }}'),
+ [
+ {title: 'foo', subtitle: '()', lineNumber: 0, columnNumber: 9},
+ {title: 'bar', subtitle: '()', lineNumber: 0, columnNumber: 25},
+ {title: 'baz', subtitle: '(a, b, c)', lineNumber: 0, columnNumber: 43},
+ ],
+ );
+ });
+
+ it('for async function statements', () => {
+ assert.deepEqual(
+ javaScriptOutline(
+ 'async function foo() { };\n' +
+ 'async function sum(x, y) { return x + y; }'),
+ [
+ {title: 'async foo', subtitle: '()', lineNumber: 0, columnNumber: 15},
+ {title: 'async sum', subtitle: '(x, y)', lineNumber: 1, columnNumber: 15},
+ ],
+ );
+ });
+
+ it('for generator function statements', () => {
+ assert.deepEqual(
+ javaScriptOutline(
+ 'function* foo() { }\n' +
+ 'async function* bar(a,b){}'),
+ [
+ {title: '*foo', subtitle: '()', lineNumber: 0, columnNumber: 10},
+ {title: 'async *bar', subtitle: '(a, b)', lineNumber: 1, columnNumber: 16},
+ ],
+ );
+ });
+
+ it('for function expressions in variable declarations', () => {
+ assert.deepEqual(
+ javaScriptOutline('const a = function(a,b) { }, b = function bar(c,d) { }'),
+ [
+ {title: 'a', subtitle: '(a, b)', lineNumber: 0, columnNumber: 6},
+ {title: 'b', subtitle: '(c, d)', lineNumber: 0, columnNumber: 29},
+ ],
+ );
+ assert.deepEqual(
+ javaScriptOutline('let a = function(a,b) { }, b = function bar(c,d) { }'),
+ [
+ {title: 'a', subtitle: '(a, b)', lineNumber: 0, columnNumber: 4},
+ {title: 'b', subtitle: '(c, d)', lineNumber: 0, columnNumber: 27},
+ ],
+ );
+ assert.deepEqual(
+ javaScriptOutline('var a = function(a,b) { }, b = function bar(c,d) { }'),
+ [
+ {title: 'a', subtitle: '(a, b)', lineNumber: 0, columnNumber: 4},
+ {title: 'b', subtitle: '(c, d)', lineNumber: 0, columnNumber: 27},
+ ],
+ );
+ });
+
+ it('for function expressions in property assignments', () => {
+ assert.deepEqual(
+ javaScriptOutline(
+ 'a.b.c = function(d, e) { };\n' +
+ 'a.b[c] = function() { };\n' +
+ 'a.b[c].d = function() { };\n' +
+ '(a || b).c = function() { };\n'),
+ [
+ {title: 'c', subtitle: '(d, e)', lineNumber: 0, columnNumber: 4},
+ {title: 'd', subtitle: '()', lineNumber: 2, columnNumber: 7},
+ {title: 'c', subtitle: '()', lineNumber: 3, columnNumber: 9},
+ ],
+ );
+ });
+
+ it('for function expressions in object literals', () => {
+ assert.deepEqual(
+ javaScriptOutline(
+ 'x = { run: function() { }, get count() { }, set count(value) { }};\n' +
+ 'var foo = { "bar": function() { }};\n' +
+ 'var foo = { 42: function() { }}\n'),
+ [
+ {title: 'run', subtitle: '()', lineNumber: 0, columnNumber: 6},
+ {title: 'get count', subtitle: '()', lineNumber: 0, columnNumber: 31},
+ {title: 'set count', subtitle: '(value)', lineNumber: 0, columnNumber: 48},
+ ],
+ );
+ });
+
+ it('for arrow functions in variable declarations', () => {
+ assert.deepEqual(
+ javaScriptOutline(
+ 'var a = x => x + 2;\n' +
+ 'var b = (x, y) => x + y'),
+ [
+ {title: 'a', subtitle: '(x)', lineNumber: 0, columnNumber: 4},
+ {title: 'b', subtitle: '(x, y)', lineNumber: 1, columnNumber: 4},
+ ],
+ );
+ assert.deepEqual(
+ javaScriptOutline(
+ 'let x = (a,b) => a + b, y = a => { return a; };\n' +
+ 'const z = x => x'),
+ [
+ {title: 'x', subtitle: '(a, b)', lineNumber: 0, columnNumber: 4},
+ {title: 'y', subtitle: '(a)', lineNumber: 0, columnNumber: 24},
+ {title: 'z', subtitle: '(x)', lineNumber: 1, columnNumber: 6},
+ ],
+ );
+ });
+
+ it('for arrow functions in property assignments', () => {
+ assert.deepEqual(
+ javaScriptOutline(
+ 'a.b.c = (d, e) => d + e;\n' +
+ 'a.b[c] = () => { };\n' +
+ 'a.b[c].d = () => { };\n' +
+ '(a || b).c = () => { };\n'),
+ [
+ {title: 'c', subtitle: '(d, e)', lineNumber: 0, columnNumber: 4},
+ {title: 'd', subtitle: '()', lineNumber: 2, columnNumber: 7},
+ {title: 'c', subtitle: '()', lineNumber: 3, columnNumber: 9},
+ ],
+ );
+ });
+
+ it('for arrow functions in object literals', () => {
+ assert.deepEqual(
+ javaScriptOutline(
+ 'const object = {\n' +
+ ' foo: x => x,\n' +
+ ' bar: (a, b) => { return a + b };\n' +
+ '};'),
+ [
+ {title: 'foo', subtitle: '(x)', lineNumber: 1, columnNumber: 2},
+ {title: 'bar', subtitle: '(a, b)', lineNumber: 2, columnNumber: 2},
+ ],
+ );
+ });
+
+ it('for async function expressions', () => {
+ assert.deepEqual(
+ javaScriptOutline(
+ 'const foo = async function() { };\n' +
+ 'var sum = async (x, y) => x + y;'),
+ [
+ {title: 'async foo', subtitle: '()', lineNumber: 0, columnNumber: 6},
+ {title: 'async sum', subtitle: '(x, y)', lineNumber: 1, columnNumber: 4},
+ ],
+ );
+ assert.deepEqual(
+ javaScriptOutline('obj.foo = async function() { return this; }'),
+ [
+ {title: 'async foo', subtitle: '()', lineNumber: 0, columnNumber: 4},
+ ],
+ );
+ assert.deepEqual(
+ javaScriptOutline(
+ '({\n' +
+ ' async foo(x) { },\n' +
+ ' async get x() { },\n' +
+ ' async set x(x) { },\n' +
+ ' bar: async function() {},\n' +
+ ' })'),
+ [
+ {title: 'async foo', subtitle: '(x)', lineNumber: 1, columnNumber: 8},
+ {title: 'async get x', subtitle: '()', lineNumber: 2, columnNumber: 12},
+ {title: 'async set x', subtitle: '(x)', lineNumber: 3, columnNumber: 12},
+ {title: 'async bar', subtitle: '()', lineNumber: 4, columnNumber: 2},
+ ],
+ );
+ });
+
+ it('for generator function expressions', () => {
+ assert.deepEqual(
+ javaScriptOutline(
+ 'const foo = function*(x) { }\n' +
+ 'var bar = async function*() {}'),
+ [
+ {title: '*foo', subtitle: '(x)', lineNumber: 0, columnNumber: 6},
+ {title: 'async *bar', subtitle: '()', lineNumber: 1, columnNumber: 4},
+ ],
+ );
+ assert.deepEqual(
+ javaScriptOutline(
+ 'const object = { foo: function*(x) { } };\n' +
+ '({ *bar() {}, async *baz() {} })'),
+ [
+ {title: '*foo', subtitle: '(x)', lineNumber: 0, columnNumber: 17},
+ {title: '*bar', subtitle: '()', lineNumber: 1, columnNumber: 4},
+ {title: 'async *baz', subtitle: '()', lineNumber: 1, columnNumber: 21},
+ ],
+ );
+ });
+
+ it('for class statements', () => {
+ assert.deepEqual(
+ javaScriptOutline('class C {}'),
+ [
+ {title: 'class C', lineNumber: 0, columnNumber: 6},
+ ],
+ );
+ assert.deepEqual(
+ javaScriptOutline('class MyAwesomeClass extends C {}'),
+ [
+ {title: 'class MyAwesomeClass', lineNumber: 0, columnNumber: 6},
+ ],
+ );
+ });
+
+ it('for class expressions in variable declarations', () => {
+ assert.deepEqual(
+ javaScriptOutline(
+ 'const C = class C {};\n' +
+ 'let A = class extends C {};'),
+ [
+ {title: 'class C', lineNumber: 0, columnNumber: 6},
+ {title: 'class A', lineNumber: 1, columnNumber: 4},
+ ],
+ );
+ });
+
+ it('for class expressions in property assignments', () => {
+ assert.deepEqual(
+ javaScriptOutline('a.b.c = class klass { };'),
+ [{title: 'class c', lineNumber: 0, columnNumber: 4}],
+ );
+ });
+
+ it('for class expressions in object literals', () => {
+ assert.deepEqual(
+ javaScriptOutline('const object = { klass: class { } }'),
+ [{title: 'class klass', lineNumber: 0, columnNumber: 17}],
+ );
+ });
+
+ it('for class constructors', () => {
+ assert.deepEqual(
+ javaScriptOutline('class Test { constructor(foo, bar) { }}'),
+ [
+ {title: 'class Test', lineNumber: 0, columnNumber: 6},
+ {title: 'constructor', subtitle: '(foo, bar)', lineNumber: 0, columnNumber: 13},
+ ],
+ );
+ });
+
+ it('for class methods', () => {
+ assert.deepEqual(
+ javaScriptOutline(
+ 'class Test { foo() {} static bar() { }};\n' +
+ '(class { baz() {} });'),
+ [
+ {title: 'class Test', lineNumber: 0, columnNumber: 6},
+ {title: 'foo', subtitle: '()', lineNumber: 0, columnNumber: 13},
+ {title: 'static bar', subtitle: '()', lineNumber: 0, columnNumber: 29},
+ {title: 'baz', subtitle: '()', lineNumber: 1, columnNumber: 9},
+ ],
+ );
+ assert.deepEqual(
+ javaScriptOutline(
+ 'class A {\n' +
+ ' get x() { return 1; }\n' +
+ ' set x(x) {}\n' +
+ ' async foo(){}\n' +
+ ' *bar() {}\n' +
+ ' async*baz() {}\n' +
+ ' static async foo(){}\n' +
+ '}'),
+ [
+ {title: 'class A', lineNumber: 0, columnNumber: 6},
+ {title: 'get x', subtitle: '()', lineNumber: 1, columnNumber: 6},
+ {title: 'set x', subtitle: '(x)', lineNumber: 2, columnNumber: 6},
+ {title: 'async foo', subtitle: '()', lineNumber: 3, columnNumber: 8},
+ {title: '*bar', subtitle: '()', lineNumber: 4, columnNumber: 3},
+ {title: 'async *baz', subtitle: '()', lineNumber: 5, columnNumber: 8},
+ {title: 'static async foo', subtitle: '()', lineNumber: 6, columnNumber: 15},
+ ],
+ );
+ });
+
+ it('for private methods', () => {
+ assert.deepEqual(
+ javaScriptOutline(
+ 'class A {\n' +
+ ' private #foo() {}\n' +
+ ' public static #bar(x) {}\n' +
+ ' protected async #baz(){}\n' +
+ '}'),
+ [
+ {title: 'class A', lineNumber: 0, columnNumber: 6},
+ {title: '#foo', subtitle: '()', lineNumber: 1, columnNumber: 10},
+ {title: 'static #bar', subtitle: '(x)', lineNumber: 2, columnNumber: 16},
+ {title: 'async #baz', subtitle: '()', lineNumber: 3, columnNumber: 18},
+ ],
+ );
+ });
+
+ it('even in the presence of syntax errors', () => {
+ assert.deepEqual(
+ javaScriptOutline(`
+function foo(a, b) {
+ if (a > b) {
+ return a;
+}
+
+function bar(eee) {
+ yield foo(eee, 2 * eee);
+}`),
+ [
+ {title: 'foo', subtitle: '(a, b)', lineNumber: 1, columnNumber: 9},
+ {title: 'bar', subtitle: '(eee)', lineNumber: 6, columnNumber: 9},
+ ],
+ );
+ });
+
+ it('for ES5-style class definitions', () => {
+ assert.deepEqual(
+ javaScriptOutline(`var Klass = (function(_super) {
+ function Klass() {
+ _super.apply(this, arguments);
+ }
+
+ Klass.prototype.initialize = function(x, y) {
+ this.x = x;
+ this.y = y;
+ }
+
+ return Klass;
+})(BaseKlass);
+`),
+ [
+ {title: 'Klass', subtitle: '()', lineNumber: 1, columnNumber: 11},
+ {title: 'initialize', subtitle: '(x, y)', lineNumber: 5, columnNumber: 18},
+ ],
+ );
+ });
+ });
+
+ describe('generates a correct JSX outline', () => {
+ function jsxOutline(doc: string) {
+ const extensions = [CodeMirror.javascript.javascript({jsx: true})];
+ const state = CodeMirror.EditorState.create({doc, extensions});
+ return Sources.OutlineQuickOpen.outline(state);
+ }
+
+ it('for an empty script', () => {
+ assert.deepEqual(jsxOutline(''), []);
+ });
+
+ it('for a simple hello world template', () => {
+ assert.deepEqual(
+ jsxOutline(`
+function getGreeting(user) {
+ if (user) {
+ return <h1>Hello, {formatName(user)}!</h1>;
+ }
+ return <h1>Hello, Stranger.</h1>;
+}
+
+const formatName = (name) => {
+ return <blink>{name}</blink>;
+}`),
+ [
+ {title: 'getGreeting', subtitle: '(user)', lineNumber: 1, columnNumber: 9},
+ {title: 'formatName', subtitle: '(name)', lineNumber: 8, columnNumber: 6},
+ ],
+ );
+ });
+ });
+
+ describe('generates a correct TypeScript outline', () => {
+ function typeScriptOutline(doc: string) {
+ const extensions = [CodeMirror.javascript.javascript({typescript: true})];
+ const state = CodeMirror.EditorState.create({doc, extensions});
+ return Sources.OutlineQuickOpen.outline(state);
+ }
+
+ it('for an empty script', () => {
+ assert.deepEqual(typeScriptOutline(''), []);
+ });
+
+ it('for function definitions with types', () => {
+ assert.deepEqual(
+ typeScriptOutline(
+ 'function foo(x: T): T { return x; }\n' +
+ 'async function func(param: Klass): Promise<Klass> { return param; }'),
+ [
+ {title: 'foo', subtitle: '(x)', lineNumber: 0, columnNumber: 9},
+ {title: 'async func', subtitle: '(param)', lineNumber: 1, columnNumber: 15},
+ ],
+ );
+ assert.deepEqual(
+ typeScriptOutline(
+ 'const sum = (o: {a: number; b: number, c: number}) => o.a + o.b + o.c;',
+ ),
+ [
+ {title: 'sum', subtitle: '(o)', lineNumber: 0, columnNumber: 6},
+ ],
+ );
+ });
+
+ it('for variable declarations with types', () => {
+ assert.deepEqual(
+ typeScriptOutline(
+ 'let foo: (a: string) => string = a => a;\n' +
+ 'const bar:(x:number,y:number)=>number = function(x:number, y:number) { return x + y; }'),
+ [
+ {title: 'foo', subtitle: '(a)', lineNumber: 0, columnNumber: 4},
+ {title: 'bar', subtitle: '(x, y)', lineNumber: 1, columnNumber: 6},
+ ],
+ );
+ });
+
+ it('for classes, functions, and methods that use type parameters', () => {
+ assert.deepEqual(
+ typeScriptOutline('class Foo<Bar> {}'),
+ [{title: 'class Foo', lineNumber: 0, columnNumber: 6}],
+ );
+ assert.deepEqual(
+ typeScriptOutline(
+ 'function foo<Bar>(bar: Bar): Bar { return new Bar(); }\n' +
+ 'function bar<A, B, C>(): A { return a; }'),
+ [
+ {title: 'foo', subtitle: '(bar)', lineNumber: 0, columnNumber: 9},
+ {title: 'bar', subtitle: '()', lineNumber: 1, columnNumber: 9},
+ ],
+ );
+ assert.deepEqual(
+ typeScriptOutline('class A { foo<D>(d: D): D { return d; } }'),
+ [
+ {title: 'class A', lineNumber: 0, columnNumber: 6},
+ {title: 'foo', subtitle: '(d)', lineNumber: 0, columnNumber: 10},
+ ],
+ );
+ });
+
+ it('for abstract classes', () => {
+ assert.deepEqual(
+ typeScriptOutline('abstract class Foo {};'),
+ [
+ {title: 'class Foo', lineNumber: 0, columnNumber: 15},
+ ],
+ );
+ });
+
+ it('for abstract methods', () => {
+ assert.deepEqual(
+ typeScriptOutline('class Foo { abstract foo() {} abstract async bar() {} };'),
+ [
+ {title: 'class Foo', lineNumber: 0, columnNumber: 6},
+ {title: 'abstract foo', subtitle: '()', lineNumber: 0, columnNumber: 21},
+ {title: 'abstract async bar', subtitle: '()', lineNumber: 0, columnNumber: 45},
+ ],
+ );
+ });
+
+ it('for overriden methods', () => {
+ assert.deepEqual(
+ typeScriptOutline(
+ 'class Foo extends Bar {\n' +
+ ' override foo() {}\n' +
+ ' override *bar() {}\n' +
+ '};'),
+ [
+ {title: 'class Foo', lineNumber: 0, columnNumber: 6},
+ {title: 'foo', subtitle: '()', lineNumber: 1, columnNumber: 10},
+ {title: '*bar', subtitle: '()', lineNumber: 2, columnNumber: 11},
+ ],
+ );
+ });
+
+ it('for private methods', () => {
+ assert.deepEqual(
+ typeScriptOutline(
+ 'class A {\n' +
+ ' private #foo() {}\n' +
+ ' public static #bar(x) {}\n' +
+ ' protected async #baz(){}\n' +
+ '}'),
+ [
+ {title: 'class A', lineNumber: 0, columnNumber: 6},
+ {title: '#foo', subtitle: '()', lineNumber: 1, columnNumber: 10},
+ {title: 'static #bar', subtitle: '(x)', lineNumber: 2, columnNumber: 16},
+ {title: 'async #baz', subtitle: '()', lineNumber: 3, columnNumber: 18},
+ ],
+ );
+ });
+
+ it('for classes and methods with privacy modifiers', () => {
+ assert.deepEqual(
+ typeScriptOutline(
+ 'class A {\n' +
+ ' private foo() {}\n' +
+ ' public static bar(x) {}\n' +
+ ' protected async baz(){}\n' +
+ '}'),
+ [
+ {title: 'class A', lineNumber: 0, columnNumber: 6},
+ {title: 'foo', subtitle: '()', lineNumber: 1, columnNumber: 10},
+ {title: 'static bar', subtitle: '(x)', lineNumber: 2, columnNumber: 16},
+ {title: 'async baz', subtitle: '()', lineNumber: 3, columnNumber: 18},
+ ],
+ );
+ });
+
+ it('for functions and methods that use null types', () => {
+ assert.deepEqual(
+ typeScriptOutline('function foo():null { return null; }'),
+ [{title: 'foo', subtitle: '()', lineNumber: 0, columnNumber: 9}],
+ );
+ assert.deepEqual(
+ typeScriptOutline(
+ 'class Klass {\n' +
+ ' foo(x:null):null { return x ?? null; }\n' +
+ ' bar():null { return null; }\n' +
+ ' baz():Klass|null { return this; }\n' +
+ '}\n'),
+ [
+ {title: 'class Klass', lineNumber: 0, columnNumber: 6},
+ {title: 'foo', subtitle: '(x)', lineNumber: 1, columnNumber: 2},
+ {title: 'bar', subtitle: '()', lineNumber: 2, columnNumber: 4},
+ {title: 'baz', subtitle: '()', lineNumber: 3, columnNumber: 6},
+ ],
+ );
+ });
+
+ it('ignoring interface declarations', () => {
+ assert.deepEqual(typeScriptOutline('interface IFoo { name(): string; }'), []);
+ });
+
+ it('for class expressions after extends', () => {
+ const outline = typeScriptOutline('class A extends class { foo() } { bar() }');
+ assert.lengthOf(outline, 3);
+ assert.strictEqual(outline[0].title, 'class A');
+ assert.strictEqual(outline[1].title, 'foo');
+ assert.strictEqual(outline[2].title, 'bar');
+ });
+
+ describe('when using decorators', () => {
+ it('on classes', () => {
+ assert.deepEqual(
+ typeScriptOutline(
+ '@Simple @Something.Complex({x: 1}) class A {\n' +
+ ' constructor() {}\n' +
+ '}\n'),
+ [
+ {title: 'class A', lineNumber: 0, columnNumber: 41},
+ {title: 'constructor', subtitle: '()', lineNumber: 1, columnNumber: 2},
+ ],
+ );
+ });
+
+ it('on methods', () => {
+ assert.deepEqual(
+ typeScriptOutline(
+ 'new (class {\n' +
+ ' @Simple @Something.Complex({x: 1}) onInit(x, y) {}\n' +
+ '})\n'),
+ [
+ {title: 'onInit', subtitle: '(x, y)', lineNumber: 1, columnNumber: 37},
+ ],
+ );
+ });
+
+ it('on function parameters', () => {
+ assert.deepEqual(
+ typeScriptOutline('function foo(@Simple xyz, @Something.Complex({x: 1}) abc) {}'),
+ [
+ {title: 'foo', subtitle: '(xyz, abc)', lineNumber: 0, columnNumber: 9},
+ ],
+ );
+ });
+
+ it('on method parameters', () => {
+ assert.deepEqual(
+ typeScriptOutline(
+ 'new (class {\n' +
+ ' onInit(@Simple y, @Something.Complex({x: 1}) x) {}\n' +
+ '})\n'),
+ [
+ {title: 'onInit', subtitle: '(y, x)', lineNumber: 1, columnNumber: 2},
+ ],
+ );
+ });
+ });
+ });
+
+ describe('generates a correct CSS outline', () => {
+ function cssOutline(doc: string) {
+ const extensions = [CodeMirror.css.css()];
+ const state = CodeMirror.EditorState.create({doc, extensions});
+ return Sources.OutlineQuickOpen.outline(state);
+ }
+
+ it('for an empty style sheet', () => {
+ assert.deepEqual(cssOutline(''), []);
+ });
+
+ it('for universal selectors', () => {
+ assert.deepEqual(
+ cssOutline(
+ '* { color: green; }\n' +
+ ' *{\n' +
+ ' background-color: red;\n' +
+ '}'),
+ [
+ {title: '*', lineNumber: 0, columnNumber: 0},
+ {title: '*', lineNumber: 1, columnNumber: 2},
+ ],
+ );
+ });
+
+ it('for type selectors', () => {
+ assert.deepEqual(
+ cssOutline(
+ 'input {\n' +
+ ' --custom-color: blue;\n' +
+ ' color: var(--custom-color);\n' +
+ '}\n' +
+ 'a { font-size: 12px; };\n'),
+ [
+ {title: 'input', lineNumber: 0, columnNumber: 0},
+ {title: 'a', lineNumber: 4, columnNumber: 0},
+ ],
+ );
+ });
+
+ it('for class selectors', () => {
+ assert.deepEqual(
+ cssOutline(
+ ' .large {\n' +
+ ' font-size: 20px;\n' +
+ ' }\n' +
+ ' a.small { font-size: 12px; };\n'),
+ [
+ {title: '.large', lineNumber: 0, columnNumber: 2},
+ {title: 'a.small', lineNumber: 3, columnNumber: 1},
+ ],
+ );
+ });
+
+ it('for ID selectors', () => {
+ assert.deepEqual(
+ cssOutline('#large {font-size: 20px;} button#small { font-size: 12px; };'),
+ [
+ {title: '#large', lineNumber: 0, columnNumber: 0},
+ {title: 'button#small', lineNumber: 0, columnNumber: 26},
+ ],
+ );
+ });
+
+ it('for attribute selectors', () => {
+ assert.deepEqual(
+ cssOutline(
+ '[aria-label="Exit button"] {}\n' +
+ 'details[open]{}\n' +
+ 'a[href*="example"]\n'),
+ [
+ {title: '[aria-label="Exit button"]', lineNumber: 0, columnNumber: 0},
+ {title: 'details[open]', lineNumber: 1, columnNumber: 0},
+ {title: 'a[href*="example"]', lineNumber: 2, columnNumber: 0},
+ ],
+ );
+ });
+
+ it('for selector lists', () => {
+ assert.deepEqual(
+ cssOutline('a#id1, a.cls1, hr { content: ""}'),
+ [
+ {title: 'a#id1', lineNumber: 0, columnNumber: 0},
+ {title: 'a.cls1', lineNumber: 0, columnNumber: 7},
+ {title: 'hr', lineNumber: 0, columnNumber: 15},
+ ],
+ );
+ });
+
+ it('for combinators', () => {
+ assert.deepEqual(
+ cssOutline(
+ 'div a {}\n' +
+ '.dark > div {}\n' +
+ '.light ~ div {}\n' +
+ ' head + body{}\n'),
+ [
+ {title: 'div a', lineNumber: 0, columnNumber: 0},
+ {title: '.dark > div', lineNumber: 1, columnNumber: 0},
+ {title: '.light ~ div', lineNumber: 2, columnNumber: 0},
+ {title: 'head + body', lineNumber: 3, columnNumber: 1},
+ ],
+ );
+ });
+
+ it('for pseudo-classes', () => {
+ assert.deepEqual(
+ cssOutline(
+ 'a:visited{}button:hover{}\n' +
+ ':host {}\n'),
+ [
+ {title: 'a:visited', lineNumber: 0, columnNumber: 0},
+ {title: 'button:hover', lineNumber: 0, columnNumber: 11},
+ {title: ':host', lineNumber: 1, columnNumber: 0},
+ ],
+ );
+ });
+ });
+
+ describe('generates a correct HTML outline', () => {
+ function htmlOutline(doc: string) {
+ const extensions = [CodeMirror.html.html()];
+ const state = CodeMirror.EditorState.create({doc, extensions});
+ return Sources.OutlineQuickOpen.outline(state);
+ }
+
+ it('for an empty document', () => {
+ assert.deepEqual(htmlOutline('<!DOCTYPE html><html></html>'), []);
+ });
+
+ it('for a document with a single inline <script>', () => {
+ assert.deepEqual(
+ htmlOutline('<!DOCTYPE html><script>function foo(){}</script>'),
+ [
+ {title: 'foo', subtitle: '()', lineNumber: 0, columnNumber: 32},
+ ],
+ );
+ assert.deepEqual(
+ htmlOutline(
+ '<!DOCTYPE html>\n' +
+ '<html>\n' +
+ ' <head>\n' +
+ ' <script type="text/javascript">\n' +
+ ' async function bar(x) { return x; }\n' +
+ ' function baz(a,b, ...rest) { return rest; };\n' +
+ ' </script>\n' +
+ ' </head>\n' +
+ '</html>'),
+ [
+ {title: 'async bar', subtitle: '(x)', lineNumber: 4, columnNumber: 21},
+ {title: 'baz', subtitle: '(a, b, ...rest)', lineNumber: 5, columnNumber: 15},
+ ],
+ );
+ assert.deepEqual(
+ htmlOutline(`<script>
+ function first() {}
+ function IrrelevantFunctionSeekOrMissEKGFreqUnderflow() {}
+ function someFunction1() {}
+ function someFunction2() {}
+ debugger;
+</script>`),
+ [
+ {title: 'first', subtitle: '()', lineNumber: 1, columnNumber: 11},
+ {title: 'IrrelevantFunctionSeekOrMissEKGFreqUnderflow', subtitle: '()', lineNumber: 2, columnNumber: 11},
+ {title: 'someFunction1', subtitle: '()', lineNumber: 3, columnNumber: 11},
+ {title: 'someFunction2', subtitle: '()', lineNumber: 4, columnNumber: 11},
+ ],
+ );
+ });
+
+ it('for a document with multiple inline <script>s', () => {
+ assert.deepEqual(
+ htmlOutline(`<!DOCTYPE html>
+<html>
+ <head>
+ <script type="text/javascript">function add(x, y) { return x + y; }</script>
+ </head>
+ <body>
+ <script>
+ const sub = (a, b) => {
+ return x + y;
+ }
+ </script>
+ </body>
+</html>`),
+ [
+ {title: 'add', subtitle: '(x, y)', lineNumber: 3, columnNumber: 44},
+ {title: 'sub', subtitle: '(a, b)', lineNumber: 7, columnNumber: 12},
+ ],
+ );
+ });
+
+ it('for a document with inline <script>s and <style>s', () => {
+ assert.deepEqual(
+ htmlOutline(`<!DOCTYPE html>
+<html>
+<head>
+ <script>function add(x, y) { return x + y; }</script>
+ <style>
+ body { background-color: green; }
+ </style>
+</head>
+<body>
+<script defer>
+const sub = (x, y) => x - y;
+</script>
+<style>
+:host {
+ --custom-variable: 5px;
+}
+</style>
+</body>
+</html>`),
+ [
+ {title: 'add', subtitle: '(x, y)', lineNumber: 3, columnNumber: 19},
+ {title: 'body', lineNumber: 5, columnNumber: 4},
+ {title: 'sub', subtitle: '(x, y)', lineNumber: 10, columnNumber: 6},
+ {title: ':host', lineNumber: 13, columnNumber: 0},
+ ],
+ );
+ });
+
+ it('for a document with <script type="text/jsx">', () => {
+ assert.deepEqual(
+ htmlOutline(
+ '<!DOCTYPE html>\n' +
+ '<html>\n' +
+ ' <head>\n' +
+ ' <script type="text/jsx">\n' +
+ ' function hello(name) { return (<h1>Hello {name}</h1>); }\n' +
+ ' function goodbye(name) { return (<h1>Goodbye, {name}, until next time!</h1>); };\n' +
+ ' </script>\n' +
+ ' </head>\n' +
+ '</html>'),
+ [
+ {title: 'hello', subtitle: '(name)', lineNumber: 4, columnNumber: 15},
+ {title: 'goodbye', subtitle: '(name)', lineNumber: 5, columnNumber: 15},
+ ],
+ );
+ });
+ });
+
+ describe('generates a reasonable C++ outline', () => {
+ let extensions: CodeMirror.Extension|undefined;
+
+ before(async () => {
+ const cpp = await CodeMirror.cpp();
+ extensions = [cpp.cpp()];
+ });
+
+ function cppOutline(doc: string) {
+ const state = CodeMirror.EditorState.create({doc, extensions});
+ return Sources.OutlineQuickOpen.outline(state);
+ }
+
+ it('for an empty program', () => {
+ assert.deepEqual(cppOutline(''), []);
+ });
+
+ it('for a hello world program', () => {
+ assert.deepEqual(
+ cppOutline(
+ '#include <stdio.h>\n' +
+ '\n' +
+ 'int main(int argc, char** argv){\n' +
+ ' printf("Hello world!\n");\n' +
+ ' return 0;\n' +
+ '}\n'),
+ [
+ {title: 'main', lineNumber: 2, columnNumber: 4},
+ ],
+ );
+ });
+
+ it('for classes, structs, and methods', () => {
+ assert.deepEqual(
+ cppOutline(
+ 'struct S {\n' +
+ ' int foo(int x) { return x; }\n' +
+ '};\n' +
+ '\n' +
+ 'class K {\n' +
+ ' public:\n' +
+ ' K& bar() { return *this; }\n' +
+ ' static K*baz() { return nullptr; }\n' +
+ '};\n'),
+ [
+ {title: 'struct S', lineNumber: 0, columnNumber: 7},
+ {title: 'foo', lineNumber: 1, columnNumber: 6},
+ {title: 'class K', lineNumber: 4, columnNumber: 6},
+ {title: 'bar', lineNumber: 6, columnNumber: 5},
+ {title: 'baz', lineNumber: 7, columnNumber: 11},
+ ],
+ );
+ });
+ });
+
+ describe('generates a correct WebAssembly outline', () => {
+ let extensions: CodeMirror.Extension|undefined;
+
+ before(async () => {
+ const wast = await CodeMirror.wast();
+ extensions = [wast.wast()];
+ });
+
+ function wastOutline(doc: string) {
+ const state = CodeMirror.EditorState.create({doc, extensions});
+ return Sources.OutlineQuickOpen.outline(state);
+ }
+
+ it('for empty modules', () => {
+ assert.deepEqual(wastOutline('(module)'), []);
+ assert.deepEqual(wastOutline('(module $foo)'), [{title: '$foo', lineNumber: 0, columnNumber: 8}]);
+ });
+
+ it('for named functions', () => {
+ assert.deepEqual(
+ wastOutline(`(module
+ (func $add (param $lhs i32) (param $rhs i32) (result i32)
+ local.get $lhs
+ local.get $rhs
+ i32.add)
+ (func (param $x i32) (param $y) (result i32)
+ i32.const 1)
+ (func $id (param $x i32) (result i32))
+ local.get $x)
+)`),
+ [
+ {title: '$add', subtitle: '($lhs, $rhs)', lineNumber: 1, columnNumber: 8},
+ {title: '$id', subtitle: '($x)', lineNumber: 7, columnNumber: 8},
+ ],
+ );
+ });
+
+ it('for functions with unnamed parameters', () => {
+ assert.deepEqual(
+ wastOutline(`(module
+ (func $foo (param $x i32) (param i32) (param i64) (param $y f32) (result i32)
+ i32.const 42)
+ (func $bar (param i32) (result i32))
+ i32.const 21)
+)`),
+ [
+ {title: '$foo', subtitle: '($x, $1, $2, $y)', lineNumber: 1, columnNumber: 8},
+ {title: '$bar', subtitle: '($0)', lineNumber: 3, columnNumber: 8},
+ ],
+ );
+ });
+ });
+});
+
+describe('OutlineQuickOpen', () => {
+ const {OutlineQuickOpen} = Sources.OutlineQuickOpen;
+
+ it('reports no items before attached', () => {
+ const provider = new OutlineQuickOpen();
+ assert.strictEqual(provider.itemCount(), 0);
+ });
+
+ it('reports no items when attached while no SourcesView is active', () => {
+ const provider = new OutlineQuickOpen();
+ provider.attach();
+ assert.strictEqual(provider.itemCount(), 0);
+ });
+
+ it('correctly scores items within a JavaScript file', () => {
+ function scoredKeys(query: string): string[] {
+ const result = [];
+ for (let i = 0; i < provider.itemCount(); ++i) {
+ result.push({
+ key: provider.itemKeyAt(i),
+ score: provider.itemScoreAt(i, query),
+ });
+ }
+ result.sort((a, b) => b.score - a.score);
+ return result.map(({key}) => key);
+ }
+
+ const doc = `
+function testFoo(arg2) { }
+function test(arg1) { }
+function testBar(arg3) { }`;
+ const extensions = [CodeMirror.javascript.javascript()];
+ const textEditor = {state: CodeMirror.EditorState.create({doc, extensions})};
+ const sourceFrame = sinon.createStubInstance(Sources.UISourceCodeFrame.UISourceCodeFrame);
+ sourceFrame.editorLocationToUILocation.callThrough();
+ sinon.stub(sourceFrame, 'textEditor').value(textEditor);
+ const sourcesView = sinon.createStubInstance(Sources.SourcesView.SourcesView);
+ sourcesView.currentSourceFrame.returns(sourceFrame);
+ UI.Context.Context.instance().setFlavor(Sources.SourcesView.SourcesView, sourcesView);
+
+ const provider = new OutlineQuickOpen();
+ provider.attach();
+
+ assert.deepEqual(scoredKeys('te'), ['testFoo(arg2)', 'test(arg1)', 'testBar(arg3)']);
+ assert.deepEqual(scoredKeys('test'), ['test(arg1)', 'testFoo(arg2)', 'testBar(arg3)']);
+ assert.deepEqual(scoredKeys('test('), ['test(arg1)', 'testFoo(arg2)', 'testBar(arg3)']);
+ assert.deepEqual(scoredKeys('test(arg'), ['test(arg1)', 'testFoo(arg2)', 'testBar(arg3)']);
+ });
+});
diff --git a/front_end/panels/sources/ResourceOriginPlugin.test.ts b/front_end/panels/sources/ResourceOriginPlugin.test.ts
new file mode 100644
index 0000000..d222f55
--- /dev/null
+++ b/front_end/panels/sources/ResourceOriginPlugin.test.ts
@@ -0,0 +1,38 @@
+// 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 Common from '../../core/common/common.js';
+import * as Workspace from '../../models/workspace/workspace.js';
+
+import * as Sources from './sources.js';
+
+const {ResourceOriginPlugin} = Sources.ResourceOriginPlugin;
+
+describe('ResourceOriginPlugin', () => {
+ describe('accepts', () => {
+ it('holds true for documents', () => {
+ const uiSourceCode = sinon.createStubInstance(Workspace.UISourceCode.UISourceCode);
+ uiSourceCode.contentType.returns(Common.ResourceType.resourceTypes.Document);
+ assert.isTrue(ResourceOriginPlugin.accepts(uiSourceCode));
+ });
+
+ it('holds true for scripts', () => {
+ const uiSourceCode = sinon.createStubInstance(Workspace.UISourceCode.UISourceCode);
+ uiSourceCode.contentType.returns(Common.ResourceType.resourceTypes.Script);
+ assert.isTrue(ResourceOriginPlugin.accepts(uiSourceCode));
+ });
+
+ it('holds true for source mapped scripts', () => {
+ const uiSourceCode = sinon.createStubInstance(Workspace.UISourceCode.UISourceCode);
+ uiSourceCode.contentType.returns(Common.ResourceType.resourceTypes.SourceMapScript);
+ assert.isTrue(ResourceOriginPlugin.accepts(uiSourceCode));
+ });
+
+ it('holds true for source mapped style sheets', () => {
+ const uiSourceCode = sinon.createStubInstance(Workspace.UISourceCode.UISourceCode);
+ uiSourceCode.contentType.returns(Common.ResourceType.resourceTypes.SourceMapStyleSheet);
+ assert.isTrue(ResourceOriginPlugin.accepts(uiSourceCode));
+ });
+ });
+});
diff --git a/front_end/panels/sources/SourcesNavigator.test.ts b/front_end/panels/sources/SourcesNavigator.test.ts
new file mode 100644
index 0000000..33bce63
--- /dev/null
+++ b/front_end/panels/sources/SourcesNavigator.test.ts
@@ -0,0 +1,616 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Common from '../../core/common/common.js';
+import * as Bindings from '../../models/bindings/bindings.js';
+import * as Breakpoints from '../../models/breakpoints/breakpoints.js';
+import * as Persistence from '../../models/persistence/persistence.js';
+import * as Root from '../../core/root/root.js';
+import type * as Platform from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import * as Sources from './sources.js';
+import * as UI from '../../ui/legacy/legacy.js';
+import * as Workspace from '../../models/workspace/workspace.js';
+import {
+ describeWithMockConnection,
+ dispatchEvent,
+ setMockConnectionResponseHandler,
+} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import {MockProtocolBackend} from '../../../test/unittests/front_end/helpers/MockScopeChain.js';
+import {createTarget} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {createContentProviderUISourceCodes} from '../../../test/unittests/front_end/helpers/UISourceCodeHelpers.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+
+describeWithMockConnection('NetworkNavigatorView', () => {
+ let workspace: Workspace.Workspace.WorkspaceImpl;
+ beforeEach(async () => {
+ const actionRegistryInstance = UI.ActionRegistry.ActionRegistry.instance({forceNew: true});
+ workspace = Workspace.Workspace.WorkspaceImpl.instance();
+ const targetManager = SDK.TargetManager.TargetManager.instance();
+ const resourceMapping = new Bindings.ResourceMapping.ResourceMapping(targetManager, workspace);
+ const debuggerWorkspaceBinding = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance({
+ forceNew: true,
+ resourceMapping,
+ targetManager,
+ });
+ Bindings.IgnoreListManager.IgnoreListManager.instance({forceNew: true, debuggerWorkspaceBinding});
+ const breakpointManager = Breakpoints.BreakpointManager.BreakpointManager.instance(
+ {forceNew: true, targetManager, workspace, debuggerWorkspaceBinding});
+ Persistence.Persistence.PersistenceImpl.instance({forceNew: true, workspace, breakpointManager});
+ Persistence.NetworkPersistenceManager.NetworkPersistenceManager.instance({forceNew: true, workspace});
+ UI.ShortcutRegistry.ShortcutRegistry.instance({forceNew: true, actionRegistry: actionRegistryInstance});
+ Root.Runtime.experiments.register(Root.Runtime.ExperimentName.AUTHORED_DEPLOYED_GROUPING, '');
+ Root.Runtime.experiments.register(Root.Runtime.ExperimentName.JUST_MY_CODE, '');
+ });
+
+ const revealMainTarget = (targetFactory: () => SDK.Target.Target) => {
+ let target: SDK.Target.Target;
+ let project: Bindings.ContentProviderBasedProject.ContentProviderBasedProject;
+
+ beforeEach(async () => {
+ target = targetFactory();
+ ({project} = createContentProviderUISourceCodes({
+ items: [
+ {url: 'http://example.com/' as Platform.DevToolsPath.UrlString, mimeType: 'text/html'},
+ {url: 'http://example.com/favicon.ico' as Platform.DevToolsPath.UrlString, mimeType: 'image/x-icon'},
+ {url: 'http://example.com/gtm.js' as Platform.DevToolsPath.UrlString, mimeType: 'application/javascript'},
+ ],
+ projectType: Workspace.Workspace.projectTypes.Network,
+ target,
+ }));
+ });
+
+ afterEach(() => {
+ Workspace.Workspace.WorkspaceImpl.instance().removeProject(project);
+ });
+
+ it('shows folder with scripts requests', async () => {
+ const {project} = createContentProviderUISourceCodes({
+ items: [
+ {
+ url: 'http://example.com/script.js' as Platform.DevToolsPath.UrlString,
+ mimeType: 'application/javascript',
+ resourceType: Common.ResourceType.resourceTypes.Script,
+ },
+ ],
+ projectType: Workspace.Workspace.projectTypes.Network,
+ target,
+ });
+
+ const navigatorView = Sources.SourcesNavigator.NetworkNavigatorView.instance({forceNew: true});
+ const rootElement = navigatorView.scriptsTree.rootElement();
+ const folder = rootElement.firstChild();
+ const file = folder?.firstChild();
+
+ assert.strictEqual(folder?.title, 'example.com');
+ assert.strictEqual(file?.title, 'script.js');
+
+ project.removeProject();
+ });
+
+ it('does not show Fetch and XHR requests', async () => {
+ const {project} = createContentProviderUISourceCodes({
+ items: [
+ {
+ url: 'http://example.com/list-xhr.json' as Platform.DevToolsPath.UrlString,
+ mimeType: 'application/json',
+ resourceType: Common.ResourceType.resourceTypes.XHR,
+ },
+ {
+ url: 'http://example.com/list-fetch.json' as Platform.DevToolsPath.UrlString,
+ mimeType: 'application/json',
+ resourceType: Common.ResourceType.resourceTypes.Fetch,
+ },
+ ],
+ projectType: Workspace.Workspace.projectTypes.Network,
+ target,
+ });
+
+ const navigatorView = Sources.SourcesNavigator.NetworkNavigatorView.instance({forceNew: true});
+ const rootElement = navigatorView.scriptsTree.rootElement();
+ assert.strictEqual(rootElement.children().length, 0);
+
+ project.removeProject();
+ });
+
+ it('reveals main frame target on navigation', async () => {
+ const navigatorView = Sources.SourcesNavigator.NetworkNavigatorView.instance({forceNew: true});
+
+ const rootElement = navigatorView.scriptsTree.rootElement();
+ assert.strictEqual(rootElement.childCount(), 1);
+ assert.strictEqual(rootElement.firstChild()?.childCount(), 3);
+ assert.isFalse(rootElement.firstChild()?.expanded);
+ assert.isTrue(rootElement.firstChild()?.selected);
+
+ target.setInspectedURL('http://example.com/' as Platform.DevToolsPath.UrlString);
+
+ assert.isTrue(navigatorView.scriptsTree.firstChild()?.expanded);
+ assert.isTrue(navigatorView.scriptsTree.firstChild()?.firstChild()?.selected);
+ });
+
+ it('reveals main frame target when added', async () => {
+ target.setInspectedURL('http://example.com/' as Platform.DevToolsPath.UrlString);
+ const navigatorView = Sources.SourcesNavigator.NetworkNavigatorView.instance({forceNew: true});
+
+ const rootElement = navigatorView.scriptsTree.rootElement();
+ assert.strictEqual(rootElement.childCount(), 1);
+ assert.strictEqual(rootElement.firstChild()?.childCount(), 3);
+ assert.isTrue(navigatorView.scriptsTree.firstChild()?.expanded);
+ assert.isTrue(navigatorView.scriptsTree.firstChild()?.firstChild()?.selected);
+ });
+ };
+
+ describe('without tab target', () => revealMainTarget(createTarget));
+ describe('with tab target', () => revealMainTarget(() => {
+ const tabTarget = createTarget({type: SDK.Target.Type.Tab});
+ createTarget({parentTarget: tabTarget, subtype: 'prerender'});
+ return createTarget({parentTarget: tabTarget});
+ }));
+
+ it('updates in scope change', () => {
+ const target = createTarget();
+ const {project} = createContentProviderUISourceCodes({
+ items: [
+ {url: 'http://example.com/' as Platform.DevToolsPath.UrlString, mimeType: 'text/html'},
+ {url: 'http://example.com/favicon.ico' as Platform.DevToolsPath.UrlString, mimeType: 'image/x-icon'},
+ {url: 'http://example.com/gtm.js' as Platform.DevToolsPath.UrlString, mimeType: 'application/javascript'},
+ ],
+ projectId: 'project',
+ projectType: Workspace.Workspace.projectTypes.Network,
+ target,
+ });
+ const anotherTarget = createTarget();
+ const {project: anotherProject} = createContentProviderUISourceCodes({
+ items: [
+ {url: 'http://example.org/' as Platform.DevToolsPath.UrlString, mimeType: 'text/html'},
+ {url: 'http://example.org/background.bmp' as Platform.DevToolsPath.UrlString, mimeType: 'image/x-icon'},
+ ],
+ projectId: 'anotherProject',
+ projectType: Workspace.Workspace.projectTypes.Network,
+ target: anotherTarget,
+ });
+
+ SDK.TargetManager.TargetManager.instance().setScopeTarget(target);
+ const navigatorView = Sources.SourcesNavigator.NetworkNavigatorView.instance({forceNew: true});
+
+ let rootElement = navigatorView.scriptsTree.rootElement();
+ assert.strictEqual(rootElement.childCount(), 1);
+ assert.strictEqual(rootElement.firstChild()?.childCount(), 3);
+ assert.deepEqual(rootElement.firstChild()?.children().map(i => i.title), ['(index)', 'gtm.js', 'favicon.ico']);
+
+ SDK.TargetManager.TargetManager.instance().setScopeTarget(anotherTarget);
+
+ rootElement = navigatorView.scriptsTree.rootElement();
+ assert.strictEqual(rootElement.childCount(), 1);
+ assert.strictEqual(rootElement.firstChild()?.childCount(), 2);
+ assert.deepEqual(rootElement.firstChild()?.children().map(i => i.title), ['(index)', 'background.bmp']);
+
+ project.removeProject();
+ anotherProject.removeProject();
+ });
+
+ describe('removing source codes selection throttling', () => {
+ let target: SDK.Target.Target;
+
+ beforeEach(() => {
+ target = createTarget();
+ });
+
+ it('selects just once when removing multiple sibling source codes', () => {
+ const {project} = createContentProviderUISourceCodes({
+ items: [
+ {url: 'http://example.com/a.js' as Platform.DevToolsPath.UrlString, mimeType: 'application/javascript'},
+ {url: 'http://example.com/b.js' as Platform.DevToolsPath.UrlString, mimeType: 'application/javascript'},
+ ],
+ projectType: Workspace.Workspace.projectTypes.Network,
+ target,
+ });
+ const {project: otherProject} = createContentProviderUISourceCodes({
+ items: [
+ {url: 'http://example.com/c.js' as Platform.DevToolsPath.UrlString, mimeType: 'application/javascript'},
+ ],
+ projectType: Workspace.Workspace.projectTypes.Network,
+ projectId: 'other',
+ target,
+ });
+
+ const navigatorView = Sources.SourcesNavigator.NetworkNavigatorView.instance({forceNew: true});
+ const rootElement = navigatorView.scriptsTree.rootElement();
+ const exampleComNode = rootElement.firstChild();
+ assertNotNullOrUndefined(exampleComNode);
+ const nodeA = exampleComNode.childAt(0);
+ const nodeB = exampleComNode.childAt(1);
+ const nodeC = exampleComNode.childAt(2);
+ assertNotNullOrUndefined(nodeA);
+ assertNotNullOrUndefined(nodeB);
+ assertNotNullOrUndefined(nodeC);
+
+ // Select the 'http://example.com/a.js' node. Remove the project with a.js and b.js and verify
+ // that the selection is moved from 'a.js' to 'c.js', without temporarily selecting 'b.js'.
+ nodeA.select();
+
+ const nodeBSelectSpy = sinon.spy(nodeB, 'select');
+ const nodeCSelectSpy = sinon.spy(nodeC, 'select');
+
+ project.removeProject();
+
+ assert.isTrue(nodeBSelectSpy.notCalled);
+ assert.isTrue(nodeCSelectSpy.called);
+
+ otherProject.removeProject();
+ });
+
+ it('selects parent after removing all children', () => {
+ const {project} = createContentProviderUISourceCodes({
+ items: [
+ {url: 'http://example.com/a.js' as Platform.DevToolsPath.UrlString, mimeType: 'application/javascript'},
+ {url: 'http://example.com/b.js' as Platform.DevToolsPath.UrlString, mimeType: 'application/javascript'},
+ {url: 'http://example.com/c.js' as Platform.DevToolsPath.UrlString, mimeType: 'application/javascript'},
+ ],
+ projectType: Workspace.Workspace.projectTypes.Network,
+ target,
+ });
+
+ const navigatorView = Sources.SourcesNavigator.NetworkNavigatorView.instance({forceNew: true});
+ const rootElement = navigatorView.scriptsTree.rootElement();
+ const nodeExampleCom = rootElement.firstChild();
+ assertNotNullOrUndefined(nodeExampleCom);
+ const nodeA = nodeExampleCom.childAt(0);
+ const nodeB = nodeExampleCom.childAt(1);
+ const nodeC = nodeExampleCom.childAt(2);
+ assertNotNullOrUndefined(nodeA);
+ assertNotNullOrUndefined(nodeB);
+ assertNotNullOrUndefined(nodeC);
+
+ // Select the 'http://example.com/a.js' node. Remove all the source codenodes and check the selection
+ // is not propagated forward to the siblings as we remove them. Instead, the selection will be moved
+ // directly to the parent.
+ nodeA.select();
+
+ const nodeBSelectSpy = sinon.spy(nodeB, 'select');
+ const nodeCSelectSpy = sinon.spy(nodeC, 'select');
+ const nodeExampleComSelectSpy = sinon.spy(nodeExampleCom, 'select');
+
+ project.removeProject();
+
+ assert.isTrue(nodeBSelectSpy.notCalled);
+ assert.isTrue(nodeCSelectSpy.notCalled);
+ assert.isTrue(nodeExampleComSelectSpy.called);
+
+ // Note that the last asserion is slightly misleading since the empty example.com node is removed.
+ // Let us make that clear here.
+ assert.strictEqual(rootElement.childCount(), 0);
+ });
+
+ it('selects sibling after removing folder children', async () => {
+ const {project} = createContentProviderUISourceCodes({
+ items: [
+ {url: 'http://example.com/d/a.js' as Platform.DevToolsPath.UrlString, mimeType: 'application/javascript'},
+ {url: 'http://example.com/d/b.js' as Platform.DevToolsPath.UrlString, mimeType: 'application/javascript'},
+ ],
+ projectType: Workspace.Workspace.projectTypes.Network,
+ target,
+ });
+ const {project: otherProject} = createContentProviderUISourceCodes({
+ items: [
+ {url: 'http://example.com/c.js' as Platform.DevToolsPath.UrlString, mimeType: 'application/javascript'},
+ ],
+ projectType: Workspace.Workspace.projectTypes.Network,
+ projectId: 'other',
+ target,
+ });
+
+ const navigatorView = Sources.SourcesNavigator.NetworkNavigatorView.instance({forceNew: true});
+ const rootElement = navigatorView.scriptsTree.rootElement();
+ const exampleComNode = rootElement.firstChild();
+ assertNotNullOrUndefined(exampleComNode);
+ const nodeD = exampleComNode.childAt(0);
+ assertNotNullOrUndefined(nodeD);
+ await nodeD.expand();
+ const nodeA = nodeD.childAt(0);
+ const nodeB = nodeD.childAt(1);
+ const nodeC = exampleComNode.childAt(1);
+ assertNotNullOrUndefined(nodeA);
+ assertNotNullOrUndefined(nodeB);
+ assertNotNullOrUndefined(nodeC);
+
+ // Select the 'http://example.com/a.js' node.
+ nodeA.select();
+
+ const nodeBSelectSpy = sinon.spy(nodeB, 'select');
+ const nodeCSelectSpy = sinon.spy(nodeC, 'select');
+
+ // Remove the project with the a.js and b.js nodes.
+ project.removeProject();
+
+ // Let us check that we do not push the selection forward over node 'b.js'.
+ // Instead, the selection will be pushed to 'c.js' (with an intermediate step at 'd').
+ // (Ideally, it would move directly from 'a.js' to 'c.js', but we are currently only
+ // optimizing away the moves to siblings.)
+ assert.isTrue(nodeBSelectSpy.notCalled);
+ assert.isTrue(nodeCSelectSpy.called);
+
+ // Also note that the folder 'd' is removed. Let us make that explicit.
+ assert.strictEqual(exampleComNode.childCount(), 1);
+ assert.strictEqual(exampleComNode.childAt(0), nodeC);
+
+ otherProject.removeProject();
+ });
+
+ it('selects sibling after removing individual folder children', async () => {
+ const {project} = createContentProviderUISourceCodes({
+ items: [
+ {url: 'http://example.com/d/a.js' as Platform.DevToolsPath.UrlString, mimeType: 'application/javascript'},
+ {url: 'http://example.com/e/b.js' as Platform.DevToolsPath.UrlString, mimeType: 'application/javascript'},
+ ],
+ projectType: Workspace.Workspace.projectTypes.Network,
+ target,
+ });
+ const {project: otherProject} = createContentProviderUISourceCodes({
+ items: [
+ {url: 'http://example.com/c.js' as Platform.DevToolsPath.UrlString, mimeType: 'application/javascript'},
+ ],
+ projectType: Workspace.Workspace.projectTypes.Network,
+ projectId: 'other',
+ target,
+ });
+
+ const navigatorView = Sources.SourcesNavigator.NetworkNavigatorView.instance({forceNew: true});
+ const rootElement = navigatorView.scriptsTree.rootElement();
+ const exampleComNode = rootElement.firstChild();
+ assertNotNullOrUndefined(exampleComNode);
+ const nodeD = exampleComNode.childAt(0);
+ const nodeE = exampleComNode.childAt(1);
+ const nodeC = exampleComNode.childAt(2);
+ assertNotNullOrUndefined(nodeD);
+ assertNotNullOrUndefined(nodeE);
+ await nodeD.expand();
+ await nodeE.expand();
+ const nodeA = nodeD.childAt(0);
+ const nodeB = nodeE.childAt(0);
+ assertNotNullOrUndefined(nodeA);
+ assertNotNullOrUndefined(nodeB);
+ assertNotNullOrUndefined(nodeC);
+
+ // Select the 'http://example.com/a.js' node.
+ nodeA.select();
+
+ const nodeESelectSpy = sinon.spy(nodeE, 'select');
+ const nodeBSelectSpy = sinon.spy(nodeB, 'select');
+ const nodeCSelectSpy = sinon.spy(nodeC, 'select');
+
+ // Remove a.js and b.js nodes. This will remove their nodes, including the containing folders.
+ // The selection will be moved from 'a.js' to its parent (folder 'd') and when that gets removed,
+ // it should move to 'c' rather being pushed forward to 'e'.
+ project.removeProject();
+
+ assert.isTrue(nodeESelectSpy.notCalled);
+ assert.isTrue(nodeBSelectSpy.notCalled);
+ assert.isTrue(nodeCSelectSpy.called);
+
+ // Also note that nodeD and nodeE are removed. Let us make that explicit.
+ assert.strictEqual(exampleComNode.childCount(), 1);
+ assert.strictEqual(exampleComNode.childAt(0), nodeC);
+
+ otherProject.removeProject();
+ });
+
+ it('selects just once when excution-context-destroyed event removes sibling source codes', async () => {
+ const backend = new MockProtocolBackend();
+
+ dispatchEvent(target, 'Runtime.executionContextCreated', {
+ context: {
+ id: 2,
+ origin: 'http://example.com',
+ name: 'c2',
+ uniqueId: 'c2',
+ auxData: {
+ frameId: 'f2',
+ },
+ },
+ });
+
+ await backend.addScript(
+ target, {content: '42', url: 'http://example.com/a.js', executionContextId: 2, hasSourceURL: false}, null);
+ await backend.addScript(
+ target, {content: '42', url: 'http://example.com/b.js', executionContextId: 2, hasSourceURL: false}, null);
+ await backend.addScript(target, {content: '42', url: 'http://example.com/c.js', hasSourceURL: false}, null);
+
+ const navigatorView = Sources.SourcesNavigator.NetworkNavigatorView.instance({forceNew: true});
+ const rootElement = navigatorView.scriptsTree.rootElement();
+ const exampleComNode = rootElement.firstChild();
+ assertNotNullOrUndefined(exampleComNode);
+ const nodeA = exampleComNode.childAt(0);
+ const nodeB = exampleComNode.childAt(1);
+ const nodeC = exampleComNode.childAt(2);
+ assertNotNullOrUndefined(nodeA);
+ assertNotNullOrUndefined(nodeB);
+ assertNotNullOrUndefined(nodeC);
+
+ // Select the 'http://example.com/a.js' node. Remove the project with a.js and b.js and verify
+ // that the selection is moved from 'a.js' to 'c.js', without temporarily selecting 'b.js'.
+ nodeA.select();
+
+ const nodeBSelectSpy = sinon.spy(nodeB, 'select');
+ const nodeCSelectSpy = sinon.spy(nodeC, 'select');
+
+ dispatchEvent(
+ target, 'Runtime.executionContextDestroyed', {executionContextId: 2, executionContextUniqueId: 'c2'});
+
+ assert.isTrue(nodeBSelectSpy.notCalled);
+ assert.isTrue(nodeCSelectSpy.called);
+
+ // Sanity check - we should have only one source now.
+ assert.strictEqual(exampleComNode.childCount(), 1);
+ });
+ });
+
+ describe('with ignore listing', () => {
+ let target: SDK.Target.Target;
+ let resolveFn: (() => void)|null = null;
+
+ beforeEach(() => {
+ target = createTarget();
+ Bindings.IgnoreListManager.IgnoreListManager.instance().addChangeListener(() => {
+ if (resolveFn) {
+ resolveFn();
+ resolveFn = null;
+ }
+ });
+ setMockConnectionResponseHandler('Debugger.setBlackboxPatterns', () => ({}));
+ });
+
+ const updatePatternSetting = async (settingValue: Common.Settings.RegExpSettingItem[]) => {
+ const setting = Common.Settings.Settings.instance().moduleSetting('skip-stack-frames-pattern') as
+ Common.Settings.RegExpSetting;
+ const promise = new Promise<void>(resolve => {
+ resolveFn = resolve;
+ });
+ setting.setAsArray(settingValue);
+ void await promise;
+ };
+ const enableIgnoreListing = () => updatePatternSetting([{pattern: '-hidden', disabled: false}]);
+ const disableIgnoreListing = () => updatePatternSetting([]);
+
+ it('shows folder with only ignore listed content as ignore listed', async () => {
+ await enableIgnoreListing();
+ const {project} = createContentProviderUISourceCodes({
+ items: [
+ {
+ url: 'http://example.com/ignored/a/a-hidden.js' as Platform.DevToolsPath.UrlString,
+ mimeType: 'application/javascript',
+ },
+ {
+ url: 'http://example.com/ignored/b/b-hidden.js' as Platform.DevToolsPath.UrlString,
+ mimeType: 'application/javascript',
+ },
+ {
+ url: 'http://example.com/mixed/a/a-hidden.js' as Platform.DevToolsPath.UrlString,
+ mimeType: 'application/javascript',
+ },
+ {
+ url: 'http://example.com/mixed/b/b.js' as Platform.DevToolsPath.UrlString,
+ mimeType: 'application/javascript',
+ },
+ ],
+ projectType: Workspace.Workspace.projectTypes.Network,
+ target,
+ });
+
+ const navigatorView = Sources.SourcesNavigator.NetworkNavigatorView.instance({forceNew: true});
+ const rootElement = navigatorView.scriptsTree.rootElement();
+ const nodeExampleCom = rootElement.firstChild();
+ const ignoredFolder = nodeExampleCom!.childAt(0);
+ const mixedFolder = nodeExampleCom!.childAt(1);
+
+ assert.strictEqual(mixedFolder!.tooltip, 'mixed');
+ assert.strictEqual(ignoredFolder!.tooltip, 'ignored (ignore listed)');
+
+ project.removeProject();
+ });
+
+ it('updates folders when ignore listing rules change', async () => {
+ const {project} = createContentProviderUISourceCodes({
+ items: [
+ {
+ url: 'http://example.com/ignored/a/a-hidden.js' as Platform.DevToolsPath.UrlString,
+ mimeType: 'application/javascript',
+ },
+ {
+ url: 'http://example.com/ignored/b/b-hidden.js' as Platform.DevToolsPath.UrlString,
+ mimeType: 'application/javascript',
+ },
+ {
+ url: 'http://example.com/mixed/a/a-hidden.js' as Platform.DevToolsPath.UrlString,
+ mimeType: 'application/javascript',
+ },
+ {
+ url: 'http://example.com/mixed/b/b.js' as Platform.DevToolsPath.UrlString,
+ mimeType: 'application/javascript',
+ },
+ ],
+ projectType: Workspace.Workspace.projectTypes.Network,
+ target,
+ });
+
+ const navigatorView = Sources.SourcesNavigator.NetworkNavigatorView.instance({forceNew: true});
+ const rootElement = navigatorView.scriptsTree.rootElement();
+ const nodeExampleCom = rootElement.firstChild();
+ const ignoredFolder = nodeExampleCom!.childAt(0);
+ const mixedFolder = nodeExampleCom!.childAt(1);
+
+ assert.strictEqual(mixedFolder!.tooltip, 'mixed');
+ assert.strictEqual(ignoredFolder!.tooltip, 'ignored');
+
+ await enableIgnoreListing();
+
+ assert.strictEqual(mixedFolder!.tooltip, 'mixed');
+ assert.strictEqual(ignoredFolder!.tooltip, 'ignored (ignore listed)');
+
+ await disableIgnoreListing();
+
+ assert.strictEqual(mixedFolder!.tooltip, 'mixed');
+ assert.strictEqual(ignoredFolder!.tooltip, 'ignored');
+
+ project.removeProject();
+ });
+
+ it('updates folders when files are added or removed', async () => {
+ await enableIgnoreListing();
+ const {project} = createContentProviderUISourceCodes({
+ items: [
+ {
+ url: 'http://example.com/ignored/a/a-hidden.js' as Platform.DevToolsPath.UrlString,
+ mimeType: 'application/javascript',
+ },
+ {
+ url: 'http://example.com/ignored/b/b-hidden.js' as Platform.DevToolsPath.UrlString,
+ mimeType: 'application/javascript',
+ },
+ {
+ url: 'http://example.com/mixed/a/a-hidden.js' as Platform.DevToolsPath.UrlString,
+ mimeType: 'application/javascript',
+ },
+ ],
+ projectType: Workspace.Workspace.projectTypes.Network,
+ target,
+ });
+
+ const navigatorView = Sources.SourcesNavigator.NetworkNavigatorView.instance({forceNew: true});
+ const rootElement = navigatorView.scriptsTree.rootElement();
+ const nodeExampleCom = rootElement.firstChild();
+ const ignoredFolder = nodeExampleCom!.childAt(0);
+ const mixedFolder = nodeExampleCom!.childAt(1);
+
+ assert.strictEqual(mixedFolder!.tooltip, 'mixed/a (ignore listed)');
+ assert.strictEqual(ignoredFolder!.tooltip, 'ignored (ignore listed)');
+
+ const {project: otherProject} = createContentProviderUISourceCodes({
+ items: [
+ {
+ url: 'http://example.com/mixed/b/b.js' as Platform.DevToolsPath.UrlString,
+ mimeType: 'application/javascript',
+ },
+ ],
+ projectType: Workspace.Workspace.projectTypes.Network,
+ target,
+ });
+
+ assert.strictEqual(mixedFolder!.tooltip, 'mixed');
+ assert.strictEqual(ignoredFolder!.tooltip, 'ignored (ignore listed)');
+
+ otherProject.removeProject();
+
+ assert.strictEqual(mixedFolder!.tooltip, 'mixed (ignore listed)');
+ assert.strictEqual(ignoredFolder!.tooltip, 'ignored (ignore listed)');
+
+ project.removeProject();
+ });
+ });
+});
diff --git a/front_end/panels/sources/SourcesView.test.ts b/front_end/panels/sources/SourcesView.test.ts
new file mode 100644
index 0000000..49f21bc
--- /dev/null
+++ b/front_end/panels/sources/SourcesView.test.ts
@@ -0,0 +1,203 @@
+// 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.
+
+const {assert} = chai;
+
+import * as Bindings from '../../models/bindings/bindings.js';
+import * as Breakpoints from '../../models/breakpoints/breakpoints.js';
+import * as Common from '../../core/common/common.js';
+import * as Persistence from '../../models/persistence/persistence.js';
+import * as Host from '../../core/host/host.js';
+import type * as Platform from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import * as SourceFrame from '../../ui/legacy/components/source_frame/source_frame.js';
+import * as Sources from './sources.js';
+import * as SourcesComponents from './components/components.js';
+import * as UI from '../../ui/legacy/legacy.js';
+import * as Workspace from '../../models/workspace/workspace.js';
+import {
+ createTarget,
+ describeWithEnvironment,
+} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {
+ createFileSystemUISourceCode,
+ createContentProviderUISourceCodes,
+} from '../../../test/unittests/front_end/helpers/UISourceCodeHelpers.js';
+import {describeWithMockConnection} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+
+describeWithEnvironment('SourcesView', () => {
+ beforeEach(async () => {
+ const actionRegistryInstance = UI.ActionRegistry.ActionRegistry.instance({forceNew: true});
+ const workspace = Workspace.Workspace.WorkspaceImpl.instance();
+ const targetManager = SDK.TargetManager.TargetManager.instance();
+ const resourceMapping = new Bindings.ResourceMapping.ResourceMapping(targetManager, workspace);
+ const debuggerWorkspaceBinding = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance({
+ forceNew: true,
+ resourceMapping,
+ targetManager,
+ });
+ const breakpointManager = Breakpoints.BreakpointManager.BreakpointManager.instance(
+ {forceNew: true, targetManager, workspace, debuggerWorkspaceBinding});
+ Persistence.Persistence.PersistenceImpl.instance({forceNew: true, workspace, breakpointManager});
+ Persistence.NetworkPersistenceManager.NetworkPersistenceManager.instance({forceNew: true, workspace});
+ UI.ShortcutRegistry.ShortcutRegistry.instance({forceNew: true, actionRegistry: actionRegistryInstance});
+ });
+
+ it('creates new source view of updated type when renamed file requires a different viewer', async () => {
+ const sourcesView = new Sources.SourcesView.SourcesView();
+ sourcesView.markAsRoot();
+ sourcesView.show(document.body);
+ const workspace = Workspace.Workspace.WorkspaceImpl.instance();
+ const {uiSourceCode, project} = createFileSystemUISourceCode({
+ url: 'file:///path/to/overrides/example.html' as Platform.DevToolsPath.UrlString,
+ mimeType: 'text/html',
+ });
+ project.canSetFileContent = () => true;
+ project.rename =
+ (uiSourceCode: Workspace.UISourceCode.UISourceCode, newName: string,
+ callback: (
+ arg0: boolean, arg1?: string, arg2?: Platform.DevToolsPath.UrlString,
+ arg3?: Common.ResourceType.ResourceType) => void) => {
+ const newURL = ('file:///path/to/overrides/' + newName) as Platform.DevToolsPath.UrlString;
+ let newContentType = Common.ResourceType.resourceTypes.Document;
+ if (newName.endsWith('.jpg')) {
+ newContentType = Common.ResourceType.resourceTypes.Image;
+ } else if (newName.endsWith('.woff')) {
+ newContentType = Common.ResourceType.resourceTypes.Font;
+ }
+ callback(true, newName, newURL, newContentType);
+ };
+
+ sourcesView.viewForFile(uiSourceCode);
+
+ assert.isTrue(sourcesView.getSourceView(uiSourceCode) instanceof Sources.UISourceCodeFrame.UISourceCodeFrame);
+
+ // Rename, but contentType stays the same
+ await uiSourceCode.rename('newName.html' as Platform.DevToolsPath.RawPathString);
+ assert.isTrue(sourcesView.getSourceView(uiSourceCode) instanceof Sources.UISourceCodeFrame.UISourceCodeFrame);
+
+ // Rename which changes contentType
+ await uiSourceCode.rename('image.jpg' as Platform.DevToolsPath.RawPathString);
+ assert.isTrue(sourcesView.getSourceView(uiSourceCode) instanceof SourceFrame.ImageView.ImageView);
+
+ // Rename which changes contentType
+ await uiSourceCode.rename('font.woff' as Platform.DevToolsPath.RawPathString);
+ assert.isTrue(sourcesView.getSourceView(uiSourceCode) instanceof SourceFrame.FontView.FontView);
+ workspace.removeProject(project);
+ sourcesView.detach();
+ });
+
+ it('creates a HeadersView when the filename is \'.headers\'', async () => {
+ const sourcesView = new Sources.SourcesView.SourcesView();
+ const uiSourceCode = new Workspace.UISourceCode.UISourceCode(
+ {} as Persistence.FileSystemWorkspaceBinding.FileSystem,
+ 'file:///path/to/overrides/www.example.com/.headers' as Platform.DevToolsPath.UrlString,
+ Common.ResourceType.resourceTypes.Document);
+ sourcesView.viewForFile(uiSourceCode);
+ assert.isTrue(sourcesView.getSourceView(uiSourceCode) instanceof SourcesComponents.HeadersView.HeadersView);
+ });
+
+ describe('viewForFile', () => {
+ it('records the correct media type in the DevTools.SourcesPanelFileOpened metric', async () => {
+ const sourcesView = new Sources.SourcesView.SourcesView();
+ const {uiSourceCode} = createFileSystemUISourceCode({
+ url: 'file:///path/to/project/example.ts' as Platform.DevToolsPath.UrlString,
+ mimeType: 'text/typescript',
+ content: 'export class Foo {}',
+ });
+ const sourcesPanelFileOpenedSpy = sinon.spy(Host.userMetrics, 'sourcesPanelFileOpened');
+ const contentLoadedPromise = new Promise(res => window.addEventListener('source-file-loaded', res));
+ const widget = sourcesView.viewForFile(uiSourceCode);
+ assert.instanceOf(widget, Sources.UISourceCodeFrame.UISourceCodeFrame);
+ const uiSourceCodeFrame = widget as Sources.UISourceCodeFrame.UISourceCodeFrame;
+
+ // Skip creating the DebuggerPlugin, which times out and simulate DOM attach/showing.
+ sinon.stub(uiSourceCodeFrame, 'loadPlugins' as keyof typeof uiSourceCodeFrame).callsFake(() => {});
+ uiSourceCodeFrame.wasShown();
+
+ await contentLoadedPromise;
+
+ assert.isTrue(sourcesPanelFileOpenedSpy.calledWithExactly('text/typescript'));
+ });
+ });
+});
+
+describeWithMockConnection('SourcesView', () => {
+ let target1: SDK.Target.Target;
+ let target2: SDK.Target.Target;
+
+ beforeEach(() => {
+ const actionRegistryInstance = UI.ActionRegistry.ActionRegistry.instance({forceNew: true});
+ UI.ShortcutRegistry.ShortcutRegistry.instance({forceNew: true, actionRegistry: actionRegistryInstance});
+ target1 = createTarget();
+ target2 = createTarget();
+ const targetManager = target1.targetManager();
+ targetManager.setScopeTarget(target1);
+ const workspace = Workspace.Workspace.WorkspaceImpl.instance();
+
+ const resourceMapping = new Bindings.ResourceMapping.ResourceMapping(targetManager, workspace);
+ Bindings.CSSWorkspaceBinding.CSSWorkspaceBinding.instance({forceNew: true, resourceMapping, targetManager});
+ const debuggerWorkspaceBinding = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance({
+ forceNew: true,
+ resourceMapping,
+ targetManager,
+ });
+ Bindings.IgnoreListManager.IgnoreListManager.instance({forceNew: true, debuggerWorkspaceBinding});
+ const breakpointManager = Breakpoints.BreakpointManager.BreakpointManager.instance(
+ {forceNew: true, targetManager, workspace, debuggerWorkspaceBinding});
+ Persistence.Persistence.PersistenceImpl.instance({forceNew: true, workspace, breakpointManager});
+ Persistence.NetworkPersistenceManager.NetworkPersistenceManager.instance({forceNew: true, workspace});
+ });
+
+ it('creates editor tabs only for in-scope uiSourceCodes', () => {
+ const addUISourceCodeSpy =
+ sinon.spy(Sources.TabbedEditorContainer.TabbedEditorContainer.prototype, 'addUISourceCode');
+ const removeUISourceCodesSpy =
+ sinon.spy(Sources.TabbedEditorContainer.TabbedEditorContainer.prototype, 'removeUISourceCodes');
+
+ createContentProviderUISourceCodes({
+ items: [
+ {url: 'http://example.com/a.js' as Platform.DevToolsPath.UrlString, mimeType: 'application/javascript'},
+ {url: 'http://example.com/b.js' as Platform.DevToolsPath.UrlString, mimeType: 'application/javascript'},
+ ],
+ projectId: 'projectId1',
+ projectType: Workspace.Workspace.projectTypes.Network,
+ target: target1,
+ });
+
+ createContentProviderUISourceCodes({
+ items: [
+ {url: 'http://foo.com/script.js' as Platform.DevToolsPath.UrlString, mimeType: 'application/javascript'},
+ ],
+ projectId: 'projectId2',
+ projectType: Workspace.Workspace.projectTypes.Network,
+ target: target2,
+ });
+
+ new Sources.SourcesView.SourcesView();
+ let addedURLs = addUISourceCodeSpy.args.map(args => args[0].url());
+ assert.deepEqual(addedURLs, ['http://example.com/a.js', 'http://example.com/b.js']);
+ assert.isTrue(removeUISourceCodesSpy.notCalled);
+
+ addUISourceCodeSpy.resetHistory();
+ target2.targetManager().setScopeTarget(target2);
+ addedURLs = addUISourceCodeSpy.args.map(args => args[0].url());
+ assert.deepEqual(addedURLs, ['http://foo.com/script.js']);
+ const removedURLs = removeUISourceCodesSpy.args.map(args => args[0][0].url());
+ assert.deepEqual(removedURLs, ['http://example.com/a.js', 'http://example.com/b.js']);
+ });
+
+ it('doesn\'t remove non-network UISourceCodes when changing the scope target', () => {
+ createFileSystemUISourceCode({
+ url: 'snippet:///foo.js' as Platform.DevToolsPath.UrlString,
+ mimeType: 'application/javascript',
+ type: 'snippets',
+ });
+
+ const sourcesView = new Sources.SourcesView.SourcesView();
+ const removeUISourceCodesSpy = sinon.spy(sourcesView.editorContainer, 'removeUISourceCodes');
+ target2.targetManager().setScopeTarget(target2);
+ assert.isTrue(removeUISourceCodesSpy.notCalled);
+ });
+});
diff --git a/front_end/panels/sources/TabbedEditorContainer.test.ts b/front_end/panels/sources/TabbedEditorContainer.test.ts
new file mode 100644
index 0000000..07c9da9
--- /dev/null
+++ b/front_end/panels/sources/TabbedEditorContainer.test.ts
@@ -0,0 +1,130 @@
+// Copyright 2019 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.
+
+const {assert} = chai;
+
+import * as Common from '../../core/common/common.js';
+import type * as Platform from '../../core/platform/platform.js';
+import * as Sources from './sources.js';
+
+describe('TabbedEditorContainer', () => {
+ describe('HistoryItem', () => {
+ const {HistoryItem} = Sources.TabbedEditorContainer;
+ const url = 'http://localhost' as Platform.DevToolsPath.UrlString;
+
+ describe('fromObject', () => {
+ it('rejects invalid resource type names', () => {
+ assert.throws(() => {
+ HistoryItem.fromObject({url, resourceTypeName: 'some-invalid-resource-type-name'});
+ });
+ });
+
+ it('correctly deserializes resource type names', () => {
+ for (const resourceType of Object.values(Common.ResourceType.resourceTypes)) {
+ const resourceTypeName = resourceType.name();
+ assert.propertyVal(HistoryItem.fromObject({url, resourceTypeName}), 'resourceType', resourceType);
+ }
+ });
+ });
+
+ describe('toObject', () => {
+ it('correctly serializes resource types', () => {
+ for (const resourceType of Object.values(Common.ResourceType.resourceTypes)) {
+ const item = new HistoryItem(url, resourceType);
+ assert.propertyVal(item.toObject(), 'resourceTypeName', resourceType.name());
+ }
+ });
+ });
+ });
+
+ describe('History', () => {
+ const {History, HistoryItem} = Sources.TabbedEditorContainer;
+
+ describe('fromObject', () => {
+ it('deserializes correctly', () => {
+ const history = History.fromObject([
+ {url: 'http://localhost/foo.js', resourceTypeName: 'script'},
+ {url: 'webpack:///src/foo.vue', resourceTypeName: 'sm-script', scrollLineNumber: 5},
+ {url: 'http://localhost/foo.js', resourceTypeName: 'sm-script'},
+ ]);
+ const keys = history.keys();
+ assert.lengthOf(keys, 3);
+ assert.propertyVal(keys[0], 'url', 'http://localhost/foo.js');
+ assert.propertyVal(keys[0], 'resourceType', Common.ResourceType.resourceTypes.Script);
+ assert.strictEqual(history.selectionRange(keys[0]), undefined);
+ assert.strictEqual(history.scrollLineNumber(keys[0]), undefined);
+ assert.propertyVal(keys[1], 'url', 'webpack:///src/foo.vue');
+ assert.propertyVal(keys[1], 'resourceType', Common.ResourceType.resourceTypes.SourceMapScript);
+ assert.strictEqual(history.selectionRange(keys[1]), undefined);
+ assert.strictEqual(history.scrollLineNumber(keys[1]), 5);
+ assert.propertyVal(keys[2], 'url', 'http://localhost/foo.js');
+ assert.propertyVal(keys[2], 'resourceType', Common.ResourceType.resourceTypes.SourceMapScript);
+ assert.strictEqual(history.selectionRange(keys[2]), undefined);
+ assert.strictEqual(history.scrollLineNumber(keys[2]), undefined);
+ });
+
+ it('gracefully ignores items with invalid resource type names', () => {
+ const history = History.fromObject([
+ {url: 'http://localhost/foo.js', resourceTypeName: 'script'},
+ {url: 'http://localhost/baz.js', resourceTypeName: 'some-invalid-resource-type-name'},
+ {url: 'http://localhost/bar.js', resourceTypeName: 'sm-script'},
+ ]);
+ const keys = history.keys();
+ assert.lengthOf(keys, 2);
+ assert.propertyVal(keys[0], 'url', 'http://localhost/foo.js');
+ assert.propertyVal(keys[1], 'url', 'http://localhost/bar.js');
+ });
+ });
+
+ describe('toObject', () => {
+ it('serializes correctly', () => {
+ const history = new History([
+ new HistoryItem(
+ 'http://localhost/foo.js' as Platform.DevToolsPath.UrlString, Common.ResourceType.resourceTypes.Script),
+ new HistoryItem(
+ 'webpack:///src/foo.vue' as Platform.DevToolsPath.UrlString,
+ Common.ResourceType.resourceTypes.SourceMapScript, undefined, 5),
+ new HistoryItem(
+ 'http://localhost/foo.js' as Platform.DevToolsPath.UrlString,
+ Common.ResourceType.resourceTypes.SourceMapScript),
+ ]);
+ const serializedHistory = history.toObject();
+ assert.lengthOf(serializedHistory, 3);
+ assert.propertyVal(serializedHistory[0], 'url', 'http://localhost/foo.js');
+ assert.propertyVal(serializedHistory[0], 'resourceTypeName', 'script');
+ assert.propertyVal(serializedHistory[1], 'url', 'webpack:///src/foo.vue');
+ assert.propertyVal(serializedHistory[1], 'resourceTypeName', 'sm-script');
+ assert.propertyVal(serializedHistory[1], 'scrollLineNumber', 5);
+ assert.propertyVal(serializedHistory[2], 'url', 'http://localhost/foo.js');
+ assert.propertyVal(serializedHistory[2], 'resourceTypeName', 'sm-script');
+ });
+ });
+
+ describe('update', () => {
+ it('moves items referenced by keys to the beginning', () => {
+ const history = new History([
+ new HistoryItem(
+ 'webpack:///src/foo.vue' as Platform.DevToolsPath.UrlString,
+ Common.ResourceType.resourceTypes.SourceMapScript),
+ new HistoryItem(
+ 'http://localhost/foo.js' as Platform.DevToolsPath.UrlString, Common.ResourceType.resourceTypes.Script),
+ new HistoryItem(
+ 'http://localhost/foo.js' as Platform.DevToolsPath.UrlString,
+ Common.ResourceType.resourceTypes.SourceMapScript),
+ ]);
+ history.update([{
+ url: 'http://localhost/foo.js' as Platform.DevToolsPath.UrlString,
+ resourceType: Common.ResourceType.resourceTypes.Script,
+ }]);
+ assert.strictEqual(
+ history.index({
+ url: 'http://localhost/foo.js' as Platform.DevToolsPath.UrlString,
+ resourceType: Common.ResourceType.resourceTypes.Script,
+ }),
+ 0,
+ );
+ });
+ });
+ });
+});
diff --git a/front_end/panels/sources/components/BUILD.gn b/front_end/panels/sources/components/BUILD.gn
index 5d3f3f9..4006e08 100644
--- a/front_end/panels/sources/components/BUILD.gn
+++ b/front_end/panels/sources/components/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../../scripts/build/ninja/devtools_module.gni")
import("../../../../scripts/build/ninja/generate_css.gni")
+import("../../../../third_party/typescript/typescript.gni")
import("../../visibility.gni")
generate_css("css_files") {
@@ -51,3 +52,25 @@
visibility += devtools_panels_visibility
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [
+ "BreakpointsView.test.ts",
+ "BreakpointsViewUtils.test.ts",
+ "HeadersView.test.ts",
+ ]
+
+ deps = [
+ ":bundle",
+ "../../../../test/unittests/front_end/helpers",
+ "../../../core/common:bundle",
+ "../../../core/sdk:bundle",
+ "../../../models/bindings:bundle",
+ "../../../models/breakpoints:bundle",
+ "../../../models/workspace:bundle",
+ "../../../ui/components/render_coordinator:bundle",
+ "../../../ui/legacy:bundle",
+ ]
+}
diff --git a/front_end/panels/sources/components/BreakpointsView.test.ts b/front_end/panels/sources/components/BreakpointsView.test.ts
new file mode 100644
index 0000000..172fdc1
--- /dev/null
+++ b/front_end/panels/sources/components/BreakpointsView.test.ts
@@ -0,0 +1,1953 @@
+// 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 {
+ assertElement,
+ assertElements,
+ assertShadowRoot,
+ dispatchClickEvent,
+ dispatchKeyDownEvent,
+ renderElementIntoDOM,
+} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {
+ createTarget,
+ describeWithEnvironment,
+} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {describeWithMockConnection} from '../../../../test/unittests/front_end/helpers/MockConnection.js';
+import {describeWithRealConnection} from '../../../../test/unittests/front_end/helpers/RealConnection.js';
+import {
+ createContentProviderUISourceCode,
+ createFakeScriptMapping,
+ setupMockedUISourceCode,
+} from '../../../../test/unittests/front_end/helpers/UISourceCodeHelpers.js';
+import * as Common from '../../../core/common/common.js';
+import type * as Platform from '../../../core/platform/platform.js';
+import {assertNotNullOrUndefined} 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 Breakpoints from '../../../models/breakpoints/breakpoints.js';
+import * as Workspace from '../../../models/workspace/workspace.js';
+import * as Coordinator from '../../../ui/components/render_coordinator/render_coordinator.js';
+import * as UI from '../../../ui/legacy/legacy.js';
+
+import * as SourcesComponents from './components.js';
+
+const DETAILS_SELECTOR = 'details';
+const EXPANDED_GROUPS_SELECTOR = 'details[open]';
+const COLLAPSED_GROUPS_SELECTOR = 'details:not([open])';
+const CODE_SNIPPET_SELECTOR = '.code-snippet';
+const GROUP_NAME_SELECTOR = '.group-header-title';
+const BREAKPOINT_ITEM_SELECTOR = '.breakpoint-item';
+const HIT_BREAKPOINT_SELECTOR = BREAKPOINT_ITEM_SELECTOR + '.hit';
+const BREAKPOINT_LOCATION_SELECTOR = '.location';
+const REMOVE_FILE_BREAKPOINTS_SELECTOR = '.group-hover-actions > button[data-remove-breakpoint]';
+const REMOVE_SINGLE_BREAKPOINT_SELECTOR = '.breakpoint-item-location-or-actions > button[data-remove-breakpoint]';
+const EDIT_SINGLE_BREAKPOINT_SELECTOR = 'button[data-edit-breakpoint]';
+const PAUSE_ON_UNCAUGHT_EXCEPTIONS_SELECTOR = '.pause-on-uncaught-exceptions';
+const PAUSE_ON_CAUGHT_EXCEPTIONS_SELECTOR = '.pause-on-caught-exceptions';
+const TABBABLE_SELECTOR = '[tabindex="0"]';
+const SUMMARY_SELECTOR = 'summary';
+const GROUP_DIFFERENTIATOR_SELECTOR = '.group-header-differentiator';
+
+const HELLO_JS_FILE = 'hello.js';
+const TEST_JS_FILE = 'test.js';
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+interface LocationTestData {
+ url: Platform.DevToolsPath.UrlString;
+ lineNumber: number;
+ columnNumber: number;
+ enabled: boolean;
+ content: string;
+ condition: Breakpoints.BreakpointManager.UserCondition;
+ isLogpoint: boolean;
+ hoverText?: string;
+}
+
+function createBreakpointLocations(testData: LocationTestData[]): Breakpoints.BreakpointManager.BreakpointLocation[] {
+ const breakpointLocations = testData.map(data => {
+ const mocked = setupMockedUISourceCode(data.url);
+ const mockedContent = Promise.resolve({content: data.content, isEncoded: true});
+ sinon.stub(mocked.sut, 'requestContent').returns(mockedContent);
+ const uiLocation = new Workspace.UISourceCode.UILocation(mocked.sut, data.lineNumber, data.columnNumber);
+ const breakpoint = sinon.createStubInstance(Breakpoints.BreakpointManager.Breakpoint);
+ breakpoint.enabled.returns(data.enabled);
+ breakpoint.condition.returns(data.condition);
+ breakpoint.isLogpoint.returns(data.isLogpoint);
+ breakpoint.breakpointStorageId.returns(`${data.url}:${data.lineNumber}:${data.columnNumber}`);
+ return new Breakpoints.BreakpointManager.BreakpointLocation(breakpoint, uiLocation);
+ });
+ return breakpointLocations;
+}
+
+function createStubBreakpointManagerAndSettings() {
+ const breakpointManager = sinon.createStubInstance(Breakpoints.BreakpointManager.BreakpointManager);
+ breakpointManager.supportsConditionalBreakpoints.returns(true);
+ const dummyStorage = new Common.Settings.SettingsStorage({});
+ const settings = Common.Settings.Settings.instance({
+ forceNew: true,
+ syncedStorage: dummyStorage,
+ globalStorage: dummyStorage,
+ localStorage: dummyStorage,
+ });
+ return {breakpointManager, settings};
+}
+
+function createStubBreakpointManagerAndSettingsWithMockdata(testData: LocationTestData[]): {
+ breakpointManager: sinon.SinonStubbedInstance<Breakpoints.BreakpointManager.BreakpointManager>,
+ settings: Common.Settings.Settings,
+} {
+ const {breakpointManager, settings} = createStubBreakpointManagerAndSettings();
+ sinon.stub(Breakpoints.BreakpointManager.BreakpointManager, 'instance').returns(breakpointManager);
+ const breakpointLocations = createBreakpointLocations(testData);
+ breakpointManager.allBreakpointLocations.returns(breakpointLocations);
+ return {breakpointManager, settings};
+}
+
+function createLocationTestData(
+ url: string, lineNumber: number, columnNumber: number, enabled: boolean = true, content: string = '',
+ condition: Breakpoints.BreakpointManager.UserCondition = Breakpoints.BreakpointManager.EMPTY_BREAKPOINT_CONDITION,
+ isLogpoint: boolean = false, hoverText?: string): LocationTestData {
+ return {
+ url: url as Platform.DevToolsPath.UrlString,
+ lineNumber,
+ columnNumber,
+ enabled,
+ content,
+ condition,
+ isLogpoint,
+ hoverText,
+ };
+}
+
+async function setUpTestWithOneBreakpointLocation(
+ params: {file: string, lineNumber: number, columnNumber: number, enabled?: boolean, snippet?: string} = {
+ file: HELLO_JS_FILE,
+ lineNumber: 10,
+ columnNumber: 3,
+ enabled: true,
+ snippet: 'const a;',
+ }) {
+ const testData = [
+ createLocationTestData(params.file, params.lineNumber, params.columnNumber, params.enabled, params.snippet),
+ ];
+ const {breakpointManager, settings} = createStubBreakpointManagerAndSettingsWithMockdata(testData);
+
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance(
+ {forceNew: true, breakpointManager, settings});
+ const data = await controller.getUpdatedBreakpointViewData();
+
+ assert.lengthOf(data.groups, 1);
+ assert.lengthOf(data.groups[0].breakpointItems, 1);
+ const locations = Breakpoints.BreakpointManager.BreakpointManager.instance().allBreakpointLocations();
+ assert.lengthOf(locations, 1);
+ return {controller, groups: data.groups, location: locations[0]};
+}
+
+class MockRevealer<T> implements Common.Revealer.Revealer<T> {
+ async reveal(_revealable: T, _omitFocus?: boolean): Promise<void> {
+ }
+}
+
+async function createAndInitializeBreakpointsView(): Promise<SourcesComponents.BreakpointsView.BreakpointsView> {
+ // Force creation of a new BreakpointsView singleton so that it gets correctly re-wired with
+ // the current controller singleton (to pick up the latest breakpoint state).
+ const component = SourcesComponents.BreakpointsView.BreakpointsView.instance({forceNew: true});
+ await coordinator.done(); // Wait until the initial rendering finishes.
+ renderElementIntoDOM(component);
+ return component;
+}
+
+async function renderNoBreakpoints(
+ {pauseOnUncaughtExceptions, pauseOnCaughtExceptions, independentPauseToggles}:
+ {pauseOnUncaughtExceptions: boolean, pauseOnCaughtExceptions: boolean, independentPauseToggles: boolean}):
+ Promise<SourcesComponents.BreakpointsView.BreakpointsView> {
+ const component = await createAndInitializeBreakpointsView();
+
+ component.data = {
+ breakpointsActive: true,
+ pauseOnUncaughtExceptions,
+ pauseOnCaughtExceptions,
+ independentPauseToggles,
+ groups: [],
+ };
+ await coordinator.done();
+ return component;
+}
+
+async function renderSingleBreakpoint(
+ type: SDK.DebuggerModel.BreakpointType = SDK.DebuggerModel.BreakpointType.REGULAR_BREAKPOINT,
+ hoverText?: string): Promise<{
+ component: SourcesComponents.BreakpointsView.BreakpointsView,
+ data: SourcesComponents.BreakpointsView.BreakpointsViewData,
+}> {
+ // Only provide a hover text if it's not a regular breakpoint.
+ assert.isTrue(!hoverText || type !== SDK.DebuggerModel.BreakpointType.REGULAR_BREAKPOINT);
+ const component = await createAndInitializeBreakpointsView();
+
+ const data: SourcesComponents.BreakpointsView.BreakpointsViewData = {
+ breakpointsActive: true,
+ pauseOnUncaughtExceptions: false,
+ pauseOnCaughtExceptions: false,
+ independentPauseToggles: true,
+ groups: [
+ {
+ name: 'test1.js',
+ url: 'https://google.com/test1.js' as Platform.DevToolsPath.UrlString,
+ editable: true,
+ expanded: true,
+ breakpointItems: [
+ {
+ id: '1',
+ location: '1',
+ codeSnippet: 'const a = 0;',
+ isHit: true,
+ status: SourcesComponents.BreakpointsView.BreakpointStatus.ENABLED,
+ type,
+ hoverText,
+ },
+ ],
+ },
+ ],
+ };
+
+ component.data = data;
+ await coordinator.done();
+ return {component, data};
+}
+
+async function renderMultipleBreakpoints(): Promise<{
+ component: SourcesComponents.BreakpointsView.BreakpointsView,
+ data: SourcesComponents.BreakpointsView.BreakpointsViewData,
+}> {
+ const component = await createAndInitializeBreakpointsView();
+
+ const data: SourcesComponents.BreakpointsView.BreakpointsViewData = {
+ breakpointsActive: true,
+ pauseOnUncaughtExceptions: false,
+ pauseOnCaughtExceptions: false,
+ independentPauseToggles: true,
+ groups: [
+ {
+ name: 'test1.js',
+ url: 'https://google.com/test1.js' as Platform.DevToolsPath.UrlString,
+ editable: true,
+ expanded: true,
+ breakpointItems: [
+ {
+ id: '1',
+ type: SDK.DebuggerModel.BreakpointType.REGULAR_BREAKPOINT,
+ location: '234',
+ codeSnippet: 'const a = x;',
+ isHit: false,
+ status: SourcesComponents.BreakpointsView.BreakpointStatus.ENABLED,
+ },
+ {
+ id: '2',
+ type: SDK.DebuggerModel.BreakpointType.REGULAR_BREAKPOINT,
+ location: '3:3',
+ codeSnippet: 'if (x > a) {',
+ isHit: true,
+ status: SourcesComponents.BreakpointsView.BreakpointStatus.DISABLED,
+ },
+ ],
+ },
+ {
+ name: 'test2.js',
+ url: 'https://google.com/test2.js' as Platform.DevToolsPath.UrlString,
+ editable: false,
+ expanded: true,
+ breakpointItems: [
+ {
+ id: '3',
+ type: SDK.DebuggerModel.BreakpointType.REGULAR_BREAKPOINT,
+ location: '11',
+ codeSnippet: 'const y;',
+ isHit: false,
+ status: SourcesComponents.BreakpointsView.BreakpointStatus.ENABLED,
+ },
+ ],
+ },
+ {
+ name: 'main.js',
+ url: 'https://test.com/main.js' as Platform.DevToolsPath.UrlString,
+ editable: true,
+ expanded: false,
+ breakpointItems: [
+ {
+ id: '4',
+ type: SDK.DebuggerModel.BreakpointType.REGULAR_BREAKPOINT,
+ location: '3',
+ codeSnippet: 'if (a == 0) {',
+ isHit: false,
+ status: SourcesComponents.BreakpointsView.BreakpointStatus.ENABLED,
+ },
+ ],
+ },
+ ],
+ };
+ component.data = data;
+ await coordinator.done();
+ return {component, data};
+}
+
+function extractBreakpointItems(data: SourcesComponents.BreakpointsView.BreakpointsViewData):
+ SourcesComponents.BreakpointsView.BreakpointItem[] {
+ const breakpointItems = data.groups.flatMap(group => group.breakpointItems);
+ assert.isAbove(breakpointItems.length, 0);
+ return breakpointItems;
+}
+
+function checkCodeSnippet(
+ renderedBreakpointItem: HTMLDivElement, breakpointItem: SourcesComponents.BreakpointsView.BreakpointItem): void {
+ const snippetElement = renderedBreakpointItem.querySelector(CODE_SNIPPET_SELECTOR);
+ assertElement(snippetElement, HTMLSpanElement);
+ assert.strictEqual(snippetElement.textContent, breakpointItem.codeSnippet);
+}
+
+function checkCheckboxState(
+ checkbox: HTMLInputElement, breakpointItem: SourcesComponents.BreakpointsView.BreakpointItem): void {
+ const checked = checkbox.checked;
+ const indeterminate = checkbox.indeterminate;
+ if (breakpointItem.status === SourcesComponents.BreakpointsView.BreakpointStatus.INDETERMINATE) {
+ assert.isTrue(indeterminate);
+ } else {
+ assert.isFalse(indeterminate);
+ assert.strictEqual((breakpointItem.status === SourcesComponents.BreakpointsView.BreakpointStatus.ENABLED), checked);
+ }
+}
+
+function checkGroupNames(
+ renderedGroupElements: Element[], breakpointGroups: SourcesComponents.BreakpointsView.BreakpointGroup[]): void {
+ assert.lengthOf(renderedGroupElements, breakpointGroups.length);
+ for (let i = 0; i < renderedGroupElements.length; ++i) {
+ const renderedGroup = renderedGroupElements[i];
+ assertElement(renderedGroup, HTMLDetailsElement);
+ const titleElement = renderedGroup.querySelector(GROUP_NAME_SELECTOR);
+ assertElement(titleElement, HTMLSpanElement);
+ assert.strictEqual(titleElement.textContent, breakpointGroups[i].name);
+ }
+}
+
+function hover(component: SourcesComponents.BreakpointsView.BreakpointsView, selector: string): Promise<void> {
+ assertShadowRoot(component.shadowRoot);
+ // Dispatch a mouse over.
+ component.shadowRoot.querySelector(selector)?.dispatchEvent(new Event('mouseover'));
+ // Wait until the re-rendering has happened.
+ return coordinator.done();
+}
+
+describeWithMockConnection('targetSupportsIndependentPauseOnExceptionToggles', () => {
+ it('can correctly identify node targets as targets that are not supporting independent pause on exception toggles',
+ async () => {
+ const target = createTarget();
+ target.markAsNodeJSForTest();
+ const supportsIndependentPauses = SourcesComponents.BreakpointsView.BreakpointsSidebarController
+ .targetSupportsIndependentPauseOnExceptionToggles();
+ assert.isFalse(supportsIndependentPauses);
+ });
+
+ it('can correctly identify non-node targets as targets that are supporting independent pause on exception toggles',
+ async () => {
+ createTarget();
+ const supportsIndependentPauses = SourcesComponents.BreakpointsView.BreakpointsSidebarController
+ .targetSupportsIndependentPauseOnExceptionToggles();
+ assert.isTrue(supportsIndependentPauses);
+ });
+});
+
+describeWithEnvironment('BreakpointsSidebarController', () => {
+ after(() => {
+ SourcesComponents.BreakpointsView.BreakpointsSidebarController.removeInstance();
+ });
+
+ it('can remove a breakpoint', async () => {
+ const {groups, location} = await setUpTestWithOneBreakpointLocation();
+ const breakpoint = location.breakpoint as sinon.SinonStubbedInstance<Breakpoints.BreakpointManager.Breakpoint>;
+ const breakpointItem = groups[0].breakpointItems[0];
+
+ SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance().breakpointsRemoved([breakpointItem]);
+ assert.isTrue(breakpoint.remove.calledOnceWith(false));
+ });
+
+ it('changes breakpoint state', async () => {
+ const {groups, location} = await setUpTestWithOneBreakpointLocation();
+ const breakpointItem = groups[0].breakpointItems[0];
+ assert.strictEqual(breakpointItem.status, SourcesComponents.BreakpointsView.BreakpointStatus.ENABLED);
+
+ const breakpoint = location.breakpoint as sinon.SinonStubbedInstance<Breakpoints.BreakpointManager.Breakpoint>;
+ SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance().breakpointStateChanged(
+ breakpointItem, false);
+ assert.isTrue(breakpoint.setEnabled.calledWith(false));
+ });
+
+ it('correctly reveals source location', async () => {
+ const {groups, location: {uiLocation}} = await setUpTestWithOneBreakpointLocation();
+ const breakpointItem = groups[0].breakpointItems[0];
+ const revealer = sinon.createStubInstance(MockRevealer<Workspace.UISourceCode.UILocation>);
+
+ Common.Revealer.registerRevealer({
+ contextTypes() {
+ return [Workspace.UISourceCode.UILocation];
+ },
+ destination: Common.Revealer.RevealerDestination.SOURCES_PANEL,
+ async loadRevealer() {
+ return revealer;
+ },
+ });
+
+ await SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance().jumpToSource(breakpointItem);
+ assert.isTrue(revealer.reveal.calledOnceWith(uiLocation));
+ });
+
+ it('correctly reveals breakpoint editor', async () => {
+ const {groups, location} = await setUpTestWithOneBreakpointLocation();
+ const breakpointItem = groups[0].breakpointItems[0];
+ const revealer = sinon.createStubInstance(MockRevealer<Breakpoints.BreakpointManager.BreakpointLocation>);
+
+ Common.Revealer.registerRevealer({
+ contextTypes() {
+ return [Breakpoints.BreakpointManager.BreakpointLocation];
+ },
+ destination: Common.Revealer.RevealerDestination.SOURCES_PANEL,
+ async loadRevealer() {
+ return revealer;
+ },
+ });
+
+ await SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance().breakpointEdited(
+ breakpointItem, false /* editButtonClicked */);
+ assert.isTrue(revealer.reveal.calledOnceWith(location));
+ });
+
+ describe('getUpdatedBreakpointViewData', () => {
+ it('extracts breakpoint data', async () => {
+ const testData = [
+ createLocationTestData(HELLO_JS_FILE, 3, 10),
+ createLocationTestData(TEST_JS_FILE, 1, 1),
+ ];
+
+ const {breakpointManager, settings} = createStubBreakpointManagerAndSettingsWithMockdata(testData);
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance(
+ {forceNew: true, breakpointManager, settings});
+ const actual = await controller.getUpdatedBreakpointViewData();
+ const createExpectedBreakpointGroups = (testData: LocationTestData) => {
+ const status = testData.enabled ? SourcesComponents.BreakpointsView.BreakpointStatus.ENABLED :
+ SourcesComponents.BreakpointsView.BreakpointStatus.DISABLED;
+ let type = SDK.DebuggerModel.BreakpointType.REGULAR_BREAKPOINT;
+
+ if (testData.condition) {
+ if (testData.isLogpoint) {
+ type = SDK.DebuggerModel.BreakpointType.LOGPOINT;
+ } else {
+ type = SDK.DebuggerModel.BreakpointType.CONDITIONAL_BREAKPOINT;
+ }
+ }
+
+ return {
+ name: testData.url as string,
+ url: testData.url,
+ editable: true,
+ expanded: true,
+ breakpointItems: [
+ {
+ id: `${testData.url}:${testData.lineNumber}:${testData.columnNumber}`,
+ location: `${testData.lineNumber + 1}`,
+ codeSnippet: '',
+ isHit: false,
+ status,
+ type,
+ hoverText: testData.hoverText,
+ },
+ ],
+ };
+ };
+ const expected: SourcesComponents.BreakpointsView.BreakpointsViewData = {
+ breakpointsActive: true,
+ pauseOnUncaughtExceptions: false,
+ pauseOnCaughtExceptions: false,
+ independentPauseToggles: true,
+ groups: testData.map(createExpectedBreakpointGroups),
+ };
+ assert.deepEqual(actual, expected);
+ });
+
+ it('respects the breakpointsActive setting', async () => {
+ const {breakpointManager, settings} = createStubBreakpointManagerAndSettingsWithMockdata([]);
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance(
+ {forceNew: true, breakpointManager, settings});
+ settings.moduleSetting('breakpoints-active').set(true);
+ let data = await controller.getUpdatedBreakpointViewData();
+ assert.strictEqual(data.breakpointsActive, true);
+ settings.moduleSetting('breakpoints-active').set(false);
+ data = await controller.getUpdatedBreakpointViewData();
+ assert.strictEqual(data.breakpointsActive, false);
+ });
+
+ it('marks groups as editable based on conditional breakpoint support', async () => {
+ const testData = [
+ createLocationTestData(HELLO_JS_FILE, 3, 10),
+ createLocationTestData(TEST_JS_FILE, 1, 1),
+ ];
+
+ const {breakpointManager, settings} = createStubBreakpointManagerAndSettingsWithMockdata(testData);
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance(
+ {forceNew: true, breakpointManager, settings});
+ breakpointManager.supportsConditionalBreakpoints.returns(false);
+ for (const group of (await controller.getUpdatedBreakpointViewData()).groups) {
+ assert.isFalse(group.editable);
+ }
+ breakpointManager.supportsConditionalBreakpoints.returns(true);
+ for (const group of (await controller.getUpdatedBreakpointViewData()).groups) {
+ assert.isTrue(group.editable);
+ }
+ });
+
+ it('groups breakpoints that are in the same file', async () => {
+ const testData = [
+ createLocationTestData(HELLO_JS_FILE, 3, 10),
+ createLocationTestData(TEST_JS_FILE, 1, 1),
+ ];
+
+ const {breakpointManager, settings} = createStubBreakpointManagerAndSettingsWithMockdata(testData);
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance(
+ {forceNew: true, breakpointManager, settings});
+ const actualViewData = await controller.getUpdatedBreakpointViewData();
+ assert.lengthOf(actualViewData.groups, 2);
+ assert.lengthOf(actualViewData.groups[0].breakpointItems, 1);
+ assert.lengthOf(actualViewData.groups[1].breakpointItems, 1);
+ });
+
+ it('correctly sets the name of the group', async () => {
+ const {groups} = await setUpTestWithOneBreakpointLocation(
+ {file: HELLO_JS_FILE, lineNumber: 0, columnNumber: 0, enabled: false});
+ assert.strictEqual(groups[0].name, HELLO_JS_FILE);
+ });
+
+ it('only extracts the line number as location if one breakpoint is on that line', async () => {
+ const {groups} = await setUpTestWithOneBreakpointLocation(
+ {file: HELLO_JS_FILE, lineNumber: 4, columnNumber: 0, enabled: false});
+ assert.strictEqual(groups[0].breakpointItems[0].location, '5');
+ });
+
+ it('extracts the line number and column number as location if more than one breakpoint is on that line',
+ async () => {
+ const testData = [
+ createLocationTestData(HELLO_JS_FILE, 3, 10),
+ createLocationTestData(HELLO_JS_FILE, 3, 15),
+ ];
+
+ const {breakpointManager, settings} = createStubBreakpointManagerAndSettingsWithMockdata(testData);
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance(
+ {forceNew: true, breakpointManager, settings});
+ const actualViewData = await controller.getUpdatedBreakpointViewData();
+ assert.lengthOf(actualViewData.groups, 1);
+ assert.lengthOf(actualViewData.groups[0].breakpointItems, 2);
+ assert.strictEqual(actualViewData.groups[0].breakpointItems[0].location, '4:11');
+ assert.strictEqual(actualViewData.groups[0].breakpointItems[1].location, '4:16');
+ });
+
+ it('orders breakpoints within a file by location', async () => {
+ const testData = [
+ createLocationTestData(HELLO_JS_FILE, 3, 15),
+ createLocationTestData(HELLO_JS_FILE, 3, 10),
+ ];
+
+ const {breakpointManager, settings} = createStubBreakpointManagerAndSettingsWithMockdata(testData);
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance(
+ {forceNew: true, breakpointManager, settings});
+ const actualViewData = await controller.getUpdatedBreakpointViewData();
+ assert.lengthOf(actualViewData.groups, 1);
+ assert.lengthOf(actualViewData.groups[0].breakpointItems, 2);
+ assert.strictEqual(actualViewData.groups[0].breakpointItems[0].location, '4:11');
+ assert.strictEqual(actualViewData.groups[0].breakpointItems[1].location, '4:16');
+ });
+
+ it('orders breakpoints within groups by location', async () => {
+ const testData = [
+ createLocationTestData(TEST_JS_FILE, 3, 15),
+ createLocationTestData(HELLO_JS_FILE, 3, 10),
+ ];
+
+ const {breakpointManager, settings} = createStubBreakpointManagerAndSettingsWithMockdata(testData);
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance(
+ {forceNew: true, breakpointManager, settings});
+ const actualViewData = await controller.getUpdatedBreakpointViewData();
+ assert.lengthOf(actualViewData.groups, 2);
+ const names = actualViewData.groups.map(group => group.name);
+ assert.deepEqual(names, [HELLO_JS_FILE, TEST_JS_FILE]);
+ });
+
+ it('merges breakpoints mapping to the same location into one', async () => {
+ const testData = [
+ createLocationTestData(TEST_JS_FILE, 3, 15),
+ createLocationTestData(TEST_JS_FILE, 3, 15),
+ ];
+
+ const {breakpointManager, settings} = createStubBreakpointManagerAndSettingsWithMockdata(testData);
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance(
+ {forceNew: true, breakpointManager, settings});
+ const actualViewData = await controller.getUpdatedBreakpointViewData();
+ assert.lengthOf(actualViewData.groups, 1);
+ assert.lengthOf(actualViewData.groups[0].breakpointItems, 1);
+ });
+
+ it('correctly extracts the enabled state', async () => {
+ const {groups} =
+ await setUpTestWithOneBreakpointLocation({file: '', lineNumber: 0, columnNumber: 0, enabled: true});
+ const breakpointItem = groups[0].breakpointItems[0];
+ assert.strictEqual(breakpointItem.status, SourcesComponents.BreakpointsView.BreakpointStatus.ENABLED);
+ });
+
+ it('correctly extracts the enabled state', async () => {
+ const {groups} =
+ await setUpTestWithOneBreakpointLocation({file: '', lineNumber: 0, columnNumber: 0, enabled: false});
+ const breakpointItem = groups[0].breakpointItems[0];
+ assert.strictEqual(breakpointItem.status, SourcesComponents.BreakpointsView.BreakpointStatus.DISABLED);
+ });
+
+ it('correctly extracts the enabled state', async () => {
+ const testData = [
+ createLocationTestData(TEST_JS_FILE, 3, 15, true /* enabled */),
+ createLocationTestData(TEST_JS_FILE, 3, 15, false /* enabled */),
+ ];
+
+ const {breakpointManager, settings} = createStubBreakpointManagerAndSettingsWithMockdata(testData);
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance(
+ {forceNew: true, breakpointManager, settings});
+ const actualViewData = await controller.getUpdatedBreakpointViewData();
+ assert.lengthOf(actualViewData.groups, 1);
+ assert.lengthOf(actualViewData.groups[0].breakpointItems, 1);
+ assert.strictEqual(
+ actualViewData.groups[0].breakpointItems[0].status,
+ SourcesComponents.BreakpointsView.BreakpointStatus.INDETERMINATE);
+ });
+
+ it('correctly extracts the disabled state', async () => {
+ const snippet = 'const a = x;';
+ const {groups} =
+ await setUpTestWithOneBreakpointLocation({file: '', lineNumber: 0, columnNumber: 0, enabled: false, snippet});
+ assert.strictEqual(groups[0].breakpointItems[0].codeSnippet, snippet);
+ });
+
+ it('correctly extracts the indeterminate state', async () => {
+ const testData = [
+ createLocationTestData(TEST_JS_FILE, 3, 15, true /* enabled */),
+ createLocationTestData(TEST_JS_FILE, 3, 15, false /* enabled */),
+ ];
+
+ const {breakpointManager, settings} = createStubBreakpointManagerAndSettingsWithMockdata(testData);
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance(
+ {forceNew: true, breakpointManager, settings});
+ const actualViewData = await controller.getUpdatedBreakpointViewData();
+ assert.lengthOf(actualViewData.groups, 1);
+ assert.lengthOf(actualViewData.groups[0].breakpointItems, 1);
+ assert.strictEqual(
+ actualViewData.groups[0].breakpointItems[0].status,
+ SourcesComponents.BreakpointsView.BreakpointStatus.INDETERMINATE);
+ });
+
+ it('correctly extracts conditional breakpoints', async () => {
+ const condition = 'x < a' as Breakpoints.BreakpointManager.UserCondition;
+ const testData = [
+ createLocationTestData(
+ TEST_JS_FILE, 3, 15, true /* enabled */, '', condition, false /* isLogpoint */, condition),
+ ];
+
+ const {breakpointManager, settings} = createStubBreakpointManagerAndSettingsWithMockdata(testData);
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance(
+ {forceNew: true, breakpointManager, settings});
+ const actualViewData = await controller.getUpdatedBreakpointViewData();
+ assert.lengthOf(actualViewData.groups, 1);
+ assert.lengthOf(actualViewData.groups[0].breakpointItems, 1);
+ const breakpointItem = actualViewData.groups[0].breakpointItems[0];
+ assert.strictEqual(breakpointItem.type, SDK.DebuggerModel.BreakpointType.CONDITIONAL_BREAKPOINT);
+ assert.strictEqual(breakpointItem.hoverText, condition);
+ });
+
+ it('correctly extracts logpoints', async () => {
+ const logExpression = 'x' as Breakpoints.BreakpointManager.UserCondition;
+ const testData = [
+ createLocationTestData(
+ TEST_JS_FILE, 3, 15, true /* enabled */, '', logExpression, true /* isLogpoint */, logExpression),
+ ];
+
+ const {breakpointManager, settings} = createStubBreakpointManagerAndSettingsWithMockdata(testData);
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance(
+ {forceNew: true, breakpointManager, settings});
+ const actualViewData = await controller.getUpdatedBreakpointViewData();
+ assert.lengthOf(actualViewData.groups, 1);
+ assert.lengthOf(actualViewData.groups[0].breakpointItems, 1);
+ const breakpointItem = actualViewData.groups[0].breakpointItems[0];
+ assert.strictEqual(breakpointItem.type, SDK.DebuggerModel.BreakpointType.LOGPOINT);
+ assert.strictEqual(breakpointItem.hoverText, logExpression);
+ });
+
+ describe('breakpoint groups', () => {
+ it('are expanded by default', async () => {
+ const {controller} = await setUpTestWithOneBreakpointLocation();
+ const actualViewData = await controller.getUpdatedBreakpointViewData();
+ assert.isTrue(actualViewData.groups[0].expanded);
+ });
+
+ it('are collapsed if user collapses it', async () => {
+ const {controller, groups} = await setUpTestWithOneBreakpointLocation();
+ controller.expandedStateChanged(groups[0].url, false /* expanded */);
+ const actualViewData = await controller.getUpdatedBreakpointViewData();
+ assert.isFalse(actualViewData.groups[0].expanded);
+ });
+
+ it('are expanded if user expands it', async () => {
+ const {controller, groups} = await setUpTestWithOneBreakpointLocation();
+ controller.expandedStateChanged(groups[0].url, true /* expanded */);
+ const actualViewData = await controller.getUpdatedBreakpointViewData();
+ assert.isTrue(actualViewData.groups[0].expanded);
+ });
+
+ it('remember the collapsed state', async () => {
+ {
+ const {controller, groups} = await setUpTestWithOneBreakpointLocation();
+ controller.expandedStateChanged(groups[0].url, false /* expanded */);
+ const actualViewData = await controller.getUpdatedBreakpointViewData();
+ assert.isFalse(actualViewData.groups[0].expanded);
+ }
+
+ // A new controller is created and initialized with the expanded settings.
+ {const breakpointManager = Breakpoints.BreakpointManager.BreakpointManager.instance();
+ const settings = Common.Settings.Settings.instance();
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance({
+ forceNew: true,
+ breakpointManager,
+ settings,
+ });
+ const actualViewData = await controller.getUpdatedBreakpointViewData();
+ assert.isFalse(actualViewData.groups[0].expanded);}
+ });
+
+ it('remember the expanded state', async () => {
+ {
+ const {controller, groups} = await setUpTestWithOneBreakpointLocation();
+ controller.expandedStateChanged(groups[0].url, true /* expanded */);
+ const actualViewData = await controller.getUpdatedBreakpointViewData();
+ assert.isTrue(actualViewData.groups[0].expanded);
+ }
+ // A new controller is created and initialized with the expanded settings.
+ {
+
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance({
+ forceNew: true,
+ breakpointManager: Breakpoints.BreakpointManager.BreakpointManager.instance(),
+ settings: Common.Settings.Settings.instance(),
+ });
+ const actualViewData = await controller.getUpdatedBreakpointViewData();
+ assert.isTrue(actualViewData.groups[0].expanded);
+
+ }
+ });
+ });
+ });
+});
+
+describeWithRealConnection('BreakpointsSidebarController', () => {
+ const DEFAULT_BREAKPOINT:
+ [Breakpoints.BreakpointManager.UserCondition, boolean, boolean, Breakpoints.BreakpointManager.BreakpointOrigin] =
+ [
+ Breakpoints.BreakpointManager.EMPTY_BREAKPOINT_CONDITION,
+ true, // enabled
+ false, // isLogpoint
+ Breakpoints.BreakpointManager.BreakpointOrigin.USER_ACTION,
+ ];
+
+ it('auto-expands if a user adds a new breakpoint', async () => {
+ const breakpointManager = Breakpoints.BreakpointManager.BreakpointManager.instance();
+ const settings = Common.Settings.Settings.instance();
+ const {uiSourceCode, project} = createContentProviderUISourceCode(
+ {url: 'test.js' as Platform.DevToolsPath.UrlString, mimeType: 'text/javascript'});
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance(
+ {forceNew: true, breakpointManager, settings});
+
+ // Add one breakpoint and collapse the tree.
+ const b1 = await breakpointManager.setBreakpoint(uiSourceCode, 0, 0, ...DEFAULT_BREAKPOINT);
+ assertNotNullOrUndefined(b1);
+ {
+ controller.expandedStateChanged(uiSourceCode.url(), false /* expanded */);
+ const data = await controller.getUpdatedBreakpointViewData();
+ assert.lengthOf(data.groups, 1);
+ assert.lengthOf(data.groups[0].breakpointItems, 1);
+ assert.isFalse(data.groups[0].expanded);
+ }
+
+ // Add a new breakpoint and check if it's expanded as expected.
+ const b2 = await breakpointManager.setBreakpoint(uiSourceCode, 0, 3, ...DEFAULT_BREAKPOINT);
+ assertNotNullOrUndefined(b2);
+ {
+ const data = await controller.getUpdatedBreakpointViewData();
+ assert.lengthOf(data.groups, 1);
+ assert.lengthOf(data.groups[0].breakpointItems, 2);
+ assert.isTrue(data.groups[0].expanded);
+ }
+
+ // Clean up.
+ await b1.remove(false /* keepInStorage */);
+ await b2.remove(false /* keepInStorage */);
+ Workspace.Workspace.WorkspaceImpl.instance().removeProject(project);
+ });
+
+ it('does not auto-expand if a breakpoint was not triggered by user action', async () => {
+ const breakpointManager = Breakpoints.BreakpointManager.BreakpointManager.instance();
+ const settings = Common.Settings.Settings.instance();
+ const {uiSourceCode, project} = createContentProviderUISourceCode(
+ {url: 'test.js' as Platform.DevToolsPath.UrlString, mimeType: 'text/javascript'});
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance(
+ {forceNew: true, breakpointManager, settings});
+
+ // Add one breakpoint and collapse the tree.
+ const b1 = await breakpointManager.setBreakpoint(uiSourceCode, 0, 0, ...DEFAULT_BREAKPOINT);
+ assertNotNullOrUndefined(b1);
+ {
+ controller.expandedStateChanged(uiSourceCode.url(), false /* expanded */);
+ const data = await controller.getUpdatedBreakpointViewData();
+ assert.lengthOf(data.groups, 1);
+ assert.lengthOf(data.groups[0].breakpointItems, 1);
+ assert.isFalse(data.groups[0].expanded);
+ }
+
+ // Add a new non-user triggered breakpoint and check if it's still collapsed.
+ const b2 = await breakpointManager.setBreakpoint(
+ uiSourceCode, 0, 3, Breakpoints.BreakpointManager.EMPTY_BREAKPOINT_CONDITION, true, false,
+ Breakpoints.BreakpointManager.BreakpointOrigin.OTHER);
+ assertNotNullOrUndefined(b2);
+ {
+ const data = await controller.getUpdatedBreakpointViewData();
+ assert.lengthOf(data.groups, 1);
+ assert.lengthOf(data.groups[0].breakpointItems, 2);
+ assert.isFalse(data.groups[0].expanded);
+ }
+
+ // Clean up.
+ await b1.remove(false /* keepInStorage */);
+ await b2.remove(false /* keepInStorage */);
+ Workspace.Workspace.WorkspaceImpl.instance().removeProject(project);
+ });
+
+ it('auto-expands if a breakpoint was hit', async () => {
+ const breakpointManager = Breakpoints.BreakpointManager.BreakpointManager.instance();
+
+ // Set up sdk and ui location, and a mapping between them, such that we can identify that
+ // the hit breakpoint is the one we are adding.
+ const scriptId = '0' as Protocol.Runtime.ScriptId;
+
+ const {uiSourceCode, project} = createContentProviderUISourceCode(
+ {url: 'test.js' as Platform.DevToolsPath.UrlString, mimeType: 'text/javascript'});
+ const uiLocation = new Workspace.UISourceCode.UILocation(uiSourceCode, 0, 0);
+
+ const debuggerModel = sinon.createStubInstance(SDK.DebuggerModel.DebuggerModel);
+ const sdkLocation = new SDK.DebuggerModel.Location(debuggerModel, scriptId, 0);
+
+ const mapping = createFakeScriptMapping(debuggerModel, uiSourceCode, 0, scriptId);
+ Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance().addSourceMapping(mapping);
+
+ // Add one breakpoint and collapse its group.
+ const b1 = await breakpointManager.setBreakpoint(
+ uiSourceCode, uiLocation.lineNumber, uiLocation.columnNumber, ...DEFAULT_BREAKPOINT);
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance(
+ {forceNew: true, breakpointManager, settings: Common.Settings.Settings.instance()});
+ assertNotNullOrUndefined(b1);
+ controller.expandedStateChanged(uiSourceCode.url(), false /* expanded */);
+
+ // Double check that the group is collapsed.
+ {
+ const data = await controller.getUpdatedBreakpointViewData();
+ assert.isFalse(data.groups[0].expanded);
+ }
+
+ // Simulating a breakpoint hit. Update the DebuggerPausedDetails to contain the info on the hit breakpoint.
+ const callFrame = sinon.createStubInstance(SDK.DebuggerModel.CallFrame);
+ callFrame.location.returns(new SDK.DebuggerModel.Location(debuggerModel, scriptId, sdkLocation.lineNumber));
+ const pausedDetails = sinon.createStubInstance(SDK.DebuggerModel.DebuggerPausedDetails);
+ pausedDetails.callFrames = [callFrame];
+
+ // Instead of setting the flavor, directly call `flavorChanged` on the controller and mock what it's set to.
+ // Setting the flavor would have other listeners listening to it, and would cause undesirable side effects.
+ sinon.stub(UI.Context.Context.instance(), 'flavor')
+ .callsFake(flavorType => flavorType === SDK.DebuggerModel.DebuggerPausedDetails ? pausedDetails : null);
+ controller.flavorChanged(pausedDetails);
+ {
+ const data = await controller.getUpdatedBreakpointViewData();
+ // Assert that the breakpoint is hit and the group is expanded.
+ assert.isTrue(data.groups[0].breakpointItems[0].isHit);
+ assert.isTrue(data.groups[0].expanded);
+ }
+
+ // Clean up.
+ await b1.remove(false /* keepInStorage */);
+ Workspace.Workspace.WorkspaceImpl.instance().removeProject(project);
+ Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance().removeSourceMapping(mapping);
+ });
+
+ it('changes pause on exception state', async () => {
+ const {breakpointManager, settings} = createStubBreakpointManagerAndSettings();
+ breakpointManager.allBreakpointLocations.returns([]);
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance(
+ {forceNew: true, breakpointManager, settings});
+ for (const pauseOnUncaughtExceptions of [true, false]) {
+ for (const pauseOnCaughtExceptions of [true, false]) {
+ controller.setPauseOnUncaughtExceptions(pauseOnUncaughtExceptions);
+ controller.setPauseOnCaughtExceptions(pauseOnCaughtExceptions);
+
+ const data = await controller.getUpdatedBreakpointViewData();
+ assert.strictEqual(data.pauseOnUncaughtExceptions, pauseOnUncaughtExceptions);
+ assert.strictEqual(data.pauseOnCaughtExceptions, pauseOnCaughtExceptions);
+ assert.strictEqual(settings.moduleSetting('pause-on-uncaught-exception').get(), pauseOnUncaughtExceptions);
+ assert.strictEqual(settings.moduleSetting('pause-on-caught-exception').get(), pauseOnCaughtExceptions);
+ }
+ }
+ });
+});
+
+describeWithMockConnection('BreakpointsView', () => {
+ beforeEach(() => {
+ const workspace = Workspace.Workspace.WorkspaceImpl.instance();
+ const targetManager = SDK.TargetManager.TargetManager.instance();
+ const resourceMapping = new Bindings.ResourceMapping.ResourceMapping(targetManager, workspace);
+ const debuggerWorkspaceBinding = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance({
+ forceNew: true,
+ resourceMapping,
+ targetManager,
+ });
+ Breakpoints.BreakpointManager.BreakpointManager.instance(
+ {forceNew: true, targetManager, workspace, debuggerWorkspaceBinding});
+ });
+
+ it('correctly expands breakpoint groups', async () => {
+ const {component, data} = await renderMultipleBreakpoints();
+ assertShadowRoot(component.shadowRoot);
+
+ const expandedGroups = data.groups.filter(group => group.expanded);
+ assert.isAbove(expandedGroups.length, 0);
+
+ const renderedExpandedGroups = Array.from(component.shadowRoot.querySelectorAll(EXPANDED_GROUPS_SELECTOR));
+ assert.lengthOf(renderedExpandedGroups, expandedGroups.length);
+
+ checkGroupNames(renderedExpandedGroups, expandedGroups);
+ });
+
+ it('correctly collapses breakpoint groups', async () => {
+ const {component, data} = await renderMultipleBreakpoints();
+ assertShadowRoot(component.shadowRoot);
+
+ const collapsedGroups = data.groups.filter(group => !group.expanded);
+ assert.isAbove(collapsedGroups.length, 0);
+
+ const renderedCollapsedGroups = Array.from(component.shadowRoot.querySelectorAll(COLLAPSED_GROUPS_SELECTOR));
+
+ checkGroupNames(renderedCollapsedGroups, collapsedGroups);
+ });
+
+ it('renders the group names', async () => {
+ const {component, data} = await renderMultipleBreakpoints();
+ assertShadowRoot(component.shadowRoot);
+
+ const renderedGroupNames = component.shadowRoot.querySelectorAll(GROUP_NAME_SELECTOR);
+ assertElements(renderedGroupNames, HTMLSpanElement);
+
+ const expectedNames = data.groups.flatMap(group => group.name);
+ const actualNames = [];
+ for (const renderedGroupName of renderedGroupNames.values()) {
+ actualNames.push(renderedGroupName.textContent);
+ }
+ assert.deepEqual(actualNames, expectedNames);
+ });
+
+ it('renders the breakpoints with their checkboxes', async () => {
+ const {component, data} = await renderMultipleBreakpoints();
+ assertShadowRoot(component.shadowRoot);
+
+ const renderedBreakpointItems = Array.from(component.shadowRoot.querySelectorAll(BREAKPOINT_ITEM_SELECTOR));
+
+ const breakpointItems = extractBreakpointItems(data);
+ assert.lengthOf(renderedBreakpointItems, breakpointItems.length);
+
+ for (let i = 0; i < renderedBreakpointItems.length; ++i) {
+ const renderedItem = renderedBreakpointItems[i];
+ assertElement(renderedItem, HTMLDivElement);
+
+ const inputElement = renderedItem.querySelector('input');
+ assertElement(inputElement, HTMLInputElement);
+ checkCheckboxState(inputElement, breakpointItems[i]);
+ }
+ });
+
+ it('renders breakpoints with their code snippet', async () => {
+ const {component, data} = await renderMultipleBreakpoints();
+ assertShadowRoot(component.shadowRoot);
+
+ const renderedBreakpointItems = Array.from(component.shadowRoot.querySelectorAll(BREAKPOINT_ITEM_SELECTOR));
+
+ const breakpointItems = extractBreakpointItems(data);
+ assert.lengthOf(renderedBreakpointItems, breakpointItems.length);
+
+ for (let i = 0; i < renderedBreakpointItems.length; ++i) {
+ const renderedBreakpointItem = renderedBreakpointItems[i];
+ assertElement(renderedBreakpointItem, HTMLDivElement);
+ checkCodeSnippet(renderedBreakpointItem, breakpointItems[i]);
+ }
+ });
+
+ it('renders breakpoint groups with a differentiator if the file names are not unique', async () => {
+ const component = await createAndInitializeBreakpointsView();
+
+ const groupTemplate = {
+ name: 'index.js',
+ url: '' as Platform.DevToolsPath.UrlString,
+ editable: true,
+ expanded: true,
+ breakpointItems: [
+ {
+ id: '1',
+ type: SDK.DebuggerModel.BreakpointType.REGULAR_BREAKPOINT,
+ location: '234',
+ codeSnippet: 'const a = x;',
+ isHit: false,
+ status: SourcesComponents.BreakpointsView.BreakpointStatus.ENABLED,
+ },
+ ],
+ };
+
+ // Create two groups with the same file name, but different url.
+ const group1 = {...groupTemplate};
+ group1.url = 'https://google.com/lib/index.js' as Platform.DevToolsPath.UrlString;
+
+ const group2 = {...groupTemplate};
+ group2.url = 'https://google.com/src/index.js' as Platform.DevToolsPath.UrlString;
+
+ const data: SourcesComponents.BreakpointsView.BreakpointsViewData = {
+ breakpointsActive: true,
+ pauseOnUncaughtExceptions: false,
+ pauseOnCaughtExceptions: false,
+ independentPauseToggles: true,
+ groups: [
+ group1,
+ group2,
+ ],
+ };
+ component.data = data;
+ await coordinator.done();
+
+ assertShadowRoot(component.shadowRoot);
+ const groupSummaries = Array.from(component.shadowRoot.querySelectorAll(SUMMARY_SELECTOR));
+ const differentiatingPath = groupSummaries.map(group => {
+ const differentiatorElement = group.querySelector(GROUP_DIFFERENTIATOR_SELECTOR);
+ assertElement(differentiatorElement, HTMLSpanElement);
+ return differentiatorElement.textContent;
+ });
+ assert.deepEqual(differentiatingPath, ['lib/', 'src/']);
+ });
+
+ it('renders breakpoints with a differentiating path', async () => {
+ const {component, data} = await renderMultipleBreakpoints();
+ assertShadowRoot(component.shadowRoot);
+
+ const renderedBreakpointItems = Array.from(component.shadowRoot.querySelectorAll(BREAKPOINT_ITEM_SELECTOR));
+
+ const breakpointItems = extractBreakpointItems(data);
+ assert.lengthOf(renderedBreakpointItems, breakpointItems.length);
+
+ for (let i = 0; i < renderedBreakpointItems.length; ++i) {
+ const renderedBreakpointItem = renderedBreakpointItems[i];
+ assertElement(renderedBreakpointItem, HTMLDivElement);
+
+ const locationElement = renderedBreakpointItem.querySelector(BREAKPOINT_LOCATION_SELECTOR);
+ assertElement(locationElement, HTMLSpanElement);
+
+ const actualLocation = locationElement.textContent;
+ const expectedLocation = breakpointItems[i].location;
+
+ assert.strictEqual(actualLocation, expectedLocation);
+ }
+ });
+
+ it('triggers an event on clicking the checkbox of a breakpoint', async () => {
+ const {component, data} = await renderSingleBreakpoint();
+ assertShadowRoot(component.shadowRoot);
+
+ const renderedItem = component.shadowRoot.querySelector(BREAKPOINT_ITEM_SELECTOR);
+ assertElement(renderedItem, HTMLDivElement);
+
+ const checkbox = renderedItem.querySelector('input');
+ assertElement(checkbox, HTMLInputElement);
+ const checked = checkbox.checked;
+
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance();
+ const breakpointStateChanged = sinon.stub(controller, 'breakpointStateChanged');
+ checkbox.click();
+
+ assert.isTrue(breakpointStateChanged.calledOnceWith(data.groups[0].breakpointItems[0], !checked));
+ });
+
+ it('triggers an event on clicking on the snippet text', async () => {
+ const {component, data} = await renderSingleBreakpoint();
+ assertShadowRoot(component.shadowRoot);
+
+ const snippet = component.shadowRoot.querySelector(CODE_SNIPPET_SELECTOR);
+ assertElement(snippet, HTMLSpanElement);
+
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance();
+ const jumpToSource = sinon.stub(controller, 'jumpToSource');
+ snippet.click();
+
+ assert.isTrue(jumpToSource.calledOnceWith(data.groups[0].breakpointItems[0]));
+ });
+
+ it('triggers an event on expanding/unexpanding', async () => {
+ const {component, data} = await renderSingleBreakpoint();
+ assertShadowRoot(component.shadowRoot);
+
+ const renderedGroupName = component.shadowRoot.querySelector(GROUP_NAME_SELECTOR);
+ assertElement(renderedGroupName, HTMLSpanElement);
+
+ const expandedInitialValue = data.groups[0].expanded;
+
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance();
+ const expandedStateChanged = sinon.stub(controller, 'expandedStateChanged');
+ renderedGroupName.click();
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+ const group = data.groups[0];
+ assert.isTrue(expandedStateChanged.calledOnceWith(group.url, group.expanded));
+ assert.notStrictEqual(group.expanded, expandedInitialValue);
+ });
+
+ it('highlights breakpoint if it is set to be hit', async () => {
+ const {component} = await renderSingleBreakpoint();
+ assertShadowRoot(component.shadowRoot);
+
+ const renderedBreakpointItem = component.shadowRoot.querySelector(HIT_BREAKPOINT_SELECTOR);
+ assertElement(renderedBreakpointItem, HTMLDivElement);
+ });
+
+ it('triggers an event on removing file breakpoints', async () => {
+ const {component, data} = await renderMultipleBreakpoints();
+ assertShadowRoot(component.shadowRoot);
+
+ await hover(component, SUMMARY_SELECTOR);
+
+ const removeFileBreakpointsButton = component.shadowRoot.querySelector(REMOVE_FILE_BREAKPOINTS_SELECTOR);
+ assertElement(removeFileBreakpointsButton, HTMLButtonElement);
+
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance();
+ const breakpointsRemoved = sinon.stub(controller, 'breakpointsRemoved');
+ removeFileBreakpointsButton.click();
+ // await new Promise(resolve => setTimeout(resolve, 0));
+ assert.isTrue(breakpointsRemoved.calledOnceWith(data.groups[0].breakpointItems));
+ });
+
+ it('triggers an event on removing one breakpoint', async () => {
+ const {component, data} = await renderMultipleBreakpoints();
+ assertShadowRoot(component.shadowRoot);
+
+ await hover(component, BREAKPOINT_ITEM_SELECTOR);
+
+ const removeFileBreakpointsButton = component.shadowRoot.querySelector(REMOVE_SINGLE_BREAKPOINT_SELECTOR);
+ assertElement(removeFileBreakpointsButton, HTMLButtonElement);
+
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance();
+ const breakpointsRemoved = sinon.stub(controller, 'breakpointsRemoved');
+ removeFileBreakpointsButton.click();
+ // await new Promise(resolve => setTimeout(resolve, 0));
+ assert.isTrue(breakpointsRemoved.calledOnce);
+ assert.deepEqual(breakpointsRemoved.firstCall.firstArg, [data.groups[0].breakpointItems[0]]);
+ });
+
+ it('triggers an event on editing one breakpoint', async () => {
+ const {component, data} = await renderMultipleBreakpoints();
+ assertShadowRoot(component.shadowRoot);
+
+ await hover(component, BREAKPOINT_ITEM_SELECTOR);
+
+ const editBreakpointButton = component.shadowRoot.querySelector(EDIT_SINGLE_BREAKPOINT_SELECTOR);
+ assertElement(editBreakpointButton, HTMLButtonElement);
+
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance();
+ const breakpointEdited = sinon.stub(controller, 'breakpointEdited');
+ editBreakpointButton.click();
+ // await new Promise(resolve => setTimeout(resolve, 0));
+ assert.isTrue(breakpointEdited.calledOnceWith(data.groups[0].breakpointItems[0], true));
+ });
+
+ it('shows a tooltip with edit condition on regular breakpoints', async () => {
+ const {component} = await renderSingleBreakpoint(SDK.DebuggerModel.BreakpointType.REGULAR_BREAKPOINT);
+ assertShadowRoot(component.shadowRoot);
+
+ await hover(component, BREAKPOINT_ITEM_SELECTOR);
+
+ const editBreakpointButton = component.shadowRoot.querySelector(EDIT_SINGLE_BREAKPOINT_SELECTOR);
+ assertElement(editBreakpointButton, HTMLButtonElement);
+
+ assert.strictEqual(editBreakpointButton.title, 'Edit condition');
+ });
+
+ describe('group checkboxes', () => {
+ async function waitForCheckboxToggledEventsWithCheckedUpdate(
+ component: SourcesComponents.BreakpointsView.BreakpointsView, numBreakpointItems: number, checked: boolean) {
+ return new Promise<void>(resolve => {
+ let numCheckboxToggledEvents = 0;
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance();
+ sinon.stub(controller, 'breakpointStateChanged').callsFake((_, checkedArg) => {
+ assert.strictEqual(checkedArg, checked);
+ ++numCheckboxToggledEvents;
+ if (numCheckboxToggledEvents === numBreakpointItems) {
+ resolve();
+ }
+ });
+ });
+ }
+ it('show a checked group checkbox if at least one breakpoint in that group is enabled', async () => {
+ const {component, data} = await renderMultipleBreakpoints();
+
+ // Make sure that at least one breakpoint is enabled.
+ data.groups[0].breakpointItems[0].status = SourcesComponents.BreakpointsView.BreakpointStatus.ENABLED;
+ component.data = data;
+ await coordinator.done();
+
+ await hover(component, SUMMARY_SELECTOR);
+
+ assertShadowRoot(component.shadowRoot);
+ const firstGroupSummary = component.shadowRoot.querySelector(SUMMARY_SELECTOR);
+ assertNotNullOrUndefined(firstGroupSummary);
+ const groupCheckbox = firstGroupSummary.querySelector('input');
+ assertElement(groupCheckbox, HTMLInputElement);
+
+ assert.isTrue(groupCheckbox.checked);
+ });
+
+ it('show an unchecked group checkbox if no breakpoint in that group is enabled', async () => {
+ const {component, data} = await renderMultipleBreakpoints();
+
+ // Make sure that all breakpoints are disabled.
+ const breakpointItems = data.groups[0].breakpointItems;
+ for (let i = 0; i < breakpointItems.length; ++i) {
+ breakpointItems[i].status = SourcesComponents.BreakpointsView.BreakpointStatus.DISABLED;
+ }
+
+ component.data = data;
+ await coordinator.done();
+
+ await hover(component, SUMMARY_SELECTOR);
+
+ assertShadowRoot(component.shadowRoot);
+ const firstGroupSummary = component.shadowRoot.querySelector(SUMMARY_SELECTOR);
+ assertNotNullOrUndefined(firstGroupSummary);
+ const groupCheckbox = firstGroupSummary.querySelector('input');
+ assertElement(groupCheckbox, HTMLInputElement);
+
+ assert.isFalse(groupCheckbox.checked);
+ });
+
+ it('disable all breakpoints on unchecking', async () => {
+ const {component, data} = await renderMultipleBreakpoints();
+
+ const numBreakpointItems = data.groups[0].breakpointItems.length;
+ assert.isTrue(numBreakpointItems > 1);
+
+ // Make sure that all breakpoints are enabled.
+ for (let i = 0; i < numBreakpointItems; ++i) {
+ data.groups[0].breakpointItems[i].status = SourcesComponents.BreakpointsView.BreakpointStatus.ENABLED;
+ }
+ component.data = data;
+ await coordinator.done();
+
+ await hover(component, SUMMARY_SELECTOR);
+
+ // Uncheck the group checkbox.
+ assertShadowRoot(component.shadowRoot);
+ const firstGroupSummary = component.shadowRoot.querySelector(SUMMARY_SELECTOR);
+ assertNotNullOrUndefined(firstGroupSummary);
+ const groupCheckbox = firstGroupSummary.querySelector('input');
+ assertElement(groupCheckbox, HTMLInputElement);
+
+ // Wait until we receive all events fired that notify us of disabled breakpoints.
+ const waitForEventPromise = waitForCheckboxToggledEventsWithCheckedUpdate(component, numBreakpointItems, false);
+
+ groupCheckbox.click();
+ await waitForEventPromise;
+ });
+
+ it('enable all breakpoints on unchecking', async () => {
+ const {component, data} = await renderMultipleBreakpoints();
+
+ const numBreakpointItems = data.groups[0].breakpointItems.length;
+ assert.isTrue(numBreakpointItems > 1);
+
+ // Make sure that all breakpoints are disabled.
+ for (let i = 0; i < numBreakpointItems; ++i) {
+ data.groups[0].breakpointItems[i].status = SourcesComponents.BreakpointsView.BreakpointStatus.DISABLED;
+ }
+ component.data = data;
+ await coordinator.done();
+
+ await hover(component, SUMMARY_SELECTOR);
+
+ // Uncheck the group checkbox.
+ assertShadowRoot(component.shadowRoot);
+ const firstGroupSummary = component.shadowRoot.querySelector(SUMMARY_SELECTOR);
+ assertNotNullOrUndefined(firstGroupSummary);
+ const groupCheckbox = firstGroupSummary.querySelector('input');
+ assertElement(groupCheckbox, HTMLInputElement);
+
+ // Wait until we receive all events fired that notify us of enabled breakpoints.
+ const waitForEventPromise = waitForCheckboxToggledEventsWithCheckedUpdate(component, numBreakpointItems, true);
+
+ groupCheckbox.click();
+ await waitForEventPromise;
+ });
+ });
+
+ it('only renders edit button for breakpoints in editable groups', async () => {
+ const component = await createAndInitializeBreakpointsView();
+
+ const data: SourcesComponents.BreakpointsView.BreakpointsViewData = {
+ breakpointsActive: true,
+ pauseOnUncaughtExceptions: false,
+ pauseOnCaughtExceptions: false,
+ independentPauseToggles: true,
+ groups: [
+ {
+ name: 'test1.js',
+ url: 'https://google.com/test1.js' as Platform.DevToolsPath.UrlString,
+ editable: false,
+ expanded: true,
+ breakpointItems: [
+ {
+ id: '1',
+ location: '1',
+ codeSnippet: 'const a = 0;',
+ isHit: true,
+ status: SourcesComponents.BreakpointsView.BreakpointStatus.ENABLED,
+ type: SDK.DebuggerModel.BreakpointType.REGULAR_BREAKPOINT,
+ },
+ ],
+ },
+ ],
+ };
+
+ component.data = data;
+ await coordinator.done();
+ assertShadowRoot(component.shadowRoot);
+
+ await hover(component, BREAKPOINT_ITEM_SELECTOR);
+
+ const editBreakpointButton = component.shadowRoot.querySelector(EDIT_SINGLE_BREAKPOINT_SELECTOR);
+ assert.isNull(editBreakpointButton);
+ });
+
+ it('initializes data from the controller on construction', async () => {
+ await setUpTestWithOneBreakpointLocation();
+ const component = await createAndInitializeBreakpointsView();
+ const renderedGroupName = component.shadowRoot?.querySelector(GROUP_NAME_SELECTOR);
+ assert.strictEqual(renderedGroupName?.textContent, HELLO_JS_FILE);
+ });
+
+ describe('conditional breakpoints', () => {
+ const breakpointDetails = 'x < a';
+
+ it('are rendered', async () => {
+ const {component} =
+ await renderSingleBreakpoint(SDK.DebuggerModel.BreakpointType.CONDITIONAL_BREAKPOINT, breakpointDetails);
+ const breakpointItem = component.shadowRoot?.querySelector(BREAKPOINT_ITEM_SELECTOR);
+ assertNotNullOrUndefined(breakpointItem);
+ assertElement(breakpointItem, HTMLDivElement);
+ assert.isTrue(breakpointItem.classList.contains('conditional-breakpoint'));
+ });
+
+ it('show a tooltip', async () => {
+ const {component} =
+ await renderSingleBreakpoint(SDK.DebuggerModel.BreakpointType.CONDITIONAL_BREAKPOINT, breakpointDetails);
+ const codeSnippet = component.shadowRoot?.querySelector(CODE_SNIPPET_SELECTOR);
+ assertNotNullOrUndefined(codeSnippet);
+ assertElement(codeSnippet, HTMLSpanElement);
+ assert.strictEqual(codeSnippet.title, `Condition: ${breakpointDetails}`);
+ });
+
+ it('show a tooltip on editing the condition', async () => {
+ const {component} =
+ await renderSingleBreakpoint(SDK.DebuggerModel.BreakpointType.CONDITIONAL_BREAKPOINT, breakpointDetails);
+ assertShadowRoot(component.shadowRoot);
+
+ await hover(component, BREAKPOINT_ITEM_SELECTOR);
+
+ const editBreakpointButton = component.shadowRoot.querySelector(EDIT_SINGLE_BREAKPOINT_SELECTOR);
+ assertElement(editBreakpointButton, HTMLButtonElement);
+
+ assert.strictEqual(editBreakpointButton.title, 'Edit condition');
+ });
+ });
+
+ describe('logpoints', () => {
+ const breakpointDetails = 'x, a';
+
+ it('are rendered', async () => {
+ const {component} = await renderSingleBreakpoint(SDK.DebuggerModel.BreakpointType.LOGPOINT, breakpointDetails);
+ const breakpointItem = component.shadowRoot?.querySelector(BREAKPOINT_ITEM_SELECTOR);
+ assertNotNullOrUndefined(breakpointItem);
+ assertElement(breakpointItem, HTMLDivElement);
+ assert.isTrue(breakpointItem.classList.contains('logpoint'));
+ });
+
+ it('show a tooltip', async () => {
+ const {component} = await renderSingleBreakpoint(SDK.DebuggerModel.BreakpointType.LOGPOINT, breakpointDetails);
+ const codeSnippet = component.shadowRoot?.querySelector(CODE_SNIPPET_SELECTOR);
+ assertNotNullOrUndefined(codeSnippet);
+ assertElement(codeSnippet, HTMLSpanElement);
+ assert.strictEqual(codeSnippet.title, `Logpoint: ${breakpointDetails}`);
+ });
+
+ it('show a tooltip on editing the logpoint', async () => {
+ const {component} = await renderSingleBreakpoint(SDK.DebuggerModel.BreakpointType.LOGPOINT, breakpointDetails);
+ assertShadowRoot(component.shadowRoot);
+
+ await hover(component, BREAKPOINT_ITEM_SELECTOR);
+
+ const editBreakpointButton = component.shadowRoot.querySelector(EDIT_SINGLE_BREAKPOINT_SELECTOR);
+ assertElement(editBreakpointButton, HTMLButtonElement);
+
+ assert.strictEqual(editBreakpointButton.title, 'Edit logpoint');
+ });
+ });
+
+ describe('pause on exceptions', () => {
+ it('state is rendered correctly when disabled', async () => {
+ const component = await renderNoBreakpoints(
+ {pauseOnUncaughtExceptions: false, pauseOnCaughtExceptions: false, independentPauseToggles: true});
+ assertShadowRoot(component.shadowRoot);
+
+ const pauseOnUncaughtExceptionsItem = component.shadowRoot.querySelector(PAUSE_ON_UNCAUGHT_EXCEPTIONS_SELECTOR);
+ assertNotNullOrUndefined(pauseOnUncaughtExceptionsItem);
+
+ const pauseOnUncaughtExceptionsCheckbox = pauseOnUncaughtExceptionsItem.querySelector('input');
+ assertElement(pauseOnUncaughtExceptionsCheckbox, HTMLInputElement);
+ assert.isFalse(pauseOnUncaughtExceptionsCheckbox.checked);
+
+ const pauseOnCaughtExceptionsItem = component.shadowRoot?.querySelector(PAUSE_ON_CAUGHT_EXCEPTIONS_SELECTOR);
+ assertNotNullOrUndefined(pauseOnCaughtExceptionsItem);
+
+ const pauseOnCaughtExceptionsCheckbox = pauseOnUncaughtExceptionsItem.querySelector('input');
+ assertElement(pauseOnCaughtExceptionsCheckbox, HTMLInputElement);
+ assert.isFalse(pauseOnCaughtExceptionsCheckbox.checked);
+ });
+
+ it('state is rendered correctly when pausing on uncaught exceptions', async () => {
+ const component = await renderNoBreakpoints(
+ {pauseOnUncaughtExceptions: true, pauseOnCaughtExceptions: false, independentPauseToggles: true});
+ assertShadowRoot(component.shadowRoot);
+
+ const pauseOnUncaughtExceptionsItem = component.shadowRoot.querySelector(PAUSE_ON_UNCAUGHT_EXCEPTIONS_SELECTOR);
+ assertNotNullOrUndefined(pauseOnUncaughtExceptionsItem);
+
+ const pauseOnUncaughtExceptionsCheckbox = pauseOnUncaughtExceptionsItem.querySelector('input');
+ assertNotNullOrUndefined(pauseOnUncaughtExceptionsCheckbox);
+ assertElement(pauseOnUncaughtExceptionsCheckbox, HTMLInputElement);
+ assert.isTrue(pauseOnUncaughtExceptionsCheckbox.checked);
+
+ const pauseOnCaughtExceptionsItem = component.shadowRoot?.querySelector(PAUSE_ON_CAUGHT_EXCEPTIONS_SELECTOR);
+ assertNotNullOrUndefined(pauseOnCaughtExceptionsItem);
+
+ const pauseOnCaughtExceptionsCheckbox = pauseOnCaughtExceptionsItem.querySelector('input');
+ assertNotNullOrUndefined(pauseOnCaughtExceptionsCheckbox);
+ assertElement(pauseOnCaughtExceptionsCheckbox, HTMLInputElement);
+ assert.isFalse(pauseOnCaughtExceptionsCheckbox.checked);
+ });
+
+ it('state is rendered correctly when pausing on all exceptions', async () => {
+ const component = await renderNoBreakpoints(
+ {pauseOnUncaughtExceptions: true, pauseOnCaughtExceptions: true, independentPauseToggles: true});
+ assertShadowRoot(component.shadowRoot);
+
+ const pauseOnUncaughtExceptionsItem = component.shadowRoot.querySelector(PAUSE_ON_UNCAUGHT_EXCEPTIONS_SELECTOR);
+ assertNotNullOrUndefined(pauseOnUncaughtExceptionsItem);
+
+ const pauseOnUncaughtExceptionsCheckbox = pauseOnUncaughtExceptionsItem.querySelector('input');
+ assertNotNullOrUndefined(pauseOnUncaughtExceptionsCheckbox);
+ assertElement(pauseOnUncaughtExceptionsCheckbox, HTMLInputElement);
+ assert.isTrue(pauseOnUncaughtExceptionsCheckbox.checked);
+
+ const pauseOnCaughtExceptionsItem = component.shadowRoot?.querySelector(PAUSE_ON_CAUGHT_EXCEPTIONS_SELECTOR);
+ assertNotNullOrUndefined(pauseOnCaughtExceptionsItem);
+
+ const pauseOnCaughtExceptionsCheckbox = pauseOnCaughtExceptionsItem.querySelector('input');
+ assertNotNullOrUndefined(pauseOnCaughtExceptionsCheckbox);
+ assertElement(pauseOnCaughtExceptionsCheckbox, HTMLInputElement);
+ assert.isTrue(pauseOnCaughtExceptionsCheckbox.checked);
+ });
+
+ it('state is rendered correctly when toggles are dependent and only pausing on uncaught exceptions', async () => {
+ const component = await renderNoBreakpoints(
+ {pauseOnUncaughtExceptions: true, pauseOnCaughtExceptions: false, independentPauseToggles: false});
+ assertShadowRoot(component.shadowRoot);
+
+ const pauseOnUncaughtExceptionsItem = component.shadowRoot.querySelector(PAUSE_ON_UNCAUGHT_EXCEPTIONS_SELECTOR);
+ assertNotNullOrUndefined(pauseOnUncaughtExceptionsItem);
+
+ const pauseOnUncaughtExceptionsCheckbox = pauseOnUncaughtExceptionsItem.querySelector('input');
+ assertNotNullOrUndefined(pauseOnUncaughtExceptionsCheckbox);
+ assertElement(pauseOnUncaughtExceptionsCheckbox, HTMLInputElement);
+ assert.isTrue(pauseOnUncaughtExceptionsCheckbox.checked);
+
+ const pauseOnCaughtExceptionsItem = component.shadowRoot?.querySelector(PAUSE_ON_CAUGHT_EXCEPTIONS_SELECTOR);
+ assertNotNullOrUndefined(pauseOnCaughtExceptionsItem);
+
+ const pauseOnCaughtExceptionsCheckbox = pauseOnCaughtExceptionsItem.querySelector('input');
+ assertElement(pauseOnCaughtExceptionsCheckbox, HTMLInputElement);
+ assert.isFalse(pauseOnCaughtExceptionsCheckbox.disabled);
+ });
+
+ it('state is rendered correctly when toggles are dependent and not pausing on uncaught exceptions', async () => {
+ const component = await renderNoBreakpoints(
+ {pauseOnUncaughtExceptions: false, pauseOnCaughtExceptions: false, independentPauseToggles: false});
+ assertShadowRoot(component.shadowRoot);
+
+ const pauseOnUncaughtExceptionsItem = component.shadowRoot.querySelector(PAUSE_ON_UNCAUGHT_EXCEPTIONS_SELECTOR);
+ assertNotNullOrUndefined(pauseOnUncaughtExceptionsItem);
+
+ const pauseOnUncaughtExceptionsCheckbox = pauseOnUncaughtExceptionsItem.querySelector('input');
+ assertNotNullOrUndefined(pauseOnUncaughtExceptionsCheckbox);
+ assertElement(pauseOnUncaughtExceptionsCheckbox, HTMLInputElement);
+ assert.isFalse(pauseOnUncaughtExceptionsCheckbox.checked);
+
+ const pauseOnCaughtExceptionsItem = component.shadowRoot?.querySelector(PAUSE_ON_CAUGHT_EXCEPTIONS_SELECTOR);
+ assertNotNullOrUndefined(pauseOnCaughtExceptionsItem);
+
+ const pauseOnCaughtExceptionsCheckbox = pauseOnCaughtExceptionsItem.querySelector('input');
+ assertElement(pauseOnCaughtExceptionsCheckbox, HTMLInputElement);
+ assert.isTrue(pauseOnCaughtExceptionsCheckbox.disabled);
+ });
+
+ it('state is rendered correctly when toggles are dependent and pausing on uncaught exceptions is unchecked',
+ async () => {
+ const component = await renderNoBreakpoints(
+ {pauseOnUncaughtExceptions: true, pauseOnCaughtExceptions: true, independentPauseToggles: false});
+ assertShadowRoot(component.shadowRoot);
+
+ const pauseOnUncaughtExceptionsItem =
+ component.shadowRoot.querySelector(PAUSE_ON_UNCAUGHT_EXCEPTIONS_SELECTOR);
+ assertElement(pauseOnUncaughtExceptionsItem, HTMLDivElement);
+
+ {
+ // Click on the pause on exceptions checkbox to uncheck.
+ const pauseOnUncaughtExceptionsCheckbox = pauseOnUncaughtExceptionsItem.querySelector('input');
+ assertElement(pauseOnUncaughtExceptionsCheckbox, HTMLInputElement);
+ dispatchClickEvent(pauseOnUncaughtExceptionsCheckbox);
+ await coordinator.done();
+ }
+ {
+ // Check that clicking on it actually unchecked.
+ const pauseOnUncaughtExceptionsCheckbox = pauseOnUncaughtExceptionsItem.querySelector('input');
+ assertElement(pauseOnUncaughtExceptionsCheckbox, HTMLInputElement);
+ assert.isFalse(pauseOnUncaughtExceptionsCheckbox.checked);
+ }
+
+ // Check if the pause on caught exception checkbox is unchecked and disabled as a result.
+ const pauseOnCaughtExceptionsItem = component.shadowRoot?.querySelector(PAUSE_ON_CAUGHT_EXCEPTIONS_SELECTOR);
+ assertNotNullOrUndefined(pauseOnCaughtExceptionsItem);
+
+ const pauseOnCaughtExceptionsCheckbox = pauseOnCaughtExceptionsItem.querySelector('input');
+ assertElement(pauseOnCaughtExceptionsCheckbox, HTMLInputElement);
+ assert.isTrue(pauseOnCaughtExceptionsCheckbox.disabled);
+ assert.isFalse(pauseOnCaughtExceptionsCheckbox.checked);
+ });
+
+ it('triggers an event when disabling pausing on all exceptions', async () => {
+ const component = await renderNoBreakpoints(
+ {pauseOnUncaughtExceptions: true, pauseOnCaughtExceptions: false, independentPauseToggles: true});
+ assertShadowRoot(component.shadowRoot);
+
+ const item = component.shadowRoot.querySelector(PAUSE_ON_UNCAUGHT_EXCEPTIONS_SELECTOR);
+ assertNotNullOrUndefined(item);
+
+ const checkbox = item.querySelector('input');
+ assertElement(checkbox, HTMLInputElement);
+ const {checked} = checkbox;
+
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance();
+ const setPauseOnUncaughtExceptions = sinon.stub(controller, 'setPauseOnUncaughtExceptions');
+
+ checkbox.click();
+
+ assert.isTrue(setPauseOnUncaughtExceptions.calledOnceWith(!checked));
+ });
+
+ it('triggers an event when enabling pausing on caught exceptions', async () => {
+ const component = await renderNoBreakpoints(
+ {pauseOnUncaughtExceptions: true, pauseOnCaughtExceptions: false, independentPauseToggles: true});
+ assertShadowRoot(component.shadowRoot);
+
+ const item = component.shadowRoot.querySelector(PAUSE_ON_CAUGHT_EXCEPTIONS_SELECTOR);
+ assertNotNullOrUndefined(item);
+
+ const checkbox = item.querySelector('input');
+ assertElement(checkbox, HTMLInputElement);
+ const {checked} = checkbox;
+
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance();
+ const setPauseOnCaughtExceptions = sinon.stub(controller, 'setPauseOnCaughtExceptions');
+
+ checkbox.click();
+
+ assert.isTrue(setPauseOnCaughtExceptions.calledOnceWith(!checked));
+ });
+
+ it('triggers an event when enabling pausing on uncaught exceptions', async () => {
+ const component = await renderNoBreakpoints(
+ {pauseOnUncaughtExceptions: false, pauseOnCaughtExceptions: true, independentPauseToggles: true});
+ assertShadowRoot(component.shadowRoot);
+
+ const item = component.shadowRoot.querySelector(PAUSE_ON_UNCAUGHT_EXCEPTIONS_SELECTOR);
+ assertNotNullOrUndefined(item);
+
+ const checkbox = item.querySelector('input');
+ assertElement(checkbox, HTMLInputElement);
+ const {checked} = checkbox;
+
+ const controller = SourcesComponents.BreakpointsView.BreakpointsSidebarController.instance();
+ const setPauseOnUncaughtExceptions = sinon.stub(controller, 'setPauseOnUncaughtExceptions');
+
+ checkbox.click();
+
+ assert.isTrue(setPauseOnUncaughtExceptions.calledOnceWith(!checked));
+ });
+ });
+
+ describe('navigating with keyboard', () => {
+ // One expanded group with 2 breakpoints, and one collapsed with 2 breakpoints.
+ async function renderBreakpointsForKeyboardNavigation(): Promise<{
+ component: SourcesComponents.BreakpointsView.BreakpointsView,
+ data: SourcesComponents.BreakpointsView.BreakpointsViewData,
+ }> {
+ const component = await createAndInitializeBreakpointsView();
+
+ const data: SourcesComponents.BreakpointsView.BreakpointsViewData = {
+ breakpointsActive: true,
+ pauseOnUncaughtExceptions: false,
+ pauseOnCaughtExceptions: false,
+ independentPauseToggles: true,
+ groups: [
+ {
+ name: 'test1.js',
+ url: 'https://google.com/test1.js' as Platform.DevToolsPath.UrlString,
+ editable: false,
+ expanded: true,
+ breakpointItems: [
+ {
+ id: '1',
+ type: SDK.DebuggerModel.BreakpointType.REGULAR_BREAKPOINT,
+ location: '234',
+ codeSnippet: 'const a = x;',
+ isHit: false,
+ status: SourcesComponents.BreakpointsView.BreakpointStatus.ENABLED,
+ },
+ {
+ id: '2',
+ type: SDK.DebuggerModel.BreakpointType.REGULAR_BREAKPOINT,
+ location: '3:3',
+ codeSnippet: 'if (x > a) {',
+ isHit: true,
+ status: SourcesComponents.BreakpointsView.BreakpointStatus.DISABLED,
+ },
+ ],
+ },
+ {
+ name: 'test2.js',
+ url: 'https://google.com/test2.js' as Platform.DevToolsPath.UrlString,
+ editable: false,
+ expanded: false,
+ breakpointItems: [
+ {
+ id: '3',
+ type: SDK.DebuggerModel.BreakpointType.REGULAR_BREAKPOINT,
+ location: '11',
+ codeSnippet: 'const y;',
+ isHit: false,
+ status: SourcesComponents.BreakpointsView.BreakpointStatus.ENABLED,
+ },
+ {
+ id: '4',
+ type: SDK.DebuggerModel.BreakpointType.REGULAR_BREAKPOINT,
+ location: '12',
+ codeSnippet: 'const y;',
+ isHit: false,
+ status: SourcesComponents.BreakpointsView.BreakpointStatus.ENABLED,
+ },
+ ],
+ },
+ ],
+ };
+ component.data = data;
+ await coordinator.done();
+ return {component, data};
+ }
+
+ it('pause on exceptions is tabbable', async () => {
+ const component = await renderNoBreakpoints(
+ {pauseOnUncaughtExceptions: true, pauseOnCaughtExceptions: false, independentPauseToggles: true});
+ assertShadowRoot(component.shadowRoot);
+
+ const focusableElements = component.shadowRoot.querySelectorAll(TABBABLE_SELECTOR);
+ assertElements(focusableElements, HTMLElement);
+ assert.lengthOf(focusableElements, 1);
+
+ const pauseOnUncaughtExceptions = component.shadowRoot.querySelector(PAUSE_ON_UNCAUGHT_EXCEPTIONS_SELECTOR);
+
+ assert.deepEqual(focusableElements.item(0), pauseOnUncaughtExceptions);
+ });
+
+ describe('pressing the HOME key', () => {
+ it('takes the user to the pause-on-exceptions line', async () => {
+ const {component} = await renderBreakpointsForKeyboardNavigation();
+ assertShadowRoot(component.shadowRoot);
+ const secondGroupsSummary =
+ component.shadowRoot.querySelector(`${DETAILS_SELECTOR}:nth-of-type(2) > ${SUMMARY_SELECTOR}`);
+ assertElement(secondGroupsSummary, HTMLElement);
+
+ // Focus on second group by clicking on it, then press Home button.
+ dispatchClickEvent(secondGroupsSummary);
+ dispatchKeyDownEvent(secondGroupsSummary, {key: 'Home', bubbles: true});
+ await coordinator.done();
+
+ const selected = component.shadowRoot.querySelector(TABBABLE_SELECTOR);
+ assertElement(selected, HTMLElement);
+ const pauseOnUncaughtExceptions = component.shadowRoot.querySelector(PAUSE_ON_UNCAUGHT_EXCEPTIONS_SELECTOR);
+ assertElement(pauseOnUncaughtExceptions, HTMLElement);
+ assert.strictEqual(selected, pauseOnUncaughtExceptions);
+ });
+ });
+
+ describe('pressing the END key', () => {
+ it('takes the user to the summary node of the last group (if last group is collapsed)', async () => {
+ const {component} = await renderBreakpointsForKeyboardNavigation();
+ assertShadowRoot(component.shadowRoot);
+ const pauseOnUncaughtExceptions = component.shadowRoot.querySelector(PAUSE_ON_UNCAUGHT_EXCEPTIONS_SELECTOR);
+ assertElement(pauseOnUncaughtExceptions, HTMLElement);
+
+ // Focus on the pause-on-exceptions line by clicking on it, then press End key.
+ dispatchClickEvent(pauseOnUncaughtExceptions);
+ dispatchKeyDownEvent(pauseOnUncaughtExceptions, {key: 'End', bubbles: true});
+ await coordinator.done();
+
+ const selected = component.shadowRoot.querySelector(TABBABLE_SELECTOR);
+ assertElement(selected, HTMLElement);
+
+ const lastGroupSummary =
+ component.shadowRoot.querySelector(`${DETAILS_SELECTOR}:nth-of-type(2) > ${SUMMARY_SELECTOR}`);
+ assertElement(lastGroupSummary, HTMLElement);
+ assert.strictEqual(selected, lastGroupSummary);
+ });
+
+ it('takes the user to the last breakpoint item (if last group is expanded))', async () => {
+ const {component, data} = await renderBreakpointsForKeyboardNavigation();
+ // Expand the last group.
+ data.groups[1].expanded = true;
+ component.data = data;
+ await coordinator.done();
+
+ assertShadowRoot(component.shadowRoot);
+ const firstGroupSummary = component.shadowRoot.querySelector(SUMMARY_SELECTOR);
+ assertElement(firstGroupSummary, HTMLElement);
+
+ // First focus on the first group by clicking on it, then press the End button.
+ dispatchClickEvent(firstGroupSummary);
+ dispatchKeyDownEvent(firstGroupSummary, {key: 'End', bubbles: true});
+ await coordinator.done();
+
+ const selected = component.shadowRoot.querySelector(TABBABLE_SELECTOR);
+ assertElement(selected, HTMLElement);
+
+ const breakpointItems = component.shadowRoot.querySelectorAll(BREAKPOINT_ITEM_SELECTOR);
+ assertElements(breakpointItems, HTMLDivElement);
+
+ const lastBreakpointItem = breakpointItems.item(breakpointItems.length - 1);
+ assert.strictEqual(selected, lastBreakpointItem);
+ });
+ });
+
+ describe('pressing the ArrowDown key', () => {
+ it('on the pause-on-uncaught-exception takes the user to the summary node of the top most details element',
+ async () => {
+ const {component} = await renderBreakpointsForKeyboardNavigation();
+ assertShadowRoot(component.shadowRoot);
+
+ const pauseOnCaughtException = component.shadowRoot.querySelector(PAUSE_ON_CAUGHT_EXCEPTIONS_SELECTOR);
+ assertElement(pauseOnCaughtException, HTMLElement);
+
+ // Focus on the pause on exception, and navigate one down.
+ dispatchClickEvent(pauseOnCaughtException);
+ dispatchKeyDownEvent(pauseOnCaughtException, {key: 'ArrowDown', bubbles: true});
+ await coordinator.done();
+
+ const selected = component.shadowRoot.querySelector(TABBABLE_SELECTOR);
+ const firstSummary = component.shadowRoot.querySelector(`${DETAILS_SELECTOR} > ${SUMMARY_SELECTOR}`);
+ assertElement(firstSummary, HTMLElement);
+ assert.strictEqual(selected, firstSummary);
+ });
+
+ it('on the summary node of an expanded group takes the user to the top most breakpoint item of that group',
+ async () => {
+ const {component} = await renderBreakpointsForKeyboardNavigation();
+ assertShadowRoot(component.shadowRoot);
+ const collapsedDetailsElement = component.shadowRoot.querySelector(COLLAPSED_GROUPS_SELECTOR);
+ assertElement(collapsedDetailsElement, HTMLDetailsElement);
+
+ const collapsedGroupSummary = collapsedDetailsElement.querySelector(SUMMARY_SELECTOR);
+ assertElement(collapsedGroupSummary, HTMLElement);
+
+ // Focus on the collapsed group and collapse it by clicking on it. Then navigate down.
+ dispatchClickEvent(collapsedGroupSummary);
+ dispatchKeyDownEvent(collapsedGroupSummary, {key: 'ArrowDown', bubbles: true});
+ await coordinator.done();
+
+ const selected = component.shadowRoot.querySelector(TABBABLE_SELECTOR);
+ assertElement(selected, HTMLElement);
+
+ const firstBreakpointItem = collapsedDetailsElement.querySelector(BREAKPOINT_ITEM_SELECTOR);
+ assertElement(firstBreakpointItem, HTMLDivElement);
+
+ assert.strictEqual(selected, firstBreakpointItem);
+ });
+
+ it('on the summary node of a collapsed group takes the user to the summary node of the next group', async () => {
+ const {component} = await renderBreakpointsForKeyboardNavigation();
+ assertShadowRoot(component.shadowRoot);
+
+ const firstGroupSummary =
+ component.shadowRoot.querySelector(`${DETAILS_SELECTOR}:nth-of-type(1) > ${SUMMARY_SELECTOR}`);
+ assertElement(firstGroupSummary, HTMLElement);
+
+ // Focus on the expanded group and collapse it by clicking on it. Then navigate down.
+ dispatchClickEvent(firstGroupSummary);
+ dispatchKeyDownEvent(firstGroupSummary, {key: 'ArrowDown', bubbles: true});
+ await coordinator.done();
+
+ const selected = component.shadowRoot.querySelector(TABBABLE_SELECTOR);
+ assertElement(selected, HTMLElement);
+
+ const secondGroupSummary =
+ component.shadowRoot.querySelector(`${DETAILS_SELECTOR}:nth-of-type(2) > ${SUMMARY_SELECTOR}`);
+ assertElement(secondGroupSummary, HTMLElement);
+ assert.strictEqual(selected, secondGroupSummary);
+ });
+
+ it('on a breakpoint item takes the user to the next breakpoint item', async () => {
+ const {component} = await renderBreakpointsForKeyboardNavigation();
+ assertShadowRoot(component.shadowRoot);
+
+ const firstDetailsElement = component.shadowRoot.querySelector('details');
+ assertElement(firstDetailsElement, HTMLDetailsElement);
+ const firstBreakpointItem = firstDetailsElement.querySelector(BREAKPOINT_ITEM_SELECTOR);
+ assertElement(firstBreakpointItem, HTMLDivElement);
+
+ // Focus on the first breakpoint item. Then navigate up.
+ dispatchClickEvent(firstBreakpointItem);
+ dispatchKeyDownEvent(firstBreakpointItem, {key: 'ArrowDown', bubbles: true});
+ await coordinator.done();
+
+ const selected = component.shadowRoot.querySelector(TABBABLE_SELECTOR);
+ assertElement(selected, HTMLElement);
+
+ const secondBreakpointItem = firstDetailsElement.querySelector(`${BREAKPOINT_ITEM_SELECTOR}:nth-of-type(2)`);
+ assertElement(secondBreakpointItem, HTMLDivElement);
+
+ assert.strictEqual(selected, secondBreakpointItem);
+ });
+ });
+
+ describe('pressing the ArrowUp key', () => {
+ it('on the first summary takes a user to the pause on exceptions', async () => {
+ const {component} = await renderBreakpointsForKeyboardNavigation();
+ assertShadowRoot(component.shadowRoot);
+ const firstSummary = component.shadowRoot.querySelector(`${DETAILS_SELECTOR} > ${SUMMARY_SELECTOR}`);
+ assertElement(firstSummary, HTMLElement);
+
+ // Focus on the summary element.
+ dispatchClickEvent(firstSummary);
+ dispatchKeyDownEvent(firstSummary, {key: 'ArrowUp', bubbles: true});
+ await coordinator.done();
+
+ const selected = component.shadowRoot.querySelector(TABBABLE_SELECTOR);
+ const pauseOnUncaughtExceptions = component.shadowRoot.querySelector(PAUSE_ON_CAUGHT_EXCEPTIONS_SELECTOR);
+ assertElement(pauseOnUncaughtExceptions, HTMLDivElement);
+
+ assert.strictEqual(selected, pauseOnUncaughtExceptions);
+ });
+
+ it('on the first breakpoint item in an expanded group takes the user to the summary node', async () => {
+ const {component} = await renderBreakpointsForKeyboardNavigation();
+ assertShadowRoot(component.shadowRoot);
+ const expandedDetails = component.shadowRoot.querySelector(EXPANDED_GROUPS_SELECTOR);
+ assertElement(expandedDetails, HTMLDetailsElement);
+
+ const firstBreakpointItem = expandedDetails.querySelector(BREAKPOINT_ITEM_SELECTOR);
+ assertElement(firstBreakpointItem, HTMLDivElement);
+
+ // Focus on first breakpoint item. Then navigate up.
+ dispatchClickEvent(firstBreakpointItem);
+ dispatchKeyDownEvent(firstBreakpointItem, {key: 'ArrowUp', bubbles: true});
+ await coordinator.done();
+
+ const selected = component.shadowRoot.querySelector(TABBABLE_SELECTOR);
+ assertElement(selected, HTMLElement);
+
+ const summary = expandedDetails.querySelector(SUMMARY_SELECTOR);
+ assertElement(summary, HTMLElement);
+
+ assert.strictEqual(selected, summary);
+ });
+
+ it('on a breakpoint item in an expanded group takes the user to the previous breakpoint item', async () => {
+ const {component} = await renderBreakpointsForKeyboardNavigation();
+ assertShadowRoot(component.shadowRoot);
+ const expandedDetails = component.shadowRoot.querySelector(EXPANDED_GROUPS_SELECTOR);
+ assertElement(expandedDetails, HTMLDetailsElement);
+
+ const breakpointItems = expandedDetails.querySelectorAll(BREAKPOINT_ITEM_SELECTOR);
+ assert.isAbove(breakpointItems.length, 1);
+
+ const lastBreakpointItem = breakpointItems.item(breakpointItems.length - 1);
+ // Focus on last breakpoint item. Then navigate up.
+ dispatchClickEvent(lastBreakpointItem);
+ dispatchKeyDownEvent(lastBreakpointItem, {key: 'ArrowUp', bubbles: true});
+ await coordinator.done();
+
+ const selected = component.shadowRoot.querySelector(TABBABLE_SELECTOR);
+ assertElement(selected, HTMLElement);
+
+ const nextToLastBreakpointItem = breakpointItems.item(breakpointItems.length - 2);
+ assertElement(nextToLastBreakpointItem, HTMLDivElement);
+ assert.strictEqual(selected, nextToLastBreakpointItem);
+ });
+
+ it('on a summary node takes the user to the last breakpoint item of the previous group', async () => {
+ const {component} = await renderBreakpointsForKeyboardNavigation();
+ assertShadowRoot(component.shadowRoot);
+ const secondGroupSummary =
+ component.shadowRoot.querySelector(`${DETAILS_SELECTOR}:nth-of-type(2) > ${SUMMARY_SELECTOR}`);
+ assertElement(secondGroupSummary, HTMLElement);
+
+ // Focus on the group. Then navigate up.
+ dispatchClickEvent(secondGroupSummary);
+ dispatchKeyDownEvent(secondGroupSummary, {key: 'ArrowUp', bubbles: true});
+ await coordinator.done();
+
+ const selected = component.shadowRoot.querySelector(TABBABLE_SELECTOR);
+ assertElement(selected, HTMLElement);
+
+ const firstDetailsElement = component.shadowRoot.querySelector(DETAILS_SELECTOR);
+ assertNotNullOrUndefined(firstDetailsElement);
+ const lastBreakpointItem = firstDetailsElement.querySelector(`${BREAKPOINT_ITEM_SELECTOR}:last-child`);
+ assertElement(lastBreakpointItem, HTMLDivElement);
+
+ assert.strictEqual(selected, lastBreakpointItem);
+ });
+ });
+ });
+});
diff --git a/front_end/panels/sources/components/BreakpointsViewUtils.test.ts b/front_end/panels/sources/components/BreakpointsViewUtils.test.ts
new file mode 100644
index 0000000..516d954
--- /dev/null
+++ b/front_end/panels/sources/components/BreakpointsViewUtils.test.ts
@@ -0,0 +1,207 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import type * as Platform from '../../../core/platform/platform.js';
+
+import * as SourcesComponents from './components.js';
+
+describe('getDifferentiatingPathMap', () => {
+ const AMBIGUOUS_FILE_NAME = 'index.js';
+ const OTHER_FILE_NAME = 'a.js';
+
+ it('can extract the differentiating segment if it is the parent folder', () => {
+ const titleInfos: SourcesComponents.BreakpointsViewUtils.TitleInfo[] = createTitleInfos({
+ ambiguous: ['http://www.google.com/src/a', 'http://www.google.com/src/b', 'http://www.google.com/src/c'],
+ nonAmbiguous: [],
+ });
+ const differentiatingPathMap = SourcesComponents.BreakpointsViewUtils.getDifferentiatingPathMap(titleInfos);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[0].url), 'a/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[1].url), 'b/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[2].url), 'c/' as Platform.DevToolsPath.UrlString);
+ });
+
+ it('can extract the differentiating segment if it is the direct parent folder', () => {
+ const titleInfos: SourcesComponents.BreakpointsViewUtils.TitleInfo[] = createTitleInfos({
+ ambiguous: ['http://www.google.com/src/a', 'http://www.google.com/src2/b'],
+ nonAmbiguous: [],
+ });
+
+ const differentiatingPathMap = SourcesComponents.BreakpointsViewUtils.getDifferentiatingPathMap(titleInfos);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[0].url), 'a/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[1].url), 'b/' as Platform.DevToolsPath.UrlString);
+ });
+
+ it('can extract the differentiating segment if it is the parent folder, but has overlapping path prefixes', () => {
+ const titleInfos: SourcesComponents.BreakpointsViewUtils.TitleInfo[] = createTitleInfos({
+ ambiguous: ['http://www.google.com/src/a', 'http://www.google.com/src2/b', 'http://www.google.com/src2/c'],
+ nonAmbiguous: [],
+ });
+
+ const differentiatingPathMap = SourcesComponents.BreakpointsViewUtils.getDifferentiatingPathMap(titleInfos);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[0].url), 'a/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[1].url), 'b/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[2].url), 'c/' as Platform.DevToolsPath.UrlString);
+ });
+
+ it('does not output any differentiating segment if the name is unique', () => {
+ const titleInfos: SourcesComponents.BreakpointsViewUtils.TitleInfo[] = createTitleInfos({
+ ambiguous: ['http://www.google.com/src/a', 'http://www.google.com/src/b'],
+ nonAmbiguous: ['http://www.google.com/src/c'],
+ });
+ const differentiatingPathMap = SourcesComponents.BreakpointsViewUtils.getDifferentiatingPathMap(titleInfos);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[0].url), 'a/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[1].url), 'b/' as Platform.DevToolsPath.UrlString);
+ assert.isUndefined(differentiatingPathMap.get(titleInfos[2].url));
+ });
+
+ it('can extract the differentiating segment if paths have overlapping prefixes and suffixes', () => {
+ const titleInfos: SourcesComponents.BreakpointsViewUtils.TitleInfo[] = createTitleInfos({
+ ambiguous: [
+ 'http://www.google.com/src/a',
+ 'http://www.google.com/src/b',
+ 'http://www.google.com/src2/a',
+ 'http://www.google.com/src2/b',
+ ],
+ nonAmbiguous: [],
+ });
+
+ const differentiatingPathMap = SourcesComponents.BreakpointsViewUtils.getDifferentiatingPathMap(titleInfos);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[0].url), 'src/a/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[1].url), 'src/b/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[2].url), 'src2/a/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[3].url), 'src2/b/' as Platform.DevToolsPath.UrlString);
+ });
+
+ it('can extract the differentiating segment if paths have overlapping prefixes and suffixes', () => {
+ const titleInfos: SourcesComponents.BreakpointsViewUtils.TitleInfo[] = createTitleInfos({
+ ambiguous: [
+ 'http://www.google.com/src/a/d',
+ 'http://www.google.com/src/a/e',
+ 'http://www.google.com/src2/a/d',
+ 'http://www.google.com/src2/a/e',
+ ],
+ nonAmbiguous: [],
+ });
+ const differentiatingPathMap = SourcesComponents.BreakpointsViewUtils.getDifferentiatingPathMap(titleInfos);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[0].url), 'src/a/d/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[1].url), 'src/a/e/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[2].url), 'src2/a/d/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[3].url), 'src2/a/e/' as Platform.DevToolsPath.UrlString);
+ });
+
+ it('can extract the differentiating segment if it is not the direct parent folder', () => {
+ const titleInfos: SourcesComponents.BreakpointsViewUtils.TitleInfo[] = createTitleInfos({
+ ambiguous: [
+ 'http://www.google.com/src/a/e',
+ 'http://www.google.com/src/b/e',
+ 'http://www.google.com/src2/c/e',
+ 'http://www.google.com/src2/d/e',
+ ],
+ nonAmbiguous: [],
+ });
+
+ const differentiatingPathMap = SourcesComponents.BreakpointsViewUtils.getDifferentiatingPathMap(titleInfos);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[0].url), 'a/…/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[1].url), 'b/…/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[2].url), 'c/…/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[3].url), 'd/…/' as Platform.DevToolsPath.UrlString);
+ });
+
+ it('can extract the differentiating segment if one path is completely overlapping', () => {
+ const titleInfos: SourcesComponents.BreakpointsViewUtils.TitleInfo[] = createTitleInfos({
+ ambiguous: ['http://www.google.com/src/a/e', 'http://www.google.com/src/a'],
+ nonAmbiguous: [],
+ });
+
+ const differentiatingPathMap = SourcesComponents.BreakpointsViewUtils.getDifferentiatingPathMap(titleInfos);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[0].url), 'e/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[1].url), 'a/' as Platform.DevToolsPath.UrlString);
+ });
+
+ it('can extract the differentiating segment if parts of the differentiating foldername is overlapping', () => {
+ const titleInfos: SourcesComponents.BreakpointsViewUtils.TitleInfo[] = createTitleInfos({
+ ambiguous: ['http://www.google.com/src/a/b/cfile', 'http://www.google.com/src/c/d/c'],
+ nonAmbiguous: [],
+ });
+
+ const differentiatingPathMap = SourcesComponents.BreakpointsViewUtils.getDifferentiatingPathMap(titleInfos);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[0].url), 'cfile/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[1].url), 'c/' as Platform.DevToolsPath.UrlString);
+ });
+
+ it('can extract the differentiating segment if part of suffix is unique', () => {
+ const titleInfos: SourcesComponents.BreakpointsViewUtils.TitleInfo[] = createTitleInfos({
+ ambiguous: [
+ 'http://www.google.com/src/a/y',
+ 'http://www.google.com/src2/a/x',
+ 'http://www.google.com/src/b/y',
+ 'http://www.google.com/src2/b/x',
+ ],
+ nonAmbiguous: [],
+ });
+ const differentiatingPathMap = SourcesComponents.BreakpointsViewUtils.getDifferentiatingPathMap(titleInfos);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[0].url), 'a/y/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[1].url), 'a/x/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[2].url), 'b/y/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[3].url), 'b/x/' as Platform.DevToolsPath.UrlString);
+ });
+
+ it('can extract the differentiating segment if separate paths of urls are unique', () => {
+ const titleInfos: SourcesComponents.BreakpointsViewUtils.TitleInfo[] = createTitleInfos({
+ ambiguous: ['http://www.google.com/src/d/y', 'http://www.google.com/src2/c/y', 'http://www.google.com/src3/c/y'],
+ nonAmbiguous: [],
+ });
+ const differentiatingPathMap = SourcesComponents.BreakpointsViewUtils.getDifferentiatingPathMap(titleInfos);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[0].url), 'd/…/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[1].url), 'src2/c/…/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[2].url), 'src3/c/…/' as Platform.DevToolsPath.UrlString);
+ });
+
+ it('can extract the differentiating segment if paths have different length', () => {
+ const titleInfos: SourcesComponents.BreakpointsViewUtils.TitleInfo[] = createTitleInfos({
+ ambiguous: [
+ 'http://www.google.com/src/d',
+ 'http://www.google.com/src/c/y/d',
+ 'http://www.google.com/src2/c/y/d',
+ 'http://www.google.com/src3/c/y/d',
+ ],
+ nonAmbiguous: [],
+ });
+ const differentiatingPathMap = SourcesComponents.BreakpointsViewUtils.getDifferentiatingPathMap(titleInfos);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[0].url), 'src/…/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[1].url), 'src/c/y/…/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[2].url), 'src2/c/y/…/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[3].url), 'src3/c/y/…/' as Platform.DevToolsPath.UrlString);
+ });
+
+ it('can extract the differentiating segment if paths have different length and are completely overlapping otherwise',
+ () => {
+ const titleInfos: SourcesComponents.BreakpointsViewUtils.TitleInfo[] = createTitleInfos({
+ ambiguous: ['http://www.google.com/src/d', 'http://www.google.com/x/src/d'],
+ nonAmbiguous: [],
+ });
+ const differentiatingPathMap = SourcesComponents.BreakpointsViewUtils.getDifferentiatingPathMap(titleInfos);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[0].url), '/…/' as Platform.DevToolsPath.UrlString);
+ assert.strictEqual(differentiatingPathMap.get(titleInfos[1].url), 'x/…/' as Platform.DevToolsPath.UrlString);
+ });
+
+ function createTitleInfos(data: {ambiguous: string[], nonAmbiguous: string[]}):
+ SourcesComponents.BreakpointsViewUtils.TitleInfo[] {
+ const infos = [];
+ for (const path of data.ambiguous) {
+ infos.push({
+ name: AMBIGUOUS_FILE_NAME,
+ url: `${path}/${AMBIGUOUS_FILE_NAME}` as Platform.DevToolsPath.UrlString,
+ });
+ }
+ for (const path of data.nonAmbiguous) {
+ infos.push({
+ name: OTHER_FILE_NAME,
+ url: `${path}/${OTHER_FILE_NAME}` as Platform.DevToolsPath.UrlString,
+ });
+ }
+
+ return infos;
+ }
+});
diff --git a/front_end/panels/sources/components/HeadersView.test.ts b/front_end/panels/sources/components/HeadersView.test.ts
new file mode 100644
index 0000000..90ef2a1
--- /dev/null
+++ b/front_end/panels/sources/components/HeadersView.test.ts
@@ -0,0 +1,575 @@
+// 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 {
+ assertElement,
+ assertShadowRoot,
+ dispatchFocusEvent,
+ dispatchFocusOutEvent,
+ dispatchInputEvent,
+ dispatchKeyDownEvent,
+ dispatchPasteEvent,
+ renderElementIntoDOM,
+} from '../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {
+ deinitializeGlobalVars,
+ initializeGlobalVars,
+} from '../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {createFileSystemUISourceCode} from '../../../../test/unittests/front_end/helpers/UISourceCodeHelpers.js';
+import {
+ recordedMetricsContain,
+ resetRecordedMetrics,
+} from '../../../../test/unittests/front_end/helpers/UserMetricsHelpers.js';
+import * as Host from '../../../core/host/host.js';
+import type * as Platform from '../../../core/platform/platform.js';
+import * as Workspace from '../../../models/workspace/workspace.js';
+import * as Coordinator from '../../../ui/components/render_coordinator/render_coordinator.js';
+import * as UI from '../../../ui/legacy/legacy.js';
+
+import * as SourcesComponents from './components.js';
+
+const {assert} = chai;
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+describe('HeadersView', () => {
+ const commitWorkingCopySpy = sinon.spy();
+
+ before(async () => {
+ await initializeGlobalVars();
+ });
+
+ after(async () => {
+ await deinitializeGlobalVars();
+ });
+
+ beforeEach(() => {
+ commitWorkingCopySpy.resetHistory();
+ resetRecordedMetrics();
+ });
+
+ async function renderEditor(): Promise<SourcesComponents.HeadersView.HeadersViewComponent> {
+ const editor = new SourcesComponents.HeadersView.HeadersViewComponent();
+ editor.data = {
+ headerOverrides: [
+ {
+ applyTo: '*',
+ headers: [
+ {
+ name: 'server',
+ value: 'DevTools Unit Test Server',
+ },
+ {
+ name: 'access-control-allow-origin',
+ value: '*',
+ },
+ ],
+ },
+ {
+ applyTo: '*.jpg',
+ headers: [
+ {
+ name: 'jpg-header',
+ value: 'only for jpg files',
+ },
+ ],
+ },
+ ],
+ parsingError: false,
+ uiSourceCode: {
+ name: () => '.headers',
+ setWorkingCopy: () => {},
+ commitWorkingCopy: commitWorkingCopySpy,
+ } as unknown as Workspace.UISourceCode.UISourceCode,
+ };
+ renderElementIntoDOM(editor);
+ assertShadowRoot(editor.shadowRoot);
+ await coordinator.done();
+ return editor;
+ }
+
+ async function renderEditorWithinWrapper(): Promise<SourcesComponents.HeadersView.HeadersViewComponent> {
+ const workspace = Workspace.Workspace.WorkspaceImpl.instance();
+ const headers = `[
+ {
+ "applyTo": "*",
+ "headers": [
+ {
+ "name": "server",
+ "value": "DevTools Unit Test Server"
+ },
+ {
+ "name": "access-control-allow-origin",
+ "value": "*"
+ }
+ ]
+ },
+ {
+ "applyTo": "*.jpg",
+ "headers": [{
+ "name": "jpg-header",
+ "value": "only for jpg files"
+ }]
+ }
+ ]`;
+ const {uiSourceCode, project} = createFileSystemUISourceCode({
+ url: 'file:///path/to/overrides/example.html' as Platform.DevToolsPath.UrlString,
+ mimeType: 'text/html',
+ content: headers,
+ });
+ uiSourceCode.commitWorkingCopy = commitWorkingCopySpy;
+ project.canSetFileContent = () => true;
+
+ const editorWrapper = new SourcesComponents.HeadersView.HeadersView(uiSourceCode);
+ await uiSourceCode.requestContent();
+ await coordinator.done();
+ const editor = editorWrapper.getComponent();
+ renderElementIntoDOM(editor);
+ assertShadowRoot(editor.shadowRoot);
+ await coordinator.done();
+ workspace.removeProject(project);
+ return editor;
+ }
+
+ async function changeEditable(editable: HTMLElement, value: string): Promise<void> {
+ dispatchFocusEvent(editable, {bubbles: true});
+ editable.innerText = value;
+ dispatchInputEvent(editable, {inputType: 'insertText', data: value, bubbles: true, composed: true});
+ dispatchFocusOutEvent(editable, {bubbles: true});
+ await coordinator.done();
+ assert.isTrue(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken,
+ Host.UserMetrics.Action.HeaderOverrideHeadersFileEdited));
+ }
+
+ async function pressButton(shadowRoot: ShadowRoot, rowIndex: number, selector: string): Promise<void> {
+ const rowElements = shadowRoot.querySelectorAll('.row');
+ const button = rowElements[rowIndex].querySelector(selector);
+ assertElement(button, HTMLElement);
+ button.click();
+ await coordinator.done();
+ }
+
+ function getRowContent(shadowRoot: ShadowRoot): string[] {
+ const rows = Array.from(shadowRoot.querySelectorAll('.row'));
+ return rows.map(row => {
+ return Array.from(row.querySelectorAll('div, .editable'))
+ .map(element => (element as HTMLElement).innerText)
+ .join('');
+ });
+ }
+
+ function getSingleRowContent(shadowRoot: ShadowRoot, rowIndex: number): string {
+ const rows = Array.from(shadowRoot.querySelectorAll('.row'));
+ assert.isTrue(rows.length > rowIndex);
+ return Array.from(rows[rowIndex].querySelectorAll('div, .editable'))
+ .map(element => (element as HTMLElement).innerText)
+ .join('');
+ }
+
+ function isWholeElementContentSelected(element: HTMLElement): boolean {
+ const textContent = element.textContent;
+ if (!textContent || textContent.length < 1 || !element.hasSelection()) {
+ return false;
+ }
+ const selection = element.getComponentSelection();
+ if (!selection || selection.rangeCount < 1) {
+ return false;
+ }
+ if (selection.anchorNode !== selection.focusNode) {
+ return false;
+ }
+ const range = selection.getRangeAt(0);
+ return (range.endOffset - range.startOffset === textContent.length);
+ }
+
+ it('shows an error message when parsingError is true', async () => {
+ const editor = new SourcesComponents.HeadersView.HeadersViewComponent();
+ editor.data = {
+ headerOverrides: [],
+ parsingError: true,
+ uiSourceCode: {
+ name: () => '.headers',
+ } as Workspace.UISourceCode.UISourceCode,
+ };
+ renderElementIntoDOM(editor);
+ assertShadowRoot(editor.shadowRoot);
+ await coordinator.done();
+
+ const errorHeader = editor.shadowRoot.querySelector('.error-header');
+ assert.strictEqual(errorHeader?.textContent, 'Error when parsing \'.headers\'.');
+ });
+
+ it('displays data and allows editing', async () => {
+ const editor = await renderEditor();
+ assertShadowRoot(editor.shadowRoot);
+
+ let rows = getRowContent(editor.shadowRoot);
+ assert.deepEqual(rows, [
+ 'Apply to:*',
+ 'server:DevTools Unit Test Server',
+ 'access-control-allow-origin:*',
+ 'Apply to:*.jpg',
+ 'jpg-header:only for jpg files',
+ ]);
+
+ const addRuleButton = editor.shadowRoot.querySelector('.add-block');
+ assertElement(addRuleButton, HTMLElement);
+ assert.strictEqual(addRuleButton.textContent?.trim(), 'Add override rule');
+
+ const learnMoreLink = editor.shadowRoot.querySelector('.learn-more-row x-link');
+ assertElement(learnMoreLink, HTMLElement);
+ assert.strictEqual(learnMoreLink.title, 'https://goo.gle/devtools-override');
+
+ const editables = editor.shadowRoot.querySelectorAll('.editable');
+ await changeEditable(editables[0] as HTMLElement, 'index.html');
+ await changeEditable(editables[1] as HTMLElement, 'content-type');
+ await changeEditable(editables[4] as HTMLElement, 'example.com');
+ await changeEditable(editables[7] as HTMLElement, 'is image');
+
+ rows = getRowContent(editor.shadowRoot);
+ assert.deepEqual(rows, [
+ 'Apply to:index.html',
+ 'content-type:DevTools Unit Test Server',
+ 'access-control-allow-origin:example.com',
+ 'Apply to:*.jpg',
+ 'jpg-header:is image',
+ ]);
+ assert.strictEqual(commitWorkingCopySpy.callCount, 4);
+ });
+
+ it('resets edited value to previous state on Escape key', async () => {
+ const editor = await renderEditor();
+ assertShadowRoot(editor.shadowRoot);
+ assert.deepEqual(getSingleRowContent(editor.shadowRoot, 1), 'server:DevTools Unit Test Server');
+
+ const editables = editor.shadowRoot.querySelectorAll('.editable');
+ assert.strictEqual(editables.length, 8);
+ const headerValue = editables[2] as HTMLElement;
+ headerValue.focus();
+ headerValue.innerText = 'discard_me';
+ assert.deepEqual(getSingleRowContent(editor.shadowRoot, 1), 'server:discard_me');
+
+ dispatchKeyDownEvent(headerValue, {
+ key: 'Escape',
+ bubbles: true,
+ });
+ await coordinator.done();
+ assert.deepEqual(getSingleRowContent(editor.shadowRoot, 1), 'server:DevTools Unit Test Server');
+
+ const headerName = editables[1] as HTMLElement;
+ headerName.focus();
+ headerName.innerText = 'discard_me_2';
+ assert.deepEqual(getSingleRowContent(editor.shadowRoot, 1), 'discard_me_2:DevTools Unit Test Server');
+
+ dispatchKeyDownEvent(headerName, {
+ key: 'Escape',
+ bubbles: true,
+ });
+ await coordinator.done();
+ assert.deepEqual(getSingleRowContent(editor.shadowRoot, 1), 'server:DevTools Unit Test Server');
+ });
+
+ it('selects the whole content when clicking on an editable field', async () => {
+ const editor = await renderEditor();
+ assertShadowRoot(editor.shadowRoot);
+ const editables = editor.shadowRoot.querySelectorAll('.editable');
+
+ let element = editables[0] as HTMLElement;
+ element.focus();
+ assert.isTrue(isWholeElementContentSelected(element));
+
+ element = editables[1] as HTMLElement;
+ element.focus();
+ assert.isTrue(isWholeElementContentSelected(element));
+
+ element = editables[2] as HTMLElement;
+ element.focus();
+ assert.isTrue(isWholeElementContentSelected(element));
+ });
+
+ it('un-selects the content when an editable field loses focus', async () => {
+ const editor = await renderEditor();
+ assertShadowRoot(editor.shadowRoot);
+ const editables = editor.shadowRoot.querySelectorAll('.editable');
+
+ const element = editables[0] as HTMLElement;
+ element.focus();
+ assert.isTrue(isWholeElementContentSelected(element));
+ element.blur();
+ assert.isFalse(element.hasSelection());
+ });
+
+ it('handles pressing \'Enter\' key by removing focus and moving it to the next field if possible', async () => {
+ const editor = await renderEditor();
+ assertShadowRoot(editor.shadowRoot);
+ const editables = editor.shadowRoot.querySelectorAll('.editable');
+ assert.strictEqual(editables.length, 8);
+
+ const lastHeaderName = editables[6] as HTMLSpanElement;
+ const lastHeaderValue = editables[7] as HTMLSpanElement;
+ assert.isFalse(lastHeaderName.hasSelection());
+ assert.isFalse(lastHeaderValue.hasSelection());
+
+ lastHeaderName.focus();
+ assert.isTrue(isWholeElementContentSelected(lastHeaderName));
+ assert.isFalse(lastHeaderValue.hasSelection());
+
+ dispatchKeyDownEvent(lastHeaderName, {key: 'Enter', bubbles: true});
+ assert.isFalse(lastHeaderName.hasSelection());
+ assert.isTrue(isWholeElementContentSelected(lastHeaderValue));
+
+ dispatchKeyDownEvent(lastHeaderValue, {key: 'Enter', bubbles: true});
+ for (const editable of editables) {
+ assert.isFalse(editable.hasSelection());
+ }
+ });
+
+ it('sets empty \'ApplyTo\' to \'*\'', async () => {
+ const editor = await renderEditor();
+ assertShadowRoot(editor.shadowRoot);
+ const editables = editor.shadowRoot.querySelectorAll('.editable');
+ assert.strictEqual(editables.length, 8);
+
+ const applyTo = editables[5] as HTMLSpanElement;
+ assert.strictEqual(applyTo.innerHTML, '*.jpg');
+
+ applyTo.innerText = '';
+ dispatchInputEvent(applyTo, {inputType: 'deleteContentBackward', data: null, bubbles: true});
+ assert.strictEqual(applyTo.innerHTML, '');
+
+ dispatchFocusOutEvent(applyTo, {bubbles: true});
+ assert.strictEqual(applyTo.innerHTML, '*');
+ assert.strictEqual(commitWorkingCopySpy.callCount, 1);
+ });
+
+ it('removes the entire header when the header name is deleted', async () => {
+ const editor = await renderEditorWithinWrapper();
+ assertShadowRoot(editor.shadowRoot);
+ let rows = getRowContent(editor.shadowRoot);
+ assert.deepEqual(rows, [
+ 'Apply to:*',
+ 'server:DevTools Unit Test Server',
+ 'access-control-allow-origin:*',
+ 'Apply to:*.jpg',
+ 'jpg-header:only for jpg files',
+ ]);
+
+ const editables = editor.shadowRoot.querySelectorAll('.editable');
+ assert.strictEqual(editables.length, 8);
+
+ const headerName = editables[1] as HTMLSpanElement;
+ assert.strictEqual(headerName.innerHTML, 'server');
+
+ headerName.innerText = '';
+ dispatchInputEvent(headerName, {inputType: 'deleteContentBackward', data: null, bubbles: true});
+ assert.strictEqual(headerName.innerHTML, '');
+
+ dispatchFocusOutEvent(headerName, {bubbles: true});
+ await coordinator.done();
+
+ rows = getRowContent(editor.shadowRoot);
+ assert.deepEqual(rows, [
+ 'Apply to:*',
+ 'access-control-allow-origin:*',
+ 'Apply to:*.jpg',
+ 'jpg-header:only for jpg files',
+ ]);
+ assert.strictEqual(commitWorkingCopySpy.callCount, 1);
+ assert.isTrue(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken,
+ Host.UserMetrics.Action.HeaderOverrideHeadersFileEdited));
+ });
+
+ it('allows adding headers', async () => {
+ const editor = await renderEditorWithinWrapper();
+ await coordinator.done();
+ assertShadowRoot(editor.shadowRoot);
+
+ let rows = getRowContent(editor.shadowRoot);
+ assert.deepEqual(rows, [
+ 'Apply to:*',
+ 'server:DevTools Unit Test Server',
+ 'access-control-allow-origin:*',
+ 'Apply to:*.jpg',
+ 'jpg-header:only for jpg files',
+ ]);
+
+ await pressButton(editor.shadowRoot, 1, '.add-header');
+
+ rows = getRowContent(editor.shadowRoot);
+ assert.deepEqual(rows, [
+ 'Apply to:*',
+ 'server:DevTools Unit Test Server',
+ 'header-name-1:header value',
+ 'access-control-allow-origin:*',
+ 'Apply to:*.jpg',
+ 'jpg-header:only for jpg files',
+ ]);
+ assert.isTrue(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken,
+ Host.UserMetrics.Action.HeaderOverrideHeadersFileEdited));
+
+ const editables = editor.shadowRoot.querySelectorAll('.editable');
+ await changeEditable(editables[3] as HTMLElement, 'cache-control');
+ await changeEditable(editables[4] as HTMLElement, 'max-age=1000');
+
+ rows = getRowContent(editor.shadowRoot);
+ assert.deepEqual(rows, [
+ 'Apply to:*',
+ 'server:DevTools Unit Test Server',
+ 'cache-control:max-age=1000',
+ 'access-control-allow-origin:*',
+ 'Apply to:*.jpg',
+ 'jpg-header:only for jpg files',
+ ]);
+ });
+
+ it('allows adding "ApplyTo"-blocks', async () => {
+ const editor = await renderEditorWithinWrapper();
+ await coordinator.done();
+ assertShadowRoot(editor.shadowRoot);
+
+ let rows = getRowContent(editor.shadowRoot);
+ assert.deepEqual(rows, [
+ 'Apply to:*',
+ 'server:DevTools Unit Test Server',
+ 'access-control-allow-origin:*',
+ 'Apply to:*.jpg',
+ 'jpg-header:only for jpg files',
+ ]);
+
+ const button = editor.shadowRoot.querySelector('.add-block');
+ assertElement(button, HTMLElement);
+ button.click();
+ await coordinator.done();
+
+ rows = getRowContent(editor.shadowRoot);
+ assert.deepEqual(rows, [
+ 'Apply to:*',
+ 'server:DevTools Unit Test Server',
+ 'access-control-allow-origin:*',
+ 'Apply to:*.jpg',
+ 'jpg-header:only for jpg files',
+ 'Apply to:*',
+ 'header-name-1:header value',
+ ]);
+ assert.isTrue(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken,
+ Host.UserMetrics.Action.HeaderOverrideHeadersFileEdited));
+
+ const editables = editor.shadowRoot.querySelectorAll('.editable');
+ await changeEditable(editables[8] as HTMLElement, 'articles/*');
+ await changeEditable(editables[9] as HTMLElement, 'cache-control');
+ await changeEditable(editables[10] as HTMLElement, 'max-age=1000');
+
+ rows = getRowContent(editor.shadowRoot);
+ assert.deepEqual(rows, [
+ 'Apply to:*',
+ 'server:DevTools Unit Test Server',
+ 'access-control-allow-origin:*',
+ 'Apply to:*.jpg',
+ 'jpg-header:only for jpg files',
+ 'Apply to:articles/*',
+ 'cache-control:max-age=1000',
+ ]);
+ });
+
+ it('allows removing headers', async () => {
+ const editor = await renderEditorWithinWrapper();
+ await coordinator.done();
+ assertShadowRoot(editor.shadowRoot);
+
+ let rows = getRowContent(editor.shadowRoot);
+ assert.deepEqual(rows, [
+ 'Apply to:*',
+ 'server:DevTools Unit Test Server',
+ 'access-control-allow-origin:*',
+ 'Apply to:*.jpg',
+ 'jpg-header:only for jpg files',
+ ]);
+
+ await pressButton(editor.shadowRoot, 1, '.remove-header');
+
+ rows = getRowContent(editor.shadowRoot);
+ assert.deepEqual(rows, [
+ 'Apply to:*',
+ 'access-control-allow-origin:*',
+ 'Apply to:*.jpg',
+ 'jpg-header:only for jpg files',
+ ]);
+ assert.isTrue(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken,
+ Host.UserMetrics.Action.HeaderOverrideHeadersFileEdited));
+
+ let hiddenDeleteElements = await editor.shadowRoot.querySelectorAll('.row.padded > .remove-header[hidden]');
+ assert.isTrue(hiddenDeleteElements.length === 0, 'remove-header button is visible');
+
+ await pressButton(editor.shadowRoot, 1, '.remove-header');
+
+ rows = getRowContent(editor.shadowRoot);
+ assert.deepEqual(rows, [
+ 'Apply to:*',
+ 'header-name-1:header value',
+ 'Apply to:*.jpg',
+ 'jpg-header:only for jpg files',
+ ]);
+
+ hiddenDeleteElements = await editor.shadowRoot.querySelectorAll('.row.padded > .remove-header[hidden]');
+ assert.isTrue(hiddenDeleteElements.length === 1, 'remove-header button is hidden');
+ });
+
+ it('allows removing "ApplyTo"-blocks', async () => {
+ const editor = await renderEditorWithinWrapper();
+ await coordinator.done();
+ assertShadowRoot(editor.shadowRoot);
+
+ let rows = getRowContent(editor.shadowRoot);
+ assert.deepEqual(rows, [
+ 'Apply to:*',
+ 'server:DevTools Unit Test Server',
+ 'access-control-allow-origin:*',
+ 'Apply to:*.jpg',
+ 'jpg-header:only for jpg files',
+ ]);
+
+ await pressButton(editor.shadowRoot, 0, '.remove-block');
+
+ rows = getRowContent(editor.shadowRoot);
+ assert.deepEqual(rows, [
+ 'Apply to:*.jpg',
+ 'jpg-header:only for jpg files',
+ ]);
+ assert.isTrue(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken,
+ Host.UserMetrics.Action.HeaderOverrideHeadersFileEdited));
+ });
+
+ it('removes formatting for pasted content', async () => {
+ const editor = await renderEditor();
+ assertShadowRoot(editor.shadowRoot);
+ const editables = editor.shadowRoot.querySelectorAll('.editable');
+ assert.strictEqual(editables.length, 8);
+ assert.deepEqual(getSingleRowContent(editor.shadowRoot, 2), 'access-control-allow-origin:*');
+
+ const headerValue = editables[4] as HTMLSpanElement;
+ headerValue.focus();
+ const dt = new DataTransfer();
+ dt.setData('text/plain', 'foo\nbar');
+ dt.setData('text/html', 'This is <b>bold</b>');
+ dispatchPasteEvent(headerValue, {clipboardData: dt, bubbles: true});
+ await coordinator.done();
+ assert.deepEqual(getSingleRowContent(editor.shadowRoot, 2), 'access-control-allow-origin:foo bar');
+ assert.isTrue(recordedMetricsContain(
+ Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken,
+ Host.UserMetrics.Action.HeaderOverrideHeadersFileEdited));
+ });
+
+ it('shows context menu', async () => {
+ const editor = await renderEditor();
+ assertShadowRoot(editor.shadowRoot);
+ const contextMenuShow = sinon.stub(UI.ContextMenu.ContextMenu.prototype, 'show').resolves();
+ editor.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true}));
+ assert.isTrue(contextMenuShow.calledOnce);
+ });
+});
diff --git a/front_end/panels/utils/BUILD.gn b/front_end/panels/utils/BUILD.gn
index 07bf158..9fea75b 100644
--- a/front_end/panels/utils/BUILD.gn
+++ b/front_end/panels/utils/BUILD.gn
@@ -3,6 +3,7 @@
# found in the LICENSE file.
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
+import("../../../third_party/typescript/typescript.gni")
import("../visibility.gni")
devtools_entrypoint("bundle") {
@@ -21,3 +22,14 @@
visibility += devtools_panels_visibility
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [ "utils.test.ts" ]
+
+ deps = [
+ ":bundle",
+ "../../third_party/diff:bundle",
+ ]
+}
diff --git a/front_end/panels/utils/utils.test.ts b/front_end/panels/utils/utils.test.ts
new file mode 100644
index 0000000..bec635f
--- /dev/null
+++ b/front_end/panels/utils/utils.test.ts
@@ -0,0 +1,70 @@
+// 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 PanelUtils from './utils.js';
+import * as Diff from '../../third_party/diff/diff.js';
+
+const {assert} = chai;
+
+describe('panels/utils', () => {
+ it('formats CSS changes from diff arrays', async () => {
+ const original = `
+ .container {
+ width: 10px;
+ height: 10px;
+ }
+
+ .child {
+ display: grid;
+ --child-theme-color: 100, 200, 0;
+ }
+
+ @supports (display: grid) {
+ .container {
+ display: grid;
+ }
+ }`;
+ const current = `
+ .container {
+ width: 15px;
+ margin: 0;
+ }
+
+ .child2 {
+ display: grid;
+ --child-theme-color: 5, 10, 15;
+ padding: 10px;
+ }
+
+ @supports (display: flex) {
+ .container {
+ display: flex;
+ }
+ }`;
+ const diff = Diff.Diff.DiffWrapper.lineDiff(original.split('\n'), current.split('\n'));
+ const changes = await PanelUtils.PanelUtils.formatCSSChangesFromDiff(diff);
+ assert.strictEqual(
+ changes, `.container {
+ /* width: 10px; */
+ /* height: 10px; */
+ width: 15px;
+ margin: 0;
+}
+
+/* .child { */
+.child2 {
+ /* --child-theme-color: 100, 200, 0; */
+ --child-theme-color: 5, 10, 15;
+ padding: 10px;
+}
+
+/* @supports (display: grid) { */
+@supports (display: flex) {
+.container {
+ /* display: grid; */
+ display: flex;
+}`,
+ 'formatted CSS changes are not correct');
+ });
+});
diff --git a/front_end/panels/webauthn/BUILD.gn b/front_end/panels/webauthn/BUILD.gn
index df1cebb..ae9cb23 100644
--- a/front_end/panels/webauthn/BUILD.gn
+++ b/front_end/panels/webauthn/BUILD.gn
@@ -5,6 +5,7 @@
import("../../../scripts/build/ninja/devtools_entrypoint.gni")
import("../../../scripts/build/ninja/devtools_module.gni")
import("../../../scripts/build/ninja/generate_css.gni")
+import("../../../third_party/typescript/typescript.gni")
import("../visibility.gni")
generate_css("css_files") {
@@ -36,7 +37,6 @@
visibility = [
":*",
"../../../test/unittests/front_end/entrypoints/missing_entrypoints/*",
- "../../../test/unittests/front_end/panels/webauthn/*",
"../../entrypoints/*",
]
@@ -54,3 +54,17 @@
visibility = [ "../../entrypoints/*" ]
}
+
+ts_library("unittests") {
+ testonly = true
+
+ sources = [ "WebauthnPane.test.ts" ]
+
+ deps = [
+ ":bundle",
+ "../../../test/unittests/front_end/helpers",
+ "../../core/platform:bundle",
+ "../../core/sdk:bundle",
+ "../../generated:protocol",
+ ]
+}
diff --git a/front_end/panels/webauthn/WebauthnPane.test.ts b/front_end/panels/webauthn/WebauthnPane.test.ts
new file mode 100644
index 0000000..005f435
--- /dev/null
+++ b/front_end/panels/webauthn/WebauthnPane.test.ts
@@ -0,0 +1,261 @@
+// Copyright (c) 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 {createTarget} from '../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {
+ describeWithMockConnection,
+} from '../../../test/unittests/front_end/helpers/MockConnection.js';
+import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import type * as Protocol from '../../generated/protocol.js';
+
+import type * as WebauthnModule from './webauthn.js';
+
+const {assert} = chai;
+
+describeWithMockConnection('WebAuthn pane', () => {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ let Webauthn: typeof WebauthnModule;
+
+ before(async () => {
+ Webauthn = await import('./webauthn.js');
+ });
+
+ it('disables the large blob checkbox if resident key is disabled', () => {
+ const panel = new Webauthn.WebauthnPane.WebauthnPaneImpl();
+ const largeBlob = panel.largeBlobCheckbox;
+ const residentKeys = panel.residentKeyCheckbox;
+
+ if (!largeBlob || !residentKeys) {
+ assert.fail('Required checkbox not found');
+ return;
+ }
+
+ // Make sure resident keys is disabled. Large blob should be disabled and
+ // unchecked.
+ residentKeys.checked = false;
+ residentKeys.dispatchEvent(new Event('change'));
+ assert.isTrue(largeBlob.disabled);
+ assert.isFalse(largeBlob.checked);
+
+ // Enable resident keys. Large blob should be enabled but still not
+ // checked.
+ residentKeys.checked = true;
+ residentKeys.dispatchEvent(new Event('change'));
+ assert.isFalse(largeBlob.disabled);
+ assert.isFalse(largeBlob.checked);
+
+ // Manually check large blob.
+ largeBlob.checked = true;
+ assert.isTrue(largeBlob.checked);
+
+ // Disabling resident keys should reset large blob to disabled and
+ // unchecked.
+ residentKeys.checked = false;
+ residentKeys.dispatchEvent(new Event('change'));
+ assert.isTrue(largeBlob.disabled);
+ assert.isFalse(largeBlob.checked);
+ });
+
+ const tests = (targetFactory: () => SDK.Target.Target, inScope: boolean) => {
+ let target: SDK.Target.Target;
+ let model: SDK.WebAuthnModel.WebAuthnModel;
+ let panel: WebauthnModule.WebauthnPane.WebauthnPaneImpl;
+ beforeEach(() => {
+ target = targetFactory();
+ SDK.TargetManager.TargetManager.instance().setScopeTarget(inScope ? target : null);
+ model = target.model(SDK.WebAuthnModel.WebAuthnModel) as SDK.WebAuthnModel.WebAuthnModel;
+ assertNotNullOrUndefined(model);
+ panel = new Webauthn.WebauthnPane.WebauthnPaneImpl();
+ });
+
+ it('adds an authenticator with large blob option', async () => {
+ const largeBlob = panel.largeBlobCheckbox;
+ const residentKeys = panel.residentKeyCheckbox;
+
+ if (!largeBlob || !residentKeys) {
+ assert.fail('Required checkbox not found');
+ return;
+ }
+ residentKeys.checked = true;
+ largeBlob.checked = true;
+
+ const addAuthenticator = sinon.stub(model, 'addAuthenticator');
+ panel.addAuthenticatorButton?.click();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ assert.strictEqual(addAuthenticator.called, inScope);
+ if (inScope) {
+ const options = addAuthenticator.firstCall.firstArg;
+ assert.isTrue(options.hasLargeBlob);
+ assert.isTrue(options.hasResidentKey);
+ }
+ });
+
+ it('adds an authenticator without the large blob option', async () => {
+ const largeBlob = panel.largeBlobCheckbox;
+ const residentKeys = panel.residentKeyCheckbox;
+
+ if (!largeBlob || !residentKeys) {
+ assert.fail('Required checkbox not found');
+ return;
+ }
+ residentKeys.checked = true;
+ largeBlob.checked = false;
+
+ const addAuthenticator = sinon.stub(model, 'addAuthenticator');
+ panel.addAuthenticatorButton?.click();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ assert.strictEqual(addAuthenticator.called, inScope);
+ if (inScope) {
+ const options = addAuthenticator.firstCall.firstArg;
+ assert.isFalse(options.hasLargeBlob);
+ assert.isTrue(options.hasResidentKey);
+ }
+ });
+
+ it('lists and removes credentials', async () => {
+ const authenticatorId = 'authenticator-1' as Protocol.WebAuthn.AuthenticatorId;
+
+ // Add an authenticator.
+ const addAuthenticator = sinon.stub(model, 'addAuthenticator').resolves(authenticatorId);
+ panel.addAuthenticatorButton?.click();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ assert.strictEqual(addAuthenticator.called, inScope);
+ if (!inScope) {
+ return;
+ }
+
+ // Verify a data grid appeared with a single row to show there is no data.
+ const dataGrid = panel.dataGrids.get(authenticatorId);
+ if (!dataGrid) {
+ assert.fail('Expected dataGrid to be truthy');
+ return;
+ }
+ assert.strictEqual(dataGrid.rootNode().children.length, 1);
+ let emptyNode = dataGrid.rootNode().children[0];
+ assert.isOk(emptyNode);
+ assert.deepEqual(emptyNode.data, {});
+
+ // Add a credential.
+ const credential = {
+ credentialId: 'credential',
+ isResidentCredential: false,
+ rpId: 'talos1.org',
+ userHandle: 'morgan',
+ signCount: 1,
+ privateKey: '',
+ };
+ model.dispatchEventToListeners(SDK.WebAuthnModel.Events.CredentialAdded, {
+ authenticatorId,
+ credential,
+ });
+
+ // Verify the credential appeared and the empty row was removed.
+ assert.strictEqual(dataGrid.rootNode().children.length, 1);
+ const credentialNode = dataGrid.rootNode().children[0];
+ assert.isOk(credentialNode);
+ assert.strictEqual(credentialNode.data, credential);
+
+ // Remove the credential.
+ const removeCredential = sinon.stub(model, 'removeCredential').resolves();
+ dataGrid.element.querySelectorAll('button')[1].click();
+ assert.strictEqual(dataGrid.rootNode().children.length, 1);
+ emptyNode = dataGrid.rootNode().children[0];
+ assert.isOk(emptyNode);
+ assert.deepEqual(emptyNode.data, {});
+ await new Promise(resolve => setTimeout(resolve, 0));
+ assert.isTrue(removeCredential.called);
+
+ assert.strictEqual(removeCredential.firstCall.firstArg, authenticatorId);
+ assert.strictEqual(removeCredential.firstCall.lastArg, credential.credentialId);
+ });
+
+ it('updates credentials', async () => {
+ const authenticatorId = 'authenticator-1' as Protocol.WebAuthn.AuthenticatorId;
+
+ // Add an authenticator.
+ const addAuthenticator = sinon.stub(model, 'addAuthenticator').resolves(authenticatorId);
+ panel.addAuthenticatorButton?.click();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ assert.strictEqual(addAuthenticator.called, inScope);
+ if (!inScope) {
+ return;
+ }
+
+ // Add a credential.
+ const credential = {
+ credentialId: 'credential',
+ isResidentCredential: false,
+ rpId: 'talos1.org',
+ userHandle: 'morgan',
+ signCount: 1,
+ privateKey: '',
+ };
+ model.dispatchEventToListeners(SDK.WebAuthnModel.Events.CredentialAdded, {
+ authenticatorId,
+ credential,
+ });
+
+ // Verify the credential appeared.
+ const dataGrid = panel.dataGrids.get(authenticatorId);
+ if (!dataGrid) {
+ assert.fail('Expected dataGrid to be truthy');
+ return;
+ }
+ assert.strictEqual(dataGrid.rootNode().children.length, 1);
+ const credentialNode = dataGrid.rootNode().children[0];
+ assert.isOk(credentialNode);
+ assert.strictEqual(credentialNode.data, credential);
+
+ // Update the credential.
+ const updatedCredential = {
+ credentialId: 'credential',
+ isResidentCredential: false,
+ rpId: 'talos1.org',
+ userHandle: 'morgan',
+ signCount: 2,
+ privateKey: '',
+ };
+ model.dispatchEventToListeners(SDK.WebAuthnModel.Events.CredentialAsserted, {
+ authenticatorId,
+ credential: updatedCredential,
+ });
+
+ // Verify the credential was updated.
+ assert.strictEqual(dataGrid.rootNode().children.length, 1);
+ assert.strictEqual(credentialNode.data, updatedCredential);
+
+ // Updating a different credential should not affect the existing one.
+ const anotherCredential = {
+ credentialId: 'another-credential',
+ isResidentCredential: false,
+ rpId: 'talos1.org',
+ userHandle: 'alex',
+ signCount: 1,
+ privateKey: '',
+ };
+ model.dispatchEventToListeners(SDK.WebAuthnModel.Events.CredentialAsserted, {
+ authenticatorId,
+ credential: anotherCredential,
+ });
+
+ // Verify the credential was unchanged.
+ assert.strictEqual(dataGrid.rootNode().children.length, 1);
+ assert.strictEqual(credentialNode.data, updatedCredential);
+ });
+ };
+
+ describe('without tab target in scope', () => tests(() => createTarget(), true));
+ describe('without tab target out of scope', () => tests(() => createTarget(), false));
+ describe('with tab target in scope', () => tests(() => {
+ const tabTarget = createTarget({type: SDK.Target.Type.Tab});
+ createTarget({parentTarget: tabTarget, subtype: 'prerender'});
+ return createTarget({parentTarget: tabTarget});
+ }, true));
+ describe('with tab target out of scope', () => tests(() => {
+ const tabTarget = createTarget({type: SDK.Target.Type.Tab});
+ createTarget({parentTarget: tabTarget, subtype: 'prerender'});
+ return createTarget({parentTarget: tabTarget});
+ }, false));
+});
diff --git a/front_end/third_party/diff/BUILD.gn b/front_end/third_party/diff/BUILD.gn
index 7963a86..e488083 100644
--- a/front_end/third_party/diff/BUILD.gn
+++ b/front_end/third_party/diff/BUILD.gn
@@ -26,6 +26,7 @@
"../../models/workspace_diff/*",
"../../panels/changes/*",
"../../panels/sources/*",
+ "../../panels/utils:unittests",
"../../ui/components/diff_view/*",
"../../ui/legacy/components/quick_open/*",
"../../ui/legacy/components/source_frame/*",