Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/player/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"build": "tsup src/index.ts --format esm,cjs --dts --clean --sourcemap",
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run"
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@scormflow/sdk": "workspace:*"
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"build": "tsup src/index.ts --format esm,cjs --dts --clean --sourcemap --external react",
"dev": "tsup src/index.ts --format esm,cjs --dts --watch --external react",
"typecheck": "tsc --noEmit",
"test": "vitest run"
"test": "vitest run --passWithNoTests"
},
"peerDependencies": {
"react": ">=18",
Expand Down
105 changes: 105 additions & 0 deletions packages/sdk/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import {
ScormError,
ScormHttpError,
ScormNetworkError,
ScormTimeoutError,
ScormAbortError,
isRetryableStatus,
} from './errors.js';

describe('ScormError', () => {
it('is an Error subclass with the correct name', () => {
const e = new ScormError('boom');
expect(e).toBeInstanceOf(Error);
expect(e.name).toBe('ScormError');
expect(e.message).toBe('boom');
});

it('attaches the cause when provided', () => {
const cause = new Error('underlying');
const e = new ScormError('boom', { cause });
expect((e as Error & { cause?: unknown }).cause).toBe(cause);
});
});

describe('ScormHttpError', () => {
it('lifts code, requestId, and details from the response body', () => {
const e = new ScormHttpError(
404,
{ code: 'course_not_found', message: 'no such course', requestId: 'req-1', details: { id: 'x' } },
'fallback',
{ code: 'course_not_found' },
);
expect(e.status).toBe(404);
expect(e.code).toBe('course_not_found');
expect(e.message).toBe('no such course');
expect(e.requestId).toBe('req-1');
expect(e.details).toEqual({ id: 'x' });
expect(e.name).toBe('ScormHttpError');
});

it('falls back to fallbackMessage and `http_<status>` code when body is missing fields', () => {
const e = new ScormHttpError(500, undefined, 'POST /x → 500');
expect(e.code).toBe('http_500');
expect(e.message).toBe('POST /x → 500');
expect(e.requestId).toBeUndefined();
expect(e.details).toBeUndefined();
});

it('preserves the raw body for inspection', () => {
const raw = { code: 'oops', extra: 'field' };
const e = new ScormHttpError(500, raw, 'fallback', raw);
expect(e.body).toBe(raw);
});
});

describe('ScormNetworkError', () => {
it('captures the original cause', () => {
const cause = new TypeError('fetch failed');
const e = new ScormNetworkError('network down', cause);
expect(e.name).toBe('ScormNetworkError');
expect((e as Error & { cause?: unknown }).cause).toBe(cause);
});

it('works without a cause', () => {
const e = new ScormNetworkError('network down');
expect((e as Error & { cause?: unknown }).cause).toBeUndefined();
});
});

describe('ScormTimeoutError', () => {
it('reports the timeout in the message and exposes timeoutMs', () => {
const e = new ScormTimeoutError(5000);
expect(e.name).toBe('ScormTimeoutError');
expect(e.timeoutMs).toBe(5000);
expect(e.message).toMatch(/5000ms/);
});
});

describe('ScormAbortError', () => {
it('has a fixed message and name', () => {
const e = new ScormAbortError();
expect(e.name).toBe('ScormAbortError');
expect(e.message).toBe('Request was aborted');
});
});

describe('isRetryableStatus', () => {
it('marks 408, 429, and 5xx as retryable', () => {
expect(isRetryableStatus(408)).toBe(true);
expect(isRetryableStatus(429)).toBe(true);
expect(isRetryableStatus(500)).toBe(true);
expect(isRetryableStatus(503)).toBe(true);
expect(isRetryableStatus(599)).toBe(true);
});

it('marks 2xx, 3xx, and other 4xx as non-retryable', () => {
expect(isRetryableStatus(200)).toBe(false);
expect(isRetryableStatus(301)).toBe(false);
expect(isRetryableStatus(400)).toBe(false);
expect(isRetryableStatus(401)).toBe(false);
expect(isRetryableStatus(404)).toBe(false);
expect(isRetryableStatus(422)).toBe(false);
});
});
97 changes: 97 additions & 0 deletions packages/sdk/src/http/retry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { computeBackoff, parseRetryAfter, sleep } from './retry.js';

describe('computeBackoff', () => {
it('grows exponentially up to maxMs', () => {
expect(computeBackoff({ baseMs: 100, maxMs: 10_000, attempt: 1, jitter: false })).toBe(100);
expect(computeBackoff({ baseMs: 100, maxMs: 10_000, attempt: 2, jitter: false })).toBe(200);
expect(computeBackoff({ baseMs: 100, maxMs: 10_000, attempt: 3, jitter: false })).toBe(400);
expect(computeBackoff({ baseMs: 100, maxMs: 10_000, attempt: 4, jitter: false })).toBe(800);
});

it('caps growth at maxMs', () => {
expect(computeBackoff({ baseMs: 1000, maxMs: 2000, attempt: 5, jitter: false })).toBe(2000);
});

it('uses the injected random fn when jitter is on', () => {
const value = computeBackoff({
baseMs: 100,
maxMs: 10_000,
attempt: 4,
jitter: true,
random: () => 0.5,
});
expect(value).toBe(400);
});

it('floors jittered values', () => {
const value = computeBackoff({
baseMs: 100,
maxMs: 10_000,
attempt: 1,
jitter: true,
random: () => 0.999,
});
expect(Number.isInteger(value)).toBe(true);
});

it('treats attempt <= 0 as the first attempt', () => {
expect(computeBackoff({ baseMs: 100, maxMs: 10_000, attempt: 0, jitter: false })).toBe(100);
expect(computeBackoff({ baseMs: 100, maxMs: 10_000, attempt: -3, jitter: false })).toBe(100);
});
});

describe('parseRetryAfter', () => {
it('returns null for missing header', () => {
expect(parseRetryAfter(null)).toBeNull();
});

it('parses seconds as a number', () => {
expect(parseRetryAfter('5')).toBe(5000);
expect(parseRetryAfter('0')).toBe(0);
});

it('parses HTTP-date and returns milliseconds from now', () => {
const future = new Date('2030-01-01T00:00:30Z').toUTCString();
const result = parseRetryAfter(future, () => Date.parse('2030-01-01T00:00:00Z'));
expect(result).toBe(30_000);
});

it('clamps past dates to 0', () => {
const past = new Date('2000-01-01T00:00:00Z').toUTCString();
const result = parseRetryAfter(past, () => Date.parse('2030-01-01T00:00:00Z'));
expect(result).toBe(0);
});

it('returns null for garbage', () => {
expect(parseRetryAfter('not-a-date-or-number')).toBeNull();
});
});

describe('sleep', () => {
it('resolves after the given delay', async () => {
const start = Date.now();
await sleep(20);
expect(Date.now() - start).toBeGreaterThanOrEqual(15);
});

it('resolves immediately for non-positive delays', async () => {
const start = Date.now();
await sleep(0);
await sleep(-100);
expect(Date.now() - start).toBeLessThan(10);
});

it('rejects when the signal is aborted mid-sleep', async () => {
const controller = new AbortController();
const p = sleep(1000, controller.signal);
controller.abort();
await expect(p).rejects.toBeDefined();
});

it('rejects immediately when the signal is already aborted', async () => {
const controller = new AbortController();
controller.abort();
await expect(sleep(1000, controller.signal)).rejects.toBeDefined();
});
});
Loading