@bravemobile/react-native-code-push 13.0.0 → 13.1.0-beta.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 (105) hide show
  1. package/CodePush.podspec +35 -3
  2. package/README.md +115 -3
  3. package/THIRD-PARTY-NOTICES +96 -0
  4. package/android/app/build.gradle +19 -0
  5. package/android/app/proguard-rules.pro +5 -0
  6. package/android/app/src/main/cpp/CMakeLists.txt +38 -0
  7. package/android/app/src/main/cpp/binarypatch_jni.c +181 -0
  8. package/android/app/src/main/java/com/microsoft/codepush/react/BinaryPatchAttempt.java +92 -0
  9. package/android/app/src/main/java/com/microsoft/codepush/react/BinaryPatchResult.java +64 -0
  10. package/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java +1 -1
  11. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushBinaryPatch.java +293 -0
  12. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java +25 -0
  13. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java +12 -2
  14. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java +159 -4
  15. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java +13 -0
  16. package/android/app/src/main/java/com/microsoft/codepush/react/HDiffPatchNative.java +52 -0
  17. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushBinaryPatchTest.java +393 -0
  18. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerBinaryPatchTest.java +207 -0
  19. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java +449 -0
  20. package/android/codepush-export.gradle +115 -0
  21. package/cli/commands/bundleCommand/bundleCodePush.ts +39 -7
  22. package/cli/commands/bundleCommand/index.ts +9 -1
  23. package/cli/commands/releaseCommand/addToReleaseHistory.ts +7 -0
  24. package/cli/commands/releaseCommand/index.test.ts +118 -0
  25. package/cli/commands/releaseCommand/index.ts +20 -2
  26. package/cli/commands/releaseCommand/release.test.ts +582 -0
  27. package/cli/commands/releaseCommand/release.ts +232 -15
  28. package/cli/dist/commands/bundleCommand/bundleCodePush.js +26 -7
  29. package/cli/dist/commands/bundleCommand/index.js +5 -1
  30. package/cli/dist/commands/releaseCommand/addToReleaseHistory.js +6 -1
  31. package/cli/dist/commands/releaseCommand/index.js +8 -1
  32. package/cli/dist/commands/releaseCommand/index.test.js +96 -0
  33. package/cli/dist/commands/releaseCommand/release.js +165 -17
  34. package/cli/dist/commands/releaseCommand/release.test.js +367 -0
  35. package/cli/dist/functions/makeBinaryPatchBundle.js +248 -0
  36. package/cli/dist/functions/makeBinaryPatchBundle.test.js +321 -0
  37. package/cli/dist/functions/makeCodePushBundle.js +4 -2
  38. package/cli/dist/functions/runHermesEmitBinaryCommand.js +48 -1
  39. package/cli/dist/functions/runHermesEmitBinaryCommand.test.js +100 -0
  40. package/cli/dist/jest.globalSetup.js +38 -0
  41. package/cli/dist/utils/binaryPatch.js +86 -0
  42. package/cli/dist/utils/binaryPatch.test.js +190 -0
  43. package/cli/functions/makeBinaryPatchBundle.test.ts +410 -0
  44. package/cli/functions/makeBinaryPatchBundle.ts +367 -0
  45. package/cli/functions/makeCodePushBundle.ts +4 -2
  46. package/cli/functions/runHermesEmitBinaryCommand.test.ts +124 -0
  47. package/cli/functions/runHermesEmitBinaryCommand.ts +57 -1
  48. package/cli/jest.globalSetup.ts +43 -0
  49. package/cli/utils/binaryPatch.test.ts +234 -0
  50. package/cli/utils/binaryPatch.ts +103 -0
  51. package/cpp/binarypatch/binarypatch_zstd_decompressor.c +187 -0
  52. package/cpp/binarypatch/binarypatch_zstd_decompressor.h +47 -0
  53. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/checksum_plugin.h +52 -0
  54. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/hpatch_mt/hpatch_mt.h +147 -0
  55. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch.c +3315 -0
  56. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch.h +268 -0
  57. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch_private.h +297 -0
  58. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch_types.h +434 -0
  59. package/cpp/binarypatch/vendor/zstd/common/allocations.h +55 -0
  60. package/cpp/binarypatch/vendor/zstd/common/bits.h +205 -0
  61. package/cpp/binarypatch/vendor/zstd/common/bitstream.h +454 -0
  62. package/cpp/binarypatch/vendor/zstd/common/compiler.h +464 -0
  63. package/cpp/binarypatch/vendor/zstd/common/cpu.h +249 -0
  64. package/cpp/binarypatch/vendor/zstd/common/debug.c +30 -0
  65. package/cpp/binarypatch/vendor/zstd/common/debug.h +107 -0
  66. package/cpp/binarypatch/vendor/zstd/common/entropy_common.c +340 -0
  67. package/cpp/binarypatch/vendor/zstd/common/error_private.c +64 -0
  68. package/cpp/binarypatch/vendor/zstd/common/error_private.h +158 -0
  69. package/cpp/binarypatch/vendor/zstd/common/fse.h +625 -0
  70. package/cpp/binarypatch/vendor/zstd/common/fse_decompress.c +315 -0
  71. package/cpp/binarypatch/vendor/zstd/common/huf.h +277 -0
  72. package/cpp/binarypatch/vendor/zstd/common/mem.h +422 -0
  73. package/cpp/binarypatch/vendor/zstd/common/pool.c +371 -0
  74. package/cpp/binarypatch/vendor/zstd/common/pool.h +81 -0
  75. package/cpp/binarypatch/vendor/zstd/common/portability_macros.h +171 -0
  76. package/cpp/binarypatch/vendor/zstd/common/threading.c +182 -0
  77. package/cpp/binarypatch/vendor/zstd/common/threading.h +142 -0
  78. package/cpp/binarypatch/vendor/zstd/common/xxhash.c +18 -0
  79. package/cpp/binarypatch/vendor/zstd/common/xxhash.h +7094 -0
  80. package/cpp/binarypatch/vendor/zstd/common/zstd_common.c +48 -0
  81. package/cpp/binarypatch/vendor/zstd/common/zstd_deps.h +123 -0
  82. package/cpp/binarypatch/vendor/zstd/common/zstd_internal.h +324 -0
  83. package/cpp/binarypatch/vendor/zstd/common/zstd_trace.h +156 -0
  84. package/cpp/binarypatch/vendor/zstd/decompress/huf_decompress.c +1944 -0
  85. package/cpp/binarypatch/vendor/zstd/decompress/zstd_ddict.c +244 -0
  86. package/cpp/binarypatch/vendor/zstd/decompress/zstd_ddict.h +44 -0
  87. package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress.c +2410 -0
  88. package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress_block.c +2209 -0
  89. package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress_block.h +73 -0
  90. package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress_internal.h +240 -0
  91. package/cpp/binarypatch/vendor/zstd/zstd.h +3206 -0
  92. package/cpp/binarypatch/vendor/zstd/zstd_errors.h +107 -0
  93. package/ios/CodePush/CodePush.h +18 -1
  94. package/ios/CodePush/CodePush.mm +16 -1
  95. package/ios/CodePush/CodePushBinaryPatch.h +80 -0
  96. package/ios/CodePush/CodePushBinaryPatch.m +473 -0
  97. package/ios/CodePush/CodePushPackage.m +183 -2
  98. package/ios/CodePush/CodePushUpdateUtils.m +49 -1
  99. package/package.json +10 -3
  100. package/scripts/binary-patch/build-hdiffpatch.sh +86 -0
  101. package/scripts/export-embedded-bundle.sh +76 -0
  102. package/src/CodePush.js +46 -3
  103. package/src/CodePush.test.js +382 -0
  104. package/src/package-mixins.js +20 -2
  105. package/typings/react-native-code-push.d.ts +77 -0
