@otakit/capacitor-updater 2.3.3 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +46 -5
  2. package/android/build.gradle +16 -0
  3. package/android/src/main/java/com/otakit/updater/BundleCrypto.java +38 -17
  4. package/android/src/main/java/com/otakit/updater/BundleInfo.java +10 -0
  5. package/android/src/main/java/com/otakit/updater/BundleStore.java +134 -86
  6. package/android/src/main/java/com/otakit/updater/CheckFailure.java +53 -0
  7. package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +46 -41
  8. package/android/src/main/java/com/otakit/updater/DeviceEventClient.java +125 -26
  9. package/android/src/main/java/com/otakit/updater/DocumentReadyBridge.java +71 -0
  10. package/android/src/main/java/com/otakit/updater/DownloadRetry.java +144 -0
  11. package/android/src/main/java/com/otakit/updater/EventOutbox.java +174 -0
  12. package/android/src/main/java/com/otakit/updater/FileDownloader.java +80 -0
  13. package/android/src/main/java/com/otakit/updater/ForegroundDeadline.java +84 -0
  14. package/android/src/main/java/com/otakit/updater/HashUtils.java +26 -0
  15. package/android/src/main/java/com/otakit/updater/ManifestClient.java +12 -9
  16. package/android/src/main/java/com/otakit/updater/ManifestKeyConfig.java +47 -0
  17. package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +22 -4
  18. package/android/src/main/java/com/otakit/updater/SDKVersion.java +9 -0
  19. package/android/src/main/java/com/otakit/updater/UpdateOwner.java +27 -0
  20. package/android/src/main/java/com/otakit/updater/UpdaterCoordinator.java +154 -59
  21. package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +262 -173
  22. package/dist/esm/definitions.d.ts +3 -3
  23. package/dist/esm/definitions.d.ts.map +1 -1
  24. package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +35 -3
  25. package/ios/Sources/UpdaterPlugin/BundleInfo.swift +13 -0
  26. package/ios/Sources/UpdaterPlugin/BundleStore.swift +103 -77
  27. package/ios/Sources/UpdaterPlugin/CheckFailure.swift +41 -0
  28. package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +29 -17
  29. package/ios/Sources/UpdaterPlugin/DeviceEventClient.swift +12 -10
  30. package/ios/Sources/UpdaterPlugin/DocumentReadyBridge.swift +43 -0
  31. package/ios/Sources/UpdaterPlugin/DownloadRetry.swift +51 -0
  32. package/ios/Sources/UpdaterPlugin/Downloader.swift +97 -39
  33. package/ios/Sources/UpdaterPlugin/EventOutbox.swift +183 -0
  34. package/ios/Sources/UpdaterPlugin/ForegroundDeadline.swift +94 -0
  35. package/ios/Sources/UpdaterPlugin/HashUtils.swift +16 -0
  36. package/ios/Sources/UpdaterPlugin/ManifestClient.swift +2 -4
  37. package/ios/Sources/UpdaterPlugin/ManifestKeyConfig.swift +20 -0
  38. package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +7 -1
  39. package/ios/Sources/UpdaterPlugin/SDKVersion.swift +4 -0
  40. package/ios/Sources/UpdaterPlugin/UpdateOwner.swift +12 -0
  41. package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +146 -110
  42. package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +206 -202
  43. package/ios/Tests/UpdaterPluginTests/BundleCryptoTests.swift +80 -0
  44. package/ios/Tests/UpdaterPluginTests/BundlePersistenceTests.swift +102 -0
  45. package/ios/Tests/UpdaterPluginTests/CheckFailureTests.swift +55 -0
  46. package/ios/Tests/UpdaterPluginTests/DeltaCacheIntegrityTests.swift +69 -0
  47. package/ios/Tests/UpdaterPluginTests/DocumentReadyBridgeTests.swift +102 -0
  48. package/ios/Tests/UpdaterPluginTests/DownloadIntegrityTests.swift +40 -0
  49. package/ios/Tests/UpdaterPluginTests/DownloadRetryTests.swift +189 -0
  50. package/ios/Tests/UpdaterPluginTests/EventDeliveryTests.swift +56 -0
  51. package/ios/Tests/UpdaterPluginTests/EventOutboxTests.swift +86 -0
  52. package/ios/Tests/UpdaterPluginTests/ForegroundDeadlineTests.swift +144 -0
  53. package/ios/Tests/UpdaterPluginTests/ManifestKeyConfigTests.swift +48 -0
  54. package/ios/Tests/UpdaterPluginTests/UpdateOwnerTests.swift +34 -0
  55. package/ios/Tests/UpdaterPluginTests/UpdaterCoordinatorTests.swift +394 -0
  56. package/package.json +7 -3
