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 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/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/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 b8c7dc62..eeaef084 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -14,14 +14,16 @@ type DiffCommandRunner = { args: [string, string]; options: { output: string; - customDiff: (...args: unknown[]) => unknown; + customDiff: (oldSource?: Buffer, newSource?: Buffer) => Buffer; + 'no-interactive': true; }; }) => Promise; hdiffFromApk: (options: { args: [string, string]; options: { output: string; - customDiff: (...args: unknown[]) => unknown; + customDiff: (oldSource?: Buffer, newSource?: Buffer) => Buffer; + 'no-interactive': true; }; }) => Promise; }; @@ -30,8 +32,12 @@ 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'; +const useFullFallbackArtifacts = + process.env.RNU_E2E_FULL_FALLBACK === 'true' || + (platform === 'android' && process.platform === 'linux'); function resolveCliRoot() { const candidates = [ @@ -41,8 +47,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,10 +58,19 @@ function resolveCliRoot() { } const cliRoot = resolveCliRoot(); -const cliEntry = path.join(cliRoot, 'lib/index.js'); -const { diffCommands } = require(path.join(cliRoot, 'lib/diff.js')) as { - diffCommands: DiffCommandRunner; -}; +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); if (!['ios', 'android'].includes(platform)) { throw new Error(`Unsupported E2E_PLATFORM: ${platform}`); @@ -66,22 +80,33 @@ 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 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, }, + timeout: 120_000, }); + if (result.error) { + throw result.error; + } if (result.status !== 0) { throw new Error( `pushy ${args.join(' ')} failed with exit code ${result.status}`, @@ -89,39 +114,70 @@ function runPushy(args: string[], cwd: string) { } } +function installHdiffModule() { + const bunResult = spawnSync( + 'bun', + ['add', '--no-save', '--trust', '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}`); } const hdiffModule = require(modulePath) as { - diff?: (...args: unknown[]) => unknown; - } & ((...args: unknown[]) => unknown); - return hdiffModule.diff || hdiffModule; + 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'), + ); + return customDiff; } function prepareDir() { @@ -130,6 +186,7 @@ function prepareDir() { } function bundleTo(entryFile: string, outputFile: string) { + console.log(`Bundling ${entryFile} -> ${outputFile}`); runPushy( [ 'bundle', @@ -146,46 +203,109 @@ function bundleTo(entryFile: string, outputFile: string) { ], projectRoot, ); + verifyGeneratedFile(`bundle ${entryFile}`, outputFile); } -async function generatePpkDiff(origin: string, next: string, output: string) { - const customDiff = ensureHdiffModule(); - await diffCommands.hdiff({ - args: [origin, next], - options: { - output, - customDiff, - }, +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 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, + 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.race([promise, timeoutPromise]); + } finally { + clearInterval(timer); + if (timeout) { + clearTimeout(timeout); + } + } +} + +async function generatePpkDiff( + origin: string, + next: string, + 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: { + output, + customDiff, + 'no-interactive': true, + }, + }), + ); + verifyGeneratedFile('ppk diff', output); } async function generateAndroidPackageDiff( apkPath: string, next: string, output: string, + customDiff: (oldSource?: Buffer, newSource?: Buffer) => Buffer, ) { - const customDiff = ensureHdiffModule(); - await diffCommands.hdiffFromApk({ - args: [apkPath, next], - options: { - output, - customDiff, - }, - }); + 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: { + output, + customDiff, + 'no-interactive': true, + }, + }), + ); + verifyGeneratedFile('package diff', output); } 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); - await generatePpkDiff(v1, v2, ppkDiff); if (platform === 'android') { const apkPath = path.join( @@ -200,15 +320,41 @@ async function main() { } fs.copyFileSync(apkPath, path.join(artifactsDir, LOCAL_UPDATE_FILES.apk)); - await generateAndroidPackageDiff( - apkPath, - v3, - path.join(artifactsDir, LOCAL_UPDATE_FILES.packageDiff), + const packageDiffPath = path.join( + artifactsDir, + LOCAL_UPDATE_FILES.packageDiff, ); + + 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'); fs.writeFileSync( - path.join(artifactsDir, 'manifest.json'), + manifestPath, JSON.stringify( { platform, @@ -216,14 +362,21 @@ async function main() { hashes: LOCAL_UPDATE_HASHES, labels: LOCAL_UPDATE_LABELS, files: LOCAL_UPDATE_FILES, + fullFallback: useFullFallbackArtifacts, }, null, 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); + }); diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 0aa7e473..0903ee4f 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}`); @@ -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(() => {}), @@ -364,3 +366,301 @@ describe('Pushy server config', () => { expect(restartApp).toHaveBeenCalled(); }); }); + +describe('downloadUpdate fallback chain', () => { + const realSetTimeout = globalThis.setTimeout; + + afterEach(() => { + globalThis.setTimeout = realSetTimeout; + }); + + const setupDownloadMocks = ({ + 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 + 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.min(100, Math.max(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('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({ + 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('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({ + 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..4204669f 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,38 @@ 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('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); + }); + + 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..294281c1 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 = Math.max(0, Math.floor(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..224b34fb 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 from received / total and clamped to range. 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..9237d1b2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -108,6 +108,11 @@ export const assertWeb = () => { return true; }; +export const computeProgress = (received: number, total: number): number => + total > 0 + ? Math.min(100, Math.max(0, Math.floor((received / total) * 100))) + : 0; + export const fetchWithTimeout = ( url: string, params: Parameters[1],