From eea6cdc1bd866d6996c880d494d2e59851413125 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 10 Mar 2026 19:18:30 -0700 Subject: [PATCH] works --- Sources/Panels/BrowserPanel.swift | 112 ++++++++-- Sources/Panels/BrowserPanelView.swift | 205 +++++++++++++++++- Sources/TerminalController.swift | 3 + cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 13 ++ ...t_browser_devtools_visibility_stability.py | 110 ++++++++++ 5 files changed, 416 insertions(+), 27 deletions(-) create mode 100644 tests_v2/test_browser_devtools_visibility_stability.py diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index aaad9f23..513e8efe 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1744,7 +1744,7 @@ final class BrowserPanel: Panel, ObservableObject { private var insecureHTTPAlertFactory: () -> NSAlert private var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow } // Persist user intent across WebKit detach/reattach churn (split/layout updates). - private var preferredDeveloperToolsVisible: Bool = false + @Published private(set) var preferredDeveloperToolsVisible: Bool = false private var forceDeveloperToolsRefreshOnNextAttach: Bool = false private var developerToolsRestoreRetryWorkItem: DispatchWorkItem? private var developerToolsRestoreRetryAttempt: Int = 0 @@ -2752,6 +2752,70 @@ extension BrowserPanel { webView.stopLoading() } + private func attachDeveloperToolsIfSupported(_ inspector: NSObject) { + let attachSelector = NSSelectorFromString("attach") + if inspector.responds(to: attachSelector) { + inspector.cmuxCallVoid(selector: attachSelector) + } + } + + private func isDeveloperToolsAttached(_ inspector: NSObject) -> Bool? { + inspector.cmuxCallBool(selector: NSSelectorFromString("isAttached")) + } + + private static func windowContainsInspectorViews(_ root: NSView) -> Bool { + if String(describing: type(of: root)).contains("WKInspector") { + return true + } + for subview in root.subviews where windowContainsInspectorViews(subview) { + return true + } + return false + } + + private static func isDetachedInspectorWindow(_ window: NSWindow) -> Bool { + guard window.title.hasPrefix("Web Inspector") else { return false } + guard let contentView = window.contentView else { return false } + return windowContainsInspectorViews(contentView) + } + + private func dismissDetachedDeveloperToolsWindowsIfNeeded() { + guard preferredDeveloperToolsVisible || isDeveloperToolsVisible(), + let mainWindow = webView.window else { return } + for window in NSApp.windows where window !== mainWindow && Self.isDetachedInspectorWindow(window) { +#if DEBUG + dlog( + "browser.devtools strayWindow.close panel=\(id.uuidString.prefix(5)) " + + "title=\(window.title) frame=\(NSStringFromRect(window.frame))" + ) +#endif + window.close() + } + } + + private func scheduleDetachedDeveloperToolsWindowDismissal() { + for delay in [0.0, 0.15] { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.dismissDetachedDeveloperToolsWindowsIfNeeded() + } + } + } + + @discardableResult + private func revealDeveloperTools(_ inspector: NSObject) -> Bool { + attachDeveloperToolsIfSupported(inspector) + + let isVisibleSelector = NSSelectorFromString("isVisible") + if inspector.cmuxCallBool(selector: isVisibleSelector) ?? false { + return true + } + + let showSelector = NSSelectorFromString("show") + guard inspector.responds(to: showSelector) else { return false } + inspector.cmuxCallVoid(selector: showSelector) + return inspector.cmuxCallBool(selector: isVisibleSelector) ?? false + } + @discardableResult func toggleDeveloperTools() -> Bool { #if DEBUG @@ -2764,14 +2828,19 @@ extension BrowserPanel { let isVisibleSelector = NSSelectorFromString("isVisible") let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false let targetVisible = !visible - let selector = NSSelectorFromString(targetVisible ? "show" : "close") - guard inspector.responds(to: selector) else { return false } - inspector.cmuxCallVoid(selector: selector) + if targetVisible { + _ = revealDeveloperTools(inspector) + } else { + let selector = NSSelectorFromString("close") + guard inspector.responds(to: selector) else { return false } + inspector.cmuxCallVoid(selector: selector) + } preferredDeveloperToolsVisible = targetVisible if targetVisible { let visibleAfterToggle = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false if visibleAfterToggle { cancelDeveloperToolsRestoreRetry() + scheduleDetachedDeveloperToolsWindowDismissal() } else { developerToolsRestoreRetryAttempt = 0 scheduleDeveloperToolsRestoreRetry() @@ -2800,14 +2869,14 @@ extension BrowserPanel { func showDeveloperTools() -> Bool { guard let inspector = webView.cmuxInspectorObject() else { return false } let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false - if !visible { - let showSelector = NSSelectorFromString("show") - guard inspector.responds(to: showSelector) else { return false } - inspector.cmuxCallVoid(selector: showSelector) + let attached = isDeveloperToolsAttached(inspector) ?? false + if !visible || !attached { + guard revealDeveloperTools(inspector) || visible else { return false } } preferredDeveloperToolsVisible = true if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) { cancelDeveloperToolsRestoreRetry() + scheduleDetachedDeveloperToolsWindowDismissal() } else { scheduleDeveloperToolsRestoreRetry() } @@ -2866,7 +2935,8 @@ extension BrowserPanel { forceDeveloperToolsRefreshOnNextAttach = false let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false - if visible { + let attached = isDeveloperToolsAttached(inspector) ?? false + if visible && attached { #if DEBUG if shouldForceRefresh { dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") @@ -2876,26 +2946,22 @@ extension BrowserPanel { return } - let selector = NSSelectorFromString("show") - guard inspector.responds(to: selector) else { - cancelDeveloperToolsRestoreRetry() - return - } #if DEBUG if shouldForceRefresh { dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") } #endif - // WebKit inspector "show" can trigger transient first-responder churn while + // WebKit inspector attach/show can trigger transient first-responder churn while // panel attachment is still stabilizing. Keep this auto-restore path from // mutating first responder so AppKit doesn't walk tearing-down responder chains. cmuxWithWindowFirstResponderBypass { - inspector.cmuxCallVoid(selector: selector) + _ = revealDeveloperTools(inspector) } preferredDeveloperToolsVisible = true let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false if visibleAfterShow { cancelDeveloperToolsRestoreRetry() + scheduleDetachedDeveloperToolsWindowDismissal() } else { scheduleDeveloperToolsRestoreRetry() } @@ -2941,6 +3007,20 @@ extension BrowserPanel { forceDeveloperToolsRefreshOnNextAttach } + func shouldPreserveDeveloperToolsIntentWhileDetached() -> Bool { + preferredDeveloperToolsVisible && + ( + forceDeveloperToolsRefreshOnNextAttach || + developerToolsRestoreRetryWorkItem != nil || + webView.superview == nil || + webView.window == nil + ) + } + + func shouldUseLocalInlineDeveloperToolsHosting() -> Bool { + preferredDeveloperToolsVisible || isDeveloperToolsVisible() + } + @discardableResult func zoomIn() -> Bool { applyPageZoom(webView.pageZoom + pageZoomStep) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 98ae7485..bdbf7513 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -313,9 +313,16 @@ struct BrowserPanelView: View { ) } + private var owningWorkspace: Workspace? { + guard let app = AppDelegate.shared, + let manager = app.tabManagerFor(tabId: panel.workspaceId) else { + return nil + } + return manager.tabs.first(where: { $0.id == panel.workspaceId }) + } + private var isCurrentPaneOwner: Bool { - guard let workspace = AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == panel.workspaceId }), - let currentPaneId = workspace.paneId(forPanelId: panel.id) else { + guard let currentPaneId = owningWorkspace?.paneId(forPanelId: panel.id) else { return false } return currentPaneId.id == paneId.id @@ -468,7 +475,10 @@ struct BrowserPanelView: View { hideSuggestions() setAddressBarFocused(false, reason: "panelFocus.onChange.unfocused") } - syncWebViewResponderPolicyWithViewState(reason: "panelFocusChanged") + syncWebViewResponderPolicyWithViewState( + reason: "panelFocusChanged", + isPanelFocusedOverride: focused + ) } .onChange(of: addressBarFocused) { focused in #if DEBUG @@ -802,12 +812,18 @@ struct BrowserPanelView: View { } private var webView: some View { - Group { + let useLocalInlineDeveloperToolsHosting = + panel.shouldUseLocalInlineDeveloperToolsHosting() && + isVisibleInUI && + isCurrentPaneOwner + + return Group { if panel.shouldRenderWebView { WebViewRepresentable( panel: panel, paneId: paneId, - shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner, + shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner && !useLocalInlineDeveloperToolsHosting, + useLocalInlineHosting: useLocalInlineDeveloperToolsHosting, shouldFocusWebView: isFocused && !addressBarFocused, isPanelFocused: isFocused, portalZPriority: portalPriority, @@ -881,15 +897,20 @@ struct BrowserPanelView: View { } } - private func syncWebViewResponderPolicyWithViewState(reason: String) { + private func syncWebViewResponderPolicyWithViewState( + reason: String, + isPanelFocusedOverride: Bool? = nil + ) { guard let cmuxWebView = panel.webView as? CmuxWebView else { return } - let next = isFocused && !panel.shouldSuppressWebViewFocus() + let isPanelFocused = isPanelFocusedOverride ?? isFocused + let next = isPanelFocused && !panel.shouldSuppressWebViewFocus() if cmuxWebView.allowsFirstResponderAcquisition != next { #if DEBUG dlog( "browser.focus.policy.resync panel=\(panel.id.uuidString.prefix(5)) " + "web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " + - "new=\(next ? 1 : 0) reason=\(reason)" + "new=\(next ? 1 : 0) reason=\(reason) " + + "panelFocusedUsed=\(isPanelFocused ? 1 : 0)" ) #endif } @@ -3519,6 +3540,7 @@ struct WebViewRepresentable: NSViewRepresentable { let panel: BrowserPanel let paneId: PaneID let shouldAttachWebView: Bool + let useLocalInlineHosting: Bool let shouldFocusWebView: Bool let isPanelFocused: Bool let portalZPriority: Int @@ -3541,6 +3563,10 @@ struct WebViewRepresentable: NSViewRepresentable { var onGeometryChanged: (() -> Void)? private(set) var geometryRevision: UInt64 = 0 private var lastReportedGeometryState: GeometryState? + private weak var hostedWebView: WKWebView? + private var hostedWebViewConstraints: [NSLayoutConstraint] = [] + private weak var localInlineSlotView: WindowBrowserSlotView? + private var localInlineSlotConstraints: [NSLayoutConstraint] = [] private struct HostedInspectorDividerHit { let containerView: NSView let pageView: NSView @@ -3701,6 +3727,65 @@ struct WebViewRepresentable: NSViewRepresentable { onGeometryChanged?() } + func ensureLocalInlineSlotView() -> WindowBrowserSlotView { + if let localInlineSlotView, localInlineSlotView.superview === self { + localInlineSlotView.isHidden = false + return localInlineSlotView + } + + let slotView = WindowBrowserSlotView(frame: bounds) + slotView.translatesAutoresizingMaskIntoConstraints = false + addSubview(slotView, positioned: .above, relativeTo: nil) + localInlineSlotConstraints = [ + slotView.topAnchor.constraint(equalTo: topAnchor), + slotView.bottomAnchor.constraint(equalTo: bottomAnchor), + slotView.leadingAnchor.constraint(equalTo: leadingAnchor), + slotView.trailingAnchor.constraint(equalTo: trailingAnchor), + ] + NSLayoutConstraint.activate(localInlineSlotConstraints) + localInlineSlotView = slotView + return slotView + } + + func setLocalInlineSlotHidden(_ hidden: Bool) { + localInlineSlotView?.isHidden = hidden + } + + func releaseHostedWebViewConstraints() { + NSLayoutConstraint.deactivate(hostedWebViewConstraints) + hostedWebViewConstraints = [] + hostedWebView = nil + } + + func pinHostedWebView(_ webView: WKWebView, in container: NSView) { + guard webView.superview === container else { return } + + let needsFrameHosting = + hostedWebView !== webView || + !hostedWebViewConstraints.isEmpty || + !webView.translatesAutoresizingMaskIntoConstraints || + webView.autoresizingMask != [.width, .height] || + webView.frame != container.bounds + guard needsFrameHosting else { + needsLayout = true + layoutSubtreeIfNeeded() + return + } + + NSLayoutConstraint.deactivate(hostedWebViewConstraints) + hostedWebViewConstraints = [] + hostedWebView = webView + + // WebKit's attached inspector does not reliably dock into a constraint-managed + // WKWebView hierarchy on macOS. Host the moved webview with autoresizing so + // the inspector can resize the content view in place. + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [.width, .height] + webView.frame = container.bounds + needsLayout = true + layoutSubtreeIfNeeded() + } + override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if window == nil { @@ -4279,6 +4364,40 @@ struct WebViewRepresentable: NSViewRepresentable { host.onGeometryChanged = nil } + private static func moveWebKitRelatedSubviewsIntoHostIfNeeded( + from sourceSuperview: NSView, + to container: WindowBrowserSlotView, + primaryWebView: WKWebView, + reason: String + ) { + guard sourceSuperview !== container else { return } + let relatedSubviews = sourceSuperview.subviews.filter { view in + if view === primaryWebView { return true } + return String(describing: type(of: view)).contains("WK") + } + guard !relatedSubviews.isEmpty else { return } +#if DEBUG + dlog( + "browser.localHost.reparent.batch reason=\(reason) source=\(Self.objectID(sourceSuperview)) " + + "container=\(Self.objectID(container)) count=\(relatedSubviews.count) " + + "sourceType=\(String(describing: type(of: sourceSuperview))) targetType=\(String(describing: type(of: container)))" + ) +#endif + for view in relatedSubviews { + let frameInWindow = sourceSuperview.convert(view.frame, to: nil) + let className = String(describing: type(of: view)) + view.removeFromSuperview() + container.addSubview(view, positioned: .above, relativeTo: nil) + view.frame = container.convert(frameInWindow, from: nil) +#if DEBUG + dlog( + "browser.localHost.reparent.batch.item reason=\(reason) class=\(className) " + + "view=\(Self.objectID(view))" + ) +#endif + } + } + private static func installPortalAnchorView(_ anchorView: NSView, in host: NSView) { // SwiftUI can keep transient replacement hosts alive off-window during split // reparenting. Never let those hosts steal the shared portal anchor, or the @@ -4307,8 +4426,66 @@ struct WebViewRepresentable: NSViewRepresentable { host.layoutSubtreeIfNeeded() } + private func updateUsingLocalInlineHosting(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool { + guard let host = nsView as? HostContainerView else { return false } + let slotView = host.ensureLocalInlineSlotView() + + let coordinator = context.coordinator + coordinator.desiredPortalVisibleInUI = false + coordinator.desiredPortalZPriority = 0 + coordinator.attachGeneration += 1 + + if panel.releasePortalHostIfOwned( + hostId: ObjectIdentifier(host), + reason: "localInlineHosting" + ) { + BrowserWindowPortalRegistry.hide( + webView: webView, + source: "viewStateChanged.localInlineHosting" + ) + } + + if webView.superview !== slotView { + if let sourceSuperview = webView.superview { + Self.moveWebKitRelatedSubviewsIntoHostIfNeeded( + from: sourceSuperview, + to: slotView, + primaryWebView: webView, + reason: "attachLocalHost" + ) + } else { + slotView.addSubview(webView, positioned: .above, relativeTo: nil) + } + } + + slotView.isHidden = false + host.pinHostedWebView(webView, in: slotView) + coordinator.lastPortalHostId = nil + coordinator.lastSynchronizedHostGeometryRevision = 0 + panel.restoreDeveloperToolsAfterAttachIfNeeded() + webView.needsLayout = true + webView.layoutSubtreeIfNeeded() + slotView.layoutSubtreeIfNeeded() + host.displayIfNeeded() + slotView.displayIfNeeded() + webView.displayIfNeeded() + +#if DEBUG + Self.logDevToolsState( + panel, + event: "localHost.update", + generation: coordinator.attachGeneration, + retryCount: 0, + details: Self.attachContext(webView: webView, host: host) + ) +#endif + return true + } + private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool { guard let host = nsView as? HostContainerView else { return false } + host.setLocalInlineSlotHidden(true) + host.releaseHostedWebViewConstraints() let coordinator = context.coordinator let paneDropContext = currentPaneDropContext() @@ -4431,7 +4608,9 @@ struct WebViewRepresentable: NSViewRepresentable { if !shouldAttachWebView { // In portal mode we no longer detach/re-attach to preserve DevTools state. // Sync the inspector preference directly so manual closes are respected. - panel.syncDeveloperToolsPreferenceFromInspector() + panel.syncDeveloperToolsPreferenceFromInspector( + preserveVisibleIntent: panel.shouldPreserveDeveloperToolsIntentWhileDetached() + ) } if host.window != nil, portalHostAccepted { @@ -4518,7 +4697,9 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.webView = webView Self.clearPortalCallbacks(for: nsView) - let hostOwnsPortal = updateUsingWindowPortal(nsView, context: context, webView: webView) + let hostOwnsPortal = useLocalInlineHosting + ? updateUsingLocalInlineHosting(nsView, context: context, webView: webView) + : updateUsingWindowPortal(nsView, context: context, webView: webView) Self.applyWebViewFirstResponderPolicy( panel: panel, webView: webView, @@ -4658,7 +4839,9 @@ struct WebViewRepresentable: NSViewRepresentable { } private func currentPaneDropContext() -> BrowserPaneDropContext? { - guard let workspace = AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == panel.workspaceId }), + guard let app = AppDelegate.shared, + let manager = app.tabManagerFor(tabId: panel.workspaceId), + let workspace = manager.tabs.first(where: { $0.id == panel.workspaceId }), let paneId = workspace.paneId(forPanelId: panel.id) else { return nil } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 1f134353..3c3b5905 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3656,6 +3656,9 @@ class TerminalController { "index_in_pane": v2OrNull(indexInPaneByPanelId[panel.id]), "selected_in_pane": v2OrNull(selectedInPaneByPanelId[panel.id]) ] + if let browserPanel = panel as? BrowserPanel { + item["developer_tools_visible"] = browserPanel.isDeveloperToolsVisible() + } return item } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 3216a55d..ae969468 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2389,14 +2389,26 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { private final class WKInspectorProbeView: NSView {} private final class FakeInspector: NSObject { + private(set) var attachCount = 0 private(set) var showCount = 0 private(set) var closeCount = 0 private var visible = false + private var attached = false @objc func isVisible() -> Bool { visible } + @objc func isAttached() -> Bool { + attached + } + + @objc func attach() { + attachCount += 1 + attached = true + show() + } + @objc func show() { showCount += 1 visible = true @@ -2405,6 +2417,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { @objc func close() { closeCount += 1 visible = false + attached = false } } diff --git a/tests_v2/test_browser_devtools_visibility_stability.py b/tests_v2/test_browser_devtools_visibility_stability.py new file mode 100644 index 00000000..01ca9e32 --- /dev/null +++ b/tests_v2/test_browser_devtools_visibility_stability.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""v2 regression: browser DevTools stays open after a single toggle.""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_until(pred, timeout_s: float, label: str) -> None: + deadline = time.time() + timeout_s + last_exc = None + while time.time() < deadline: + try: + if pred(): + return + except Exception as exc: # noqa: BLE001 + last_exc = exc + time.sleep(0.05) + if last_exc is not None: + raise cmuxError(f"Timed out waiting for {label}: {last_exc}") + raise cmuxError(f"Timed out waiting for {label}") + + +def _surface_row(c: cmux, workspace_id: str, surface_id: str) -> dict: + payload = c._call("surface.list", {"workspace_id": workspace_id}) or {} + for row in payload.get("surfaces") or []: + if str(row.get("id") or "") == surface_id: + return row + raise cmuxError(f"surface.list missing surface {surface_id} in workspace {workspace_id}: {payload}") + + +def _devtools_visible(c: cmux, workspace_id: str, surface_id: str) -> bool: + row = _surface_row(c, workspace_id, surface_id) + return bool(row.get("developer_tools_visible")) + + +def _focus_browser_webview(c: cmux, surface_id: str, timeout_s: float = 2.0) -> None: + deadline = time.time() + timeout_s + last_exc = None + while time.time() < deadline: + try: + c.focus_surface(surface_id) + c.focus_webview(surface_id) + if c.is_webview_focused(surface_id): + return + except Exception as exc: # noqa: BLE001 + last_exc = exc + time.sleep(0.05) + raise cmuxError(f"Timed out waiting for browser webview focus: {last_exc}") + + +def main() -> int: + with cmux(SOCKET_PATH) as c: + workspace_id = c.new_workspace() + try: + c.select_workspace(workspace_id) + time.sleep(0.3) + + surface_id = c.new_surface(panel_type="browser", url="https://example.com") + _wait_until( + lambda: _surface_row(c, workspace_id, surface_id).get("type") == "browser", + timeout_s=5.0, + label="browser surface in surface.list", + ) + _focus_browser_webview(c, surface_id, timeout_s=3.0) + + _must( + _devtools_visible(c, workspace_id, surface_id) is False, + "Expected DevTools to start closed", + ) + + c.simulate_shortcut("cmd+opt+i") + + _wait_until( + lambda: _devtools_visible(c, workspace_id, surface_id), + timeout_s=3.0, + label="DevTools visible after toggle", + ) + + deadline = time.time() + 1.5 + while time.time() < deadline: + _must( + _devtools_visible(c, workspace_id, surface_id) is True, + "DevTools reopened/closed unexpectedly after initial open", + ) + time.sleep(0.05) + finally: + try: + c.close_workspace(workspace_id) + except Exception: + pass + + print("PASS: browser DevTools stays open after a single toggle") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())