From c2d15b1721153772bae24e129f5d59ebd6074e7e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 17:00:58 -0700 Subject: [PATCH] Deflake Ghostty overlay CI coverage --- .github/workflows/ci.yml | 1 - cmuxTests/TerminalAndGhosttyTests.swift | 213 +++++++++++++----------- 2 files changed, 117 insertions(+), 97 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bdcb5ccb16a..a75b9b0a42d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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}" diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 5120aa46e5f0..707fe1784f2c 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -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 @@ -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( @@ -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 @@ -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 } @@ -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( @@ -3362,14 +3382,10 @@ 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" ) NotificationCenter.default.post(name: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil) @@ -3377,6 +3393,11 @@ final class GhosttySurfaceOverlayTests: XCTestCase { 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, @@ -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") @@ -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")) @@ -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") @@ -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 @@ -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( @@ -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()) @@ -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] @@ -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 } @@ -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)) @@ -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))