Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,12 @@ drivers
# Local tool and build outputs
cpp/binarypatch/host/build/
.claude/

# E2E files written into the example apps during a run
Examples/*/App.ios.tsx
Examples/*/App.android.tsx
Examples/*/e2e-marker-assets-*/
Examples/*/code-push.config.local.ts

# Release history the CLI stages in the directory it is invoked from
codepush-release-history/
133 changes: 133 additions & 0 deletions cli/commands/createHistoryCommand/createReleaseHistory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import fs from "fs";
import os from "os";
import path from "path";
import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
import { createReleaseHistory } from "./createReleaseHistory.js";
import { updateReleaseHistory } from "../updateHistoryCommand/updateReleaseHistory.js";

/**
* Covers where the release history is staged before the config is handed it.
*
* The config receives a path rather than the history itself, so the command has to put the
* file somewhere first. Two releases of one app for different platforms are the same binary
* version, so a staging path derived from that version alone is the same path for both, and
* they overwrite each other's contents before either has read its own.
*/

const BINARY_VERSION = "1.0.0";

let workingDir: string;
let previousCwd: string;

beforeEach(() => {
previousCwd = process.cwd();
workingDir = fs.mkdtempSync(path.join(os.tmpdir(), "codepush-history-staging-"));
process.chdir(workingDir);
});

afterEach(() => {
process.chdir(previousCwd);
fs.rmSync(workingDir, { recursive: true, force: true });
jest.restoreAllMocks();
});

function stagedPath(platform: "ios" | "android"): string {
return path.join(workingDir, "codepush-release-history", platform, `${BINARY_VERSION}.json`);
}

/**
* Turns the command's own exit into a throw, so a staging failure fails the test instead
* of taking the worker down with it.
*/
function failOnExit(): void {
jest.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`the command exited with code ${String(code)}`);
});
jest.spyOn(console, "error").mockImplementation(() => undefined);
jest.spyOn(console, "log").mockImplementation(() => undefined);
}

describe("staging the release history a config is handed", () => {
it("gives each platform writing at the same time a file of its own", async () => {
failOnExit();

const staged: Record<string, { jsonFilePath: string; contents: string }> = {};
const setReleaseHistory = async (
_binaryVersion: string,
jsonFilePath: string,
_releaseInfo: unknown,
platform: "ios" | "android",
): Promise<void> => {
// Yields once, which is all it takes for the other platform to reach its own
// write and its own clean-up while this one still holds only a path.
await Promise.resolve();
staged[platform] = { jsonFilePath, contents: fs.readFileSync(jsonFilePath, "utf8") };
};

await Promise.all([
createReleaseHistory(BINARY_VERSION, setReleaseHistory, "ios", "RN0840"),
createReleaseHistory(BINARY_VERSION, setReleaseHistory, "android", "RN0840"),
]);

expect(staged.ios.jsonFilePath).not.toBe(staged.android.jsonFilePath);
});

it("hands each platform the history that platform released", async () => {
failOnExit();

const released: Record<string, string> = {
ios: JSON.stringify({ [BINARY_VERSION]: { enabled: true, mandatory: false, downloadUrl: "", packageHash: "" }, "1.0.1": { enabled: true, mandatory: true, downloadUrl: "ios-url", packageHash: "ios-hash" } }),
android: JSON.stringify({ [BINARY_VERSION]: { enabled: true, mandatory: false, downloadUrl: "", packageHash: "" }, "1.0.1": { enabled: true, mandatory: true, downloadUrl: "android-url", packageHash: "android-hash" } }),
};

const staged: Record<string, string> = {};
const getReleaseHistory = async (
_binaryVersion: string,
platform: "ios" | "android",
) => JSON.parse(released[platform]);
const setReleaseHistory = async (
_binaryVersion: string,
jsonFilePath: string,
_releaseInfo: unknown,
platform: "ios" | "android",
): Promise<void> => {
await Promise.resolve();
staged[platform] = fs.readFileSync(jsonFilePath, "utf8");
};

await Promise.all([
updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "ios", "RN0840", undefined, false, undefined),
updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "android", "RN0840", undefined, false, undefined),
]);

expect(staged.ios).toContain("ios-url");
expect(staged.ios).not.toContain("android-url");
expect(staged.android).toContain("android-url");
expect(staged.android).not.toContain("ios-url");
});

it("takes the file away once the config has stored the history", async () => {
failOnExit();

const setReleaseHistory = async (): Promise<void> => undefined;
await createReleaseHistory(BINARY_VERSION, setReleaseHistory, "ios", "RN0840");

expect(fs.existsSync(stagedPath("ios"))).toBe(false);
});

it("leaves the file behind when the config could not store the history", async () => {
failOnExit();

const setReleaseHistory = async (): Promise<void> => {
throw new Error("the storage backend rejected the history");
};

await expect(createReleaseHistory(BINARY_VERSION, setReleaseHistory, "ios", "RN0840")).rejects.toThrow(
"the command exited with code 1",
);

// What the release tried to store is worth reading back, so a failed release keeps
// its history at a path that is the same on every run.
expect(Object.keys(JSON.parse(fs.readFileSync(stagedPath("ios"), "utf8")))).toEqual([BINARY_VERSION]);
});
});
14 changes: 3 additions & 11 deletions cli/commands/createHistoryCommand/createReleaseHistory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import fs from "fs";
import path from "path";
import type { CliConfigInterface, ReleaseHistoryInterface, ReleaseInfo } from "../../../typings/react-native-code-push.d.ts";
import { stageReleaseHistoryFile } from "../../functions/stageReleaseHistoryFile.js";

export async function createReleaseHistory(
targetVersion: string,
Expand All @@ -20,15 +19,8 @@ export async function createReleaseHistory(
};

try {
const JSON_FILE_NAME = `${targetVersion}.json`;
const JSON_FILE_PATH = path.resolve(process.cwd(), JSON_FILE_NAME);

console.log(`log: creating JSON file... ("${JSON_FILE_NAME}")\n`, JSON.stringify(INITIAL_HISTORY, null, 2));
fs.writeFileSync(JSON_FILE_PATH, JSON.stringify(INITIAL_HISTORY));

await setReleaseHistory(targetVersion, JSON_FILE_PATH, INITIAL_HISTORY, platform, identifier)

fs.unlinkSync(JSON_FILE_PATH);
await stageReleaseHistoryFile(targetVersion, INITIAL_HISTORY, platform, (jsonFilePath) =>
setReleaseHistory(targetVersion, jsonFilePath, INITIAL_HISTORY, platform, identifier));
} catch (error) {
console.error('Error occurred while creating new history:', error);
process.exit(1)
Expand Down
14 changes: 3 additions & 11 deletions cli/commands/releaseCommand/addToReleaseHistory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import path from "path";
import fs from "fs";
import type { CliConfigInterface } from "../../../typings/react-native-code-push.d.ts";
import { stageReleaseHistoryFile } from "../../functions/stageReleaseHistoryFile.js";

export async function addToReleaseHistory(
appVersion: string,
Expand Down Expand Up @@ -51,15 +50,8 @@ export async function addToReleaseHistory(
}

try {
const JSON_FILE_NAME = `${binaryVersion}.json`;
const JSON_FILE_PATH = path.resolve(process.cwd(), JSON_FILE_NAME);

console.log(`log: creating JSON file... ("${JSON_FILE_NAME}")\n`, JSON.stringify(newReleaseHistory, null, 2));
fs.writeFileSync(JSON_FILE_PATH, JSON.stringify(newReleaseHistory));

await setReleaseHistory(binaryVersion, JSON_FILE_PATH, newReleaseHistory, platform, identifier)

fs.unlinkSync(JSON_FILE_PATH);
await stageReleaseHistoryFile(binaryVersion, newReleaseHistory, platform, (jsonFilePath) =>
setReleaseHistory(binaryVersion, jsonFilePath, newReleaseHistory, platform, identifier));
} catch (error) {
console.error('Error occurred while updating history:', error);
process.exit(1)
Expand Down
6 changes: 3 additions & 3 deletions cli/commands/releaseCommand/release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,9 @@ beforeEach(() => {

afterEach(() => {
jest.restoreAllMocks();
// addToReleaseHistory writes its JSON next to the invocation, and only removes it
// when the history was stored successfully.
fs.rmSync(path.resolve(process.cwd(), `${BINARY_VERSION}.json`), { force: true });
// addToReleaseHistory writes its JSON under the directory it was invoked in, and only
// removes it when the history was stored successfully.
fs.rmSync(path.resolve(process.cwd(), "codepush-release-history"), { recursive: true, force: true });
});

describe("release without --binary-bundle-path", () => {
Expand Down
14 changes: 3 additions & 11 deletions cli/commands/updateHistoryCommand/updateReleaseHistory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import fs from "fs";
import path from "path";
import type { CliConfigInterface } from "../../../typings/react-native-code-push.d.ts";
import { stageReleaseHistoryFile } from "../../functions/stageReleaseHistoryFile.js";

export async function updateReleaseHistory(
appVersion: string,
Expand All @@ -23,15 +22,8 @@ export async function updateReleaseHistory(
if (typeof rollout === "number") updateInfo.rollout = rollout;

try {
const JSON_FILE_NAME = `${binaryVersion}.json`;
const JSON_FILE_PATH = path.resolve(process.cwd(), JSON_FILE_NAME);

console.log(`log: creating JSON file... ("${JSON_FILE_NAME}")\n`, JSON.stringify(releaseHistory, null, 2));
fs.writeFileSync(JSON_FILE_PATH, JSON.stringify(releaseHistory));

await setReleaseHistory(binaryVersion, JSON_FILE_PATH, releaseHistory, platform, identifier)

fs.unlinkSync(JSON_FILE_PATH);
await stageReleaseHistoryFile(binaryVersion, releaseHistory, platform, (jsonFilePath) =>
setReleaseHistory(binaryVersion, jsonFilePath, releaseHistory, platform, identifier));
} catch (error) {
console.error('Error occurred while updating history:', error);
process.exit(1)
Expand Down
38 changes: 38 additions & 0 deletions cli/functions/stageReleaseHistoryFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import fs from "fs";
import path from "path";
import type { ReleaseHistoryInterface } from "../../typings/react-native-code-push.d.ts";

const STAGING_DIR_NAME = 'codepush-release-history';

/**
* Writes the release history to a file for the config to read, and takes it away again
* once the config has stored it.
*
* Written under a directory of the platform's own rather than straight into the directory
* the command ran in. The name follows from the binary version alone, so two commands
* releasing the same version of one app for different platforms shared one path: whichever
* finished first took the file away while the other was still about to read it, and before
* that each had overwritten the other's contents. The name itself is kept as it was,
* because the name is part of what the config is handed.
*
* A file whose history was never stored is left where it was written, so a release that
* failed can be read back from a path that is the same on every run.
*/
export async function stageReleaseHistoryFile<T>(
binaryVersion: string,
releaseHistory: ReleaseHistoryInterface,
platform: 'ios' | 'android',
store: (jsonFilePath: string) => Promise<T>,
): Promise<T> {
const jsonFileName = `${binaryVersion}.json`;
const stagingDir = path.resolve(process.cwd(), STAGING_DIR_NAME, platform);
const jsonFilePath = path.join(stagingDir, jsonFileName);

fs.mkdirSync(stagingDir, { recursive: true });
console.log(`log: creating JSON file... ("${jsonFileName}")\n`, JSON.stringify(releaseHistory, null, 2));
fs.writeFileSync(jsonFilePath, JSON.stringify(releaseHistory));

const stored = await store(jsonFilePath);
fs.rmSync(jsonFilePath, { force: true });
return stored;
}
Loading