summaryrefslogtreecommitdiff
path: root/macos
diff options
context:
space:
mode:
authorMitchell Hashimoto <m@mitchellh.com>2025-10-08 21:09:06 -0700
committerMitchell Hashimoto <m@mitchellh.com>2025-10-08 21:09:06 -0700
commita55de09944865044d2f44a37a177bf0d5f882cdb (patch)
tree7c8333218a61fc39d20ba9990c0315e6ef3c9be3 /macos
parent59829f53598b48c73137fbfb1d0c7e81375569ac (diff)
macos: update simulator to test various scenarios in UI
Diffstat (limited to 'macos')
-rw-r--r--macos/Sources/App/macOS/AppDelegate.swift76
-rw-r--r--macos/Sources/Features/Update/UpdateSimulator.swift272
2 files changed, 273 insertions, 75 deletions
diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift
index 4fd6dfb3f..a9f72b58b 100644
--- a/macos/Sources/App/macOS/AppDelegate.swift
+++ b/macos/Sources/App/macOS/AppDelegate.swift
@@ -1008,82 +1008,8 @@ class AppDelegate: NSObject,
}
@IBAction func checkForUpdates(_ sender: Any?) {
- // Demo mode: simulate update check with new UpdateState
- updateViewModel.state = .checking(.init(cancel: { [weak self] in
- self?.updateViewModel.state = .idle
- }))
-
- DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
- guard let self else { return }
-
- self.updateViewModel.state = .updateAvailable(.init(
- appcastItem: SUAppcastItem.empty(),
- reply: { [weak self] choice in
- if choice == .install {
- self?.simulateDownload()
- } else {
- self?.updateViewModel.state = .idle
- }
- }
- ))
- }
- }
-
- private func simulateDownload() {
- let download = UpdateState.Downloading(
- cancel: { [weak self] in
- self?.updateViewModel.state = .idle
- },
- expectedLength: nil,
- progress: 0,
- )
- updateViewModel.state = .downloading(download)
-
- for i in 1...10 {
- DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { [weak self] in
- let updatedDownload = UpdateState.Downloading(
- cancel: download.cancel,
- expectedLength: 1000,
- progress: UInt64(i * 100)
- )
- self?.updateViewModel.state = .downloading(updatedDownload)
-
- if i == 10 {
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
- self?.simulateExtract()
- }
- }
- }
- }
- }
-
- private func simulateExtract() {
- updateViewModel.state = .extracting(.init(progress: 0.0))
-
- for j in 1...5 {
- DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) { [weak self] in
- self?.updateViewModel.state = .extracting(.init(progress: Double(j) / 5.0))
-
- if j == 5 {
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
- self?.updateViewModel.state = .readyToInstall(.init(
- reply: { [weak self] choice in
- if choice == .install {
- self?.updateViewModel.state = .installing
- DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
- self?.updateViewModel.state = .idle
- }
- } else {
- self?.updateViewModel.state = .idle
- }
- }
- ))
- }
- }
- }
- }
+ UpdateSimulator.notFound.simulate(with: updateViewModel)
}
-
@IBAction func newWindow(_ sender: Any?) {
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
+ }
+ }
+ ))
+ }
+ }
+ }
+ }
+ }
+}