Skip to content

Commit 7df1727

Browse files
committed
feat: progress percentage, download retry, fallback chain tests, Cresc tests, globals.d.ts
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
1 parent 311cefd commit 7df1727

6 files changed

Lines changed: 349 additions & 57 deletions

File tree

src/__tests__/client.test.ts

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,3 +364,236 @@ describe('Pushy server config', () => {
364364
expect(restartApp).toHaveBeenCalled();
365365
});
366366
});
367+
368+
describe('downloadUpdate fallback chain', () => {
369+
const setupDownloadMocks = ({
370+
downloadPatchFromPpk = mock(() => Promise.resolve()),
371+
downloadPatchFromPackage = mock(() => Promise.resolve()),
372+
downloadFullUpdate = mock(() => Promise.resolve()),
373+
}: {
374+
downloadPatchFromPpk?: ReturnType<typeof mock>;
375+
downloadPatchFromPackage?: ReturnType<typeof mock>;
376+
downloadFullUpdate?: ReturnType<typeof mock>;
377+
} = {}) => {
378+
setupClientMocks({
379+
downloadPatchFromPpk,
380+
downloadPatchFromPackage,
381+
downloadFullUpdate,
382+
});
383+
384+
// Override setTimeout to skip real backoff delays in retry tests
385+
const realSetTimeout = globalThis.setTimeout.bind(globalThis);
386+
globalThis.setTimeout = ((fn: (...args: any[]) => void, _ms?: number) =>
387+
realSetTimeout(fn, 0)) as unknown as typeof setTimeout;
388+
389+
// Mock testUrls to return urls directly (skip actual HEAD ping)
390+
mock.module('../utils', () => ({
391+
__esModule: true,
392+
assertWeb: () => true,
393+
computeProgress: (received: number, total: number) =>
394+
total > 0 ? Math.floor((received / total) * 100) : 0,
395+
DEFAULT_FETCH_TIMEOUT_MS: 5000,
396+
emptyObj: {},
397+
fetchWithTimeout: mock(() => Promise.resolve()),
398+
info: mock(() => {}),
399+
joinUrls: (paths: string[], fileName?: string) =>
400+
fileName ? paths.map(p => `${p}/${fileName}`) : undefined,
401+
log: mock(() => {}),
402+
noop: () => {},
403+
promiseAny: mock(() => Promise.resolve()),
404+
testUrls: (urls?: string[]) =>
405+
Promise.resolve(urls?.[0] || null),
406+
}));
407+
408+
return { downloadPatchFromPpk, downloadPatchFromPackage, downloadFullUpdate };
409+
};
410+
411+
const updateInfo = {
412+
update: true as const,
413+
hash: 'new-hash',
414+
diff: 'diff.ppk',
415+
pdiff: 'pdiff.ppk',
416+
full: 'full.ppk',
417+
paths: ['https://cdn.example.com'],
418+
name: 'v2.0',
419+
description: 'test update',
420+
};
421+
422+
test('uses diff when available', async () => {
423+
const { downloadPatchFromPpk } = setupDownloadMocks();
424+
const { Pushy, sharedState } = await importFreshClient('dl-diff-ok');
425+
sharedState.downloadedHash = undefined;
426+
const client = new Pushy({ appKey: 'demo-app' });
427+
428+
const hash = await client.downloadUpdate(updateInfo);
429+
430+
expect(hash).toBe('new-hash');
431+
expect(downloadPatchFromPpk).toHaveBeenCalledTimes(1);
432+
});
433+
434+
test('falls back to pdiff when diff fails', async () => {
435+
const { downloadPatchFromPpk, downloadPatchFromPackage } =
436+
setupDownloadMocks({
437+
downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))),
438+
});
439+
const { Pushy, sharedState } = await importFreshClient('dl-fallback-pdiff');
440+
sharedState.downloadedHash = undefined;
441+
const client = new Pushy({ appKey: 'demo-app' });
442+
443+
const hash = await client.downloadUpdate(updateInfo);
444+
445+
expect(hash).toBe('new-hash');
446+
expect(downloadPatchFromPpk).toHaveBeenCalledTimes(1);
447+
expect(downloadPatchFromPackage).toHaveBeenCalledTimes(1);
448+
});
449+
450+
test('falls back to full when diff and pdiff fail', async () => {
451+
const { downloadPatchFromPpk, downloadPatchFromPackage, downloadFullUpdate } =
452+
setupDownloadMocks({
453+
downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))),
454+
downloadPatchFromPackage: mock(() =>
455+
Promise.reject(Error('pdiff fail')),
456+
),
457+
});
458+
const { Pushy, sharedState } = await importFreshClient('dl-fallback-full');
459+
sharedState.downloadedHash = undefined;
460+
const client = new Pushy({ appKey: 'demo-app' });
461+
462+
const hash = await client.downloadUpdate(updateInfo);
463+
464+
expect(hash).toBe('new-hash');
465+
expect(downloadPatchFromPpk).toHaveBeenCalledTimes(1);
466+
expect(downloadPatchFromPackage).toHaveBeenCalledTimes(1);
467+
expect(downloadFullUpdate).toHaveBeenCalledTimes(1);
468+
});
469+
470+
test('throws when all download methods fail', async () => {
471+
setupDownloadMocks({
472+
downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))),
473+
downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))),
474+
downloadFullUpdate: mock(() => Promise.reject(Error('full fail'))),
475+
});
476+
const { Pushy, sharedState } = await importFreshClient('dl-all-fail');
477+
sharedState.downloadedHash = undefined;
478+
const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 });
479+
480+
await expect(client.downloadUpdate(updateInfo)).rejects.toThrow(
481+
'error_full_patch_failed',
482+
);
483+
});
484+
485+
test('retries download when maxRetries is set', async () => {
486+
let callCount = 0;
487+
const { downloadFullUpdate } = setupDownloadMocks({
488+
downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))),
489+
downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))),
490+
downloadFullUpdate: mock(() => {
491+
callCount++;
492+
if (callCount === 1) {
493+
return Promise.reject(Error('full fail attempt 1'));
494+
}
495+
return Promise.resolve();
496+
}),
497+
});
498+
const { Pushy, sharedState } = await importFreshClient('dl-retry-ok');
499+
sharedState.downloadedHash = undefined;
500+
const client = new Pushy({ appKey: 'demo-app', maxRetries: 2 });
501+
502+
const hash = await client.downloadUpdate(updateInfo);
503+
504+
expect(hash).toBe('new-hash');
505+
expect(downloadFullUpdate).toHaveBeenCalledTimes(2);
506+
});
507+
508+
test('defaults to 3 retries when maxRetries is not set', async () => {
509+
const { downloadFullUpdate } = setupDownloadMocks({
510+
downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))),
511+
downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))),
512+
downloadFullUpdate: mock(() => Promise.reject(Error('full fail'))),
513+
});
514+
const { Pushy, sharedState } = await importFreshClient('dl-default-retries');
515+
sharedState.downloadedHash = undefined;
516+
const client = new Pushy({ appKey: 'demo-app' });
517+
518+
await expect(client.downloadUpdate(updateInfo)).rejects.toThrow();
519+
// 1 initial + 3 retries = 4 calls
520+
expect(downloadFullUpdate).toHaveBeenCalledTimes(4);
521+
});
522+
523+
test('exhausts retries and throws on persistent failure', async () => {
524+
setupDownloadMocks({
525+
downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))),
526+
downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))),
527+
downloadFullUpdate: mock(() => Promise.reject(Error('full fail'))),
528+
});
529+
const { Pushy, sharedState } = await importFreshClient('dl-retry-exhaust');
530+
sharedState.downloadedHash = undefined;
531+
const client = new Pushy({ appKey: 'demo-app', maxRetries: 2 });
532+
533+
await expect(client.downloadUpdate(updateInfo)).rejects.toThrow(
534+
'error_full_patch_failed',
535+
);
536+
});
537+
});
538+
539+
describe('Cresc class', () => {
540+
test('uses Cresc server endpoints', async () => {
541+
setupClientMocks();
542+
543+
const { Cresc } = await importFreshClient('cresc-endpoints');
544+
const client = new Cresc({ appKey: 'demo-app' });
545+
546+
expect(client.getConfiguredCheckEndpoints()).toEqual([
547+
'https://api.cresc.dev',
548+
'https://api.cresc.app',
549+
]);
550+
});
551+
552+
test('defaults locale to en for Cresc', async () => {
553+
setupClientMocks();
554+
// Override i18n mock AFTER setupClientMocks to avoid being overwritten
555+
const setLocale = mock(() => {});
556+
mock.module('../i18n', () => ({
557+
default: {
558+
t: (key: string) => key,
559+
setLocale,
560+
},
561+
}));
562+
563+
const { Cresc } = await importFreshClient('cresc-locale');
564+
const client = new Cresc({ appKey: 'demo-app' });
565+
566+
expect(client.clientType).toBe('Cresc');
567+
expect(setLocale).toHaveBeenCalledWith('en');
568+
});
569+
570+
test('Cresc is instance of Pushy', async () => {
571+
setupClientMocks();
572+
573+
const { Cresc, Pushy } = await importFreshClient('cresc-instanceof');
574+
const client = new Cresc({ appKey: 'demo-app' });
575+
576+
expect(client).toBeInstanceOf(Pushy);
577+
expect(client).toBeInstanceOf(Cresc);
578+
});
579+
580+
test('Cresc custom server overrides default endpoints', async () => {
581+
setupClientMocks();
582+
583+
const { Cresc } = await importFreshClient('cresc-custom-server');
584+
const client = new Cresc({
585+
appKey: 'demo-app',
586+
server: {
587+
main: ['https://custom.example.com'],
588+
queryUrls: ['https://q.example.com'],
589+
},
590+
});
591+
592+
expect(client.getConfiguredCheckEndpoints()).toEqual([
593+
'https://custom.example.com',
594+
]);
595+
expect(client.options.server?.queryUrls).toEqual([
596+
'https://q.example.com',
597+
]);
598+
});
599+
});

