Files
minstrel/web/src/lib/player/stallRetry.test.ts
T
bvandeusen 7bc254e17e feat(web): stream resilience — preload=auto, stall recovery, next-track prefetch
Phases 1+2 of the background-play resilience work. Net effect on
desktop: gap-free track transitions on slow networks, automatic
recovery from transient stalls / network errors mid-track.

- preload="metadata" → "auto" on the main audio element so the
  browser pre-buffers more aggressively.
- attachStallRetry() — listens for stalled / waiting / network-error
  events. Schedules a reload-from-current-position after delayMs
  (3s default) if the buffer hasn't recovered. MEDIA_ERR_NETWORK
  triggers immediate retry. Bounded by maxRetries (3 default) to
  prevent tight loops on persistent failures.
- audioLoader seam — `lib/player/audioLoader.ts` exposes a
  resolve(track) → URL + optional prefetch(track) hint. Default
  returns track.stream_url; the planned Tauri shell can swap via
  setAudioLoader() to return blob URLs backed by a Rust cache,
  mirroring the Flutter drift+LockCachingAudioSource pattern.
- Hidden prefetch <audio> element. When current track passes 50%,
  start loading queue[index+1]. On track-end the swap is gap-free
  via browser HTTP cache + audio buffer warm-up.

Phase 3 (service worker chunk cache) deliberately deferred — that
work belongs in the Tauri shell as native Rust caching, not as a
web-only investment that would compete with it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:43:41 -04:00

134 lines
3.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { attachStallRetry } from './stallRetry';
// Minimal HTMLAudioElement stand-in for vitest. Real DOM elements
// are over-featured; we only need the methods + addEventListener.
function makeAudioStub() {
const handlers = new Map<string, Set<EventListener>>();
const stub = {
currentTime: 0,
paused: false,
readyState: 4,
error: null as MediaError | null,
load: vi.fn(),
play: vi.fn().mockResolvedValue(undefined),
addEventListener(type: string, fn: EventListener) {
if (!handlers.has(type)) handlers.set(type, new Set());
handlers.get(type)!.add(fn);
},
removeEventListener(type: string, fn: EventListener) {
handlers.get(type)?.delete(fn);
},
fire(type: string) {
handlers.get(type)?.forEach((fn) => fn(new Event(type)));
}
};
return stub;
}
describe('attachStallRetry', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test('stalled event triggers reload after delayMs when buffer not recovered', () => {
const el = makeAudioStub();
el.currentTime = 42;
el.readyState = 1; // not yet HAVE_FUTURE_DATA
attachStallRetry(el as unknown as HTMLAudioElement, { delayMs: 1000 });
el.fire('stalled');
expect(el.load).not.toHaveBeenCalled(); // not yet — waiting
vi.advanceTimersByTime(1000);
expect(el.load).toHaveBeenCalledTimes(1);
expect(el.currentTime).toBe(42); // restored after load()
expect(el.play).toHaveBeenCalled(); // wasPlaying = true (paused: false)
});
test('skips reload when readyState recovered before delay elapses', () => {
const el = makeAudioStub();
el.readyState = 1;
attachStallRetry(el as unknown as HTMLAudioElement, { delayMs: 1000 });
el.fire('stalled');
el.readyState = 4; // recovered
vi.advanceTimersByTime(1000);
expect(el.load).not.toHaveBeenCalled();
});
test('canplay clears retry counter so a later stall can retry again', () => {
const el = makeAudioStub();
el.readyState = 1;
attachStallRetry(el as unknown as HTMLAudioElement, {
delayMs: 100,
maxRetries: 1
});
el.fire('stalled');
vi.advanceTimersByTime(100);
expect(el.load).toHaveBeenCalledTimes(1);
el.fire('canplay'); // resets retry count
el.fire('stalled');
vi.advanceTimersByTime(100);
expect(el.load).toHaveBeenCalledTimes(2);
});
test('maxRetries caps recovery attempts', () => {
const el = makeAudioStub();
el.readyState = 1;
attachStallRetry(el as unknown as HTMLAudioElement, {
delayMs: 100,
maxRetries: 2
});
for (let i = 0; i < 5; i++) {
el.fire('stalled');
vi.advanceTimersByTime(100);
}
expect(el.load).toHaveBeenCalledTimes(2);
});
test('MEDIA_ERR_NETWORK error triggers immediate reload', () => {
const el = makeAudioStub();
el.error = { code: 2 } as MediaError;
el.currentTime = 17;
attachStallRetry(el as unknown as HTMLAudioElement);
el.fire('error');
expect(el.load).toHaveBeenCalled();
expect(el.currentTime).toBe(17);
});
test('non-network errors (DECODE, SRC_NOT_SUPPORTED) are not retried', () => {
const el = makeAudioStub();
el.error = { code: 3 } as MediaError; // MEDIA_ERR_DECODE
attachStallRetry(el as unknown as HTMLAudioElement);
el.fire('error');
expect(el.load).not.toHaveBeenCalled();
});
test('teardown removes listeners and clears pending timer', () => {
const el = makeAudioStub();
el.readyState = 1;
const teardown = attachStallRetry(el as unknown as HTMLAudioElement, {
delayMs: 100
});
el.fire('stalled');
teardown();
vi.advanceTimersByTime(100);
expect(el.load).not.toHaveBeenCalled();
// After teardown, firing events does nothing.
el.fire('stalled');
vi.advanceTimersByTime(100);
expect(el.load).not.toHaveBeenCalled();
});
});