Stabilize update UI test flow

This commit is contained in:
Lawrence Chen 2026-01-28 03:56:11 -08:00
parent 03ee628fb6
commit db17170b26
6 changed files with 97 additions and 32 deletions

View file

@ -82,19 +82,6 @@ final class UpdatePillUITests: XCTestCase {
XCTAssertTrue(waitForLabel(pill, label: "No Updates Available", timeout: 5.0))
assertVisibleSize(pill)
attachScreenshot(name: "mock-no-updates")
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)
}
private func waitForLabel(_ element: XCUIElement, label: String, timeout: TimeInterval) -> Bool {
@ -122,6 +109,7 @@ final class UpdatePillUITests: XCTestCase {
app.launchEnvironment["CMUX_UI_TEST_FEED_URL"] = "https://cmuxterm.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

View file

@ -34,12 +34,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
UpdateTestSupport.applyIfNeeded(to: updateController.viewModel)
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_TRIGGER_UPDATE_CHECK"] == "1" {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.updateController.checkForUpdates()
guard let self else { return }
if UpdateTestSupport.performMockFeedCheckIfNeeded(on: self.updateController.viewModel) {
return
}
self.updateController.checkForUpdatesWhenReady()
}
}
#endif
}
func applicationDidBecomeActive(_ notification: Notification) {
guard let tabManager, let notificationStore else { return }
guard let tabId = tabManager.selectedTabId else { return }
let surfaceId = tabManager.focusedSurfaceId(for: tabId)
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return }
if let surfaceId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) {
tab.triggerNotificationFocusFlash(surfaceId: surfaceId, requiresSplit: false)
}
notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId)
}
func applicationWillTerminate(_ notification: Notification) {
notificationStore?.clearAll()
}

View file

@ -81,6 +81,23 @@ class UpdateController {
}
}
/// Check for updates once the updater is ready (used by UI tests).
func checkForUpdatesWhenReady(retries: Int = 10) {
let canCheck = updater.canCheckForUpdates
UpdateLogStore.shared.append("checkForUpdatesWhenReady invoked (canCheck=\(canCheck))")
if canCheck {
checkForUpdates()
return
}
guard retries > 0 else {
UpdateLogStore.shared.append("checkForUpdatesWhenReady timed out")
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.checkForUpdatesWhenReady(retries: retries - 1)
}
}
/// Validate the check for updates menu item.
func validateMenuItem(_ item: NSMenuItem) -> Bool {
if item.action == #selector(checkForUpdates) {

View file

@ -19,6 +19,14 @@ class UpdateDriver: NSObject, SPUUserDriver {
func show(_ request: SPUUpdatePermissionRequest,
reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) {
#if DEBUG
let env = ProcessInfo.processInfo.environment
if env["CMUX_UI_TEST_TRIGGER_UPDATE_CHECK"] == "1" || env["CMUX_UI_TEST_AUTO_ALLOW_PERMISSION"] == "1" {
UpdateLogStore.shared.append("auto-allow update permission (ui test)")
reply(SUUpdatePermissionResponse(automaticUpdateChecks: true, sendSystemProfile: false))
return
}
#endif
UpdateLogStore.shared.append("show update permission request")
setState(.permissionRequest(.init(request: request, reply: { [weak viewModel] response in
viewModel?.state = .idle

View file

@ -22,25 +22,11 @@ struct UpdatePill: View {
UpdatePopoverView(model: model)
}
.transition(.opacity.combined(with: .scale(scale: 0.95)))
.onAppear {
scheduleNoUpdateDismiss(for: model.effectiveState)
}
.onChange(of: model.effectiveState) { newState in
resetTask?.cancel()
if case .notFound(let notFound) = newState, model.overrideState == nil {
recordUITestTimestamp(key: "noUpdateShownAt")
resetTask = Task { [weak model] in
let delay = UInt64(UpdateTiming.noUpdateDisplayDuration * 1_000_000_000)
try? await Task.sleep(nanoseconds: delay)
guard !Task.isCancelled, case .notFound? = model?.state else { return }
await MainActor.run {
withAnimation(.easeInOut(duration: 0.25)) {
recordUITestTimestamp(key: "noUpdateHiddenAt")
model?.state = .idle
}
}
notFound.acknowledgement()
}
} else {
resetTask = nil
}
scheduleNoUpdateDismiss(for: newState)
}
}
}
@ -104,4 +90,25 @@ struct UpdatePill: View {
}
#endif
}
private func scheduleNoUpdateDismiss(for state: UpdateState) {
resetTask?.cancel()
if case .notFound(let notFound) = state, model.overrideState == nil {
recordUITestTimestamp(key: "noUpdateShownAt")
resetTask = Task { [weak model] in
let delay = UInt64(UpdateTiming.noUpdateDisplayDuration * 1_000_000_000)
try? await Task.sleep(nanoseconds: delay)
guard !Task.isCancelled, case .notFound? = model?.state else { return }
await MainActor.run {
withAnimation(.easeInOut(duration: 0.25)) {
recordUITestTimestamp(key: "noUpdateHiddenAt")
model?.state = .idle
}
}
notFound.acknowledgement()
}
} else {
resetTask = nil
}
}
}

View file

@ -24,6 +24,34 @@ enum UpdateTestSupport {
}
}
static func performMockFeedCheckIfNeeded(on viewModel: UpdateViewModel) -> Bool {
let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_TRIGGER_UPDATE_CHECK"] == "1" else { return false }
guard let feedURLString = env["CMUX_UI_TEST_FEED_URL"],
let feedURL = URL(string: feedURLString) else { return false }
UpdateTestURLProtocol.registerIfNeeded()
DispatchQueue.main.async {
viewModel.state = .checking(.init(cancel: {}))
}
let task = URLSession.shared.dataTask(with: feedURL) { data, _, _ in
let xml = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
let version = env["CMUX_UI_TEST_UPDATE_VERSION"] ?? "9.9.9"
let hasItem = xml.contains("<item>")
DispatchQueue.main.async {
if hasItem {
let appcastItem = makeAppcastItem(displayVersion: version) ?? SUAppcastItem.empty()
viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: { _ in }))
} else {
viewModel.state = .notFound(.init(acknowledgement: {}))
}
}
}
task.resume()
return true
}
private static func transition(to state: UpdateState, on viewModel: UpdateViewModel) {
viewModel.state = .checking(.init(cancel: {}))
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {