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

@ -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) {