From 3351bf2b49e6c03acdca539625ce4b0e97c2c75d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 13 Sep 2026 23:44:27 +0700 Subject: [PATCH] fix(settings): keep Sparkle from starting in a UI test launch Claude-Session: https://claude.ai/code/session_01373GBhF8GLfq9E4SHffNh4 --- .../Infrastructure/UpdaterBridge.swift | 9 ++- .../SoftwareUpdateSettingsUITests.swift | 64 +++++++++++-------- 2 files changed, 44 insertions(+), 29 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/UpdaterBridge.swift b/TablePro/Core/Services/Infrastructure/UpdaterBridge.swift index 69c500534..b0fa5daf1 100644 --- a/TablePro/Core/Services/Infrastructure/UpdaterBridge.swift +++ b/TablePro/Core/Services/Infrastructure/UpdaterBridge.swift @@ -51,7 +51,7 @@ final class UpdaterBridge: UpdaterSettingsWriting { private init() { controller = SPUStandardUpdaterController( - startingUpdater: true, + startingUpdater: Self.startsUpdater, updaterDelegate: nil, userDriverDelegate: nil ) @@ -59,6 +59,13 @@ final class UpdaterBridge: UpdaterSettingsWriting { refreshFromUpdater() } + /// Sparkle keeps its state in the real `com.TablePro` domain, which a UI test's storage sandbox + /// cannot redirect. Started there, it asks the runner for permission on the second launch, and + /// that prompt takes the key window from every test after it. + private static var startsUpdater: Bool { + !AppStorageEnvironment.shared.isIsolated + } + var updater: SPUUpdater { controller.updater } diff --git a/TableProUITests/SoftwareUpdateSettingsUITests.swift b/TableProUITests/SoftwareUpdateSettingsUITests.swift index 7cd884d9a..0bc5d5177 100644 --- a/TableProUITests/SoftwareUpdateSettingsUITests.swift +++ b/TableProUITests/SoftwareUpdateSettingsUITests.swift @@ -5,35 +5,54 @@ import XCTest /// them is a default flipped with no way back. /// /// Nothing here writes an update preference. Sparkle reads and writes them through `SUHost`, which -/// uses `NSUserDefaults.standard` for the main bundle, so a click on one of these controls lands +/// uses the standard user defaults for the main bundle, so a click on one of these controls lands /// in the real `com.TablePro` domain rather than the UI-test suite, and the runner is sandboxed /// away from clearing it again. A case that flipped a toggle would change the update behaviour of -/// the machine it ran on and leak into every later case. +/// the machine it ran on and leak into every later case. A launch argument such as +/// `-SUEnableAutomaticChecks YES` lands in the argument domain instead, which is read first and +/// never written, so that is how a case pins the state a control depends on. final class SoftwareUpdateSettingsUITests: UITestCase { func testSoftwareUpdateControlsAreOffered() throws { let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let settingsMenuItem = app.menuBars.menuItems["Settings…"] - XCTAssertTrue(settingsMenuItem.waitToExist(timeout: 10)) - settingsMenuItem.click() - - let generalPaneButton = app.toolbars.buttons["General"] - XCTAssertTrue(generalPaneButton.waitToExist(timeout: 10)) - generalPaneButton.click() + openGeneralSettings(in: app) - let checkToggle = app.checkBoxes["automatic-update-check-toggle"].firstMatch - XCTAssertTrue(checkToggle.waitToExist(timeout: 10)) + XCTAssertTrue(app.switches["automatic-update-check-toggle"].firstMatch.waitToExist(timeout: 10)) + XCTAssertTrue(app.switches["automatic-update-install-toggle"].firstMatch.waitToExist(timeout: 10)) + XCTAssertTrue(app.popUpButtons["update-check-frequency-picker"].firstMatch.waitToExist(timeout: 10)) + } - let installToggle = app.checkBoxes["automatic-update-install-toggle"].firstMatch - XCTAssertTrue(installToggle.waitToExist(timeout: 10)) + func testCheckFrequencyOffersDailyAndWeekly() throws { + let app = try launchApp(arguments: ["-SUEnableAutomaticChecks", "YES"]) + openGeneralSettings(in: app) let frequency = app.popUpButtons["update-check-frequency-picker"].firstMatch XCTAssertTrue(frequency.waitToExist(timeout: 10)) + XCTAssertTrue(frequency.isEnabled, "The frequency applies only while automatic checks are on") + + frequency.click() + XCTAssertTrue(app.menuItems["Daily"].waitToExist(timeout: 10)) + XCTAssertTrue(app.menuItems["Weekly"].exists) + + // Dismissed rather than chosen: selecting an item would write updateCheckInterval into the + // machine's real Sparkle domain, which nothing in the suite can put back. + app.typeKey(.escape, modifierFlags: []) } - func testCheckFrequencyOffersDailyAndWeekly() throws { - let app = try launchApp() + /// A started updater in a test launch asks for permission on the runner's second launch, and + /// that prompt took the key window from thirteen tests in eleven unrelated suites. Check for + /// Updates is enabled by Sparkle's `startUpdater`. Automatic checks are pinned off so a started + /// updater neither prompts nor begins a background check, either of which would disable the + /// button for a while and let this pass without proving anything. + func testATestLaunchNeverStartsTheUpdater() throws { + let app = try launchApp(arguments: ["-SUEnableAutomaticChecks", "NO"]) + openGeneralSettings(in: app) + + let checkNow = app.windows["settings"].buttons["Check for Updates…"].firstMatch + XCTAssertTrue(checkNow.waitToExist(timeout: 10)) + XCTAssertFalse(checkNow.isEnabled, "A UI test launch must not start Sparkle") + } + + private func openGeneralSettings(in app: XCUIApplication) { XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) let settingsMenuItem = app.menuBars.menuItems["Settings…"] @@ -43,16 +62,5 @@ final class SoftwareUpdateSettingsUITests: UITestCase { let generalPaneButton = app.toolbars.buttons["General"] XCTAssertTrue(generalPaneButton.waitToExist(timeout: 10)) generalPaneButton.click() - - let frequency = app.popUpButtons["update-check-frequency-picker"].firstMatch - XCTAssertTrue(frequency.waitToExist(timeout: 10)) - - frequency.click() - XCTAssertTrue(app.menuItems["Daily"].waitToExist(timeout: 10)) - XCTAssertTrue(app.menuItems["Weekly"].exists) - - // Dismissed rather than chosen: selecting an item would write updateCheckInterval into the - // machine's real Sparkle domain, which nothing in the suite can put back. - app.typeKey(.escape, modifierFlags: []) } }