From 5df23d924183a795a8376e69d52c2e4fd1a45119 Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Wed, 16 Sep 2026 16:55:39 +0900 Subject: [PATCH 1/9] feat: let a release history entry set its own minimumBackgroundDuration A release can now carry `minimumBackgroundDuration`, which wins over the one the `sync` call passes, so a single release can be applied sooner or later than the app asks for by default. --- src/CodePush.js | 19 ++++++++-- src/CodePush.test.js | 57 +++++++++++++++++++++++++++++ typings/react-native-code-push.d.ts | 20 ++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/CodePush.js b/src/CodePush.js index eabefd7d..b4246c47 100644 --- a/src/CodePush.js +++ b/src/CodePush.js @@ -200,6 +200,11 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) { * back on when the diff fails on its asset side. */ asset_diff_download_url: diffPackageDownloadUrl, + /** + * Only present when the release asked for a background duration of its own, which + * then wins over the one the `sync` call passes. + */ + minimum_background_duration: latestReleaseInfo.minimumBackgroundDuration, // (`enabled` will always be true in the release information obtained from the previous process.) is_available: latestReleaseInfo.enabled, package_hash: latestReleaseInfo.packageHash, @@ -309,6 +314,11 @@ function mapToRemotePackageMetadata(updateInfo) { ...(updateInfo.asset_diff_download_url ? { assetDiffDownloadUrl: updateInfo.asset_diff_download_url } : {}), + // Presence is read off the type, not off truthiness, so that a release asking for `0` + // seconds keeps its own value instead of looking like a release that asked for nothing. + ...(typeof updateInfo.minimum_background_duration === 'number' + ? { minimumBackgroundDuration: updateInfo.minimum_background_duration } + : {}), }; } @@ -552,6 +562,7 @@ const sync = (() => { */ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProgressCallback, handleBinaryVersionMismatchCallback) { let resolvedInstallMode; + let resolvedMinimumBackgroundDuration; const syncOptions = { deploymentKey: null, ignoreFailedUpdates: true, @@ -596,8 +607,8 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESTART) { log("Update is installed and will be run on the next app restart."); } else if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESUME) { - if (syncOptions.minimumBackgroundDuration > 0) { - log(`Update is installed and will be run after the app has been in the background for at least ${syncOptions.minimumBackgroundDuration} seconds.`); + if (resolvedMinimumBackgroundDuration > 0) { + log(`Update is installed and will be run after the app has been in the background for at least ${resolvedMinimumBackgroundDuration} seconds.`); } else { log("Update is installed and will be run when the app next resumes."); } @@ -630,9 +641,11 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg // Determine the correct install mode based on whether the update is mandatory or not. resolvedInstallMode = localPackage.isMandatory ? syncOptions.mandatoryInstallMode : syncOptions.installMode; + // `??` rather than `||`, so a release asking for `0` seconds keeps its own value. + resolvedMinimumBackgroundDuration = remotePackage.minimumBackgroundDuration ?? syncOptions.minimumBackgroundDuration; syncStatusChangeCallback(CodePush.SyncStatus.INSTALLING_UPDATE); - await localPackage.install(resolvedInstallMode, syncOptions.minimumBackgroundDuration, () => { + await localPackage.install(resolvedInstallMode, resolvedMinimumBackgroundDuration, () => { syncStatusChangeCallback(CodePush.SyncStatus.UPDATE_INSTALLED); }); diff --git a/src/CodePush.test.js b/src/CodePush.test.js index 56f5e117..f9a3621f 100644 --- a/src/CodePush.test.js +++ b/src/CodePush.test.js @@ -649,3 +649,60 @@ describe('telemetry callback errors', () => { expect(nativeBridge.saveStatusReportForRetry).not.toHaveBeenCalled(); }); }); + +/** + * A release can ask for its own minimum background duration - how long the app has to have + * been in the background before an update installed with `ON_NEXT_RESUME` is applied. The + * app passes a duration to `sync` for the releases that ask for nothing, and whichever of + * the two wins is what the native module is given when the update is installed. + */ +describe('the minimum background duration an update is installed with', () => { + /** The release the CLI writes when the release carries its own background duration. */ + function releaseAskingFor(minimumBackgroundDuration) { + const history = fullOnlyRelease(); + history[LABEL].minimumBackgroundDuration = minimumBackgroundDuration; + return history; + } + + /** The background duration the native module is given, in seconds. */ + function installedMinimumBackgroundDuration(nativeBridge) { + expect(nativeBridge.installUpdate).toHaveBeenCalledTimes(1); + return nativeBridge.installUpdate.mock.calls[0][2]; + } + + it('takes the one the release history entry asks for over the one the sync call passes', async () => { + const { CodePush, nativeBridge } = loadCodePush({ releaseHistory: releaseAskingFor(600) }); + + const syncStatus = await CodePush.sync({ + installMode: InstallMode.ON_NEXT_RESUME, + minimumBackgroundDuration: 3600, + }); + + expect(syncStatus).toBe(CodePush.SyncStatus.UPDATE_INSTALLED); + expect(installedMinimumBackgroundDuration(nativeBridge)).toBe(600); + }); + + it('keeps the one the sync call passes when the release history entry asks for none', async () => { + const { CodePush, nativeBridge } = loadCodePush({ releaseHistory: fullOnlyRelease() }); + + const syncStatus = await CodePush.sync({ + installMode: InstallMode.ON_NEXT_RESUME, + minimumBackgroundDuration: 3600, + }); + + expect(syncStatus).toBe(CodePush.SyncStatus.UPDATE_INSTALLED); + expect(installedMinimumBackgroundDuration(nativeBridge)).toBe(3600); + }); + + it('runs a release that asks for zero seconds on the next resume, however long the sync call would have waited', async () => { + const { CodePush, nativeBridge } = loadCodePush({ releaseHistory: releaseAskingFor(0) }); + + const syncStatus = await CodePush.sync({ + installMode: InstallMode.ON_NEXT_RESUME, + minimumBackgroundDuration: 3600, + }); + + expect(syncStatus).toBe(CodePush.SyncStatus.UPDATE_INSTALLED); + expect(installedMinimumBackgroundDuration(nativeBridge)).toBe(0); + }); +}); diff --git a/typings/react-native-code-push.d.ts b/typings/react-native-code-push.d.ts index 7fea346f..5151918f 100644 --- a/typings/react-native-code-push.d.ts +++ b/typings/react-native-code-push.d.ts @@ -44,6 +44,13 @@ export interface ReleaseInfo { */ diffPackages?: Record; packageHash: string; + /** + * The minimum number of seconds the app has to have been in the background before this + * release is applied, with the same meaning as `SyncOptions.minimumBackgroundDuration`. + * When it is present it takes precedence over the value passed to `sync`, so a single + * release can be applied sooner or later than the app asks for by default. + */ + minimumBackgroundDuration?: number; rollout?: number; } @@ -73,6 +80,11 @@ export interface UpdateCheckResponse { should_run_binary_version?: boolean; update_app_version?: boolean; is_mandatory?: boolean; + /** + * The minimum number of seconds the app has to have been in the background before this + * update is applied. It is only present when the release asked for one of its own. + */ + minimum_background_duration?: number; } /** @@ -345,6 +357,13 @@ export interface RemotePackage extends Package { * `UpdateArchiveResult`. */ assetDiffDownloadUrl?: string; + + /** + * The minimum number of seconds the app has to have been in the background before this + * update is applied. It is only present when the release history entry set it, and it + * then takes precedence over the `minimumBackgroundDuration` passed to `sync`. + */ + minimumBackgroundDuration?: number; } export interface SyncOptions { @@ -372,6 +391,7 @@ export interface SyncOptions { * 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. */ minimumBackgroundDuration?: number; From b1b36d656448842ec073d05d93417fd74844d5b0 Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Wed, 16 Sep 2026 17:05:51 +0900 Subject: [PATCH 2/9] feat(cli): add --minimum-background-duration to the release command `release --minimum-background-duration ` writes the value on the release history entry it creates, so the release decides its own background wait instead of taking the one the `sync` call passes. Without the option the entry says nothing about it, exactly as before. --- .../releaseCommand/addToReleaseHistory.ts | 6 ++++ cli/commands/releaseCommand/index.test.ts | 29 +++++++++++++++++++ cli/commands/releaseCommand/index.ts | 9 ++++++ cli/commands/releaseCommand/release.test.ts | 20 +++++++++++++ cli/commands/releaseCommand/release.ts | 2 ++ 5 files changed, 66 insertions(+) diff --git a/cli/commands/releaseCommand/addToReleaseHistory.ts b/cli/commands/releaseCommand/addToReleaseHistory.ts index ebee5118..85fd60e0 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 15f3fbce..3560af1d 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 0111d6da..ee6219d9 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 65324c68..9f72ca1a 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,21 @@ 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("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 9c89a433..89d22483 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) { From 83084da648bcef0185a2835a33644c1134fcd235 Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Wed, 16 Sep 2026 17:05:58 +0900 Subject: [PATCH 3/9] feat(cli): add --minimum-background-duration to update-history and count --rollout as an option `update-history --minimum-background-duration ` edits the background wait of a release that is already out, which is how a team lowers it to 0 once the release has been running for a while. The "no options specified" guard only looked at --mandatory and --enable, so `update-history --rollout 50` exited instead of saving the percentage. It now counts every option that changes the entry. --- .../createReleaseHistory.test.ts | 4 +- .../updateHistoryCommand/index.test.ts | 119 ++++++++++++++++++ cli/commands/updateHistoryCommand/index.ts | 22 +++- .../updateReleaseHistory.ts | 3 + 4 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 cli/commands/updateHistoryCommand/index.test.ts diff --git a/cli/commands/createHistoryCommand/createReleaseHistory.test.ts b/cli/commands/createHistoryCommand/createReleaseHistory.test.ts index 6f1ebe92..9f85ec6e 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/updateHistoryCommand/index.test.ts b/cli/commands/updateHistoryCommand/index.test.ts new file mode 100644 index 00000000..879a8ebe --- /dev/null +++ b/cli/commands/updateHistoryCommand/index.test.ts @@ -0,0 +1,119 @@ +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); + }); +}); diff --git a/cli/commands/updateHistoryCommand/index.ts b/cli/commands/updateHistoryCommand/index.ts index 2429dfae..c8ab16a2 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,24 @@ 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) } + 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 +52,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 6c1c9837..ccefda7d 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) => From 1428f5713f3416e985d8c8773fbe8a4bd57b51c1 Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Wed, 16 Sep 2026 17:14:22 +0900 Subject: [PATCH 4/9] docs: describe per-release minimumBackgroundDuration The release and update-history commands can now set a background wait on a single release, and the runtime prefers it over the minimumBackgroundDuration the sync call passes, so the CLI option list and the JS API reference say so. --- README.md | 4 ++++ docs/api-js.md | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 432116b1..0c5ba707 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. (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. (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/docs/api-js.md b/docs/api-js.md index 8a811e5d..e2c37f15 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. From 4ae1125497de67e57b3949afd3423ed106cf9bbe Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Wed, 16 Sep 2026 17:31:20 +0900 Subject: [PATCH 5/9] fix(cli): validate --rollout on update-history now that it counts as an option --- cli/commands/updateHistoryCommand/index.test.ts | 10 ++++++++++ cli/commands/updateHistoryCommand/index.ts | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/cli/commands/updateHistoryCommand/index.test.ts b/cli/commands/updateHistoryCommand/index.test.ts index 879a8ebe..b476d685 100644 --- a/cli/commands/updateHistoryCommand/index.test.ts +++ b/cli/commands/updateHistoryCommand/index.test.ts @@ -116,4 +116,14 @@ describe("update-history command options", () => { 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 c8ab16a2..dcca0d42 100644 --- a/cli/commands/updateHistoryCommand/index.ts +++ b/cli/commands/updateHistoryCommand/index.ts @@ -37,6 +37,14 @@ program.command('update-history') 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.'); From 6a401b67ce99f01e6a9c9d63c4c562e7936d7a8a Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Wed, 16 Sep 2026 17:31:20 +0900 Subject: [PATCH 6/9] test(cli): pin that release writes a zero minimumBackgroundDuration --- cli/commands/releaseCommand/release.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cli/commands/releaseCommand/release.test.ts b/cli/commands/releaseCommand/release.test.ts index 9f72ca1a..9ad65184 100644 --- a/cli/commands/releaseCommand/release.test.ts +++ b/cli/commands/releaseCommand/release.test.ts @@ -841,6 +841,14 @@ describe("release --minimum-background-duration", () => { 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"); From f4cf70ddf6b8dcc0a419a29cda4c769cb7a37462 Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Wed, 16 Sep 2026 17:31:20 +0900 Subject: [PATCH 7/9] docs: add --minimum-background-duration to the CLI reference --- README.md | 4 ++-- cli/README.ko.md | 7 +++++-- cli/README.md | 7 +++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0c5ba707..49a3cfa5 100644 --- a/README.md +++ b/README.md @@ -501,7 +501,7 @@ 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. (whole seconds, 0 or greater) +- `--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` @@ -511,7 +511,7 @@ 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. (whole seconds, 0 or greater) +- 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:** diff --git a/cli/README.ko.md b/cli/README.ko.md index 79245fa2..95f8dc8c 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 76e6e0fc..f4f61dd4 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, From c1733f630eb3c12b8fc0c26394f3fe23615e8190 Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Wed, 16 Sep 2026 20:04:04 +0900 Subject: [PATCH 8/9] test(e2e): cover a release that sets its own minimumBackgroundDuration --- e2e/README.ko.md | 6 +- e2e/README.md | 6 +- ...te-on-resume-history-0s-over-sync-20s.yaml | 41 +++++++++++++ ...te-on-resume-history-20s-over-sync-0s.yaml | 57 +++++++++++++++++++ e2e/helpers/binary-patch-fixtures.ts | 1 + e2e/helpers/prepare-bundle.ts | 8 +++ e2e/helpers/prepare-config.ts | 25 ++++++++ e2e/run.ts | 25 +++++++- 8 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 e2e/flows-optional/05-optional-update-on-resume-history-0s-over-sync-20s.yaml create mode 100644 e2e/flows-optional/06-optional-update-on-resume-history-20s-over-sync-0s.yaml diff --git a/e2e/README.ko.md b/e2e/README.ko.md index 8ae44b62..7841e1a7 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`์œผ๋กœ ๋ฐฐํฌํ•œ ๋ฆด๋ฆฌ์Šค๊ฐ€ ์ฒซ resume์—์„œ ์ ์šฉ๋˜๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. ๋ฆด๋ฆฌ์Šค์— ์ ํžŒ ๊ฐ’์ด 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 9f1edb36..4215de0f 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 00000000..093b8492 --- /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 00000000..4b24da1e --- /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 1b4f460e..d786ec96 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 edba018f..9947e63b 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 32f16ca0..b92dfc25 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}