From 4a69652c813d7ccea3f589c00f0bd31dd97e85dc Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Tue, 11 Aug 2026 23:54:33 +0800 Subject: [PATCH 1/5] feat: disableNativeCheck opt-out, 10.51.0 docs and native check e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cold-start check shipped as unconditional behavior: every integrator got one extra background request per launch with no way to decline. That contradicts how the rest of this SDK treats integrator intent, and the plumbing to honor a decline already existed — all three orchestrators already bail on a `disabled` config before any IO. ClientOptions gains disableNativeCheck, documented as the trade it is: one fewer request per cold start in exchange for giving up the recovery path a bricked device depends on. It stays orthogonal to checkStrategy, which governs activation authority rather than whether the check runs. Both READMEs describe the check itself — that it never blocks startup, when it may activate a version, how the console's force-boot rescue works and how to turn the whole thing off — and the version moves to 10.51.0, which is also the floor the consoles gate their force-boot entry on. The feature also shipped with no e2e coverage of its own, which is why it took breaking someone else's suite to surface a contract conflict. The new suite covers the capability minus the one step Detox cannot stage (it cannot attach to an app whose JS never boots): the app performs no JS check at all, yet a force-boot version still installs and activates — an outcome only the native orchestrator can produce. A second case pins the other direction: without the directive, and with automatic checks off, the check may download but must never activate on its own. The mock server grows a /control/force-boot endpoint so the directive is opt-in per test and the other suites keep full control over activation. Co-Authored-By: Claude Fable 5 --- Example/e2etest/e2e/native-check.test.ts | 139 ++++++++++++++++++++ Example/e2etest/scripts/local-e2e-server.ts | 29 +++- README-CN.md | 14 +- README.md | 14 +- package.json | 2 +- src/__tests__/client.test.ts | 19 +++ src/client.ts | 21 ++- src/type.ts | 18 +++ 8 files changed, 248 insertions(+), 8 deletions(-) create mode 100644 Example/e2etest/e2e/native-check.test.ts diff --git a/Example/e2etest/e2e/native-check.test.ts b/Example/e2etest/e2e/native-check.test.ts new file mode 100644 index 00000000..245af58c --- /dev/null +++ b/Example/e2etest/e2e/native-check.test.ts @@ -0,0 +1,139 @@ +import { by, device, element, waitFor } from 'detox'; +import { + getLocalUpdateEndpoint, + LOCAL_UPDATE_HASHES, + LOCAL_UPDATE_LABELS, + LOCAL_UPDATE_PORT, +} from './localUpdateConfig.ts'; + +// The native cold-start check (NATIVE_CHECKUPDATE_DESIGN §10) exists for one +// scenario: the running update is broken badly enough that JS never starts, so +// nothing in JS can fetch the fix. Staging a genuinely bricked bundle is not +// testable through Detox — it cannot attach to an app whose JS never boots — so +// this suite covers the same capability minus that one step: the app never +// performs a JS check (checkStrategy: null, and the check button is never +// tapped), yet the device still ends up on a new version. Only the native +// orchestrator can produce that outcome. +// +// The activation itself is driven by the server's per-version forceBoot +// directive, which is exactly how a real rescue is triggered from the console. + +const NATIVE_CHECK_SETTLE_MS = 25000; +const READY_TIMEOUT = 30000; +const LABEL_TIMEOUT = 30000; + +function getDetoxLaunchArgs() { + if (device.getPlatform() !== 'android') { + return {}; + } + return { launchArgs: { detoxEnableSynchronization: '0' } }; +} + +async function relaunchAppPreservingData() { + await device.launchApp({ newInstance: true, ...getDetoxLaunchArgs() }); + // The native check talks to the same local server; keeping those requests out + // of Detox's idle synchronization is what the other suites do too. + await device.setURLBlacklist([`.*:${LOCAL_UPDATE_PORT}.*`]); +} + +async function setForceBoot(enabled: boolean) { + const endpoint = getLocalUpdateEndpoint(device.getPlatform()); + // Reached from the test runner (the host), not from the device, so localhost + // is correct even when the app talks to 10.0.2.2. + const hostEndpoint = endpoint.replace('10.0.2.2', '127.0.0.1'); + const response = await fetch(`${hostEndpoint}/control/force-boot`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }); + if (!response.ok) { + throw new Error(`failed to set forceBoot=${enabled}: ${response.status}`); + } +} + +async function waitForReady() { + await waitFor(element(by.id('bundle-label'))) + .toBeVisible() + .withTimeout(READY_TIMEOUT); +} + +async function waitForBundleLabel(label: string) { + await waitFor(element(by.id('bundle-label'))) + .toHaveText(`bundleLabel: ${label}`) + .withTimeout(LABEL_TIMEOUT); +} + +async function waitForHash(hash: string) { + await waitFor(element(by.id('current-hash'))) + .toHaveText(`currentHash: ${hash || '(empty)'}`) + .withTimeout(LABEL_TIMEOUT); +} + +async function resetToPackagedBundle() { + await waitFor(element(by.id('reset-to-packaged'))) + .toBeVisible() + .withTimeout(READY_TIMEOUT); + await element(by.id('reset-to-packaged')).tap(); + await waitFor(element(by.id('last-event'))) + .toHaveText('lastEvent: resetDone') + .withTimeout(15000); +} + +describe('Native cold-start check', () => { + beforeAll(async () => { + await device.launchApp({ delete: true, ...getDetoxLaunchArgs() }); + }); + + beforeEach(async () => { + await setForceBoot(false); + await relaunchAppPreservingData(); + await waitForReady(); + await resetToPackagedBundle(); + await relaunchAppPreservingData(); + await waitForReady(); + await waitForBundleLabel(LOCAL_UPDATE_LABELS.base); + await waitForHash(''); + }); + + afterAll(async () => { + // Never leave the directive on: the other suites drive activation + // themselves and would race a forced one. + await setForceBoot(false); + }); + + it('installs a forceBoot version without any JS check', async () => { + await setForceBoot(true); + + // This launch schedules the native round; the app itself never checks + // (checkStrategy is null and the check button is not tapped). + await relaunchAppPreservingData(); + await waitForReady(); + await waitForBundleLabel(LOCAL_UPDATE_LABELS.base); + await new Promise((resolve) => + setTimeout(resolve, NATIVE_CHECK_SETTLE_MS) + ); + + // Turn the directive off before observing, so the next launch cannot walk + // further along the update chain while the assertions run. + await setForceBoot(false); + await relaunchAppPreservingData(); + await waitForReady(); + await waitForBundleLabel(LOCAL_UPDATE_LABELS.full); + await waitForHash(LOCAL_UPDATE_HASHES.full); + }); + + it('leaves the app on the packaged bundle when nothing is forced', async () => { + // Same wait, no directive: with checkStrategy null the native check may + // download but must never activate on its own. + await relaunchAppPreservingData(); + await waitForReady(); + await new Promise((resolve) => + setTimeout(resolve, NATIVE_CHECK_SETTLE_MS) + ); + + await relaunchAppPreservingData(); + await waitForReady(); + await waitForBundleLabel(LOCAL_UPDATE_LABELS.base); + await waitForHash(''); + }); +}); diff --git a/Example/e2etest/scripts/local-e2e-server.ts b/Example/e2etest/scripts/local-e2e-server.ts index a628cc59..7cbdfab9 100644 --- a/Example/e2etest/scripts/local-e2e-server.ts +++ b/Example/e2etest/scripts/local-e2e-server.ts @@ -22,6 +22,13 @@ const appKeyToPlatform = Object.fromEntries( ]) ); +// Flipped by the /control/force-boot endpoint. When on, every update response +// carries config.forceBoot so the client's native cold-start check activates +// the version it downloads — the brick-rescue path, which by definition has to +// work without any JS check. Off by default so the other suites keep full +// control over when a version becomes active. +let forceBootEnabled = false; + const contentTypes: Record = { '.json': 'application/json; charset=utf-8', '.ppk': 'application/octet-stream', @@ -170,6 +177,17 @@ const server = Bun.serve({ }); } + if (url.pathname === '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/control/force-boot') { + if (request.method !== 'POST') { + return new Response('method not allowed', { status: 405 }); + } + const body = (await request.json().catch(() => ({}))) as { + enabled?: unknown; + }; + forceBootEnabled = body.enabled === true; + return json({ forceBoot: forceBootEnabled }); + } + if (url.pathname.startsWith('/checkUpdate/')) { if (request.method !== 'POST') { return new Response('method not allowed', { status: 405 }); @@ -188,9 +206,16 @@ const server = Bun.serve({ const currentHash = typeof payload.hash === 'string' ? payload.hash : ''; const diffV = typeof payload.diffV === 'number' ? payload.diffV : 0; - return json( - buildUpdateResponse(platform, currentHash, diffV, url.origin) + const response = buildUpdateResponse( + platform, + currentHash, + diffV, + url.origin ); + if (forceBootEnabled && 'update' in response) { + return json({ ...response, config: { forceBoot: true } }); + } + return json(response); } if (url.pathname.startsWith('/artifacts/')) { diff --git a/README-CN.md b/README-CN.md index ada4d1eb..458106cb 100644 --- a/README-CN.md +++ b/README-CN.md @@ -27,7 +27,19 @@ 7. 支持崩溃回滚,安全可靠,结合健康度监控可及时发现并止损问题版本。 8. meta 信息及开放 API,提供更高扩展性。 9. 提供 **MCP 服务**:把热更新服务接进 Claude Desktop、IDE 或自建 Agent,用自然语言排查"这台设备为什么没收到更新",并可与 GitHub、Sentry、CI 等工具组合定位问题。全程只读、按应用授权([Pushy 文档](https://pushy.reactnative.cn/docs/mcp) / [Cresc 文档](https://cresc.dev/docs/mcp))。 -10. 提供付费的专人技术支持。 +10. **原生冷启动自愈**:即使热更版本坏到 JS 完全跑不起来(白屏、启动即崩),设备也能在下次启动时由原生侧自动拉到修复版——不需要用户重装,也不需要你发新的应用商店版本(详见下方[原生冷启动检测](#原生冷启动检测))。 +11. 提供付费的专人技术支持。 + +### 原生冷启动检测 + +自 10.51.0 起,每次冷启动后数秒会在后台线程执行一次**不依赖 app bundle** 的更新检查(下载、打补丁与状态切换全部在原生侧完成)。它的存在只为一件事:**当前热更版本坏到 JS 起不来时,仍有一条能把修复版拉下来的通路**——常规更新流程仍由应用内的 JS 负责,检查结果会被 JS 侧复用,不会重复请求。 + +几个需要知道的点: + +- **不阻塞启动**:延迟数秒、跑在后台线程,成果在**下次启动**生效。 +- **是否自动激活取决于你的配置**:`updateStrategy` 为 `silentAndNow` / `silentAndLater` 且未关闭自动检查(`checkStrategy` 不为 `null`)时,原生侧才会把下载好的版本设为下次启动生效;其余情况只下载,激活权仍在 JS。 +- **救砖指令**:控制台可按版本标记「强制启动」,被标记的版本无视上述策略直接在下次启动生效——这是把已被坏版本卡死的设备捞回来的手段。设备本地的崩溃回滚保护仍然优先,已回滚过的版本不会被再装回去。 +- **可以关闭**:`disableNativeCheck: true`。关闭后每次冷启动少一次后台请求,代价是**放弃上述自愈能力**——被坏热更卡死的设备将无法自动恢复。仅在这次请求本身构成问题时(流量/耗电预算、隐私清单申报、需用户同意后才可联网)才建议关闭。 ### Diff 算法对比 diff --git a/README.md b/README.md index 756c8650..094f0df1 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,19 @@ See the docs: 7. Built-in crash rollback keeps updates safe and reliable, and health monitoring helps you catch and stop a bad release early. 8. Meta information and open APIs make the system more extensible. 9. An **MCP server** lets you connect the update service to Claude Desktop, an IDE or your own agent, ask in plain language why a device never received an update, and investigate alongside GitHub, Sentry or CI. Everything is read-only and scoped per app ([Cresc docs](https://cresc.dev/docs/mcp) / [Pushy docs](https://pushy.reactnative.cn/docs/mcp)). -10. Paid technical support is available. +10. **Native cold-start recovery**: even when an update is broken badly enough that JS never runs (white screen, crash on launch), the device pulls the fixed version on its next launch from the native side — no reinstall, no app-store release (see [Native cold-start check](#native-cold-start-check)). +11. Paid technical support is available. + +## Native cold-start check + +Since 10.51.0 every cold start runs one background update check a few seconds after launch that **does not depend on the app bundle** — the request, the download, the patch and the version switch all happen natively. It exists for exactly one reason: **when the running update is broken enough that JS never starts, something still has to be able to fetch the fix.** Normal updates remain the JS flow's job; the JS check reuses this result instead of issuing its own request. + +What to know: + +- **It never blocks startup**: it is delayed by a few seconds, runs off the main thread, and its result takes effect on the *next* launch. +- **Whether it activates depends on your configuration**: only with `updateStrategy` set to `silentAndNow` / `silentAndLater` *and* automatic checks left on (`checkStrategy` not `null`) will the native side mark a downloaded version for the next launch. Otherwise it downloads and leaves activation to JS. +- **Rescue directive**: the dashboard can mark a version "force boot", which activates on the next launch regardless of the strategies above — this is how a fleet stuck on a broken version is recovered. The device-local crash-rollback guard still wins: a version this device already rolled back from is never reinstalled. +- **It can be turned off**: `disableNativeCheck: true` removes one background request per cold start, at the cost of **giving up the recovery above** — a device bricked by a bad update can no longer heal itself. Choose it only when that request is itself the problem (traffic/battery budgets, privacy manifests, consent-gated networking). ## Diff Algorithm Comparison diff --git a/package.json b/package.json index 8ebe60a8..78955880 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-update", - "version": "10.50.0", + "version": "10.51.0", "description": "react-native hot update", "main": "src/index", "types": "src/index.ts", diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 90c8f1c7..04aa634c 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -1459,6 +1459,25 @@ describe('syncNativeConfig', () => { expect(config.afterDownload).toBe('setNeedUpdate'); }); + test('disableNativeCheck writes a disabling config the natives bail on', async () => { + const syncNativeConfig = mock(() => Promise.resolve()); + setupClientMocks({ syncNativeConfig }); + const { Pushy } = await importFreshClient('sync-config-optout'); + new Pushy({ + appKey: 'demo-app', + updateStrategy: 'silentAndNow', + disableNativeCheck: true, + }); + + const config = JSON.parse( + (syncNativeConfig.mock.calls.at(-1) as unknown as string[])[0] + ); + expect(config.disabled).toBe(true); + // No endpoints: the orchestrators return on `disabled` before any IO. + expect(config.endpoints).toBeUndefined(); + expect(config.appKey).toBe('demo-app'); + }); + test('alert strategies keep activation with JS (afterDownload none)', async () => { const syncNativeConfig = mock(() => Promise.resolve()); setupClientMocks({ syncNativeConfig }); diff --git a/src/client.ts b/src/client.ts index a3edd2ab..7f1f4337 100644 --- a/src/client.ts +++ b/src/client.ts @@ -97,8 +97,8 @@ const cloneServerConfig = (server: UpdateServerConfig): UpdateServerConfig => ({ }); // Persist an object (rather than an empty string) so every native bridge keeps -// accepting the payload while the orchestrators treat the missing appKey and -// endpoints as an explicit disabled state. +// accepting the payload while the orchestrators read `disabled` and skip the +// cold-start check entirely. const NATIVE_CONFIG_DISABLED_JSON = '{"disabled":true}'; const excludeConfiguredEndpoints = ( @@ -286,8 +286,23 @@ export class Pushy { // and the feature-detect would false-positive. return undefined; } - const { appKey, server, updateStrategy, checkStrategy } = this.options; + const { + appKey, + server, + updateStrategy, + checkStrategy, + disableNativeCheck, + } = this.options; + if (disableNativeCheck) { + // Explicit opt-out: keep the identity fields so the persisted config + // still describes this app, and let the orchestrators bail on `disabled` + // before any IO. + return { disabled: true, appKey, rnu: cInfo.rnu, rn: cInfo.rn }; + } if (!appKey || !server?.main?.length) { + // Unusable rather than merely absent: the JS check would fail on these + // options too (NO_ENDPOINTS / APPKEY_REQUIRED). The caller turns this + // into an explicit disabled state. return undefined; } // An app that turned automatic checks off (checkStrategy: null) must not diff --git a/src/type.ts b/src/type.ts index b6c57a4c..67978815 100644 --- a/src/type.ts +++ b/src/type.ts @@ -177,6 +177,24 @@ export interface ClientOptions { * the version health view in the console. Default: false (enabled). */ disableTelemetry?: boolean; + /** + * Disable the native cold-start update check: the background check that runs + * a few seconds after every launch, independent of JS + * (NATIVE_CHECKUPDATE_DESIGN §10). Default: false (enabled). + * + * That check is what rescues a device bricked by a bad update — its JS never + * runs, so nothing else can pull the fix. Turning it off gives up that + * recovery path (and the response cache the JS check reuses) in exchange for + * one fewer background request per cold start; choose it only when the extra + * request is itself the problem (traffic/battery budgets, privacy manifests, + * consent-gated networking). + * + * Orthogonal to `checkStrategy`, which governs activation authority rather + * than whether the check runs: with `checkStrategy: null` the native check + * still downloads but never activates on its own — only the server's + * per-version forceBoot directive may. + */ + disableNativeCheck?: boolean; } export interface UpdateTestPayload { From 7bd26e51c0129ef2d3709ca4393f2438103566a3 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Wed, 12 Aug 2026 12:52:01 +0800 Subject: [PATCH 2/5] test(e2e): halve the native check suite's app launches The first version spent about nine app launches across two tests: a beforeEach that reset to the packaged bundle and relaunched, then two launches per test. On the iOS simulator every launch is real wall clock, and this suite shares a 40-minute job budget whose green baseline is already ~16 minutes. A fresh install already sits on the packaged bundle, so the reset round-trip buys nothing, and both directions of the assertion can share one install: settle once with no directive and confirm nothing activated, then flip the directive on and confirm the version installs. Four launches instead of nine, with both assertions intact. Co-Authored-By: Claude Fable 5 --- Example/e2etest/e2e/native-check.test.ts | 63 ++++++++---------------- 1 file changed, 20 insertions(+), 43 deletions(-) diff --git a/Example/e2etest/e2e/native-check.test.ts b/Example/e2etest/e2e/native-check.test.ts index 245af58c..126f4abe 100644 --- a/Example/e2etest/e2e/native-check.test.ts +++ b/Example/e2etest/e2e/native-check.test.ts @@ -69,30 +69,15 @@ async function waitForHash(hash: string) { .withTimeout(LABEL_TIMEOUT); } -async function resetToPackagedBundle() { - await waitFor(element(by.id('reset-to-packaged'))) - .toBeVisible() - .withTimeout(READY_TIMEOUT); - await element(by.id('reset-to-packaged')).tap(); - await waitFor(element(by.id('last-event'))) - .toHaveText('lastEvent: resetDone') - .withTimeout(15000); -} - describe('Native cold-start check', () => { beforeAll(async () => { - await device.launchApp({ delete: true, ...getDetoxLaunchArgs() }); - }); - - beforeEach(async () => { await setForceBoot(false); - await relaunchAppPreservingData(); - await waitForReady(); - await resetToPackagedBundle(); - await relaunchAppPreservingData(); + // A fresh install already sits on the packaged bundle, which is the state + // both halves below start from — no reset round-trip needed. + await device.launchApp({ delete: true, ...getDetoxLaunchArgs() }); + await device.setURLBlacklist([`.*:${LOCAL_UPDATE_PORT}.*`]); await waitForReady(); await waitForBundleLabel(LOCAL_UPDATE_LABELS.base); - await waitForHash(''); }); afterAll(async () => { @@ -101,39 +86,31 @@ describe('Native cold-start check', () => { await setForceBoot(false); }); - it('installs a forceBoot version without any JS check', async () => { - await setForceBoot(true); + // Both directions live in one test on purpose: every extra app launch costs + // real wall clock on the iOS simulator, and this suite shares a 40-minute + // job budget with the rest of the e2e. + it('activates only what the server forces, with no JS check involved', async () => { + // Without the directive the round may download, but with automatic checks + // off (checkStrategy: null) it must never activate on its own. + await new Promise((resolve) => setTimeout(resolve, NATIVE_CHECK_SETTLE_MS)); + await relaunchAppPreservingData(); + await waitForReady(); + await waitForBundleLabel(LOCAL_UPDATE_LABELS.base); + await waitForHash(''); - // This launch schedules the native round; the app itself never checks - // (checkStrategy is null and the check button is not tapped). + // Flip the directive before the launch whose round should honor it. + await setForceBoot(true); await relaunchAppPreservingData(); await waitForReady(); await waitForBundleLabel(LOCAL_UPDATE_LABELS.base); - await new Promise((resolve) => - setTimeout(resolve, NATIVE_CHECK_SETTLE_MS) - ); + await new Promise((resolve) => setTimeout(resolve, NATIVE_CHECK_SETTLE_MS)); - // Turn the directive off before observing, so the next launch cannot walk - // further along the update chain while the assertions run. + // Turn it off before observing, so the next launch cannot walk further + // along the update chain while the assertions run. await setForceBoot(false); await relaunchAppPreservingData(); await waitForReady(); await waitForBundleLabel(LOCAL_UPDATE_LABELS.full); await waitForHash(LOCAL_UPDATE_HASHES.full); }); - - it('leaves the app on the packaged bundle when nothing is forced', async () => { - // Same wait, no directive: with checkStrategy null the native check may - // download but must never activate on its own. - await relaunchAppPreservingData(); - await waitForReady(); - await new Promise((resolve) => - setTimeout(resolve, NATIVE_CHECK_SETTLE_MS) - ); - - await relaunchAppPreservingData(); - await waitForReady(); - await waitForBundleLabel(LOCAL_UPDATE_LABELS.base); - await waitForHash(''); - }); }); From d0492555542ac3c7374df12d74849154ee9d9fb1 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Wed, 12 Aug 2026 14:49:41 +0800 Subject: [PATCH 3/5] ci: run the native check e2e as its own iOS job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS e2e job had no room left: its last green run took 36 of its 40 allotted minutes, and the native check suite needs about three more on a healthy runner (186s measured in a retry). Every run since has died at the 40-minute wall — not because the suite fails (it passes on both Android matrices and passed on iOS in that retry) but because the job can no longer hold everything. The suite moves to e2e/native/ with its own runner config, mirroring how the harmony and smoke suites are already separated, and the iOS job gains a matrix so core and native run as independent legs with independent budgets and fail-fast off — a timeout in one no longer hides the other's verdict. New test files still land in core automatically; only this directory is carved out. Android keeps both suites in one job by invoking the second config after the first: that job finishes in about nine of its thirty minutes, so it has the budget iOS lacks and splitting it would only cost another emulator. Cost note: the built-app cache key covers package.json and ios/**, so a run that changes those (like this one) now builds twice in parallel instead of once. That is the price of the wall-clock win; runs that only touch JS restore the cache in both legs. Co-Authored-By: Claude Fable 5 --- .github/workflows/e2e_android.yml | 12 ++++++-- .github/workflows/e2e_ios.yml | 16 ++++++++-- Example/e2etest/e2e/jest.config.js | 4 ++- Example/e2etest/e2e/native/jest.config.js | 29 +++++++++++++++++++ .../e2e/{ => native}/native-check.test.ts | 2 +- 5 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 Example/e2etest/e2e/native/jest.config.js rename Example/e2etest/e2e/{ => native}/native-check.test.ts (99%) diff --git a/.github/workflows/e2e_android.yml b/.github/workflows/e2e_android.yml index 29afde1c..e5ee1dcc 100644 --- a/.github/workflows/e2e_android.yml +++ b/.github/workflows/e2e_android.yml @@ -141,7 +141,12 @@ jobs: # --maxWorkers 1 below). Unpin once a run proves 37.x stable. emulator-build: 15507667 emulator-boot-timeout: 900 - script: cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 + script: | + cd Example/e2etest + E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 + # 原生冷启动检测有自己的 runner 配置(见 e2e/native/jest.config.js); + # Android 这个 job 有预算,不必像 iOS 那样拆成独立 job + E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 --config e2e/native/jest.config.js - name: Upload Detox artifacts # cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场 @@ -373,7 +378,10 @@ jobs: # --maxWorkers 1 below). Unpin once a run proves 37.x stable. emulator-build: 15507667 emulator-boot-timeout: 900 - script: cd .e2e-rn077-oldarch/AwesomeProject && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 + script: | + cd .e2e-rn077-oldarch/AwesomeProject + E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 + E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 --config e2e/native/jest.config.js - name: Upload Detox artifacts # cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场 diff --git a/.github/workflows/e2e_ios.yml b/.github/workflows/e2e_ios.yml index 5838a21d..6c56762b 100644 --- a/.github/workflows/e2e_ios.yml +++ b/.github/workflows/e2e_ios.yml @@ -46,6 +46,18 @@ jobs: # 绿色基线 ~16 分钟;40 分钟覆盖最坏情况:一次 --retries 1 失败重试 # (失败用例会等满各级长超时) timeout-minutes: 40 + strategy: + # 两条 leg 各自独立成败:一条超时不该掩盖另一条的结论 + fail-fast: false + matrix: + include: + # 既有 suite(local-merge / bundle-hash),沿用默认 runner 配置 + - suite: core + jest-config: '' + # 原生冷启动检测:每条断言都要真实重启 app,单独跑才不会把 core + # 顶过 40 分钟(拆分前实测 core 已占 36 分) + - suite: native + jest-config: '--config e2e/native/jest.config.js' steps: - name: Checkout react-native-update uses: actions/checkout@v7 @@ -210,14 +222,14 @@ jobs: env: RNU_CLI_ROOT: ${{ github.workspace }}/react-native-update-cli RNU_E2E_SKIP_PREPARE: 'true' - run: cd Example/e2etest && E2E_PLATFORM=ios bunx detox test --configuration ios.sim.release --retries 1 + run: cd Example/e2etest && E2E_PLATFORM=ios bunx detox test --configuration ios.sim.release --retries 1 ${{ matrix.jest-config }} - name: Upload Detox artifacts # cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场 if: failure() || cancelled() uses: actions/upload-artifact@v7 with: - name: e2e-ios-detox-artifacts + name: e2e-ios-detox-artifacts-${{ matrix.suite }} path: Example/e2etest/artifacts if-no-files-found: ignore retention-days: 7 diff --git a/Example/e2etest/e2e/jest.config.js b/Example/e2etest/e2e/jest.config.js index be8e8b6b..74a100ab 100644 --- a/Example/e2etest/e2e/jest.config.js +++ b/Example/e2etest/e2e/jest.config.js @@ -8,7 +8,9 @@ const config = { testMatch: ['/e2e/**/*.test.ts'], // Harmony tests use their own runner (harmony.jest.config.js), not Detox. // The debug boot smoke has its own runner config too (smoke/jest.config.js). - testPathIgnorePatterns: ['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/e2e/harmony/', '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/e2e/smoke/'], + // The native cold-start suite (native/jest.config.js) runs as its own CI job: + // it costs several app launches, and the iOS job's budget has no room left. + testPathIgnorePatterns: ['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/e2e/harmony/', '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/e2e/smoke/', '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/e2e/native/'], testTimeout: 300000, maxWorkers: 1, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], diff --git a/Example/e2etest/e2e/native/jest.config.js b/Example/e2etest/e2e/native/jest.config.js new file mode 100644 index 00000000..a5a5d862 --- /dev/null +++ b/Example/e2etest/e2e/native/jest.config.js @@ -0,0 +1,29 @@ +const path = require('node:path'); + +const moduleDir = __dirname; + +// 原生冷启动检测专用 runner 配置。与 ../jest.config.js 的唯一区别是 testMatch: +// 该 suite 每条断言都要真实重启 app,单独成 CI job 后与其它 suite 并行,不再 +// 挤占 iOS 那个已经贴着 40 分钟上限的预算。本地更新 server 与 ppk 产物仍然 +// 需要,所以沿用同一套 globalSetup。 +/** @type {import('jest').Config} */ +const config = { + rootDir: '../..', + testMatch: ['/e2e/native/**/*.test.ts'], + testTimeout: 300000, + maxWorkers: 1, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + transform: { + '^.+\\.(js|jsx|ts|tsx)$': [ + 'babel-jest', + { configFile: path.resolve(moduleDir, '../../babel.config.js') }, + ], + }, + globalSetup: '/e2e/globalSetup.js', + globalTeardown: '/e2e/globalTeardown.js', + reporters: ['detox/runners/jest/reporter'], + testEnvironment: 'detox/runners/jest/testEnvironment', + verbose: true, +}; + +module.exports = config; diff --git a/Example/e2etest/e2e/native-check.test.ts b/Example/e2etest/e2e/native/native-check.test.ts similarity index 99% rename from Example/e2etest/e2e/native-check.test.ts rename to Example/e2etest/e2e/native/native-check.test.ts index 126f4abe..49d7e735 100644 --- a/Example/e2etest/e2e/native-check.test.ts +++ b/Example/e2etest/e2e/native/native-check.test.ts @@ -4,7 +4,7 @@ import { LOCAL_UPDATE_HASHES, LOCAL_UPDATE_LABELS, LOCAL_UPDATE_PORT, -} from './localUpdateConfig.ts'; +} from '../localUpdateConfig.ts'; // The native cold-start check (NATIVE_CHECKUPDATE_DESIGN §10) exists for one // scenario: the running update is broken badly enough that JS never starts, so From 700fe945e0692acbd39d7aa7cbd2d1d117553c70 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Wed, 12 Aug 2026 14:57:42 +0800 Subject: [PATCH 4/5] ci: give each Android detox command its own cd android-emulator-runner runs each line of a multi-line script in its own shell, so the standalone `cd` I added never reached the second detox invocation: it ran from the repository root and died with "Could not resolve jest package from the current working directory", taking both Android release jobs down. CodeRabbit flagged exactly this on the diff and CI confirmed it minutes later. Each command now carries its own cd. The iOS job also gains an explicit name, since a matrix with two keys was composing checks called "e2e-ios (native, --config e2e/native/jest.config.js)". Co-Authored-By: Claude Fable 5 --- .github/workflows/e2e_android.yml | 13 +++++++------ .github/workflows/e2e_ios.yml | 2 ++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e_android.yml b/.github/workflows/e2e_android.yml index e5ee1dcc..6f42b3a1 100644 --- a/.github/workflows/e2e_android.yml +++ b/.github/workflows/e2e_android.yml @@ -141,12 +141,13 @@ jobs: # --maxWorkers 1 below). Unpin once a run proves 37.x stable. emulator-build: 15507667 emulator-boot-timeout: 900 + # 每条命令自带 cd:android-emulator-runner 逐行起独立 shell, + # 单独一行的 cd 不会保持到下一条(实测 detox 会在仓库根找不到 jest) script: | - cd Example/e2etest - E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 + cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 # 原生冷启动检测有自己的 runner 配置(见 e2e/native/jest.config.js); # Android 这个 job 有预算,不必像 iOS 那样拆成独立 job - E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 --config e2e/native/jest.config.js + cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 --config e2e/native/jest.config.js - name: Upload Detox artifacts # cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场 @@ -378,10 +379,10 @@ jobs: # --maxWorkers 1 below). Unpin once a run proves 37.x stable. emulator-build: 15507667 emulator-boot-timeout: 900 + # 每条命令自带 cd,理由同上 script: | - cd .e2e-rn077-oldarch/AwesomeProject - E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 - E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 --config e2e/native/jest.config.js + cd .e2e-rn077-oldarch/AwesomeProject && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 + cd .e2e-rn077-oldarch/AwesomeProject && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 --config e2e/native/jest.config.js - name: Upload Detox artifacts # cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场 diff --git a/.github/workflows/e2e_ios.yml b/.github/workflows/e2e_ios.yml index 6c56762b..7fc18746 100644 --- a/.github/workflows/e2e_ios.yml +++ b/.github/workflows/e2e_ios.yml @@ -42,6 +42,8 @@ concurrency: jobs: e2e-ios: + # 不带 matrix 值全量拼名,否则 check 叫 "e2e-ios (native, --config e2e/…)" + name: e2e-ios (${{ matrix.suite }}) runs-on: macos-26 # 绿色基线 ~16 分钟;40 分钟覆盖最坏情况:一次 --retries 1 失败重试 # (失败用例会等满各级长超时) From a88be762231b7888e55b33b4bb080bcc1c90a632 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Wed, 12 Aug 2026 15:47:09 +0800 Subject: [PATCH 5/5] ci: stop killing healthy iOS work with timeouts that are too tight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two failures on the same run, both self-inflicted by our own caps rather than by anything under test: - The native leg died in "Prepare local update artifacts" with spawnSync ETIMEDOUT. runPushy capped every CLI call at 120s, and a cold Metro bundle on a macOS runner routinely needs longer — the banner printed at 07:05:54 and the process was killed at 07:07:52, making progress the whole time. Raised to 300s; it is a stuck-process guard, and the step/job timeouts still bound the happy path. - The core leg was cancelled at the 40-minute wall with its --retries 1 rerun still in flight (build was 1 min from cache, so the test phase alone ate 30). Budgeting less than one full retry makes retries useless. Raised to 60. Neither masks a product defect: bundleHash's 341s (vs ~2s on Android) is a pre-existing iOS runner pathology tracked separately. --- .github/workflows/e2e_ios.yml | 7 ++++--- Example/e2etest/scripts/prepare-local-update-artifacts.ts | 8 +++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e_ios.yml b/.github/workflows/e2e_ios.yml index 7fc18746..778b4bc9 100644 --- a/.github/workflows/e2e_ios.yml +++ b/.github/workflows/e2e_ios.yml @@ -45,9 +45,10 @@ jobs: # 不带 matrix 值全量拼名,否则 check 叫 "e2e-ios (native, --config e2e/…)" name: e2e-ios (${{ matrix.suite }}) runs-on: macos-26 - # 绿色基线 ~16 分钟;40 分钟覆盖最坏情况:一次 --retries 1 失败重试 - # (失败用例会等满各级长超时) - timeout-minutes: 40 + # 绿色基线 ~16 分钟。60 分钟覆盖最坏情况:一个用例等满 300s testTimeout, + # 再叠一次 --retries 1 全量重试。40 分钟不够——实测重试跑到一半被腰斩, + # 把"慢但能恢复"的运行变成 cancelled,等于白开了 retries。 + timeout-minutes: 60 strategy: # 两条 leg 各自独立成败:一条超时不该掩盖另一条的结论 fail-fast: false diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index 634b8e96..a96c010d 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -118,6 +118,12 @@ const { diffCommands } = require(path.join(cliRoot, 'lib/exports.js')) as { diffCommands: DiffCommandRunner; }; +// A cold Metro bundle on a GitHub macOS runner regularly needs well over two +// minutes, so the old 120s cap killed a healthy `pushy bundle` mid-flight +// (spawnSync ETIMEDOUT). This is a stuck-process guard, not a perf budget — +// the step and job timeouts already bound the happy path. +const PUSHY_TIMEOUT_MS = 300_000; + function runPushy(args: string[], cwd: string) { const cliNodeModules = path.join(cliRoot, 'node_modules'); const projectNodeModules = path.join(projectRoot, 'node_modules'); @@ -135,7 +141,7 @@ function runPushy(args: string[], cwd: string) { PUSHY_REGISTRY: localRegistry, RNU_API: localRegistry, }, - timeout: 120_000, + timeout: PUSHY_TIMEOUT_MS, }); if (result.error) {