Keep cmux browser Find shortcuts authoritative (#2356)
* Route browser Find shortcuts through web content first * Keep cmux browser Find shortcuts authoritative * Add browser Find inspector regression test * Fix browser Find routing follow-ups
This commit is contained in:
parent
6a39bac0e1
commit
867c93e4fa
5 changed files with 696 additions and 22 deletions
|
|
@ -1851,6 +1851,115 @@ func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum BrowserFindCommandEquivalent {
|
||||||
|
case find
|
||||||
|
case findNext
|
||||||
|
case findPrevious
|
||||||
|
case hideFind
|
||||||
|
case useSelection
|
||||||
|
|
||||||
|
var keepsCmuxBrowserFindBarOwnershipWhenVisible: Bool {
|
||||||
|
switch self {
|
||||||
|
case .find, .findNext, .findPrevious, .hideFind:
|
||||||
|
return true
|
||||||
|
case .useSelection:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cmuxIsLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool {
|
||||||
|
guard let responder else { return false }
|
||||||
|
let responderType = String(describing: type(of: responder))
|
||||||
|
if responderType.contains("WKInspector") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
guard let view = responder as? NSView else { return false }
|
||||||
|
var node: NSView? = view
|
||||||
|
var hops = 0
|
||||||
|
while let current = node, hops < 64 {
|
||||||
|
if String(describing: type(of: current)).contains("WKInspector") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
node = current.superview
|
||||||
|
hops += 1
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func browserFindCommandEquivalent(for event: NSEvent) -> BrowserFindCommandEquivalent? {
|
||||||
|
let flags = event.modifierFlags
|
||||||
|
.intersection(.deviceIndependentFlagsMask)
|
||||||
|
.subtracting([.numericPad, .function, .capsLock])
|
||||||
|
|
||||||
|
let normalizedChars = KeyboardLayout.normalizedCharacters(for: event).lowercased()
|
||||||
|
let hasSingleASCIIShortcutChar =
|
||||||
|
normalizedChars.count == 1 && normalizedChars.allSatisfy(\.isASCII)
|
||||||
|
let producedAnyASCIIShortcutChar = normalizedChars.contains(where: \.isASCII)
|
||||||
|
func matches(_ chars: String, keyCode: UInt16) -> Bool {
|
||||||
|
if hasSingleASCIIShortcutChar {
|
||||||
|
return normalizedChars == chars
|
||||||
|
}
|
||||||
|
if !producedAnyASCIIShortcutChar {
|
||||||
|
return event.keyCode == keyCode
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch flags {
|
||||||
|
case [.command]:
|
||||||
|
if matches("e", keyCode: 14) { // kVK_ANSI_E
|
||||||
|
return .useSelection
|
||||||
|
}
|
||||||
|
if matches("f", keyCode: 3) { // kVK_ANSI_F
|
||||||
|
return .find
|
||||||
|
}
|
||||||
|
if matches("g", keyCode: 5) { // kVK_ANSI_G
|
||||||
|
return .findNext
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case [.command, .shift]:
|
||||||
|
if matches("f", keyCode: 3) { // kVK_ANSI_F
|
||||||
|
return .hideFind
|
||||||
|
}
|
||||||
|
if matches("g", keyCode: 5) { // kVK_ANSI_G
|
||||||
|
return .findPrevious
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For browser content, let the page try the Find command family before cmux's menu fallback.
|
||||||
|
/// This preserves native web-app shortcuts like VS Code's Cmd+F while still allowing cmux's
|
||||||
|
/// browser find overlay to keep owning its visible Find UI shortcuts.
|
||||||
|
func shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(
|
||||||
|
_ event: NSEvent,
|
||||||
|
responder: NSResponder? = nil,
|
||||||
|
owningWebView: CmuxWebView? = nil
|
||||||
|
) -> Bool {
|
||||||
|
guard let shortcut = browserFindCommandEquivalent(for: event) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmuxIsLikelyWebInspectorResponder(responder) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if shortcut.keepsCmuxBrowserFindBarOwnershipWhenVisible,
|
||||||
|
let owningWebView {
|
||||||
|
let browserFindBarIsVisible = MainActor.assumeIsolated {
|
||||||
|
AppDelegate.shared?.browserFindBarIsVisible(for: owningWebView) == true
|
||||||
|
}
|
||||||
|
if browserFindBarIsVisible {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? {
|
func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? {
|
||||||
guard let responder else { return nil }
|
guard let responder else { return nil }
|
||||||
if let ghosttyView = responder as? GhosttyNSView {
|
if let ghosttyView = responder as? GhosttyNSView {
|
||||||
|
|
@ -2132,6 +2241,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
private var didSetupGotoSplitUITest = false
|
private var didSetupGotoSplitUITest = false
|
||||||
private var didSetupBonsplitTabDragUITest = false
|
private var didSetupBonsplitTabDragUITest = false
|
||||||
private var bonsplitTabDragUITestRecorder: DispatchSourceTimer?
|
private var bonsplitTabDragUITestRecorder: DispatchSourceTimer?
|
||||||
|
private var gotoSplitUITestRecorder: DispatchSourceTimer?
|
||||||
private var gotoSplitUITestObservers: [NSObjectProtocol] = []
|
private var gotoSplitUITestObservers: [NSObjectProtocol] = []
|
||||||
private var didSetupMultiWindowNotificationsUITest = false
|
private var didSetupMultiWindowNotificationsUITest = false
|
||||||
private var didSetupDisplayResolutionUITestDiagnostics = false
|
private var didSetupDisplayResolutionUITestDiagnostics = false
|
||||||
|
|
@ -7226,7 +7336,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = URL(string: "https://example.com")
|
let requestedBrowserURL = env["CMUX_UI_TEST_GOTO_SPLIT_BROWSER_URL"]?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let url = requestedBrowserURL.flatMap { rawURL in
|
||||||
|
guard !rawURL.isEmpty else { return nil }
|
||||||
|
return URL(string: rawURL)
|
||||||
|
} ?? URL(string: "https://example.com")
|
||||||
|
guard let url else {
|
||||||
|
self.writeGotoSplitTestData(["setupError": "Invalid browser URL"])
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let browserPanelId = tabManager.newBrowserSplit(
|
guard let browserPanelId = tabManager.newBrowserSplit(
|
||||||
tabId: tab.id,
|
tabId: tab.id,
|
||||||
fromPanelId: initialPanelId,
|
fromPanelId: initialPanelId,
|
||||||
|
|
@ -7460,12 +7579,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
.first(where: { $0.searchState != nil })
|
.first(where: { $0.searchState != nil })
|
||||||
updates["terminalFindPanelId"] = terminalWithFind?.id.uuidString ?? ""
|
updates["terminalFindPanelId"] = terminalWithFind?.id.uuidString ?? ""
|
||||||
updates["terminalFindNeedle"] = terminalWithFind?.searchState?.needle ?? ""
|
updates["terminalFindNeedle"] = terminalWithFind?.searchState?.needle ?? ""
|
||||||
|
updates["terminalFindVisible"] = terminalWithFind == nil ? "false" : "true"
|
||||||
|
|
||||||
let browserWithFind = workspace.panels.values
|
let browserWithFind = workspace.panels.values
|
||||||
.compactMap { $0 as? BrowserPanel }
|
.compactMap { $0 as? BrowserPanel }
|
||||||
.first(where: { $0.searchState != nil })
|
.first(where: { $0.searchState != nil })
|
||||||
updates["browserFindPanelId"] = browserWithFind?.id.uuidString ?? ""
|
updates["browserFindPanelId"] = browserWithFind?.id.uuidString ?? ""
|
||||||
updates["browserFindNeedle"] = browserWithFind?.searchState?.needle ?? ""
|
updates["browserFindNeedle"] = browserWithFind?.searchState?.needle ?? ""
|
||||||
|
updates["browserFindSelected"] = browserWithFind?.searchState?.selected.map {
|
||||||
|
String($0 + 1)
|
||||||
|
} ?? ""
|
||||||
|
updates["browserFindTotal"] = browserWithFind?.searchState?.total.map(String.init) ?? ""
|
||||||
|
updates["browserFindVisible"] = browserWithFind == nil ? "false" : "true"
|
||||||
|
|
||||||
return updates
|
return updates
|
||||||
}
|
}
|
||||||
|
|
@ -7513,6 +7638,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
|
|
||||||
resolved = true
|
resolved = true
|
||||||
cleanup()
|
cleanup()
|
||||||
|
self.startGotoSplitUITestRecorder(browserPanelId: browserPanelId)
|
||||||
writeGotoSplitTestData([
|
writeGotoSplitTestData([
|
||||||
"browserPanelId": browserPanelId.uuidString,
|
"browserPanelId": browserPanelId.uuidString,
|
||||||
"browserPaneId": browserPaneId.description,
|
"browserPaneId": browserPaneId.description,
|
||||||
|
|
@ -7563,6 +7689,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
recordFocusedState()
|
recordFocusedState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func startGotoSplitUITestRecorder(browserPanelId: UUID) {
|
||||||
|
guard isGotoSplitUITestRecordingEnabled() else { return }
|
||||||
|
gotoSplitUITestRecorder?.cancel()
|
||||||
|
gotoSplitUITestRecorder = nil
|
||||||
|
|
||||||
|
let timer = DispatchSource.makeTimerSource(queue: .main)
|
||||||
|
timer.schedule(deadline: .now(), repeating: .milliseconds(100))
|
||||||
|
timer.setEventHandler { [weak self] in
|
||||||
|
self?.recordGotoSplitUITestState(browserPanelId: browserPanelId)
|
||||||
|
}
|
||||||
|
gotoSplitUITestRecorder = timer
|
||||||
|
timer.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordGotoSplitUITestState(browserPanelId: UUID) {
|
||||||
|
guard let tabManager,
|
||||||
|
let workspace = tabManager.selectedWorkspace,
|
||||||
|
let browserPanel = workspace.browserPanel(for: browserPanelId) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates = gotoSplitFindStateSnapshot(for: workspace)
|
||||||
|
updates["browserPageTitle"] = browserPanel.webView.title?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
updates["browserPageURL"] = browserPanel.preferredURLStringForOmnibar() ?? ""
|
||||||
|
writeGotoSplitTestData(updates)
|
||||||
|
}
|
||||||
|
|
||||||
private func isWebViewFocused(_ panel: BrowserPanel) -> Bool {
|
private func isWebViewFocused(_ panel: BrowserPanel) -> Bool {
|
||||||
guard let window = panel.webView.window else { return false }
|
guard let window = panel.webView.window else { return false }
|
||||||
guard let fr = window.firstResponder as? NSView else { return false }
|
guard let fr = window.firstResponder as? NSView else { return false }
|
||||||
|
|
@ -10414,22 +10568,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool {
|
private func isLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool {
|
||||||
guard let responder else { return false }
|
cmuxIsLikelyWebInspectorResponder(responder)
|
||||||
let responderType = String(describing: type(of: responder))
|
|
||||||
if responderType.contains("WKInspector") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
guard let view = responder as? NSView else { return false }
|
|
||||||
var node: NSView? = view
|
|
||||||
var hops = 0
|
|
||||||
while let current = node, hops < 64 {
|
|
||||||
if String(describing: type(of: current)).contains("WKInspector") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
node = current.superview
|
|
||||||
hops += 1
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|
@ -11259,6 +11398,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
return tabManager?.selectedWorkspace?.browserPanel(for: panelId)
|
return tabManager?.selectedWorkspace?.browserPanel(for: panelId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fileprivate func browserFindBarIsVisible(for webView: CmuxWebView) -> Bool {
|
||||||
|
browserPanelOwning(webView)?.searchState != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func browserPanelOwning(_ webView: CmuxWebView) -> BrowserPanel? {
|
||||||
|
var candidateManagers: [TabManager] = []
|
||||||
|
var seenManagers = Set<ObjectIdentifier>()
|
||||||
|
|
||||||
|
func appendCandidate(_ manager: TabManager?) {
|
||||||
|
guard let manager else { return }
|
||||||
|
let identifier = ObjectIdentifier(manager)
|
||||||
|
guard seenManagers.insert(identifier).inserted else { return }
|
||||||
|
candidateManagers.append(manager)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let window = webView.window,
|
||||||
|
let context = contextForMainWindow(window) {
|
||||||
|
appendCandidate(context.tabManager)
|
||||||
|
}
|
||||||
|
appendCandidate(tabManager)
|
||||||
|
for context in mainWindowContexts.values {
|
||||||
|
appendCandidate(context.tabManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
for manager in candidateManagers {
|
||||||
|
if let panel = browserPanelOwning(webView, in: manager) {
|
||||||
|
return panel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func browserPanelOwning(_ webView: CmuxWebView, in manager: TabManager) -> BrowserPanel? {
|
||||||
|
for workspace in manager.tabs {
|
||||||
|
if let panel = workspace.panels.values
|
||||||
|
.compactMap({ $0 as? BrowserPanel })
|
||||||
|
.first(where: { $0.webView === webView }) {
|
||||||
|
return panel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
private func setActiveMainWindow(_ window: NSWindow) {
|
private func setActiveMainWindow(_ window: NSWindow) {
|
||||||
guard let context = contextForMainTerminalWindow(window) else { return }
|
guard let context = contextForMainTerminalWindow(window) else { return }
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|
@ -12726,6 +12908,27 @@ private extension NSWindow {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let firstResponderWebView,
|
||||||
|
shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(
|
||||||
|
event,
|
||||||
|
responder: self.firstResponder,
|
||||||
|
owningWebView: firstResponderWebView
|
||||||
|
) {
|
||||||
|
let result = firstResponderWebView.performKeyEquivalent(with: event)
|
||||||
|
#if DEBUG
|
||||||
|
if result {
|
||||||
|
dlog(" → browser find command resolved before window menu path")
|
||||||
|
} else {
|
||||||
|
dlog(" → browser find command preflight left unclaimed; suppressing replay")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
// The focused web view has already received this Find-family shortcut once.
|
||||||
|
// Do not fall through into the original NSWindow.performKeyEquivalent path,
|
||||||
|
// or WebKit can observe the same key equivalent a second time before AppKit
|
||||||
|
// reaches keyDown/menu fallback.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true {
|
if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
dlog(" → consumed by handleBrowserSurfaceKeyEquivalent")
|
dlog(" → consumed by handleBrowserSurfaceKeyEquivalent")
|
||||||
|
|
|
||||||
|
|
@ -103,9 +103,9 @@ enum BrowserImageCopyPasteboardBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WKWebView tends to consume some Command-key equivalents (e.g. Cmd+N/Cmd+W),
|
/// WKWebView tends to consume some Command-key equivalents (e.g. Cmd+N/Cmd+W),
|
||||||
/// preventing the app menu/SwiftUI Commands from receiving them. Route menu
|
/// preventing the app menu/SwiftUI Commands from receiving them. Route app/menu
|
||||||
/// key equivalents first so app-level shortcuts continue to work when WebKit is
|
/// shortcuts first by default, but allow browser content to try the Find command
|
||||||
/// the first responder.
|
/// family before cmux falls back to its own browser find overlay.
|
||||||
final class CmuxWebView: WKWebView {
|
final class CmuxWebView: WKWebView {
|
||||||
// Some sites/WebKit paths report middle-click link activations as
|
// Some sites/WebKit paths report middle-click link activations as
|
||||||
// WKNavigationAction.buttonNumber=4 instead of 2. Track a recent local
|
// WKNavigationAction.buttonNumber=4 instead of 2. Track a recent local
|
||||||
|
|
@ -248,6 +248,22 @@ final class CmuxWebView: WKWebView {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var replayedBrowserFindShortcutIntoWebContent = false
|
||||||
|
if shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(
|
||||||
|
event,
|
||||||
|
responder: window?.firstResponder,
|
||||||
|
owningWebView: self
|
||||||
|
) {
|
||||||
|
replayedBrowserFindShortcutIntoWebContent = true
|
||||||
|
let result = super.performKeyEquivalent(with: event)
|
||||||
|
#if DEBUG
|
||||||
|
handled = result
|
||||||
|
#endif
|
||||||
|
if result {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !shouldRouteCommandEquivalentDirectlyToMainMenu(event) {
|
if !shouldRouteCommandEquivalentDirectlyToMainMenu(event) {
|
||||||
let result = super.performKeyEquivalent(with: event)
|
let result = super.performKeyEquivalent(with: event)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|
@ -273,7 +289,14 @@ final class CmuxWebView: WKWebView {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = super.performKeyEquivalent(with: event)
|
let result: Bool
|
||||||
|
if replayedBrowserFindShortcutIntoWebContent {
|
||||||
|
// A browser-first Find preflight has already exposed this shortcut to WebKit once.
|
||||||
|
// Avoid a second `super.performKeyEquivalent` replay when menu/app fallback does not claim it.
|
||||||
|
result = false
|
||||||
|
} else {
|
||||||
|
result = super.performKeyEquivalent(with: event)
|
||||||
|
}
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
handled = result
|
handled = result
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,36 @@ import XCTest
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private let appDelegateLastSurfaceCloseShortcutDefaultsKey = "closeWorkspaceOnLastSurfaceShortcut"
|
private let appDelegateLastSurfaceCloseShortcutDefaultsKey = "closeWorkspaceOnLastSurfaceShortcut"
|
||||||
|
private final class FakeWKInspectorContainerView: NSView {}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class AppDelegateShortcutRoutingTests: XCTestCase {
|
final class AppDelegateShortcutRoutingTests: XCTestCase {
|
||||||
private var savedShortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:]
|
private var savedShortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:]
|
||||||
private var actionsWithPersistedShortcut: Set<KeyboardShortcutSettings.Action> = []
|
private var actionsWithPersistedShortcut: Set<KeyboardShortcutSettings.Action> = []
|
||||||
|
|
||||||
|
private func makeKeyEvent(
|
||||||
|
modifierFlags: NSEvent.ModifierFlags,
|
||||||
|
characters: String,
|
||||||
|
charactersIgnoringModifiers: String,
|
||||||
|
keyCode: UInt16
|
||||||
|
) -> NSEvent {
|
||||||
|
guard let event = NSEvent.keyEvent(
|
||||||
|
with: .keyDown,
|
||||||
|
location: .zero,
|
||||||
|
modifierFlags: modifierFlags,
|
||||||
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||||
|
windowNumber: 0,
|
||||||
|
context: nil,
|
||||||
|
characters: characters,
|
||||||
|
charactersIgnoringModifiers: charactersIgnoringModifiers,
|
||||||
|
isARepeat: false,
|
||||||
|
keyCode: keyCode
|
||||||
|
) else {
|
||||||
|
fatalError("Failed to construct key event")
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
override func setUp() {
|
override func setUp() {
|
||||||
super.setUp()
|
super.setUp()
|
||||||
// Prevent a single hanging test from consuming the entire CI timeout budget.
|
// Prevent a single hanging test from consuming the entire CI timeout budget.
|
||||||
|
|
@ -3092,6 +3116,110 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
||||||
|
|
||||||
// MARK: - Non-Latin keyboard layout shortcut tests
|
// MARK: - Non-Latin keyboard layout shortcut tests
|
||||||
|
|
||||||
|
func testBrowserFirstFindShortcutRoutingRecognizesFindCommandFamily() {
|
||||||
|
let cases: [(name: String, modifiers: NSEvent.ModifierFlags, chars: String, keyCode: UInt16)] = [
|
||||||
|
("cmd-f", [.command], "f", 3),
|
||||||
|
("cmd-g", [.command], "g", 5),
|
||||||
|
("cmd-shift-g", [.command, .shift], "g", 5),
|
||||||
|
("cmd-shift-f", [.command, .shift], "f", 3),
|
||||||
|
("cmd-e", [.command], "e", 14),
|
||||||
|
]
|
||||||
|
|
||||||
|
for testCase in cases {
|
||||||
|
let event = makeKeyEvent(
|
||||||
|
modifierFlags: testCase.modifiers,
|
||||||
|
characters: testCase.chars,
|
||||||
|
charactersIgnoringModifiers: testCase.chars,
|
||||||
|
keyCode: testCase.keyCode
|
||||||
|
)
|
||||||
|
XCTAssertTrue(
|
||||||
|
shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(event),
|
||||||
|
"Expected browser-first routing for \(testCase.name)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBrowserFirstFindShortcutRoutingFallsBackToKeyCodeForNonLatinInput() {
|
||||||
|
let event = makeKeyEvent(
|
||||||
|
modifierFlags: [.command],
|
||||||
|
characters: "",
|
||||||
|
charactersIgnoringModifiers: "а", // Cyrillic a from a non-Latin input source
|
||||||
|
keyCode: 3 // kVK_ANSI_F
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(event),
|
||||||
|
"Expected browser-first routing to keep Cmd+F eligible under non-Latin input"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBrowserFirstFindShortcutRoutingDoesNotUseANSIPositionsForMismatchedASCIICharacters() {
|
||||||
|
let cases: [(name: String, modifiers: NSEvent.ModifierFlags, chars: String, keyCode: UInt16)] = [
|
||||||
|
("cmd-u-on-ansi-f", [.command], "u", 3),
|
||||||
|
("cmd-o-on-ansi-g", [.command], "o", 5),
|
||||||
|
("cmd-period-on-ansi-e", [.command], ".", 14),
|
||||||
|
("cmd-shift-u-on-ansi-f", [.command, .shift], "u", 3),
|
||||||
|
("cmd-shift-o-on-ansi-g", [.command, .shift], "o", 5),
|
||||||
|
]
|
||||||
|
|
||||||
|
for testCase in cases {
|
||||||
|
let event = makeKeyEvent(
|
||||||
|
modifierFlags: testCase.modifiers,
|
||||||
|
characters: testCase.chars,
|
||||||
|
charactersIgnoringModifiers: testCase.chars,
|
||||||
|
keyCode: testCase.keyCode
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertFalse(
|
||||||
|
shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(event),
|
||||||
|
"Did not expect browser-first routing for mismatched ASCII shortcut \(testCase.name)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBrowserFirstFindShortcutRoutingExcludesWebInspectorResponders() {
|
||||||
|
let inspectorContainer = FakeWKInspectorContainerView(frame: .zero)
|
||||||
|
let inspectorChild = NSView(frame: .zero)
|
||||||
|
inspectorContainer.addSubview(inspectorChild)
|
||||||
|
|
||||||
|
let event = makeKeyEvent(
|
||||||
|
modifierFlags: [.command],
|
||||||
|
characters: "f",
|
||||||
|
charactersIgnoringModifiers: "f",
|
||||||
|
keyCode: 3
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertFalse(
|
||||||
|
shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(
|
||||||
|
event,
|
||||||
|
responder: inspectorChild
|
||||||
|
),
|
||||||
|
"Did not expect browser-first routing while a Web Inspector responder is focused"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBrowserFirstFindShortcutRoutingExcludesNonFindCommands() {
|
||||||
|
let cases: [(name: String, modifiers: NSEvent.ModifierFlags, chars: String, keyCode: UInt16)] = [
|
||||||
|
("cmd-n", [.command], "n", 45),
|
||||||
|
("cmd-w", [.command], "w", 13),
|
||||||
|
("cmd-l", [.command], "l", 37),
|
||||||
|
("cmd-option-f", [.command, .option], "f", 3),
|
||||||
|
]
|
||||||
|
|
||||||
|
for testCase in cases {
|
||||||
|
let event = makeKeyEvent(
|
||||||
|
modifierFlags: testCase.modifiers,
|
||||||
|
characters: testCase.chars,
|
||||||
|
charactersIgnoringModifiers: testCase.chars,
|
||||||
|
keyCode: testCase.keyCode
|
||||||
|
)
|
||||||
|
XCTAssertFalse(
|
||||||
|
shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(event),
|
||||||
|
"Did not expect browser-first routing for \(testCase.name)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testCmdTWorksWithRussianKeyboardLayout() {
|
func testCmdTWorksWithRussianKeyboardLayout() {
|
||||||
guard let appDelegate = AppDelegate.shared else {
|
guard let appDelegate = AppDelegate.shared else {
|
||||||
XCTFail("Expected AppDelegate.shared")
|
XCTFail("Expected AppDelegate.shared")
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import UserNotifications
|
||||||
|
|
||||||
var cmuxUnitTestInspectorAssociationKey: UInt8 = 0
|
var cmuxUnitTestInspectorAssociationKey: UInt8 = 0
|
||||||
var cmuxUnitTestInspectorOverrideInstalled = false
|
var cmuxUnitTestInspectorOverrideInstalled = false
|
||||||
|
var cmuxUnitTestWKWebViewPerformKeyEquivalentOverrideInstalled = false
|
||||||
|
var cmuxUnitTestWKWebViewPerformKeyEquivalentHook: ((WKWebView, NSEvent) -> Bool?)?
|
||||||
|
|
||||||
extension CmuxWebView {
|
extension CmuxWebView {
|
||||||
@objc func cmuxUnitTestInspector() -> NSObject? {
|
@objc func cmuxUnitTestInspector() -> NSObject? {
|
||||||
|
|
@ -24,6 +26,14 @@ extension CmuxWebView {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension WKWebView {
|
extension WKWebView {
|
||||||
|
@objc func cmuxUnitTest_performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||||
|
if let hook = cmuxUnitTestWKWebViewPerformKeyEquivalentHook,
|
||||||
|
let result = hook(self, event) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return cmuxUnitTest_performKeyEquivalent(with: event)
|
||||||
|
}
|
||||||
|
|
||||||
func cmuxSetUnitTestInspector(_ inspector: NSObject?) {
|
func cmuxSetUnitTestInspector(_ inspector: NSObject?) {
|
||||||
objc_setAssociatedObject(
|
objc_setAssociatedObject(
|
||||||
self,
|
self,
|
||||||
|
|
@ -57,6 +67,38 @@ func installCmuxUnitTestInspectorOverride() {
|
||||||
cmuxUnitTestInspectorOverrideInstalled = true
|
cmuxUnitTestInspectorOverrideInstalled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func installCmuxUnitTestWKWebViewPerformKeyEquivalentOverride() {
|
||||||
|
guard !cmuxUnitTestWKWebViewPerformKeyEquivalentOverrideInstalled else { return }
|
||||||
|
|
||||||
|
let originalSelector = #selector(NSResponder.performKeyEquivalent(with:))
|
||||||
|
let swizzledSelector = #selector(WKWebView.cmuxUnitTest_performKeyEquivalent(with:))
|
||||||
|
|
||||||
|
guard let originalMethod = class_getInstanceMethod(WKWebView.self, originalSelector),
|
||||||
|
let swizzledMethod = class_getInstanceMethod(WKWebView.self, swizzledSelector) else {
|
||||||
|
fatalError("Unable to locate WKWebView performKeyEquivalent methods for swizzling")
|
||||||
|
}
|
||||||
|
|
||||||
|
let didAddMethod = class_addMethod(
|
||||||
|
WKWebView.self,
|
||||||
|
originalSelector,
|
||||||
|
method_getImplementation(swizzledMethod),
|
||||||
|
method_getTypeEncoding(swizzledMethod)
|
||||||
|
)
|
||||||
|
|
||||||
|
if didAddMethod {
|
||||||
|
class_replaceMethod(
|
||||||
|
WKWebView.self,
|
||||||
|
swizzledSelector,
|
||||||
|
method_getImplementation(originalMethod),
|
||||||
|
method_getTypeEncoding(originalMethod)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
method_exchangeImplementations(originalMethod, swizzledMethod)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmuxUnitTestWKWebViewPerformKeyEquivalentOverrideInstalled = true
|
||||||
|
}
|
||||||
|
|
||||||
private final class BrowserMarkedTextProbeTextView: NSTextView {
|
private final class BrowserMarkedTextProbeTextView: NSTextView {
|
||||||
var hasMarkedTextForTesting = false
|
var hasMarkedTextForTesting = false
|
||||||
private(set) var keyDownEvents: [NSEvent] = []
|
private(set) var keyDownEvents: [NSEvent] = []
|
||||||
|
|
@ -102,6 +144,10 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
|
||||||
override var acceptsFirstResponder: Bool { true }
|
override var acceptsFirstResponder: Bool { true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class FakeWKInspectorResponderView: NSView {
|
||||||
|
override var acceptsFirstResponder: Bool { true }
|
||||||
|
}
|
||||||
|
|
||||||
private final class DelegateProbeTextView: NSTextView {
|
private final class DelegateProbeTextView: NSTextView {
|
||||||
private(set) var delegateReadCount = 0
|
private(set) var delegateReadCount = 0
|
||||||
|
|
||||||
|
|
@ -780,6 +826,65 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testCmdFDoesNotPreflightIntoPageWhenWebInspectorResponderIsFocused() {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
installCmuxUnitTestWKWebViewPerformKeyEquivalentOverride()
|
||||||
|
|
||||||
|
let spy = ActionSpy()
|
||||||
|
installMenu(spy: spy, key: "f", modifiers: [.command])
|
||||||
|
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
|
||||||
|
styleMask: [.titled, .closable],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
|
||||||
|
window.contentView = container
|
||||||
|
|
||||||
|
let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
|
||||||
|
webView.autoresizingMask = [.width, .height]
|
||||||
|
container.addSubview(webView)
|
||||||
|
|
||||||
|
let inspectorView = FakeWKInspectorResponderView(frame: NSRect(x: 0, y: 0, width: 32, height: 20))
|
||||||
|
webView.addSubview(inspectorView)
|
||||||
|
|
||||||
|
var forwardedEvents: [NSEvent] = []
|
||||||
|
cmuxUnitTestWKWebViewPerformKeyEquivalentHook = { currentWebView, event in
|
||||||
|
guard currentWebView === webView else { return nil }
|
||||||
|
forwardedEvents.append(event)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
defer {
|
||||||
|
cmuxUnitTestWKWebViewPerformKeyEquivalentHook = nil
|
||||||
|
window.orderOut(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertTrue(window.makeFirstResponder(inspectorView))
|
||||||
|
guard let event = makeKeyDownEvent(
|
||||||
|
key: "f",
|
||||||
|
modifiers: [.command],
|
||||||
|
keyCode: 3,
|
||||||
|
windowNumber: window.windowNumber
|
||||||
|
) else {
|
||||||
|
XCTFail("Failed to construct Cmd+F event")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let consumed = webView.performKeyEquivalent(with: event)
|
||||||
|
|
||||||
|
XCTAssertTrue(consumed, "Expected the menu/inspector path to keep consuming Cmd+F")
|
||||||
|
XCTAssertTrue(spy.invoked, "Expected Cmd+F to stay on the menu/inspector path while Web Inspector is focused")
|
||||||
|
XCTAssertEqual(
|
||||||
|
forwardedEvents.count,
|
||||||
|
0,
|
||||||
|
"Did not expect CmuxWebView to preflight Cmd+F into page content while Web Inspector is focused"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) {
|
private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) {
|
||||||
installMenu(
|
installMenu(
|
||||||
target: spy,
|
target: spy,
|
||||||
|
|
|
||||||
|
|
@ -89,12 +89,119 @@ final class MenuKeyEquivalentRoutingUITests: XCTestCase {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func launchWithBrowserSetup() -> XCUIApplication {
|
func testCmdFFirstLetsWebContentHandleFindShortcut() {
|
||||||
|
let app = launchWithBrowserSetup(browserURL: makeBrowserHandledCmdFPageURL())
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
waitForGotoSplitMatch(timeout: 10.0) { data in
|
||||||
|
data["browserPageTitle"] == "cmdf-pending"
|
||||||
|
},
|
||||||
|
"Expected the browser test page to finish loading before Cmd+F"
|
||||||
|
)
|
||||||
|
|
||||||
|
app.typeKey("f", modifierFlags: [.command])
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
waitForGotoSplitMatch(timeout: 5.0) { data in
|
||||||
|
data["browserPageTitle"] == "cmdf-handled" &&
|
||||||
|
data["browserFindVisible"] == "false"
|
||||||
|
},
|
||||||
|
"Expected Cmd+F to reach browser content before cmux find overlay. data=\(loadGotoSplit() ?? [:])"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBrowserFirstFindShortcutDoesNotReplayUnclaimedCmdEIntoWebContentTwice() {
|
||||||
|
let app = launchWithBrowserSetup(browserURL: makeBrowserObservedCmdEPageURL())
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
waitForGotoSplitMatch(timeout: 10.0) { data in
|
||||||
|
data["browserPageTitle"] == "cmde-0"
|
||||||
|
},
|
||||||
|
"Expected the Cmd+E test page to finish loading before the shortcut. data=\(loadGotoSplit() ?? [:])"
|
||||||
|
)
|
||||||
|
|
||||||
|
app.typeKey("e", modifierFlags: [.command])
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
waitForGotoSplitMatch(timeout: 5.0) { data in
|
||||||
|
data["browserPageTitle"] == "cmde-1"
|
||||||
|
},
|
||||||
|
"Expected Cmd+E to reach browser content exactly once. data=\(loadGotoSplit() ?? [:])"
|
||||||
|
)
|
||||||
|
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||||
|
XCTAssertEqual(
|
||||||
|
loadGotoSplit()?["browserPageTitle"],
|
||||||
|
"cmde-1",
|
||||||
|
"Expected Cmd+E to avoid a second WebKit replay. data=\(loadGotoSplit() ?? [:])"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVisibleBrowserFindBarKeepsCmdGAndCmdShiftFOwnedByCmux() {
|
||||||
|
let app = launchWithBrowserSetup(browserURL: makeVisibleBrowserFindOwnershipPageURL())
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
waitForGotoSplitMatch(timeout: 10.0) { data in
|
||||||
|
data["browserPageTitle"] == "find-owner-idle"
|
||||||
|
},
|
||||||
|
"Expected the browser find ownership page to finish loading before opening find. data=\(loadGotoSplit() ?? [:])"
|
||||||
|
)
|
||||||
|
|
||||||
|
app.typeKey("f", modifierFlags: [.command])
|
||||||
|
|
||||||
|
let findField = app.textFields["BrowserFindSearchTextField"].firstMatch
|
||||||
|
XCTAssertTrue(findField.waitForExistence(timeout: 6.0), "Expected browser find field after Cmd+F")
|
||||||
|
|
||||||
|
app.typeText("needle")
|
||||||
|
XCTAssertTrue(
|
||||||
|
waitForGotoSplitMatch(timeout: 6.0) { data in
|
||||||
|
data["browserFindVisible"] == "true" &&
|
||||||
|
data["browserFindNeedle"] == "needle" &&
|
||||||
|
data["browserFindSelected"] == "1" &&
|
||||||
|
data["browserFindTotal"] == "3"
|
||||||
|
},
|
||||||
|
"Expected cmux browser find bar to open and capture the query before page-focus checks. data=\(loadGotoSplit() ?? [:])"
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let browserPanelId = loadGotoSplit()?["browserPanelId"], !browserPanelId.isEmpty else {
|
||||||
|
XCTFail("Missing browserPanelId in goto_split setup data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clickBrowserPane(app: app, browserPanelId: browserPanelId)
|
||||||
|
|
||||||
|
app.typeKey("g", modifierFlags: [.command])
|
||||||
|
XCTAssertTrue(
|
||||||
|
waitForGotoSplitMatch(timeout: 6.0) { data in
|
||||||
|
data["browserPageTitle"] == "find-owner-idle" &&
|
||||||
|
data["browserFindVisible"] == "true" &&
|
||||||
|
data["browserFindSelected"] == "2" &&
|
||||||
|
data["browserFindTotal"] == "3"
|
||||||
|
},
|
||||||
|
"Expected visible cmux browser find bar to keep Cmd+G ownership after page refocus. data=\(loadGotoSplit() ?? [:])"
|
||||||
|
)
|
||||||
|
|
||||||
|
clickBrowserPane(app: app, browserPanelId: browserPanelId)
|
||||||
|
|
||||||
|
app.typeKey("f", modifierFlags: [.command, .shift])
|
||||||
|
XCTAssertTrue(
|
||||||
|
waitForGotoSplitMatch(timeout: 6.0) { data in
|
||||||
|
data["browserPageTitle"] == "find-owner-idle" &&
|
||||||
|
data["browserFindVisible"] == "false"
|
||||||
|
},
|
||||||
|
"Expected visible cmux browser find bar to keep Cmd+Shift+F ownership after page refocus. data=\(loadGotoSplit() ?? [:])"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func launchWithBrowserSetup(browserURL: String? = nil) -> XCUIApplication {
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = gotoSplitPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = gotoSplitPath
|
||||||
app.launchEnvironment["CMUX_UI_TEST_KEYEQUIV_PATH"] = keyequivPath
|
app.launchEnvironment["CMUX_UI_TEST_KEYEQUIV_PATH"] = keyequivPath
|
||||||
|
if let browserURL {
|
||||||
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_BROWSER_URL"] = browserURL
|
||||||
|
}
|
||||||
app.launch()
|
app.launch()
|
||||||
app.activate()
|
app.activate()
|
||||||
|
|
||||||
|
|
@ -110,6 +217,114 @@ final class MenuKeyEquivalentRoutingUITests: XCTestCase {
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeBrowserHandledCmdFPageURL() -> String {
|
||||||
|
let html = """
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>cmdf-pending</title>
|
||||||
|
</head>
|
||||||
|
<body tabindex="-1">
|
||||||
|
<main>Browser find shortcut passthrough</main>
|
||||||
|
<script>
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
document.body.focus();
|
||||||
|
});
|
||||||
|
window.addEventListener('keydown', (event) => {
|
||||||
|
const key = String(event.key || '').toLowerCase();
|
||||||
|
if (event.metaKey && !event.shiftKey && !event.altKey && !event.ctrlKey && key === 'f') {
|
||||||
|
event.preventDefault();
|
||||||
|
document.title = 'cmdf-handled';
|
||||||
|
document.body.dataset.cmdf = 'handled';
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return makeDataURL(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeBrowserObservedCmdEPageURL() -> String {
|
||||||
|
let html = """
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>cmde-0</title>
|
||||||
|
</head>
|
||||||
|
<body tabindex="-1">
|
||||||
|
<main>Cmd+E should only reach the page once</main>
|
||||||
|
<script>
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
document.body.focus();
|
||||||
|
});
|
||||||
|
let countState = { value: 0 };
|
||||||
|
window.addEventListener('keydown', (event) => {
|
||||||
|
const key = String(event.key || '').toLowerCase();
|
||||||
|
if (event.metaKey && !event.shiftKey && !event.altKey && !event.ctrlKey && key === 'e') {
|
||||||
|
countState.value += 1;
|
||||||
|
document.title = `cmde-${countState.value}`;
|
||||||
|
document.body.dataset.cmdeCount = String(countState.value);
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return makeDataURL(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeVisibleBrowserFindOwnershipPageURL() -> String {
|
||||||
|
let html = """
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>find-owner-idle</title>
|
||||||
|
</head>
|
||||||
|
<body tabindex="-1">
|
||||||
|
<main>needle alpha</main>
|
||||||
|
<main>needle beta</main>
|
||||||
|
<main>needle gamma</main>
|
||||||
|
<script>
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
document.body.focus();
|
||||||
|
});
|
||||||
|
window.addEventListener('keydown', (event) => {
|
||||||
|
const key = String(event.key || '').toLowerCase();
|
||||||
|
if (event.metaKey && !event.altKey && !event.ctrlKey && !event.shiftKey && key === 'g') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
document.title = 'page-handled-cmdg';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.metaKey && event.shiftKey && !event.altKey && !event.ctrlKey && key === 'f') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
document.title = 'page-handled-cmdshiftf';
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return makeDataURL(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeDataURL(_ html: String) -> String {
|
||||||
|
let encoded = Data(html.utf8).base64EncodedString()
|
||||||
|
return "data:text/html;base64,\(encoded)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clickBrowserPane(app: XCUIApplication, browserPanelId: String) {
|
||||||
|
let browserPane = app.otherElements["BrowserPanelContent.\(browserPanelId)"].firstMatch
|
||||||
|
XCTAssertTrue(browserPane.waitForExistence(timeout: 6.0), "Expected browser pane content for click target")
|
||||||
|
browserPane.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click()
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.15))
|
||||||
|
}
|
||||||
|
|
||||||
private func refocusWebView(app: XCUIApplication) {
|
private func refocusWebView(app: XCUIApplication) {
|
||||||
// Cmd+L focuses the omnibar (so WebKit is no longer first responder).
|
// Cmd+L focuses the omnibar (so WebKit is no longer first responder).
|
||||||
app.typeKey("l", modifierFlags: [.command])
|
app.typeKey("l", modifierFlags: [.command])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue