From b960b34c5585c33c57acde9cf62df06fccae4d1e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:26:40 -0700 Subject: [PATCH 1/3] Keep background browser surfaces automatable --- Sources/Panels/BrowserPanel.swift | 26 ++ .../Panels/BrowserScreenshotSnapshotter.swift | 259 +++++++++--------- Sources/TerminalController.swift | 7 + cmuxTests/BrowserPanelTests.swift | 128 ++++++++- 4 files changed, 281 insertions(+), 139 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index da45953e655..d76703b5c29 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -7580,6 +7580,32 @@ extension BrowserPanel { } } + @discardableResult + func beginAutomationCommandLease(reason: String) -> BrowserScreenshotWebViewSnapshotter.OffscreenRenderHostLease? { + activeVisualAutomationCaptureCount += 1 + cancelHiddenWebViewDiscard() + restoreDiscardedWebViewIfNeeded(reason: "\(reason).restore") + refreshWebViewLifecycleState() + + guard shouldUseOffscreenRenderHostForVisualAutomation else { return nil } + return BrowserScreenshotWebViewSnapshotter.OffscreenRenderHostLease( + webView: webView, + viewportSize: visualAutomationViewportSize() + ) + } + + func endAutomationCommandLease( + _ lease: BrowserScreenshotWebViewSnapshotter.OffscreenRenderHostLease?, + reason: String + ) { + lease?.end() + activeVisualAutomationCaptureCount = max(0, activeVisualAutomationCaptureCount - 1) + refreshWebViewLifecycleState() + if activeVisualAutomationCaptureCount == 0, !isWebViewVisibleInUI { + scheduleHiddenWebViewDiscardIfNeeded(reason: "\(reason).finished") + } + } + @discardableResult func ensureVisualAutomationRestoreHostIfNeeded(reason: String) -> Bool { guard shouldUseOffscreenRenderHostForVisualAutomation else { return false } diff --git a/Sources/Panels/BrowserScreenshotSnapshotter.swift b/Sources/Panels/BrowserScreenshotSnapshotter.swift index cba41db4081..267a721adbc 100644 --- a/Sources/Panels/BrowserScreenshotSnapshotter.swift +++ b/Sources/Panels/BrowserScreenshotSnapshotter.swift @@ -60,6 +60,129 @@ enum BrowserScreenshotCaptureBounds { @MainActor enum BrowserScreenshotWebViewSnapshotter { + @MainActor + final class OffscreenRenderHostLease { + private weak var webView: WKWebView? + private let previousSuperview: NSView? + private let previousFrame: NSRect + private let previousBounds: NSRect + private let previousAutoresizingMask: NSView.AutoresizingMask + private let previousTranslatesAutoresizingMaskIntoConstraints: Bool + private let restoreAnchor: NSView? + private let restorePosition: NSWindow.OrderingMode + private let window: BrowserScreenshotOffscreenRenderPanel + private var isActive = true + + init(webView: WKWebView, viewportSize: NSSize) { + self.webView = webView + previousSuperview = webView.superview + let previousSubviews = previousSuperview?.subviews ?? [] + let previousIndex = previousSubviews.firstIndex(of: webView) + previousFrame = webView.frame + previousBounds = webView.bounds + previousAutoresizingMask = webView.autoresizingMask + previousTranslatesAutoresizingMaskIntoConstraints = webView.translatesAutoresizingMaskIntoConstraints + + if let previousIndex, previousIndex > 0 { + restoreAnchor = previousSubviews[previousIndex - 1] + restorePosition = .above + } else if let previousIndex, previousIndex == 0, previousSubviews.count > 1 { + restoreAnchor = previousSubviews[1] + restorePosition = .below + } else { + restoreAnchor = nil + restorePosition = .above + } + + let normalizedSize = Self.normalizedViewportSize(viewportSize) + let frame = NSRect( + x: -100_000 - normalizedSize.width, + y: -100_000 - normalizedSize.height, + width: normalizedSize.width, + height: normalizedSize.height + ) + let window = BrowserScreenshotOffscreenRenderPanel( + contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("cmux.browserVisualAutomationRender") + window.hasShadow = false + window.isOpaque = false + window.backgroundColor = .clear + window.alphaValue = 0.01 + window.ignoresMouseEvents = true + window.hidesOnDeactivate = false + window.collectionBehavior = [.transient, .ignoresCycle, .stationary, .canJoinAllSpaces] + window.isExcludedFromWindowsMenu = true + self.window = window + + let contentView = NSView(frame: NSRect(origin: .zero, size: normalizedSize)) + contentView.wantsLayer = true + webView.removeFromSuperview() + webView.frame = contentView.bounds + webView.autoresizingMask = [.width, .height] + contentView.addSubview(webView) + window.contentView = contentView + window.orderFrontRegardless() + } + + func end() { + guard isActive else { return } + isActive = false + if let webView { + Self.restoreWebView( + webView, + to: previousSuperview, + frame: previousFrame, + bounds: previousBounds, + autoresizingMask: previousAutoresizingMask, + translatesAutoresizingMaskIntoConstraints: previousTranslatesAutoresizingMaskIntoConstraints, + anchor: restoreAnchor, + position: restorePosition + ) + } + window.orderOut(nil) + window.contentView = nil + window.close() + } + + deinit { + guard isActive else { return } + MainActor.assumeIsolated { + end() + } + } + + private static func normalizedViewportSize(_ viewportSize: NSSize) -> NSSize { + BrowserScreenshotWebViewSnapshotter.normalizedViewportSize(viewportSize) + } + + private static func restoreWebView( + _ webView: WKWebView, + to superview: NSView?, + frame: NSRect, + bounds: NSRect, + autoresizingMask: NSView.AutoresizingMask, + translatesAutoresizingMaskIntoConstraints: Bool, + anchor: NSView?, + position: NSWindow.OrderingMode + ) { + BrowserScreenshotWebViewSnapshotter.restoreWebView( + webView, + to: superview, + frame: frame, + bounds: bounds, + autoresizingMask: autoresizingMask, + translatesAutoresizingMaskIntoConstraints: translatesAutoresizingMaskIntoConstraints, + anchor: anchor, + position: position + ) + } + } + static func captureFullPage( from webView: WKWebView, afterScreenUpdates: Bool = true @@ -180,73 +303,9 @@ enum BrowserScreenshotWebViewSnapshotter { expectedURL: URL?, operation: () async throws -> T ) async throws -> T { - let previousSuperview = webView.superview - let previousSubviews = previousSuperview?.subviews ?? [] - let previousIndex = previousSubviews.firstIndex(of: webView) - let previousFrame = webView.frame - let previousBounds = webView.bounds - let previousAutoresizingMask = webView.autoresizingMask - let previousTranslatesAutoresizingMaskIntoConstraints = webView.translatesAutoresizingMaskIntoConstraints - let restoreAnchor: NSView? - let restorePosition: NSWindow.OrderingMode - if let previousIndex, previousIndex > 0 { - restoreAnchor = previousSubviews[previousIndex - 1] - restorePosition = .above - } else if let previousIndex, previousIndex == 0, previousSubviews.count > 1 { - restoreAnchor = previousSubviews[1] - restorePosition = .below - } else { - restoreAnchor = nil - restorePosition = .above - } - - let normalizedSize = normalizedViewportSize(viewportSize) - let frame = NSRect( - x: -100_000 - normalizedSize.width, - y: -100_000 - normalizedSize.height, - width: normalizedSize.width, - height: normalizedSize.height - ) - let window = BrowserScreenshotOffscreenRenderPanel( - contentRect: frame, - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, - defer: false - ) - window.isReleasedWhenClosed = false - window.identifier = NSUserInterfaceItemIdentifier("cmux.browserVisualAutomationRender") - window.hasShadow = false - window.isOpaque = false - window.backgroundColor = .clear - window.alphaValue = 0.01 - window.ignoresMouseEvents = true - window.hidesOnDeactivate = false - window.collectionBehavior = [.transient, .ignoresCycle, .stationary, .canJoinAllSpaces] - window.isExcludedFromWindowsMenu = true - - let contentView = NSView(frame: NSRect(origin: .zero, size: normalizedSize)) - contentView.wantsLayer = true - webView.removeFromSuperview() - webView.frame = contentView.bounds - webView.autoresizingMask = [.width, .height] - contentView.addSubview(webView) - window.contentView = contentView - window.orderFrontRegardless() - + let lease = OffscreenRenderHostLease(webView: webView, viewportSize: viewportSize) defer { - restoreWebView( - webView, - to: previousSuperview, - frame: previousFrame, - bounds: previousBounds, - autoresizingMask: previousAutoresizingMask, - translatesAutoresizingMaskIntoConstraints: previousTranslatesAutoresizingMaskIntoConstraints, - anchor: restoreAnchor, - position: restorePosition - ) - window.orderOut(nil) - window.contentView = nil - window.close() + lease.end() } try await prepareForVisualCapture(webView, expectedURL: expectedURL) @@ -261,59 +320,7 @@ enum BrowserScreenshotWebViewSnapshotter { operation: @escaping (@escaping (Result) -> Void) -> Void, completion: @escaping (Result) -> Void ) { - let previousSuperview = webView.superview - let previousSubviews = previousSuperview?.subviews ?? [] - let previousIndex = previousSubviews.firstIndex(of: webView) - let previousFrame = webView.frame - let previousBounds = webView.bounds - let previousAutoresizingMask = webView.autoresizingMask - let previousTranslatesAutoresizingMaskIntoConstraints = webView.translatesAutoresizingMaskIntoConstraints - let restoreAnchor: NSView? - let restorePosition: NSWindow.OrderingMode - if let previousIndex, previousIndex > 0 { - restoreAnchor = previousSubviews[previousIndex - 1] - restorePosition = .above - } else if let previousIndex, previousIndex == 0, previousSubviews.count > 1 { - restoreAnchor = previousSubviews[1] - restorePosition = .below - } else { - restoreAnchor = nil - restorePosition = .above - } - - let normalizedSize = normalizedViewportSize(viewportSize) - let frame = NSRect( - x: -100_000 - normalizedSize.width, - y: -100_000 - normalizedSize.height, - width: normalizedSize.width, - height: normalizedSize.height - ) - let window = BrowserScreenshotOffscreenRenderPanel( - contentRect: frame, - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, - defer: false - ) - window.isReleasedWhenClosed = false - window.identifier = NSUserInterfaceItemIdentifier("cmux.browserVisualAutomationRender") - window.hasShadow = false - window.isOpaque = false - window.backgroundColor = .clear - window.alphaValue = 0.01 - window.ignoresMouseEvents = true - window.hidesOnDeactivate = false - window.collectionBehavior = [.transient, .ignoresCycle, .stationary, .canJoinAllSpaces] - window.isExcludedFromWindowsMenu = true - - let contentView = NSView(frame: NSRect(origin: .zero, size: normalizedSize)) - contentView.wantsLayer = true - webView.removeFromSuperview() - webView.frame = contentView.bounds - webView.autoresizingMask = [.width, .height] - contentView.addSubview(webView) - window.contentView = contentView - window.orderFrontRegardless() - + let lease = OffscreenRenderHostLease(webView: webView, viewportSize: viewportSize) var didFinish = false var timeoutTimer: Timer? let finish: (Result) -> Void = { result in @@ -321,19 +328,7 @@ enum BrowserScreenshotWebViewSnapshotter { didFinish = true timeoutTimer?.invalidate() timeoutTimer = nil - restoreWebView( - webView, - to: previousSuperview, - frame: previousFrame, - bounds: previousBounds, - autoresizingMask: previousAutoresizingMask, - translatesAutoresizingMaskIntoConstraints: previousTranslatesAutoresizingMaskIntoConstraints, - anchor: restoreAnchor, - position: restorePosition - ) - window.orderOut(nil) - window.contentView = nil - window.close() + lease.end() completion(result) } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 071305f059c..cc5ed5a2368 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -5302,6 +5302,7 @@ class TerminalController { _ body: (_ ctx: V2BrowserPanelContext) -> V2CallResult ) -> V2CallResult { var resolved: V2BrowserPanelContext? + var automationLease: BrowserScreenshotWebViewSnapshotter.OffscreenRenderHostLease? var failure: V2CallResult = .err(code: "internal_error", message: "Browser operation failed", data: nil) v2MainSync { guard let tabManager = v2ResolveTabManager(params: params) else { @@ -5325,6 +5326,7 @@ class TerminalController { failure = .err(code: "invalid_params", message: "Surface is not a browser", data: ["surface_id": surfaceId.uuidString]) return } + automationLease = browserPanel.beginAutomationCommandLease(reason: "browser.socketCommand") resolved = V2BrowserPanelContext( workspaceId: ws.id, surfaceId: surfaceId, @@ -5333,6 +5335,11 @@ class TerminalController { ) } guard let resolved else { return failure } + defer { + v2MainSync { + resolved.browserPanel.endAutomationCommandLease(automationLease, reason: "browser.socketCommand") + } + } return body(resolved) } diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift index 4a7d8c97181..f6b6cd7c1ae 100644 --- a/cmuxTests/BrowserPanelTests.swift +++ b/cmuxTests/BrowserPanelTests.swift @@ -303,25 +303,37 @@ final class BrowserHiddenWebViewDiscardManagerTests: XCTestCase { @MainActor final class BrowserPanelVisualAutomationRestoreHostTests: XCTestCase { + private func realizeWindowLayout(_ window: NSWindow) { + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + window.contentView?.layoutSubtreeIfNeeded() + } + func testRestoredDiscardedHiddenWebViewGetsRestoreHostBeforeOffscreenCapture() { - let discardedAt = Date(timeIntervalSince1970: 400) + let hiddenAt = Date() let panel = BrowserPanel( workspaceId: UUID(), - initialURL: URL(string: "about:blank")!, + initialURL: URL(string: "data:text/html,restore-host")!, isRemoteWorkspace: false ) defer { panel.close() } - let deadline = Date().addingTimeInterval(1.0) - while panel.webView.isLoading, + let deadline = Date().addingTimeInterval(2.0) + while (panel.webView.isLoading || panel.isLoading), RunLoop.main.run(mode: .default, before: deadline), Date() < deadline {} - XCTAssertFalse(panel.webView.isLoading, "Timed out waiting for about:blank to finish loading") + XCTAssertFalse(panel.webView.isLoading, "Timed out waiting for data URL to finish loading") + XCTAssertFalse(panel.isLoading, "Timed out waiting for panel loading state to finish") - panel.noteWebViewVisibility(false, reason: "test.hidden", now: discardedAt) + panel.noteWebViewVisibility(false, reason: "test.hidden", now: hiddenAt) let originalWebView = panel.webView - XCTAssertTrue(panel.discardHiddenWebViewForMemory(reason: "test.discard", now: discardedAt)) + XCTAssertTrue( + panel.discardHiddenWebViewForMemory(reason: "test.discard", now: hiddenAt), + "blockers: \(panel.hiddenWebViewDiscardSnapshot)" + ) XCTAssertFalse(panel.webView === originalWebView) XCTAssertNil(panel.webView.superview) XCTAssertFalse(panel.hasBackgroundPreloadHost) @@ -336,6 +348,108 @@ final class BrowserPanelVisualAutomationRestoreHostTests: XCTestCase { XCTAssertNotNil(panel.webView.window) XCTAssertFalse(panel.ensureVisualAutomationRestoreHostIfNeeded(reason: "test.visualAutomation.alreadyAttached")) } + + func testAutomationCommandLeaseTemporarilyHostsHiddenPortalWebViewOffscreen() { + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: URL(string: "about:blank")!, + isRemoteWorkspace: false + ) + defer { panel.close() } + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) + contentView.addSubview(anchor) + BrowserWindowPortalRegistry.bind(webView: panel.webView, to: anchor, visibleInUI: true) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + drainBrowserPanelMainQueue() + + guard let originalSuperview = panel.webView.superview else { + XCTFail("Expected portal-hosted webview") + return + } + + panel.noteWebViewVisibility(false, reason: "test.hidden", now: Date()) + BrowserWindowPortalRegistry.updateEntryVisibility(for: panel.webView, visibleInUI: false, zPriority: 0) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + drainBrowserPanelMainQueue() + + XCTAssertTrue(panel.webView.superview === originalSuperview) + XCTAssertTrue(panel.webView.isHiddenOrHasHiddenAncestor) + + let lease = panel.beginAutomationCommandLease(reason: "test.automation") + XCTAssertNotNil(lease) + XCTAssertFalse(panel.webView.superview === originalSuperview) + XCTAssertNotNil(panel.webView.window) + XCTAssertFalse(panel.webView.isHiddenOrHasHiddenAncestor) + XCTAssertFalse( + panel.discardHiddenWebViewForMemory(reason: "test.discardWhileAutomating"), + "Socket automation must block hidden-webview discard while it owns the temporary host" + ) + + panel.endAutomationCommandLease(lease, reason: "test.automation") + + XCTAssertTrue(panel.webView.superview === originalSuperview) + XCTAssertTrue(panel.webView.isHiddenOrHasHiddenAncestor) + } + + func testAutomationCommandLeaseRestoresDiscardedHiddenWebViewBeforeHosting() { + let hiddenAt = Date() + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: URL(string: "data:text/html,socket-restore")!, + isRemoteWorkspace: false + ) + defer { panel.close() } + + let deadline = Date().addingTimeInterval(2.0) + while (panel.webView.isLoading || panel.isLoading), + RunLoop.main.run(mode: .default, before: deadline), + Date() < deadline {} + XCTAssertFalse(panel.webView.isLoading, "Timed out waiting for data URL to finish loading") + XCTAssertFalse(panel.isLoading, "Timed out waiting for panel loading state to finish") + + panel.noteWebViewVisibility(false, reason: "test.hidden", now: hiddenAt) + let originalWebView = panel.webView + + XCTAssertTrue( + panel.discardHiddenWebViewForMemory(reason: "test.discard", now: hiddenAt), + "blockers: \(panel.hiddenWebViewDiscardSnapshot)" + ) + XCTAssertFalse(panel.webView === originalWebView) + XCTAssertNil(panel.webView.superview) + XCTAssertEqual(panel.webViewLifecycleState, .discarded) + + let restoredWebView = panel.webView + let lease = panel.beginAutomationCommandLease(reason: "test.automation") + XCTAssertNotNil(lease) + XCTAssertTrue(panel.webView === restoredWebView) + XCTAssertEqual(panel.webViewLifecycleState, .liveHidden) + XCTAssertNotNil(panel.webView.window) + XCTAssertFalse(panel.webView.isHiddenOrHasHiddenAncestor) + XCTAssertFalse( + panel.discardHiddenWebViewForMemory(reason: "test.discardWhileAutomating"), + "Socket automation must block hidden-webview discard after restoring the replacement webview" + ) + + panel.endAutomationCommandLease(lease, reason: "test.automation") + + XCTAssertNil(panel.webView.superview) + XCTAssertEqual(panel.webViewLifecycleState, .liveHidden) + } } @MainActor From 1fff7d9aa5b5a12305fb0949d5563651007d14b1 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:30:38 -0700 Subject: [PATCH 2/3] Delay hidden browser discard after automation --- .../BrowserHiddenWebViewDiscardManager.swift | 6 ++++- Sources/Panels/BrowserPanel.swift | 8 +++++++ cmuxTests/BrowserPanelTests.swift | 22 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift b/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift index 4c318ceb29f..821772756fd 100644 --- a/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift +++ b/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift @@ -5,6 +5,7 @@ import Foundation protocol BrowserHiddenWebViewDiscardManagerDelegate: AnyObject { var hiddenWebViewDiscardSnapshot: BrowserHiddenWebViewDiscardManager.BlockerSnapshot { get } var hiddenWebViewDiscardHiddenAt: Date? { get } + var hiddenWebViewDiscardLastAutomationActivityAt: Date? { get } var hiddenWebViewDiscardWebViewInstanceID: UUID { get } func hiddenWebViewDiscardManagerDidRequestDiscard( @@ -101,11 +102,14 @@ final class BrowserHiddenWebViewDiscardManager { let observedWebViewInstanceID = delegate.hiddenWebViewDiscardWebViewInstanceID let generation = scheduleGeneration let hiddenAt = delegate.hiddenWebViewDiscardHiddenAt ?? Date() + let lastAutomationActivityAt = delegate.hiddenWebViewDiscardLastAutomationActivityAt // Restart the countdown from the latest wake: WebKit pages reconnect and // re-navigate right after wake, and replacing/releasing a WKWebView in // that window crashed in WebPageProxy::updateActivityState // (https://github.com/manaflow-ai/cmux/issues/5261). - let effectiveHiddenAt = lastSystemWakeAt.map { max(hiddenAt, $0) } ?? hiddenAt + let effectiveHiddenAt = [hiddenAt, lastSystemWakeAt, lastAutomationActivityAt] + .compactMap { $0 } + .max() ?? hiddenAt let elapsed = Date().timeIntervalSince(effectiveHiddenAt) let remaining = max(0, BrowserHiddenWebViewDiscardPolicy.hiddenDelay - elapsed) if remaining <= 0 { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index d76703b5c29..041f776f520 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3397,6 +3397,7 @@ final class BrowserPanel: Panel, ObservableObject { private var backgroundPreloadWindow: NSWindow? private let visualAutomationCaptureGate = BrowserScreenshotCaptureGate() private var activeVisualAutomationCaptureCount: Int = 0 + private var webViewLastAutomationActivityAt: Date? private struct PendingInteractiveBrowserPrompt { let present: (NSWindow, @escaping () -> Void) -> Void let cancel: () -> Void @@ -3788,6 +3789,7 @@ final class BrowserPanel: Panel, ObservableObject { webViewLastHiddenAt = nil webViewLastVisibilityChangeAt = nil webViewLastVisibilityChangeReason = nil + webViewLastAutomationActivityAt = nil isWebViewVisibleInUI = false } hiddenWebViewDiscardManager.resetMetadata() @@ -6395,6 +6397,10 @@ extension BrowserPanel: BrowserHiddenWebViewDiscardManagerDelegate { webViewLastHiddenAt } + var hiddenWebViewDiscardLastAutomationActivityAt: Date? { + webViewLastAutomationActivityAt + } + var hiddenWebViewDiscardWebViewInstanceID: UUID { webViewInstanceID } @@ -7582,6 +7588,7 @@ extension BrowserPanel { @discardableResult func beginAutomationCommandLease(reason: String) -> BrowserScreenshotWebViewSnapshotter.OffscreenRenderHostLease? { + webViewLastAutomationActivityAt = Date() activeVisualAutomationCaptureCount += 1 cancelHiddenWebViewDiscard() restoreDiscardedWebViewIfNeeded(reason: "\(reason).restore") @@ -7599,6 +7606,7 @@ extension BrowserPanel { reason: String ) { lease?.end() + webViewLastAutomationActivityAt = Date() activeVisualAutomationCaptureCount = max(0, activeVisualAutomationCaptureCount - 1) refreshWebViewLifecycleState() if activeVisualAutomationCaptureCount == 0, !isWebViewVisibleInUI { diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift index f6b6cd7c1ae..2869486bfa7 100644 --- a/cmuxTests/BrowserPanelTests.swift +++ b/cmuxTests/BrowserPanelTests.swift @@ -64,6 +64,7 @@ private final class BrowserPanelTestScriptMessageHandler: NSObject, WKScriptMess private final class BrowserHiddenWebViewDiscardTestDelegate: BrowserHiddenWebViewDiscardManagerDelegate { var snapshot: BrowserHiddenWebViewDiscardManager.BlockerSnapshot var hiddenAt: Date? + var lastAutomationActivityAt: Date? var webViewInstanceID = UUID() var discardRequestCount = 0 @@ -80,6 +81,10 @@ private final class BrowserHiddenWebViewDiscardTestDelegate: BrowserHiddenWebVie hiddenAt } + var hiddenWebViewDiscardLastAutomationActivityAt: Date? { + lastAutomationActivityAt + } + var hiddenWebViewDiscardWebViewInstanceID: UUID { webViewInstanceID } @@ -274,6 +279,23 @@ final class BrowserHiddenWebViewDiscardManagerTests: XCTestCase { XCTAssertTrue(manager.hasScheduledDiscard) } + func testAutomationActivityRestartsHiddenWebViewDiscardCountdown() { + let snapshot = makeHiddenWebViewDiscardBlockerSnapshot() + let manager = BrowserHiddenWebViewDiscardManager() + let now = Date() + let delegate = BrowserHiddenWebViewDiscardTestDelegate( + snapshot: snapshot, + hiddenAt: now.addingTimeInterval(-BrowserHiddenWebViewDiscardPolicy.hiddenDelay - 1) + ) + delegate.lastAutomationActivityAt = now + manager.delegate = delegate + + manager.scheduleIfNeeded(reason: "test.automationActivity") + + XCTAssertEqual(delegate.discardRequestCount, 0) + XCTAssertTrue(manager.hasScheduledDiscard) + } + // Regression coverage for https://github.com/manaflow-ai/cmux/issues/5261: // sleep cancels an armed discard countdown and blocks re-arming until wake, // and wake re-arms a fresh countdown without discarding. From e3fd55b167947a62c379ffce80c4d315534be030 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:42:25 -0700 Subject: [PATCH 3/3] Fix browser automation test budget --- .github/swift-file-length-budget.tsv | 8 +- cmux.xcodeproj/project.pbxproj | 4 + cmuxTests/BrowserPanelTests.swift | 151 ---------------- ...anelVisualAutomationRestoreHostTests.swift | 168 ++++++++++++++++++ 4 files changed, 176 insertions(+), 155 deletions(-) create mode 100644 cmuxTests/BrowserPanelVisualAutomationRestoreHostTests.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index a77847e9eb0..a799f0e6130 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -6,8 +6,8 @@ 19265 Sources/ContentView.swift 18118 Sources/AppDelegate.swift 16674 Sources/GhosttyTerminalView.swift -14622 Sources/TerminalController.swift -13606 Sources/Panels/BrowserPanel.swift +14629 Sources/TerminalController.swift +13640 Sources/Panels/BrowserPanel.swift 12044 cmuxTests/AppDelegateShortcutRoutingTests.swift 10020 Sources/TabManager.swift 9345 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -23,7 +23,7 @@ 5462 Sources/cmuxApp.swift 4801 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 4460 Sources/Panels/FilePreviewPanel.swift -4400 cmuxTests/BrowserPanelTests.swift +4385 cmuxTests/BrowserPanelTests.swift 4227 Sources/BrowserWindowPortal.swift 4009 cmuxTests/WindowAndDragTests.swift 3937 Sources/Feed/FeedPanelView.swift @@ -95,7 +95,7 @@ 905 Sources/CmuxSSHURLRequest.swift 896 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 878 Sources/WorkspaceContentView.swift -868 Sources/Panels/BrowserScreenshotSnapshotter.swift +863 Sources/Panels/BrowserScreenshotSnapshotter.swift 864 Sources/Panels/TerminalPanel.swift 856 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift 852 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 026dc12e411..8a29acc6efb 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -100,6 +100,7 @@ A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */; }; A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; }; + B9A710000000000000000002 /* BrowserPanelVisualAutomationRestoreHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9A710000000000000000001 /* BrowserPanelVisualAutomationRestoreHostTests.swift */; }; D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; }; A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; }; 8E3EEA13A7936E8D115D895B /* BrowserProxyEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70C6FCD43B61DCCA7D74E07 /* BrowserProxyEndpoint.swift */; }; @@ -952,6 +953,7 @@ A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = ""; }; 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelTests.swift; sourceTree = ""; }; A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = ""; }; + B9A710000000000000000001 /* BrowserPanelVisualAutomationRestoreHostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelVisualAutomationRestoreHostTests.swift; sourceTree = ""; }; D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = ""; }; A5007421 /* BrowserPopupWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPopupWindowController.swift; sourceTree = ""; }; B70C6FCD43B61DCCA7D74E07 /* BrowserProxyEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserProxyEndpoint.swift; sourceTree = ""; }; @@ -2367,6 +2369,7 @@ D3622001A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift */, D3622101A1B2C3D4E5F60718 /* EditableTextViewArrowKeyForwardingTests.swift */, 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */, + B9A710000000000000000001 /* BrowserPanelVisualAutomationRestoreHostTests.swift */, 19D536229C6194FD62B44503 /* BrowserHistorySuggestionCacheTests.swift */, D0B1000DA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift */, D0B1000FA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift */, @@ -3467,6 +3470,7 @@ C2B6A97D1F2E4C71A8B9D001 /* BrowserOmnibarPerformanceSupportTests.swift in Sources */, D0B1000EA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift in Sources */, 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */, + B9A710000000000000000002 /* BrowserPanelVisualAutomationRestoreHostTests.swift in Sources */, C7B800062D4202A13C962D2D /* BrowserSystemProxyMirrorTests.swift in Sources */, C0DE49870000000000000001 /* BrowserWebContentProcessTests.swift in Sources */, C0DE35530000000000000101 /* BundledCLILinkageTests.swift in Sources */, diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift index 2869486bfa7..99a83670535 100644 --- a/cmuxTests/BrowserPanelTests.swift +++ b/cmuxTests/BrowserPanelTests.swift @@ -323,157 +323,6 @@ final class BrowserHiddenWebViewDiscardManagerTests: XCTestCase { } } -@MainActor -final class BrowserPanelVisualAutomationRestoreHostTests: XCTestCase { - private func realizeWindowLayout(_ window: NSWindow) { - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - window.contentView?.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - window.contentView?.layoutSubtreeIfNeeded() - } - - func testRestoredDiscardedHiddenWebViewGetsRestoreHostBeforeOffscreenCapture() { - let hiddenAt = Date() - let panel = BrowserPanel( - workspaceId: UUID(), - initialURL: URL(string: "data:text/html,restore-host")!, - isRemoteWorkspace: false - ) - defer { panel.close() } - - let deadline = Date().addingTimeInterval(2.0) - while (panel.webView.isLoading || panel.isLoading), - RunLoop.main.run(mode: .default, before: deadline), - Date() < deadline {} - XCTAssertFalse(panel.webView.isLoading, "Timed out waiting for data URL to finish loading") - XCTAssertFalse(panel.isLoading, "Timed out waiting for panel loading state to finish") - - panel.noteWebViewVisibility(false, reason: "test.hidden", now: hiddenAt) - let originalWebView = panel.webView - - XCTAssertTrue( - panel.discardHiddenWebViewForMemory(reason: "test.discard", now: hiddenAt), - "blockers: \(panel.hiddenWebViewDiscardSnapshot)" - ) - XCTAssertFalse(panel.webView === originalWebView) - XCTAssertNil(panel.webView.superview) - XCTAssertFalse(panel.hasBackgroundPreloadHost) - - XCTAssertTrue(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore")) - XCTAssertEqual(panel.webViewLifecycleState, .liveHidden) - XCTAssertNil(panel.webView.superview) - - XCTAssertTrue(panel.ensureVisualAutomationRestoreHostIfNeeded(reason: "test.visualAutomation")) - XCTAssertTrue(panel.hasBackgroundPreloadHost) - XCTAssertNotNil(panel.webView.superview) - XCTAssertNotNil(panel.webView.window) - XCTAssertFalse(panel.ensureVisualAutomationRestoreHostIfNeeded(reason: "test.visualAutomation.alreadyAttached")) - } - - func testAutomationCommandLeaseTemporarilyHostsHiddenPortalWebViewOffscreen() { - let panel = BrowserPanel( - workspaceId: UUID(), - initialURL: URL(string: "about:blank")!, - isRemoteWorkspace: false - ) - defer { panel.close() } - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) - contentView.addSubview(anchor) - BrowserWindowPortalRegistry.bind(webView: panel.webView, to: anchor, visibleInUI: true) - BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) - drainBrowserPanelMainQueue() - - guard let originalSuperview = panel.webView.superview else { - XCTFail("Expected portal-hosted webview") - return - } - - panel.noteWebViewVisibility(false, reason: "test.hidden", now: Date()) - BrowserWindowPortalRegistry.updateEntryVisibility(for: panel.webView, visibleInUI: false, zPriority: 0) - BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) - drainBrowserPanelMainQueue() - - XCTAssertTrue(panel.webView.superview === originalSuperview) - XCTAssertTrue(panel.webView.isHiddenOrHasHiddenAncestor) - - let lease = panel.beginAutomationCommandLease(reason: "test.automation") - XCTAssertNotNil(lease) - XCTAssertFalse(panel.webView.superview === originalSuperview) - XCTAssertNotNil(panel.webView.window) - XCTAssertFalse(panel.webView.isHiddenOrHasHiddenAncestor) - XCTAssertFalse( - panel.discardHiddenWebViewForMemory(reason: "test.discardWhileAutomating"), - "Socket automation must block hidden-webview discard while it owns the temporary host" - ) - - panel.endAutomationCommandLease(lease, reason: "test.automation") - - XCTAssertTrue(panel.webView.superview === originalSuperview) - XCTAssertTrue(panel.webView.isHiddenOrHasHiddenAncestor) - } - - func testAutomationCommandLeaseRestoresDiscardedHiddenWebViewBeforeHosting() { - let hiddenAt = Date() - let panel = BrowserPanel( - workspaceId: UUID(), - initialURL: URL(string: "data:text/html,socket-restore")!, - isRemoteWorkspace: false - ) - defer { panel.close() } - - let deadline = Date().addingTimeInterval(2.0) - while (panel.webView.isLoading || panel.isLoading), - RunLoop.main.run(mode: .default, before: deadline), - Date() < deadline {} - XCTAssertFalse(panel.webView.isLoading, "Timed out waiting for data URL to finish loading") - XCTAssertFalse(panel.isLoading, "Timed out waiting for panel loading state to finish") - - panel.noteWebViewVisibility(false, reason: "test.hidden", now: hiddenAt) - let originalWebView = panel.webView - - XCTAssertTrue( - panel.discardHiddenWebViewForMemory(reason: "test.discard", now: hiddenAt), - "blockers: \(panel.hiddenWebViewDiscardSnapshot)" - ) - XCTAssertFalse(panel.webView === originalWebView) - XCTAssertNil(panel.webView.superview) - XCTAssertEqual(panel.webViewLifecycleState, .discarded) - - let restoredWebView = panel.webView - let lease = panel.beginAutomationCommandLease(reason: "test.automation") - XCTAssertNotNil(lease) - XCTAssertTrue(panel.webView === restoredWebView) - XCTAssertEqual(panel.webViewLifecycleState, .liveHidden) - XCTAssertNotNil(panel.webView.window) - XCTAssertFalse(panel.webView.isHiddenOrHasHiddenAncestor) - XCTAssertFalse( - panel.discardHiddenWebViewForMemory(reason: "test.discardWhileAutomating"), - "Socket automation must block hidden-webview discard after restoring the replacement webview" - ) - - panel.endAutomationCommandLease(lease, reason: "test.automation") - - XCTAssertNil(panel.webView.superview) - XCTAssertEqual(panel.webViewLifecycleState, .liveHidden) - } -} - @MainActor private func makeTemporaryBrowserPanelProfile(named prefix: String) throws -> BrowserProfileDefinition { try XCTUnwrap( diff --git a/cmuxTests/BrowserPanelVisualAutomationRestoreHostTests.swift b/cmuxTests/BrowserPanelVisualAutomationRestoreHostTests.swift new file mode 100644 index 00000000000..d56d15d8aec --- /dev/null +++ b/cmuxTests/BrowserPanelVisualAutomationRestoreHostTests.swift @@ -0,0 +1,168 @@ +import XCTest +import AppKit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +private func drainBrowserPanelVisualAutomationMainQueue() { + let expectation = XCTestExpectation(description: "drain main queue") + DispatchQueue.main.async { + expectation.fulfill() + } + XCTWaiter().wait(for: [expectation], timeout: 1.0) +} + +@MainActor +final class BrowserPanelVisualAutomationRestoreHostTests: XCTestCase { + private func realizeWindowLayout(_ window: NSWindow) { + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + window.contentView?.layoutSubtreeIfNeeded() + } + + func testRestoredDiscardedHiddenWebViewGetsRestoreHostBeforeOffscreenCapture() { + let hiddenAt = Date() + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: URL(string: "data:text/html,restore-host")!, + isRemoteWorkspace: false + ) + defer { panel.close() } + + let deadline = Date().addingTimeInterval(2.0) + while (panel.webView.isLoading || panel.isLoading), + RunLoop.main.run(mode: .default, before: deadline), + Date() < deadline {} + XCTAssertFalse(panel.webView.isLoading, "Timed out waiting for data URL to finish loading") + XCTAssertFalse(panel.isLoading, "Timed out waiting for panel loading state to finish") + + panel.noteWebViewVisibility(false, reason: "test.hidden", now: hiddenAt) + let originalWebView = panel.webView + + XCTAssertTrue( + panel.discardHiddenWebViewForMemory(reason: "test.discard", now: hiddenAt), + "blockers: \(panel.hiddenWebViewDiscardSnapshot)" + ) + XCTAssertFalse(panel.webView === originalWebView) + XCTAssertNil(panel.webView.superview) + XCTAssertFalse(panel.hasBackgroundPreloadHost) + + XCTAssertTrue(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore")) + XCTAssertEqual(panel.webViewLifecycleState, .liveHidden) + XCTAssertNil(panel.webView.superview) + + XCTAssertTrue(panel.ensureVisualAutomationRestoreHostIfNeeded(reason: "test.visualAutomation")) + XCTAssertTrue(panel.hasBackgroundPreloadHost) + XCTAssertNotNil(panel.webView.superview) + XCTAssertNotNil(panel.webView.window) + XCTAssertFalse(panel.ensureVisualAutomationRestoreHostIfNeeded(reason: "test.visualAutomation.alreadyAttached")) + } + + func testAutomationCommandLeaseTemporarilyHostsHiddenPortalWebViewOffscreen() { + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: URL(string: "about:blank")!, + isRemoteWorkspace: false + ) + defer { panel.close() } + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) + contentView.addSubview(anchor) + BrowserWindowPortalRegistry.bind(webView: panel.webView, to: anchor, visibleInUI: true) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + drainBrowserPanelVisualAutomationMainQueue() + + guard let originalSuperview = panel.webView.superview else { + XCTFail("Expected portal-hosted webview") + return + } + + panel.noteWebViewVisibility(false, reason: "test.hidden", now: Date()) + BrowserWindowPortalRegistry.updateEntryVisibility(for: panel.webView, visibleInUI: false, zPriority: 0) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + drainBrowserPanelVisualAutomationMainQueue() + + XCTAssertTrue(panel.webView.superview === originalSuperview) + XCTAssertTrue(panel.webView.isHiddenOrHasHiddenAncestor) + + let lease = panel.beginAutomationCommandLease(reason: "test.automation") + XCTAssertNotNil(lease) + XCTAssertFalse(panel.webView.superview === originalSuperview) + XCTAssertNotNil(panel.webView.window) + XCTAssertFalse(panel.webView.isHiddenOrHasHiddenAncestor) + XCTAssertFalse( + panel.discardHiddenWebViewForMemory(reason: "test.discardWhileAutomating"), + "Socket automation must block hidden-webview discard while it owns the temporary host" + ) + + panel.endAutomationCommandLease(lease, reason: "test.automation") + + XCTAssertTrue(panel.webView.superview === originalSuperview) + XCTAssertTrue(panel.webView.isHiddenOrHasHiddenAncestor) + } + + func testAutomationCommandLeaseRestoresDiscardedHiddenWebViewBeforeHosting() { + let hiddenAt = Date() + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: URL(string: "data:text/html,socket-restore")!, + isRemoteWorkspace: false + ) + defer { panel.close() } + + let deadline = Date().addingTimeInterval(2.0) + while (panel.webView.isLoading || panel.isLoading), + RunLoop.main.run(mode: .default, before: deadline), + Date() < deadline {} + XCTAssertFalse(panel.webView.isLoading, "Timed out waiting for data URL to finish loading") + XCTAssertFalse(panel.isLoading, "Timed out waiting for panel loading state to finish") + + panel.noteWebViewVisibility(false, reason: "test.hidden", now: hiddenAt) + let originalWebView = panel.webView + + XCTAssertTrue( + panel.discardHiddenWebViewForMemory(reason: "test.discard", now: hiddenAt), + "blockers: \(panel.hiddenWebViewDiscardSnapshot)" + ) + XCTAssertFalse(panel.webView === originalWebView) + XCTAssertNil(panel.webView.superview) + XCTAssertEqual(panel.webViewLifecycleState, .discarded) + + let restoredWebView = panel.webView + let lease = panel.beginAutomationCommandLease(reason: "test.automation") + XCTAssertNotNil(lease) + XCTAssertTrue(panel.webView === restoredWebView) + XCTAssertEqual(panel.webViewLifecycleState, .liveHidden) + XCTAssertNotNil(panel.webView.window) + XCTAssertFalse(panel.webView.isHiddenOrHasHiddenAncestor) + XCTAssertFalse( + panel.discardHiddenWebViewForMemory(reason: "test.discardWhileAutomating"), + "Socket automation must block hidden-webview discard after restoring the replacement webview" + ) + + panel.endAutomationCommandLease(lease, reason: "test.automation") + + XCTAssertNil(panel.webView.superview) + XCTAssertEqual(panel.webViewLifecycleState, .liveHidden) + } +}