summaryrefslogtreecommitdiff
path: root/macos/Sources/Features/Update
diff options
context:
space:
mode:
Diffstat (limited to 'macos/Sources/Features/Update')
-rw-r--r--macos/Sources/Features/Update/UpdateSimulator.swift272
1 files changed, 272 insertions, 0 deletions
diff --git a/macos/Sources/Features/Update/UpdateSimulator.swift b/macos/Sources/Features/Update/UpdateSimulator.swift
new file mode 100644
index 000000000..0cf2d221b
--- /dev/null
+++ b/macos/Sources/Features/Update/UpdateSimulator.swift
@@ -0,0 +1,272 @@
+import Foundation
+import Sparkle
+
+/// Simulates various update scenarios for testing the update UI.
+///
+/// The expected usage is by overriding the `checkForUpdates` function in AppDelegate and
+/// calling one of these instead. This will allow us to test the update flows without having to use
+/// real updates.
+enum UpdateSimulator {
+ /// Complete successful update flow: checking → available → download → extract → ready → install → idle
+ case happyPath
+
+ /// No updates available: checking (2s) → "No Updates Available" (3s) → idle
+ case notFound
+
+ /// Error during check: checking (2s) → error with retry callback
+ case error
+
+ /// Slower download for testing progress UI: checking → available → download (20 steps, ~10s) → extract → install
+ case slowDownload
+
+ /// Initial permission request flow: shows permission dialog → proceeds with happy path if accepted
+ case permissionRequest
+
+ /// User cancels during download: checking → available → download (5 steps) → cancels → idle
+ case cancelDuringDownload
+
+ /// User cancels while checking: checking (1s) → cancels → idle
+ case cancelDuringChecking
+
+ func simulate(with viewModel: UpdateViewModel) {
+ switch self {
+ case .happyPath:
+ simulateHappyPath(viewModel)
+ case .notFound:
+ simulateNotFound(viewModel)
+ case .error:
+ simulateError(viewModel)
+ case .slowDownload:
+ simulateSlowDownload(viewModel)
+ case .permissionRequest:
+ simulatePermissionRequest(viewModel)
+ case .cancelDuringDownload:
+ simulateCancelDuringDownload(viewModel)
+ case .cancelDuringChecking:
+ simulateCancelDuringChecking(viewModel)
+ }
+ }
+
+ private func simulateHappyPath(_ viewModel: UpdateViewModel) {
+ viewModel.state = .checking(.init(cancel: {
+ viewModel.state = .idle
+ }))
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+ viewModel.state = .updateAvailable(.init(
+ appcastItem: SUAppcastItem.empty(),
+ reply: { choice in
+ if choice == .install {
+ simulateDownload(viewModel)
+ } else {
+ viewModel.state = .idle
+ }
+ }
+ ))
+ }
+ }
+
+ private func simulateNotFound(_ viewModel: UpdateViewModel) {
+ viewModel.state = .checking(.init(cancel: {
+ viewModel.state = .idle
+ }))
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+ viewModel.state = .notFound
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
+ viewModel.state = .idle
+ }
+ }
+ }
+
+ private func simulateError(_ viewModel: UpdateViewModel) {
+ viewModel.state = .checking(.init(cancel: {
+ viewModel.state = .idle
+ }))
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+ viewModel.state = .error(.init(
+ error: NSError(domain: "UpdateError", code: 1, userInfo: [
+ NSLocalizedDescriptionKey: "Failed to check for updates"
+ ]),
+ retry: {
+ simulateHappyPath(viewModel)
+ }
+ ))
+ }
+ }
+
+ private func simulateSlowDownload(_ viewModel: UpdateViewModel) {
+ viewModel.state = .checking(.init(cancel: {
+ viewModel.state = .idle
+ }))
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+ viewModel.state = .updateAvailable(.init(
+ appcastItem: SUAppcastItem.empty(),
+ reply: { choice in
+ if choice == .install {
+ simulateSlowDownloadProgress(viewModel)
+ } else {
+ viewModel.state = .idle
+ }
+ }
+ ))
+ }
+ }
+
+ private func simulateSlowDownloadProgress(_ viewModel: UpdateViewModel) {
+ let download = UpdateState.Downloading(
+ cancel: {
+ viewModel.state = .idle
+ },
+ expectedLength: nil,
+ progress: 0
+ )
+ viewModel.state = .downloading(download)
+
+ for i in 1...20 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.5) {
+ let updatedDownload = UpdateState.Downloading(
+ cancel: download.cancel,
+ expectedLength: 2000,
+ progress: UInt64(i * 100)
+ )
+ viewModel.state = .downloading(updatedDownload)
+
+ if i == 20 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ simulateExtract(viewModel)
+ }
+ }
+ }
+ }
+ }
+
+ private func simulatePermissionRequest(_ viewModel: UpdateViewModel) {
+ let request = SPUUpdatePermissionRequest(systemProfile: [])
+ viewModel.state = .permissionRequest(.init(
+ request: request,
+ reply: { response in
+ if response.automaticUpdateChecks {
+ simulateHappyPath(viewModel)
+ } else {
+ viewModel.state = .idle
+ }
+ }
+ ))
+ }
+
+ private func simulateCancelDuringDownload(_ viewModel: UpdateViewModel) {
+ viewModel.state = .checking(.init(cancel: {
+ viewModel.state = .idle
+ }))
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+ viewModel.state = .updateAvailable(.init(
+ appcastItem: SUAppcastItem.empty(),
+ reply: { choice in
+ if choice == .install {
+ simulateDownloadThenCancel(viewModel)
+ } else {
+ viewModel.state = .idle
+ }
+ }
+ ))
+ }
+ }
+
+ private func simulateDownloadThenCancel(_ viewModel: UpdateViewModel) {
+ let download = UpdateState.Downloading(
+ cancel: {
+ viewModel.state = .idle
+ },
+ expectedLength: nil,
+ progress: 0
+ )
+ viewModel.state = .downloading(download)
+
+ for i in 1...5 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) {
+ let updatedDownload = UpdateState.Downloading(
+ cancel: download.cancel,
+ expectedLength: 1000,
+ progress: UInt64(i * 100)
+ )
+ viewModel.state = .downloading(updatedDownload)
+
+ if i == 5 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ viewModel.state = .idle
+ }
+ }
+ }
+ }
+ }
+
+ private func simulateCancelDuringChecking(_ viewModel: UpdateViewModel) {
+ viewModel.state = .checking(.init(cancel: {
+ viewModel.state = .idle
+ }))
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
+ viewModel.state = .idle
+ }
+ }
+
+ private func simulateDownload(_ viewModel: UpdateViewModel) {
+ let download = UpdateState.Downloading(
+ cancel: {
+ viewModel.state = .idle
+ },
+ expectedLength: nil,
+ progress: 0
+ )
+ viewModel.state = .downloading(download)
+
+ for i in 1...10 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) {
+ let updatedDownload = UpdateState.Downloading(
+ cancel: download.cancel,
+ expectedLength: 1000,
+ progress: UInt64(i * 100)
+ )
+ viewModel.state = .downloading(updatedDownload)
+
+ if i == 10 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ simulateExtract(viewModel)
+ }
+ }
+ }
+ }
+ }
+
+ private func simulateExtract(_ viewModel: UpdateViewModel) {
+ viewModel.state = .extracting(.init(progress: 0.0))
+
+ for j in 1...5 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) {
+ viewModel.state = .extracting(.init(progress: Double(j) / 5.0))
+
+ if j == 5 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ viewModel.state = .readyToInstall(.init(
+ reply: { choice in
+ if choice == .install {
+ viewModel.state = .installing
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+ viewModel.state = .idle
+ }
+ } else {
+ viewModel.state = .idle
+ }
+ }
+ ))
+ }
+ }
+ }
+ }
+ }
+}