diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 0e832f4f7c36..95c4d9f7e5d9 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -17705,6 +17705,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 106; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSPrincipalClass = CmuxTestsPrincipal; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.64.25; ONLY_ACTIVE_ARCH = YES; @@ -17725,6 +17726,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 106; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSPrincipalClass = CmuxTestsPrincipal; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.64.25; ONLY_ACTIVE_ARCH = NO; diff --git a/cmuxTests/AppDelegateDisplayConfigRestoreTests.swift b/cmuxTests/AppDelegateDisplayConfigRestoreTests.swift index 4a0249200440..59de86f6c07c 100644 --- a/cmuxTests/AppDelegateDisplayConfigRestoreTests.swift +++ b/cmuxTests/AppDelegateDisplayConfigRestoreTests.swift @@ -8,7 +8,7 @@ import Testing @testable import cmux #endif /// Round-trip coverage for per-monitor window-geometry memory (issue #2135). -@Suite(.serialized) +@Suite(.serialized, .exclusiveAppContext) @MainActor struct AppDelegateDisplayConfigRestoreTests { // MARK: fixtures diff --git a/cmuxTests/AppDelegateMainWindowTestingSupport.swift b/cmuxTests/AppDelegateMainWindowTestingSupport.swift index c8f460fc214d..77d0a381344d 100644 --- a/cmuxTests/AppDelegateMainWindowTestingSupport.swift +++ b/cmuxTests/AppDelegateMainWindowTestingSupport.swift @@ -1,5 +1,8 @@ import AppKit +import CmuxTerminal import Foundation +import Testing +import XCTest #if canImport(cmux_DEV) @testable import cmux_DEV @@ -290,3 +293,76 @@ struct PortalRenderingAuthorityUnavailable: Error, CustomStringConvertible { final class KeyStatusTestWindow: NSWindow { override var isKeyWindow: Bool { true } } + +/// The cmuxTests bundle's NSPrincipalClass. XCTest creates it when the bundle +/// loads, before the first test, and it restores `AppDelegate.shared` after +/// every XCTest case. +/// +/// `AppDelegate.init` installs the new delegate as `shared`, and hundreds of +/// tests build a throwaway delegate without restoring the host's. Whichever +/// test ran next in the same host inherited the leftover, and which tests +/// share a host depends on the timing-based shard layout, so the resulting +/// failures moved from run to run. `AppDelegate.init` also points the surface +/// registry's weak route retirer at itself, so that is put back too. Swift +/// Testing tests are not observed here; a Swift Testing suite that constructs +/// `AppDelegate()` or reads `shared` across a suspension point takes +/// `.exclusiveAppContext`, which serializes it with the other app-context tests +/// and restores `shared` the same way. +@objc(CmuxTestsPrincipal) +final class CmuxTestsPrincipal: NSObject, XCTestObservation { + private var sharedAtStart: AppDelegate? + + override init() { + super.init() + XCTestObservationCenter.shared.addTestObserver(self) + } + + func testCaseWillStart(_ testCase: XCTestCase) { + sharedAtStart = AppDelegate.shared + } + + func testCaseDidFinish(_ testCase: XCTestCase) { + if AppDelegate.shared !== sharedAtStart { + AppDelegate.shared = sharedAtStart + if let sharedAtStart { + GhosttyApp.terminalSurfaceRegistry.attachRouteRetirer(sharedAtStart) + } + } + sharedAtStart = nil + } +} + +/// Swift Testing counterpart of `CmuxTestsPrincipal`: runs each test in the +/// suite inside `AppContextSerialGate`, so suites in parallel cannot swap +/// `AppDelegate.shared` under each other at a suspension point, and then puts +/// `shared` and the surface registry's route retirer back. +struct ExclusiveAppContextTrait: SuiteTrait, TestTrait, TestScoping { + var isRecursive: Bool { true } + + func scopeProvider(for test: Test, testCase: Test.Case?) -> Self? { + testCase == nil ? nil : self + } + + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + try await AppContextSerialGate.withExclusiveAppContext { + let sharedAtStart = AppDelegate.shared + defer { + if AppDelegate.shared !== sharedAtStart { + AppDelegate.shared = sharedAtStart + if let sharedAtStart { + GhosttyApp.terminalSurfaceRegistry.attachRouteRetirer(sharedAtStart) + } + } + } + try await function() + } + } +} + +extension Trait where Self == ExclusiveAppContextTrait { + static var exclusiveAppContext: Self { Self() } +} diff --git a/cmuxTests/NewCloudWorkspaceShortcutTests.swift b/cmuxTests/NewCloudWorkspaceShortcutTests.swift index b1331828b9b0..a99aea1af232 100644 --- a/cmuxTests/NewCloudWorkspaceShortcutTests.swift +++ b/cmuxTests/NewCloudWorkspaceShortcutTests.swift @@ -14,7 +14,7 @@ import Testing /// rows with their live shortcut hints, and the shared action every /// entrypoint routes through. @MainActor -@Suite(.serialized) +@Suite(.serialized, .exclusiveAppContext) final class NewCloudWorkspaceShortcutTests { private final class RecordingSheetPresenter: NewMachineSheetPresenting { private(set) var presentCount = 0 diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index cc9a2d4ec8bc..59d2d8f6f1fe 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -587,6 +587,32 @@ final class AppDelegateWindowContextRoutingTests: XCTestCase { } +/// `AppDelegate.init` installs the new delegate as `AppDelegate.shared`, and +/// many tests build a throwaway one without putting the host's back. The next +/// test in the same host then ran against the leftover: detached-inspector +/// Cmd-W tests failed on main whenever the shard layout placed them after +/// AppDelegateWindowContextRoutingTests. XCTest runs these two in name order. +@MainActor +final class AppDelegateSharedIsolationTests: XCTestCase { + private static var sharedBeforeLeak: AppDelegate?? + + func test1ConstructingAnAppDelegateReplacesShared() { + Self.sharedBeforeLeak = .some(AppDelegate.shared) + let leaked = AppDelegate() + XCTAssertTrue(AppDelegate.shared === leaked) + } + + func test2NextTestStartsWithTheHostSharedDelegate() throws { + guard let expected = Self.sharedBeforeLeak else { + throw XCTSkip("Runs after test1ConstructingAnAppDelegateReplacesShared in the same host") + } + XCTAssertTrue( + AppDelegate.shared === expected, + "A delegate a previous test constructed must not stay installed as AppDelegate.shared" + ) + } +} + @MainActor final class AppDelegateLaunchServicesRegistrationTests: XCTestCase { func testDefaultTerminalRegistrationKeepsAllAdvertisedTargets() { diff --git a/cmuxTests/WorkspaceGroupCycleShortcutTests.swift b/cmuxTests/WorkspaceGroupCycleShortcutTests.swift index 4f1cebc08f44..107b40839c9f 100644 --- a/cmuxTests/WorkspaceGroupCycleShortcutTests.swift +++ b/cmuxTests/WorkspaceGroupCycleShortcutTests.swift @@ -11,7 +11,7 @@ import Testing #if DEBUG @MainActor -@Suite("Workspace group cycle shortcuts", .serialized) +@Suite("Workspace group cycle shortcuts", .serialized, .exclusiveAppContext) struct WorkspaceGroupCycleShortcutTests { @Test func actionsAreVisibleAndUnboundByDefault() throws { let actions: [KeyboardShortcutSettings.Action] = [