@hot-updater/react-native 0.36.7 → 1.0.0-rc.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 (239) hide show
  1. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +357 -6
  2. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +268 -34
  3. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +75 -9
  4. package/android/src/main/java/com/hotupdater/NativeConfigUtils.kt +2 -16
  5. package/android/src/main/java/com/hotupdater/ReleaseCatalogCacheService.kt +166 -0
  6. package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +1 -2
  7. package/android/src/newarch/HotUpdater.kt +1 -1
  8. package/android/src/newarch/HotUpdaterModule.kt +116 -0
  9. package/android/src/oldarch/HotUpdater.kt +1 -1
  10. package/android/src/oldarch/HotUpdaterModule.kt +128 -0
  11. package/android/src/oldarch/HotUpdaterSpec.kt +38 -0
  12. package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +280 -2
  13. package/android/src/test/java/com/hotupdater/HotUpdaterImplTest.kt +57 -2
  14. package/android/src/test/java/com/hotupdater/ReleaseCatalogCacheServiceTest.kt +118 -0
  15. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +491 -30
  16. package/ios/HotUpdater/Internal/BundleMetadata.swift +229 -6
  17. package/ios/HotUpdater/Internal/HotUpdater.mm +122 -0
  18. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +176 -7
  19. package/ios/HotUpdater/Internal/ReleaseCatalogCacheService.swift +213 -0
  20. package/lib/commonjs/catalogCacheNative.js +44 -0
  21. package/lib/commonjs/catalogCacheNative.js.map +1 -0
  22. package/lib/commonjs/catalogCacheNative.spec.js +31 -0
  23. package/lib/commonjs/catalogCacheNative.spec.js.map +1 -0
  24. package/lib/commonjs/checkForUpdate.js +231 -60
  25. package/lib/commonjs/checkForUpdate.js.map +1 -1
  26. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js +604 -0
  27. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js.map +1 -0
  28. package/lib/commonjs/error.js +8 -1
  29. package/lib/commonjs/error.js.map +1 -1
  30. package/lib/commonjs/{fetchUpdateInfo.js → fetchJSON.js} +11 -14
  31. package/lib/commonjs/fetchJSON.js.map +1 -0
  32. package/lib/commonjs/httpClient.js +153 -0
  33. package/lib/commonjs/httpClient.js.map +1 -0
  34. package/lib/commonjs/httpClient.spec.js +102 -0
  35. package/lib/commonjs/httpClient.spec.js.map +1 -0
  36. package/lib/commonjs/index.js +48 -71
  37. package/lib/commonjs/index.js.map +1 -1
  38. package/lib/commonjs/index.spec.js +117 -161
  39. package/lib/commonjs/index.spec.js.map +1 -1
  40. package/lib/commonjs/iosInterop.spec.js +18 -0
  41. package/lib/commonjs/iosInterop.spec.js.map +1 -0
  42. package/lib/commonjs/native.js +222 -55
  43. package/lib/commonjs/native.js.map +1 -1
  44. package/lib/commonjs/native.spec.js +226 -12
  45. package/lib/commonjs/native.spec.js.map +1 -1
  46. package/lib/commonjs/notifyAppReadyAnalytics.boundary.spec.js +218 -0
  47. package/lib/commonjs/notifyAppReadyAnalytics.boundary.spec.js.map +1 -0
  48. package/lib/commonjs/notifyAppReadyAnalytics.js +133 -0
  49. package/lib/commonjs/notifyAppReadyAnalytics.js.map +1 -0
  50. package/lib/commonjs/notifyAppReadyAnalytics.spec.js +246 -0
  51. package/lib/commonjs/notifyAppReadyAnalytics.spec.js.map +1 -0
  52. package/lib/commonjs/notifyAppReadyAnalytics.test-utils.js +24 -0
  53. package/lib/commonjs/notifyAppReadyAnalytics.test-utils.js.map +1 -0
  54. package/lib/commonjs/notifyAppReadyAnalytics.transport.spec.js +117 -0
  55. package/lib/commonjs/notifyAppReadyAnalytics.transport.spec.js.map +1 -0
  56. package/lib/commonjs/releaseCatalogCache.js +179 -0
  57. package/lib/commonjs/releaseCatalogCache.js.map +1 -0
  58. package/lib/commonjs/releaseCatalogCache.spec.js +195 -0
  59. package/lib/commonjs/releaseCatalogCache.spec.js.map +1 -0
  60. package/lib/commonjs/releaseCatalogCacheNativeContract.spec.js +64 -0
  61. package/lib/commonjs/releaseCatalogCacheNativeContract.spec.js.map +1 -0
  62. package/lib/commonjs/sdkVersion.js +1 -1
  63. package/lib/commonjs/sdkVersion.js.map +1 -1
  64. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  65. package/lib/commonjs/types.js +0 -12
  66. package/lib/commonjs/types.js.map +1 -1
  67. package/lib/commonjs/wrap.js +7 -82
  68. package/lib/commonjs/wrap.js.map +1 -1
  69. package/lib/commonjs/wrap.spec.js +203 -56
  70. package/lib/commonjs/wrap.spec.js.map +1 -1
  71. package/lib/commonjs/wrap.types.js +6 -0
  72. package/lib/commonjs/wrap.types.js.map +1 -0
  73. package/lib/module/catalogCacheNative.js +36 -0
  74. package/lib/module/catalogCacheNative.js.map +1 -0
  75. package/lib/module/catalogCacheNative.spec.js +31 -0
  76. package/lib/module/catalogCacheNative.spec.js.map +1 -0
  77. package/lib/module/checkForUpdate.js +233 -62
  78. package/lib/module/checkForUpdate.js.map +1 -1
  79. package/lib/module/checkForUpdate.releaseCatalog.spec.js +603 -0
  80. package/lib/module/checkForUpdate.releaseCatalog.spec.js.map +1 -0
  81. package/lib/module/error.js +6 -0
  82. package/lib/module/error.js.map +1 -1
  83. package/lib/module/{fetchUpdateInfo.js → fetchJSON.js} +9 -12
  84. package/lib/module/fetchJSON.js.map +1 -0
  85. package/lib/module/httpClient.js +148 -0
  86. package/lib/module/httpClient.js.map +1 -0
  87. package/lib/module/httpClient.spec.js +102 -0
  88. package/lib/module/httpClient.spec.js.map +1 -0
  89. package/lib/module/index.js +49 -66
  90. package/lib/module/index.js.map +1 -1
  91. package/lib/module/index.spec.js +117 -161
  92. package/lib/module/index.spec.js.map +1 -1
  93. package/lib/module/iosInterop.spec.js +18 -0
  94. package/lib/module/iosInterop.spec.js.map +1 -0
  95. package/lib/module/native.js +206 -51
  96. package/lib/module/native.js.map +1 -1
  97. package/lib/module/native.spec.js +226 -12
  98. package/lib/module/native.spec.js.map +1 -1
  99. package/lib/module/notifyAppReadyAnalytics.boundary.spec.js +217 -0
  100. package/lib/module/notifyAppReadyAnalytics.boundary.spec.js.map +1 -0
  101. package/lib/module/notifyAppReadyAnalytics.js +128 -0
  102. package/lib/module/notifyAppReadyAnalytics.js.map +1 -0
  103. package/lib/module/notifyAppReadyAnalytics.spec.js +245 -0
  104. package/lib/module/notifyAppReadyAnalytics.spec.js.map +1 -0
  105. package/lib/module/notifyAppReadyAnalytics.test-utils.js +18 -0
  106. package/lib/module/notifyAppReadyAnalytics.test-utils.js.map +1 -0
  107. package/lib/module/notifyAppReadyAnalytics.transport.spec.js +116 -0
  108. package/lib/module/notifyAppReadyAnalytics.transport.spec.js.map +1 -0
  109. package/lib/module/releaseCatalogCache.js +171 -0
  110. package/lib/module/releaseCatalogCache.js.map +1 -0
  111. package/lib/module/releaseCatalogCache.spec.js +195 -0
  112. package/lib/module/releaseCatalogCache.spec.js.map +1 -0
  113. package/lib/module/releaseCatalogCacheNativeContract.spec.js +64 -0
  114. package/lib/module/releaseCatalogCacheNativeContract.spec.js.map +1 -0
  115. package/lib/module/sdkVersion.js +1 -1
  116. package/lib/module/sdkVersion.js.map +1 -1
  117. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  118. package/lib/module/types.js +0 -12
  119. package/lib/module/types.js.map +1 -1
  120. package/lib/module/wrap.js +6 -81
  121. package/lib/module/wrap.js.map +1 -1
  122. package/lib/module/wrap.spec.js +203 -56
  123. package/lib/module/wrap.spec.js.map +1 -1
  124. package/lib/module/wrap.types.js +4 -0
  125. package/lib/module/wrap.types.js.map +1 -0
  126. package/lib/typescript/commonjs/catalogCacheNative.d.ts +4 -0
  127. package/lib/typescript/commonjs/catalogCacheNative.d.ts.map +1 -0
  128. package/lib/typescript/commonjs/checkForUpdate.d.ts +14 -4
  129. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  130. package/lib/typescript/commonjs/error.d.ts +3 -0
  131. package/lib/typescript/commonjs/error.d.ts.map +1 -1
  132. package/lib/typescript/commonjs/fetchJSON.d.ts +6 -0
  133. package/lib/typescript/commonjs/fetchJSON.d.ts.map +1 -0
  134. package/lib/typescript/commonjs/httpClient.d.ts +56 -0
  135. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -0
  136. package/lib/typescript/commonjs/index.d.ts +32 -41
  137. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  138. package/lib/typescript/commonjs/native.d.ts +83 -18
  139. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  140. package/lib/typescript/commonjs/notifyAppReadyAnalytics.d.ts +13 -0
  141. package/lib/typescript/commonjs/notifyAppReadyAnalytics.d.ts.map +1 -0
  142. package/lib/typescript/commonjs/notifyAppReadyAnalytics.test-utils.d.ts +8 -0
  143. package/lib/typescript/commonjs/notifyAppReadyAnalytics.test-utils.d.ts.map +1 -0
  144. package/lib/typescript/commonjs/releaseCatalogCache.d.ts +25 -0
  145. package/lib/typescript/commonjs/releaseCatalogCache.d.ts.map +1 -0
  146. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  147. package/lib/typescript/commonjs/sdkVersion.d.ts.map +1 -1
  148. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +40 -5
  149. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  150. package/lib/typescript/commonjs/types.d.ts +0 -118
  151. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  152. package/lib/typescript/commonjs/wrap.d.ts +2 -191
  153. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  154. package/lib/typescript/commonjs/wrap.types.d.ts +65 -0
  155. package/lib/typescript/commonjs/wrap.types.d.ts.map +1 -0
  156. package/lib/typescript/module/catalogCacheNative.d.ts +4 -0
  157. package/lib/typescript/module/catalogCacheNative.d.ts.map +1 -0
  158. package/lib/typescript/module/checkForUpdate.d.ts +14 -4
  159. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  160. package/lib/typescript/module/error.d.ts +3 -0
  161. package/lib/typescript/module/error.d.ts.map +1 -1
  162. package/lib/typescript/module/fetchJSON.d.ts +6 -0
  163. package/lib/typescript/module/fetchJSON.d.ts.map +1 -0
  164. package/lib/typescript/module/httpClient.d.ts +56 -0
  165. package/lib/typescript/module/httpClient.d.ts.map +1 -0
  166. package/lib/typescript/module/index.d.ts +32 -41
  167. package/lib/typescript/module/index.d.ts.map +1 -1
  168. package/lib/typescript/module/native.d.ts +83 -18
  169. package/lib/typescript/module/native.d.ts.map +1 -1
  170. package/lib/typescript/module/notifyAppReadyAnalytics.d.ts +13 -0
  171. package/lib/typescript/module/notifyAppReadyAnalytics.d.ts.map +1 -0
  172. package/lib/typescript/module/notifyAppReadyAnalytics.test-utils.d.ts +8 -0
  173. package/lib/typescript/module/notifyAppReadyAnalytics.test-utils.d.ts.map +1 -0
  174. package/lib/typescript/module/releaseCatalogCache.d.ts +25 -0
  175. package/lib/typescript/module/releaseCatalogCache.d.ts.map +1 -0
  176. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  177. package/lib/typescript/module/sdkVersion.d.ts.map +1 -1
  178. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +40 -5
  179. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  180. package/lib/typescript/module/types.d.ts +0 -118
  181. package/lib/typescript/module/types.d.ts.map +1 -1
  182. package/lib/typescript/module/wrap.d.ts +2 -191
  183. package/lib/typescript/module/wrap.d.ts.map +1 -1
  184. package/lib/typescript/module/wrap.types.d.ts +65 -0
  185. package/lib/typescript/module/wrap.types.d.ts.map +1 -0
  186. package/package.json +6 -24
  187. package/src/catalogCacheNative.spec.ts +63 -0
  188. package/src/catalogCacheNative.ts +74 -0
  189. package/src/checkForUpdate.releaseCatalog.spec.ts +596 -0
  190. package/src/checkForUpdate.ts +368 -97
  191. package/src/error.ts +7 -0
  192. package/src/{fetchUpdateInfo.ts → fetchJSON.ts} +8 -15
  193. package/src/httpClient.spec.ts +132 -0
  194. package/src/httpClient.ts +261 -0
  195. package/src/index.spec.ts +110 -203
  196. package/src/index.ts +70 -93
  197. package/src/iosInterop.spec.ts +30 -0
  198. package/src/native.spec.ts +226 -10
  199. package/src/native.ts +385 -83
  200. package/src/notifyAppReadyAnalytics.boundary.spec.ts +234 -0
  201. package/src/notifyAppReadyAnalytics.spec.ts +243 -0
  202. package/src/notifyAppReadyAnalytics.test-utils.ts +31 -0
  203. package/src/notifyAppReadyAnalytics.transport.spec.ts +130 -0
  204. package/src/notifyAppReadyAnalytics.ts +194 -0
  205. package/src/releaseCatalogCache.spec.ts +282 -0
  206. package/src/releaseCatalogCache.ts +341 -0
  207. package/src/releaseCatalogCacheNativeContract.spec.ts +80 -0
  208. package/src/sdkVersion.ts +1 -1
  209. package/src/specs/NativeHotUpdater.ts +51 -5
  210. package/src/types.ts +0 -140
  211. package/src/wrap.spec.ts +251 -56
  212. package/src/wrap.tsx +23 -318
  213. package/src/wrap.types.ts +78 -0
  214. package/android/src/main/java/com/hotupdater/StringResourceUtils.kt +0 -45
  215. package/app.plugin.js +0 -1
  216. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.h +0 -7
  217. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.mm +0 -4
  218. package/lib/commonjs/DefaultResolver.js +0 -50
  219. package/lib/commonjs/DefaultResolver.js.map +0 -1
  220. package/lib/commonjs/DefaultResolver.spec.js +0 -144
  221. package/lib/commonjs/DefaultResolver.spec.js.map +0 -1
  222. package/lib/commonjs/fetchUpdateInfo.js.map +0 -1
  223. package/lib/module/DefaultResolver.js +0 -46
  224. package/lib/module/DefaultResolver.js.map +0 -1
  225. package/lib/module/DefaultResolver.spec.js +0 -144
  226. package/lib/module/DefaultResolver.spec.js.map +0 -1
  227. package/lib/module/fetchUpdateInfo.js.map +0 -1
  228. package/lib/typescript/commonjs/DefaultResolver.d.ts +0 -11
  229. package/lib/typescript/commonjs/DefaultResolver.d.ts.map +0 -1
  230. package/lib/typescript/commonjs/fetchUpdateInfo.d.ts +0 -7
  231. package/lib/typescript/commonjs/fetchUpdateInfo.d.ts.map +0 -1
  232. package/lib/typescript/module/DefaultResolver.d.ts +0 -11
  233. package/lib/typescript/module/DefaultResolver.d.ts.map +0 -1
  234. package/lib/typescript/module/fetchUpdateInfo.d.ts +0 -7
  235. package/lib/typescript/module/fetchUpdateInfo.d.ts.map +0 -1
  236. package/plugin/build/transformers.js +0 -314
  237. package/plugin/build/withHotUpdater.js +0 -228
  238. package/src/DefaultResolver.spec.ts +0 -193
  239. package/src/DefaultResolver.ts +0 -63
