2935 lines
121 KiB
Swift
2935 lines
121 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
|
|
|
|
private func drainBrowserPanelMainQueue() {
|
|
let expectation = XCTestExpectation(description: "drain main queue")
|
|
DispatchQueue.main.async {
|
|
expectation.fulfill()
|
|
}
|
|
XCTWaiter().wait(for: [expectation], timeout: 1.0)
|
|
}
|
|
|
|
@MainActor
|
|
private func makeTemporaryBrowserPanelProfile(named prefix: String) throws -> BrowserProfileDefinition {
|
|
try XCTUnwrap(
|
|
BrowserProfileStore.shared.createProfile(
|
|
named: "\(prefix)-\(UUID().uuidString)"
|
|
)
|
|
)
|
|
}
|
|
|
|
final class BrowserPanelChromeBackgroundColorTests: XCTestCase {
|
|
func testLightModeUsesThemeBackgroundColor() {
|
|
assertResolvedColorMatchesTheme(for: .light)
|
|
}
|
|
|
|
func testDarkModeUsesThemeBackgroundColor() {
|
|
assertResolvedColorMatchesTheme(for: .dark)
|
|
}
|
|
|
|
private func assertResolvedColorMatchesTheme(
|
|
for colorScheme: ColorScheme,
|
|
file: StaticString = #filePath,
|
|
line: UInt = #line
|
|
) {
|
|
let themeBackground = NSColor(srgbRed: 0.13, green: 0.29, blue: 0.47, alpha: 1.0)
|
|
|
|
guard
|
|
let actual = resolvedBrowserChromeBackgroundColor(
|
|
for: colorScheme,
|
|
themeBackgroundColor: themeBackground
|
|
).usingColorSpace(.sRGB),
|
|
let expected = themeBackground.usingColorSpace(.sRGB)
|
|
else {
|
|
XCTFail("Expected sRGB-convertible colors", file: file, line: line)
|
|
return
|
|
}
|
|
|
|
XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.001, file: file, line: line)
|
|
XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.001, file: file, line: line)
|
|
XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.001, file: file, line: line)
|
|
XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.001, file: file, line: line)
|
|
}
|
|
}
|
|
|
|
|
|
final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase {
|
|
func testLightModeSlightlyDarkensThemeBackground() {
|
|
assertResolvedColorMatchesExpectedBlend(for: .light, darkenMix: 0.04)
|
|
}
|
|
|
|
func testDarkModeSlightlyDarkensThemeBackground() {
|
|
assertResolvedColorMatchesExpectedBlend(for: .dark, darkenMix: 0.05)
|
|
}
|
|
|
|
private func assertResolvedColorMatchesExpectedBlend(
|
|
for colorScheme: ColorScheme,
|
|
darkenMix: CGFloat,
|
|
file: StaticString = #filePath,
|
|
line: UInt = #line
|
|
) {
|
|
let themeBackground = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0)
|
|
let expected = themeBackground.blended(withFraction: darkenMix, of: .black) ?? themeBackground
|
|
|
|
guard
|
|
let actual = resolvedBrowserOmnibarPillBackgroundColor(
|
|
for: colorScheme,
|
|
themeBackgroundColor: themeBackground
|
|
).usingColorSpace(.sRGB),
|
|
let expectedSRGB = expected.usingColorSpace(.sRGB),
|
|
let themeSRGB = themeBackground.usingColorSpace(.sRGB)
|
|
else {
|
|
XCTFail("Expected sRGB-convertible colors", file: file, line: line)
|
|
return
|
|
}
|
|
|
|
XCTAssertEqual(actual.redComponent, expectedSRGB.redComponent, accuracy: 0.001, file: file, line: line)
|
|
XCTAssertEqual(actual.greenComponent, expectedSRGB.greenComponent, accuracy: 0.001, file: file, line: line)
|
|
XCTAssertEqual(actual.blueComponent, expectedSRGB.blueComponent, accuracy: 0.001, file: file, line: line)
|
|
XCTAssertEqual(actual.alphaComponent, expectedSRGB.alphaComponent, accuracy: 0.001, file: file, line: line)
|
|
XCTAssertNotEqual(actual.redComponent, themeSRGB.redComponent, file: file, line: line)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class BrowserPanelProfileIsolationTests: XCTestCase {
|
|
func testStaleDidFinishDoesNotRecordVisitIntoSwitchedProfileHistory() throws {
|
|
let alternateProfile = try makeTemporaryBrowserPanelProfile(named: "Switched")
|
|
let defaultStore = BrowserHistoryStore.shared
|
|
let alternateStore = BrowserProfileStore.shared.historyStore(for: alternateProfile.id)
|
|
defaultStore.clearHistory()
|
|
alternateStore.clearHistory()
|
|
defer {
|
|
defaultStore.clearHistory()
|
|
alternateStore.clearHistory()
|
|
}
|
|
|
|
let panel = BrowserPanel(
|
|
workspaceId: UUID(),
|
|
profileID: BrowserProfileStore.shared.builtInDefaultProfileID
|
|
)
|
|
let staleWebView = panel.webView
|
|
let staleDelegate = try XCTUnwrap(staleWebView.navigationDelegate)
|
|
let staleURL = try XCTUnwrap(URL(string: "https://example.com/stale-finish"))
|
|
staleWebView.loadHTMLString(
|
|
"<html><head><title>Stale</title></head><body>stale</body></html>",
|
|
baseURL: staleURL
|
|
)
|
|
|
|
XCTAssertTrue(
|
|
panel.switchToProfile(alternateProfile.id),
|
|
"Expected profile switch to succeed, current=\(panel.profileID) requested=\(alternateProfile.id) exists=\(BrowserProfileStore.shared.profileDefinition(id: alternateProfile.id) != nil)"
|
|
)
|
|
defaultStore.clearHistory()
|
|
alternateStore.clearHistory()
|
|
|
|
staleDelegate.webView?(staleWebView, didFinish: nil)
|
|
drainBrowserPanelMainQueue()
|
|
|
|
XCTAssertTrue(
|
|
defaultStore.entries.isEmpty,
|
|
"Expected stale completion callbacks to avoid writing into the old profile history store, found \(defaultStore.entries.map { $0.url })"
|
|
)
|
|
XCTAssertTrue(
|
|
alternateStore.entries.isEmpty,
|
|
"Expected stale completion callbacks to avoid writing into the newly selected profile history store, found \(alternateStore.entries.map { $0.url })"
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class BrowserPanelAddressBarFocusRequestTests: XCTestCase {
|
|
func testRequestPersistsUntilAcknowledged() {
|
|
let panel = BrowserPanel(workspaceId: UUID())
|
|
XCTAssertNil(panel.pendingAddressBarFocusRequestId)
|
|
|
|
let requestId = panel.requestAddressBarFocus()
|
|
XCTAssertEqual(panel.pendingAddressBarFocusRequestId, requestId)
|
|
XCTAssertTrue(panel.shouldSuppressWebViewFocus())
|
|
|
|
panel.acknowledgeAddressBarFocusRequest(requestId)
|
|
XCTAssertNil(panel.pendingAddressBarFocusRequestId)
|
|
|
|
// Acknowledgement only clears the durable request; focus suppression follows
|
|
// explicit blur state transitions.
|
|
XCTAssertTrue(panel.shouldSuppressWebViewFocus())
|
|
panel.endSuppressWebViewFocusForAddressBar()
|
|
XCTAssertFalse(panel.shouldSuppressWebViewFocus())
|
|
}
|
|
|
|
func testRequestCoalescesWhilePending() {
|
|
let panel = BrowserPanel(workspaceId: UUID())
|
|
let firstRequest = panel.requestAddressBarFocus()
|
|
let secondRequest = panel.requestAddressBarFocus()
|
|
|
|
XCTAssertEqual(firstRequest, secondRequest)
|
|
XCTAssertEqual(panel.pendingAddressBarFocusRequestId, firstRequest)
|
|
}
|
|
|
|
func testStaleAcknowledgementDoesNotClearNewestRequest() {
|
|
let panel = BrowserPanel(workspaceId: UUID())
|
|
let firstRequest = panel.requestAddressBarFocus()
|
|
panel.acknowledgeAddressBarFocusRequest(firstRequest)
|
|
let secondRequest = panel.requestAddressBarFocus()
|
|
|
|
XCTAssertNotEqual(firstRequest, secondRequest)
|
|
XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest)
|
|
|
|
panel.acknowledgeAddressBarFocusRequest(firstRequest)
|
|
XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest)
|
|
|
|
panel.acknowledgeAddressBarFocusRequest(secondRequest)
|
|
XCTAssertNil(panel.pendingAddressBarFocusRequestId)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class WindowBrowserHostViewTests: XCTestCase {
|
|
private final class CapturingView: NSView {
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
bounds.contains(point) ? self : nil
|
|
}
|
|
}
|
|
|
|
private final class PrimaryPageProbeView: NSView {
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
bounds.contains(point) ? self : nil
|
|
}
|
|
}
|
|
|
|
private final class WKInspectorProbeView: NSView {
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
bounds.contains(point) ? self : nil
|
|
}
|
|
}
|
|
|
|
private final class EdgeTransparentWKInspectorProbeView: NSView {
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
let localPoint = convert(point, from: superview)
|
|
guard bounds.contains(localPoint) else { return nil }
|
|
return localPoint.x <= 12 ? nil : self
|
|
}
|
|
}
|
|
|
|
private final class TrailingEdgeTransparentWKInspectorProbeView: NSView {
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
let localPoint = convert(point, from: superview)
|
|
guard bounds.contains(localPoint) else { return nil }
|
|
return localPoint.x >= bounds.maxX - 12 ? nil : self
|
|
}
|
|
}
|
|
|
|
private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {}
|
|
|
|
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
|
|
}
|
|
|
|
private func isInspectorOwnedHit(_ hit: NSView?, inspectorView: NSView, pageView: NSView) -> Bool {
|
|
guard let hit else { return false }
|
|
if hit === pageView || hit.isDescendant(of: pageView) {
|
|
return false
|
|
}
|
|
if hit === inspectorView || hit.isDescendant(of: inspectorView) {
|
|
return true
|
|
}
|
|
return inspectorView.isDescendant(of: hit) && !(pageView === hit || pageView.isDescendant(of: hit))
|
|
}
|
|
|
|
func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 300, height: 180),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let splitView = NSSplitView(frame: contentView.bounds)
|
|
splitView.autoresizingMask = [.width, .height]
|
|
splitView.isVertical = true
|
|
splitView.dividerStyle = .thin
|
|
let splitDelegate = BonsplitMockSplitDelegate()
|
|
splitView.delegate = splitDelegate
|
|
let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height))
|
|
let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height))
|
|
splitView.addSubview(first)
|
|
splitView.addSubview(second)
|
|
contentView.addSubview(splitView)
|
|
splitView.setPosition(1, ofDividerAt: 0)
|
|
splitView.adjustSubviews()
|
|
contentView.layoutSubtreeIfNeeded()
|
|
|
|
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]
|
|
let child = CapturingView(frame: host.bounds)
|
|
child.autoresizingMask = [.width, .height]
|
|
host.addSubview(child)
|
|
container.addSubview(host, positioned: .above, relativeTo: contentView)
|
|
|
|
let dividerPointInSplit = NSPoint(
|
|
x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5),
|
|
y: splitView.bounds.midY
|
|
)
|
|
let dividerPointInWindow = splitView.convert(dividerPointInSplit, to: nil)
|
|
let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
|
|
XCTAssertLessThanOrEqual(splitView.arrangedSubviews[0].frame.width, 1.5)
|
|
XCTAssertNil(
|
|
host.hitTest(dividerPointInHost),
|
|
"Browser host must pass through divider hits even when one pane is nearly collapsed"
|
|
)
|
|
|
|
let contentPointInSplit = NSPoint(x: dividerPointInSplit.x + 40, y: splitView.bounds.midY)
|
|
let contentPointInWindow = splitView.convert(contentPointInSplit, to: nil)
|
|
let contentPointInHost = host.convert(contentPointInWindow, from: nil)
|
|
XCTAssertTrue(host.hitTest(contentPointInHost) === child)
|
|
}
|
|
|
|
func testWindowBrowserPortalIgnoresHostedInspectorSplitResizeNotifications() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
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 appSplit = NSSplitView(frame: contentView.bounds)
|
|
appSplit.autoresizingMask = [.width, .height]
|
|
appSplit.isVertical = true
|
|
appSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height)))
|
|
appSplit.addSubview(NSView(frame: NSRect(x: 121, y: 0, width: 299, height: contentView.bounds.height)))
|
|
contentView.addSubview(appSplit)
|
|
|
|
let inspectorSplit = NSSplitView(frame: host.bounds)
|
|
inspectorSplit.autoresizingMask = [.width, .height]
|
|
inspectorSplit.isVertical = true
|
|
inspectorSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height)))
|
|
inspectorSplit.addSubview(NSView(frame: NSRect(x: 121, y: 0, width: 299, height: host.bounds.height)))
|
|
host.addSubview(inspectorSplit)
|
|
|
|
XCTAssertTrue(
|
|
WindowBrowserPortal.shouldTreatSplitResizeAsExternalGeometry(
|
|
appSplit,
|
|
window: window,
|
|
hostView: host
|
|
),
|
|
"App layout splits should still trigger browser portal geometry sync"
|
|
)
|
|
XCTAssertFalse(
|
|
WindowBrowserPortal.shouldTreatSplitResizeAsExternalGeometry(
|
|
inspectorSplit,
|
|
window: window,
|
|
hostView: host
|
|
),
|
|
"Hosted DevTools/internal splits should not trigger browser portal geometry sync"
|
|
)
|
|
}
|
|
|
|
func testDragHoverEventsPassThroughForTabTransferOnBrowserHoverEvents() {
|
|
XCTAssertTrue(
|
|
WindowBrowserHostView.shouldPassThroughToDragTargets(
|
|
pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType],
|
|
eventType: .cursorUpdate
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
WindowBrowserHostView.shouldPassThroughToDragTargets(
|
|
pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType],
|
|
eventType: .mouseEntered
|
|
)
|
|
)
|
|
}
|
|
|
|
func testDragHoverEventsPassThroughForSidebarReorderWithoutMouseButtonState() {
|
|
XCTAssertTrue(
|
|
WindowBrowserHostView.shouldPassThroughToDragTargets(
|
|
pasteboardTypes: [DragOverlayRoutingPolicy.sidebarTabReorderType],
|
|
eventType: .cursorUpdate
|
|
)
|
|
)
|
|
}
|
|
|
|
func testDragHoverEventsDoNotPassThroughForUnrelatedPasteboardTypes() {
|
|
XCTAssertFalse(
|
|
WindowBrowserHostView.shouldPassThroughToDragTargets(
|
|
pasteboardTypes: [.fileURL],
|
|
eventType: .cursorUpdate
|
|
)
|
|
)
|
|
}
|
|
|
|
func testHostViewKeepsHostedInspectorDividerInteractive() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
guard let container = contentView.superview else {
|
|
XCTFail("Expected content container")
|
|
return
|
|
}
|
|
|
|
// Underlying app layout split that should still be pass-through.
|
|
let appSplit = NSSplitView(frame: contentView.bounds)
|
|
appSplit.autoresizingMask = [.width, .height]
|
|
appSplit.isVertical = true
|
|
appSplit.dividerStyle = .thin
|
|
let appSplitDelegate = BonsplitMockSplitDelegate()
|
|
appSplit.delegate = appSplitDelegate
|
|
let leading = NSView(frame: NSRect(x: 0, y: 0, width: 210, height: contentView.bounds.height))
|
|
let trailing = NSView(frame: NSRect(x: 211, y: 0, width: 209, height: contentView.bounds.height))
|
|
appSplit.addSubview(leading)
|
|
appSplit.addSubview(trailing)
|
|
contentView.addSubview(appSplit)
|
|
appSplit.adjustSubviews()
|
|
|
|
let hostFrame = container.convert(contentView.bounds, from: contentView)
|
|
let host = WindowBrowserHostView(frame: hostFrame)
|
|
host.autoresizingMask = [.width, .height]
|
|
container.addSubview(host, positioned: .above, relativeTo: contentView)
|
|
|
|
// WebKit inspector uses an internal split (page + console). Divider drags
|
|
// here must stay in hosted content, not pass through to appSplit behind it.
|
|
let inspectorSplit = NSSplitView(frame: host.bounds)
|
|
inspectorSplit.autoresizingMask = [.width, .height]
|
|
inspectorSplit.isVertical = false
|
|
inspectorSplit.dividerStyle = .thin
|
|
let inspectorDelegate = BonsplitMockSplitDelegate()
|
|
inspectorSplit.delegate = inspectorDelegate
|
|
let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 160))
|
|
let consoleView = CapturingView(frame: NSRect(x: 0, y: 161, width: host.bounds.width, height: 99))
|
|
inspectorSplit.addSubview(pageView)
|
|
inspectorSplit.addSubview(consoleView)
|
|
host.addSubview(inspectorSplit)
|
|
inspectorSplit.setPosition(160, ofDividerAt: 0)
|
|
inspectorSplit.adjustSubviews()
|
|
contentView.layoutSubtreeIfNeeded()
|
|
|
|
let appDividerPointInSplit = NSPoint(
|
|
x: appSplit.arrangedSubviews[0].frame.maxX + (appSplit.dividerThickness * 0.5),
|
|
y: appSplit.bounds.midY
|
|
)
|
|
let appDividerPointInWindow = appSplit.convert(appDividerPointInSplit, to: nil)
|
|
let appDividerPointInHost = host.convert(appDividerPointInWindow, from: nil)
|
|
XCTAssertNil(
|
|
host.hitTest(appDividerPointInHost),
|
|
"Underlying app split divider should still pass through with a hosted inspector split present"
|
|
)
|
|
|
|
let dividerPointInInspector = NSPoint(
|
|
x: inspectorSplit.bounds.midX,
|
|
y: inspectorSplit.arrangedSubviews[0].frame.maxY + (inspectorSplit.dividerThickness * 0.5)
|
|
)
|
|
let dividerPointInWindow = inspectorSplit.convert(dividerPointInInspector, to: nil)
|
|
let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
|
|
let hit = host.hitTest(dividerPointInHost)
|
|
|
|
XCTAssertNotNil(
|
|
hit,
|
|
"Inspector divider should receive hit-testing in hosted content, not pass through"
|
|
)
|
|
XCTAssertFalse(hit === host)
|
|
if let hit {
|
|
XCTAssertTrue(
|
|
hit === inspectorSplit || hit.isDescendant(of: inspectorSplit),
|
|
"Expected hit to remain inside inspector split subtree"
|
|
)
|
|
}
|
|
}
|
|
|
|
func testHostViewKeepsHostedVerticalInspectorDividerInteractiveAtSlotLeadingEdge() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
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: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
|
|
slot.autoresizingMask = [.minXMargin, .height]
|
|
host.addSubview(slot)
|
|
|
|
let inspectorSplit = NSSplitView(frame: slot.bounds)
|
|
inspectorSplit.autoresizingMask = [.width, .height]
|
|
inspectorSplit.isVertical = true
|
|
inspectorSplit.dividerStyle = .thin
|
|
let inspectorDelegate = BonsplitMockSplitDelegate()
|
|
inspectorSplit.delegate = inspectorDelegate
|
|
let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: 1, height: slot.bounds.height))
|
|
let inspectorView = CapturingView(
|
|
frame: NSRect(x: 2, y: 0, width: slot.bounds.width - 2, height: slot.bounds.height)
|
|
)
|
|
inspectorSplit.addSubview(pageView)
|
|
inspectorSplit.addSubview(inspectorView)
|
|
slot.addSubview(inspectorSplit)
|
|
inspectorSplit.setPosition(1, ofDividerAt: 0)
|
|
inspectorSplit.adjustSubviews()
|
|
contentView.layoutSubtreeIfNeeded()
|
|
|
|
let dividerPointInSplit = NSPoint(
|
|
x: inspectorSplit.arrangedSubviews[0].frame.maxX + (inspectorSplit.dividerThickness * 0.5),
|
|
y: inspectorSplit.bounds.midY
|
|
)
|
|
let dividerPointInWindow = inspectorSplit.convert(dividerPointInSplit, to: nil)
|
|
let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
|
|
|
|
XCTAssertLessThanOrEqual(inspectorSplit.arrangedSubviews[0].frame.width, 1.5)
|
|
XCTAssertTrue(
|
|
abs(dividerPointInHost.x - slot.frame.minX) <= SidebarResizeInteraction.hitWidthPerSide,
|
|
"Expected collapsed hosted divider to overlap the browser slot leading-edge resizer zone"
|
|
)
|
|
|
|
let hit = host.hitTest(dividerPointInHost)
|
|
XCTAssertNotNil(
|
|
hit,
|
|
"Hosted vertical inspector divider should stay interactive even when collapsed onto the slot edge"
|
|
)
|
|
XCTAssertFalse(hit === host)
|
|
if let hit {
|
|
XCTAssertTrue(
|
|
hit === inspectorSplit || hit.isDescendant(of: inspectorSplit),
|
|
"Expected hit to remain inside hosted inspector split subtree at the slot edge"
|
|
)
|
|
}
|
|
}
|
|
|
|
func testHostViewPrefersNativeHostedInspectorSiblingDividerHit() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
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: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
|
|
slot.autoresizingMask = [.minXMargin, .height]
|
|
host.addSubview(slot)
|
|
|
|
let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height))
|
|
let inspectorView = WKInspectorProbeView(
|
|
frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height)
|
|
)
|
|
slot.addSubview(pageView)
|
|
slot.addSubview(inspectorView)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
|
|
let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY)
|
|
let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
|
|
let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
|
|
let bodyPointInSlot = NSPoint(x: inspectorView.frame.minX + 18, y: slot.bounds.midY)
|
|
let bodyPointInWindow = slot.convert(bodyPointInSlot, to: nil)
|
|
let bodyPointInHost = host.convert(bodyPointInWindow, from: nil)
|
|
|
|
let dividerHit = host.hitTest(dividerPointInHost)
|
|
XCTAssertTrue(
|
|
isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView),
|
|
"Hosted right-docked inspector divider should stay on the native WebKit hit path when WebKit exposes a hittable inspector-side view. actual=\(String(describing: dividerHit))"
|
|
)
|
|
let interiorHit = host.hitTest(bodyPointInHost)
|
|
XCTAssertTrue(
|
|
isInspectorOwnedHit(interiorHit, inspectorView: inspectorView, pageView: pageView),
|
|
"Only the divider edge should be claimed; interior inspector hits should still reach WebKit content. actual=\(String(describing: interiorHit))"
|
|
)
|
|
}
|
|
|
|
func testHostViewPrefersNativeNestedHostedInspectorSiblingDividerHit() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
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: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
|
|
slot.autoresizingMask = [.minXMargin, .height]
|
|
host.addSubview(slot)
|
|
|
|
let wrapper = NSView(frame: slot.bounds)
|
|
wrapper.autoresizingMask = [.width, .height]
|
|
slot.addSubview(wrapper)
|
|
|
|
let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: wrapper.bounds.height))
|
|
let inspectorContainer = NSView(
|
|
frame: NSRect(x: 92, y: 0, width: wrapper.bounds.width - 92, height: wrapper.bounds.height)
|
|
)
|
|
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
|
|
inspectorView.autoresizingMask = [.width, .height]
|
|
inspectorContainer.addSubview(inspectorView)
|
|
wrapper.addSubview(pageView)
|
|
wrapper.addSubview(inspectorContainer)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
|
|
let dividerPointInSlot = NSPoint(x: inspectorContainer.frame.minX + 2, y: slot.bounds.midY)
|
|
let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
|
|
let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
|
|
let bodyPointInSlot = NSPoint(x: inspectorContainer.frame.minX + 18, y: slot.bounds.midY)
|
|
let bodyPointInWindow = slot.convert(bodyPointInSlot, to: nil)
|
|
let bodyPointInHost = host.convert(bodyPointInWindow, from: nil)
|
|
|
|
let dividerHit = host.hitTest(dividerPointInHost)
|
|
XCTAssertTrue(
|
|
isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView),
|
|
"Portal host should prefer the native nested WebKit hit target on the right-docked divider when available. actual=\(String(describing: dividerHit))"
|
|
)
|
|
let interiorHit = host.hitTest(bodyPointInHost)
|
|
XCTAssertTrue(
|
|
isInspectorOwnedHit(interiorHit, inspectorView: inspectorView, pageView: pageView),
|
|
"Only the divider edge should be claimed; interior nested inspector hits should still reach WebKit content. actual=\(String(describing: interiorHit))"
|
|
)
|
|
}
|
|
|
|
func testHostViewReappliesStoredHostedInspectorWidthAfterSlotLayoutReset() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
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: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
|
|
slot.autoresizingMask = [.minXMargin, .height]
|
|
host.addSubview(slot)
|
|
|
|
let wrapper = NSView(frame: slot.bounds)
|
|
wrapper.autoresizingMask = [.width, .height]
|
|
slot.addSubview(wrapper)
|
|
|
|
let originalPageFrame = NSRect(x: 0, y: 0, width: 92, height: wrapper.bounds.height)
|
|
let originalInspectorFrame = NSRect(
|
|
x: 92,
|
|
y: 0,
|
|
width: wrapper.bounds.width - 92,
|
|
height: wrapper.bounds.height
|
|
)
|
|
let pageView = PrimaryPageProbeView(frame: originalPageFrame)
|
|
let inspectorContainer = NSView(frame: originalInspectorFrame)
|
|
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
|
|
inspectorView.autoresizingMask = [.width, .height]
|
|
inspectorContainer.addSubview(inspectorView)
|
|
wrapper.addSubview(pageView)
|
|
wrapper.addSubview(inspectorContainer)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
|
|
let dividerPointInSlot = NSPoint(x: inspectorContainer.frame.minX, y: slot.bounds.midY)
|
|
let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
|
|
|
|
let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
|
|
host.mouseDown(with: down)
|
|
let drag = makeMouseEvent(
|
|
type: .leftMouseDragged,
|
|
location: NSPoint(x: dividerPointInWindow.x + 48, y: dividerPointInWindow.y),
|
|
window: window
|
|
)
|
|
host.mouseDragged(with: drag)
|
|
host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
|
|
|
|
let draggedPageWidth = pageView.frame.width
|
|
let draggedInspectorMinX = inspectorContainer.frame.minX
|
|
XCTAssertGreaterThan(draggedPageWidth, originalPageFrame.width)
|
|
XCTAssertGreaterThan(draggedInspectorMinX, originalInspectorFrame.minX)
|
|
|
|
pageView.frame = originalPageFrame
|
|
inspectorContainer.frame = originalInspectorFrame
|
|
slot.needsLayout = true
|
|
slot.layoutSubtreeIfNeeded()
|
|
host.layoutSubtreeIfNeeded()
|
|
|
|
XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5)
|
|
XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5)
|
|
}
|
|
|
|
func testHostViewFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
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: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
|
|
slot.autoresizingMask = [.minXMargin, .height]
|
|
host.addSubview(slot)
|
|
|
|
let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height))
|
|
let inspectorView = EdgeTransparentWKInspectorProbeView(
|
|
frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height)
|
|
)
|
|
slot.addSubview(pageView)
|
|
slot.addSubview(inspectorView)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
|
|
let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY)
|
|
let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
|
|
let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
|
|
|
|
let dividerHit = host.hitTest(dividerPointInHost)
|
|
XCTAssertTrue(
|
|
dividerHit === host,
|
|
"Host should only take the manual fallback path when the right-docked divider edge is not natively hittable. actual=\(String(describing: dividerHit))"
|
|
)
|
|
|
|
let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
|
|
host.mouseDown(with: down)
|
|
let drag = makeMouseEvent(
|
|
type: .leftMouseDragged,
|
|
location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y),
|
|
window: window
|
|
)
|
|
host.mouseDragged(with: drag)
|
|
host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
|
|
|
|
XCTAssertGreaterThan(pageView.frame.width, 92)
|
|
XCTAssertGreaterThan(inspectorView.frame.minX, 92)
|
|
}
|
|
|
|
func testHostViewFallsBackToManualHostedInspectorDragForLeftDockedInspector() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
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: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
|
|
slot.autoresizingMask = [.minXMargin, .height]
|
|
host.addSubview(slot)
|
|
|
|
let inspectorView = TrailingEdgeTransparentWKInspectorProbeView(
|
|
frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height)
|
|
)
|
|
let pageView = PrimaryPageProbeView(
|
|
frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height)
|
|
)
|
|
slot.addSubview(inspectorView)
|
|
slot.addSubview(pageView)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
|
|
let dividerPointInSlot = NSPoint(x: inspectorView.frame.maxX - 2, y: slot.bounds.midY)
|
|
let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
|
|
let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
|
|
|
|
XCTAssertTrue(
|
|
host.hitTest(dividerPointInHost) === host,
|
|
"Host should take the manual fallback path for a left-docked divider when the native edge is not hittable"
|
|
)
|
|
|
|
let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
|
|
host.mouseDown(with: down)
|
|
let drag = makeMouseEvent(
|
|
type: .leftMouseDragged,
|
|
location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y),
|
|
window: window
|
|
)
|
|
host.mouseDragged(with: drag)
|
|
host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
|
|
|
|
XCTAssertGreaterThan(inspectorView.frame.width, 92)
|
|
XCTAssertGreaterThan(pageView.frame.minX, 92)
|
|
}
|
|
|
|
func testHostViewClaimsCollapsedHostedInspectorSiblingDividerAtSlotLeadingEdge() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
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: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
|
|
slot.autoresizingMask = [.minXMargin, .height]
|
|
host.addSubview(slot)
|
|
|
|
let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 0, height: slot.bounds.height))
|
|
let inspectorView = WKInspectorProbeView(frame: slot.bounds)
|
|
slot.addSubview(pageView)
|
|
slot.addSubview(inspectorView)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
|
|
let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY)
|
|
let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
|
|
let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
|
|
|
|
XCTAssertLessThanOrEqual(dividerPointInHost.x - slot.frame.minX, SidebarResizeInteraction.hitWidthPerSide)
|
|
let dividerHit = host.hitTest(dividerPointInHost)
|
|
XCTAssertTrue(
|
|
isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView),
|
|
"Collapsed right-docked hosted inspector divider should stay on the native WebKit hit path while still beating the sidebar-resizer overlap zone. actual=\(String(describing: dividerHit))"
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class BrowserPanelHostContainerViewTests: XCTestCase {
|
|
private final class PrimaryPageProbeView: NSView {
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
bounds.contains(point) ? self : nil
|
|
}
|
|
}
|
|
|
|
private final class TrackingInspectorFrontendWebView: WKWebView {
|
|
private(set) var evaluatedJavaScript: [String] = []
|
|
|
|
@MainActor override func evaluateJavaScript(
|
|
_ javaScriptString: String,
|
|
completionHandler: (@MainActor @Sendable (Any?, (any Error)?) -> Void)? = nil
|
|
) {
|
|
evaluatedJavaScript.append(javaScriptString)
|
|
completionHandler?(nil, nil)
|
|
}
|
|
}
|
|
|
|
private final class WKInspectorProbeView: NSView {
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
bounds.contains(point) ? self : nil
|
|
}
|
|
}
|
|
|
|
private final class EdgeTransparentWKInspectorProbeView: NSView {
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
let localPoint = convert(point, from: superview)
|
|
guard bounds.contains(localPoint) else { return nil }
|
|
return localPoint.x <= 12 ? nil : self
|
|
}
|
|
}
|
|
|
|
private final class TrailingEdgeTransparentWKInspectorProbeView: NSView {
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
let localPoint = convert(point, from: superview)
|
|
guard bounds.contains(localPoint) else { return nil }
|
|
return localPoint.x >= bounds.maxX - 12 ? nil : self
|
|
}
|
|
}
|
|
|
|
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 testBrowserPanelHostPrefersNativeHostedInspectorSiblingDividerHit() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
|
|
host.autoresizingMask = [.minXMargin, .height]
|
|
contentView.addSubview(host)
|
|
|
|
let webViewRoot = NSView(frame: host.bounds)
|
|
webViewRoot.autoresizingMask = [.width, .height]
|
|
host.addSubview(webViewRoot)
|
|
|
|
let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height))
|
|
let inspectorContainer = NSView(
|
|
frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height)
|
|
)
|
|
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
|
|
inspectorView.autoresizingMask = [.width, .height]
|
|
inspectorContainer.addSubview(inspectorView)
|
|
webViewRoot.addSubview(pageView)
|
|
webViewRoot.addSubview(inspectorContainer)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
|
|
let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
|
|
let bodyPointInHost = NSPoint(x: inspectorContainer.frame.minX + 18, y: host.bounds.midY)
|
|
let interiorHit = host.hitTest(bodyPointInHost)
|
|
|
|
XCTAssertTrue(
|
|
host.hitTest(dividerPointInHost) === host,
|
|
"Browser panel host should claim the right-docked divider edge for the manual resize path"
|
|
)
|
|
XCTAssertTrue(
|
|
interiorHit == nil || interiorHit !== host,
|
|
"Only the divider edge should be claimed; interior inspector hits should not be stolen by the host. actual=\(String(describing: interiorHit))"
|
|
)
|
|
}
|
|
|
|
func testBrowserPanelHostClaimsCollapsedHostedInspectorSiblingDividerAtLeadingEdge() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
|
|
host.autoresizingMask = [.minXMargin, .height]
|
|
contentView.addSubview(host)
|
|
|
|
let webViewRoot = NSView(frame: host.bounds)
|
|
webViewRoot.autoresizingMask = [.width, .height]
|
|
host.addSubview(webViewRoot)
|
|
|
|
let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 0, height: webViewRoot.bounds.height))
|
|
let inspectorContainer = NSView(frame: webViewRoot.bounds)
|
|
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
|
|
inspectorView.autoresizingMask = [.width, .height]
|
|
inspectorContainer.addSubview(inspectorView)
|
|
webViewRoot.addSubview(pageView)
|
|
webViewRoot.addSubview(inspectorContainer)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
|
|
let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
|
|
let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
|
|
|
|
XCTAssertTrue(
|
|
host.hitTest(dividerPointInHost) === host,
|
|
"Collapsed right-docked divider should stay on the manual browser-panel resize path while beating the sidebar-resizer overlap"
|
|
)
|
|
|
|
let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
|
|
host.mouseDown(with: down)
|
|
let drag = makeMouseEvent(
|
|
type: .leftMouseDragged,
|
|
location: NSPoint(x: dividerPointInWindow.x + 36, y: dividerPointInWindow.y),
|
|
window: window
|
|
)
|
|
host.mouseDragged(with: drag)
|
|
host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
|
|
|
|
XCTAssertGreaterThan(pageView.frame.width, 0)
|
|
XCTAssertGreaterThan(inspectorContainer.frame.minX, 0)
|
|
}
|
|
|
|
func testBrowserPanelHostClaimsHostedInspectorDividerAcrossFullHeight() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
|
|
host.autoresizingMask = [.minXMargin, .height]
|
|
contentView.addSubview(host)
|
|
|
|
let webViewRoot = NSView(frame: host.bounds)
|
|
webViewRoot.autoresizingMask = [.width, .height]
|
|
host.addSubview(webViewRoot)
|
|
|
|
let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 20, width: 92, height: webViewRoot.bounds.height - 40))
|
|
let inspectorContainer = EdgeTransparentWKInspectorProbeView(
|
|
frame: NSRect(x: 92, y: 20, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height - 40)
|
|
)
|
|
webViewRoot.addSubview(pageView)
|
|
webViewRoot.addSubview(inspectorContainer)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
|
|
XCTAssertTrue(
|
|
host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host,
|
|
"The custom DevTools divider should remain draggable at the top edge of the browser pane"
|
|
)
|
|
XCTAssertTrue(
|
|
host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host,
|
|
"The custom DevTools divider should remain draggable at the bottom edge of the browser pane"
|
|
)
|
|
}
|
|
|
|
func testBrowserPanelHostFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
|
|
host.autoresizingMask = [.minXMargin, .height]
|
|
contentView.addSubview(host)
|
|
|
|
let webViewRoot = NSView(frame: host.bounds)
|
|
webViewRoot.autoresizingMask = [.width, .height]
|
|
host.addSubview(webViewRoot)
|
|
|
|
let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height))
|
|
let inspectorContainer = EdgeTransparentWKInspectorProbeView(
|
|
frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height)
|
|
)
|
|
webViewRoot.addSubview(pageView)
|
|
webViewRoot.addSubview(inspectorContainer)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
|
|
let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
|
|
let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
|
|
|
|
XCTAssertTrue(
|
|
host.hitTest(dividerPointInHost) === host,
|
|
"Browser panel host should only take the manual fallback path when the divider edge is not natively hittable"
|
|
)
|
|
|
|
let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
|
|
host.mouseDown(with: down)
|
|
let drag = makeMouseEvent(
|
|
type: .leftMouseDragged,
|
|
location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y),
|
|
window: window
|
|
)
|
|
host.mouseDragged(with: drag)
|
|
host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
|
|
|
|
XCTAssertGreaterThan(pageView.frame.width, 92)
|
|
XCTAssertGreaterThan(inspectorContainer.frame.minX, 92)
|
|
}
|
|
|
|
func testBrowserPanelHostKeepsInspectorResizableAfterShrinkingToMinimumWidth() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
|
|
host.autoresizingMask = [.minXMargin, .height]
|
|
contentView.addSubview(host)
|
|
|
|
let webViewRoot = NSView(frame: host.bounds)
|
|
webViewRoot.autoresizingMask = [.width, .height]
|
|
host.addSubview(webViewRoot)
|
|
|
|
let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height))
|
|
let inspectorContainer = EdgeTransparentWKInspectorProbeView(
|
|
frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height)
|
|
)
|
|
webViewRoot.addSubview(pageView)
|
|
webViewRoot.addSubview(inspectorContainer)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
|
|
let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
|
|
let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
|
|
|
|
host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window))
|
|
let drag = makeMouseEvent(
|
|
type: .leftMouseDragged,
|
|
location: NSPoint(x: dividerPointInWindow.x + 220, y: dividerPointInWindow.y),
|
|
window: window
|
|
)
|
|
host.mouseDragged(with: drag)
|
|
host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
|
|
|
|
XCTAssertGreaterThanOrEqual(
|
|
inspectorContainer.frame.width,
|
|
120,
|
|
"Shrinking the DevTools pane should clamp to a recoverable minimum width"
|
|
)
|
|
XCTAssertTrue(
|
|
host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host,
|
|
"After clamping, the DevTools divider should still be draggable near the top edge"
|
|
)
|
|
XCTAssertTrue(
|
|
host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host,
|
|
"After clamping, the DevTools divider should still be draggable near the bottom edge"
|
|
)
|
|
}
|
|
|
|
func testBrowserPanelHostPromotesVisibleRightDockedInspectorIntoManagedSideDock() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
|
|
host.autoresizingMask = [.minXMargin, .height]
|
|
contentView.addSubview(host)
|
|
|
|
let slotView = host.ensureLocalInlineSlotView()
|
|
let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height + 180))
|
|
let inspectorView = WKWebView(
|
|
frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height)
|
|
)
|
|
slotView.addSubview(pageView)
|
|
slotView.addSubview(inspectorView)
|
|
host.pinHostedWebView(pageView, in: slotView)
|
|
host.setHostedInspectorFrontendWebView(inspectorView)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
host.layoutSubtreeIfNeeded()
|
|
|
|
XCTAssertTrue(
|
|
host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(),
|
|
"A visible right-docked inspector should not wait on async dock-configuration JS before entering the managed side-dock path"
|
|
)
|
|
XCTAssertTrue(
|
|
pageView.superview === inspectorView.superview && pageView.superview !== slotView,
|
|
"Promotion should move both hosted inspector siblings into the managed side-dock container"
|
|
)
|
|
XCTAssertEqual(
|
|
pageView.frame.height,
|
|
host.bounds.height,
|
|
accuracy: 0.5,
|
|
"Promotion should normalize stale page heights to the host height so the page layer stops covering the divider"
|
|
)
|
|
XCTAssertEqual(
|
|
inspectorView.frame.height,
|
|
host.bounds.height,
|
|
accuracy: 0.5,
|
|
"Promotion should normalize the inspector height to the host height"
|
|
)
|
|
}
|
|
|
|
func testBrowserPanelHostAllowsRightDockedInspectorToExpandLeftAfterPromotion() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
|
|
host.autoresizingMask = [.minXMargin, .height]
|
|
contentView.addSubview(host)
|
|
|
|
let slotView = host.ensureLocalInlineSlotView()
|
|
let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height))
|
|
let inspectorView = WKWebView(
|
|
frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height)
|
|
)
|
|
slotView.addSubview(pageView)
|
|
slotView.addSubview(inspectorView)
|
|
host.pinHostedWebView(pageView, in: slotView)
|
|
host.setHostedInspectorFrontendWebView(inspectorView)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
host.layoutSubtreeIfNeeded()
|
|
|
|
XCTAssertTrue(
|
|
host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(),
|
|
"The managed side-dock path should be active before drag assertions run"
|
|
)
|
|
|
|
let initialPageWidth = pageView.frame.width
|
|
let initialInspectorWidth = inspectorView.frame.width
|
|
let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY)
|
|
let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
|
|
|
|
host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window))
|
|
let drag = makeMouseEvent(
|
|
type: .leftMouseDragged,
|
|
location: NSPoint(x: dividerPointInWindow.x - 40, y: dividerPointInWindow.y),
|
|
window: window
|
|
)
|
|
host.mouseDragged(with: drag)
|
|
host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
|
|
|
|
XCTAssertGreaterThan(
|
|
inspectorView.frame.width,
|
|
initialInspectorWidth,
|
|
"Right-docked DevTools should expand when the divider is dragged left"
|
|
)
|
|
XCTAssertLessThan(
|
|
pageView.frame.width,
|
|
initialPageWidth,
|
|
"Expanding right-docked DevTools should shrink the page width"
|
|
)
|
|
}
|
|
|
|
func testBrowserPanelHostKeepsAutomaticRightDockedWidthAboveMinimumWhileShrinking() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 140, y: 0, width: 280, height: contentView.bounds.height))
|
|
host.autoresizingMask = [.minXMargin, .height]
|
|
contentView.addSubview(host)
|
|
|
|
let slotView = host.ensureLocalInlineSlotView()
|
|
let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 132, height: host.bounds.height))
|
|
let inspectorView = WKWebView(
|
|
frame: NSRect(x: 132, y: 0, width: slotView.bounds.width - 132, height: host.bounds.height)
|
|
)
|
|
slotView.addSubview(pageView)
|
|
slotView.addSubview(inspectorView)
|
|
host.pinHostedWebView(pageView, in: slotView)
|
|
host.setHostedInspectorFrontendWebView(inspectorView)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
host.layoutSubtreeIfNeeded()
|
|
|
|
XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded())
|
|
|
|
host.setPreferredHostedInspectorWidth(width: 80, widthFraction: nil)
|
|
host.setFrameSize(NSSize(width: 210, height: host.frame.height))
|
|
contentView.layoutSubtreeIfNeeded()
|
|
host.layoutSubtreeIfNeeded()
|
|
|
|
XCTAssertGreaterThanOrEqual(
|
|
inspectorView.frame.width,
|
|
120,
|
|
"Automatic pane resize should honor the same minimum hosted inspector width as manual dragging"
|
|
)
|
|
XCTAssertEqual(
|
|
inspectorView.frame.height,
|
|
host.bounds.height,
|
|
accuracy: 0.5,
|
|
"Automatic shrink should keep the inspector vertically normalized to the host height"
|
|
)
|
|
}
|
|
|
|
func testBrowserPanelHostRequestsBottomDockWhenSideDockLeavesTooLittlePageWidth() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 280, height: contentView.bounds.height))
|
|
host.autoresizingMask = [.minXMargin, .height]
|
|
contentView.addSubview(host)
|
|
|
|
let slotView = host.ensureLocalInlineSlotView()
|
|
let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height))
|
|
let inspectorView = TrackingInspectorFrontendWebView(
|
|
frame: NSRect(x: 120, y: 0, width: slotView.bounds.width - 120, height: host.bounds.height)
|
|
)
|
|
slotView.addSubview(pageView)
|
|
slotView.addSubview(inspectorView)
|
|
host.pinHostedWebView(pageView, in: slotView)
|
|
host.setHostedInspectorFrontendWebView(inspectorView)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
host.layoutSubtreeIfNeeded()
|
|
|
|
XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded())
|
|
|
|
host.setFrameSize(NSSize(width: 210, height: host.frame.height))
|
|
contentView.layoutSubtreeIfNeeded()
|
|
host.layoutSubtreeIfNeeded()
|
|
|
|
XCTAssertTrue(
|
|
inspectorView.evaluatedJavaScript.contains(where: { $0.contains("WI._dockBottom()") }),
|
|
"Narrow pane widths should request bottom-docked DevTools instead of leaving the side-docked inspector in an unstable layout"
|
|
)
|
|
XCTAssertTrue(
|
|
inspectorView.evaluatedJavaScript.contains(where: { $0.contains("const allowSideDock = false;") }),
|
|
"Once a narrow pane proves it cannot safely side-dock DevTools, the inspector frontend should hide and disable left/right dock controls"
|
|
)
|
|
}
|
|
|
|
func testBrowserPanelManagedSideDockDoesNotAutoresizeDraggedFrames() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
|
|
host.autoresizingMask = [.minXMargin, .height]
|
|
contentView.addSubview(host)
|
|
|
|
let slotView = host.ensureLocalInlineSlotView()
|
|
let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height))
|
|
let inspectorView = WKWebView(
|
|
frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height)
|
|
)
|
|
slotView.addSubview(pageView)
|
|
slotView.addSubview(inspectorView)
|
|
host.pinHostedWebView(pageView, in: slotView)
|
|
host.setHostedInspectorFrontendWebView(inspectorView)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
host.layoutSubtreeIfNeeded()
|
|
|
|
XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded())
|
|
|
|
let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY)
|
|
let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
|
|
host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window))
|
|
let drag = makeMouseEvent(
|
|
type: .leftMouseDragged,
|
|
location: NSPoint(x: dividerPointInWindow.x - 30, y: dividerPointInWindow.y),
|
|
window: window
|
|
)
|
|
host.mouseDragged(with: drag)
|
|
host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
|
|
|
|
guard let managedContainer = pageView.superview else {
|
|
XCTFail("Expected managed side-dock container")
|
|
return
|
|
}
|
|
let draggedPageFrame = pageView.frame
|
|
let draggedInspectorFrame = inspectorView.frame
|
|
|
|
managedContainer.setFrameSize(
|
|
NSSize(width: managedContainer.frame.width, height: managedContainer.frame.height + 24)
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
pageView.frame.origin.x,
|
|
draggedPageFrame.origin.x,
|
|
accuracy: 0.5,
|
|
"Managed side-dock container should not autoresize the page back to a stale divider position"
|
|
)
|
|
XCTAssertEqual(
|
|
pageView.frame.width,
|
|
draggedPageFrame.width,
|
|
accuracy: 0.5,
|
|
"Managed side-dock container should preserve the dragged page width until the host explicitly reapplies layout"
|
|
)
|
|
XCTAssertEqual(
|
|
inspectorView.frame.origin.x,
|
|
draggedInspectorFrame.origin.x,
|
|
accuracy: 0.5,
|
|
"Managed side-dock container should preserve the dragged inspector origin"
|
|
)
|
|
XCTAssertEqual(
|
|
inspectorView.frame.width,
|
|
draggedInspectorFrame.width,
|
|
accuracy: 0.5,
|
|
"Managed side-dock container should preserve the dragged inspector width"
|
|
)
|
|
}
|
|
|
|
func testBrowserPanelHostFallsBackToManualHostedInspectorDragForLeftDockedInspector() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
|
|
host.autoresizingMask = [.minXMargin, .height]
|
|
contentView.addSubview(host)
|
|
|
|
let webViewRoot = NSView(frame: host.bounds)
|
|
webViewRoot.autoresizingMask = [.width, .height]
|
|
host.addSubview(webViewRoot)
|
|
|
|
let inspectorContainer = TrailingEdgeTransparentWKInspectorProbeView(
|
|
frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)
|
|
)
|
|
let pageView = PrimaryPageProbeView(
|
|
frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height)
|
|
)
|
|
webViewRoot.addSubview(inspectorContainer)
|
|
webViewRoot.addSubview(pageView)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
|
|
let dividerPointInHost = NSPoint(x: inspectorContainer.frame.maxX - 2, y: host.bounds.midY)
|
|
let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
|
|
|
|
XCTAssertTrue(
|
|
host.hitTest(dividerPointInHost) === host,
|
|
"Browser panel host should take the manual fallback path for a left-docked divider when the native edge is not hittable"
|
|
)
|
|
|
|
let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
|
|
host.mouseDown(with: down)
|
|
let drag = makeMouseEvent(
|
|
type: .leftMouseDragged,
|
|
location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y),
|
|
window: window
|
|
)
|
|
host.mouseDragged(with: drag)
|
|
host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
|
|
|
|
XCTAssertGreaterThan(inspectorContainer.frame.width, 92)
|
|
XCTAssertGreaterThan(pageView.frame.minX, 92)
|
|
}
|
|
|
|
func testBrowserPanelHostReappliesStoredHostedInspectorWidthAfterLayoutReset() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let host = WebViewRepresentable.HostContainerView(
|
|
frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)
|
|
)
|
|
host.autoresizingMask = [.minXMargin, .height]
|
|
contentView.addSubview(host)
|
|
|
|
let webViewRoot = NSView(frame: host.bounds)
|
|
webViewRoot.autoresizingMask = [.width, .height]
|
|
host.addSubview(webViewRoot)
|
|
|
|
let originalPageFrame = NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)
|
|
let originalInspectorFrame = NSRect(
|
|
x: 92,
|
|
y: 0,
|
|
width: webViewRoot.bounds.width - 92,
|
|
height: webViewRoot.bounds.height
|
|
)
|
|
let pageView = PrimaryPageProbeView(frame: originalPageFrame)
|
|
let inspectorContainer = NSView(frame: originalInspectorFrame)
|
|
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
|
|
inspectorView.autoresizingMask = [.width, .height]
|
|
inspectorContainer.addSubview(inspectorView)
|
|
webViewRoot.addSubview(pageView)
|
|
webViewRoot.addSubview(inspectorContainer)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
|
|
let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
|
|
let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
|
|
|
|
let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
|
|
host.mouseDown(with: down)
|
|
let drag = makeMouseEvent(
|
|
type: .leftMouseDragged,
|
|
location: NSPoint(x: dividerPointInWindow.x + 48, y: dividerPointInWindow.y),
|
|
window: window
|
|
)
|
|
host.mouseDragged(with: drag)
|
|
host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
|
|
|
|
let draggedPageWidth = pageView.frame.width
|
|
let draggedInspectorMinX = inspectorContainer.frame.minX
|
|
XCTAssertGreaterThan(draggedPageWidth, originalPageFrame.width)
|
|
XCTAssertGreaterThan(draggedInspectorMinX, originalInspectorFrame.minX)
|
|
|
|
pageView.frame = originalPageFrame
|
|
inspectorContainer.frame = originalInspectorFrame
|
|
host.needsLayout = true
|
|
host.layoutSubtreeIfNeeded()
|
|
|
|
XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5)
|
|
XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5)
|
|
}
|
|
|
|
func testWindowBrowserSlotPinsHostedWebViewWithAutoresizingForAttachedInspector() {
|
|
let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 240, height: 180))
|
|
let webView = WKWebView(frame: .zero)
|
|
slot.addSubview(webView)
|
|
|
|
slot.pinHostedWebView(webView)
|
|
slot.frame = NSRect(x: 0, y: 0, width: 300, height: 220)
|
|
slot.layoutSubtreeIfNeeded()
|
|
|
|
XCTAssertTrue(webView.translatesAutoresizingMaskIntoConstraints)
|
|
XCTAssertEqual(webView.autoresizingMask, [.width, .height])
|
|
XCTAssertEqual(webView.frame, slot.bounds)
|
|
}
|
|
|
|
func testWindowBrowserSlotReattachesPlainWebViewAtFullBoundsAfterHiddenHostResize() {
|
|
let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 400, height: 180))
|
|
let webView = WKWebView(frame: .zero)
|
|
slot.addSubview(webView)
|
|
slot.pinHostedWebView(webView)
|
|
XCTAssertEqual(webView.frame, slot.bounds)
|
|
|
|
let externalHost = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 180))
|
|
webView.removeFromSuperview()
|
|
externalHost.addSubview(webView)
|
|
webView.frame = externalHost.bounds
|
|
webView.translatesAutoresizingMaskIntoConstraints = true
|
|
webView.autoresizingMask = [.width, .height]
|
|
|
|
slot.addSubview(webView)
|
|
slot.pinHostedWebView(webView)
|
|
|
|
slot.frame = NSRect(x: 0, y: 0, width: 300, height: 180)
|
|
slot.layoutSubtreeIfNeeded()
|
|
|
|
XCTAssertEqual(
|
|
webView.frame,
|
|
slot.bounds,
|
|
"Reattaching a plain web view should restore full-bounds hosting instead of preserving a stale inset frame from a hidden host"
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class BrowserPaneDropRoutingTests: XCTestCase {
|
|
func testVerticalZonesFollowAppKitCoordinates() {
|
|
let size = CGSize(width: 240, height: 180)
|
|
|
|
XCTAssertEqual(
|
|
BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: size.height - 8), in: size),
|
|
.top
|
|
)
|
|
XCTAssertEqual(
|
|
BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: 8), in: size),
|
|
.bottom
|
|
)
|
|
}
|
|
|
|
func testTopChromeHeightPushesTopSplitThresholdIntoWebView() {
|
|
let size = CGSize(width: 240, height: 180)
|
|
|
|
XCTAssertEqual(
|
|
BrowserPaneDropRouting.zone(
|
|
for: CGPoint(x: size.width * 0.5, y: 110),
|
|
in: size,
|
|
topChromeHeight: 36
|
|
),
|
|
.center
|
|
)
|
|
XCTAssertEqual(
|
|
BrowserPaneDropRouting.zone(
|
|
for: CGPoint(x: size.width * 0.5, y: 150),
|
|
in: size,
|
|
topChromeHeight: 36
|
|
),
|
|
.top
|
|
)
|
|
}
|
|
|
|
func testHitTestingCapturesOnlyForRelevantDragEvents() {
|
|
XCTAssertTrue(
|
|
BrowserPaneDropTargetView.shouldCaptureHitTesting(
|
|
pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType],
|
|
eventType: .cursorUpdate
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
BrowserPaneDropTargetView.shouldCaptureHitTesting(
|
|
pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType],
|
|
eventType: .leftMouseDown
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
BrowserPaneDropTargetView.shouldCaptureHitTesting(
|
|
pasteboardTypes: [.fileURL],
|
|
eventType: .cursorUpdate
|
|
)
|
|
)
|
|
}
|
|
|
|
func testCenterDropOnSamePaneIsNoOp() {
|
|
let paneId = PaneID(id: UUID())
|
|
let target = BrowserPaneDropContext(
|
|
workspaceId: UUID(),
|
|
panelId: UUID(),
|
|
paneId: paneId
|
|
)
|
|
let transfer = BrowserPaneDragTransfer(
|
|
tabId: UUID(),
|
|
sourcePaneId: paneId.id,
|
|
sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier)
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
BrowserPaneDropRouting.action(for: transfer, target: target, zone: .center),
|
|
.noOp
|
|
)
|
|
}
|
|
|
|
func testRightEdgeDropBuildsSplitMoveAction() {
|
|
let paneId = PaneID(id: UUID())
|
|
let target = BrowserPaneDropContext(
|
|
workspaceId: UUID(),
|
|
panelId: UUID(),
|
|
paneId: paneId
|
|
)
|
|
let tabId = UUID()
|
|
let transfer = BrowserPaneDragTransfer(
|
|
tabId: tabId,
|
|
sourcePaneId: UUID(),
|
|
sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier)
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
BrowserPaneDropRouting.action(for: transfer, target: target, zone: .right),
|
|
.move(
|
|
tabId: tabId,
|
|
targetWorkspaceId: target.workspaceId,
|
|
targetPane: paneId,
|
|
splitTarget: BrowserPaneSplitTarget(orientation: .horizontal, insertFirst: false)
|
|
)
|
|
)
|
|
}
|
|
|
|
func testDecodeTransferPayloadReadsTabAndSourcePane() {
|
|
let tabId = UUID()
|
|
let sourcePaneId = UUID()
|
|
let payload = try! JSONSerialization.data(
|
|
withJSONObject: [
|
|
"tab": ["id": tabId.uuidString],
|
|
"sourcePaneId": sourcePaneId.uuidString,
|
|
"sourceProcessId": ProcessInfo.processInfo.processIdentifier,
|
|
]
|
|
)
|
|
|
|
let transfer = BrowserPaneDragTransfer.decode(from: payload)
|
|
|
|
XCTAssertEqual(transfer?.tabId, tabId)
|
|
XCTAssertEqual(transfer?.sourcePaneId, sourcePaneId)
|
|
XCTAssertTrue(transfer?.isFromCurrentProcess == true)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class WindowBrowserSlotViewTests: XCTestCase {
|
|
private final class CapturingView: NSView {
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
bounds.contains(point) ? self : nil
|
|
}
|
|
}
|
|
|
|
private func advanceAnimations() {
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
|
|
}
|
|
|
|
func testDropZoneOverlayStaysAboveContentWithoutBlockingHits() {
|
|
let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
|
|
let slot = WindowBrowserSlotView(frame: container.bounds)
|
|
container.addSubview(slot)
|
|
let child = CapturingView(frame: slot.bounds)
|
|
child.autoresizingMask = [.width, .height]
|
|
slot.addSubview(child)
|
|
|
|
slot.setDropZoneOverlay(zone: .right)
|
|
container.layoutSubtreeIfNeeded()
|
|
|
|
guard let overlay = container.subviews.first(where: {
|
|
$0 !== slot && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
|
|
}) else {
|
|
XCTFail("Expected browser slot drop-zone overlay")
|
|
return
|
|
}
|
|
|
|
XCTAssertTrue(container.subviews.last === overlay, "Overlay should stay above the hosted web view")
|
|
XCTAssertFalse(overlay.isHidden)
|
|
XCTAssertEqual(overlay.frame.origin.x, 100, accuracy: 0.5)
|
|
XCTAssertEqual(overlay.frame.origin.y, 4, accuracy: 0.5)
|
|
XCTAssertEqual(overlay.frame.size.width, 96, accuracy: 0.5)
|
|
XCTAssertEqual(overlay.frame.size.height, 92, accuracy: 0.5)
|
|
XCTAssertNil(overlay.hitTest(NSPoint(x: 120, y: 50)), "Overlay should never intercept pointer hits")
|
|
XCTAssertTrue(slot.hitTest(NSPoint(x: 120, y: 50)) === child)
|
|
|
|
slot.setDropZoneOverlay(zone: nil)
|
|
advanceAnimations()
|
|
XCTAssertTrue(overlay.isHidden, "Clearing the drop zone should hide the overlay")
|
|
}
|
|
|
|
func testTopDropZoneOverlayUsesFullBrowserContentHeight() {
|
|
let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
|
|
let slot = WindowBrowserSlotView(frame: container.bounds)
|
|
container.addSubview(slot)
|
|
|
|
slot.setPaneTopChromeHeight(20)
|
|
slot.setDropZoneOverlay(zone: .top)
|
|
container.layoutSubtreeIfNeeded()
|
|
|
|
guard let overlay = container.subviews.first(where: {
|
|
String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
|
|
}) else {
|
|
XCTFail("Expected browser slot drop-zone overlay")
|
|
return
|
|
}
|
|
|
|
XCTAssertFalse(overlay.isHidden)
|
|
XCTAssertEqual(overlay.frame.origin.x, 4, accuracy: 0.5)
|
|
XCTAssertEqual(overlay.frame.origin.y, 60, accuracy: 0.5)
|
|
XCTAssertEqual(overlay.frame.size.width, 192, accuracy: 0.5)
|
|
XCTAssertEqual(overlay.frame.size.height, 56, accuracy: 0.5)
|
|
XCTAssertGreaterThan(overlay.frame.maxY, slot.frame.maxY)
|
|
XCTAssertEqual(slot.layer?.masksToBounds, true)
|
|
|
|
slot.setDropZoneOverlay(zone: nil)
|
|
advanceAnimations()
|
|
XCTAssertEqual(slot.layer?.masksToBounds, true)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class BrowserWindowPortalLifecycleTests: XCTestCase {
|
|
private final class TrackingPortalWebView: WKWebView {
|
|
private(set) var displayIfNeededCount = 0
|
|
private(set) var reattachRenderingStateCount = 0
|
|
|
|
override func displayIfNeeded() {
|
|
displayIfNeededCount += 1
|
|
super.displayIfNeeded()
|
|
}
|
|
|
|
@objc(_enterInWindow)
|
|
func cmuxUnitTestEnterInWindow() {
|
|
reattachRenderingStateCount += 1
|
|
}
|
|
|
|
@objc(_endDeferringViewInWindowChangesSync)
|
|
func cmuxUnitTestEndDeferringViewInWindowChangesSync() {
|
|
reattachRenderingStateCount += 1
|
|
}
|
|
}
|
|
|
|
private final class WKInspectorProbeView: NSView {}
|
|
|
|
private func realizeWindowLayout(_ window: NSWindow) {
|
|
window.makeKeyAndOrderFront(nil)
|
|
window.displayIfNeeded()
|
|
window.contentView?.layoutSubtreeIfNeeded()
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
|
window.contentView?.layoutSubtreeIfNeeded()
|
|
}
|
|
|
|
private func advanceAnimations() {
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
|
|
}
|
|
|
|
private func dropZoneOverlay(in slot: WindowBrowserSlotView, excluding webView: WKWebView) -> NSView? {
|
|
let candidates = slot.subviews + (slot.superview?.subviews ?? [])
|
|
return candidates.first(where: {
|
|
$0 !== slot &&
|
|
$0 !== webView &&
|
|
String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
|
|
})
|
|
}
|
|
|
|
func testPortalHostInstallsAboveContentViewForVisibility() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
let portal = WindowBrowserPortal(window: window)
|
|
_ = portal.webViewAtWindowPoint(NSPoint(x: 1, y: 1))
|
|
|
|
guard let contentView = window.contentView,
|
|
let container = contentView.superview else {
|
|
XCTFail("Expected content container")
|
|
return
|
|
}
|
|
|
|
guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }),
|
|
let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else {
|
|
XCTFail("Expected host/content views in same container")
|
|
return
|
|
}
|
|
|
|
XCTAssertGreaterThan(
|
|
hostIndex,
|
|
contentIndex,
|
|
"Browser portal host must remain above content view so portal-hosted web views stay visible"
|
|
)
|
|
}
|
|
|
|
func testBrowserPortalHostStaysAboveTerminalPortalHostDuringPortalChurn() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
|
|
let browserPortal = WindowBrowserPortal(window: window)
|
|
let terminalPortal = WindowTerminalPortal(window: window)
|
|
_ = browserPortal.webViewAtWindowPoint(NSPoint(x: 1, y: 1))
|
|
_ = terminalPortal.viewAtWindowPoint(NSPoint(x: 1, y: 1))
|
|
|
|
guard let contentView = window.contentView,
|
|
let container = contentView.superview else {
|
|
XCTFail("Expected content container")
|
|
return
|
|
}
|
|
|
|
func assertHostOrder(_ message: String) {
|
|
guard let browserHostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }),
|
|
let terminalHostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }) else {
|
|
XCTFail("Expected both portal hosts in same container")
|
|
return
|
|
}
|
|
|
|
XCTAssertGreaterThan(
|
|
browserHostIndex,
|
|
terminalHostIndex,
|
|
message
|
|
)
|
|
}
|
|
|
|
assertHostOrder("Browser portal host should start above terminal portal host")
|
|
|
|
let terminalAnchor = NSView(frame: NSRect(x: 20, y: 20, width: 200, height: 140))
|
|
contentView.addSubview(terminalAnchor)
|
|
let terminalHostedView = GhosttySurfaceScrollView(
|
|
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
|
|
)
|
|
terminalPortal.bind(hostedView: terminalHostedView, to: terminalAnchor, visibleInUI: true)
|
|
terminalPortal.synchronizeHostedViewForAnchor(terminalAnchor)
|
|
assertHostOrder("Terminal portal sync should not rise above the browser portal host")
|
|
|
|
let browserAnchor = NSView(frame: NSRect(x: 240, y: 20, width: 220, height: 140))
|
|
contentView.addSubview(browserAnchor)
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
browserPortal.bind(webView: webView, to: browserAnchor, visibleInUI: true)
|
|
browserPortal.synchronizeWebViewForAnchor(browserAnchor)
|
|
assertHostOrder("Browser portal sync should keep browser panes above portal-hosted terminals")
|
|
}
|
|
|
|
func testAnchorRebindKeepsWebViewInStablePortalSuperview() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
let portal = WindowBrowserPortal(window: window)
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
|
|
let anchor2 = NSView(frame: NSRect(x: 240, y: 40, width: 180, height: 120))
|
|
contentView.addSubview(anchor1)
|
|
contentView.addSubview(anchor2)
|
|
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
portal.bind(webView: webView, to: anchor1, visibleInUI: true)
|
|
let firstSuperview = webView.superview
|
|
|
|
XCTAssertNotNil(firstSuperview)
|
|
XCTAssertTrue(firstSuperview is WindowBrowserSlotView)
|
|
|
|
portal.bind(webView: webView, to: anchor2, visibleInUI: true)
|
|
XCTAssertTrue(webView.superview === firstSuperview, "Anchor moves should not reparent the web view")
|
|
|
|
contentView.layoutSubtreeIfNeeded()
|
|
portal.synchronizeWebViewForAnchor(anchor2)
|
|
guard let slot = webView.superview as? WindowBrowserSlotView,
|
|
let host = slot.superview as? WindowBrowserHostView else {
|
|
XCTFail("Expected browser slot + host views")
|
|
return
|
|
}
|
|
let expectedFrame = host.convert(anchor2.bounds, from: anchor2)
|
|
XCTAssertEqual(slot.frame.origin.x, expectedFrame.origin.x, accuracy: 0.5)
|
|
XCTAssertEqual(slot.frame.origin.y, expectedFrame.origin.y, accuracy: 0.5)
|
|
XCTAssertEqual(slot.frame.size.width, expectedFrame.size.width, accuracy: 0.5)
|
|
XCTAssertEqual(slot.frame.size.height, expectedFrame.size.height, accuracy: 0.5)
|
|
}
|
|
|
|
func testPortalClampsWebViewFrameToHostBoundsWhenAnchorOverflowsSidebar() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
let portal = WindowBrowserPortal(window: window)
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
// Simulate a transient oversized anchor rect during split churn.
|
|
let anchor = NSView(frame: NSRect(x: 120, y: 20, width: 260, height: 150))
|
|
contentView.addSubview(anchor)
|
|
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
portal.bind(webView: webView, to: anchor, visibleInUI: true)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
|
|
guard let slot = webView.superview as? WindowBrowserSlotView else {
|
|
XCTFail("Expected web view slot")
|
|
return
|
|
}
|
|
|
|
XCTAssertFalse(slot.isHidden, "Partially visible browser anchor should stay visible")
|
|
XCTAssertEqual(slot.frame.origin.x, 120, accuracy: 0.5)
|
|
XCTAssertEqual(slot.frame.origin.y, 20, accuracy: 0.5)
|
|
XCTAssertEqual(slot.frame.size.width, 200, accuracy: 0.5)
|
|
XCTAssertEqual(slot.frame.size.height, 150, accuracy: 0.5)
|
|
}
|
|
|
|
func testPortalClipsAnchorFrameThroughAncestorBounds() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
let portal = WindowBrowserPortal(window: window)
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let clipView = NSView(frame: NSRect(x: 60, y: 40, width: 150, height: 120))
|
|
contentView.addSubview(clipView)
|
|
|
|
// Simulate SwiftUI/AppKit reporting an anchor wider than the actual visible pane.
|
|
let anchor = NSView(frame: NSRect(x: -30, y: 0, width: 220, height: 120))
|
|
clipView.addSubview(anchor)
|
|
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
portal.bind(webView: webView, to: anchor, visibleInUI: true)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
clipView.layoutSubtreeIfNeeded()
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
|
|
guard let slot = webView.superview as? WindowBrowserSlotView else {
|
|
XCTFail("Expected browser slot")
|
|
return
|
|
}
|
|
|
|
XCTAssertFalse(slot.isHidden, "Ancestor clipping should keep the browser visible in the real pane")
|
|
XCTAssertEqual(slot.frame.origin.x, 60, accuracy: 0.5)
|
|
XCTAssertEqual(slot.frame.origin.y, 40, accuracy: 0.5)
|
|
XCTAssertEqual(slot.frame.size.width, 150, accuracy: 0.5)
|
|
XCTAssertEqual(slot.frame.size.height, 120, accuracy: 0.5)
|
|
}
|
|
|
|
func testPortalSyncNormalizesOutOfBoundsWebFrame() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
let portal = WindowBrowserPortal(window: window)
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let anchor = NSView(frame: NSRect(x: 40, y: 20, width: 220, height: 160))
|
|
contentView.addSubview(anchor)
|
|
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
portal.bind(webView: webView, to: anchor, visibleInUI: true)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
|
|
guard let slot = webView.superview as? WindowBrowserSlotView else {
|
|
XCTFail("Expected browser slot")
|
|
return
|
|
}
|
|
|
|
// Reproduce observed drift from logs where WebKit shifts/expands frame beyond slot bounds.
|
|
webView.frame = NSRect(x: 0, y: 250, width: slot.bounds.width, height: slot.bounds.height)
|
|
XCTAssertGreaterThan(webView.frame.maxY, slot.bounds.maxY)
|
|
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
XCTAssertEqual(webView.frame.origin.x, slot.bounds.origin.x, accuracy: 0.5)
|
|
XCTAssertEqual(webView.frame.origin.y, slot.bounds.origin.y, accuracy: 0.5)
|
|
XCTAssertEqual(webView.frame.size.width, slot.bounds.size.width, accuracy: 0.5)
|
|
XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5)
|
|
}
|
|
|
|
func testPortalSlotPinPreservesSideDockedInspectorManagedWebViewFrameOnRehost() {
|
|
let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 240, height: 160))
|
|
let webView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 132, height: 160), configuration: WKWebViewConfiguration())
|
|
let inspectorContainer = NSView(frame: NSRect(x: 132, y: 0, width: 108, height: 160))
|
|
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
|
|
inspectorView.autoresizingMask = [.width, .height]
|
|
inspectorContainer.addSubview(inspectorView)
|
|
slot.addSubview(webView)
|
|
slot.addSubview(inspectorContainer)
|
|
|
|
webView.translatesAutoresizingMaskIntoConstraints = false
|
|
webView.autoresizingMask = []
|
|
slot.pinHostedWebView(webView)
|
|
|
|
XCTAssertEqual(
|
|
webView.frame.maxX,
|
|
inspectorContainer.frame.minX,
|
|
accuracy: 0.5,
|
|
"Rehosting a portal-managed browser should preserve the WebKit-owned side inspector split"
|
|
)
|
|
XCTAssertLessThan(
|
|
webView.frame.width,
|
|
slot.bounds.width,
|
|
"The page frame should stay narrower than the full slot while a side-docked inspector is present"
|
|
)
|
|
}
|
|
|
|
func testPortalResizePreservesSideDockedInspectorManagedWebViewFrame() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
let portal = WindowBrowserPortal(window: window)
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180))
|
|
contentView.addSubview(anchor)
|
|
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
portal.bind(webView: webView, to: anchor, visibleInUI: true)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
|
|
guard let slot = webView.superview as? WindowBrowserSlotView else {
|
|
XCTFail("Expected browser slot")
|
|
return
|
|
}
|
|
|
|
let initialInspectorWidth: CGFloat = 110
|
|
let inspectorContainer = NSView(
|
|
frame: NSRect(
|
|
x: slot.bounds.width - initialInspectorWidth,
|
|
y: 0,
|
|
width: initialInspectorWidth,
|
|
height: slot.bounds.height
|
|
)
|
|
)
|
|
inspectorContainer.autoresizingMask = [.minXMargin, .height]
|
|
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
|
|
inspectorView.autoresizingMask = [.width, .height]
|
|
inspectorContainer.addSubview(inspectorView)
|
|
slot.addSubview(inspectorContainer)
|
|
|
|
webView.frame = NSRect(
|
|
x: 0,
|
|
y: 0,
|
|
width: slot.bounds.width - initialInspectorWidth,
|
|
height: slot.bounds.height
|
|
)
|
|
webView.autoresizingMask = [.width, .height]
|
|
slot.layoutSubtreeIfNeeded()
|
|
|
|
anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
|
|
XCTAssertFalse(slot.isHidden, "Resizing the browser pane should keep the hosted browser visible")
|
|
XCTAssertEqual(
|
|
webView.frame.maxX,
|
|
inspectorContainer.frame.minX,
|
|
accuracy: 0.5,
|
|
"Portal sync should preserve the side-docked inspector split instead of stretching the page back over the inspector"
|
|
)
|
|
XCTAssertLessThan(
|
|
webView.frame.width,
|
|
slot.bounds.width,
|
|
"Side-docked inspector should still own part of the slot after pane resize"
|
|
)
|
|
}
|
|
|
|
func testPortalAnchorResizeDoesNotForceHostedWebViewPresentationRefresh() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
let portal = WindowBrowserPortal(window: window)
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160))
|
|
contentView.addSubview(anchor)
|
|
|
|
let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
portal.bind(webView: webView, to: anchor, visibleInUI: true)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
advanceAnimations()
|
|
|
|
guard let slot = webView.superview as? WindowBrowserSlotView else {
|
|
XCTFail("Expected browser slot")
|
|
return
|
|
}
|
|
|
|
let initialDisplayCount = webView.displayIfNeededCount
|
|
let initialReattachCount = webView.reattachRenderingStateCount
|
|
anchor.frame = NSRect(x: 52, y: 30, width: 248, height: 178)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
advanceAnimations()
|
|
|
|
XCTAssertFalse(slot.isHidden, "Anchor resize should keep the portal-hosted browser visible")
|
|
XCTAssertEqual(slot.frame.origin.x, 52, accuracy: 0.5)
|
|
XCTAssertEqual(slot.frame.origin.y, 30, accuracy: 0.5)
|
|
XCTAssertEqual(slot.frame.size.width, 248, accuracy: 0.5)
|
|
XCTAssertEqual(slot.frame.size.height, 178, accuracy: 0.5)
|
|
XCTAssertGreaterThan(
|
|
webView.displayIfNeededCount,
|
|
initialDisplayCount,
|
|
"Pure anchor geometry updates should still repaint the hosted browser"
|
|
)
|
|
XCTAssertEqual(
|
|
webView.reattachRenderingStateCount,
|
|
initialReattachCount,
|
|
"Pure anchor geometry updates should not trigger the WebKit reattach path"
|
|
)
|
|
}
|
|
|
|
func testExternalSplitResizeDoesNotForceHostedWebViewPresentationRefresh() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 640, height: 360),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
let portal = WindowBrowserPortal(window: window)
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let splitView = NSSplitView(frame: contentView.bounds)
|
|
splitView.autoresizingMask = [.width, .height]
|
|
splitView.isVertical = true
|
|
|
|
let leadingPane = NSView(
|
|
frame: NSRect(x: 0, y: 0, width: 220, height: contentView.bounds.height)
|
|
)
|
|
leadingPane.autoresizingMask = [.height]
|
|
let trailingPane = NSView(
|
|
frame: NSRect(
|
|
x: 221,
|
|
y: 0,
|
|
width: contentView.bounds.width - 221,
|
|
height: contentView.bounds.height
|
|
)
|
|
)
|
|
trailingPane.autoresizingMask = [.width, .height]
|
|
splitView.addSubview(leadingPane)
|
|
splitView.addSubview(trailingPane)
|
|
contentView.addSubview(splitView)
|
|
splitView.adjustSubviews()
|
|
|
|
let anchor = NSView(frame: trailingPane.bounds.insetBy(dx: 12, dy: 12))
|
|
anchor.autoresizingMask = [.width, .height]
|
|
trailingPane.addSubview(anchor)
|
|
|
|
let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
portal.bind(webView: webView, to: anchor, visibleInUI: true)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
advanceAnimations()
|
|
|
|
guard let slot = webView.superview as? WindowBrowserSlotView else {
|
|
XCTFail("Expected browser slot")
|
|
return
|
|
}
|
|
|
|
let initialDisplayCount = webView.displayIfNeededCount
|
|
let initialReattachCount = webView.reattachRenderingStateCount
|
|
let initialWidth = slot.frame.width
|
|
|
|
splitView.setPosition(280, ofDividerAt: 0)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
NotificationCenter.default.post(name: NSSplitView.didResizeSubviewsNotification, object: splitView)
|
|
advanceAnimations()
|
|
|
|
XCTAssertFalse(slot.isHidden, "App split resize should keep the browser slot visible")
|
|
XCTAssertLessThan(
|
|
slot.frame.width,
|
|
initialWidth,
|
|
"Moving the app split divider should shrink the hosted browser slot"
|
|
)
|
|
XCTAssertGreaterThan(
|
|
webView.displayIfNeededCount,
|
|
initialDisplayCount,
|
|
"External split resize should still repaint the hosted browser"
|
|
)
|
|
XCTAssertEqual(
|
|
webView.reattachRenderingStateCount,
|
|
initialReattachCount,
|
|
"External split resize should not trigger the WebKit reattach path"
|
|
)
|
|
}
|
|
|
|
func testPortalSyncRepairsBottomDockedInspectorOverflowedPageFrame() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
let portal = WindowBrowserPortal(window: window)
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180))
|
|
contentView.addSubview(anchor)
|
|
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
portal.bind(webView: webView, to: anchor, visibleInUI: true)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
|
|
guard let slot = webView.superview as? WindowBrowserSlotView else {
|
|
XCTFail("Expected browser slot")
|
|
return
|
|
}
|
|
|
|
let inspectorHeight: CGFloat = 84
|
|
let inspectorContainer = NSView(
|
|
frame: NSRect(x: 0, y: 0, width: slot.bounds.width, height: inspectorHeight)
|
|
)
|
|
inspectorContainer.autoresizingMask = [.width]
|
|
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
|
|
inspectorView.autoresizingMask = [.width, .height]
|
|
inspectorContainer.addSubview(inspectorView)
|
|
slot.addSubview(inspectorContainer)
|
|
|
|
webView.frame = NSRect(
|
|
x: 0,
|
|
y: inspectorHeight,
|
|
width: slot.bounds.width,
|
|
height: slot.bounds.height
|
|
)
|
|
webView.autoresizingMask = [.width, .height]
|
|
slot.layoutSubtreeIfNeeded()
|
|
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
|
|
XCTAssertFalse(slot.isHidden, "Portal sync should keep the hosted browser visible")
|
|
XCTAssertEqual(
|
|
webView.frame.minY,
|
|
inspectorHeight,
|
|
accuracy: 0.5,
|
|
"Portal sync should keep the page viewport below a bottom-docked inspector instead of shifting the page upward"
|
|
)
|
|
XCTAssertEqual(
|
|
webView.frame.height,
|
|
slot.bounds.height - inspectorHeight,
|
|
accuracy: 0.5,
|
|
"Portal sync should shrink the page viewport to the space above a bottom-docked inspector"
|
|
)
|
|
XCTAssertEqual(
|
|
webView.frame.maxY,
|
|
slot.bounds.maxY,
|
|
accuracy: 0.5,
|
|
"The repaired page viewport should stay flush with the top edge of the slot"
|
|
)
|
|
}
|
|
|
|
func testHidingBrowserSlotYieldsOwnedInspectorFirstResponder() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let slot = WindowBrowserSlotView(frame: NSRect(x: 40, y: 24, width: 260, height: 180))
|
|
contentView.addSubview(slot)
|
|
|
|
let inspectorContainer = NSView(frame: slot.bounds)
|
|
inspectorContainer.autoresizingMask = [.width, .height]
|
|
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
|
|
inspectorView.autoresizingMask = [.width, .height]
|
|
inspectorContainer.addSubview(inspectorView)
|
|
slot.addSubview(inspectorContainer)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
|
|
XCTAssertTrue(
|
|
window.makeFirstResponder(inspectorView),
|
|
"Precondition failed: inspector probe should become first responder"
|
|
)
|
|
XCTAssertTrue(window.firstResponder === inspectorView)
|
|
|
|
slot.isHidden = true
|
|
|
|
XCTAssertFalse(
|
|
window.firstResponder === inspectorView,
|
|
"Hiding a browser slot should yield any owned inspector responder before it goes off-screen"
|
|
)
|
|
if let firstResponderView = window.firstResponder as? NSView {
|
|
XCTAssertFalse(
|
|
firstResponderView === slot || firstResponderView.isDescendant(of: slot),
|
|
"Hiding a browser slot should not leave first responder inside the hidden slot"
|
|
)
|
|
}
|
|
}
|
|
|
|
func testHiddenPortalSyncDoesNotStealLocallyHostedDevToolsWebViewDuringResize() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
let portal = WindowBrowserPortal(window: window)
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180))
|
|
contentView.addSubview(anchor)
|
|
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
portal.bind(webView: webView, to: anchor, visibleInUI: true)
|
|
contentView.layoutSubtreeIfNeeded()
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
advanceAnimations()
|
|
|
|
guard let hiddenPortalSlot = webView.superview as? WindowBrowserSlotView else {
|
|
XCTFail("Expected browser slot")
|
|
return
|
|
}
|
|
|
|
portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0)
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
advanceAnimations()
|
|
XCTAssertTrue(hiddenPortalSlot.isHidden, "Hidden portal entry should keep its slot hidden")
|
|
|
|
let localInlineSlot = WindowBrowserSlotView(frame: anchor.frame)
|
|
contentView.addSubview(localInlineSlot)
|
|
|
|
let inspectorView = WKInspectorProbeView(
|
|
frame: NSRect(x: 0, y: 0, width: localInlineSlot.bounds.width, height: 72)
|
|
)
|
|
inspectorView.autoresizingMask = [.width]
|
|
localInlineSlot.addSubview(inspectorView)
|
|
|
|
localInlineSlot.addSubview(webView)
|
|
webView.frame = NSRect(
|
|
x: 0,
|
|
y: inspectorView.frame.maxY,
|
|
width: localInlineSlot.bounds.width,
|
|
height: localInlineSlot.bounds.height - inspectorView.frame.height
|
|
)
|
|
localInlineSlot.layoutSubtreeIfNeeded()
|
|
|
|
anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180)
|
|
localInlineSlot.frame = anchor.frame
|
|
contentView.layoutSubtreeIfNeeded()
|
|
localInlineSlot.layoutSubtreeIfNeeded()
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
|
|
XCTAssertTrue(
|
|
webView.superview === localInlineSlot,
|
|
"Hidden portal sync should not steal a DevTools-hosted web view back out of local inline hosting during pane resize"
|
|
)
|
|
XCTAssertTrue(
|
|
inspectorView.superview === localInlineSlot,
|
|
"Hidden portal sync should leave local DevTools companion views in the local inline host"
|
|
)
|
|
XCTAssertTrue(hiddenPortalSlot.isHidden, "The retiring hidden portal slot should stay hidden during local inline hosting")
|
|
}
|
|
|
|
func testPortalHostBoundsBecomeReadyAfterBindingInFrameDrivenHierarchy() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
let portal = WindowBrowserPortal(window: window)
|
|
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160))
|
|
contentView.addSubview(anchor)
|
|
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
portal.bind(webView: webView, to: anchor, visibleInUI: true)
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
|
|
guard let slot = webView.superview as? WindowBrowserSlotView,
|
|
let host = slot.superview as? WindowBrowserHostView else {
|
|
XCTFail("Expected portal slot + host views")
|
|
return
|
|
}
|
|
XCTAssertGreaterThan(host.bounds.width, 1, "Portal host width should be ready for clipping/sync")
|
|
XCTAssertGreaterThan(host.bounds.height, 1, "Portal host height should be ready for clipping/sync")
|
|
}
|
|
|
|
func testPortalDropZoneOverlayPersistsAcrossVisibilityChanges() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
let portal = WindowBrowserPortal(window: window)
|
|
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160))
|
|
contentView.addSubview(anchor)
|
|
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
portal.bind(webView: webView, to: anchor, visibleInUI: true)
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
|
|
guard let slot = webView.superview as? WindowBrowserSlotView,
|
|
let overlay = dropZoneOverlay(in: slot, excluding: webView) else {
|
|
XCTFail("Expected browser slot overlay")
|
|
return
|
|
}
|
|
|
|
XCTAssertTrue(overlay.isHidden, "Overlay should start hidden without an active drop zone")
|
|
|
|
portal.updateDropZoneOverlay(forWebViewId: ObjectIdentifier(webView), zone: .right)
|
|
slot.layoutSubtreeIfNeeded()
|
|
XCTAssertFalse(overlay.isHidden)
|
|
XCTAssertTrue(slot.superview?.subviews.last === overlay, "Overlay should remain above the hosted web view")
|
|
XCTAssertEqual(overlay.frame.origin.x, slot.frame.origin.x + 110, accuracy: 0.5)
|
|
XCTAssertEqual(overlay.frame.origin.y, slot.frame.origin.y + 4, accuracy: 0.5)
|
|
XCTAssertEqual(overlay.frame.size.width, 106, accuracy: 0.5)
|
|
XCTAssertEqual(overlay.frame.size.height, 152, accuracy: 0.5)
|
|
|
|
portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0)
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
advanceAnimations()
|
|
XCTAssertTrue(overlay.isHidden, "Invisible browser entries should hide the overlay")
|
|
|
|
portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: true, zPriority: 0)
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
XCTAssertFalse(overlay.isHidden, "Restoring visibility should restore the active drop-zone overlay")
|
|
}
|
|
|
|
func testPortalRevealRefreshesHostedWebViewWithoutFrameDelta() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
let portal = WindowBrowserPortal(window: window)
|
|
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160))
|
|
contentView.addSubview(anchor)
|
|
|
|
let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
portal.bind(webView: webView, to: anchor, visibleInUI: true)
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
advanceAnimations()
|
|
let initialDisplayCount = webView.displayIfNeededCount
|
|
let initialReattachCount = webView.reattachRenderingStateCount
|
|
|
|
portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0)
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
advanceAnimations()
|
|
let hiddenDisplayCount = webView.displayIfNeededCount
|
|
let hiddenReattachCount = webView.reattachRenderingStateCount
|
|
|
|
portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: true, zPriority: 0)
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
advanceAnimations()
|
|
|
|
XCTAssertGreaterThanOrEqual(hiddenDisplayCount, initialDisplayCount)
|
|
XCTAssertEqual(
|
|
hiddenReattachCount,
|
|
initialReattachCount,
|
|
"Hiding a portal-hosted browser should not itself trigger the WebKit reattach path"
|
|
)
|
|
XCTAssertGreaterThan(
|
|
webView.displayIfNeededCount,
|
|
hiddenDisplayCount,
|
|
"Revealing an existing portal-hosted browser should refresh WebKit presentation immediately"
|
|
)
|
|
XCTAssertGreaterThan(
|
|
webView.reattachRenderingStateCount,
|
|
hiddenReattachCount,
|
|
"Revealing an existing portal-hosted browser should trigger the WebKit reattach path"
|
|
)
|
|
}
|
|
|
|
func testVisiblePortalEntryHidesWithoutDetachingDuringTransientAnchorRemovalUntilRebind() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
let portal = WindowBrowserPortal(window: window)
|
|
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160)
|
|
let anchor1 = NSView(frame: anchorFrame)
|
|
contentView.addSubview(anchor1)
|
|
|
|
let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
portal.bind(webView: webView, to: anchor1, visibleInUI: true)
|
|
portal.synchronizeWebViewForAnchor(anchor1)
|
|
advanceAnimations()
|
|
|
|
guard let slot = webView.superview as? WindowBrowserSlotView else {
|
|
XCTFail("Expected browser slot")
|
|
return
|
|
}
|
|
|
|
anchor1.removeFromSuperview()
|
|
portal.synchronizeWebViewForAnchor(anchor1)
|
|
advanceAnimations()
|
|
|
|
XCTAssertTrue(webView.superview === slot, "Visible browser entries should not detach during transient anchor removal")
|
|
XCTAssertTrue(
|
|
slot.isHidden,
|
|
"Transient anchor churn should hide the stale browser slot instead of rendering in the wrong pane"
|
|
)
|
|
XCTAssertEqual(portal.debugEntryCount(), 1)
|
|
|
|
let displayCountBeforeRebind = webView.displayIfNeededCount
|
|
let anchor2 = NSView(frame: anchorFrame)
|
|
contentView.addSubview(anchor2)
|
|
portal.bind(webView: webView, to: anchor2, visibleInUI: true)
|
|
portal.synchronizeWebViewForAnchor(anchor2)
|
|
advanceAnimations()
|
|
|
|
XCTAssertTrue(webView.superview === slot, "Rebinding after transient anchor removal should reuse the existing portal slot")
|
|
XCTAssertFalse(slot.isHidden)
|
|
XCTAssertEqual(portal.debugEntryCount(), 1)
|
|
XCTAssertGreaterThan(
|
|
webView.displayIfNeededCount,
|
|
displayCountBeforeRebind,
|
|
"Anchor rebinds should refresh hosted browser presentation even when geometry is unchanged"
|
|
)
|
|
}
|
|
|
|
func testVisiblePortalEntryStaysVisibleDuringOffWindowAnchorReparentUntilRebind() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
let portal = WindowBrowserPortal(window: window)
|
|
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160)
|
|
let anchor = NSView(frame: anchorFrame)
|
|
contentView.addSubview(anchor)
|
|
|
|
let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
portal.bind(webView: webView, to: anchor, visibleInUI: true)
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
advanceAnimations()
|
|
|
|
guard let slot = webView.superview as? WindowBrowserSlotView else {
|
|
XCTFail("Expected browser slot")
|
|
return
|
|
}
|
|
|
|
let offWindowContainer = NSView(frame: anchorFrame)
|
|
anchor.removeFromSuperview()
|
|
offWindowContainer.addSubview(anchor)
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
advanceAnimations()
|
|
|
|
XCTAssertTrue(
|
|
webView.superview === slot,
|
|
"Off-window anchor reparent should preserve the hosted browser slot during drag churn"
|
|
)
|
|
XCTAssertFalse(
|
|
slot.isHidden,
|
|
"Off-window anchor reparent should keep the visible browser portal alive until the anchor returns"
|
|
)
|
|
XCTAssertEqual(portal.debugEntryCount(), 1)
|
|
|
|
contentView.addSubview(anchor)
|
|
portal.synchronizeWebViewForAnchor(anchor)
|
|
advanceAnimations()
|
|
|
|
XCTAssertTrue(webView.superview === slot, "Rebinding after off-window reparent should reuse the existing portal slot")
|
|
XCTAssertFalse(slot.isHidden)
|
|
XCTAssertEqual(portal.debugEntryCount(), 1)
|
|
}
|
|
|
|
func testRegistryDetachRemovesPortalHostedWebView() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
|
|
contentView.addSubview(anchor)
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
|
|
BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true)
|
|
XCTAssertNotNil(webView.superview)
|
|
|
|
BrowserWindowPortalRegistry.detach(webView: webView)
|
|
XCTAssertNil(webView.superview)
|
|
}
|
|
|
|
func testRegistryHideKeepsPortalHostedWebViewAttachedButHidden() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
|
|
contentView.addSubview(anchor)
|
|
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
|
|
BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true)
|
|
BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
|
|
advanceAnimations()
|
|
|
|
guard let slot = webView.superview as? WindowBrowserSlotView else {
|
|
XCTFail("Expected browser slot")
|
|
return
|
|
}
|
|
XCTAssertFalse(slot.isHidden)
|
|
|
|
BrowserWindowPortalRegistry.hide(webView: webView, source: "unitTest")
|
|
advanceAnimations()
|
|
|
|
XCTAssertTrue(webView.superview === slot, "Hiding should preserve the hosted WKWebView attachment")
|
|
XCTAssertTrue(slot.isHidden, "Hiding should immediately hide the existing portal slot")
|
|
}
|
|
|
|
func testHiddenPortalEntrySurvivesAnchorRemovalUntilWorkspaceRebind() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer { window.orderOut(nil) }
|
|
realizeWindowLayout(window)
|
|
let portal = WindowBrowserPortal(window: window)
|
|
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
|
|
let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160)
|
|
let oldAnchor = NSView(frame: anchorFrame)
|
|
contentView.addSubview(oldAnchor)
|
|
|
|
let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
portal.bind(webView: webView, to: oldAnchor, visibleInUI: true)
|
|
portal.synchronizeWebViewForAnchor(oldAnchor)
|
|
advanceAnimations()
|
|
|
|
guard let slot = webView.superview as? WindowBrowserSlotView else {
|
|
XCTFail("Expected browser slot")
|
|
return
|
|
}
|
|
|
|
portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0)
|
|
portal.synchronizeWebViewForAnchor(oldAnchor)
|
|
advanceAnimations()
|
|
XCTAssertTrue(slot.isHidden, "Workspace handoff should hide the retiring browser before unmount")
|
|
|
|
oldAnchor.removeFromSuperview()
|
|
portal.synchronizeWebViewForAnchor(oldAnchor)
|
|
advanceAnimations()
|
|
|
|
XCTAssertTrue(
|
|
webView.superview === slot,
|
|
"Hidden workspace browsers should stay attached while their SwiftUI anchor is temporarily unmounted"
|
|
)
|
|
XCTAssertTrue(slot.isHidden, "Unmounted hidden workspace browser should remain hidden until rebound")
|
|
XCTAssertEqual(portal.debugEntryCount(), 1, "Workspace handoff should keep the hidden browser portal entry alive")
|
|
|
|
let displayCountBeforeRebind = webView.displayIfNeededCount
|
|
let newAnchor = NSView(frame: anchorFrame)
|
|
contentView.addSubview(newAnchor)
|
|
portal.bind(webView: webView, to: newAnchor, visibleInUI: true)
|
|
portal.synchronizeWebViewForAnchor(newAnchor)
|
|
advanceAnimations()
|
|
|
|
XCTAssertTrue(
|
|
webView.superview === slot,
|
|
"Selecting the workspace again should reuse the existing hidden browser portal slot"
|
|
)
|
|
XCTAssertFalse(slot.isHidden, "Rebinding the workspace browser should reveal the existing portal slot")
|
|
XCTAssertEqual(portal.debugEntryCount(), 1)
|
|
XCTAssertGreaterThan(
|
|
webView.displayIfNeededCount,
|
|
displayCountBeforeRebind,
|
|
"Workspace rebind should refresh the preserved browser without recreating its portal slot"
|
|
)
|
|
}
|
|
}
|