diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index bc90fd35..ded1addf 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -13,9 +13,15 @@ const createJsonResponse = (payload: unknown) => const setupClientMocks = ({ isFirstTime = false, markSuccess = mock(() => {}), + downloadPatchFromPpk = mock(() => Promise.resolve()), + downloadPatchFromPackage = mock(() => Promise.resolve()), + downloadFullUpdate = mock(() => Promise.resolve()), }: { isFirstTime?: boolean; markSuccess?: ReturnType; + downloadPatchFromPpk?: ReturnType; + downloadPatchFromPackage?: ReturnType; + downloadFullUpdate?: ReturnType; } = {}) => { (globalThis as any).__DEV__ = false; @@ -38,9 +44,9 @@ const setupClientMocks = ({ markSuccess, reloadUpdate: mock(() => Promise.resolve()), setNeedUpdate: mock(() => Promise.resolve()), - downloadPatchFromPpk: mock(() => Promise.resolve()), - downloadPatchFromPackage: mock(() => Promise.resolve()), - downloadFullUpdate: mock(() => Promise.resolve()), + downloadPatchFromPpk, + downloadPatchFromPackage, + downloadFullUpdate, downloadAndInstallApk: mock(() => Promise.resolve()), restartApp: mock(() => Promise.resolve()), }, @@ -206,6 +212,42 @@ describe('Pushy server config', () => { }); }); + test('skips downloading when update hash is already current', async () => { + const downloadPatchFromPpk = mock(() => Promise.resolve()); + const downloadPatchFromPackage = mock(() => Promise.resolve()); + const downloadFullUpdate = mock(() => Promise.resolve()); + const logger = mock(() => {}); + setupClientMocks({ + downloadPatchFromPpk, + downloadPatchFromPackage, + downloadFullUpdate, + }); + + const { Pushy } = await importFreshClient('skip-current-hash-download'); + const client = new Pushy({ + appKey: 'demo-app', + logger, + }); + + await expect( + client.downloadUpdate({ + update: true, + hash: 'hash', + full: 'hash', + paths: ['cdn.example.com'], + }), + ).resolves.toBeUndefined(); + + expect(downloadPatchFromPpk).not.toHaveBeenCalled(); + expect(downloadPatchFromPackage).not.toHaveBeenCalled(); + expect(downloadFullUpdate).not.toHaveBeenCalled(); + expect(logger).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: 'downloading', + }), + ); + }); + test('waits for native markSuccess before logging success', async () => { let resolveNativeMarkSuccess = () => {}; const nativeMarkSuccess = mock( diff --git a/src/__tests__/provider.test.ts b/src/__tests__/provider.test.ts new file mode 100644 index 00000000..129dcc63 --- /dev/null +++ b/src/__tests__/provider.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'bun:test'; +import { currentVersion, packageVersion } from '../core'; +import { resolveCheckResult } from '../resolveCheckResult'; +import type { CheckResult } from '../type'; + +const createRootResult = (overrides: Partial = {}): CheckResult => ({ + update: true, + hash: 'full-hash', + name: 'full-version', + description: 'full description', + metaInfo: 'full meta', + diff: 'current-full.hdiff', + pdiff: 'package-full.phdiff', + full: 'full-hash', + paths: ['cdn.example.com'], + ...overrides, +}); + +describe('resolveCheckResult', () => { + test('returns upToDate when rollout target is already current', () => { + const result = resolveCheckResult( + createRootResult({ + expVersion: { + name: 'gray-current', + hash: currentVersion, + description: 'gray description', + metaInfo: 'gray meta', + config: { + rollout: { + [packageVersion]: 100, + }, + }, + }, + }), + ); + + expect(result).toEqual({ upToDate: true }); + }); + + test('does not inherit root diff artifacts for rollout target', () => { + const result = resolveCheckResult( + createRootResult({ + expVersion: { + name: 'gray-next', + hash: 'gray-hash', + description: 'gray description', + metaInfo: 'gray meta', + config: { + rollout: { + [packageVersion]: 100, + }, + }, + }, + }), + ); + + expect(result).toEqual({ + update: true, + hash: 'gray-hash', + name: 'gray-next', + description: 'gray description', + metaInfo: 'gray meta', + config: { + rollout: { + [packageVersion]: 100, + }, + }, + paths: ['cdn.example.com'], + }); + }); + + test('falls back to root result when rollout target is not selected', () => { + const result = resolveCheckResult( + createRootResult({ + expVersion: { + name: 'gray-next', + hash: 'gray-hash', + description: 'gray description', + metaInfo: 'gray meta', + config: { + rollout: { + [packageVersion]: 0, + }, + }, + }, + }), + ); + + expect(result).toEqual(createRootResult()); + }); + + test('returns upToDate when root target is already current', () => { + const result = resolveCheckResult( + createRootResult({ hash: currentVersion }), + ); + + expect(result).toEqual({ upToDate: true }); + }); +}); diff --git a/src/client.ts b/src/client.ts index b9849076..a24c10db 100644 --- a/src/client.ts +++ b/src/client.ts @@ -452,6 +452,10 @@ export class Pushy { if (!updateInfo.update || !hash) { return; } + if (hash === currentVersion) { + log(`current hash ${currentVersion}, ignored`); + return; + } if (rolledBackVersion === hash) { log(`rolledback hash ${rolledBackVersion}, ignored`); return; diff --git a/src/provider.tsx b/src/provider.tsx index 08dec9ac..2ee48427 100644 --- a/src/provider.tsx +++ b/src/provider.tsx @@ -23,11 +23,10 @@ import { CheckResult, ProgressData, UpdateTestPayload, - VersionInfo, } from './type'; import { UpdateContext } from './context'; import { URL } from 'react-native-url-polyfill'; -import { isInRollout } from './isInRollout'; +import { resolveCheckResult } from './resolveCheckResult'; import { assertWeb, log } from './utils'; export const UpdateProvider = ({ @@ -181,94 +180,77 @@ export const UpdateProvider = ({ if (!rootInfo) { return; } - const versions = [rootInfo.expVersion, rootInfo].filter( - Boolean, - ) as VersionInfo[]; - delete rootInfo.expVersion; - for (const versionInfo of versions) { - const info: CheckResult = { - ...rootInfo, - ...versionInfo, - }; - const rollout = info.config?.rollout?.[packageVersion]; - if (info.update && rollout) { - if (!isInRollout(rollout)) { - log(`${info.name} not in ${rollout}% rollout, ignored`); - continue; - } - log(`${info.name} in ${rollout}% rollout, continue`); - } - if (info.update) { - info.description = info.description ?? ''; + const info = resolveCheckResult(rootInfo); + if (info.update) { + info.description = info.description ?? ''; + } + updateInfoRef.current = info; + setUpdateInfo(info); + if (info.expired) { + if ( + options.onPackageExpired && + (await options.onPackageExpired(info)) === false + ) { + log('onPackageExpired returned false, skipping'); + return; } - updateInfoRef.current = info; - setUpdateInfo(info); - if (info.expired) { - if ( - options.onPackageExpired && - (await options.onPackageExpired(info)) === false - ) { - log('onPackageExpired returned false, skipping'); - return; - } - const { downloadUrl } = info; - if (downloadUrl && sharedState.apkStatus === null) { - if (options.updateStrategy === 'silentAndNow') { - if (Platform.OS === 'android' && downloadUrl.endsWith('.apk')) { - downloadAndInstallApk(downloadUrl); - } else { - Linking.openURL(downloadUrl); - } - return info; + const { downloadUrl } = info; + if (downloadUrl && sharedState.apkStatus === null) { + if (options.updateStrategy === 'silentAndNow') { + if (Platform.OS === 'android' && downloadUrl.endsWith('.apk')) { + downloadAndInstallApk(downloadUrl); + } else { + Linking.openURL(downloadUrl); } - alertUpdate( - client.t('alert_title'), - client.t('alert_app_updated'), - [ - { - text: client.t('alert_update_button'), - onPress: () => { - if ( - Platform.OS === 'android' && - downloadUrl.endsWith('.apk') - ) { - downloadAndInstallApk(downloadUrl); - } else { - Linking.openURL(downloadUrl); - } - }, - }, - ], - ); - } - } else if (info.update) { - if ( - options.updateStrategy === 'silentAndNow' || - options.updateStrategy === 'silentAndLater' - ) { - downloadUpdate(info); return info; } alertUpdate( client.t('alert_title'), - client.t('alert_new_version_found', { - name: info.name!, - description: info.description!, - }), + client.t('alert_app_updated'), [ - { text: client.t('alert_cancel'), style: 'cancel' }, { - text: client.t('alert_confirm'), - style: 'default', + text: client.t('alert_update_button'), onPress: () => { - downloadUpdate(); + if ( + Platform.OS === 'android' && + downloadUrl.endsWith('.apk') + ) { + downloadAndInstallApk(downloadUrl); + } else { + Linking.openURL(downloadUrl); + } }, }, ], ); } - return info; + } else if (info.update) { + if ( + options.updateStrategy === 'silentAndNow' || + options.updateStrategy === 'silentAndLater' + ) { + downloadUpdate(info); + return info; + } + alertUpdate( + client.t('alert_title'), + client.t('alert_new_version_found', { + name: info.name!, + description: info.description!, + }), + [ + { text: client.t('alert_cancel'), style: 'cancel' }, + { + text: client.t('alert_confirm'), + style: 'default', + onPress: () => { + downloadUpdate(); + }, + }, + ], + ); } + return info; }, [ client, diff --git a/src/resolveCheckResult.ts b/src/resolveCheckResult.ts new file mode 100644 index 00000000..52ccdcb1 --- /dev/null +++ b/src/resolveCheckResult.ts @@ -0,0 +1,30 @@ +import { currentVersion, packageVersion } from './core'; +import { isInRollout } from './isInRollout'; +import { CheckResult } from './type'; +import { log } from './utils'; + +export function resolveCheckResult(rootInfo: CheckResult): CheckResult { + const { expVersion, ...rootResult } = rootInfo; + const rollout = expVersion?.config?.rollout?.[packageVersion]; + if (rootResult.update && expVersion && typeof rollout === 'number') { + if (isInRollout(rollout)) { + log(`${expVersion.name} in ${rollout}% rollout, continue`); + if (expVersion.hash === currentVersion) { + return { upToDate: true }; + } + const info: CheckResult = { + update: true, + ...expVersion, + }; + if (rootResult.paths) { + info.paths = rootResult.paths; + } + return info; + } + log(`${expVersion.name} not in ${rollout}% rollout, ignored`); + } + if (rootResult.update && rootResult.hash === currentVersion) { + return { upToDate: true }; + } + return rootResult; +}