Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,6 @@ jobs:
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-skip-testing:cmuxTests/FileExplorerStoreTests/testRemoteWorkspaceRootRequestResolvesSSHHomeInsteadOfKeepingLocalPath \
-skip-testing:cmuxTests/GhosttySurfaceOverlayTests \
test 2>&1 &
local xcodebuild_pid=$!
local timeout_seconds="${CMUX_UNIT_TEST_TIMEOUT_SECONDS:-900}"
Expand Down
213 changes: 117 additions & 96 deletions cmuxTests/TerminalAndGhosttyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3065,6 +3065,8 @@ final class WindowTerminalHostViewTests: XCTestCase {

@MainActor
final class GhosttySurfaceOverlayTests: XCTestCase {
private var surfacesToRelease: [TerminalSurface] = []

private final class ScrollProbeSurfaceView: GhosttyNSView {
private(set) var scrollWheelCallCount = 0

Expand All @@ -3087,6 +3089,10 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
}
}

private final class KeyStatusTestWindow: NSWindow {
override var isKeyWindow: Bool { true }
}

private func makeScrollbar(total: UInt64, offset: UInt64, len: UInt64) -> GhosttyScrollbar {
GhosttyScrollbar(
c: ghostty_action_scrollbar_s(
Expand All @@ -3097,6 +3103,28 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
)
}

override func tearDown() {
GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil
for surface in surfacesToRelease.reversed() {
surface.releaseSurfaceForTesting()
}
surfacesToRelease.removeAll()
super.tearDown()
}

private func makeTrackedTerminalSurface(
tabId: UUID = UUID()
) -> TerminalSurface {
let surface = TerminalSurface(
tabId: tabId,
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
surfacesToRelease.append(surface)
return surface
}

private func findEditableTextField(in view: NSView) -> NSTextField? {
if let field = view as? NSTextField, field.isEditable {
return field
Expand Down Expand Up @@ -3129,17 +3157,14 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
line: UInt = #line,
_ condition: @escaping () -> Bool
) -> Bool {
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
if Thread.isMainThread {
return condition()
}
return DispatchQueue.main.sync(execute: condition)
},
object: NSObject()
)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
guard result == .completed else {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() {
return true
}
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
}
guard condition() else {
XCTFail("Timed out waiting for \(description)", file: file, line: line)
return false
}
Expand Down Expand Up @@ -3296,12 +3321,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
}

func testPreferredScrollerStyleChangeRestoresOverlayScrollbarWidth() {
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let surface = makeTrackedTerminalSurface()
let hostedView = surface.hostedView

let window = NSWindow(
Expand Down Expand Up @@ -3362,21 +3382,22 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
scrollView.scrollerStyle = .legacy
scrollView.layoutSubtreeIfNeeded()
let legacyContentWidth = scrollView.contentSize.width
XCTAssertLessThan(
legacyContentWidth,
initialContentWidth,
"Legacy scrollbars should reserve width in the scroll view content area"
)
XCTAssertEqual(scrollView.scrollerStyle, .legacy)
assertPendingSurfaceWidth(
initialSurfaceSize.width,
"Changing the scroll view style alone should leave the terminal grid stale until the scroller-style observer runs"
"Changing the scroll view style alone should leave the terminal grid unchanged until the scroller-style observer runs"
)
Comment on lines +3385 to 3389

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The assertion that verifies the behavior of legacy scrollbars (that they reduce the scroll view's content width) was replaced with a trivial precondition check: XCTAssertEqual(scrollView.scrollerStyle, .legacy) only confirms that the assignment took effect, not that the terminal content area was actually affected. If the scrollerStyle-to-content-width mapping regresses, this assertion won't catch it. The assertion below (restoredContentWidth >= legacyContentWidth) covers the recovery direction but not the initial contraction. If the flake was caused by the assertion failing on headless CI runners that report zero-size scroll views, a better deflake guard would be to skip the assertion when initialContentWidth == 0 rather than dropping the check entirely.

Suggested change
XCTAssertEqual(scrollView.scrollerStyle, .legacy)
assertPendingSurfaceWidth(
initialSurfaceSize.width,
"Changing the scroll view style alone should leave the terminal grid stale until the scroller-style observer runs"
"Changing the scroll view style alone should leave the terminal grid unchanged until the scroller-style observer runs"
)
XCTAssertEqual(scrollView.scrollerStyle, .legacy)
if initialContentWidth > 0 {
XCTAssertLessThan(
legacyContentWidth,
initialContentWidth,
"Legacy scrollbars should reserve width in the scroll view content area"
)
}
assertPendingSurfaceWidth(
initialSurfaceSize.width,
"Changing the scroll view style alone should leave the terminal grid unchanged until the scroller-style observer runs"
)


NotificationCenter.default.post(name: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil)
RunLoop.current.run(until: Date().addingTimeInterval(0.05))

let restoredContentWidth = scrollView.contentSize.width
XCTAssertEqual(scrollView.scrollerStyle, .overlay)
XCTAssertGreaterThanOrEqual(
restoredContentWidth,
legacyContentWidth,
"Preferred scroller style changes should not shrink terminal content when overlay scrollbars return"
)
XCTAssertEqual(
restoredContentWidth,
initialContentWidth,
Expand Down Expand Up @@ -3434,13 +3455,29 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
}

func testSearchOverlayMountsAndUnmountsWithSearchState() {
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let surface = makeTrackedTerminalSurface()
let hostedView = surface.hostedView
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }

guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
hostedView.frame = contentView.bounds
hostedView.autoresizingMask = [.width, .height]
contentView.addSubview(hostedView)

window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
contentView.layoutSubtreeIfNeeded()
hostedView.layoutSubtreeIfNeeded()

XCTAssertFalse(hostedView.debugHasSearchOverlay())

let searchState = TerminalSurface.SearchState(needle: "example")
Expand All @@ -3458,12 +3495,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
}

func testRapidSearchOverlayToggleDoesNotLeaveStaleOverlayMounted() {
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let surface = makeTrackedTerminalSurface()
let hostedView = surface.hostedView

hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "example"))
Expand All @@ -3477,21 +3509,36 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
}

func testSearchOverlayFocusesSearchFieldAfterDeferredAttach() {
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let hostedView = surface.hostedView
let previousAppDelegate = AppDelegate.shared
let appDelegate = previousAppDelegate ?? AppDelegate()
let originalTabManager = appDelegate.tabManager
let manager = TabManager()
let windowId = appDelegate.registerMainWindowContextForTesting(tabManager: manager)
AppDelegate.shared = appDelegate
appDelegate.tabManager = manager

let window = NSWindow(
let window = KeyStatusTestWindow(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
defer {
appDelegate.unregisterMainWindowContextForTesting(windowId: windowId)
appDelegate.tabManager = originalTabManager
AppDelegate.shared = previousAppDelegate
window.orderOut(nil)
}

guard let workspace = manager.selectedWorkspace,
let terminalPanel = workspace.focusedTerminalPanel else {
XCTFail("Expected initial focused terminal panel")
return
}

let surface = terminalPanel.surface
let hostedView = terminalPanel.hostedView
surfacesToRelease.append(surface)

guard let contentView = window.contentView else {
XCTFail("Expected content view")
Expand All @@ -3510,26 +3557,22 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
let searchState = TerminalSurface.SearchState(needle: "")
surface.searchState = searchState
hostedView.setSearchOverlay(searchState: searchState)
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
waitUntil(description: "search overlay to mount and expose field") {
self.findEditableTextField(in: hostedView) != nil
}

guard let searchField = findEditableTextField(in: hostedView) else {
XCTFail("Expected mounted find text field")
return
}

XCTAssertTrue(
firstResponderOwnsTextField(window.firstResponder, textField: searchField),
"Deferred search overlay attach should still move focus into the find field"
)
waitUntil(description: "search field to become first responder") {
self.firstResponderOwnsTextField(window.firstResponder, textField: searchField)
}
}

func testStartOrFocusTerminalSearchReusesExistingSearchState() {
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let surface = makeTrackedTerminalSurface()
let existingSearchState = TerminalSurface.SearchState(needle: "existing")
surface.searchState = existingSearchState

Expand All @@ -3551,12 +3594,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
func testEscapeDismissingFindOverlayDoesNotLeakEscapeKeyUpToTerminal() {
_ = NSApplication.shared

let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let surface = makeTrackedTerminalSurface()
let hostedView = surface.hostedView

let window = NSWindow(
Expand Down Expand Up @@ -3644,12 +3682,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase {

@MainActor
func testKeyboardCopyModeIndicatorMountsAndUnmounts() {
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let surface = makeTrackedTerminalSurface()
let hostedView = surface.hostedView
XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator())

Expand Down Expand Up @@ -3706,12 +3739,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
return
}

let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let surface = makeTrackedTerminalSurface()
let hostedView = surface.hostedView
hostedView.frame = contentView.bounds
hostedView.autoresizingMask = [.width, .height]
Expand All @@ -3737,23 +3765,26 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
func testSearchOverlayMountDoesNotRetainTerminalSurface() {
weak var weakSurface: TerminalSurface?

let hostedView: GhosttySurfaceScrollView = {
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
weakSurface = surface
let hostedView = surface.hostedView
var surface: TerminalSurface? = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
weakSurface = surface
guard let hostedView = surface?.hostedView else {
XCTFail("Expected hosted terminal view")
return
}
hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "retain-check"))
return hostedView
}()

waitUntil(description: "search overlay to mount") {
hostedView.debugHasSearchOverlay()
}
XCTAssertTrue(hostedView.debugHasSearchOverlay())

surface?.releaseSurfaceForTesting()
surface = nil
waitUntil(description: "terminal surface to deallocate after search overlay mount") {
weakSurface == nil
}
Expand All @@ -3780,12 +3811,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
contentView.addSubview(anchorA)
contentView.addSubview(anchorB)

let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let surface = makeTrackedTerminalSurface()
let hostedView = surface.hostedView
hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "split"))
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
Expand Down Expand Up @@ -3819,12 +3845,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
let anchor = NSView(frame: NSRect(x: 40, y: 40, width: 220, height: 160))
contentView.addSubview(anchor)

let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let surface = makeTrackedTerminalSurface()
let hostedView = surface.hostedView
hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "workspace"))
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
Expand Down
Loading