|
|
@@ -0,0 +1,420 @@
|
|
|
+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]
|
|
|
+ }
|
|
|
+ }
|
|
|
+})
|