Fix browser Cmd+F overlay clipping in portal mode (#916)

* Fix browser Cmd+F overlay clipping in portal mode

* Fix browser Cmd+F panel update regression

* Fix browser find overlay lifecycle and focus

* Extract regression test helpers for browser find guards

* Restore new-tab Cmd+F overlay and harden test helper

* Fix browser Cmd+F focus handoff race

* Fix browser Cmd+F focus loss across page load

* Address review feedback on browser find focus guards

* Add Cmd+F pane-switch regression UI tests

* Run Cmd+F pane-switch regressions from existing UI suite

* Restore browser find focus on pane refocus

* Stabilize Cmd+F pane-switch regressions with focus-state recorder

* Make autofocus race UI test wait on deterministic page signal

* Fix cmuxTests WebViewRepresentable init after browser search state param
This commit is contained in:
Lawrence Chen 2026-03-05 15:36:47 -08:00 committed by GitHub
parent 9b215eddab
commit e49e572505
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 629 additions and 29 deletions

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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
}

View file

@ -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<BrowserSearchOverlay>?
}
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

View file

@ -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)
}
}
}

View file

@ -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,

View file

@ -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(

View file

@ -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}")

View file

@ -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<BrowserSearchOverlay>?"
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())