CmuxWebViewKeyEquivalentTests.swift grew to 15,907 lines with 100+ test classes. Swift compiles per-file, so this single file serialized all type-checking onto one compiler process, pushing CI past the 20-minute timeout after core-file changes. Split into 10 domain-based files (1k-3k lines each) so Xcode can compile them in parallel. Also bump timeout-minutes from 20 to 30 for headroom, stream xcodebuild output via tee instead of capturing to a variable (makes CI logs debuggable), and add 5 test files that were missing from the pbxproj Sources build phase. Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
3108 lines
114 KiB
Swift
3108 lines
114 KiB
Swift
import XCTest
|
|
import AppKit
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
import WebKit
|
|
import ObjectiveC.runtime
|
|
import Bonsplit
|
|
import UserNotifications
|
|
|
|
#if canImport(cmux_DEV)
|
|
@testable import cmux_DEV
|
|
#elseif canImport(cmux)
|
|
@testable import cmux
|
|
#endif
|
|
|
|
var cmuxUnitTestInspectorAssociationKey: UInt8 = 0
|
|
var cmuxUnitTestInspectorOverrideInstalled = false
|
|
|
|
extension CmuxWebView {
|
|
@objc func cmuxUnitTestInspector() -> NSObject? {
|
|
objc_getAssociatedObject(self, &cmuxUnitTestInspectorAssociationKey) as? NSObject
|
|
}
|
|
}
|
|
|
|
extension WKWebView {
|
|
func cmuxSetUnitTestInspector(_ inspector: NSObject?) {
|
|
objc_setAssociatedObject(
|
|
self,
|
|
&cmuxUnitTestInspectorAssociationKey,
|
|
inspector,
|
|
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
|
)
|
|
}
|
|
}
|
|
|
|
func installCmuxUnitTestInspectorOverride() {
|
|
guard !cmuxUnitTestInspectorOverrideInstalled else { return }
|
|
|
|
guard let replacementMethod = class_getInstanceMethod(
|
|
CmuxWebView.self,
|
|
#selector(CmuxWebView.cmuxUnitTestInspector)
|
|
) else {
|
|
fatalError("Unable to locate test inspector replacement method")
|
|
}
|
|
|
|
let added = class_addMethod(
|
|
CmuxWebView.self,
|
|
NSSelectorFromString("_inspector"),
|
|
method_getImplementation(replacementMethod),
|
|
method_getTypeEncoding(replacementMethod)
|
|
)
|
|
guard added else {
|
|
fatalError("Unable to install CmuxWebView _inspector test override")
|
|
}
|
|
|
|
cmuxUnitTestInspectorOverrideInstalled = true
|
|
}
|
|
|
|
final class CmuxWebViewKeyEquivalentTests: XCTestCase {
|
|
private final class ActionSpy: NSObject {
|
|
private(set) var invoked: Bool = false
|
|
|
|
@objc func didInvoke(_ sender: Any?) {
|
|
invoked = true
|
|
}
|
|
}
|
|
|
|
private final class WindowCyclingActionSpy: NSObject {
|
|
weak var firstWindow: NSWindow?
|
|
weak var secondWindow: NSWindow?
|
|
private(set) var invocationCount = 0
|
|
|
|
@objc func cycleWindow(_ sender: Any?) {
|
|
invocationCount += 1
|
|
guard let firstWindow, let secondWindow else { return }
|
|
|
|
if NSApp.keyWindow === firstWindow {
|
|
secondWindow.makeKeyAndOrderFront(nil)
|
|
} else {
|
|
firstWindow.makeKeyAndOrderFront(nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class FirstResponderView: NSView {
|
|
override var acceptsFirstResponder: Bool { true }
|
|
}
|
|
|
|
private final class DelegateProbeTextView: NSTextView {
|
|
private(set) var delegateReadCount = 0
|
|
|
|
override var delegate: NSTextViewDelegate? {
|
|
get {
|
|
delegateReadCount += 1
|
|
return super.delegate
|
|
}
|
|
set {
|
|
super.delegate = newValue
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class FieldEditorProbeTextView: NSTextView {
|
|
private(set) var delegateReadCount = 0
|
|
|
|
override var delegate: NSTextViewDelegate? {
|
|
get {
|
|
delegateReadCount += 1
|
|
return super.delegate
|
|
}
|
|
set {
|
|
super.delegate = newValue
|
|
}
|
|
}
|
|
|
|
override var isFieldEditor: Bool {
|
|
get { true }
|
|
set {}
|
|
}
|
|
}
|
|
func testCmdNRoutesToMainMenuWhenWebViewIsFirstResponder() {
|
|
let spy = ActionSpy()
|
|
installMenu(spy: spy, key: "n", modifiers: [.command])
|
|
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
let event = makeKeyDownEvent(key: "n", modifiers: [.command], keyCode: 45) // kVK_ANSI_N
|
|
XCTAssertNotNil(event)
|
|
|
|
XCTAssertTrue(webView.performKeyEquivalent(with: event!))
|
|
XCTAssertTrue(spy.invoked)
|
|
}
|
|
|
|
func testCmdWRoutesToMainMenuWhenWebViewIsFirstResponder() {
|
|
let spy = ActionSpy()
|
|
installMenu(spy: spy, key: "w", modifiers: [.command])
|
|
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
let event = makeKeyDownEvent(key: "w", modifiers: [.command], keyCode: 13) // kVK_ANSI_W
|
|
XCTAssertNotNil(event)
|
|
|
|
XCTAssertTrue(webView.performKeyEquivalent(with: event!))
|
|
XCTAssertTrue(spy.invoked)
|
|
}
|
|
|
|
func testCmdRRoutesToMainMenuWhenWebViewIsFirstResponder() {
|
|
let spy = ActionSpy()
|
|
installMenu(spy: spy, key: "r", modifiers: [.command])
|
|
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
let event = makeKeyDownEvent(key: "r", modifiers: [.command], keyCode: 15) // kVK_ANSI_R
|
|
XCTAssertNotNil(event)
|
|
|
|
XCTAssertTrue(webView.performKeyEquivalent(with: event!))
|
|
XCTAssertTrue(spy.invoked)
|
|
}
|
|
|
|
func testReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() {
|
|
let spy = ActionSpy()
|
|
installMenu(spy: spy, key: "\r", modifiers: [])
|
|
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
let event = makeKeyDownEvent(key: "\r", modifiers: [], keyCode: 36) // kVK_Return
|
|
XCTAssertNotNil(event)
|
|
|
|
XCTAssertFalse(webView.performKeyEquivalent(with: event!))
|
|
XCTAssertFalse(spy.invoked)
|
|
}
|
|
|
|
func testCmdReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() {
|
|
let spy = ActionSpy()
|
|
installMenu(spy: spy, key: "\r", modifiers: [.command])
|
|
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
let event = makeKeyDownEvent(key: "\r", modifiers: [.command], keyCode: 36) // kVK_Return
|
|
XCTAssertNotNil(event)
|
|
|
|
XCTAssertFalse(webView.performKeyEquivalent(with: event!))
|
|
XCTAssertFalse(spy.invoked)
|
|
}
|
|
|
|
func testKeypadEnterDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() {
|
|
let spy = ActionSpy()
|
|
installMenu(spy: spy, key: "\r", modifiers: [])
|
|
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
let event = makeKeyDownEvent(key: "\r", modifiers: [], keyCode: 76) // kVK_ANSI_KeypadEnter
|
|
XCTAssertNotNil(event)
|
|
|
|
XCTAssertFalse(webView.performKeyEquivalent(with: event!))
|
|
XCTAssertFalse(spy.invoked)
|
|
}
|
|
|
|
@MainActor
|
|
func testCanBlockFirstResponderAcquisitionWhenPaneIsUnfocused() {
|
|
_ = NSApplication.shared
|
|
|
|
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)
|
|
|
|
window.makeKeyAndOrderFront(nil)
|
|
defer { window.orderOut(nil) }
|
|
|
|
webView.allowsFirstResponderAcquisition = true
|
|
XCTAssertTrue(window.makeFirstResponder(webView))
|
|
|
|
_ = window.makeFirstResponder(nil)
|
|
webView.allowsFirstResponderAcquisition = false
|
|
XCTAssertFalse(webView.becomeFirstResponder())
|
|
|
|
_ = window.makeFirstResponder(webView)
|
|
if let firstResponderView = window.firstResponder as? NSView {
|
|
XCTAssertFalse(firstResponderView === webView || firstResponderView.isDescendant(of: webView))
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func testPointerFocusAllowanceCanTemporarilyOverrideBlockedFirstResponderAcquisition() {
|
|
_ = NSApplication.shared
|
|
|
|
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)
|
|
|
|
window.makeKeyAndOrderFront(nil)
|
|
defer { window.orderOut(nil) }
|
|
|
|
webView.allowsFirstResponderAcquisition = false
|
|
_ = window.makeFirstResponder(nil)
|
|
XCTAssertFalse(webView.becomeFirstResponder(), "Expected focus to stay blocked by policy")
|
|
|
|
webView.withPointerFocusAllowance {
|
|
XCTAssertTrue(webView.becomeFirstResponder(), "Expected explicit pointer intent to bypass policy")
|
|
}
|
|
|
|
_ = window.makeFirstResponder(nil)
|
|
XCTAssertFalse(webView.becomeFirstResponder(), "Expected pointer allowance to be temporary")
|
|
}
|
|
|
|
@MainActor
|
|
func testWindowFirstResponderGuardBlocksDescendantWhenPaneIsUnfocused() {
|
|
_ = NSApplication.shared
|
|
AppDelegate.installWindowResponderSwizzlesForTesting()
|
|
|
|
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 descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
|
|
webView.addSubview(descendant)
|
|
|
|
window.makeKeyAndOrderFront(nil)
|
|
defer { window.orderOut(nil) }
|
|
|
|
webView.allowsFirstResponderAcquisition = true
|
|
XCTAssertTrue(window.makeFirstResponder(descendant))
|
|
|
|
_ = window.makeFirstResponder(nil)
|
|
webView.allowsFirstResponderAcquisition = false
|
|
XCTAssertFalse(window.makeFirstResponder(descendant))
|
|
|
|
if let firstResponderView = window.firstResponder as? NSView {
|
|
XCTAssertFalse(firstResponderView === descendant || firstResponderView.isDescendant(of: webView))
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func testWindowFirstResponderGuardAllowsDescendantDuringPointerFocusAllowance() {
|
|
_ = NSApplication.shared
|
|
AppDelegate.installWindowResponderSwizzlesForTesting()
|
|
|
|
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 descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
|
|
webView.addSubview(descendant)
|
|
|
|
window.makeKeyAndOrderFront(nil)
|
|
defer { window.orderOut(nil) }
|
|
|
|
webView.allowsFirstResponderAcquisition = false
|
|
_ = window.makeFirstResponder(nil)
|
|
XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus outside pointer allowance")
|
|
|
|
_ = window.makeFirstResponder(nil)
|
|
webView.withPointerFocusAllowance {
|
|
XCTAssertTrue(window.makeFirstResponder(descendant), "Expected pointer allowance to bypass guard")
|
|
}
|
|
|
|
_ = window.makeFirstResponder(nil)
|
|
XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer allowance to remain temporary")
|
|
}
|
|
|
|
@MainActor
|
|
func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusWhenPolicyIsBlocked() {
|
|
_ = NSApplication.shared
|
|
AppDelegate.installWindowResponderSwizzlesForTesting()
|
|
|
|
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 descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
|
|
webView.addSubview(descendant)
|
|
|
|
window.makeKeyAndOrderFront(nil)
|
|
defer {
|
|
AppDelegate.clearWindowFirstResponderGuardTesting()
|
|
window.orderOut(nil)
|
|
}
|
|
|
|
webView.allowsFirstResponderAcquisition = false
|
|
_ = window.makeFirstResponder(nil)
|
|
XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus without pointer click context")
|
|
|
|
let timestamp = ProcessInfo.processInfo.systemUptime
|
|
let pointerDownEvent = NSEvent.mouseEvent(
|
|
with: .leftMouseDown,
|
|
location: NSPoint(x: 5, y: 5),
|
|
modifierFlags: [],
|
|
timestamp: timestamp,
|
|
windowNumber: window.windowNumber,
|
|
context: nil,
|
|
eventNumber: 1,
|
|
clickCount: 1,
|
|
pressure: 1.0
|
|
)
|
|
XCTAssertNotNil(pointerDownEvent)
|
|
|
|
AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: descendant)
|
|
_ = window.makeFirstResponder(nil)
|
|
XCTAssertTrue(window.makeFirstResponder(descendant), "Expected pointer click context to bypass blocked policy")
|
|
|
|
AppDelegate.clearWindowFirstResponderGuardTesting()
|
|
_ = window.makeFirstResponder(nil)
|
|
XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer bypass to be limited to click context")
|
|
}
|
|
|
|
@MainActor
|
|
func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusFromPortalHostedInspectorSibling() {
|
|
_ = NSApplication.shared
|
|
AppDelegate.installWindowResponderSwizzlesForTesting()
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame))
|
|
window.contentView = contentView
|
|
|
|
window.makeKeyAndOrderFront(nil)
|
|
defer {
|
|
AppDelegate.clearWindowFirstResponderGuardTesting()
|
|
window.orderOut(nil)
|
|
}
|
|
|
|
guard let container = contentView.superview else {
|
|
XCTFail("Expected content container")
|
|
return
|
|
}
|
|
|
|
let hostFrame = container.convert(contentView.bounds, from: contentView)
|
|
let host = WindowBrowserHostView(frame: hostFrame)
|
|
host.autoresizingMask = [.width, .height]
|
|
container.addSubview(host, positioned: .above, relativeTo: contentView)
|
|
|
|
let slot = WindowBrowserSlotView(frame: host.bounds)
|
|
slot.autoresizingMask = [.width, .height]
|
|
host.addSubview(slot)
|
|
|
|
let webView = CmuxWebView(frame: slot.bounds, configuration: WKWebViewConfiguration())
|
|
webView.autoresizingMask = [.width, .height]
|
|
slot.addSubview(webView)
|
|
|
|
let inspector = FirstResponderView(frame: NSRect(x: 440, y: 0, width: 200, height: slot.bounds.height))
|
|
inspector.autoresizingMask = [.minXMargin, .height]
|
|
slot.addSubview(inspector)
|
|
|
|
webView.allowsFirstResponderAcquisition = false
|
|
_ = window.makeFirstResponder(nil)
|
|
XCTAssertFalse(
|
|
window.makeFirstResponder(inspector),
|
|
"Expected portal-hosted inspector focus to stay blocked without pointer click context"
|
|
)
|
|
|
|
let pointInInspector = NSPoint(x: inspector.bounds.midX, y: inspector.bounds.midY)
|
|
let pointInWindow = inspector.convert(pointInInspector, to: nil)
|
|
let pointerDownEvent = NSEvent.mouseEvent(
|
|
with: .leftMouseDown,
|
|
location: pointInWindow,
|
|
modifierFlags: [],
|
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: window.windowNumber,
|
|
context: nil,
|
|
eventNumber: 1,
|
|
clickCount: 1,
|
|
pressure: 1.0
|
|
)
|
|
XCTAssertNotNil(pointerDownEvent)
|
|
|
|
AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: nil)
|
|
_ = window.makeFirstResponder(nil)
|
|
XCTAssertTrue(
|
|
window.makeFirstResponder(inspector),
|
|
"Expected portal-hosted inspector click to bypass blocked policy using the overlay hit target"
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusFromBoundPortalInspectorSiblingWhenHitTestMisses() {
|
|
_ = NSApplication.shared
|
|
AppDelegate.installWindowResponderSwizzlesForTesting()
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame))
|
|
window.contentView = contentView
|
|
|
|
let anchor = NSView(frame: NSRect(x: 80, y: 60, width: 480, height: 260))
|
|
contentView.addSubview(anchor)
|
|
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
|
|
window.makeKeyAndOrderFront(nil)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
|
BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true, zPriority: 1)
|
|
BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
|
|
|
|
defer {
|
|
BrowserWindowPortalRegistry.detach(webView: webView)
|
|
AppDelegate.clearWindowFirstResponderGuardTesting()
|
|
window.orderOut(nil)
|
|
}
|
|
|
|
guard let slot = webView.superview as? WindowBrowserSlotView else {
|
|
XCTFail("Expected bound portal slot")
|
|
return
|
|
}
|
|
|
|
let inspector = FirstResponderView(frame: NSRect(x: 320, y: 0, width: 160, height: slot.bounds.height))
|
|
inspector.autoresizingMask = [.minXMargin, .height]
|
|
slot.addSubview(inspector)
|
|
|
|
webView.allowsFirstResponderAcquisition = false
|
|
_ = window.makeFirstResponder(nil)
|
|
XCTAssertFalse(
|
|
window.makeFirstResponder(inspector),
|
|
"Expected bound portal inspector focus to stay blocked without pointer click context"
|
|
)
|
|
|
|
let pointInInspector = NSPoint(x: inspector.bounds.midX, y: inspector.bounds.midY)
|
|
let pointInWindow = inspector.convert(pointInInspector, to: nil)
|
|
XCTAssertTrue(
|
|
BrowserWindowPortalRegistry.webViewAtWindowPoint(pointInWindow, in: window) === webView,
|
|
"Expected portal registry to resolve the owning web view from a click inside inspector chrome"
|
|
)
|
|
|
|
let pointerDownEvent = NSEvent.mouseEvent(
|
|
with: .leftMouseDown,
|
|
location: pointInWindow,
|
|
modifierFlags: [],
|
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: window.windowNumber,
|
|
context: nil,
|
|
eventNumber: 1,
|
|
clickCount: 1,
|
|
pressure: 1.0
|
|
)
|
|
XCTAssertNotNil(pointerDownEvent)
|
|
|
|
AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: nil)
|
|
_ = window.makeFirstResponder(nil)
|
|
XCTAssertTrue(
|
|
window.makeFirstResponder(inspector),
|
|
"Expected bound portal inspector click to bypass blocked policy through portal registry fallback"
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
func testWindowFirstResponderGuardAvoidsTextViewDelegateLookupForWebViewResolution() {
|
|
_ = NSApplication.shared
|
|
AppDelegate.installWindowResponderSwizzlesForTesting()
|
|
|
|
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 textView = DelegateProbeTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 40))
|
|
container.addSubview(textView)
|
|
|
|
window.makeKeyAndOrderFront(nil)
|
|
defer { window.orderOut(nil) }
|
|
|
|
_ = window.makeFirstResponder(nil)
|
|
_ = window.makeFirstResponder(textView)
|
|
|
|
XCTAssertEqual(
|
|
textView.delegateReadCount,
|
|
0,
|
|
"WebView ownership resolution should not touch NSTextView.delegate (unsafe-unretained in AppKit)"
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
func testWindowFirstResponderGuardResolvesTrackedWebViewForFieldEditorResponder() {
|
|
_ = NSApplication.shared
|
|
AppDelegate.installWindowResponderSwizzlesForTesting()
|
|
|
|
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 descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
|
|
webView.addSubview(descendant)
|
|
|
|
let fieldEditor = FieldEditorProbeTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 20))
|
|
|
|
window.makeKeyAndOrderFront(nil)
|
|
defer {
|
|
AppDelegate.clearWindowFirstResponderGuardTesting()
|
|
window.orderOut(nil)
|
|
}
|
|
|
|
webView.allowsFirstResponderAcquisition = true
|
|
XCTAssertTrue(window.makeFirstResponder(descendant))
|
|
|
|
let timestamp = ProcessInfo.processInfo.systemUptime
|
|
let pointerDownEvent = NSEvent.mouseEvent(
|
|
with: .leftMouseDown,
|
|
location: NSPoint(x: 5, y: 5),
|
|
modifierFlags: [],
|
|
timestamp: timestamp,
|
|
windowNumber: window.windowNumber,
|
|
context: nil,
|
|
eventNumber: 1,
|
|
clickCount: 1,
|
|
pressure: 1.0
|
|
)
|
|
XCTAssertNotNil(pointerDownEvent)
|
|
|
|
AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: descendant)
|
|
XCTAssertTrue(window.makeFirstResponder(fieldEditor))
|
|
|
|
AppDelegate.clearWindowFirstResponderGuardTesting()
|
|
_ = window.makeFirstResponder(nil)
|
|
webView.allowsFirstResponderAcquisition = false
|
|
XCTAssertFalse(window.makeFirstResponder(fieldEditor))
|
|
XCTAssertEqual(
|
|
fieldEditor.delegateReadCount,
|
|
0,
|
|
"Field-editor webview ownership should come from tracked associations, not NSTextView.delegate"
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
func testWindowFirstResponderBypassBlocksSwizzledMakeFirstResponder() {
|
|
_ = NSApplication.shared
|
|
AppDelegate.installWindowResponderSwizzlesForTesting()
|
|
|
|
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 responder = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 80, height: 40))
|
|
container.addSubview(responder)
|
|
|
|
window.makeKeyAndOrderFront(nil)
|
|
defer { window.orderOut(nil) }
|
|
|
|
_ = window.makeFirstResponder(nil)
|
|
cmuxWithWindowFirstResponderBypass {
|
|
XCTAssertFalse(
|
|
window.makeFirstResponder(responder),
|
|
"Bypass scope should block transient first-responder changes during devtools auto-restore"
|
|
)
|
|
}
|
|
XCTAssertTrue(window.makeFirstResponder(responder))
|
|
}
|
|
|
|
@MainActor
|
|
func testCmdBacktickMenuActionThatChangesKeyWindowOnlyRunsOnceWhenTerminalIsFirstResponder() {
|
|
_ = NSApplication.shared
|
|
AppDelegate.installWindowResponderSwizzlesForTesting()
|
|
|
|
let firstWindow = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
let secondWindow = NSWindow(
|
|
contentRect: NSRect(x: 40, y: 40, width: 640, height: 420),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
|
|
let firstContainer = NSView(frame: firstWindow.contentRect(forFrameRect: firstWindow.frame))
|
|
let secondContainer = NSView(frame: secondWindow.contentRect(forFrameRect: secondWindow.frame))
|
|
firstWindow.contentView = firstContainer
|
|
secondWindow.contentView = secondContainer
|
|
|
|
let firstTerminal = GhosttyNSView(frame: firstContainer.bounds)
|
|
firstTerminal.autoresizingMask = [.width, .height]
|
|
firstContainer.addSubview(firstTerminal)
|
|
|
|
let secondTerminal = GhosttyNSView(frame: secondContainer.bounds)
|
|
secondTerminal.autoresizingMask = [.width, .height]
|
|
secondContainer.addSubview(secondTerminal)
|
|
|
|
let spy = WindowCyclingActionSpy()
|
|
spy.firstWindow = firstWindow
|
|
spy.secondWindow = secondWindow
|
|
installMenu(
|
|
target: spy,
|
|
action: #selector(WindowCyclingActionSpy.cycleWindow(_:)),
|
|
key: "`",
|
|
modifiers: [.command]
|
|
)
|
|
|
|
secondWindow.orderFront(nil)
|
|
firstWindow.makeKeyAndOrderFront(nil)
|
|
defer {
|
|
secondWindow.orderOut(nil)
|
|
firstWindow.orderOut(nil)
|
|
}
|
|
|
|
XCTAssertTrue(firstWindow.makeFirstResponder(firstTerminal))
|
|
guard let event = makeKeyDownEvent(
|
|
key: "`",
|
|
modifiers: [.command],
|
|
keyCode: 50,
|
|
windowNumber: firstWindow.windowNumber
|
|
) else {
|
|
XCTFail("Failed to construct Cmd+` event")
|
|
return
|
|
}
|
|
|
|
NSApp.sendEvent(event)
|
|
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
|
|
|
XCTAssertEqual(spy.invocationCount, 1, "Cmd+` should only trigger one window-cycle action")
|
|
}
|
|
|
|
@MainActor
|
|
func testCmdBacktickDoesNotRouteDirectlyToMainMenuWhenWebViewIsFirstResponder() {
|
|
_ = NSApplication.shared
|
|
|
|
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 spy = ActionSpy()
|
|
installMenu(
|
|
target: spy,
|
|
action: #selector(ActionSpy.didInvoke(_:)),
|
|
key: "`",
|
|
modifiers: [.command]
|
|
)
|
|
|
|
window.makeKeyAndOrderFront(nil)
|
|
defer {
|
|
window.orderOut(nil)
|
|
}
|
|
|
|
XCTAssertTrue(window.makeFirstResponder(webView))
|
|
guard let event = makeKeyDownEvent(
|
|
key: "`",
|
|
modifiers: [.command],
|
|
keyCode: 50,
|
|
windowNumber: window.windowNumber
|
|
) else {
|
|
XCTFail("Failed to construct Cmd+` event")
|
|
return
|
|
}
|
|
|
|
XCTAssertFalse(shouldRouteCommandEquivalentDirectlyToMainMenu(event))
|
|
_ = webView.performKeyEquivalent(with: event)
|
|
XCTAssertFalse(
|
|
spy.invoked,
|
|
"CmuxWebView should not route Cmd+` directly to the menu when WebKit is first responder"
|
|
)
|
|
}
|
|
|
|
private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) {
|
|
installMenu(
|
|
target: spy,
|
|
action: #selector(ActionSpy.didInvoke(_:)),
|
|
key: key,
|
|
modifiers: modifiers
|
|
)
|
|
}
|
|
|
|
private func installMenu(
|
|
target: NSObject,
|
|
action: Selector,
|
|
key: String,
|
|
modifiers: NSEvent.ModifierFlags
|
|
) {
|
|
let mainMenu = NSMenu()
|
|
|
|
let fileItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "")
|
|
let fileMenu = NSMenu(title: "File")
|
|
|
|
let item = NSMenuItem(title: "Test Item", action: action, keyEquivalent: key)
|
|
item.keyEquivalentModifierMask = modifiers
|
|
item.target = target
|
|
fileMenu.addItem(item)
|
|
|
|
mainMenu.addItem(fileItem)
|
|
mainMenu.setSubmenu(fileMenu, for: fileItem)
|
|
|
|
// Ensure NSApp exists and has a menu for performKeyEquivalent to consult.
|
|
_ = NSApplication.shared
|
|
NSApp.mainMenu = mainMenu
|
|
}
|
|
|
|
private func makeKeyDownEvent(
|
|
key: String,
|
|
modifiers: NSEvent.ModifierFlags,
|
|
keyCode: UInt16,
|
|
windowNumber: Int = 0
|
|
) -> NSEvent? {
|
|
NSEvent.keyEvent(
|
|
with: .keyDown,
|
|
location: .zero,
|
|
modifierFlags: modifiers,
|
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: windowNumber,
|
|
context: nil,
|
|
characters: key,
|
|
charactersIgnoringModifiers: key,
|
|
isARepeat: false,
|
|
keyCode: keyCode
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class CmuxWebViewContextMenuTests: XCTestCase {
|
|
private func makeRightMouseDownEvent() -> NSEvent {
|
|
guard let event = NSEvent.mouseEvent(
|
|
with: .rightMouseDown,
|
|
location: .zero,
|
|
modifierFlags: [],
|
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: 0,
|
|
context: nil,
|
|
eventNumber: 0,
|
|
clickCount: 1,
|
|
pressure: 1.0
|
|
) else {
|
|
fatalError("Failed to create rightMouseDown event")
|
|
}
|
|
return event
|
|
}
|
|
|
|
func testWillOpenMenuAddsOpenLinkInDefaultBrowserAndRoutesSelectionToDefaultBrowserOpener() {
|
|
_ = NSApplication.shared
|
|
let webView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), configuration: WKWebViewConfiguration())
|
|
let menu = NSMenu()
|
|
let openLinkItem = NSMenuItem(title: "Open Link", action: nil, keyEquivalent: "")
|
|
openLinkItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierOpenLink")
|
|
menu.addItem(openLinkItem)
|
|
menu.addItem(NSMenuItem(title: "Copy Link", action: nil, keyEquivalent: ""))
|
|
|
|
var openedURL: URL?
|
|
webView.contextMenuLinkURLProvider = { _, _, completion in
|
|
completion(URL(string: "https://example.com/docs")!)
|
|
}
|
|
webView.contextMenuDefaultBrowserOpener = { url in
|
|
openedURL = url
|
|
return true
|
|
}
|
|
|
|
webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
|
|
|
|
guard let defaultBrowserItemIndex = menu.items.firstIndex(where: { $0.title == "Open Link in Default Browser" }) else {
|
|
XCTFail("Expected Open Link in Default Browser item in context menu")
|
|
return
|
|
}
|
|
guard let openLinkIndex = menu.items.firstIndex(where: { $0.identifier?.rawValue == "WKMenuItemIdentifierOpenLink" }) else {
|
|
XCTFail("Expected Open Link item in context menu")
|
|
return
|
|
}
|
|
|
|
XCTAssertEqual(defaultBrowserItemIndex, openLinkIndex + 1)
|
|
let defaultBrowserItem = menu.items[defaultBrowserItemIndex]
|
|
XCTAssertTrue(defaultBrowserItem.target === webView)
|
|
XCTAssertNotNil(defaultBrowserItem.action)
|
|
|
|
let dispatched = NSApp.sendAction(
|
|
defaultBrowserItem.action!,
|
|
to: defaultBrowserItem.target,
|
|
from: defaultBrowserItem
|
|
)
|
|
XCTAssertTrue(dispatched)
|
|
XCTAssertEqual(openedURL?.absoluteString, "https://example.com/docs")
|
|
}
|
|
|
|
func testWillOpenMenuSkipsDefaultBrowserItemWhenContextHasNoOpenLinkEntry() {
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
let menu = NSMenu()
|
|
menu.addItem(NSMenuItem(title: "Back", action: nil, keyEquivalent: ""))
|
|
menu.addItem(NSMenuItem(title: "Forward", action: nil, keyEquivalent: ""))
|
|
|
|
webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
|
|
|
|
XCTAssertFalse(menu.items.contains { $0.title == "Open Link in Default Browser" })
|
|
}
|
|
|
|
func testWillOpenMenuHooksDownloadImageToDiskMenuVariant() {
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
let menu = NSMenu()
|
|
let originalTarget = NSObject()
|
|
let originalAction = NSSelectorFromString("downloadImageToDisk:")
|
|
let downloadItem = NSMenuItem(title: "Download Image As...", action: originalAction, keyEquivalent: "")
|
|
downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadImageToDisk")
|
|
downloadItem.target = originalTarget
|
|
menu.addItem(downloadItem)
|
|
|
|
webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
|
|
|
|
XCTAssertTrue(downloadItem.target === webView)
|
|
XCTAssertNotNil(downloadItem.action)
|
|
XCTAssertNotEqual(downloadItem.action, originalAction)
|
|
}
|
|
|
|
func testWillOpenMenuHooksDownloadLinkedFileToDiskMenuVariant() {
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
let menu = NSMenu()
|
|
let originalTarget = NSObject()
|
|
let originalAction = NSSelectorFromString("downloadLinkToDisk:")
|
|
let downloadItem = NSMenuItem(title: "Download Linked File As...", action: originalAction, keyEquivalent: "")
|
|
downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadLinkToDisk")
|
|
downloadItem.target = originalTarget
|
|
menu.addItem(downloadItem)
|
|
|
|
webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
|
|
|
|
XCTAssertTrue(downloadItem.target === webView)
|
|
XCTAssertNotNil(downloadItem.action)
|
|
XCTAssertNotEqual(downloadItem.action, originalAction)
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase {
|
|
private func makeIsolatedDefaults() -> UserDefaults {
|
|
let suiteName = "BrowserDevToolsButtonDebugSettingsTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
fatalError("Failed to create defaults suite")
|
|
}
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
addTeardownBlock {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
return defaults
|
|
}
|
|
|
|
func testIconCatalogIncludesExpandedChoices() {
|
|
XCTAssertGreaterThanOrEqual(BrowserDevToolsIconOption.allCases.count, 10)
|
|
XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.terminal))
|
|
XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.globe))
|
|
XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.curlyBracesSquare))
|
|
}
|
|
|
|
func testIconOptionFallsBackToDefaultForUnknownRawValue() {
|
|
let defaults = makeIsolatedDefaults()
|
|
defaults.set("this.symbol.does.not.exist", forKey: BrowserDevToolsButtonDebugSettings.iconNameKey)
|
|
|
|
XCTAssertEqual(
|
|
BrowserDevToolsButtonDebugSettings.iconOption(defaults: defaults),
|
|
BrowserDevToolsButtonDebugSettings.defaultIcon
|
|
)
|
|
}
|
|
|
|
func testColorOptionFallsBackToDefaultForUnknownRawValue() {
|
|
let defaults = makeIsolatedDefaults()
|
|
defaults.set("notAValidColor", forKey: BrowserDevToolsButtonDebugSettings.iconColorKey)
|
|
|
|
XCTAssertEqual(
|
|
BrowserDevToolsButtonDebugSettings.colorOption(defaults: defaults),
|
|
BrowserDevToolsButtonDebugSettings.defaultColor
|
|
)
|
|
}
|
|
|
|
func testBrowserToolbarAccessorySpacingDefaultsToTwoWhenUnset() {
|
|
let defaults = makeIsolatedDefaults()
|
|
defaults.removeObject(forKey: BrowserToolbarAccessorySpacingDebugSettings.key)
|
|
|
|
XCTAssertEqual(
|
|
BrowserToolbarAccessorySpacingDebugSettings.current(defaults: defaults),
|
|
BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing
|
|
)
|
|
}
|
|
|
|
func testBrowserToolbarAccessorySpacingFallsBackToDefaultForUnsupportedValue() {
|
|
let defaults = makeIsolatedDefaults()
|
|
defaults.set(99, forKey: BrowserToolbarAccessorySpacingDebugSettings.key)
|
|
|
|
XCTAssertEqual(
|
|
BrowserToolbarAccessorySpacingDebugSettings.current(defaults: defaults),
|
|
BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing
|
|
)
|
|
}
|
|
|
|
func testBrowserProfilePopoverPaddingDefaultsWhenUnset() {
|
|
let defaults = makeIsolatedDefaults()
|
|
defaults.removeObject(forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey)
|
|
defaults.removeObject(forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey)
|
|
|
|
XCTAssertEqual(
|
|
BrowserProfilePopoverDebugSettings.currentHorizontalPadding(defaults: defaults),
|
|
BrowserProfilePopoverDebugSettings.defaultHorizontalPadding
|
|
)
|
|
XCTAssertEqual(
|
|
BrowserProfilePopoverDebugSettings.currentVerticalPadding(defaults: defaults),
|
|
BrowserProfilePopoverDebugSettings.defaultVerticalPadding
|
|
)
|
|
}
|
|
|
|
func testBrowserProfilePopoverPaddingFallsBackForUnsupportedValues() {
|
|
let defaults = makeIsolatedDefaults()
|
|
defaults.set(-3, forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey)
|
|
defaults.set(999, forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey)
|
|
|
|
XCTAssertEqual(
|
|
BrowserProfilePopoverDebugSettings.currentHorizontalPadding(defaults: defaults),
|
|
BrowserProfilePopoverDebugSettings.defaultHorizontalPadding
|
|
)
|
|
XCTAssertEqual(
|
|
BrowserProfilePopoverDebugSettings.currentVerticalPadding(defaults: defaults),
|
|
BrowserProfilePopoverDebugSettings.defaultVerticalPadding
|
|
)
|
|
}
|
|
|
|
func testCopyPayloadUsesPersistedValues() {
|
|
let defaults = makeIsolatedDefaults()
|
|
defaults.set(BrowserDevToolsIconOption.scope.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconNameKey)
|
|
defaults.set(BrowserDevToolsIconColorOption.bonsplitActive.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconColorKey)
|
|
|
|
let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults)
|
|
XCTAssertTrue(payload.contains("browserDevToolsIconName=scope"))
|
|
XCTAssertTrue(payload.contains("browserDevToolsIconColor=bonsplitActive"))
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserThemeSettingsTests: XCTestCase {
|
|
private func makeIsolatedDefaults() -> UserDefaults {
|
|
let suiteName = "BrowserThemeSettingsTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
fatalError("Failed to create defaults suite")
|
|
}
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
addTeardownBlock {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
return defaults
|
|
}
|
|
|
|
func testDefaultsMatchConfiguredFallbacks() {
|
|
let defaults = makeIsolatedDefaults()
|
|
XCTAssertEqual(
|
|
BrowserThemeSettings.mode(defaults: defaults),
|
|
BrowserThemeSettings.defaultMode
|
|
)
|
|
}
|
|
|
|
func testModeReadsPersistedValue() {
|
|
let defaults = makeIsolatedDefaults()
|
|
defaults.set(BrowserThemeMode.dark.rawValue, forKey: BrowserThemeSettings.modeKey)
|
|
XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .dark)
|
|
|
|
defaults.set(BrowserThemeMode.light.rawValue, forKey: BrowserThemeSettings.modeKey)
|
|
XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .light)
|
|
}
|
|
|
|
func testModeMigratesLegacyForcedDarkModeFlag() {
|
|
let defaults = makeIsolatedDefaults()
|
|
defaults.set(true, forKey: BrowserThemeSettings.legacyForcedDarkModeEnabledKey)
|
|
XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .dark)
|
|
XCTAssertEqual(defaults.string(forKey: BrowserThemeSettings.modeKey), BrowserThemeMode.dark.rawValue)
|
|
|
|
let otherDefaults = makeIsolatedDefaults()
|
|
otherDefaults.set(false, forKey: BrowserThemeSettings.legacyForcedDarkModeEnabledKey)
|
|
XCTAssertEqual(BrowserThemeSettings.mode(defaults: otherDefaults), .system)
|
|
XCTAssertEqual(otherDefaults.string(forKey: BrowserThemeSettings.modeKey), BrowserThemeMode.system.rawValue)
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase {
|
|
func testSafariDefaultShortcutForToggleDeveloperTools() {
|
|
let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
|
|
XCTAssertEqual(shortcut.key, "i")
|
|
XCTAssertTrue(shortcut.command)
|
|
XCTAssertTrue(shortcut.option)
|
|
XCTAssertFalse(shortcut.shift)
|
|
XCTAssertFalse(shortcut.control)
|
|
}
|
|
|
|
func testSafariDefaultShortcutForShowJavaScriptConsole() {
|
|
let shortcut = KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut
|
|
XCTAssertEqual(shortcut.key, "c")
|
|
XCTAssertTrue(shortcut.command)
|
|
XCTAssertTrue(shortcut.option)
|
|
XCTAssertFalse(shortcut.shift)
|
|
XCTAssertFalse(shortcut.control)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class BrowserDeveloperToolsConfigurationTests: XCTestCase {
|
|
func testBrowserPanelEnablesInspectableWebViewAndDeveloperExtras() {
|
|
let panel = BrowserPanel(workspaceId: UUID())
|
|
let developerExtras = panel.webView.configuration.preferences.value(forKey: "developerExtrasEnabled") as? Bool
|
|
XCTAssertEqual(developerExtras, true)
|
|
|
|
if #available(macOS 13.3, *) {
|
|
XCTAssertTrue(panel.webView.isInspectable)
|
|
}
|
|
}
|
|
|
|
func testBrowserPanelRefreshesUnderPageBackgroundColorWhenGhosttyBackgroundChanges() {
|
|
let panel = BrowserPanel(workspaceId: UUID())
|
|
let updatedColor = NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0)
|
|
let updatedOpacity = 0.57
|
|
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDefaultBackgroundDidChange,
|
|
object: nil,
|
|
userInfo: [
|
|
GhosttyNotificationKey.backgroundColor: updatedColor,
|
|
GhosttyNotificationKey.backgroundOpacity: updatedOpacity
|
|
]
|
|
)
|
|
|
|
guard let actual = panel.webView.underPageBackgroundColor?.usingColorSpace(.sRGB),
|
|
let expected = updatedColor.withAlphaComponent(updatedOpacity).usingColorSpace(.sRGB) else {
|
|
XCTFail("Expected sRGB-convertible under-page background colors")
|
|
return
|
|
}
|
|
|
|
XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.005)
|
|
XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.005)
|
|
XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.005)
|
|
XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.005)
|
|
}
|
|
|
|
func testBrowserPanelStartsAsNewTabWithoutLoadingAboutBlank() {
|
|
let panel = BrowserPanel(workspaceId: UUID())
|
|
|
|
XCTAssertEqual(panel.displayTitle, "New tab")
|
|
XCTAssertFalse(panel.shouldRenderWebView)
|
|
XCTAssertTrue(panel.isShowingNewTabPage)
|
|
XCTAssertNil(panel.webView.url)
|
|
XCTAssertNil(panel.currentURL)
|
|
}
|
|
|
|
func testBrowserPanelLeavesNewTabPageStateWhenNavigationStarts() {
|
|
let panel = BrowserPanel(workspaceId: UUID())
|
|
|
|
XCTAssertTrue(panel.isShowingNewTabPage)
|
|
panel.navigate(to: URL(string: "https://example.com")!)
|
|
XCTAssertFalse(panel.isShowingNewTabPage)
|
|
}
|
|
|
|
func testBrowserPanelThemeModeUpdatesWebViewAppearance() {
|
|
let panel = BrowserPanel(workspaceId: UUID())
|
|
|
|
panel.setBrowserThemeMode(.dark)
|
|
XCTAssertEqual(panel.webView.appearance?.bestMatch(from: [.darkAqua, .aqua]), .darkAqua)
|
|
|
|
panel.setBrowserThemeMode(.light)
|
|
XCTAssertEqual(panel.webView.appearance?.bestMatch(from: [.aqua, .darkAqua]), .aqua)
|
|
|
|
panel.setBrowserThemeMode(.system)
|
|
XCTAssertNil(panel.webView.appearance)
|
|
}
|
|
|
|
func testBrowserPanelRefreshesUnderPageBackgroundColorWithGhosttyOpacity() {
|
|
let panel = BrowserPanel(workspaceId: UUID())
|
|
let updatedColor = NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0)
|
|
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDefaultBackgroundDidChange,
|
|
object: nil,
|
|
userInfo: [
|
|
GhosttyNotificationKey.backgroundColor: updatedColor,
|
|
GhosttyNotificationKey.backgroundOpacity: NSNumber(value: 0.57),
|
|
]
|
|
)
|
|
|
|
guard let actual = panel.webView.underPageBackgroundColor?.usingColorSpace(.sRGB),
|
|
let expected = updatedColor.withAlphaComponent(0.57).usingColorSpace(.sRGB) else {
|
|
XCTFail("Expected sRGB-convertible under-page background colors")
|
|
return
|
|
}
|
|
|
|
XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.005)
|
|
XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.005)
|
|
XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.005)
|
|
XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.005)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class BrowserInsecureHTTPAlertPresentationTests: XCTestCase {
|
|
private final class BrowserInsecureHTTPAlertSpy: NSAlert {
|
|
private(set) var beginSheetModalCallCount = 0
|
|
private(set) var runModalCallCount = 0
|
|
var nextResponse: NSApplication.ModalResponse = .alertThirdButtonReturn
|
|
|
|
override func beginSheetModal(
|
|
for sheetWindow: NSWindow,
|
|
completionHandler handler: ((NSApplication.ModalResponse) -> Void)?
|
|
) {
|
|
beginSheetModalCallCount += 1
|
|
handler?(nextResponse)
|
|
}
|
|
|
|
override func runModal() -> NSApplication.ModalResponse {
|
|
runModalCallCount += 1
|
|
return nextResponse
|
|
}
|
|
}
|
|
|
|
func testInsecureHTTPPromptUsesSheetWhenWindowIsAvailable() {
|
|
let panel = BrowserPanel(workspaceId: UUID())
|
|
defer { panel.resetInsecureHTTPAlertHooksForTesting() }
|
|
|
|
let alertSpy = BrowserInsecureHTTPAlertSpy()
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
|
|
styleMask: [.titled],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
|
|
panel.configureInsecureHTTPAlertHooksForTesting(
|
|
alertFactory: { alertSpy },
|
|
windowProvider: { window }
|
|
)
|
|
panel.presentInsecureHTTPAlertForTesting(url: URL(string: "http://example.com")!)
|
|
|
|
XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1)
|
|
XCTAssertEqual(alertSpy.runModalCallCount, 0)
|
|
}
|
|
|
|
func testInsecureHTTPPromptFallsBackToRunModalWithoutWindow() {
|
|
let panel = BrowserPanel(workspaceId: UUID())
|
|
defer { panel.resetInsecureHTTPAlertHooksForTesting() }
|
|
|
|
let alertSpy = BrowserInsecureHTTPAlertSpy()
|
|
panel.configureInsecureHTTPAlertHooksForTesting(
|
|
alertFactory: { alertSpy },
|
|
windowProvider: { nil }
|
|
)
|
|
panel.presentInsecureHTTPAlertForTesting(url: URL(string: "http://example.com")!)
|
|
|
|
XCTAssertEqual(alertSpy.beginSheetModalCallCount, 0)
|
|
XCTAssertEqual(alertSpy.runModalCallCount, 1)
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserNavigationNewTabDecisionTests: XCTestCase {
|
|
func testLinkActivatedCmdClickOpensInNewTab() {
|
|
XCTAssertTrue(
|
|
browserNavigationShouldOpenInNewTab(
|
|
navigationType: .linkActivated,
|
|
modifierFlags: [.command],
|
|
buttonNumber: 0
|
|
)
|
|
)
|
|
}
|
|
|
|
func testLinkActivatedMiddleClickOpensInNewTab() {
|
|
XCTAssertTrue(
|
|
browserNavigationShouldOpenInNewTab(
|
|
navigationType: .linkActivated,
|
|
modifierFlags: [],
|
|
buttonNumber: 2
|
|
)
|
|
)
|
|
}
|
|
|
|
func testLinkActivatedPlainLeftClickStaysInCurrentTab() {
|
|
XCTAssertFalse(
|
|
browserNavigationShouldOpenInNewTab(
|
|
navigationType: .linkActivated,
|
|
modifierFlags: [],
|
|
buttonNumber: 0
|
|
)
|
|
)
|
|
}
|
|
|
|
func testOtherNavigationMiddleClickOpensInNewTab() {
|
|
XCTAssertTrue(
|
|
browserNavigationShouldOpenInNewTab(
|
|
navigationType: .other,
|
|
modifierFlags: [],
|
|
buttonNumber: 2
|
|
)
|
|
)
|
|
}
|
|
|
|
func testOtherNavigationLeftClickStaysInCurrentTab() {
|
|
XCTAssertFalse(
|
|
browserNavigationShouldOpenInNewTab(
|
|
navigationType: .other,
|
|
modifierFlags: [],
|
|
buttonNumber: 0
|
|
)
|
|
)
|
|
}
|
|
|
|
func testLinkActivatedButtonFourWithoutMiddleIntentStaysInCurrentTab() {
|
|
XCTAssertFalse(
|
|
browserNavigationShouldOpenInNewTab(
|
|
navigationType: .linkActivated,
|
|
modifierFlags: [],
|
|
buttonNumber: 4,
|
|
hasRecentMiddleClickIntent: false
|
|
)
|
|
)
|
|
}
|
|
|
|
func testLinkActivatedButtonFourWithRecentMiddleIntentOpensInNewTab() {
|
|
XCTAssertTrue(
|
|
browserNavigationShouldOpenInNewTab(
|
|
navigationType: .linkActivated,
|
|
modifierFlags: [],
|
|
buttonNumber: 4,
|
|
hasRecentMiddleClickIntent: true
|
|
)
|
|
)
|
|
}
|
|
|
|
func testLinkActivatedUsesCurrentEventFallbackForMiddleClick() {
|
|
XCTAssertTrue(
|
|
browserNavigationShouldOpenInNewTab(
|
|
navigationType: .linkActivated,
|
|
modifierFlags: [],
|
|
buttonNumber: 0,
|
|
currentEventType: .otherMouseUp,
|
|
currentEventButtonNumber: 2
|
|
)
|
|
)
|
|
}
|
|
|
|
func testCurrentEventFallbackDoesNotAffectNonLinkNavigation() {
|
|
XCTAssertFalse(
|
|
browserNavigationShouldOpenInNewTab(
|
|
navigationType: .reload,
|
|
modifierFlags: [],
|
|
buttonNumber: 0,
|
|
currentEventType: .otherMouseUp,
|
|
currentEventButtonNumber: 2
|
|
)
|
|
)
|
|
}
|
|
|
|
func testNonLinkNavigationNeverForcesNewTab() {
|
|
XCTAssertFalse(
|
|
browserNavigationShouldOpenInNewTab(
|
|
navigationType: .reload,
|
|
modifierFlags: [.command],
|
|
buttonNumber: 2
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserPopupDecisionTests: XCTestCase {
|
|
func testLinkActivatedPlainLeftClickDoesNotCreatePopup() {
|
|
XCTAssertFalse(
|
|
browserNavigationShouldCreatePopup(
|
|
navigationType: .linkActivated,
|
|
modifierFlags: [],
|
|
buttonNumber: 0
|
|
)
|
|
)
|
|
}
|
|
|
|
func testOtherNavigationPlainLeftClickCreatesPopup() {
|
|
XCTAssertTrue(
|
|
browserNavigationShouldCreatePopup(
|
|
navigationType: .other,
|
|
modifierFlags: [],
|
|
buttonNumber: 0
|
|
)
|
|
)
|
|
}
|
|
|
|
func testOtherNavigationMiddleClickDoesNotCreatePopup() {
|
|
XCTAssertFalse(
|
|
browserNavigationShouldCreatePopup(
|
|
navigationType: .other,
|
|
modifierFlags: [],
|
|
buttonNumber: 2
|
|
)
|
|
)
|
|
}
|
|
|
|
func testLinkActivatedCmdClickDoesNotCreatePopup() {
|
|
XCTAssertFalse(
|
|
browserNavigationShouldCreatePopup(
|
|
navigationType: .linkActivated,
|
|
modifierFlags: [.command],
|
|
buttonNumber: 0
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserNilTargetFallbackDecisionTests: XCTestCase {
|
|
func testOtherNavigationDoesNotFallbackToNewTab() {
|
|
XCTAssertFalse(
|
|
browserNavigationShouldFallbackNilTargetToNewTab(
|
|
navigationType: .other
|
|
)
|
|
)
|
|
}
|
|
|
|
func testLinkActivatedNavigationFallsBackToNewTab() {
|
|
XCTAssertTrue(
|
|
browserNavigationShouldFallbackNilTargetToNewTab(
|
|
navigationType: .linkActivated
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserPopupContentRectTests: XCTestCase {
|
|
func testExplicitTopOriginCoordinatesConvertToAppKitBottomOrigin() {
|
|
let rect = browserPopupContentRect(
|
|
requestedWidth: 400,
|
|
requestedHeight: 300,
|
|
requestedX: 150,
|
|
requestedTopY: 120,
|
|
visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800)
|
|
)
|
|
|
|
XCTAssertEqual(rect.origin.x, 150, accuracy: 0.01)
|
|
XCTAssertEqual(rect.origin.y, 430, accuracy: 0.01)
|
|
XCTAssertEqual(rect.width, 400, accuracy: 0.01)
|
|
XCTAssertEqual(rect.height, 300, accuracy: 0.01)
|
|
}
|
|
|
|
func testExplicitCoordinatesClampToVisibleFrame() {
|
|
let rect = browserPopupContentRect(
|
|
requestedWidth: 1400,
|
|
requestedHeight: 1200,
|
|
requestedX: 900,
|
|
requestedTopY: -25,
|
|
visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800)
|
|
)
|
|
|
|
XCTAssertEqual(rect.origin.x, 100, accuracy: 0.01)
|
|
XCTAssertEqual(rect.origin.y, 50, accuracy: 0.01)
|
|
XCTAssertEqual(rect.width, 1000, accuracy: 0.01)
|
|
XCTAssertEqual(rect.height, 800, accuracy: 0.01)
|
|
}
|
|
|
|
func testMissingCoordinatesCentersPopup() {
|
|
let rect = browserPopupContentRect(
|
|
requestedWidth: 300,
|
|
requestedHeight: 200,
|
|
requestedX: nil,
|
|
requestedTopY: nil,
|
|
visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800)
|
|
)
|
|
|
|
XCTAssertEqual(rect.origin.x, 450, accuracy: 0.01)
|
|
XCTAssertEqual(rect.origin.y, 350, accuracy: 0.01)
|
|
XCTAssertEqual(rect.width, 300, accuracy: 0.01)
|
|
XCTAssertEqual(rect.height, 200, accuracy: 0.01)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class BrowserJavaScriptDialogDelegateTests: XCTestCase {
|
|
func testBrowserPanelUIDelegateImplementsJavaScriptDialogSelectors() {
|
|
let panel = BrowserPanel(workspaceId: UUID())
|
|
guard let uiDelegate = panel.webView.uiDelegate as? NSObject else {
|
|
XCTFail("Expected BrowserPanel webView.uiDelegate to be an NSObject")
|
|
return
|
|
}
|
|
|
|
XCTAssertTrue(
|
|
uiDelegate.responds(
|
|
to: #selector(
|
|
WKUIDelegate.webView(
|
|
_:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler:
|
|
)
|
|
)
|
|
),
|
|
"Browser UI delegate must implement JavaScript alert handling"
|
|
)
|
|
XCTAssertTrue(
|
|
uiDelegate.responds(
|
|
to: #selector(
|
|
WKUIDelegate.webView(
|
|
_:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler:
|
|
)
|
|
)
|
|
),
|
|
"Browser UI delegate must implement JavaScript confirm handling"
|
|
)
|
|
XCTAssertTrue(
|
|
uiDelegate.responds(
|
|
to: #selector(
|
|
WKUIDelegate.webView(
|
|
_:runJavaScriptTextInputPanelWithPrompt:defaultText:initiatedByFrame:completionHandler:
|
|
)
|
|
)
|
|
),
|
|
"Browser UI delegate must implement JavaScript prompt handling"
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class BrowserSessionHistoryRestoreTests: XCTestCase {
|
|
func testSessionNavigationHistorySnapshotUsesRestoredStacks() {
|
|
let panel = BrowserPanel(workspaceId: UUID())
|
|
|
|
panel.restoreSessionNavigationHistory(
|
|
backHistoryURLStrings: [
|
|
"https://example.com/a",
|
|
"https://example.com/b"
|
|
],
|
|
forwardHistoryURLStrings: [
|
|
"https://example.com/d"
|
|
],
|
|
currentURLString: "https://example.com/c"
|
|
)
|
|
|
|
XCTAssertTrue(panel.canGoBack)
|
|
XCTAssertTrue(panel.canGoForward)
|
|
|
|
let snapshot = panel.sessionNavigationHistorySnapshot()
|
|
XCTAssertEqual(
|
|
snapshot.backHistoryURLStrings,
|
|
["https://example.com/a", "https://example.com/b"]
|
|
)
|
|
XCTAssertEqual(
|
|
snapshot.forwardHistoryURLStrings,
|
|
["https://example.com/d"]
|
|
)
|
|
}
|
|
|
|
func testSessionNavigationHistoryBackAndForwardUpdateStacks() {
|
|
let panel = BrowserPanel(workspaceId: UUID())
|
|
|
|
panel.restoreSessionNavigationHistory(
|
|
backHistoryURLStrings: [
|
|
"https://example.com/a",
|
|
"https://example.com/b"
|
|
],
|
|
forwardHistoryURLStrings: [
|
|
"https://example.com/d"
|
|
],
|
|
currentURLString: "https://example.com/c"
|
|
)
|
|
|
|
panel.goBack()
|
|
let afterBack = panel.sessionNavigationHistorySnapshot()
|
|
XCTAssertEqual(afterBack.backHistoryURLStrings, ["https://example.com/a"])
|
|
XCTAssertEqual(
|
|
afterBack.forwardHistoryURLStrings,
|
|
["https://example.com/c", "https://example.com/d"]
|
|
)
|
|
XCTAssertTrue(panel.canGoBack)
|
|
XCTAssertTrue(panel.canGoForward)
|
|
|
|
panel.goForward()
|
|
let afterForward = panel.sessionNavigationHistorySnapshot()
|
|
XCTAssertEqual(
|
|
afterForward.backHistoryURLStrings,
|
|
["https://example.com/a", "https://example.com/b"]
|
|
)
|
|
XCTAssertEqual(afterForward.forwardHistoryURLStrings, ["https://example.com/d"])
|
|
XCTAssertTrue(panel.canGoBack)
|
|
XCTAssertTrue(panel.canGoForward)
|
|
}
|
|
|
|
func testWebViewReplacementAfterProcessTerminationUpdatesInstanceIdentity() {
|
|
let panel = BrowserPanel(
|
|
workspaceId: UUID(),
|
|
initialURL: URL(string: "https://example.com")
|
|
)
|
|
let oldWebView = panel.webView
|
|
let oldInstanceID = panel.webViewInstanceID
|
|
|
|
panel.debugSimulateWebContentProcessTermination()
|
|
|
|
XCTAssertFalse(panel.webView === oldWebView)
|
|
XCTAssertNotEqual(panel.webViewInstanceID, oldInstanceID)
|
|
XCTAssertNotNil(panel.webView.navigationDelegate)
|
|
XCTAssertNotNil(panel.webView.uiDelegate)
|
|
}
|
|
|
|
func testWebViewReplacementPreservesEmptyNewTabRenderState() {
|
|
let panel = BrowserPanel(workspaceId: UUID())
|
|
XCTAssertFalse(panel.shouldRenderWebView)
|
|
|
|
panel.debugSimulateWebContentProcessTermination()
|
|
|
|
XCTAssertFalse(panel.shouldRenderWebView)
|
|
}
|
|
|
|
func testResetSidebarContextClearsBrowserPanelsIntoNewTabState() throws {
|
|
let workspace = Workspace()
|
|
let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first)
|
|
let contextPanelId = try XCTUnwrap(workspace.focusedPanelId)
|
|
let browser = try XCTUnwrap(
|
|
workspace.newBrowserSurface(
|
|
inPane: paneId,
|
|
url: URL(string: "https://example.com"),
|
|
focus: false
|
|
)
|
|
)
|
|
|
|
browser.restoreSessionNavigationHistory(
|
|
backHistoryURLStrings: ["https://example.com/prev"],
|
|
forwardHistoryURLStrings: ["https://example.com/next"],
|
|
currentURLString: "https://example.com/current"
|
|
)
|
|
browser.startFind()
|
|
|
|
workspace.statusEntries["task"] = SidebarStatusEntry(key: "task", value: "Issue #1208")
|
|
workspace.metadataBlocks["notes"] = SidebarMetadataBlock(
|
|
key: "notes",
|
|
markdown: "test",
|
|
priority: 0,
|
|
timestamp: Date()
|
|
)
|
|
workspace.progress = SidebarProgressState(value: 0.5, label: "Loading")
|
|
workspace.updatePanelGitBranch(panelId: contextPanelId, branch: "issue-1208", isDirty: false)
|
|
workspace.updatePanelPullRequest(
|
|
panelId: contextPanelId,
|
|
number: 1208,
|
|
label: "PR",
|
|
url: try XCTUnwrap(URL(string: "https://example.com/pull/1208")),
|
|
status: .open
|
|
)
|
|
workspace.logEntries.append(
|
|
SidebarLogEntry(
|
|
message: "Issue #1208",
|
|
level: .info,
|
|
source: "test",
|
|
timestamp: Date()
|
|
)
|
|
)
|
|
workspace.surfaceListeningPorts[contextPanelId] = [3000]
|
|
workspace.recomputeListeningPorts()
|
|
|
|
XCTAssertTrue(browser.shouldRenderWebView)
|
|
XCTAssertNotNil(browser.preferredURLStringForOmnibar())
|
|
XCTAssertTrue(browser.canGoBack)
|
|
XCTAssertTrue(browser.canGoForward)
|
|
XCTAssertNotNil(browser.searchState)
|
|
XCTAssertFalse(workspace.statusEntries.isEmpty)
|
|
XCTAssertFalse(workspace.logEntries.isEmpty)
|
|
XCTAssertFalse(workspace.metadataBlocks.isEmpty)
|
|
XCTAssertNotNil(workspace.progress)
|
|
XCTAssertNotNil(workspace.gitBranch)
|
|
XCTAssertNotNil(workspace.pullRequest)
|
|
XCTAssertEqual(workspace.listeningPorts, [3000])
|
|
|
|
let priorWebView = browser.webView
|
|
let priorInstanceID = browser.webViewInstanceID
|
|
workspace.resetSidebarContext(reason: "test")
|
|
|
|
XCTAssertTrue(workspace.statusEntries.isEmpty)
|
|
XCTAssertTrue(workspace.logEntries.isEmpty)
|
|
XCTAssertTrue(workspace.metadataBlocks.isEmpty)
|
|
XCTAssertNil(workspace.progress)
|
|
XCTAssertNil(workspace.gitBranch)
|
|
XCTAssertTrue(workspace.panelGitBranches.isEmpty)
|
|
XCTAssertNil(workspace.pullRequest)
|
|
XCTAssertTrue(workspace.panelPullRequests.isEmpty)
|
|
XCTAssertTrue(workspace.surfaceListeningPorts.isEmpty)
|
|
XCTAssertTrue(workspace.listeningPorts.isEmpty)
|
|
XCTAssertFalse(browser.shouldRenderWebView)
|
|
XCTAssertNil(browser.preferredURLStringForOmnibar())
|
|
XCTAssertFalse(browser.canGoBack)
|
|
XCTAssertFalse(browser.canGoForward)
|
|
XCTAssertNil(browser.searchState)
|
|
XCTAssertFalse(browser.webView === priorWebView)
|
|
XCTAssertNotEqual(browser.webViewInstanceID, priorInstanceID)
|
|
}
|
|
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
|
private final class WKInspectorProbeView: NSView {
|
|
override var acceptsFirstResponder: Bool { true }
|
|
}
|
|
|
|
private final class FakeInspector: NSObject {
|
|
enum HideBehavior {
|
|
case unsupported
|
|
case noEffect
|
|
case hides
|
|
}
|
|
|
|
private(set) var attachCount = 0
|
|
private(set) var showCount = 0
|
|
private(set) var hideCount = 0
|
|
private(set) var closeCount = 0
|
|
private let hideBehavior: HideBehavior
|
|
private var visible = false
|
|
private var attached = false
|
|
|
|
init(hideBehavior: HideBehavior = .unsupported) {
|
|
self.hideBehavior = hideBehavior
|
|
super.init()
|
|
}
|
|
|
|
override func responds(to aSelector: Selector!) -> Bool {
|
|
guard NSStringFromSelector(aSelector) == "hide" else {
|
|
return super.responds(to: aSelector)
|
|
}
|
|
return hideBehavior != .unsupported
|
|
}
|
|
|
|
@objc func isVisible() -> Bool {
|
|
visible
|
|
}
|
|
|
|
@objc func isAttached() -> Bool {
|
|
attached
|
|
}
|
|
|
|
@objc func attach() {
|
|
attachCount += 1
|
|
attached = true
|
|
show()
|
|
}
|
|
|
|
@objc func show() {
|
|
showCount += 1
|
|
visible = true
|
|
}
|
|
|
|
@objc func hide() {
|
|
hideCount += 1
|
|
guard hideBehavior == .hides else { return }
|
|
visible = false
|
|
}
|
|
|
|
@objc func close() {
|
|
closeCount += 1
|
|
visible = false
|
|
attached = false
|
|
}
|
|
}
|
|
|
|
override class func setUp() {
|
|
super.setUp()
|
|
installCmuxUnitTestInspectorOverride()
|
|
}
|
|
|
|
private func makePanelWithInspector(
|
|
hideBehavior: FakeInspector.HideBehavior = .unsupported
|
|
) -> (BrowserPanel, FakeInspector) {
|
|
let panel = BrowserPanel(workspaceId: UUID())
|
|
let inspector = FakeInspector(hideBehavior: hideBehavior)
|
|
panel.webView.cmuxSetUnitTestInspector(inspector)
|
|
return (panel, inspector)
|
|
}
|
|
|
|
private func findHostContainerView(in root: NSView) -> WebViewRepresentable.HostContainerView? {
|
|
if let host = root as? WebViewRepresentable.HostContainerView {
|
|
return host
|
|
}
|
|
for subview in root.subviews {
|
|
if let host = findHostContainerView(in: subview) {
|
|
return host
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func waitForDeveloperToolsTransitions() {
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
|
}
|
|
|
|
private func findWindowBrowserSlotView(in root: NSView) -> WindowBrowserSlotView? {
|
|
if let slot = root as? WindowBrowserSlotView {
|
|
return slot
|
|
}
|
|
for subview in root.subviews {
|
|
if let slot = findWindowBrowserSlotView(in: subview) {
|
|
return slot
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() {
|
|
let (panel, inspector) = makePanelWithInspector()
|
|
|
|
XCTAssertTrue(panel.showDeveloperTools())
|
|
XCTAssertTrue(panel.isDeveloperToolsVisible())
|
|
XCTAssertEqual(inspector.showCount, 1)
|
|
|
|
// Simulate WebKit closing inspector during detach/reattach churn.
|
|
inspector.close()
|
|
XCTAssertFalse(panel.isDeveloperToolsVisible())
|
|
XCTAssertEqual(inspector.closeCount, 1)
|
|
|
|
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
|
XCTAssertTrue(panel.isDeveloperToolsVisible())
|
|
XCTAssertEqual(inspector.showCount, 2)
|
|
}
|
|
|
|
func testSyncRespectsManualCloseAndPreventsUnexpectedRestore() {
|
|
let (panel, inspector) = makePanelWithInspector()
|
|
|
|
XCTAssertTrue(panel.showDeveloperTools())
|
|
XCTAssertEqual(inspector.showCount, 1)
|
|
|
|
// Simulate user closing inspector before detach.
|
|
inspector.close()
|
|
panel.syncDeveloperToolsPreferenceFromInspector()
|
|
|
|
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
|
XCTAssertFalse(panel.isDeveloperToolsVisible())
|
|
XCTAssertEqual(inspector.showCount, 1)
|
|
}
|
|
|
|
func testSyncCanPreserveVisibleIntentDuringDetachChurn() {
|
|
let (panel, inspector) = makePanelWithInspector()
|
|
|
|
XCTAssertTrue(panel.showDeveloperTools())
|
|
XCTAssertEqual(inspector.showCount, 1)
|
|
|
|
// Simulate a transient close caused by view detach, not user intent.
|
|
inspector.close()
|
|
panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true)
|
|
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
|
|
|
XCTAssertTrue(panel.isDeveloperToolsVisible())
|
|
XCTAssertEqual(inspector.showCount, 2)
|
|
}
|
|
|
|
func testForcedRefreshAfterAttachKeepsVisibleInspectorState() {
|
|
let (panel, inspector) = makePanelWithInspector()
|
|
|
|
XCTAssertTrue(panel.showDeveloperTools())
|
|
XCTAssertTrue(panel.isDeveloperToolsVisible())
|
|
XCTAssertEqual(inspector.showCount, 1)
|
|
XCTAssertEqual(inspector.closeCount, 0)
|
|
|
|
panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test")
|
|
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
|
|
|
XCTAssertTrue(panel.isDeveloperToolsVisible())
|
|
XCTAssertEqual(inspector.closeCount, 0)
|
|
XCTAssertEqual(inspector.showCount, 1)
|
|
|
|
// The force-refresh request should be one-shot.
|
|
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
|
XCTAssertEqual(inspector.closeCount, 0)
|
|
XCTAssertEqual(inspector.showCount, 1)
|
|
}
|
|
|
|
func testRefreshRequestTracksPendingStateUntilRestoreRuns() {
|
|
let (panel, _) = makePanelWithInspector()
|
|
|
|
XCTAssertTrue(panel.showDeveloperTools())
|
|
XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach())
|
|
|
|
panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test")
|
|
XCTAssertTrue(panel.hasPendingDeveloperToolsRefreshAfterAttach())
|
|
|
|
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
|
XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach())
|
|
}
|
|
|
|
func testRapidToggleCoalescesToFinalVisibleIntentWithoutExtraInspectorCalls() {
|
|
let (panel, inspector) = makePanelWithInspector()
|
|
|
|
XCTAssertTrue(panel.toggleDeveloperTools())
|
|
XCTAssertTrue(panel.toggleDeveloperTools())
|
|
XCTAssertTrue(panel.toggleDeveloperTools())
|
|
XCTAssertEqual(inspector.showCount, 1)
|
|
XCTAssertEqual(inspector.closeCount, 0)
|
|
|
|
waitForDeveloperToolsTransitions()
|
|
|
|
XCTAssertTrue(panel.isDeveloperToolsVisible())
|
|
XCTAssertEqual(inspector.showCount, 1)
|
|
XCTAssertEqual(inspector.closeCount, 0)
|
|
}
|
|
|
|
func testRapidToggleQueuesHideAfterOpenTransitionSettles() {
|
|
let (panel, inspector) = makePanelWithInspector()
|
|
|
|
XCTAssertTrue(panel.toggleDeveloperTools())
|
|
XCTAssertTrue(panel.toggleDeveloperTools())
|
|
XCTAssertEqual(inspector.showCount, 1)
|
|
XCTAssertEqual(inspector.closeCount, 0)
|
|
|
|
waitForDeveloperToolsTransitions()
|
|
|
|
XCTAssertFalse(panel.isDeveloperToolsVisible())
|
|
XCTAssertEqual(inspector.showCount, 1)
|
|
XCTAssertEqual(inspector.closeCount, 1)
|
|
}
|
|
|
|
func testToggleDeveloperToolsFallsBackToCloseWhenHideDoesNotConcealInspector() {
|
|
let (panel, inspector) = makePanelWithInspector(hideBehavior: .noEffect)
|
|
|
|
XCTAssertTrue(panel.showDeveloperTools())
|
|
XCTAssertTrue(panel.isDeveloperToolsVisible())
|
|
|
|
XCTAssertTrue(panel.toggleDeveloperTools())
|
|
|
|
XCTAssertEqual(inspector.hideCount, 1)
|
|
XCTAssertEqual(inspector.closeCount, 1)
|
|
XCTAssertFalse(panel.isDeveloperToolsVisible())
|
|
}
|
|
|
|
func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() {
|
|
let (panel, _) = makePanelWithInspector()
|
|
|
|
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
|
|
XCTAssertTrue(panel.showDeveloperTools())
|
|
XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
|
|
XCTAssertTrue(panel.hideDeveloperTools())
|
|
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
|
|
}
|
|
|
|
func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsVisible() {
|
|
let (panel, _) = makePanelWithInspector()
|
|
let paneId = PaneID(id: UUID())
|
|
XCTAssertTrue(panel.showDeveloperTools())
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
let anchor = NSView(frame: NSRect(x: 30, y: 30, width: 180, height: 140))
|
|
window.contentView?.addSubview(anchor)
|
|
window.makeKeyAndOrderFront(nil)
|
|
window.displayIfNeeded()
|
|
window.contentView?.layoutSubtreeIfNeeded()
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
|
|
|
BrowserWindowPortalRegistry.bind(webView: panel.webView, to: anchor, visibleInUI: true, zPriority: 1)
|
|
BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
|
|
XCTAssertNotNil(panel.webView.superview)
|
|
|
|
let representable = WebViewRepresentable(
|
|
panel: panel,
|
|
paneId: paneId,
|
|
shouldAttachWebView: true,
|
|
useLocalInlineHosting: false,
|
|
shouldFocusWebView: false,
|
|
isPanelFocused: true,
|
|
portalZPriority: 0,
|
|
paneDropZone: nil,
|
|
searchOverlay: nil,
|
|
paneTopChromeHeight: 0
|
|
)
|
|
let coordinator = representable.makeCoordinator()
|
|
coordinator.webView = panel.webView
|
|
WebViewRepresentable.dismantleNSView(anchor, coordinator: coordinator)
|
|
|
|
XCTAssertNotNil(panel.webView.superview)
|
|
window.orderOut(nil)
|
|
}
|
|
|
|
func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsHidden() {
|
|
let (panel, _) = makePanelWithInspector()
|
|
let paneId = PaneID(id: UUID())
|
|
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 200, height: 150))
|
|
window.contentView?.addSubview(anchor)
|
|
window.makeKeyAndOrderFront(nil)
|
|
window.displayIfNeeded()
|
|
window.contentView?.layoutSubtreeIfNeeded()
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
|
|
|
BrowserWindowPortalRegistry.bind(webView: panel.webView, to: anchor, visibleInUI: true, zPriority: 1)
|
|
BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
|
|
XCTAssertNotNil(panel.webView.superview)
|
|
|
|
let representable = WebViewRepresentable(
|
|
panel: panel,
|
|
paneId: paneId,
|
|
shouldAttachWebView: true,
|
|
useLocalInlineHosting: false,
|
|
shouldFocusWebView: false,
|
|
isPanelFocused: true,
|
|
portalZPriority: 0,
|
|
paneDropZone: nil,
|
|
searchOverlay: nil,
|
|
paneTopChromeHeight: 0
|
|
)
|
|
let coordinator = representable.makeCoordinator()
|
|
coordinator.webView = panel.webView
|
|
WebViewRepresentable.dismantleNSView(anchor, coordinator: coordinator)
|
|
|
|
XCTAssertNotNil(panel.webView.superview)
|
|
window.orderOut(nil)
|
|
}
|
|
|
|
func testTransientHideAttachmentPreserveDisablesForSideDockedInspectorLayout() {
|
|
let (panel, _) = makePanelWithInspector()
|
|
XCTAssertTrue(panel.showDeveloperTools())
|
|
|
|
let host = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 240))
|
|
panel.webView.frame = NSRect(x: 0, y: 0, width: 120, height: host.bounds.height)
|
|
host.addSubview(panel.webView)
|
|
|
|
let inspectorContainer = NSView(
|
|
frame: NSRect(x: 120, y: 0, width: host.bounds.width - 120, height: host.bounds.height)
|
|
)
|
|
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
|
|
inspectorView.autoresizingMask = [.width, .height]
|
|
inspectorContainer.addSubview(inspectorView)
|
|
host.addSubview(inspectorContainer)
|
|
|
|
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
|
|
}
|
|
|
|
func testTransientHideAttachmentPreserveStaysEnabledForBottomDockedInspectorLayout() {
|
|
let (panel, _) = makePanelWithInspector()
|
|
XCTAssertTrue(panel.showDeveloperTools())
|
|
|
|
let host = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 240))
|
|
panel.webView.frame = NSRect(x: 0, y: 80, width: host.bounds.width, height: host.bounds.height - 80)
|
|
host.addSubview(panel.webView)
|
|
|
|
let inspectorContainer = NSView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 80))
|
|
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
|
|
inspectorView.autoresizingMask = [.width, .height]
|
|
inspectorContainer.addSubview(inspectorView)
|
|
host.addSubview(inspectorContainer)
|
|
|
|
XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
|
|
}
|
|
|
|
func testOffWindowReplacementLocalHostDoesNotStealVisibleDevToolsWebView() {
|
|
let (panel, _) = makePanelWithInspector()
|
|
XCTAssertTrue(panel.showDeveloperTools())
|
|
|
|
let paneId = PaneID(id: UUID())
|
|
let representable = WebViewRepresentable(
|
|
panel: panel,
|
|
paneId: paneId,
|
|
shouldAttachWebView: false,
|
|
useLocalInlineHosting: true,
|
|
shouldFocusWebView: false,
|
|
isPanelFocused: true,
|
|
portalZPriority: 0,
|
|
paneDropZone: nil,
|
|
searchOverlay: nil,
|
|
paneTopChromeHeight: 0
|
|
)
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let visibleHosting = NSHostingView(rootView: representable)
|
|
visibleHosting.frame = contentView.bounds
|
|
visibleHosting.autoresizingMask = [.width, .height]
|
|
contentView.addSubview(visibleHosting)
|
|
window.makeKeyAndOrderFront(nil)
|
|
window.displayIfNeeded()
|
|
contentView.layoutSubtreeIfNeeded()
|
|
visibleHosting.layoutSubtreeIfNeeded()
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
|
|
|
guard let visibleHost = findHostContainerView(in: visibleHosting) else {
|
|
XCTFail("Expected visible local host")
|
|
return
|
|
}
|
|
guard let visibleSlot = panel.webView.superview as? WindowBrowserSlotView else {
|
|
XCTFail("Expected visible local inline slot")
|
|
return
|
|
}
|
|
|
|
let inspectorView = WKInspectorProbeView(
|
|
frame: NSRect(x: 0, y: 0, width: visibleSlot.bounds.width, height: 72)
|
|
)
|
|
inspectorView.autoresizingMask = [.width]
|
|
visibleSlot.addSubview(inspectorView)
|
|
panel.webView.frame = NSRect(
|
|
x: 0,
|
|
y: inspectorView.frame.maxY,
|
|
width: visibleSlot.bounds.width,
|
|
height: visibleSlot.bounds.height - inspectorView.frame.height
|
|
)
|
|
visibleSlot.layoutSubtreeIfNeeded()
|
|
|
|
let detachedRoot = NSView(frame: visibleHosting.frame)
|
|
let offWindowHosting = NSHostingView(rootView: representable)
|
|
offWindowHosting.frame = detachedRoot.bounds
|
|
offWindowHosting.autoresizingMask = [.width, .height]
|
|
detachedRoot.addSubview(offWindowHosting)
|
|
detachedRoot.layoutSubtreeIfNeeded()
|
|
offWindowHosting.layoutSubtreeIfNeeded()
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
|
|
|
XCTAssertNotNil(findHostContainerView(in: offWindowHosting), "Expected off-window replacement host")
|
|
XCTAssertTrue(visibleHost.window === window)
|
|
XCTAssertTrue(
|
|
panel.webView.superview === visibleSlot,
|
|
"An off-window replacement host should not steal a visible DevTools-hosted web view during split zoom churn"
|
|
)
|
|
XCTAssertTrue(
|
|
inspectorView.superview === visibleSlot,
|
|
"An off-window replacement host should leave DevTools companion views in the visible local host"
|
|
)
|
|
}
|
|
|
|
func testVisibleReplacementLocalHostNormalizesBottomDockedInspectorFrames() {
|
|
let (panel, _) = makePanelWithInspector()
|
|
XCTAssertTrue(panel.showDeveloperTools())
|
|
|
|
let paneId = PaneID(id: UUID())
|
|
let representable = WebViewRepresentable(
|
|
panel: panel,
|
|
paneId: paneId,
|
|
shouldAttachWebView: false,
|
|
useLocalInlineHosting: true,
|
|
shouldFocusWebView: false,
|
|
isPanelFocused: true,
|
|
portalZPriority: 0,
|
|
paneDropZone: nil,
|
|
searchOverlay: nil,
|
|
paneTopChromeHeight: 0
|
|
)
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let narrowHosting = NSHostingView(rootView: representable)
|
|
narrowHosting.frame = NSRect(x: 180, y: 0, width: 180, height: 240)
|
|
contentView.addSubview(narrowHosting)
|
|
|
|
window.makeKeyAndOrderFront(nil)
|
|
window.displayIfNeeded()
|
|
contentView.layoutSubtreeIfNeeded()
|
|
narrowHosting.layoutSubtreeIfNeeded()
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
|
|
|
guard let initialSlot = panel.webView.superview as? WindowBrowserSlotView else {
|
|
XCTFail("Expected initial local inline slot")
|
|
return
|
|
}
|
|
|
|
let inspectorView = WKInspectorProbeView(
|
|
frame: NSRect(x: 0, y: 0, width: initialSlot.bounds.width, height: 72)
|
|
)
|
|
inspectorView.autoresizingMask = [.width]
|
|
initialSlot.addSubview(inspectorView)
|
|
panel.webView.frame = NSRect(
|
|
x: 0,
|
|
y: inspectorView.frame.maxY,
|
|
width: initialSlot.bounds.width,
|
|
height: initialSlot.bounds.height - inspectorView.frame.height
|
|
)
|
|
initialSlot.layoutSubtreeIfNeeded()
|
|
|
|
let replacementHosting = NSHostingView(rootView: representable)
|
|
replacementHosting.frame = contentView.bounds
|
|
replacementHosting.autoresizingMask = [.width, .height]
|
|
contentView.addSubview(replacementHosting, positioned: .above, relativeTo: narrowHosting)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
replacementHosting.layoutSubtreeIfNeeded()
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
|
|
|
replacementHosting.rootView = representable
|
|
contentView.layoutSubtreeIfNeeded()
|
|
replacementHosting.layoutSubtreeIfNeeded()
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
|
|
|
narrowHosting.removeFromSuperview()
|
|
contentView.layoutSubtreeIfNeeded()
|
|
replacementHosting.layoutSubtreeIfNeeded()
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
|
|
|
guard let replacementHost = findHostContainerView(in: replacementHosting),
|
|
let replacementSlot = findWindowBrowserSlotView(in: replacementHost) else {
|
|
XCTFail("Expected replacement local inline host")
|
|
return
|
|
}
|
|
|
|
XCTAssertTrue(
|
|
panel.webView.superview === replacementSlot,
|
|
"A visible replacement local host should take over the hosted page"
|
|
)
|
|
XCTAssertTrue(
|
|
inspectorView.superview === replacementSlot,
|
|
"A visible replacement local host should move the DevTools companion views with the page"
|
|
)
|
|
XCTAssertEqual(inspectorView.frame.minX, 0, accuracy: 0.5)
|
|
XCTAssertEqual(inspectorView.frame.minY, 0, accuracy: 0.5)
|
|
XCTAssertEqual(inspectorView.frame.width, replacementSlot.bounds.width, accuracy: 0.5)
|
|
XCTAssertEqual(inspectorView.frame.height, 72, accuracy: 0.5)
|
|
XCTAssertEqual(panel.webView.frame.minX, 0, accuracy: 0.5)
|
|
XCTAssertEqual(panel.webView.frame.minY, 72, accuracy: 0.5)
|
|
XCTAssertEqual(panel.webView.frame.width, replacementSlot.bounds.width, accuracy: 0.5)
|
|
XCTAssertEqual(panel.webView.frame.height, replacementSlot.bounds.height - 72, accuracy: 0.5)
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserOmnibarCommandNavigationTests: XCTestCase {
|
|
func testArrowNavigationDeltaRequiresFocusedAddressBarAndNoModifierFlags() {
|
|
XCTAssertNil(
|
|
browserOmnibarSelectionDeltaForArrowNavigation(
|
|
hasFocusedAddressBar: false,
|
|
flags: [],
|
|
keyCode: 126
|
|
)
|
|
)
|
|
XCTAssertNil(
|
|
browserOmnibarSelectionDeltaForArrowNavigation(
|
|
hasFocusedAddressBar: true,
|
|
flags: [.command],
|
|
keyCode: 126
|
|
)
|
|
)
|
|
XCTAssertEqual(
|
|
browserOmnibarSelectionDeltaForArrowNavigation(
|
|
hasFocusedAddressBar: true,
|
|
flags: [],
|
|
keyCode: 126
|
|
),
|
|
-1
|
|
)
|
|
XCTAssertEqual(
|
|
browserOmnibarSelectionDeltaForArrowNavigation(
|
|
hasFocusedAddressBar: true,
|
|
flags: [],
|
|
keyCode: 125
|
|
),
|
|
1
|
|
)
|
|
}
|
|
|
|
func testArrowNavigationDeltaIgnoresCapsLockModifier() {
|
|
XCTAssertEqual(
|
|
browserOmnibarSelectionDeltaForArrowNavigation(
|
|
hasFocusedAddressBar: true,
|
|
flags: [.capsLock],
|
|
keyCode: 126
|
|
),
|
|
-1
|
|
)
|
|
XCTAssertEqual(
|
|
browserOmnibarSelectionDeltaForArrowNavigation(
|
|
hasFocusedAddressBar: true,
|
|
flags: [.capsLock],
|
|
keyCode: 125
|
|
),
|
|
1
|
|
)
|
|
}
|
|
|
|
func testCommandNavigationDeltaRequiresFocusedAddressBarAndCommandOrControlOnly() {
|
|
XCTAssertNil(
|
|
browserOmnibarSelectionDeltaForCommandNavigation(
|
|
hasFocusedAddressBar: false,
|
|
flags: [.command],
|
|
chars: "n"
|
|
)
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
browserOmnibarSelectionDeltaForCommandNavigation(
|
|
hasFocusedAddressBar: true,
|
|
flags: [.command],
|
|
chars: "n"
|
|
),
|
|
1
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
browserOmnibarSelectionDeltaForCommandNavigation(
|
|
hasFocusedAddressBar: true,
|
|
flags: [.command],
|
|
chars: "p"
|
|
),
|
|
-1
|
|
)
|
|
|
|
XCTAssertNil(
|
|
browserOmnibarSelectionDeltaForCommandNavigation(
|
|
hasFocusedAddressBar: true,
|
|
flags: [.command, .shift],
|
|
chars: "n"
|
|
)
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
browserOmnibarSelectionDeltaForCommandNavigation(
|
|
hasFocusedAddressBar: true,
|
|
flags: [.control],
|
|
chars: "p"
|
|
),
|
|
-1
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
browserOmnibarSelectionDeltaForCommandNavigation(
|
|
hasFocusedAddressBar: true,
|
|
flags: [.control],
|
|
chars: "n"
|
|
),
|
|
1
|
|
)
|
|
}
|
|
|
|
func testCommandNavigationDeltaIgnoresCapsLockModifier() {
|
|
XCTAssertEqual(
|
|
browserOmnibarSelectionDeltaForCommandNavigation(
|
|
hasFocusedAddressBar: true,
|
|
flags: [.control, .capsLock],
|
|
chars: "n"
|
|
),
|
|
1
|
|
)
|
|
XCTAssertEqual(
|
|
browserOmnibarSelectionDeltaForCommandNavigation(
|
|
hasFocusedAddressBar: true,
|
|
flags: [.command, .capsLock],
|
|
chars: "p"
|
|
),
|
|
-1
|
|
)
|
|
}
|
|
|
|
func testSubmitOnReturnIgnoresCapsLockModifier() {
|
|
XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: []))
|
|
XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift]))
|
|
XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.capsLock]))
|
|
XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift, .capsLock]))
|
|
XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.command, .capsLock]))
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserReturnKeyDownRoutingTests: XCTestCase {
|
|
func testRoutesForReturnWhenBrowserFirstResponder() {
|
|
XCTAssertTrue(
|
|
shouldDispatchBrowserReturnViaFirstResponderKeyDown(
|
|
keyCode: 36,
|
|
firstResponderIsBrowser: true,
|
|
flags: []
|
|
)
|
|
)
|
|
}
|
|
|
|
func testRoutesForKeypadEnterWhenBrowserFirstResponder() {
|
|
XCTAssertTrue(
|
|
shouldDispatchBrowserReturnViaFirstResponderKeyDown(
|
|
keyCode: 76,
|
|
firstResponderIsBrowser: true,
|
|
flags: []
|
|
)
|
|
)
|
|
}
|
|
|
|
func testDoesNotRouteForNonEnterKey() {
|
|
XCTAssertFalse(
|
|
shouldDispatchBrowserReturnViaFirstResponderKeyDown(
|
|
keyCode: 13,
|
|
firstResponderIsBrowser: true,
|
|
flags: []
|
|
)
|
|
)
|
|
}
|
|
|
|
func testDoesNotRouteWhenFirstResponderIsNotBrowser() {
|
|
XCTAssertFalse(
|
|
shouldDispatchBrowserReturnViaFirstResponderKeyDown(
|
|
keyCode: 36,
|
|
firstResponderIsBrowser: false,
|
|
flags: []
|
|
)
|
|
)
|
|
}
|
|
|
|
func testRoutesForShiftReturnWhenBrowserFirstResponder() {
|
|
XCTAssertTrue(
|
|
shouldDispatchBrowserReturnViaFirstResponderKeyDown(
|
|
keyCode: 36,
|
|
firstResponderIsBrowser: true,
|
|
flags: [.shift]
|
|
)
|
|
)
|
|
}
|
|
|
|
func testDoesNotRouteForCommandShiftReturnWhenBrowserFirstResponder() {
|
|
XCTAssertFalse(
|
|
shouldDispatchBrowserReturnViaFirstResponderKeyDown(
|
|
keyCode: 36,
|
|
firstResponderIsBrowser: true,
|
|
flags: [.command, .shift]
|
|
)
|
|
)
|
|
}
|
|
|
|
func testDoesNotRouteForCommandReturnWhenBrowserFirstResponder() {
|
|
XCTAssertFalse(
|
|
shouldDispatchBrowserReturnViaFirstResponderKeyDown(
|
|
keyCode: 36,
|
|
firstResponderIsBrowser: true,
|
|
flags: [.command]
|
|
)
|
|
)
|
|
}
|
|
|
|
func testDoesNotRouteForOptionReturnWhenBrowserFirstResponder() {
|
|
XCTAssertFalse(
|
|
shouldDispatchBrowserReturnViaFirstResponderKeyDown(
|
|
keyCode: 36,
|
|
firstResponderIsBrowser: true,
|
|
flags: [.option]
|
|
)
|
|
)
|
|
}
|
|
|
|
func testDoesNotRouteForControlReturnWhenBrowserFirstResponder() {
|
|
XCTAssertFalse(
|
|
shouldDispatchBrowserReturnViaFirstResponderKeyDown(
|
|
keyCode: 36,
|
|
firstResponderIsBrowser: true,
|
|
flags: [.control]
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserZoomShortcutActionTests: XCTestCase {
|
|
func testZoomInSupportsEqualsAndPlusVariants() {
|
|
XCTAssertEqual(
|
|
browserZoomShortcutAction(flags: [.command], chars: "=", keyCode: 24),
|
|
.zoomIn
|
|
)
|
|
XCTAssertEqual(
|
|
browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 24),
|
|
.zoomIn
|
|
)
|
|
XCTAssertEqual(
|
|
browserZoomShortcutAction(flags: [.command, .shift], chars: "+", keyCode: 24),
|
|
.zoomIn
|
|
)
|
|
XCTAssertEqual(
|
|
browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 30),
|
|
.zoomIn
|
|
)
|
|
}
|
|
|
|
func testZoomOutSupportsMinusAndUnderscoreVariants() {
|
|
XCTAssertEqual(
|
|
browserZoomShortcutAction(flags: [.command], chars: "-", keyCode: 27),
|
|
.zoomOut
|
|
)
|
|
XCTAssertEqual(
|
|
browserZoomShortcutAction(flags: [.command, .shift], chars: "_", keyCode: 27),
|
|
.zoomOut
|
|
)
|
|
}
|
|
|
|
func testZoomInSupportsShiftedLiteralFromDifferentPhysicalKey() {
|
|
XCTAssertEqual(
|
|
browserZoomShortcutAction(
|
|
flags: [.command, .shift],
|
|
chars: ";",
|
|
keyCode: 41,
|
|
literalChars: "+"
|
|
),
|
|
.zoomIn
|
|
)
|
|
|
|
XCTAssertNil(
|
|
browserZoomShortcutAction(
|
|
flags: [.command, .shift],
|
|
chars: ";",
|
|
keyCode: 41
|
|
)
|
|
)
|
|
}
|
|
|
|
func testZoomRequiresCommandWithoutOptionOrControl() {
|
|
XCTAssertNil(browserZoomShortcutAction(flags: [], chars: "=", keyCode: 24))
|
|
XCTAssertNil(browserZoomShortcutAction(flags: [.command, .option], chars: "=", keyCode: 24))
|
|
XCTAssertNil(browserZoomShortcutAction(flags: [.command, .control], chars: "-", keyCode: 27))
|
|
}
|
|
|
|
func testResetSupportsCommandZero() {
|
|
XCTAssertEqual(
|
|
browserZoomShortcutAction(flags: [.command], chars: "0", keyCode: 29),
|
|
.reset
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase {
|
|
func testRoutesWhenGhosttyIsFirstResponderAndShortcutIsZoom() {
|
|
XCTAssertTrue(
|
|
shouldRouteTerminalFontZoomShortcutToGhostty(
|
|
firstResponderIsGhostty: true,
|
|
flags: [.command],
|
|
chars: "=",
|
|
keyCode: 24
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
shouldRouteTerminalFontZoomShortcutToGhostty(
|
|
firstResponderIsGhostty: true,
|
|
flags: [.command],
|
|
chars: "-",
|
|
keyCode: 27
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
shouldRouteTerminalFontZoomShortcutToGhostty(
|
|
firstResponderIsGhostty: true,
|
|
flags: [.command],
|
|
chars: "0",
|
|
keyCode: 29
|
|
)
|
|
)
|
|
}
|
|
|
|
func testDoesNotRouteWhenFirstResponderIsNotGhostty() {
|
|
XCTAssertFalse(
|
|
shouldRouteTerminalFontZoomShortcutToGhostty(
|
|
firstResponderIsGhostty: false,
|
|
flags: [.command],
|
|
chars: "=",
|
|
keyCode: 24
|
|
)
|
|
)
|
|
}
|
|
|
|
func testDoesNotRouteForNonZoomShortcuts() {
|
|
XCTAssertFalse(
|
|
shouldRouteTerminalFontZoomShortcutToGhostty(
|
|
firstResponderIsGhostty: true,
|
|
flags: [.command],
|
|
chars: "n",
|
|
keyCode: 45
|
|
)
|
|
)
|
|
}
|
|
|
|
func testRoutesForShiftedLiteralZoomShortcut() {
|
|
XCTAssertTrue(
|
|
shouldRouteTerminalFontZoomShortcutToGhostty(
|
|
firstResponderIsGhostty: true,
|
|
flags: [.command, .shift],
|
|
chars: ";",
|
|
keyCode: 41,
|
|
literalChars: "+"
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserSearchEngineTests: XCTestCase {
|
|
func testGoogleSearchURL() throws {
|
|
let url = try XCTUnwrap(BrowserSearchEngine.google.searchURL(query: "hello world"))
|
|
XCTAssertEqual(url.host, "www.google.com")
|
|
XCTAssertEqual(url.path, "/search")
|
|
XCTAssertTrue(url.absoluteString.contains("q=hello%20world"))
|
|
}
|
|
|
|
func testDuckDuckGoSearchURL() throws {
|
|
let url = try XCTUnwrap(BrowserSearchEngine.duckduckgo.searchURL(query: "hello world"))
|
|
XCTAssertEqual(url.host, "duckduckgo.com")
|
|
XCTAssertEqual(url.path, "/")
|
|
XCTAssertTrue(url.absoluteString.contains("q=hello%20world"))
|
|
}
|
|
|
|
func testBingSearchURL() throws {
|
|
let url = try XCTUnwrap(BrowserSearchEngine.bing.searchURL(query: "hello world"))
|
|
XCTAssertEqual(url.host, "www.bing.com")
|
|
XCTAssertEqual(url.path, "/search")
|
|
XCTAssertTrue(url.absoluteString.contains("q=hello%20world"))
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserSearchSettingsTests: XCTestCase {
|
|
func testCurrentSearchSuggestionsEnabledDefaultsToTrueWhenUnset() {
|
|
let suiteName = "BrowserSearchSettingsTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
defaults.removeObject(forKey: BrowserSearchSettings.searchSuggestionsEnabledKey)
|
|
XCTAssertTrue(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults))
|
|
}
|
|
|
|
func testCurrentSearchSuggestionsEnabledHonorsExplicitValue() {
|
|
let suiteName = "BrowserSearchSettingsTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
defaults.set(false, forKey: BrowserSearchSettings.searchSuggestionsEnabledKey)
|
|
XCTAssertFalse(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults))
|
|
|
|
defaults.set(true, forKey: BrowserSearchSettings.searchSuggestionsEnabledKey)
|
|
XCTAssertTrue(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults))
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserHistoryStoreTests: XCTestCase {
|
|
func testRecordVisitDedupesAndSuggests() async throws {
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("BrowserHistoryStoreTests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
defer {
|
|
try? FileManager.default.removeItem(at: tempDir)
|
|
}
|
|
|
|
let fileURL = tempDir.appendingPathComponent("browser_history.json")
|
|
let store = await MainActor.run { BrowserHistoryStore(fileURL: fileURL) }
|
|
|
|
let u1 = try XCTUnwrap(URL(string: "https://example.com/foo"))
|
|
let u2 = try XCTUnwrap(URL(string: "https://example.com/bar"))
|
|
|
|
await MainActor.run {
|
|
store.recordVisit(url: u1, title: "Example Foo")
|
|
store.recordVisit(url: u2, title: "Example Bar")
|
|
store.recordVisit(url: u1, title: "Example Foo Updated")
|
|
}
|
|
|
|
let suggestions = await MainActor.run { store.suggestions(for: "foo", limit: 10) }
|
|
XCTAssertEqual(suggestions.first?.url, "https://example.com/foo")
|
|
XCTAssertEqual(suggestions.first?.visitCount, 2)
|
|
XCTAssertEqual(suggestions.first?.title, "Example Foo Updated")
|
|
}
|
|
|
|
func testSuggestionsLoadsPersistedHistoryImmediatelyOnFirstQuery() async throws {
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("BrowserHistoryStoreTests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
defer {
|
|
try? FileManager.default.removeItem(at: tempDir)
|
|
}
|
|
|
|
let fileURL = tempDir.appendingPathComponent("browser_history.json")
|
|
let now = Date()
|
|
let seededEntries = [
|
|
BrowserHistoryStore.Entry(
|
|
id: UUID(),
|
|
url: "https://go.dev/",
|
|
title: "The Go Programming Language",
|
|
lastVisited: now,
|
|
visitCount: 3
|
|
),
|
|
BrowserHistoryStore.Entry(
|
|
id: UUID(),
|
|
url: "https://www.google.com/",
|
|
title: "Google",
|
|
lastVisited: now.addingTimeInterval(-120),
|
|
visitCount: 2
|
|
),
|
|
]
|
|
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.withoutEscapingSlashes]
|
|
let data = try encoder.encode(seededEntries)
|
|
try data.write(to: fileURL, options: [.atomic])
|
|
|
|
let store = await MainActor.run { BrowserHistoryStore(fileURL: fileURL) }
|
|
let suggestions = await MainActor.run { store.suggestions(for: "go", limit: 10) }
|
|
|
|
XCTAssertGreaterThanOrEqual(suggestions.count, 2)
|
|
XCTAssertEqual(suggestions.first?.url, "https://go.dev/")
|
|
XCTAssertTrue(suggestions.contains(where: { $0.url == "https://www.google.com/" }))
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class CmuxWebViewDragRoutingTests: XCTestCase {
|
|
func testRejectsInternalPaneDragEvenWhenFilePromiseTypesArePresent() {
|
|
XCTAssertTrue(
|
|
CmuxWebView.shouldRejectInternalPaneDrag([
|
|
DragOverlayRoutingPolicy.bonsplitTabTransferType,
|
|
NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-url"),
|
|
])
|
|
)
|
|
}
|
|
|
|
func testAllowsRegularExternalFileDrops() {
|
|
XCTAssertFalse(CmuxWebView.shouldRejectInternalPaneDrag([.fileURL]))
|
|
}
|
|
}
|
|
|
|
#if compiler(>=6.2)
|
|
@available(macOS 26.0, *)
|
|
private struct DragConfigurationOperationsSnapshot: Equatable {
|
|
let allowCopy: Bool
|
|
let allowMove: Bool
|
|
let allowDelete: Bool
|
|
let allowAlias: Bool
|
|
}
|
|
|
|
@available(macOS 26.0, *)
|
|
private enum DragConfigurationSnapshotError: Error {
|
|
case missingBoolField(primary: String, fallback: String?)
|
|
}
|
|
|
|
@available(macOS 26.0, *)
|
|
private func dragConfigurationOperationsSnapshot<T>(from operations: T) throws -> DragConfigurationOperationsSnapshot {
|
|
let mirror = Mirror(reflecting: operations)
|
|
|
|
func readBool(_ primary: String, fallback: String? = nil) throws -> Bool {
|
|
if let value = mirror.descendant(primary) as? Bool {
|
|
return value
|
|
}
|
|
if let fallback, let value = mirror.descendant(fallback) as? Bool {
|
|
return value
|
|
}
|
|
throw DragConfigurationSnapshotError.missingBoolField(primary: primary, fallback: fallback)
|
|
}
|
|
|
|
return try DragConfigurationOperationsSnapshot(
|
|
allowCopy: readBool("allowCopy", fallback: "_allowCopy"),
|
|
allowMove: readBool("allowMove", fallback: "_allowMove"),
|
|
allowDelete: readBool("allowDelete", fallback: "_allowDelete"),
|
|
allowAlias: readBool("allowAlias", fallback: "_allowAlias")
|
|
)
|
|
}
|
|
|
|
|
|
final class BrowserLinkOpenSettingsTests: XCTestCase {
|
|
private var suiteName: String!
|
|
private var defaults: UserDefaults!
|
|
|
|
override func setUp() {
|
|
super.setUp()
|
|
suiteName = "BrowserLinkOpenSettingsTests.\(UUID().uuidString)"
|
|
defaults = UserDefaults(suiteName: suiteName)
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
override func tearDown() {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
defaults = nil
|
|
suiteName = nil
|
|
super.tearDown()
|
|
}
|
|
|
|
func testTerminalLinksDefaultToCmuxBrowser() {
|
|
XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults))
|
|
}
|
|
|
|
func testTerminalLinksPreferenceUsesStoredValue() {
|
|
defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
|
|
XCTAssertFalse(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults))
|
|
|
|
defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
|
|
XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults))
|
|
}
|
|
|
|
func testSidebarPullRequestLinksDefaultToCmuxBrowser() {
|
|
XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults))
|
|
}
|
|
|
|
func testSidebarPullRequestLinksPreferenceUsesStoredValue() {
|
|
defaults.set(false, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
|
|
XCTAssertFalse(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults))
|
|
|
|
defaults.set(true, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
|
|
XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults))
|
|
}
|
|
|
|
func testOpenCommandInterceptionDefaultsToCmuxBrowser() {
|
|
XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
|
|
}
|
|
|
|
func testOpenCommandInterceptionUsesStoredValue() {
|
|
defaults.set(false, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey)
|
|
XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
|
|
|
|
defaults.set(true, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey)
|
|
XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
|
|
}
|
|
|
|
func testOpenCommandInterceptionFallsBackToLegacyLinkToggleWhenUnset() {
|
|
defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
|
|
XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
|
|
|
|
defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
|
|
XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
|
|
}
|
|
|
|
func testSettingsInitialOpenCommandInterceptionValueFallsBackToLegacyLinkToggleWhenUnset() {
|
|
defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
|
|
XCTAssertFalse(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults))
|
|
|
|
defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
|
|
XCTAssertTrue(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults))
|
|
}
|
|
|
|
func testExternalOpenPatternsDefaultToEmpty() {
|
|
XCTAssertTrue(BrowserLinkOpenSettings.externalOpenPatterns(defaults: defaults).isEmpty)
|
|
}
|
|
|
|
func testExternalOpenLiteralPatternMatchesCaseInsensitively() {
|
|
defaults.set("openai.com/account/usage", forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey)
|
|
XCTAssertTrue(
|
|
BrowserLinkOpenSettings.shouldOpenExternally(
|
|
"https://platform.OPENAI.com/account/usage",
|
|
defaults: defaults
|
|
)
|
|
)
|
|
}
|
|
|
|
func testExternalOpenRegexPatternMatchesCaseInsensitively() {
|
|
defaults.set(
|
|
"re:^https?://[^/]*\\.example\\.com/(billing|usage)",
|
|
forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey
|
|
)
|
|
XCTAssertTrue(
|
|
BrowserLinkOpenSettings.shouldOpenExternally(
|
|
"https://FOO.example.com/BILLING",
|
|
defaults: defaults
|
|
)
|
|
)
|
|
}
|
|
|
|
func testExternalOpenRegexPatternSupportsDigitCharacterClass() {
|
|
defaults.set(
|
|
"re:^https://example\\.com/usage/\\d+$",
|
|
forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey
|
|
)
|
|
XCTAssertTrue(
|
|
BrowserLinkOpenSettings.shouldOpenExternally(
|
|
"https://example.com/usage/42",
|
|
defaults: defaults
|
|
)
|
|
)
|
|
}
|
|
|
|
func testExternalOpenPatternsIgnoreInvalidRegexEntries() {
|
|
defaults.set("re:(\nexample.com", forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey)
|
|
XCTAssertTrue(
|
|
BrowserLinkOpenSettings.shouldOpenExternally(
|
|
"https://example.com/path",
|
|
defaults: defaults
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserNavigableURLResolutionTests: XCTestCase {
|
|
func testResolvesFileSchemeAsNavigableURL() throws {
|
|
let resolved = try XCTUnwrap(resolveBrowserNavigableURL("file:///tmp/cmux-local-test.html"))
|
|
XCTAssertTrue(resolved.isFileURL)
|
|
XCTAssertEqual(resolved.path, "/tmp/cmux-local-test.html")
|
|
}
|
|
|
|
func testRejectsNonWebNonFileScheme() {
|
|
XCTAssertNil(resolveBrowserNavigableURL("mailto:test@example.com"))
|
|
XCTAssertNil(resolveBrowserNavigableURL("ftp://example.com/file.html"))
|
|
}
|
|
|
|
func testRejectsHostOnlyFileURL() {
|
|
XCTAssertNil(resolveBrowserNavigableURL("file://example.html"))
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserReadAccessURLTests: XCTestCase {
|
|
func testUsesParentDirectoryForFileURL() throws {
|
|
let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
|
let dir = tempRoot.appendingPathComponent("BrowserReadAccessURLTests-\(UUID().uuidString)", isDirectory: true)
|
|
let file = dir.appendingPathComponent("sample.html")
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
try "<html></html>".write(to: file, atomically: true, encoding: .utf8)
|
|
|
|
let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: file))
|
|
XCTAssertEqual(readAccessURL.standardizedFileURL, dir.standardizedFileURL)
|
|
}
|
|
|
|
func testUsesDirectoryURLWhenTargetIsDirectory() throws {
|
|
let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
|
let dir = tempRoot.appendingPathComponent("BrowserReadAccessURLTests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
|
|
let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: dir))
|
|
XCTAssertEqual(readAccessURL.standardizedFileURL, dir.standardizedFileURL)
|
|
}
|
|
|
|
func testUsesParentDirectoryWhenFileDoesNotExist() throws {
|
|
let missing = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).html")
|
|
let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: missing))
|
|
XCTAssertEqual(readAccessURL.standardizedFileURL, missing.deletingLastPathComponent().standardizedFileURL)
|
|
}
|
|
|
|
func testReturnsNilForHostOnlyFileURL() throws {
|
|
let hostOnly = try XCTUnwrap(URL(string: "file://example.html"))
|
|
XCTAssertNil(browserReadAccessURL(forLocalFileURL: hostOnly))
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserExternalNavigationSchemeTests: XCTestCase {
|
|
func testCustomAppSchemesOpenExternally() throws {
|
|
let discord = try XCTUnwrap(URL(string: "discord://login/one-time?token=abc"))
|
|
let slack = try XCTUnwrap(URL(string: "slack://open"))
|
|
let zoom = try XCTUnwrap(URL(string: "zoommtg://zoom.us/join"))
|
|
let mailto = try XCTUnwrap(URL(string: "mailto:test@example.com"))
|
|
|
|
XCTAssertTrue(browserShouldOpenURLExternally(discord))
|
|
XCTAssertTrue(browserShouldOpenURLExternally(slack))
|
|
XCTAssertTrue(browserShouldOpenURLExternally(zoom))
|
|
XCTAssertTrue(browserShouldOpenURLExternally(mailto))
|
|
}
|
|
|
|
func testEmbeddedBrowserSchemesStayInWebView() throws {
|
|
let https = try XCTUnwrap(URL(string: "https://example.com"))
|
|
let http = try XCTUnwrap(URL(string: "http://example.com"))
|
|
let about = try XCTUnwrap(URL(string: "about:blank"))
|
|
let data = try XCTUnwrap(URL(string: "data:text/plain,hello"))
|
|
let file = try XCTUnwrap(URL(string: "file:///tmp/cmux-local-test.html"))
|
|
let blob = try XCTUnwrap(URL(string: "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000"))
|
|
let javascript = try XCTUnwrap(URL(string: "javascript:void(0)"))
|
|
let webkitInternal = try XCTUnwrap(URL(string: "applewebdata://local/page"))
|
|
|
|
XCTAssertFalse(browserShouldOpenURLExternally(https))
|
|
XCTAssertFalse(browserShouldOpenURLExternally(http))
|
|
XCTAssertFalse(browserShouldOpenURLExternally(about))
|
|
XCTAssertFalse(browserShouldOpenURLExternally(data))
|
|
XCTAssertFalse(browserShouldOpenURLExternally(file))
|
|
XCTAssertFalse(browserShouldOpenURLExternally(blob))
|
|
XCTAssertFalse(browserShouldOpenURLExternally(javascript))
|
|
XCTAssertFalse(browserShouldOpenURLExternally(webkitInternal))
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserHostWhitelistTests: XCTestCase {
|
|
private var suiteName: String!
|
|
private var defaults: UserDefaults!
|
|
|
|
override func setUp() {
|
|
super.setUp()
|
|
suiteName = "BrowserHostWhitelistTests.\(UUID().uuidString)"
|
|
defaults = UserDefaults(suiteName: suiteName)
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
override func tearDown() {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
defaults = nil
|
|
suiteName = nil
|
|
super.tearDown()
|
|
}
|
|
|
|
func testEmptyWhitelistAllowsAll() {
|
|
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
|
|
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
|
|
}
|
|
|
|
func testExactMatch() {
|
|
defaults.set("localhost\n127.0.0.1", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
|
|
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
|
|
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults))
|
|
XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
|
|
}
|
|
|
|
func testExactMatchIsCaseInsensitive() {
|
|
defaults.set("LocalHost", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
|
|
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
|
|
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("LOCALHOST", defaults: defaults))
|
|
}
|
|
|
|
func testWildcardSuffix() {
|
|
defaults.set("*.localtest.me", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
|
|
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.localtest.me", defaults: defaults))
|
|
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.app.localtest.me", defaults: defaults))
|
|
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localtest.me", defaults: defaults))
|
|
XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
|
|
}
|
|
|
|
func testWildcardIsCaseInsensitive() {
|
|
defaults.set("*.Example.COM", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
|
|
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.example.com", defaults: defaults))
|
|
}
|
|
|
|
func testBlankLinesAndWhitespaceIgnored() {
|
|
defaults.set(" localhost \n\n 127.0.0.1 \n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
|
|
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
|
|
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults))
|
|
XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
|
|
}
|
|
|
|
func testMixedExactAndWildcard() {
|
|
defaults.set("localhost\n127.0.0.1\n*.local.dev", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
|
|
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
|
|
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults))
|
|
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.local.dev", defaults: defaults))
|
|
XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("github.com", defaults: defaults))
|
|
}
|
|
|
|
func testDefaultWhitelistIsEmpty() {
|
|
let patterns = BrowserLinkOpenSettings.hostWhitelist(defaults: defaults)
|
|
XCTAssertTrue(patterns.isEmpty)
|
|
}
|
|
|
|
func testWildcardRequiresDotBoundary() {
|
|
defaults.set("*.example.com", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
|
|
XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("badexample.com", defaults: defaults))
|
|
XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com.evil", defaults: defaults))
|
|
}
|
|
|
|
func testWhitelistNormalizesSchemesPortsAndTrailingDots() {
|
|
defaults.set("https://LOCALHOST:3000/path\n*.Example.COM:443", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
|
|
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost.", defaults: defaults))
|
|
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("api.example.com", defaults: defaults))
|
|
}
|
|
|
|
func testInvalidWhitelistEntriesDoNotImplicitlyAllowAll() {
|
|
defaults.set("http://\n*.\n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
|
|
XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
|
|
}
|
|
|
|
func testUnicodeWhitelistEntryMatchesPunycodeHost() {
|
|
defaults.set("b\u{00FC}cher.example", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
|
|
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("xn--bcher-kva.example", defaults: defaults))
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserOmnibarFocusPolicyTests: XCTestCase {
|
|
func testReacquiresFocusWhenOmnibarStillWantsFocusAndNextResponderIsNotAnotherTextField() {
|
|
XCTAssertTrue(
|
|
browserOmnibarShouldReacquireFocusAfterEndEditing(
|
|
desiredOmnibarFocus: true,
|
|
nextResponderIsOtherTextField: false
|
|
)
|
|
)
|
|
}
|
|
|
|
func testDoesNotReacquireFocusWhenAnotherTextFieldAlreadyTookFocus() {
|
|
XCTAssertFalse(
|
|
browserOmnibarShouldReacquireFocusAfterEndEditing(
|
|
desiredOmnibarFocus: true,
|
|
nextResponderIsOtherTextField: true
|
|
)
|
|
)
|
|
}
|
|
|
|
func testDoesNotReacquireFocusWhenOmnibarNoLongerWantsFocus() {
|
|
XCTAssertFalse(
|
|
browserOmnibarShouldReacquireFocusAfterEndEditing(
|
|
desiredOmnibarFocus: false,
|
|
nextResponderIsOtherTextField: false
|
|
)
|
|
)
|
|
}
|
|
}
|