Files
ArtPlayer/test/display-pip.test.js
T

216 lines
8.0 KiB
JavaScript

import assert from 'node:assert/strict'
import { getEventListeners } from 'node:events'
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
import { test } from 'node:test'
import { setImmediate } from 'node:timers/promises'
import { loadModules } from './helpers/load.js'
const { Emitter, nativePip, webkitPip, beginLifecycle, getScope } = await loadModules({
Emitter: 'packages/artplayer/src/utils/emitter',
nativePip: { file: 'packages/artplayer/src/display/native-pip', name: 'nativePip' },
webkitPip: { file: 'packages/artplayer/src/display/webkit-pip', name: 'webkitPip' },
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
})
function fixture(kind = 'native') {
const video = new EventTarget()
video.ownerDocument = {
pictureInPictureElement: null,
exitPictureInPicture() {
const previous = this.pictureInPictureElement
this.pictureInPictureElement = null
previous?.dispatchEvent(new Event('leavepictureinpicture'))
return Promise.resolve()
},
}
video.requestPictureInPicture = () => {
video.ownerDocument.pictureInPictureElement = video
video.dispatchEvent(new Event('enterpictureinpicture'))
return Promise.resolve({ width: 320, height: 180 })
}
video.webkitPresentationMode = 'inline'
video.webkitSetPresentationMode = (mode) => {
video.webkitPresentationMode = mode
video.dispatchEvent(new Event('webkitpresentationmodechanged'))
video.dispatchEvent(new Event(mode === 'picture-in-picture' ? 'enterpictureinpicture' : 'leavepictureinpicture'))
}
const art = Object.assign(new Emitter(), { template: { $video: video }, state: 'standard', notice: { show: '' }, i18n: { get: key => key } })
beginLifecycle(art)
Object.defineProperty(art, 'pip', kind === 'native' ? nativePip(art) : webkitPip(art))
const events = []
art.on('pip', value => events.push(value))
return { art, video, document: video.ownerDocument, events }
}
test('native PiP invokes immediately, retains element identity and void setter', () => {
const { art, video, events } = fixture()
assert.equal(Object.getOwnPropertyDescriptor(art, 'pip').set(true), undefined)
assert.equal(art.pip, video)
assert.deepEqual(events, [true])
art.pip = false
assert.equal(art.pip, null)
assert.deepEqual(events, [true, false])
getScope(art).dispose()
})
test('native PiP ignores a queued leave from an older session while the video still owns PiP', () => {
const { art, video, events } = fixture()
art.pip = true
video.dispatchEvent(new Event('leavepictureinpicture'))
assert.equal(art.pip, video)
assert.deepEqual(events, [true])
art.pip = false
assert.deepEqual(events, [true, false])
getScope(art).dispose()
})
test('native PiP rejection preserves the notice error without unhandled rejection', async () => {
const { art, video, events } = fixture()
const failure = new Error('denied')
video.requestPictureInPicture = () => Promise.reject(failure)
art.pip = true
await setImmediate()
assert.equal(art.notice.show, failure)
assert.equal(art.pip, null)
assert.deepEqual(events, [])
getScope(art).dispose()
})
test('native PiP synchronous errors remain synchronous and retain identity', () => {
const { art, video } = fixture()
const failure = new Error('sync denial')
video.requestPictureInPicture = () => {
throw failure
}
assert.throws(() => {
art.pip = true
}, error => error === failure)
getScope(art).dispose()
})
test('obsolete native PiP failure does not replace the latest notice', async () => {
const { art, video } = fixture()
let reject
video.requestPictureInPicture = () => new Promise((resolve, fail) => reject = fail)
art.pip = true
art.pip = false
art.notice.show = 'current notice'
reject(new Error('old request'))
await setImmediate()
assert.equal(art.notice.show, 'current notice')
getScope(art).dispose()
})
test('destroyed native PiP ignores late completion when another video owns PiP', async () => {
const { art, video, document } = fixture()
let complete
video.requestPictureInPicture = () => new Promise(resolve => complete = resolve)
art.pip = true
getScope(art).dispose()
document.pictureInPictureElement = {}
document.exitPictureInPicture = () => assert.fail('exited another video')
complete({})
await setImmediate()
assert.equal(getEventListeners(video, 'enterpictureinpicture').length, 0)
assert.equal(getEventListeners(video, 'leavepictureinpicture').length, 0)
})
test('native PiP exit rejection keeps real state and permits retry', async () => {
const { art, document, video, events } = fixture()
art.pip = true
await setImmediate()
const exit = document.exitPictureInPicture
const failure = new Error('exit denied')
document.exitPictureInPicture = () => Promise.reject(failure)
art.pip = false
await setImmediate()
assert.equal(art.pip, video)
assert.equal(art.notice.show, failure)
document.exitPictureInPicture = exit
art.pip = false
await setImmediate()
assert.equal(art.pip, null)
assert.deepEqual(events, [true, false])
getScope(art).dispose()
})
test('WebKit PiP retains boolean getter and repeated synchronous setter events', () => {
const { art, events } = fixture('webkit')
art.pip = true
art.pip = true
assert.equal(art.pip, true)
art.pip = false
assert.equal(art.pip, false)
assert.deepEqual(events, [true, true, false])
getScope(art).dispose()
})
test('WebKit PiP observes external presentation transitions without duplicate native signals', () => {
const { art, video, events } = fixture('webkit')
video.webkitSetPresentationMode('picture-in-picture')
video.webkitSetPresentationMode('inline')
assert.deepEqual(events, [true, false])
getScope(art).dispose()
})
test('WebKit PiP false does not exit a different fullscreen presentation', () => {
const { art, video } = fixture('webkit')
video.webkitPresentationMode = 'fullscreen'
art.pip = false
assert.equal(video.webkitPresentationMode, 'fullscreen')
getScope(art).dispose()
})
test('destroyed WebKit PiP releases delayed entry without retaining instance listeners', () => {
const { art, video, events } = fixture('webkit')
const setMode = video.webkitSetPresentationMode
video.webkitSetPresentationMode = () => {}
art.pip = true
getScope(art).dispose()
video.webkitSetPresentationMode = setMode
setMode('picture-in-picture')
assert.equal(video.webkitPresentationMode, 'inline')
assert.deepEqual(events, [])
assert.equal(getEventListeners(video, 'webkitpresentationmodechanged').length, 0)
})
test('WebKit PiP destruction inside entry callback stops stale notifications', () => {
const { art, video, events } = fixture('webkit')
art.on('pip', () => getScope(art).dispose())
art.pip = true
assert.equal(video.webkitPresentationMode, 'inline')
assert.deepEqual(events, [true])
})
for (const kind of ['native', 'webkit']) {
test(`${kind} PiP reentrant cancellation retains its branch-specific setter event contract`, async () => {
const { art, video, events } = fixture(kind)
// eslint-disable-next-line accessor-pairs -- Intercept mode synchronization without implementing the unrelated state getter.
Object.defineProperty(art, 'state', { set(value) {
if (value === 'pip')
art.pip = false
} })
if (kind === 'native')
await video.requestPictureInPicture()
else
video.webkitSetPresentationMode('picture-in-picture')
assert.deepEqual(events, kind === 'native' ? [] : [false])
getScope(art).dispose()
})
}
test('WebKit PiP checks current support and can retry after media becomes eligible', () => {
const { art, video, events } = fixture('webkit')
video.webkitSupportsPresentationMode = () => false
art.pip = true
assert.equal(art.pip, false)
assert.equal(art.notice.show, 'PIP Not Supported')
assert.deepEqual(events, [])
video.webkitSupportsPresentationMode = () => true
art.pip = true
assert.equal(art.pip, true)
assert.deepEqual(events, [true])
getScope(art).dispose()
})