From 86fc5e1d89e6f6e48b91f0402d5dd100d276c055 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 14 Jul 2026 09:57:02 -0300 Subject: [PATCH 1/3] fix: stop CLI notify SSH bootstrap test crashing on a socket/file race The test waited on a fixed 5s XCTestExpectation for the mock socket server to observe the SSH bootstrap client closing its connection, then immediately read a log file written by a separate process. Under a full serial CI run, the accept-loop thread's GCD scheduling can legitimately lag behind the subprocess round-trip, so the expectation timeout fired first and the log read threw an uncaught file-not-found error on top of it (the '1 unexpected' failure in CI). Give the expectation CI-safe headroom and poll for the log file actually having its expected content instead of reading it the instant the expectation resolves, so a scheduling delay produces one clear assertion failure instead of a cascading crash-like error. --- .../WorkspaceRemoteConnectionTests.swift | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/programaTests/WorkspaceRemoteConnectionTests.swift b/programaTests/WorkspaceRemoteConnectionTests.swift index 09245e1f..4f17a92b 100644 --- a/programaTests/WorkspaceRemoteConnectionTests.swift +++ b/programaTests/WorkspaceRemoteConnectionTests.swift @@ -1554,6 +1554,25 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { return String(data: data ?? Data("{}".utf8), encoding: .utf8) ?? "{}" } + /// Polls `condition` until it returns true or `timeout` elapses. Same house pattern + /// as the `waitUntil` helpers in TerminalAndGhosttyTests.swift / + /// TerminalControllerSocketSecurityTests.swift: an `XCTNSPredicateExpectation` + /// driven by `XCTWaiter`, so a slow-but-eventual condition (e.g. a background + /// thread's file write finishing under CI scheduling contention) produces a clean, + /// bounded pass instead of a flaky immediate check. + @discardableResult + private func waitUntil( + timeout: TimeInterval = 5.0, + description: String, + _ condition: @escaping () -> Bool + ) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in condition() }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } + @MainActor func testNotifyFallsBackFromStaleCallerWorkspaceAndSurfaceIDs() throws { let cliPath = try bundledCLIPath() @@ -2189,10 +2208,30 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { timeout: 5 ) - wait(for: [foregroundAuthHandled], timeout: 5) + // The accept()-loop thread that fulfills `foregroundAuthHandled` (see + // startMockServer) only observes EOF and calls fulfill() once GCD schedules it — + // under a full serial suite run with heavy CPU contention that can legitimately + // take longer than the subprocess round-trip itself. Give it CI headroom rather + // than a hair-trigger local timeout. + wait(for: [foregroundAuthHandled], timeout: 15) XCTAssertFalse(startupResult.timedOut, startupResult.stderr) XCTAssertEqual(startupResult.status, 0, startupResult.stderr) + // Don't read fakeSSHLog the instant the socket expectation resolves: the log + // content is written by a *separate* process (the fake ssh script's nested + // LocalCommand hop) than the one whose exit this test already waited on above, + // so its write can still be in flight even once the RPC round-trip completed. + // Reading immediately previously turned a scheduling delay into an uncaught + // "file not found" error (XCTest reports this as an "unexpected" failure, + // distinct from — and more confusing than — a plain assertion failure) instead + // of a clear, bounded diagnostic. Poll for the real completion signal we + // actually depend on: the log file existing with its expected line count. + let logIsReady = waitUntil(timeout: 15, description: "fake ssh log to record at least 2 invocations") { + guard let contents = try? String(contentsOf: fakeSSHLog, encoding: .utf8) else { return false } + return contents.split(separator: "\n").count >= 2 + } + XCTAssertTrue(logIsReady, "Expected fake ssh log at \(fakeSSHLog.path) to record at least 2 invocations in time") + let logLines = try String(contentsOf: fakeSSHLog, encoding: .utf8) .split(separator: "\n") .map(String.init) From 082bd083a8e07a60c9eb0683943495c78e254e38 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 14 Jul 2026 09:57:15 -0300 Subject: [PATCH 2/3] test: harden timing-sensitive tests against slow CI runners These tests pass locally on fast machines but flake on slower shared CI runners (macos-26 GitHub runners), where fixed-duration waits (single RunLoop spins, tight XCTestExpectation timeouts) starve under a heavily contended scheduler running the full serial suite. Replace fixed sleeps with condition-based polling where a real completion signal exists, and bump fixed timeouts to generous CI-safe ceilings (matching this repo's existing hardening precedent) where the wait is on a real subprocess or background-queue round trip. Fast machines still finish immediately since the poll returns as soon as the condition is true; only a genuinely slow runner needs the extra headroom. Covers: - AppDelegateShortcutRoutingTests: poll for focus repair after sendEvent instead of a single 0.05s spin - BrowserDeveloperToolsVisibilityPersistenceTests: poll for the queued hide transition to actually settle instead of a fixed 2s spin - GhosttySurfaceOverlayTests: poll for overlay mount/dismiss instead of fixed RunLoop spins (drop-zone hide, find-overlay mount and dismiss) - NotificationDockBadgeTests: give the real subprocess spawn and the main-queue drain more CI headroom - TabManagerPullRequestProbeTests: give the git subprocess + probe round trip the same headroom already used by its sibling test - WorkspaceTerminalFocusRecoveryTests: give the focus-reassert wait more than a hair-trigger 1s ceiling --- .../AppDelegateShortcutRoutingTests.swift | 12 +++++++++- programaTests/BrowserConfigTests.swift | 24 +++++++++++++++---- .../NotificationAndMenuBarTests.swift | 11 +++++++-- programaTests/TabManagerUnitTests.swift | 17 +++++++++---- programaTests/TerminalAndGhosttyTests.swift | 17 +++++++++---- programaTests/WorkspaceUnitTests.swift | 5 +++- 6 files changed, 70 insertions(+), 16 deletions(-) diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index 812c6cb5..00d6af2a 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -4397,7 +4397,17 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } window.sendEvent(keyDown) - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + // The focus-repair path triggered by sendEvent runs asynchronously (see the + // searchFieldDeadline poll above for the same class of flake). A single fixed + // 0.05s spin can land before it completes under a full serial suite run with + // CPU contention from hundreds of prior tests — poll instead. + let repairDeadline = Date(timeIntervalSinceNow: 2.0) + while Date() < repairDeadline { + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + if firstResponderOwnsTextField(window.firstResponder, textField: searchField) { + break + } + } XCTAssertTrue( firstResponderOwnsTextField(window.firstResponder, textField: searchField), diff --git a/programaTests/BrowserConfigTests.swift b/programaTests/BrowserConfigTests.swift index b83ef880..41ecc5f5 100644 --- a/programaTests/BrowserConfigTests.swift +++ b/programaTests/BrowserConfigTests.swift @@ -2041,10 +2041,24 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { return nil } - private func waitForDeveloperToolsTransitions() { + private func waitForDeveloperToolsTransitions( + timeout: TimeInterval = 2.0, + until condition: (() -> Bool)? = nil + ) { // Give real headroom under a full serial suite run, where the main queue can - // carry a genuine backlog from other tests' pending async work. - RunLoop.current.run(until: Date().addingTimeInterval(2.0)) + // carry a genuine backlog from other tests' pending async work. When a + // completion condition is supplied, poll for it directly instead of trusting + // a fixed spin alone — a queued transition (e.g. toggleDeveloperTools' coalesced + // hide) can still be in flight when the spin ends under CI contention. + guard let condition else { + RunLoop.current.run(until: Date().addingTimeInterval(timeout)) + return + } + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + if condition() { return } + } } private func findWindowBrowserSlotView(in root: NSView) -> WindowBrowserSlotView? { @@ -2192,7 +2206,9 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertEqual(inspector.showCount, 1) XCTAssertEqual(inspector.closeCount, 0) - waitForDeveloperToolsTransitions() + waitForDeveloperToolsTransitions(timeout: 10.0) { + inspector.closeCount == 1 + } XCTAssertFalse(panel.isDeveloperToolsVisible()) XCTAssertEqual(inspector.showCount, 1) diff --git a/programaTests/NotificationAndMenuBarTests.swift b/programaTests/NotificationAndMenuBarTests.swift index c53fbf12..0f04e2bb 100644 --- a/programaTests/NotificationAndMenuBarTests.swift +++ b/programaTests/NotificationAndMenuBarTests.swift @@ -489,7 +489,10 @@ final class NotificationDockBadgeTests: XCTestCase { }, object: NSObject() ) - XCTAssertEqual(XCTWaiter().wait(for: [commandFinished], timeout: 2.0), .completed) + // This spawns a real shell subprocess to write commandOutputURL. Under a full + // serial suite run with CPU contention from hundreds of prior tests, process + // scheduling can legitimately take longer than a couple seconds. + XCTAssertEqual(XCTWaiter().wait(for: [commandFinished], timeout: 15.0), .completed) XCTAssertTrue(deliveredNotificationIDs.isEmpty) let output = try String(contentsOf: commandOutputURL, encoding: .utf8) @@ -577,9 +580,13 @@ final class NotificationDockBadgeTests: XCTestCase { ) store.promptToEnableNotificationsForTesting() + // See the identical comment on this pattern in + // testNotificationSettingsPromptRetriesUntilWindowExists below: under a full + // serial suite run the main queue can carry a real backlog from other tests' + // pending async work, so give this more than the bare minimum headroom. let drained = expectation(description: "main queue drained") DispatchQueue.main.async { drained.fulfill() } - wait(for: [drained], timeout: 1.0) + wait(for: [drained], timeout: 5.0) XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1) XCTAssertEqual(alertSpy.runModalCallCount, 0) diff --git a/programaTests/TabManagerUnitTests.swift b/programaTests/TabManagerUnitTests.swift index c83a17a7..495cc969 100644 --- a/programaTests/TabManagerUnitTests.swift +++ b/programaTests/TabManagerUnitTests.swift @@ -526,7 +526,10 @@ final class TabManagerPullRequestProbeTests: XCTestCase { XCTAssertNotEqual(manager.selectedTabId, backgroundWorkspace.id) XCTAssertTrue( - waitForCondition { + // Real git subprocess + GitMetadataProber round trip, not a fixed dispatch + // delay — needs the same headroom as testRemoteSplitSkipsInitialGitMetadataProbe + // below under a full serial suite run's CPU contention. + waitForCondition(timeout: 12.0) { backgroundWorkspace.panelGitBranches[backgroundPanelId]?.branch == "main" } ) @@ -570,7 +573,9 @@ final class TabManagerPullRequestProbeTests: XCTestCase { manager.refreshTrackedWorkspaceGitMetadataForTesting() XCTAssertTrue( - waitForCondition { + // See timeout comment on testInheritedBackgroundWorkspaceFetchesGitBranchWithoutSelection + // above: real git subprocess + probe round trip needs headroom under CI load. + waitForCondition(timeout: 12.0) { workspace.panelGitBranches[panelId]?.branch == "feature/sidebar-live-refresh" } ) @@ -613,7 +618,9 @@ final class TabManagerPullRequestProbeTests: XCTestCase { manager.refreshTrackedWorkspaceGitMetadataForTesting() XCTAssertTrue( - waitForCondition { + // See timeout comment on testInheritedBackgroundWorkspaceFetchesGitBranchWithoutSelection + // above: real git subprocess + probe round trip needs headroom under CI load. + waitForCondition(timeout: 12.0) { workspace.panelGitBranches[panelId]?.branch == "main" } ) @@ -733,7 +740,9 @@ final class TabManagerPullRequestProbeTests: XCTestCase { manager.refreshTrackedWorkspaceGitMetadataForTesting() XCTAssertTrue( - waitForCondition { + // See timeout comment on testInheritedBackgroundWorkspaceFetchesGitBranchWithoutSelection + // above: real git subprocess + probe round trip needs headroom under CI load. + waitForCondition(timeout: 12.0) { workspace.panelGitBranches[panelId]?.branch == "main" && workspace.panelPullRequests[panelId] == nil } diff --git a/programaTests/TerminalAndGhosttyTests.swift b/programaTests/TerminalAndGhosttyTests.swift index 74df532e..3cf88000 100644 --- a/programaTests/TerminalAndGhosttyTests.swift +++ b/programaTests/TerminalAndGhosttyTests.swift @@ -2612,12 +2612,17 @@ final class GhosttySurfaceOverlayTests: XCTestCase { contentView.layoutSubtreeIfNeeded() hostedView.setVisibleInUI(true) hostedView.setActive(true) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) let searchState = TerminalSurface.SearchState(needle: "") surface.searchState = searchState hostedView.setSearchOverlay(searchState: searchState) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + // Poll for the deferred mount instead of trusting one fixed spin — see the + // identical rationale on the other waitUntil("search overlay to mount") call + // sites in this class. + waitUntil(timeout: 3.0, description: "find overlay to mount") { + self.findEditableTextField(in: hostedView) != nil + } guard let searchField = findEditableTextField(in: hostedView) else { XCTFail("Expected mounted find text field") @@ -2661,7 +2666,9 @@ final class GhosttySurfaceOverlayTests: XCTestCase { NSApp.sendEvent(escapeKeyDown) NSApp.sendEvent(escapeKeyUp) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitUntil(timeout: 3.0, description: "find overlay to dismiss after Escape") { + surface.searchState == nil + } XCTAssertNil(surface.searchState, "Escape should dismiss find overlay when search text is empty") XCTAssertEqual( @@ -2730,7 +2737,9 @@ final class GhosttySurfaceOverlayTests: XCTestCase { XCTAssertEqual(state.frame.size.height, 112, accuracy: 0.5) hostedView.setDropZoneOverlay(zone: nil) - RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + waitUntil(timeout: 3.0, description: "drop zone overlay to hide") { + hostedView.debugDropZoneOverlayState().isHidden + } XCTAssertTrue(hostedView.debugDropZoneOverlayState().isHidden) } diff --git a/programaTests/WorkspaceUnitTests.swift b/programaTests/WorkspaceUnitTests.swift index caa04396..da383859 100644 --- a/programaTests/WorkspaceUnitTests.swift +++ b/programaTests/WorkspaceUnitTests.swift @@ -2411,13 +2411,16 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { "Expected leftSurfaceView to hold first responder stably before exercising clearSuppressReparentFocus" ) leftPanel.hostedView.clearSuppressReparentFocus() + // See the stable-streak comment above: under a full serial suite run the main + // queue backlog varies unpredictably, so give the focus-reassert path more than + // a hair-trigger 1s ceiling. let focusRecovered = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in leftPanel.surface.debugDesiredFocusState() }, object: NSObject() ) - wait(for: [focusRecovered], timeout: 1.0) + wait(for: [focusRecovered], timeout: 10.0) #else throw XCTSkip("Debug-only regression test") #endif From 68517eff488fa824b7b0b56ff2d1a645731923cb Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 14 Jul 2026 12:22:02 -0300 Subject: [PATCH 3/3] test: scale CI wait budgets and stub gh for pull-request probe tests The 8 CI-only failures on loaded macos-26 runners are subprocess/ scheduling starvation: GitMetadataProber's initialWorkspaceGitMetadata Snapshot runs git subprocesses and, for non-main/master branches, a synchronous gh call with a 5.0s timeout before the snapshot is applied. On CI, gh is unauthenticated and can block to the full timeout, and git subprocesses contend with the rest of the serial suite. Scale wait budgets by 4x under CI (GitHub Actions always sets CI=true; local runs stay at 1x): - TabManagerUnitTests.swift: shared waitForCondition helper now scales its timeout internally, covering all 5 TabManagerPullRequestProbeTests failures (default 3.0s and explicit 12.0s call sites alike). - AppDelegateShortcutRoutingTests.swift: scale the two fixed poll deadlines in testWindowSendEventRepairsFocusedTerminalSearchTyping AfterResponderDrift. - WorkspaceRemoteConnectionTests.swift: scale the subprocess/mock-socket timeouts in testSSHBootstrapStartupCommandPassesRemoteInstallScript AsSingleSSHCommand. - TerminalAndGhosttyTests.swift: replace the fixed 0.3s RunLoop spin in testScheduledExternalGeometrySyncWaitsForQueuedLayoutShift with two condition-polling loops (layout shift, then the separately-scheduled external geometry sync settling), each CI-scaled. Also stub gh on PATH for the duration of each TabManagerPullRequestProbeTests test (setUp/tearDown), so these fixtures never depend on a real gh binary or network access, mirroring the existing PATH-stub pattern used by shell-integration tests. Verified the stub actually intercepts a real gh pr list call (added and removed a temporary probe test) before landing it. No production code changed; no assertions weakened. --- .../AppDelegateShortcutRoutingTests.swift | 6 +- programaTests/TabManagerUnitTests.swift | 82 ++++++++++++++++++- programaTests/TerminalAndGhosttyTests.swift | 29 ++++++- .../WorkspaceRemoteConnectionTests.swift | 15 ++-- 4 files changed, 121 insertions(+), 11 deletions(-) diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index 00d6af2a..b3e7b1fd 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -4358,7 +4358,9 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { // apart, since the SwiftUI-hosted field may not be laid out yet on the first // attempt). A single fixed 0.05s spin can land before that loop finishes, // causing rare flakes. Poll instead of assuming one spin is enough. - let searchFieldDeadline = Date(timeIntervalSinceNow: 2.0) + // See `ciScale` (TabManagerUnitTests.swift): scale the poll deadline under CI, + // where this retry loop can legitimately need longer than a fast local machine. + let searchFieldDeadline = Date(timeIntervalSinceNow: 2.0 * ciScale) var searchField: NSTextField? while Date() < searchFieldDeadline { RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) @@ -4401,7 +4403,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { // searchFieldDeadline poll above for the same class of flake). A single fixed // 0.05s spin can land before it completes under a full serial suite run with // CPU contention from hundreds of prior tests — poll instead. - let repairDeadline = Date(timeIntervalSinceNow: 2.0) + let repairDeadline = Date(timeIntervalSinceNow: 2.0 * ciScale) while Date() < repairDeadline { RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) if firstResponderOwnsTextField(window.firstResponder, textField: searchField) { diff --git a/programaTests/TabManagerUnitTests.swift b/programaTests/TabManagerUnitTests.swift index 495cc969..c0a6e072 100644 --- a/programaTests/TabManagerUnitTests.swift +++ b/programaTests/TabManagerUnitTests.swift @@ -15,6 +15,12 @@ import UserNotifications let lastSurfaceCloseShortcutDefaultsKey = "closeWorkspaceOnLastSurfaceShortcut" +// GitHub Actions always sets CI=true, and shared macos-26 runners can be under enough +// scheduler/subprocess contention (see GitMetadataProber's git/gh probing) that fixed +// local-machine timeouts flake even when generously sized. Scale wait budgets up under +// CI only, so local runs stay fast. +let ciScale: Double = ProcessInfo.processInfo.environment["CI"] != nil ? 4.0 : 1.0 + func drainMainQueue() { // A fixed 1s wait isn't reliable headroom for this main-queue turn under a full // serial suite run, where the queue can carry a real backlog from hundreds of prior @@ -38,8 +44,10 @@ private func waitForCondition( return true } + // Scale the caller's timeout (default or explicit) under CI — see `ciScale`. + let scaledTimeout = timeout * ciScale let expectation = XCTestExpectation(description: "wait for condition") - let deadline = Date().addingTimeInterval(timeout) + let deadline = Date().addingTimeInterval(scaledTimeout) func poll() { if condition() { @@ -56,7 +64,7 @@ private func waitForCondition( poll() } - let result = XCTWaiter().wait(for: [expectation], timeout: timeout + pollInterval + 0.1) + let result = XCTWaiter().wait(for: [expectation], timeout: scaledTimeout + pollInterval + 0.1) if result != .completed { XCTFail("Timed out waiting for condition", file: file, line: line) return false @@ -304,6 +312,76 @@ final class TabManagerWorkspaceOwnershipTests: XCTestCase { @MainActor final class TabManagerPullRequestProbeTests: XCTestCase { + // GitMetadataProber shells out to the ambient `gh` CLI to resolve pull-request + // metadata for any TabManager-created workspace whose directory resolves to a real + // git repo on a non-main/master branch (see + // GitMetadataProber.workspacePullRequestSnapshot). On CI that directory can be this + // very checkout (a real repo, checked out to a non-main PR branch), so a + // `TabManager()`'s default workspace can trigger a real, unauthenticated `gh pr + // list`/`gh pr checks` call that blocks for up to its 5s timeout per repo remote — + // on the same per-instance serial probe queue these tests are themselves waiting + // on. Stub `gh` on PATH for the duration of each test so these fixtures never + // depend on a real `gh` binary or network access, mirroring the PATH-stub pattern + // already used for shell-integration tests (e.g. GhosttyConfigTests' fake + // `tmux`/`programa` binaries). + private var originalPATHForGHStub: String? + private var ghStubBinDirectory: URL? + + override func setUp() { + super.setUp() + let fileManager = FileManager.default + let currentPath = ProcessInfo.processInfo.environment["PATH"] ?? "" + originalPATHForGHStub = currentPath + + let binDir = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-gh-stub-\(UUID().uuidString)", + isDirectory: true + ) + do { + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + let ghStub = binDir.appendingPathComponent("gh", isDirectory: false) + // Deterministic, network-free stand-in for the two `gh` invocations + // GitMetadataProber makes (`pr list`, `pr checks`). Both fast-exit with an + // empty JSON array, which the prober decodes as "no pull request found" — + // exactly right for these branch/metadata-focused tests, none of which + // assert on actual PR data. + let script = """ + #!/bin/sh + case "$1 $2" in + "pr list") + echo '[]' + exit 0 + ;; + "pr checks") + echo '[]' + exit 0 + ;; + *) + exit 1 + ;; + esac + """ + try script.write(to: ghStub, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: ghStub.path) + ghStubBinDirectory = binDir + setenv("PATH", "\(binDir.path):\(currentPath)", 1) + } catch { + XCTFail("Failed to install gh stub: \(error)") + } + } + + override func tearDown() { + if let originalPATHForGHStub { + setenv("PATH", originalPATHForGHStub, 1) + } + if let ghStubBinDirectory { + try? FileManager.default.removeItem(at: ghStubBinDirectory) + } + originalPATHForGHStub = nil + ghStubBinDirectory = nil + super.tearDown() + } + func testGitHubRepositorySlugsPrioritizeUpstreamThenOriginAndDeduplicate() { let output = """ origin https://github.com/austinwang/cmux.git (fetch) diff --git a/programaTests/TerminalAndGhosttyTests.swift b/programaTests/TerminalAndGhosttyTests.swift index 3cf88000..a50c9574 100644 --- a/programaTests/TerminalAndGhosttyTests.swift +++ b/programaTests/TerminalAndGhosttyTests.swift @@ -3482,7 +3482,18 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { window.displayIfNeeded() } - RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + // The queued layout shift (posted via DispatchQueue.main.async above) needs a + // main-run-loop turn to execute. A single fixed 0.3s spin can land before it + // has settled under a full serial suite run's CPU contention — poll for the + // real completion signal (the anchor frame actually reflecting the shift) + // instead of assuming one spin is enough. See `ciScale` (TabManagerUnitTests.swift). + let layoutShiftDeadline = Date(timeIntervalSinceNow: 0.3 * ciScale) + while Date() < layoutShiftDeadline { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.02)) + if anchor.convert(anchor.bounds, to: nil).minX > originalAnchorFrameInWindow.minX + 1 { + break + } + } let shiftedAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) XCTAssertGreaterThan( @@ -3503,6 +3514,22 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { x: (originalAnchorFrameInWindow.maxX + shiftedAnchorFrameInWindow.maxX) / 2, y: shiftedAnchorFrameInWindow.midY ) + + // The layout shift settling (above) and the *separately* scheduled external + // geometry sync racing against it are two independent async operations — the + // anchor moving doesn't mean the queued sync has also caught up and re-bound + // the portal to the new position yet. Poll for that actual completion signal + // (the hit-test state the assertions below check) rather than checking it once + // immediately after only the first operation has been confirmed. + let externalSyncDeadline = Date(timeIntervalSinceNow: 0.3 * ciScale) + while Date() < externalSyncDeadline { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.02)) + if TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredStaleWindowPoint, in: window) == nil, + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window) != nil { + break + } + } + XCTAssertNil( TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredStaleWindowPoint, in: window), "The queued external sync should wait until the later layout shift settles, clearing the stale portal location" diff --git a/programaTests/WorkspaceRemoteConnectionTests.swift b/programaTests/WorkspaceRemoteConnectionTests.swift index 4f17a92b..5b399a20 100644 --- a/programaTests/WorkspaceRemoteConnectionTests.swift +++ b/programaTests/WorkspaceRemoteConnectionTests.swift @@ -2112,10 +2112,13 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { "cmux-macmini", ], environment: environment, - timeout: 5 + // See `ciScale` (TabManagerUnitTests.swift): scale this subprocess/mock-socket + // round trip under CI, where it can legitimately take longer than on a fast + // local machine. + timeout: 5 * ciScale ) - wait(for: [serverHandled], timeout: 5) + wait(for: [serverHandled], timeout: 5 * ciScale) XCTAssertFalse(result.timedOut, result.stderr) XCTAssertEqual(result.status, 0, result.stderr) @@ -2205,15 +2208,15 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { executablePath: "/bin/sh", arguments: ["-c", initialCommand], environment: startupEnvironment, - timeout: 5 + timeout: 5 * ciScale ) // The accept()-loop thread that fulfills `foregroundAuthHandled` (see // startMockServer) only observes EOF and calls fulfill() once GCD schedules it — // under a full serial suite run with heavy CPU contention that can legitimately // take longer than the subprocess round-trip itself. Give it CI headroom rather - // than a hair-trigger local timeout. - wait(for: [foregroundAuthHandled], timeout: 15) + // than a hair-trigger local timeout. See `ciScale` (TabManagerUnitTests.swift). + wait(for: [foregroundAuthHandled], timeout: 15 * ciScale) XCTAssertFalse(startupResult.timedOut, startupResult.stderr) XCTAssertEqual(startupResult.status, 0, startupResult.stderr) @@ -2226,7 +2229,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { // distinct from — and more confusing than — a plain assertion failure) instead // of a clear, bounded diagnostic. Poll for the real completion signal we // actually depend on: the log file existing with its expected line count. - let logIsReady = waitUntil(timeout: 15, description: "fake ssh log to record at least 2 invocations") { + let logIsReady = waitUntil(timeout: 15 * ciScale, description: "fake ssh log to record at least 2 invocations") { guard let contents = try? String(contentsOf: fakeSSHLog, encoding: .utf8) else { return false } return contents.split(separator: "\n").count >= 2 }