@hot-updater/react-native 0.36.7 → 1.0.0-rc.1

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 +47 -70
  37. package/lib/commonjs/index.js.map +1 -1
  38. package/lib/commonjs/index.spec.js +115 -159
  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 -53
  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 +48 -65
  90. package/lib/module/index.js.map +1 -1
  91. package/lib/module/index.spec.js +115 -159
  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 -49
  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 +31 -40
  137. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  138. package/lib/typescript/commonjs/native.d.ts +84 -17
  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 +31 -40
  167. package/lib/typescript/module/index.d.ts.map +1 -1
  168. package/lib/typescript/module/native.d.ts +84 -17
  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 +367 -96
  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 +108 -201
  196. package/src/index.ts +68 -91
  197. package/src/iosInterop.spec.ts +30 -0
  198. package/src/native.spec.ts +226 -10
  199. package/src/native.ts +385 -81
  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,234 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import type {
4
+ NotifyAppReadyAnalyticsEvent,
5
+ NotifyAppReadyResult,
6
+ } from "./native";
7
+ import {
8
+ createNotifyReadResult,
9
+ stubNotifyFrame,
10
+ } from "./notifyAppReadyAnalytics.test-utils";
11
+
12
+ const platformState = vi.hoisted((): { OS: "ios" | "android" } => ({
13
+ OS: "ios",
14
+ }));
15
+
16
+ vi.mock("react-native", () => ({
17
+ NativeEventEmitter: class {},
18
+ Platform: platformState,
19
+ }));
20
+
21
+ const nativePersistence = vi.hoisted(() => {
22
+ let persistedInstallId = "install-persisted";
23
+
24
+ return {
25
+ getConstants: vi.fn(() => ({
26
+ APP_VERSION: "1.0.0",
27
+ CHANNEL: "production",
28
+ DEFAULT_CHANNEL: "production",
29
+ FINGERPRINT_HASH: null,
30
+ MIN_BUNDLE_ID: "min-bundle-id",
31
+ })),
32
+ getInstallId: vi.fn(() => persistedInstallId),
33
+ setPersistedInstallId: (installId: string) => {
34
+ persistedInstallId = installId;
35
+ },
36
+ };
37
+ });
38
+
39
+ const mocks = vi.hoisted(() => ({
40
+ addListener: vi.fn(() => () => {}),
41
+ checkForUpdate: vi.fn(),
42
+ getAppVersion: vi.fn<() => string | undefined>(() => "1.0.0"),
43
+ getBundleId: vi.fn<() => string | undefined>(() => "bundle-id"),
44
+ getChannel: vi.fn(() => "production"),
45
+ getCohort: vi.fn(() => "123"),
46
+ getFingerprintHash: vi.fn(() => "fingerprint-hash"),
47
+ getInstallId: vi.fn<() => string | undefined>(() => "install-id"),
48
+ getPersistedUserIdentity: vi.fn(() => ({})),
49
+ readNotifyAppReady: vi.fn<
50
+ () => {
51
+ analyticsEvent: NotifyAppReadyAnalyticsEvent | null;
52
+ pending: boolean;
53
+ result: NotifyAppReadyResult;
54
+ }
55
+ >(() => createNotifyReadResult()),
56
+ reload: vi.fn(),
57
+ }));
58
+
59
+ vi.mock("./checkForUpdate", () => ({
60
+ checkForUpdate: mocks.checkForUpdate,
61
+ }));
62
+
63
+ vi.mock("./native", () => ({
64
+ addListener: mocks.addListener,
65
+ getAppVersion: mocks.getAppVersion,
66
+ getBundleId: mocks.getBundleId,
67
+ getChannel: mocks.getChannel,
68
+ getCohort: mocks.getCohort,
69
+ getFingerprintHash: mocks.getFingerprintHash,
70
+ getInstallId: mocks.getInstallId,
71
+ getPersistedUserIdentity: mocks.getPersistedUserIdentity,
72
+ readNotifyAppReady: mocks.readNotifyAppReady,
73
+ reload: mocks.reload,
74
+ }));
75
+
76
+ vi.mock("./specs/NativeHotUpdater", () => ({
77
+ default: nativePersistence,
78
+ }));
79
+
80
+ const createClient = () => {
81
+ const sendAnalyticsEvent = vi.fn().mockResolvedValue(undefined);
82
+ return {
83
+ client: {
84
+ createSession: vi.fn(async () => ({
85
+ fetchReleaseCatalog: vi.fn(),
86
+ resolveArtifact: vi.fn(),
87
+ sendAnalyticsEvent,
88
+ })),
89
+ },
90
+ sendAnalyticsEvent,
91
+ };
92
+ };
93
+
94
+ describe("automatic notifyAppReady analytics boundaries", () => {
95
+ beforeEach(() => {
96
+ vi.resetModules();
97
+ vi.unstubAllGlobals();
98
+ vi.useRealTimers();
99
+ platformState.OS = "ios";
100
+
101
+ for (const mock of Object.values(mocks)) {
102
+ mock.mockReset();
103
+ }
104
+
105
+ mocks.addListener.mockReturnValue(() => {});
106
+ mocks.getAppVersion.mockReturnValue("1.0.0");
107
+ mocks.getBundleId.mockReturnValue("bundle-id");
108
+ mocks.getChannel.mockReturnValue("production");
109
+ mocks.getCohort.mockReturnValue("123");
110
+ mocks.getFingerprintHash.mockReturnValue("fingerprint-hash");
111
+ mocks.getInstallId.mockReturnValue("install-id");
112
+ mocks.getPersistedUserIdentity.mockReturnValue({});
113
+ mocks.readNotifyAppReady.mockReturnValue(createNotifyReadResult());
114
+ nativePersistence.getConstants.mockClear();
115
+ nativePersistence.getInstallId.mockClear();
116
+ nativePersistence.setPersistedInstallId("install-persisted");
117
+ });
118
+
119
+ it("warns when the private HTTP session fails and preserves readiness", async () => {
120
+ stubNotifyFrame();
121
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
122
+ const onError = vi.fn();
123
+ const onNotifyAppReady = vi.fn();
124
+ const { init } = await import("./wrap");
125
+
126
+ init({
127
+ analytics: true,
128
+ client: {
129
+ createSession: vi.fn(async () => {
130
+ throw new Error("baseURL unavailable");
131
+ }),
132
+ },
133
+ onError,
134
+ onNotifyAppReady,
135
+ });
136
+ await vi.runOnlyPendingTimersAsync();
137
+
138
+ expect(onError).not.toHaveBeenCalled();
139
+ expect(onNotifyAppReady).toHaveBeenCalledWith({ status: "UNCHANGED" });
140
+ expect(warn).toHaveBeenCalledWith(
141
+ "[HotUpdater] Automatic notifyAppReady analytics failed:",
142
+ expect.objectContaining({
143
+ message: "baseURL unavailable",
144
+ }),
145
+ );
146
+ warn.mockRestore();
147
+ });
148
+
149
+ it.each([
150
+ { label: "throws", value: new Error("native install id unavailable") },
151
+ { label: "returns no identity", value: undefined },
152
+ ])(
153
+ "warns for a missing native install identity when it $label",
154
+ async ({ value }) => {
155
+ stubNotifyFrame();
156
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
157
+ const onError = vi.fn();
158
+ const onNotifyAppReady = vi.fn();
159
+ if (value instanceof Error) {
160
+ mocks.getInstallId.mockImplementation(() => {
161
+ throw value;
162
+ });
163
+ } else {
164
+ mocks.getInstallId.mockReturnValue(value);
165
+ }
166
+ const { client, sendAnalyticsEvent } = createClient();
167
+ const { init } = await import("./wrap");
168
+
169
+ init({ analytics: true, client, onError, onNotifyAppReady });
170
+ await vi.runOnlyPendingTimersAsync();
171
+
172
+ expect(sendAnalyticsEvent).not.toHaveBeenCalled();
173
+ expect(onError).not.toHaveBeenCalled();
174
+ expect(onNotifyAppReady).toHaveBeenCalledWith({ status: "UNCHANGED" });
175
+ expect(warn).toHaveBeenCalledWith(
176
+ "[HotUpdater] Automatic notifyAppReady analytics failed:",
177
+ expect.any(Error),
178
+ );
179
+ warn.mockRestore();
180
+ },
181
+ );
182
+
183
+ it.each([
184
+ { label: "throws", value: new Error("native bundle id unavailable") },
185
+ { label: "returns no bundle", value: undefined },
186
+ ])(
187
+ "warns for a missing current bundle when getBundleId $label",
188
+ async ({ value }) => {
189
+ stubNotifyFrame();
190
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
191
+ const onError = vi.fn();
192
+ const onNotifyAppReady = vi.fn();
193
+ if (value instanceof Error) {
194
+ mocks.getBundleId.mockImplementation(() => {
195
+ throw value;
196
+ });
197
+ } else {
198
+ mocks.getBundleId.mockReturnValue(value);
199
+ }
200
+ const { client, sendAnalyticsEvent } = createClient();
201
+ const { init } = await import("./wrap");
202
+
203
+ init({ analytics: true, client, onError, onNotifyAppReady });
204
+ await vi.runOnlyPendingTimersAsync();
205
+
206
+ expect(sendAnalyticsEvent).not.toHaveBeenCalled();
207
+ expect(onError).not.toHaveBeenCalled();
208
+ expect(onNotifyAppReady).toHaveBeenCalledWith({ status: "UNCHANGED" });
209
+ expect(warn).toHaveBeenCalledWith(
210
+ "[HotUpdater] Automatic notifyAppReady analytics failed:",
211
+ expect.any(Error),
212
+ );
213
+ warn.mockRestore();
214
+ },
215
+ );
216
+
217
+ it.each(["ios", "android"] as const)(
218
+ "reads persisted install id through native.ts across JS module resets on %s",
219
+ async (platform) => {
220
+ platformState.OS = platform;
221
+ nativePersistence.setPersistedInstallId("install-persisted");
222
+ const native =
223
+ await vi.importActual<typeof import("./native")>("./native");
224
+
225
+ expect(native.getInstallId()).toBe("install-persisted");
226
+ vi.resetModules();
227
+ const nativeAfterReset =
228
+ await vi.importActual<typeof import("./native")>("./native");
229
+
230
+ expect(nativeAfterReset.getInstallId()).toBe("install-persisted");
231
+ expect(nativePersistence.getInstallId).toHaveBeenCalledTimes(2);
232
+ },
233
+ );
234
+ });
@@ -0,0 +1,243 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import type {
4
+ NotifyAppReadyAnalyticsEvent,
5
+ NotifyAppReadyResult,
6
+ } from "./native";
7
+ import {
8
+ createNotifyReadResult,
9
+ stubNotifyFrame,
10
+ } from "./notifyAppReadyAnalytics.test-utils";
11
+
12
+ vi.mock("react-native", () => ({
13
+ Platform: { OS: "ios" },
14
+ }));
15
+
16
+ const mocks = vi.hoisted(() => ({
17
+ addListener: vi.fn(() => () => {}),
18
+ checkForUpdate: vi.fn(),
19
+ getAppVersion: vi.fn(() => "1.0.0"),
20
+ getBundleId: vi.fn(() => "bundle-id"),
21
+ getChannel: vi.fn(() => "production"),
22
+ getCohort: vi.fn(() => "123"),
23
+ getFingerprintHash: vi.fn(() => "fingerprint-hash"),
24
+ getInstallId: vi.fn(() => "install-id"),
25
+ getPersistedUserIdentity: vi.fn(() => ({})),
26
+ readNotifyAppReady: vi.fn<
27
+ () => {
28
+ analyticsEvent: NotifyAppReadyAnalyticsEvent | null;
29
+ pending: boolean;
30
+ result: NotifyAppReadyResult;
31
+ }
32
+ >(() => createNotifyReadResult()),
33
+ reload: vi.fn(),
34
+ }));
35
+
36
+ vi.mock("./checkForUpdate", () => ({
37
+ checkForUpdate: mocks.checkForUpdate,
38
+ }));
39
+
40
+ vi.mock("./native", () => ({
41
+ addListener: mocks.addListener,
42
+ getAppVersion: mocks.getAppVersion,
43
+ getBundleId: mocks.getBundleId,
44
+ getChannel: mocks.getChannel,
45
+ getCohort: mocks.getCohort,
46
+ getFingerprintHash: mocks.getFingerprintHash,
47
+ getInstallId: mocks.getInstallId,
48
+ getPersistedUserIdentity: mocks.getPersistedUserIdentity,
49
+ readNotifyAppReady: mocks.readNotifyAppReady,
50
+ reload: mocks.reload,
51
+ }));
52
+
53
+ const createClient = () => {
54
+ const sendAnalyticsEvent = vi.fn().mockResolvedValue(undefined);
55
+ return {
56
+ client: {
57
+ createSession: vi.fn(async () => ({
58
+ fetchReleaseCatalog: vi.fn(),
59
+ resolveArtifact: vi.fn(),
60
+ sendAnalyticsEvent,
61
+ })),
62
+ },
63
+ sendAnalyticsEvent,
64
+ };
65
+ };
66
+
67
+ describe("automatic notifyAppReady analytics", () => {
68
+ beforeEach(() => {
69
+ vi.resetModules();
70
+ vi.unstubAllGlobals();
71
+ vi.useRealTimers();
72
+
73
+ for (const mock of Object.values(mocks)) {
74
+ mock.mockReset();
75
+ }
76
+
77
+ mocks.getAppVersion.mockReturnValue("1.0.0");
78
+ mocks.addListener.mockReturnValue(() => {});
79
+ mocks.getBundleId.mockReturnValue("bundle-id");
80
+ mocks.getChannel.mockReturnValue("production");
81
+ mocks.getCohort.mockReturnValue("123");
82
+ mocks.getFingerprintHash.mockReturnValue("fingerprint-hash");
83
+ mocks.getInstallId.mockReturnValue("install-id");
84
+ mocks.getPersistedUserIdentity.mockReturnValue({});
85
+ mocks.readNotifyAppReady.mockReturnValue(createNotifyReadResult());
86
+ });
87
+
88
+ it("sends UPDATE_APPLIED with transition metadata and invokes readiness", async () => {
89
+ stubNotifyFrame();
90
+ mocks.getPersistedUserIdentity.mockReturnValue({
91
+ userId: "user-123",
92
+ username: "alice",
93
+ });
94
+ mocks.readNotifyAppReady.mockReturnValue(
95
+ createNotifyReadResult(
96
+ {
97
+ fromBundleId: "bundle-a",
98
+ status: "UPDATE_APPLIED",
99
+ toBundleId: "bundle-b",
100
+ },
101
+ {
102
+ fromBundleId: "bundle-a",
103
+ toBundleId: "bundle-b",
104
+ type: "UPDATE_APPLIED",
105
+ updateStrategy: "fingerprint",
106
+ },
107
+ ),
108
+ );
109
+
110
+ const { client, sendAnalyticsEvent } = createClient();
111
+ const onNotifyAppReady = vi.fn();
112
+ const { init } = await import("./wrap");
113
+
114
+ init({
115
+ analytics: true,
116
+ onNotifyAppReady,
117
+ requestHeaders: { Authorization: "Bearer token" },
118
+ requestTimeout: 1000,
119
+ client,
120
+ });
121
+ await vi.runOnlyPendingTimersAsync();
122
+
123
+ expect(sendAnalyticsEvent).toHaveBeenCalledWith({
124
+ appVersion: "1.0.0",
125
+ channel: "production",
126
+ cohort: "123",
127
+ fingerprintHash: "fingerprint-hash",
128
+ fromBundleId: "bundle-a",
129
+ fromReleaseId: null,
130
+ installId: "install-id",
131
+ platform: "ios",
132
+ requestHeaders: { Authorization: "Bearer token" },
133
+ requestTimeout: 1000,
134
+ toBundleId: "bundle-b",
135
+ toReleaseId: null,
136
+ type: "UPDATE_APPLIED",
137
+ updateStrategy: "fingerprint",
138
+ userId: "user-123",
139
+ username: "alice",
140
+ });
141
+ expect(onNotifyAppReady).toHaveBeenCalledWith({
142
+ fromBundleId: "bundle-a",
143
+ status: "UPDATE_APPLIED",
144
+ toBundleId: "bundle-b",
145
+ });
146
+ });
147
+
148
+ it("sends RECOVERED with directional ids and invokes readiness", async () => {
149
+ stubNotifyFrame();
150
+ mocks.readNotifyAppReady.mockReturnValue(
151
+ createNotifyReadResult(
152
+ {
153
+ fromBundleId: "bundle-b",
154
+ status: "RECOVERED",
155
+ toBundleId: "bundle-a",
156
+ },
157
+ {
158
+ fromBundleId: "bundle-b",
159
+ toBundleId: "bundle-a",
160
+ type: "RECOVERED",
161
+ updateStrategy: "appVersion",
162
+ },
163
+ ),
164
+ );
165
+ const { client, sendAnalyticsEvent } = createClient();
166
+ const onNotifyAppReady = vi.fn();
167
+ const { init } = await import("./wrap");
168
+
169
+ init({ analytics: true, client, onNotifyAppReady });
170
+ await vi.runOnlyPendingTimersAsync();
171
+
172
+ expect(sendAnalyticsEvent).toHaveBeenCalledWith({
173
+ appVersion: "1.0.0",
174
+ channel: "production",
175
+ cohort: "123",
176
+ fingerprintHash: "fingerprint-hash",
177
+ fromBundleId: "bundle-b",
178
+ fromReleaseId: null,
179
+ installId: "install-id",
180
+ platform: "ios",
181
+ requestHeaders: undefined,
182
+ requestTimeout: undefined,
183
+ toBundleId: "bundle-a",
184
+ toReleaseId: null,
185
+ type: "RECOVERED",
186
+ updateStrategy: "appVersion",
187
+ });
188
+ expect(onNotifyAppReady).toHaveBeenCalledWith({
189
+ fromBundleId: "bundle-b",
190
+ status: "RECOVERED",
191
+ toBundleId: "bundle-a",
192
+ });
193
+ });
194
+
195
+ it("sends one UNCHANGED event for repeated init calls", async () => {
196
+ stubNotifyFrame();
197
+ const { client, sendAnalyticsEvent } = createClient();
198
+ const onNotifyAppReady = vi.fn();
199
+ const { init } = await import("./wrap");
200
+
201
+ init({ analytics: true, client, onNotifyAppReady });
202
+ init({ analytics: true, client, onNotifyAppReady });
203
+ await vi.runOnlyPendingTimersAsync();
204
+
205
+ expect(sendAnalyticsEvent).toHaveBeenCalledTimes(1);
206
+ expect(sendAnalyticsEvent).toHaveBeenCalledWith({
207
+ appVersion: "1.0.0",
208
+ channel: "production",
209
+ cohort: "123",
210
+ fingerprintHash: "fingerprint-hash",
211
+ fromBundleId: null,
212
+ fromReleaseId: null,
213
+ installId: "install-id",
214
+ platform: "ios",
215
+ requestHeaders: undefined,
216
+ requestTimeout: undefined,
217
+ toBundleId: "bundle-id",
218
+ toReleaseId: null,
219
+ type: "UNCHANGED",
220
+ updateStrategy: null,
221
+ });
222
+ expect(onNotifyAppReady).toHaveBeenCalledTimes(2);
223
+ expect(onNotifyAppReady).toHaveBeenNthCalledWith(1, {
224
+ status: "UNCHANGED",
225
+ });
226
+ expect(onNotifyAppReady).toHaveBeenNthCalledWith(2, {
227
+ status: "UNCHANGED",
228
+ });
229
+ });
230
+
231
+ it("skips automatic analytics when disabled while preserving readiness", async () => {
232
+ stubNotifyFrame();
233
+ const { client, sendAnalyticsEvent } = createClient();
234
+ const onNotifyAppReady = vi.fn();
235
+ const { init } = await import("./wrap");
236
+
237
+ init({ analytics: false, client, onNotifyAppReady });
238
+ await vi.runOnlyPendingTimersAsync();
239
+
240
+ expect(sendAnalyticsEvent).not.toHaveBeenCalled();
241
+ expect(onNotifyAppReady).toHaveBeenCalledWith({ status: "UNCHANGED" });
242
+ });
243
+ });
@@ -0,0 +1,31 @@
1
+ import { vi } from "vitest";
2
+
3
+ import type {
4
+ NotifyAppReadyAnalyticsEvent,
5
+ NotifyAppReadyResult,
6
+ } from "./native";
7
+
8
+ export const createNotifyReadResult = (
9
+ result: NotifyAppReadyResult = { status: "UNCHANGED" },
10
+ analyticsEvent: NotifyAppReadyAnalyticsEvent | null = null,
11
+ pending = false,
12
+ ): {
13
+ analyticsEvent: NotifyAppReadyAnalyticsEvent | null;
14
+ pending: boolean;
15
+ result: NotifyAppReadyResult;
16
+ } => ({
17
+ analyticsEvent,
18
+ pending,
19
+ result,
20
+ });
21
+
22
+ export const stubNotifyFrame = () => {
23
+ vi.useFakeTimers();
24
+ vi.stubGlobal(
25
+ "requestAnimationFrame",
26
+ vi.fn((callback: (timestamp: number) => void) => {
27
+ setTimeout(() => callback(0), 0);
28
+ return 1;
29
+ }),
30
+ );
31
+ };
@@ -0,0 +1,130 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import type {
4
+ NotifyAppReadyAnalyticsEvent,
5
+ NotifyAppReadyResult,
6
+ } from "./native";
7
+ import {
8
+ createNotifyReadResult,
9
+ stubNotifyFrame,
10
+ } from "./notifyAppReadyAnalytics.test-utils";
11
+
12
+ vi.mock("react-native", () => ({
13
+ Platform: { OS: "ios" },
14
+ }));
15
+
16
+ const failureCases = [
17
+ { label: "400", responseStatus: 400 },
18
+ { label: "404", responseStatus: 404 },
19
+ { label: "500", responseStatus: 500 },
20
+ {
21
+ error: Object.assign(new Error("request aborted"), { name: "AbortError" }),
22
+ label: "timeout",
23
+ },
24
+ { error: new Error("network unavailable"), label: "network" },
25
+ ] as const;
26
+
27
+ const mocks = vi.hoisted(() => ({
28
+ addListener: vi.fn(() => () => {}),
29
+ checkForUpdate: vi.fn(),
30
+ getAppVersion: vi.fn(() => "1.0.0"),
31
+ getBundleId: vi.fn(() => "bundle-id"),
32
+ getChannel: vi.fn(() => "production"),
33
+ getCohort: vi.fn(() => "123"),
34
+ getFingerprintHash: vi.fn(() => "fingerprint-hash"),
35
+ getInstallId: vi.fn(() => "install-id"),
36
+ getPersistedUserIdentity: vi.fn(() => ({})),
37
+ readNotifyAppReady: vi.fn<
38
+ () => {
39
+ analyticsEvent: NotifyAppReadyAnalyticsEvent | null;
40
+ pending: boolean;
41
+ result: NotifyAppReadyResult;
42
+ }
43
+ >(() => createNotifyReadResult()),
44
+ reload: vi.fn(),
45
+ }));
46
+
47
+ vi.mock("./checkForUpdate", () => ({
48
+ checkForUpdate: mocks.checkForUpdate,
49
+ }));
50
+
51
+ vi.mock("./native", () => ({
52
+ addListener: mocks.addListener,
53
+ getAppVersion: mocks.getAppVersion,
54
+ getBundleId: mocks.getBundleId,
55
+ getChannel: mocks.getChannel,
56
+ getCohort: mocks.getCohort,
57
+ getFingerprintHash: mocks.getFingerprintHash,
58
+ getInstallId: mocks.getInstallId,
59
+ getPersistedUserIdentity: mocks.getPersistedUserIdentity,
60
+ readNotifyAppReady: mocks.readNotifyAppReady,
61
+ reload: mocks.reload,
62
+ }));
63
+
64
+ describe("automatic notifyAppReady transport failures", () => {
65
+ beforeEach(() => {
66
+ vi.resetModules();
67
+ vi.unstubAllGlobals();
68
+ vi.useRealTimers();
69
+
70
+ for (const mock of Object.values(mocks)) {
71
+ mock.mockReset();
72
+ }
73
+
74
+ mocks.addListener.mockReturnValue(() => {});
75
+ mocks.getAppVersion.mockReturnValue("1.0.0");
76
+ mocks.getBundleId.mockReturnValue("bundle-id");
77
+ mocks.getChannel.mockReturnValue("production");
78
+ mocks.getCohort.mockReturnValue("123");
79
+ mocks.getFingerprintHash.mockReturnValue("fingerprint-hash");
80
+ mocks.getInstallId.mockReturnValue("install-id");
81
+ mocks.getPersistedUserIdentity.mockReturnValue({});
82
+ mocks.readNotifyAppReady.mockReturnValue(createNotifyReadResult());
83
+ });
84
+
85
+ it.each(failureCases)(
86
+ "warns and preserves UNCHANGED readiness for a real HTTP client $label failure",
87
+ async (failureCase) => {
88
+ stubNotifyFrame();
89
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
90
+ const onError = vi.fn();
91
+ const onNotifyAppReady = vi.fn();
92
+ const fetchMock = vi.fn(async () => {
93
+ if ("error" in failureCase) {
94
+ throw failureCase.error;
95
+ }
96
+
97
+ return new Response(null, { status: failureCase.responseStatus });
98
+ });
99
+ vi.stubGlobal("fetch", fetchMock);
100
+ Reflect.set(globalThis, "HotUpdater", {
101
+ SDK_VERSION: "test-sdk-version",
102
+ });
103
+
104
+ try {
105
+ const [{ createHttpClient }, { init }] = await Promise.all([
106
+ import("./httpClient"),
107
+ import("./wrap"),
108
+ ]);
109
+ const client = createHttpClient(
110
+ "https://updates.example.test/hot-updater",
111
+ );
112
+
113
+ init({ analytics: true, client, onError, onNotifyAppReady });
114
+ await vi.runOnlyPendingTimersAsync();
115
+
116
+ expect(fetchMock).toHaveBeenCalledTimes(1);
117
+ expect(onError).not.toHaveBeenCalled();
118
+ expect(onNotifyAppReady).toHaveBeenCalledWith({
119
+ status: "UNCHANGED",
120
+ });
121
+ expect(warn).toHaveBeenCalledWith(
122
+ "[HotUpdater] Automatic notifyAppReady analytics failed:",
123
+ expect.any(Error),
124
+ );
125
+ } finally {
126
+ warn.mockRestore();
127
+ }
128
+ },
129
+ );
130
+ });