Skip to content

Commit a0f7c25

Browse files
authored
Merge pull request #128 from darkroomengineering/ci/harden-timing-tests
test: harden timing-sensitive unit tests for slow CI runners
2 parents 332af61 + 68517ef commit a0f7c25

7 files changed

Lines changed: 227 additions & 24 deletions

programaTests/AppDelegateShortcutRoutingTests.swift

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4358,7 +4358,9 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
43584358
// apart, since the SwiftUI-hosted field may not be laid out yet on the first
43594359
// attempt). A single fixed 0.05s spin can land before that loop finishes,
43604360
// causing rare flakes. Poll instead of assuming one spin is enough.
4361-
let searchFieldDeadline = Date(timeIntervalSinceNow: 2.0)
4361+
// See `ciScale` (TabManagerUnitTests.swift): scale the poll deadline under CI,
4362+
// where this retry loop can legitimately need longer than a fast local machine.
4363+
let searchFieldDeadline = Date(timeIntervalSinceNow: 2.0 * ciScale)
43624364
var searchField: NSTextField?
43634365
while Date() < searchFieldDeadline {
43644366
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
@@ -4397,7 +4399,17 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
43974399
}
43984400

43994401
window.sendEvent(keyDown)
4400-
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
4402+
// The focus-repair path triggered by sendEvent runs asynchronously (see the
4403+
// searchFieldDeadline poll above for the same class of flake). A single fixed
4404+
// 0.05s spin can land before it completes under a full serial suite run with
4405+
// CPU contention from hundreds of prior tests — poll instead.
4406+
let repairDeadline = Date(timeIntervalSinceNow: 2.0 * ciScale)
4407+
while Date() < repairDeadline {
4408+
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
4409+
if firstResponderOwnsTextField(window.firstResponder, textField: searchField) {
4410+
break
4411+
}
4412+
}
44014413

44024414
XCTAssertTrue(
44034415
firstResponderOwnsTextField(window.firstResponder, textField: searchField),

programaTests/BrowserConfigTests.swift

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2041,10 +2041,24 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
20412041
return nil
20422042
}
20432043

2044-
private func waitForDeveloperToolsTransitions() {
2044+
private func waitForDeveloperToolsTransitions(
2045+
timeout: TimeInterval = 2.0,
2046+
until condition: (() -> Bool)? = nil
2047+
) {
20452048
// Give real headroom under a full serial suite run, where the main queue can
2046-
// carry a genuine backlog from other tests' pending async work.
2047-
RunLoop.current.run(until: Date().addingTimeInterval(2.0))
2049+
// carry a genuine backlog from other tests' pending async work. When a
2050+
// completion condition is supplied, poll for it directly instead of trusting
2051+
// a fixed spin alone — a queued transition (e.g. toggleDeveloperTools' coalesced
2052+
// hide) can still be in flight when the spin ends under CI contention.
2053+
guard let condition else {
2054+
RunLoop.current.run(until: Date().addingTimeInterval(timeout))
2055+
return
2056+
}
2057+
let deadline = Date().addingTimeInterval(timeout)
2058+
while Date() < deadline {
2059+
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
2060+
if condition() { return }
2061+
}
20482062
}
20492063

20502064
private func findWindowBrowserSlotView(in root: NSView) -> WindowBrowserSlotView? {
@@ -2192,7 +2206,9 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
21922206
XCTAssertEqual(inspector.showCount, 1)
21932207
XCTAssertEqual(inspector.closeCount, 0)
21942208

2195-
waitForDeveloperToolsTransitions()
2209+
waitForDeveloperToolsTransitions(timeout: 10.0) {
2210+
inspector.closeCount == 1
2211+
}
21962212

21972213
XCTAssertFalse(panel.isDeveloperToolsVisible())
21982214
XCTAssertEqual(inspector.showCount, 1)

programaTests/NotificationAndMenuBarTests.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,10 @@ final class NotificationDockBadgeTests: XCTestCase {
489489
},
490490
object: NSObject()
491491
)
492-
XCTAssertEqual(XCTWaiter().wait(for: [commandFinished], timeout: 2.0), .completed)
492+
// This spawns a real shell subprocess to write commandOutputURL. Under a full
493+
// serial suite run with CPU contention from hundreds of prior tests, process
494+
// scheduling can legitimately take longer than a couple seconds.
495+
XCTAssertEqual(XCTWaiter().wait(for: [commandFinished], timeout: 15.0), .completed)
493496
XCTAssertTrue(deliveredNotificationIDs.isEmpty)
494497

495498
let output = try String(contentsOf: commandOutputURL, encoding: .utf8)
@@ -577,9 +580,13 @@ final class NotificationDockBadgeTests: XCTestCase {
577580
)
578581

579582
store.promptToEnableNotificationsForTesting()
583+
// See the identical comment on this pattern in
584+
// testNotificationSettingsPromptRetriesUntilWindowExists below: under a full
585+
// serial suite run the main queue can carry a real backlog from other tests'
586+
// pending async work, so give this more than the bare minimum headroom.
580587
let drained = expectation(description: "main queue drained")
581588
DispatchQueue.main.async { drained.fulfill() }
582-
wait(for: [drained], timeout: 1.0)
589+
wait(for: [drained], timeout: 5.0)
583590

584591
XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1)
585592
XCTAssertEqual(alertSpy.runModalCallCount, 0)

