| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242 |
- 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 })
- options.onStart?.()
- if (options.fail) throw options.fail(attempts.length)
- throw new Error('暂时无法连接视频')
- },
- status: async () => ({ sessionId: 'session-A', status: 'FAILED' }),
- stop: async (session) => { options.onStop?.(); return 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', 'SESSION_EXPIRED', '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() }
- })
- test('a manual replay after an exhausted budget starts a fresh budget', 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)
- assert.equal(h.visible().retriesLeft, 0, 'an exhausted budget reports zero left')
- // A human clicking replay must get all five automatic attempts back.
- const replay = h.live.start('A')
- await new Promise(setImmediate)
- await clock.advance(200000)
- await replay
- assert.equal(h.attempts.length, 12, 'the manual replay spends a full second budget')
- } finally { await h.live.dispose() }
- })
- test('a late live response during the retry wait cannot present media for a stopped attempt', async () => {
- const clock = new Clock(), updates = []
- let visible = {}, respond
- const delayed = new Promise((resolve) => { respond = resolve })
- const live = createVideoLiveSession({
- prepare: async (code) => prepared(code),
- start: async () => delayed,
- stop: async (session) => stopped(session), heartbeat: async () => {},
- }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } }, clock)
- try {
- const opening = live.start('A')
- await new Promise(setImmediate)
- // The start deadline fires, so the attempt is cancelled and a retry is scheduled.
- await clock.advance(35000)
- assert.equal(visible.phase, 'retrying')
- // The cancelled attempt's ACTIVE response arrives late; it must never reach the panel.
- respond(data('A'))
- await new Promise(setImmediate); await new Promise(setImmediate)
- assert.equal(visible.media, null, 'a late response must not publish media')
- assert.equal(visible.phase, 'retrying')
- assert.equal(updates.some((patch) => patch.phase === 'connecting'), false)
- await clock.advance(200000)
- await opening
- } finally { await live.dispose() }
- })
- test('every attempt releases its own identity exactly once before the next begins', async () => {
- const clock = new Clock(), events = []
- const h = failingLive(clock, { onStop: () => events.push(['stop', clock.now]), onStart: () => events.push(['start', clock.now]) })
- try {
- const opening = h.live.start('A')
- await new Promise(setImmediate)
- await clock.advance(200000)
- await opening
- // Six attempts, six releases, alternating strictly so no two overlap.
- assert.deepEqual(events.map(([kind]) => kind), ['start', 'stop', 'start', 'stop', 'start', 'stop', 'start', 'stop', 'start', 'stop', 'start', 'stop'],
- 'every attempt releases exactly once before the next begins')
- } finally { await h.live.dispose() }
- })
|