@@ -0,0 +1,56 @@
1
+ import Foundation
2
+ import XCTest
3
+ @testable import UpdaterPlugin
4
+
5
+ final class EventDeliveryTests: XCTestCase {
6
+ func testURLSessionRetryPreservesBodyAndDeletesOnlyAfterAcceptance() async throws {
7
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
8
+ defer { try? FileManager.default.removeItem(at: directory) }
9
+ let outbox = try EventOutbox(directory: directory)
10
+ let body = try JSONSerialization.data(withJSONObject: ["eventId": UUID().uuidString, "action": "applied"])
11
+ let accepted = expectation(description: "HTTP retry accepted")
12
+ let lock = NSLock()
13
+ var requests: [Data] = []
14
+ EventHTTPProtocol.respond = { request in
15
+ var bytes = request.httpBody ?? Data()
16
+ if let stream = request.httpBodyStream {
17
+ stream.open(); defer { stream.close() }
18
+ var buffer = [UInt8](repeating: 0, count: 1024)
19
+ while stream.hasBytesAvailable {
20
+ let count = stream.read(&buffer, maxLength: buffer.count)
21
+ if count <= 0 { break }
22
+ bytes.append(contentsOf: buffer.prefix(count))
23
+ }
24
+ }
25
+ XCTAssertEqual(request.value(forHTTPHeaderField: "X-App-Id"), "app-a")
26
+ lock.lock(); requests.append(bytes); let count = requests.count; lock.unlock()
27
+ if count == 2 { accepted.fulfill() }
28
+ return count == 1 ? 503 : 202
29
+ }
30
+ defer { EventHTTPProtocol.respond = nil }
31
+ let configuration = URLSessionConfiguration.ephemeral
32
+ configuration.protocolClasses = [EventHTTPProtocol.self]
33
+ let delivery = EventDelivery(outbox: outbox, configuration: configuration)
34
+ delivery.enqueue(url: URL(string: "https://ingest.example/events")!, appId: "app-a", body: body)
35
+ await fulfillment(of: [accepted], timeout: 15)
36
+ let deadline = Date().addingTimeInterval(3)
37
+ while outbox.wait(now: Date().timeIntervalSince1970) != nil && Date() < deadline {
38
+ try await Task.sleep(nanoseconds: 20_000_000)
39
+ }
40
+ XCTAssertNil(try EventOutbox(directory: directory).wait(now: Date().timeIntervalSince1970))
41
+ XCTAssertEqual(requests, [body, body])
42
+ }
43
+ }
44
+
45
+ private final class EventHTTPProtocol: URLProtocol {
46
+ static var respond: ((URLRequest) -> Int)?
47
+ override class func canInit(with request: URLRequest) -> Bool { true }
48
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
49
+ override func startLoading() {
50
+ let status = Self.respond?(request) ?? 500
51
+ let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: [:])!
52
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
53
+ client?.urlProtocolDidFinishLoading(self)
54
+ }
55
+ override func stopLoading() {}
56
+ }
@@ -0,0 +1,86 @@
1
+ import Foundation
2
+ import XCTest
3
+ @testable import UpdaterPlugin
4
+
5
+ final class EventOutboxTests: XCTestCase {
6
+ private var directory: URL!
7
+ override func setUpWithError() throws {
8
+ directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
9
+ }
10
+ override func tearDownWithError() throws { try? FileManager.default.removeItem(at: directory) }
11
+ private func body(_ id: String) throws -> Data {
12
+ try JSONSerialization.data(withJSONObject: ["eventId": id, "sentAt": "2026-09-20T12:00:00Z", "action": "applied"], options: .sortedKeys)
13
+ }
14
+ private func enqueue(_ queue: EventOutbox, _ id: String, _ now: TimeInterval = 0) throws {
15
+ try queue.enqueue(url: URL(string: "https://ingest.example/events")!, appId: "app-a", body: body(id), now: now)
16
+ }
17
+ func testRestartAndLostResponsePreserveOriginalIdentityAndBody() throws {
18
+ let queue = try EventOutbox(directory: directory)
19
+ try enqueue(queue, "original-id", 1)
20
+ try queue.complete(id: "original-id", status: 0, retryAfter: nil, now: 1, random: 0.5)
21
+ let reopened = try EventOutbox(directory: directory)
22
+ XCTAssertNil(try reopened.ready(now: 5.999))
23
+ let retry = try XCTUnwrap(reopened.ready(now: 6))
24
+ XCTAssertEqual(retry.id, "original-id")
25
+ XCTAssertEqual(retry.body, try body("original-id"))
26
+ XCTAssertEqual(retry.appId, "app-a")
27
+ XCTAssertEqual(retry.attempts, 1)
28
+ try reopened.complete(id: retry.id, status: 202, retryAfter: nil, now: 6, random: 0.5)
29
+ XCTAssertNil(try EventOutbox(directory: directory).ready(now: 6))
30
+ }
31
+ func testRateLimitSurvivesRestartAndDoesNotBlockOtherEvents() throws {
32
+ let queue = try EventOutbox(directory: directory)
33
+ try enqueue(queue, "limited", 1)
34
+ try queue.complete(id: "limited", status: 429, retryAfter: "120", now: 1, random: 0.5)
35
+ try enqueue(queue, "other", 2)
36
+ let reopened = try EventOutbox(directory: directory)
37
+ XCTAssertEqual(try reopened.ready(now: 2)?.id, "other")
38
+ try reopened.complete(id: "other", status: 200, retryAfter: nil, now: 2, random: 0.5)
39
+ XCTAssertNil(try reopened.ready(now: 120.999))
40
+ XCTAssertEqual(try reopened.ready(now: 121)?.id, "limited")
41
+ }
42
+ func testPermanentRejectionIsDroppedAndServerFailuresRetry() throws {
43
+ let queue = try EventOutbox(directory: directory)
44
+ try enqueue(queue, "invalid")
45
+ try queue.complete(id: "invalid", status: 400, retryAfter: nil, now: 0, random: 0.5)
46
+ XCTAssertNil(queue.wait(now: 0))
47
+ for status in [408, 425, 429, 500, 502, 503, 504] {
48
+ try enqueue(queue, "retry-\(status)")
49
+ try queue.complete(id: "retry-\(status)", status: status, retryAfter: nil, now: 0, random: 0.5)
50
+ }
51
+ XCTAssertNil(try queue.ready(now: 4.999))
52
+ XCTAssertNotNil(try queue.ready(now: 5))
53
+ }
54
+ func testQueueIsBoundedAndExpiresWithoutRetryingLongDelayEarly() throws {
55
+ let queue = try EventOutbox(directory: directory)
56
+ for index in 0...EventOutbox.limit { try enqueue(queue, "event-\(index)") }
57
+ XCTAssertEqual(try queue.ready(now: 0)?.id, "event-1")
58
+ try queue.complete(id: "event-1", status: 429, retryAfter: String(repeating: "9", count: 500), now: 0, random: 0.5)
59
+ XCTAssertEqual(try queue.ready(now: 0)?.id, "event-2")
60
+ XCTAssertNil(try queue.ready(now: EventOutbox.ttl))
61
+ XCTAssertNil(queue.wait(now: EventOutbox.ttl))
62
+ }
63
+ func testFailedSaveKeepsUnacknowledgedEventInMemoryAndOnDisk() throws {
64
+ let queue = try EventOutbox(directory: directory)
65
+ try enqueue(queue, "keep")
66
+ let file = directory.appendingPathComponent("events.json")
67
+ let original = try Data(contentsOf: file)
68
+ let backup = directory.appendingPathComponent("backup.json")
69
+ try FileManager.default.moveItem(at: file, to: backup)
70
+ try FileManager.default.createDirectory(at: file, withIntermediateDirectories: false)
71
+ try Data([1]).write(to: file.appendingPathComponent("block"))
72
+ XCTAssertThrowsError(try queue.complete(id: "keep", status: 200, retryAfter: nil, now: 0, random: 0.5))
73
+ XCTAssertEqual(try queue.ready(now: 0)?.id, "keep")
74
+ try FileManager.default.removeItem(at: file)
75
+ try FileManager.default.moveItem(at: backup, to: file)
76
+ XCTAssertEqual(try Data(contentsOf: file), original)
77
+ XCTAssertEqual(try EventOutbox(directory: directory).ready(now: 0)?.id, "keep")
78
+ }
79
+ func testCorruptJSONDoesNotPermanentlyDisableDelivery() throws {
80
+ _ = try EventOutbox(directory: directory)
81
+ try Data("{".utf8).write(to: directory.appendingPathComponent("events.json"))
82
+ let queue = try EventOutbox(directory: directory)
83
+ try enqueue(queue, "new")
84
+ XCTAssertEqual(try EventOutbox(directory: directory).ready(now: 0)?.id, "new")
85
+ }
86
+ }
@@ -0,0 +1,144 @@
1
+ import Foundation
2
+ import UIKit
3
+ import XCTest
4
+ @testable import UpdaterPlugin
5
+
6
+ final class ForegroundDeadlineTests: XCTestCase {
7
+ func testBackgroundTimeDoesNotConsumeRemainingBudget() {
8
+ let clock = DeadlineClock()
9
+ let deadline = clock.makeDeadline()
10
+ var expirations = 0
11
+ deadline.setForeground(true)
12
+ deadline.start(timeout: 10) { expirations += 1 }
13
+ let originalCallback = clock.jobs.last!.callback
14
+ clock.now = 3
15
+ deadline.setForeground(false)
16
+ clock.now = 3603
17
+ originalCallback() // Simulate an already queued callback despite cancellation.
18
+ XCTAssertEqual(expirations, 0)
19
+ deadline.setForeground(true)
20
+ XCTAssertEqual(clock.jobs.last!.delay, 7)
21
+ clock.now += 7
22
+ clock.jobs.last!.callback()
23
+ XCTAssertEqual(expirations, 1)
24
+ originalCallback()
25
+ XCTAssertEqual(expirations, 1)
26
+ }
27
+
28
+ func testTrialStartedInBackgroundWaitsForForeground() {
29
+ let clock = DeadlineClock()
30
+ let deadline = clock.makeDeadline()
31
+ var expired = false
32
+ deadline.start(timeout: 10) { expired = true }
33
+ clock.now = 1000
34
+ XCTAssertTrue(clock.jobs.isEmpty)
35
+ deadline.setForeground(true)
36
+ XCTAssertEqual(clock.jobs.last!.delay, 10)
37
+ XCTAssertFalse(expired)
38
+ clock.now += 10
39
+ clock.jobs.last!.callback()
40
+ XCTAssertTrue(expired)
41
+ }
42
+
43
+ func testRepeatedLifecycleEventsDoNotResetBudget() {
44
+ let clock = DeadlineClock()
45
+ let deadline = clock.makeDeadline()
46
+ deadline.setForeground(true)
47
+ deadline.start(timeout: 10) {}
48
+ clock.now = 2
49
+ deadline.setForeground(true)
50
+ deadline.setForeground(false)
51
+ clock.now = 50
52
+ deadline.setForeground(false)
53
+ deadline.setForeground(true)
54
+ XCTAssertEqual(clock.jobs.last!.delay, 8)
55
+ clock.now = 54
56
+ deadline.setForeground(false)
57
+ clock.now = 90
58
+ deadline.setForeground(true)
59
+ XCTAssertEqual(clock.jobs.last!.delay, 4)
60
+ }
61
+
62
+ func testCancelledAndReplacedCallbacksCannotExpireNewTrial() {
63
+ let clock = DeadlineClock()
64
+ let deadline = clock.makeDeadline()
65
+ var expired = "none"
66
+ deadline.setForeground(true)
67
+ deadline.start(timeout: 10) { expired = "A" }
68
+ let old = clock.jobs.last!.callback
69
+ deadline.cancel()
70
+ deadline.start(timeout: 10) { expired = "B" }
71
+ clock.now = 10
72
+ old()
73
+ XCTAssertEqual(expired, "none")
74
+ let current = clock.jobs.last!.callback
75
+ current()
76
+ current()
77
+ XCTAssertEqual(expired, "B")
78
+ deadline.start(timeout: 10) { expired = "C" }
79
+ let cancelled = clock.jobs.last!.callback
80
+ deadline.cancel()
81
+ clock.now = 20
82
+ cancelled()
83
+ XCTAssertEqual(expired, "B")
84
+ }
85
+
86
+ func testEarlySchedulerCallbackWaitsForRemainder() {
87
+ let clock = DeadlineClock()
88
+ let deadline = clock.makeDeadline()
89
+ var expired = false
90
+ deadline.setForeground(true)
91
+ deadline.start(timeout: 10) { expired = true }
92
+ clock.now = 3
93
+ clock.jobs.last!.callback()
94
+ XCTAssertFalse(expired)
95
+ XCTAssertEqual(clock.jobs.last!.delay, 7)
96
+ clock.now = 10
97
+ clock.jobs.last!.callback()
98
+ XCTAssertTrue(expired)
99
+ }
100
+
101
+ @MainActor
102
+ func testApplicationNotificationsPauseRealTrialBeforeRollback() throws {
103
+ let fixture = try CoordinatorFixture()
104
+ defer { try? fixture.cleanup() }
105
+ try fixture.installHealthy("A")
106
+ try fixture.apply("B")
107
+ let trial = try XCTUnwrap(fixture.trial)
108
+ let clock = DeadlineClock()
109
+ let deadline = clock.makeDeadline()
110
+ let center = NotificationCenter()
111
+ deadline.observe(center: center, active: UIApplication.didBecomeActiveNotification,
112
+ inactive: UIApplication.willResignActiveNotification, initiallyActive: true)
113
+ deadline.start(timeout: 10) {
114
+ do {
115
+ let rollback = try fixture.coordinator.prepareRollback(expectedTrial: trial,
116
+ reason: "notify_timeout", isBundleUsable: fixture.isUsable)
117
+ fixture.coordinator.cleanupBundles(rollback.cleanupBundleIds)
118
+ } catch { XCTFail("Rollback failed: \(error)") }
119
+ }
120
+ clock.now = 4
121
+ center.post(name: UIApplication.willResignActiveNotification, object: nil)
122
+ clock.now = 600
123
+ clock.jobs.last!.callback()
124
+ XCTAssertEqual(fixture.store.getCurrentBundle().id, "B")
125
+ XCTAssertTrue(fixture.indexExists("A"))
126
+ center.post(name: UIApplication.didBecomeActiveNotification, object: nil)
127
+ XCTAssertEqual(clock.jobs.last!.delay, 6)
128
+ clock.now = 606
129
+ clock.jobs.last!.callback()
130
+ XCTAssertEqual(fixture.store.getCurrentBundle().id, "A")
131
+ XCTAssertFalse(fixture.indexExists("B"))
132
+ }
133
+ }
134
+
135
+ private final class DeadlineClock {
136
+ var now: TimeInterval = 0
137
+ var jobs: [(delay: TimeInterval, callback: () -> Void)] = []
138
+ func makeDeadline() -> ForegroundDeadline {
139
+ ForegroundDeadline(now: { [unowned self] in self.now }, schedule: { [unowned self] delay, callback in
140
+ self.jobs.append((delay, callback))
141
+ return {} // Deliberately allow tests to deliver stale callbacks.
142
+ })
143
+ }
144
+ }
@@ -0,0 +1,48 @@
1
+ import Foundation
2
+ import XCTest
3
+ @testable import UpdaterPlugin
4
+
5
+ final class ManifestKeyConfigTests: XCTestCase {
6
+ func testMalformedExplicitConfigRejectsUnsignedHTTPManifest() async throws {
7
+ let body = Data(#"{"version":"1.0","sha256":"hash","size":1,"releaseId":"release-a","url":"https://example.test/bundle.zip"}"#.utf8)
8
+ let server = try DownloadHTTPServer(responses: [.init(status: 200, retryAfter: nil, body: body, advertisedLength: body.count)])
9
+ let url = try await server.start()
10
+ defer { server.stop() }
11
+ // Intentional self-hosted unsigned mode remains available without explicit keys.
12
+ let unsigned = try await ManifestClient.fetchLatest(cdnUrl: url.absoluteString, appId: "app",
13
+ channel: nil, runtimeVersion: nil, allowInsecureUrls: true)
14
+ XCTAssertNotNil(unsigned)
15
+ let keys = ManifestKeyConfig.parse(["manifestKeys": "mistyped"]).map { ManifestKey(kid: $0.kid, derData: $0.key) }
16
+ do {
17
+ _ = try await ManifestClient.fetchLatest(cdnUrl: url.absoluteString, appId: "app",
18
+ channel: nil, runtimeVersion: nil, allowInsecureUrls: true, manifestKeys: keys)
19
+ XCTFail("Malformed explicit keys must not accept an unsigned update")
20
+ } catch ManifestVerifierError.missingSignature {} // Expected fail-closed result.
21
+ XCTAssertEqual(server.requestCount, 2)
22
+ }
23
+
24
+ func testWrongTypeCannotSilentlyDisableVerification() {
25
+ for value: Any in ["mistyped", [String: String](), 123, true, NSNull()] {
26
+ XCTAssertFalse(ManifestKeyConfig.parse(["manifestKeys": value]).isEmpty, "\(value)")
27
+ }
28
+ }
29
+
30
+ func testAbsentAndEmptyRetainIntentionalDefaults() {
31
+ XCTAssertTrue(ManifestKeyConfig.parse([:]).isEmpty)
32
+ XCTAssertTrue(ManifestKeyConfig.parse(["manifestKeys": [Any]()]).isEmpty)
33
+ }
34
+
35
+ func testMalformedEntriesRemainFailClosed() {
36
+ for value: Any in [[[:]], [123], [["kid": "key"]]] {
37
+ XCTAssertFalse(ManifestKeyConfig.parse(["manifestKeys": value]).isEmpty)
38
+ }
39
+ }
40
+
41
+ func testValidConfiguredKeyIsPreserved() throws {
42
+ let key = try XCTUnwrap(HostedManifestKeys.defaults.first)
43
+ let parsed = ManifestKeyConfig.parse(["manifestKeys": [["kid": key.kid, "key": key.key.base64EncodedString()]]])
44
+ XCTAssertEqual(parsed.count, 1)
45
+ XCTAssertEqual(parsed.first?.kid, key.kid)
46
+ XCTAssertEqual(parsed.first?.key, key.key)
47
+ }
48
+ }
@@ -0,0 +1,34 @@
1
+ import Foundation
2
+ import XCTest
3
+ @testable import UpdaterPlugin
4
+
5
+ final class UpdateOwnerTests: XCTestCase {
6
+ func testLostBridgeCannotPublishOrActivateAnUpdate() throws {
7
+ let fixture = try CoordinatorFixture()
8
+ defer { try? fixture.cleanup() }
9
+ try fixture.installHealthy("A")
10
+ try fixture.stage("B")
11
+ var bridgeAvailable = true
12
+ let owner = UpdateOwner(isAvailable: { bridgeAvailable })
13
+ // A worker may have started before the bridge disappeared.
14
+ try owner.run {}
15
+ bridgeAvailable = false
16
+ XCTAssertThrowsError(try owner.run {
17
+ try fixture.coordinator.prepareApplyStaged(isCompatibleRuntime: { _ in true }, isBundleUsable: fixture.isUsable)
18
+ }) { XCTAssertTrue($0 is CancellationError) }
19
+ XCTAssertThrowsError(try owner.run { try fixture.stage("C") })
20
+ XCTAssertEqual(fixture.store.getCurrentBundle().id, "A")
21
+ XCTAssertEqual(fixture.store.getFallbackBundleId(), "A")
22
+ XCTAssertEqual(fixture.store.getStagedBundleId(), "B")
23
+ XCTAssertTrue(fixture.indexExists("A"))
24
+ XCTAssertFalse(fixture.indexExists("C"))
25
+ }
26
+
27
+ func testLiveOwnerCanStageActivateAndConfirm() throws {
28
+ let fixture = try CoordinatorFixture()
29
+ defer { try? fixture.cleanup() }
30
+ let owner = UpdateOwner(isAvailable: { true })
31
+ try owner.run { try fixture.installHealthy("A") }
32
+ XCTAssertEqual(fixture.store.getCurrentBundle().status, .success)
33
+ }
34
+ }