From bf3d22fa8a3a0e5199c9a37858eb1f63539a7bfa Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:12:28 -0800 Subject: [PATCH] Fix flaky WebKit escape focus tests on slow environments (#57) Two fixes for escape-focus flakiness on VMs: 1. App-side: recordGotoSplitUITestWebViewFocus now retries with increasing delays (0.05, 0.1, 0.25, 0.5s) for the exit-address-bar case, giving WebKit more time to accept first responder. 2. Test-side: both testEscapeLeavesOmnibarAndFocusesWebView and refocusWebView helper send a second Escape if the first only clears suggestions/editing state (Chrome-like two-stage escape behavior). --- .../BrowserPaneNavigationKeybindUITests.swift | 5 +++++ .../MenuKeyEquivalentRoutingUITests.swift | 5 +++++ Sources/AppDelegate.swift | 15 +++++++++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/GhosttyTabsUITests/BrowserPaneNavigationKeybindUITests.swift b/GhosttyTabsUITests/BrowserPaneNavigationKeybindUITests.swift index 7694b5e3..6027832a 100644 --- a/GhosttyTabsUITests/BrowserPaneNavigationKeybindUITests.swift +++ b/GhosttyTabsUITests/BrowserPaneNavigationKeybindUITests.swift @@ -157,7 +157,12 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { ) // Escape should leave the omnibar and focus WebKit again. + // Send Escape twice: the first may only clear suggestions/editing state + // (Chrome-like two-stage escape), the second triggers blur to WebView. app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + if !waitForDataMatch(timeout: 2.0, predicate: { $0["webViewFocusedAfterAddressBarExit"] == "true" }) { + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + } XCTAssertTrue( waitForDataMatch(timeout: 5.0) { data in data["webViewFocusedAfterAddressBarExit"] == "true" diff --git a/GhosttyTabsUITests/MenuKeyEquivalentRoutingUITests.swift b/GhosttyTabsUITests/MenuKeyEquivalentRoutingUITests.swift index 8709e8e7..ef2a79f1 100644 --- a/GhosttyTabsUITests/MenuKeyEquivalentRoutingUITests.swift +++ b/GhosttyTabsUITests/MenuKeyEquivalentRoutingUITests.swift @@ -111,7 +111,12 @@ final class MenuKeyEquivalentRoutingUITests: XCTestCase { ) // Escape should leave the omnibar and focus WebKit again. + // Send Escape twice: the first may only clear suggestions/editing state + // (Chrome-like two-stage escape), the second triggers blur to WebView. app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + if !waitForGotoSplitMatch(timeout: 2.0, predicate: { $0["webViewFocusedAfterAddressBarExit"] == "true" }) { + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + } XCTAssertTrue( waitForGotoSplitMatch(timeout: 5.0) { data in data["webViewFocusedAfterAddressBarExit"] == "true" diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index cdeba617..8f0c4bc2 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1188,11 +1188,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func recordGotoSplitUITestWebViewFocus(panelId: UUID, key: String) { - // Give the responder chain time to settle. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + // Give the responder chain time to settle, retrying for slow environments (e.g. VM). + recordGotoSplitUITestWebViewFocusRetry(panelId: panelId, key: key, attempt: 0) + } + + private func recordGotoSplitUITestWebViewFocusRetry(panelId: UUID, key: String, attempt: Int) { + let delays: [Double] = [0.05, 0.1, 0.25, 0.5] + let delay = attempt < delays.count ? delays[attempt] : delays.last! + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self, let tabManager, let tab = tabManager.selectedWorkspace, let panel = tab.browserPanel(for: panelId) else { return } let focused = self.isWebViewFocused(panel) + // If focus hasn't settled yet and we have retries left, try again. + if !focused && key.contains("Exit") && attempt < delays.count - 1 { + self.recordGotoSplitUITestWebViewFocusRetry(panelId: panelId, key: key, attempt: attempt + 1) + return + } self.writeGotoSplitTestData([ key: focused ? "true" : "false", "\(key)PanelId": panelId.uuidString