From 7df17270da9a52a52b32daff4fccade058b508fd Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Fri, 26 Jun 2026 14:23:13 +0800 Subject: [PATCH] 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],