import test from 'node:test' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { createVideoPlayer } from '../../src/modules/cockpit/services/videoPlayer.js' const settle = () => new Promise((resolve) => setImmediate(resolve)) const offerSdp = 'v=0\r\no=- 1 1 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=recvonly\r\n' const answerSdp = 'v=0\r\no=- 2 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=sendonly\r\n' const signalingUrl = 'https://media.test/index/api/webrtc?app=rtp&stream=camera%2F01&type=play' const answer = () => new Response(JSON.stringify({ code: 0, type: 'answer', sdp: answerSdp }), { headers: { 'Content-Type': 'application/json' }, }) class Peer extends EventTarget { static instances = [] constructor(configuration) { super() this.configuration = configuration this.iceGatheringState = 'new' this.connectionState = 'new' this.receivers = [] this.transceivers = [] Peer.instances.push(this) } addTransceiver(kind, options) { this.transceivers.push({ kind, ...options }) } async createOffer() { return { type: 'offer', sdp: offerSdp } } async setLocalDescription(description) { this.localDescription = description; this.iceGatheringState = 'gathering' } async setRemoteDescription(description) { this.remoteDescription = description } gather() { this.localDescription = { type: 'offer', sdp: offerSdp + 'a=candidate:1 1 udp 1 192.0.2.1 5000 typ host\r\n' } this.iceGatheringState = 'complete' this.dispatchEvent(new Event('icegatheringstatechange')) } receive(track, streamless = false) { this.receivers.push({ track }) const stream = { getTracks: () => [track] } this.dispatchEvent(Object.assign(new Event('track'), { track, streams: streamless ? [] : [stream] })) return stream } getReceivers() { return this.receivers } close() { this.connectionState = 'closed' } } class Clock { now = 0 next = 1 tasks = new Map() setTimeout(callback, delay) { const id = this.next++; this.tasks.set(id, { at: this.now + delay, callback }); return id } clearTimeout(id) { this.tasks.delete(id) } advance(ms) { const end = this.now + ms while (true) { const entry = [...this.tasks].sort((a, b) => a[1].at - b[1].at).find(([, item]) => item.at <= end) if (!entry) break const [id, item] = entry this.tasks.delete(id); this.now = item.at; item.callback() } this.now = end } } class Track extends EventTarget { kind = 'video' readyState = 'live' stop() { this.readyState = 'ended' } } class Video extends EventTarget { constructor(protocol = 'https:') { super() this.ownerDocument = { location: { protocol } } this.attributes = new Map() this.srcObject = null this.paused = true this.loads = 0 } set src(value) { this.setAttribute('src', value) } get src() { return this.getAttribute('src') || '' } setAttribute(name, value) { this.attributes.set(name, String(value)) } getAttribute(name) { return this.attributes.get(name) ?? null } removeAttribute(name) { this.attributes.delete(name) } play() { this.paused = false; return Promise.resolve() } pause() { this.paused = true } load() { this.loads++ } fire(type) { this.dispatchEvent(new Event(type)) } } // 已确认接缝:真实driver + HTMLMediaElement/Peer/HTTP/时钟的外部边界替身。 // 只观察媒体元素、回调、信令合同与本地资源释放,不读取driver私有状态。 test('原生视频仅在playing后就绪,销毁时清空媒体且不再报告迟到事件', async () => { const video = new Video() const ready = [], errors = [] const player = createVideoPlayer(video, { kind: 'native', url: 'https://media.test/live.mp4' }, { onReady: () => ready.push('画面就绪'), onError: (error) => errors.push(error), }) await settle() assert.equal(video.src, 'https://media.test/live.mp4') assert.equal(video.muted, true) video.fire('loadedmetadata') assert.deepEqual(ready, []) video.fire('playing') assert.deepEqual(ready, ['画面就绪']) player.destroy() player.destroy() assert.equal(video.src, '') assert.equal(video.srcObject, null) assert.equal(video.paused, true) video.fire('playing') video.fire('error') assert.deepEqual(ready, ['画面就绪']) assert.deepEqual(errors, []) }) test('协议、凭据、混合内容和非原生媒体地址在触达媒体前被拒绝', async () => { const invalid = [ { kind: 'native', url: 'javascript:alert(1)' }, { kind: 'native', url: 'webrtc://media.test/live' }, { kind: 'native', url: '/live.mp4' }, { kind: 'native', url: 'https://user:secret@media.test/live.mp4' }, { kind: 'native', url: 'http://media.test/live.mp4' }, { kind: 'native', url: 'https://media.test/live.flv?token=secret' }, { kind: 'native', url: 'https://media.test/index/api/webrtc?app=live&type=play' }, { kind: 'unknown', url: 'https://media.test/live.mp4' }, ] for (const media of invalid) { const video = new Video(), errors = [] const player = createVideoPlayer(video, media, { onError: (error) => errors.push(error) }) await settle() assert.equal(video.src, '') assert.equal(errors.length, 1) assert.equal(errors[0].code, 'VIDEO_URL_INVALID') assert.doesNotMatch(errors[0].message, /secret|media\.test/) player.destroy() } }) test('WebRTC收集ICE后发送实际ZLM SDP,接收轨道且playing才就绪', async () => { const video = new Video(), requests = [], ready = [], errors = [] const player = createVideoPlayer(video, { kind: 'webrtc', url: signalingUrl }, { PeerConnection: Peer, fetchImpl: async (url, init) => { requests.push({ url, ...init }); return answer() }, onReady: () => ready.push('已播放'), onError: (error) => errors.push(error), }) await settle() const peer = Peer.instances.at(-1) assert.ok(peer, '通过浏览器Peer边界建立连接') assert.deepEqual(peer.configuration?.iceServers || [], []) assert.deepEqual(peer.transceivers, [{ kind: 'video', direction: 'recvonly' }, { kind: 'audio', direction: 'recvonly' }]) assert.equal(requests.length, 0, '不提交尚未收集完的offer') assert.equal(video.src, '', '信令地址不得进入video src') peer.gather() await settle() assert.equal(requests.length, 1) assert.equal(requests[0].url, signalingUrl) assert.equal(requests[0].method, 'POST') assert.equal(new Headers(requests[0].headers).get('content-type'), 'text/plain') assert.equal(new Headers(requests[0].headers).get('authorization'), null) assert.equal(requests[0].credentials, 'omit') assert.equal(requests[0].redirect, 'error') assert.match(requests[0].body, /a=candidate:1/) assert.deepEqual(peer.remoteDescription, { type: 'answer', sdp: answerSdp }) const track = new Track(), stream = peer.receive(track) await settle() assert.equal(video.srcObject, stream) assert.deepEqual(ready, []) video.fire('playing') assert.deepEqual(ready, ['已播放']) player.destroy() assert.equal(peer.connectionState, 'closed') assert.equal(track.readyState, 'ended') assert.equal(video.srcObject, null) assert.deepEqual(errors, []) assert.equal(requests.length, 1, '本地销毁不调用后端stop或重连') }) test('ICE收集最多等待5秒,超时关闭Peer且不发出不完整信令', async () => { const video = new Video(), clock = new Clock(), errors = [], requests = [] const player = createVideoPlayer(video, { kind: 'webrtc', url: signalingUrl }, { PeerConnection: Peer, timers: clock, fetchImpl: async (...args) => { requests.push(args); return answer() }, onError: (error) => errors.push(error), }) await settle() const peer = Peer.instances.at(-1) clock.advance(4999) assert.equal(errors.length, 0) clock.advance(1) await settle() assert.equal(errors.length, 1) assert.equal(errors[0].code, 'VIDEO_ICE_TIMEOUT') assert.equal(peer.connectionState, 'closed') assert.deepEqual(requests, []) player.destroy() }) test('媒体错误、轨道结束与Peer断流会释放本地资源并只报一次失败', async () => { for (const failure of ['video-error', 'video-ended', 'track-ended', 'peer-failed', 'peer-disconnected', 'ice-failed']) { const video = new Video(), errors = [], ready = [], requests = [] const player = createVideoPlayer(video, { kind: 'webrtc', url: signalingUrl }, { PeerConnection: Peer, fetchImpl: async (...args) => { requests.push(args); return answer() }, onError: (error) => errors.push(error), onReady: () => ready.push('已播放'), }) await settle() const peer = Peer.instances.at(-1) peer.gather(); await settle() const track = new Track() peer.receive(track); await settle() video.fire('playing') if (failure.startsWith('video-')) video.fire(failure.slice(6)) if (failure === 'track-ended') track.dispatchEvent(new Event('ended')) if (failure.startsWith('peer-')) { peer.connectionState = failure.slice(5) peer.dispatchEvent(new Event('connectionstatechange')) } if (failure === 'ice-failed') { peer.iceConnectionState = 'failed' peer.dispatchEvent(new Event('iceconnectionstatechange')) } await settle() assert.equal(errors.length, 1, failure) assert.equal(peer.connectionState, 'closed', failure) assert.equal(track.readyState, 'ended', failure) assert.equal(video.srcObject, null, failure) video.fire('error'); video.fire('playing') assert.equal(errors.length, 1) assert.deepEqual(ready, ['已播放']) assert.equal(requests.length, 1) player.destroy() } }) test('销毁中的ICE和迟到信令不会再挂轨道、报就绪或请求后端停止', async () => { const clock = new Clock(), errors = [], requests = [] const first = createVideoPlayer(new Video(), { kind: 'webrtc', url: signalingUrl }, { PeerConnection: Peer, timers: clock, onError: (error) => errors.push(error), fetchImpl: async (...args) => { requests.push(args); return answer() }, }) await settle() const waiting = Peer.instances.at(-1) first.destroy(); clock.advance(10000); waiting.gather(); await settle() assert.equal(waiting.connectionState, 'closed') assert.equal(requests.length, 0) assert.equal(errors.length, 0) const video = new Video(), ready = [] let resolveResponse const response = new Promise((resolve) => { resolveResponse = resolve }) const second = createVideoPlayer(video, { kind: 'webrtc', url: signalingUrl }, { PeerConnection: Peer, onReady: () => ready.push(true), onError: (error) => errors.push(error), fetchImpl: (url, init) => { requests.push({ url, ...init }); return response }, }) await settle() const peer = Peer.instances.at(-1) peer.gather(); await settle() assert.equal(requests.length, 1) second.destroy() assert.equal(requests[0].signal.aborted, true) resolveResponse(answer()); await settle() assert.equal(peer.remoteDescription, undefined) assert.equal(video.srcObject, null) video.fire('playing') assert.deepEqual(ready, []) assert.deepEqual(errors, []) assert.equal(requests.length, 1) }) test('HTML、非成功信令与非法answer不被视作可播放媒体', async () => { const responses = [ () => new Response('secret', { headers: { 'Content-Type': 'text/html' } }), () => new Response('upstream secret', { status: 502 }), () => new Response(JSON.stringify({ code: 1, msg: 'secret' }), { headers: { 'Content-Type': 'application/json' } }), () => new Response(JSON.stringify({ code: 0, type: 'offer', sdp: answerSdp }), { headers: { 'Content-Type': 'application/json' } }), () => new Response(JSON.stringify({ code: 0, type: 'answer', sdp: 'secret' }), { headers: { 'Content-Type': 'application/json' } }), () => new Response('null', { headers: { 'Content-Type': 'application/json' } }), () => { throw new Error('upstream secret at https://media.test/token') }, ] for (const response of responses) { const video = new Video(), errors = [], ready = [] const player = createVideoPlayer(video, { kind: 'webrtc', url: signalingUrl }, { PeerConnection: Peer, fetchImpl: async () => response(), onError: (error) => errors.push(error), onReady: () => ready.push(true), }) await settle() const peer = Peer.instances.at(-1) peer.gather(); await settle() assert.equal(errors.length, 1) assert.equal(errors[0].code, 'VIDEO_SIGNALING_FAILED') assert.doesNotMatch(errors[0].message, /secret|media\.test|token/) assert.equal(peer.remoteDescription, undefined) assert.equal(peer.connectionState, 'closed') assert.equal(video.src, '') assert.deepEqual(ready, []) player.destroy() } }) test('原生播放拒绝和缺失WebRTC能力均返回可清理的失败而非伪就绪', async () => { const video = new Video(), errors = [], ready = [] video.play = () => Promise.reject(new Error('autoplay secret')) const player = createVideoPlayer(video, { kind: 'native', url: 'https://media.test/live.mp4' }, { onError: (error) => errors.push(error), onReady: () => ready.push(true), }) await settle() assert.equal(errors[0].code, 'VIDEO_PLAYBACK_FAILED') assert.doesNotMatch(errors[0].message, /secret/) assert.equal(video.src, '') video.fire('playing') assert.deepEqual(ready, []) player.destroy() const unsupported = createVideoPlayer(new Video(), { kind: 'webrtc', url: signalingUrl }, { onError: (error) => errors.push(error), }) await settle() assert.equal(errors[1].code, 'VIDEO_UNSUPPORTED') unsupported.destroy() }) test('无关联stream的音视频轨道保留在同一媒体流,只有视频轨道可报画面就绪', async () => { class MediaStream { tracks = [] addTrack(track) { this.tracks.push(track) } getTracks() { return this.tracks } } const video = new Video(), errors = [], ready = [] video.ownerDocument.defaultView = { MediaStream } const player = createVideoPlayer(video, { kind: 'webrtc', url: signalingUrl }, { PeerConnection: Peer, fetchImpl: async () => answer(), onError: (error) => errors.push(error), onReady: () => ready.push('画面就绪'), }) await settle() const peer = Peer.instances.at(-1) peer.gather(); await settle() const audio = new Track(), picture = new Track() audio.kind = 'audio' peer.receive(audio, true); await settle() assert.deepEqual(errors, []) video.fire('playing') assert.deepEqual(ready, [], '不能把只有音频当作视频画面就绪') peer.receive(picture, true); await settle() assert.deepEqual(video.srcObject.getTracks(), [audio, picture]) video.fire('playing') assert.deepEqual(ready, ['画面就绪']) player.destroy() assert.equal(audio.readyState, 'ended') assert.equal(picture.readyState, 'ended') }) test('Vue播放器公开media与ready接缝可挂载,换媒体和卸载清理旧播放', async () => { const { parseHTML } = await import('linkedom') const { window } = parseHTML('
') const previous = new Map() for (const name of ['window', 'document', 'Element', 'HTMLElement', 'SVGElement']) { previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name)) Object.defineProperty(globalThis, name, { configurable: true, writable: true, value: window[name] }) } // LinkeDOM不实现媒体播放;只在HTMLVideoElement外部边界提供播放/清理替身。 const createElement = window.document.createElement.bind(window.document) window.document.createElement = (name, ...args) => { const element = createElement(name, ...args) if (name === 'video') { Object.defineProperty(element, 'src', { get: () => element.getAttribute('src') || '', set: (value) => element.setAttribute('src', value), }) element.play = () => Promise.resolve() element.pause = () => { element.dataset.paused = 'true' } element.load = () => {} } return element } let app try { const { parse, compileScript } = await import('@vue/compiler-sfc') const file = new URL('../../src/modules/cockpit/components/VideoPlayer.vue', import.meta.url) const { descriptor, errors: parseErrors } = parse(await readFile(file, 'utf8'), { filename: file.pathname }) assert.deepEqual(parseErrors, []) const compiled = compileScript(descriptor, { id: 'player-test', inlineTemplate: true }) const code = compiled.content .replace(/from ["']vue["']/g, `from ${JSON.stringify(import.meta.resolve('vue'))}`) .replace(/from ["']\.\.\/services\/videoPlayer\.js["']/g, `from ${JSON.stringify(new URL('../../src/modules/cockpit/services/videoPlayer.js', import.meta.url).href)}`) const { default: Component } = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`) const { createApp, h, shallowRef, nextTick } = await import('vue') const media = shallowRef({ kind: 'native', url: 'https://media.test/first.mp4' }) const ready = [], errors = [] app = createApp({ render: () => h(Component, { media: media.value, onReady: () => ready.push('ready'), onError: (error) => errors.push(error), }) }) app.mount(window.document.querySelector('#host')) await nextTick(); await settle() const video = window.document.querySelector('video') assert.ok(video) assert.equal(video.src, 'https://media.test/first.mp4') assert.equal(video.hasAttribute('controls'), true) assert.match(video.getAttribute('controlslist'), /nodownload/) assert.equal(video.hasAttribute('disablepictureinpicture'), true) assert.equal(video.hasAttribute('disableremoteplayback'), true) video.dispatchEvent(new window.Event('playing')) assert.deepEqual(ready, ['ready']) media.value = { kind: 'native', url: 'https://media.test/second.mp4' } await nextTick(); await settle() assert.equal(video.src, 'https://media.test/second.mp4') assert.equal(video.dataset.paused, 'true') video.dispatchEvent(new window.Event('playing')) assert.deepEqual(ready, ['ready', 'ready']) app.unmount(); app = null assert.equal(video.getAttribute('src'), null) video.dispatchEvent(new window.Event('playing')) assert.deepEqual(ready, ['ready', 'ready']) assert.deepEqual(errors, []) } finally { app?.unmount() for (const [name, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, name, descriptor) else delete globalThis[name] } } })