@@ -1,13 +1,20 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { bundleCodePush } from "../bundleCommand/bundleCodePush.js";
3
+ import { bundleCodePush, resolveJsBundleName } from "../bundleCommand/bundleCodePush.js";
4
4
  import { addToReleaseHistory } from "./addToReleaseHistory.js";
5
5
  import { generatePackageHashFromDirectory } from "../../utils/hash-utils.js";
6
6
  import { unzip } from "../../utils/unzip.js";
7
- export async function release(bundleUploader, getReleaseHistory, setReleaseHistory, binaryVersion, appVersion, framework, platform, identifier, outputPath, entryFile, jsBundleName, mandatory, enable, rollout, skipBundle, skipCleanup, bundleDirectory, outputMetroDir, hashCalc) {
8
- const bundleFileName = skipBundle
9
- ? readBundleFileNameFrom(bundleDirectory)
10
- : await bundleCodePush(framework, platform, outputPath, entryFile, jsBundleName, bundleDirectory, outputMetroDir);
7
+ import { BINARY_PATCH_ARCHIVE_SUFFIX, BINARY_PATCH_BASE_RECORD_NAME, DEFAULT_OVERSIZED_PATCH_POLICY, extractCodePushBundleContents, formatBinaryPatchSummary, hashBundleFile, isPatchArchiveOversized, makeBinaryPatchBundle, readBinaryPatchBaseRecord, } from "../../functions/makeBinaryPatchBundle.js";
8
+ export async function release(bundleUploader, getReleaseHistory, setReleaseHistory, binaryVersion, appVersion, framework, platform, identifier, outputPath, entryFile, jsBundleName, mandatory, enable, rollout, skipBundle, skipCleanup, bundleDirectory, outputMetroDir, hashCalc, baseBundlePath, onOversizedPatch = DEFAULT_OVERSIZED_PATCH_POLICY) {
9
+ if (baseBundlePath) {
10
+ // Checked before the bundler runs, so the wrong base bundle costs a second rather
11
+ // than a full build.
12
+ verifyExportedBaseBundleRecord(baseBundlePath, binaryVersion);
13
+ }
14
+ const codePushBundle = skipBundle
15
+ ? null
16
+ : await bundleCodePush(framework, platform, outputPath, entryFile, jsBundleName, bundleDirectory, outputMetroDir, baseBundlePath);
17
+ const bundleFileName = codePushBundle?.bundleFileName ?? readBundleFileNameFrom(bundleDirectory);
11
18
  const bundleFilePath = `${bundleDirectory}/${bundleFileName}`;
12
19
  const packageHash = await (() => {
13
20
  if (skipBundle && hashCalc) {
@@ -16,17 +23,28 @@ export async function release(bundleUploader, getReleaseHistory, setReleaseHisto
16
23
  // If not using --skip-bundle, the bundleFileName represents package hash already.
17
24
  return bundleFileName;
18
25
  })();
19
- const downloadUrl = await (async () => {
20
- try {
21
- const { downloadUrl } = await bundleUploader(bundleFilePath, platform, identifier);
22
- return downloadUrl;
23
- }
24
- catch (error) {
25
- console.error('Failed to upload the bundle file. Exiting the program.\n', error);
26
- process.exit(1);
27
- }
28
- })();
29
- await addToReleaseHistory(appVersion, binaryVersion, downloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout);
26
+ const binaryPatch = baseBundlePath
27
+ ? await makeBinaryPatchArtifact({
28
+ baseBundlePath,
29
+ contentsPath: codePushBundle?.contentsPath,
30
+ bundleFilePath,
31
+ jsBundleName: codePushBundle?.jsBundleName ?? resolveJsBundleName(platform, jsBundleName),
32
+ bundleDirectory,
33
+ packageHash,
34
+ outputPath,
35
+ platform,
36
+ onOversizedPatch,
37
+ })
38
+ : null;
39
+ // Every artifact is uploaded before the release history is touched, so a failed
40
+ // upload leaves the history describing only updates that can actually be downloaded.
41
+ const downloadUrl = await uploadArtifact(bundleUploader, bundleFilePath, platform, identifier, 'bundle');
42
+ let patchDownloadUrl;
43
+ if (binaryPatch) {
44
+ patchDownloadUrl = await uploadArtifact(bundleUploader, binaryPatch.patchBundleFilePath, platform, identifier, 'binary patch bundle');
45
+ console.log(`log: Binary patch archive uploaded (download url: ${patchDownloadUrl})`);
46
+ }
47
+ await addToReleaseHistory(appVersion, binaryVersion, downloadUrl, patchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout);
30
48
  if (!skipCleanup) {
31
49
  cleanUpOutputs(outputPath);
32
50
  }
@@ -35,7 +53,9 @@ function cleanUpOutputs(dir) {
35
53
  fs.rmSync(dir, { recursive: true });
36
54
  }
37
55
  function readBundleFileNameFrom(bundleDirectory) {
38
- const files = fs.readdirSync(bundleDirectory);
56
+ // A previous release of the same bundle may have left its patch archive here, and
57
+ // that archive is derived from the bundle file rather than a candidate for release.
58
+ const files = fs.readdirSync(bundleDirectory).filter((file) => !file.endsWith(BINARY_PATCH_ARCHIVE_SUFFIX));
39
59
  if (files.length !== 1) {
40
60
  console.error('The bundlePath must contain only one file.');
41
61
  process.exit(1);
@@ -60,3 +80,131 @@ async function calcHashFromBundleFile(bundleFilePath) {
60
80
  fs.rmSync(tempDir, { recursive: true, force: true });
61
81
  }
62
82
  }
83
+ /**
84
+ * Builds the binary patch artifact of this release and reports what it saves, before
85
+ * anything is uploaded.
86
+ *
87
+ * A patch that is not smaller than the full archive is not worth publishing, and the
88
+ * CLI cannot ask: it runs unattended. `onOversizedPatch` decides instead - `skip`
89
+ * releases the full bundle alone, `fail` stops the release while nothing has been
90
+ * uploaded yet.
91
+ *
92
+ * @param contentsPath {string | undefined} Update contents of a bundle that was just built. Absent with `--skip-bundle`, where the bundle file being released is unpacked instead, so the patch describes exactly the bytes that go out.
93
+ * @return {Promise<BinaryPatchBundle | null>} The artifact to upload, or `null` when the patch was skipped.
94
+ */
95
+ async function makeBinaryPatchArtifact({ baseBundlePath, contentsPath, bundleFilePath, jsBundleName, bundleDirectory, packageHash, outputPath, platform, onOversizedPatch, }) {
96
+ warnOnBaseBundleMismatch(outputPath, baseBundlePath);
97
+ let patchContentsPath = contentsPath;
98
+ let extractDir = null;
99
+ if (patchContentsPath === undefined) {
100
+ const extracted = await extractCodePushBundleContents(bundleFilePath, outputPath);
101
+ patchContentsPath = extracted.contentsPath;
102
+ extractDir = extracted.extractDir;
103
+ }
104
+ try {
105
+ const binaryPatch = await makeBinaryPatchBundle({
106
+ contentsPath: patchContentsPath,
107
+ baseBundlePath,
108
+ bundleRelativePath: jsBundleName,
109
+ bundleDirectory,
110
+ packageHash,
111
+ });
112
+ const fullArchiveSize = fs.statSync(bundleFilePath).size;
113
+ const patchArchiveSize = fs.statSync(binaryPatch.patchBundleFilePath).size;
114
+ const oversized = isPatchArchiveOversized(fullArchiveSize, patchArchiveSize);
115
+ console.log(formatBinaryPatchSummary({
116
+ platform,
117
+ baseBundleHash: binaryPatch.manifest.baseBundleHash,
118
+ targetBundleHash: binaryPatch.manifest.targetBundleHash,
119
+ fullArchiveSize,
120
+ patchArchiveSize,
121
+ patchSkipped: oversized && onOversizedPatch === 'skip',
122
+ }));
123
+ if (oversized) {
124
+ if (onOversizedPatch === 'fail') {
125
+ throw new Error(`The binary patch archive (${patchArchiveSize} bytes) is not smaller than the full archive (${fullArchiveSize} bytes), ` +
126
+ 'and --on-oversized-patch is set to "fail". Nothing was uploaded.');
127
+ }
128
+ console.warn(`warn: The binary patch archive (${patchArchiveSize} bytes) is not smaller than the full archive (${fullArchiveSize} bytes). ` +
129
+ 'Releasing the full bundle only, without a binary patch.');
130
+ return null;
131
+ }
132
+ return binaryPatch;
133
+ }
134
+ finally {
135
+ if (extractDir) {
136
+ fs.rmSync(extractDir, { recursive: true, force: true });
137
+ }
138
+ }
139
+ }
140
+ /**
141
+ * Cross-checks the base bundle against the record a build hook exported next to it.
142
+ *
143
+ * The base bundle is the one input a release cannot verify on its own: pass the bundle of
144
+ * a different build and everything still succeeds, only for the patch to be unappliable
145
+ * on every device. The record says which bytes and which binary the export came from, so
146
+ * when it is there, the two mistakes that produce a broken release - a base file that was
147
+ * replaced after it was exported, and a base exported from a different binary version -
148
+ * stop the release before anything is built or uploaded.
149
+ *
150
+ * A base bundle produced some other way has no record next to it, and releases exactly as
151
+ * it did before. A record that cannot be read only warns: it is a cross-check, and losing
152
+ * it must never be able to fail a release that is otherwise fine.
153
+ */
154
+ function verifyExportedBaseBundleRecord(baseBundlePath, binaryVersion) {
155
+ const recordPath = path.join(path.dirname(path.resolve(baseBundlePath)), BINARY_PATCH_BASE_RECORD_NAME);
156
+ let contents;
157
+ try {
158
+ contents = fs.readFileSync(recordPath, 'utf8');
159
+ }
160
+ catch {
161
+ return;
162
+ }
163
+ let record = {};
164
+ try {
165
+ record = JSON.parse(contents) ?? {};
166
+ }
167
+ catch {
168
+ // Left empty, which is reported below as a record that says nothing to check.
169
+ }
170
+ if (typeof record.baseBundleHash !== 'string') {
171
+ console.warn(`warn: "${recordPath}" is not a readable binary patch base record, so the base bundle was released without cross-checking it.`);
172
+ return;
173
+ }
174
+ const baseBundleHash = hashBundleFile(baseBundlePath);
175
+ if (record.baseBundleHash !== baseBundleHash) {
176
+ throw new Error(`The base bundle "${baseBundlePath}" does not match the record exported next to it: the record describes ${record.baseBundleHash}, ` +
177
+ `but the file hashes to ${baseBundleHash}. Export the bundle again from the build that produced the binary.`);
178
+ }
179
+ if (typeof record.binaryVersion === 'string' && record.binaryVersion !== binaryVersion) {
180
+ throw new Error(`The base bundle "${baseBundlePath}" was exported from binary version ${record.binaryVersion}, but this release targets ${binaryVersion} ` +
181
+ '(--binary-version). Release against the bundle of the binary being targeted.');
182
+ }
183
+ }
184
+ /**
185
+ * The bundle being released may have been compiled by an earlier `bundle` run against a
186
+ * different base. The patch stays valid - it is always computed against the base given
187
+ * here - but the bytecode alignment that keeps it small is lost, which is worth saying
188
+ * out loud instead of leaving it to be noticed in the size summary.
189
+ */
190
+ function warnOnBaseBundleMismatch(outputPath, baseBundlePath) {
191
+ const record = readBinaryPatchBaseRecord(outputPath);
192
+ if (!record) {
193
+ return;
194
+ }
195
+ const baseBundleHash = hashBundleFile(baseBundlePath);
196
+ if (record.baseBundleHash !== baseBundleHash) {
197
+ console.warn(`warn: The bundle was compiled against base bundle ${record.baseBundleHash}, but this release patches against ${baseBundleHash}. ` +
198
+ 'The patch is valid but larger than an aligned one.');
199
+ }
200
+ }
201
+ async function uploadArtifact(bundleUploader, filePath, platform, identifier, artifactName) {
202
+ try {
203
+ const { downloadUrl } = await bundleUploader(filePath, platform, identifier);
204
+ return downloadUrl;
205
+ }
206
+ catch (error) {
207
+ console.error(`Failed to upload the ${artifactName} file. Exiting the program.\n`, error);
208
+ process.exit(1);
209
+ }
210
+ }
@@ -0,0 +1,367 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals";
5
+ import { release } from "./release.js";
6
+ import { makeCodePushBundle } from "../../functions/makeCodePushBundle.js";
7
+ import { BINARY_PATCH_ARCHIVE_SUFFIX, BINARY_PATCH_BASE_RECORD_NAME, BINARY_PATCH_MANIFEST_NAME, hashBundleFile, writeBinaryPatchBaseRecord, } from "../../functions/makeBinaryPatchBundle.js";
8
+ import { applyPatch } from "../../utils/binaryPatch.js";
9
+ import { generatePackageHashFromDirectory } from "../../utils/hash-utils.js";
10
+ import { unzip } from "../../utils/unzip.js";
11
+ /**
12
+ * Covers the release flow around the binary patch option with `--skip-bundle`, which is
13
+ * the one path that reaches every decision - which artifacts exist, in which order they
14
+ * are uploaded, what the history ends up saying - without running the bundler.
15
+ */
16
+ /** Filled in by `bundle`, so `release --skip-bundle` starts from a real archive. */
17
+ const CONTENTS_DIR_NAME = 'CodePush';
18
+ const OUTPUT_DIR_NAME = 'build';
19
+ const BUNDLE_OUTPUT_DIR_NAME = 'bundleOutput';
20
+ const BINARY_VERSION = '9.9.9';
21
+ const APP_VERSION = '9.9.10';
22
+ function findRepoRoot() {
23
+ let dir = process.cwd();
24
+ for (;;) {
25
+ if (fs.existsSync(path.join(dir, "scripts", "binary-patch", "build-hdiffpatch.sh"))) {
26
+ return dir;
27
+ }
28
+ const parent = path.dirname(dir);
29
+ if (parent === dir) {
30
+ throw new Error(`cannot locate the repository root from ${process.cwd()}`);
31
+ }
32
+ dir = parent;
33
+ }
34
+ }
35
+ const repoRoot = findRepoRoot();
36
+ const fixtureDir = path.join(repoRoot, "cli", "fixtures", "binary-patch");
37
+ const baseFixture = path.join(fixtureDir, "base.bundle");
38
+ const targetFixture = path.join(fixtureDir, "target.bundle");
39
+ let workDir;
40
+ /**
41
+ * Leaves an output directory in the state a finished `bundle` run leaves it in: the
42
+ * bundle file, and no update contents - the contents are the bundler's scratch space,
43
+ * and `--skip-bundle` cannot assume they survived.
44
+ */
45
+ async function stageBundleOutput(caseName, files = { 'main.jsbundle': fs.readFileSync(targetFixture) }) {
46
+ const caseDir = fs.mkdtempSync(path.join(workDir, `${caseName}-`));
47
+ const outputPath = path.join(caseDir, OUTPUT_DIR_NAME);
48
+ const contentsPath = path.join(outputPath, CONTENTS_DIR_NAME);
49
+ for (const [relativePath, content] of Object.entries(files)) {
50
+ const filePath = path.join(contentsPath, relativePath);
51
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
52
+ fs.writeFileSync(filePath, content);
53
+ }
54
+ const bundleDirectory = path.join(outputPath, BUNDLE_OUTPUT_DIR_NAME);
55
+ const { bundleFileName } = await makeCodePushBundle(contentsPath, bundleDirectory);
56
+ fs.rmSync(contentsPath, { recursive: true, force: true });
57
+ return { outputPath, bundleDirectory, bundleFileName };
58
+ }
59
+ class ProcessExitError extends Error {
60
+ code;
61
+ constructor(code) {
62
+ super(`process.exit(${code})`);
63
+ this.code = code;
64
+ }
65
+ }
66
+ function recordingUploader(uploads, failOn) {
67
+ return async (filePath) => {
68
+ if (failOn?.(filePath)) {
69
+ throw new Error(`upload rejected: ${path.basename(filePath)}`);
70
+ }
71
+ const downloadUrl = `https://cdn.example.com/${path.basename(filePath)}`;
72
+ uploads.push({ filePath, downloadUrl, logCountBeforeUpload: logs.length });
73
+ return { downloadUrl };
74
+ };
75
+ }
76
+ function historyStore() {
77
+ const saved = [];
78
+ return {
79
+ saved,
80
+ getReleaseHistory: async () => ({}),
81
+ setReleaseHistory: async (_binaryVersion, _jsonFilePath, releaseInfo) => {
82
+ saved.push(releaseInfo);
83
+ },
84
+ };
85
+ }
86
+ async function runRelease(staged, overrides = {}) {
87
+ const uploads = overrides.uploads ?? [];
88
+ const history = historyStore();
89
+ await release(recordingUploader(uploads, overrides.uploadFailsFor), history.getReleaseHistory, history.setReleaseHistory, BINARY_VERSION, APP_VERSION, undefined, overrides.platform ?? 'ios', undefined, staged.outputPath, 'index.ts',
90
+ // Left unset unless the case is about -j, the way commander leaves it.
91
+ overrides.jsBundleName, false, true, undefined, true, // skipBundle
92
+ overrides.skipCleanup ?? true, staged.bundleDirectory, undefined, undefined, overrides.binaryBundlePath, overrides.onOversizedPatch);
93
+ return { uploads, releaseHistories: history.saved };
94
+ }
95
+ let logs;
96
+ beforeAll(() => {
97
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), "codepush-release-"));
98
+ });
99
+ afterAll(() => {
100
+ fs.rmSync(workDir, { recursive: true, force: true });
101
+ });
102
+ beforeEach(() => {
103
+ logs = [];
104
+ const collect = (...args) => {
105
+ logs.push(args.map(String).join(' '));
106
+ };
107
+ jest.spyOn(console, 'log').mockImplementation(collect);
108
+ jest.spyOn(console, 'warn').mockImplementation(collect);
109
+ jest.spyOn(console, 'error').mockImplementation(collect);
110
+ jest.spyOn(process, 'exit').mockImplementation(((code) => {
111
+ throw new ProcessExitError(code ?? 0);
112
+ }));
113
+ });
114
+ afterEach(() => {
115
+ jest.restoreAllMocks();
116
+ // addToReleaseHistory writes its JSON next to the invocation, and only removes it
117
+ // when the history was stored successfully.
118
+ fs.rmSync(path.resolve(process.cwd(), `${BINARY_VERSION}.json`), { force: true });
119
+ });
120
+ describe("release without --binary-bundle-path", () => {
121
+ it("uploads the full bundle only, exactly as before binary patches existed", async () => {
122
+ const staged = await stageBundleOutput("full-only");
123
+ const { uploads, releaseHistories } = await runRelease(staged);
124
+ expect(uploads).toHaveLength(1);
125
+ expect(uploads[0].filePath).toBe(`${staged.bundleDirectory}/${staged.bundleFileName}`);
126
+ expect(fs.readdirSync(staged.bundleDirectory)).toEqual([staged.bundleFileName]);
127
+ expect(releaseHistories[0][APP_VERSION]).toEqual({
128
+ enabled: true,
129
+ mandatory: false,
130
+ downloadUrl: uploads[0].downloadUrl,
131
+ packageHash: staged.bundleFileName,
132
+ });
133
+ // A release without a patch says nothing about one, so a client reading this
134
+ // history behaves exactly as it did before binary patches existed.
135
+ expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('binaryPatchDownloadUrl');
136
+ });
137
+ });
138
+ describe("release --skip-bundle --binary-bundle-path", () => {
139
+ it("patches the bundle that is already in the output directory and uploads both artifacts", async () => {
140
+ const staged = await stageBundleOutput("skip-bundle");
141
+ const { uploads, releaseHistories } = await runRelease(staged, { binaryBundlePath: baseFixture });
142
+ const patchFileName = `${staged.bundleFileName}${BINARY_PATCH_ARCHIVE_SUFFIX}`;
143
+ // The full archive goes first: the patch is an optimisation on top of it.
144
+ expect(uploads.map(({ filePath }) => path.basename(filePath))).toEqual([staged.bundleFileName, patchFileName]);
145
+ expect(fs.existsSync(path.join(staged.bundleDirectory, patchFileName))).toBe(true);
146
+ // Both artifacts are described in the same entry: the full bundle every client can
147
+ // download, and the patch a client holding the matching binary can apply instead.
148
+ expect(releaseHistories[0][APP_VERSION].downloadUrl).toBe(uploads[0].downloadUrl);
149
+ expect(releaseHistories[0][APP_VERSION].binaryPatchDownloadUrl).toBe(uploads[1].downloadUrl);
150
+ expect(releaseHistories[0][APP_VERSION].packageHash).toBe(staged.bundleFileName);
151
+ });
152
+ it("prints the size summary before uploading anything", async () => {
153
+ const staged = await stageBundleOutput("summary");
154
+ const { uploads } = await runRelease(staged, { binaryBundlePath: baseFixture });
155
+ const summaryIndex = logs.findIndex((line) => line.startsWith('Binary patch summary (ios)'));
156
+ expect(summaryIndex).toBeGreaterThanOrEqual(0);
157
+ expect(logs[summaryIndex]).toContain('Saved:');
158
+ // Printed while the release can still be stopped, so before the first upload.
159
+ expect(uploads).toHaveLength(2);
160
+ expect(summaryIndex).toBeLessThan(uploads[0].logCountBeforeUpload);
161
+ });
162
+ it("uses the platform's own bundle name when picking the target to patch", async () => {
163
+ const staged = await stageBundleOutput("android", {
164
+ 'index.android.bundle': fs.readFileSync(targetFixture),
165
+ });
166
+ await runRelease(staged, { binaryBundlePath: baseFixture, platform: 'android' });
167
+ expect(logs.some((line) => line.startsWith('Binary patch summary (android)'))).toBe(true);
168
+ });
169
+ it("patches a bundle that was built with a custom JS bundle name", async () => {
170
+ const staged = await stageBundleOutput("custom-name", {
171
+ 'custom.jsbundle': fs.readFileSync(targetFixture),
172
+ });
173
+ const { uploads } = await runRelease(staged, {
174
+ binaryBundlePath: baseFixture,
175
+ jsBundleName: 'custom.jsbundle',
176
+ });
177
+ expect(uploads.map(({ filePath }) => path.basename(filePath))).toEqual([
178
+ staged.bundleFileName,
179
+ `${staged.bundleFileName}${BINARY_PATCH_ARCHIVE_SUFFIX}`,
180
+ ]);
181
+ const extractRoot = path.join(staged.outputPath, 'extracted');
182
+ fs.mkdirSync(extractRoot, { recursive: true });
183
+ await unzip(path.join(staged.bundleDirectory, `${staged.bundleFileName}${BINARY_PATCH_ARCHIVE_SUFFIX}`), extractRoot);
184
+ const manifest = JSON.parse(fs.readFileSync(path.join(extractRoot, CONTENTS_DIR_NAME, BINARY_PATCH_MANIFEST_NAME), 'utf8'));
185
+ expect(manifest.bundlePath).toBe('custom.jsbundle');
186
+ expect(manifest.patchFile).toBe('custom.jsbundle.patch');
187
+ });
188
+ it("fails without -j when the released bundle uses a custom JS bundle name", async () => {
189
+ const staged = await stageBundleOutput("custom-name-without-option", {
190
+ 'custom.jsbundle': fs.readFileSync(targetFixture),
191
+ });
192
+ // The error names -j because passing it is what makes this release work.
193
+ await expect(runRelease(staged, { binaryBundlePath: baseFixture })).rejects.toThrow(/main\.jsbundle.*--js-bundle-name/s);
194
+ });
195
+ it("fails with an actionable error when the released bundle holds no matching JS bundle", async () => {
196
+ const staged = await stageBundleOutput("no-target", {
197
+ 'index.android.bundle': fs.readFileSync(targetFixture),
198
+ });
199
+ await expect(runRelease(staged, { binaryBundlePath: baseFixture, platform: 'ios' })).rejects.toThrow(/main\.jsbundle.*--js-bundle-name/s);
200
+ });
201
+ it("warns when the bundle was compiled against a different base bundle", async () => {
202
+ const staged = await stageBundleOutput("mismatch");
203
+ writeBinaryPatchBaseRecord(staged.outputPath, 'f'.repeat(64));
204
+ await runRelease(staged, { binaryBundlePath: baseFixture });
205
+ expect(logs.filter((line) => line.startsWith('warn:')).join('\n')).toMatch(/compiled against base bundle/);
206
+ });
207
+ it("does not warn when the recorded base bundle is the one being patched against", async () => {
208
+ const staged = await stageBundleOutput("match");
209
+ // The record the `bundle` command writes holds the hash of this same file.
210
+ writeBinaryPatchBaseRecord(staged.outputPath, hashBundleFile(baseFixture));
211
+ await runRelease(staged, { binaryBundlePath: baseFixture });
212
+ expect(logs.filter((line) => line.startsWith('warn:'))).toEqual([]);
213
+ });
214
+ it("keeps the release history untouched when the patch archive cannot be uploaded", async () => {
215
+ const staged = await stageBundleOutput("patch-upload-failure");
216
+ const uploads = [];
217
+ const history = historyStore();
218
+ await expect(release(recordingUploader(uploads, (filePath) => filePath.endsWith(BINARY_PATCH_ARCHIVE_SUFFIX)), history.getReleaseHistory, history.setReleaseHistory, BINARY_VERSION, APP_VERSION, undefined, 'ios', undefined, staged.outputPath, 'index.ts', '', false, true, undefined, true, true, staged.bundleDirectory, undefined, undefined, baseFixture)).rejects.toThrow(ProcessExitError);
219
+ expect(uploads.map(({ filePath }) => path.basename(filePath))).toEqual([staged.bundleFileName]);
220
+ expect(history.saved).toEqual([]);
221
+ });
222
+ it("leaves no working directories behind and cleans up the output when asked", async () => {
223
+ const staged = await stageBundleOutput("cleanup");
224
+ await runRelease(staged, { binaryBundlePath: baseFixture, skipCleanup: true });
225
+ expect(fs.readdirSync(staged.outputPath)).toEqual([BUNDLE_OUTPUT_DIR_NAME]);
226
+ await runRelease(staged, { binaryBundlePath: baseFixture, skipCleanup: false });
227
+ expect(fs.existsSync(staged.outputPath)).toBe(false);
228
+ });
229
+ it("still finds the bundle to release when a previous run left its patch archive behind", async () => {
230
+ const staged = await stageBundleOutput("leftover-patch");
231
+ fs.writeFileSync(path.join(staged.bundleDirectory, `stale${BINARY_PATCH_ARCHIVE_SUFFIX}`), 'stale');
232
+ const { uploads } = await runRelease(staged, { binaryBundlePath: baseFixture });
233
+ expect(path.basename(uploads[0].filePath)).toBe(staged.bundleFileName);
234
+ });
235
+ it("uploads the patch when it is smaller than the full bundle, without warning about its size", async () => {
236
+ const staged = await stageBundleOutput("worthwhile-patch");
237
+ const { uploads } = await runRelease(staged, { binaryBundlePath: baseFixture });
238
+ expect(uploads).toHaveLength(2);
239
+ expect(logs.filter((line) => line.startsWith('warn:'))).toEqual([]);
240
+ expect(logs.join('\n')).not.toContain('Patch skipped:');
241
+ });
242
+ it("ships a patch archive a client can turn back into the released bundle", async () => {
243
+ const staged = await stageBundleOutput("manifest", {
244
+ 'main.jsbundle': fs.readFileSync(targetFixture),
245
+ 'assets/logo.png': Buffer.from('logo-bytes'),
246
+ });
247
+ await runRelease(staged, { binaryBundlePath: baseFixture });
248
+ const extractRoot = path.join(staged.outputPath, 'extracted');
249
+ fs.mkdirSync(extractRoot, { recursive: true });
250
+ await unzip(path.join(staged.bundleDirectory, `${staged.bundleFileName}${BINARY_PATCH_ARCHIVE_SUFFIX}`), extractRoot);
251
+ const contents = path.join(extractRoot, CONTENTS_DIR_NAME);
252
+ const manifest = JSON.parse(fs.readFileSync(path.join(contents, BINARY_PATCH_MANIFEST_NAME), 'utf8'));
253
+ expect(manifest.bundlePath).toBe('main.jsbundle');
254
+ applyPatch(baseFixture, path.join(contents, manifest.patchFile), path.join(contents, manifest.bundlePath));
255
+ fs.rmSync(path.join(contents, manifest.patchFile));
256
+ fs.rmSync(path.join(contents, BINARY_PATCH_MANIFEST_NAME));
257
+ expect(await generatePackageHashFromDirectory(contents, extractRoot)).toBe(staged.bundleFileName);
258
+ });
259
+ });
260
+ /**
261
+ * The build hooks export the bundle of a native build with a record describing it, so the
262
+ * base bundle a release is handed can be checked against the build it came out of.
263
+ */
264
+ describe("release --binary-bundle-path with an exported base bundle record", () => {
265
+ /** A base bundle laid out the way a build hook leaves it: the bundle, and its record beside it. */
266
+ function stageExportedBaseBundle(caseName, record) {
267
+ const exportDir = fs.mkdtempSync(path.join(workDir, `${caseName}-export-`));
268
+ const baseBundlePath = path.join(exportDir, 'main.jsbundle');
269
+ fs.copyFileSync(baseFixture, baseBundlePath);
270
+ if (record !== undefined) {
271
+ fs.writeFileSync(path.join(exportDir, BINARY_PATCH_BASE_RECORD_NAME), record);
272
+ }
273
+ return baseBundlePath;
274
+ }
275
+ function exportedRecord(overrides = {}) {
276
+ return JSON.stringify({
277
+ baseBundleHash: hashBundleFile(baseFixture),
278
+ binaryVersion: BINARY_VERSION,
279
+ buildNumber: '42',
280
+ gitSha: 'a'.repeat(40),
281
+ ...overrides,
282
+ }, null, 2);
283
+ }
284
+ it("releases both artifacts when the record describes this bundle and this binary version", async () => {
285
+ const staged = await stageBundleOutput("record-match");
286
+ const baseBundlePath = stageExportedBaseBundle("record-match", exportedRecord());
287
+ const { uploads } = await runRelease(staged, { binaryBundlePath: baseBundlePath });
288
+ expect(uploads).toHaveLength(2);
289
+ expect(logs.filter((line) => line.startsWith('warn:'))).toEqual([]);
290
+ });
291
+ it("fails before anything is built when the base bundle is not the file the record describes", async () => {
292
+ const staged = await stageBundleOutput("record-hash-mismatch");
293
+ const baseBundlePath = stageExportedBaseBundle("record-hash-mismatch", exportedRecord({ baseBundleHash: 'f'.repeat(64) }));
294
+ const uploads = [];
295
+ await expect(runRelease(staged, { binaryBundlePath: baseBundlePath, uploads })).rejects.toThrow(/does not match the record exported next to it/);
296
+ expect(uploads).toEqual([]);
297
+ });
298
+ it("fails when the base bundle was exported from a different binary version", async () => {
299
+ const staged = await stageBundleOutput("record-version-mismatch");
300
+ const baseBundlePath = stageExportedBaseBundle("record-version-mismatch", exportedRecord({ binaryVersion: '1.0.0' }));
301
+ const uploads = [];
302
+ await expect(runRelease(staged, { binaryBundlePath: baseBundlePath, uploads })).rejects.toThrow(new RegExp(`exported from binary version 1\\.0\\.0, but this release targets ${BINARY_VERSION}`));
303
+ expect(uploads).toEqual([]);
304
+ });
305
+ it.each([
306
+ ['is not JSON at all', 'not json at all'],
307
+ ['holds nothing to read a record out of', 'null'],
308
+ ])("warns and releases when the record %s", async (caseName, contents) => {
309
+ const staged = await stageBundleOutput("record-unreadable");
310
+ const baseBundlePath = stageExportedBaseBundle(`record-unreadable-${caseName.replace(/\W+/g, '-')}`, contents);
311
+ const { uploads } = await runRelease(staged, { binaryBundlePath: baseBundlePath });
312
+ expect(logs.filter((line) => line.startsWith('warn:')).join('\n')).toMatch(/not a readable binary patch base record/);
313
+ expect(uploads).toHaveLength(2);
314
+ });
315
+ it("warns and releases when the record leaves out the hash it is checked against", async () => {
316
+ const staged = await stageBundleOutput("record-no-hash");
317
+ const baseBundlePath = stageExportedBaseBundle("record-no-hash", JSON.stringify({ binaryVersion: '1.0.0' }));
318
+ const { uploads } = await runRelease(staged, { binaryBundlePath: baseBundlePath });
319
+ expect(logs.filter((line) => line.startsWith('warn:')).join('\n')).toMatch(/not a readable binary patch base record/);
320
+ expect(uploads).toHaveLength(2);
321
+ });
322
+ it("releases exactly as before when the base bundle has no record beside it", async () => {
323
+ const staged = await stageBundleOutput("record-absent");
324
+ const baseBundlePath = stageExportedBaseBundle("record-absent");
325
+ const { uploads } = await runRelease(staged, { binaryBundlePath: baseBundlePath });
326
+ expect(uploads).toHaveLength(2);
327
+ expect(logs.filter((line) => line.startsWith('warn:'))).toEqual([]);
328
+ });
329
+ });
330
+ /**
331
+ * A patch is only worth publishing when it is smaller than the archive it replaces. The
332
+ * CLI runs unattended, so `--on-oversized-patch` decides what happens when it is not.
333
+ *
334
+ * An update whose bundle is a few bytes long produces one: the patch container and the
335
+ * manifest that describes it together outweigh the whole full archive.
336
+ */
337
+ describe("release --on-oversized-patch", () => {
338
+ const tinyContents = () => ({ 'main.jsbundle': Buffer.from('tiny') });
339
+ it("skips the patch by default and releases the full bundle alone", async () => {
340
+ const staged = await stageBundleOutput("oversized-skip", tinyContents());
341
+ const { uploads, releaseHistories } = await runRelease(staged, { binaryBundlePath: baseFixture });
342
+ expect(uploads.map(({ filePath }) => path.basename(filePath))).toEqual([staged.bundleFileName]);
343
+ expect(logs.filter((line) => line.startsWith('warn:')).join('\n')).toMatch(/not smaller than the full archive/);
344
+ expect(logs.join('\n')).toContain('Patch skipped:');
345
+ expect(releaseHistories[0][APP_VERSION].downloadUrl).toBe(uploads[0].downloadUrl);
346
+ // Nothing was uploaded to patch from, so the entry must not point at one.
347
+ expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('binaryPatchDownloadUrl');
348
+ });
349
+ it("fails before any upload when the policy is fail", async () => {
350
+ const staged = await stageBundleOutput("oversized-fail", tinyContents());
351
+ const uploads = [];
352
+ const history = historyStore();
353
+ await expect(release(recordingUploader(uploads), history.getReleaseHistory, history.setReleaseHistory, BINARY_VERSION, APP_VERSION, undefined, 'ios', undefined, staged.outputPath, 'index.ts', undefined, false, true, undefined, true, true, staged.bundleDirectory, undefined, undefined, baseFixture, 'fail')).rejects.toThrow(/not smaller than the full archive/);
354
+ expect(uploads).toEqual([]);
355
+ expect(history.saved).toEqual([]);
356
+ // The temp directories are still cleaned up on the way out.
357
+ expect(fs.readdirSync(staged.outputPath)).toEqual([BUNDLE_OUTPUT_DIR_NAME]);
358
+ });
359
+ it("uploads a worthwhile patch even when the policy is fail", async () => {
360
+ const staged = await stageBundleOutput("worthwhile-under-fail");
361
+ const { uploads } = await runRelease(staged, {
362
+ binaryBundlePath: baseFixture,
363
+ onOversizedPatch: 'fail',
364
+ });
365
+ expect(uploads).toHaveLength(2);
366
+ });
367
+ });