| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182 |
- import test from 'node:test'
- import assert from 'node:assert/strict'
- import { createVideoLiveSession } from '../../src/modules/cockpit/services/videoLiveSession.js'
- const media = (code) => ({ native: true, url: `https://media.invalid/${code}.mp4` })
- const data = (code) => ({ deviceCode: code, sessionId: `session-${code}`, mode: 'LIVE', status: 'ACTIVE', media: media(code) })
- const prepared = (code) => ({ deviceCode: code, sessionId: `session-${code}`, sessionToken: `owner-${code}`, mode: 'LIVE', status: 'PREPARED' })
- const stopped = (session) => ({ sessionId: session.sessionId, status: 'STOPPED' })
- class Clock {
- now = 0; next = 1; jobs = new Map()
- setTimeout(fn, delay) { const id = this.next++; this.jobs.set(id, { fn, at: this.now + delay }); return id }
- clearTimeout(id) { this.jobs.delete(id) }
- setInterval(fn, delay) { const id = this.setTimeout(fn, delay); this.jobs.get(id).interval = delay; return id }
- clearInterval(id) { this.clearTimeout(id) }
- async advance(duration) {
- const end = this.now + duration
- while (true) {
- const due = Array.from(this.jobs).filter(([, job]) => job.at <= end).sort((a, b) => a[1].at - b[1].at)[0]
- if (!due) break
- const [id, job] = due; this.now = job.at
- if (job.interval) job.at += job.interval; else this.jobs.delete(id)
- job.fn(); await new Promise(setImmediate)
- }
- this.now = end; await new Promise(setImmediate)
- }
- }
- /** Builds a session whose live start always fails, and records every attempt. */
- function failingLive(clock, options = {}) {
- const attempts = [], updates = []
- let visible = {}
- const live = createVideoLiveSession({
- prepare: async (code) => prepared(code),
- start: async (session) => {
- attempts.push({ at: clock.now, code: session.deviceCode })
- if (options.fail) throw options.fail(attempts.length)
- throw new Error('暂时无法连接视频')
- },
- status: async () => ({ sessionId: 'session-A', status: 'FAILED' }),
- stop: async (session) => stopped(session),
- heartbeat: async () => {},
- }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } }, clock)
- return { live, attempts, updates, visible: () => visible }
- }
- test('live reconnects at most five times and never for the first normal play', async () => {
- const clock = new Clock(), h = failingLive(clock)
- try {
- const opening = h.live.start('A')
- await new Promise(setImmediate)
- await clock.advance(200000)
- await opening
- assert.equal(h.attempts.length, 6, 'one initial attempt plus five bounded retries')
- assert.equal(h.visible().phase, 'failed')
- } finally { await h.live.dispose() }
- })
- test('retries wait 2/4/8/16/30 seconds serially without stacking sessions', async () => {
- const clock = new Clock(), h = failingLive(clock)
- try {
- const opening = h.live.start('A')
- await new Promise(setImmediate)
- await clock.advance(200000)
- await opening
- const gaps = h.attempts.slice(1).map((attempt, index) => attempt.at - h.attempts[index].at)
- assert.deepEqual(gaps, [2000, 4000, 8000, 16000, 30000])
- const retrying = h.updates.filter((patch) => patch.phase === 'retrying').map((patch) => patch.retryAttempt)
- assert.deepEqual(retrying, [1, 2, 3, 4, 5], 'each waiting attempt is observable')
- } finally { await h.live.dispose() }
- })
- test('only thirty seconds of real playback restores the retry budget', async () => {
- // Control: playback shorter than the stable window keeps the already-spent budget,
- // so the following disconnect can only fan out from the remaining allowance.
- const short = await fixtureWithPlayback(29000)
- try {
- await short.drain()
- assert.equal(short.attempts.length, 6, 'a short window keeps the already-spent budget')
- } finally { await short.live.dispose() }
- // Full 30 seconds of playback replenishes the budget, so the next disconnect gets five fresh tries.
- const full = await fixtureWithPlayback(30000)
- try {
- await full.drain()
- assert.equal(full.attempts.length, 7, 'a full stable window restores the budget')
- } finally { await full.live.dispose() }
- })
- /** Plays successfully, stays up for `stableMs`, then drops and reports how many starts happened. */
- async function fixtureWithPlayback(stableMs) {
- const clock = new Clock(), attempts = []
- let healthy = false
- const live = createVideoLiveSession({
- prepare: async (code) => prepared(code),
- start: async () => { attempts.push(clock.now); if (healthy) return data('A'); throw new Error('暂时无法连接视频') },
- stop: async (session) => stopped(session), heartbeat: async () => {},
- }, () => {}, clock)
- const opening = live.start('A')
- await new Promise(setImmediate)
- healthy = true
- await clock.advance(2000)
- await opening
- live.ready()
- await clock.advance(stableMs)
- healthy = false
- live.fail('断流')
- await clock.advance(4000)
- return { live, attempts, drain: () => clock.advance(200000) }
- }
- test('unrecoverable outcomes never retry automatically', async () => {
- // VideoRequestError uses `category`; these are the real terminal categories from videoApi.
- for (const category of ['DEVICE_NOT_FOUND', 'VIDEO_UNAVAILABLE', 'SESSION_FORBIDDEN', 'INVALID_RESPONSE']) {
- const clock = new Clock(), h = failingLive(clock, { fail: () => { throw Object.assign(new Error(category), { category }) } })
- try {
- const opening = h.live.start('A')
- await new Promise(setImmediate)
- await clock.advance(120000)
- await opening
- assert.equal(h.attempts.length, 1, `${category} must not be retried`)
- assert.equal(h.visible().phase, 'failed')
- } finally { await h.live.dispose() }
- }
- // The player reports permanent browser/media errors with `code`, not `category`.
- for (const code of ['VIDEO_URL_INVALID', 'VIDEO_UNSUPPORTED']) {
- const clock = new Clock(), h = failingLive(clock, { fail: () => { throw Object.assign(new Error(code), { code }) } })
- try {
- const opening = h.live.start('A')
- await new Promise(setImmediate)
- await clock.advance(120000)
- await opening
- assert.equal(h.attempts.length, 1, `${code} must not be retried`)
- assert.equal(h.visible().phase, 'failed')
- } finally { await h.live.dispose() }
- }
- // A temporary 503-style failure is retried.
- const clock = new Clock(), retryable = failingLive(clock, { fail: () => { throw Object.assign(new Error('暂时不可用'), { category: 'VIDEO_START_FAILED' }) } })
- try {
- const opening = retryable.live.start('A')
- await new Promise(setImmediate)
- await clock.advance(120000)
- await opening
- assert.equal(retryable.attempts.length, 6, 'a temporary failure spends the whole budget')
- } finally { await retryable.live.dispose() }
- })
- test('an exhausted budget names the reason instead of pretending it was the first failure', async () => {
- const clock = new Clock(), h = failingLive(clock)
- try {
- const opening = h.live.start('A')
- await new Promise(setImmediate)
- await clock.advance(200000)
- await opening
- assert.equal(h.visible().phase, 'failed')
- assert.ok(h.visible().error.includes('已自动重试 5 次仍失败'), h.visible().error)
- // Terminal state carries no stale retry indicators that the panel could still render.
- assert.equal(h.visible().retryAttempt, 0)
- assert.equal(h.visible().retriesLeft, 0)
- } finally { await h.live.dispose() }
- })
- test('closing during a retry wait both cancels and releases cleanly', async () => {
- const clock = new Clock(), h = failingLive(clock)
- try {
- const opening = h.live.start('A')
- await new Promise(setImmediate)
- // We are now inside the retry wait window: the failed attempt already released its identity.
- assert.equal(h.visible().phase, 'retrying')
- assert.equal(await h.live.stop(), true, 'closing in the wait window must report a clean release')
- await clock.advance(200000)
- await opening
- assert.equal(h.attempts.length, 1, 'no retry may run after close')
- assert.equal(h.visible().media, null)
- assert.equal(h.visible().cleanupError, '', 'a cleanly released viewer must not stay in cleanup pending')
- // The same viewer must be usable again; a stuck pending set would refuse this forever.
- await h.live.start('A')
- await new Promise(setImmediate)
- assert.equal(h.visible().cleanupError, '')
- assert.notEqual(h.visible().error, '上次清理结果尚未确认,暂不能重复建流')
- assert.equal(h.attempts.length, 2, 'reopening must actually attempt a new live start')
- } finally { await h.live.dispose() }
- })
|