@bravemobile/react-native-code-push 13.1.0-beta.1 → 13.1.0-beta.3
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.
- package/README.md +43 -1
- package/android/app/src/main/java/com/microsoft/codepush/react/CodePushBinaryPatch.java +22 -8
- package/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java +5 -2
- package/android/app/src/test/java/com/microsoft/codepush/react/CodePushBinaryPatchTest.java +37 -0
- package/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java +189 -2
- package/cli/commands/releaseCommand/addToReleaseHistory.ts +7 -0
- package/cli/commands/releaseCommand/index.test.ts +32 -0
- package/cli/commands/releaseCommand/index.ts +16 -1
- package/cli/commands/releaseCommand/release.test.ts +212 -5
- package/cli/commands/releaseCommand/release.ts +128 -6
- package/cli/dist/commands/releaseCommand/addToReleaseHistory.js +6 -1
- package/cli/dist/commands/releaseCommand/index.js +12 -2
- package/cli/dist/commands/releaseCommand/index.test.js +25 -0
- package/cli/dist/commands/releaseCommand/release.js +90 -7
- package/cli/dist/commands/releaseCommand/release.test.js +175 -5
- package/cli/dist/functions/makeAssetDiffBundle.js +114 -0
- package/cli/dist/functions/makeAssetDiffBundle.test.js +215 -0
- package/cli/dist/functions/resolveAssetDiffBases.js +71 -0
- package/cli/dist/functions/resolveAssetDiffBases.test.js +174 -0
- package/cli/dist/functions/runExpoBundleCommand.js +4 -0
- package/cli/dist/functions/runReactNativeBundleCommand.js +10 -0
- package/cli/dist/utils/zip.js +12 -2
- package/cli/functions/makeAssetDiffBundle.test.ts +283 -0
- package/cli/functions/makeAssetDiffBundle.ts +166 -0
- package/cli/functions/resolveAssetDiffBases.test.ts +220 -0
- package/cli/functions/resolveAssetDiffBases.ts +101 -0
- package/cli/functions/runExpoBundleCommand.ts +4 -0
- package/cli/functions/runReactNativeBundleCommand.ts +10 -0
- package/cli/package.json +2 -0
- package/cli/utils/zip.ts +14 -3
- package/ios/CodePush/CodePushBinaryPatch.m +21 -8
- package/ios/CodePush/CodePushPackage.m +29 -2
- package/package.json +1 -1
- package/src/CodePush.js +7 -1
- package/src/CodePush.test.js +75 -5
- package/typings/react-native-code-push.d.ts +19 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, it } from "@jest/globals";
|
|
6
|
+
import {
|
|
7
|
+
ASSET_DIFF_ARCHIVE_INFIX,
|
|
8
|
+
ASSET_DIFF_MANIFEST_NAME,
|
|
9
|
+
assetDiffArchiveName,
|
|
10
|
+
makeAssetDiffBundle,
|
|
11
|
+
} from "./makeAssetDiffBundle.js";
|
|
12
|
+
import { BINARY_PATCH_MANIFEST_NAME, makeBinaryPatchBundle } from "./makeBinaryPatchBundle.js";
|
|
13
|
+
import { makeCodePushBundle } from "./makeCodePushBundle.js";
|
|
14
|
+
import { applyPatch } from "../utils/binaryPatch.js";
|
|
15
|
+
import { generatePackageHashFromDirectory } from "../utils/hash-utils.js";
|
|
16
|
+
import { walk } from "../utils/promisfied-fs.js";
|
|
17
|
+
import { unzip } from "../utils/unzip.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Exercises the diff artifact against real archives, real hdiffz output and real hashes.
|
|
21
|
+
* A diff is only worth shipping if a client that owns the base package can rebuild the
|
|
22
|
+
* exact contents of the new full archive from it, and only real bytes prove that.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The directory a CodePush archive keeps its contents in, for legacy reasons. */
|
|
26
|
+
const CONTENTS_DIR_NAME = 'CodePush';
|
|
27
|
+
|
|
28
|
+
const BUNDLE_NAME = 'index.android.bundle';
|
|
29
|
+
|
|
30
|
+
function findRepoRoot(): string {
|
|
31
|
+
let dir = process.cwd();
|
|
32
|
+
for (;;) {
|
|
33
|
+
if (fs.existsSync(path.join(dir, "scripts", "binary-patch", "build-hdiffpatch.sh"))) {
|
|
34
|
+
return dir;
|
|
35
|
+
}
|
|
36
|
+
const parent = path.dirname(dir);
|
|
37
|
+
if (parent === dir) {
|
|
38
|
+
throw new Error(`cannot locate the repository root from ${process.cwd()}`);
|
|
39
|
+
}
|
|
40
|
+
dir = parent;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const repoRoot = findRepoRoot();
|
|
45
|
+
const fixtureDir = path.join(repoRoot, "cli", "fixtures", "binary-patch");
|
|
46
|
+
const baseFixture = path.join(fixtureDir, "base.bundle");
|
|
47
|
+
const targetFixture = path.join(fixtureDir, "target.bundle");
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The asset both releases hold. Sized like a real asset rather than a few bytes, because
|
|
51
|
+
* the builder only ships a diff that is smaller than the patch archive: dropping a
|
|
52
|
+
* four-byte file would save less than the deletion manifest costs, and the builder would
|
|
53
|
+
* rightly report the diff as not worth shipping.
|
|
54
|
+
*/
|
|
55
|
+
const unchangedAsset = crypto.randomBytes(4096);
|
|
56
|
+
|
|
57
|
+
let workDir: string;
|
|
58
|
+
|
|
59
|
+
/** A unique directory per test so a failed run never leaks state into the next one. */
|
|
60
|
+
function makeCaseDir(name: string): string {
|
|
61
|
+
return fs.mkdtempSync(path.join(workDir, `${name}-`));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Writes an update contents directory shaped like the one the bundler produces. */
|
|
65
|
+
function writeUpdateContents(parentDir: string, files: Record<string, Buffer | string>): string {
|
|
66
|
+
const contentsPath = path.join(parentDir, CONTENTS_DIR_NAME);
|
|
67
|
+
for (const [relativePath, content] of Object.entries(files)) {
|
|
68
|
+
const filePath = path.join(contentsPath, relativePath);
|
|
69
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
70
|
+
fs.writeFileSync(filePath, content);
|
|
71
|
+
}
|
|
72
|
+
return contentsPath;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function baseContentFiles(): Record<string, Buffer | string> {
|
|
76
|
+
return {
|
|
77
|
+
[BUNDLE_NAME]: fs.readFileSync(baseFixture),
|
|
78
|
+
'assets/keep.png': unchangedAsset,
|
|
79
|
+
'assets/change.png': 'OLD',
|
|
80
|
+
'assets/gone.png': 'GONE',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function updateContentFiles(): Record<string, Buffer | string> {
|
|
85
|
+
return {
|
|
86
|
+
[BUNDLE_NAME]: fs.readFileSync(targetFixture),
|
|
87
|
+
'assets/keep.png': unchangedAsset,
|
|
88
|
+
'assets/change.png': 'NEW',
|
|
89
|
+
'assets/new.png': 'ADDED',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type Release = {
|
|
94
|
+
caseDir: string;
|
|
95
|
+
bundleDirectory: string;
|
|
96
|
+
packageHash: string;
|
|
97
|
+
basePackageHash: string;
|
|
98
|
+
baseBundleFilePath: string;
|
|
99
|
+
patchBundleFilePath: string;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Releases a base package and then the update, exactly the way the CLI does: a full
|
|
104
|
+
* archive for each, plus the patch archive the diff is derived from.
|
|
105
|
+
*/
|
|
106
|
+
async function stageRelease(name: string, baseFiles: Record<string, Buffer | string>): Promise<Release> {
|
|
107
|
+
const caseDir = makeCaseDir(name);
|
|
108
|
+
const bundleDirectory = path.join(caseDir, 'bundleOutput');
|
|
109
|
+
|
|
110
|
+
const baseContents = writeUpdateContents(path.join(caseDir, 'base'), baseFiles);
|
|
111
|
+
const { bundleFileName: basePackageHash } = await makeCodePushBundle(baseContents, bundleDirectory);
|
|
112
|
+
|
|
113
|
+
const updateContents = writeUpdateContents(path.join(caseDir, 'update'), updateContentFiles());
|
|
114
|
+
const { bundleFileName: packageHash } = await makeCodePushBundle(updateContents, bundleDirectory);
|
|
115
|
+
|
|
116
|
+
const { patchBundleFilePath } = await makeBinaryPatchBundle({
|
|
117
|
+
contentsPath: updateContents,
|
|
118
|
+
baseBundlePath: baseFixture,
|
|
119
|
+
bundleRelativePath: BUNDLE_NAME,
|
|
120
|
+
bundleDirectory,
|
|
121
|
+
packageHash,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
caseDir,
|
|
126
|
+
bundleDirectory,
|
|
127
|
+
packageHash,
|
|
128
|
+
basePackageHash,
|
|
129
|
+
baseBundleFilePath: path.join(bundleDirectory, basePackageHash),
|
|
130
|
+
patchBundleFilePath,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function extractTo(archivePath: string, destination: string): Promise<string> {
|
|
135
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
136
|
+
await unzip(archivePath, destination);
|
|
137
|
+
return destination;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Every file in an archive, as the archive names it. */
|
|
141
|
+
async function listArchiveFiles(archivePath: string, destination: string): Promise<string[]> {
|
|
142
|
+
await extractTo(archivePath, destination);
|
|
143
|
+
const files = await walk(destination);
|
|
144
|
+
return files.map((filePath) => path.relative(destination, filePath).split(path.sep).join('/')).sort();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
beforeAll(() => {
|
|
148
|
+
// hdiffz/hpatchz are provisioned for the whole run by the jest global setup.
|
|
149
|
+
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "codepush-asset-diff-"));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
afterAll(() => {
|
|
153
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("makeAssetDiffBundle", () => {
|
|
157
|
+
it("ships only the files the base does not already hold", async () => {
|
|
158
|
+
const release = await stageRelease("ships", baseContentFiles());
|
|
159
|
+
|
|
160
|
+
const diff = await makeAssetDiffBundle({
|
|
161
|
+
patchBundleFilePath: release.patchBundleFilePath,
|
|
162
|
+
baseBundleFilePath: release.baseBundleFilePath,
|
|
163
|
+
bundleDirectory: release.bundleDirectory,
|
|
164
|
+
packageHash: release.packageHash,
|
|
165
|
+
basePackageHash: release.basePackageHash,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(diff).not.toBeNull();
|
|
169
|
+
expect(path.basename(diff!.diffBundleFilePath)).toBe(
|
|
170
|
+
`${release.packageHash}${ASSET_DIFF_ARCHIVE_INFIX}${release.basePackageHash}.zip`,
|
|
171
|
+
);
|
|
172
|
+
expect(await listArchiveFiles(diff!.diffBundleFilePath, path.join(release.caseDir, "extracted"))).toEqual(
|
|
173
|
+
[
|
|
174
|
+
ASSET_DIFF_MANIFEST_NAME,
|
|
175
|
+
`${CONTENTS_DIR_NAME}/${BINARY_PATCH_MANIFEST_NAME}`,
|
|
176
|
+
`${CONTENTS_DIR_NAME}/${BUNDLE_NAME}.patch`,
|
|
177
|
+
`${CONTENTS_DIR_NAME}/assets/change.png`,
|
|
178
|
+
`${CONTENTS_DIR_NAME}/assets/new.png`,
|
|
179
|
+
].sort(),
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("lists files the base holds but the update dropped, with the contents prefix", async () => {
|
|
184
|
+
const release = await stageRelease("deletions", baseContentFiles());
|
|
185
|
+
|
|
186
|
+
const diff = await makeAssetDiffBundle({
|
|
187
|
+
patchBundleFilePath: release.patchBundleFilePath,
|
|
188
|
+
baseBundleFilePath: release.baseBundleFilePath,
|
|
189
|
+
bundleDirectory: release.bundleDirectory,
|
|
190
|
+
packageHash: release.packageHash,
|
|
191
|
+
basePackageHash: release.basePackageHash,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const extracted = await extractTo(diff!.diffBundleFilePath, path.join(release.caseDir, "extracted"));
|
|
195
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(extracted, ASSET_DIFF_MANIFEST_NAME), 'utf8'));
|
|
196
|
+
|
|
197
|
+
expect(manifest.deletedFiles).toEqual([`${CONTENTS_DIR_NAME}/assets/gone.png`]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("reproduces the full package hash when merged the way the client merges", async () => {
|
|
201
|
+
const release = await stageRelease("round-trip", baseContentFiles());
|
|
202
|
+
|
|
203
|
+
const diff = await makeAssetDiffBundle({
|
|
204
|
+
patchBundleFilePath: release.patchBundleFilePath,
|
|
205
|
+
baseBundleFilePath: release.baseBundleFilePath,
|
|
206
|
+
bundleDirectory: release.bundleDirectory,
|
|
207
|
+
packageHash: release.packageHash,
|
|
208
|
+
basePackageHash: release.basePackageHash,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// The installed base package, which is what a client merges the diff onto.
|
|
212
|
+
const packageDir = await extractTo(release.baseBundleFilePath, path.join(release.caseDir, "package"));
|
|
213
|
+
const diffDir = await extractTo(diff!.diffBundleFilePath, path.join(release.caseDir, "downloaded"));
|
|
214
|
+
const diffContents = path.join(diffDir, CONTENTS_DIR_NAME);
|
|
215
|
+
|
|
216
|
+
// Rebuild the bundle from its patch, then drop the two patch-only files.
|
|
217
|
+
const patchManifest = JSON.parse(fs.readFileSync(path.join(diffContents, BINARY_PATCH_MANIFEST_NAME), 'utf8'));
|
|
218
|
+
applyPatch(
|
|
219
|
+
baseFixture,
|
|
220
|
+
path.join(diffContents, patchManifest.patchFile),
|
|
221
|
+
path.join(diffContents, patchManifest.bundlePath),
|
|
222
|
+
);
|
|
223
|
+
fs.rmSync(path.join(diffContents, patchManifest.patchFile));
|
|
224
|
+
fs.rmSync(path.join(diffContents, BINARY_PATCH_MANIFEST_NAME));
|
|
225
|
+
|
|
226
|
+
// Apply the deletions to the base package, then drop the deletion manifest.
|
|
227
|
+
const diffManifestPath = path.join(diffDir, ASSET_DIFF_MANIFEST_NAME);
|
|
228
|
+
const { deletedFiles } = JSON.parse(fs.readFileSync(diffManifestPath, 'utf8'));
|
|
229
|
+
for (const deletedFile of deletedFiles) {
|
|
230
|
+
fs.rmSync(path.join(packageDir, deletedFile));
|
|
231
|
+
}
|
|
232
|
+
fs.rmSync(diffManifestPath);
|
|
233
|
+
|
|
234
|
+
// Overlay what the diff shipped onto the base package.
|
|
235
|
+
fs.cpSync(diffDir, packageDir, { recursive: true });
|
|
236
|
+
|
|
237
|
+
expect(await generatePackageHashFromDirectory(path.join(packageDir, CONTENTS_DIR_NAME), packageDir)).toBe(
|
|
238
|
+
release.packageHash,
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("skips the diff when it would not be smaller than the patch archive", async () => {
|
|
243
|
+
// A base that shares no bytes with the update leaves nothing to prune, so the diff
|
|
244
|
+
// is the patch archive plus a deletion manifest.
|
|
245
|
+
const release = await stageRelease("oversized", {
|
|
246
|
+
[BUNDLE_NAME]: fs.readFileSync(baseFixture),
|
|
247
|
+
'assets/keep.png': crypto.randomBytes(4096),
|
|
248
|
+
'assets/change.png': 'OLD',
|
|
249
|
+
});
|
|
250
|
+
const archivesBefore = fs.readdirSync(release.bundleDirectory).sort();
|
|
251
|
+
|
|
252
|
+
const diff = await makeAssetDiffBundle({
|
|
253
|
+
patchBundleFilePath: release.patchBundleFilePath,
|
|
254
|
+
baseBundleFilePath: release.baseBundleFilePath,
|
|
255
|
+
bundleDirectory: release.bundleDirectory,
|
|
256
|
+
packageHash: release.packageHash,
|
|
257
|
+
basePackageHash: release.basePackageHash,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(diff).toBeNull();
|
|
261
|
+
expect(
|
|
262
|
+
fs.existsSync(
|
|
263
|
+
path.join(release.bundleDirectory, assetDiffArchiveName(release.packageHash, release.basePackageHash)),
|
|
264
|
+
),
|
|
265
|
+
).toBe(false);
|
|
266
|
+
expect(fs.readdirSync(release.bundleDirectory).sort()).toEqual(archivesBefore);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("leaves no working directory behind", async () => {
|
|
270
|
+
const release = await stageRelease("cleanup", baseContentFiles());
|
|
271
|
+
const entriesBefore = fs.readdirSync(release.caseDir).sort();
|
|
272
|
+
|
|
273
|
+
await makeAssetDiffBundle({
|
|
274
|
+
patchBundleFilePath: release.patchBundleFilePath,
|
|
275
|
+
baseBundleFilePath: release.baseBundleFilePath,
|
|
276
|
+
bundleDirectory: release.bundleDirectory,
|
|
277
|
+
packageHash: release.packageHash,
|
|
278
|
+
basePackageHash: release.basePackageHash,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
expect(fs.readdirSync(release.caseDir).sort()).toEqual(entriesBefore);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the diff artifact a client can install on top of one particular base package.
|
|
3
|
+
*
|
|
4
|
+
* A diff archive is the patch archive of this release with every file the base package
|
|
5
|
+
* already holds byte for byte taken out, plus a manifest naming the files the base holds
|
|
6
|
+
* that the update dropped. A client copies its installed base package, applies the
|
|
7
|
+
* deletions, rebuilds the JS bundle from the patch and overlays what the diff shipped -
|
|
8
|
+
* which leaves it holding exactly the contents of the full archive, and therefore the
|
|
9
|
+
* same `packageHash`. One release can serve a diff per base it was built against.
|
|
10
|
+
*
|
|
11
|
+
* The diff is derived from the artifacts the release already produced rather than from
|
|
12
|
+
* the bundler, so it costs no extra bundling and cannot drift from what was published.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from "fs";
|
|
16
|
+
import path from "path";
|
|
17
|
+
import shell from "shelljs";
|
|
18
|
+
import { normalizePath } from "../utils/file-utils.js";
|
|
19
|
+
import { walk } from "../utils/promisfied-fs.js";
|
|
20
|
+
import { zipDirectoryContents } from "../utils/zip.js";
|
|
21
|
+
import {
|
|
22
|
+
BINARY_PATCH_MANIFEST_NAME,
|
|
23
|
+
type BinaryPatchManifest,
|
|
24
|
+
extractCodePushBundleContents,
|
|
25
|
+
} from "./makeBinaryPatchBundle.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Manifest listing what a client must delete from its copy of the base package. It sits
|
|
29
|
+
* at the archive root, outside the contents directory, because that is where the native
|
|
30
|
+
* clients look for it - and because a file inside the contents would change the package
|
|
31
|
+
* hash the merged folder has to reproduce.
|
|
32
|
+
*/
|
|
33
|
+
export const ASSET_DIFF_MANIFEST_NAME = 'hotcodepush.json';
|
|
34
|
+
|
|
35
|
+
/** Separates the two hashes, so a diff archive names both the update and the base it needs. */
|
|
36
|
+
export const ASSET_DIFF_ARCHIVE_INFIX = '-diff-';
|
|
37
|
+
|
|
38
|
+
const TEMP_ASSET_DIFF_DIR_NAME = 'temp_contents_for_asset_diff';
|
|
39
|
+
|
|
40
|
+
export type AssetDiffManifest = {
|
|
41
|
+
/** Paths relative to the package folder root, so with the contents directory prefix. */
|
|
42
|
+
deletedFiles: string[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function assetDiffArchiveName(packageHash: string, basePackageHash: string): string {
|
|
46
|
+
return `${packageHash}${ASSET_DIFF_ARCHIVE_INFIX}${basePackageHash}.zip`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Creates the diff archive for a release against one base package.
|
|
51
|
+
*
|
|
52
|
+
* @param patchBundleFilePath {string} Patch archive this release already built
|
|
53
|
+
* @param baseBundleFilePath {string} Full archive of the base release, downloaded locally
|
|
54
|
+
* @param bundleDirectory {string} Directory the release artifacts are written to
|
|
55
|
+
* @param packageHash {string} Package hash of this release
|
|
56
|
+
* @param basePackageHash {string} Package hash of the base release
|
|
57
|
+
* @return The diff archive, or `null` when it is not smaller than the patch archive it
|
|
58
|
+
* was derived from, in which case there is nothing to gain from publishing it.
|
|
59
|
+
*/
|
|
60
|
+
export async function makeAssetDiffBundle({
|
|
61
|
+
patchBundleFilePath,
|
|
62
|
+
baseBundleFilePath,
|
|
63
|
+
bundleDirectory,
|
|
64
|
+
packageHash,
|
|
65
|
+
basePackageHash,
|
|
66
|
+
}: {
|
|
67
|
+
patchBundleFilePath: string;
|
|
68
|
+
baseBundleFilePath: string;
|
|
69
|
+
bundleDirectory: string;
|
|
70
|
+
packageHash: string;
|
|
71
|
+
basePackageHash: string;
|
|
72
|
+
}): Promise<{ diffBundleFilePath: string } | null> {
|
|
73
|
+
// Kept next to the release output rather than in the system temp directory so a crash
|
|
74
|
+
// leaves the leftovers where the rest of the build output is cleaned up. Named after
|
|
75
|
+
// the base so building the diffs of one release against several bases at once cannot
|
|
76
|
+
// have two of them share a working directory.
|
|
77
|
+
const tempRoot = path.join(
|
|
78
|
+
path.dirname(path.resolve(bundleDirectory)),
|
|
79
|
+
`${TEMP_ASSET_DIFF_DIR_NAME}_${basePackageHash}`,
|
|
80
|
+
);
|
|
81
|
+
const stagingRoot = path.join(tempRoot, 'archive');
|
|
82
|
+
|
|
83
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
// Extracted into separate parents: `extractCodePushBundleContents` reuses one
|
|
87
|
+
// directory name, so a shared parent would have the second unpack wipe the first.
|
|
88
|
+
const { contentsPath: patchContents } = await extractCodePushBundleContents(
|
|
89
|
+
patchBundleFilePath,
|
|
90
|
+
path.join(tempRoot, 'patch-archive'),
|
|
91
|
+
);
|
|
92
|
+
const { contentsPath: baseContents } = await extractCodePushBundleContents(
|
|
93
|
+
baseBundleFilePath,
|
|
94
|
+
path.join(tempRoot, 'base-archive'),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const patchManifest: BinaryPatchManifest = JSON.parse(
|
|
98
|
+
fs.readFileSync(path.join(patchContents, BINARY_PATCH_MANIFEST_NAME), 'utf8'),
|
|
99
|
+
);
|
|
100
|
+
// Manifest paths are compared against archive-relative paths, which are POSIX.
|
|
101
|
+
const patchFile = normalizePath(patchManifest.patchFile);
|
|
102
|
+
const bundlePath = normalizePath(patchManifest.bundlePath);
|
|
103
|
+
|
|
104
|
+
// What the full archive of this release holds: the patch archive without its two
|
|
105
|
+
// patch-only files, plus the bundle the patch rebuilds. Deletions are worked out
|
|
106
|
+
// against that set, not against the subset the diff happens to ship.
|
|
107
|
+
const patchFiles = await listFilesRelative(patchContents);
|
|
108
|
+
const updateFiles = new Set(
|
|
109
|
+
patchFiles.filter(
|
|
110
|
+
(relativePath) => relativePath !== BINARY_PATCH_MANIFEST_NAME && relativePath !== patchFile,
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
updateFiles.add(bundlePath);
|
|
114
|
+
|
|
115
|
+
// The archive root directory name is part of the contents layout, so the diff
|
|
116
|
+
// archive has to reuse the name the full archive used.
|
|
117
|
+
const contentsDirName = path.basename(patchContents);
|
|
118
|
+
const stagedContents = path.join(stagingRoot, contentsDirName);
|
|
119
|
+
fs.cpSync(patchContents, stagedContents, { recursive: true });
|
|
120
|
+
for (const relativePath of patchFiles) {
|
|
121
|
+
const baseFile = path.join(baseContents, relativePath);
|
|
122
|
+
if (fs.existsSync(baseFile) && sameBytes(path.join(patchContents, relativePath), baseFile)) {
|
|
123
|
+
fs.rmSync(path.join(stagedContents, relativePath));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const manifest: AssetDiffManifest = {
|
|
128
|
+
deletedFiles: (await listFilesRelative(baseContents))
|
|
129
|
+
.filter((relativePath) => !updateFiles.has(relativePath))
|
|
130
|
+
.map((relativePath) => `${contentsDirName}/${relativePath}`),
|
|
131
|
+
};
|
|
132
|
+
fs.writeFileSync(path.join(stagingRoot, ASSET_DIFF_MANIFEST_NAME), JSON.stringify(manifest, null, 2));
|
|
133
|
+
|
|
134
|
+
const diffArchiveZipPath = await zipDirectoryContents(stagingRoot);
|
|
135
|
+
if (fs.statSync(diffArchiveZipPath).size >= fs.statSync(patchBundleFilePath).size) {
|
|
136
|
+
// A diff that costs a client as much as the patch archive is never the better
|
|
137
|
+
// artifact of the two, and the release still publishes that patch archive.
|
|
138
|
+
fs.rmSync(diffArchiveZipPath);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const diffBundleFilePath = path.join(bundleDirectory, assetDiffArchiveName(packageHash, basePackageHash));
|
|
143
|
+
shell.mkdir('-p', bundleDirectory);
|
|
144
|
+
shell.mv(diffArchiveZipPath, diffBundleFilePath);
|
|
145
|
+
|
|
146
|
+
return { diffBundleFilePath };
|
|
147
|
+
} finally {
|
|
148
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Every file below `directoryPath`, as POSIX paths relative to it. */
|
|
153
|
+
async function listFilesRelative(directoryPath: string): Promise<string[]> {
|
|
154
|
+
const files = await walk(directoryPath);
|
|
155
|
+
return files.map((filePath) => normalizePath(path.relative(directoryPath, filePath)));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function sameBytes(left: string, right: string): boolean {
|
|
159
|
+
// A base that holds a directory where the update holds a file has nothing the update
|
|
160
|
+
// can be spared from downloading, so the file ships.
|
|
161
|
+
const rightStats = fs.statSync(right);
|
|
162
|
+
if (!rightStats.isFile() || fs.statSync(left).size !== rightStats.size) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
return fs.readFileSync(left).equals(fs.readFileSync(right));
|
|
166
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it, jest } from "@jest/globals";
|
|
5
|
+
import { makeCodePushBundle } from "./makeCodePushBundle.js";
|
|
6
|
+
import { resolveAssetDiffBases } from "./resolveAssetDiffBases.js";
|
|
7
|
+
import type { CliConfigInterface, ReleaseHistoryInterface } from "../../typings/react-native-code-push.d.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Exercises base acquisition against real archives and real hashes: a base is only usable
|
|
11
|
+
* if the bytes that came back are the ones the release history recorded, and only real
|
|
12
|
+
* archives prove that the verification accepts a good download and rejects a wrong one.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The directory a CodePush archive keeps its contents in, for legacy reasons. */
|
|
16
|
+
const CONTENTS_DIR_NAME = 'CodePush';
|
|
17
|
+
|
|
18
|
+
/** Where the resolver unpacks a base for verification, so leftovers are recognizable. */
|
|
19
|
+
const TEMP_DIR_PREFIX = 'codepush-asset-diff-base-';
|
|
20
|
+
|
|
21
|
+
type StagedRelease = {
|
|
22
|
+
version: string;
|
|
23
|
+
downloadUrl: string;
|
|
24
|
+
packageHash: string;
|
|
25
|
+
archivePath: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
let workDir: string;
|
|
29
|
+
let bundleDirectory: string;
|
|
30
|
+
|
|
31
|
+
/** Releases one full archive the way the CLI does, and returns what the history records. */
|
|
32
|
+
async function stageRelease(version: string): Promise<StagedRelease> {
|
|
33
|
+
const contentsPath = path.join(workDir, version, CONTENTS_DIR_NAME);
|
|
34
|
+
fs.mkdirSync(contentsPath, { recursive: true });
|
|
35
|
+
fs.writeFileSync(path.join(contentsPath, 'index.android.bundle'), `bundle bytes of ${version}`);
|
|
36
|
+
|
|
37
|
+
const { bundleFileName } = await makeCodePushBundle(contentsPath, bundleDirectory);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
version,
|
|
41
|
+
downloadUrl: `https://downloads.test/${version}.zip`,
|
|
42
|
+
packageHash: bundleFileName,
|
|
43
|
+
archivePath: path.join(bundleDirectory, bundleFileName),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function stageReleases(versions: string[]): Promise<StagedRelease[]> {
|
|
48
|
+
const releases: StagedRelease[] = [];
|
|
49
|
+
for (const version of versions) {
|
|
50
|
+
releases.push(await stageRelease(version));
|
|
51
|
+
}
|
|
52
|
+
return releases;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function releaseHistoryOf(releases: StagedRelease[]): ReleaseHistoryInterface {
|
|
56
|
+
return Object.fromEntries(
|
|
57
|
+
releases.map((release) => [
|
|
58
|
+
release.version,
|
|
59
|
+
{
|
|
60
|
+
enabled: true,
|
|
61
|
+
mandatory: false,
|
|
62
|
+
downloadUrl: release.downloadUrl,
|
|
63
|
+
packageHash: release.packageHash,
|
|
64
|
+
},
|
|
65
|
+
]),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Stands in for the consumer's downloader: hands back a local archive per download url. */
|
|
70
|
+
function fakeDownloader(archives: Record<string, string | Error>) {
|
|
71
|
+
const calls: Array<[string, string, string | undefined]> = [];
|
|
72
|
+
const download: NonNullable<CliConfigInterface['bundleDownloader']> = async (
|
|
73
|
+
downloadUrl,
|
|
74
|
+
platform,
|
|
75
|
+
identifier,
|
|
76
|
+
) => {
|
|
77
|
+
calls.push([downloadUrl, platform, identifier]);
|
|
78
|
+
|
|
79
|
+
const archive = archives[downloadUrl];
|
|
80
|
+
if (archive instanceof Error) {
|
|
81
|
+
throw archive;
|
|
82
|
+
}
|
|
83
|
+
if (!archive) {
|
|
84
|
+
throw new Error(`the downloader was called with an unexpected url: ${downloadUrl}`);
|
|
85
|
+
}
|
|
86
|
+
return { downloadedFilePath: archive };
|
|
87
|
+
};
|
|
88
|
+
return { download, calls };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function leftoverTempDirs(): string[] {
|
|
92
|
+
return fs.readdirSync(os.tmpdir()).filter((entry) => entry.startsWith(TEMP_DIR_PREFIX));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
beforeAll(() => {
|
|
96
|
+
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "codepush-base-resolution-"));
|
|
97
|
+
bundleDirectory = path.join(workDir, 'bundleOutput');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
jest.restoreAllMocks();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
afterAll(() => {
|
|
105
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("resolveAssetDiffBases", () => {
|
|
109
|
+
it("takes the newest releases first, up to the requested count", async () => {
|
|
110
|
+
const releases = await stageReleases(['1.0.0', '1.0.1', '1.0.2', '1.0.9', '1.0.10']);
|
|
111
|
+
const [newest, second] = [releases[4], releases[3]];
|
|
112
|
+
const { download, calls } = fakeDownloader(
|
|
113
|
+
Object.fromEntries(releases.map((release) => [release.downloadUrl, release.archivePath])),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const bases = await resolveAssetDiffBases({
|
|
117
|
+
releaseHistory: releaseHistoryOf(releases),
|
|
118
|
+
diffBaseCount: 2,
|
|
119
|
+
bundleDownloader: download,
|
|
120
|
+
platform: 'android',
|
|
121
|
+
identifier: 'my-app',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(bases).toEqual([
|
|
125
|
+
{ basePackageHash: newest.packageHash, baseBundleFilePath: newest.archivePath },
|
|
126
|
+
{ basePackageHash: second.packageHash, baseBundleFilePath: second.archivePath },
|
|
127
|
+
]);
|
|
128
|
+
expect(calls).toEqual([
|
|
129
|
+
[newest.downloadUrl, 'android', 'my-app'],
|
|
130
|
+
[second.downloadUrl, 'android', 'my-app'],
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("skips history entries that lack a download url or a package hash", async () => {
|
|
135
|
+
const [released] = await stageReleases(['1.1.0']);
|
|
136
|
+
const { download, calls } = fakeDownloader({ [released.downloadUrl]: released.archivePath });
|
|
137
|
+
|
|
138
|
+
const bases = await resolveAssetDiffBases({
|
|
139
|
+
releaseHistory: {
|
|
140
|
+
...releaseHistoryOf([released]),
|
|
141
|
+
// What `create-history` seeds for the update inside the app binary.
|
|
142
|
+
'1.1.1': { enabled: true, mandatory: false, downloadUrl: "", packageHash: "" },
|
|
143
|
+
'1.1.2': { enabled: true, mandatory: false, downloadUrl: released.downloadUrl, packageHash: "" },
|
|
144
|
+
},
|
|
145
|
+
diffBaseCount: 1,
|
|
146
|
+
bundleDownloader: download,
|
|
147
|
+
platform: 'ios',
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(bases).toEqual([
|
|
151
|
+
{ basePackageHash: released.packageHash, baseBundleFilePath: released.archivePath },
|
|
152
|
+
]);
|
|
153
|
+
expect(calls).toEqual([[released.downloadUrl, 'ios', undefined]]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("ignores a history entry whose key is not a version, and resolves the rest", async () => {
|
|
157
|
+
const [released] = await stageReleases(['1.4.0']);
|
|
158
|
+
const { download, calls } = fakeDownloader({ [released.downloadUrl]: released.archivePath });
|
|
159
|
+
|
|
160
|
+
const bases = await resolveAssetDiffBases({
|
|
161
|
+
releaseHistory: {
|
|
162
|
+
...releaseHistoryOf([released]),
|
|
163
|
+
'not-a-version': {
|
|
164
|
+
enabled: true,
|
|
165
|
+
mandatory: false,
|
|
166
|
+
downloadUrl: released.downloadUrl,
|
|
167
|
+
packageHash: released.packageHash,
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
diffBaseCount: 2,
|
|
171
|
+
bundleDownloader: download,
|
|
172
|
+
platform: 'ios',
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(bases).toEqual([
|
|
176
|
+
{ basePackageHash: released.packageHash, baseBundleFilePath: released.archivePath },
|
|
177
|
+
]);
|
|
178
|
+
expect(calls).toEqual([[released.downloadUrl, 'ios', undefined]]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("drops a base whose downloaded bytes do not match its recorded package hash", async () => {
|
|
182
|
+
const [older, newer] = await stageReleases(['1.2.0', '1.2.1']);
|
|
183
|
+
// The newest base comes back as some other release's archive.
|
|
184
|
+
const { download } = fakeDownloader({
|
|
185
|
+
[newer.downloadUrl]: older.archivePath,
|
|
186
|
+
[older.downloadUrl]: older.archivePath,
|
|
187
|
+
});
|
|
188
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
189
|
+
|
|
190
|
+
const bases = await resolveAssetDiffBases({
|
|
191
|
+
releaseHistory: releaseHistoryOf([older, newer]),
|
|
192
|
+
diffBaseCount: 2,
|
|
193
|
+
bundleDownloader: download,
|
|
194
|
+
platform: 'android',
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(bases).toEqual([{ basePackageHash: older.packageHash, baseBundleFilePath: older.archivePath }]);
|
|
198
|
+
expect(warn.mock.calls.join('\n')).toMatch(/1\.2\.1/);
|
|
199
|
+
expect(leftoverTempDirs()).toEqual([]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("drops a base whose download fails instead of failing the release", async () => {
|
|
203
|
+
const [older, newer] = await stageReleases(['1.3.0', '1.3.1']);
|
|
204
|
+
const { download } = fakeDownloader({
|
|
205
|
+
[newer.downloadUrl]: new Error('the network is down'),
|
|
206
|
+
[older.downloadUrl]: older.archivePath,
|
|
207
|
+
});
|
|
208
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
209
|
+
|
|
210
|
+
const bases = await resolveAssetDiffBases({
|
|
211
|
+
releaseHistory: releaseHistoryOf([older, newer]),
|
|
212
|
+
diffBaseCount: 2,
|
|
213
|
+
bundleDownloader: download,
|
|
214
|
+
platform: 'android',
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(bases).toEqual([{ basePackageHash: older.packageHash, baseBundleFilePath: older.archivePath }]);
|
|
218
|
+
expect(warn.mock.calls.join('\n')).toMatch(/1\.3\.1.*the network is down/s);
|
|
219
|
+
});
|
|
220
|
+
});
|