From e63684ce8a96e5f6bac66a21ed4d4e480847d494 Mon Sep 17 00:00:00 2001 From: freelw <826231693@qq.com> Date: Wed, 17 Sep 2025 15:34:09 +0800 Subject: [PATCH 1/8] =?UTF-8?q?ScreencastApp=E5=8E=BB=E6=8E=89inspector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front_end/panels/screencast/ScreencastApp.ts | 23 ++++++++++---------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/front_end/panels/screencast/ScreencastApp.ts b/front_end/panels/screencast/ScreencastApp.ts index 509b45ad0ac1..ac8c56ef1a05 100644 --- a/front_end/panels/screencast/ScreencastApp.ts +++ b/front_end/panels/screencast/ScreencastApp.ts @@ -52,7 +52,7 @@ export class ScreencastApp implements Common.App.App, this.rootSplitWidget.setVertical(true); this.rootSplitWidget.setSecondIsSidebar(true); this.rootSplitWidget.show(this.rootView.element); - this.rootSplitWidget.hideMain(); + this.rootSplitWidget.hideSidebar(); this.rootSplitWidget.setSidebarWidget(UI.InspectorView.InspectorView.instance()); UI.InspectorView.InspectorView.instance().setOwnerSplit(this.rootSplitWidget); @@ -94,16 +94,17 @@ export class ScreencastApp implements Common.App.App, } private onScreencastEnabledChanged(): void { - if (!this.rootSplitWidget) { - return; - } - const enabled = Boolean(this.enabledSetting.get() && this.screencastView); - this.toggleButton.setToggled(enabled); - if (enabled) { - this.rootSplitWidget.showBoth(); - } else { - this.rootSplitWidget.hideMain(); - } + this.rootSplitWidget?.hideSidebar(); + // if (!this.rootSplitWidget) { + // return; + // } + // const enabled = Boolean(this.enabledSetting.get() && this.screencastView); + // this.toggleButton.setToggled(enabled); + // if (enabled) { + // this.rootSplitWidget.showBoth(); + // } else { + // this.rootSplitWidget.hideMain(); + // } } } From f09b11630649d0464b4cd71e0f1cf25aa68965a3 Mon Sep 17 00:00:00 2001 From: wangli Date: Mon, 29 Dec 2025 15:17:51 +0800 Subject: [PATCH 2/8] Update default value for 'breakpoints-active' setting to false --- front_end/core/sdk/sdk-meta.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front_end/core/sdk/sdk-meta.ts b/front_end/core/sdk/sdk-meta.ts index c1b79585e906..b36b1d50208f 100644 --- a/front_end/core/sdk/sdk-meta.ts +++ b/front_end/core/sdk/sdk-meta.ts @@ -491,7 +491,7 @@ Common.Settings.registerSettingExtension({ settingName: 'breakpoints-active', settingType: Common.Settings.SettingType.BOOLEAN, storageType: Common.Settings.SettingStorageType.SESSION, - defaultValue: true, + defaultValue: false, }); Common.Settings.registerSettingExtension({ From eec51b932b354ff6acd69d35934b391d510a2268 Mon Sep 17 00:00:00 2001 From: guojinghua Date: Tue, 31 Mar 2026 17:41:07 +0800 Subject: [PATCH 3/8] Revert "Merge pull request #1 from LexMount/wangli_dev_20250917_1" This reverts commit 9afef0c3c7730abe915e344002b38ec3a7ecdd9d, reversing changes made to 0c588635a8873583297db4c241fc1d348836bfa6. --- front_end/panels/screencast/ScreencastApp.ts | 23 ++++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/front_end/panels/screencast/ScreencastApp.ts b/front_end/panels/screencast/ScreencastApp.ts index ac8c56ef1a05..509b45ad0ac1 100644 --- a/front_end/panels/screencast/ScreencastApp.ts +++ b/front_end/panels/screencast/ScreencastApp.ts @@ -52,7 +52,7 @@ export class ScreencastApp implements Common.App.App, this.rootSplitWidget.setVertical(true); this.rootSplitWidget.setSecondIsSidebar(true); this.rootSplitWidget.show(this.rootView.element); - this.rootSplitWidget.hideSidebar(); + this.rootSplitWidget.hideMain(); this.rootSplitWidget.setSidebarWidget(UI.InspectorView.InspectorView.instance()); UI.InspectorView.InspectorView.instance().setOwnerSplit(this.rootSplitWidget); @@ -94,17 +94,16 @@ export class ScreencastApp implements Common.App.App, } private onScreencastEnabledChanged(): void { - this.rootSplitWidget?.hideSidebar(); - // if (!this.rootSplitWidget) { - // return; - // } - // const enabled = Boolean(this.enabledSetting.get() && this.screencastView); - // this.toggleButton.setToggled(enabled); - // if (enabled) { - // this.rootSplitWidget.showBoth(); - // } else { - // this.rootSplitWidget.hideMain(); - // } + if (!this.rootSplitWidget) { + return; + } + const enabled = Boolean(this.enabledSetting.get() && this.screencastView); + this.toggleButton.setToggled(enabled); + if (enabled) { + this.rootSplitWidget.showBoth(); + } else { + this.rootSplitWidget.hideMain(); + } } } From 392a23728a02afc6787d0e550b1045178bc82268 Mon Sep 17 00:00:00 2001 From: guojinghua Date: Tue, 31 Mar 2026 17:46:29 +0800 Subject: [PATCH 4/8] feat: Add support for toggling Inspector view in Screencast --- front_end/panels/screencast/ScreencastApp.ts | 25 +++++++++++++++++++ front_end/panels/screencast/ScreencastView.ts | 19 ++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/front_end/panels/screencast/ScreencastApp.ts b/front_end/panels/screencast/ScreencastApp.ts index 509b45ad0ac1..bacc8b534053 100644 --- a/front_end/panels/screencast/ScreencastApp.ts +++ b/front_end/panels/screencast/ScreencastApp.ts @@ -10,6 +10,9 @@ import * as UI from '../../ui/legacy/legacy.js'; import {ScreencastView} from './ScreencastView.js'; +// @lexmount +import {ShowMode} from '../../ui/legacy/SplitWidget.js'; + const UIStrings = { /** * @description Tooltip text that appears when hovering over largeicon phone button in Screencast App of the Remote Devices tab when toggling screencast @@ -54,6 +57,11 @@ export class ScreencastApp implements Common.App.App, this.rootSplitWidget.show(this.rootView.element); this.rootSplitWidget.hideMain(); + // guojinghua@lexmount: call hideSidebar by default if screencast is enabled + if (this.enabledSetting.get()) { + this.rootSplitWidget.hideSidebar(); + } + this.rootSplitWidget.setSidebarWidget(UI.InspectorView.InspectorView.instance()); UI.InspectorView.InspectorView.instance().setOwnerSplit(this.rootSplitWidget); this.rootView.attachToDocument(document); @@ -87,6 +95,18 @@ export class ScreencastApp implements Common.App.App, this.onScreencastEnabledChanged(); } + // guojinghua@lexmount: public method for toggle inspector view + toggleInspectorView(): void { + if (!this.rootSplitWidget) { + return; + } + if (this.rootSplitWidget.showMode() === ShowMode.ONLY_MAIN) { + this.rootSplitWidget.showBoth(); + } else { + this.rootSplitWidget.hideSidebar(); + } + } + private toggleButtonClicked(): void { const enabled = this.toggleButton.isToggled(); this.enabledSetting.set(enabled); @@ -100,6 +120,11 @@ export class ScreencastApp implements Common.App.App, const enabled = Boolean(this.enabledSetting.get() && this.screencastView); this.toggleButton.setToggled(enabled); if (enabled) { + // guojinghua@lexmount: do not show sidebar if it is hidden + if (this.rootSplitWidget.showMode() === ShowMode.ONLY_MAIN) { + return; + } + this.rootSplitWidget.showBoth(); } else { this.rootSplitWidget.hideMain(); diff --git a/front_end/panels/screencast/ScreencastView.ts b/front_end/panels/screencast/ScreencastView.ts index 2810e5d80a2e..471d0b46ab0d 100644 --- a/front_end/panels/screencast/ScreencastView.ts +++ b/front_end/panels/screencast/ScreencastView.ts @@ -677,6 +677,13 @@ export class ScreencastView extends UI.Widget.VBox implements SDK.OverlayModel.H this.touchInputToggleIcon = this.touchInputToggle.appendChild(IconButton.Icon.create('touch-app')); UI.ARIAUtils.setLabel(this.touchInputToggle, i18nString(UIStrings.touchInput)); + // guojinghua@lexmount: add screencast toggle button + this.inspectorToggle = this.navigationBar.createChild('button'); + this.inspectorToggleIcon = this.inspectorToggle.appendChild(IconButton.Icon.create('global')); + // TODO(guojinghua): i18n + UI.ARIAUtils.setLabel(this.inspectorToggle, 'Toggle inspector view'); + this.inspectorToggle.addEventListener('click', this.#toggleInspectorView.bind(this), false); + this.navigationProgressBar = new ProgressTracker( this.resourceTreeModel, this.networkManager, this.navigationBar.createChild('div', 'progress')); @@ -747,6 +754,18 @@ export class ScreencastView extends UI.Widget.VBox implements SDK.OverlayModel.H this.canvasContainerElement.classList.toggle('touchable', value); } + // guojinghua@lexmount: click handler for inspector toggle button + private inspectorToggle?: HTMLButtonElement; + private inspectorToggleIcon?: IconButton.Icon.Icon; + async #toggleInspectorView(): Promise { + if (!this.inspectorToggle || !this.inspectorToggleIcon) { + return; + } + const {ScreencastApp} = await import('./ScreencastApp.js'); + ScreencastApp.instance().toggleInspectorView(); + this.inspectorToggleIcon.classList.toggle('toggled', !this.inspectorToggleIcon.classList.contains('toggled')); + } + private requestNavigationHistoryEvent(): void { void this.requestNavigationHistory(); } From fc2cfd6a755a9b8329b22d114e55da6f8fb1b3e7 Mon Sep 17 00:00:00 2001 From: LX-GuoJH Date: Tue, 26 May 2026 20:06:41 +0800 Subject: [PATCH 5/8] feat: Support copy & paste across remote browser (#5) --- front_end/panels/screencast/InputModel.ts | 5 ++ front_end/panels/screencast/ScreencastView.ts | 68 ++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/front_end/panels/screencast/InputModel.ts b/front_end/panels/screencast/InputModel.ts index cfa97617c47f..aad5189e98dd 100644 --- a/front_end/panels/screencast/InputModel.ts +++ b/front_end/panels/screencast/InputModel.ts @@ -62,6 +62,11 @@ export class InputModel extends SDK.SDKModel.SDKModel { }); } + // guojinghua@lexmount: add interface for inserting text to page + emitText(text: string): void { + void this.inputAgent.invoke_insertText({text}); + } + emitMouseEvent(event: MouseEvent, offsetTop: number, zoom: number): void { if (!(event.type in MOUSE_EVENT_TYPES)) { return; diff --git a/front_end/panels/screencast/ScreencastView.ts b/front_end/panels/screencast/ScreencastView.ts index 471d0b46ab0d..fd5650f5942c 100644 --- a/front_end/panels/screencast/ScreencastView.ts +++ b/front_end/panels/screencast/ScreencastView.ts @@ -329,12 +329,33 @@ export class ScreencastView extends UI.Widget.VBox implements SDK.OverlayModel.H event.preventDefault(); } - private handleKeyEvent(event: KeyboardEvent): void { + // guojinghua@lexmount: change to async function + private async handleKeyEvent(event: KeyboardEvent): Promise { if (this.isGlassPaneActive()) { event.consume(); return; } + // guojinghua@lexmount: toolkit for copy & paste across remote browser + if (document.activeElement === this.canvasElement) { + if (event.type === 'keydown' && !event.repeat) { + if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey) { + switch(event.key.toLowerCase()) { + case 'c': + await this.copyRemoteSelectionToClipboard(); + break; + case 'v': + if (await this.pasteClipboardTextToRemote()) { + event.consume(); + this.canvasElement.focus(); + return; + } + break; + } + } + } + } + const shortcutKey = UI.KeyboardShortcut.KeyboardShortcut.makeKeyFromEvent(event); const handler = this.shortcuts[shortcutKey]; if (handler?.(event)) { @@ -349,6 +370,51 @@ export class ScreencastView extends UI.Widget.VBox implements SDK.OverlayModel.H this.canvasElement.focus(); } + // guojinghua@lexmount: function for copying text from remote browser + private async copyRemoteSelectionToClipboard(): Promise { + const expression = `(() => { + const activeElement = (() => { + let element = document.activeElement; + while (element && element.shadowRoot && element.shadowRoot.activeElement) { + element = element.shadowRoot.activeElement; + } + return element; + })(); + if (activeElement) { + const tagName = activeElement.tagName ? activeElement.tagName.toLowerCase() : ''; + const type = activeElement.type ? activeElement.type.toLowerCase() : ''; + const hasTextSelection = (tagName === 'textarea' || tagName === 'input') && type !== 'password' && + typeof activeElement.selectionStart === 'number' && typeof activeElement.selectionEnd === 'number'; + if (hasTextSelection && activeElement.selectionStart !== activeElement.selectionEnd) { + return activeElement.value.slice(activeElement.selectionStart, activeElement.selectionEnd); + } + } + const selection = window.getSelection ? window.getSelection() : null; + return selection ? selection.toString() : ''; + })()`; + const response = await this.screenCaptureModel.target().runtimeAgent().invoke_evaluate({ + expression, + returnByValue: true, + silent: true, + }); + if (response.result && response.result.value) { + await navigator.clipboard.writeText(response.result.value); + return true; + } + return false; + } + + // guojinghua@lexmount: function for pasting text to remote browser + private async pasteClipboardTextToRemote(): Promise { + const text = await navigator.clipboard.readText(); + if (!text) { + return false; + } + + await this.inputModel?.emitText(text); + return true; + } + private handleBlurEvent(): void { if (this.inputModel && this.mouseInputToggle?.disabled) { const event = new MouseEvent('mouseup'); From 98039ef0caf883f4d4ff6d5646ddac66cc6605bd Mon Sep 17 00:00:00 2001 From: guojinghua Date: Mon, 27 Jul 2026 14:08:31 +0800 Subject: [PATCH 6/8] feat: Support disable inspector panel --- front_end/panels/screencast/ScreencastView.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/front_end/panels/screencast/ScreencastView.ts b/front_end/panels/screencast/ScreencastView.ts index fd5650f5942c..35a2e28c8ef5 100644 --- a/front_end/panels/screencast/ScreencastView.ts +++ b/front_end/panels/screencast/ScreencastView.ts @@ -744,11 +744,17 @@ export class ScreencastView extends UI.Widget.VBox implements SDK.OverlayModel.H UI.ARIAUtils.setLabel(this.touchInputToggle, i18nString(UIStrings.touchInput)); // guojinghua@lexmount: add screencast toggle button - this.inspectorToggle = this.navigationBar.createChild('button'); - this.inspectorToggleIcon = this.inspectorToggle.appendChild(IconButton.Icon.create('global')); - // TODO(guojinghua): i18n - UI.ARIAUtils.setLabel(this.inspectorToggle, 'Toggle inspector view'); - this.inspectorToggle.addEventListener('click', this.#toggleInspectorView.bind(this), false); + const lexmountDevtoolsParams = new URLSearchParams(window.location.search || ''); + const lexmountDevtoolsModeEnabled = lexmountDevtoolsParams.get('isdevtools') === 'true' || + lexmountDevtoolsParams.get('enable_devtools_mode') === 'true'; + if (lexmountDevtoolsModeEnabled) { + // guojinghua@lexmount: add screencast toggle button + this.inspectorToggle = this.navigationBar.createChild('button'); + this.inspectorToggleIcon = this.inspectorToggle.appendChild(IconButton.Icon.create('global')); + // TODO(guojinghua): i18n + UI.ARIAUtils.setLabel(this.inspectorToggle, 'Toggle inspector view'); + this.inspectorToggle.addEventListener('click', this.#toggleInspectorView.bind(this), false); + } this.navigationProgressBar = new ProgressTracker( this.resourceTreeModel, this.networkManager, this.navigationBar.createChild('div', 'progress')); From adcc916b1598b3c9af0b7bfceaa718c55fe42506 Mon Sep 17 00:00:00 2001 From: freelw <826231693@qq.com> Date: Thu, 10 Sep 2026 16:00:21 +0800 Subject: [PATCH 7/8] fix: maximize screencast viewport (#7) --- front_end/panels/screencast/ScreencastView.ts | 12 ++++-------- front_end/panels/screencast/screencastView.css | 5 ----- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/front_end/panels/screencast/ScreencastView.ts b/front_end/panels/screencast/ScreencastView.ts index 35a2e28c8ef5..edef341fdd81 100644 --- a/front_end/panels/screencast/ScreencastView.ts +++ b/front_end/panels/screencast/ScreencastView.ts @@ -235,13 +235,12 @@ export class ScreencastView extends UI.Widget.VBox implements SDK.OverlayModel.H dimensionsCSS.width / this.imageElement.naturalWidth, dimensionsCSS.height / (this.imageElement.naturalWidth * deviceSizeRatio)); this.viewportElement.classList.remove('hidden'); - const bordersSize = BORDERS_SIZE; if (this.imageZoom < 1.01 / window.devicePixelRatio) { this.imageZoom = 1 / window.devicePixelRatio; } this.screenZoom = this.imageElement.naturalWidth * this.imageZoom / metadata.deviceWidth; - this.viewportElement.style.width = metadata.deviceWidth * this.screenZoom + bordersSize + 'px'; - this.viewportElement.style.height = metadata.deviceHeight * this.screenZoom + bordersSize + 'px'; + this.viewportElement.style.width = metadata.deviceWidth * this.screenZoom + 'px'; + this.viewportElement.style.height = metadata.deviceHeight * this.screenZoom + 'px'; const data = this.highlightNode ? {node: this.highlightNode, selectorList: undefined} : {clear: true}; void this.updateHighlightInOverlayAndRepaint(data, this.highlightConfig); @@ -678,10 +677,8 @@ export class ScreencastView extends UI.Widget.VBox implements SDK.OverlayModel.H } private viewportDimensions(): {width: number, height: number} { - const gutterSize = 30; - const bordersSize = BORDERS_SIZE; - const width = this.element.offsetWidth - bordersSize - gutterSize; - const height = this.element.offsetHeight - bordersSize - gutterSize - NAVBAR_HEIGHT; + const width = this.element.offsetWidth; + const height = this.element.offsetHeight - NAVBAR_HEIGHT; return {width, height}; } @@ -871,7 +868,6 @@ export class ScreencastView extends UI.Widget.VBox implements SDK.OverlayModel.H } } -export const BORDERS_SIZE = 44; export const NAVBAR_HEIGHT = 29; export const HTTP_REGEX = /^http:\/\/(.+)/; export const SCHEME_REGEX = /^(https?|about|chrome):/; diff --git a/front_end/panels/screencast/screencastView.css b/front_end/panels/screencast/screencastView.css index 6b0ef15c5935..2db9469355b9 100644 --- a/front_end/panels/screencast/screencastView.css +++ b/front_end/panels/screencast/screencastView.css @@ -54,18 +54,13 @@ .screencast-viewport { display: flex; - border: 1px solid var(--sys-color-divider); - border-radius: 20px; flex: none; - padding: 20px; margin: auto; - background-color: var(--sys-color-surface-variant); } .screencast-canvas-container { flex: auto; display: flex; - border: 1px solid var(--sys-color-divider); position: relative; } From a6738f258306ecebffbd7dfe20ad94e527022406 Mon Sep 17 00:00:00 2001 From: freelw <826231693@qq.com> Date: Wed, 16 Sep 2026 14:13:22 +0800 Subject: [PATCH 8/8] fix(inspector): reconnect after WebSocket interruptions (#8) * fix(inspector): reconnect after WebSocket interruptions * test(inspector): use compatible Sinon namespace import --- front_end/entrypoints/inspector_main/BUILD.gn | 2 + .../inspector_main/InspectorMain.ts | 14 +- .../inspector_main/InspectorReconnect.test.ts | 126 ++++++++++++++++++ .../inspector_main/InspectorReconnect.ts | 80 +++++++++++ 4 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 front_end/entrypoints/inspector_main/InspectorReconnect.test.ts create mode 100644 front_end/entrypoints/inspector_main/InspectorReconnect.ts diff --git a/front_end/entrypoints/inspector_main/BUILD.gn b/front_end/entrypoints/inspector_main/BUILD.gn index ec1cb93fe52f..03f4cbd9636c 100644 --- a/front_end/entrypoints/inspector_main/BUILD.gn +++ b/front_end/entrypoints/inspector_main/BUILD.gn @@ -19,6 +19,7 @@ generate_css("css_files") { devtools_module("inspector_main") { sources = [ "InspectorMain.ts", + "InspectorReconnect.ts", "OutermostTargetSelector.ts", "RenderingOptions.ts", ] @@ -78,6 +79,7 @@ ts_library("unittests") { sources = [ "InspectorMain.test.ts", + "InspectorReconnect.test.ts", "OutermostTargetSelector.test.ts", ] diff --git a/front_end/entrypoints/inspector_main/InspectorMain.ts b/front_end/entrypoints/inspector_main/InspectorMain.ts index efb41d9a7466..19080517bed1 100644 --- a/front_end/entrypoints/inspector_main/InspectorMain.ts +++ b/front_end/entrypoints/inspector_main/InspectorMain.ts @@ -14,6 +14,7 @@ import * as Security from '../../panels/security/security.js'; import * as Components from '../../ui/legacy/components/utils/utils.js'; import * as UI from '../../ui/legacy/legacy.js'; +import {InspectorReconnect} from './InspectorReconnect.js'; import nodeIconStyles from './nodeIcon.css.js'; const UIStrings = { @@ -60,6 +61,14 @@ export class InspectorMainImpl implements Common.Runnable.Runnable { async run(): Promise { let firstCall = true; + const ws = Root.Runtime.Runtime.queryParam('ws'); + const wss = Root.Runtime.Runtime.queryParam('wss'); + const reconnect = ws || wss ? + new InspectorReconnect(ws ? `ws://${ws}` : `wss://${wss}`, () => window.location.reload()) : + null; + if (reconnect) { + window.addEventListener('pagehide', () => reconnect.stop(), {once: true}); + } await SDK.Connections.initMainConnection(async () => { const type = Root.Runtime.Runtime.queryParam('v8only') ? SDK.Target.Type.NODE : @@ -108,7 +117,10 @@ export class InspectorMainImpl implements Common.Runnable.Runnable { if (type !== SDK.Target.Type.TAB) { void target.runtimeAgent().invoke_runIfWaitingForDebugger(); } - }, Components.TargetDetachedDialog.TargetDetachedDialog.connectionLost); + }, message => { + Components.TargetDetachedDialog.TargetDetachedDialog.connectionLost(message); + reconnect?.schedule(); + }); new SourcesPanelIndicator(); new BackendSettingsSync(); diff --git a/front_end/entrypoints/inspector_main/InspectorReconnect.test.ts b/front_end/entrypoints/inspector_main/InspectorReconnect.test.ts new file mode 100644 index 000000000000..24288add0b22 --- /dev/null +++ b/front_end/entrypoints/inspector_main/InspectorReconnect.test.ts @@ -0,0 +1,126 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import {assert} from 'chai'; +import * as sinon from 'sinon'; + +// Keep the retry controller testable without initializing the entire DevTools UI. +// eslint-disable-next-line rulesdir/es-modules-import +import {InspectorReconnect} from './InspectorReconnect.js'; + +class FakeSocket { + static instances: FakeSocket[] = []; + onopen: (() => void)|null = null; + onclose: (() => void)|null = null; + onerror: (() => void)|null = null; + close = sinon.spy(); + constructor(readonly url: string) { + FakeSocket.instances.push(this); + } +} + +describe('InspectorReconnect', () => { + let clock: sinon.SinonFakeTimers; + let reconnect: InspectorReconnect; + let reload: sinon.SinonSpy; + const originalWebSocket = globalThis.WebSocket; + + beforeEach(() => { + clock = sinon.useFakeTimers(); + FakeSocket.instances = []; + globalThis.WebSocket = FakeSocket as unknown as typeof WebSocket; + reload = sinon.spy(); + reconnect = new InspectorReconnect('wss://example.test/devtools/page/existing', reload); + }); + + afterEach(() => { + reconnect.stop(); + clock.restore(); + globalThis.WebSocket = originalWebSocket; + }); + + it('coalesces disconnect notifications and reloads only after the same endpoint opens', () => { + reconnect.schedule(); + reconnect.schedule(); + clock.tick(999); + assert.lengthOf(FakeSocket.instances, 0); + clock.tick(1); + const socket = FakeSocket.instances[0]; + assert.strictEqual(socket.url, 'wss://example.test/devtools/page/existing'); + sinon.assert.notCalled(reload); + socket.onopen?.(); + sinon.assert.calledOnce(socket.close); + sinon.assert.calledOnce(reload); + clock.tick(60000); + assert.lengthOf(FakeSocket.instances, 1); + }); + + it('backs off after errors, closes failed probes, and ignores late callbacks', () => { + reconnect.schedule(); + clock.tick(1000); + const first = FakeSocket.instances[0]; + const staleOpen = first.onopen; + const staleClose = first.onclose; + first.onerror?.(); + sinon.assert.calledOnce(first.close); + clock.tick(1999); + assert.lengthOf(FakeSocket.instances, 1); + clock.tick(1); + staleOpen?.(); + staleClose?.(); + sinon.assert.notCalled(reload); + const second = FakeSocket.instances[1]; + sinon.assert.notCalled(second.close); + second.onopen?.(); + sinon.assert.calledOnce(reload); + }); + + it('times out stalled handshakes and stops after eight attempts for an unavailable session', () => { + reconnect.schedule(); + clock.tick(300000); + assert.lengthOf(FakeSocket.instances, 8); + for (const socket of FakeSocket.instances) { + sinon.assert.calledOnce(socket.close); + } + sinon.assert.notCalled(reload); + reconnect.schedule(); + clock.tick(60000); + assert.lengthOf(FakeSocket.instances, 8); + }); + + it('cancels a pending retry when the inspector leaves', () => { + reconnect.schedule(); + reconnect.stop(); + clock.tick(60000); + assert.lengthOf(FakeSocket.instances, 0); + }); + + it('closes an in-flight probe and ignores its late open after stop', () => { + reconnect.schedule(); + clock.tick(1000); + const socket = FakeSocket.instances[0]; + const open = socket.onopen; + reconnect.stop(); + open?.(); + clock.tick(60000); + sinon.assert.calledOnce(socket.close); + sinon.assert.notCalled(reload); + assert.lengthOf(FakeSocket.instances, 1); + }); + + it('retries close-before-open and synchronous constructor failures', () => { + globalThis.WebSocket = function(): never { + throw new Error('unavailable'); + } as unknown as typeof WebSocket; + reconnect.schedule(); + clock.tick(1000); + globalThis.WebSocket = FakeSocket as unknown as typeof WebSocket; + clock.tick(2000); + FakeSocket.instances[0].onclose?.(); + clock.tick(4000); + assert.lengthOf(FakeSocket.instances, 2); + FakeSocket.instances[1].onopen?.(); + sinon.assert.calledOnce(reload); + }); +}); diff --git a/front_end/entrypoints/inspector_main/InspectorReconnect.ts b/front_end/entrypoints/inspector_main/InspectorReconnect.ts new file mode 100644 index 000000000000..8cb034c39810 --- /dev/null +++ b/front_end/entrypoints/inspector_main/InspectorReconnect.ts @@ -0,0 +1,80 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** Probe the original endpoint before rebuilding the inspector's CDP models. */ +export class InspectorReconnect { + readonly #url: string; + readonly #onReconnect: () => void; + #timer: ReturnType|undefined; + #socket: WebSocket|null = null; + #attempts = 0; + #stopped = false; + + constructor(url: string, onReconnect: () => void) { + this.#url = url; + this.#onReconnect = onReconnect; + } + + schedule(): void { + if (this.#stopped || this.#timer !== undefined || this.#socket || this.#attempts >= 8) { + return; + } + const delay = Math.min(1000 * 2 ** this.#attempts, 15000); + this.#timer = setTimeout(() => { + this.#timer = undefined; + this.#attempts++; + this.#probe(); + }, delay); + } + + stop(): void { + this.#stopped = true; + clearTimeout(this.#timer); + this.#timer = undefined; + this.#closeSocket(); + } + + #closeSocket(): void { + const socket = this.#socket; + this.#socket = null; + if (socket) { + socket.onopen = null; + socket.onclose = null; + socket.onerror = null; + socket.close(); + } + } + + #probe(): void { + let socket: WebSocket; + try { + socket = new WebSocket(this.#url); + } catch { + this.schedule(); + return; + } + this.#socket = socket; + const failed = (): void => { + if (this.#stopped || this.#socket !== socket) { + return; + } + clearTimeout(this.#timer); + this.#timer = undefined; + this.#closeSocket(); + this.schedule(); + }; + socket.onopen = () => { + if (this.#stopped || this.#socket !== socket) { + return; + } + this.stop(); + // A new transport alone cannot restore CDP subscriptions or screencast. + // Recreate the inspector, without reloading the inspected browser page. + this.#onReconnect(); + }; + socket.onerror = failed; + socket.onclose = failed; + this.#timer = setTimeout(failed, 10000); + } +}