@@ -15,6 +15,12 @@ import UserNotifications
1515
1616let lastSurfaceCloseShortcutDefaultsKey = " closeWorkspaceOnLastSurfaceShortcut "
1717
18+ // GitHub Actions always sets CI=true, and shared macos-26 runners can be under enough
19+ // scheduler/subprocess contention (see GitMetadataProber's git/gh probing) that fixed
20+ // local-machine timeouts flake even when generously sized. Scale wait budgets up under
21+ // CI only, so local runs stay fast.
22+ let ciScale : Double = ProcessInfo . processInfo. environment [ " CI " ] != nil ? 4.0 : 1.0
23+
1824func drainMainQueue( ) {
1925 // A fixed 1s wait isn't reliable headroom for this main-queue turn under a full
2026 // serial suite run, where the queue can carry a real backlog from hundreds of prior
@@ -38,8 +44,10 @@ private func waitForCondition(
3844 return true
3945 }
4046
47+ // Scale the caller's timeout (default or explicit) under CI — see `ciScale`.
48+ let scaledTimeout = timeout * ciScale
4149 let expectation = XCTestExpectation ( description: " wait for condition " )
42- let deadline = Date ( ) . addingTimeInterval ( timeout )
50+ let deadline = Date ( ) . addingTimeInterval ( scaledTimeout )
4351
4452 func poll( ) {
4553 if condition ( ) {
@@ -56,7 +64,7 @@ private func waitForCondition(
5664 poll ( )
5765 }
5866
59- let result = XCTWaiter ( ) . wait ( for: [ expectation] , timeout: timeout + pollInterval + 0.1 )
67+ let result = XCTWaiter ( ) . wait ( for: [ expectation] , timeout: scaledTimeout + pollInterval + 0.1 )
6068 if result != . completed {
6169 XCTFail ( " Timed out waiting for condition " , file: file, line: line)
6270 return false
@@ -304,6 +312,76 @@ final class TabManagerWorkspaceOwnershipTests: XCTestCase {
304312
305313@MainActor
306314final class TabManagerPullRequestProbeTests : XCTestCase {
315+ // GitMetadataProber shells out to the ambient `gh` CLI to resolve pull-request
316+ // metadata for any TabManager-created workspace whose directory resolves to a real
317+ // git repo on a non-main/master branch (see
318+ // GitMetadataProber.workspacePullRequestSnapshot). On CI that directory can be this
319+ // very checkout (a real repo, checked out to a non-main PR branch), so a
320+ // `TabManager()`'s default workspace can trigger a real, unauthenticated `gh pr
321+ // list`/`gh pr checks` call that blocks for up to its 5s timeout per repo remote —
322+ // on the same per-instance serial probe queue these tests are themselves waiting
323+ // on. Stub `gh` on PATH for the duration of each test so these fixtures never
324+ // depend on a real `gh` binary or network access, mirroring the PATH-stub pattern
325+ // already used for shell-integration tests (e.g. GhosttyConfigTests' fake
326+ // `tmux`/`programa` binaries).
327+ private var originalPATHForGHStub : String ?
328+ private var ghStubBinDirectory : URL ?
329+
330+ override func setUp( ) {
331+ super. setUp ( )
332+ let fileManager = FileManager . default
333+ let currentPath = ProcessInfo . processInfo. environment [ " PATH " ] ?? " "
334+ originalPATHForGHStub = currentPath
335+
336+ let binDir = fileManager. temporaryDirectory. appendingPathComponent (
337+ " cmux-gh-stub- \( UUID ( ) . uuidString) " ,
338+ isDirectory: true
339+ )
340+ do {
341+ try fileManager. createDirectory ( at: binDir, withIntermediateDirectories: true )
342+ let ghStub = binDir. appendingPathComponent ( " gh " , isDirectory: false )
343+ // Deterministic, network-free stand-in for the two `gh` invocations
344+ // GitMetadataProber makes (`pr list`, `pr checks`). Both fast-exit with an
345+ // empty JSON array, which the prober decodes as "no pull request found" —
346+ // exactly right for these branch/metadata-focused tests, none of which
347+ // assert on actual PR data.
348+ let script = """
349+ #!/bin/sh
350+ case " $1 $2 " in
351+ " pr list " )
352+ echo '[]'
353+ exit 0
354+ ;;
355+ " pr checks " )
356+ echo '[]'
357+ exit 0
358+ ;;
359+ *)
360+ exit 1
361+ ;;
362+ esac
363+ """
364+ try script. write ( to: ghStub, atomically: true , encoding: . utf8)
365+ try fileManager. setAttributes ( [ . posixPermissions: 0o755 ] , ofItemAtPath: ghStub. path)
366+ ghStubBinDirectory = binDir
367+ setenv ( " PATH " , " \( binDir. path) : \( currentPath) " , 1 )
368+ } catch {
369+ XCTFail ( " Failed to install gh stub: \( error) " )
370+ }
371+ }
372+
373+ override func tearDown( ) {
374+ if let originalPATHForGHStub {
375+ setenv ( " PATH " , originalPATHForGHStub, 1 )
376+ }
377+ if let ghStubBinDirectory {
378+ try ? FileManager . default. removeItem ( at: ghStubBinDirectory)
379+ }
380+ originalPATHForGHStub = nil
381+ ghStubBinDirectory = nil
382+ super. tearDown ( )
383+ }
384+
307385 func testGitHubRepositorySlugsPrioritizeUpstreamThenOriginAndDeduplicate( ) {
308386 let output = """
309387 origin https://github.com/austinwang/cmux.git (fetch)
@@ -526,7 +604,10 @@ final class TabManagerPullRequestProbeTests: XCTestCase {
526604
527605 XCTAssertNotEqual ( manager. selectedTabId, backgroundWorkspace. id)
528606 XCTAssertTrue (
529- waitForCondition {
607+ // Real git subprocess + GitMetadataProber round trip, not a fixed dispatch
608+ // delay — needs the same headroom as testRemoteSplitSkipsInitialGitMetadataProbe
609+ // below under a full serial suite run's CPU contention.
610+ waitForCondition ( timeout: 12.0 ) {
530611 backgroundWorkspace. panelGitBranches [ backgroundPanelId] ? . branch == " main "
531612 }
532613 )
@@ -570,7 +651,9 @@ final class TabManagerPullRequestProbeTests: XCTestCase {
570651 manager. refreshTrackedWorkspaceGitMetadataForTesting ( )
571652
572653 XCTAssertTrue (
573- waitForCondition {
654+ // See timeout comment on testInheritedBackgroundWorkspaceFetchesGitBranchWithoutSelection
655+ // above: real git subprocess + probe round trip needs headroom under CI load.
656+ waitForCondition ( timeout: 12.0 ) {
574657 workspace. panelGitBranches [ panelId] ? . branch == " feature/sidebar-live-refresh "
575658 }
576659 )
@@ -613,7 +696,9 @@ final class TabManagerPullRequestProbeTests: XCTestCase {
613696 manager. refreshTrackedWorkspaceGitMetadataForTesting ( )
614697
615698 XCTAssertTrue (
616- waitForCondition {
699+ // See timeout comment on testInheritedBackgroundWorkspaceFetchesGitBranchWithoutSelection
700+ // above: real git subprocess + probe round trip needs headroom under CI load.
701+ waitForCondition ( timeout: 12.0 ) {
617702 workspace. panelGitBranches [ panelId] ? . branch == " main "
618703 }
619704 )
@@ -733,7 +818,9 @@ final class TabManagerPullRequestProbeTests: XCTestCase {
733818 manager. refreshTrackedWorkspaceGitMetadataForTesting ( )
734819
735820 XCTAssertTrue (
736- waitForCondition {
821+ // See timeout comment on testInheritedBackgroundWorkspaceFetchesGitBranchWithoutSelection
822+ // above: real git subprocess + probe round trip needs headroom under CI load.
823+ waitForCondition ( timeout: 12.0 ) {
737824 workspace. panelGitBranches [ panelId] ? . branch == " main "
738825 && workspace. panelPullRequests [ panelId] == nil
739826 }
0 commit comments