1314 lines
49 KiB
Swift
1314 lines
49 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
|
|
|
|
@MainActor
|
|
final class AppDelegateWindowContextRoutingTests: XCTestCase {
|
|
private func makeMainWindow(id: UUID) -> NSWindow {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(id.uuidString)")
|
|
return window
|
|
}
|
|
|
|
func testSynchronizeActiveMainWindowContextPrefersProvidedWindowOverStaleActiveManager() {
|
|
_ = NSApplication.shared
|
|
let app = AppDelegate()
|
|
|
|
let windowAId = UUID()
|
|
let windowBId = UUID()
|
|
let windowA = makeMainWindow(id: windowAId)
|
|
let windowB = makeMainWindow(id: windowBId)
|
|
defer {
|
|
windowA.orderOut(nil)
|
|
windowB.orderOut(nil)
|
|
}
|
|
|
|
let managerA = TabManager()
|
|
let managerB = TabManager()
|
|
app.registerMainWindow(
|
|
windowA,
|
|
windowId: windowAId,
|
|
tabManager: managerA,
|
|
sidebarState: SidebarState(),
|
|
sidebarSelectionState: SidebarSelectionState()
|
|
)
|
|
app.registerMainWindow(
|
|
windowB,
|
|
windowId: windowBId,
|
|
tabManager: managerB,
|
|
sidebarState: SidebarState(),
|
|
sidebarSelectionState: SidebarSelectionState()
|
|
)
|
|
|
|
windowB.makeKeyAndOrderFront(nil)
|
|
_ = app.synchronizeActiveMainWindowContext(preferredWindow: windowB)
|
|
XCTAssertTrue(app.tabManager === managerB)
|
|
|
|
windowA.makeKeyAndOrderFront(nil)
|
|
let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: windowA)
|
|
XCTAssertTrue(resolved === managerA, "Expected provided active window to win over stale active manager")
|
|
XCTAssertTrue(app.tabManager === managerA)
|
|
}
|
|
|
|
func testSynchronizeActiveMainWindowContextFallsBackToActiveManagerWithoutFocusedWindow() {
|
|
_ = NSApplication.shared
|
|
let app = AppDelegate()
|
|
|
|
let windowAId = UUID()
|
|
let windowBId = UUID()
|
|
let windowA = makeMainWindow(id: windowAId)
|
|
let windowB = makeMainWindow(id: windowBId)
|
|
defer {
|
|
windowA.orderOut(nil)
|
|
windowB.orderOut(nil)
|
|
}
|
|
|
|
let managerA = TabManager()
|
|
let managerB = TabManager()
|
|
app.registerMainWindow(
|
|
windowA,
|
|
windowId: windowAId,
|
|
tabManager: managerA,
|
|
sidebarState: SidebarState(),
|
|
sidebarSelectionState: SidebarSelectionState()
|
|
)
|
|
app.registerMainWindow(
|
|
windowB,
|
|
windowId: windowBId,
|
|
tabManager: managerB,
|
|
sidebarState: SidebarState(),
|
|
sidebarSelectionState: SidebarSelectionState()
|
|
)
|
|
|
|
// Seed active manager and clear focus windows to force fallback routing.
|
|
windowA.makeKeyAndOrderFront(nil)
|
|
_ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA)
|
|
XCTAssertTrue(app.tabManager === managerA)
|
|
windowA.orderOut(nil)
|
|
windowB.orderOut(nil)
|
|
|
|
let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: nil)
|
|
XCTAssertTrue(resolved === managerA, "Expected fallback to preserve current active manager instead of arbitrary window")
|
|
XCTAssertTrue(app.tabManager === managerA)
|
|
}
|
|
|
|
func testSynchronizeActiveMainWindowContextUsesRegisteredWindowEvenIfIdentifierMutates() {
|
|
_ = NSApplication.shared
|
|
let app = AppDelegate()
|
|
|
|
let windowId = UUID()
|
|
let window = makeMainWindow(id: windowId)
|
|
defer { window.orderOut(nil) }
|
|
|
|
let manager = TabManager()
|
|
app.registerMainWindow(
|
|
window,
|
|
windowId: windowId,
|
|
tabManager: manager,
|
|
sidebarState: SidebarState(),
|
|
sidebarSelectionState: SidebarSelectionState()
|
|
)
|
|
|
|
// SwiftUI can replace the NSWindow identifier string at runtime.
|
|
window.identifier = NSUserInterfaceItemIdentifier("SwiftUI.AppWindow.IdentifierChanged")
|
|
|
|
let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: window)
|
|
XCTAssertTrue(resolved === manager, "Expected registered window object identity to win even if identifier string changed")
|
|
XCTAssertTrue(app.tabManager === manager)
|
|
}
|
|
|
|
func testAddWorkspaceWithoutBringToFrontPreservesActiveWindowAndSelection() {
|
|
_ = NSApplication.shared
|
|
let app = AppDelegate()
|
|
|
|
let windowAId = UUID()
|
|
let windowBId = UUID()
|
|
let windowA = makeMainWindow(id: windowAId)
|
|
let windowB = makeMainWindow(id: windowBId)
|
|
defer {
|
|
windowA.orderOut(nil)
|
|
windowB.orderOut(nil)
|
|
}
|
|
|
|
let managerA = TabManager()
|
|
let managerB = TabManager()
|
|
app.registerMainWindow(
|
|
windowA,
|
|
windowId: windowAId,
|
|
tabManager: managerA,
|
|
sidebarState: SidebarState(),
|
|
sidebarSelectionState: SidebarSelectionState()
|
|
)
|
|
app.registerMainWindow(
|
|
windowB,
|
|
windowId: windowBId,
|
|
tabManager: managerB,
|
|
sidebarState: SidebarState(),
|
|
sidebarSelectionState: SidebarSelectionState()
|
|
)
|
|
|
|
windowA.makeKeyAndOrderFront(nil)
|
|
_ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA)
|
|
XCTAssertTrue(app.tabManager === managerA)
|
|
|
|
let originalSelectedA = managerA.selectedTabId
|
|
let originalSelectedB = managerB.selectedTabId
|
|
let originalTabCountB = managerB.tabs.count
|
|
|
|
let createdWorkspaceId = app.addWorkspace(windowId: windowBId, bringToFront: false)
|
|
|
|
XCTAssertNotNil(createdWorkspaceId)
|
|
XCTAssertTrue(app.tabManager === managerA, "Expected non-focus workspace creation to preserve active window routing")
|
|
XCTAssertEqual(managerA.selectedTabId, originalSelectedA)
|
|
XCTAssertEqual(managerB.selectedTabId, originalSelectedB, "Expected background workspace creation to preserve selected tab")
|
|
XCTAssertEqual(managerB.tabs.count, originalTabCountB + 1)
|
|
XCTAssertTrue(managerB.tabs.contains(where: { $0.id == createdWorkspaceId }))
|
|
}
|
|
|
|
func testApplicationOpenURLsAddsWorkspaceForDroppedFolderURL() throws {
|
|
_ = NSApplication.shared
|
|
let app = AppDelegate()
|
|
|
|
let windowId = UUID()
|
|
let window = makeMainWindow(id: windowId)
|
|
defer { window.orderOut(nil) }
|
|
|
|
let manager = TabManager()
|
|
app.registerMainWindow(
|
|
window,
|
|
windowId: windowId,
|
|
tabManager: manager,
|
|
sidebarState: SidebarState(),
|
|
sidebarSelectionState: SidebarSelectionState()
|
|
)
|
|
|
|
window.makeKeyAndOrderFront(nil)
|
|
_ = app.synchronizeActiveMainWindowContext(preferredWindow: window)
|
|
|
|
let defaults = UserDefaults.standard
|
|
let previousWelcomeShown = defaults.object(forKey: WelcomeSettings.shownKey)
|
|
defaults.set(true, forKey: WelcomeSettings.shownKey)
|
|
defer {
|
|
if let previousWelcomeShown {
|
|
defaults.set(previousWelcomeShown, forKey: WelcomeSettings.shownKey)
|
|
} else {
|
|
defaults.removeObject(forKey: WelcomeSettings.shownKey)
|
|
}
|
|
}
|
|
|
|
let rootDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
let droppedDirectory = rootDirectory.appendingPathComponent("project", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: droppedDirectory, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: rootDirectory) }
|
|
|
|
let existingWorkspaceIds = Set(manager.tabs.map(\.id))
|
|
|
|
app.application(
|
|
NSApplication.shared,
|
|
open: [URL(fileURLWithPath: droppedDirectory.path)]
|
|
)
|
|
|
|
let createdWorkspace = manager.tabs.first { !existingWorkspaceIds.contains($0.id) }
|
|
XCTAssertNotNil(createdWorkspace)
|
|
XCTAssertEqual(createdWorkspace?.currentDirectory, droppedDirectory.path)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class AppDelegateLaunchServicesRegistrationTests: XCTestCase {
|
|
func testScheduleLaunchServicesRegistrationDefersRegisterWork() {
|
|
_ = NSApplication.shared
|
|
let app = AppDelegate()
|
|
|
|
var scheduledWork: (@Sendable () -> Void)?
|
|
var registerCallCount = 0
|
|
|
|
app.scheduleLaunchServicesBundleRegistrationForTesting(
|
|
bundleURL: URL(fileURLWithPath: "/tmp/../tmp/cmux-launch-services-test.app"),
|
|
scheduler: { work in
|
|
scheduledWork = work
|
|
},
|
|
register: { _ in
|
|
registerCallCount += 1
|
|
return noErr
|
|
}
|
|
)
|
|
|
|
XCTAssertEqual(registerCallCount, 0, "Registration should not run inline on the startup call path")
|
|
XCTAssertNotNil(scheduledWork, "Registration work should be handed to the scheduler")
|
|
|
|
scheduledWork?()
|
|
|
|
XCTAssertEqual(registerCallCount, 1)
|
|
}
|
|
}
|
|
|
|
|
|
final class FocusFlashPatternTests: XCTestCase {
|
|
func testFocusFlashPatternMatchesTerminalDoublePulseShape() {
|
|
XCTAssertEqual(FocusFlashPattern.values, [0, 1, 0, 1, 0])
|
|
XCTAssertEqual(FocusFlashPattern.keyTimes, [0, 0.25, 0.5, 0.75, 1])
|
|
XCTAssertEqual(FocusFlashPattern.duration, 0.9, accuracy: 0.0001)
|
|
XCTAssertEqual(FocusFlashPattern.curves, [.easeOut, .easeIn, .easeOut, .easeIn])
|
|
XCTAssertEqual(FocusFlashPattern.ringInset, 6, accuracy: 0.0001)
|
|
XCTAssertEqual(FocusFlashPattern.ringCornerRadius, 10, accuracy: 0.0001)
|
|
}
|
|
|
|
func testFocusFlashPatternSegmentsCoverFullDoublePulseTimeline() {
|
|
let segments = FocusFlashPattern.segments
|
|
XCTAssertEqual(segments.count, 4)
|
|
|
|
XCTAssertEqual(segments[0].delay, 0.0, accuracy: 0.0001)
|
|
XCTAssertEqual(segments[0].duration, 0.225, accuracy: 0.0001)
|
|
XCTAssertEqual(segments[0].targetOpacity, 1, accuracy: 0.0001)
|
|
XCTAssertEqual(segments[0].curve, .easeOut)
|
|
|
|
XCTAssertEqual(segments[1].delay, 0.225, accuracy: 0.0001)
|
|
XCTAssertEqual(segments[1].duration, 0.225, accuracy: 0.0001)
|
|
XCTAssertEqual(segments[1].targetOpacity, 0, accuracy: 0.0001)
|
|
XCTAssertEqual(segments[1].curve, .easeIn)
|
|
|
|
XCTAssertEqual(segments[2].delay, 0.45, accuracy: 0.0001)
|
|
XCTAssertEqual(segments[2].duration, 0.225, accuracy: 0.0001)
|
|
XCTAssertEqual(segments[2].targetOpacity, 1, accuracy: 0.0001)
|
|
XCTAssertEqual(segments[2].curve, .easeOut)
|
|
|
|
XCTAssertEqual(segments[3].delay, 0.675, accuracy: 0.0001)
|
|
XCTAssertEqual(segments[3].duration, 0.225, accuracy: 0.0001)
|
|
XCTAssertEqual(segments[3].targetOpacity, 0, accuracy: 0.0001)
|
|
XCTAssertEqual(segments[3].curve, .easeIn)
|
|
}
|
|
}
|
|
|
|
|
|
@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")
|
|
)
|
|
}
|
|
|
|
#if compiler(>=6.2)
|
|
@MainActor
|
|
final class InternalTabDragConfigurationTests: XCTestCase {
|
|
func testDisablesExternalOperationsForInternalTabDrags() throws {
|
|
guard #available(macOS 26.0, *) else {
|
|
throw XCTSkip("Requires macOS 26 drag configuration APIs")
|
|
}
|
|
|
|
let configuration = InternalTabDragConfigurationProvider.value
|
|
let withinApp = try dragConfigurationOperationsSnapshot(from: configuration.operationsWithinApp)
|
|
let outsideApp = try dragConfigurationOperationsSnapshot(from: configuration.operationsOutsideApp)
|
|
|
|
XCTAssertEqual(
|
|
withinApp,
|
|
DragConfigurationOperationsSnapshot(
|
|
allowCopy: false,
|
|
allowMove: true,
|
|
allowDelete: false,
|
|
allowAlias: false
|
|
)
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
outsideApp,
|
|
DragConfigurationOperationsSnapshot(
|
|
allowCopy: false,
|
|
allowMove: false,
|
|
allowDelete: false,
|
|
allowAlias: false
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class InternalTabDragBundleDeclarationTests: XCTestCase {
|
|
private func exportedTypeIdentifiers(bundle: Bundle) -> Set<String> {
|
|
let declarations = (bundle.object(forInfoDictionaryKey: "UTExportedTypeDeclarations") as? [[String: Any]]) ?? []
|
|
return Set(declarations.compactMap { $0["UTTypeIdentifier"] as? String })
|
|
}
|
|
|
|
func testAppBundleExportsInternalDragTypes() {
|
|
let exported = exportedTypeIdentifiers(bundle: Bundle(for: AppDelegate.self))
|
|
|
|
XCTAssertTrue(
|
|
exported.contains("com.splittabbar.tabtransfer"),
|
|
"Expected app bundle to export bonsplit tab-transfer type, got \(exported)"
|
|
)
|
|
XCTAssertTrue(
|
|
exported.contains("com.cmux.sidebar-tab-reorder"),
|
|
"Expected app bundle to export sidebar tab-reorder type, got \(exported)"
|
|
)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
|
|
@MainActor
|
|
final class WindowDragHandleHitTests: XCTestCase {
|
|
private final class CapturingView: NSView {
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
bounds.contains(point) ? self : nil
|
|
}
|
|
}
|
|
|
|
private final class HostContainerView: NSView {}
|
|
private final class BlockingTopHitContainerView: NSView {
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
bounds.contains(point) ? self : nil
|
|
}
|
|
}
|
|
private final class PassThroughProbeView: NSView {
|
|
var onHitTest: (() -> Void)?
|
|
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
guard bounds.contains(point) else { return nil }
|
|
onHitTest?()
|
|
return nil
|
|
}
|
|
}
|
|
private final class PassiveHostContainerView: NSView {
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
guard bounds.contains(point) else { return nil }
|
|
return super.hitTest(point) ?? self
|
|
}
|
|
}
|
|
|
|
private final class MutatingSiblingView: NSView {
|
|
weak var container: NSView?
|
|
private var didMutate = false
|
|
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
guard bounds.contains(point) else { return nil }
|
|
guard !didMutate, let container else { return nil }
|
|
didMutate = true
|
|
let transient = NSView(frame: .zero)
|
|
container.addSubview(transient)
|
|
transient.removeFromSuperview()
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private final class ReentrantDragHandleView: NSView {
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
let shouldCapture = windowDragHandleShouldCaptureHit(point, in: self, eventType: .leftMouseDown, eventWindow: self.window)
|
|
return shouldCapture ? self : nil
|
|
}
|
|
}
|
|
|
|
/// A sibling view whose hitTest re-enters windowDragHandleShouldCaptureHit,
|
|
/// simulating the crash path where sibling.hitTest triggers a SwiftUI layout
|
|
/// pass that calls back into the drag handle's hit resolution.
|
|
private final class ReentrantSiblingView: NSView {
|
|
weak var dragHandle: NSView?
|
|
var reenteredResult: Bool?
|
|
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
guard bounds.contains(point), let dragHandle else { return nil }
|
|
// Simulate the re-entry: during sibling hit test, SwiftUI layout
|
|
// calls windowDragHandleShouldCaptureHit on the drag handle again.
|
|
reenteredResult = windowDragHandleShouldCaptureHit(
|
|
point, in: dragHandle, eventType: .leftMouseDown, eventWindow: dragHandle.window
|
|
)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func testDragHandleCapturesHitWhenNoSiblingClaimsPoint() {
|
|
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
|
|
let dragHandle = NSView(frame: container.bounds)
|
|
container.addSubview(dragHandle)
|
|
|
|
XCTAssertTrue(
|
|
windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown),
|
|
"Empty titlebar space should drag the window"
|
|
)
|
|
}
|
|
|
|
func testDragHandleYieldsWhenSiblingClaimsPoint() {
|
|
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
|
|
let dragHandle = NSView(frame: container.bounds)
|
|
container.addSubview(dragHandle)
|
|
|
|
let folderIconHost = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16))
|
|
container.addSubview(folderIconHost)
|
|
|
|
XCTAssertFalse(
|
|
windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown),
|
|
"Interactive titlebar controls should receive the mouse event"
|
|
)
|
|
XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown))
|
|
}
|
|
|
|
func testDragHandleIgnoresHiddenSiblingWhenResolvingHit() {
|
|
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
|
|
let dragHandle = NSView(frame: container.bounds)
|
|
container.addSubview(dragHandle)
|
|
|
|
let hidden = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16))
|
|
hidden.isHidden = true
|
|
container.addSubview(hidden)
|
|
|
|
XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown))
|
|
}
|
|
|
|
func testDragHandleDoesNotCaptureOutsideBounds() {
|
|
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
|
|
let dragHandle = NSView(frame: container.bounds)
|
|
container.addSubview(dragHandle)
|
|
|
|
XCTAssertFalse(windowDragHandleShouldCaptureHit(NSPoint(x: 240, y: 18), in: dragHandle, eventType: .leftMouseDown))
|
|
}
|
|
|
|
func testDragHandleSkipsCaptureForPassivePointerEvents() {
|
|
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
|
|
let dragHandle = NSView(frame: container.bounds)
|
|
container.addSubview(dragHandle)
|
|
|
|
let point = NSPoint(x: 180, y: 18)
|
|
XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .mouseMoved))
|
|
XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .cursorUpdate))
|
|
XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: nil))
|
|
XCTAssertTrue(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .leftMouseDown))
|
|
}
|
|
|
|
func testDragHandleSkipsForeignLeftMouseDownDuringLaunch() {
|
|
let point = NSPoint(x: 180, y: 18)
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let container = NSView(frame: contentView.bounds)
|
|
container.autoresizingMask = [.width, .height]
|
|
contentView.addSubview(container)
|
|
|
|
let dragHandle = NSView(frame: container.bounds)
|
|
dragHandle.autoresizingMask = [.width, .height]
|
|
container.addSubview(dragHandle)
|
|
|
|
let foreignWindow = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
|
|
styleMask: [.titled],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { foreignWindow.orderOut(nil) }
|
|
|
|
XCTAssertFalse(
|
|
windowDragHandleShouldCaptureHit(
|
|
point,
|
|
in: dragHandle,
|
|
eventType: .leftMouseDown,
|
|
eventWindow: nil
|
|
),
|
|
"Launch activation events without a matching window should not trigger drag-handle hierarchy walk"
|
|
)
|
|
|
|
XCTAssertFalse(
|
|
windowDragHandleShouldCaptureHit(
|
|
point,
|
|
in: dragHandle,
|
|
eventType: .leftMouseDown,
|
|
eventWindow: foreignWindow
|
|
),
|
|
"Left mouse-down events for a different window should be treated as passive"
|
|
)
|
|
|
|
XCTAssertTrue(
|
|
windowDragHandleShouldCaptureHit(
|
|
point,
|
|
in: dragHandle,
|
|
eventType: .leftMouseDown,
|
|
eventWindow: window
|
|
),
|
|
"Left mouse-down events for this window should still capture empty titlebar space"
|
|
)
|
|
}
|
|
|
|
func testPassiveHostingTopHitClassification() {
|
|
XCTAssertTrue(windowDragHandleShouldTreatTopHitAsPassiveHost(HostContainerView(frame: .zero)))
|
|
XCTAssertFalse(windowDragHandleShouldTreatTopHitAsPassiveHost(NSButton(frame: .zero)))
|
|
}
|
|
|
|
func testDragHandleIgnoresPassiveHostSiblingHit() {
|
|
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
|
|
let dragHandle = NSView(frame: container.bounds)
|
|
container.addSubview(dragHandle)
|
|
|
|
let passiveHost = PassiveHostContainerView(frame: container.bounds)
|
|
container.addSubview(passiveHost)
|
|
|
|
XCTAssertTrue(
|
|
windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown),
|
|
"Passive host wrappers should not block titlebar drag capture"
|
|
)
|
|
}
|
|
|
|
func testDragHandleRespectsInteractiveChildInsidePassiveHost() {
|
|
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
|
|
let dragHandle = NSView(frame: container.bounds)
|
|
container.addSubview(dragHandle)
|
|
|
|
let passiveHost = PassiveHostContainerView(frame: container.bounds)
|
|
let folderControl = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16))
|
|
passiveHost.addSubview(folderControl)
|
|
container.addSubview(passiveHost)
|
|
|
|
XCTAssertFalse(
|
|
windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown),
|
|
"Interactive controls inside passive host wrappers should still receive hits"
|
|
)
|
|
}
|
|
|
|
func testTopHitResolutionStateIsScopedPerWindow() {
|
|
let point = NSPoint(x: 100, y: 18)
|
|
|
|
let outerWindow = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { outerWindow.orderOut(nil) }
|
|
guard let outerContentView = outerWindow.contentView else {
|
|
XCTFail("Expected outer content view")
|
|
return
|
|
}
|
|
let outerContainer = NSView(frame: outerContentView.bounds)
|
|
outerContainer.autoresizingMask = [.width, .height]
|
|
outerContentView.addSubview(outerContainer)
|
|
let outerDragHandle = NSView(frame: outerContainer.bounds)
|
|
outerDragHandle.autoresizingMask = [.width, .height]
|
|
outerContainer.addSubview(outerDragHandle)
|
|
|
|
let nestedWindow = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { nestedWindow.orderOut(nil) }
|
|
guard let nestedContentView = nestedWindow.contentView else {
|
|
XCTFail("Expected nested content view")
|
|
return
|
|
}
|
|
let nestedContainer = BlockingTopHitContainerView(frame: nestedContentView.bounds)
|
|
nestedContainer.autoresizingMask = [.width, .height]
|
|
nestedContentView.addSubview(nestedContainer)
|
|
let nestedDragHandle = NSView(frame: nestedContainer.bounds)
|
|
nestedDragHandle.autoresizingMask = [.width, .height]
|
|
nestedContainer.addSubview(nestedDragHandle)
|
|
|
|
XCTAssertFalse(
|
|
windowDragHandleShouldCaptureHit(point, in: nestedDragHandle, eventType: .leftMouseDown, eventWindow: nestedWindow),
|
|
"Nested window drag handle should be blocked by top-hit titlebar container"
|
|
)
|
|
|
|
var nestedCaptureResult: Bool?
|
|
let probe = PassThroughProbeView(frame: outerContainer.bounds)
|
|
probe.autoresizingMask = [.width, .height]
|
|
probe.onHitTest = {
|
|
nestedCaptureResult = windowDragHandleShouldCaptureHit(point, in: nestedDragHandle, eventType: .leftMouseDown, eventWindow: nestedWindow)
|
|
}
|
|
outerContainer.addSubview(probe)
|
|
|
|
_ = windowDragHandleShouldCaptureHit(point, in: outerDragHandle, eventType: .leftMouseDown, eventWindow: outerWindow)
|
|
|
|
XCTAssertEqual(
|
|
nestedCaptureResult,
|
|
false,
|
|
"Top-hit recursion in one window must not disable top-hit resolution in another window"
|
|
)
|
|
}
|
|
|
|
func testDragHandleRemainsStableWhenSiblingMutatesSubviewsDuringHitTest() {
|
|
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
|
|
let dragHandle = NSView(frame: container.bounds)
|
|
container.addSubview(dragHandle)
|
|
|
|
let mutatingSibling = MutatingSiblingView(frame: container.bounds)
|
|
mutatingSibling.container = container
|
|
container.addSubview(mutatingSibling)
|
|
|
|
XCTAssertTrue(
|
|
windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown),
|
|
"Subview mutations during hit testing should not crash or break drag-handle capture"
|
|
)
|
|
}
|
|
|
|
func testDragHandleSiblingHitTestReentrancyDoesNotCrash() {
|
|
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
|
|
let dragHandle = NSView(frame: container.bounds)
|
|
container.addSubview(dragHandle)
|
|
|
|
let reentrantSibling = ReentrantSiblingView(frame: container.bounds)
|
|
reentrantSibling.dragHandle = dragHandle
|
|
container.addSubview(reentrantSibling)
|
|
|
|
// The outer call enters the sibling walk, which calls
|
|
// reentrantSibling.hitTest(), which re-enters
|
|
// windowDragHandleShouldCaptureHit. Without the re-entrancy guard
|
|
// this would trigger a Swift exclusive-access violation (SIGABRT).
|
|
let outerResult = windowDragHandleShouldCaptureHit(
|
|
NSPoint(x: 110, y: 18), in: dragHandle, eventType: .leftMouseDown
|
|
)
|
|
XCTAssertTrue(outerResult, "Outer call should still capture when sibling returns nil")
|
|
XCTAssertEqual(
|
|
reentrantSibling.reenteredResult, false,
|
|
"Re-entrant call should bail out (return false) instead of crashing"
|
|
)
|
|
}
|
|
|
|
func testDragHandleTopHitResolutionSurvivesSameWindowReentrancy() {
|
|
let point = NSPoint(x: 180, y: 18)
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let container = NSView(frame: contentView.bounds)
|
|
container.autoresizingMask = [.width, .height]
|
|
contentView.addSubview(container)
|
|
|
|
let dragHandle = ReentrantDragHandleView(frame: container.bounds)
|
|
dragHandle.autoresizingMask = [.width, .height]
|
|
container.addSubview(dragHandle)
|
|
|
|
XCTAssertTrue(
|
|
windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .leftMouseDown, eventWindow: window),
|
|
"Reentrant same-window top-hit resolution should not trigger exclusivity crashes"
|
|
)
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
|
|
|
|
@MainActor
|
|
final class DraggableFolderHitTests: XCTestCase {
|
|
func testFolderHitTestReturnsContainerWhenInsideBounds() {
|
|
let folderView = DraggableFolderNSView(directory: "/tmp")
|
|
folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16)
|
|
|
|
guard let hit = folderView.hitTest(NSPoint(x: 8, y: 8)) else {
|
|
XCTFail("Expected folder icon to capture inside hit")
|
|
return
|
|
}
|
|
XCTAssertTrue(hit === folderView)
|
|
}
|
|
|
|
func testFolderHitTestReturnsNilOutsideBounds() {
|
|
let folderView = DraggableFolderNSView(directory: "/tmp")
|
|
folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16)
|
|
|
|
XCTAssertNil(folderView.hitTest(NSPoint(x: 20, y: 8)))
|
|
}
|
|
|
|
func testFolderIconDisablesWindowMoveBehavior() {
|
|
let folderView = DraggableFolderNSView(directory: "/tmp")
|
|
XCTAssertFalse(folderView.mouseDownCanMoveWindow)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class TitlebarLeadingInsetPassthroughViewTests: XCTestCase {
|
|
func testLeadingInsetViewDoesNotParticipateInHitTesting() {
|
|
let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40))
|
|
XCTAssertNil(view.hitTest(NSPoint(x: 20, y: 10)))
|
|
}
|
|
|
|
func testLeadingInsetViewCannotMoveWindowViaMouseDown() {
|
|
let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40))
|
|
XCTAssertFalse(view.mouseDownCanMoveWindow)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class FolderWindowMoveSuppressionTests: XCTestCase {
|
|
private func makeWindow() -> NSWindow {
|
|
NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 320, height: 180),
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
}
|
|
|
|
func testSuppressionDisablesMovableWindow() {
|
|
let window = makeWindow()
|
|
window.isMovable = true
|
|
|
|
let previous = temporarilyDisableWindowDragging(window: window)
|
|
|
|
XCTAssertEqual(previous, true)
|
|
XCTAssertFalse(window.isMovable)
|
|
}
|
|
|
|
func testSuppressionPreservesAlreadyImmovableWindow() {
|
|
let window = makeWindow()
|
|
window.isMovable = false
|
|
|
|
let previous = temporarilyDisableWindowDragging(window: window)
|
|
|
|
XCTAssertEqual(previous, false)
|
|
XCTAssertFalse(window.isMovable)
|
|
}
|
|
|
|
func testRestoreAppliesPreviousMovableState() {
|
|
let window = makeWindow()
|
|
window.isMovable = false
|
|
|
|
restoreWindowDragging(window: window, previousMovableState: true)
|
|
XCTAssertTrue(window.isMovable)
|
|
|
|
restoreWindowDragging(window: window, previousMovableState: false)
|
|
XCTAssertFalse(window.isMovable)
|
|
}
|
|
|
|
func testWindowDragSuppressionDepthLifecycle() {
|
|
let window = makeWindow()
|
|
XCTAssertEqual(windowDragSuppressionDepth(window: window), 0)
|
|
XCTAssertFalse(isWindowDragSuppressed(window: window))
|
|
|
|
XCTAssertEqual(beginWindowDragSuppression(window: window), 1)
|
|
XCTAssertEqual(windowDragSuppressionDepth(window: window), 1)
|
|
XCTAssertTrue(isWindowDragSuppressed(window: window))
|
|
|
|
XCTAssertEqual(endWindowDragSuppression(window: window), 0)
|
|
XCTAssertEqual(windowDragSuppressionDepth(window: window), 0)
|
|
XCTAssertFalse(isWindowDragSuppressed(window: window))
|
|
}
|
|
|
|
func testWindowDragSuppressionIsReferenceCounted() {
|
|
let window = makeWindow()
|
|
XCTAssertEqual(beginWindowDragSuppression(window: window), 1)
|
|
XCTAssertEqual(beginWindowDragSuppression(window: window), 2)
|
|
XCTAssertEqual(windowDragSuppressionDepth(window: window), 2)
|
|
XCTAssertTrue(isWindowDragSuppressed(window: window))
|
|
|
|
XCTAssertEqual(endWindowDragSuppression(window: window), 1)
|
|
XCTAssertEqual(windowDragSuppressionDepth(window: window), 1)
|
|
XCTAssertTrue(isWindowDragSuppressed(window: window))
|
|
|
|
XCTAssertEqual(endWindowDragSuppression(window: window), 0)
|
|
XCTAssertEqual(windowDragSuppressionDepth(window: window), 0)
|
|
XCTAssertFalse(isWindowDragSuppressed(window: window))
|
|
}
|
|
|
|
func testTemporaryWindowMovableEnableRestoresImmovableWindow() {
|
|
let window = makeWindow()
|
|
window.isMovable = false
|
|
|
|
let previous = withTemporaryWindowMovableEnabled(window: window) {
|
|
XCTAssertTrue(window.isMovable)
|
|
}
|
|
|
|
XCTAssertEqual(previous, false)
|
|
XCTAssertFalse(window.isMovable)
|
|
}
|
|
|
|
func testTemporaryWindowMovableEnablePreservesMovableWindow() {
|
|
let window = makeWindow()
|
|
window.isMovable = true
|
|
|
|
let previous = withTemporaryWindowMovableEnabled(window: window) {
|
|
XCTAssertTrue(window.isMovable)
|
|
}
|
|
|
|
XCTAssertEqual(previous, true)
|
|
XCTAssertTrue(window.isMovable)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class WindowMoveSuppressionHitPathTests: XCTestCase {
|
|
private func makeWindowWithContentView() -> (NSWindow, NSView) {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 320, height: 180),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame))
|
|
window.contentView = contentView
|
|
return (window, contentView)
|
|
}
|
|
|
|
private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent {
|
|
guard let event = NSEvent.mouseEvent(
|
|
with: type,
|
|
location: location,
|
|
modifierFlags: [],
|
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: window.windowNumber,
|
|
context: nil,
|
|
eventNumber: 0,
|
|
clickCount: 1,
|
|
pressure: 1.0
|
|
) else {
|
|
fatalError("Failed to create \(type) mouse event")
|
|
}
|
|
return event
|
|
}
|
|
|
|
func testSuppressionHitPathRecognizesFolderView() {
|
|
let folderView = DraggableFolderNSView(directory: "/tmp")
|
|
XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: folderView))
|
|
}
|
|
|
|
func testSuppressionHitPathRecognizesDescendantOfFolderView() {
|
|
let folderView = DraggableFolderNSView(directory: "/tmp")
|
|
let child = NSView(frame: .zero)
|
|
folderView.addSubview(child)
|
|
XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: child))
|
|
}
|
|
|
|
func testSuppressionHitPathIgnoresUnrelatedViews() {
|
|
XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: NSView(frame: .zero)))
|
|
XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: nil))
|
|
}
|
|
|
|
func testSuppressionEventPathRecognizesFolderHitInsideWindow() {
|
|
let (window, contentView) = makeWindowWithContentView()
|
|
window.isMovable = true
|
|
let folderView = DraggableFolderNSView(directory: "/tmp")
|
|
folderView.frame = NSRect(x: 10, y: 10, width: 16, height: 16)
|
|
contentView.addSubview(folderView)
|
|
|
|
let event = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 14, y: 14), window: window)
|
|
|
|
XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(window: window, event: event))
|
|
}
|
|
|
|
func testSuppressionEventPathRejectsNonFolderAndNonMouseDownEvents() {
|
|
let (window, contentView) = makeWindowWithContentView()
|
|
window.isMovable = true
|
|
let plainView = NSView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
|
|
contentView.addSubview(plainView)
|
|
|
|
let down = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 20, y: 20), window: window)
|
|
XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: down))
|
|
|
|
let dragged = makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 20, y: 20), window: window)
|
|
XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: dragged))
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class FileDropOverlayViewTests: XCTestCase {
|
|
private final class DragSpyWebView: WKWebView {
|
|
var dragCalls: [String] = []
|
|
|
|
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
|
dragCalls.append("entered")
|
|
return .copy
|
|
}
|
|
|
|
override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool {
|
|
dragCalls.append("prepare")
|
|
return true
|
|
}
|
|
|
|
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
|
|
dragCalls.append("perform")
|
|
return true
|
|
}
|
|
|
|
override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) {
|
|
dragCalls.append("conclude")
|
|
}
|
|
}
|
|
|
|
private final class MockDraggingInfo: NSObject, NSDraggingInfo {
|
|
let draggingDestinationWindow: NSWindow?
|
|
let draggingSourceOperationMask: NSDragOperation
|
|
let draggingLocation: NSPoint
|
|
let draggedImageLocation: NSPoint
|
|
let draggedImage: NSImage?
|
|
let draggingPasteboard: NSPasteboard
|
|
let draggingSource: Any?
|
|
let draggingSequenceNumber: Int
|
|
var draggingFormation: NSDraggingFormation = .default
|
|
var animatesToDestination = false
|
|
var numberOfValidItemsForDrop = 1
|
|
let springLoadingHighlight: NSSpringLoadingHighlight = .none
|
|
|
|
init(
|
|
window: NSWindow,
|
|
location: NSPoint,
|
|
pasteboard: NSPasteboard,
|
|
sourceOperationMask: NSDragOperation = .copy,
|
|
draggingSource: Any? = nil,
|
|
sequenceNumber: Int = 1
|
|
) {
|
|
self.draggingDestinationWindow = window
|
|
self.draggingSourceOperationMask = sourceOperationMask
|
|
self.draggingLocation = location
|
|
self.draggedImageLocation = location
|
|
self.draggedImage = nil
|
|
self.draggingPasteboard = pasteboard
|
|
self.draggingSource = draggingSource
|
|
self.draggingSequenceNumber = sequenceNumber
|
|
}
|
|
|
|
func slideDraggedImage(to screenPoint: NSPoint) {}
|
|
|
|
override func namesOfPromisedFilesDropped(atDestination dropDestination: URL) -> [String]? {
|
|
nil
|
|
}
|
|
|
|
func enumerateDraggingItems(
|
|
options enumOpts: NSDraggingItemEnumerationOptions = [],
|
|
for view: NSView?,
|
|
classes classArray: [AnyClass],
|
|
searchOptions: [NSPasteboard.ReadingOptionKey: Any] = [:],
|
|
using block: (NSDraggingItem, Int, UnsafeMutablePointer<ObjCBool>) -> Void
|
|
) {}
|
|
|
|
func resetSpringLoading() {}
|
|
}
|
|
|
|
private func realizeWindowLayout(_ window: NSWindow) {
|
|
window.makeKeyAndOrderFront(nil)
|
|
window.displayIfNeeded()
|
|
window.contentView?.layoutSubtreeIfNeeded()
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
|
window.contentView?.layoutSubtreeIfNeeded()
|
|
}
|
|
|
|
func testOverlayResolvesPortalHostedBrowserWebViewForFileDrops() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 280),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer {
|
|
NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window)
|
|
window.orderOut(nil)
|
|
}
|
|
realizeWindowLayout(window)
|
|
|
|
guard let contentView = window.contentView,
|
|
let container = contentView.superview else {
|
|
XCTFail("Expected content container")
|
|
return
|
|
}
|
|
|
|
let anchor = NSView(frame: NSRect(x: 40, y: 36, width: 220, height: 150))
|
|
contentView.addSubview(anchor)
|
|
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true)
|
|
BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
|
|
|
|
let overlay = FileDropOverlayView(frame: container.bounds)
|
|
overlay.autoresizingMask = [.width, .height]
|
|
container.addSubview(overlay, positioned: .above, relativeTo: nil)
|
|
|
|
let point = anchor.convert(
|
|
NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY),
|
|
to: nil
|
|
)
|
|
XCTAssertTrue(
|
|
overlay.webViewUnderPoint(point) === webView,
|
|
"File-drop overlay should resolve portal-hosted browser panes so Finder uploads still reach WKWebView"
|
|
)
|
|
}
|
|
|
|
func testOverlayForwardsFullDragLifecycleToPortalHostedBrowserWebView() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 280),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer {
|
|
NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window)
|
|
window.orderOut(nil)
|
|
}
|
|
realizeWindowLayout(window)
|
|
|
|
guard let contentView = window.contentView,
|
|
let container = contentView.superview else {
|
|
XCTFail("Expected content container")
|
|
return
|
|
}
|
|
|
|
let anchor = NSView(frame: NSRect(x: 52, y: 44, width: 210, height: 140))
|
|
contentView.addSubview(anchor)
|
|
|
|
let webView = DragSpyWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true)
|
|
BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
|
|
defer { BrowserWindowPortalRegistry.detach(webView: webView) }
|
|
|
|
let overlay = FileDropOverlayView(frame: container.bounds)
|
|
overlay.autoresizingMask = [.width, .height]
|
|
container.addSubview(overlay, positioned: .above, relativeTo: nil)
|
|
|
|
let pasteboard = NSPasteboard(name: NSPasteboard.Name("cmux.test.drag.\(UUID().uuidString)"))
|
|
pasteboard.clearContents()
|
|
XCTAssertTrue(
|
|
pasteboard.writeObjects([URL(fileURLWithPath: "/tmp/upload.mov") as NSURL]),
|
|
"Expected file URL drag payload"
|
|
)
|
|
|
|
let dropPoint = anchor.convert(
|
|
NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY),
|
|
to: nil
|
|
)
|
|
let dragInfo = MockDraggingInfo(
|
|
window: window,
|
|
location: dropPoint,
|
|
pasteboard: pasteboard
|
|
)
|
|
|
|
XCTAssertEqual(overlay.draggingEntered(dragInfo), .copy)
|
|
XCTAssertTrue(overlay.prepareForDragOperation(dragInfo))
|
|
XCTAssertTrue(overlay.performDragOperation(dragInfo))
|
|
overlay.concludeDragOperation(dragInfo)
|
|
|
|
XCTAssertEqual(
|
|
webView.dragCalls,
|
|
["entered", "prepare", "perform", "conclude"],
|
|
"Finder file drops need the full AppKit drag lifecycle forwarded into the portal-hosted WKWebView"
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class MarkdownPanelPointerObserverViewTests: XCTestCase {
|
|
private func makeWindow() -> NSWindow {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 320, height: 180),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.makeKeyAndOrderFront(nil)
|
|
window.displayIfNeeded()
|
|
window.contentView?.layoutSubtreeIfNeeded()
|
|
return window
|
|
}
|
|
|
|
private func makeMouseEvent(
|
|
type: NSEvent.EventType,
|
|
location: NSPoint,
|
|
window: NSWindow,
|
|
eventNumber: Int = 1
|
|
) -> NSEvent {
|
|
guard let event = NSEvent.mouseEvent(
|
|
with: type,
|
|
location: location,
|
|
modifierFlags: [],
|
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: window.windowNumber,
|
|
context: nil,
|
|
eventNumber: eventNumber,
|
|
clickCount: 1,
|
|
pressure: 1.0
|
|
) else {
|
|
fatalError("Expected to create mouse event")
|
|
}
|
|
return event
|
|
}
|
|
|
|
func testObserverTriggersFocusForVisibleLeftClickInsideBounds() {
|
|
let window = makeWindow()
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds)
|
|
overlay.autoresizingMask = [.width, .height]
|
|
let focusExpectation = expectation(description: "observer forwards focus callback")
|
|
var pointerDownCount = 0
|
|
overlay.onPointerDown = {
|
|
pointerDownCount += 1
|
|
focusExpectation.fulfill()
|
|
}
|
|
contentView.addSubview(overlay)
|
|
|
|
_ = overlay.handleEventIfNeeded(
|
|
makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: window)
|
|
)
|
|
wait(for: [focusExpectation], timeout: 1.0)
|
|
|
|
XCTAssertEqual(pointerDownCount, 1)
|
|
}
|
|
|
|
func testObserverIgnoresOutsideOrForeignWindowClicks() {
|
|
let window = makeWindow()
|
|
defer { window.orderOut(nil) }
|
|
let otherWindow = makeWindow()
|
|
defer { otherWindow.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds)
|
|
overlay.autoresizingMask = [.width, .height]
|
|
let noFocusExpectation = expectation(description: "observer ignores invalid clicks")
|
|
noFocusExpectation.isInverted = true
|
|
var pointerDownCount = 0
|
|
overlay.onPointerDown = {
|
|
pointerDownCount += 1
|
|
noFocusExpectation.fulfill()
|
|
}
|
|
contentView.addSubview(overlay)
|
|
|
|
_ = overlay.handleEventIfNeeded(
|
|
makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 400, y: 400), window: window)
|
|
)
|
|
_ = overlay.handleEventIfNeeded(
|
|
makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: otherWindow, eventNumber: 2)
|
|
)
|
|
_ = overlay.handleEventIfNeeded(
|
|
makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 60, y: 60), window: window, eventNumber: 3)
|
|
)
|
|
wait(for: [noFocusExpectation], timeout: 0.1)
|
|
|
|
XCTAssertEqual(pointerDownCount, 0)
|
|
}
|
|
|
|
func testObserverDoesNotParticipateInHitTesting() {
|
|
let overlay = MarkdownPanelPointerObserverView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
|
|
XCTAssertNil(overlay.hitTest(NSPoint(x: 40, y: 30)))
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class TmuxWorkspacePaneOverlayTests: XCTestCase {
|
|
func testTmuxWorkspacePaneOverlayModelTracksFlashReason() {
|
|
let model = TmuxWorkspacePaneOverlayModel()
|
|
let initialState = TmuxWorkspacePaneOverlayRenderState(
|
|
workspaceId: UUID(),
|
|
unreadRects: [],
|
|
flashRect: CGRect(x: 10, y: 20, width: 300, height: 200),
|
|
flashToken: 1,
|
|
flashReason: .notificationArrival
|
|
)
|
|
let laterState = TmuxWorkspacePaneOverlayRenderState(
|
|
workspaceId: initialState.workspaceId,
|
|
unreadRects: [],
|
|
flashRect: CGRect(x: 10, y: 20, width: 300, height: 200),
|
|
flashToken: 2,
|
|
flashReason: .navigation
|
|
)
|
|
|
|
model.apply(initialState)
|
|
model.apply(laterState)
|
|
|
|
XCTAssertEqual(model.flashReason, .navigation)
|
|
}
|
|
|
|
func testNavigationFlashUsesNonNotificationPresentation() {
|
|
XCTAssertNotEqual(
|
|
WorkspaceAttentionCoordinator.flashStyle(for: .navigation),
|
|
WorkspaceAttentionCoordinator.flashStyle(for: .notificationArrival)
|
|
)
|
|
}
|
|
|
|
func testNavigationFlashUsesNonNeutralAccent() {
|
|
XCTAssertEqual(
|
|
WorkspaceAttentionCoordinator.flashStyle(for: .navigation).accent,
|
|
.navigationTeal
|
|
)
|
|
}
|
|
|
|
func testTmuxWorkspacePaneExactRectReturnsContentRelativeFrameForDescendantView() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 640, height: 400),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected contentView")
|
|
return
|
|
}
|
|
|
|
let targetView = NSView(frame: NSRect(x: 120, y: 48, width: 300, height: 200))
|
|
contentView.addSubview(targetView)
|
|
|
|
XCTAssertEqual(
|
|
ContentView.tmuxWorkspacePaneExactRect(for: targetView, in: contentView),
|
|
CGRect(x: 120, y: 48, width: 300, height: 200)
|
|
)
|
|
}
|
|
}
|
|
#endif
|