@bravemobile/react-native-code-push 13.1.0-beta.4 → 13.1.0-beta.6

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 (52) hide show
  1. package/CodePush.podspec +5 -0
  2. package/README.md +15 -145
  3. package/android/app/src/main/java/com/microsoft/codepush/react/ArchiveAttemptLog.java +181 -0
  4. package/android/app/src/main/java/com/microsoft/codepush/react/{BinaryPatchResult.java → ArchiveRestoreResult.java} +17 -7
  5. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushBinaryPatch.java +20 -20
  6. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java +20 -1
  7. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushErrorCode.java +86 -0
  8. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushHttpException.java +29 -0
  9. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushIncompleteDownloadException.java +17 -0
  10. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java +12 -7
  11. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java +143 -43
  12. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushBinaryPatchTest.java +31 -31
  13. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushErrorCodeTest.java +90 -0
  14. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerBinaryPatchTest.java +159 -19
  15. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java +304 -15
  16. package/cli/commands/buildPatchToolsCommand/buildPatchTools.test.ts +118 -0
  17. package/cli/commands/buildPatchToolsCommand/buildPatchTools.ts +46 -0
  18. package/cli/commands/buildPatchToolsCommand/index.ts +44 -0
  19. package/cli/commands/createHistoryCommand/createReleaseHistory.test.ts +133 -0
  20. package/cli/commands/createHistoryCommand/createReleaseHistory.ts +3 -11
  21. package/cli/commands/releaseCommand/addToReleaseHistory.ts +3 -11
  22. package/cli/commands/releaseCommand/release.test.ts +3 -3
  23. package/cli/commands/updateHistoryCommand/updateReleaseHistory.ts +3 -11
  24. package/cli/dist/commands/buildPatchToolsCommand/buildPatchTools.js +34 -0
  25. package/cli/dist/commands/buildPatchToolsCommand/buildPatchTools.test.js +88 -0
  26. package/cli/dist/commands/buildPatchToolsCommand/index.js +32 -0
  27. package/cli/dist/commands/createHistoryCommand/createReleaseHistory.js +2 -8
  28. package/cli/dist/commands/createHistoryCommand/createReleaseHistory.test.js +95 -0
  29. package/cli/dist/commands/releaseCommand/addToReleaseHistory.js +2 -8
  30. package/cli/dist/commands/releaseCommand/release.test.js +3 -3
  31. package/cli/dist/commands/updateHistoryCommand/updateReleaseHistory.js +2 -8
  32. package/cli/dist/functions/stageReleaseHistoryFile.js +28 -0
  33. package/cli/dist/index.js +4 -0
  34. package/cli/dist/utils/binaryPatch.js +8 -5
  35. package/cli/dist/utils/binaryPatch.test.js +1 -1
  36. package/cli/functions/stageReleaseHistoryFile.ts +38 -0
  37. package/cli/index.ts +5 -0
  38. package/cli/utils/binaryPatch.test.ts +1 -1
  39. package/cli/utils/binaryPatch.ts +8 -5
  40. package/ios/CodePush/CodePush.h +6 -3
  41. package/ios/CodePush/CodePush.mm +10 -10
  42. package/ios/CodePush/CodePushBinaryPatch.h +18 -8
  43. package/ios/CodePush/CodePushBinaryPatch.m +23 -22
  44. package/ios/CodePush/CodePushDownloadHandler.m +2 -1
  45. package/ios/CodePush/CodePushErrorUtils.m +55 -0
  46. package/ios/CodePush/CodePushPackage.m +242 -71
  47. package/package.json +2 -1
  48. package/src/CodePush.js +28 -18
  49. package/src/CodePush.test.js +110 -55
  50. package/src/package-mixins.js +19 -10
  51. package/typings/react-native-code-push.d.ts +110 -29
  52. package/android/app/src/main/java/com/microsoft/codepush/react/BinaryPatchAttempt.java +0 -92
@@ -2,9 +2,11 @@ package com.microsoft.codepush.react;
2
2
 
3
3
  import static org.junit.Assert.assertArrayEquals;
4
4
  import static org.junit.Assert.assertEquals;
5
+ import static org.junit.Assert.fail;
5
6
  import static org.junit.Assert.assertFalse;
6
7
  import static org.junit.Assert.assertTrue;
7
8
 
9
+ import org.json.JSONException;
8
10
  import org.json.JSONObject;
9
11
  import org.junit.After;
10
12
  import org.junit.Before;
@@ -90,7 +92,7 @@ public class CodePushUpdateManagerDownloadTest {
90
92
  }
91
93
 
92
94
  @Test
