diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 07a7ba8b..91625919 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5482,6 +5482,60 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func isGotoSplitUITestRecordingEnabled() -> Bool { + let env = ProcessInfo.processInfo.environment + return env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" || env["CMUX_UI_TEST_GOTO_SPLIT_RECORD_ONLY"] == "1" + } + + private func gotoSplitUITestDataPath() -> String? { + guard isGotoSplitUITestRecordingEnabled() else { return nil } + let env = ProcessInfo.processInfo.environment + guard let path = env["CMUX_UI_TEST_GOTO_SPLIT_PATH"], !path.isEmpty else { return nil } + return path + } + + private func gotoSplitFindStateSnapshot(for workspace: Workspace) -> [String: String] { + var updates: [String: String] = [ + "focusedPaneId": workspace.bonsplitController.focusedPaneId?.description ?? "" + ] + + if let focusedPanelId = workspace.focusedPanelId { + updates["focusedPanelId"] = focusedPanelId.uuidString + if let terminal = workspace.terminalPanel(for: focusedPanelId) { + updates["focusedPanelKind"] = "terminal" + updates["focusedTerminalFindNeedle"] = terminal.searchState?.needle ?? "" + updates["focusedBrowserFindNeedle"] = "" + } else if let browser = workspace.browserPanel(for: focusedPanelId) { + updates["focusedPanelKind"] = "browser" + updates["focusedBrowserFindNeedle"] = browser.searchState?.needle ?? "" + updates["focusedTerminalFindNeedle"] = "" + } else { + updates["focusedPanelKind"] = "other" + updates["focusedTerminalFindNeedle"] = "" + updates["focusedBrowserFindNeedle"] = "" + } + } else { + updates["focusedPanelId"] = "" + updates["focusedPanelKind"] = "none" + updates["focusedTerminalFindNeedle"] = "" + updates["focusedBrowserFindNeedle"] = "" + } + + let terminalWithFind = workspace.panels.values + .compactMap { $0 as? TerminalPanel } + .first(where: { $0.searchState != nil }) + updates["terminalFindPanelId"] = terminalWithFind?.id.uuidString ?? "" + updates["terminalFindNeedle"] = terminalWithFind?.searchState?.needle ?? "" + + let browserWithFind = workspace.panels.values + .compactMap { $0 as? BrowserPanel } + .first(where: { $0.searchState != nil }) + updates["browserFindPanelId"] = browserWithFind?.id.uuidString ?? "" + updates["browserFindNeedle"] = browserWithFind?.searchState?.needle ?? "" + + return updates + } + private func focusWebViewForGotoSplitUITest(tab: Workspace, browserPanelId: UUID, attempt: Int = 0) { let maxAttempts = 120 guard attempt < maxAttempts else { @@ -5603,10 +5657,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func recordGotoSplitMoveIfNeeded(direction: NavigationDirection) { - let env = ProcessInfo.processInfo.environment - guard env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" else { return } - guard let tabManager, - let focusedPaneId = tabManager.selectedWorkspace?.bonsplitController.focusedPaneId else { return } + guard isGotoSplitUITestRecordingEnabled() else { return } + guard let tabManager, let workspace = tabManager.selectedWorkspace else { return } let directionValue: String switch direction { @@ -5620,15 +5672,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent directionValue = "down" } - writeGotoSplitTestData([ - "lastMoveDirection": directionValue, - "focusedPaneId": focusedPaneId.description - ]) + var updates = gotoSplitFindStateSnapshot(for: workspace) + updates["lastMoveDirection"] = directionValue + writeGotoSplitTestData(updates) } private func recordGotoSplitSplitIfNeeded(direction: SplitDirection) { - let env = ProcessInfo.processInfo.environment - guard env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" else { return } + guard isGotoSplitUITestRecordingEnabled() else { return } guard let workspace = tabManager?.selectedWorkspace else { return } let directionValue: String @@ -5643,16 +5693,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent directionValue = "down" } - writeGotoSplitTestData([ - "lastSplitDirection": directionValue, - "paneCountAfterSplit": String(workspace.bonsplitController.allPaneIds.count), - "focusedPaneId": workspace.bonsplitController.focusedPaneId?.description ?? "" - ]) + var updates = gotoSplitFindStateSnapshot(for: workspace) + updates["lastSplitDirection"] = directionValue + updates["paneCountAfterSplit"] = String(workspace.bonsplitController.allPaneIds.count) + writeGotoSplitTestData(updates) } private func writeGotoSplitTestData(_ updates: [String: String]) { - let env = ProcessInfo.processInfo.environment - guard let path = env["CMUX_UI_TEST_GOTO_SPLIT_PATH"], !path.isEmpty else { return } + guard let path = gotoSplitUITestDataPath() else { return } var payload = loadGotoSplitTestData(at: path) for (key, value) in updates { payload[key] = value diff --git a/Sources/Find/BrowserSearchOverlay.swift b/Sources/Find/BrowserSearchOverlay.swift index 635aecdb..9a022e5f 100644 --- a/Sources/Find/BrowserSearchOverlay.swift +++ b/Sources/Find/BrowserSearchOverlay.swift @@ -14,11 +14,21 @@ struct BrowserSearchOverlay: View { private let padding: CGFloat = 8 + private func requestSearchFieldFocus(maxAttempts: Int = 3) { + guard maxAttempts > 0 else { return } + isSearchFieldFocused = true + guard maxAttempts > 1 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + requestSearchFieldFocus(maxAttempts: maxAttempts - 1) + } + } + var body: some View { GeometryReader { geo in HStack(spacing: 4) { TextField("Search", text: $searchState.needle) .textFieldStyle(.plain) + .accessibilityIdentifier("BrowserFindSearchTextField") .frame(width: 180) .padding(.leading, 8) .padding(.trailing, 50) @@ -95,13 +105,13 @@ struct BrowserSearchOverlay: View { #if DEBUG dlog("browser.findbar.appear panel=\(panelId.uuidString.prefix(5))") #endif - isSearchFieldFocused = true + requestSearchFieldFocus() } .onReceive(NotificationCenter.default.publisher(for: .browserSearchFocus)) { notification in guard let notifiedPanelId = notification.object as? UUID, notifiedPanelId == panelId else { return } DispatchQueue.main.async { - isSearchFieldFocused = true + requestSearchFieldFocus() } } .background( diff --git a/Sources/Find/SurfaceSearchOverlay.swift b/Sources/Find/SurfaceSearchOverlay.swift index 17c795e6..0efc3d50 100644 --- a/Sources/Find/SurfaceSearchOverlay.swift +++ b/Sources/Find/SurfaceSearchOverlay.swift @@ -55,6 +55,7 @@ struct SurfaceSearchOverlay: View { onNavigateSearch(action) } ) + .accessibilityIdentifier("TerminalFindSearchTextField") .frame(width: 180) .padding(.leading, 8) .padding(.trailing, 50) @@ -303,6 +304,7 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable { let field = SearchNativeTextField(frame: .zero) field.font = .systemFont(ofSize: NSFont.systemFontSize) field.placeholderString = String(localized: "search.placeholder", defaultValue: "Search") + field.setAccessibilityIdentifier("TerminalFindSearchTextField") field.delegate = context.coordinator field.stringValue = text context.coordinator.parentField = field diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 885dd16d..89858475 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1595,10 +1595,8 @@ final class BrowserPanel: Panel, ObservableObject { Task { @MainActor [weak self] in self?.refreshFavicon(from: webView) self?.applyBrowserThemeModeIfNeeded() - // Clear find-in-page on navigation so stale highlights don't persist. - if self?.searchState != nil { - self?.searchState = nil - } + // Keep find-in-page open through load completion and refresh matches for the new DOM. + self?.restoreFindStateAfterNavigation(replaySearch: true) } } navDelegate.didFailNavigation = { [weak self] _, failedURL in @@ -1609,10 +1607,8 @@ final class BrowserPanel: Panel, ObservableObject { self.pageTitle = failedURL.isEmpty ? "" : failedURL self.faviconPNGData = nil self.lastFaviconURLString = nil - // Clear find-in-page so stale highlights don't persist. - if self.searchState != nil { - self.searchState = nil - } + // Keep find-in-page open and clear stale counters on failed loads. + self.restoreFindStateAfterNavigation(replaySearch: false) } } navDelegate.openInNewTab = { [weak self] url in @@ -2645,6 +2641,18 @@ extension BrowserPanel { if searchState == nil { searchState = BrowserSearchState() } + postBrowserSearchFocusNotification() + // Focus notification can race with portal overlay mount. Re-post on the + // next runloop and shortly after so the find field can claim first responder. + DispatchQueue.main.async { [weak self] in + self?.postBrowserSearchFocusNotification() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.postBrowserSearchFocusNotification() + } + } + + private func postBrowserSearchFocusNotification() { NotificationCenter.default.post(name: .browserSearchFocus, object: id) } @@ -2668,6 +2676,16 @@ extension BrowserPanel { searchState = nil } + private func restoreFindStateAfterNavigation(replaySearch: Bool) { + guard let state = searchState else { return } + state.total = nil + state.selected = nil + if replaySearch, !state.needle.isEmpty { + executeFindSearch(state.needle) + } + postBrowserSearchFocusNotification() + } + private func executeFindSearch(_ needle: String) { guard !needle.isEmpty else { executeFindClear() @@ -2743,6 +2761,9 @@ extension BrowserPanel { if suppressWebViewFocusForAddressBar { return true } + if searchState != nil { + return true + } if let until = suppressWebViewFocusUntil { return Date() < until } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 73f8e3e5..07295066 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -318,7 +318,10 @@ struct BrowserPanelView: View { .allowsHitTesting(false) } .overlay { - if let searchState = panel.searchState { + // Keep Cmd+F usable when the browser is still in the empty new-tab + // state (no WKWebView mounted yet). WebView-backed cases are hosted + // in AppKit by WebViewRepresentable to avoid layering/clipping issues. + if !panel.shouldRenderWebView, let searchState = panel.searchState { BrowserSearchOverlay( panelId: panel.id, searchState: searchState, @@ -735,6 +738,7 @@ struct BrowserPanelView: View { if panel.shouldRenderWebView { WebViewRepresentable( panel: panel, + browserSearchState: panel.searchState, shouldAttachWebView: isVisibleInUI, shouldFocusWebView: isFocused && !addressBarFocused, isPanelFocused: isFocused, @@ -3034,6 +3038,7 @@ private struct OmnibarSuggestionsView: View { /// NSViewRepresentable wrapper for WKWebView struct WebViewRepresentable: NSViewRepresentable { let panel: BrowserPanel + let browserSearchState: BrowserSearchState? let shouldAttachWebView: Bool let shouldFocusWebView: Bool let isPanelFocused: Bool @@ -3047,6 +3052,7 @@ struct WebViewRepresentable: NSViewRepresentable { var desiredPortalVisibleInUI: Bool = true var desiredPortalZPriority: Int = 0 var lastPortalHostId: ObjectIdentifier? + var searchOverlayHostingView: NSHostingView? } private final class HostContainerView: NSView { @@ -3199,6 +3205,67 @@ struct WebViewRepresentable: NSViewRepresentable { host.onGeometryChanged = nil } + private static func removeSearchOverlay(from coordinator: Coordinator) { + coordinator.searchOverlayHostingView?.removeFromSuperview() + coordinator.searchOverlayHostingView = nil + } + + private static func updateSearchOverlay( + panel: BrowserPanel, + coordinator: Coordinator, + containerView: NSView? + ) { + // Layering contract: keep browser Cmd+F UI in the portal-hosted AppKit layer. + // SwiftUI panel overlays can be covered by portal-hosted WKWebView content. + guard let searchState = panel.searchState, + let containerView else { + removeSearchOverlay(from: coordinator) + return + } + + let rootView = BrowserSearchOverlay( + panelId: panel.id, + searchState: searchState, + onNext: { [weak panel] in + panel?.findNext() + }, + onPrevious: { [weak panel] in + panel?.findPrevious() + }, + onClose: { [weak panel] in + panel?.hideFind() + } + ) + + if let overlay = coordinator.searchOverlayHostingView { + overlay.rootView = rootView + if overlay.superview !== containerView { + overlay.removeFromSuperview() + containerView.addSubview(overlay, positioned: .above, relativeTo: nil) + NSLayoutConstraint.activate([ + overlay.topAnchor.constraint(equalTo: containerView.topAnchor), + overlay.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + overlay.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + ]) + } else if containerView.subviews.last !== overlay { + containerView.addSubview(overlay, positioned: .above, relativeTo: nil) + } + return + } + + let overlay = NSHostingView(rootView: rootView) + overlay.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(overlay, positioned: .above, relativeTo: nil) + NSLayoutConstraint.activate([ + overlay.topAnchor.constraint(equalTo: containerView.topAnchor), + overlay.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + overlay.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + ]) + coordinator.searchOverlayHostingView = overlay + } + private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) { guard let host = nsView as? HostContainerView else { return } @@ -3223,6 +3290,13 @@ struct WebViewRepresentable: NSViewRepresentable { ) BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: paneDropContext) coordinator.lastPortalHostId = ObjectIdentifier(host) + if let panel = coordinator.panel { + Self.updateSearchOverlay( + panel: panel, + coordinator: coordinator, + containerView: webView.superview + ) + } } host.onGeometryChanged = { [weak host, weak coordinator] in guard let host, let coordinator else { return } @@ -3254,6 +3328,11 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.lastPortalHostId = hostId } BrowserWindowPortalRegistry.synchronizeForAnchor(host) + Self.updateSearchOverlay( + panel: panel, + coordinator: coordinator, + containerView: webView.superview + ) } else { // Bind is deferred until host moves into a window. Keep the current // portal entry's desired state in sync so stale callbacks cannot keep @@ -3263,6 +3342,7 @@ struct WebViewRepresentable: NSViewRepresentable { visibleInUI: coordinator.desiredPortalVisibleInUI, zPriority: coordinator.desiredPortalZPriority ) + Self.removeSearchOverlay(from: coordinator) } BrowserWindowPortalRegistry.updateDropZoneOverlay( @@ -3291,6 +3371,7 @@ struct WebViewRepresentable: NSViewRepresentable { let webView = panel.webView let coordinator = context.coordinator if let previousWebView = coordinator.webView, previousWebView !== webView { + Self.removeSearchOverlay(from: coordinator) BrowserWindowPortalRegistry.detach(webView: previousWebView) coordinator.lastPortalHostId = nil } @@ -3362,6 +3443,7 @@ struct WebViewRepresentable: NSViewRepresentable { static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.attachGeneration += 1 clearPortalCallbacks(for: nsView) + removeSearchOverlay(from: coordinator) guard let webView = coordinator.webView else { return } let panel = coordinator.panel diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a4a870ea..969819ee 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3074,7 +3074,14 @@ final class Workspace: Identifiable, ObservableObject { } if let browserPanel = panels[panelId] as? BrowserPanel { - maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) + // Keep browser find focus behavior aligned with terminal find behavior. + // When switching back to a pane with an already-open find bar, reassert + // focus to that field instead of leaving first responder stale. + if browserPanel.searchState != nil { + browserPanel.startFind() + } else { + maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) + } } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index e5f4b40f..2177f000 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2446,6 +2446,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { let representable = WebViewRepresentable( panel: panel, + browserSearchState: nil, shouldAttachWebView: true, shouldFocusWebView: false, isPanelFocused: true, @@ -2483,6 +2484,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { let representable = WebViewRepresentable( panel: panel, + browserSearchState: nil, shouldAttachWebView: true, shouldFocusWebView: false, isPanelFocused: true, diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift index 4ed0a584..9cd9f038 100644 --- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift @@ -423,6 +423,180 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { ) } + func testCmdOptionPaneSwitchPreservesFindFieldFocus() { + runFindFocusPersistenceScenario(route: .cmdOptionArrows, useAutofocusRacePage: false) + } + + func testCmdCtrlPaneSwitchPreservesFindFieldFocus() { + runFindFocusPersistenceScenario(route: .cmdCtrlLetters, useAutofocusRacePage: false) + } + + func testCmdOptionPaneSwitchPreservesFindFieldFocusDuringPageAutofocusRace() { + runFindFocusPersistenceScenario(route: .cmdOptionArrows, useAutofocusRacePage: true) + } + + private enum FindFocusRoute { + case cmdOptionArrows + case cmdCtrlLetters + } + + private func runFindFocusPersistenceScenario(route: FindFocusRoute, useAutofocusRacePage: Bool) { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_RECORD_ONLY"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + if route == .cmdCtrlLetters { + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + } + launchAndEnsureForeground(app) + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 10.0), "Expected main window to exist") + + // Repro setup: split, open browser split, navigate to example.com. + app.typeKey("d", modifierFlags: [.command]) + focusRightPaneForFindScenario(app, route: route) + + app.typeKey("l", modifierFlags: [.command, .shift]) + let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch + XCTAssertTrue(omnibar.waitForExistence(timeout: 8.0), "Expected browser omnibar after Cmd+Shift+L") + + app.typeKey("a", modifierFlags: [.command]) + app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: []) + if useAutofocusRacePage { + app.typeText(autofocusRacePageURL) + } else { + app.typeText("example.com") + } + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + if useAutofocusRacePage { + XCTAssertTrue( + waitForOmnibarToContain(omnibar, value: "data:text/html", timeout: 8.0), + "Expected browser navigation to data URL before running find flow. value=\(String(describing: omnibar.value))" + ) + } else { + XCTAssertTrue( + waitForOmnibarToContainExampleDomain(omnibar, timeout: 8.0), + "Expected browser navigation to example domain before running find flow. value=\(String(describing: omnibar.value))" + ) + } + + // Left terminal: Cmd+F then type "la". + focusLeftPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["focusedPanelKind"] == "terminal" + }, + "Expected left terminal pane to be focused before terminal find. data=\(String(describing: loadData()))" + ) + app.typeKey("f", modifierFlags: [.command]) + app.typeText("la") + + // Right browser: Cmd+F then type "am". + focusRightPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["lastMoveDirection"] == "right" + && data["focusedPanelKind"] == "browser" + && data["terminalFindNeedle"] == "la" + }, + "Expected terminal find query to persist as 'la' after focusing browser pane. data=\(String(describing: loadData()))" + ) + app.typeKey("f", modifierFlags: [.command]) + app.typeText("am") + + if useAutofocusRacePage { + XCTAssertTrue( + waitForOmnibarToContain(omnibar, value: "#focused", timeout: 5.0), + "Expected autofocus race page to signal focus handoff via URL hash. value=\(String(describing: omnibar.value))" + ) + } + + // Left terminal: typing should keep going into terminal find field. + focusLeftPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["lastMoveDirection"] == "left" + && data["focusedPanelKind"] == "terminal" + && data["browserFindNeedle"] == "am" + }, + "Expected browser find query to persist as 'am' after returning left. data=\(String(describing: loadData()))" + ) + app.typeText("foo") + + // Right browser: typing should keep going into browser find field. + focusRightPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["lastMoveDirection"] == "right" + && data["focusedPanelKind"] == "browser" + && data["terminalFindNeedle"] == "lafoo" + }, + "Expected terminal find query to stay focused and become 'lafoo'. data=\(String(describing: loadData()))" + ) + app.typeText("do") + + // Move left once more so the recorder captures browser find state after typing. + focusLeftPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["lastMoveDirection"] == "left" + && data["focusedPanelKind"] == "terminal" + && data["browserFindNeedle"] == "amdo" + }, + "Expected browser find query to stay focused and become 'amdo'. data=\(String(describing: loadData()))" + ) + } + + private func focusLeftPaneForFindScenario(_ app: XCUIApplication, route: FindFocusRoute) { + switch route { + case .cmdOptionArrows: + app.typeKey(XCUIKeyboardKey.leftArrow.rawValue, modifierFlags: [.command, .option]) + case .cmdCtrlLetters: + app.typeKey("h", modifierFlags: [.command, .control]) + } + } + + private func focusRightPaneForFindScenario(_ app: XCUIApplication, route: FindFocusRoute) { + switch route { + case .cmdOptionArrows: + app.typeKey(XCUIKeyboardKey.rightArrow.rawValue, modifierFlags: [.command, .option]) + case .cmdCtrlLetters: + app.typeKey("l", modifierFlags: [.command, .control]) + } + } + + private func waitForOmnibarToContainExampleDomain(_ omnibar: XCUIElement, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let value = (omnibar.value as? String) ?? "" + if value.contains("example.com") || value.contains("example.org") { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + let value = (omnibar.value as? String) ?? "" + return value.contains("example.com") || value.contains("example.org") + } + + private func waitForOmnibarToContain(_ omnibar: XCUIElement, value expectedSubstring: String, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let value = (omnibar.value as? String) ?? "" + if value.contains(expectedSubstring) { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + let value = (omnibar.value as? String) ?? "" + return value.contains(expectedSubstring) + } + + private var autofocusRacePageURL: String { + "data:text/html,%3Cinput%20id%3D%22q%22%3E%3Cscript%3EsetTimeout%28function%28%29%7Bdocument.getElementById%28%22q%22%29.focus%28%29%3Blocation.hash%3D%22focused%22%3B%7D%2C700%29%3B%3C%2Fscript%3E" + } + private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) { app.launch() XCTAssertTrue( diff --git a/tests/regression_helpers.py b/tests/regression_helpers.py new file mode 100644 index 00000000..73965c51 --- /dev/null +++ b/tests/regression_helpers.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Shared helpers for static regression tests.""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def repo_root() -> Path: + git = shutil.which("git") + if git is None: + return Path(__file__).resolve().parents[1] + try: + result = subprocess.run( + [git, "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + timeout=2, + ) + except (subprocess.TimeoutExpired, OSError): + return Path(__file__).resolve().parents[1] + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path(__file__).resolve().parents[1] + + +def extract_block(source: str, signature: str) -> str: + # Targeted helper for this regression suite: assumes braces in the matched + # block are structural (not inside strings/comments/character literals). + start = source.find(signature) + if start < 0: + raise ValueError(f"Missing signature: {signature}") + + brace_start = source.find("{", start) + if brace_start < 0: + raise ValueError(f"Missing opening brace for: {signature}") + + depth = 0 + for idx in range(brace_start, len(source)): + char = source[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[brace_start : idx + 1] + + raise ValueError(f"Unbalanced braces for: {signature}") diff --git a/tests/test_browser_find_overlay_portal_regression.py b/tests/test_browser_find_overlay_portal_regression.py new file mode 100644 index 00000000..468a1892 --- /dev/null +++ b/tests/test_browser_find_overlay_portal_regression.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Regression guards for browser Cmd+F overlay layering in portal mode.""" + +from __future__ import annotations + +from regression_helpers import extract_block, repo_root + + +def main() -> int: + root = repo_root() + view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift" + panel_path = root / "Sources" / "Panels" / "BrowserPanel.swift" + overlay_path = root / "Sources" / "Find" / "BrowserSearchOverlay.swift" + source = view_path.read_text(encoding="utf-8") + panel_source = panel_path.read_text(encoding="utf-8") + overlay_source = overlay_path.read_text(encoding="utf-8") + failures: list[str] = [] + + try: + browser_panel_view_block = extract_block( + source, "struct BrowserPanelView: View" + ) + except ValueError as error: + failures.append(str(error)) + browser_panel_view_block = "" + + try: + body_block = extract_block(browser_panel_view_block, "var body: some View") + except ValueError as error: + failures.append(str(error)) + body_block = "" + + fallback_signature = ( + "if !panel.shouldRenderWebView, let searchState = panel.searchState {" + ) + fallback_block = "" + if body_block: + try: + fallback_block = extract_block(body_block, fallback_signature) + except ValueError: + failures.append( + "BrowserPanelView must provide BrowserSearchOverlay fallback for new-tab state " + "(when WKWebView is not mounted)" + ) + if fallback_block and "BrowserSearchOverlay(" not in fallback_block: + failures.append( + "BrowserPanelView fallback branch must mount BrowserSearchOverlay for new-tab state" + ) + + try: + webview_repr_block = extract_block( + source, "struct WebViewRepresentable: NSViewRepresentable" + ) + except ValueError as error: + failures.append(str(error)) + webview_repr_block = "" + + if webview_repr_block: + if "let browserSearchState: BrowserSearchState?" not in webview_repr_block: + failures.append( + "WebViewRepresentable must include browserSearchState so Cmd+F state changes trigger updates" + ) + if ( + "var searchOverlayHostingView: NSHostingView?" + not in webview_repr_block + ): + failures.append( + "WebViewRepresentable.Coordinator must own a BrowserSearchOverlay hosting view" + ) + if "private static func updateSearchOverlay(" not in webview_repr_block: + failures.append( + "WebViewRepresentable must define updateSearchOverlay helper" + ) + if "containerView: webView.superview" not in webview_repr_block: + failures.append( + "Portal updates must sync BrowserSearchOverlay against the web view container" + ) + if "removeSearchOverlay(from: coordinator)" not in webview_repr_block: + failures.append( + "WebViewRepresentable must remove browser search overlays during teardown/rebind" + ) + + if "browserSearchState: panel.searchState" not in source: + failures.append( + "BrowserPanelView must pass panel.searchState into WebViewRepresentable" + ) + + try: + update_ns_view_block = extract_block( + webview_repr_block, "func updateNSView(_ nsView: NSView, context: Context)" + ) + except ValueError as error: + failures.append(str(error)) + update_ns_view_block = "" + + if "updateSearchOverlay(" in update_ns_view_block: + failures.append( + "updateNSView must not re-run updateSearchOverlay outside portal lifecycle paths" + ) + + try: + suppress_focus_block = extract_block( + panel_source, "func shouldSuppressWebViewFocus() -> Bool" + ) + except ValueError as error: + failures.append(str(error)) + suppress_focus_block = "" + + if "if searchState != nil {" not in suppress_focus_block: + failures.append( + "BrowserPanel.shouldSuppressWebViewFocus must suppress focus while find-in-page is active" + ) + + try: + start_find_block = extract_block(panel_source, "func startFind()") + except ValueError as error: + failures.append(str(error)) + start_find_block = "" + + if start_find_block: + if "postBrowserSearchFocusNotification()" not in start_find_block: + failures.append( + "BrowserPanel.startFind must publish browserSearchFocus notifications" + ) + if "DispatchQueue.main.async {" not in start_find_block: + failures.append( + "BrowserPanel.startFind must re-post focus on next runloop to avoid mount races" + ) + if "DispatchQueue.main.asyncAfter" not in start_find_block: + failures.append( + "BrowserPanel.startFind must re-post focus shortly after to avoid portal mount races" + ) + + try: + init_block = extract_block(panel_source, "init(workspaceId: UUID") + except ValueError as error: + failures.append(str(error)) + init_block = "" + + if init_block: + if ( + "self?.searchState = nil" in init_block + or "self.searchState = nil" in init_block + ): + failures.append( + "BrowserPanel navigation callbacks must not clear searchState entirely to avoid losing find bar focus" + ) + if "restoreFindStateAfterNavigation(replaySearch: true)" not in init_block: + failures.append( + "BrowserPanel.didFinish must preserve find state and replay search on the new page" + ) + if "restoreFindStateAfterNavigation(replaySearch: false)" not in init_block: + failures.append( + "BrowserPanel.didFailNavigation must preserve find state without replaying search" + ) + + try: + restore_find_state_block = extract_block( + panel_source, "private func restoreFindStateAfterNavigation(replaySearch: Bool)" + ) + except ValueError as error: + failures.append(str(error)) + restore_find_state_block = "" + + if restore_find_state_block: + if "state.total = nil" not in restore_find_state_block: + failures.append( + "BrowserPanel restoreFindStateAfterNavigation must clear stale find total count" + ) + if "state.selected = nil" not in restore_find_state_block: + failures.append( + "BrowserPanel restoreFindStateAfterNavigation must clear stale selected match" + ) + if "if replaySearch, !state.needle.isEmpty {" not in restore_find_state_block: + failures.append( + "BrowserPanel restoreFindStateAfterNavigation must only replay search for successful navigations" + ) + if "postBrowserSearchFocusNotification()" not in restore_find_state_block: + failures.append( + "BrowserPanel restoreFindStateAfterNavigation must reassert find field focus" + ) + + if "private func requestSearchFieldFocus(" not in overlay_source: + failures.append( + "BrowserSearchOverlay must define requestSearchFieldFocus retry helper" + ) + if "requestSearchFieldFocus()" not in overlay_source: + failures.append( + "BrowserSearchOverlay must request text focus from appear/notification paths" + ) + + if failures: + print("FAIL: browser find overlay portal regression guards failed") + for failure in failures: + print(f" - {failure}") + return 1 + + print("PASS: browser find overlay remains mounted in portal-hosted AppKit layer") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())