programaTests/TabManagerUnitTests.swift

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ import UserNotifications
1515

1616
let 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+
1824
func 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
306314
final 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
}

programaTests/TerminalAndGhosttyTests.swift

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2612,12 +2612,17 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
26122612
contentView.layoutSubtreeIfNeeded()
26132613
hostedView.setVisibleInUI(true)
26142614
hostedView.setActive(true)
2615-
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
2615+
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
26162616

26172617
let searchState = TerminalSurface.SearchState(needle: "")
26182618
surface.searchState = searchState
26192619
hostedView.setSearchOverlay(searchState: searchState)
2620-
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
2620+
// Poll for the deferred mount instead of trusting one fixed spin — see the
2621+
// identical rationale on the other waitUntil("search overlay to mount") call
2622+
// sites in this class.
2623+
waitUntil(timeout: 3.0, description: "find overlay to mount") {
2624+
self.findEditableTextField(in: hostedView) != nil
2625+
}
26212626

26222627
guard let searchField = findEditableTextField(in: hostedView) else {
26232628
XCTFail("Expected mounted find text field")
@@ -2661,7 +2666,9 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
26612666

26622667
NSApp.sendEvent(escapeKeyDown)
26632668
NSApp.sendEvent(escapeKeyUp)
2664-
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
2669+
waitUntil(timeout: 3.0, description: "find overlay to dismiss after Escape") {
2670+
surface.searchState == nil
2671+
}
26652672

26662673
XCTAssertNil(surface.searchState, "Escape should dismiss find overlay when search text is empty")
26672674
XCTAssertEqual(
@@ -2730,7 +2737,9 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
27302737
XCTAssertEqual(state.frame.size.height, 112, accuracy: 0.5)
27312738

27322739
hostedView.setDropZoneOverlay(zone: nil)
2733-
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
2740+
waitUntil(timeout: 3.0, description: "drop zone overlay to hide") {
2741+
hostedView.debugDropZoneOverlayState().isHidden
2742+
}
27342743
XCTAssertTrue(hostedView.debugDropZoneOverlayState().isHidden)
27352744
}
27362745

@@ -3473,7 +3482,18 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase {
34733482
window.displayIfNeeded()
34743483
}
34753484

3476-
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
3485+
// The queued layout shift (posted via DispatchQueue.main.async above) needs a
3486+
// main-run-loop turn to execute. A single fixed 0.3s spin can land before it
3487+
// has settled under a full serial suite run's CPU contention — poll for the
3488+
// real completion signal (the anchor frame actually reflecting the shift)
3489+
// instead of assuming one spin is enough. See `ciScale` (TabManagerUnitTests.swift).
3490+
let layoutShiftDeadline = Date(timeIntervalSinceNow: 0.3 * ciScale)
3491+
while Date() < layoutShiftDeadline {
3492+
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.02))
3493+
if anchor.convert(anchor.bounds, to: nil).minX > originalAnchorFrameInWindow.minX + 1 {
3494+
break
3495+
}
3496+
}
34773497

34783498
let shiftedAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil)
34793499
XCTAssertGreaterThan(
@@ -3494,6 +3514,22 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase {
34943514
x: (originalAnchorFrameInWindow.maxX + shiftedAnchorFrameInWindow.maxX) / 2,
34953515
y: shiftedAnchorFrameInWindow.midY
34963516
)
3517+
3518+
// The layout shift settling (above) and the *separately* scheduled external
3519+
// geometry sync racing against it are two independent async operations — the
3520+
// anchor moving doesn't mean the queued sync has also caught up and re-bound
3521+
// the portal to the new position yet. Poll for that actual completion signal
3522+
// (the hit-test state the assertions below check) rather than checking it once
3523+
// immediately after only the first operation has been confirmed.
3524+
let externalSyncDeadline = Date(timeIntervalSinceNow: 0.3 * ciScale)
3525+
while Date() < externalSyncDeadline {
3526+
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.02))
3527+
if TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredStaleWindowPoint, in: window) == nil,
3528+
TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window) != nil {
3529+
break
3530+
}
3531+
}
3532+
34973533
XCTAssertNil(
34983534
TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredStaleWindowPoint, in: window),
34993535
"The queued external sync should wait until the later layout shift settles, clearing the stale portal location"

0 commit comments

Comments
 (0)