diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e17b02090a..2ae8ec56cf 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1511,12 +1511,38 @@ func shouldDispatchBrowserReturnViaFirstResponderKeyDown( ) -> Bool { guard firstResponderIsBrowser else { return false } guard keyCode == 36 || keyCode == 76 else { return false } - // Keep browser Return forwarding narrow: only plain/Shift Return should be - // treated as submit-intent. Command-modified Return is reserved for app shortcuts - // like Toggle Pane Zoom (Cmd+Shift+Enter). return browserOmnibarShouldSubmitOnReturn(flags: flags) } +func shouldDirectRouteBrowserFirstResponderKeyDown( + firstResponder: NSResponder?, + firstResponderIsBrowser: Bool, + focusedBrowserAddressBarPanelId: UUID? +) -> Bool { + guard firstResponderIsBrowser else { return false } + guard focusedBrowserAddressBarPanelId == nil else { return false } + return firstResponder != nil +} + +enum BrowserDirectKeyRoutingStrategy: Equatable { + case keyDown + case menuOrAppShortcutThenKeyDown + case deferToOriginalPerformKeyEquivalent +} + +func browserDirectKeyRoutingStrategy(for event: NSEvent) -> BrowserDirectKeyRoutingStrategy { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard flags.contains(.command) else { + return .keyDown + } + + guard shouldRouteCommandEquivalentDirectlyToMainMenu(event) else { + return .deferToOriginalPerformKeyEquivalent + } + + return .menuOrAppShortcutThenKeyDown +} + func shouldToggleMainWindowFullScreenForCommandControlFShortcut( flags: NSEvent.ModifierFlags, chars: String, @@ -2021,6 +2047,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var browserOmnibarRepeatDelta: Int = 0 private var browserAddressBarFocusObserver: NSObjectProtocol? private var browserAddressBarBlurObserver: NSObjectProtocol? + private var browserWebViewClickObserver: NSObjectProtocol? + private var browserWebViewFocusObserver: NSObjectProtocol? private let updateController = UpdateController() private lazy var titlebarAccessoryController = UpdateTitlebarAccessoryController(viewModel: updateViewModel) private let windowDecorationsController = WindowDecorationsController() @@ -2074,10 +2102,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var didSetupGotoSplitUITest = false private var didSetupBonsplitTabDragUITest = false private var bonsplitTabDragUITestRecorder: DispatchSourceTimer? + private var gotoSplitUITestArrowRecorder: DispatchSourceTimer? + private var gotoSplitUITestInputSetupGeneration = 0 + private var gotoSplitUITestContentEditableSetupGeneration = 0 private var gotoSplitUITestObservers: [NSObjectProtocol] = [] private var didSetupMultiWindowNotificationsUITest = false private var didSetupDisplayResolutionUITestDiagnostics = false private var displayResolutionUITestObservers: [NSObjectProtocol] = [] + private var uiTestStartupActivationWorkItems: [DispatchWorkItem] = [] + private var uiTestStartupActivationGeneration = 0 private struct UITestRenderDiagnosticsSnapshot { let panelId: UUID let drawCount: Int @@ -2365,6 +2398,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // In UI tests, `WindowGroup` occasionally fails to materialize a window quickly on the VM. // If there are no windows shortly after launch, force-create one so XCUITest can proceed. if isRunningUnderXCTest { + ensureUITestStartupForegroundIfNeeded() if let rawVariant = env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_VARIANT"] { UserDefaults.standard.set( BrowserImportHintSettings.variant(for: rawVariant).rawValue, @@ -2389,6 +2423,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.openNewMainWindow(nil) } self.moveUITestWindowToTargetDisplayIfNeeded() + self.ensureUITestStartupForegroundIfNeeded() NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) // On headless CI runners, activate() silently fails (no GUI session). // Force windows visible so the terminal surface starts rendering. @@ -2438,9 +2473,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent payload["pid"] = String(ProcessInfo.processInfo.processIdentifier) payload["bundleId"] = Bundle.main.bundleIdentifier ?? "" payload["isRunningUnderXCTest"] = isRunningUnderXCTest ? "1" : "0" + payload["appIsActive"] = NSApp.isActive ? "1" : "0" payload["windowsCount"] = String(windows.count) payload["windowIdentifiers"] = ids payload["windowVisibleFlags"] = vis + payload["keyWindowIdentifier"] = NSApp.keyWindow?.identifier?.rawValue ?? "" + payload["mainWindowIdentifier"] = NSApp.mainWindow?.identifier?.rawValue ?? "" payload["windowScreenDisplayIDs"] = screenIDs payload["uiTestTargetDisplayID"] = targetDisplayID if let rawDisplayID = UInt32(targetDisplayID) { @@ -2569,6 +2607,81 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } + private func ensureUITestStartupForegroundIfNeeded() { + let env = ProcessInfo.processInfo.environment + guard isRunningUnderXCTest(env) else { return } + + cancelUITestStartupActivation() + uiTestStartupActivationGeneration += 1 + let generation = uiTestStartupActivationGeneration + let delays: [TimeInterval] = [0, 0.05, 0.2, 0.5, 1.0, 2.0, 4.0, 8.0, 12.0] + + for (index, delay) in delays.enumerated() { + let isFinalAttempt = index == delays.count - 1 + let workItem = DispatchWorkItem { [weak self] in + self?.applyUITestStartupForegroundAttempt( + generation: generation, + attempt: index, + isFinalAttempt: isFinalAttempt + ) + } + uiTestStartupActivationWorkItems.append(workItem) + if delay == 0 { + DispatchQueue.main.async(execute: workItem) + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + } + } + + private func applyUITestStartupForegroundAttempt( + generation: Int, + attempt: Int, + isFinalAttempt: Bool + ) { + guard uiTestStartupActivationGeneration == generation else { return } + + if let window = preferredUITestStartupWindow() { + NSApp.unhide(nil) + if !window.isMainWindow { + window.makeMain() + } + if !window.isKeyWindow { + window.makeKey() + } + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + } else if NSApp.windows.isEmpty { + openNewMainWindow(nil) + } + + NSApp.activate(ignoringOtherApps: true) + _ = NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + writeUITestDiagnosticsIfNeeded(stage: "startupActivate.\(attempt)") + + if (NSApp.isActive && NSApp.keyWindow != nil) || isFinalAttempt { + cancelUITestStartupActivation() + } + } + + private func preferredUITestStartupWindow() -> NSWindow? { + if let mainWindow = NSApp.windows.first(where: { window in + guard let raw = window.identifier?.rawValue else { return false } + return raw == "cmux.main" || raw.hasPrefix("cmux.main.") + }) { + return mainWindow + } + if let visibleWindow = NSApp.windows.first(where: \.isVisible) { + return visibleWindow + } + return NSApp.windows.first + } + + private func cancelUITestStartupActivation() { + uiTestStartupActivationWorkItems.forEach { $0.cancel() } + uiTestStartupActivationWorkItems.removeAll() + } + private func moveUITestWindowToTargetDisplayIfNeeded(attempt: Int = 0) { let env = ProcessInfo.processInfo.environment guard let rawDisplayID = env["CMUX_UI_TEST_TARGET_DISPLAY_ID"], @@ -7349,7 +7462,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard gotoSplitUITestObservers.isEmpty else { return } gotoSplitUITestObservers.append(NotificationCenter.default.addObserver( - forName: .browserFocusAddressBar, + forName: .browserDidFocusAddressBar, object: nil, queue: .main ) { [weak self] notification in @@ -7471,14 +7584,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func setupFocusedInputForGotoSplitUITest(panel: BrowserPanel) { let script = """ (() => { + const readArrowState = () => { + const report = window.__cmuxArrowKeyReport || { + down: 0, + up: 0, + commandShiftDown: 0, + commandShiftUp: 0 + }; + const active = document.activeElement; + return { + arrowDown: Number(report.down || 0), + arrowUp: Number(report.up || 0), + commandShiftDown: Number(report.commandShiftDown || 0), + commandShiftUp: Number(report.commandShiftUp || 0), + selectionStart: active && typeof active.selectionStart === "number" ? active.selectionStart : null, + selectionEnd: active && typeof active.selectionEnd === "number" ? active.selectionEnd : null + }; + }; const snapshot = () => { const active = document.activeElement; + const arrowState = readArrowState(); return { focused: false, id: "", secondaryId: "", + textareaId: "", secondaryCenterX: -1, secondaryCenterY: -1, + textareaCenterX: -1, + textareaCenterY: -1, activeId: active && typeof active.id === "string" ? active.id : "", activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", trackerInstalled: window.__cmuxAddressBarFocusTrackerInstalled === true, @@ -7487,10 +7621,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent typeof window.__cmuxAddressBarFocusState.id === "string" ? window.__cmuxAddressBarFocusState.id : "", - readyState: String(document.readyState || "") + readyState: String(document.readyState || ""), + arrowDown: arrowState.arrowDown, + arrowUp: arrowState.arrowUp, + commandShiftDown: arrowState.commandShiftDown, + commandShiftUp: arrowState.commandShiftUp, + selectionStart: arrowState.selectionStart, + selectionEnd: arrowState.selectionEnd }; }; const seed = () => { + if (!document.body) { + return snapshot(); + } const ensureInput = (id, value) => { const existing = document.getElementById(id); const input = (existing && existing.tagName && existing.tagName.toLowerCase() === "input") @@ -7518,6 +7661,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent input.style.color = "black"; return input; }; + const ensureTextarea = (id, value) => { + const existing = document.getElementById(id); + const textarea = (existing && existing.tagName && existing.tagName.toLowerCase() === "textarea") + ? existing + : (() => { + const created = document.createElement("textarea"); + created.id = id; + created.value = value; + return created; + })(); + textarea.autocapitalize = "off"; + textarea.autocomplete = "off"; + textarea.spellcheck = false; + textarea.rows = 4; + textarea.wrap = "soft"; + textarea.style.display = "block"; + textarea.style.width = "100%"; + textarea.style.minHeight = "96px"; + textarea.style.margin = "0"; + textarea.style.padding = "8px 10px"; + textarea.style.border = "1px solid #5f6368"; + textarea.style.borderRadius = "6px"; + textarea.style.boxSizing = "border-box"; + textarea.style.fontSize = "14px"; + textarea.style.fontFamily = "system-ui, -apple-system, sans-serif"; + textarea.style.background = "white"; + textarea.style.color = "black"; + textarea.style.lineHeight = "1.4"; + textarea.style.resize = "none"; + return textarea; + }; let container = document.getElementById("cmux-ui-test-focus-container"); if (!container || !container.tagName || container.tagName.toLowerCase() !== "div") { @@ -7540,12 +7714,51 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent const input = ensureInput("cmux-ui-test-focus-input", "cmux-ui-focus-primary"); const secondaryInput = ensureInput("cmux-ui-test-focus-input-secondary", "cmux-ui-focus-secondary"); + const textarea = ensureTextarea( + "cmux-ui-test-focus-textarea", + "cmux-ui-focus-textarea line 1\\ncmux-ui-focus-textarea line 2\\ncmux-ui-focus-textarea line 3" + ); if (input.parentElement !== container) { container.appendChild(input); } if (secondaryInput.parentElement !== container) { container.appendChild(secondaryInput); } + if (textarea.parentElement !== container) { + container.appendChild(textarea); + } + + if (!window.__cmuxArrowKeyReport || typeof window.__cmuxArrowKeyReport !== "object") { + window.__cmuxArrowKeyReport = { + down: 0, + up: 0, + commandShiftDown: 0, + commandShiftUp: 0 + }; + } + if (typeof window.__cmuxArrowKeyReport.commandShiftDown !== "number") { + window.__cmuxArrowKeyReport.commandShiftDown = 0; + } + if (typeof window.__cmuxArrowKeyReport.commandShiftUp !== "number") { + window.__cmuxArrowKeyReport.commandShiftUp = 0; + } + const installArrowTracker = (element) => { + if (!element || element.__cmuxArrowKeyReportInstalled) return; + element.__cmuxArrowKeyReportInstalled = true; + element.addEventListener("keydown", (event) => { + if (event.key === "ArrowDown") window.__cmuxArrowKeyReport.down += 1; + if (event.key === "ArrowUp") window.__cmuxArrowKeyReport.up += 1; + if (event.key === "ArrowDown" && event.metaKey && event.shiftKey) { + window.__cmuxArrowKeyReport.commandShiftDown += 1; + } + if (event.key === "ArrowUp" && event.metaKey && event.shiftKey) { + window.__cmuxArrowKeyReport.commandShiftUp += 1; + } + }, true); + }; + installArrowTracker(input); + installArrowTracker(secondaryInput); + installArrowTracker(textarea); input.focus({ preventScroll: true }); if (typeof input.setSelectionRange === "function") { @@ -7569,8 +7782,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } const secondaryRect = secondaryInput.getBoundingClientRect(); + const textareaRect = textarea.getBoundingClientRect(); + const primaryRect = input.getBoundingClientRect(); const viewportWidth = Math.max(Number(window.innerWidth) || 0, 1); const viewportHeight = Math.max(Number(window.innerHeight) || 0, 1); + const primaryCenterX = Math.min( + 0.98, + Math.max(0.02, (primaryRect.left + (primaryRect.width / 2)) / viewportWidth) + ); + const primaryCenterY = Math.min( + 0.98, + Math.max(0.02, (primaryRect.top + (primaryRect.height / 2)) / viewportHeight) + ); const secondaryCenterX = Math.min( 0.98, Math.max(0.02, (secondaryRect.left + (secondaryRect.width / 2)) / viewportWidth) @@ -7579,13 +7802,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent 0.98, Math.max(0.02, (secondaryRect.top + (secondaryRect.height / 2)) / viewportHeight) ); + const textareaCenterX = Math.min( + 0.98, + Math.max(0.02, (textareaRect.left + (textareaRect.width / 2)) / viewportWidth) + ); + const textareaCenterY = Math.min( + 0.98, + Math.max(0.02, (textareaRect.top + (textareaRect.height / 2)) / viewportHeight) + ); const active = document.activeElement; + const arrowState = readArrowState(); return { focused: active === input, id: input.id || "", secondaryId: secondaryInput.id || "", + textareaId: textarea.id || "", + primaryCenterX, + primaryCenterY, secondaryCenterX, secondaryCenterY, + textareaCenterX, + textareaCenterY, activeId: active && typeof active.id === "string" ? active.id : "", activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", trackerInstalled: window.__cmuxAddressBarFocusTrackerInstalled === true, @@ -7594,129 +7831,542 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent typeof window.__cmuxAddressBarFocusState.id === "string" ? window.__cmuxAddressBarFocusState.id : "", - readyState: String(document.readyState || "") + readyState: String(document.readyState || ""), + arrowDown: arrowState.arrowDown, + arrowUp: arrowState.arrowUp, + commandShiftDown: arrowState.commandShiftDown, + commandShiftUp: arrowState.commandShiftUp, + selectionStart: arrowState.selectionStart, + selectionEnd: arrowState.selectionEnd }; }; - const ready = () => + const ready = window.__cmuxAddressBarFocusTrackerInstalled === true && - String(document.readyState || "") === "complete"; + String(document.readyState || "") === "complete" && + !!document.body; - if (ready()) { - try { - return seed(); - } catch (_) { - return snapshot(); - } + if (!ready) { + return snapshot(); } - return new Promise((resolve) => { - let finished = false; - let observer = null; - const cleanups = []; - const finish = (value) => { - if (finished) return; - finished = true; - if (observer) observer.disconnect(); - for (const cleanup of cleanups) { - try { cleanup(); } catch (_) {} - } - resolve(value); - }; - const maybeFinish = () => { - if (!ready()) return; - try { - finish(seed()); - } catch (_) { - finish(snapshot()); - } - }; - const addListener = (target, eventName, options) => { - if (!target || typeof target.addEventListener !== "function") return; - const handler = () => maybeFinish(); - target.addEventListener(eventName, handler, options); - cleanups.push(() => target.removeEventListener(eventName, handler, options)); - }; - try { - observer = new MutationObserver(() => maybeFinish()); - observer.observe(document.documentElement || document, { - childList: true, - subtree: true, - attributes: true, - characterData: true - }); - } catch (_) {} - addListener(document, "readystatechange", true); - addListener(window, "load", true); - const timeoutId = window.setTimeout(() => finish(snapshot()), 4000); - cleanups.push(() => window.clearTimeout(timeoutId)); - maybeFinish(); - }); + try { + return seed(); + } catch (_) { + return snapshot(); + } })(); """ - panel.webView.evaluateJavaScript(script) { [weak self] result, _ in - guard let self else { return } - let payload = result as? [String: Any] - let focused = (payload?["focused"] as? Bool) ?? false - let inputId = (payload?["id"] as? String) ?? "" - let secondaryInputId = (payload?["secondaryId"] as? String) ?? "" - let secondaryCenterX = (payload?["secondaryCenterX"] as? NSNumber)?.doubleValue ?? -1 - let secondaryCenterY = (payload?["secondaryCenterY"] as? NSNumber)?.doubleValue ?? -1 - let activeId = (payload?["activeId"] as? String) ?? "" - let trackerInstalled = (payload?["trackerInstalled"] as? Bool) ?? false - let trackedStateId = (payload?["trackedStateId"] as? String) ?? "" - let readyState = (payload?["readyState"] as? String) ?? "" - var secondaryClickOffsetX = -1.0 - var secondaryClickOffsetY = -1.0 - if let window = panel.webView.window { - let webFrame = panel.webView.convert(panel.webView.bounds, to: nil) - let contentHeight = Double(window.contentView?.bounds.height ?? 0) - if webFrame.width > 1, - webFrame.height > 1, - contentHeight > 1, + gotoSplitUITestInputSetupGeneration += 1 + let generation = gotoSplitUITestInputSetupGeneration + let deadline = Date().addingTimeInterval(8.0) + + func attempt() { + guard gotoSplitUITestInputSetupGeneration == generation else { return } + + panel.webView.evaluateJavaScript(script) { [weak self, weak panel] result, _ in + guard let self, + self.gotoSplitUITestInputSetupGeneration == generation, + let panel else { return } + + let payload = result as? [String: Any] + let focused = (payload?["focused"] as? Bool) ?? false + let inputId = (payload?["id"] as? String) ?? "" + let secondaryInputId = (payload?["secondaryId"] as? String) ?? "" + let textareaId = (payload?["textareaId"] as? String) ?? "" + let primaryCenterX = (payload?["primaryCenterX"] as? NSNumber)?.doubleValue ?? -1 + let primaryCenterY = (payload?["primaryCenterY"] as? NSNumber)?.doubleValue ?? -1 + let secondaryCenterX = (payload?["secondaryCenterX"] as? NSNumber)?.doubleValue ?? -1 + let secondaryCenterY = (payload?["secondaryCenterY"] as? NSNumber)?.doubleValue ?? -1 + let textareaCenterX = (payload?["textareaCenterX"] as? NSNumber)?.doubleValue ?? -1 + let textareaCenterY = (payload?["textareaCenterY"] as? NSNumber)?.doubleValue ?? -1 + let activeId = (payload?["activeId"] as? String) ?? "" + let trackerInstalled = (payload?["trackerInstalled"] as? Bool) ?? false + let trackedStateId = (payload?["trackedStateId"] as? String) ?? "" + let readyState = (payload?["readyState"] as? String) ?? "" + let arrowDown = (payload?["arrowDown"] as? NSNumber)?.intValue ?? 0 + let arrowUp = (payload?["arrowUp"] as? NSNumber)?.intValue ?? 0 + let commandShiftDown = (payload?["commandShiftDown"] as? NSNumber)?.intValue ?? 0 + let commandShiftUp = (payload?["commandShiftUp"] as? NSNumber)?.intValue ?? 0 + let selectionStart = (payload?["selectionStart"] as? NSNumber)?.intValue + let selectionEnd = (payload?["selectionEnd"] as? NSNumber)?.intValue + var primaryClickOffsetX = -1.0 + var primaryClickOffsetY = -1.0 + var secondaryClickOffsetX = -1.0 + var secondaryClickOffsetY = -1.0 + var textareaClickOffsetX = -1.0 + var textareaClickOffsetY = -1.0 + let windowAvailable = panel.webView.window != nil + + if let window = panel.webView.window { + let webFrame = panel.webView.convert(panel.webView.bounds, to: nil) + let contentHeight = Double(window.contentView?.bounds.height ?? 0) + if webFrame.width > 1, + webFrame.height > 1, + contentHeight > 1, + primaryCenterX > 0, + primaryCenterX < 1, + primaryCenterY > 0, + primaryCenterY < 1, + secondaryCenterX > 0, + secondaryCenterX < 1, + secondaryCenterY > 0, + secondaryCenterY < 1, + textareaCenterX > 0, + textareaCenterX < 1, + textareaCenterY > 0, + textareaCenterY < 1 { + let primaryXInContent = Double(webFrame.minX) + (primaryCenterX * Double(webFrame.width)) + let primaryYFromTopInWeb = primaryCenterY * Double(webFrame.height) + let primaryYInContent = Double(webFrame.maxY) - primaryYFromTopInWeb + let yFromTopInContent = contentHeight - primaryYInContent + let titlebarHeight = max(0, Double(window.frame.height) - contentHeight) + primaryClickOffsetX = primaryXInContent + primaryClickOffsetY = titlebarHeight + yFromTopInContent + + let secondaryXInContent = Double(webFrame.minX) + (secondaryCenterX * Double(webFrame.width)) + let secondaryYFromTopInWeb = secondaryCenterY * Double(webFrame.height) + let secondaryYInContent = Double(webFrame.maxY) - secondaryYFromTopInWeb + secondaryClickOffsetX = secondaryXInContent + secondaryClickOffsetY = titlebarHeight + (contentHeight - secondaryYInContent) + + let textareaXInContent = Double(webFrame.minX) + (textareaCenterX * Double(webFrame.width)) + let textareaYFromTopInWeb = textareaCenterY * Double(webFrame.height) + let textareaYInContent = Double(webFrame.maxY) - textareaYFromTopInWeb + textareaClickOffsetX = textareaXInContent + textareaClickOffsetY = titlebarHeight + (contentHeight - textareaYInContent) + } + } + + if focused, + !inputId.isEmpty, + !secondaryInputId.isEmpty, + !textareaId.isEmpty, + inputId == activeId, + trackerInstalled, + !trackedStateId.isEmpty, + primaryCenterX > 0, + primaryCenterX < 1, + primaryCenterY > 0, + primaryCenterY < 1, secondaryCenterX > 0, secondaryCenterX < 1, secondaryCenterY > 0, - secondaryCenterY < 1 { - let xInContent = Double(webFrame.minX) + (secondaryCenterX * Double(webFrame.width)) - let yFromTopInWeb = secondaryCenterY * Double(webFrame.height) - let yInContent = Double(webFrame.maxY) - yFromTopInWeb - let yFromTopInContent = contentHeight - yInContent - let titlebarHeight = max(0, Double(window.frame.height) - contentHeight) - secondaryClickOffsetX = xInContent - secondaryClickOffsetY = titlebarHeight + yFromTopInContent + secondaryCenterY < 1, + textareaCenterX > 0, + textareaCenterX < 1, + textareaCenterY > 0, + textareaCenterY < 1, + primaryClickOffsetX > 0, + primaryClickOffsetY > 0, + secondaryClickOffsetX > 0, + secondaryClickOffsetY > 0, + textareaClickOffsetX > 0, + textareaClickOffsetY > 0 { + self.writeGotoSplitTestData([ + "webInputFocusSeeded": "true", + "webInputFocusElementId": inputId, + "webInputFocusSecondaryElementId": secondaryInputId, + "webInputFocusTextareaElementId": textareaId, + "webInputFocusPrimaryCenterX": "\(primaryCenterX)", + "webInputFocusPrimaryCenterY": "\(primaryCenterY)", + "webInputFocusPrimaryClickOffsetX": "\(primaryClickOffsetX)", + "webInputFocusPrimaryClickOffsetY": "\(primaryClickOffsetY)", + "webInputFocusSecondaryCenterX": "\(secondaryCenterX)", + "webInputFocusSecondaryCenterY": "\(secondaryCenterY)", + "webInputFocusSecondaryClickOffsetX": "\(secondaryClickOffsetX)", + "webInputFocusSecondaryClickOffsetY": "\(secondaryClickOffsetY)", + "webInputFocusTextareaCenterX": "\(textareaCenterX)", + "webInputFocusTextareaCenterY": "\(textareaCenterY)", + "webInputFocusTextareaClickOffsetX": "\(textareaClickOffsetX)", + "webInputFocusTextareaClickOffsetY": "\(textareaClickOffsetY)", + "webInputFocusActiveElementId": activeId, + "webInputFocusTrackerInstalled": trackerInstalled ? "true" : "false", + "webInputFocusTrackedStateId": trackedStateId, + "webInputFocusReadyState": readyState, + "webInputFocusArrowDownCount": "\(arrowDown)", + "webInputFocusArrowUpCount": "\(arrowUp)", + "webInputFocusCommandShiftDownCount": "\(commandShiftDown)", + "webInputFocusCommandShiftUpCount": "\(commandShiftUp)", + "webInputFocusSelectionStart": selectionStart.map(String.init) ?? "", + "webInputFocusSelectionEnd": selectionEnd.map(String.init) ?? "" + ]) + if ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_ARROW_SETUP"] == "1" { + self.startGotoSplitUITestArrowRecorder(panelId: panel.id) + } + if ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_CONTENTEDITABLE_SETUP"] == "1" { + self.setupContentEditableForGotoSplitUITest(panel: panel) + } + return } + + if Date() < deadline { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in + guard let self, + self.gotoSplitUITestInputSetupGeneration == generation else { return } + attempt() + } + return + } + + self.writeGotoSplitTestData([ + "webInputFocusSeeded": "false", + "webInputFocusElementId": inputId, + "webInputFocusSecondaryElementId": secondaryInputId, + "webInputFocusTextareaElementId": textareaId, + "webInputFocusPrimaryCenterX": "\(primaryCenterX)", + "webInputFocusPrimaryCenterY": "\(primaryCenterY)", + "webInputFocusPrimaryClickOffsetX": "\(primaryClickOffsetX)", + "webInputFocusPrimaryClickOffsetY": "\(primaryClickOffsetY)", + "webInputFocusSecondaryCenterX": "\(secondaryCenterX)", + "webInputFocusSecondaryCenterY": "\(secondaryCenterY)", + "webInputFocusSecondaryClickOffsetX": "\(secondaryClickOffsetX)", + "webInputFocusSecondaryClickOffsetY": "\(secondaryClickOffsetY)", + "webInputFocusTextareaCenterX": "\(textareaCenterX)", + "webInputFocusTextareaCenterY": "\(textareaCenterY)", + "webInputFocusTextareaClickOffsetX": "\(textareaClickOffsetX)", + "webInputFocusTextareaClickOffsetY": "\(textareaClickOffsetY)", + "webInputFocusActiveElementId": activeId, + "webInputFocusTrackerInstalled": trackerInstalled ? "true" : "false", + "webInputFocusTrackedStateId": trackedStateId, + "webInputFocusReadyState": readyState, + "webInputFocusArrowDownCount": "\(arrowDown)", + "webInputFocusArrowUpCount": "\(arrowUp)", + "webInputFocusCommandShiftDownCount": "\(commandShiftDown)", + "webInputFocusCommandShiftUpCount": "\(commandShiftUp)", + "webInputFocusSelectionStart": selectionStart.map(String.init) ?? "", + "webInputFocusSelectionEnd": selectionEnd.map(String.init) ?? "", + "setupError": + "Timed out focusing page input for omnibar restore test " + + "focused=\(focused) inputId=\(inputId) secondaryInputId=\(secondaryInputId) textareaId=\(textareaId) " + + "activeId=\(activeId) trackerInstalled=\(trackerInstalled) trackedStateId=\(trackedStateId) " + + "readyState=\(readyState) windowAvailable=\(windowAvailable) " + + "primaryCenterX=\(primaryCenterX) primaryCenterY=\(primaryCenterY) " + + "primaryClickOffsetX=\(primaryClickOffsetX) primaryClickOffsetY=\(primaryClickOffsetY) " + + "secondaryCenterX=\(secondaryCenterX) secondaryCenterY=\(secondaryCenterY) " + + "secondaryClickOffsetX=\(secondaryClickOffsetX) secondaryClickOffsetY=\(secondaryClickOffsetY) " + + "textareaCenterX=\(textareaCenterX) textareaCenterY=\(textareaCenterY) " + + "textareaClickOffsetX=\(textareaClickOffsetX) textareaClickOffsetY=\(textareaClickOffsetY)" + ]) + } + } + + attempt() + } + + private func setupContentEditableForGotoSplitUITest(panel: BrowserPanel) { + guard ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_CONTENTEDITABLE_SETUP"] == "1" else { + return + } + + gotoSplitUITestContentEditableSetupGeneration += 1 + let generation = gotoSplitUITestContentEditableSetupGeneration + let deadline = Date().addingTimeInterval(8.0) + let script = """ + (() => { + const snapshot = () => { + const active = document.activeElement; + return { + seeded: false, + editorId: "", + activeId: active && typeof active.id === "string" ? active.id : "", + readyState: String(document.readyState || ""), + secondaryCenterX: -1, + secondaryCenterY: -1, + editorCenterX: -1, + editorCenterY: -1 + }; + }; + const seed = () => { + const parent = document.getElementById("cmux-ui-test-focus-container"); + const secondary = document.getElementById("cmux-ui-test-focus-input-secondary"); + if (!document.body || !parent || !secondary) { + return snapshot(); + } + + let editor = document.getElementById("cmux-ui-test-contenteditable"); + if (!editor || !editor.tagName || editor.tagName.toLowerCase() !== "div") { + editor = document.createElement("div"); + editor.id = "cmux-ui-test-contenteditable"; + } + + editor.setAttribute("contenteditable", "true"); + editor.setAttribute("role", "textbox"); + editor.setAttribute("aria-label", "cmux-ui-test-contenteditable"); + editor.spellcheck = false; + editor.tabIndex = 0; + editor.innerHTML = "alpha
beta
gamma"; + editor.style.display = "block"; + editor.style.minHeight = "84px"; + editor.style.padding = "8px 10px"; + editor.style.border = "1px solid #5f6368"; + editor.style.borderRadius = "6px"; + editor.style.boxSizing = "border-box"; + editor.style.fontSize = "14px"; + editor.style.fontFamily = "system-ui, -apple-system, sans-serif"; + editor.style.background = "white"; + editor.style.color = "black"; + editor.style.whiteSpace = "pre-wrap"; + editor.style.outline = "none"; + + if (editor.parentElement !== parent) { + parent.appendChild(editor); + } + + if (!window.__cmuxContentEditableArrowReport || typeof window.__cmuxContentEditableArrowReport !== "object") { + window.__cmuxContentEditableArrowReport = { + down: 0, + up: 0, + commandShiftDown: 0, + commandShiftUp: 0 + }; + } + + if (!editor.__cmuxContentEditableArrowReportInstalled) { + editor.__cmuxContentEditableArrowReportInstalled = true; + editor.addEventListener("keydown", (event) => { + if (event.key === "ArrowDown") window.__cmuxContentEditableArrowReport.down += 1; + if (event.key === "ArrowUp") window.__cmuxContentEditableArrowReport.up += 1; + if (event.key === "ArrowDown" && event.metaKey && event.shiftKey) { + window.__cmuxContentEditableArrowReport.commandShiftDown += 1; + } + if (event.key === "ArrowUp" && event.metaKey && event.shiftKey) { + window.__cmuxContentEditableArrowReport.commandShiftUp += 1; + } + }, true); + } + + editor.focus({ preventScroll: true }); + const selection = window.getSelection(); + if (selection) { + const range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); } - if focused, - !inputId.isEmpty, - !secondaryInputId.isEmpty, - inputId == activeId, - trackerInstalled, - !trackedStateId.isEmpty, - secondaryCenterX > 0, - secondaryCenterX < 1, - secondaryCenterY > 0, - secondaryCenterY < 1, - secondaryClickOffsetX > 0, - secondaryClickOffsetY > 0 { + + const secondaryRect = secondary.getBoundingClientRect(); + const editorRect = editor.getBoundingClientRect(); + const active = document.activeElement; + return { + seeded: active === editor, + editorId: editor.id || "", + activeId: active && typeof active.id === "string" ? active.id : "", + readyState: String(document.readyState || ""), + secondaryCenterX: secondaryRect.left + (secondaryRect.width / 2), + secondaryCenterY: secondaryRect.top + (secondaryRect.height / 2), + editorCenterX: editorRect.left + (editorRect.width / 2), + editorCenterY: editorRect.top + (editorRect.height / 2) + }; + }; + const ready = + String(document.readyState || "") === "complete" && + !!document.body && + !!document.getElementById("cmux-ui-test-focus-container") && + !!document.getElementById("cmux-ui-test-focus-input-secondary"); + + if (!ready) { + return snapshot(); + } + + try { + return seed(); + } catch (_) { + return snapshot(); + } + })(); + """ + + func attempt() { + guard gotoSplitUITestContentEditableSetupGeneration == generation else { return } + + panel.webView.evaluateJavaScript(script) { [weak self, weak panel] result, _ in + guard let self, + self.gotoSplitUITestContentEditableSetupGeneration == generation, + let panel else { return } + + let payload = result as? [String: Any] + let seeded = (payload?["seeded"] as? Bool) ?? false + let editorId = (payload?["editorId"] as? String) ?? "" + let activeId = (payload?["activeId"] as? String) ?? "" + let readyState = (payload?["readyState"] as? String) ?? "" + let secondaryCenterX = (payload?["secondaryCenterX"] as? NSNumber)?.doubleValue ?? -1 + let secondaryCenterY = (payload?["secondaryCenterY"] as? NSNumber)?.doubleValue ?? -1 + let editorCenterX = (payload?["editorCenterX"] as? NSNumber)?.doubleValue ?? -1 + let editorCenterY = (payload?["editorCenterY"] as? NSNumber)?.doubleValue ?? -1 + var editorClickOffsetX = -1.0 + var editorClickOffsetY = -1.0 + + if let window = panel.webView.window { + let webFrame = panel.webView.convert(panel.webView.bounds, to: nil) + let contentHeight = Double(window.contentView?.bounds.height ?? 0) + if webFrame.width > 1, + webFrame.height > 1, + contentHeight > 1, + secondaryCenterX >= 0, + secondaryCenterY >= 0, + editorCenterX >= 0, + editorCenterY >= 0 { + let editorXInContent = Double(webFrame.minX) + editorCenterX + let editorYInContent = Double(webFrame.maxY) - editorCenterY + let titlebarHeight = max(0, Double(window.frame.height) - contentHeight) + editorClickOffsetX = editorXInContent + editorClickOffsetY = titlebarHeight + (contentHeight - editorYInContent) + } + } + + if seeded, + !editorId.isEmpty, + activeId == editorId, + editorClickOffsetX > 0, + editorClickOffsetY > 0 { + self.writeGotoSplitTestData([ + "webContentEditableSeeded": "true", + "webContentEditableElementId": editorId, + "webContentEditableActiveElementId": activeId, + "webContentEditableReadyState": readyState, + "webContentEditableClickOffsetX": "\(editorClickOffsetX)", + "webContentEditableClickOffsetY": "\(editorClickOffsetY)" + ]) + return + } + + if Date() < deadline { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in + guard let self, + self.gotoSplitUITestContentEditableSetupGeneration == generation else { return } + attempt() + } + return + } + self.writeGotoSplitTestData([ - "webInputFocusSeeded": "true", - "webInputFocusElementId": inputId, - "webInputFocusSecondaryElementId": secondaryInputId, - "webInputFocusSecondaryCenterX": "\(secondaryCenterX)", - "webInputFocusSecondaryCenterY": "\(secondaryCenterY)", - "webInputFocusSecondaryClickOffsetX": "\(secondaryClickOffsetX)", - "webInputFocusSecondaryClickOffsetY": "\(secondaryClickOffsetY)", - "webInputFocusActiveElementId": activeId, - "webInputFocusTrackerInstalled": trackerInstalled ? "true" : "false", - "webInputFocusTrackedStateId": trackedStateId, - "webInputFocusReadyState": readyState + "webContentEditableSeeded": "false", + "webContentEditableElementId": editorId, + "webContentEditableActiveElementId": activeId, + "webContentEditableReadyState": readyState, + "webContentEditableClickOffsetX": "\(editorClickOffsetX)", + "webContentEditableClickOffsetY": "\(editorClickOffsetY)", + "setupError": + "Timed out focusing contenteditable for omnibar restore test " + + "seeded=\(seeded) editorId=\(editorId) activeId=\(activeId) " + + "readyState=\(readyState) secondaryCenterX=\(secondaryCenterX) " + + "secondaryCenterY=\(secondaryCenterY) editorCenterX=\(editorCenterX) " + + "editorCenterY=\(editorCenterY) editorClickOffsetX=\(editorClickOffsetX) " + + "editorClickOffsetY=\(editorClickOffsetY)" ]) + } + } + + attempt() + } + + private func startGotoSplitUITestArrowRecorder(panelId: UUID) { + guard ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_ARROW_SETUP"] == "1" else { return } + + gotoSplitUITestArrowRecorder?.cancel() + gotoSplitUITestArrowRecorder = nil + + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now(), repeating: .milliseconds(100)) + timer.setEventHandler { [weak self] in + self?.recordGotoSplitUITestArrowState(panelId: panelId) + } + gotoSplitUITestArrowRecorder = timer + timer.resume() + } + + private func recordGotoSplitUITestArrowState(panelId: UUID) { + guard ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_ARROW_SETUP"] == "1" else { return } + guard let tabManager, + let workspace = tabManager.tabs.first(where: { $0.browserPanel(for: panelId) != nil }), + let panel = workspace.browserPanel(for: panelId) else { + return + } + + let script = """ + (() => { + const report = window.__cmuxArrowKeyReport || { + down: 0, + up: 0, + commandShiftDown: 0, + commandShiftUp: 0 + }; + const contentEditableReport = window.__cmuxContentEditableArrowReport || { + down: 0, + up: 0, + commandShiftDown: 0, + commandShiftUp: 0 + }; + const active = document.activeElement; + return { + installed: !!window.__cmuxArrowKeyReport, + down: Number(report.down || 0), + up: Number(report.up || 0), + commandShiftDown: Number(report.commandShiftDown || 0), + commandShiftUp: Number(report.commandShiftUp || 0), + contentEditableInstalled: !!window.__cmuxContentEditableArrowReport, + contentEditableDown: Number(contentEditableReport.down || 0), + contentEditableUp: Number(contentEditableReport.up || 0), + contentEditableCommandShiftDown: Number(contentEditableReport.commandShiftDown || 0), + contentEditableCommandShiftUp: Number(contentEditableReport.commandShiftUp || 0), + activeId: active && typeof active.id === "string" ? active.id : "", + selectionStart: active && typeof active.selectionStart === "number" ? active.selectionStart : null, + selectionEnd: active && typeof active.selectionEnd === "number" ? active.selectionEnd : null, + readyState: String(document.readyState || "") + }; + })(); + """ + + panel.webView.evaluateJavaScript(script) { [weak self] result, _ in + guard let self, + let payload = result as? [String: Any] else { return } + + let firstResponder = panel.webView.window?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "" + let firstResponderIsFieldEditor = ((firstResponder as? NSTextView)?.isFieldEditor == true) + let firstResponderIsCmuxWebView = (firstResponder as? CmuxWebView) === panel.webView + let focusedAddressBarPanelId = self.focusedBrowserAddressBarPanelId()?.uuidString ?? "" + let down = (payload["down"] as? NSNumber)?.intValue ?? 0 + let up = (payload["up"] as? NSNumber)?.intValue ?? 0 + let commandShiftDown = (payload["commandShiftDown"] as? NSNumber)?.intValue ?? 0 + let commandShiftUp = (payload["commandShiftUp"] as? NSNumber)?.intValue ?? 0 + let contentEditableInstalled = (payload["contentEditableInstalled"] as? Bool) ?? false + let contentEditableDown = (payload["contentEditableDown"] as? NSNumber)?.intValue ?? 0 + let contentEditableUp = (payload["contentEditableUp"] as? NSNumber)?.intValue ?? 0 + let contentEditableCommandShiftDown = (payload["contentEditableCommandShiftDown"] as? NSNumber)?.intValue ?? 0 + let contentEditableCommandShiftUp = (payload["contentEditableCommandShiftUp"] as? NSNumber)?.intValue ?? 0 + let activeId = (payload["activeId"] as? String) ?? "" + let selectionStart = (payload["selectionStart"] as? NSNumber)?.intValue + let selectionEnd = (payload["selectionEnd"] as? NSNumber)?.intValue + let readyState = (payload["readyState"] as? String) ?? "" + let installed = (payload["installed"] as? Bool) ?? false + self.writeGotoSplitTestData([ - "webInputFocusSeeded": "false", - "setupError": "Timed out focusing page input for omnibar restore test" + "browserArrowPanelId": panelId.uuidString, + "browserArrowInstalled": installed ? "true" : "false", + "browserArrowDownCount": "\(down)", + "browserArrowUpCount": "\(up)", + "browserArrowCommandShiftDownCount": "\(commandShiftDown)", + "browserArrowCommandShiftUpCount": "\(commandShiftUp)", + "browserArrowActiveElementId": activeId, + "browserArrowSelectionStart": selectionStart.map(String.init) ?? "", + "browserArrowSelectionEnd": selectionEnd.map(String.init) ?? "", + "browserArrowReadyState": readyState, + "browserContentEditableInstalled": contentEditableInstalled ? "true" : "false", + "browserContentEditableDownCount": "\(contentEditableDown)", + "browserContentEditableUpCount": "\(contentEditableUp)", + "browserContentEditableCommandShiftDownCount": "\(contentEditableCommandShiftDown)", + "browserContentEditableCommandShiftUpCount": "\(contentEditableCommandShiftUp)", + "browserContentEditableActiveElementId": activeId, + "browserContentEditableReadyState": readyState, + "browserArrowFirstResponderType": firstResponderType, + "browserArrowFirstResponderIsFieldEditor": firstResponderIsFieldEditor ? "true" : "false", + "browserArrowFirstResponderIsCmuxWebView": firstResponderIsCmuxWebView ? "true" : "false", + "browserArrowFocusedAddressBarPanelId": focusedAddressBarPanelId ]) } } @@ -10951,36 +11601,96 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func installBrowserAddressBarFocusObservers() { - guard browserAddressBarFocusObserver == nil, browserAddressBarBlurObserver == nil else { return } + guard browserAddressBarFocusObserver == nil, + browserAddressBarBlurObserver == nil, + browserWebViewClickObserver == nil, + browserWebViewFocusObserver == nil else { return } browserAddressBarFocusObserver = NotificationCenter.default.addObserver( forName: .browserDidFocusAddressBar, object: nil, - queue: .main + queue: nil ) { [weak self] notification in - guard let self else { return } - guard let panelId = notification.object as? UUID else { return } - self.browserPanel(for: panelId)?.beginSuppressWebViewFocusForAddressBar() - self.browserAddressBarFocusedPanelId = panelId - self.stopBrowserOmnibarSelectionRepeat() + MainActor.assumeIsolated { + guard let self else { return } + guard let panelId = notification.object as? UUID else { return } + if let panel = self.browserPanel(for: panelId) { + panel.beginSuppressWebViewFocusForAddressBar() + let isPanelFocused = self.tabManager?.selectedWorkspace?.focusedPanelId == panelId + panel.syncWebViewFirstResponderPolicy( + isPanelFocused: isPanelFocused, + reason: "addressBarFocusObserver" + ) + } + self.browserAddressBarFocusedPanelId = panelId + self.stopBrowserOmnibarSelectionRepeat() #if DEBUG - dlog("addressBar FOCUS panelId=\(panelId.uuidString.prefix(8))") + dlog("addressBar FOCUS panelId=\(panelId.uuidString.prefix(8))") #endif + } } browserAddressBarBlurObserver = NotificationCenter.default.addObserver( forName: .browserDidBlurAddressBar, object: nil, - queue: .main + queue: nil ) { [weak self] notification in - guard let self else { return } - guard let panelId = notification.object as? UUID else { return } - self.browserPanel(for: panelId)?.endSuppressWebViewFocusForAddressBar() - if self.browserAddressBarFocusedPanelId == panelId { - self.browserAddressBarFocusedPanelId = nil - self.stopBrowserOmnibarSelectionRepeat() + MainActor.assumeIsolated { + guard let self else { return } + guard let panelId = notification.object as? UUID else { return } + if let panel = self.browserPanel(for: panelId) { + panel.endSuppressWebViewFocusForAddressBar() + let isPanelFocused = self.tabManager?.selectedWorkspace?.focusedPanelId == panelId + panel.syncWebViewFirstResponderPolicy( + isPanelFocused: isPanelFocused, + reason: "addressBarBlurObserver" + ) + } + if self.browserAddressBarFocusedPanelId == panelId { + self.browserAddressBarFocusedPanelId = nil + self.stopBrowserOmnibarSelectionRepeat() +#if DEBUG + dlog("addressBar BLUR panelId=\(panelId.uuidString.prefix(8))") +#endif + } + } + } + + browserWebViewClickObserver = NotificationCenter.default.addObserver( + forName: .webViewDidReceiveClick, + object: nil, + queue: nil + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + guard let panelId = self.browserAddressBarFocusedPanelId else { return } + + NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: panelId) +#if DEBUG + dlog( + "addressBar STALE_CLEAR panelId=\(panelId.uuidString.prefix(8)) " + + "reason=webViewClick" + ) +#endif + } + } + + browserWebViewFocusObserver = NotificationCenter.default.addObserver( + forName: .browserDidBecomeFirstResponderWebView, + object: nil, + queue: nil + ) { [weak self] notification in + MainActor.assumeIsolated { + guard let self else { return } + guard let panelId = self.browserAddressBarFocusedPanelId else { return } + guard let webView = notification.object as? WKWebView else { return } + + NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: panelId) #if DEBUG - dlog("addressBar BLUR panelId=\(panelId.uuidString.prefix(8))") + dlog( + "addressBar STALE_CLEAR panelId=\(panelId.uuidString.prefix(8)) " + + "reason=webViewFirstResponder web=\(ObjectIdentifier(webView))" + ) #endif } } @@ -12075,7 +12785,7 @@ private var cmuxFirstResponderGuardHitViewOverride: NSView? private var cmuxFirstResponderGuardCurrentEventContext: NSEvent? private var cmuxFirstResponderGuardHitViewContext: NSView? private var cmuxFirstResponderGuardContextWindowNumber: Int? -private var cmuxBrowserReturnForwardingDepth = 0 +private var cmuxBrowserKeyDownForwardingDepth = 0 private var cmuxWindowFirstResponderBypassDepth = 0 private var cmuxFieldEditorOwningWebViewAssociationKey: UInt8 = 0 @@ -12100,6 +12810,11 @@ private final class CmuxFieldEditorOwningWebViewBox: NSObject { } } +private struct CmuxBrowserFirstResponderKeyRoute { + let webView: CmuxWebView + let keyDownTarget: NSResponder +} + private extension NSApplication { @objc func cmux_applicationSendEvent(_ event: NSEvent) { #if DEBUG @@ -12139,6 +12854,103 @@ private extension AppDelegate { } private extension NSWindow { + static func cmuxBrowserFirstResponderKeyRoute( + for firstResponder: NSResponder?, + in window: NSWindow, + event: NSEvent? + ) -> CmuxBrowserFirstResponderKeyRoute? { + guard let firstResponder else { return nil } + guard let webView = cmuxOwningWebView(for: firstResponder, in: window, event: event) else { + return nil + } + + let keyDownTarget: NSResponder + if let textView = firstResponder as? NSTextView, textView.isFieldEditor { + keyDownTarget = textView + } else { + keyDownTarget = webView + } + + return CmuxBrowserFirstResponderKeyRoute( + webView: webView, + keyDownTarget: keyDownTarget + ) + } + + func cmuxDirectlyHandleBrowserFirstResponderKeyDown( + _ event: NSEvent, + invokedFromPerformKeyEquivalent: Bool = false + ) -> Bool { + guard event.type == .keyDown else { return false } + guard let browserRoute = Self.cmuxBrowserFirstResponderKeyRoute( + for: self.firstResponder, + in: self, + event: event + ) else { + return false + } + guard shouldDirectRouteBrowserFirstResponderKeyDown( + firstResponder: self.firstResponder, + firstResponderIsBrowser: true, + focusedBrowserAddressBarPanelId: AppDelegate.shared?.focusedBrowserAddressBarPanelId() + ) else { + return false + } + + switch browserDirectKeyRoutingStrategy(for: event) { + case .deferToOriginalPerformKeyEquivalent: + return false + + case .menuOrAppShortcutThenKeyDown: + if let mainMenu = NSApp.mainMenu, + mainMenu.performKeyEquivalent(with: event) { +#if DEBUG + dlog("window.browserKeyDirect route=mainMenu") +#endif + return true + } + + if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { +#if DEBUG + dlog("window.browserKeyDirect route=appShortcut") +#endif + return true + } + + case .keyDown: + break + } + + if invokedFromPerformKeyEquivalent { + if cmuxBrowserKeyDownForwardingDepth > 0 { +#if DEBUG + dlog("window.browserKeyDirect route=keyDownReentry") +#endif + return false + } + do { + cmuxBrowserKeyDownForwardingDepth += 1 + defer { + cmuxBrowserKeyDownForwardingDepth = max(0, cmuxBrowserKeyDownForwardingDepth - 1) + } + +#if DEBUG + let targetType = String(describing: type(of: browserRoute.keyDownTarget)) + dlog("window.browserKeyDirect route=keyDown target=\(targetType)") +#endif + browserRoute.keyDownTarget.keyDown(with: event) + return true + } + } + +#if DEBUG + let targetType = String(describing: type(of: browserRoute.keyDownTarget)) + dlog("window.browserKeyDirect route=keyDown target=\(targetType)") +#endif + browserRoute.keyDownTarget.keyDown(with: event) + return true + } + @objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool { if cmuxIsWindowFirstResponderBypassActive() { #if DEBUG @@ -12231,6 +13043,13 @@ private extension NSWindow { } else if let fieldEditor = self.firstResponder as? NSTextView, fieldEditor.isFieldEditor { Self.cmuxTrackFieldEditor(fieldEditor, owningWebView: responderWebView) } + if let responderWebView, + !(responder is CmuxWebView) { + NotificationCenter.default.post( + name: .browserDidBecomeFirstResponderWebView, + object: responderWebView + ) + } } return result } @@ -12306,6 +13125,10 @@ private extension NSWindow { cmuxFirstResponderGuardContextWindowNumber = previousContextWindowNumber } + if cmuxDirectlyHandleBrowserFirstResponderKeyDown(event) { + return + } + guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event), let contentView = self.contentView else { #if DEBUG @@ -12385,9 +13208,6 @@ private extension NSWindow { // (handleCustomShortcut) already handles app-level shortcuts, and anything // remaining should be menu items. let firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder) - let firstResponderWebView = self.firstResponder.flatMap { - Self.cmuxOwningWebView(for: $0, in: self, event: event) - } if let ghosttyView = firstResponderGhosttyView { // If the IME is composing and the key has no Cmd modifier, don't intercept — // let it flow through normal AppKit event dispatch so the input method can @@ -12424,36 +13244,10 @@ private extension NSWindow { } } - // Web forms rely on Return/Enter flowing through keyDown. If the original - // NSWindow.performKeyEquivalent consumes Enter first, submission never reaches - // WebKit. Route Return/Enter directly to the current first responder and - // mark handled to avoid the AppKit alert sound path. - if shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: event.keyCode, - firstResponderIsBrowser: firstResponderWebView != nil, - flags: event.modifierFlags + if cmuxDirectlyHandleBrowserFirstResponderKeyDown( + event, + invokedFromPerformKeyEquivalent: true ) { - // Forwarding keyDown can re-enter performKeyEquivalent in WebKit/AppKit internals. - // On re-entry, fall back to normal dispatch to avoid an infinite loop. - if cmuxBrowserReturnForwardingDepth > 0 { -#if DEBUG - dlog(" → browser Return/Enter reentry; using normal dispatch") -#endif - return false - } - cmuxBrowserReturnForwardingDepth += 1 - defer { cmuxBrowserReturnForwardingDepth = max(0, cmuxBrowserReturnForwardingDepth - 1) } -#if DEBUG - dlog(" → browser Return/Enter routed to firstResponder.keyDown") -#endif - self.firstResponder?.keyDown(with: event) - return true - } - - if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { -#if DEBUG - dlog(" → consumed by handleBrowserSurfaceKeyEquivalent") -#endif return true } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index f0bb3778ac..e67a6565f6 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -4908,6 +4908,28 @@ extension BrowserPanel { #endif } + func syncWebViewFirstResponderPolicy(isPanelFocused: Bool, reason: String) { + guard let cmuxWebView = webView as? CmuxWebView else { return } + let next = isPanelFocused && !shouldSuppressWebViewFocus() + if cmuxWebView.allowsFirstResponderAcquisition != next { +#if DEBUG + dlog( + "browser.focus.policy.resync panel=\(id.uuidString.prefix(5)) " + + "web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " + + "new=\(next ? 1 : 0) reason=\(reason) " + + "panelFocusedUsed=\(isPanelFocused ? 1 : 0)" + ) +#endif + } + cmuxWebView.allowsFirstResponderAcquisition = next + } + + func prepareForExplicitWebViewFocus(isPanelFocused: Bool, reason: String) { + endSuppressWebViewFocusForAddressBar() + clearWebViewFocusSuppression() + syncWebViewFirstResponderPolicy(isPanelFocused: isPanelFocused, reason: reason) + } + func shouldSuppressOmnibarAutofocus() -> Bool { if let until = suppressOmnibarAutofocusUntil { return Date() < until diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 2e401164b5..ef64193ef7 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -510,17 +510,54 @@ struct BrowserPanelView: View { guard let webView = note.object as? CmuxWebView else { return false } return webView === panel?.webView }) { _ in + let shouldDismissOmnibar = browserWebViewClickShouldDismissOmnibar( + localAddressBarFocused: addressBarFocused, + focusedAddressBarPanelId: AppDelegate.shared?.focusedBrowserAddressBarPanelId(), + pendingAddressBarFocusRequestId: panel.pendingAddressBarFocusRequestId, + panelId: panel.id + ) + let shouldPromoteWebViewFocus = browserWebViewClickShouldPromoteWebViewFocus( + isPanelFocused: isFocused, + shouldDismissOmnibar: shouldDismissOmnibar + ) #if DEBUG dlog( "browser.focus.clickIntent panel=\(panel.id.uuidString.prefix(5)) " + "isFocused=\(isFocused ? 1 : 0) " + - "addressFocused=\(addressBarFocused ? 1 : 0)" + "addressFocused=\(addressBarFocused ? 1 : 0) " + + "dismissOmnibar=\(shouldDismissOmnibar ? 1 : 0) " + + "promoteWeb=\(shouldPromoteWebViewFocus ? 1 : 0)" ) #endif - if addressBarFocused { + if shouldDismissOmnibar { #if DEBUG logBrowserFocusState(event: "addressBarFocus.webViewClickBlur") #endif + NotificationCenter.default.post( + name: .browserWillBlurAddressBarForWebViewClick, + object: panel.id + ) + } + if shouldPromoteWebViewFocus { + panel.prepareForExplicitWebViewFocus( + isPanelFocused: true, + reason: "webView.clickIntent" + ) + if let window = panel.webView.window, + !panel.webView.isHiddenOrHasHiddenAncestor { + let focusedWebView = window.makeFirstResponder(panel.webView) + if focusedWebView { + panel.noteWebViewFocused() + } +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.webViewClickFocus", + detail: "focusedWebView=\(focusedWebView ? 1 : 0)" + ) +#endif + } + } + if shouldDismissOmnibar { setAddressBarFocused(false, reason: "webView.clickIntent") } if !isFocused { @@ -1033,6 +1070,7 @@ struct BrowserPanelView: View { } OmnibarTextFieldRepresentable( + panelId: panel.id, text: Binding( get: { omnibarState.buffer }, set: { newValue in @@ -1239,20 +1277,8 @@ struct BrowserPanelView: View { reason: String, isPanelFocusedOverride: Bool? = nil ) { - guard let cmuxWebView = panel.webView as? CmuxWebView else { return } let isPanelFocused = isPanelFocusedOverride ?? isFocused - let next = isPanelFocused && !panel.shouldSuppressWebViewFocus() - if cmuxWebView.allowsFirstResponderAcquisition != next { -#if DEBUG - dlog( - "browser.focus.policy.resync panel=\(panel.id.uuidString.prefix(5)) " + - "web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " + - "new=\(next ? 1 : 0) reason=\(reason) " + - "panelFocusedUsed=\(isPanelFocused ? 1 : 0)" - ) -#endif - } - cmuxWebView.allowsFirstResponderAcquisition = next + panel.syncWebViewFirstResponderPolicy(isPanelFocused: isPanelFocused, reason: reason) } private func setAddressBarFocused(_ focused: Bool, reason: String) { @@ -3181,9 +3207,28 @@ struct OmnibarSuggestion: Identifiable, Hashable { func browserOmnibarShouldReacquireFocusAfterEndEditing( desiredOmnibarFocus: Bool, - nextResponderIsOtherTextField: Bool + nextResponderIsOtherTextField: Bool, + explicitPointerBlurIntent: Bool = false ) -> Bool { - desiredOmnibarFocus && !nextResponderIsOtherTextField + desiredOmnibarFocus && !nextResponderIsOtherTextField && !explicitPointerBlurIntent +} + +func browserWebViewClickShouldDismissOmnibar( + localAddressBarFocused: Bool, + focusedAddressBarPanelId: UUID?, + pendingAddressBarFocusRequestId: UUID?, + panelId: UUID +) -> Bool { + localAddressBarFocused || + focusedAddressBarPanelId == panelId || + pendingAddressBarFocusRequestId != nil +} + +func browserWebViewClickShouldPromoteWebViewFocus( + isPanelFocused: Bool, + shouldDismissOmnibar: Bool +) -> Bool { + isPanelFocused || shouldDismissOmnibar } private final class OmnibarNativeTextField: NSTextField { @@ -3349,6 +3394,7 @@ private final class OmnibarNativeTextField: NSTextField { } private struct OmnibarTextFieldRepresentable: NSViewRepresentable { + let panelId: UUID @Binding var text: String @Binding var isFocused: Bool let inlineCompletion: OmnibarInlineCompletion? @@ -3368,15 +3414,33 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { var parent: OmnibarTextFieldRepresentable var isProgrammaticMutation: Bool = false var selectionObserver: NSObjectProtocol? + var explicitPointerBlurObserver: NSObjectProtocol? weak var observedEditor: NSTextView? var appliedInlineCompletion: OmnibarInlineCompletion? var lastPublishedSelection: NSRange = NSRange(location: NSNotFound, length: 0) var lastPublishedHasMarkedText: Bool = false /// Guards against infinite focus loops: `true` = focus requested, `false` = blur requested, `nil` = idle. var pendingFocusRequest: Bool? + var pendingExplicitPointerBlurIntent: Bool = false init(parent: OmnibarTextFieldRepresentable) { self.parent = parent + super.init() + explicitPointerBlurObserver = NotificationCenter.default.addObserver( + forName: .browserWillBlurAddressBarForWebViewClick, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self, + let panelId = notification.object as? UUID, + panelId == self.parent.panelId else { + return + } + self.pendingExplicitPointerBlurIntent = true +#if DEBUG + self.logFocusEvent("webViewClickBlurIntent") +#endif + } } #if DEBUG @@ -3412,6 +3476,9 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { if let selectionObserver { NotificationCenter.default.removeObserver(selectionObserver) } + if let explicitPointerBlurObserver { + NotificationCenter.default.removeObserver(explicitPointerBlurObserver) + } } private func nextResponderIsOtherTextField(window: NSWindow?) -> Bool { @@ -3483,12 +3550,10 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { } private func shouldReacquireFocusAfterEndEditing(window: NSWindow?) -> Bool { - if pointerDownBlurIntent(window: window) { - return false - } return browserOmnibarShouldReacquireFocusAfterEndEditing( desiredOmnibarFocus: parent.isFocused, - nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window) + nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window), + explicitPointerBlurIntent: pointerDownBlurIntent(window: window) || pendingExplicitPointerBlurIntent ) } @@ -3496,6 +3561,7 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { #if DEBUG logFocusEvent("controlTextDidBeginEditing") #endif + pendingExplicitPointerBlurIntent = false if !parent.isFocused { DispatchQueue.main.async { #if DEBUG @@ -3509,16 +3575,23 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { } func controlTextDidEndEditing(_ obj: Notification) { -#if DEBUG + let explicitPointerBlurIntent = + pointerDownBlurIntent(window: parentField?.window) || pendingExplicitPointerBlurIntent + pendingExplicitPointerBlurIntent = false let nextOther = nextResponderIsOtherTextField(window: parentField?.window) - let pointerBlur = pointerDownBlurIntent(window: parentField?.window) + let shouldReacquire = browserOmnibarShouldReacquireFocusAfterEndEditing( + desiredOmnibarFocus: parent.isFocused, + nextResponderIsOtherTextField: nextOther, + explicitPointerBlurIntent: explicitPointerBlurIntent + ) +#if DEBUG logFocusEvent( "controlTextDidEndEditing", - detail: "nextOther=\(nextOther ? 1 : 0) pointerBlur=\(pointerBlur ? 1 : 0) shouldReacquire=\(shouldReacquireFocusAfterEndEditing(window: parentField?.window) ? 1 : 0)" + detail: "nextOther=\(nextOther ? 1 : 0) pointerBlur=\(explicitPointerBlurIntent ? 1 : 0) shouldReacquire=\(shouldReacquire ? 1 : 0)" ) #endif if parent.isFocused { - if shouldReacquireFocusAfterEndEditing(window: parentField?.window) { + if shouldReacquire { #if DEBUG logFocusEvent("controlTextDidEndEditing.reacquire.begin") #endif @@ -6336,19 +6409,11 @@ struct WebViewRepresentable: NSViewRepresentable { webView: WKWebView, isPanelFocused: Bool ) { - guard let cmuxWebView = webView as? CmuxWebView else { return } - let next = isPanelFocused && !panel.shouldSuppressWebViewFocus() - if cmuxWebView.allowsFirstResponderAcquisition != next { -#if DEBUG - dlog( - "browser.focus.policy panel=\(panel.id.uuidString.prefix(5)) " + - "web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " + - "new=\(next ? 1 : 0) isPanelFocused=\(isPanelFocused ? 1 : 0) " + - "suppress=\(panel.shouldSuppressWebViewFocus() ? 1 : 0)" - ) -#endif - } - cmuxWebView.allowsFirstResponderAcquisition = next + guard webView is CmuxWebView else { return } + panel.syncWebViewFirstResponderPolicy( + isPanelFocused: isPanelFocused, + reason: "applyWebViewFirstResponderPolicy" + ) } static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 273cc43a20..7b1c90ec72 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -5182,6 +5182,7 @@ extension Notification.Name { static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar") static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar") static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar") + static let browserWillBlurAddressBarForWebViewClick = Notification.Name("browserWillBlurAddressBarForWebViewClick") static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick") static let terminalPortalVisibilityDidChange = Notification.Name("cmux.terminalPortalVisibilityDidChange") static let browserPortalRegistryDidChange = Notification.Name("cmux.browserPortalRegistryDidChange") diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index c6509f464b..fbd397bcf1 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8635,6 +8635,12 @@ class TerminalController { tabManager.selectWorkspace(ws) } + browserPanel.prepareForExplicitWebViewFocus( + isPanelFocused: true, + reason: "socket.browser.focus_webview" + ) + NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: surfaceId) + // Prevent omnibar auto-focus from immediately stealing first responder back. browserPanel.suppressOmnibarAutofocus(for: 1.0) @@ -11804,19 +11810,19 @@ class TerminalController { case "left": storedKey = "←" keyCode = 123 - charactersIgnoringModifiers = storedKey + charactersIgnoringModifiers = String(UnicodeScalar(NSLeftArrowFunctionKey)!) case "right": storedKey = "→" keyCode = 124 - charactersIgnoringModifiers = storedKey + charactersIgnoringModifiers = String(UnicodeScalar(NSRightArrowFunctionKey)!) case "down": storedKey = "↓" keyCode = 125 - charactersIgnoringModifiers = storedKey + charactersIgnoringModifiers = String(UnicodeScalar(NSDownArrowFunctionKey)!) case "up": storedKey = "↑" keyCode = 126 - charactersIgnoringModifiers = storedKey + charactersIgnoringModifiers = String(UnicodeScalar(NSUpArrowFunctionKey)!) case "enter", "return": storedKey = "\r" keyCode = UInt16(kVK_Return) @@ -13587,8 +13593,10 @@ class TerminalController { // Programmatic WebView focus should win over stale omnibar focus state, especially // after workspace switches where the blank-page omnibar auto-focus can re-trigger. - browserPanel.endSuppressWebViewFocusForAddressBar() - browserPanel.clearWebViewFocusSuppression() + browserPanel.prepareForExplicitWebViewFocus( + isPanelFocused: true, + reason: "socket.focus_webview" + ) NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: panelId) // Prevent omnibar auto-focus from immediately stealing first responder back. diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index a2b611bd43..53883d604d 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -1,4 +1,5 @@ import XCTest +import WebKit #if canImport(cmux_DEV) @testable import cmux_DEV @@ -2328,6 +2329,68 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(observedDelta, 1) } + func testDownArrowIsNotConsumedAfterBrowserWebViewRetakesFocusFromStaleAddressBarState() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId), + let contentView = window.contentView else { + XCTFail("Expected test window") + return + } + + let webView = CmuxWebView(frame: contentView.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + contentView.addSubview(webView) + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertTrue(window.makeFirstResponder(webView), "Expected test web view to become first responder") + + let stalePanelId = UUID() + NotificationCenter.default.post(name: .browserDidFocusAddressBar, object: stalePanelId) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual( + appDelegate.focusedBrowserAddressBarPanelId(), + stalePanelId, + "Expected stale address-bar focus state to be installed for the regression setup" + ) + + NotificationCenter.default.post(name: .browserDidBecomeFirstResponderWebView, object: webView) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + guard let downArrowEvent = makeKeyDownEvent( + key: String(UnicodeScalar(NSDownArrowFunctionKey)!), + modifiers: [], + keyCode: 125, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Down Arrow event") + return + } + +#if DEBUG + XCTAssertFalse( + appDelegate.debugHandleCustomShortcut(event: downArrowEvent), + "Plain Down Arrow should reach WebKit once the page retakes focus from the omnibar" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + XCTAssertNil( + appDelegate.focusedBrowserAddressBarPanelId(), + "Browser web view focus should clear stale omnibar focus state" + ) + } + func testEscapeDismissesCommandPaletteWhenVisibilityStateStaysStalePastInitialPendingWindow() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index ae60de6bf9..e17d1b9be3 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -2547,6 +2547,116 @@ final class BrowserReturnKeyDownRoutingTests: XCTestCase { } } +final class BrowserDirectKeyDownRoutingTests: XCTestCase { + func testRoutesWhenBrowserWebViewIsFirstResponder() { + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + XCTAssertTrue( + shouldDirectRouteBrowserFirstResponderKeyDown( + firstResponder: webView, + firstResponderIsBrowser: true, + focusedBrowserAddressBarPanelId: nil + ) + ) + } + + func testRoutesWhenBrowserFieldEditorIsFirstResponder() { + let fieldEditor = NSTextView(frame: .zero) + fieldEditor.isFieldEditor = true + + XCTAssertTrue( + shouldDirectRouteBrowserFirstResponderKeyDown( + firstResponder: fieldEditor, + firstResponderIsBrowser: true, + focusedBrowserAddressBarPanelId: nil + ) + ) + } + + func testDoesNotRouteWhenBrowserAddressBarRemainsFocused() { + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + XCTAssertFalse( + shouldDirectRouteBrowserFirstResponderKeyDown( + firstResponder: webView, + firstResponderIsBrowser: true, + focusedBrowserAddressBarPanelId: UUID() + ) + ) + } + + func testDoesNotRouteWhenFirstResponderIsNotBrowser() { + let view = NSView(frame: .zero) + + XCTAssertFalse( + shouldDirectRouteBrowserFirstResponderKeyDown( + firstResponder: view, + firstResponderIsBrowser: false, + focusedBrowserAddressBarPanelId: nil + ) + ) + } + + func testNonCommandBrowserKeysGoStraightToKeyDown() { + let event = makeKeyDownEvent( + characters: String(UnicodeScalar(NSDownArrowFunctionKey)!), + modifiers: [], + keyCode: 125 + ) + + XCTAssertEqual(browserDirectKeyRoutingStrategy(for: event), .keyDown) + } + + func testCommandShiftArrowFallsBackToBrowserKeyDownWhenNoShortcutConsumesIt() { + let event = makeKeyDownEvent( + characters: String(UnicodeScalar(NSDownArrowFunctionKey)!), + modifiers: [.command, .shift], + keyCode: 125 + ) + + XCTAssertEqual( + browserDirectKeyRoutingStrategy(for: event), + .menuOrAppShortcutThenKeyDown + ) + } + + func testCommandBacktickDefersToOriginalPerformKeyEquivalent() { + let event = makeKeyDownEvent( + characters: "`", + modifiers: [.command], + keyCode: 50 + ) + + XCTAssertEqual( + browserDirectKeyRoutingStrategy(for: event), + .deferToOriginalPerformKeyEquivalent + ) + } + + private func makeKeyDownEvent( + characters: String, + modifiers: NSEvent.ModifierFlags, + keyCode: UInt16 + ) -> NSEvent { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode + ) else { + XCTFail("Failed to create keyDown event for routing strategy test") + fatalError("Failed to create keyDown event") + } + + return event + } +} final class BrowserZoomShortcutActionTests: XCTestCase { func testZoomInSupportsEqualsAndPlusVariants() { @@ -3152,4 +3262,95 @@ final class BrowserOmnibarFocusPolicyTests: XCTestCase { ) ) } + + func testDoesNotReacquireFocusWhenExplicitPointerBlurIntentExists() { + XCTAssertFalse( + browserOmnibarShouldReacquireFocusAfterEndEditing( + desiredOmnibarFocus: true, + nextResponderIsOtherTextField: false, + explicitPointerBlurIntent: true + ) + ) + } +} + +final class BrowserWebViewClickFocusPolicyTests: XCTestCase { + func testDismissesOmnibarWhenLocalAddressBarStateIsFocused() { + let panelId = UUID() + + XCTAssertTrue( + browserWebViewClickShouldDismissOmnibar( + localAddressBarFocused: true, + focusedAddressBarPanelId: nil, + pendingAddressBarFocusRequestId: nil, + panelId: panelId + ) + ) + } + + func testDismissesOmnibarWhenAppFocusStateStillPointsAtThisPanel() { + let panelId = UUID() + + XCTAssertTrue( + browserWebViewClickShouldDismissOmnibar( + localAddressBarFocused: false, + focusedAddressBarPanelId: panelId, + pendingAddressBarFocusRequestId: nil, + panelId: panelId + ) + ) + } + + func testDismissesOmnibarWhenAddressBarFocusRequestIsStillPending() { + let panelId = UUID() + + XCTAssertTrue( + browserWebViewClickShouldDismissOmnibar( + localAddressBarFocused: false, + focusedAddressBarPanelId: nil, + pendingAddressBarFocusRequestId: UUID(), + panelId: panelId + ) + ) + } + + func testDoesNotDismissOmnibarForAnotherPanelsFocusedAddressBar() { + let panelId = UUID() + + XCTAssertFalse( + browserWebViewClickShouldDismissOmnibar( + localAddressBarFocused: false, + focusedAddressBarPanelId: UUID(), + pendingAddressBarFocusRequestId: nil, + panelId: panelId + ) + ) + } + + func testPromotesWebViewFocusWhenPanelIsFocused() { + XCTAssertTrue( + browserWebViewClickShouldPromoteWebViewFocus( + isPanelFocused: true, + shouldDismissOmnibar: false + ) + ) + } + + func testPromotesWebViewFocusWhenOmnibarStillOwnsFocus() { + XCTAssertTrue( + browserWebViewClickShouldPromoteWebViewFocus( + isPanelFocused: false, + shouldDismissOmnibar: true + ) + ) + } + + func testDoesNotPromoteWebViewFocusWhenPanelIsNotFocusedAndOmnibarDoesNotOwnFocus() { + XCTAssertFalse( + browserWebViewClickShouldPromoteWebViewFocus( + isPanelFocused: false, + shouldDismissOmnibar: false + ) + ) + } } diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift index f10d7a5f0e..c89322276e 100644 --- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift @@ -4,6 +4,8 @@ import Foundation final class BrowserPaneNavigationKeybindUITests: XCTestCase { private var dataPath = "" private var socketPath = "" + private var launchDiagnosticsPath = "" + private var launchTag = "" override func setUp() { super.setUp() @@ -12,6 +14,29 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { try? FileManager.default.removeItem(atPath: dataPath) socketPath = "/tmp/cmux-ui-test-socket-\(UUID().uuidString).sock" try? FileManager.default.removeItem(atPath: socketPath) + launchDiagnosticsPath = "/tmp/cmux-ui-test-launch-\(UUID().uuidString).json" + try? FileManager.default.removeItem(atPath: launchDiagnosticsPath) + launchTag = "ui-tests-browser-nav-\(UUID().uuidString.prefix(8))" + + let diagnosticsPath = launchDiagnosticsPath + addTeardownBlock { [weak self] in + guard let self, + let contents = try? String(contentsOfFile: diagnosticsPath, encoding: .utf8), + !contents.isEmpty else { + return + } + print("UI_TEST_LAUNCH_DIAGNOSTICS_BEGIN") + print(contents) + print("UI_TEST_LAUNCH_DIAGNOSTICS_END") + let attachment = XCTAttachment(string: contents) + attachment.name = "ui-test-launch-diagnostics" + attachment.lifetime = .deleteOnSuccess + self.add(attachment) + } + + let cleanup = XCUIApplication() + cleanup.terminate() + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) } func testCmdCtrlHMovesLeftWhenWebViewFocused() { @@ -319,6 +344,849 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { ) } + func testArrowKeysReachClickedPageInputAfterCmdL() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_ARROW_SETUP"] = "1" + launchAndEnsureForeground(app) + + XCTAssertTrue( + waitForData( + keys: [ + "browserPanelId", + "webViewFocused", + "webInputFocusSeeded", + "webInputFocusElementId", + "webInputFocusSecondaryElementId", + "webInputFocusPrimaryClickOffsetX", + "webInputFocusPrimaryClickOffsetY", + "webInputFocusSecondaryClickOffsetX", + "webInputFocusSecondaryClickOffsetY" + ], + timeout: 20.0 + ), + "Expected focused page input setup data to be written. data=\(String(describing: loadData()))" + ) + + guard let setup = loadData() else { + XCTFail("Missing goto_split setup data") + return + } + + XCTAssertEqual(setup["webInputFocusSeeded"], "true", "Expected test page input to be focused before arrow-key checks") + guard let primaryInputId = setup["webInputFocusElementId"], !primaryInputId.isEmpty else { + XCTFail("Missing webInputFocusElementId in setup data") + return + } + guard let secondaryInputId = setup["webInputFocusSecondaryElementId"], !secondaryInputId.isEmpty else { + XCTFail("Missing webInputFocusSecondaryElementId in setup data") + return + } + guard let primaryClickOffsetXRaw = setup["webInputFocusPrimaryClickOffsetX"], + let primaryClickOffsetYRaw = setup["webInputFocusPrimaryClickOffsetY"], + let primaryClickOffsetX = Double(primaryClickOffsetXRaw), + let primaryClickOffsetY = Double(primaryClickOffsetYRaw) else { + XCTFail( + "Missing or invalid primary input click offsets in setup data. " + + "webInputFocusPrimaryClickOffsetX=\(setup["webInputFocusPrimaryClickOffsetX"] ?? "nil") " + + "webInputFocusPrimaryClickOffsetY=\(setup["webInputFocusPrimaryClickOffsetY"] ?? "nil")" + ) + return + } + guard let secondaryClickOffsetXRaw = setup["webInputFocusSecondaryClickOffsetX"], + let secondaryClickOffsetYRaw = setup["webInputFocusSecondaryClickOffsetY"], + let secondaryClickOffsetX = Double(secondaryClickOffsetXRaw), + let secondaryClickOffsetY = Double(secondaryClickOffsetYRaw) else { + XCTFail( + "Missing or invalid secondary input click offsets in setup data. " + + "webInputFocusSecondaryClickOffsetX=\(setup["webInputFocusSecondaryClickOffsetX"] ?? "nil") " + + "webInputFocusSecondaryClickOffsetY=\(setup["webInputFocusSecondaryClickOffsetY"] ?? "nil")" + ) + return + } + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window before arrow-key regression check") + + window + .coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0)) + .withOffset(CGVector(dx: primaryClickOffsetX, dy: primaryClickOffsetY)) + .click() + + guard let initialArrowSnapshot = waitForDataSnapshot( + timeout: 8.0, + predicate: { data in + data["browserArrowInstalled"] == "true" && + data["browserArrowActiveElementId"] == primaryInputId && + data["browserArrowDownCount"] == "0" && + data["browserArrowUpCount"] == "0" + } + ) else { + XCTFail( + "Expected arrow recorder to initialize with the primary page input focused. " + + "data=\(String(describing: loadData()))" + ) + return + } + let initialDownCount = Int(initialArrowSnapshot["browserArrowDownCount"] ?? "") ?? -1 + let initialUpCount = Int(initialArrowSnapshot["browserArrowUpCount"] ?? "") ?? -1 + + simulateShortcut("down", app: app) + guard let baselineDownSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserArrowActiveElementId"] == primaryInputId && + data["browserArrowDownCount"] == "\(initialDownCount + 1)" && + data["browserArrowUpCount"] == "\(initialUpCount)" + } + ) else { + XCTFail( + "Expected baseline Down Arrow to reach the primary page input. " + + "data=\(String(describing: loadData()))" + ) + return + } + let baselineDownCount = Int(baselineDownSnapshot["browserArrowDownCount"] ?? "") ?? -1 + let baselineUpCount = Int(baselineDownSnapshot["browserArrowUpCount"] ?? "") ?? -1 + + simulateShortcut("up", app: app) + guard let baselineUpSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserArrowActiveElementId"] == primaryInputId && + data["browserArrowDownCount"] == "\(baselineDownCount)" && + data["browserArrowUpCount"] == "\(baselineUpCount + 1)" + } + ) else { + XCTFail( + "Expected baseline Up Arrow to reach the primary page input. " + + "data=\(String(describing: loadData()))" + ) + return + } + let baselineUpCountAfterUp = Int(baselineUpSnapshot["browserArrowUpCount"] ?? "") ?? -1 + + app.typeKey("l", modifierFlags: [.command]) + XCTAssertTrue( + waitForDataMatch(timeout: 5.0) { data in + data["webViewFocusedAfterAddressBarFocus"] == "false" + }, + "Expected Cmd+L to focus the omnibar before the page-click arrow-key check" + ) + + window + .coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0)) + .withOffset(CGVector(dx: secondaryClickOffsetX, dy: secondaryClickOffsetY)) + .click() + + guard waitForDataMatch( + timeout: 5.0, + predicate: { data in + data["browserArrowActiveElementId"] == secondaryInputId + } + ) else { + XCTFail( + "Expected clicking the page to focus the secondary page input before sending arrows. " + + "data=\(String(describing: loadData()))" + ) + return + } + + simulateShortcut("down", app: app) + guard let postCmdLDownSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserArrowActiveElementId"] == secondaryInputId && + data["browserArrowDownCount"] == "\(baselineDownCount + 1)" && + data["browserArrowUpCount"] == "\(baselineUpCountAfterUp)" + } + ) else { + XCTFail( + "Expected Down Arrow after Cmd+L and page click to reach the secondary page input. " + + "data=\(String(describing: loadData()))" + ) + return + } + let postCmdLDownCount = Int(postCmdLDownSnapshot["browserArrowDownCount"] ?? "") ?? -1 + + simulateShortcut("up", app: app) + guard let postCmdLUpSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserArrowActiveElementId"] == secondaryInputId && + data["browserArrowDownCount"] == "\(postCmdLDownCount)" && + data["browserArrowUpCount"] == "\(baselineUpCountAfterUp + 1)" + } + ) else { + XCTFail( + "Expected Up Arrow after Cmd+L and page click to reach the secondary page input. " + + "postCmdLDownSnapshot=\(postCmdLDownSnapshot) " + + "data=\(String(describing: loadData()))" + ) + return + } + + let baselineCommandShiftDownCount = Int(postCmdLUpSnapshot["browserArrowCommandShiftDownCount"] ?? "") ?? -1 + let baselineCommandShiftUpCount = Int(postCmdLUpSnapshot["browserArrowCommandShiftUpCount"] ?? "") ?? -1 + guard baselineCommandShiftDownCount >= 0, baselineCommandShiftUpCount >= 0 else { + XCTFail( + "Expected browser arrow recorder to report Cmd+Shift+arrow counters. " + + "data=\(String(describing: loadData()))" + ) + return + } + + simulateShortcut("cmdShiftDown", app: app) + guard let postCmdLCommandShiftDownSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserArrowActiveElementId"] == secondaryInputId && + data["browserArrowCommandShiftDownCount"] == "\(baselineCommandShiftDownCount + 1)" && + data["browserArrowCommandShiftUpCount"] == "\(baselineCommandShiftUpCount)" + } + ) else { + XCTFail( + "Expected Cmd+Shift+Down after Cmd+L and page click to reach the secondary page input. " + + "data=\(String(describing: loadData()))" + ) + return + } + let postCmdLCommandShiftDownCount = Int(postCmdLCommandShiftDownSnapshot["browserArrowCommandShiftDownCount"] ?? "") ?? -1 + let postCmdLCommandShiftUpCount = Int(postCmdLCommandShiftDownSnapshot["browserArrowCommandShiftUpCount"] ?? "") ?? -1 + guard postCmdLCommandShiftDownCount >= 0, postCmdLCommandShiftUpCount >= 0 else { + XCTFail( + "Expected browser arrow recorder to report Cmd+Shift+Down counters. " + + "data=\(String(describing: loadData()))" + ) + return + } + + simulateShortcut("cmdShiftUp", app: app) + guard let postCmdLCommandShiftUpSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserArrowActiveElementId"] == secondaryInputId && + data["browserArrowCommandShiftDownCount"] == "\(postCmdLCommandShiftDownCount)" && + data["browserArrowCommandShiftUpCount"] == "\(postCmdLCommandShiftUpCount + 1)" + } + ) else { + XCTFail( + "Expected Cmd+Shift+Up after Cmd+L and page click to reach the secondary page input. " + + "data=\(String(describing: loadData()))" + ) + return + } + + XCTAssertEqual(postCmdLUpSnapshot["browserArrowActiveElementId"], secondaryInputId, "Expected the clicked secondary page input to remain focused") + XCTAssertEqual(postCmdLCommandShiftUpSnapshot["browserArrowActiveElementId"], secondaryInputId, "Expected the clicked secondary page input to remain focused after Cmd+Shift+arrows") + } + + func testArrowKeysDoNotLeakToPageWhileOmnibarFocused() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_ARROW_SETUP"] = "1" + launchAndEnsureForeground(app) + + XCTAssertTrue( + waitForData( + keys: [ + "browserPanelId", + "webInputFocusSeeded", + "webInputFocusElementId", + "webInputFocusPrimaryClickOffsetX", + "webInputFocusPrimaryClickOffsetY" + ], + timeout: 20.0 + ), + "Expected focused page input setup data before omnibar leak check. data=\(String(describing: loadData()))" + ) + + guard let setup = loadData() else { + XCTFail("Missing goto_split setup data") + return + } + + guard let browserPanelId = setup["browserPanelId"], !browserPanelId.isEmpty else { + XCTFail("Missing browserPanelId in setup data") + return + } + guard let primaryInputId = setup["webInputFocusElementId"], !primaryInputId.isEmpty else { + XCTFail("Missing webInputFocusElementId in setup data") + return + } + guard let primaryClickOffsetXRaw = setup["webInputFocusPrimaryClickOffsetX"], + let primaryClickOffsetYRaw = setup["webInputFocusPrimaryClickOffsetY"], + let primaryClickOffsetX = Double(primaryClickOffsetXRaw), + let primaryClickOffsetY = Double(primaryClickOffsetYRaw) else { + XCTFail( + "Missing or invalid primary input click offsets in setup data. " + + "webInputFocusPrimaryClickOffsetX=\(setup["webInputFocusPrimaryClickOffsetX"] ?? "nil") " + + "webInputFocusPrimaryClickOffsetY=\(setup["webInputFocusPrimaryClickOffsetY"] ?? "nil")" + ) + return + } + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window before omnibar leak check") + + window + .coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0)) + .withOffset(CGVector(dx: primaryClickOffsetX, dy: primaryClickOffsetY)) + .click() + + guard let initialSnapshot = waitForDataSnapshot( + timeout: 8.0, + predicate: { data in + data["browserArrowInstalled"] == "true" && + data["browserArrowActiveElementId"] == primaryInputId && + data["browserArrowDownCount"] == "0" && + data["browserArrowUpCount"] == "0" && + data["browserArrowCommandShiftDownCount"] == "0" && + data["browserArrowCommandShiftUpCount"] == "0" + } + ) else { + XCTFail("Expected page input to be focused before omnibar leak check. data=\(String(describing: loadData()))") + return + } + + let baselineDownCount = Int(initialSnapshot["browserArrowDownCount"] ?? "") ?? -1 + let baselineUpCount = Int(initialSnapshot["browserArrowUpCount"] ?? "") ?? -1 + let baselineCommandShiftDownCount = Int(initialSnapshot["browserArrowCommandShiftDownCount"] ?? "") ?? -1 + let baselineCommandShiftUpCount = Int(initialSnapshot["browserArrowCommandShiftUpCount"] ?? "") ?? -1 + guard baselineDownCount == 0, + baselineUpCount == 0, + baselineCommandShiftDownCount == 0, + baselineCommandShiftUpCount == 0 else { + XCTFail("Expected zeroed arrow counters before omnibar leak check. data=\(initialSnapshot)") + return + } + + app.typeKey("l", modifierFlags: [.command]) + XCTAssertTrue( + waitForDataMatch(timeout: 5.0) { data in + data["webViewFocusedAfterAddressBarFocus"] == "false" && + data["webViewFocusedAfterAddressBarFocusPanelId"] == browserPanelId && + data["browserArrowFocusedAddressBarPanelId"] == browserPanelId + }, + "Expected Cmd+L to keep omnibar focused before leak check. data=\(String(describing: loadData()))" + ) + + simulateShortcut("down", app: app) + XCTAssertTrue( + browserArrowCountersRemainUnchanged( + down: baselineDownCount, + up: baselineUpCount, + commandShiftDown: baselineCommandShiftDownCount, + commandShiftUp: baselineCommandShiftUpCount, + timeout: 1.0 + ), + "Expected Down Arrow to stay out of the page while omnibar remained focused. data=\(String(describing: loadData()))" + ) + + simulateShortcut("up", app: app) + XCTAssertTrue( + browserArrowCountersRemainUnchanged( + down: baselineDownCount, + up: baselineUpCount, + commandShiftDown: baselineCommandShiftDownCount, + commandShiftUp: baselineCommandShiftUpCount, + timeout: 1.0 + ), + "Expected Up Arrow to stay out of the page while omnibar remained focused. data=\(String(describing: loadData()))" + ) + + simulateShortcut("cmdShiftDown", app: app) + XCTAssertTrue( + browserArrowCountersRemainUnchanged( + down: baselineDownCount, + up: baselineUpCount, + commandShiftDown: baselineCommandShiftDownCount, + commandShiftUp: baselineCommandShiftUpCount, + timeout: 1.0 + ), + "Expected Cmd+Shift+Down to stay out of the page while omnibar remained focused. data=\(String(describing: loadData()))" + ) + + simulateShortcut("cmdShiftUp", app: app) + XCTAssertTrue( + browserArrowCountersRemainUnchanged( + down: baselineDownCount, + up: baselineUpCount, + commandShiftDown: baselineCommandShiftDownCount, + commandShiftUp: baselineCommandShiftUpCount, + timeout: 1.0 + ), + "Expected Cmd+Shift+Up to stay out of the page while omnibar remained focused. data=\(String(describing: loadData()))" + ) + } + + func testArrowKeysReachClickedTextareaAfterCmdL() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_ARROW_SETUP"] = "1" + launchAndEnsureForeground(app) + + XCTAssertTrue( + waitForData( + keys: [ + "browserPanelId", + "webViewFocused", + "webInputFocusSeeded", + "webInputFocusTextareaElementId", + "webInputFocusTextareaClickOffsetX", + "webInputFocusTextareaClickOffsetY" + ], + timeout: 20.0 + ), + "Expected textarea setup data to be written. data=\(String(describing: loadData()))" + ) + + guard let setup = loadData() else { + XCTFail("Missing goto_split setup data") + return + } + + guard let browserPanelId = setup["browserPanelId"], !browserPanelId.isEmpty else { + XCTFail("Missing browserPanelId in setup data") + return + } + XCTAssertEqual(setup["webInputFocusSeeded"], "true", "Expected page input harness to be seeded before textarea check") + guard let textareaId = setup["webInputFocusTextareaElementId"], !textareaId.isEmpty else { + XCTFail("Missing webInputFocusTextareaElementId in setup data") + return + } + guard let textareaClickOffsetXRaw = setup["webInputFocusTextareaClickOffsetX"], + let textareaClickOffsetYRaw = setup["webInputFocusTextareaClickOffsetY"], + let textareaClickOffsetX = Double(textareaClickOffsetXRaw), + let textareaClickOffsetY = Double(textareaClickOffsetYRaw) else { + XCTFail( + "Missing or invalid textarea click offsets in setup data. " + + "webInputFocusTextareaClickOffsetX=\(setup["webInputFocusTextareaClickOffsetX"] ?? "nil") " + + "webInputFocusTextareaClickOffsetY=\(setup["webInputFocusTextareaClickOffsetY"] ?? "nil")" + ) + return + } + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window before textarea regression check") + + window + .coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0)) + .withOffset(CGVector(dx: textareaClickOffsetX, dy: textareaClickOffsetY)) + .click() + + guard let initialSnapshot = waitForDataSnapshot( + timeout: 8.0, + predicate: { data in + data["browserArrowInstalled"] == "true" && + data["browserArrowActiveElementId"] == textareaId && + data["browserArrowDownCount"] == "0" && + data["browserArrowUpCount"] == "0" && + data["browserArrowCommandShiftDownCount"] == "0" && + data["browserArrowCommandShiftUpCount"] == "0" + } + ) else { + XCTFail("Expected textarea to be focused before baseline arrows. data=\(String(describing: loadData()))") + return + } + + let initialDownCount = Int(initialSnapshot["browserArrowDownCount"] ?? "") ?? -1 + let initialUpCount = Int(initialSnapshot["browserArrowUpCount"] ?? "") ?? -1 + let initialCommandShiftDownCount = Int(initialSnapshot["browserArrowCommandShiftDownCount"] ?? "") ?? -1 + let initialCommandShiftUpCount = Int(initialSnapshot["browserArrowCommandShiftUpCount"] ?? "") ?? -1 + + simulateShortcut("down", app: app) + guard let baselineDownSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserArrowActiveElementId"] == textareaId && + data["browserArrowDownCount"] == "\(initialDownCount + 1)" && + data["browserArrowUpCount"] == "\(initialUpCount)" + } + ) else { + XCTFail("Expected baseline Down Arrow to reach the textarea. data=\(String(describing: loadData()))") + return + } + let baselineDownCount = Int(baselineDownSnapshot["browserArrowDownCount"] ?? "") ?? -1 + + simulateShortcut("up", app: app) + guard waitForDataMatch( + timeout: 5.0, + predicate: { data in + data["browserArrowActiveElementId"] == textareaId && + data["browserArrowDownCount"] == "\(baselineDownCount)" && + data["browserArrowUpCount"] == "\(initialUpCount + 1)" + } + ) else { + XCTFail("Expected baseline Up Arrow to reach the textarea. data=\(String(describing: loadData()))") + return + } + simulateShortcut("cmdShiftDown", app: app) + guard let baselineCommandShiftDownSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserArrowActiveElementId"] == textareaId && + data["browserArrowCommandShiftDownCount"] == "\(initialCommandShiftDownCount + 1)" && + data["browserArrowCommandShiftUpCount"] == "\(initialCommandShiftUpCount)" + } + ) else { + XCTFail("Expected baseline Cmd+Shift+Down to reach the textarea. data=\(String(describing: loadData()))") + return + } + let baselineCommandShiftDownCount = Int(baselineCommandShiftDownSnapshot["browserArrowCommandShiftDownCount"] ?? "") ?? -1 + + simulateShortcut("cmdShiftUp", app: app) + guard let baselineCommandShiftUpSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserArrowActiveElementId"] == textareaId && + data["browserArrowCommandShiftDownCount"] == "\(baselineCommandShiftDownCount)" && + data["browserArrowCommandShiftUpCount"] == "\(initialCommandShiftUpCount + 1)" + } + ) else { + XCTFail("Expected baseline Cmd+Shift+Up to reach the textarea. data=\(String(describing: loadData()))") + return + } + let baselineDownCountAfterModifiers = Int(baselineCommandShiftUpSnapshot["browserArrowDownCount"] ?? "") ?? -1 + let baselineUpCountAfterModifiers = Int(baselineCommandShiftUpSnapshot["browserArrowUpCount"] ?? "") ?? -1 + let baselineCommandShiftUpCount = Int(baselineCommandShiftUpSnapshot["browserArrowCommandShiftUpCount"] ?? "") ?? -1 + + app.typeKey("l", modifierFlags: [.command]) + XCTAssertTrue( + waitForDataMatch(timeout: 5.0) { data in + data["webViewFocusedAfterAddressBarFocus"] == "false" && + data["webViewFocusedAfterAddressBarFocusPanelId"] == browserPanelId && + data["browserArrowFocusedAddressBarPanelId"] == browserPanelId + }, + "Expected Cmd+L to focus omnibar before the textarea click path" + ) + + window + .coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0)) + .withOffset(CGVector(dx: textareaClickOffsetX, dy: textareaClickOffsetY)) + .click() + + guard waitForDataMatch( + timeout: 5.0, + predicate: { data in + data["browserArrowActiveElementId"] == textareaId + } + ) else { + XCTFail("Expected clicking the page to re-focus the textarea after Cmd+L. data=\(String(describing: loadData()))") + return + } + + simulateShortcut("down", app: app) + guard let postCmdLDownSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserArrowActiveElementId"] == textareaId && + data["browserArrowDownCount"] == "\(baselineDownCountAfterModifiers + 1)" && + data["browserArrowUpCount"] == "\(baselineUpCountAfterModifiers)" && + data["browserArrowCommandShiftDownCount"] == "\(baselineCommandShiftDownCount)" && + data["browserArrowCommandShiftUpCount"] == "\(baselineCommandShiftUpCount)" + } + ) else { + XCTFail("Expected Down Arrow after Cmd+L to reach the textarea. data=\(String(describing: loadData()))") + return + } + let postCmdLDownCount = Int(postCmdLDownSnapshot["browserArrowDownCount"] ?? "") ?? -1 + + simulateShortcut("up", app: app) + guard let postCmdLUpSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserArrowActiveElementId"] == textareaId && + data["browserArrowDownCount"] == "\(postCmdLDownCount)" && + data["browserArrowUpCount"] == "\(baselineUpCountAfterModifiers + 1)" + } + ) else { + XCTFail("Expected Up Arrow after Cmd+L to reach the textarea. data=\(String(describing: loadData()))") + return + } + let postCmdLUpCount = Int(postCmdLUpSnapshot["browserArrowUpCount"] ?? "") ?? -1 + + simulateShortcut("cmdShiftDown", app: app) + guard let postCmdLCommandShiftDownSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserArrowActiveElementId"] == textareaId && + data["browserArrowDownCount"] == "\(postCmdLDownCount + 1)" && + data["browserArrowUpCount"] == "\(postCmdLUpCount)" && + data["browserArrowCommandShiftDownCount"] == "\(baselineCommandShiftDownCount + 1)" && + data["browserArrowCommandShiftUpCount"] == "\(baselineCommandShiftUpCount)" + } + ) else { + XCTFail("Expected Cmd+Shift+Down after Cmd+L to reach the textarea. data=\(String(describing: loadData()))") + return + } + let postCmdLDownCountAfterCommandShiftDown = Int(postCmdLCommandShiftDownSnapshot["browserArrowDownCount"] ?? "") ?? -1 + let postCmdLCommandShiftDownCount = Int(postCmdLCommandShiftDownSnapshot["browserArrowCommandShiftDownCount"] ?? "") ?? -1 + + simulateShortcut("cmdShiftUp", app: app) + guard let postCmdLCommandShiftUpSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserArrowActiveElementId"] == textareaId && + data["browserArrowDownCount"] == "\(postCmdLDownCountAfterCommandShiftDown)" && + data["browserArrowUpCount"] == "\(postCmdLUpCount + 1)" && + data["browserArrowCommandShiftDownCount"] == "\(postCmdLCommandShiftDownCount)" && + data["browserArrowCommandShiftUpCount"] == "\(baselineCommandShiftUpCount + 1)" + } + ) else { + XCTFail("Expected Cmd+Shift+Up after Cmd+L to reach the textarea. data=\(String(describing: loadData()))") + return + } + + XCTAssertEqual( + postCmdLCommandShiftUpSnapshot["browserArrowActiveElementId"], + textareaId, + "Expected the clicked textarea to remain focused after Cmd+Shift+arrows" + ) + } + + func testArrowKeysReachClickedContentEditableAfterCmdL() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_ARROW_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_CONTENTEDITABLE_SETUP"] = "1" + launchAndEnsureForeground(app) + + XCTAssertTrue( + waitForData( + keys: [ + "browserPanelId", + "webInputFocusSeeded", + "webContentEditableSeeded", + "webContentEditableElementId", + "webContentEditableClickOffsetX", + "webContentEditableClickOffsetY" + ], + timeout: 20.0 + ), + "Expected focused page input setup data before contenteditable regression check. data=\(String(describing: loadData()))" + ) + + guard let setup = loadData() else { + XCTFail("Missing goto_split setup data") + return + } + + guard let browserPanelId = setup["browserPanelId"], !browserPanelId.isEmpty else { + XCTFail("Missing browserPanelId in setup data") + return + } + XCTAssertEqual(setup["webInputFocusSeeded"], "true", "Expected test page inputs to be seeded before contenteditable regression check") + XCTAssertEqual(setup["webContentEditableSeeded"], "true", "Expected contenteditable fixture to be seeded before contenteditable regression check") + guard let editorId = setup["webContentEditableElementId"], !editorId.isEmpty else { + XCTFail("Missing webContentEditableElementId in setup data") + return + } + guard let editorClickOffsetXRaw = setup["webContentEditableClickOffsetX"], + let editorClickOffsetYRaw = setup["webContentEditableClickOffsetY"], + let editorClickOffsetX = Double(editorClickOffsetXRaw), + let editorClickOffsetY = Double(editorClickOffsetYRaw) else { + XCTFail( + "Missing or invalid contenteditable click offsets in setup data. " + + "webContentEditableClickOffsetX=\(setup["webContentEditableClickOffsetX"] ?? "nil") " + + "webContentEditableClickOffsetY=\(setup["webContentEditableClickOffsetY"] ?? "nil")" + ) + return + } + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window before contenteditable regression check") + + window + .coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0)) + .withOffset(CGVector(dx: editorClickOffsetX, dy: editorClickOffsetY)) + .click() + + guard let initialSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserContentEditableInstalled"] == "true" && + data["browserContentEditableActiveElementId"] == editorId && + data["browserContentEditableDownCount"] == "0" && + data["browserContentEditableUpCount"] == "0" && + data["browserContentEditableCommandShiftDownCount"] == "0" && + data["browserContentEditableCommandShiftUpCount"] == "0" + } + ) else { + XCTFail("Expected contenteditable fixture to be focused before baseline arrows. data=\(String(describing: loadData()))") + return + } + let initialDownCount = Int(initialSnapshot["browserContentEditableDownCount"] ?? "") ?? -1 + let initialUpCount = Int(initialSnapshot["browserContentEditableUpCount"] ?? "") ?? -1 + let initialCommandShiftDownCount = Int(initialSnapshot["browserContentEditableCommandShiftDownCount"] ?? "") ?? -1 + let initialCommandShiftUpCount = Int(initialSnapshot["browserContentEditableCommandShiftUpCount"] ?? "") ?? -1 + + simulateShortcut("down", app: app) + guard let baselineDownSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserContentEditableActiveElementId"] == editorId && + data["browserContentEditableDownCount"] == "\(initialDownCount + 1)" && + data["browserContentEditableUpCount"] == "\(initialUpCount)" + } + ) else { + XCTFail("Expected baseline Down Arrow to reach the contenteditable fixture. data=\(String(describing: loadData()))") + return + } + let baselineDownCount = Int(baselineDownSnapshot["browserContentEditableDownCount"] ?? "") ?? -1 + + simulateShortcut("up", app: app) + guard waitForDataMatch( + timeout: 5.0, + predicate: { data in + data["browserContentEditableActiveElementId"] == editorId && + data["browserContentEditableDownCount"] == "\(baselineDownCount)" && + data["browserContentEditableUpCount"] == "\(initialUpCount + 1)" + } + ) else { + XCTFail("Expected baseline Up Arrow to reach the contenteditable fixture. data=\(String(describing: loadData()))") + return + } + simulateShortcut("cmdShiftDown", app: app) + guard let baselineCommandShiftDownSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserContentEditableActiveElementId"] == editorId && + data["browserContentEditableCommandShiftDownCount"] == "\(initialCommandShiftDownCount + 1)" && + data["browserContentEditableCommandShiftUpCount"] == "\(initialCommandShiftUpCount)" + } + ) else { + XCTFail("Expected baseline Cmd+Shift+Down to reach the contenteditable fixture. data=\(String(describing: loadData()))") + return + } + let baselineCommandShiftDownCount = Int(baselineCommandShiftDownSnapshot["browserContentEditableCommandShiftDownCount"] ?? "") ?? -1 + + simulateShortcut("cmdShiftUp", app: app) + guard let baselineCommandShiftUpSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserContentEditableActiveElementId"] == editorId && + data["browserContentEditableCommandShiftDownCount"] == "\(baselineCommandShiftDownCount)" && + data["browserContentEditableCommandShiftUpCount"] == "\(initialCommandShiftUpCount + 1)" + } + ) else { + XCTFail("Expected baseline Cmd+Shift+Up to reach the contenteditable fixture. data=\(String(describing: loadData()))") + return + } + let baselineDownCountAfterModifiers = Int(baselineCommandShiftUpSnapshot["browserContentEditableDownCount"] ?? "") ?? -1 + let baselineUpCountAfterModifiers = Int(baselineCommandShiftUpSnapshot["browserContentEditableUpCount"] ?? "") ?? -1 + let baselineCommandShiftUpCount = Int(baselineCommandShiftUpSnapshot["browserContentEditableCommandShiftUpCount"] ?? "") ?? -1 + + app.typeKey("l", modifierFlags: [.command]) + XCTAssertTrue( + waitForDataMatch(timeout: 5.0) { data in + data["webViewFocusedAfterAddressBarFocus"] == "false" && + data["webViewFocusedAfterAddressBarFocusPanelId"] == browserPanelId && + data["browserArrowFocusedAddressBarPanelId"] == browserPanelId + }, + "Expected Cmd+L to focus omnibar before the contenteditable click path" + ) + + window + .coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0)) + .withOffset(CGVector(dx: editorClickOffsetX, dy: editorClickOffsetY)) + .click() + + guard waitForDataMatch( + timeout: 5.0, + predicate: { data in + data["browserContentEditableActiveElementId"] == editorId + } + ) else { + XCTFail("Expected clicking the page to re-focus the contenteditable fixture after Cmd+L. data=\(String(describing: loadData()))") + return + } + + simulateShortcut("down", app: app) + guard let postCmdLDownSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserContentEditableActiveElementId"] == editorId && + data["browserContentEditableDownCount"] == "\(baselineDownCountAfterModifiers + 1)" && + data["browserContentEditableUpCount"] == "\(baselineUpCountAfterModifiers)" && + data["browserContentEditableCommandShiftDownCount"] == "\(baselineCommandShiftDownCount)" && + data["browserContentEditableCommandShiftUpCount"] == "\(baselineCommandShiftUpCount)" + } + ) else { + XCTFail("Expected Down Arrow after Cmd+L to reach the contenteditable fixture. data=\(String(describing: loadData()))") + return + } + let postCmdLDownCount = Int(postCmdLDownSnapshot["browserContentEditableDownCount"] ?? "") ?? -1 + + simulateShortcut("up", app: app) + guard let postCmdLUpSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserContentEditableActiveElementId"] == editorId && + data["browserContentEditableDownCount"] == "\(postCmdLDownCount)" && + data["browserContentEditableUpCount"] == "\(baselineUpCountAfterModifiers + 1)" + } + ) else { + XCTFail("Expected Up Arrow after Cmd+L to reach the contenteditable fixture. data=\(String(describing: loadData()))") + return + } + let postCmdLUpCount = Int(postCmdLUpSnapshot["browserContentEditableUpCount"] ?? "") ?? -1 + + simulateShortcut("cmdShiftDown", app: app) + guard let postCmdLCommandShiftDownSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserContentEditableActiveElementId"] == editorId && + data["browserContentEditableDownCount"] == "\(postCmdLDownCount + 1)" && + data["browserContentEditableUpCount"] == "\(postCmdLUpCount)" && + data["browserContentEditableCommandShiftDownCount"] == "\(baselineCommandShiftDownCount + 1)" && + data["browserContentEditableCommandShiftUpCount"] == "\(baselineCommandShiftUpCount)" + } + ) else { + XCTFail("Expected Cmd+Shift+Down after Cmd+L to reach the contenteditable fixture. data=\(String(describing: loadData()))") + return + } + let postCmdLDownCountAfterCommandShiftDown = Int(postCmdLCommandShiftDownSnapshot["browserContentEditableDownCount"] ?? "") ?? -1 + let postCmdLCommandShiftDownCount = Int(postCmdLCommandShiftDownSnapshot["browserContentEditableCommandShiftDownCount"] ?? "") ?? -1 + + simulateShortcut("cmdShiftUp", app: app) + guard let postCmdLCommandShiftUpSnapshot = waitForDataSnapshot( + timeout: 5.0, + predicate: { data in + data["browserContentEditableActiveElementId"] == editorId && + data["browserContentEditableDownCount"] == "\(postCmdLDownCountAfterCommandShiftDown)" && + data["browserContentEditableUpCount"] == "\(postCmdLUpCount + 1)" && + data["browserContentEditableCommandShiftDownCount"] == "\(postCmdLCommandShiftDownCount)" && + data["browserContentEditableCommandShiftUpCount"] == "\(baselineCommandShiftUpCount + 1)" + } + ) else { + XCTFail("Expected Cmd+Shift+Up after Cmd+L to reach the contenteditable fixture. data=\(String(describing: loadData()))") + return + } + + XCTAssertEqual( + postCmdLCommandShiftUpSnapshot["browserContentEditableActiveElementId"], + editorId, + "Expected the clicked contenteditable fixture to remain focused after Cmd+Shift+arrows" + ) + } + func testCmdLOpensBrowserWhenTerminalFocused() { let app = XCUIApplication() app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath @@ -1014,29 +1882,76 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { } private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) { + prepareLaunchEnvironment(app) + // On headless CI runners (no GUI session), XCUIApplication.launch() - // blocks ~60s then fails with "Failed to activate application - // (current state: Running Background)". Mark this as an expected - // failure so the test can continue — keyboard and element APIs work - // via accessibility even when the app is in .runningBackground. + // can fail activation even though the app is usable through + // accessibility. Keep the launch diagnostics/socket setup above, then + // tolerate a background-only launch before failing hard. let options = XCTExpectedFailure.Options() options.isStrict = false XCTExpectFailure("App activation may fail on headless CI runners", options: options) { app.launch() } - if app.state == .runningForeground { return } + if ensureForegroundAfterLaunch(app, timeout: timeout) { + return + } if app.state == .runningBackground { - // App launched but couldn't activate — continue in background. - // XCUIElement queries and keyboard input work through the - // accessibility framework regardless of activation state. return } XCTFail("App failed to start. state=\(app.state.rawValue)") } + private func prepareLaunchEnvironment(_ app: XCUIApplication) { + if app.launchEnvironment["CMUX_UI_TEST_MODE"] == nil { + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + } + if app.launchEnvironment["CMUX_TAG"] == nil { + app.launchEnvironment["CMUX_TAG"] = launchTag + } + if app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] == nil { + app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = launchDiagnosticsPath + } + if app.launchEnvironment["CMUX_SOCKET_PATH"] != nil, + app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] == nil { + app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1" + } + if app.launchEnvironment["CMUX_SOCKET_PATH"] != nil { + if app.launchEnvironment["CMUX_ALLOW_SOCKET_OVERRIDE"] == nil { + app.launchEnvironment["CMUX_ALLOW_SOCKET_OVERRIDE"] = "1" + } + if !app.launchArguments.contains("-socketControlMode") { + app.launchArguments += ["-socketControlMode", "allowAll"] + } + if app.launchEnvironment["CMUX_SOCKET_ENABLE"] == nil { + app.launchEnvironment["CMUX_SOCKET_ENABLE"] = "1" + } + if app.launchEnvironment["CMUX_SOCKET_MODE"] == nil { + app.launchEnvironment["CMUX_SOCKET_MODE"] = "allowAll" + } + } + } + + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { + if app.wait(for: .runningForeground, timeout: timeout) { + return true + } + + let activationDeadline = Date().addingTimeInterval(12.0) + while app.state == .runningBackground && Date() < activationDeadline { + app.activate() + if app.wait(for: .runningForeground, timeout: 2.0) { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } + + return app.state == .runningForeground + } + private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { waitForCondition(timeout: timeout) { guard let data = self.loadData() else { return false } @@ -1051,12 +1966,62 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { } } + private func waitForDataSnapshot( + timeout: TimeInterval, + predicate: @escaping ([String: String]) -> Bool + ) -> [String: String]? { + var matched: [String: String]? + let didMatch = waitForCondition(timeout: timeout) { + guard let data = self.loadData(), predicate(data) else { return false } + matched = data + return true + } + return didMatch ? matched : nil + } + + private func browserArrowCountersRemainUnchanged( + down: Int, + up: Int, + commandShiftDown: Int, + commandShiftUp: Int, + timeout: TimeInterval + ) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let data = loadData() { + if data["browserArrowDownCount"] != "\(down)" || + data["browserArrowUpCount"] != "\(up)" || + data["browserArrowCommandShiftDownCount"] != "\(commandShiftDown)" || + data["browserArrowCommandShiftUpCount"] != "\(commandShiftUp)" { + return false + } + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return true + } + private func waitForNonExistence(_ element: XCUIElement, timeout: TimeInterval) -> Bool { let predicate = NSPredicate(format: "exists == false") let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } + private func simulateShortcut(_ combo: String, app: XCUIApplication) { + switch combo { + case "down": + app.typeKey(XCUIKeyboardKey.downArrow.rawValue, modifierFlags: []) + case "up": + app.typeKey(XCUIKeyboardKey.upArrow.rawValue, modifierFlags: []) + case "cmdShiftDown": + app.typeKey(XCUIKeyboardKey.downArrow.rawValue, modifierFlags: [.command, .shift]) + case "cmdShiftUp": + app.typeKey(XCUIKeyboardKey.upArrow.rawValue, modifierFlags: [.command, .shift]) + default: + XCTFail("Unsupported test shortcut combo \(combo)") + } + } + private func loadData() -> [String: String]? { guard let data = try? Data(contentsOf: URL(fileURLWithPath: dataPath)) else { return nil @@ -1071,4 +2036,5 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { ) return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } + }