diff --git a/README.md b/README.md index 432116b1b..49a3cfa59 100644 --- a/README.md +++ b/README.md @@ -501,6 +501,8 @@ npx code-push release --framework expo --binary-version 1.0.0 --app-version 1.0. > `--app-version` should be greater than `--binary-version` (SemVer comparison). - `--rollout`: The rollout percentage for the update. (0~100, inclusive) +- `--minimum-background-duration`: The number of seconds the app must have been in the background before this update is applied on resume, for `ON_NEXT_RESUME` and `ON_NEXT_SUSPEND` installs only. (whole seconds, 0 or greater) + - The value set on the release takes precedence over the `minimumBackgroundDuration` passed to `sync`, and `0` applies the update on the next resume. #### `update-history` @@ -509,6 +511,8 @@ Update the release history for a specific CodePush update. - Use the `--mandatory` option to make the update as mandatory or optional. - Use the `--rollout` option to change the rollout percentage of the update. (0~100, inclusive) - If the rollout percentage is reduced, users who fall outside the new target will have their rollout canceled and rollback to the previous latest version. +- Use the `--minimum-background-duration` option to change how many seconds the app must have been in the background before the update is applied on resume, for `ON_NEXT_RESUME` and `ON_NEXT_SUSPEND` installs only. (whole seconds, 0 or greater) + - It can be lowered after a release has gone out - setting it to `0`, for example, applies the update on the next resume instead of waiting. **Example:** - Rollback the CodePush update `1.0.1` (targeting the binary app version `1.0.0`). diff --git a/cli/README.ko.md b/cli/README.ko.md index 79245fa23..95f8dc8cd 100644 --- a/cli/README.ko.md +++ b/cli/README.ko.md @@ -111,6 +111,7 @@ npx code-push release [options] | `-m, --mandatory ` | 필수 업데이트로 설정 | `false` | | `--enable ` | 릴리스 활성화 여부 | `true` | | `--rollout ` | 롤아웃 비율 (0–100) | — | +| `--minimum-background-duration ` | 이 업데이트가 적용되기 전까지 앱이 백그라운드에 머물러야 하는 시간(초). `ON_NEXT_RESUME`, `ON_NEXT_SUSPEND` 설치에만 적용되며 sync 옵션의 `minimumBackgroundDuration`보다 우선합니다. `0`이면 다음 포그라운드 진입 때 바로 적용합니다 | — | | `--skip-bundle ` | 번들 단계 건너뛰기 (기존 번들 사용) | `false` | | `--hash-calc ` | 기존 번들에서 해시 계산 (`--skip-bundle true` 필요) | — | | `--skip-cleanup ` | 출력 디렉토리 정리 건너뛰기 | `false` | @@ -300,8 +301,9 @@ npx code-push update-history [options] | `-m, --mandatory ` | 필수 업데이트 플래그 설정 | — | | `-e, --enable ` | 릴리스 활성화 또는 비활성화 | — | | `--rollout ` | 롤아웃 비율 (0–100) | — | +| `--minimum-background-duration ` | 이 업데이트가 적용되기 전까지 앱이 백그라운드에 머물러야 하는 시간(초). `ON_NEXT_RESUME`, `ON_NEXT_SUSPEND` 설치에만 적용되며 sync 옵션의 `minimumBackgroundDuration`보다 우선합니다. `0`이면 다음 포그라운드 진입 때 바로 적용합니다 | — | -`--mandatory`, `--enable`, `--rollout` 중 하나 이상을 반드시 지정해야 합니다. +`--mandatory`, `--enable`, `--rollout`, `--minimum-background-duration` 중 하나 이상을 반드시 지정해야 합니다. **예시:** @@ -353,7 +355,8 @@ npx code-push show-history -b 1.0.0 -p ios "mandatory": false, "downloadUrl": "https://storage.example.com/bundles/ios/staging/a1b2c3...", "packageHash": "a1b2c3...", - "rollout": 100 + "rollout": 100, + "minimumBackgroundDuration": 600 }, "1.0.2": { "enabled": true, diff --git a/cli/README.md b/cli/README.md index 76e6e0fca..f4f61dd41 100644 --- a/cli/README.md +++ b/cli/README.md @@ -109,6 +109,7 @@ npx code-push release [options] | `-m, --mandatory ` | Make the release mandatory | `false` | | `--enable ` | Enable the release | `true` | | `--rollout ` | Rollout percentage (0-100) | — | +| `--minimum-background-duration ` | Seconds the app must have been in the background before this update is applied on resume (`ON_NEXT_RESUME` / `ON_NEXT_SUSPEND` installs only). Overrides the `minimumBackgroundDuration` sync option; `0` applies it on the next resume | — | | `--skip-bundle ` | Skip bundle step (use existing bundle) | `false` | | `--hash-calc ` | Calculate hash from existing bundle (requires `--skip-bundle true`) | — | | `--skip-cleanup ` | Skip output directory cleanup | `false` | @@ -302,8 +303,9 @@ npx code-push update-history [options] | `-m, --mandatory ` | Set mandatory flag | — | | `-e, --enable ` | Enable or disable the release | — | | `--rollout ` | Rollout percentage (0-100) | — | +| `--minimum-background-duration ` | Seconds the app must have been in the background before this update is applied on resume (`ON_NEXT_RESUME` / `ON_NEXT_SUSPEND` installs only). Overrides the `minimumBackgroundDuration` sync option; `0` applies it on the next resume | — | -You must pass at least one of `--mandatory`, `--enable`, or `--rollout`. +You must pass at least one of `--mandatory`, `--enable`, `--rollout`, or `--minimum-background-duration`. ```bash # Disable a release @@ -351,7 +353,8 @@ The release history is a JSON object keyed by app version. For example, the hist "mandatory": false, "downloadUrl": "https://storage.example.com/bundles/ios/staging/a1b2c3...", "packageHash": "a1b2c3...", - "rollout": 100 + "rollout": 100, + "minimumBackgroundDuration": 600 }, "1.0.2": { "enabled": true, diff --git a/cli/commands/createHistoryCommand/createReleaseHistory.test.ts b/cli/commands/createHistoryCommand/createReleaseHistory.test.ts index 6f1ebe922..9f85ec6e5 100644 --- a/cli/commands/createHistoryCommand/createReleaseHistory.test.ts +++ b/cli/commands/createHistoryCommand/createReleaseHistory.test.ts @@ -96,8 +96,8 @@ describe("staging the release history a config is handed", () => { }; 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), + updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "ios", "RN0840", undefined, false, undefined, undefined), + updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "android", "RN0840", undefined, false, undefined, undefined), ]); expect(staged.ios).toContain("ios-url"); diff --git a/cli/commands/releaseCommand/addToReleaseHistory.ts b/cli/commands/releaseCommand/addToReleaseHistory.ts index ebee51183..85fd60e02 100644 --- a/cli/commands/releaseCommand/addToReleaseHistory.ts +++ b/cli/commands/releaseCommand/addToReleaseHistory.ts @@ -15,6 +15,7 @@ export async function addToReleaseHistory( enable: boolean, rollout: number | undefined, diffPackages: Record | undefined, + minimumBackgroundDuration: number | undefined, ): Promise { const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier); @@ -49,6 +50,11 @@ export async function addToReleaseHistory( newReleaseHistory[appVersion].rollout = rollout; } + // An entry without it leaves the wait to the sync option, so 0 has to be written. + if (typeof minimumBackgroundDuration === 'number') { + newReleaseHistory[appVersion].minimumBackgroundDuration = minimumBackgroundDuration; + } + try { await stageReleaseHistoryFile(binaryVersion, newReleaseHistory, platform, (jsonFilePath) => setReleaseHistory(binaryVersion, jsonFilePath, newReleaseHistory, platform, identifier)); diff --git a/cli/commands/releaseCommand/index.test.ts b/cli/commands/releaseCommand/index.test.ts index 15f3fbce6..3560af1d4 100644 --- a/cli/commands/releaseCommand/index.test.ts +++ b/cli/commands/releaseCommand/index.test.ts @@ -32,6 +32,7 @@ const ARG_INDEX = { onOversizedPatch: 20, bundleDownloader: 21, diffBaseCount: 22, + minimumBackgroundDuration: 23, } as const; /** @@ -147,4 +148,32 @@ describe("release command options", () => { const { release } = await import("./release.js"); expect(jest.mocked(release)).not.toHaveBeenCalled(); }); + + it("passes the chosen minimum background duration through to the release", async () => { + const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--minimum-background-duration', '600']); + + expect(args[ARG_INDEX.minimumBackgroundDuration]).toBe(600); + }); + + it("leaves the minimum background duration unset when the option is not given, so the sync option decides", async () => { + const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1']); + + expect(args[ARG_INDEX.minimumBackgroundDuration]).toBeUndefined(); + }); + + it.each([ + ['is negative', '-1'], + ['is not a number at all', 'soon'], + ])("rejects a minimum background duration that %s", async (_caseName, value) => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); + + await expect(parseReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--minimum-background-duration', value])) + .rejects.toThrow('process.exit(1)'); + + const { release } = await import("./release.js"); + expect(jest.mocked(release)).not.toHaveBeenCalled(); + }); }); diff --git a/cli/commands/releaseCommand/index.ts b/cli/commands/releaseCommand/index.ts index 0111d6da6..ee6219d91 100644 --- a/cli/commands/releaseCommand/index.ts +++ b/cli/commands/releaseCommand/index.ts @@ -31,6 +31,7 @@ type Options = { binaryBundlePath?: string; onOversizedPatch: OversizedPatchPolicy; diffBaseCount: number; + minimumBackgroundDuration?: number; } program.command('release') @@ -57,6 +58,7 @@ program.command('release') .choices(OVERSIZED_PATCH_POLICIES) .default(DEFAULT_OVERSIZED_PATCH_POLICY)) .option('--diff-base-count ', 'how many recent releases to build asset diff archives against (0 disables). Requires `bundleDownloader` in the config file.', parseDecimalInt, DEFAULT_DIFF_BASE_COUNT) + .option('--minimum-background-duration ', 'seconds the app must have been in the background before this update is applied on resume. Overrides the minimumBackgroundDuration sync option.', parseDecimalInt) .action(async (options: Options) => { const config = findAndReadConfigFile(process.cwd(), options.config); @@ -70,6 +72,12 @@ program.command('release') process.exit(1); } + if (options.minimumBackgroundDuration !== undefined + && (!Number.isInteger(options.minimumBackgroundDuration) || options.minimumBackgroundDuration < 0)) { + console.error('--minimum-background-duration must be a whole number of seconds, 0 or greater.'); + process.exit(1); + } + if (options.hashCalc && !options.skipBundle) { console.error('--hash-calc option can be used only when --skip-bundle is set to true.'); process.exit(1); @@ -102,6 +110,7 @@ program.command('release') options.onOversizedPatch, config.bundleDownloader, options.diffBaseCount, + options.minimumBackgroundDuration, ) console.log('🚀 Release completed.') diff --git a/cli/commands/releaseCommand/release.test.ts b/cli/commands/releaseCommand/release.test.ts index 65324c68c..9ad651846 100644 --- a/cli/commands/releaseCommand/release.test.ts +++ b/cli/commands/releaseCommand/release.test.ts @@ -142,6 +142,7 @@ type ReleaseOverrides = { releaseHistory?: ReleaseHistoryInterface; bundleDownloader?: CliConfigInterface['bundleDownloader']; diffBaseCount?: number; + minimumBackgroundDuration?: number; }; async function runRelease(staged: StagedBundle, overrides: ReleaseOverrides = {}) { @@ -176,6 +177,7 @@ async function runRelease(staged: StagedBundle, overrides: ReleaseOverrides = {} overrides.onOversizedPatch, overrides.bundleDownloader, overrides.diffBaseCount, + overrides.minimumBackgroundDuration, ); return { uploads, releaseHistories: history.saved, uploadCountsWhenHistorySaved }; @@ -829,3 +831,29 @@ describe("release with asset diff bases", () => { expect(path.basename(uploads[0].filePath)).toBe(staged.bundleFileName); }); }); + +describe("release --minimum-background-duration", () => { + it("records the background wait on the release it publishes", async () => { + const staged = await stageBundleOutput("minimum-background-duration"); + + const { releaseHistories } = await runRelease(staged, { minimumBackgroundDuration: 600 }); + + expect(releaseHistories[0][APP_VERSION].minimumBackgroundDuration).toBe(600); + }); + + it("releases a background wait of zero seconds as zero, not as an unset option", async () => { + const staged = await stageBundleOutput("zero-minimum-background-duration"); + + const { releaseHistories } = await runRelease(staged, { minimumBackgroundDuration: 0 }); + + expect(releaseHistories[0][APP_VERSION].minimumBackgroundDuration).toBe(0); + }); + + it("leaves the release saying nothing about the background wait when the option is not given", async () => { + const staged = await stageBundleOutput("no-minimum-background-duration"); + + const { releaseHistories } = await runRelease(staged); + + expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('minimumBackgroundDuration'); + }); +}); diff --git a/cli/commands/releaseCommand/release.ts b/cli/commands/releaseCommand/release.ts index 9c89a4335..89d224834 100644 --- a/cli/commands/releaseCommand/release.ts +++ b/cli/commands/releaseCommand/release.ts @@ -49,6 +49,7 @@ export async function release( onOversizedPatch: OversizedPatchPolicy = DEFAULT_OVERSIZED_PATCH_POLICY, bundleDownloader?: CliConfigInterface['bundleDownloader'], diffBaseCount: number = DEFAULT_DIFF_BASE_COUNT, + minimumBackgroundDuration?: number, ): Promise { if (baseBundlePath) { // Checked before the bundler runs, so the wrong base bundle costs a second rather @@ -167,6 +168,7 @@ export async function release( enable, rollout, Object.keys(diffPackages).length > 0 ? diffPackages : undefined, + minimumBackgroundDuration, ) if (!skipCleanup) { diff --git a/cli/commands/updateHistoryCommand/index.test.ts b/cli/commands/updateHistoryCommand/index.test.ts new file mode 100644 index 000000000..b476d685c --- /dev/null +++ b/cli/commands/updateHistoryCommand/index.test.ts @@ -0,0 +1,129 @@ +import fs from "fs"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import type { CliConfigInterface, ReleaseHistoryInterface } from "../../../typings/react-native-code-push.d.ts"; + +/** + * Checks the command definition against the entry it saves. Everything this command does + * ends up in the release history the config is handed, so an option that never reaches it + * leaves the release exactly as it was - which the command still reports as a success. + */ + +const BINARY_VERSION = '1.0.0'; +const APP_VERSION = '1.0.1'; + +let mockConfig: CliConfigInterface; + +jest.mock("../../utils/fsUtils.js", () => ({ + findAndReadConfigFile: () => mockConfig, +})); + +/** + * Puts one released version in the config's history and records every history it is + * handed back, so a case can read the entry as the consumer would store it. + */ +function stageReleaseHistory(): ReleaseHistoryInterface[] { + const releaseHistory: ReleaseHistoryInterface = { + [APP_VERSION]: { + enabled: true, + mandatory: false, + downloadUrl: 'https://cdn.example.com/bundle', + packageHash: 'a3f1c0', + }, + }; + const saved: ReleaseHistoryInterface[] = []; + + mockConfig = { + bundleUploader: async () => ({ downloadUrl: 'https://cdn.example.com/bundle' }), + getReleaseHistory: async () => releaseHistory, + // The command edits the history in place, so what it saved is copied out here. + setReleaseHistory: async (_binaryVersion, _jsonFilePath, releaseInfo) => { + saved.push(structuredClone(releaseInfo)); + }, + }; + + return saved; +} + +/** + * Parses an `update-history` invocation against the real command definition. Commander is + * asked to throw instead of exiting, and to keep its diagnostics to itself, so a rejected + * option can be asserted on without ending the worker or the output. + */ +async function parseUpdateHistoryCommand(args: string[]): Promise { + const { program } = await import("commander"); + await import("./index.js"); + + const updateHistoryCommand = program.commands.find((command) => command.name() === 'update-history'); + updateHistoryCommand?.exitOverride(); + updateHistoryCommand?.configureOutput({ writeErr: () => {} }); + + await program.parseAsync(['update-history', ...args], { from: 'user' }); +} + +async function runUpdateHistoryCommand(args: string[]): Promise { + await parseUpdateHistoryCommand(['-b', BINARY_VERSION, '-v', APP_VERSION, ...args]); +} + +let saved: ReleaseHistoryInterface[]; + +beforeEach(() => { + jest.resetModules(); + saved = stageReleaseHistory(); + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); +}); + +afterEach(() => { + jest.restoreAllMocks(); + // The command writes its JSON under the directory it was invoked in. + fs.rmSync(path.resolve(process.cwd(), "codepush-release-history"), { recursive: true, force: true }); +}); + +describe("update-history command options", () => { + it("lowers the background wait of a release that is already out to zero seconds", async () => { + await runUpdateHistoryCommand(['--minimum-background-duration', '0']); + + expect(saved).toHaveLength(1); + expect(saved[0][APP_VERSION].minimumBackgroundDuration).toBe(0); + }); + + it("leaves the entry saying nothing about the background wait when only --enable is given", async () => { + await runUpdateHistoryCommand(['--enable', 'false']); + + expect(saved[0][APP_VERSION].enabled).toBe(false); + expect(saved[0][APP_VERSION]).not.toHaveProperty('minimumBackgroundDuration'); + }); + + it("saves the rollout percentage when --rollout is the only option given", async () => { + await runUpdateHistoryCommand(['--rollout', '50']); + + expect(saved[0][APP_VERSION].rollout).toBe(50); + }); + + it("exits without saving anything when no option says what to change", async () => { + await expect(runUpdateHistoryCommand([])).rejects.toThrow('process.exit(1)'); + + expect(saved).toHaveLength(0); + }); + + it("rejects a negative background wait and saves nothing", async () => { + await expect(runUpdateHistoryCommand(['--minimum-background-duration', '-1'])) + .rejects.toThrow('process.exit(1)'); + + expect(saved).toHaveLength(0); + }); + + it.each([ + ['a percentage above 100', '150'], + ['a percentage that is not a number', 'abc'], + ])("rejects %s and saves nothing", async (_scenario, rollout) => { + await expect(runUpdateHistoryCommand(['--rollout', rollout])) + .rejects.toThrow('process.exit(1)'); + + expect(saved).toHaveLength(0); + }); +}); diff --git a/cli/commands/updateHistoryCommand/index.ts b/cli/commands/updateHistoryCommand/index.ts index 2429dfaee..dcca0d425 100644 --- a/cli/commands/updateHistoryCommand/index.ts +++ b/cli/commands/updateHistoryCommand/index.ts @@ -12,6 +12,7 @@ type Options = { mandatory?: boolean; enable?: boolean; rollout?: number; + minimumBackgroundDuration?: number; } program.command('update-history') @@ -24,14 +25,32 @@ program.command('update-history') .option('-m, --mandatory ', 'make the release to be mandatory', parseBoolean, undefined) .option('-e, --enable ', 'make the release to be enabled', parseBoolean, undefined) .option('--rollout ', 'rollout percentage (0-100)', parseFloat, undefined) + .option('--minimum-background-duration ', 'seconds the app must have been in the background before this update is applied on resume. Overrides the minimumBackgroundDuration sync option.', parseDecimalInt, undefined) .action(async (options: Options) => { const config = findAndReadConfigFile(process.cwd(), options.config); - if (typeof options.mandatory !== "boolean" && typeof options.enable !== "boolean") { + if (typeof options.mandatory !== "boolean" + && typeof options.enable !== "boolean" + && typeof options.rollout !== "number" + && typeof options.minimumBackgroundDuration !== "number") { console.error('No options specified. Exiting the program.') process.exit(1) } + // `Number.isFinite` is what rejects a non-numeric `--rollout`: `parseFloat` turns it into + // NaN, and both `NaN < 0` and `NaN > 100` are false. + if (options.rollout !== undefined + && (!Number.isFinite(options.rollout) || options.rollout < 0 || options.rollout > 100)) { + console.error('Rollout percentage number must be between 0 and 100 (inclusive).'); + process.exit(1); + } + + if (options.minimumBackgroundDuration !== undefined + && (!Number.isInteger(options.minimumBackgroundDuration) || options.minimumBackgroundDuration < 0)) { + console.error('--minimum-background-duration must be a whole number of seconds, 0 or greater.'); + process.exit(1); + } + await updateReleaseHistory( options.appVersion, options.binaryVersion, @@ -41,10 +60,17 @@ program.command('update-history') options.identifier, options.mandatory, options.enable, - options.rollout + options.rollout, + options.minimumBackgroundDuration ) }); +// Not `parseInt` itself: commander hands a coercion function the current value as its +// second argument, which `parseInt` reads as the radix. +function parseDecimalInt(value: string): number { + return parseInt(value, 10); +} + function parseBoolean(value: string) { if (value === 'true') return true; if (value === 'false') return false; diff --git a/cli/commands/updateHistoryCommand/updateReleaseHistory.ts b/cli/commands/updateHistoryCommand/updateReleaseHistory.ts index 6c1c98374..ccefda7d4 100644 --- a/cli/commands/updateHistoryCommand/updateReleaseHistory.ts +++ b/cli/commands/updateHistoryCommand/updateReleaseHistory.ts @@ -11,6 +11,7 @@ export async function updateReleaseHistory( mandatory: boolean | undefined, enable: boolean | undefined, rollout: number | undefined, + minimumBackgroundDuration: number | undefined, ): Promise { const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier); @@ -20,6 +21,8 @@ export async function updateReleaseHistory( if (typeof mandatory === "boolean") updateInfo.mandatory = mandatory; if (typeof enable === "boolean") updateInfo.enabled = enable; if (typeof rollout === "number") updateInfo.rollout = rollout; + // 0 is what lowers the wait to "apply on the next resume", so truthiness cannot decide here. + if (typeof minimumBackgroundDuration === "number") updateInfo.minimumBackgroundDuration = minimumBackgroundDuration; try { await stageReleaseHistoryFile(binaryVersion, releaseHistory, platform, (jsonFilePath) => diff --git a/docs/api-js.md b/docs/api-js.md index 8a811e5d8..e2c37f157 100644 --- a/docs/api-js.md +++ b/docs/api-js.md @@ -128,7 +128,7 @@ The `codePush` decorator accepts an "options" object that allows you to customiz * __mandatoryInstallMode__ *(codePush.InstallMode)* - Specifies when you would like to install updates which are marked as mandatory. Defaults to `codePush.InstallMode.IMMEDIATE`. Refer to the [`InstallMode`](#installmode) enum reference for a description of the available options and what they do. -* __minimumBackgroundDuration__ *(Number)* - Specifies the minimum number of seconds that the app needs to have been in the background before restarting the app. This property only applies to updates which are installed using `InstallMode.ON_NEXT_RESUME` or `InstallMode.ON_NEXT_SUSPEND`, and can be useful for getting your update in front of end users sooner, without being too obtrusive. Defaults to `0`, which has the effect of applying the update immediately after a resume or unless the app suspension is long enough to not matter, regardless how long it was in the background. +* __minimumBackgroundDuration__ *(Number)* - Specifies the minimum number of seconds that the app needs to have been in the background before restarting the app. This property only applies to updates which are installed using `InstallMode.ON_NEXT_RESUME` or `InstallMode.ON_NEXT_SUSPEND`, and can be useful for getting your update in front of end users sooner, without being too obtrusive. Defaults to `0`, which has the effect of applying the update immediately after a resume or unless the app suspension is long enough to not matter, regardless how long it was in the background. A `minimumBackgroundDuration` set on the release history entry of the update being installed takes precedence over this option. * __onDownloadStart__ *((label: String) => void | Promise<void>)* - Called when the download of an available update begins, with the label of the release being downloaded. @@ -144,7 +144,7 @@ The `codePush` decorator accepts an "options" object that allows you to customiz * __onUpdateSuccess__ *((label: String) => void | Promise<void>)* - Called when an installed update has run successfully, with the label of the release that ran. The report is sent when [`notifyAppReady`](#codepushnotifyappready) marks the update successful, which [`sync`](#codepushsync) does for you. -* __releaseHistoryFetcher__ *((updateRequest: UpdateCheckRequest) => Promise<ReleaseHistoryInterface>)* - **Required.** Specifies the function that supplies the release history an update is picked from. It receives an `UpdateCheckRequest` describing the running app - its binary version, package hash, currently running label and client id - and must resolve to a `ReleaseHistoryInterface` for that binary version. There is no default: configuring the plugin without one throws. Refer to ["CodePush-ify" Your App](../README.md#4-codepush-ify-your-app) for an example implementation, and to the `ReleaseHistoryInterface` type in [typings/react-native-code-push.d.ts](../typings/react-native-code-push.d.ts) for what it has to return. +* __releaseHistoryFetcher__ *((updateRequest: UpdateCheckRequest) => Promise<ReleaseHistoryInterface>)* - **Required.** Specifies the function that supplies the release history an update is picked from. It receives an `UpdateCheckRequest` describing the running app - its binary version, package hash, currently running label and client id - and must resolve to a `ReleaseHistoryInterface` for that binary version. There is no default: configuring the plugin without one throws. An entry of that history may also carry a `minimumBackgroundDuration` (in seconds), which - when present - takes precedence over the [`minimumBackgroundDuration`](#codepushoptions) passed to [`sync`](#codepushsync) for that release. Refer to ["CodePush-ify" Your App](../README.md#4-codepush-ify-your-app) for an example implementation, and to the `ReleaseHistoryInterface` type in [typings/react-native-code-push.d.ts](../typings/react-native-code-push.d.ts) for what it has to return. * __updateChecker__ *((updateRequest: UpdateCheckRequest) => Promise<{ update_info: UpdateCheckResponse }>)* - *Deprecated.* Specifies a function that performs the update check itself, so it can be self-hosted. It will be removed in the next major version - `releaseHistoryFetcher` replaces it. Setting it takes that function out of the path entirely: it is never called, though it is still required, so pass a no-op such as `async () => ({})`. No rollout evaluation is applied to what the checker returns. diff --git a/e2e/README.ko.md b/e2e/README.ko.md index 8ae44b627..c5b52d8a7 100644 --- a/e2e/README.ko.md +++ b/e2e/README.ko.md @@ -64,7 +64,7 @@ Maestro 드라이버 두 개가 나눠 씁니다. 이 부하에서 타이밍 민 | `--framework ` | 아니오 | Expo 예제 앱인 경우 `expo` 지정 | | `--simulator ` | 아니오 | iOS 시뮬레이터 이름 (부팅된 시뮬레이터 자동 감지, 기본값 "iPhone 16") | | `--maestro-only` | 아니오 | 빌드 단계 생략, 테스트 플로우만 실행 | -| `--exclude-timing-sensitive` | 아니오 | 타이밍 민감 optional 시나리오(`03`, `04`)를 제외합니다. 기본값: 비활성, 즉 로컬 실행에는 기본 포함 | +| `--exclude-timing-sensitive` | 아니오 | 타이밍 민감 optional 시나리오(`03`, `04`, `06`)를 제외합니다. 기본값: 비활성, 즉 로컬 실행에는 기본 포함 | ## 실행 과정 @@ -95,12 +95,14 @@ Maestro 드라이버 두 개가 나눠 씁니다. 이 부하에서 타이밍 민 ### Phase 4 — Optional Install Mode 검증 (`flows-optional/`) -12. **시나리오별 optional 릴리스 준비** — 각 시나리오마다 히스토리를 다시 만들고 `npx code-push release -m false`로 not mandatory 릴리스를 배포합니다. +12. **시나리오별 optional 릴리스 준비** — 각 시나리오마다 히스토리를 다시 만들고 `npx code-push release -m false`로 not mandatory 릴리스를 배포합니다. `05`와 `06` 시나리오는 `--minimum-background-duration`도 함께 넘겨 릴리스 자체에 대기 시간을 기록합니다. 13. **optional 업데이트 플로우 실행** — 아래 조건에서 업데이트가 적용되는지 확인합니다. - `01-optional-update-on-relaunch` — 앱을 종료 후 재실행할 때 - `02-optional-update-on-restart-button` — 앱 내 "Restart app" 버튼을 누를 때 - `03-optional-update-on-resume-after-20s` — 앱이 백그라운드에 20초 이상 머문 뒤 포그라운드로 돌아올 때 `ON_NEXT_RESUME`으로 업데이트가 적용되는지 확인합니다. `--exclude-timing-sensitive`를 주지 않으면 실행됩니다. - `04-optional-update-on-suspend-after-20s` — 앱이 백그라운드에 20초 이상 머무는 동안 `ON_NEXT_SUSPEND`로 업데이트가 적용되고, 다음 포그라운드 진입 시 반영된 번들이 보이는지 확인합니다. `--exclude-timing-sensitive`를 주지 않으면 실행됩니다. + - `05-optional-update-on-resume-history-0s-over-sync-20s` — 앱의 `sync`가 20초를 요청했더라도 `--minimum-background-duration 0`으로 배포한 릴리스가 처음 포그라운드로 돌아올 때 적용되는지 확인합니다. 릴리스에 적힌 값이 sync 옵션보다 우선합니다. + - `06-optional-update-on-resume-history-20s-over-sync-0s` — 앱의 `sync`가 대기 없음을 요청했더라도 `--minimum-background-duration 20`으로 배포한 릴리스가 백그라운드 2초 뒤에는 적용되지 않고 20초 뒤에 적용되는지 확인합니다. `--exclude-timing-sensitive`를 주지 않으면 실행됩니다. ### Phase 6 — 바이너리 패치 업데이트 (`flows-binary-patch/`) diff --git a/e2e/README.md b/e2e/README.md index 9f1edb36b..4215de0fa 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -66,7 +66,7 @@ flaking under that load, `--exclude-timing-sensitive` and `--retry-count` are th | `--framework ` | No | Use `expo` for Expo example apps | | `--simulator ` | No | iOS simulator name (auto-detects booted simulator, defaults to "iPhone 16") | | `--maestro-only` | No | Skip build step, only run test flows | -| `--exclude-timing-sensitive` | No | Skip timing-sensitive optional scenarios (`03`, `04`). Default: off, so local runs include them | +| `--exclude-timing-sensitive` | No | Skip timing-sensitive optional scenarios (`03`, `04`, `06`). Default: off, so local runs include them | ## What It Does @@ -97,12 +97,14 @@ The test runner (`e2e/run.ts`) executes these phases in order: ### Phase 4 — Optional Install Modes (`flows-optional/`) -12. **Prepare optional release per scenario** — For each scenario, recreates history and deploys a non-mandatory release (`-m false`) using `npx code-push release`. +12. **Prepare optional release per scenario** — For each scenario, recreates history and deploys a non-mandatory release (`-m false`) using `npx code-push release`. Scenarios `05` and `06` also pass `--minimum-background-duration`, so the release itself carries the wait. 13. **Run optional update flows** — Verifies optional updates are applied when: - `01-optional-update-on-relaunch` — The app is killed and relaunched. - `02-optional-update-on-restart-button` — The in-app "Restart app" button is pressed. - `03-optional-update-on-resume-after-20s` — Verifies `ON_NEXT_RESUME` applies the update when the app returns to foreground after staying in background for at least 20 seconds. Runs unless `--exclude-timing-sensitive` is passed. - `04-optional-update-on-suspend-after-20s` — Verifies `ON_NEXT_SUSPEND` applies the update while the app stays in background for at least 20 seconds, so the updated bundle is visible on the next foreground. Runs unless `--exclude-timing-sensitive` is passed. + - `05-optional-update-on-resume-history-0s-over-sync-20s` — Verifies a release published with `--minimum-background-duration 0` is applied on the first resume even though the app's `sync` asked for 20 seconds: the release's value wins. + - `06-optional-update-on-resume-history-20s-over-sync-0s` — Verifies a release published with `--minimum-background-duration 20` is not applied after a 2 second background and is applied after 20 seconds, even though the app's `sync` asked for no wait. Runs unless `--exclude-timing-sensitive` is passed. ### Phase 6 — Binary Patch Updates (`flows-binary-patch/`) diff --git a/e2e/flows-optional/05-optional-update-on-resume-history-0s-over-sync-20s.yaml b/e2e/flows-optional/05-optional-update-on-resume-history-0s-over-sync-20s.yaml new file mode 100644 index 000000000..093b84921 --- /dev/null +++ b/e2e/flows-optional/05-optional-update-on-resume-history-0s-over-sync-20s.yaml @@ -0,0 +1,41 @@ +appId: ${APP_ID} +--- +- launchApp +- assertVisible: "React Native.*" + +# Ensure binary state before scenario (keeps retries deterministic) +- tapOn: "Clear updates" +- tapOn: "Restart app" +- waitForAnimationToEnd: + timeout: 5000 +- assertVisible: "React Native.*" +- assertNotVisible: "UPDATED!" + +# Download optional update with ON_NEXT_RESUME and minimumBackgroundDuration=20 +- tapOn: "(?i)sync on_next_resume \\(20s\\)" +- waitForAnimationToEnd: + timeout: 30000 +- assertVisible: "Result: UPDATE_INSTALLED" + +# Not applied yet before background/resume (still running previous bundle) +- tapOn: "Get update metadata" +- waitForAnimationToEnd: + timeout: 3000 +- assertNotVisible: "METADATA_V1.1.5" + +# Background for <20s, resume should apply update: the release asks for 0 seconds, which wins over the 20 the sync call asked for +- pressKey: Home +- runScript: + file: ../scripts/sleep.js + env: + WAIT_MS: "2000" +- launchApp: + stopApp: false +- waitForAnimationToEnd: + timeout: 30000 +- assertVisible: "React Native.*" + +- tapOn: "Get update metadata" +- waitForAnimationToEnd: + timeout: 3000 +- assertVisible: "METADATA_V1.1.5" diff --git a/e2e/flows-optional/06-optional-update-on-resume-history-20s-over-sync-0s.yaml b/e2e/flows-optional/06-optional-update-on-resume-history-20s-over-sync-0s.yaml new file mode 100644 index 000000000..4b24da1e0 --- /dev/null +++ b/e2e/flows-optional/06-optional-update-on-resume-history-20s-over-sync-0s.yaml @@ -0,0 +1,57 @@ +appId: ${APP_ID} +--- +- launchApp +- assertVisible: "React Native.*" + +# Ensure binary state before scenario (keeps retries deterministic) +- tapOn: "Clear updates" +- tapOn: "Restart app" +- waitForAnimationToEnd: + timeout: 5000 +- assertVisible: "React Native.*" +- assertNotVisible: "UPDATED!" + +# Download optional update with ON_NEXT_RESUME and minimumBackgroundDuration=0 +- tapOn: "(?i)sync on_next_resume \\(0s\\)" +- waitForAnimationToEnd: + timeout: 30000 +- assertVisible: "Result: UPDATE_INSTALLED" + +# Not applied yet before background/resume (still running previous bundle) +- tapOn: "Get update metadata" +- waitForAnimationToEnd: + timeout: 3000 +- assertNotVisible: "METADATA_V1.1.6" + +# Background for <20s, resume should NOT apply update: the release asks for 20 seconds, which wins over the no wait the sync call asked for +- pressKey: Home +- runScript: + file: ../scripts/sleep.js + env: + WAIT_MS: "2000" +- launchApp: + stopApp: false +- waitForAnimationToEnd: + timeout: 10000 +- assertVisible: "React Native.*" +- tapOn: "Get update metadata" +- waitForAnimationToEnd: + timeout: 3000 +- assertNotVisible: "METADATA_V1.1.6" + +# Background for >=20s, then resume should apply update +- pressKey: Home +- runScript: + file: ../scripts/sleep.js + env: + WAIT_MS: "25000" +- launchApp: + stopApp: false +- waitForAnimationToEnd: + timeout: 30000 +- assertVisible: "React Native.*" + +- tapOn: "Get update metadata" +- waitForAnimationToEnd: + timeout: 3000 +- assertVisible: "METADATA_V1.1.6" diff --git a/e2e/helpers/binary-patch-fixtures.ts b/e2e/helpers/binary-patch-fixtures.ts index 1b4f460e6..d786ec965 100644 --- a/e2e/helpers/binary-patch-fixtures.ts +++ b/e2e/helpers/binary-patch-fixtures.ts @@ -128,6 +128,7 @@ export function readReleaseHistory( packageHash: string; binaryPatchDownloadUrl?: string; diffPackages?: Record; + minimumBackgroundDuration?: number; }> { return JSON.parse(fs.readFileSync(getHistoryFilePath(platform, identifier, binaryVersion), "utf8")); } diff --git a/e2e/helpers/prepare-bundle.ts b/e2e/helpers/prepare-bundle.ts index edba018f3..9947e63b5 100644 --- a/e2e/helpers/prepare-bundle.ts +++ b/e2e/helpers/prepare-bundle.ts @@ -31,6 +31,8 @@ interface PrepareBundleOptions { assetMarkers?: AssetMarker[]; /** Skipped when the release should join the history that is already being served. */ createHistory?: boolean; + /** Written on the release history entry; the app then waits this long instead of what `sync` asked. */ + minimumBackgroundDuration?: number; } export function setReleasingBundle(appPath: string, platform: "ios" | "android", value: boolean): void { @@ -213,6 +215,7 @@ export async function prepareBundle( mandatory, framework, options.binaryBundlePath, + options.minimumBackgroundDuration, ); } finally { if (releaseMarkerVersion) { @@ -236,6 +239,7 @@ function runCodePushRelease( mandatory: boolean, framework?: "expo", binaryBundlePath?: string, + minimumBackgroundDuration?: number, ): Promise { const { frameworkArgs, entryFile } = getCodePushReleaseArgs(appPath, framework); return runCodePushCommand(appPath, platform, [ @@ -249,6 +253,10 @@ function runCodePushRelease( "-e", entryFile, "-m", mandatory ? "true" : "false", ...(binaryBundlePath ? ["--binary-bundle-path", binaryBundlePath] : []), + // Checked against the type, because 0 is a value a release can ask for. + ...(typeof minimumBackgroundDuration === "number" + ? ["--minimum-background-duration", String(minimumBackgroundDuration)] + : []), ]); } diff --git a/e2e/helpers/prepare-config.ts b/e2e/helpers/prepare-config.ts index 32f16ca01..b92dfc256 100644 --- a/e2e/helpers/prepare-config.ts +++ b/e2e/helpers/prepare-config.ts @@ -3,6 +3,7 @@ import path from "path"; import { getAppEntryPath, getAppSourceEntryPath, getMockServerHost } from "../config"; const RESUME_SYNC_BUTTON_TITLE = "Sync ON_NEXT_RESUME (20s)"; +const RESUME_NO_WAIT_SYNC_BUTTON_TITLE = "Sync ON_NEXT_RESUME (0s)"; const SUSPEND_SYNC_BUTTON_TITLE = "Sync ON_NEXT_SUSPEND (20s)"; const ALERT_SYNC_BUTTON_TITLE = "Sync with updateDialog"; const ALERT_DIALOG_TITLE = "E2E Update Dialog"; @@ -108,6 +109,7 @@ function injectUpdateArchiveResultProbe(content: string): string { function injectResumeSyncSupport(content: string): string { if ( content.includes(RESUME_SYNC_BUTTON_TITLE) + && content.includes(RESUME_NO_WAIT_SYNC_BUTTON_TITLE) && content.includes(SUSPEND_SYNC_BUTTON_TITLE) && content.includes(ALERT_SYNC_BUTTON_TITLE) ) { @@ -137,6 +139,28 @@ function injectResumeSyncSupport(content: string): string { " });", " }, []);", "", + " const handleSyncOnNextResumeWithoutWait = useCallback(() => {", + " CodePush.sync(", + " {", + " installMode: CodePush.InstallMode.ON_NEXT_RESUME,", + " mandatoryInstallMode: CodePush.InstallMode.ON_NEXT_RESUME,", + " minimumBackgroundDuration: 0,", + " },", + " status => {", + " setSyncResult(findKeyByValue(CodePush.SyncStatus, status) ?? '');", + " },", + " ({ receivedBytes, totalBytes }) => {", + " setProgress(Math.round((receivedBytes / totalBytes) * 100));", + " },", + " mismatch => {", + " console.log('CodePush mismatch', JSON.stringify(mismatch, null, 2));", + " },", + " ).catch(error => {", + " console.error(error);", + " console.log('Sync failed', error.message ?? 'Unknown error');", + " });", + " }, []);", + "", " const handleSyncWithUpdateDialog = useCallback(() => {", " CodePush.sync(", " {", @@ -202,6 +226,7 @@ function injectResumeSyncSupport(content: string): string { `${indent}