@@ -0,0 +1,596 @@
1
+ import {
2
+ createReleaseCatalogScopeKey,
3
+ encodeChannelKey,
4
+ type PersistedSelectionReceipt,
5
+ type ReleaseCatalog,
6
+ } from "@hot-updater/core";
7
+ import { beforeEach, describe, expect, it, vi } from "vitest";
8
+
9
+ import type { HotUpdaterHttpClient } from "./httpClient";
10
+
11
+ const MINIMUM_RELEASE_ID = "00000000-0000-7000-8000-000000000001";
12
+ const RELEASE_ID = "00000000-0000-7000-8000-000000000002";
13
+ const ACTIVE_RELEASE_ID = "00000000-0000-7000-8000-000000000003";
14
+ const CURRENT_BUNDLE_ID = "00000000-0000-7001-8000-000000000001";
15
+ const TARGET_BUNDLE_ID = "00000000-0000-7001-8000-000000000002";
16
+ const CATALOG_ID = "project-a";
17
+ const CHANNEL = "production";
18
+ const CATALOG_HASH = `sha256:${"a".repeat(64)}`;
19
+ const SCOPE_KEY = createReleaseCatalogScopeKey({
20
+ channelKey: encodeChannelKey(CHANNEL),
21
+ platform: "ios",
22
+ strategy: "APP_VERSION",
23
+ });
24
+
25
+ const mocks = vi.hoisted(() => ({
26
+ addListener: vi.fn(() => () => {}),
27
+ acceptReleaseCatalog: vi.fn(() => true),
28
+ commitReleaseSelection: vi.fn(async () => true),
29
+ getActiveUpdateState: vi.fn(() => ({
30
+ activeSelection: null as PersistedSelectionReceipt | null,
31
+ highestSeenCatalogs: {},
32
+ stableSelection: null as PersistedSelectionReceipt | null,
33
+ verificationPending: false,
34
+ })),
35
+ getAppVersion: vi.fn(() => "1.2"),
36
+ getBundleId: vi.fn(() => CURRENT_BUNDLE_ID),
37
+ getChannel: vi.fn(() => CHANNEL),
38
+ getCohort: vi.fn(() => "123"),
39
+ getCrashHistory: vi.fn(() => []),
40
+ getDefaultChannel: vi.fn(() => CHANNEL),
41
+ getFingerprintHash: vi.fn(() => null),
42
+ getInstallId: vi.fn(() => "install-id"),
43
+ getMinimumReleaseId: vi.fn(() => MINIMUM_RELEASE_ID),
44
+ getPersistedUserIdentity: vi.fn(() => ({})),
45
+ isChannelSwitched: vi.fn(() => false),
46
+ isReleaseSelectionCurrent: vi.fn(() => true),
47
+ resetChannel: vi.fn(),
48
+ updateBundle: vi.fn(async () => true),
49
+ }));
50
+
51
+ vi.mock("react-native", () => ({ Platform: { OS: "ios" } }));
52
+
53
+ vi.mock("./native", () => mocks);
54
+
55
+ const createCatalog = (
56
+ overrides: Partial<ReleaseCatalog> = {},
57
+ ): ReleaseCatalog => ({
58
+ catalogId: CATALOG_ID,
59
+ catalogHash: CATALOG_HASH,
60
+ fallbackPolicy: "BUILTIN_IF_ACTIVE_INELIGIBLE",
61
+ generation: 2,
62
+ releases: [
63
+ {
64
+ bundleId: TARGET_BUNDLE_ID,
65
+ kind: "BUNDLE",
66
+ message: "Release two",
67
+ releaseId: RELEASE_ID,
68
+ rolloutCohortCount: 1000,
69
+ shouldForceUpdate: false,
70
+ targetCohorts: [],
71
+ },
72
+ ],
73
+ schemaVersion: 1,
74
+ scopeKey: SCOPE_KEY,
75
+ ...overrides,
76
+ });
77
+
78
+ const createClient = (catalog = createCatalog()) => {
79
+ const fetchReleaseCatalog = vi.fn(async () => catalog);
80
+ const resolveArtifact = vi.fn(async () => ({
81
+ fileHash: "bundle-hash",
82
+ fileUrl: "https://updates.example.com/bundle.zip",
83
+ }));
84
+ const sendAnalyticsEvent = vi.fn(async () => undefined);
85
+ const session = {
86
+ fetchReleaseCatalog,
87
+ resolveArtifact,
88
+ sendAnalyticsEvent,
89
+ };
90
+ const client: HotUpdaterHttpClient = {
91
+ createSession: vi.fn(async () => session),
92
+ };
93
+ return { client, fetchReleaseCatalog, resolveArtifact, sendAnalyticsEvent };
94
+ };
95
+
96
+ describe("checkForUpdate Release catalog protocol", () => {
97
+ beforeEach(() => {
98
+ vi.clearAllMocks();
99
+ vi.stubGlobal("__DEV__", false);
100
+ mocks.acceptReleaseCatalog.mockReturnValue(true);
101
+ mocks.getActiveUpdateState.mockReturnValue({
102
+ activeSelection: null,
103
+ highestSeenCatalogs: {},
104
+ stableSelection: null,
105
+ verificationPending: false,
106
+ });
107
+ mocks.isReleaseSelectionCurrent.mockReturnValue(true);
108
+ mocks.commitReleaseSelection.mockResolvedValue(true);
109
+ mocks.updateBundle.mockResolvedValue(true);
110
+ });
111
+
112
+ it("selects locally and defers Bundle artifact resolution until install", async () => {
113
+ const { checkForUpdate } = await import("./checkForUpdate");
114
+ const { client, fetchReleaseCatalog, resolveArtifact } = createClient();
115
+
116
+ const result = await checkForUpdate({
117
+ client,
118
+ updateStrategy: "appVersion",
119
+ });
120
+
121
+ expect(result).toMatchObject({
122
+ id: TARGET_BUNDLE_ID,
123
+ releaseId: RELEASE_ID,
124
+ transitionKind: "INSTALL",
125
+ });
126
+ expect(fetchReleaseCatalog).toHaveBeenCalledWith({
127
+ appVersion: "1.2.0",
128
+ channel: CHANNEL,
129
+ fingerprintHash: null,
130
+ platform: "ios",
131
+ requestHeaders: undefined,
132
+ requestTimeout: undefined,
133
+ updateStrategy: "appVersion",
134
+ });
135
+ expect(resolveArtifact).not.toHaveBeenCalled();
136
+
137
+ await expect(result?.updateBundle()).resolves.toBe(true);
138
+
139
+ expect(resolveArtifact).toHaveBeenCalledWith({
140
+ currentBundleId: CURRENT_BUNDLE_ID,
141
+ requestHeaders: undefined,
142
+ requestTimeout: undefined,
143
+ targetBundleId: TARGET_BUNDLE_ID,
144
+ });
145
+ expect(mocks.updateBundle).toHaveBeenCalledWith(
146
+ expect.objectContaining({
147
+ bundleId: TARGET_BUNDLE_ID,
148
+ selection: expect.objectContaining({
149
+ catalogId: CATALOG_ID,
150
+ releaseId: RELEASE_ID,
151
+ scopeKey: SCOPE_KEY,
152
+ }),
153
+ }),
154
+ );
155
+ });
156
+
157
+ it("adopts a newer Release for the same Bundle without resolving bytes", async () => {
158
+ const active: PersistedSelectionReceipt = {
159
+ catalogId: CATALOG_ID,
160
+ bundleId: TARGET_BUNDLE_ID,
161
+ catalogHash: `sha256:${"b".repeat(64)}`,
162
+ channel: CHANNEL,
163
+ generation: 1,
164
+ kind: "BUNDLE",
165
+ releaseId: MINIMUM_RELEASE_ID,
166
+ scopeKey: SCOPE_KEY,
167
+ selectionContextHash: "old-context",
168
+ };
169
+ mocks.getBundleId.mockReturnValueOnce(TARGET_BUNDLE_ID);
170
+ mocks.getActiveUpdateState.mockReturnValueOnce({
171
+ activeSelection: active,
172
+ highestSeenCatalogs: {},
173
+ stableSelection: active,
174
+ verificationPending: false,
175
+ });
176
+ const { checkForUpdate } = await import("./checkForUpdate");
177
+ const forceCatalog = createCatalog({
178
+ releases: [
179
+ {
180
+ ...createCatalog().releases[0]!,
181
+ shouldForceUpdate: true,
182
+ },
183
+ ],
184
+ });
185
+ const { client, resolveArtifact, sendAnalyticsEvent } =
186
+ createClient(forceCatalog);
187
+
188
+ const result = await checkForUpdate({
189
+ client,
190
+ updateStrategy: "appVersion",
191
+ });
192
+
193
+ expect(result).toMatchObject({
194
+ shouldForceUpdate: false,
195
+ transitionKind: "ADOPT_RELEASE",
196
+ });
197
+ await expect(result?.updateBundle()).resolves.toBe(true);
198
+ expect(resolveArtifact).not.toHaveBeenCalled();
199
+ expect(mocks.updateBundle).not.toHaveBeenCalled();
200
+ expect(sendAnalyticsEvent).not.toHaveBeenCalled();
201
+ expect(mocks.commitReleaseSelection).toHaveBeenCalledWith({
202
+ guard: expect.objectContaining({ generation: 2, scopeKey: SCOPE_KEY }),
203
+ selection: expect.objectContaining({
204
+ bundleId: TARGET_BUNDLE_ID,
205
+ releaseId: RELEASE_ID,
206
+ }),
207
+ });
208
+ });
209
+
210
+ it("sends RELEASE_ADOPTED only when analytics is enabled", async () => {
211
+ const active: PersistedSelectionReceipt = {
212
+ catalogId: CATALOG_ID,
213
+ bundleId: TARGET_BUNDLE_ID,
214
+ catalogHash: `sha256:${"b".repeat(64)}`,
215
+ channel: CHANNEL,
216
+ generation: 1,
217
+ kind: "BUNDLE",
218
+ releaseId: MINIMUM_RELEASE_ID,
219
+ scopeKey: SCOPE_KEY,
220
+ selectionContextHash: "old-context",
221
+ };
222
+ mocks.getBundleId.mockReturnValueOnce(TARGET_BUNDLE_ID);
223
+ mocks.getActiveUpdateState.mockReturnValueOnce({
224
+ activeSelection: active,
225
+ highestSeenCatalogs: {},
226
+ stableSelection: active,
227
+ verificationPending: false,
228
+ });
229
+ const { checkForUpdate } = await import("./checkForUpdate");
230
+ const { client, sendAnalyticsEvent } = createClient();
231
+
232
+ const result = await checkForUpdate({
233
+ analytics: true,
234
+ client,
235
+ updateStrategy: "appVersion",
236
+ });
237
+ await result?.updateBundle();
238
+
239
+ expect(sendAnalyticsEvent).toHaveBeenCalledWith(
240
+ expect.objectContaining({
241
+ fromReleaseId: MINIMUM_RELEASE_ID,
242
+ toReleaseId: RELEASE_ID,
243
+ type: "RELEASE_ADOPTED",
244
+ }),
245
+ );
246
+ });
247
+
248
+ it("selects a lower-id Release when explicitly switching scopes", async () => {
249
+ const betaChannel = "beta";
250
+ const betaScopeKey = createReleaseCatalogScopeKey({
251
+ channelKey: encodeChannelKey(betaChannel),
252
+ platform: "ios",
253
+ strategy: "APP_VERSION",
254
+ });
255
+ const active: PersistedSelectionReceipt = {
256
+ catalogId: CATALOG_ID,
257
+ bundleId: CURRENT_BUNDLE_ID,
258
+ catalogHash: `sha256:${"b".repeat(64)}`,
259
+ channel: CHANNEL,
260
+ generation: 1,
261
+ kind: "BUNDLE",
262
+ releaseId: ACTIVE_RELEASE_ID,
263
+ scopeKey: SCOPE_KEY,
264
+ selectionContextHash: "old-context",
265
+ };
266
+ mocks.getActiveUpdateState.mockReturnValueOnce({
267
+ activeSelection: active,
268
+ highestSeenCatalogs: {},
269
+ stableSelection: active,
270
+ verificationPending: false,
271
+ });
272
+ const betaCatalog = createCatalog({ scopeKey: betaScopeKey });
273
+ const { checkForUpdate } = await import("./checkForUpdate");
274
+ const { client } = createClient(betaCatalog);
275
+
276
+ const result = await checkForUpdate({
277
+ channel: betaChannel,
278
+ client,
279
+ updateStrategy: "appVersion",
280
+ });
281
+
282
+ expect(result).toMatchObject({
283
+ id: TARGET_BUNDLE_ID,
284
+ releaseId: RELEASE_ID,
285
+ status: "UPDATE",
286
+ transitionKind: "INSTALL",
287
+ });
288
+ });
289
+
290
+ it("refreshes the same Release receipt when catalog provenance changes", async () => {
291
+ const active: PersistedSelectionReceipt = {
292
+ catalogId: CATALOG_ID,
293
+ bundleId: TARGET_BUNDLE_ID,
294
+ catalogHash: `sha256:${"b".repeat(64)}`,
295
+ channel: CHANNEL,
296
+ generation: 1,
297
+ kind: "BUNDLE",
298
+ releaseId: RELEASE_ID,
299
+ scopeKey: SCOPE_KEY,
300
+ selectionContextHash: "old-context",
301
+ };
302
+ mocks.getBundleId.mockReturnValueOnce(TARGET_BUNDLE_ID);
303
+ mocks.getActiveUpdateState.mockReturnValueOnce({
304
+ activeSelection: active,
305
+ highestSeenCatalogs: {},
306
+ stableSelection: active,
307
+ verificationPending: false,
308
+ });
309
+ const { checkForUpdate } = await import("./checkForUpdate");
310
+ const { client, resolveArtifact } = createClient();
311
+
312
+ const result = await checkForUpdate({
313
+ client,
314
+ updateStrategy: "appVersion",
315
+ });
316
+
317
+ expect(result).toMatchObject({
318
+ id: TARGET_BUNDLE_ID,
319
+ releaseId: RELEASE_ID,
320
+ transitionKind: "ADOPT_RELEASE",
321
+ });
322
+ await expect(result?.updateBundle()).resolves.toBe(true);
323
+ expect(resolveArtifact).not.toHaveBeenCalled();
324
+ expect(mocks.updateBundle).not.toHaveBeenCalled();
325
+ expect(mocks.commitReleaseSelection).toHaveBeenCalledWith({
326
+ guard: expect.objectContaining({
327
+ catalogHash: CATALOG_HASH,
328
+ generation: 2,
329
+ scopeKey: SCOPE_KEY,
330
+ }),
331
+ selection: expect.objectContaining({
332
+ bundleId: TARGET_BUNDLE_ID,
333
+ catalogHash: CATALOG_HASH,
334
+ generation: 2,
335
+ releaseId: RELEASE_ID,
336
+ }),
337
+ });
338
+ });
339
+
340
+ it("rejects a slow artifact completion after a newer catalog wins", async () => {
341
+ mocks.isReleaseSelectionCurrent
342
+ .mockReturnValueOnce(true)
343
+ .mockReturnValueOnce(false);
344
+ const { StaleReleaseCatalogError } = await import("./error");
345
+ const { checkForUpdate } = await import("./checkForUpdate");
346
+ const { client } = createClient();
347
+ const result = await checkForUpdate({
348
+ client,
349
+ updateStrategy: "appVersion",
350
+ });
351
+
352
+ await expect(result?.updateBundle()).rejects.toBeInstanceOf(
353
+ StaleReleaseCatalogError,
354
+ );
355
+ expect(mocks.updateBundle).not.toHaveBeenCalled();
356
+ });
357
+
358
+ it("installs an older enabled Release as a forced rollback", async () => {
359
+ const active: PersistedSelectionReceipt = {
360
+ catalogId: CATALOG_ID,
361
+ bundleId: CURRENT_BUNDLE_ID,
362
+ catalogHash: `sha256:${"b".repeat(64)}`,
363
+ channel: CHANNEL,
364
+ generation: 1,
365
+ kind: "BUNDLE",
366
+ releaseId: ACTIVE_RELEASE_ID,
367
+ scopeKey: SCOPE_KEY,
368
+ selectionContextHash: "old-context",
369
+ };
370
+ mocks.getActiveUpdateState.mockReturnValueOnce({
371
+ activeSelection: active,
372
+ highestSeenCatalogs: {},
373
+ stableSelection: active,
374
+ verificationPending: false,
375
+ });
376
+ const catalog = createCatalog({
377
+ rollbackReleases: [
378
+ {
379
+ bundleId: TARGET_BUNDLE_ID,
380
+ kind: "BUNDLE",
381
+ message: "Previous Release",
382
+ releaseId: RELEASE_ID,
383
+ rolloutCohortCount: 0,
384
+ shouldForceUpdate: false,
385
+ targetCohorts: [],
386
+ },
387
+ ],
388
+ });
389
+ const { checkForUpdate } = await import("./checkForUpdate");
390
+ const { client } = createClient(catalog);
391
+
392
+ const result = await checkForUpdate({
393
+ client,
394
+ updateStrategy: "appVersion",
395
+ });
396
+
397
+ expect(result).toMatchObject({
398
+ id: TARGET_BUNDLE_ID,
399
+ releaseId: RELEASE_ID,
400
+ shouldForceUpdate: true,
401
+ status: "ROLLBACK",
402
+ transitionKind: "INSTALL",
403
+ });
404
+ await expect(result?.updateBundle()).resolves.toBe(true);
405
+ expect(mocks.updateBundle).toHaveBeenCalledWith(
406
+ expect.objectContaining({
407
+ bundleId: TARGET_BUNDLE_ID,
408
+ status: "ROLLBACK",
409
+ }),
410
+ );
411
+ });
412
+
413
+ it("uses current Bundle identity when migrating an unauthenticated receipt", async () => {
414
+ const migratedCurrentBundleId = "00000000-0000-7001-8000-000000000003";
415
+ const active: PersistedSelectionReceipt = {
416
+ catalogId: null,
417
+ bundleId: migratedCurrentBundleId,
418
+ catalogHash: null,
419
+ channel: CHANNEL,
420
+ generation: null,
421
+ kind: "BUNDLE",
422
+ releaseId: null,
423
+ scopeKey: null,
424
+ selectionContextHash: null,
425
+ };
426
+ mocks.getBundleId.mockReturnValueOnce(migratedCurrentBundleId);
427
+ mocks.getActiveUpdateState.mockReturnValueOnce({
428
+ activeSelection: active,
429
+ highestSeenCatalogs: {},
430
+ stableSelection: active,
431
+ verificationPending: false,
432
+ });
433
+ const catalog = createCatalog({
434
+ releases: [],
435
+ rollbackReleases: [
436
+ {
437
+ ...createCatalog().releases[0]!,
438
+ rolloutCohortCount: 0,
439
+ },
440
+ ],
441
+ });
442
+ const { checkForUpdate } = await import("./checkForUpdate");
443
+ const { client } = createClient(catalog);
444
+
445
+ const result = await checkForUpdate({
446
+ client,
447
+ updateStrategy: "appVersion",
448
+ });
449
+
450
+ expect(result).toMatchObject({
451
+ id: TARGET_BUNDLE_ID,
452
+ shouldForceUpdate: true,
453
+ status: "ROLLBACK",
454
+ transitionKind: "INSTALL",
455
+ });
456
+ });
457
+
458
+ it("returns a forced built-in rollback only for an active OTA", async () => {
459
+ const active: PersistedSelectionReceipt = {
460
+ catalogId: CATALOG_ID,
461
+ bundleId: CURRENT_BUNDLE_ID,
462
+ catalogHash: `sha256:${"b".repeat(64)}`,
463
+ channel: CHANNEL,
464
+ generation: 1,
465
+ kind: "BUNDLE",
466
+ releaseId: ACTIVE_RELEASE_ID,
467
+ scopeKey: SCOPE_KEY,
468
+ selectionContextHash: "old-context",
469
+ };
470
+ mocks.getActiveUpdateState.mockReturnValueOnce({
471
+ activeSelection: active,
472
+ highestSeenCatalogs: {},
473
+ stableSelection: active,
474
+ verificationPending: false,
475
+ });
476
+ const { checkForUpdate } = await import("./checkForUpdate");
477
+ const { client } = createClient(
478
+ createCatalog({ releases: [], rollbackReleases: [] }),
479
+ );
480
+
481
+ const result = await checkForUpdate({
482
+ client,
483
+ updateStrategy: "appVersion",
484
+ });
485
+
486
+ expect(result).toMatchObject({
487
+ id: MINIMUM_RELEASE_ID,
488
+ releaseId: null,
489
+ shouldForceUpdate: true,
490
+ status: "ROLLBACK",
491
+ transitionKind: "USE_BUILTIN",
492
+ });
493
+ await expect(result?.updateBundle()).resolves.toBe(true);
494
+ expect(mocks.commitReleaseSelection).toHaveBeenCalledWith(
495
+ expect.objectContaining({
496
+ selection: expect.objectContaining({
497
+ kind: "BUILTIN",
498
+ releaseId: null,
499
+ }),
500
+ }),
501
+ );
502
+ });
503
+
504
+ it("does not surface a rollback when the app already uses built-in bytes", async () => {
505
+ mocks.getBundleId.mockReturnValueOnce(MINIMUM_RELEASE_ID);
506
+ const { checkForUpdate } = await import("./checkForUpdate");
507
+ const { client } = createClient(
508
+ createCatalog({ releases: [], rollbackReleases: [] }),
509
+ );
510
+
511
+ await expect(
512
+ checkForUpdate({ client, updateStrategy: "appVersion" }),
513
+ ).resolves.toBeNull();
514
+ expect(mocks.commitReleaseSelection).not.toHaveBeenCalled();
515
+ });
516
+
517
+ it("reports a rejected generation without selecting or resolving artifacts", async () => {
518
+ mocks.acceptReleaseCatalog.mockReturnValueOnce(false);
519
+ const onError = vi.fn();
520
+ const { checkForUpdate } = await import("./checkForUpdate");
521
+ const { client, resolveArtifact } = createClient();
522
+
523
+ await expect(
524
+ checkForUpdate({ client, onError, updateStrategy: "appVersion" }),
525
+ ).resolves.toBeNull();
526
+
527
+ expect(onError).toHaveBeenCalledWith(
528
+ expect.objectContaining({
529
+ message: "Rejected a stale or inconsistent Release catalog",
530
+ }),
531
+ );
532
+ expect(resolveArtifact).not.toHaveBeenCalled();
533
+ });
534
+
535
+ it("rejects mismatched catalog scope before native catalog state mutates", async () => {
536
+ const wrongScope = createReleaseCatalogScopeKey({
537
+ channelKey: encodeChannelKey("beta"),
538
+ platform: "ios",
539
+ strategy: "APP_VERSION",
540
+ });
541
+ const onError = vi.fn();
542
+ const { checkForUpdate } = await import("./checkForUpdate");
543
+ const { client } = createClient(createCatalog({ scopeKey: wrongScope }));
544
+
545
+ await expect(
546
+ checkForUpdate({ client, onError, updateStrategy: "appVersion" }),
547
+ ).resolves.toBeNull();
548
+
549
+ expect(onError).toHaveBeenCalledWith(
550
+ expect.objectContaining({
551
+ message: "Received an invalid Release catalog",
552
+ }),
553
+ );
554
+ expect(mocks.acceptReleaseCatalog).not.toHaveBeenCalled();
555
+ expect(mocks.commitReleaseSelection).not.toHaveBeenCalled();
556
+ expect(mocks.updateBundle).not.toHaveBeenCalled();
557
+ });
558
+
559
+ it("rejects an unexpected Catalog identity before accepting catalog state", async () => {
560
+ const active: PersistedSelectionReceipt = {
561
+ catalogId: "existing-project",
562
+ bundleId: CURRENT_BUNDLE_ID,
563
+ catalogHash: `sha256:${"b".repeat(64)}`,
564
+ channel: CHANNEL,
565
+ generation: 1,
566
+ kind: "BUNDLE",
567
+ releaseId: ACTIVE_RELEASE_ID,
568
+ scopeKey: createReleaseCatalogScopeKey({
569
+ channelKey: encodeChannelKey(CHANNEL),
570
+ platform: "ios",
571
+ strategy: "APP_VERSION",
572
+ }),
573
+ selectionContextHash: "old-context",
574
+ };
575
+ mocks.getActiveUpdateState.mockReturnValueOnce({
576
+ activeSelection: active,
577
+ highestSeenCatalogs: {},
578
+ stableSelection: active,
579
+ verificationPending: false,
580
+ });
581
+ const onError = vi.fn();
582
+ const { checkForUpdate } = await import("./checkForUpdate");
583
+ const { client } = createClient();
584
+
585
+ await expect(
586
+ checkForUpdate({ client, onError, updateStrategy: "appVersion" }),
587
+ ).resolves.toBeNull();
588
+
589
+ expect(onError).toHaveBeenCalledWith(
590
+ expect.objectContaining({
591
+ message: "Release transition rejected: UNSOLICITED_SCOPE",
592
+ }),
593
+ );
594
+ expect(mocks.acceptReleaseCatalog).not.toHaveBeenCalled();
595
+ });
596
+ });