[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/*",