Skip to content

Commit dac666a

Browse files
marduc812claude
andcommitted
Exempt the app under UI test from the single-instance guard
The guard's test-host check keys off the XCTest environment, which only a unit test host has — those run inside a real MiniMe process. UI tests drive MiniMe as its own process through XCUIApplication, which carries none of it, so the guard was live inside the app under test. With any other copy running (the installed one, usually) it exited before the runner could see it and every UI test waited out its timeout. The UI tests now pass MINIME_UI_TESTING and the guard honours it. The flag is deliberately not "-"-prefixed, so it can't land in NSArgumentDomain and shadow an @AppStorage key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent dce0822 commit dac666a

8 files changed

Lines changed: 80 additions & 6 deletions

MiniMe/App/SingleInstanceGuard.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,25 @@ enum SingleInstanceGuard {
4747
|| environment["XCTestBundlePath"] != nil
4848
}
4949

50+
/// Passed by the UI tests so the app under test keeps the guard switched off.
51+
///
52+
/// Deliberately not `-`-prefixed: macOS folds `-key value` launch arguments
53+
/// into `NSArgumentDomain`, where the name could shadow one of the app's
54+
/// `@AppStorage` keys.
55+
static let uiTestLaunchArgument = "MINIME_UI_TESTING"
56+
57+
/// Whether the UI test runner launched this process.
58+
///
59+
/// `isTestHost` can't answer this. UI tests drive MiniMe as its own process
60+
/// through `XCUIApplication`, which does not carry the XCTest environment a
61+
/// unit test host has — so the guard was live inside the app under test, and
62+
/// any other copy running on the machine (the installed one, usually) made
63+
/// it exit before the runner could see it. Every UI test then waited out its
64+
/// timeout.
65+
static func isUITestRun(arguments: [String]) -> Bool {
66+
arguments.contains(uiTestLaunchArgument)
67+
}
68+
5069
/// Exits the process if another MiniMe already holds the menu bar, bringing
5170
/// that one forward first so the launch still shows the user something.
5271
///
@@ -55,6 +74,7 @@ enum SingleInstanceGuard {
5574
@MainActor
5675
static func yieldToRunningInstance() {
5776
guard !isTestHost(environment: ProcessInfo.processInfo.environment) else { return }
77+
guard !isUITestRun(arguments: ProcessInfo.processInfo.arguments) else { return }
5878
guard let bundleID = Bundle.main.bundleIdentifier else { return }
5979

6080
let running = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID)

MiniMeTests/SingleInstanceGuardTests.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,33 @@ struct SingleInstanceGuardTests {
5656
#expect(!SingleInstanceGuard.isTestHost(environment: ["HOME": "/Users/someone"]))
5757
}
5858

59+
/// The UI tests launch MiniMe as its own process, which carries none of the
60+
/// XCTest environment above — so they hand the exemption over explicitly.
61+
/// Without this the guard fired inside the app under test and every UI test
62+
/// timed out waiting for a window that had already exited.
63+
@Test func theAppUnderUITestIsExemptFromTheGuard() {
64+
#expect(SingleInstanceGuard.isUITestRun(
65+
arguments: ["/path/to/MiniMe", SingleInstanceGuard.uiTestLaunchArgument]
66+
))
67+
}
68+
69+
@Test func anOrdinaryLaunchIsNotAUITestRun() {
70+
#expect(!SingleInstanceGuard.isUITestRun(arguments: []))
71+
#expect(!SingleInstanceGuard.isUITestRun(arguments: ["/path/to/MiniMe"]))
72+
#expect(!SingleInstanceGuard.isUITestRun(arguments: ["/path/to/MiniMe", "-psn_0_12345"]))
73+
}
74+
75+
/// Pins the literal. `XCUIApplication.miniMe()` spells the same string out
76+
/// on the UI test side — a target can't import the app module — so renaming
77+
/// it here alone would silently arm the guard against the app under test.
78+
///
79+
/// The leading character matters too: `NSArgumentDomain` only picks up
80+
/// `-`-prefixed names, so this one can't shadow an `@AppStorage` key.
81+
@Test func theUITestFlagIsTheOneTheUITestsPass() {
82+
#expect(SingleInstanceGuard.uiTestLaunchArgument == "MINIME_UI_TESTING")
83+
#expect(!SingleInstanceGuard.uiTestLaunchArgument.hasPrefix("-"))
84+
}
85+
5986
/// More than two copies can pile up over a long Xcode session; the guard
6087
/// reports every one so the caller can defer to the oldest.
6188
@Test func everyOtherCopyIsReported() {

MiniMeUITests/MiniMeUITests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ final class MiniMeUITests: XCTestCase {
1313

1414
override func setUpWithError() throws {
1515
continueAfterFailure = false
16-
app = XCUIApplication()
16+
app = .miniMe()
1717
app.launch()
1818
}
1919

@@ -87,7 +87,7 @@ final class MiniMeUITests: XCTestCase {
8787
func testLaunchPerformance() throws {
8888
if #available(macOS 10.15, *) {
8989
measure(metrics: [XCTApplicationLaunchMetric()]) {
90-
XCUIApplication().launch()
90+
XCUIApplication.miniMe().launch()
9191
}
9292
}
9393
}

MiniMeUITests/MiniMeUITestsLaunchTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ final class MiniMeUITestsLaunchTests: XCTestCase {
1919

2020
@MainActor
2121
func testLaunch() throws {
22-
let app = XCUIApplication()
22+
let app = XCUIApplication.miniMe()
2323
app.launch()
2424

2525
// Insert steps here to perform after app launch but before taking a screenshot,

MiniMeUITests/OnboardingUITests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ final class OnboardingUITests: XCTestCase {
1919

2020
override func setUpWithError() throws {
2121
continueAfterFailure = false
22-
app = XCUIApplication()
22+
app = .miniMe()
2323
app.launch()
2424
XCTAssertTrue(app.statusItems.firstMatch.waitForExistence(timeout: 10),
2525
"MiniMe's status item never appeared")

MiniMeUITests/SettingsUITests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ final class SettingsUITests: XCTestCase {
1111

1212
override func setUpWithError() throws {
1313
continueAfterFailure = false
14-
app = XCUIApplication()
14+
app = .miniMe()
1515
app.launch()
1616
}
1717

MiniMeUITests/ToolToggleUITests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ final class ToolToggleUITests: XCTestCase {
1818

1919
override func setUpWithError() throws {
2020
continueAfterFailure = false
21-
app = XCUIApplication()
21+
app = .miniMe()
2222
app.launch()
2323
XCTAssertTrue(app.statusItems.firstMatch.waitForExistence(timeout: 10),
2424
"MiniMe's status item never appeared")
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//
2+
// XCUIApplication+MiniMe.swift
3+
// MiniMeUITests
4+
//
5+
6+
import XCTest
7+
8+
extension XCUIApplication {
9+
10+
/// Must match `SingleInstanceGuard.uiTestLaunchArgument`. A UI test target
11+
/// can't import the app module, so the string is spelled out on both sides;
12+
/// `theUITestFlagIsTheOneTheUITestsPass` in `SingleInstanceGuardTests` pins
13+
/// the app's half to this same literal.
14+
private static let uiTestLaunchArgument = "MINIME_UI_TESTING"
15+
16+
/// MiniMe with the single-instance guard switched off.
17+
///
18+
/// The guard exits a second copy at launch, and it can't tell the app under
19+
/// test apart from an ordinary launch — so without the flag every UI test on
20+
/// a machine already running MiniMe waits out its timeout on a process that
21+
/// quit before the runner reached it. Build every `XCUIApplication` here.
22+
static func miniMe() -> XCUIApplication {
23+
let app = XCUIApplication()
24+
app.launchArguments.append(uiTestLaunchArgument)
25+
return app
26+
}
27+
}

0 commit comments

Comments
 (0)