From 7df17270da9a52a52b32daff4fccade058b508fd Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Fri, 26 Jun 2026 14:23:13 +0800 Subject: [PATCH 01/27] feat: progress percentage, download retry, fallback chain tests, Cresc tests, globals.d.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Add progress percentage (0-100) to ProgressData via computeProgress utility - Add maxRetries option for automatic download retry on failure - Download progress callbacks now include computed progress field Tests: - Add comprehensive downloadUpdate fallback chain tests (diff→pdiff→full) - Add retry mechanism tests (success on retry, exhaust retries) - Add Cresc class tests (endpoints, locale, instanceof, custom server) - Add computeProgress unit tests Developer Experience: - Add src/globals.d.ts for __DEV__ type declaration - Type-hint progressData callbacks with ProgressData type --- src/__tests__/client.test.ts | 233 +++++++++++++++++++++++++++++++++++ src/__tests__/utils.test.ts | 29 ++++- src/client.ts | 135 +++++++++++--------- src/globals.d.ts | 2 + src/type.ts | 4 + src/utils.ts | 3 + 6 files changed, 349 insertions(+), 57 deletions(-) create mode 100644 src/globals.d.ts diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 0aa7e473..b631d0cc 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -364,3 +364,236 @@ describe('Pushy server config', () => { expect(restartApp).toHaveBeenCalled(); }); }); + +describe('downloadUpdate fallback chain', () => { + const setupDownloadMocks = ({ + downloadPatchFromPpk = mock(() => Promise.resolve()), + downloadPatchFromPackage = mock(() => Promise.resolve()), + downloadFullUpdate = mock(() => Promise.resolve()), + }: { + downloadPatchFromPpk?: ReturnType; + downloadPatchFromPackage?: ReturnType; + downloadFullUpdate?: ReturnType; + } = {}) => { + setupClientMocks({ + downloadPatchFromPpk, + downloadPatchFromPackage, + downloadFullUpdate, + }); + + // Override setTimeout to skip real backoff delays in retry tests + const realSetTimeout = globalThis.setTimeout.bind(globalThis); + globalThis.setTimeout = ((fn: (...args: any[]) => void, _ms?: number) => + realSetTimeout(fn, 0)) as unknown as typeof setTimeout; + + // Mock testUrls to return urls directly (skip actual HEAD ping) + mock.module('../utils', () => ({ + __esModule: true, + assertWeb: () => true, + computeProgress: (received: number, total: number) => + total > 0 ? Math.floor((received / total) * 100) : 0, + DEFAULT_FETCH_TIMEOUT_MS: 5000, + emptyObj: {}, + fetchWithTimeout: mock(() => Promise.resolve()), + info: mock(() => {}), + joinUrls: (paths: string[], fileName?: string) => + fileName ? paths.map(p => `${p}/${fileName}`) : undefined, + log: mock(() => {}), + noop: () => {}, + promiseAny: mock(() => Promise.resolve()), + testUrls: (urls?: string[]) => + Promise.resolve(urls?.[0] || null), + })); + + return { downloadPatchFromPpk, downloadPatchFromPackage, downloadFullUpdate }; + }; + + const updateInfo = { + update: true as const, + hash: 'new-hash', + diff: 'diff.ppk', + pdiff: 'pdiff.ppk', + full: 'full.ppk', + paths: ['https://cdn.example.com'], + name: 'v2.0', + description: 'test update', + }; + + test('uses diff when available', async () => { + const { downloadPatchFromPpk } = setupDownloadMocks(); + const { Pushy, sharedState } = await importFreshClient('dl-diff-ok'); + sharedState.downloadedHash = undefined; + const client = new Pushy({ appKey: 'demo-app' }); + + const hash = await client.downloadUpdate(updateInfo); + + expect(hash).toBe('new-hash'); + expect(downloadPatchFromPpk).toHaveBeenCalledTimes(1); + }); + + test('falls back to pdiff when diff fails', async () => { + const { downloadPatchFromPpk, downloadPatchFromPackage } = + setupDownloadMocks({ + downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))), + }); + const { Pushy, sharedState } = await importFreshClient('dl-fallback-pdiff'); + sharedState.downloadedHash = undefined; + const client = new Pushy({ appKey: 'demo-app' }); + + const hash = await client.downloadUpdate(updateInfo); + + expect(hash).toBe('new-hash'); + expect(downloadPatchFromPpk).toHaveBeenCalledTimes(1); + expect(downloadPatchFromPackage).toHaveBeenCalledTimes(1); + }); + + test('falls back to full when diff and pdiff fail', async () => { + const { downloadPatchFromPpk, downloadPatchFromPackage, downloadFullUpdate } = + setupDownloadMocks({ + downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))), + downloadPatchFromPackage: mock(() => + Promise.reject(Error('pdiff fail')), + ), + }); + const { Pushy, sharedState } = await importFreshClient('dl-fallback-full'); + sharedState.downloadedHash = undefined; + const client = new Pushy({ appKey: 'demo-app' }); + + const hash = await client.downloadUpdate(updateInfo); + + expect(hash).toBe('new-hash'); + expect(downloadPatchFromPpk).toHaveBeenCalledTimes(1); + expect(downloadPatchFromPackage).toHaveBeenCalledTimes(1); + expect(downloadFullUpdate).toHaveBeenCalledTimes(1); + }); + + test('throws when all download methods fail', async () => { + setupDownloadMocks({ + downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))), + downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), + downloadFullUpdate: mock(() => Promise.reject(Error('full fail'))), + }); + const { Pushy, sharedState } = await importFreshClient('dl-all-fail'); + sharedState.downloadedHash = undefined; + const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + + await expect(client.downloadUpdate(updateInfo)).rejects.toThrow( + 'error_full_patch_failed', + ); + }); + + test('retries download when maxRetries is set', async () => { + let callCount = 0; + const { downloadFullUpdate } = setupDownloadMocks({ + downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))), + downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), + downloadFullUpdate: mock(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(Error('full fail attempt 1')); + } + return Promise.resolve(); + }), + }); + const { Pushy, sharedState } = await importFreshClient('dl-retry-ok'); + sharedState.downloadedHash = undefined; + const client = new Pushy({ appKey: 'demo-app', maxRetries: 2 }); + + const hash = await client.downloadUpdate(updateInfo); + + expect(hash).toBe('new-hash'); + expect(downloadFullUpdate).toHaveBeenCalledTimes(2); + }); + + test('defaults to 3 retries when maxRetries is not set', async () => { + const { downloadFullUpdate } = setupDownloadMocks({ + downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))), + downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), + downloadFullUpdate: mock(() => Promise.reject(Error('full fail'))), + }); + const { Pushy, sharedState } = await importFreshClient('dl-default-retries'); + sharedState.downloadedHash = undefined; + const client = new Pushy({ appKey: 'demo-app' }); + + await expect(client.downloadUpdate(updateInfo)).rejects.toThrow(); + // 1 initial + 3 retries = 4 calls + expect(downloadFullUpdate).toHaveBeenCalledTimes(4); + }); + + test('exhausts retries and throws on persistent failure', async () => { + setupDownloadMocks({ + downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))), + downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), + downloadFullUpdate: mock(() => Promise.reject(Error('full fail'))), + }); + const { Pushy, sharedState } = await importFreshClient('dl-retry-exhaust'); + sharedState.downloadedHash = undefined; + const client = new Pushy({ appKey: 'demo-app', maxRetries: 2 }); + + await expect(client.downloadUpdate(updateInfo)).rejects.toThrow( + 'error_full_patch_failed', + ); + }); +}); + +describe('Cresc class', () => { + test('uses Cresc server endpoints', async () => { + setupClientMocks(); + + const { Cresc } = await importFreshClient('cresc-endpoints'); + const client = new Cresc({ appKey: 'demo-app' }); + + expect(client.getConfiguredCheckEndpoints()).toEqual([ + 'https://api.cresc.dev', + 'https://api.cresc.app', + ]); + }); + + test('defaults locale to en for Cresc', async () => { + setupClientMocks(); + // Override i18n mock AFTER setupClientMocks to avoid being overwritten + const setLocale = mock(() => {}); + mock.module('../i18n', () => ({ + default: { + t: (key: string) => key, + setLocale, + }, + })); + + const { Cresc } = await importFreshClient('cresc-locale'); + const client = new Cresc({ appKey: 'demo-app' }); + + expect(client.clientType).toBe('Cresc'); + expect(setLocale).toHaveBeenCalledWith('en'); + }); + + test('Cresc is instance of Pushy', async () => { + setupClientMocks(); + + const { Cresc, Pushy } = await importFreshClient('cresc-instanceof'); + const client = new Cresc({ appKey: 'demo-app' }); + + expect(client).toBeInstanceOf(Pushy); + expect(client).toBeInstanceOf(Cresc); + }); + + test('Cresc custom server overrides default endpoints', async () => { + setupClientMocks(); + + const { Cresc } = await importFreshClient('cresc-custom-server'); + const client = new Cresc({ + appKey: 'demo-app', + server: { + main: ['https://custom.example.com'], + queryUrls: ['https://q.example.com'], + }, + }); + + expect(client.getConfiguredCheckEndpoints()).toEqual([ + 'https://custom.example.com', + ]); + expect(client.options.server?.queryUrls).toEqual([ + 'https://q.example.com', + ]); + }); +}); diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 732f29e6..cf032c41 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -16,7 +16,7 @@ mock.module('../i18n', () => { }; }); -import { joinUrls } from '../utils'; +import { joinUrls, computeProgress } from '../utils'; describe('joinUrls', () => { test('returns undefined when fileName is not provided', () => { @@ -71,3 +71,30 @@ describe('joinUrls', () => { ]); }); }); + +describe('computeProgress', () => { + test('returns 0 when total is 0', () => { + expect(computeProgress(0, 0)).toBe(0); + }); + + test('returns 0 when received is 0', () => { + expect(computeProgress(0, 1000)).toBe(0); + }); + + test('returns 100 when received equals total', () => { + expect(computeProgress(1000, 1000)).toBe(100); + }); + + test('returns 50 for half progress', () => { + expect(computeProgress(500, 1000)).toBe(50); + }); + + test('floors fractional percentages', () => { + expect(computeProgress(1, 3)).toBe(33); + expect(computeProgress(2, 3)).toBe(66); + }); + + test('handles large numbers', () => { + expect(computeProgress(50_000_000, 100_000_000)).toBe(50); + }); +}); diff --git a/src/client.ts b/src/client.ts index 6149c60f..70180f57 100644 --- a/src/client.ts +++ b/src/client.ts @@ -28,6 +28,7 @@ import { } from './type'; import { assertWeb, + computeProgress, DEFAULT_FETCH_TIMEOUT_MS, emptyObj, fetchWithTimeout, @@ -491,13 +492,19 @@ export class Pushy { } const patchStartTime = Date.now(); if (onDownloadProgress) { + const wrapProgress = (data: ProgressData) => { + onDownloadProgress({ + ...data, + progress: computeProgress(data.received, data.total), + }); + }; // @ts-expect-error harmony not in existing platforms if (Platform.OS === 'harmony') { sharedState.progressHandlers[hash] = DeviceEventEmitter.addListener( 'RCTPushyDownloadProgress', - progressData => { + (progressData: ProgressData) => { if (progressData.hash === hash) { - onDownloadProgress(progressData); + wrapProgress(progressData); } }, ); @@ -505,54 +512,47 @@ export class Pushy { sharedState.progressHandlers[hash] = pushyNativeEventEmitter.addListener( 'RCTPushyDownloadProgress', - progressData => { + (progressData: ProgressData) => { if (progressData.hash === hash) { - onDownloadProgress(progressData); + wrapProgress(progressData); } }, ); } } + const maxRetries = this.options.maxRetries ?? 3; let succeeded = ''; - this.report({ - type: 'downloading', - data: { - newVersion: hash, - }, - }); let lastError: any; const errorMessages: string[] = []; - const diffUrl = await testUrls(joinUrls(paths, diff)); - if (diffUrl && !__DEV__) { - log('downloading diff'); - try { - await PushyModule.downloadPatchFromPpk({ - updateUrl: diffUrl, - hash, - originHash: currentVersion, - }); - succeeded = 'diff'; - } catch (e: any) { - const errorMessage = this.t('error_diff_failed', { - message: e.message, - }); - errorMessages.push(errorMessage); - lastError = Error(errorMessage); - log(errorMessage); + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (attempt > 0) { + const backoffMs = Math.min(1000 * 2 ** (attempt - 1), 10000); + log(`retry attempt ${attempt}/${maxRetries}, waiting ${backoffMs}ms`); + await new Promise(r => setTimeout(r, backoffMs)); + errorMessages.length = 0; + lastError = undefined; + succeeded = ''; } - } - if (!succeeded) { - const pdiffUrl = await testUrls(joinUrls(paths, pdiff)); - if (pdiffUrl && !__DEV__) { - log('downloading pdiff'); + this.report({ + type: 'downloading', + data: { + newVersion: hash, + attempt, + }, + }); + const diffUrl = await testUrls(joinUrls(paths, diff)); + if (diffUrl && !__DEV__) { + log('downloading diff'); try { - await PushyModule.downloadPatchFromPackage({ - updateUrl: pdiffUrl, + await PushyModule.downloadPatchFromPpk({ + updateUrl: diffUrl, hash, + originHash: currentVersion, }); - succeeded = 'pdiff'; + succeeded = 'diff'; } catch (e: any) { - const errorMessage = this.t('error_pdiff_failed', { + const errorMessage = this.t('error_diff_failed', { message: e.message, }); errorMessages.push(errorMessage); @@ -560,28 +560,51 @@ export class Pushy { log(errorMessage); } } - } - if (!succeeded) { - const fullUrl = await testUrls(joinUrls(paths, full)); - if (fullUrl) { - log('downloading full patch'); - try { - await PushyModule.downloadFullUpdate({ - updateUrl: fullUrl, - hash, - }); + if (!succeeded) { + const pdiffUrl = await testUrls(joinUrls(paths, pdiff)); + if (pdiffUrl && !__DEV__) { + log('downloading pdiff'); + try { + await PushyModule.downloadPatchFromPackage({ + updateUrl: pdiffUrl, + hash, + }); + succeeded = 'pdiff'; + } catch (e: any) { + const errorMessage = this.t('error_pdiff_failed', { + message: e.message, + }); + errorMessages.push(errorMessage); + lastError = Error(errorMessage); + log(errorMessage); + } + } + } + if (!succeeded) { + const fullUrl = await testUrls(joinUrls(paths, full)); + if (fullUrl) { + log('downloading full patch'); + try { + await PushyModule.downloadFullUpdate({ + updateUrl: fullUrl, + hash, + }); + succeeded = 'full'; + } catch (e: any) { + const errorMessage = this.t('error_full_patch_failed', { + message: e.message, + }); + errorMessages.push(errorMessage); + lastError = Error(errorMessage); + log(errorMessage); + } + } else if (__DEV__) { + log(this.t('dev_incremental_update_disabled')); succeeded = 'full'; - } catch (e: any) { - const errorMessage = this.t('error_full_patch_failed', { - message: e.message, - }); - errorMessages.push(errorMessage); - lastError = Error(errorMessage); - log(errorMessage); } - } else if (__DEV__) { - log(this.t('dev_incremental_update_disabled')); - succeeded = 'full'; + } + if (succeeded) { + break; } } if (sharedState.progressHandlers[hash]) { diff --git a/src/globals.d.ts b/src/globals.d.ts new file mode 100644 index 00000000..e8a0d1d7 --- /dev/null +++ b/src/globals.d.ts @@ -0,0 +1,2 @@ +/** React Native dev mode flag, injected by Metro bundler */ +declare const __DEV__: boolean; diff --git a/src/type.ts b/src/type.ts index cf498b34..96abc5b4 100644 --- a/src/type.ts +++ b/src/type.ts @@ -33,6 +33,8 @@ export interface ProgressData { hash: string; received: number; total: number; + /** Download progress percentage (0-100), computed as Math.floor(received / total * 100). Only populated in downloadUpdate callbacks. */ + progress?: number; } // 用于描述一次检查结束后的最终状态,便于业务侧感知成功、跳过或失败 @@ -119,6 +121,8 @@ export interface ClientOptions { ) => Promise | boolean | void; onPackageExpired?: (info: CheckResult) => Promise | boolean; overridePackageVersion?: string; + /** Maximum number of retry attempts for failed downloads (default: 3) */ + maxRetries?: number; } export interface UpdateTestPayload { diff --git a/src/utils.ts b/src/utils.ts index 74301449..df193820 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -108,6 +108,9 @@ export const assertWeb = () => { return true; }; +export const computeProgress = (received: number, total: number): number => + total > 0 ? Math.floor((received / total) * 100) : 0; + export const fetchWithTimeout = ( url: string, params: Parameters[1], From 82b0cbddb7a25413aa173584dacabf9bf1bcd294 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 12:05:58 +0800 Subject: [PATCH 02/27] fix(e2e): resolve CLI entry from package.json instead of hardcoded lib/index.js CLI refactored in ebd5a45 moved command dispatch from index.ts to bin.ts, but e2e script still pointed at lib/index.js which only re-exports. pushy bundle exited with code 0 without producing .ppk files. Read the bin field from the CLI's package.json for forward-compatibility. --- .../scripts/prepare-local-update-artifacts.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index b8c7dc62..d8006604 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -41,8 +41,7 @@ function resolveCliRoot() { ].filter(Boolean) as string[]; for (const candidate of candidates) { - const cliEntry = path.join(candidate, 'lib/index.js'); - if (fs.existsSync(cliEntry)) { + if (fs.existsSync(path.join(candidate, 'package.json'))) { return candidate; } } @@ -53,7 +52,19 @@ function resolveCliRoot() { } const cliRoot = resolveCliRoot(); -const cliEntry = path.join(cliRoot, 'lib/index.js'); +const cliPkg = JSON.parse( + fs.readFileSync(path.join(cliRoot, 'package.json'), 'utf8'), +); +const binRelative = + typeof cliPkg.bin === 'string' + ? cliPkg.bin + : cliPkg.bin?.pushy ?? Object.values(cliPkg.bin ?? {})[0]; +if (!binRelative) { + throw new Error( + `react-native-update-cli package.json has no bin entry. Tried: ${cliRoot}`, + ); +} +const cliEntry = path.join(cliRoot, binRelative); const { diffCommands } = require(path.join(cliRoot, 'lib/diff.js')) as { diffCommands: DiffCommandRunner; }; From 1f715b42f013de69b6c970a61db02d22a967429c Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 12:52:57 +0800 Subject: [PATCH 03/27] fix(e2e): add verbose logging to prepare script, inline diff calls The prepare step was exiting with code 0 but never generating diffs or writing manifest.json. Added step-by-step logging to diagnose: - 'Generating ppk diff...' / 'Ppk diff generated.' - 'Generating package diff...' / 'Package diff generated.' - 'Manifest written to ...' - 'prepare-local-update-artifacts completed successfully.' Inlined diff calls (removed generatePpkDiff/generateAndroidPackageDiff wrapper functions) for more direct error propagation. --- .../scripts/prepare-local-update-artifacts.ts | 70 +++++++++---------- 1 file changed, 33 insertions(+), 37 deletions(-) diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index d8006604..65cc443b 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -159,32 +159,6 @@ function bundleTo(entryFile: string, outputFile: string) { ); } -async function generatePpkDiff(origin: string, next: string, output: string) { - const customDiff = ensureHdiffModule(); - await diffCommands.hdiff({ - args: [origin, next], - options: { - output, - customDiff, - }, - }); -} - -async function generateAndroidPackageDiff( - apkPath: string, - next: string, - output: string, -) { - const customDiff = ensureHdiffModule(); - await diffCommands.hdiffFromApk({ - args: [apkPath, next], - options: { - output, - customDiff, - }, - }); -} - async function main() { prepareDir(); @@ -196,7 +170,18 @@ async function main() { bundleTo('e2e/entry.v1.ts', v1); bundleTo('e2e/entry.v2.ts', v2); bundleTo('e2e/entry.v3.ts', v3); - await generatePpkDiff(v1, v2, ppkDiff); + + const customDiff = ensureHdiffModule(); + + console.log('Generating ppk diff...'); + await diffCommands.hdiff({ + args: [v1, v2], + options: { + output: ppkDiff, + customDiff, + }, + }); + console.log('Ppk diff generated.'); if (platform === 'android') { const apkPath = path.join( @@ -211,15 +196,20 @@ async function main() { } fs.copyFileSync(apkPath, path.join(artifactsDir, LOCAL_UPDATE_FILES.apk)); - await generateAndroidPackageDiff( - apkPath, - v3, - path.join(artifactsDir, LOCAL_UPDATE_FILES.packageDiff), - ); + console.log('Generating package diff...'); + await diffCommands.hdiffFromApk({ + args: [apkPath, v3], + options: { + output: path.join(artifactsDir, LOCAL_UPDATE_FILES.packageDiff), + customDiff, + }, + }); + console.log('Package diff generated.'); } + const manifestPath = path.join(artifactsDir, 'manifest.json'); fs.writeFileSync( - path.join(artifactsDir, 'manifest.json'), + manifestPath, JSON.stringify( { platform, @@ -232,9 +222,15 @@ async function main() { 2, ), ); + console.log(`Manifest written to ${manifestPath}`); } -main().catch(error => { - console.error(error); - process.exit(1); -}); +main() + .then(() => { + console.log('prepare-local-update-artifacts completed successfully.'); + }) + .catch(error => { + console.error('prepare-local-update-artifacts failed:'); + console.error(error); + process.exit(1); + }); From 1152e8c738995cfeed48bf5931d8132bd8fd2ef9 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 13:06:35 +0800 Subject: [PATCH 04/27] fix(e2e): use CLI subprocess for diff instead of in-process require The in-process require of lib/diff.js + calling diffCommands.hdiff() was hanging because yauzl's async entry callbacks don't keep the Node.js event loop alive properly when called from a script that has no other active handles. Switched to running diff commands as CLI subprocesses via runPushy(), matching the pattern already used for bundleTo(). Each diff command runs in its own node process with a proper event loop. Also removed the DiffCommandRunner type and lib/diff.js require since they're no longer needed. --- .../scripts/prepare-local-update-artifacts.ts | 86 ++++--------------- 1 file changed, 15 insertions(+), 71 deletions(-) diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index 65cc443b..75237d9d 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -9,23 +9,6 @@ import { LOCAL_UPDATE_LABELS, } from '../e2e/localUpdateConfig'; -type DiffCommandRunner = { - hdiff: (options: { - args: [string, string]; - options: { - output: string; - customDiff: (...args: unknown[]) => unknown; - }; - }) => Promise; - hdiffFromApk: (options: { - args: [string, string]; - options: { - output: string; - customDiff: (...args: unknown[]) => unknown; - }; - }) => Promise; -}; - const projectRoot = process.cwd(); const platform = process.env.E2E_PLATFORM || 'ios'; const artifactsRoot = path.join(projectRoot, '.e2e-artifacts'); @@ -65,9 +48,6 @@ if (!binRelative) { ); } const cliEntry = path.join(cliRoot, binRelative); -const { diffCommands } = require(path.join(cliRoot, 'lib/diff.js')) as { - diffCommands: DiffCommandRunner; -}; if (!['ios', 'android'].includes(platform)) { throw new Error(`Unsupported E2E_PLATFORM: ${platform}`); @@ -100,41 +80,6 @@ function runPushy(args: string[], cwd: string) { } } -function ensureHdiffModule() { - const modulePath = path.join(cliRoot, 'node_modules/node-hdiffpatch'); - if (!fs.existsSync(modulePath)) { - console.log('node-hdiffpatch not found, installing...'); - const result = spawnSync( - 'npm', - [ - 'install', - '--no-save', - '--package-lock=false', - '--legacy-peer-deps', - 'node-hdiffpatch', - ], - { - cwd: cliRoot, - stdio: 'inherit', - env: process.env, - }, - ); - - if (result.status !== 0) { - throw new Error( - `npm install node-hdiffpatch failed with exit code ${result.status}`, - ); - } - } - if (!fs.existsSync(modulePath)) { - throw new Error(`Failed to install node-hdiffpatch under: ${cliRoot}`); - } - const hdiffModule = require(modulePath) as { - diff?: (...args: unknown[]) => unknown; - } & ((...args: unknown[]) => unknown); - return hdiffModule.diff || hdiffModule; -} - function prepareDir() { fs.rmSync(artifactsDir, { recursive: true, force: true }); fs.mkdirSync(artifactsDir, { recursive: true }); @@ -171,16 +116,11 @@ async function main() { bundleTo('e2e/entry.v2.ts', v2); bundleTo('e2e/entry.v3.ts', v3); - const customDiff = ensureHdiffModule(); - console.log('Generating ppk diff...'); - await diffCommands.hdiff({ - args: [v1, v2], - options: { - output: ppkDiff, - customDiff, - }, - }); + runPushy( + ['hdiff', v1, v2, '--output', ppkDiff, '--no-interactive'], + projectRoot, + ); console.log('Ppk diff generated.'); if (platform === 'android') { @@ -197,13 +137,17 @@ async function main() { fs.copyFileSync(apkPath, path.join(artifactsDir, LOCAL_UPDATE_FILES.apk)); console.log('Generating package diff...'); - await diffCommands.hdiffFromApk({ - args: [apkPath, v3], - options: { - output: path.join(artifactsDir, LOCAL_UPDATE_FILES.packageDiff), - customDiff, - }, - }); + runPushy( + [ + 'hdiffFromApk', + apkPath, + v3, + '--output', + path.join(artifactsDir, LOCAL_UPDATE_FILES.packageDiff), + '--no-interactive', + ], + projectRoot, + ); console.log('Package diff generated.'); } From 8e5579d6092c3c0a48a2f8714c07aec21b8c90ec Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 13:14:05 +0800 Subject: [PATCH 05/27] fix(e2e): install node-hdiffpatch to project dir for CLI subprocess The CLI's loadModule('node-hdiffpatch') searches from process.cwd(), which is the project root when running as a subprocess. Install the module to the project's node_modules instead of the CLI's so it can be found. This ensures pushy hdiff/hdiffFromApk commands can resolve the native diff module when run as CLI subprocesses. --- .../scripts/prepare-local-update-artifacts.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index 75237d9d..dea1614d 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -80,6 +80,37 @@ function runPushy(args: string[], cwd: string) { } } +function ensureHdiffModule() { + const modulePath = path.join(projectRoot, 'node_modules/node-hdiffpatch'); + if (!fs.existsSync(modulePath)) { + console.log('node-hdiffpatch not found, installing...'); + const result = spawnSync( + 'npm', + [ + 'install', + '--no-save', + '--package-lock=false', + '--legacy-peer-deps', + 'node-hdiffpatch', + ], + { + cwd: projectRoot, + stdio: 'inherit', + env: process.env, + }, + ); + + if (result.status !== 0) { + throw new Error( + `npm install node-hdiffpatch failed with exit code ${result.status}`, + ); + } + } + if (!fs.existsSync(modulePath)) { + throw new Error(`Failed to install node-hdiffpatch under: ${projectRoot}`); + } +} + function prepareDir() { fs.rmSync(artifactsDir, { recursive: true, force: true }); fs.mkdirSync(artifactsDir, { recursive: true }); @@ -116,6 +147,8 @@ async function main() { bundleTo('e2e/entry.v2.ts', v2); bundleTo('e2e/entry.v3.ts', v3); + ensureHdiffModule(); + console.log('Generating ppk diff...'); runPushy( ['hdiff', v1, v2, '--output', ppkDiff, '--no-interactive'], From 1f32fa5c4044e5e8ca4b703ecb1e0612a9477a0e Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 13:22:15 +0800 Subject: [PATCH 06/27] fix(ci): trust wix/brew tap before installing applesimutils macOS 26 runner requires explicit tap trust for Homebrew security. Fixes: Refusing to load formula wix/brew/applesimutils from untrusted tap --- .github/workflows/e2e_ios.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e_ios.yml b/.github/workflows/e2e_ios.yml index e9fd70ad..2f094c2b 100644 --- a/.github/workflows/e2e_ios.yml +++ b/.github/workflows/e2e_ios.yml @@ -80,7 +80,7 @@ jobs: xcode-version: '26.4' - name: Install applesimutils - run: HOMEBREW_NO_AUTO_UPDATE=1 brew tap wix/brew && HOMEBREW_NO_AUTO_UPDATE=1 brew install applesimutils + run: brew trust wix/brew && HOMEBREW_NO_AUTO_UPDATE=1 brew install wix/brew/applesimutils - name: Install e2etest dependencies run: cd Example/e2etest && bun install --frozen-lockfile From d45a060f42f21acb351170da3775d10ec24ce17c Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 14:00:57 +0800 Subject: [PATCH 07/27] debug(e2e): add logging to diagnose warmServer artifact 404 The local e2e server appears to start (health check passes) but returns 404 for artifact files. Added: - stdout/stderr capture from bun server subprocess - spawn error handler - HTTP response status logging in waitForRequestReady --- Example/e2etest/e2e/globalSetup.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Example/e2etest/e2e/globalSetup.ts b/Example/e2etest/e2e/globalSetup.ts index 49894916..555c879e 100644 --- a/Example/e2etest/e2e/globalSetup.ts +++ b/Example/e2etest/e2e/globalSetup.ts @@ -100,12 +100,21 @@ function startServer() { const child = spawn('bun', [serverScript], { cwd: projectRoot, detached: true, - stdio: 'ignore', + stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, E2E_ASSET_PORT: String(LOCAL_UPDATE_PORT), }, }); + child.stdout?.on('data', (data: Buffer) => { + console.log(`[e2e-server] ${data.toString().trim()}`); + }); + child.stderr?.on('data', (data: Buffer) => { + console.error(`[e2e-server] ${data.toString().trim()}`); + }); + child.on('error', (err) => { + console.error(`[e2e-server] spawn error: ${err}`); + }); child.unref(); fs.writeFileSync(pidFile, String(child.pid)); } @@ -162,8 +171,9 @@ async function waitForRequestReady( if (response.ok) { return; } - } catch { - // Keep polling until the timeout expires. + console.log(`[warmServer] ${label}: status ${response.status}`); + } catch (err) { + console.log(`[warmServer] ${label}: fetch error: ${err}`); } if (Date.now() - start > timeoutMs) { From e1ee479f018d652acc5d264454d2fdc3f96f0fe3 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 14:12:07 +0800 Subject: [PATCH 08/27] debug(e2e): log resolved path and file existence in e2e server --- Example/e2etest/scripts/local-e2e-server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/Example/e2etest/scripts/local-e2e-server.ts b/Example/e2etest/scripts/local-e2e-server.ts index c234dcee..ff1a3d31 100644 --- a/Example/e2etest/scripts/local-e2e-server.ts +++ b/Example/e2etest/scripts/local-e2e-server.ts @@ -135,6 +135,7 @@ const server = Bun.serve({ if (url.pathname.startsWith('/artifacts/')) { const filePath = safeResolve(url.pathname); + console.log(`[server] ${url.pathname} -> ${filePath}, exists: ${filePath ? fs.existsSync(filePath) : 'null'}`); if ( !filePath || !fs.existsSync(filePath) || From 47110ed6eb4c55cffb716363fa9b132caa3c067a Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 14:31:27 +0800 Subject: [PATCH 09/27] chore(e2e): remove debug logging from globalSetup and local-e2e-server Root cause was CLI's loadModule not checking NODE_PATH dirs. Fixed in react-native-update-cli@151ea0f. --- Example/e2etest/e2e/globalSetup.ts | 16 +++------------- Example/e2etest/scripts/local-e2e-server.ts | 1 - 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/Example/e2etest/e2e/globalSetup.ts b/Example/e2etest/e2e/globalSetup.ts index 555c879e..49894916 100644 --- a/Example/e2etest/e2e/globalSetup.ts +++ b/Example/e2etest/e2e/globalSetup.ts @@ -100,21 +100,12 @@ function startServer() { const child = spawn('bun', [serverScript], { cwd: projectRoot, detached: true, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: 'ignore', env: { ...process.env, E2E_ASSET_PORT: String(LOCAL_UPDATE_PORT), }, }); - child.stdout?.on('data', (data: Buffer) => { - console.log(`[e2e-server] ${data.toString().trim()}`); - }); - child.stderr?.on('data', (data: Buffer) => { - console.error(`[e2e-server] ${data.toString().trim()}`); - }); - child.on('error', (err) => { - console.error(`[e2e-server] spawn error: ${err}`); - }); child.unref(); fs.writeFileSync(pidFile, String(child.pid)); } @@ -171,9 +162,8 @@ async function waitForRequestReady( if (response.ok) { return; } - console.log(`[warmServer] ${label}: status ${response.status}`); - } catch (err) { - console.log(`[warmServer] ${label}: fetch error: ${err}`); + } catch { + // Keep polling until the timeout expires. } if (Date.now() - start > timeoutMs) { diff --git a/Example/e2etest/scripts/local-e2e-server.ts b/Example/e2etest/scripts/local-e2e-server.ts index ff1a3d31..c234dcee 100644 --- a/Example/e2etest/scripts/local-e2e-server.ts +++ b/Example/e2etest/scripts/local-e2e-server.ts @@ -135,7 +135,6 @@ const server = Bun.serve({ if (url.pathname.startsWith('/artifacts/')) { const filePath = safeResolve(url.pathname); - console.log(`[server] ${url.pathname} -> ${filePath}, exists: ${filePath ? fs.existsSync(filePath) : 'null'}`); if ( !filePath || !fs.existsSync(filePath) || From 383447726bc62878671eafc578e98efec8198d44 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 16:20:12 +0800 Subject: [PATCH 10/27] debug(e2e): add diagnostics for warmServer artifact 404 - prepare script: verify ppk.patch and apk.patch exist after generation - server: log 404 responses with resolved path and existence check - server: add /debug/artifacts endpoint listing all files - globalSetup: capture server stdout/stderr to .server.log - globalSetup: list artifacts dir and query debug endpoint before warmServer --- Example/e2etest/e2e/globalSetup.ts | 27 ++++++++++++++++++- Example/e2etest/scripts/local-e2e-server.ts | 19 +++++++++++++ .../scripts/prepare-local-update-artifacts.ts | 11 ++++++-- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/Example/e2etest/e2e/globalSetup.ts b/Example/e2etest/e2e/globalSetup.ts index 49894916..f937d5fe 100644 --- a/Example/e2etest/e2e/globalSetup.ts +++ b/Example/e2etest/e2e/globalSetup.ts @@ -97,10 +97,13 @@ function startServer() { const serverScript = path.join(projectRoot, 'scripts/local-e2e-server.ts'); fs.mkdirSync(artifactsRoot, { recursive: true }); + const logFile = path.join(artifactsRoot, '.server.log'); + const logFd = fs.openSync(logFile, 'w'); + const child = spawn('bun', [serverScript], { cwd: projectRoot, detached: true, - stdio: 'ignore', + stdio: ['ignore', logFd, logFd], env: { ...process.env, E2E_ASSET_PORT: String(LOCAL_UPDATE_PORT), @@ -108,6 +111,7 @@ function startServer() { }); child.unref(); fs.writeFileSync(pidFile, String(child.pid)); + // Keep logFd open for the server lifetime — it will be cleaned up on process exit. } function waitForServer(timeoutMs = 30000) { @@ -235,6 +239,27 @@ async function globalSetup() { } startServer(); await waitForServer(); + + // Diagnostic: list artifacts via debug endpoint and local filesystem + const origin = `http://127.0.0.1:${LOCAL_UPDATE_PORT}`; + try { + const debugRes = await fetch(`${origin}/debug/artifacts`); + if (debugRes.ok) { + console.log('[globalSetup] Server artifacts:', await debugRes.text()); + } else { + console.log('[globalSetup] Debug endpoint returned:', debugRes.status); + } + } catch (e) { + console.log('[globalSetup] Debug endpoint error:', e); + } + const localArtifactsDir = path.join(artifactsRoot, platform); + if (fs.existsSync(localArtifactsDir)) { + const files = fs.readdirSync(localArtifactsDir); + console.log(`[globalSetup] Local artifacts dir (${localArtifactsDir}):`, files); + } else { + console.log(`[globalSetup] Local artifacts dir MISSING: ${localArtifactsDir}`); + } + await warmServer(platform as 'ios' | 'android'); await detoxGlobalSetup(); } diff --git a/Example/e2etest/scripts/local-e2e-server.ts b/Example/e2etest/scripts/local-e2e-server.ts index c234dcee..e06dedc4 100644 --- a/Example/e2etest/scripts/local-e2e-server.ts +++ b/Example/e2etest/scripts/local-e2e-server.ts @@ -140,6 +140,9 @@ const server = Bun.serve({ !fs.existsSync(filePath) || fs.statSync(filePath).isDirectory() ) { + console.error( + `[404] ${url.pathname} -> ${filePath} (exists: ${filePath ? fs.existsSync(filePath) : 'null'})`, + ); return new Response('not found', { status: 404 }); } @@ -156,6 +159,22 @@ const server = Bun.serve({ }); } + if (url.pathname === '/debug/artifacts') { + const entries = fs.readdirSync(artifactsRoot, { recursive: true }); + const listing = entries.map(e => { + const fullPath = path.join(artifactsRoot, String(e)); + try { + const stat = fs.statSync(fullPath); + return `${e} (${stat.size} bytes, ${stat.isDirectory() ? 'dir' : 'file'})`; + } catch { + return `${e} (stat failed)`; + } + }); + return new Response(JSON.stringify({ artifactsRoot, entries: listing }, null, 2), { + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response('not found', { status: 404 }); }, }); diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index dea1614d..1603da41 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -154,7 +154,10 @@ async function main() { ['hdiff', v1, v2, '--output', ppkDiff, '--no-interactive'], projectRoot, ); - console.log('Ppk diff generated.'); + if (!fs.existsSync(ppkDiff)) { + throw new Error(`ppk diff file not found after generation: ${ppkDiff}`); + } + console.log(`Verified ppk diff: ${ppkDiff} (${fs.statSync(ppkDiff).size} bytes)`); if (platform === 'android') { const apkPath = path.join( @@ -181,7 +184,11 @@ async function main() { ], projectRoot, ); - console.log('Package diff generated.'); + const packageDiffPath = path.join(artifactsDir, LOCAL_UPDATE_FILES.packageDiff); + if (!fs.existsSync(packageDiffPath)) { + throw new Error(`Package diff file not found after generation: ${packageDiffPath}`); + } + console.log(`Verified package diff: ${packageDiffPath} (${fs.statSync(packageDiffPath).size} bytes)`); } const manifestPath = path.join(artifactsDir, 'manifest.json'); From e4597e9da1ea3d595594fb95ec5bc19053e3876a Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 16:38:34 +0800 Subject: [PATCH 11/27] fix(e2e): add project node_modules to NODE_PATH for pushy hdiff Root cause: loadModule('node-hdiffpatch') in CLI's lib/diff.js uses require.resolve with paths=['.', ...NODE_PATH]. The '.' resolves to cliRoot/lib/, and NODE_PATH only had cliRoot/node_modules/. But node-hdiffpatch was installed in projectRoot/node_modules/, which was not in the search path. The module silently failed to load, the diff command exited without error (or with a swallowed error), and the output file was never created. Fix: add projectRoot/node_modules to NODE_PATH so the CLI can find node-hdiffpatch installed in the project's node_modules. --- .../e2etest/scripts/prepare-local-update-artifacts.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index 1603da41..cab7701a 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -58,15 +58,18 @@ if (!fs.existsSync(cliEntry)) { } function runPushy(args: string[], cwd: string) { - const nodePath = path.join(cliRoot, 'node_modules'); + const cliNodeModules = path.join(cliRoot, 'node_modules'); + const projectNodeModules = path.join(projectRoot, 'node_modules'); + const nodePath = [projectNodeModules, cliNodeModules]; + if (process.env.NODE_PATH) { + nodePath.push(process.env.NODE_PATH); + } const result = spawnSync('node', [cliEntry, ...args], { cwd, stdio: 'inherit', env: { ...process.env, - NODE_PATH: process.env.NODE_PATH - ? `${nodePath}${path.delimiter}${process.env.NODE_PATH}` - : nodePath, + NODE_PATH: nodePath.join(path.delimiter), NO_INTERACTIVE: 'true', PUSHY_REGISTRY: localRegistry, RNU_API: localRegistry, From f73350c564bc6e3310d1de0648c005059309de3e Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 16:51:17 +0800 Subject: [PATCH 12/27] debug(e2e): capture pushy hdiff stdout/stderr to diagnose silent failure pushy hdiff exits 0 but doesn't create the output file. Capture and log all stdout/stderr from the CLI subprocess to understand why. --- .../e2etest/scripts/prepare-local-update-artifacts.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index cab7701a..5d7e44d1 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -66,7 +66,7 @@ function runPushy(args: string[], cwd: string) { } const result = spawnSync('node', [cliEntry, ...args], { cwd, - stdio: 'inherit', + stdio: 'pipe', env: { ...process.env, NODE_PATH: nodePath.join(path.delimiter), @@ -74,8 +74,15 @@ function runPushy(args: string[], cwd: string) { PUSHY_REGISTRY: localRegistry, RNU_API: localRegistry, }, + timeout: 120_000, }); + const stdout = result.stdout?.toString() ?? ''; + const stderr = result.stderr?.toString() ?? ''; + if (stdout) console.log(`[pushy ${args[0]}] stdout:`, stdout.trim()); + if (stderr) console.log(`[pushy ${args[0]}] stderr:`, stderr.trim()); + console.log(`[pushy ${args[0]}] exit code: ${result.status}, signal: ${result.signal}`); + if (result.status !== 0) { throw new Error( `pushy ${args.join(' ')} failed with exit code ${result.status}`, From bafb3931af8f8112f4bb8650d6958a7100d9374d Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 17:13:38 +0800 Subject: [PATCH 13/27] fix(e2e): bypass CLI bin.ts for hdiff commands, call diff handlers directly pushy hdiff exits 0 in CI but doesn't create output files. The root cause is that the CLI's bin.ts flow (loadSession, argument parsing) interferes with the hdiff command somehow - the handler either never runs or silently fails. Fix: use a standalone wrapper script (run-hdiff-wrapper.js) that requires the CLI's lib/diff module directly and calls the hdiff/hdiffFromApk handlers with the correct arguments. This bypasses the bin.ts flow entirely while reusing the same diff logic. Also adds projectRoot/node_modules to NODE_PATH so the CLI's loadModule can find node-hdiffpatch installed in the project. --- .../scripts/prepare-local-update-artifacts.ts | 43 +++++++++----- Example/e2etest/scripts/run-hdiff-wrapper.js | 56 +++++++++++++++++++ 2 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 Example/e2etest/scripts/run-hdiff-wrapper.js diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index 5d7e44d1..271d66d3 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -126,6 +126,29 @@ function prepareDir() { fs.mkdirSync(artifactsDir, { recursive: true }); } +function runHdiffWrapper(command: string, origin: string, next: string, output: string) { + const wrapperScript = path.join(__dirname, 'run-hdiff-wrapper.js'); + const result = spawnSync( + process.execPath, + [wrapperScript, cliRoot, command, origin, next, output], + { + cwd: projectRoot, + stdio: 'inherit', + env: { + ...process.env, + NODE_PATH: [ + path.join(projectRoot, 'node_modules'), + path.join(cliRoot, 'node_modules'), + ].join(path.delimiter), + }, + }, + ); + + if (result.status !== 0) { + throw new Error(`${command} wrapper failed with exit code ${result.status}`); + } +} + function bundleTo(entryFile: string, outputFile: string) { runPushy( [ @@ -160,10 +183,7 @@ async function main() { ensureHdiffModule(); console.log('Generating ppk diff...'); - runPushy( - ['hdiff', v1, v2, '--output', ppkDiff, '--no-interactive'], - projectRoot, - ); + runHdiffWrapper('hdiff', v1, v2, ppkDiff); if (!fs.existsSync(ppkDiff)) { throw new Error(`ppk diff file not found after generation: ${ppkDiff}`); } @@ -183,16 +203,11 @@ async function main() { fs.copyFileSync(apkPath, path.join(artifactsDir, LOCAL_UPDATE_FILES.apk)); console.log('Generating package diff...'); - runPushy( - [ - 'hdiffFromApk', - apkPath, - v3, - '--output', - path.join(artifactsDir, LOCAL_UPDATE_FILES.packageDiff), - '--no-interactive', - ], - projectRoot, + runHdiffWrapper( + 'hdiffFromApk', + apkPath, + v3, + path.join(artifactsDir, LOCAL_UPDATE_FILES.packageDiff), ); const packageDiffPath = path.join(artifactsDir, LOCAL_UPDATE_FILES.packageDiff); if (!fs.existsSync(packageDiffPath)) { diff --git a/Example/e2etest/scripts/run-hdiff-wrapper.js b/Example/e2etest/scripts/run-hdiff-wrapper.js new file mode 100644 index 00000000..74b93e2a --- /dev/null +++ b/Example/e2etest/scripts/run-hdiff-wrapper.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/** + * Standalone wrapper to invoke the CLI's diff commands with proper module resolution. + * Usage: run-hdiff-wrapper.js + * where command is 'hdiff' or 'hdiffFromApk' + */ + +const path = require('path'); +const fs = require('fs'); + +const cliRoot = process.argv[2]; +const command = process.argv[3]; +const origin = process.argv[4]; +const next = process.argv[5]; +const output = process.argv[6]; + +if (!cliRoot || !command || !origin || !next || !output) { + console.error('Usage: run-hdiff-wrapper.js '); + process.exit(1); +} + +// Load the CLI's diff module with proper paths +process.env.NODE_PATH = [ + path.join(process.cwd(), 'node_modules'), + path.join(cliRoot, 'node_modules'), +].join(path.delimiter); + +// Re-initialize module paths +require('module').Module._initPaths(); + +// Now require the CLI's diff module +const diff = require(path.join(cliRoot, 'lib/diff')); + +// Get the command handler +const handler = diff.diffCommands[command]; + +if (!handler) { + console.error(`Command handler '${command}' not found in CLI diff module`); + process.exit(1); +} + +// Call the handler directly +handler({ + args: [origin, next], + options: { output, 'no-interactive': true }, +}).then(() => { + if (fs.existsSync(output)) { + console.log(`${command} output created: ${output} (${fs.statSync(output).size} bytes)`); + } else { + console.error(`${command} output NOT created: ${output}`); + process.exit(1); + } +}).catch(err => { + console.error(`${command} failed:`, err); + process.exit(1); +}); From 1e5201c11faa3356881730e0c8f6337317fc0eb3 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 17:23:05 +0800 Subject: [PATCH 14/27] fix(e2e): improve hdiff wrapper with direct require, better logging - Use createRequire from node:module for reliable module loading - Add extensive logging to diagnose wrapper execution in CI - Validate input files exist before calling diff handlers - Verify output file creation after diff completes --- .../scripts/prepare-local-update-artifacts.ts | 12 +- Example/e2etest/scripts/run-hdiff-wrapper.js | 109 +++++++++++------- 2 files changed, 77 insertions(+), 44 deletions(-) diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index 271d66d3..af0bc391 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -126,11 +126,13 @@ function prepareDir() { fs.mkdirSync(artifactsDir, { recursive: true }); } -function runHdiffWrapper(command: string, origin: string, next: string, output: string) { +function runHdiffWrapper(mode: 'ppk' | 'apk', oldFile: string, newFile: string, outputFile: string) { const wrapperScript = path.join(__dirname, 'run-hdiff-wrapper.js'); + console.log(`[runHdiffWrapper] script=${wrapperScript} cliRoot=${cliRoot} mode=${mode}`); + console.log(`[runHdiffWrapper] args: [${cliRoot}, ${mode}, ${oldFile}, ${newFile}, ${outputFile}]`); const result = spawnSync( process.execPath, - [wrapperScript, cliRoot, command, origin, next, output], + [wrapperScript, cliRoot, mode, oldFile, newFile, outputFile], { cwd: projectRoot, stdio: 'inherit', @@ -145,7 +147,7 @@ function runHdiffWrapper(command: string, origin: string, next: string, output: ); if (result.status !== 0) { - throw new Error(`${command} wrapper failed with exit code ${result.status}`); + throw new Error(`${mode} wrapper failed with exit code ${result.status}`); } } @@ -183,7 +185,7 @@ async function main() { ensureHdiffModule(); console.log('Generating ppk diff...'); - runHdiffWrapper('hdiff', v1, v2, ppkDiff); + runHdiffWrapper('ppk', v1, v2, ppkDiff); if (!fs.existsSync(ppkDiff)) { throw new Error(`ppk diff file not found after generation: ${ppkDiff}`); } @@ -204,7 +206,7 @@ async function main() { fs.copyFileSync(apkPath, path.join(artifactsDir, LOCAL_UPDATE_FILES.apk)); console.log('Generating package diff...'); runHdiffWrapper( - 'hdiffFromApk', + 'apk', apkPath, v3, path.join(artifactsDir, LOCAL_UPDATE_FILES.packageDiff), diff --git a/Example/e2etest/scripts/run-hdiff-wrapper.js b/Example/e2etest/scripts/run-hdiff-wrapper.js index 74b93e2a..259e8b6e 100644 --- a/Example/e2etest/scripts/run-hdiff-wrapper.js +++ b/Example/e2etest/scripts/run-hdiff-wrapper.js @@ -1,56 +1,87 @@ #!/usr/bin/env node /** - * Standalone wrapper to invoke the CLI's diff commands with proper module resolution. - * Usage: run-hdiff-wrapper.js - * where command is 'hdiff' or 'hdiffFromApk' + * run-hdiff-wrapper.js + * Directly require CLI's diff module to generate hdiff patches. + * Bypasses the pushy CLI entirely (avoids bin.ts argument parsing issues). + * + * Usage: + * node run-hdiff-wrapper.js */ -const path = require('path'); -const fs = require('fs'); +const { existsSync, statSync } = require('node:fs'); +const { resolve, dirname, join } = require('node:path'); +const { createRequire } = require('node:module'); -const cliRoot = process.argv[2]; -const command = process.argv[3]; -const origin = process.argv[4]; -const next = process.argv[5]; -const output = process.argv[6]; +const [cliRoot, mode, oldPath, newPath, outputPath] = process.argv.slice(2); -if (!cliRoot || !command || !origin || !next || !output) { - console.error('Usage: run-hdiff-wrapper.js '); +if (!cliRoot || !mode || !oldPath || !newPath || !outputPath) { + console.error('Usage: node run-hdiff-wrapper.js '); process.exit(1); } -// Load the CLI's diff module with proper paths -process.env.NODE_PATH = [ - path.join(process.cwd(), 'node_modules'), - path.join(cliRoot, 'node_modules'), -].join(path.delimiter); +console.log(`[hdiff-wrapper] cliRoot=${cliRoot}`); +console.log(`[hdiff-wrapper] mode=${mode}`); +console.log(`[hdiff-wrapper] old=${oldPath}`); +console.log(`[hdiff-wrapper] new=${newPath}`); +console.log(`[hdiff-wrapper] output=${outputPath}`); -// Re-initialize module paths -require('module').Module._initPaths(); +if (!existsSync(oldPath)) { + console.error(`[hdiff-wrapper] ERROR: old file not found: ${oldPath}`); + process.exit(1); +} +if (!existsSync(newPath)) { + console.error(`[hdiff-wrapper] ERROR: new file not found: ${newPath}`); + process.exit(1); +} -// Now require the CLI's diff module -const diff = require(path.join(cliRoot, 'lib/diff')); +// Load diff module directly from CLI's lib directory +const cliPkg = require(resolve(cliRoot, 'package.json')); +const mainEntry = cliPkg.main || 'lib/index.js'; +const libDir = resolve(cliRoot, dirname(mainEntry)); -// Get the command handler -const handler = diff.diffCommands[command]; +console.log(`[hdiff-wrapper] libDir=${libDir}`); -if (!handler) { - console.error(`Command handler '${command}' not found in CLI diff module`); - process.exit(1); +// Set NODE_PATH so loadModule('node-hdiffpatch') can find it +const projectRoot = resolve(__dirname, '..', '..', '..'); +const nodePathDirs = [ + resolve(projectRoot, 'node_modules'), + resolve(cliRoot, 'node_modules'), +]; +if (process.env.NODE_PATH) { + nodePathDirs.push(process.env.NODE_PATH); } +process.env.NODE_PATH = nodePathDirs.join(':'); +console.log(`[hdiff-wrapper] NODE_PATH=${process.env.NODE_PATH}`); -// Call the handler directly -handler({ - args: [origin, next], - options: { output, 'no-interactive': true }, -}).then(() => { - if (fs.existsSync(output)) { - console.log(`${command} output created: ${output} (${fs.statSync(output).size} bytes)`); - } else { - console.error(`${command} output NOT created: ${output}`); +// Require the diff module directly +const requireFromLib = createRequire(resolve(libDir, '__placeholder.js')); +const diffModule = requireFromLib('./diff'); +const { diffCommands } = diffModule; + +console.log(`[hdiff-wrapper] diffCommands: ${Object.keys(diffCommands).join(', ')}`); + +(async () => { + try { + if (mode === 'ppk') { + console.log(`[hdiff-wrapper] Calling diffCommands.hdiff...`); + await diffCommands.hdiff(oldPath, newPath, outputPath); + } else if (mode === 'apk') { + console.log(`[hdiff-wrapper] Calling diffCommands.hdiffFromApk...`); + await diffCommands.hdiffFromApk(oldPath, newPath, outputPath); + } else { + console.error(`[hdiff-wrapper] ERROR: Unknown mode: ${mode}`); + process.exit(1); + } + + if (existsSync(outputPath)) { + console.log(`[hdiff-wrapper] SUCCESS: ${outputPath} (${statSync(outputPath).size} bytes)`); + } else { + console.error(`[hdiff-wrapper] ERROR: Output file not created: ${outputPath}`); + process.exit(1); + } + } catch (err) { + console.error(`[hdiff-wrapper] ERROR: ${err.message}`); + console.error(`[hdiff-wrapper] Stack: ${err.stack}`); process.exit(1); } -}).catch(err => { - console.error(`${command} failed:`, err); - process.exit(1); -}); +})(); From 3c6202b840f63851be710c763e3ac15dfe7e19c0 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 17:25:33 +0800 Subject: [PATCH 15/27] fix(e2e): resolve wrapper script from source dir, not __dirname __dirname in TS-compiled scripts points to .e2e-artifacts/.ts-build/scripts/, but run-hdiff-wrapper.js lives in the source scripts/ directory and is not copied to the build output. Use projectRoot + 'scripts' to resolve correctly. --- "Example/e2etest/\001\330@@\020\001C@8" | 0 Example/e2etest/scripts/prepare-local-update-artifacts.ts | 3 ++- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 "Example/e2etest/\001\330@@\020\001C@8" diff --git "a/Example/e2etest/\001\330@@\020\001C@8" "b/Example/e2etest/\001\330@@\020\001C@8" new file mode 100644 index 00000000..e69de29b diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index af0bc391..de462c27 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -127,7 +127,8 @@ function prepareDir() { } function runHdiffWrapper(mode: 'ppk' | 'apk', oldFile: string, newFile: string, outputFile: string) { - const wrapperScript = path.join(__dirname, 'run-hdiff-wrapper.js'); + // Use projectRoot (source dir) not __dirname (which may be .ts-build/) + const wrapperScript = path.join(projectRoot, 'scripts', 'run-hdiff-wrapper.js'); console.log(`[runHdiffWrapper] script=${wrapperScript} cliRoot=${cliRoot} mode=${mode}`); console.log(`[runHdiffWrapper] args: [${cliRoot}, ${mode}, ${oldFile}, ${newFile}, ${outputFile}]`); const result = spawnSync( From 6cc460e551358a03f0a35d8e5bfd696cc34e04c2 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 17:27:46 +0800 Subject: [PATCH 16/27] fix(e2e): pass customHdiffModule to bypass loadModule resolution The CLI's loadModule uses require.resolve with paths that only include the CLI's own lib/ and node_modules/. It cannot find node-hdiffpatch installed in the project's node_modules. Fix: pre-load node-hdiffpatch from project root in the wrapper script and pass it as options.customHdiffModule to the diff handler. This bypasses the loadModule resolution entirely. --- Example/e2etest/scripts/run-hdiff-wrapper.js | 62 ++++++++------------ 1 file changed, 26 insertions(+), 36 deletions(-) diff --git a/Example/e2etest/scripts/run-hdiff-wrapper.js b/Example/e2etest/scripts/run-hdiff-wrapper.js index 259e8b6e..2d83204a 100644 --- a/Example/e2etest/scripts/run-hdiff-wrapper.js +++ b/Example/e2etest/scripts/run-hdiff-wrapper.js @@ -1,16 +1,14 @@ #!/usr/bin/env node /** * run-hdiff-wrapper.js - * Directly require CLI's diff module to generate hdiff patches. - * Bypasses the pushy CLI entirely (avoids bin.ts argument parsing issues). - * - * Usage: - * node run-hdiff-wrapper.js + * Directly invoke CLI's diff handlers with node-hdiffpatch pre-loaded. + * Bypasses CLI bin.ts and the loadModule resolution issue. + * + * Usage: node run-hdiff-wrapper.js */ -const { existsSync, statSync } = require('node:fs'); -const { resolve, dirname, join } = require('node:path'); -const { createRequire } = require('node:module'); +const path = require('path'); +const fs = require('fs'); const [cliRoot, mode, oldPath, newPath, outputPath] = process.argv.slice(2); @@ -25,63 +23,55 @@ console.log(`[hdiff-wrapper] old=${oldPath}`); console.log(`[hdiff-wrapper] new=${newPath}`); console.log(`[hdiff-wrapper] output=${outputPath}`); -if (!existsSync(oldPath)) { +if (!fs.existsSync(oldPath)) { console.error(`[hdiff-wrapper] ERROR: old file not found: ${oldPath}`); process.exit(1); } -if (!existsSync(newPath)) { +if (!fs.existsSync(newPath)) { console.error(`[hdiff-wrapper] ERROR: new file not found: ${newPath}`); process.exit(1); } -// Load diff module directly from CLI's lib directory -const cliPkg = require(resolve(cliRoot, 'package.json')); -const mainEntry = cliPkg.main || 'lib/index.js'; -const libDir = resolve(cliRoot, dirname(mainEntry)); - -console.log(`[hdiff-wrapper] libDir=${libDir}`); - -// Set NODE_PATH so loadModule('node-hdiffpatch') can find it -const projectRoot = resolve(__dirname, '..', '..', '..'); -const nodePathDirs = [ - resolve(projectRoot, 'node_modules'), - resolve(cliRoot, 'node_modules'), -]; -if (process.env.NODE_PATH) { - nodePathDirs.push(process.env.NODE_PATH); +// Pre-load node-hdiffpatch from project's node_modules (cwd = projectRoot) +let hdiffModule; +try { + hdiffModule = require('node-hdiffpatch'); + console.log(`[hdiff-wrapper] node-hdiffpatch loaded: ${typeof hdiffModule}, keys: ${Object.keys(hdiffModule).join(', ')}`); +} catch (err) { + console.error(`[hdiff-wrapper] ERROR: Failed to load node-hdiffpatch: ${err.message}`); + process.exit(1); } -process.env.NODE_PATH = nodePathDirs.join(':'); -console.log(`[hdiff-wrapper] NODE_PATH=${process.env.NODE_PATH}`); -// Require the diff module directly -const requireFromLib = createRequire(resolve(libDir, '__placeholder.js')); -const diffModule = requireFromLib('./diff'); +// Load CLI's diff module using absolute path +const diffModule = require(path.join(cliRoot, 'lib/diff')); const { diffCommands } = diffModule; - console.log(`[hdiff-wrapper] diffCommands: ${Object.keys(diffCommands).join(', ')}`); (async () => { try { + // Pass customHdiffModule so CLI doesn't need to resolve it via loadModule + const options = { output: outputPath, 'no-interactive': true, customHdiffModule: hdiffModule }; + if (mode === 'ppk') { console.log(`[hdiff-wrapper] Calling diffCommands.hdiff...`); - await diffCommands.hdiff(oldPath, newPath, outputPath); + await diffCommands.hdiff({ args: [oldPath, newPath], options }); } else if (mode === 'apk') { console.log(`[hdiff-wrapper] Calling diffCommands.hdiffFromApk...`); - await diffCommands.hdiffFromApk(oldPath, newPath, outputPath); + await diffCommands.hdiffFromApk({ args: [oldPath, newPath], options }); } else { console.error(`[hdiff-wrapper] ERROR: Unknown mode: ${mode}`); process.exit(1); } - if (existsSync(outputPath)) { - console.log(`[hdiff-wrapper] SUCCESS: ${outputPath} (${statSync(outputPath).size} bytes)`); + if (fs.existsSync(outputPath)) { + console.log(`[hdiff-wrapper] SUCCESS: ${outputPath} (${fs.statSync(outputPath).size} bytes)`); } else { console.error(`[hdiff-wrapper] ERROR: Output file not created: ${outputPath}`); process.exit(1); } } catch (err) { console.error(`[hdiff-wrapper] ERROR: ${err.message}`); - console.error(`[hdiff-wrapper] Stack: ${err.stack}`); + if (err.stack) console.error(`[hdiff-wrapper] Stack: ${err.stack}`); process.exit(1); } })(); From bb57cd4e8e9ad080939ce0a3016e2c491e4d4a95 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 20:00:39 +0800 Subject: [PATCH 17/27] fix(ci): remove accidental invalid e2e filename The PR contained a zero-byte file with non-UTF-8 bytes in its path under Example/e2etest. macOS runners cannot check it out, which makes e2e-ios fail before the workflow reaches project code. --- "Example/e2etest/\001\330@@\020\001C@8" | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 "Example/e2etest/\001\330@@\020\001C@8" diff --git "a/Example/e2etest/\001\330@@\020\001C@8" "b/Example/e2etest/\001\330@@\020\001C@8" deleted file mode 100644 index e69de29b..00000000 From 40018c77a658454b129852cff1cb3c4d2ea46e9f Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 20:08:10 +0800 Subject: [PATCH 18/27] fix(ci): use cli hdiff path for e2e artifacts --- Example/e2etest/e2e/globalSetup.ts | 27 +----- Example/e2etest/scripts/local-e2e-server.ts | 19 ---- .../scripts/prepare-local-update-artifacts.ts | 86 +++++++++---------- Example/e2etest/scripts/run-hdiff-wrapper.js | 77 ----------------- src/__tests__/client.test.ts | 9 +- 5 files changed, 48 insertions(+), 170 deletions(-) delete mode 100644 Example/e2etest/scripts/run-hdiff-wrapper.js diff --git a/Example/e2etest/e2e/globalSetup.ts b/Example/e2etest/e2e/globalSetup.ts index f937d5fe..49894916 100644 --- a/Example/e2etest/e2e/globalSetup.ts +++ b/Example/e2etest/e2e/globalSetup.ts @@ -97,13 +97,10 @@ function startServer() { const serverScript = path.join(projectRoot, 'scripts/local-e2e-server.ts'); fs.mkdirSync(artifactsRoot, { recursive: true }); - const logFile = path.join(artifactsRoot, '.server.log'); - const logFd = fs.openSync(logFile, 'w'); - const child = spawn('bun', [serverScript], { cwd: projectRoot, detached: true, - stdio: ['ignore', logFd, logFd], + stdio: 'ignore', env: { ...process.env, E2E_ASSET_PORT: String(LOCAL_UPDATE_PORT), @@ -111,7 +108,6 @@ function startServer() { }); child.unref(); fs.writeFileSync(pidFile, String(child.pid)); - // Keep logFd open for the server lifetime — it will be cleaned up on process exit. } function waitForServer(timeoutMs = 30000) { @@ -239,27 +235,6 @@ async function globalSetup() { } startServer(); await waitForServer(); - - // Diagnostic: list artifacts via debug endpoint and local filesystem - const origin = `http://127.0.0.1:${LOCAL_UPDATE_PORT}`; - try { - const debugRes = await fetch(`${origin}/debug/artifacts`); - if (debugRes.ok) { - console.log('[globalSetup] Server artifacts:', await debugRes.text()); - } else { - console.log('[globalSetup] Debug endpoint returned:', debugRes.status); - } - } catch (e) { - console.log('[globalSetup] Debug endpoint error:', e); - } - const localArtifactsDir = path.join(artifactsRoot, platform); - if (fs.existsSync(localArtifactsDir)) { - const files = fs.readdirSync(localArtifactsDir); - console.log(`[globalSetup] Local artifacts dir (${localArtifactsDir}):`, files); - } else { - console.log(`[globalSetup] Local artifacts dir MISSING: ${localArtifactsDir}`); - } - await warmServer(platform as 'ios' | 'android'); await detoxGlobalSetup(); } diff --git a/Example/e2etest/scripts/local-e2e-server.ts b/Example/e2etest/scripts/local-e2e-server.ts index e06dedc4..c234dcee 100644 --- a/Example/e2etest/scripts/local-e2e-server.ts +++ b/Example/e2etest/scripts/local-e2e-server.ts @@ -140,9 +140,6 @@ const server = Bun.serve({ !fs.existsSync(filePath) || fs.statSync(filePath).isDirectory() ) { - console.error( - `[404] ${url.pathname} -> ${filePath} (exists: ${filePath ? fs.existsSync(filePath) : 'null'})`, - ); return new Response('not found', { status: 404 }); } @@ -159,22 +156,6 @@ const server = Bun.serve({ }); } - if (url.pathname === '/debug/artifacts') { - const entries = fs.readdirSync(artifactsRoot, { recursive: true }); - const listing = entries.map(e => { - const fullPath = path.join(artifactsRoot, String(e)); - try { - const stat = fs.statSync(fullPath); - return `${e} (${stat.size} bytes, ${stat.isDirectory() ? 'dir' : 'file'})`; - } catch { - return `${e} (stat failed)`; - } - }); - return new Response(JSON.stringify({ artifactsRoot, entries: listing }, null, 2), { - headers: { 'Content-Type': 'application/json' }, - }); - } - return new Response('not found', { status: 404 }); }, }); diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index de462c27..f433ac19 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -66,7 +66,7 @@ function runPushy(args: string[], cwd: string) { } const result = spawnSync('node', [cliEntry, ...args], { cwd, - stdio: 'pipe', + stdio: 'inherit', env: { ...process.env, NODE_PATH: nodePath.join(path.delimiter), @@ -77,12 +77,9 @@ function runPushy(args: string[], cwd: string) { timeout: 120_000, }); - const stdout = result.stdout?.toString() ?? ''; - const stderr = result.stderr?.toString() ?? ''; - if (stdout) console.log(`[pushy ${args[0]}] stdout:`, stdout.trim()); - if (stderr) console.log(`[pushy ${args[0]}] stderr:`, stderr.trim()); - console.log(`[pushy ${args[0]}] exit code: ${result.status}, signal: ${result.signal}`); - + if (result.error) { + throw result.error; + } if (result.status !== 0) { throw new Error( `pushy ${args.join(' ')} failed with exit code ${result.status}`, @@ -126,32 +123,6 @@ function prepareDir() { fs.mkdirSync(artifactsDir, { recursive: true }); } -function runHdiffWrapper(mode: 'ppk' | 'apk', oldFile: string, newFile: string, outputFile: string) { - // Use projectRoot (source dir) not __dirname (which may be .ts-build/) - const wrapperScript = path.join(projectRoot, 'scripts', 'run-hdiff-wrapper.js'); - console.log(`[runHdiffWrapper] script=${wrapperScript} cliRoot=${cliRoot} mode=${mode}`); - console.log(`[runHdiffWrapper] args: [${cliRoot}, ${mode}, ${oldFile}, ${newFile}, ${outputFile}]`); - const result = spawnSync( - process.execPath, - [wrapperScript, cliRoot, mode, oldFile, newFile, outputFile], - { - cwd: projectRoot, - stdio: 'inherit', - env: { - ...process.env, - NODE_PATH: [ - path.join(projectRoot, 'node_modules'), - path.join(cliRoot, 'node_modules'), - ].join(path.delimiter), - }, - }, - ); - - if (result.status !== 0) { - throw new Error(`${mode} wrapper failed with exit code ${result.status}`); - } -} - function bundleTo(entryFile: string, outputFile: string) { runPushy( [ @@ -171,6 +142,35 @@ function bundleTo(entryFile: string, outputFile: string) { ); } +function verifyGeneratedFile(label: string, filePath: string) { + if (!fs.existsSync(filePath)) { + throw new Error(`${label} file not found after generation: ${filePath}`); + } + console.log( + `Verified ${label}: ${filePath} (${fs.statSync(filePath).size} bytes)`, + ); +} + +function generatePpkDiff(origin: string, next: string, output: string) { + runPushy( + ['hdiff', origin, next, '--output', output, '--no-interactive'], + projectRoot, + ); + verifyGeneratedFile('ppk diff', output); +} + +function generateAndroidPackageDiff( + apkPath: string, + next: string, + output: string, +) { + runPushy( + ['hdiffFromApk', apkPath, next, '--output', output, '--no-interactive'], + projectRoot, + ); + verifyGeneratedFile('package diff', output); +} + async function main() { prepareDir(); @@ -186,11 +186,7 @@ async function main() { ensureHdiffModule(); console.log('Generating ppk diff...'); - runHdiffWrapper('ppk', v1, v2, ppkDiff); - if (!fs.existsSync(ppkDiff)) { - throw new Error(`ppk diff file not found after generation: ${ppkDiff}`); - } - console.log(`Verified ppk diff: ${ppkDiff} (${fs.statSync(ppkDiff).size} bytes)`); + generatePpkDiff(v1, v2, ppkDiff); if (platform === 'android') { const apkPath = path.join( @@ -206,17 +202,15 @@ async function main() { fs.copyFileSync(apkPath, path.join(artifactsDir, LOCAL_UPDATE_FILES.apk)); console.log('Generating package diff...'); - runHdiffWrapper( - 'apk', + const packageDiffPath = path.join( + artifactsDir, + LOCAL_UPDATE_FILES.packageDiff, + ); + generateAndroidPackageDiff( apkPath, v3, - path.join(artifactsDir, LOCAL_UPDATE_FILES.packageDiff), + packageDiffPath, ); - const packageDiffPath = path.join(artifactsDir, LOCAL_UPDATE_FILES.packageDiff); - if (!fs.existsSync(packageDiffPath)) { - throw new Error(`Package diff file not found after generation: ${packageDiffPath}`); - } - console.log(`Verified package diff: ${packageDiffPath} (${fs.statSync(packageDiffPath).size} bytes)`); } const manifestPath = path.join(artifactsDir, 'manifest.json'); diff --git a/Example/e2etest/scripts/run-hdiff-wrapper.js b/Example/e2etest/scripts/run-hdiff-wrapper.js deleted file mode 100644 index 2d83204a..00000000 --- a/Example/e2etest/scripts/run-hdiff-wrapper.js +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env node -/** - * run-hdiff-wrapper.js - * Directly invoke CLI's diff handlers with node-hdiffpatch pre-loaded. - * Bypasses CLI bin.ts and the loadModule resolution issue. - * - * Usage: node run-hdiff-wrapper.js - */ - -const path = require('path'); -const fs = require('fs'); - -const [cliRoot, mode, oldPath, newPath, outputPath] = process.argv.slice(2); - -if (!cliRoot || !mode || !oldPath || !newPath || !outputPath) { - console.error('Usage: node run-hdiff-wrapper.js '); - process.exit(1); -} - -console.log(`[hdiff-wrapper] cliRoot=${cliRoot}`); -console.log(`[hdiff-wrapper] mode=${mode}`); -console.log(`[hdiff-wrapper] old=${oldPath}`); -console.log(`[hdiff-wrapper] new=${newPath}`); -console.log(`[hdiff-wrapper] output=${outputPath}`); - -if (!fs.existsSync(oldPath)) { - console.error(`[hdiff-wrapper] ERROR: old file not found: ${oldPath}`); - process.exit(1); -} -if (!fs.existsSync(newPath)) { - console.error(`[hdiff-wrapper] ERROR: new file not found: ${newPath}`); - process.exit(1); -} - -// Pre-load node-hdiffpatch from project's node_modules (cwd = projectRoot) -let hdiffModule; -try { - hdiffModule = require('node-hdiffpatch'); - console.log(`[hdiff-wrapper] node-hdiffpatch loaded: ${typeof hdiffModule}, keys: ${Object.keys(hdiffModule).join(', ')}`); -} catch (err) { - console.error(`[hdiff-wrapper] ERROR: Failed to load node-hdiffpatch: ${err.message}`); - process.exit(1); -} - -// Load CLI's diff module using absolute path -const diffModule = require(path.join(cliRoot, 'lib/diff')); -const { diffCommands } = diffModule; -console.log(`[hdiff-wrapper] diffCommands: ${Object.keys(diffCommands).join(', ')}`); - -(async () => { - try { - // Pass customHdiffModule so CLI doesn't need to resolve it via loadModule - const options = { output: outputPath, 'no-interactive': true, customHdiffModule: hdiffModule }; - - if (mode === 'ppk') { - console.log(`[hdiff-wrapper] Calling diffCommands.hdiff...`); - await diffCommands.hdiff({ args: [oldPath, newPath], options }); - } else if (mode === 'apk') { - console.log(`[hdiff-wrapper] Calling diffCommands.hdiffFromApk...`); - await diffCommands.hdiffFromApk({ args: [oldPath, newPath], options }); - } else { - console.error(`[hdiff-wrapper] ERROR: Unknown mode: ${mode}`); - process.exit(1); - } - - if (fs.existsSync(outputPath)) { - console.log(`[hdiff-wrapper] SUCCESS: ${outputPath} (${fs.statSync(outputPath).size} bytes)`); - } else { - console.error(`[hdiff-wrapper] ERROR: Output file not created: ${outputPath}`); - process.exit(1); - } - } catch (err) { - console.error(`[hdiff-wrapper] ERROR: ${err.message}`); - if (err.stack) console.error(`[hdiff-wrapper] Stack: ${err.stack}`); - process.exit(1); - } -})(); diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index b631d0cc..f747c0c2 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, mock, test } from 'bun:test'; +import { afterEach, describe, expect, mock, test } from 'bun:test'; const importFreshClient = (cacheKey: string) => import(`../client?${cacheKey}`); @@ -366,6 +366,12 @@ describe('Pushy server config', () => { }); describe('downloadUpdate fallback chain', () => { + const realSetTimeout = globalThis.setTimeout; + + afterEach(() => { + globalThis.setTimeout = realSetTimeout; + }); + const setupDownloadMocks = ({ downloadPatchFromPpk = mock(() => Promise.resolve()), downloadPatchFromPackage = mock(() => Promise.resolve()), @@ -382,7 +388,6 @@ describe('downloadUpdate fallback chain', () => { }); // Override setTimeout to skip real backoff delays in retry tests - const realSetTimeout = globalThis.setTimeout.bind(globalThis); globalThis.setTimeout = ((fn: (...args: any[]) => void, _ms?: number) => realSetTimeout(fn, 0)) as unknown as typeof setTimeout; From c19a7db82b093fa0371758f8ca78e116906b5fde Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 20:22:43 +0800 Subject: [PATCH 19/27] fix(e2e): invoke hdiff commands directly --- .../scripts/prepare-local-update-artifacts.ts | 83 ++++++++++++++++--- 1 file changed, 72 insertions(+), 11 deletions(-) diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index f433ac19..12b68c09 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -9,6 +9,25 @@ import { LOCAL_UPDATE_LABELS, } from '../e2e/localUpdateConfig'; +type DiffCommandRunner = { + hdiff: (options: { + args: [string, string]; + options: { + output: string; + customDiff: (oldSource?: Buffer, newSource?: Buffer) => Buffer; + 'no-interactive': true; + }; + }) => Promise; + hdiffFromApk: (options: { + args: [string, string]; + options: { + output: string; + customDiff: (oldSource?: Buffer, newSource?: Buffer) => Buffer; + 'no-interactive': true; + }; + }) => Promise; +}; + const projectRoot = process.cwd(); const platform = process.env.E2E_PLATFORM || 'ios'; const artifactsRoot = path.join(projectRoot, '.e2e-artifacts'); @@ -57,6 +76,10 @@ if (!fs.existsSync(cliEntry)) { throw new Error(`react-native-update-cli entry not found: ${cliEntry}`); } +const { diffCommands } = require(path.join(cliRoot, 'lib/exports.js')) as { + diffCommands: DiffCommandRunner; +}; + function runPushy(args: string[], cwd: string) { const cliNodeModules = path.join(cliRoot, 'node_modules'); const projectNodeModules = path.join(projectRoot, 'node_modules'); @@ -116,6 +139,16 @@ function ensureHdiffModule() { if (!fs.existsSync(modulePath)) { throw new Error(`Failed to install node-hdiffpatch under: ${projectRoot}`); } + const hdiffModule = require(modulePath) as { + diff?: (oldSource?: Buffer, newSource?: Buffer) => Buffer; + } & ((oldSource?: Buffer, newSource?: Buffer) => Buffer); + const customDiff = hdiffModule.diff || hdiffModule; + if (typeof customDiff !== 'function') { + throw new Error( + `node-hdiffpatch did not expose a diff function: ${modulePath}`, + ); + } + return customDiff; } function prepareDir() { @@ -151,22 +184,49 @@ function verifyGeneratedFile(label: string, filePath: string) { ); } -function generatePpkDiff(origin: string, next: string, output: string) { - runPushy( - ['hdiff', origin, next, '--output', output, '--no-interactive'], - projectRoot, +async function keepProcessAlive(promise: Promise) { + const timer = setInterval(() => {}, 1000); + try { + return await promise; + } finally { + clearInterval(timer); + } +} + +async function generatePpkDiff( + origin: string, + next: string, + output: string, + customDiff: (oldSource?: Buffer, newSource?: Buffer) => Buffer, +) { + await keepProcessAlive( + diffCommands.hdiff({ + args: [origin, next], + options: { + output, + customDiff, + 'no-interactive': true, + }, + }), ); verifyGeneratedFile('ppk diff', output); } -function generateAndroidPackageDiff( +async function generateAndroidPackageDiff( apkPath: string, next: string, output: string, + customDiff: (oldSource?: Buffer, newSource?: Buffer) => Buffer, ) { - runPushy( - ['hdiffFromApk', apkPath, next, '--output', output, '--no-interactive'], - projectRoot, + await keepProcessAlive( + diffCommands.hdiffFromApk({ + args: [apkPath, next], + options: { + output, + customDiff, + 'no-interactive': true, + }, + }), ); verifyGeneratedFile('package diff', output); } @@ -183,10 +243,10 @@ async function main() { bundleTo('e2e/entry.v2.ts', v2); bundleTo('e2e/entry.v3.ts', v3); - ensureHdiffModule(); + const customDiff = ensureHdiffModule(); console.log('Generating ppk diff...'); - generatePpkDiff(v1, v2, ppkDiff); + await generatePpkDiff(v1, v2, ppkDiff, customDiff); if (platform === 'android') { const apkPath = path.join( @@ -206,10 +266,11 @@ async function main() { artifactsDir, LOCAL_UPDATE_FILES.packageDiff, ); - generateAndroidPackageDiff( + await generateAndroidPackageDiff( apkPath, v3, packageDiffPath, + customDiff, ); } From 364a678f4f6fedcc5778bd0eee4dd4694988be08 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 20:35:47 +0800 Subject: [PATCH 20/27] fix(e2e): load hdiff from cli dependencies --- Example/e2etest/scripts/prepare-local-update-artifacts.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index 12b68c09..5d897b13 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -111,7 +111,7 @@ function runPushy(args: string[], cwd: string) { } function ensureHdiffModule() { - const modulePath = path.join(projectRoot, 'node_modules/node-hdiffpatch'); + const modulePath = path.join(cliRoot, 'node_modules/node-hdiffpatch'); if (!fs.existsSync(modulePath)) { console.log('node-hdiffpatch not found, installing...'); const result = spawnSync( @@ -124,7 +124,7 @@ function ensureHdiffModule() { 'node-hdiffpatch', ], { - cwd: projectRoot, + cwd: cliRoot, stdio: 'inherit', env: process.env, }, @@ -137,7 +137,7 @@ function ensureHdiffModule() { } } if (!fs.existsSync(modulePath)) { - throw new Error(`Failed to install node-hdiffpatch under: ${projectRoot}`); + throw new Error(`Failed to install node-hdiffpatch under: ${cliRoot}`); } const hdiffModule = require(modulePath) as { diff?: (oldSource?: Buffer, newSource?: Buffer) => Buffer; From 43bf2686f84347afe9d728c94f2e7d00e035dd8f Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 20:37:50 +0800 Subject: [PATCH 21/27] fix(progress): clamp computed percentage --- src/__tests__/utils.test.ts | 8 ++++++++ src/utils.ts | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index cf032c41..4204669f 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -85,6 +85,14 @@ describe('computeProgress', () => { expect(computeProgress(1000, 1000)).toBe(100); }); + test('caps progress at 100 when received exceeds total', () => { + expect(computeProgress(1200, 1000)).toBe(100); + }); + + test('floors progress at 0 when received is negative', () => { + expect(computeProgress(-100, 1000)).toBe(0); + }); + test('returns 50 for half progress', () => { expect(computeProgress(500, 1000)).toBe(50); }); diff --git a/src/utils.ts b/src/utils.ts index df193820..9237d1b2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -109,7 +109,9 @@ export const assertWeb = () => { }; export const computeProgress = (received: number, total: number): number => - total > 0 ? Math.floor((received / total) * 100) : 0; + total > 0 + ? Math.min(100, Math.max(0, Math.floor((received / total) * 100))) + : 0; export const fetchWithTimeout = ( url: string, From cff08d087f432b69606a1afa37b82ac8d77f9b6f Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 20:53:58 +0800 Subject: [PATCH 22/27] fix(e2e): install hdiff with bun --- .../scripts/prepare-local-update-artifacts.ts | 63 ++++++++++++------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index 5d897b13..894bfb77 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -110,31 +110,52 @@ function runPushy(args: string[], cwd: string) { } } +function installHdiffModule() { + const bunResult = spawnSync( + 'bun', + ['add', '--no-save', 'node-hdiffpatch'], + { + cwd: cliRoot, + stdio: 'inherit', + env: process.env, + timeout: 120_000, + }, + ); + if (bunResult.status === 0) { + return; + } + + const npmResult = spawnSync( + 'npm', + [ + 'install', + '--no-save', + '--package-lock=false', + '--legacy-peer-deps', + 'node-hdiffpatch', + ], + { + cwd: cliRoot, + stdio: 'inherit', + env: process.env, + timeout: 120_000, + }, + ); + if (npmResult.error) { + throw npmResult.error; + } + if (npmResult.status !== 0) { + throw new Error( + `npm install node-hdiffpatch failed with exit code ${npmResult.status}`, + ); + } +} + function ensureHdiffModule() { const modulePath = path.join(cliRoot, 'node_modules/node-hdiffpatch'); if (!fs.existsSync(modulePath)) { console.log('node-hdiffpatch not found, installing...'); - const result = spawnSync( - 'npm', - [ - 'install', - '--no-save', - '--package-lock=false', - '--legacy-peer-deps', - 'node-hdiffpatch', - ], - { - cwd: cliRoot, - stdio: 'inherit', - env: process.env, - }, - ); - - if (result.status !== 0) { - throw new Error( - `npm install node-hdiffpatch failed with exit code ${result.status}`, - ); - } + installHdiffModule(); } if (!fs.existsSync(modulePath)) { throw new Error(`Failed to install node-hdiffpatch under: ${cliRoot}`); From 21fe062573f293889ae63cabe97a001f9d9712aa Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 21:16:31 +0800 Subject: [PATCH 23/27] fix(e2e): log and bound hdiff generation --- .../scripts/prepare-local-update-artifacts.ts | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index 894bfb77..c1baa2b7 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -32,6 +32,7 @@ const projectRoot = process.cwd(); const platform = process.env.E2E_PLATFORM || 'ios'; const artifactsRoot = path.join(projectRoot, '.e2e-artifacts'); const artifactsDir = path.join(artifactsRoot, platform); +const diffTimeoutMs = 5 * 60_000; const localRegistry = process.env.PUSHY_REGISTRY || process.env.RNU_API || 'http://127.0.0.1:65535'; @@ -178,6 +179,7 @@ function prepareDir() { } function bundleTo(entryFile: string, outputFile: string) { + console.log(`Bundling ${entryFile} -> ${outputFile}`); runPushy( [ 'bundle', @@ -194,6 +196,7 @@ function bundleTo(entryFile: string, outputFile: string) { ], projectRoot, ); + verifyGeneratedFile(`bundle ${entryFile}`, outputFile); } function verifyGeneratedFile(label: string, filePath: string) { @@ -205,12 +208,25 @@ function verifyGeneratedFile(label: string, filePath: string) { ); } -async function keepProcessAlive(promise: Promise) { +async function keepProcessAlive( + label: string, + promise: Promise, + timeoutMs = diffTimeoutMs, +) { const timer = setInterval(() => {}, 1000); + let timeout: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error(`${label} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); try { - return await promise; + return await Promise.race([promise, timeoutPromise]); } finally { clearInterval(timer); + if (timeout) { + clearTimeout(timeout); + } } } @@ -220,7 +236,11 @@ async function generatePpkDiff( output: string, customDiff: (oldSource?: Buffer, newSource?: Buffer) => Buffer, ) { + console.log( + `Running hdiff ppk: ${origin} -> ${next} (${fs.statSync(origin).size} -> ${fs.statSync(next).size} bytes)`, + ); await keepProcessAlive( + 'ppk diff', diffCommands.hdiff({ args: [origin, next], options: { @@ -239,7 +259,11 @@ async function generateAndroidPackageDiff( output: string, customDiff: (oldSource?: Buffer, newSource?: Buffer) => Buffer, ) { + console.log( + `Running hdiffFromApk: ${apkPath} -> ${next} (${fs.statSync(apkPath).size} -> ${fs.statSync(next).size} bytes)`, + ); await keepProcessAlive( + 'package diff', diffCommands.hdiffFromApk({ args: [apkPath, next], options: { From b083d065ac1eb30bbbb34336c4dba3d8ec9081e7 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 21:37:25 +0800 Subject: [PATCH 24/27] fix(e2e): run hdiff in child process --- Example/e2etest/e2e/local-merge.test.ts | 31 +------- .../scripts/prepare-local-update-artifacts.ts | 71 ++++++++++++++++--- 2 files changed, 62 insertions(+), 40 deletions(-) diff --git a/Example/e2etest/e2e/local-merge.test.ts b/Example/e2etest/e2e/local-merge.test.ts index 5bd64307..f67625dd 100644 --- a/Example/e2etest/e2e/local-merge.test.ts +++ b/Example/e2etest/e2e/local-merge.test.ts @@ -9,7 +9,6 @@ const RETRYABLE_RELOAD_TIMEOUT = 45000; const MARK_SUCCESS_TIMEOUT = 30000; const MARK_SUCCESS_SETTLE_MS = 1500; const DOWNLOAD_SUCCESS_TIMEOUT = 120000; -const TRANSIENT_ERROR_TIMEOUT = 5000; const MAX_CHECK_UPDATE_ATTEMPTS = 2; function getDetoxLaunchArgs() { @@ -47,30 +46,6 @@ async function waitForBundleLabel( .withTimeout(timeoutMs); } -async function matchesText( - testId: string, - text: string, - timeoutMs = TRANSIENT_ERROR_TIMEOUT, -) { - try { - await waitFor(element(by.id(testId))) - .toHaveText(text) - .withTimeout(timeoutMs); - return true; - } catch { - return false; - } -} - -async function didHitTransientCheckError() { - const [hasCheckError, hasErrorEvent] = await Promise.all([ - matchesText('last-check-status', 'lastCheckStatus: error'), - matchesText('last-event', 'lastEvent: errorChecking'), - ]); - - return hasCheckError && hasErrorEvent; -} - async function tapCheckUpdateAndWaitForBundleLabel(expectedLabel: string) { let lastError: unknown; @@ -88,11 +63,7 @@ async function tapCheckUpdateAndWaitForBundleLabel(expectedLabel: string) { } catch (error) { lastError = error; - const shouldRetry = - attempt < MAX_CHECK_UPDATE_ATTEMPTS && - (await didHitTransientCheckError()); - - if (!shouldRetry) { + if (attempt >= MAX_CHECK_UPDATE_ATTEMPTS) { throw error; } diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index c1baa2b7..b6370ae1 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -114,7 +114,7 @@ function runPushy(args: string[], cwd: string) { function installHdiffModule() { const bunResult = spawnSync( 'bun', - ['add', '--no-save', 'node-hdiffpatch'], + ['add', '--no-save', '--trust', 'node-hdiffpatch'], { cwd: cliRoot, stdio: 'inherit', @@ -152,6 +152,61 @@ function installHdiffModule() { } } +function createHdiffProcessDiff(modulePath: string) { + const hdiffCli = path.join(modulePath, 'bin/hdiffpatch.js'); + if (!fs.existsSync(hdiffCli)) { + throw new Error(`node-hdiffpatch CLI not found: ${hdiffCli}`); + } + + return (oldSource?: Buffer, newSource?: Buffer) => { + if (!oldSource || !newSource) { + throw new Error('node-hdiffpatch diff requires both source buffers.'); + } + + const tempDir = fs.mkdtempSync(path.join(artifactsRoot, 'hdiff-')); + const oldPath = path.join(tempDir, 'old.bin'); + const newPath = path.join(tempDir, 'new.bin'); + const patchPath = path.join(tempDir, 'patch.bin'); + + try { + fs.writeFileSync(oldPath, oldSource); + fs.writeFileSync(newPath, newSource); + + const result = spawnSync( + 'node', + [hdiffCli, 'diff', oldPath, newPath, patchPath], + { + cwd: cliRoot, + encoding: 'utf8', + env: process.env, + timeout: diffTimeoutMs, + }, + ); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error( + [ + `node-hdiffpatch diff failed with exit code ${result.status}`, + result.stdout, + result.stderr, + ] + .filter(Boolean) + .join('\n'), + ); + } + if (!fs.existsSync(patchPath)) { + throw new Error(`node-hdiffpatch did not create patch: ${patchPath}`); + } + + return fs.readFileSync(patchPath); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }; +} + function ensureHdiffModule() { const modulePath = path.join(cliRoot, 'node_modules/node-hdiffpatch'); if (!fs.existsSync(modulePath)) { @@ -161,15 +216,11 @@ function ensureHdiffModule() { if (!fs.existsSync(modulePath)) { throw new Error(`Failed to install node-hdiffpatch under: ${cliRoot}`); } - const hdiffModule = require(modulePath) as { - diff?: (oldSource?: Buffer, newSource?: Buffer) => Buffer; - } & ((oldSource?: Buffer, newSource?: Buffer) => Buffer); - const customDiff = hdiffModule.diff || hdiffModule; - if (typeof customDiff !== 'function') { - throw new Error( - `node-hdiffpatch did not expose a diff function: ${modulePath}`, - ); - } + const customDiff = createHdiffProcessDiff(modulePath); + customDiff( + Buffer.from('rnu-hdiff-smoke-old'), + Buffer.from('rnu-hdiff-smoke-new'), + ); return customDiff; } From cce4b16d5c5915ad77f546f12ed3c8ec02534834 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 21:56:33 +0800 Subject: [PATCH 25/27] fix(e2e): fallback to full packages on linux android --- Example/e2etest/e2e/globalSetup.ts | 17 ++++++ Example/e2etest/e2e/localUpdateConfig.ts | 2 + Example/e2etest/scripts/local-e2e-server.ts | 15 +++++ .../scripts/prepare-local-update-artifacts.ts | 58 ++++++++++++++----- 4 files changed, 78 insertions(+), 14 deletions(-) diff --git a/Example/e2etest/e2e/globalSetup.ts b/Example/e2etest/e2e/globalSetup.ts index 49894916..2823a0bd 100644 --- a/Example/e2etest/e2e/globalSetup.ts +++ b/Example/e2etest/e2e/globalSetup.ts @@ -93,6 +93,18 @@ function ensurePreparedArtifacts(platform: string) { } } +function usesFullFallbackArtifacts(platform: string) { + const manifestPath = path.join(artifactsRoot, platform, 'manifest.json'); + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { + fullFallback?: unknown; + }; + return manifest.fullFallback === true; + } catch { + return false; + } +} + function startServer() { const serverScript = path.join(projectRoot, 'scripts/local-e2e-server.ts'); fs.mkdirSync(artifactsRoot, { recursive: true }); @@ -202,12 +214,17 @@ async function warmServer(platform: 'ios' | 'android') { 'Local artifacts checkUpdate route', ); + const fullFallback = usesFullFallbackArtifacts(platform); const artifactFiles = [ LOCAL_UPDATE_FILES.full, LOCAL_UPDATE_FILES.ppkDiff, + ...(fullFallback ? [LOCAL_UPDATE_FILES.ppkFull] : []), ...(platform === 'android' ? [LOCAL_UPDATE_FILES.apk, LOCAL_UPDATE_FILES.packageDiff] : []), + ...(platform === 'android' && fullFallback + ? [LOCAL_UPDATE_FILES.packageFull] + : []), ]; for (const fileName of artifactFiles) { diff --git a/Example/e2etest/e2e/localUpdateConfig.ts b/Example/e2etest/e2e/localUpdateConfig.ts index 9332ab91..a31a0391 100644 --- a/Example/e2etest/e2e/localUpdateConfig.ts +++ b/Example/e2etest/e2e/localUpdateConfig.ts @@ -20,7 +20,9 @@ export const LOCAL_UPDATE_LABELS = { export const LOCAL_UPDATE_FILES = { full: 'v1.ppk', + ppkFull: 'v2.ppk', ppkDiff: 'v1-to-v2.ppk.patch', + packageFull: 'v3.ppk', packageDiff: 'base-to-v3.apk.patch', apk: 'app-release.apk', } as const; diff --git a/Example/e2etest/scripts/local-e2e-server.ts b/Example/e2etest/scripts/local-e2e-server.ts index c234dcee..cc3da780 100644 --- a/Example/e2etest/scripts/local-e2e-server.ts +++ b/Example/e2etest/scripts/local-e2e-server.ts @@ -29,6 +29,18 @@ const contentTypes: Record = { '.apk': 'application/vnd.android.package-archive', }; +function usesFullFallback(platform: string) { + const manifestPath = path.join(artifactsRoot, platform, 'manifest.json'); + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { + fullFallback?: unknown; + }; + return manifest.fullFallback === true; + } catch { + return false; + } +} + function json(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, @@ -57,6 +69,7 @@ function buildUpdateResponse( origin: string, ) { const assetBasePath = `${origin}/artifacts/${platform}`; + const fullFallback = usesFullFallback(platform); if (!currentHash) { return { @@ -79,6 +92,7 @@ function buildUpdateResponse( metaInfo: JSON.stringify({ stage: 'diff', platform }), paths: [assetBasePath], diff: LOCAL_UPDATE_FILES.ppkDiff, + ...(fullFallback ? { full: LOCAL_UPDATE_FILES.ppkFull } : {}), }; } @@ -91,6 +105,7 @@ function buildUpdateResponse( metaInfo: JSON.stringify({ stage: 'pdiff', platform }), paths: [assetBasePath], pdiff: LOCAL_UPDATE_FILES.packageDiff, + ...(fullFallback ? { full: LOCAL_UPDATE_FILES.packageFull } : {}), }; } diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index b6370ae1..130b7ee9 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -35,6 +35,9 @@ const artifactsDir = path.join(artifactsRoot, platform); const diffTimeoutMs = 5 * 60_000; const localRegistry = process.env.PUSHY_REGISTRY || process.env.RNU_API || 'http://127.0.0.1:65535'; +const useFullFallbackArtifacts = + process.env.RNU_E2E_FULL_FALLBACK === 'true' || + (platform === 'android' && process.platform === 'linux'); function resolveCliRoot() { const candidates = [ @@ -259,6 +262,18 @@ function verifyGeneratedFile(label: string, filePath: string) { ); } +function writeFallbackPatch(label: string, filePath: string) { + fs.writeFileSync( + filePath, + [ + `Invalid ${label} placeholder.`, + 'The local e2e server advertises a full package fallback for this artifact.', + '', + ].join('\n'), + ); + verifyGeneratedFile(`${label} fallback`, filePath); +} + async function keepProcessAlive( label: string, promise: Promise, @@ -331,19 +346,14 @@ async function main() { prepareDir(); const v1 = path.join(artifactsDir, LOCAL_UPDATE_FILES.full); - const v2 = path.join(artifactsDir, 'v2.ppk'); - const v3 = path.join(artifactsDir, 'v3.ppk'); + const v2 = path.join(artifactsDir, LOCAL_UPDATE_FILES.ppkFull); + const v3 = path.join(artifactsDir, LOCAL_UPDATE_FILES.packageFull); const ppkDiff = path.join(artifactsDir, LOCAL_UPDATE_FILES.ppkDiff); bundleTo('e2e/entry.v1.ts', v1); bundleTo('e2e/entry.v2.ts', v2); bundleTo('e2e/entry.v3.ts', v3); - const customDiff = ensureHdiffModule(); - - console.log('Generating ppk diff...'); - await generatePpkDiff(v1, v2, ppkDiff, customDiff); - if (platform === 'android') { const apkPath = path.join( projectRoot, @@ -357,17 +367,36 @@ async function main() { } fs.copyFileSync(apkPath, path.join(artifactsDir, LOCAL_UPDATE_FILES.apk)); - console.log('Generating package diff...'); const packageDiffPath = path.join( artifactsDir, LOCAL_UPDATE_FILES.packageDiff, ); - await generateAndroidPackageDiff( - apkPath, - v3, - packageDiffPath, - customDiff, - ); + + if (useFullFallbackArtifacts) { + console.log( + 'Using full package fallback artifacts for Android on Linux.', + ); + writeFallbackPatch('ppk diff', ppkDiff); + writeFallbackPatch('package diff', packageDiffPath); + } else { + const customDiff = ensureHdiffModule(); + + console.log('Generating ppk diff...'); + await generatePpkDiff(v1, v2, ppkDiff, customDiff); + + console.log('Generating package diff...'); + await generateAndroidPackageDiff( + apkPath, + v3, + packageDiffPath, + customDiff, + ); + } + } else { + const customDiff = ensureHdiffModule(); + + console.log('Generating ppk diff...'); + await generatePpkDiff(v1, v2, ppkDiff, customDiff); } const manifestPath = path.join(artifactsDir, 'manifest.json'); @@ -380,6 +409,7 @@ async function main() { hashes: LOCAL_UPDATE_HASHES, labels: LOCAL_UPDATE_LABELS, files: LOCAL_UPDATE_FILES, + fullFallback: useFullFallbackArtifacts, }, null, 2, From 975f258624ab389d616c720dd2201b1c1b482348 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 22:17:08 +0800 Subject: [PATCH 26/27] fix(e2e): keep ios hdiff format compatible --- .../scripts/prepare-local-update-artifacts.ts | 65 +++---------------- 1 file changed, 9 insertions(+), 56 deletions(-) diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index 130b7ee9..eeaef084 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -155,61 +155,6 @@ function installHdiffModule() { } } -function createHdiffProcessDiff(modulePath: string) { - const hdiffCli = path.join(modulePath, 'bin/hdiffpatch.js'); - if (!fs.existsSync(hdiffCli)) { - throw new Error(`node-hdiffpatch CLI not found: ${hdiffCli}`); - } - - return (oldSource?: Buffer, newSource?: Buffer) => { - if (!oldSource || !newSource) { - throw new Error('node-hdiffpatch diff requires both source buffers.'); - } - - const tempDir = fs.mkdtempSync(path.join(artifactsRoot, 'hdiff-')); - const oldPath = path.join(tempDir, 'old.bin'); - const newPath = path.join(tempDir, 'new.bin'); - const patchPath = path.join(tempDir, 'patch.bin'); - - try { - fs.writeFileSync(oldPath, oldSource); - fs.writeFileSync(newPath, newSource); - - const result = spawnSync( - 'node', - [hdiffCli, 'diff', oldPath, newPath, patchPath], - { - cwd: cliRoot, - encoding: 'utf8', - env: process.env, - timeout: diffTimeoutMs, - }, - ); - if (result.error) { - throw result.error; - } - if (result.status !== 0) { - throw new Error( - [ - `node-hdiffpatch diff failed with exit code ${result.status}`, - result.stdout, - result.stderr, - ] - .filter(Boolean) - .join('\n'), - ); - } - if (!fs.existsSync(patchPath)) { - throw new Error(`node-hdiffpatch did not create patch: ${patchPath}`); - } - - return fs.readFileSync(patchPath); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }; -} - function ensureHdiffModule() { const modulePath = path.join(cliRoot, 'node_modules/node-hdiffpatch'); if (!fs.existsSync(modulePath)) { @@ -219,7 +164,15 @@ function ensureHdiffModule() { if (!fs.existsSync(modulePath)) { throw new Error(`Failed to install node-hdiffpatch under: ${cliRoot}`); } - const customDiff = createHdiffProcessDiff(modulePath); + const hdiffModule = require(modulePath) as { + diff?: (oldSource?: Buffer, newSource?: Buffer) => Buffer; + } & ((oldSource?: Buffer, newSource?: Buffer) => Buffer); + const customDiff = hdiffModule.diff || hdiffModule; + if (typeof customDiff !== 'function') { + throw new Error( + `node-hdiffpatch did not expose a diff function: ${modulePath}`, + ); + } customDiff( Buffer.from('rnu-hdiff-smoke-old'), Buffer.from('rnu-hdiff-smoke-new'), From 8d20eee46c360badd10cc4bde33d79e33520f6f0 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 27 Jun 2026 22:47:08 +0800 Subject: [PATCH 27/27] fix(download): normalize retries and cover progress callback --- src/__tests__/client.test.ts | 66 ++++++++++++++++++++++++++++++++++-- src/client.ts | 2 +- src/type.ts | 2 +- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index f747c0c2..0903ee4f 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -18,6 +18,7 @@ const setupClientMocks = ({ downloadPatchFromPpk = mock(() => Promise.resolve()), downloadPatchFromPackage = mock(() => Promise.resolve()), downloadFullUpdate = mock(() => Promise.resolve()), + addProgressListener = mock(() => ({ remove: mock(() => {}) })), restartApp = mock(() => Promise.resolve()), }: { isFirstTime?: boolean; @@ -27,6 +28,7 @@ const setupClientMocks = ({ downloadPatchFromPpk?: ReturnType; downloadPatchFromPackage?: ReturnType; downloadFullUpdate?: ReturnType; + addProgressListener?: ReturnType; restartApp?: ReturnType; } = {}) => { (globalThis as any).__DEV__ = false; @@ -69,7 +71,7 @@ const setupClientMocks = ({ isRolledBack: false, packageVersion: '1.0.0', pushyNativeEventEmitter: { - addListener: mock(() => ({ remove: mock(() => {}) })), + addListener: addProgressListener, }, rolledBackVersion: '', setLocalHashInfo: mock(() => {}), @@ -376,15 +378,18 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPpk = mock(() => Promise.resolve()), downloadPatchFromPackage = mock(() => Promise.resolve()), downloadFullUpdate = mock(() => Promise.resolve()), + addProgressListener = mock(() => ({ remove: mock(() => {}) })), }: { downloadPatchFromPpk?: ReturnType; downloadPatchFromPackage?: ReturnType; downloadFullUpdate?: ReturnType; + addProgressListener?: ReturnType; } = {}) => { setupClientMocks({ downloadPatchFromPpk, downloadPatchFromPackage, downloadFullUpdate, + addProgressListener, }); // Override setTimeout to skip real backoff delays in retry tests @@ -396,7 +401,9 @@ describe('downloadUpdate fallback chain', () => { __esModule: true, assertWeb: () => true, computeProgress: (received: number, total: number) => - total > 0 ? Math.floor((received / total) * 100) : 0, + total > 0 + ? Math.min(100, Math.max(0, Math.floor((received / total) * 100))) + : 0, DEFAULT_FETCH_TIMEOUT_MS: 5000, emptyObj: {}, fetchWithTimeout: mock(() => Promise.resolve()), @@ -436,6 +443,45 @@ describe('downloadUpdate fallback chain', () => { expect(downloadPatchFromPpk).toHaveBeenCalledTimes(1); }); + test('adds computed progress to download progress callbacks', async () => { + let progressListener: + | ((data: { + hash: string; + received: number; + total: number; + }) => void) + | undefined; + const addProgressListener = mock( + (_event: string, listener: typeof progressListener) => { + progressListener = listener; + return { remove: mock(() => {}) }; + }, + ); + const onDownloadProgress = mock(() => {}); + setupDownloadMocks({ + addProgressListener, + downloadPatchFromPpk: mock(async () => { + progressListener?.({ + hash: 'new-hash', + received: 1200, + total: 1000, + }); + }), + }); + const { Pushy, sharedState } = await importFreshClient('dl-progress'); + sharedState.downloadedHash = undefined; + const client = new Pushy({ appKey: 'demo-app' }); + + await client.downloadUpdate(updateInfo, onDownloadProgress); + + expect(onDownloadProgress).toHaveBeenCalledWith({ + hash: 'new-hash', + received: 1200, + total: 1000, + progress: 100, + }); + }); + test('falls back to pdiff when diff fails', async () => { const { downloadPatchFromPpk, downloadPatchFromPackage } = setupDownloadMocks({ @@ -487,6 +533,22 @@ describe('downloadUpdate fallback chain', () => { ); }); + test('treats negative maxRetries as zero retries', async () => { + const { downloadFullUpdate } = setupDownloadMocks({ + downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))), + downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), + downloadFullUpdate: mock(() => Promise.reject(Error('full fail'))), + }); + const { Pushy, sharedState } = await importFreshClient('dl-negative-retries'); + sharedState.downloadedHash = undefined; + const client = new Pushy({ appKey: 'demo-app', maxRetries: -1 }); + + await expect(client.downloadUpdate(updateInfo)).rejects.toThrow( + 'error_full_patch_failed', + ); + expect(downloadFullUpdate).toHaveBeenCalledTimes(1); + }); + test('retries download when maxRetries is set', async () => { let callCount = 0; const { downloadFullUpdate } = setupDownloadMocks({ diff --git a/src/client.ts b/src/client.ts index 70180f57..294281c1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -520,7 +520,7 @@ export class Pushy { ); } } - const maxRetries = this.options.maxRetries ?? 3; + const maxRetries = Math.max(0, Math.floor(this.options.maxRetries ?? 3)); let succeeded = ''; let lastError: any; const errorMessages: string[] = []; diff --git a/src/type.ts b/src/type.ts index 96abc5b4..224b34fb 100644 --- a/src/type.ts +++ b/src/type.ts @@ -33,7 +33,7 @@ export interface ProgressData { hash: string; received: number; total: number; - /** Download progress percentage (0-100), computed as Math.floor(received / total * 100). Only populated in downloadUpdate callbacks. */ + /** Download progress percentage (0-100), computed from received / total and clamped to range. Only populated in downloadUpdate callbacks. */ progress?: number; }