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:
parent
9b215eddab
commit
e49e572505
10 changed files with 629 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
51
tests/regression_helpers.py
Normal file
51
tests/regression_helpers.py
Normal 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}")
|
||||
203
tests/test_browser_find_overlay_portal_regression.py
Normal file
203
tests/test_browser_find_overlay_portal_regression.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue