92 lines
4.3 KiB
JavaScript
92 lines
4.3 KiB
JavaScript
|
|
'use strict';
|
||
|
|
const test = require('node:test');
|
||
|
|
const assert = require('node:assert/strict');
|
||
|
|
const LivePlayer = require('./web/live-player.js');
|
||
|
|
|
||
|
|
function fixture() {
|
||
|
|
let now = 100, serial = 0;
|
||
|
|
const timeouts = new Map(), intervals = new Map();
|
||
|
|
const clock = {
|
||
|
|
setTimeout(fn, ms) {const id=++serial;timeouts.set(id,{fn,ms});return id;},
|
||
|
|
clearTimeout(id) {timeouts.delete(id);},
|
||
|
|
setInterval(fn, ms) {const id=++serial;intervals.set(id,{fn,ms});return id;},
|
||
|
|
clearInterval(id) {intervals.delete(id);}
|
||
|
|
};
|
||
|
|
const listeners = new Map(), indicators = {'.video-empty':{hidden:false},'.video-error':{hidden:true}};
|
||
|
|
const video = {
|
||
|
|
currentTime:0,readyState:0,videoWidth:0,paused:false,ended:false,error:null,
|
||
|
|
addEventListener(e,f) {listeners.set(e,f);},removeEventListener(e) {listeners.delete(e);},
|
||
|
|
emit(e) {listeners.get(e)?.();},
|
||
|
|
play() {this.paused=false;this.emit('play');return Promise.resolve();},
|
||
|
|
pause() {this.paused=true;this.emit('pause');},removeAttribute() {},load() {},
|
||
|
|
canPlayType() {return '';}
|
||
|
|
};
|
||
|
|
const instances=[];
|
||
|
|
class Hls {
|
||
|
|
static Events={ERROR:'error',MANIFEST_PARSED:'manifest'};
|
||
|
|
static isSupported() {return true;}
|
||
|
|
constructor() {this.events={};instances.push(this);}
|
||
|
|
on(e,f) {this.events[e]=f;}
|
||
|
|
loadSource() {} attachMedia() {} destroy() {this.destroyed=true;}
|
||
|
|
fail() {this.events.error(null,{fatal:true});}
|
||
|
|
}
|
||
|
|
const player=new LivePlayer(video,{querySelector:s=>indicators[s]},'/example',{Hls,clock,now:()=>now});
|
||
|
|
return {player,video,instances,timeouts,intervals,indicators,
|
||
|
|
advance(ms) {now+=ms;player.watch();},
|
||
|
|
playing() {video.readyState=4;video.videoWidth=1920;video.emit('playing');},
|
||
|
|
retry() {const [id,t]=timeouts.entries().next().value;timeouts.delete(id);now+=t.ms;t.fn();}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
test('readiness, recording status and labels do not change playback identity',()=>{
|
||
|
|
const camera={id:'one',enabled:true,ready:true,name:'A',recordingActive:false};
|
||
|
|
const original=LivePlayer.key([camera],'hd');
|
||
|
|
assert.equal(LivePlayer.key([{...camera,ready:false,name:'B',recordingActive:true}],'hd'),original);
|
||
|
|
assert.notEqual(LivePlayer.key([camera],'compat'),original);
|
||
|
|
});
|
||
|
|
test('fatal error reconnects only its own player with bounded backoff',()=>{
|
||
|
|
const a=fixture(),b=fixture();
|
||
|
|
for(let i=0;i<8;i++) {
|
||
|
|
a.instances.at(-1).fail();
|
||
|
|
assert.equal(a.timeouts.size,1);
|
||
|
|
assert.ok([...a.timeouts.values()][0].ms<=30000);
|
||
|
|
a.retry();
|
||
|
|
}
|
||
|
|
assert.equal(b.instances.length,1);
|
||
|
|
a.player.destroy();b.player.destroy();
|
||
|
|
});
|
||
|
|
test('duplicate errors schedule only one retry',()=>{
|
||
|
|
const f=fixture();f.instances[0].fail();f.video.emit('error');
|
||
|
|
assert.equal(f.timeouts.size,1);f.player.destroy();
|
||
|
|
});
|
||
|
|
test('continuous playback does not restart during repeated health checks',()=>{
|
||
|
|
const f=fixture();f.playing();
|
||
|
|
for(let i=0;i<72;i++) {f.video.currentTime+=5;f.advance(5000);}
|
||
|
|
assert.equal(f.instances.length,1);assert.equal(f.timeouts.size,0);f.player.destroy();
|
||
|
|
});
|
||
|
|
test('stalled playback automatically reconnects',()=>{
|
||
|
|
const f=fixture();f.playing();f.advance(30000);
|
||
|
|
assert.equal(f.timeouts.size,1);f.retry();assert.equal(f.instances.length,2);f.player.destroy();
|
||
|
|
});
|
||
|
|
test('startup has enough time to wait for a source',()=>{
|
||
|
|
const f=fixture();f.advance(60000);assert.equal(f.timeouts.size,0);
|
||
|
|
f.advance(6000);assert.equal(f.timeouts.size,1);f.player.destroy();
|
||
|
|
});
|
||
|
|
test('intentional pause is preserved and resume can recover a failed connection',()=>{
|
||
|
|
const f=fixture();f.playing();f.video.pause();f.instances[0].fail();f.advance(90000);
|
||
|
|
assert.equal(f.instances.length,1);assert.equal(f.timeouts.size,0);
|
||
|
|
f.video.play();assert.equal(f.instances.length,2);f.player.destroy();
|
||
|
|
});
|
||
|
|
test('destroy cancels timers and releases the old player',()=>{
|
||
|
|
const f=fixture();f.instances[0].fail();f.player.destroy();
|
||
|
|
assert.equal(f.timeouts.size,0);assert.equal(f.intervals.size,0);assert.equal(f.instances[0].destroyed,true);
|
||
|
|
});
|
||
|
|
test('recovery before timeout cancels pending retry',()=>{
|
||
|
|
const f=fixture();f.instances[0].fail();f.playing();
|
||
|
|
assert.equal(f.timeouts.size,0);assert.equal(f.indicators['.video-error'].hidden,true);f.player.destroy();
|
||
|
|
});
|
||
|
|
test('error from an old HLS instance cannot restart its replacement',()=>{
|
||
|
|
const f=fixture();const old=f.instances[0];old.fail();f.retry();old.fail();
|
||
|
|
assert.equal(f.timeouts.size,0);assert.equal(f.instances.length,2);f.player.destroy();
|
||
|
|
});
|