@hot-updater/react-native 0.30.11 → 0.31.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 (128) hide show
  1. package/HotUpdater.podspec +1 -0
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/com/hotupdater/BsdiffPatch.kt +284 -0
  4. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +885 -5
  5. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +13 -2
  6. package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +37 -8
  7. package/android/src/main/java/com/hotupdater/ReactNativeValueConverters.kt +48 -12
  8. package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +60 -22
  9. package/android/src/newarch/HotUpdater.kt +14 -2
  10. package/android/src/newarch/HotUpdaterModule.kt +112 -1
  11. package/android/src/oldarch/HotUpdater.kt +14 -2
  12. package/android/src/oldarch/HotUpdaterModule.kt +107 -3
  13. package/android/src/test/java/com/hotupdater/BsdiffPatchTest.kt +46 -0
  14. package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +29 -1
  15. package/android/src/test/java/com/hotupdater/HotUpdaterImplTest.kt +4 -1
  16. package/ios/HotUpdater/Internal/BsdiffPatchBridge.h +16 -0
  17. package/ios/HotUpdater/Internal/BsdiffPatchBridge.mm +343 -0
  18. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +1093 -47
  19. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +2 -0
  20. package/ios/HotUpdater/Internal/HotUpdater.mm +12 -2
  21. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +64 -5
  22. package/ios/HotUpdater/Internal/NotificationExtension.swift +2 -1
  23. package/ios/HotUpdater/Internal/SignatureVerifier.swift +28 -5
  24. package/ios/HotUpdater/Internal/StreamingTarArchiveExtractor.swift +11 -0
  25. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +11 -5
  26. package/lib/commonjs/DefaultResolver.js +18 -4
  27. package/lib/commonjs/DefaultResolver.js.map +1 -1
  28. package/lib/commonjs/DefaultResolver.spec.js +136 -0
  29. package/lib/commonjs/DefaultResolver.spec.js.map +1 -0
  30. package/lib/commonjs/checkForUpdate.js +6 -0
  31. package/lib/commonjs/checkForUpdate.js.map +1 -1
  32. package/lib/commonjs/index.js +53 -18
  33. package/lib/commonjs/index.js.map +1 -1
  34. package/lib/commonjs/index.spec.js +133 -1
  35. package/lib/commonjs/index.spec.js.map +1 -1
  36. package/lib/commonjs/native.js +40 -16
  37. package/lib/commonjs/native.js.map +1 -1
  38. package/lib/commonjs/native.spec.js +119 -3
  39. package/lib/commonjs/native.spec.js.map +1 -1
  40. package/lib/commonjs/sdkVersion.js +8 -0
  41. package/lib/commonjs/sdkVersion.js.map +1 -0
  42. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  43. package/lib/commonjs/store.js +72 -5
  44. package/lib/commonjs/store.js.map +1 -1
  45. package/lib/commonjs/store.spec.js +265 -8
  46. package/lib/commonjs/store.spec.js.map +1 -1
  47. package/lib/commonjs/types.js.map +1 -1
  48. package/lib/commonjs/wrap.js +19 -5
  49. package/lib/commonjs/wrap.js.map +1 -1
  50. package/lib/commonjs/wrap.spec.js +74 -0
  51. package/lib/commonjs/wrap.spec.js.map +1 -1
  52. package/lib/module/DefaultResolver.js +18 -4
  53. package/lib/module/DefaultResolver.js.map +1 -1
  54. package/lib/module/DefaultResolver.spec.js +135 -0
  55. package/lib/module/DefaultResolver.spec.js.map +1 -0
  56. package/lib/module/checkForUpdate.js +6 -0
  57. package/lib/module/checkForUpdate.js.map +1 -1
  58. package/lib/module/index.js +53 -18
  59. package/lib/module/index.js.map +1 -1
  60. package/lib/module/index.spec.js +133 -1
  61. package/lib/module/index.spec.js.map +1 -1
  62. package/lib/module/native.js +40 -16
  63. package/lib/module/native.js.map +1 -1
  64. package/lib/module/native.spec.js +119 -3
  65. package/lib/module/native.spec.js.map +1 -1
  66. package/lib/module/sdkVersion.js +4 -0
  67. package/lib/module/sdkVersion.js.map +1 -0
  68. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  69. package/lib/module/store.js +72 -5
  70. package/lib/module/store.js.map +1 -1
  71. package/lib/module/store.spec.js +265 -8
  72. package/lib/module/store.spec.js.map +1 -1
  73. package/lib/module/types.js.map +1 -1
  74. package/lib/module/wrap.js +19 -5
  75. package/lib/module/wrap.js.map +1 -1
  76. package/lib/module/wrap.spec.js +74 -0
  77. package/lib/module/wrap.spec.js.map +1 -1
  78. package/lib/typescript/commonjs/DefaultResolver.d.ts +4 -3
  79. package/lib/typescript/commonjs/DefaultResolver.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/checkForUpdate.d.ts +2 -2
  81. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/index.d.ts +37 -9
  83. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/native.d.ts +56 -3
  85. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  86. package/lib/typescript/commonjs/sdkVersion.d.ts +2 -0
  87. package/lib/typescript/commonjs/sdkVersion.d.ts.map +1 -0
  88. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +27 -0
  89. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  90. package/lib/typescript/commonjs/store.d.ts +5 -0
  91. package/lib/typescript/commonjs/store.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/types.d.ts +1 -0
  93. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/wrap.d.ts +48 -32
  95. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  96. package/lib/typescript/module/DefaultResolver.d.ts +4 -3
  97. package/lib/typescript/module/DefaultResolver.d.ts.map +1 -1
  98. package/lib/typescript/module/checkForUpdate.d.ts +2 -2
  99. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  100. package/lib/typescript/module/index.d.ts +37 -9
  101. package/lib/typescript/module/index.d.ts.map +1 -1
  102. package/lib/typescript/module/native.d.ts +56 -3
  103. package/lib/typescript/module/native.d.ts.map +1 -1
  104. package/lib/typescript/module/sdkVersion.d.ts +2 -0
  105. package/lib/typescript/module/sdkVersion.d.ts.map +1 -0
  106. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +27 -0
  107. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  108. package/lib/typescript/module/store.d.ts +5 -0
  109. package/lib/typescript/module/store.d.ts.map +1 -1
  110. package/lib/typescript/module/types.d.ts +1 -0
  111. package/lib/typescript/module/types.d.ts.map +1 -1
  112. package/lib/typescript/module/wrap.d.ts +48 -32
  113. package/lib/typescript/module/wrap.d.ts.map +1 -1
  114. package/package.json +17 -9
  115. package/src/DefaultResolver.spec.ts +178 -0
  116. package/src/DefaultResolver.ts +32 -6
  117. package/src/checkForUpdate.ts +9 -2
  118. package/src/index.spec.ts +170 -2
  119. package/src/index.ts +100 -40
  120. package/src/native.spec.ts +139 -1
  121. package/src/native.ts +145 -31
  122. package/src/sdkVersion.ts +1 -0
  123. package/src/specs/NativeHotUpdater.ts +28 -0
  124. package/src/store.spec.ts +324 -9
  125. package/src/store.ts +133 -7
  126. package/src/types.ts +2 -0
  127. package/src/wrap.spec.ts +91 -0
  128. package/src/wrap.tsx +84 -45