src/__tests__/utils.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ mock.module('../i18n', () => {
1616
};
1717
});
1818

19-
import { joinUrls } from '../utils';
19+
import { joinUrls, computeProgress } from '../utils';
2020

2121
describe('joinUrls', () => {
2222
test('returns undefined when fileName is not provided', () => {
@@ -71,3 +71,30 @@ describe('joinUrls', () => {
7171
]);
7272
});
7373
});
74+
75+
describe('computeProgress', () => {
76+
test('returns 0 when total is 0', () => {
77+
expect(computeProgress(0, 0)).toBe(0);
78+
});
79+
80+
test('returns 0 when received is 0', () => {
81+
expect(computeProgress(0, 1000)).toBe(0);
82+
});
83+
84+
test('returns 100 when received equals total', () => {
85+
expect(computeProgress(1000, 1000)).toBe(100);
86+
});
87+
88+
test('returns 50 for half progress', () => {
89+
expect(computeProgress(500, 1000)).toBe(50);
90+
});
91+
92+
test('floors fractional percentages', () => {
93+
expect(computeProgress(1, 3)).toBe(33);
94+
expect(computeProgress(2, 3)).toBe(66);
95+
});
96+
97+
test('handles large numbers', () => {
98+
expect(computeProgress(50_000_000, 100_000_000)).toBe(50);
99+
});
100+
});

0 commit comments

Comments
 (0)