93
- public void installsAnUpdateFromItsBinaryPatchArchive() throws IOException {
95
+ public void installsAnUpdateFromItsBinaryPatchArchive() throws IOException, JSONException {
94
96
  String patchUrl = serve("/patch.zip", zipOf(patchArchiveContents()));
95
97
  String fullUrl = serve("/full.zip", zipOf(fullArchiveContents()));
96
98
 
@@ -102,7 +104,12 @@ public class CodePushUpdateManagerDownloadTest {
102
104
  assertInstalledContents();
103
105
  assertEquals("applied", patchResult.optString("status", null));
104
106
  assertFalse("an applied patch has nothing to report a reason for", patchResult.has("fallbackReason"));
105
- assertTrue("the apply is timed", patchResult.optLong("applyDurationMs", -1) >= 0);
107
+ assertTrue("the whole path is timed", patchResult.optLong("totalDurationMs", -1) >= 0);
108
+ assertFalse("the result no longer carries a top level apply time", patchResult.has("applyDurationMs"));
109
+
110
+ JSONObject appliedAttempt = patchResult.getJSONArray("attempts").getJSONObject(0);
111
+ assertTrue("an applied attempt carries its apply time", appliedAttempt.has("applyDurationMs"));
112
+ assertTrue(appliedAttempt.optLong("applyDurationMs", -1) >= 0);
106
113
  }
107
114
 
108
115
  @Test
@@ -116,22 +123,73 @@ public class CodePushUpdateManagerDownloadTest {
116
123
 
117
124
  assertEquals(Arrays.asList("/patch.zip", "/full.zip"), mServer.requestedPaths());
118
125
  assertInstalledContents();
119
- assertFallbackResult(patchResult, BinaryPatchResult.REASON_INVALID_MANIFEST);
126
+ assertFallbackResult(patchResult, ArchiveRestoreResult.REASON_INVALID_MANIFEST);
120
127
  }
121
128
 
122
129
  @Test
123
130
  public void reportsAnInvalidManifestWhenThePatchUrlDoesNotServeAnArchive() throws IOException {
124
131
  String patchUrl = serve("/patch.zip", ERROR_PAGE);
125
132
 
126
- BinaryPatchResult result = updateManager(applierWriting(TARGET_BUNDLE)).downloadAndInstallPackage(
133
+ ArchiveRestoreResult result = updateManager(applierWriting(TARGET_BUNDLE)).downloadAndInstallPackage(
127
134
  updatePackage("https://example.test/unused.zip", patchUrl), BUNDLE_FILE_NAME, ignoreProgress(),
128
- patchUrl, true, new BinaryPatchAttempt());
135
+ patchUrl, true, new ArchiveAttemptLog());
129
136
 
130
137
  assertFalse(result.succeeded());
131
- assertEquals(BinaryPatchResult.REASON_INVALID_MANIFEST, result.getFailureReason());
138
+ assertEquals(ArchiveRestoreResult.REASON_INVALID_MANIFEST, result.getFailureReason());
132
139
  assertFalse("bytes that are not an update must not reach the package folder", mPackageFolder.exists());
133
140
  }
134
141
 
142
+ @Test
143
+ public void failsTheDownloadWhenTheServerAnswersTheArchiveWithAnErrorStatus() throws IOException {
144
+ // Nothing is served at this path, so the server answers it with a 404.
145
+ String fullUrl = mServer.urlOf("/missing-full.zip");
146
+
147
+ try {
148
+ updateManager(applierWriting(TARGET_BUNDLE)).downloadPackage(
149
+ fullUpdatePackage(mPackageHash, fullUrl), BUNDLE_FILE_NAME, ignoreProgress());
150
+ fail("a download the server refused must not be reported as installed");
151
+ } catch (CodePushHttpException e) {
152
+ assertEquals(404, e.getStatusCode());
153
+ assertTrue("the message names the status the server answered with",
154
+ e.getMessage().contains("404"));
155
+ }
156
+
157
+ assertFalse("nothing the server refused reaches the package folder", mPackageFolder.exists());
158
+ }
159
+
160
+ @Test
161
+ public void givesUpTheDownloadWhenThePatchArchiveArrivesShort() throws IOException {
162
+ // The connection carried part of the body and stopped. The full archive is behind
163
+ // the same connection and is larger, so there is nothing to fall back to.
164
+ byte[] patchArchive = zipOf(patchArchiveContents());
165
+ String patchUrl = mServer.serveClaimingLength("/patch.zip", patchArchive, patchArchive.length + 1024);
166
+ String fullUrl = serve("/full.zip", zipOf(fullArchiveContents()));
167
+
168
+ try {
169
+ updateManager(applierWriting(TARGET_BUNDLE))
170
+ .downloadPackage(updatePackage(fullUrl, patchUrl), BUNDLE_FILE_NAME, ignoreProgress());
171
+ fail("a download that stopped short must not be reported as installed");
172
+ } catch (IOException e) {
173
+ assertEquals(CodePushErrorCode.NETWORK, CodePushErrorCode.of(e));
174
+ }
175
+
176
+ assertEquals("the full archive is behind the connection that just stopped",
177
+ Arrays.asList("/patch.zip"), mServer.requestedPaths());
178
+ assertFalse("nothing that arrived short reaches the package folder", mPackageFolder.exists());
179
+ }
180
+
181
+ @Test
182
+ public void installsAnUpdateTheServerSentWithoutDeclaringItsLength() throws IOException {
183
+ // `getContentLength()` answers -1 for a body sent with no `Content-Length`, which no
184
+ // read total matches - so a download checked against it anyway could never arrive.
185
+ String fullUrl = mServer.serveWithoutContentLength("/full.zip", zipOf(fullArchiveContents()));
186
+
187
+ updateManager(applierWriting(TARGET_BUNDLE)).downloadPackage(
188
+ fullUpdatePackage(mPackageHash, fullUrl), BUNDLE_FILE_NAME, ignoreProgress());
189
+
190
+ assertInstalledContents();
191
+ }
192
+
135
193
  @Test
136
194
  public void fallsBackToTheFullArchiveWhenApplyingThePatchRunsOutOfMemory() throws IOException {
137
195
  String patchUrl = serve("/patch.zip", zipOf(patchArchiveContents()));
@@ -167,7 +225,7 @@ public class CodePushUpdateManagerDownloadTest {
167
225
 
168
226
  assertEquals(Arrays.asList("/patch.zip", "/full.zip"), mServer.requestedPaths());
169
227
  assertInstalledContents();
170
- assertFallbackResult(patchResult, BinaryPatchResult.REASON_PACKAGE_VERIFICATION_FAILED);
228
+ assertFallbackResult(patchResult, ArchiveRestoreResult.REASON_PACKAGE_VERIFICATION_FAILED);
171
229
  }
172
230
 
173
231
  @Test
@@ -204,7 +262,7 @@ public class CodePushUpdateManagerDownloadTest {
204
262
  .downloadPackage(updatePackage(updateHash, fullUrl, diffUrl), BUNDLE_FILE_NAME, ignoreProgress());
205
263
 
206
264
  assertEquals(Arrays.asList("/installed.zip", "/diff.zip", "/full.zip"), mServer.requestedPaths());
207
- assertFallbackResult(patchResult, BinaryPatchResult.REASON_PACKAGE_VERIFICATION_FAILED);
265
+ assertFallbackResult(patchResult, ArchiveRestoreResult.REASON_PACKAGE_VERIFICATION_FAILED);
208
266
  assertInstalledContents(updateHash, updateContents);
209
267
  }
210
268
 
@@ -221,10 +279,177 @@ public class CodePushUpdateManagerDownloadTest {
221
279
  .downloadPackage(updatePackage(updateHash, fullUrl, diffUrl), BUNDLE_FILE_NAME, ignoreProgress());
222
280
 
223
281
  assertEquals(Arrays.asList("/diff.zip", "/full.zip"), mServer.requestedPaths());
224
- assertFallbackResult(patchResult, BinaryPatchResult.REASON_PACKAGE_VERIFICATION_FAILED);
282
+ assertFallbackResult(patchResult, ArchiveRestoreResult.REASON_PACKAGE_VERIFICATION_FAILED);
283
+ assertInstalledContents(updateHash, updateContents);
284
+ }
285
+
286
+ @Test
287
+ public void fallsBackToThePatchArchiveWhenTheInstalledPackageIsGoneFromDisk() throws IOException {
288
+ // The metadata still names the installed update, but its files are gone: the merge
289
+ // has nothing to read, which is a failure of the merge itself rather than of its
290
+ // result - and the one failure the patch archive, carrying every asset, is not
291
+ // implicated in.
292
+ installBaseUpdate();
293
+ FileUtils.deleteDirectoryAtPath(new File(
294
+ new File(mDocumentsDirectory, CodePushConstants.CODE_PUSH_FOLDER_PREFIX),
295
+ packageHashOf(installedArchiveContents())).getAbsolutePath());
296
+
297
+ Map<String, byte[]> updateContents = assetDiffTargetContents();
298
+ String updateHash = packageHashOf(updateContents);
299
+ String diffUrl = serve("/diff.zip", zipOf(assetDiffArchiveContents(DROPPED_ASSET_PATH)));
300
+ String patchUrl = serve("/patch.zip", zipOf(patchArchiveContentsForAssetDiffTarget()));
301
+ String fullUrl = serve("/full.zip", zipOf(updateContents));
302
+
303
+ JSONObject patchResult = updateManager(applierWriting(TARGET_BUNDLE)).downloadPackage(
304
+ updatePackageWithAssetDiff(updateHash, fullUrl, patchUrl, diffUrl), BUNDLE_FILE_NAME, ignoreProgress());
305
+
306
+ assertEquals(Arrays.asList("/installed.zip", "/diff.zip", "/patch.zip"), mServer.requestedPaths());
307
+ assertEquals("applied", patchResult.optString("status", null));
308
+ assertEquals("binary-patch", patchResult.optString("archive", null));
309
+ assertEquals(ArchiveRestoreResult.REASON_ASSET_MERGE_FAILED,
310
+ patchResult.optJSONArray("attempts").optJSONObject(0).optString("fallbackReason", null));
311
+ assertInstalledContents(updateHash, updateContents);
312
+ }
313
+
314
+ @Test
315
+ public void fallsBackToThePatchArchiveWhenTheAssetDiffManifestCannotBeParsed() throws IOException {
316
+ // Bytes that are not JSON leave nothing to read the deletions out of, so the merge
317
+ // has no way to know what it was supposed to delete - a failure of the merge itself.
318
+ installBaseUpdate();
319
+ Map<String, byte[]> updateContents = assetDiffTargetContents();
320
+ String updateHash = packageHashOf(updateContents);
321
+ String diffUrl = serve("/diff.zip",
322
+ zipOf(assetDiffArchiveContentsWithManifest(bytes("{\"deletedFiles\":"))));
323
+ String patchUrl = serve("/patch.zip", zipOf(patchArchiveContentsForAssetDiffTarget()));
324
+ String fullUrl = serve("/full.zip", zipOf(updateContents));
325
+
326
+ JSONObject patchResult = updateManager(applierWriting(TARGET_BUNDLE)).downloadPackage(
327
+ updatePackageWithAssetDiff(updateHash, fullUrl, patchUrl, diffUrl), BUNDLE_FILE_NAME, ignoreProgress());
328
+
329
+ assertEquals(Arrays.asList("/installed.zip", "/diff.zip", "/patch.zip"), mServer.requestedPaths());
330
+ assertEquals("applied", patchResult.optString("status", null));
331
+ assertEquals("binary-patch", patchResult.optString("archive", null));
332
+ assertEquals(ArchiveRestoreResult.REASON_ASSET_MERGE_FAILED,
333
+ patchResult.optJSONArray("attempts").optJSONObject(0).optString("fallbackReason", null));
334
+ assertInstalledContents(updateHash, updateContents);
335
+ }
336
+
337
+ @Test
338
+ public void fallsBackToThePatchArchiveWhenTheAssetDiffManifestDoesNotNameTheFilesToDelete() throws IOException {
339
+ // A manifest without the key is not a manifest with nothing to delete: the CLI always
340
+ // writes it, an empty list included, so its absence says the manifest is not the one
341
+ // the release published - and merging past it would leave behind files the update
342
+ // dropped.
343
+ installBaseUpdate();
344
+ Map<String, byte[]> updateContents = assetDiffTargetContents();
345
+ String updateHash = packageHashOf(updateContents);
346
+ String diffUrl = serve("/diff.zip", zipOf(assetDiffArchiveContentsWithManifest(bytes("{}"))));
347
+ String patchUrl = serve("/patch.zip", zipOf(patchArchiveContentsForAssetDiffTarget()));
348
+ String fullUrl = serve("/full.zip", zipOf(updateContents));
349
+
350
+ JSONObject patchResult = updateManager(applierWriting(TARGET_BUNDLE)).downloadPackage(
351
+ updatePackageWithAssetDiff(updateHash, fullUrl, patchUrl, diffUrl), BUNDLE_FILE_NAME, ignoreProgress());
352
+
353
+ assertEquals(Arrays.asList("/installed.zip", "/diff.zip", "/patch.zip"), mServer.requestedPaths());
354
+ assertEquals("applied", patchResult.optString("status", null));
355
+ assertEquals("binary-patch", patchResult.optString("archive", null));
356
+ assertEquals(ArchiveRestoreResult.REASON_ASSET_MERGE_FAILED,
357
+ patchResult.optJSONArray("attempts").optJSONObject(0).optString("fallbackReason", null));
358
+ assertInstalledContents(updateHash, updateContents);
359
+ }
360
+
361
+ @Test
362
+ public void triesThePatchArchiveWhenTheServerDoesNotServeTheAssetDiff() throws IOException {
363
+ // A 404 is a verdict on the URL it was asked of. Diffs are published one per recent
364
+ // version and are the first thing a retention policy clears out, while the patch
365
+ // archive at its own URL stays - so nothing about a diff that is gone says the patch
366
+ // archive is.
367
+ Map<String, byte[]> updateContents = assetDiffTargetContents();
368
+ String updateHash = packageHashOf(updateContents);
369
+ String diffUrl = mServer.urlOf("/missing-diff.zip");
370
+ String patchUrl = serve("/patch.zip", zipOf(patchArchiveContentsForAssetDiffTarget()));
371
+ String fullUrl = serve("/full.zip", zipOf(updateContents));
372
+
373
+ JSONObject patchResult = updateManager(applierWriting(TARGET_BUNDLE)).downloadPackage(
374
+ updatePackageWithAssetDiff(updateHash, fullUrl, patchUrl, diffUrl), BUNDLE_FILE_NAME, ignoreProgress());
375
+
376
+ assertEquals("the full archive is not downloaded when the patch archive installs",
377
+ Arrays.asList("/missing-diff.zip", "/patch.zip"), mServer.requestedPaths());
378
+ assertEquals("applied", patchResult.optString("status", null));
379
+ assertEquals("binary-patch", patchResult.optString("archive", null));
380
+ assertEquals(2, patchResult.optJSONArray("attempts").length());
381
+ assertInstalledContents(updateHash, updateContents);
382
+ }
383
+
384
+ @Test
385
+ public void skipsThePatchArchiveWhenTheAssetDiffFailsInItsBundlePatch() throws IOException {
386
+ // Both archives carry that patch byte for byte, so an applier that refused it here
387
+ // would refuse it there - and trying it would put a second doomed download in front
388
+ // of the full one.
389
+ Map<String, byte[]> updateContents = assetDiffTargetContents();
390
+ String updateHash = packageHashOf(updateContents);
391
+ String diffUrl = serve("/diff.zip", zipOf(assetDiffArchiveContents(DROPPED_ASSET_PATH)));
392
+ String patchUrl = serve("/patch.zip", zipOf(patchArchiveContentsForAssetDiffTarget()));
393
+ String fullUrl = serve("/full.zip", zipOf(updateContents));
394
+
395
+ JSONObject patchResult = updateManager(applierRefusingThePatch()).downloadPackage(
396
+ updatePackageWithAssetDiff(updateHash, fullUrl, patchUrl, diffUrl), BUNDLE_FILE_NAME, ignoreProgress());
397
+
398
+ assertEquals(Arrays.asList("/diff.zip", "/full.zip"), mServer.requestedPaths());
399
+ assertEquals(1, patchResult.optJSONArray("attempts").length());
225
400
  assertInstalledContents(updateHash, updateContents);
226
401
  }
227
402
 
403
+ @Test
404
+ public void givesUpTheDownloadWhenTheNetworkCannotCarryTheAssetDiff() throws IOException {
405
+ // A refused connection is the network failing rather than a verdict on the archive,
406
+ // and the archives behind it are behind the same network - the full one only larger
407
+ // and started over from nothing.
408
+ String unreachableDiffUrl = "http://127.0.0.1:" + portNothingListensOn() + "/diff.zip";
409
+ String patchUrl = serve("/patch.zip", zipOf(patchArchiveContents()));
410
+ String fullUrl = serve("/full.zip", zipOf(fullArchiveContents()));
411
+
412
+ try {
413
+ updateManager(applierWriting(TARGET_BUNDLE)).downloadPackage(
414
+ updatePackageWithAssetDiff(mPackageHash, fullUrl, patchUrl, unreachableDiffUrl),
415
+ BUNDLE_FILE_NAME, ignoreProgress());
416
+ fail("a network that carried nothing must not be reported as an installed update");
417
+ } catch (IOException e) {
418
+ assertEquals(CodePushErrorCode.NETWORK, CodePushErrorCode.of(e));
419
+ }
420
+
421
+ assertTrue("no archive behind the same network is asked for", mServer.requestedPaths().isEmpty());
422
+ assertFalse("nothing that never arrived reaches the package folder", mPackageFolder.exists());
423
+ }
424
+
425
+
426
+ /** A loopback port that is opened only to be closed, so connecting to it is refused. */
427
+ private static int portNothingListensOn() throws IOException {
428
+ ServerSocket socket = new ServerSocket(0, 0, InetAddress.getByName("127.0.0.1"));
429
+ int port = socket.getLocalPort();
430
+ socket.close();
431
+ return port;
432
+ }
433
+
434
+ @Test
435
+ public void installsFromThePatchArchiveWhenTheAssetDiffUrlIsAnEmptyString() throws IOException {
436
+ // An empty slot is not an archive on offer. Attempting it would fail for want of a
437
+ // URL and leave no verdict behind, which is what skips the patch archive - so the
438
+ // update would be downloaded in full with a perfectly good patch archive untried.
439
+ String patchUrl = serve("/patch.zip", zipOf(patchArchiveContents()));
440
+ String fullUrl = serve("/full.zip", zipOf(fullArchiveContents()));
441
+
442
+ JSONObject patchResult = updateManager(applierWriting(TARGET_BUNDLE)).downloadPackage(
443
+ updatePackageWithAssetDiff(mPackageHash, fullUrl, patchUrl, ""), BUNDLE_FILE_NAME, ignoreProgress());
444
+
445
+ assertEquals("the empty diff slot is not downloaded and does not cost the patch archive its try",
446
+ Arrays.asList("/patch.zip"), mServer.requestedPaths());
447
+ assertEquals("applied", patchResult.optString("status", null));
448
+ assertEquals("binary-patch", patchResult.optString("archive", null));
449
+ assertEquals(1, patchResult.optJSONArray("attempts").length());
450
+ assertInstalledContents();
451
+ }
452
+
228
453
  @Test
229
454
  public void leavesAFileOutsideThePackageFolderAloneWhenTheAssetDiffManifestNamesIt() throws IOException {
230
455
  // A manifest entry that climbs out of the package folder: the client is not the one
@@ -287,7 +512,8 @@ public class CodePushUpdateManagerDownloadTest {
287
512
  private static void assertFallbackResult(JSONObject patchResult, String expectedReason) {
288
513
  assertEquals("fallback", patchResult.optString("status", null));
289
514
  assertEquals(expectedReason, patchResult.optString("fallbackReason", null));
290
- assertTrue("the attempt is timed", patchResult.optLong("applyDurationMs", -1) >= 0);
515
+ assertTrue("the whole path is timed", patchResult.optLong("totalDurationMs", -1) >= 0);
516
+ assertFalse("the result no longer carries a top level apply time", patchResult.has("applyDurationMs"));
291
517
  }
292
518
 
293
519
  /** The installed update is the full archive's contents, whichever archive it came from. */
@@ -307,7 +533,7 @@ public class CodePushUpdateManagerDownloadTest {
307
533
  // How the update was downloaded says nothing about the update, so it is not part of
308
534
  // what the update is installed from and outlives the download in no file.
309
535
  assertFalse("the patch attempt reached the stored metadata",
310
- metadata.has(CodePushConstants.BINARY_PATCH_RESULT_KEY));
536
+ metadata.has(CodePushConstants.UPDATE_ARCHIVE_RESULT_KEY));
311
537
 
312
538
  File binaryPatchFolder = new File(
313
539
  new File(mDocumentsDirectory, CodePushConstants.CODE_PUSH_FOLDER_PREFIX),
@@ -378,6 +604,16 @@ public class CodePushUpdateManagerDownloadTest {
378
604
  return new CodePushUpdateManager(mDocumentsDirectory, binaryPatch);
379
605
  }
380
606
 
607
+ /** An applier that refuses the bundle patch, which every archive of a release carries. */
608
+ private static CodePushBinaryPatch.PatchApplier applierRefusingThePatch() {
609
+ return new CodePushBinaryPatch.PatchApplier() {
610
+ @Override
611
+ public int apply(byte[] base, byte[] patch, String outputPath, long expectedTargetSize) {
612
+ return RESULT_APPLY_FAILED;
613
+ }
614
+ };
615
+ }
616
+
381
617
  private static CodePushBinaryPatch.PatchApplier applierWriting(final byte[] restoredBundle) {
382
618
  return new CodePushBinaryPatch.PatchApplier() {
383
619
  @Override
@@ -437,11 +673,16 @@ public class CodePushUpdateManagerDownloadTest {
437
673
  * holds unchanged is not shipped at all - the client copies it over.
438
674
  */
439
675
  private Map<String, byte[]> assetDiffArchiveContents(String deletedAssetPath) {
676
+ return assetDiffArchiveContentsWithManifest(
677
+ bytes("{\"deletedFiles\":[\"" + CONTENTS_DIR_NAME + "/" + deletedAssetPath + "\"]}"));
678
+ }
679
+
680
+ /** The same archive carrying a manifest of its own, which is how one the CLI did not write arrives. */
681
+ private Map<String, byte[]> assetDiffArchiveContentsWithManifest(byte[] manifest) {
440
682
  Map<String, byte[]> contents = patchArchiveContents();
441
683
  contents.remove(CONTENTS_DIR_NAME + "/" + ASSET_PATH);
442
684
  contents.put(CONTENTS_DIR_NAME + "/" + ADDED_ASSET_PATH, ADDED_ASSET);
443
- contents.put(CodePushConstants.DIFF_MANIFEST_FILE_NAME,
444
- bytes("{\"deletedFiles\":[\"" + CONTENTS_DIR_NAME + "/" + deletedAssetPath + "\"]}"));
685
+ contents.put(CodePushConstants.DIFF_MANIFEST_FILE_NAME, manifest);
445
686
  return contents;
446
687
  }
447
688
 
@@ -453,6 +694,13 @@ public class CodePushUpdateManagerDownloadTest {
453
694
  return contents;
454
695
  }
455
696
 
697
+ /** The patch archive of the release the asset diff belongs to: every asset, no merge. */
698
+ private Map<String, byte[]> patchArchiveContentsForAssetDiffTarget() {
699
+ Map<String, byte[]> contents = patchArchiveContents();
700
+ contents.put(CONTENTS_DIR_NAME + "/" + ADDED_ASSET_PATH, ADDED_ASSET);
701
+ return contents;
702
+ }
703
+
456
704
  /** What an asset diff has to add up to: the contents of the update's full archive. */
457
705
  private Map<String, byte[]> assetDiffTargetContents() {
458
706
  Map<String, byte[]> contents = fullArchiveContents();
@@ -470,6 +718,14 @@ public class CodePushUpdateManagerDownloadTest {
470
718
  return updatePackage;
471
719
  }
472
720
 
721
+ /** A release offering all three archives, the diff in the slot of its own the JS fills. */
722
+ private JSONObject updatePackageWithAssetDiff(String packageHash, String downloadUrl,
723
+ String binaryPatchDownloadUrl, String assetDiffDownloadUrl) {
724
+ JSONObject updatePackage = updatePackage(packageHash, downloadUrl, binaryPatchDownloadUrl);
725
+ CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.ASSET_DIFF_DOWNLOAD_URL_KEY, assetDiffDownloadUrl);
726
+ return updatePackage;
727
+ }
728
+
473
729
  /** A release with no second archive to try, so it is downloaded in full. */
474
730
  private JSONObject fullUpdatePackage(String packageHash, String downloadUrl) {
475
731
  JSONObject updatePackage = new JSONObject();
@@ -491,7 +747,10 @@ public class CodePushUpdateManagerDownloadTest {
491
747
  private static class TestArchiveServer {
492
748
 
493
749
  private final ServerSocket mSocket;
750
+ private static final long NO_CONTENT_LENGTH = -1;
751
+
494
752
  private final Map<String, byte[]> mBodies = new HashMap<>();
753
+ private final Map<String, Long> mDeclaredLengths = new HashMap<>();
495
754
  private final List<String> mRequestedPaths = Collections.synchronizedList(new ArrayList<String>());
496
755
 
497
756
  TestArchiveServer() throws IOException {
@@ -508,6 +767,23 @@ public class CodePushUpdateManagerDownloadTest {
508
767
 
509
768
  synchronized String serve(String path, byte[] body) {
510
769
  mBodies.put(path, body);
770
+ return urlOf(path);
771
+ }
772
+
773
+ /** Serves a body under a `Content-Length` of the server's choosing rather than its own. */
774
+ synchronized String serveClaimingLength(String path, byte[] body, long declaredLength) {
775
+ mBodies.put(path, body);
776
+ mDeclaredLengths.put(path, declaredLength);
777
+ return urlOf(path);
778
+ }
779
+
780
+ /** Serves a body with no `Content-Length` at all, which the client reads until it closes. */
781
+ synchronized String serveWithoutContentLength(String path, byte[] body) {
782
+ return serveClaimingLength(path, body, NO_CONTENT_LENGTH);
783
+ }
784
+
785
+ /** The URL of a path this server answers - with a 404, when nothing is served there. */
786
+ String urlOf(String path) {
511
787
  return "http://127.0.0.1:" + mSocket.getLocalPort() + path;
512
788
  }
513
789
 
@@ -557,8 +833,17 @@ public class CodePushUpdateManagerDownloadTest {
557
833
  if (body == null) {
558
834
  response.write(bytes("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"));
559
835
  } else {
560
- response.write(bytes("HTTP/1.1 200 OK\r\nContent-Length: " + body.length
561
- + "\r\nConnection: close\r\n\r\n"));
836
+ Long declaredLength = declaredLengthFor(path);
837
+ if (declaredLength == null) {
838
+ response.write(bytes("HTTP/1.1 200 OK\r\nContent-Length: " + body.length
839
+ + "\r\nConnection: close\r\n\r\n"));
840
+ } else if (declaredLength == NO_CONTENT_LENGTH) {
841
+ response.write(bytes("HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n"));
842
+ } else {
843
+ response.write(bytes("HTTP/1.1 200 OK\r\nContent-Length: " + declaredLength
844
+ + "\r\nConnection: close\r\n\r\n"));
845
+ }
846
+
562
847
  response.write(body);
563
848
  }
564
849
  response.flush();
@@ -573,6 +858,10 @@ public class CodePushUpdateManagerDownloadTest {
573
858
  private synchronized byte[] bodyFor(String path) {
574
859
  return mBodies.get(path);
575
860
  }
861
+
862
+ private synchronized Long declaredLengthFor(String path) {
863
+ return mDeclaredLengths.get(path);
864
+ }
576
865
  }
577
866
 
578
867
  private static byte[] zipOf(Map<String, byte[]> contents) throws IOException {
@@ -0,0 +1,118 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";
5
+ import { buildPatchTools, hashBuildScript } from "./buildPatchTools.js";
6
+
7
+ /**
8
+ * The command is a thin wrapper around the build script this package ships. What it has
9
+ * to get right is what it hands the script: the directory the CLI will later look in, and
10
+ * `--force` when asked. A script that records what it was handed proves both without
11
+ * cloning and compiling HDiffPatch. The shipped script is run once at the end, against an
12
+ * install it already finds complete, to show the wrapper speaks its actual interface.
13
+ */
14
+
15
+ const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
16
+ const SHIPPED_BUILD_SCRIPT = path.join(REPO_ROOT, "scripts", "binary-patch", "build-hdiffpatch.sh");
17
+ /** Where `jest.globalSetup.ts` built the tools for this run. */
18
+ const INSTALLED_TOOLS_DIR = process.env.HDIFFPATCH_TOOLS_DIR || path.join(REPO_ROOT, ".hdiffpatch-tools");
19
+
20
+ let workDir: string;
21
+
22
+ beforeEach(() => {
23
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), "build-patch-tools-"));
24
+ });
25
+
26
+ afterEach(() => {
27
+ fs.rmSync(workDir, { recursive: true, force: true });
28
+ });
29
+
30
+ function writeScript(body: string): string {
31
+ const scriptPath = path.join(workDir, "build.sh");
32
+ fs.writeFileSync(scriptPath, `#!/bin/sh\n${body}\n`, { mode: 0o755 });
33
+ return scriptPath;
34
+ }
35
+
36
+ /** A script that writes the install directory it was given, then each argument, one per line. */
37
+ function writeRecordingScript(): string {
38
+ return writeScript('printf \'%s\\n\' "$HDIFFPATCH_TOOLS_DIR" "$@" > "$HDIFFPATCH_TOOLS_DIR/invocation"');
39
+ }
40
+
41
+ function readInvocation(toolsDir: string): string[] {
42
+ return fs.readFileSync(path.join(toolsDir, "invocation"), "utf8").trimEnd().split("\n");
43
+ }
44
+
45
+ describe("buildPatchTools", () => {
46
+ it("runs the build script against the tools directory it was given, without forcing a rebuild", () => {
47
+ const toolsDir = path.join(workDir, "tools");
48
+ fs.mkdirSync(toolsDir);
49
+
50
+ buildPatchTools({ buildScriptPath: writeRecordingScript(), toolsDir, force: false });
51
+
52
+ expect(readInvocation(toolsDir)).toEqual([toolsDir]);
53
+ });
54
+
55
+ it("asks the build script to rebuild when forced", () => {
56
+ const toolsDir = path.join(workDir, "tools");
57
+ fs.mkdirSync(toolsDir);
58
+
59
+ buildPatchTools({ buildScriptPath: writeRecordingScript(), toolsDir, force: true });
60
+
61
+ expect(readInvocation(toolsDir)).toEqual([toolsDir, "--force"]);
62
+ });
63
+
64
+ it("fails with the exit code when the build script fails", () => {
65
+ const buildScriptPath = writeScript("exit 3");
66
+
67
+ expect(() => buildPatchTools({ buildScriptPath, toolsDir: workDir, force: false })).toThrow(/exit code 3/);
68
+ });
69
+
70
+ it("fails naming the script when it cannot be started", () => {
71
+ const buildScriptPath = path.join(workDir, "no-such-script.sh");
72
+
73
+ expect(() => buildPatchTools({ buildScriptPath, toolsDir: workDir, force: false })).toThrow(
74
+ /no-such-script\.sh/,
75
+ );
76
+ });
77
+
78
+ it("succeeds against an install the shipped script already finds complete", () => {
79
+ // Without an install the shipped script clones and compiles for minutes, and
80
+ // `spawnSync` holds the worker past any jest timeout. `jest.globalSetup.ts`
81
+ // provides the install; this says so when it did not.
82
+ const missing = ["hdiffz", "hpatchz"].filter((tool) => !fs.existsSync(path.join(INSTALLED_TOOLS_DIR, tool)));
83
+ if (missing.length > 0) {
84
+ throw new Error(
85
+ `${missing.join(", ")} not installed in ${INSTALLED_TOOLS_DIR}: jest.globalSetup.ts should have built the tools first`,
86
+ );
87
+ }
88
+
89
+ expect(() =>
90
+ buildPatchTools({ buildScriptPath: SHIPPED_BUILD_SCRIPT, toolsDir: INSTALLED_TOOLS_DIR, force: false }),
91
+ ).not.toThrow();
92
+ });
93
+ });
94
+
95
+ /**
96
+ * A CI cache of the installed tools is keyed by this hash, so it has to stay the same for as
97
+ * long as the script would build the same tools, and change as soon as it would not.
98
+ */
99
+ describe("hashBuildScript", () => {
100
+ it("hashes two scripts with the same contents to the same hex digest", () => {
101
+ const first = path.join(workDir, "first.sh");
102
+ const second = path.join(workDir, "second.sh");
103
+ fs.writeFileSync(first, "#!/bin/sh\nHDIFFPATCH_TAG=v5.1.3\n");
104
+ fs.writeFileSync(second, "#!/bin/sh\nHDIFFPATCH_TAG=v5.1.3\n");
105
+
106
+ expect(hashBuildScript(first)).toBe(hashBuildScript(second));
107
+ expect(hashBuildScript(first)).toMatch(/^[0-9a-f]{64}$/);
108
+ });
109
+
110
+ it("hashes a script whose pinned version changed to a different value", () => {
111
+ const before = path.join(workDir, "before.sh");
112
+ const after = path.join(workDir, "after.sh");
113
+ fs.writeFileSync(before, "#!/bin/sh\nHDIFFPATCH_TAG=v5.1.3\n");
114
+ fs.writeFileSync(after, "#!/bin/sh\nHDIFFPATCH_TAG=v5.1.4\n");
115
+
116
+ expect(hashBuildScript(after)).not.toBe(hashBuildScript(before));
117
+ });
118
+ });
@@ -0,0 +1,46 @@
1
+ import { spawnSync } from "child_process";
2
+ import crypto from "crypto";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { TOOLS_DIR_ENV_NAME } from "../../utils/binaryPatch.js";
6
+
7
+ interface BuildPatchToolsOptions {
8
+ /** The `build-hdiffpatch.sh` this package ships. */
9
+ buildScriptPath: string;
10
+ /** Where the script installs `hdiffz` and `hpatchz`. It has to be a place the CLI looks in. */
11
+ toolsDir: string;
12
+ /** Rebuild even when both tools are already there. */
13
+ force: boolean;
14
+ }
15
+
16
+ /**
17
+ * Runs the build script with the install directory the caller chose.
18
+ *
19
+ * The script's output goes straight to the terminal: a first build clones and compiles
20
+ * HDiffPatch, which takes minutes, and a silent wait would look like a hang. So when the
21
+ * script fails, its own message has already been shown, and the error raised here only
22
+ * has to say that it did.
23
+ */
24
+ export function buildPatchTools({ buildScriptPath, toolsDir, force }: BuildPatchToolsOptions): void {
25
+ const result = spawnSync(buildScriptPath, force ? ['--force'] : [], {
26
+ stdio: 'inherit',
27
+ env: { ...process.env, [TOOLS_DIR_ENV_NAME]: toolsDir },
28
+ });
29
+
30
+ if (result.error) {
31
+ throw new Error(`failed to run ${buildScriptPath}: ${result.error.message}`);
32
+ }
33
+ if (result.status !== 0) {
34
+ const reason = result.status === null ? `signal ${result.signal}` : `exit code ${result.status}`;
35
+ throw new Error(`${path.basename(buildScriptPath)} failed with ${reason}`);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * SHA-256 of the build script's bytes, for keying a CI cache of the installed tools. The
41
+ * script pins the sources and the build flags, so it changes whenever the tools it would
42
+ * build do.
43
+ */
44
+ export function hashBuildScript(buildScriptPath: string): string {
45
+ return crypto.createHash('sha256').update(fs.readFileSync(buildScriptPath)).digest('hex');
46
+ }
@@ -0,0 +1,44 @@
1
+ import path from "path";
2
+ import { fileURLToPath } from "url";
3
+ import { program } from "commander";
4
+ import { TOOLS_DIR_ENV_NAME, TOOLS_DIR_NAME } from "../../utils/binaryPatch.js";
5
+ import { buildPatchTools, hashBuildScript } from "./buildPatchTools.js";
6
+
7
+ type Options = {
8
+ toolsDir: string;
9
+ force: boolean;
10
+ printHash: boolean;
11
+ }
12
+
13
+ /**
14
+ * The CLI runs compiled, from `cli/dist/commands/buildPatchToolsCommand/`, which puts the
15
+ * package root - and the script shipped under it - four levels up.
16
+ */
17
+ const BUILD_SCRIPT_PATH = fileURLToPath(
18
+ new URL("../../../../scripts/binary-patch/build-hdiffpatch.sh", import.meta.url),
19
+ );
20
+
21
+ /**
22
+ * Installs where `release` will look: `HDIFFPATCH_TOOLS_DIR` when it is set, and otherwise
23
+ * a `.hdiffpatch-tools` directory in the working directory, the first place the lookup
24
+ * checks before walking up.
25
+ */
26
+ const DEFAULT_TOOLS_DIR = process.env[TOOLS_DIR_ENV_NAME] || path.resolve(process.cwd(), TOOLS_DIR_NAME);
27
+
28
+ program.command('build-patch-tools')
29
+ .description('Builds hdiffz and hpatchz from source and installs them where `release --binary-bundle-path` looks for them.\nThe build clones the pinned HDiffPatch sources, so it needs git, make, a C/C++ compiler and network access. It does nothing when the tools are already installed.')
30
+ .option('--tools-dir <path>', 'directory to install the tools into', DEFAULT_TOOLS_DIR)
31
+ .option('--force', 'rebuild even when the tools are already installed', false)
32
+ .option('--print-hash', 'print a hash of the build script, for keying a CI cache of the tools, and exit without building', false)
33
+ .action((options: Options) => {
34
+ if (options.printHash) {
35
+ console.log(hashBuildScript(BUILD_SCRIPT_PATH));
36
+ return;
37
+ }
38
+
39
+ buildPatchTools({
40
+ buildScriptPath: BUILD_SCRIPT_PATH,
41
+ toolsDir: path.resolve(options.toolsDir),
42
+ force: options.force,
43
+ });
44
+ });