package/src/native.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type ChangedAsset,
2
3
  INVALID_COHORT_ERROR_MESSAGE,
3
4
  isValidCohort,
4
5
  normalizeCohortValue,
@@ -24,6 +25,7 @@ const normalizeAndValidateCohort = (cohort: string): string => {
24
25
 
25
26
  export interface ManifestAsset {
26
27
  fileHash: string;
28
+ signature?: string;
27
29
  }
28
30
 
29
31
  export interface Manifest {
@@ -186,11 +188,38 @@ const cloneManifest = (manifest: Manifest): Manifest => ({
186
188
  assets: Object.fromEntries(
187
189
  Object.entries(manifest.assets).map(([key, asset]) => [
188
190
  key,
189
- { fileHash: asset.fileHash },
191
+ {
192
+ fileHash: asset.fileHash,
193
+ ...(asset.signature ? { signature: asset.signature } : {}),
194
+ },
190
195
  ]),
191
196
  ),
192
197
  });
193
198
 
199
+ const getNativeBundleId = (): string | null => {
200
+ const nativeModule = HotUpdaterNative as typeof HotUpdaterNative & {
201
+ getBundleId?: () => string | null;
202
+ };
203
+
204
+ if (typeof nativeModule.getBundleId !== "function") {
205
+ throw new Error(
206
+ "[HotUpdater] Native module is missing 'getBundleId()'. This JS bundle requires a newer native @hot-updater/react-native SDK. Rebuild and release a new app version before delivering this OTA update.",
207
+ );
208
+ }
209
+
210
+ return nativeModule.getBundleId();
211
+ };
212
+
213
+ const resolveBundleId = (bundleId: string | null): string => {
214
+ return !bundleId || bundleId === NIL_UUID ? getMinBundleId() : bundleId;
215
+ };
216
+
217
+ const getFreshBundleId = (): string => {
218
+ const resolvedBundleId = resolveBundleId(getNativeBundleId());
219
+ sessionState.cacheBundleId(resolvedBundleId);
220
+ return resolvedBundleId;
221
+ };
222
+
194
223
  const getReloadProcess = (): (() => Promise<void>) | null => {
195
224
  const nativeModule = HotUpdaterNative as typeof HotUpdaterNative & {
196
225
  reloadProcess?: () => Promise<void>;
@@ -201,10 +230,73 @@ const getReloadProcess = (): (() => Promise<void>) | null => {
201
230
  : null;
202
231
  };
203
232
 
233
+ export type HotUpdaterProgressArtifactType = "archive" | "diff";
234
+
235
+ export type HotUpdaterDiffFileStatus =
236
+ | "pending"
237
+ | "downloading"
238
+ | "downloaded"
239
+ | "failed";
240
+
241
+ export interface HotUpdaterDiffFileSnapshot {
242
+ /**
243
+ * Manifest asset path for this progress entry.
244
+ *
245
+ * This is the stable identity of the asset in the installed bundle. Use this
246
+ * when matching progress back to the manifest or to the final file location.
247
+ */
248
+ path: string;
249
+ /**
250
+ * Artifact path currently being transferred for this progress entry.
251
+ *
252
+ * Usually this is the same as `path`. It can differ when the updater
253
+ * downloads an intermediate artifact that will be applied to produce the
254
+ * final asset.
255
+ */
256
+ downloadPath: string;
257
+ /**
258
+ * Current download state for this asset within the manifest diff update.
259
+ */
260
+ status: HotUpdaterDiffFileStatus;
261
+ /**
262
+ * Download progress for this file, normalized from 0 to 1.
263
+ */
264
+ progress: number;
265
+ /**
266
+ * Stable display order among diff files.
267
+ */
268
+ order: number;
269
+ /**
270
+ * Bytes downloaded for `downloadPath`, when the native downloader reports it.
271
+ */
272
+ downloadedBytes?: number;
273
+ /**
274
+ * Total expected bytes for `downloadPath`, when known.
275
+ */
276
+ totalBytes?: number;
277
+ }
278
+
279
+ export interface HotUpdaterDiffProgressDetails {
280
+ totalFilesCount: number;
281
+ completedFilesCount: number;
282
+ files: HotUpdaterDiffFileSnapshot[];
283
+ }
284
+
285
+ export type HotUpdaterProgressEvent =
286
+ | {
287
+ progress: number;
288
+ artifactType: "archive";
289
+ downloadedBytes?: number;
290
+ totalBytes?: number;
291
+ }
292
+ | {
293
+ progress: number;
294
+ artifactType: "diff";
295
+ details: HotUpdaterDiffProgressDetails;
296
+ };
297
+
204
298
  export type HotUpdaterEvent = {
205
- onProgress: {
206
- progress: number;
207
- };
299
+ onProgress: HotUpdaterProgressEvent;
208
300
  };
209
301
 
210
302
  const eventEmitter = new NativeEventEmitter(HotUpdaterNative);
@@ -252,8 +344,21 @@ export async function updateBundle(
252
344
  const status =
253
345
  typeof paramsOrBundleId === "string" ? "UPDATE" : paramsOrBundleId.status;
254
346
 
255
- // If we have already installed this bundle in this session, skip re-download.
256
- if (status === "UPDATE" && sessionState.hasInstalledBundle(updateBundleId)) {
347
+ const targetFileUrl =
348
+ typeof paramsOrBundleId === "string"
349
+ ? (fileUrl ?? null)
350
+ : paramsOrBundleId.fileUrl;
351
+
352
+ const currentBundleId = status === "UPDATE" ? getFreshBundleId() : undefined;
353
+
354
+ // If native is still on the same bundle we installed in this session,
355
+ // skip re-download. Native state can move back to the built-in bundle after
356
+ // rollback/reset, so check a fresh native bundle id before using this guard.
357
+ if (
358
+ status === "UPDATE" &&
359
+ sessionState.hasInstalledBundle(updateBundleId) &&
360
+ currentBundleId === updateBundleId
361
+ ) {
257
362
  return true;
258
363
  }
259
364
 
@@ -265,7 +370,8 @@ export async function updateBundle(
265
370
  if (
266
371
  !shouldSkipCurrentBundleIdCheck &&
267
372
  status === "UPDATE" &&
268
- updateBundleId.localeCompare(getBundleId()) <= 0
373
+ currentBundleId !== undefined &&
374
+ updateBundleId.localeCompare(currentBundleId) <= 0
269
375
  ) {
270
376
  throw new Error(
271
377
  "Update bundle id is not newer than the current bundle id. Preventing infinite update loop.",
@@ -276,11 +382,6 @@ export async function updateBundle(
276
382
  const existing = sessionState.getInflightUpdate(updateBundleId);
277
383
  if (existing) return existing;
278
384
 
279
- const targetFileUrl =
280
- typeof paramsOrBundleId === "string"
281
- ? (fileUrl ?? null)
282
- : paramsOrBundleId.fileUrl;
283
-
284
385
  const targetFileHash =
285
386
  typeof paramsOrBundleId === "string"
286
387
  ? undefined
@@ -288,14 +389,30 @@ export async function updateBundle(
288
389
 
289
390
  const targetChannel =
290
391
  typeof paramsOrBundleId === "string" ? undefined : paramsOrBundleId.channel;
392
+ const targetManifestUrl =
393
+ typeof paramsOrBundleId === "string"
394
+ ? undefined
395
+ : paramsOrBundleId.manifestUrl;
396
+ const targetManifestFileHash =
397
+ typeof paramsOrBundleId === "string"
398
+ ? undefined
399
+ : paramsOrBundleId.manifestFileHash;
400
+ const targetChangedAssets =
401
+ typeof paramsOrBundleId === "string"
402
+ ? undefined
403
+ : paramsOrBundleId.changedAssets;
291
404
 
292
405
  const promise = (async () => {
293
406
  try {
294
407
  const ok = await HotUpdaterNative.updateBundle({
295
408
  bundleId: updateBundleId,
296
409
  channel: targetChannel,
410
+ changedAssets:
411
+ (targetChangedAssets as Record<string, ChangedAsset> | null) ?? null,
297
412
  fileUrl: targetFileUrl,
298
413
  fileHash: targetFileHash ?? null,
414
+ manifestFileHash: targetManifestFileHash ?? null,
415
+ manifestUrl: targetManifestUrl ?? null,
299
416
  });
300
417
  if (ok) {
301
418
  sessionState.markBundleInstalled(updateBundleId, targetChannel);
@@ -424,23 +541,7 @@ export const getBundleId = (): string => {
424
541
  return cachedBundleId;
425
542
  }
426
543
 
427
- const nativeModule = HotUpdaterNative as typeof HotUpdaterNative & {
428
- getBundleId?: () => string | null;
429
- };
430
-
431
- if (typeof nativeModule.getBundleId !== "function") {
432
- throw new Error(
433
- "[HotUpdater] Native module is missing 'getBundleId()'. This JS bundle requires a newer native @hot-updater/react-native SDK. Rebuild and release a new app version before delivering this OTA update.",
434
- );
435
- }
436
-
437
- const bundleId = nativeModule.getBundleId();
438
-
439
- const resolvedBundleId =
440
- !bundleId || bundleId === NIL_UUID ? getMinBundleId() : bundleId;
441
-
442
- sessionState.cacheBundleId(resolvedBundleId);
443
- return resolvedBundleId;
544
+ return getFreshBundleId();
444
545
  };
445
546
 
446
547
  /**
@@ -617,12 +718,25 @@ const normalizeManifestAssets = (value: unknown): Manifest["assets"] => {
617
718
  return [];
618
719
  }
619
720
 
620
- const { fileHash } = entry as { fileHash?: unknown };
721
+ const { fileHash, signature } = entry as {
722
+ fileHash?: unknown;
723
+ signature?: unknown;
724
+ };
621
725
  if (typeof fileHash !== "string" || !fileHash.trim()) {
622
726
  return [];
623
727
  }
624
728
 
625
- return [[trimmedKey, { fileHash: fileHash.trim() }] as const];
729
+ return [
730
+ [
731
+ trimmedKey,
732
+ {
733
+ fileHash: fileHash.trim(),
734
+ ...(typeof signature === "string" && signature.trim()
735
+ ? { signature: signature.trim() }
736
+ : {}),
737
+ },
738
+ ] as const,
739
+ ];
626
740
  }),
627
741
  );
628
742
  };
@@ -0,0 +1 @@
1
+ export const HOT_UPDATER_SDK_VERSION = "0.30.12";
@@ -2,6 +2,21 @@ import type { TurboModule } from "react-native";
2
2
  import { TurboModuleRegistry } from "react-native";
3
3
  import type { UnsafeObject } from "react-native/Libraries/Types/CodegenTypes";
4
4
 
5
+ export interface ChangedAsset {
6
+ file?: {
7
+ compression?: "br" | null;
8
+ url: string;
9
+ } | null;
10
+ fileHash: string;
11
+ patch?: {
12
+ algorithm: "bsdiff";
13
+ baseBundleId: string;
14
+ baseFileHash: string;
15
+ patchFileHash: string;
16
+ patchUrl: string;
17
+ } | null;
18
+ }
19
+
5
20
  export interface UpdateBundleParams {
6
21
  bundleId: string;
7
22
  channel?: string;
@@ -16,6 +31,19 @@ export interface UpdateBundleParams {
16
31
  * Native determines verification mode by checking for "sig:" prefix.
17
32
  */
18
33
  fileHash: string | null;
34
+ /**
35
+ * Optional signed manifest URL for manifest-driven installation.
36
+ */
37
+ manifestUrl?: string | null;
38
+ /**
39
+ * File hash/signature for the manifest file itself.
40
+ */
41
+ manifestFileHash?: string | null;
42
+ /**
43
+ * Per-file URLs for assets that must be downloaded instead of reused from
44
+ * the currently active bundle.
45
+ */
46
+ changedAssets?: UnsafeObject | null;
19
47
  }
20
48
 
21
49
  export interface Spec extends TurboModule {
package/src/store.spec.ts CHANGED
@@ -1,9 +1,18 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- const addListenerMock = vi.hoisted(() => vi.fn());
3
+ const listeners = vi.hoisted(
4
+ () => new Map<string, (event: Record<string, unknown>) => void>(),
5
+ );
4
6
 
5
7
  vi.mock("./native", () => ({
6
- addListener: addListenerMock,
8
+ addListener: vi.fn(
9
+ (eventName: string, listener: (event: Record<string, unknown>) => void) => {
10
+ listeners.set(eventName, listener);
11
+ return () => {
12
+ listeners.delete(eventName);
13
+ };
14
+ },
15
+ ),
7
16
  }));
8
17
 
9
18
  const importStore = async () => {
@@ -13,8 +22,8 @@ const importStore = async () => {
13
22
 
14
23
  describe("hotUpdaterStore", () => {
15
24
  beforeEach(() => {
25
+ listeners.clear();
16
26
  vi.resetModules();
17
- addListenerMock.mockReset();
18
27
  });
19
28
 
20
29
  it("does not notify subscribers when state values are unchanged", async () => {
@@ -35,23 +44,329 @@ describe("hotUpdaterStore", () => {
35
44
  it("notifies subscribers only when progress events change the snapshot", async () => {
36
45
  const store = await importStore();
37
46
  const listener = vi.fn();
38
- const progressListener = addListenerMock.mock.calls[0][1] as (state: {
39
- progress: number;
40
- }) => void;
47
+ const emitProgress = listeners.get("onProgress");
48
+
49
+ expect(emitProgress).toBeTypeOf("function");
41
50
 
42
51
  const unsubscribe = store.subscribe(listener);
43
52
 
44
- progressListener({ progress: 0.5 });
45
- progressListener({ progress: 0.5 });
46
- progressListener({ progress: 1 });
53
+ emitProgress?.({
54
+ artifactType: "archive",
55
+ progress: 0.5,
56
+ });
57
+ emitProgress?.({
58
+ artifactType: "archive",
59
+ progress: 0.5,
60
+ });
61
+ emitProgress?.({
62
+ artifactType: "archive",
63
+ progress: 1,
64
+ });
47
65
  store.setState({ isUpdateDownloaded: true });
48
66
 
49
67
  expect(listener).toHaveBeenCalledTimes(2);
50
68
  expect(store.getSnapshot()).toEqual({
69
+ artifactType: "archive",
70
+ details: null,
71
+ downloadedBytes: undefined,
51
72
  isUpdateDownloaded: true,
52
73
  progress: 1,
74
+ totalBytes: undefined,
53
75
  });
54
76
 
55
77
  unsubscribe();
56
78
  });
79
+
80
+ it("stores archive byte metadata and notifies when only bytes change", async () => {
81
+ const store = await importStore();
82
+ const listener = vi.fn();
83
+ const emitProgress = listeners.get("onProgress");
84
+
85
+ const unsubscribe = store.subscribe(listener);
86
+
87
+ emitProgress?.({
88
+ artifactType: "archive",
89
+ downloadedBytes: 100.4,
90
+ progress: 0.5,
91
+ totalBytes: 1_000,
92
+ });
93
+ emitProgress?.({
94
+ artifactType: "archive",
95
+ downloadedBytes: 250,
96
+ progress: 0.5,
97
+ totalBytes: 1_000,
98
+ });
99
+
100
+ expect(listener).toHaveBeenCalledTimes(2);
101
+ expect(store.getSnapshot()).toEqual({
102
+ artifactType: "archive",
103
+ details: null,
104
+ downloadedBytes: 250,
105
+ isUpdateDownloaded: false,
106
+ progress: 0.5,
107
+ totalBytes: 1_000,
108
+ });
109
+
110
+ unsubscribe();
111
+ });
112
+
113
+ it("stores manifest progress metadata while keeping overall progress", async () => {
114
+ const store = await importStore();
115
+ const emitProgress = listeners.get("onProgress");
116
+
117
+ expect(emitProgress).toBeTypeOf("function");
118
+
119
+ emitProgress?.({
120
+ artifactType: "diff",
121
+ details: {
122
+ completedFilesCount: 1,
123
+ files: [
124
+ {
125
+ downloadPath: "index.ios.bundle",
126
+ order: 0,
127
+ path: "index.ios.bundle",
128
+ progress: 1,
129
+ status: "downloaded",
130
+ },
131
+ {
132
+ downloadPath: "assets/logo.png",
133
+ order: 1,
134
+ path: "assets/logo.png",
135
+ progress: 0.5,
136
+ status: "downloading",
137
+ },
138
+ ],
139
+ totalFilesCount: 2,
140
+ },
141
+ progress: 0.42,
142
+ });
143
+
144
+ expect(store.getSnapshot()).toEqual({
145
+ artifactType: "diff",
146
+ details: {
147
+ completedFilesCount: 1,
148
+ files: [
149
+ {
150
+ downloadPath: "index.ios.bundle",
151
+ order: 0,
152
+ path: "index.ios.bundle",
153
+ progress: 1,
154
+ status: "downloaded",
155
+ },
156
+ {
157
+ downloadPath: "assets/logo.png",
158
+ order: 1,
159
+ path: "assets/logo.png",
160
+ progress: 0.5,
161
+ status: "downloading",
162
+ },
163
+ ],
164
+ totalFilesCount: 2,
165
+ },
166
+ downloadedBytes: undefined,
167
+ isUpdateDownloaded: false,
168
+ progress: 0.42,
169
+ totalBytes: undefined,
170
+ });
171
+ });
172
+
173
+ it("preserves patch download metadata for manifest diff files", async () => {
174
+ const store = await importStore();
175
+ const emitProgress = listeners.get("onProgress");
176
+
177
+ emitProgress?.({
178
+ artifactType: "diff",
179
+ details: {
180
+ completedFilesCount: 0,
181
+ files: [
182
+ {
183
+ downloadPath: "index.ios.bundle.bsdiff",
184
+ order: 0,
185
+ path: "index.ios.bundle",
186
+ progress: 0.4,
187
+ status: "downloading",
188
+ },
189
+ ],
190
+ totalFilesCount: 1,
191
+ },
192
+ progress: 0.32,
193
+ });
194
+
195
+ expect(store.getSnapshot().details?.files).toEqual([
196
+ {
197
+ downloadPath: "index.ios.bundle.bsdiff",
198
+ downloadedBytes: undefined,
199
+ order: 0,
200
+ path: "index.ios.bundle",
201
+ progress: 0.4,
202
+ status: "downloading",
203
+ totalBytes: undefined,
204
+ },
205
+ ]);
206
+ });
207
+
208
+ it("stores diff snapshot transitions from downloading to downloaded", async () => {
209
+ const store = await importStore();
210
+ const emitProgress = listeners.get("onProgress");
211
+
212
+ emitProgress?.({
213
+ artifactType: "diff",
214
+ details: {
215
+ completedFilesCount: 0,
216
+ files: [
217
+ {
218
+ order: 0,
219
+ path: "index.ios.bundle",
220
+ progress: 0.6,
221
+ status: "downloading",
222
+ },
223
+ {
224
+ order: 1,
225
+ path: "assets/logo.png",
226
+ progress: 0,
227
+ status: "pending",
228
+ },
229
+ ],
230
+ totalFilesCount: 2,
231
+ },
232
+ progress: 0.4,
233
+ });
234
+
235
+ emitProgress?.({
236
+ artifactType: "diff",
237
+ details: {
238
+ completedFilesCount: 1,
239
+ files: [
240
+ {
241
+ downloadPath: "index.ios.bundle",
242
+ order: 0,
243
+ path: "index.ios.bundle",
244
+ progress: 1,
245
+ status: "downloaded",
246
+ },
247
+ {
248
+ downloadPath: "assets/logo.png",
249
+ order: 1,
250
+ path: "assets/logo.png",
251
+ progress: 0,
252
+ status: "pending",
253
+ },
254
+ ],
255
+ totalFilesCount: 2,
256
+ },
257
+ progress: 0.6,
258
+ });
259
+
260
+ expect(store.getSnapshot()).toEqual({
261
+ artifactType: "diff",
262
+ details: {
263
+ completedFilesCount: 1,
264
+ files: [
265
+ {
266
+ downloadPath: "index.ios.bundle",
267
+ order: 0,
268
+ path: "index.ios.bundle",
269
+ progress: 1,
270
+ status: "downloaded",
271
+ },
272
+ {
273
+ downloadPath: "assets/logo.png",
274
+ order: 1,
275
+ path: "assets/logo.png",
276
+ progress: 0,
277
+ status: "pending",
278
+ },
279
+ ],
280
+ totalFilesCount: 2,
281
+ },
282
+ downloadedBytes: undefined,
283
+ isUpdateDownloaded: false,
284
+ progress: 0.6,
285
+ totalBytes: undefined,
286
+ });
287
+ });
288
+
289
+ it("clears manifest metadata when archive progress events arrive", async () => {
290
+ const store = await importStore();
291
+ const emitProgress = listeners.get("onProgress");
292
+
293
+ emitProgress?.({
294
+ artifactType: "diff",
295
+ details: {
296
+ completedFilesCount: 2,
297
+ files: [
298
+ {
299
+ order: 0,
300
+ path: "index.ios.bundle",
301
+ progress: 1,
302
+ status: "downloaded",
303
+ },
304
+ {
305
+ order: 1,
306
+ path: "assets/logo.png",
307
+ progress: 1,
308
+ status: "downloaded",
309
+ },
310
+ {
311
+ order: 2,
312
+ path: "assets/bg.png",
313
+ progress: 0,
314
+ status: "pending",
315
+ },
316
+ ],
317
+ totalFilesCount: 3,
318
+ },
319
+ progress: 0.9,
320
+ });
321
+
322
+ emitProgress?.({
323
+ artifactType: "archive",
324
+ progress: 0.25,
325
+ });
326
+
327
+ expect(store.getSnapshot()).toEqual({
328
+ artifactType: "archive",
329
+ details: null,
330
+ downloadedBytes: undefined,
331
+ isUpdateDownloaded: false,
332
+ progress: 0.25,
333
+ totalBytes: undefined,
334
+ });
335
+ });
336
+
337
+ it("clears stale manifest metadata even when archive events omit optional fields", async () => {
338
+ const store = await importStore();
339
+ const emitProgress = listeners.get("onProgress");
340
+
341
+ emitProgress?.({
342
+ artifactType: "diff",
343
+ details: {
344
+ completedFilesCount: 0,
345
+ files: [
346
+ {
347
+ order: 0,
348
+ path: "manifest.asset.png",
349
+ progress: 0,
350
+ status: "failed",
351
+ },
352
+ ],
353
+ totalFilesCount: 1,
354
+ },
355
+ progress: 0.2,
356
+ });
357
+
358
+ emitProgress?.({
359
+ artifactType: "archive",
360
+ progress: 0.4,
361
+ });
362
+
363
+ expect(store.getSnapshot()).toEqual({
364
+ artifactType: "archive",
365
+ details: null,
366
+ downloadedBytes: undefined,
367
+ isUpdateDownloaded: false,
368
+ progress: 0.4,
369
+ totalBytes: undefined,
370
+ });
371
+ });
57
372
  });