Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion cmuxTests/AppDelegateDisplayConfigRestoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions cmuxTests/AppDelegateMainWindowTestingSupport.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import AppKit
import CmuxTerminal
import Foundation
import Testing
import XCTest

#if canImport(cmux_DEV)
@testable import cmux_DEV
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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() }
}
2 changes: 1 addition & 1 deletion cmuxTests/NewCloudWorkspaceShortcutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions cmuxTests/WindowAndDragTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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??
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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() {
Expand Down
2 changes: 1 addition & 1 deletion cmuxTests/WorkspaceGroupCycleShortcutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand Down
Loading