| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420 |
- 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('<html>secret</html>', { 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: '<html>secret</html>' }), { 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('<html><body><div id="host"></div></body></html>')
- 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]
- }
- }
- })
|