Files
NodecastV2/test/hls-audio-recovery.test.js
2026-10-13 17:23:00 -07:00

161 lines
7.4 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const HlsAudioRecovery = require('../public/js/components/HlsAudioRecovery');
const events = { FRAG_CHANGED: 'fragment', DESTROYING: 'destroying', MEDIA_ATTACHED: 'attached', MANIFEST_LOADING: 'manifest-loading' };
const tick = () => new Promise(resolve => setTimeout(resolve, 10));
function fixture(t, chromium = true) {
let current = true, now = 10000;
const video = new EventEmitter();
Object.assign(video, { currentTime: 83.5, paused: false, volume: 0.6, muted: true, playbackRate: 1.25,
addEventListener: video.on.bind(video), removeEventListener: video.off.bind(video),
play: async () => { video.paused = false; video.plays++; },
pause: () => { video.paused = true; }, plays: 0 });
const hls = new EventEmitter();
hls.config = { startPosition: -1 };
hls.positions = [];
hls.recoverMediaError = () => { hls.positions.push(hls.config.startPosition); video.pause(); hls.emit(events.MEDIA_ATTACHED); };
const recovery = new HlsAudioRecovery(hls, video, events, () => current, { chromium, now: () => now });
t.after(() => recovery.dispose());
return { recovery, hls, video, fragment: (cc, sn = 1) => hls.emit(events.FRAG_CHANGED, 'fragment', { frag: { cc, sn } }),
advance: time => { now += time; }, supersede: () => { current = false; } };
}
test('Chromium resets once at each played discontinuity, preserving time and playback settings', async t => {
const f = fixture(t);
f.fragment(0); f.fragment(0); f.fragment(0, 'initSegment');
await tick();
assert.deepEqual(f.hls.positions, []);
f.fragment(1); f.fragment(1);
assert.deepEqual(f.hls.positions, [], 'do not detach inside HLS event dispatch');
await tick();
assert.deepEqual(f.hls.positions, [83.5]);
assert.equal(f.hls.config.startPosition, -1);
assert.equal(f.video.paused, false);
assert.deepEqual([f.video.volume, f.video.muted, f.video.playbackRate], [0.6, true, 1.25]);
f.fragment(1);
await tick();
assert.equal(f.hls.positions.length, 1, 'reattachment does not create a recovery loop');
f.advance(3000); f.video.currentTime = 89;
f.fragment(2);
await tick();
assert.deepEqual(f.hls.positions, [83.5, 89]);
});
test('Firefox skips proactive discontinuity resets but supports requested media-error recovery', async t => {
const f = fixture(t, false);
f.fragment(0); f.fragment(1);
await tick();
assert.deepEqual(f.hls.positions, []);
assert.equal(f.recovery.requestRecovery('media error'), true);
await tick();
assert.deepEqual(f.hls.positions, [83.5]);
});
test('paused boundaries wait for playback and error recovery does not start paused video', async t => {
const f = fixture(t);
f.video.pause();
f.fragment(0); f.fragment(1);
await tick();
assert.deepEqual(f.hls.positions, []);
f.video.paused = false; f.video.emit('playing');
await tick();
assert.equal(f.hls.positions.length, 1);
f.advance(3000); f.video.pause();
f.recovery.requestRecovery('media error');
await tick();
assert.equal(f.video.paused, true);
assert.equal(f.video.plays, 1);
});
test('superseded or destroyed players cancel queued recovery and late attachment callbacks', async t => {
const f = fixture(t);
f.fragment(0); f.fragment(1);
f.supersede();
await tick();
assert.deepEqual(f.hls.positions, []);
const other = fixture(t);
other.hls.recoverMediaError = () => { other.video.pause(); };
other.recovery.requestRecovery('media error');
await tick();
const late = other.hls.listeners(events.MEDIA_ATTACHED)[0];
other.hls.emit(events.DESTROYING);
late();
assert.equal(other.video.plays, 0);
assert.equal(other.hls.config.startPosition, -1);
assert.equal(other.video.listenerCount('playing'), 0);
assert.equal(other.hls.listenerCount(events.MEDIA_ATTACHED), 0);
});
test('cooldown and retry budget bound frequent discontinuities and media errors', async t => {
const f = fixture(t);
assert.equal(f.recovery.requestRecovery('media error'), true);
assert.equal(f.recovery.requestRecovery('media error'), true, 'coalesce simultaneous requests');
await tick();
assert.equal(f.recovery.requestRecovery('media error'), false);
for (let i = 0; i < 2; i++) { f.advance(3000); f.recovery.requestRecovery('media error'); await tick(); }
f.advance(3000);
assert.equal(f.recovery.requestRecovery('media error'), false);
assert.equal(f.hls.positions.length, 3);
f.advance(30000);
assert.equal(f.recovery.requestRecovery('media error'), true);
await tick();
assert.equal(f.hls.positions.length, 4);
});
test('loading a new manifest cancels old recovery and establishes a fresh discontinuity baseline', async t => {
const f = fixture(t);
f.fragment(0); f.fragment(1);
f.hls.emit(events.MANIFEST_LOADING);
await tick();
assert.deepEqual(f.hls.positions, []);
f.fragment(20);
await tick();
assert.deepEqual(f.hls.positions, []);
f.fragment(21);
await tick();
assert.deepEqual(f.hls.positions, [83.5]);
});
for (const name of ['VideoPlayer', 'WatchPage']) {
test(`${name} recovers append errors on the active instance and cancels superseded errors`, async () => {
const window = {};
class Hls extends EventEmitter {
static Events = { ...events, ERROR: 'error', MANIFEST_PARSED: 'manifest',
SUBTITLE_TRACKS_UPDATED: 'subtitles', SUBTITLE_TRACK_SWITCH: 'switch',
NON_NATIVE_TEXT_TRACKS_FOUND: 'non-native', CUES_PARSED: 'cues' };
static ErrorTypes = { MEDIA_ERROR: 'media', NETWORK_ERROR: 'network' };
constructor(config) { super(); this.config = config; this.positions = []; }
loadSource() {} attachMedia() {}
destroy() { this.emit(events.DESTROYING); }
recoverMediaError() { this.positions.push(this.config.startPosition); this.emit(events.MEDIA_ATTACHED); }
}
const file = name === 'VideoPlayer' ? 'components/VideoPlayer.js' : 'pages/WatchPage.js';
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js', file), 'utf8'), {
window, Hls, HlsAudioRecovery, CaptionTracks: require('../public/js/components/CaptionTracks'), console
});
const player = Object.create(window[name].prototype);
Object.assign(player, { playbackGeneration: 1, video: { currentTime: 42, paused: false,
play: async () => {}, pause() {}, querySelectorAll: () => [] } });
player.playHls('/api/transcode/session/playlist.m3u8');
const active = player.hls;
const error = { type: 'media', fatal: false, details: 'bufferAppendError' };
active.emit('error', 'error', error);
await tick();
assert.deepEqual(active.positions, [42]);
const obsoleteHandler = active.listeners('error')[0];
active.emit('error', 'error', error);
player.playbackGeneration++;
player.playHls('/api/transcode/replacement/playlist.m3u8');
obsoleteHandler('error', { ...error, fatal: true });
await tick();
assert.deepEqual(player.hls.positions, []);
assert.equal(player.hls.config.startPosition, undefined);
player.hls.destroy();
});
}