* Show update-available banner automatically on launch Probe for updates immediately on launch via Sparkle's checkForUpdateInformation() so the sidebar surfaces a passive update indicator without waiting for the 24h scheduler. When Sparkle detects an available update in the background, the pill now shows "Update Available: X.Y.Z" with accent styling while the updater is idle. Clicking it triggers the full interactive update flow. Also fixes thread safety in delegate callbacks by dispatching @Published mutations to the main queue. Closes #1643 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add periodic background update probe every 15 minutes The launch-only probe wouldn't catch updates published while the app is already running. Add a repeating 15-minute timer that calls checkForUpdateInformation() so the sidebar banner appears within a reasonable window after a new version is published. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Change background update probe interval to 30 minutes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Change update check interval to 1 hour and migrate existing users Reduce Sparkle's scheduled check interval from 24h to 1h so update banners appear sooner. Migrate users stuck on the old 24h default by bumping the migration key to v2. Align background probe interval with the Sparkle check interval instead of hardcoding 30 minutes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
379 lines
16 KiB
Swift
379 lines
16 KiB
Swift
import XCTest
|
|
import Foundation
|
|
|
|
// UI runners can adjust wall clock time mid-test; use monotonic uptime for polling deadlines.
|
|
private func pollUntil(
|
|
timeout: TimeInterval,
|
|
pollInterval: TimeInterval = 0.05,
|
|
condition: () -> Bool
|
|
) -> Bool {
|
|
let start = ProcessInfo.processInfo.systemUptime
|
|
while true {
|
|
if condition() {
|
|
return true
|
|
}
|
|
if (ProcessInfo.processInfo.systemUptime - start) >= timeout {
|
|
return false
|
|
}
|
|
RunLoop.current.run(until: Date().addingTimeInterval(pollInterval))
|
|
}
|
|
}
|
|
|
|
final class UpdatePillUITests: XCTestCase {
|
|
override func setUp() {
|
|
super.setUp()
|
|
continueAfterFailure = false
|
|
}
|
|
|
|
func testUpdatePillShowsForAvailableUpdate() {
|
|
let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences")
|
|
systemSettings.terminate()
|
|
let app = XCUIApplication()
|
|
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
|
app.launchEnvironment["CMUX_UI_TEST_UPDATE_STATE"] = "available"
|
|
app.launchEnvironment["CMUX_UI_TEST_UPDATE_VERSION"] = "9.9.9"
|
|
launchAndActivate(app)
|
|
|
|
let pill = pillButton(app: app, expectedLabel: "Update Available: 9.9.9")
|
|
XCTAssertTrue(pill.waitForExistence(timeout: 6.0))
|
|
XCTAssertEqual(pill.label, "Update Available: 9.9.9")
|
|
assertVisibleSize(pill)
|
|
attachScreenshot(name: "update-available")
|
|
// Element screenshots are flaky on the UTM VM (image creation fails intermittently).
|
|
// Keep a stable attachment with element state instead.
|
|
attachElementDebug(name: "update-available-pill", element: pill)
|
|
}
|
|
|
|
func testDetectedBackgroundUpdateShowsPillWithoutManualCheck() {
|
|
let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences")
|
|
systemSettings.terminate()
|
|
let app = XCUIApplication()
|
|
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
|
app.launchEnvironment["CMUX_UI_TEST_DETECTED_UPDATE_VERSION"] = "9.9.9"
|
|
launchAndActivate(app)
|
|
|
|
let pill = pillButton(app: app, expectedLabel: "Update Available: 9.9.9")
|
|
XCTAssertTrue(pill.waitForExistence(timeout: 6.0))
|
|
XCTAssertEqual(pill.label, "Update Available: 9.9.9")
|
|
assertVisibleSize(pill)
|
|
attachScreenshot(name: "background-detected-update-available")
|
|
}
|
|
|
|
func testUpdatePillShowsForNoUpdateThenDismisses() {
|
|
let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences")
|
|
systemSettings.terminate()
|
|
let timingPath = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-ui-test-timing-\(UUID().uuidString).json")
|
|
let app = XCUIApplication()
|
|
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
|
app.launchEnvironment["CMUX_UI_TEST_UPDATE_STATE"] = "notFound"
|
|
app.launchEnvironment["CMUX_UI_TEST_TIMING_PATH"] = timingPath.path
|
|
launchAndActivate(app)
|
|
|
|
let pill = pillButton(app: app, expectedLabel: "No Updates Available")
|
|
XCTAssertTrue(pill.waitForExistence(timeout: 6.0))
|
|
XCTAssertEqual(pill.label, "No Updates Available")
|
|
assertVisibleSize(pill)
|
|
attachScreenshot(name: "no-updates")
|
|
attachElementDebug(name: "no-updates-pill", element: pill)
|
|
|
|
let gone = XCTNSPredicateExpectation(
|
|
predicate: NSPredicate(format: "exists == false"),
|
|
object: pill
|
|
)
|
|
XCTAssertEqual(XCTWaiter().wait(for: [gone], timeout: 7.0), .completed)
|
|
|
|
let payload = loadTimingPayload(from: timingPath)
|
|
let shownAt = payload["noUpdateShownAt"] ?? 0
|
|
let hiddenAt = payload["noUpdateHiddenAt"] ?? 0
|
|
XCTAssertGreaterThan(shownAt, 0)
|
|
XCTAssertGreaterThan(hiddenAt, shownAt)
|
|
XCTAssertGreaterThanOrEqual(hiddenAt - shownAt, 4.8)
|
|
}
|
|
|
|
func testCheckForUpdatesUsesMockFeedWithUpdate() {
|
|
let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences")
|
|
systemSettings.terminate()
|
|
let app = launchAppWithMockFeed(mode: "available", version: "9.9.9")
|
|
|
|
let pill = pillButton(app: app, expectedLabel: "Update Available: 9.9.9")
|
|
XCTAssertTrue(pill.waitForExistence(timeout: 6.0))
|
|
XCTAssertEqual(pill.label, "Update Available: 9.9.9")
|
|
assertVisibleSize(pill)
|
|
attachScreenshot(name: "mock-update-available")
|
|
}
|
|
|
|
func testCheckForUpdatesUsesMockFeedWithNoUpdate() {
|
|
let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences")
|
|
systemSettings.terminate()
|
|
let timingPath = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-ui-test-timing-\(UUID().uuidString).json")
|
|
let app = launchAppWithMockFeed(mode: "none", version: "9.9.9", timingPath: timingPath)
|
|
|
|
let pill = pillButton(app: app, expectedLabel: "No Updates Available")
|
|
XCTAssertTrue(pill.waitForExistence(timeout: 6.0))
|
|
XCTAssertEqual(pill.label, "No Updates Available")
|
|
assertVisibleSize(pill)
|
|
attachScreenshot(name: "mock-no-updates")
|
|
}
|
|
|
|
func testCheckForUpdatesShowsLoadingThenNoUpdateInSidebarFooter() {
|
|
let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences")
|
|
systemSettings.terminate()
|
|
let app = launchAppWithMockFeed(
|
|
mode: "none",
|
|
version: "9.9.9",
|
|
extraEnvironment: [
|
|
"CMUX_UI_TEST_MOCK_FEED_DELAY_MS": "7000",
|
|
]
|
|
)
|
|
|
|
XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0))
|
|
|
|
let checkingPill = pillButton(app: app, expectedLabel: "Checking for Updates…")
|
|
XCTAssertTrue(checkingPill.waitForExistence(timeout: 6.0))
|
|
assertVisibleSize(checkingPill)
|
|
|
|
let noUpdatePill = pillButton(app: app, expectedLabel: "No Updates Available")
|
|
XCTAssertTrue(noUpdatePill.waitForExistence(timeout: 8.0))
|
|
assertVisibleSize(noUpdatePill)
|
|
}
|
|
|
|
func testBackgroundDetectedUpdateKeepsOnlyBottomUpdatePill() {
|
|
let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences")
|
|
systemSettings.terminate()
|
|
let app = XCUIApplication()
|
|
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
|
app.launchEnvironment["CMUX_UI_TEST_DETECTED_UPDATE_VERSION"] = "9.9.9"
|
|
app.launchEnvironment["CMUX_UI_TEST_UPDATE_STATE"] = "available"
|
|
app.launchEnvironment["CMUX_UI_TEST_UPDATE_VERSION"] = "9.9.9"
|
|
launchAndActivate(app)
|
|
|
|
let pill = pillButton(app: app, expectedLabel: "Update Available: 9.9.9")
|
|
XCTAssertTrue(pill.waitForExistence(timeout: 6.0))
|
|
assertVisibleSize(pill)
|
|
XCTAssertFalse(app.otherElements["SidebarUpdateBanner"].exists)
|
|
XCTAssertFalse(app.buttons["SidebarUpdateBannerAction"].exists)
|
|
}
|
|
|
|
func testNoSparklePermissionDialogIsShown() {
|
|
let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences")
|
|
systemSettings.terminate()
|
|
|
|
let app = XCUIApplication()
|
|
// Make Sparkle re-request permission on startup, but we should auto-handle it with no UI.
|
|
app.launchEnvironment["CMUX_UI_TEST_RESET_SPARKLE_PERMISSION"] = "1"
|
|
launchAndActivate(app)
|
|
|
|
XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0))
|
|
|
|
// Sparkle's default permission prompt is an NSAlert with these labels.
|
|
XCTAssertFalse(app.staticTexts["Check for updates automatically?"].waitForExistence(timeout: 2.0))
|
|
XCTAssertFalse(app.buttons["Don't Check"].exists)
|
|
XCTAssertFalse(app.buttons["Check Automatically"].exists)
|
|
}
|
|
|
|
private func pillButton(app: XCUIApplication, expectedLabel: String) -> XCUIElement {
|
|
// On macOS, SwiftUI accessibility identifiers are not always reliably surfaced for titlebar-style
|
|
// UI across OS/Xcode versions. Prefer the pill's accessibility label, but keep an identifier
|
|
// fallback for local runs.
|
|
return app.buttons[expectedLabel]
|
|
}
|
|
|
|
private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
|
pollUntil(timeout: timeout) {
|
|
app.windows.count >= count
|
|
}
|
|
}
|
|
|
|
private func assertVisibleSize(_ element: XCUIElement, timeout: TimeInterval = 2.0) {
|
|
let pollInterval: TimeInterval = 0.05
|
|
var size = element.frame.size
|
|
var exists = element.exists
|
|
var hittable = element.isHittable
|
|
|
|
let visible = pollUntil(timeout: timeout, pollInterval: pollInterval) {
|
|
size = element.frame.size
|
|
exists = element.exists
|
|
hittable = element.isHittable
|
|
return size.width > 20 && size.height > 10
|
|
}
|
|
if !visible {
|
|
XCTFail(
|
|
"Expected UpdatePill to have visible size, got \(size), exists=\(exists), hittable=\(hittable)"
|
|
)
|
|
}
|
|
}
|
|
|
|
private func attachScreenshot(name: String, screenshot: XCUIScreenshot = XCUIScreen.main.screenshot()) {
|
|
let attachment = XCTAttachment(screenshot: screenshot)
|
|
attachment.name = name
|
|
attachment.lifetime = .keepAlways
|
|
add(attachment)
|
|
}
|
|
|
|
private func attachElementDebug(name: String, element: XCUIElement) {
|
|
let payload = """
|
|
label: \(element.label)
|
|
exists: \(element.exists)
|
|
hittable: \(element.isHittable)
|
|
frame: \(element.frame)
|
|
"""
|
|
let attachment = XCTAttachment(string: payload)
|
|
attachment.name = name
|
|
attachment.lifetime = .keepAlways
|
|
add(attachment)
|
|
}
|
|
|
|
private func launchAppWithMockFeed(
|
|
mode: String,
|
|
version: String,
|
|
timingPath: URL? = nil,
|
|
extraEnvironment: [String: String] = [:]
|
|
) -> XCUIApplication {
|
|
let app = XCUIApplication()
|
|
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
|
app.launchEnvironment["CMUX_UI_TEST_FEED_URL"] = "https://cmux.test/appcast.xml"
|
|
app.launchEnvironment["CMUX_UI_TEST_FEED_MODE"] = mode
|
|
app.launchEnvironment["CMUX_UI_TEST_UPDATE_VERSION"] = version
|
|
app.launchEnvironment["CMUX_UI_TEST_AUTO_ALLOW_PERMISSION"] = "1"
|
|
app.launchEnvironment["CMUX_UI_TEST_TRIGGER_UPDATE_CHECK"] = "1"
|
|
if let timingPath {
|
|
app.launchEnvironment["CMUX_UI_TEST_TIMING_PATH"] = timingPath.path
|
|
}
|
|
for (key, value) in extraEnvironment {
|
|
app.launchEnvironment[key] = value
|
|
}
|
|
launchAndActivate(app)
|
|
return app
|
|
}
|
|
|
|
private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) {
|
|
app.launch()
|
|
let activated = pollUntil(timeout: activateTimeout) {
|
|
guard app.state != .runningForeground else {
|
|
return true
|
|
}
|
|
app.activate()
|
|
return app.state == .runningForeground
|
|
}
|
|
if !activated {
|
|
app.activate()
|
|
}
|
|
}
|
|
|
|
private func loadTimingPayload(from url: URL) -> [String: Double] {
|
|
guard let data = try? Data(contentsOf: url),
|
|
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
|
|
return [:]
|
|
}
|
|
return object
|
|
}
|
|
}
|
|
|
|
final class TitlebarShortcutHintsUITests: XCTestCase {
|
|
override func setUp() {
|
|
super.setUp()
|
|
continueAfterFailure = false
|
|
}
|
|
|
|
func testTitlebarShortcutHintsAlignWithoutShiftingControls() {
|
|
let baselineApp = launchApp(alwaysShowHints: false)
|
|
XCTAssertTrue(waitForWindowCount(atLeast: 1, app: baselineApp, timeout: 8.0))
|
|
|
|
let baselineToggle = baselineApp.buttons["titlebarControl.toggleSidebar"]
|
|
let baselineNotifications = baselineApp.buttons["titlebarControl.showNotifications"]
|
|
let baselineNewTab = baselineApp.buttons["titlebarControl.newTab"]
|
|
|
|
XCTAssertTrue(waitForElementVisible(baselineToggle, timeout: 6.0))
|
|
XCTAssertTrue(waitForElementVisible(baselineNotifications, timeout: 6.0))
|
|
XCTAssertTrue(waitForElementVisible(baselineNewTab, timeout: 6.0))
|
|
|
|
let baselineToggleFrame = baselineToggle.frame
|
|
let baselineNotificationsFrame = baselineNotifications.frame
|
|
let baselineNewTabFrame = baselineNewTab.frame
|
|
|
|
baselineApp.terminate()
|
|
|
|
let hintedApp = launchApp(alwaysShowHints: true)
|
|
XCTAssertTrue(waitForWindowCount(atLeast: 1, app: hintedApp, timeout: 8.0))
|
|
|
|
let hintedToggle = hintedApp.buttons["titlebarControl.toggleSidebar"]
|
|
let hintedNotifications = hintedApp.buttons["titlebarControl.showNotifications"]
|
|
let hintedNewTab = hintedApp.buttons["titlebarControl.newTab"]
|
|
|
|
XCTAssertTrue(waitForElementVisible(hintedToggle, timeout: 6.0))
|
|
XCTAssertTrue(waitForElementVisible(hintedNotifications, timeout: 6.0))
|
|
XCTAssertTrue(waitForElementVisible(hintedNewTab, timeout: 6.0))
|
|
|
|
let sidebarHint = hintedApp.staticTexts["titlebarShortcutHint.toggleSidebar"]
|
|
let notificationsHint = hintedApp.staticTexts["titlebarShortcutHint.showNotifications"]
|
|
let newTabHint = hintedApp.staticTexts["titlebarShortcutHint.newTab"]
|
|
|
|
XCTAssertTrue(waitForElementVisible(sidebarHint, timeout: 6.0))
|
|
XCTAssertTrue(waitForElementVisible(notificationsHint, timeout: 6.0))
|
|
XCTAssertTrue(waitForElementVisible(newTabHint, timeout: 6.0))
|
|
|
|
let hintedToggleFrame = hintedToggle.frame
|
|
let hintedNotificationsFrame = hintedNotifications.frame
|
|
let hintedNewTabFrame = hintedNewTab.frame
|
|
|
|
XCTAssertEqual(hintedToggleFrame.minY, baselineToggleFrame.minY, accuracy: 1.0)
|
|
XCTAssertEqual(hintedNotificationsFrame.minY, baselineNotificationsFrame.minY, accuracy: 1.0)
|
|
XCTAssertEqual(hintedNewTabFrame.minY, baselineNewTabFrame.minY, accuracy: 1.0)
|
|
|
|
let sidebarHintFrame = sidebarHint.frame
|
|
let notificationsHintFrame = notificationsHint.frame
|
|
let newTabHintFrame = newTabHint.frame
|
|
|
|
XCTAssertEqual(sidebarHintFrame.minY, notificationsHintFrame.minY, accuracy: 1.0)
|
|
XCTAssertEqual(notificationsHintFrame.minY, newTabHintFrame.minY, accuracy: 1.0)
|
|
// Keep the sidebar hint lane to the right of the sidebar icon so it cannot clip into the traffic-light backdrop.
|
|
XCTAssertGreaterThanOrEqual(sidebarHintFrame.minX, hintedToggleFrame.minX - 4.0)
|
|
|
|
let sortedHintFrames = [sidebarHintFrame, notificationsHintFrame, newTabHintFrame]
|
|
.sorted { $0.minX < $1.minX }
|
|
for index in 1..<sortedHintFrames.count {
|
|
let previousFrame = sortedHintFrames[index - 1]
|
|
let currentFrame = sortedHintFrames[index]
|
|
XCTAssertGreaterThanOrEqual(currentFrame.minX - previousFrame.maxX, 2.0)
|
|
}
|
|
}
|
|
|
|
private func launchApp(alwaysShowHints: Bool) -> XCUIApplication {
|
|
let app = XCUIApplication()
|
|
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
|
app.launchArguments += ["-shortcutHintAlwaysShow", alwaysShowHints ? "YES" : "NO"]
|
|
app.launchArguments += ["-shortcutHintTitlebarXOffset", "4"]
|
|
app.launchArguments += ["-shortcutHintTitlebarYOffset", "0"]
|
|
app.launch()
|
|
|
|
_ = pollUntil(timeout: 2.0) {
|
|
guard app.state != .runningForeground else {
|
|
return true
|
|
}
|
|
app.activate()
|
|
return app.state == .runningForeground
|
|
}
|
|
|
|
return app
|
|
}
|
|
|
|
private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
|
pollUntil(timeout: timeout) {
|
|
app.windows.count >= count
|
|
}
|
|
}
|
|
|
|
private func waitForElementVisible(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
|
|
pollUntil(timeout: timeout) {
|
|
if element.exists {
|
|
let frame = element.frame
|
|
if frame.width > 1, frame.height > 1 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
}
|