diff --git a/.github/workflows/e2e_android.yml b/.github/workflows/e2e_android.yml index 29afde1c..6f42b3a1 100644 --- a/.github/workflows/e2e_android.yml +++ b/.github/workflows/e2e_android.yml @@ -141,7 +141,13 @@ 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 + # 每条命令自带 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 + # 原生冷启动检测有自己的 runner 配置(见 e2e/native/jest.config.js); + # Android 这个 job 有预算,不必像 iOS 那样拆成独立 job + 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 超时被杀的场景,否则超时时拿不到现场 @@ -373,7 +379,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 + # 每条命令自带 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 + 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 5838a21d..778b4bc9 100644 --- a/.github/workflows/e2e_ios.yml +++ b/.github/workflows/e2e_ios.yml @@ -42,10 +42,25 @@ concurrency: jobs: e2e-ios: + # 不带 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 + 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 +225,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/native-check.test.ts b/Example/e2etest/e2e/native/native-check.test.ts new file mode 100644 index 00000000..49d7e735 --- /dev/null +++ b/Example/e2etest/e2e/native/native-check.test.ts @@ -0,0 +1,116 @@ +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); +} + +describe('Native cold-start check', () => { + beforeAll(async () => { + await setForceBoot(false); + // 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); + }); + + afterAll(async () => { + // Never leave the directive on: the other suites drive activation + // themselves and would race a forced one. + await setForceBoot(false); + }); + + // 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(''); + + // 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)); + + // 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); + }); +}); 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/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) { 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 {