refactor(core): CORE-16 type display modes and own fullscreen lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 09:42:30 +08:00
1 parent 1b83081e8f
commit 88aef830df
81 files changed
+33897 -1503

No files matched your search

+285
View File
@@ -0,0 +1,285 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
window.createPlayer('/test/pattern.mp4')
window.miniEvents = []
window.art.on('mini', value => window.miniEvents.push(value))
const video = window.art.template.$video
const parent = document.createElement('div')
parent.id = 'original-video-parent'
const next = document.createElement('span')
next.id = 'original-video-next'
video.before(parent)
parent.append(video, next)
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
test(`${core}: mini retains first-entry and reuse DOM order and display`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.mini = true
const popup = art.template.$mini
const first = popup.firstChild === art.template.$video
const display = popup.style.display
art.mini = true
const result = { first, display, last: popup.lastChild === art.template.$video, reusedDisplay: popup.style.display }
art.destroy()
return result
})).toEqual({ first: true, display: '', last: true, reusedDisplay: 'flex' })
})
test(`${core}: mini restores the original video parent and sibling after repeated entry`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.mini = true
const popup = art.template.$mini
art.mini = true
art.mini = false
const result = { restored: art.template.$video.parentElement.id === 'original-video-parent', next: art.template.$video.nextSibling?.id === 'original-video-next', reused: popup === art.template.$mini, hidden: popup.style.display, events: [...window.miniEvents] }
art.destroy()
return result
})).toEqual({ restored: core === 'candidate', next: core === 'candidate', reused: true, hidden: 'none', events: [true, true, false] })
})
for (const removeHtml of [false, true]) {
test(`${core}: destroying active mini removeHtml=${removeHtml} removes the owned popup`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate((removeHtml) => {
const art = window.art
art.mini = true
const popup = art.template.$mini
const video = art.template.$video
window.miniEvents.length = 0
art.destroy(removeHtml)
return { popupConnected: popup.isConnected, videoInPopup: popup.contains(video), events: window.miniEvents }
}, removeHtml)).toEqual({ popupConnected: core === 'published', videoInPopup: core === 'published', events: [] })
})
}
}
test('candidate: hiding mini cancels drag and later document events cannot persist a hidden popup', async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(() => {
const art = window.art
art.mini = true
const popup = art.template.$mini
const original = { left: art.storage.get('left'), top: art.storage.get('top') }
popup.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 20, clientY: 20 }))
art.emit('document:mousemove', new MouseEvent('mousemove', { clientX: 40, clientY: 50 }))
art.mini = false
art.emit('document:mouseup', new MouseEvent('mouseup'))
const result = { original, after: { left: art.storage.get('left'), top: art.storage.get('top') }, dragging: popup.classList.contains('art-mini-dragging'), transform: popup.style.transform }
art.destroy()
return result
})
expect(result.after).toEqual(result.original)
expect(result.dragging).toBe(false)
expect(result.transform).toBe('')
})
test('candidate: mini remains in the viewport on small screens', async ({ page }) => {
await page.setViewportSize({ width: 280, height: 160 })
await setup(page, 'candidate')
await page.evaluate(() => {
window.art.storage.set('left', 9000)
window.art.storage.set('top', 9000)
window.art.mini = true
})
const rect = await page.locator('.art-mini-popup').boundingBox()
expect(rect.x).toBeGreaterThanOrEqual(0)
expect(rect.y).toBeGreaterThanOrEqual(0)
expect(rect.x + rect.width).toBeLessThanOrEqual(280)
expect(rect.y + rect.height).toBeLessThanOrEqual(160)
await page.evaluate(() => window.art.destroy())
})
test('candidate: mini drag stores the actual position and playback buttons remain usable', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => window.art.mini = true)
const popup = page.locator('.art-mini-popup')
const rect = await popup.boundingBox()
await page.mouse.move(rect.x + 30, rect.y + 20)
await page.mouse.down()
await page.mouse.move(rect.x - 30, rect.y - 20)
await page.mouse.up()
expect(await page.evaluate(() => {
const art = window.art
const rect = art.template.$mini.getBoundingClientRect()
return Math.abs(art.storage.get('left') - rect.left) < 1 && Math.abs(art.storage.get('top') - rect.top) < 1
})).toBe(true)
await popup.locator('.art-icon-play').click()
await expect.poll(() => page.evaluate(() => window.art.playing && window.art.currentTime > 0)).toBe(true)
await popup.locator('.art-icon-pause').click()
await expect.poll(() => page.evaluate(() => window.art.template.$video.paused)).toBe(true)
await popup.locator('.art-mini-close').click()
await expect(popup).toBeHidden()
expect(await page.evaluate(() => window.art.template.$video.parentElement.id)).toBe('original-video-parent')
await page.evaluate(() => window.art.destroy())
})
test('candidate: mini exit failure keeps the active video and can restore on retry', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const parent = art.template.$video.parentNode
art.mini = true
const insert = parent.insertBefore
const failure = new Error('controlled mini restore failure')
parent.insertBefore = () => {
throw failure
}
let rejected = false
try {
art.mini = false
}
catch (error) { rejected = error === failure }
const active = art.mini && art.template.$mini.contains(art.template.$video)
parent.insertBefore = insert
art.mini = false
const result = { rejected, active, restored: art.template.$video.parentNode === parent }
art.destroy()
return result
})).toEqual({ rejected: true, active: true, restored: true })
})
test('candidate: mini reentry during restoration retains the new placement snapshot', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const parent = art.template.$video.parentNode
const insert = parent.insertBefore
art.mini = true
parent.insertBefore = function (...args) {
const result = insert.apply(this, args)
parent.insertBefore = insert
art.mini = true
return result
}
art.mini = false
const active = art.mini
art.mini = false
const result = { active, restored: art.template.$video.parentNode === parent, events: [...window.miniEvents] }
art.destroy()
return result
})).toEqual({ active: true, restored: true, events: [true, true, false] })
})
for (const action of ['hide', 'destroy', 'fail']) {
test(`candidate: mini construction ${action} releases incomplete UI and restores the video`, async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate((action) => {
const art = window.art
const icons = art.icons
const failure = new Error('controlled mini icon failure')
art.icons = {
get close() {
if (action === 'hide')
art.mini = false
else if (action === 'destroy')
art.destroy(false)
else
throw failure
return icons.close
},
get play() { return icons.play },
get pause() { return icons.pause },
}
let rejected = false
try {
art.mini = true
}
catch (error) {
rejected = error === failure
}
const restored = art.template.$video.parentElement.id === 'original-video-parent'
const active = art.mini
let retry = null
if (action !== 'destroy') {
art.icons = icons
art.mini = true
retry = art.mini && document.querySelectorAll('.art-mini-popup').length === 1
art.destroy()
}
return { restored, active, rejected, retry, remaining: document.querySelectorAll('.art-mini-popup').length }
}, action)).toEqual({ restored: true, active: false, rejected: action === 'fail', retry: action === 'destroy' ? null : true, remaining: 0 })
})
}
test('candidate: mini preserves a caller-owned popup on destruction', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const popup = document.createElement('div')
popup.style.cssText = 'display: grid; width: 320px; height: 180px;'
document.body.append(popup)
art.template.$mini = popup
art.mini = true
art.destroy(false)
const result = { connected: popup.isConnected, display: popup.style.display, restored: art.template.$video.parentElement.id === 'original-video-parent', empty: popup.childNodes.length === 0 }
popup.remove()
return result
})).toEqual({ connected: true, display: 'grid', restored: true, empty: true })
})
test('candidate: reentrant mini creation allocates only one owned popup', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const icons = art.icons
let reentered = false
art.icons = {
get close() {
if (!reentered) {
reentered = true
art.mini = true
}
return icons.close
},
get play() { return icons.play },
get pause() { return icons.pause },
}
art.mini = true
const result = { popups: document.querySelectorAll('.art-mini-popup').length, ownVideo: art.template.$mini.contains(art.template.$video), events: [...window.miniEvents] }
art.destroy()
return result
})).toEqual({ popups: 1, ownVideo: true, events: [true] })
})
test('candidate: mini switches to body web fullscreen and restores the original video placement', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
window.Artplayer.FULLSCREEN_WEB_IN_BODY = true
art.mini = true
art.fullscreenWeb = true
const switched = !art.mini && art.fullscreenWeb && art.template.$mini.style.display === 'none'
art.fullscreenWeb = false
const restored = art.template.$video.parentElement.id === 'original-video-parent'
art.mini = true
art.mini = false
const events = [...window.miniEvents]
art.destroy()
return { switched, restored, events }
})).toEqual({ switched: true, restored: true, events: [true, false, true, false] })
})
test('candidate: destroying a mini drag removes its document subscriptions', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
art.mini = true
art.template.$mini.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 10, clientY: 10 }))
art.destroy(false)
let writes = 0
art.storage.set = () => writes++
art.emit('document:mousemove', new MouseEvent('mousemove', { clientX: 100, clientY: 100 }))
art.emit('document:mouseup', new MouseEvent('mouseup'))
return writes
})).toBe(0)
})
+251
View File
@@ -0,0 +1,251 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
const maps = [
['requestFullscreen', 'exitFullscreen', 'fullscreenElement', 'fullscreenchange'],
['webkitRequestFullscreen', 'webkitExitFullscreen', 'webkitFullscreenElement', 'webkitfullscreenchange'],
['webkitRequestFullScreen', 'webkitCancelFullScreen', 'webkitCurrentFullScreenElement', 'webkitfullscreenchange'],
['mozRequestFullScreen', 'mozCancelFullScreen', 'mozFullScreenElement', 'mozfullscreenchange'],
['msRequestFullscreen', 'msExitFullscreen', 'msFullscreenElement', 'MSFullscreenChange'],
]
window.fullscreenAPI = maps.find(([, exit]) => exit in document)
window.createPlayer('/test/pattern.mp4')
window.art.template.$container.id = 'native-fullscreen-main'
const container = document.createElement('div')
Object.assign(container.style, { width: '320px', height: '180px' })
document.body.append(container)
window.other = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true })
window.fullscreenEvents = [[], []]
window.art.on('fullscreen', value => window.fullscreenEvents[0].push(value))
window.other.on('fullscreen', value => window.fullscreenEvents[1].push(value))
document.querySelector('#play').onclick = () => {
window.art.fullscreen = true
}
})
await expect.poll(() => page.evaluate(() => window.art.isReady && window.other.isReady)).toBe(true)
expect(await page.evaluate(() => Boolean(window.fullscreenAPI))).toBe(true)
}
test('candidate: void exit waits for the owned video to leave and accepts another owner as completion', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(async () => {
const art = window.art
const [, exitName, elementName, eventName] = window.fullscreenAPI
const elementDescriptor = Object.getOwnPropertyDescriptor(document, elementName)
const originalExit = document[exitName]
let element = art.template.$video
Object.defineProperty(document, elementName, { configurable: true, get: () => element })
document.dispatchEvent(new Event(eventName))
document[exitName] = () => undefined
let settled = false
const result = Object.getOwnPropertyDescriptor(art, 'fullscreen').set(false).then(() => settled = true)
await new Promise(resolve => setTimeout(resolve, 0))
const early = settled
element = window.other.template.$player
document.dispatchEvent(new Event(eventName))
await result
const completed = settled && !art.fullscreen && window.other.fullscreen
element = null
document.dispatchEvent(new Event(eventName))
if (elementDescriptor)
Object.defineProperty(document, elementName, elementDescriptor)
else
delete document[elementName]
document[exitName] = originalExit
art.destroy()
window.other.destroy()
return { early, completed }
})).toEqual({ early: false, completed: true })
})
for (const core of ['published', 'candidate']) {
test(`${core}: native fullscreen belongs to its player and other players cannot exit it`, async ({ page }) => {
await setup(page, core)
await page.click('#play')
await expect.poll(() => page.evaluate(() => document[window.fullscreenAPI[2]] === window.art.template.$player)).toBe(true)
await expect.poll(() => page.evaluate(() => window.fullscreenEvents[0])).toEqual([true])
expect(await page.evaluate(() => ({ own: window.art.fullscreen, other: window.other.fullscreen, className: window.other.template.$player.classList.contains('art-fullscreen'), otherEvents: window.fullscreenEvents[1] }))).toEqual({ own: true, other: core === 'published', className: core === 'published', otherEvents: core === 'published' ? [true] : [] })
await page.evaluate(async () => {
await Object.getOwnPropertyDescriptor(window.other, 'fullscreen').set.call(window.other, false)
})
await expect.poll(() => page.evaluate(() => Boolean(document[window.fullscreenAPI[2]]))).toBe(core === 'candidate')
await page.evaluate(async () => {
if (document[window.fullscreenAPI[2]])
await document[window.fullscreenAPI[1]]()
window.art.destroy()
window.other.destroy()
})
})
test(`${core}: destroyed players stop receiving later native fullscreen events`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => window.other.destroy(false))
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.fullscreenEvents[0])).toEqual([true])
expect(await page.evaluate(() => ({ events: window.fullscreenEvents[1], className: window.other.template.$player.classList.contains('art-fullscreen') }))).toEqual({ events: core === 'published' ? [true] : [], className: core === 'published' })
await page.evaluate(async () => {
await document[window.fullscreenAPI[1]]()
window.art.destroy()
})
})
test(`${core}: rejected native fullscreen request removes its temporary change listener`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(async () => {
const art = window.art
const [request, , , change] = window.fullscreenAPI
const add = document.addEventListener
const remove = document.removeEventListener
const active = new Set()
document.addEventListener = function (name, callback, options) {
if (name === change)
active.add(callback)
return add.call(this, name, callback, options)
}
document.removeEventListener = function (name, callback, options) {
if (name === change)
active.delete(callback)
return remove.call(this, name, callback, options)
}
const failure = new Error('controlled fullscreen rejection')
art.template.$player[request] = () => Promise.reject(failure)
const rejected = await Object.getOwnPropertyDescriptor(art, 'fullscreen').set.call(art, true).then(() => false, error => error === failure)
const result = { rejected, leaked: active.size }
document.addEventListener = add
document.removeEventListener = remove
art.destroy()
window.other.destroy()
return result
})).toEqual({ rejected: true, leaked: core === 'published' ? 1 : 0 })
})
}
test('candidate: fullscreen assignment rejection shows notice without an unhandled rejection', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => {
const [request] = window.fullscreenAPI
window.art.template.$player[request] = () => Promise.reject(new Error('controlled assignment denial'))
window.art.fullscreen = true
})
await expect(page.locator('#native-fullscreen-main .art-notice')).toContainText('controlled assignment denial')
await page.evaluate(() => {
window.art.destroy()
window.other.destroy()
})
})
test('candidate: reentrant entry preserves real fullscreen UI and event order', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => {
window.fullscreenOrder = []
window.art.on('fullscreen', (value) => {
if (value) {
window.fullscreenOrder.push(['fullscreen', window.art.template.$player.classList.contains('art-fullscreen')])
window.art.fullscreen = true
}
})
window.art.on('resize', () => {
if (window.art.fullscreen)
window.fullscreenOrder.push(['resize', window.art.template.$player.classList.contains('art-fullscreen')])
})
})
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.fullscreen && window.art.template.$player.classList.contains('art-fullscreen'))).toBe(true)
expect(await page.evaluate(() => window.fullscreenOrder.slice(0, 2))).toEqual([['fullscreen', false], ['resize', true]])
await page.evaluate(async () => {
await document[window.fullscreenAPI[1]]()
window.art.destroy()
window.other.destroy()
})
})
for (const mode of ['promise-exit', 'promise-destroy', 'void-exit', 'void-destroy']) {
test(`candidate: pending native entry ${mode} releases a late own entry`, async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(async (mode) => {
const art = window.art
const [request, exit, element, change] = window.fullscreenAPI
const originalExit = document[exit]
const originalElement = Object.getOwnPropertyDescriptor(document, element)
let current = null
let complete
let exits = 0
Object.defineProperty(document, element, { configurable: true, get: () => current })
art.template.$player[request] = () => {
if (mode.startsWith('promise'))
return new Promise(resolve => complete = resolve)
complete = () => document.dispatchEvent(new Event(change))
}
document[exit] = () => {
exits++
current = null
document.dispatchEvent(new Event(change))
return Promise.resolve()
}
const setter = Object.getOwnPropertyDescriptor(art, 'fullscreen').set
const pending = setter.call(art, true)
if (mode.endsWith('destroy'))
art.destroy(false)
else
await setter.call(art, false)
await pending
current = art.template.$player
complete()
// Flush the request's native Promise observer and setter settlement chain.
await new Promise(resolve => setTimeout(resolve, 0))
const result = { exits, fullscreen: art.fullscreen, className: art.template.$player.classList.contains('art-fullscreen'), events: window.fullscreenEvents[0] }
art.destroy()
window.other.destroy()
document[exit] = originalExit
if (originalElement)
Object.defineProperty(document, element, originalElement)
else
delete document[element]
return result
}, mode)).toEqual({ exits: 1, fullscreen: false, className: false, events: [] })
})
}
test('candidate: cancelling an actual native request in the initiating click releases fullscreen', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => {
window.entrySettled = false
document.querySelector('#play').onclick = () => {
const setter = Object.getOwnPropertyDescriptor(window.art, 'fullscreen').set
const entering = setter.call(window.art, true)
window.art.fullscreen = false
void entering.then(() => window.entrySettled = true)
}
})
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.entrySettled && !document[window.fullscreenAPI[2]])).toBe(true)
await page.evaluate(() => {
window.art.destroy()
window.other.destroy()
})
})
test('candidate: rejected exit retains actual fullscreen state and can be retried', async ({ page }) => {
await setup(page, 'candidate')
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.template.$player.classList.contains('art-fullscreen'))).toBe(true)
expect(await page.evaluate(async () => {
const [, exit] = window.fullscreenAPI
const originalExit = document[exit]
const failure = new Error('controlled exit denial')
document[exit] = () => Promise.reject(failure)
const setter = Object.getOwnPropertyDescriptor(window.art, 'fullscreen').set
const rejected = await setter.call(window.art, false).then(() => false, error => error === failure)
const result = { rejected, fullscreen: window.art.fullscreen, className: window.art.template.$player.classList.contains('art-fullscreen') }
document[exit] = originalExit
await setter.call(window.art, false)
return result
})).toEqual({ rejected: true, fullscreen: true, className: true })
await expect.poll(() => page.evaluate(() => window.art.fullscreen || window.art.template.$player.classList.contains('art-fullscreen'))).toBe(false)
await page.evaluate(() => {
window.art.destroy()
window.other.destroy()
})
})
+161
View File
@@ -0,0 +1,161 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.setViewportSize({ width: 400, height: 800 })
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android orientation fixture' }))
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
window.Artplayer.AUTO_ORIENTATION_TIME = 10
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, autoOrientation: true })
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
test('candidate: a cancelled rotation timer cannot rotate a later fullscreen session early', async ({ page }) => {
await setup(page, 'candidate')
await page.clock.install({ time: new Date('2026-09-11T00:00:00Z') })
await page.clock.pauseAt(new Date('2026-09-11T00:00:01Z'))
await page.evaluate(() => {
window.Artplayer.AUTO_ORIENTATION_TIME = 1000
window.art.fullscreenWeb = true
})
await page.clock.runFor(700)
await page.evaluate(() => {
window.art.fullscreenWeb = false
window.art.fullscreenWeb = true
})
await page.clock.runFor(400)
expect(await page.evaluate(() => window.art.plugins.autoOrientation.state)).toBe(false)
await page.clock.runFor(600)
expect(await page.evaluate(() => window.art.plugins.autoOrientation.state)).toBe(true)
await page.evaluate(() => window.art.destroy())
})
for (const removeHtml of [false, true]) {
test(`candidate: rotation destruction restores styles and cancels subscriptions removeHtml=${removeHtml}`, async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => {
const art = window.art
art.constructor.FULLSCREEN_WEB_IN_BODY = true
art.template.$player.style.cssText = 'width:75%;height:80%;transform:scale(0.9);transform-origin:center;'
window.beforeRotation = art.template.$player.getAttribute('style')
art.fullscreenWeb = true
})
await expect.poll(() => page.evaluate(() => window.art.plugins.autoOrientation.state)).toBe(true)
expect(await page.evaluate((remove) => {
const art = window.art
const player = art.template.$player
art.destroy(remove)
const style = player.getAttribute('style')
art.emit('fullscreenWeb', true)
art.emit('fullscreen', true)
return { restored: style === window.beforeRotation, rotated: art.isRotate, state: art.plugins.autoOrientation.state, retained: player.isConnected, changed: style !== player.getAttribute('style') }
}, removeHtml)).toEqual({ restored: true, rotated: false, state: false, retained: !removeHtml, changed: false })
})
}
test('candidate: destroying a pending rotation removes its timer and produces no late resize', async ({ page }) => {
await setup(page, 'candidate')
await page.clock.install({ time: new Date('2026-09-11T00:00:00Z') })
await page.clock.pauseAt(new Date('2026-09-11T00:00:01Z'))
await page.evaluate(() => {
const art = window.art
art.constructor.AUTO_ORIENTATION_TIME = 1000
art.fullscreenWeb = true
art.destroy(false)
window.lateRotations = []
art.on('resize', () => window.lateRotations.push(art.isRotate))
})
await page.clock.runFor(2000)
expect(await page.evaluate(() => ({ rotated: window.art.plugins.autoOrientation.state, events: window.lateRotations }))).toEqual({ rotated: false, events: [] })
})
test('candidate: rotated playback supports ratio and flip changes before restoring normal layout', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(async () => {
await window.art.play()
window.art.fullscreenWeb = true
})
await expect.poll(() => page.evaluate(() => window.art.plugins.autoOrientation.state && window.art.currentTime > 0.1)).toBe(true)
expect(await page.evaluate(() => {
const art = window.art
art.aspectRatio = '4:3'
art.flip = 'horizontal'
const video = art.template.$video
const ratio = video.clientWidth / video.clientHeight
const flipped = art.flip
art.fullscreenWeb = false
art.aspectRatio = 'default'
art.flip = 'normal'
const result = { ratioClose: Math.abs(ratio - 4 / 3) < 0.01, flipped, playing: art.playing, rotate: art.isRotate, cleared: video.style.width === '' && video.style.height === '' }
art.destroy()
return result
})).toEqual({ ratioClose: true, flipped: 'horizontal', playing: true, rotate: false, cleared: true })
})
test('candidate: repeated web fullscreen retains rotated dimensions and final styles', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => {
window.art.fullscreenWeb = true
})
await expect.poll(() => page.evaluate(() => window.art.plugins.autoOrientation.state)).toBe(true)
expect(await page.evaluate(() => {
const art = window.art
art.fullscreenWeb = true
const result = { width: art.template.$player.style.width, height: art.template.$player.style.height, state: art.plugins.autoOrientation.state }
art.destroy()
return result
})).toEqual({ width: '800px', height: '400px', state: true })
})
for (const core of ['published', 'candidate']) {
test(`${core}: rotated web fullscreen restores original inline geometry`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => {
const art = window.art
art.template.$player.style.cssText = 'width:75%;height:80%;transform:scale(0.9);transform-origin:center;'
window.beforeRotation = art.template.$player.getAttribute('style')
art.fullscreenWeb = true
})
await expect.poll(() => page.evaluate(() => window.art.isRotate)).toBe(true)
expect(await page.evaluate(() => {
const art = window.art
const rotated = { state: art.plugins.autoOrientation.state, width: art.template.$player.style.width, height: art.template.$player.style.height }
art.fullscreenWeb = false
const original = document.createElement('div')
original.setAttribute('style', window.beforeRotation)
const restored = ['width', 'height', 'transform', 'transform-origin'].every(name => art.template.$player.style.getPropertyValue(name) === original.style.getPropertyValue(name))
const result = { rotated, restored, state: art.plugins.autoOrientation.state, isRotate: art.isRotate }
art.destroy()
return result
})).toEqual({ rotated: { state: true, width: '800px', height: '400px' }, restored: core === 'candidate', state: false, isRotate: false })
})
test(`${core}: late orientation lock after fullscreen exit does not reactivate the player`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(async () => {
const art = window.art
const calls = []
let resolveLock
Object.defineProperty(screen, 'orientation', { configurable: true, value: {
type: 'portrait-primary',
lock(mode) {
calls.push(mode)
return new Promise((resolve) => {
resolveLock = resolve
})
},
unlock() {
calls.push('unlock')
},
} })
art.emit('fullscreen', true)
art.emit('fullscreen', false)
resolveLock()
await new Promise(resolve => setTimeout(resolve, 0))
const result = { requested: calls[0], unlocked: calls.includes('unlock'), active: art.template.$player.classList.contains('art-auto-orientation-fullscreen') }
art.destroy()
return result
})).toEqual({ requested: 'landscape', unlocked: core === 'candidate', active: core !== 'candidate' })
})
}
+77
View File
@@ -0,0 +1,77 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.addInitScript(() => {
Object.defineProperty(document, 'pictureInPictureEnabled', { configurable: true, value: false })
const modes = new WeakMap()
Object.defineProperties(HTMLVideoElement.prototype, {
webkitSupportsPresentationMode: { configurable: true, writable: true, value: () => true },
webkitPresentationMode: { configurable: true, get() { return modes.get(this) || 'inline' } },
webkitSetPresentationMode: { configurable: true, writable: true, value(mode) {
modes.set(this, mode)
this.dispatchEvent(new Event('webkitpresentationmodechanged'))
this.dispatchEvent(new Event(mode === 'picture-in-picture' ? 'enterpictureinpicture' : 'leavepictureinpicture'))
} },
})
})
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
window.createPlayer('/test/pattern.mp4')
window.pipEvents = []
window.art.on('pip', value => window.pipEvents.push(value))
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
test(`${core}: WebKit PiP keeps boolean state and repeated synchronous setter events`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.pip = true
art.pip = true
const entered = art.pip
art.pip = false
const result = { entered, exited: art.pip, events: [...window.pipEvents] }
art.destroy()
return result
})).toEqual({ entered: true, exited: false, events: [true, true, false] })
})
test(`${core}: WebKit PiP follows native UI transitions`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const video = window.art.template.$video
video.webkitSetPresentationMode('picture-in-picture')
video.webkitSetPresentationMode('inline')
const result = [...window.pipEvents]
window.art.destroy()
return result
})).toEqual(core === 'candidate' ? [true, false] : [])
})
test(`${core}: destroying WebKit PiP returns presentation to inline`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
window.art.pip = true
window.pipEvents.length = 0
window.art.destroy(false)
return { mode: window.art.template.$video.webkitPresentationMode, events: window.pipEvents }
})).toEqual({ mode: core === 'candidate' ? 'inline' : 'picture-in-picture', events: [] })
})
}
test('candidate: WebKit PiP pending entry is cleaned up after destruction', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const video = art.template.$video
const setMode = video.webkitSetPresentationMode.bind(video)
video.webkitSetPresentationMode = () => {}
art.pip = true
art.destroy(false)
video.webkitSetPresentationMode = setMode
setMode('picture-in-picture')
return { mode: video.webkitPresentationMode, events: window.pipEvents }
})).toEqual({ mode: 'inline', events: [] })
})
+161
View File
@@ -0,0 +1,161 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.addInitScript(() => {
window.pipElement = null
window.pipExits = 0
Object.defineProperties(document, {
pictureInPictureEnabled: { configurable: true, value: true },
pictureInPictureElement: { configurable: true, get: () => window.pipElement },
exitPictureInPicture: { configurable: true, writable: true, value() {
window.pipExits++
const previous = window.pipElement
window.pipElement = null
previous?.dispatchEvent(new Event('leavepictureinpicture'))
return Promise.resolve()
} },
})
Object.defineProperty(HTMLVideoElement.prototype, 'requestPictureInPicture', {
configurable: true,
writable: true,
value() {
window.pipElement = this
this.dispatchEvent(new Event('enterpictureinpicture'))
return Promise.resolve({ width: 320, height: 180 })
},
})
})
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
window.createPlayer('/test/pattern.mp4')
window.pipEvents = []
window.art.on('pip', value => window.pipEvents.push(value))
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
test(`${core}: native PiP preserves element/null getter, void setter and boolean events`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const setter = Object.getOwnPropertyDescriptor(art, 'pip').set
const entered = setter.call(art, true)
const ownElement = art.pip === art.template.$video
const exited = setter.call(art, false)
const result = { ownElement, empty: art.pip === null, entered: typeof entered, exited: typeof exited, events: [...window.pipEvents] }
art.destroy()
return result
})).toEqual({ ownElement: true, empty: true, entered: 'undefined', exited: 'undefined', events: [true, false] })
})
test(`${core}: a player cannot report or exit another video's PiP`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const other = document.createElement('video')
window.pipElement = other
const reported = window.art.pip === other
window.art.pip = false
const result = { reported, stillOther: window.pipElement === other, exits: window.pipExits }
window.pipElement = null
window.art.destroy()
return result
})).toEqual({ reported: core === 'published', stillOther: core === 'candidate', exits: core === 'published' ? 1 : 0 })
})
test(`${core}: destroy releases active PiP without a late player event`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(async () => {
window.art.pip = true
await Promise.resolve()
window.pipEvents.length = 0
window.art.destroy(false)
await Promise.resolve()
return { active: Boolean(window.pipElement), events: window.pipEvents }
})).toEqual({ active: core === 'published', events: [] })
})
}
for (const action of ['cancel', 'destroy']) {
test(`candidate: pending PiP ${action} releases late native entry`, async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(async (action) => {
const art = window.art
const video = art.template.$video
let complete
video.requestPictureInPicture = () => new Promise(resolve => complete = resolve)
art.pip = true
if (action === 'destroy')
art.destroy(false)
else
art.pip = false
window.pipElement = video
video.dispatchEvent(new Event('enterpictureinpicture'))
complete({ width: 320, height: 180 })
await new Promise(resolve => setTimeout(resolve, 0))
const result = { active: Boolean(window.pipElement), events: [...window.pipEvents] }
art.destroy()
return result
}, action)).toEqual({ active: false, events: [] })
})
}
test('candidate: rejected PiP assignment shows notice without an unhandled rejection', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => {
window.art.template.$video.requestPictureInPicture = () => Promise.reject(new Error('controlled PiP denial'))
window.art.pip = true
})
await expect(page.locator('.art-notice')).toContainText('controlled PiP denial')
await page.evaluate(() => window.art.destroy())
})
test('candidate: actual native PiP gesture and playback or explicit unsupported capability', async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
const capability = await page.evaluate(() => ({
enabled: document.pictureInPictureEnabled === true,
request: typeof window.art.template.$video.requestPictureInPicture === 'function',
exit: typeof document.exitPictureInPicture === 'function',
}))
await testInfo.attach('native-pip-capability', { contentType: 'application/json', body: JSON.stringify(capability) })
if (!capability.enabled || !capability.request || !capability.exit) {
expect(await page.evaluate(() => window.art.pip)).toBe(false)
await page.evaluate(() => window.art.destroy())
return
}
await page.evaluate(() => {
const video = window.art.template.$video
const request = video.requestPictureInPicture.bind(video)
video.requestPictureInPicture = () => {
const result = request()
void result.then(pipWindow => window.actualPipWindow = { width: pipWindow.width, height: pipWindow.height })
return result
}
document.querySelector('#play').onclick = () => {
void window.art.play()
window.art.pip = true
}
})
await page.evaluate(() => window.art.mini = true)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.pip === window.art.template.$video)).toBe(true)
expect(await page.evaluate(() => window.art.mini)).toBe(false)
await expect.poll(() => page.evaluate(() => window.actualPipWindow?.width > 0 && window.art.currentTime > 0 && !window.art.template.$video.paused)).toBe(true)
await testInfo.attach('native-pip-playback', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({ window: window.actualPipWindow, currentTime: window.art.currentTime, events: window.events }))) })
await page.evaluate(() => window.art.fullscreenWeb = true)
await expect.poll(() => page.evaluate(() => document.pictureInPictureElement)).toBe(null)
expect(await page.evaluate(() => window.art.fullscreenWeb)).toBe(true)
await page.evaluate(() => window.art.fullscreenWeb = false)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.pip === window.art.template.$video)).toBe(true)
await page.evaluate(() => {
document.querySelector('#play').onclick = () => window.art.fullscreen = true
})
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.fullscreen && !document.pictureInPictureElement)).toBe(true)
await page.evaluate(() => window.art.fullscreen = false)
await expect.poll(() => page.evaluate(() => window.art.fullscreen)).toBe(false)
await page.evaluate(() => window.art.destroy())
})
+161
View File
@@ -0,0 +1,161 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
test(`${core}: sizing methods remain detachable own properties and ratio parsing retains legacy coercion`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const descriptors = ['autoSize', 'autoHeight', 'aspectRatio', 'flip'].map((name) => {
const descriptor = Object.getOwnPropertyDescriptor(art, name)
return { name, configurable: descriptor.configurable, enumerable: descriptor.enumerable, writable: descriptor.writable ?? null }
})
const { autoSize, autoHeight } = art
const returns = [typeof Reflect.apply(autoSize, null, []), typeof Reflect.apply(autoHeight, {}, [])]
art.template.$player.style.cssText = 'width:640px;height:360px;'
const events = []
art.on('aspectRatio', value => events.push(value))
art.aspectRatio = ' 4 : 3 : ignored'
const parsed = { value: art.aspectRatio, width: art.template.$video.style.width, height: art.template.$video.style.height }
art.aspectRatio = ''
art.aspectRatio = 'default'
art.flip = 'custom'
const custom = art.flip
art.flip = ''
const result = { descriptors, returns, parsed, events, custom, reset: art.flip }
art.destroy()
return result
})).toEqual({
descriptors: ['autoSize', 'autoHeight', 'aspectRatio', 'flip'].map(name => ({ name, configurable: false, enumerable: false, writable: name.startsWith('auto') ? false : null })),
returns: ['undefined', 'undefined'],
parsed: { value: ' 4 : 3 : ignored', width: '480px', height: '100%' },
events: [' 4 : 3 : ignored', 'default', 'default'],
custom: 'custom',
reset: 'normal',
})
})
}
test('candidate: hidden containers defer sizing and recover after becoming visible', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const { $container, $video, $player } = art.template
Object.defineProperties($video, { videoWidth: { configurable: true, value: 1920 }, videoHeight: { configurable: true, value: 1080 } })
$container.style.cssText = 'display:none;width:640px;height:480px;'
$player.style.cssText = 'width:80%;height:70%;'
const events = []
art.on('autoSize', value => events.push(['size', value]))
art.on('autoHeight', value => events.push(['height', value]))
art.autoSize()
art.autoHeight()
const hidden = { width: $player.style.width, height: $player.style.height, containerHeight: $container.style.height, count: events.length }
$container.style.display = 'block'
art.autoSize()
art.autoHeight()
const result = { hidden, events, height: $container.style.height }
art.destroy()
return result
})).toEqual({ hidden: { width: '80%', height: '70%', containerHeight: '480px', count: 0 }, events: [['size', { width: 640, height: 360 }], ['height', 360]], height: '360px' })
})
test('candidate: malformed ratios preserve layout while retaining notices, datasets and events', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const { $video } = art.template
art.aspectRatio = '4:3'
const before = $video.getAttribute('style')
const values = []
art.on('aspectRatio', value => values.push(value))
const stable = ['bad', '0:0', '-1:2', '1:0', 'Infinity:2'].map((value) => {
art.aspectRatio = value
return $video.getAttribute('style') === before && art.aspectRatio === value && art.template.$notice.textContent.includes(value)
})
art.destroy()
return { stable, values }
})).toEqual({ stable: [true, true, true, true, true], values: ['bad', '0:0', '-1:2', '1:0', 'Infinity:2'] })
})
test('candidate: retained display setters and sizing methods do not write or emit after destroy', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const { $container, $player, $video } = art.template
art.destroy(false)
const before = [$container.outerHTML, $player.outerHTML, $video.outerHTML]
const events = []
for (const name of ['autoSize', 'autoHeight', 'aspectRatio', 'flip'])
art.on(name, () => events.push(name))
art.autoSize()
art.autoHeight()
art.aspectRatio = '4:3'
art.flip = 'horizontal'
return { same: before.every((html, index) => html === [$container, $player, $video][index].outerHTML), events }
})).toEqual({ same: true, events: [] })
})
for (const core of ['published', 'candidate']) {
test(`${core}: sizing preserves valid geometry, return values and emitted measurements`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const { $container, $player, $video } = art.template
Object.defineProperties($video, { videoWidth: { configurable: true, value: 1920 }, videoHeight: { configurable: true, value: 1080 } })
$container.style.cssText = 'width:640px;height:480px;'
const sizes = []
const heights = []
art.on('autoSize', value => sizes.push(value))
art.on('autoHeight', value => heights.push(value))
const autoSize = art.autoSize()
const styles = { width: $player.style.width, height: $player.style.height }
const autoHeight = art.autoHeight()
const result = { styles, sizes, heights, returns: [typeof autoSize, typeof autoHeight], containerHeight: $container.style.height }
art.destroy()
return result
})).toEqual({ styles: { width: '100%', height: '75%' }, sizes: [{ width: 640, height: 360 }], heights: [360], returns: ['undefined', 'undefined'], containerHeight: '360px' })
})
test(`${core}: aspect ratio and flip preserve dataset, notices and event order`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const events = []
art.on('aspectRatio', value => events.push(['aspectRatio', value]))
art.on('flip', value => events.push(['flip', value]))
art.aspectRatio = '4:3'
const video = art.template.$video
const ratio = { width: video.style.width, height: video.style.height, margin: video.style.margin, value: art.aspectRatio }
art.flip = 'horizontal'
const flip = art.flip
art.aspectRatio = 'default'
art.flip = 'normal'
const result = { ratio, flip, reset: { width: video.style.width, height: video.style.height, margin: video.style.margin, ratio: art.aspectRatio, flip: art.flip }, events }
art.destroy()
return result
})).toEqual({ ratio: { width: '480px', height: '100%', margin: '0px auto', value: '4:3' }, flip: 'horizontal', reset: { width: '', height: '', margin: '', ratio: 'default', flip: 'normal' }, events: [['aspectRatio', '4:3'], ['flip', 'horizontal'], ['aspectRatio', 'default'], ['flip', 'normal']] })
})
test(`${core}: unresolved media dimensions do not partially change layout or emit NaN height`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const { $video, $player } = art.template
Object.defineProperties($video, { videoWidth: { configurable: true, value: 0 }, videoHeight: { configurable: true, value: 0 } })
$player.style.width = '80%'
$player.style.height = '70%'
const events = []
art.on('autoHeight', value => events.push(Number.isFinite(value)))
art.autoSize()
art.autoHeight()
const result = { width: $player.style.width, height: $player.style.height, finiteHeightEvents: events }
art.destroy()
return result
})).toEqual({ width: core === 'candidate' ? '80%' : '100%', height: '70%', finiteHeightEvents: core === 'candidate' ? [] : [false] })
})
}
@@ -0,0 +1,99 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.addInitScript(() => {
// Exercise the video-only branch without claiming an iOS device test.
for (const name of ['fullscreenEnabled', 'webkitFullscreenEnabled', 'webkitFullScreenEnabled', 'mozFullScreenEnabled', 'msFullscreenEnabled'])
Object.defineProperty(document, name, { configurable: true, value: false })
const state = new WeakMap()
Object.defineProperties(HTMLVideoElement.prototype, {
webkitSupportsFullscreen: { configurable: true, get: () => true },
webkitDisplayingFullscreen: { configurable: true, get() { return state.get(this) === true } },
webkitPresentationMode: { configurable: true, get() { return state.get(this) ? 'fullscreen' : 'inline' } },
webkitEnterFullscreen: { configurable: true, writable: true, value() {
state.set(this, true)
this.dispatchEvent(new Event('webkitbeginfullscreen'))
this.dispatchEvent(new Event('webkitpresentationmodechanged'))
} },
webkitExitFullscreen: { configurable: true, writable: true, value() {
state.set(this, false)
this.dispatchEvent(new Event('webkitendfullscreen'))
this.dispatchEvent(new Event('webkitpresentationmodechanged'))
} },
})
window.videoFullscreenState = state
})
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
window.createPlayer('/test/pattern.mp4')
window.modeEvents = []
window.art.on('fullscreen', value => window.modeEvents.push(value))
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
test(`${core}: video-only fullscreen reads its native video state and deduplicates signals`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.fullscreen = true
const entered = art.fullscreen
art.emit('document:webkitfullscreenchange')
art.fullscreen = false
const result = { entered, exited: art.fullscreen, events: [...window.modeEvents] }
art.destroy()
return result
})).toEqual({ entered: core === 'candidate', exited: false, events: core === 'candidate' ? [true, false] : [false] })
})
test(`${core}: destroying video-only fullscreen releases native presentation`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.fullscreen = true
window.modeEvents.length = 0
art.destroy(false)
return { native: art.template.$video.webkitDisplayingFullscreen, events: window.modeEvents }
})).toEqual({ native: core === 'published', events: [] })
})
}
test('candidate: cancelled video-only entry after destroy is released without late player events', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const video = art.template.$video
video.webkitEnterFullscreen = () => {}
art.fullscreen = true
art.destroy(false)
window.videoFullscreenState.set(video, true)
video.dispatchEvent(new Event('webkitbeginfullscreen'))
return { native: video.webkitDisplayingFullscreen, events: window.modeEvents }
})).toEqual({ native: false, events: [] })
})
test('candidate: video-only fullscreen preserves synchronous errors and supports exit retry', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const video = art.template.$video
art.fullscreen = true
const exit = video.webkitExitFullscreen
const failure = new Error('controlled video exit denial')
video.webkitExitFullscreen = () => {
throw failure
}
let rejected = false
try {
art.fullscreen = false
}
catch (error) { rejected = error === failure }
const active = art.fullscreen
video.webkitExitFullscreen = exit
art.fullscreen = false
const result = { rejected, active, exited: art.fullscreen, events: window.modeEvents }
art.destroy()
return result
})).toEqual({ rejected: true, active: true, exited: false, events: [true, false] })
})
+233
View File
@@ -0,0 +1,233 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const removeHtml of [false, true]) {
for (const movedBeforeThrow of [false, true]) {
test(`candidate: failed destroy restoration cannot orphan the player removeHtml=${removeHtml} movedBeforeThrow=${movedBeforeThrow}`, async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(({ removeHtml, movedBeforeThrow }) => {
const art = window.art
const { $player: player, $container: container } = art.template
art.constructor.FULLSCREEN_WEB_IN_BODY = true
art.fullscreenWeb = true
const original = container.insertBefore
const failure = new Error('destroy restore denied')
container.insertBefore = function (node, anchor) {
if (movedBeforeThrow)
original.call(this, node, anchor)
throw failure
}
let sameError = false
try {
art.destroy(removeHtml)
}
catch (error) {
sameError = error === failure
}
container.insertBefore = original
return { sameError, orphan: player.isConnected && !container.contains(player), retained: container.contains(player), destroyed: art.isDestroy, registered: art.constructor.instances.includes(art) }
}, { removeHtml, movedBeforeThrow })).toEqual({ sameError: true, orphan: false, retained: movedBeforeThrow && !removeHtml, destroyed: true, registered: false })
})
}
}
test('candidate: web fullscreen listener can exit without stale resize or overwritten restoration', async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(() => {
const art = window.art
const player = art.template.$player
player.style.width = '75%'
const style = player.style.cssText
const events = []
art.on('fullscreenWeb', (value) => {
events.push(value)
if (value)
art.fullscreenWeb = false
})
art.on('resize', () => events.push('resize'))
art.fullscreenWeb = true
const result = { events, active: art.fullscreenWeb, restored: player.style.cssText === style }
art.destroy()
return result
})
expect(result).toEqual({ events: [true, false, 'resize'], active: false, restored: true })
})
test('candidate: web fullscreen listener destroy restores ownership without post-destroy resize', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const { $player: player, $container: container } = art.template
art.constructor.FULLSCREEN_WEB_IN_BODY = true
const style = player.style.cssText
const events = []
art.on('fullscreenWeb', () => art.destroy(false))
art.on('destroy', () => events.push('destroy'))
art.on('resize', () => events.push('resize'))
art.fullscreenWeb = true
art.fullscreenWeb = true
return { events, retained: container.contains(player), style: player.style.cssText === style, fullscreen: player.classList.contains('art-fullscreen-web') }
})).toEqual({ events: ['destroy'], retained: true, style: true, fullscreen: false })
})
test('candidate: a failed body move restores style and keeps its original error', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const { $player: player, $container: container } = art.template
const style = player.getAttribute('style')
art.constructor.FULLSCREEN_WEB_IN_BODY = true
const append = document.body.appendChild
const failure = new Error('controlled body insertion failure')
document.body.appendChild = function (node) {
if (node === player)
throw failure
return append.call(this, node)
}
let sameError = false
try {
art.fullscreenWeb = true
}
catch (error) {
sameError = error === failure
}
document.body.appendChild = append
const result = { sameError, retained: container.contains(player), style: player.getAttribute('style') === style, active: art.fullscreenWeb }
art.destroy()
return result
})).toEqual({ sameError: true, retained: true, style: true, active: false })
})
test('candidate: a failed web fullscreen exit can retry restoring the original placement', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const { $player: player, $container: container } = art.template
art.constructor.FULLSCREEN_WEB_IN_BODY = true
const style = player.getAttribute('style')
art.fullscreenWeb = true
const insert = container.insertBefore
const failure = new Error('controlled restore failure')
container.insertBefore = () => {
throw failure
}
let sameError = false
try {
art.fullscreenWeb = false
}
catch (error) {
sameError = error === failure
}
container.insertBefore = insert
art.fullscreenWeb = false
const result = { sameError, retained: container.contains(player), style: player.getAttribute('style') === style, active: art.fullscreenWeb }
art.destroy()
return result
})).toEqual({ sameError: true, retained: true, style: true, active: false })
})
test('candidate: reentry during web fullscreen restoration preserves the newer active mode', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const { $player: player, $container: container } = art.template
art.constructor.FULLSCREEN_WEB_IN_BODY = true
player.style.width = '65%'
const style = player.getAttribute('style')
art.fullscreenWeb = true
const insert = container.insertBefore
container.insertBefore = function (node, anchor) {
container.insertBefore = insert
const result = insert.call(this, node, anchor)
art.fullscreenWeb = true
return result
}
art.fullscreenWeb = false
const winner = art.fullscreenWeb && player.parentNode === document.body && player.style.width === '100%'
art.fullscreenWeb = false
const restored = container.contains(player) && player.getAttribute('style') === style
art.destroy()
return { winner, restored }
})).toEqual({ winner: true, restored: true })
})
for (const core of ['published', 'candidate']) {
for (const initial of ['', 'width: 70%; height: 80%; color: red;']) {
test(`${core}: web fullscreen restores initial style after repeated entry (${initial || 'empty'})`, async ({ page }, testInfo) => {
await setup(page, core)
const result = await page.evaluate((initial) => {
const art = window.art
art.constructor.FULLSCREEN_WEB_IN_BODY = false
const player = art.template.$player
player.style.cssText = initial
const before = player.style.cssText
const events = []
let restoredAtExit
art.on('fullscreenWeb', (value) => {
events.push(value)
if (!value)
restoredAtExit = player.style.cssText
})
art.fullscreenWeb = true
art.fullscreenWeb = true
art.fullscreenWeb = false
const result = { restored: restoredAtExit === before, events, active: art.fullscreenWeb, dimensions: [player.style.width, player.style.height, player.style.color], styles: { before, restoredAtExit, after: player.style.cssText } }
art.destroy()
return result
}, initial)
const { styles, ...observed } = result
await testInfo.attach('fullscreen-styles', { contentType: 'application/json', body: JSON.stringify(styles) })
expect(observed).toEqual({ restored: core === 'candidate', events: [true, true, false], active: false, dimensions: core === 'candidate' ? (initial ? ['70%', '80%', 'red'] : ['', '', '']) : ['100%', '100%', initial ? 'red' : ''] })
})
}
for (const changeFlag of [false, true]) {
test(`${core}: body fullscreen restores original parent and sibling when flag changes=${changeFlag}`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate((changeFlag) => {
const art = window.art
const player = art.template.$player
const wrapper = document.createElement('div')
const sibling = document.createElement('span')
art.template.$container.append(wrapper)
wrapper.append(player, sibling)
art.constructor.FULLSCREEN_WEB_IN_BODY = true
art.fullscreenWeb = true
const entered = player.parentNode === document.body
if (changeFlag)
art.constructor.FULLSCREEN_WEB_IN_BODY = false
art.fullscreenWeb = false
const result = { entered, parent: player.parentNode === wrapper, sibling: player.nextSibling === sibling, inBody: player.parentNode === document.body }
art.destroy()
player.remove()
return result
}, changeFlag)
expect(result).toEqual({ entered: true, parent: core === 'candidate', sibling: core === 'candidate', inBody: core === 'published' && changeFlag })
})
}
for (const removeHtml of [false, true]) {
test(`${core}: destroy while web fullscreen removeHtml=${removeHtml} restores DOM ownership`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate((removeHtml) => {
const art = window.art
const { $player: player, $container: container } = art.template
const style = player.style.cssText
art.constructor.FULLSCREEN_WEB_IN_BODY = true
art.fullscreenWeb = true
art.destroy(removeHtml)
const result = { orphan: player.parentNode === document.body, retained: container.contains(player), style: player.style.cssText === style, fullscreen: player.classList.contains('art-fullscreen-web') }
player.remove()
return result
}, removeHtml)
expect(result).toEqual(core === 'candidate'
? { orphan: false, retained: !removeHtml, style: true, fullscreen: false }
: { orphan: true, retained: false, style: false, fullscreen: true })
})
}
}
+30
View File
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
import { test } from 'node:test'
import { loadModules } from './helpers/load.js'
const { miniPosition, clampMini } = await loadModules({
miniPosition: { file: 'packages/artplayer/src/display/mini-layout', name: 'miniPosition' },
clampMini: { file: 'packages/artplayer/src/display/mini-layout', name: 'clampMini' },
})
const geometry = { width: 320, height: 180, viewportWidth: 960, viewportHeight: 720 }
test('mini preserves finite visible stored coordinates including zero', () => {
assert.deepEqual(miniPosition(0, 0, geometry), { left: 0, top: 0, reset: false })
assert.deepEqual(miniPosition(640, 540, geometry), { left: 640, top: 540, reset: false })
})
test('mini replaces missing, non-finite and offscreen storage with the legacy 50px inset', () => {
for (const [left, top] of [[undefined, null], [Number.NaN, 20], [20, Infinity], ['20', 20], [-1, 50], [800, 400], [200, 700]])
assert.deepEqual(miniPosition(left, top, geometry), { left: 590, top: 490, reset: true })
})
test('mini default position remains reachable on small viewports', () => {
assert.deepEqual(miniPosition(null, null, { width: 280, height: 160, viewportWidth: 280, viewportHeight: 160 }), { left: 0, top: 0, reset: true })
})
test('mini drag clamps both boundaries without changing positions inside the viewport', () => {
assert.deepEqual(clampMini(-100, 900, geometry), { left: 0, top: 540 })
assert.deepEqual(clampMini(600, 400, geometry), { left: 600, top: 400 })
assert.deepEqual(clampMini(900, -2, geometry), { left: 640, top: 0 })
})
+324
View File
@@ -0,0 +1,324 @@
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 { ResourceScope, requestFullscreen, nativeFullscreen, beginLifecycle, getScope } = await loadModules({
ResourceScope: 'packages/artplayer/src/lifecycle/scope',
requestFullscreen: { file: 'packages/artplayer/src/display/fullscreen-request', name: 'requestFullscreen' },
nativeFullscreen: { file: 'packages/artplayer/src/display/native-fullscreen', name: 'nativeFullscreen' },
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
})
function fixture() {
const classes = new Set()
const target = { classList: {
remove: name => classes.delete(name),
toggle: (name, value) => value ? classes.add(name) : classes.delete(name),
} }
const adapter = {
document: new EventTarget(),
target,
element: null,
elementProperty: 'fullscreenElement',
changeEvent: 'fullscreenchange',
errorEvent: 'fullscreenerror',
request: () => Promise.resolve(),
exit: () => Promise.resolve(),
}
Object.defineProperty(adapter.document, 'fullscreenElement', { get: () => adapter.element })
const events = []
const art = {
template: { $player: target, $video: {} },
notice: { show: '' },
state: 'standard',
emit: (...args) => events.push(args),
}
beginLifecycle(art)
const descriptor = nativeFullscreen(art, adapter)
const change = (element) => {
adapter.element = element
adapter.document.dispatchEvent(new Event(adapter.changeEvent))
}
const listeners = () => [adapter.changeEvent, adapter.errorEvent].map(name => getEventListeners(adapter.document, name).length)
return { art, adapter, classes, descriptor, change, events, listeners }
}
test('fullscreen native invocation retains the caller stack and original rejection', async () => {
const { adapter } = fixture()
const parent = new ResourceScope()
const failure = new Error('denied')
let called = false
adapter.request = () => {
called = true
return Promise.reject(failure)
}
const operation = requestFullscreen(adapter, parent, true, () => assert.fail('not cancelled'))
assert.equal(called, true)
await assert.rejects(operation.promise, error => error === failure)
assert.equal(getEventListeners(adapter.document, adapter.changeEvent).length, 1)
assert.equal(getEventListeners(adapter.document, adapter.errorEvent).length, 1)
parent.dispose()
})
test('void fullscreen APIs wait for the actual change and remove operation listeners', async () => {
const { adapter, change, listeners } = fixture()
adapter.request = () => undefined
const parent = new ResourceScope()
const operation = requestFullscreen(adapter, parent, true, () => {})
let settled = false
void operation.promise.then(() => settled = true)
await setImmediate()
assert.equal(settled, false)
assert.deepEqual(listeners(), [2, 2])
change(adapter.target)
await operation.promise
assert.deepEqual(listeners(), [1, 1])
parent.dispose()
})
test('void native exit waits while the instance video still owns fullscreen', async () => {
const { art, adapter, descriptor, change, listeners } = fixture()
change(art.template.$video)
adapter.exit = () => undefined
let settled = false
const exit = descriptor.set(false).then(() => settled = true)
await setImmediate()
assert.equal(settled, false)
assert.deepEqual(listeners(), [2, 2])
change(null)
await exit
assert.equal(settled, true)
assert.deepEqual(listeners(), [1, 1])
getScope(art).dispose()
})
test('fullscreen request disposal settles without waiting and handles late completion once', async () => {
const { adapter, listeners } = fixture()
let complete
let late = 0
adapter.request = () => new Promise(resolve => complete = resolve)
const parent = new ResourceScope()
const operation = requestFullscreen(adapter, parent, true, () => late++)
parent.dispose()
await operation.promise
assert.deepEqual(listeners(), [1, 1])
complete()
await setImmediate()
assert.equal(late, 1)
})
test('closed fullscreen request scopes never call the browser', async () => {
const { adapter } = fixture()
adapter.request = () => assert.fail('closed request invoked')
const parent = new ResourceScope()
parent.dispose()
await requestFullscreen(adapter, parent, true, () => {}).promise
})
test('native promise rejection retains its error when fullscreenerror arrives first', async () => {
const { adapter } = fixture()
const failure = new Error('browser permission denied')
adapter.request = () => {
adapter.document.dispatchEvent(new Event(adapter.errorEvent))
return Promise.reject(failure)
}
const parent = new ResourceScope()
await assert.rejects(requestFullscreen(adapter, parent, true, () => {}).promise, error => error === failure)
parent.dispose()
})
test('void fullscreen errors reject and release both temporary listeners', async () => {
const { adapter, listeners } = fixture()
adapter.request = () => undefined
const parent = new ResourceScope()
const operation = requestFullscreen(adapter, parent, true, () => {})
adapter.document.dispatchEvent(new Event(adapter.errorEvent))
await assert.rejects(operation.promise, /Fullscreen request failed/)
assert.deepEqual(listeners(), [1, 1])
parent.dispose()
})
test('synchronous void fullscreen errors reject without waiting for a second event', async () => {
const { adapter, listeners } = fixture()
adapter.request = () => {
adapter.document.dispatchEvent(new Event(adapter.errorEvent))
}
const parent = new ResourceScope()
await assert.rejects(requestFullscreen(adapter, parent, true, () => {}).promise, /Fullscreen request failed/)
assert.deepEqual(listeners(), [1, 1])
parent.dispose()
})
for (const close of ['exit', 'destroy']) {
test(`pending fullscreen ${close} settles promptly and exits its late native entry`, async () => {
const { art, adapter, descriptor, listeners } = fixture()
let complete
let exits = 0
adapter.request = () => new Promise(resolve => complete = resolve)
adapter.exit = () => {
exits++
adapter.element = null
return Promise.resolve()
}
const entering = descriptor.set(true)
if (close === 'destroy')
getScope(art).dispose()
else
await descriptor.set(false)
await entering
assert.deepEqual(listeners(), close === 'destroy' ? [0, 0] : [1, 1])
adapter.element = adapter.target
complete()
await setImmediate()
assert.equal(exits, 1)
assert.equal(descriptor.get(), false)
})
}
test('late cancelled fullscreen completion cannot exit another player', async () => {
const { art, adapter, descriptor } = fixture()
let complete
adapter.request = () => new Promise(resolve => complete = resolve)
adapter.exit = () => assert.fail('exited another player')
const entering = descriptor.set(true)
getScope(art).dispose()
await entering
adapter.element = {}
complete()
await setImmediate()
})
test('cancelled void fullscreen requests retain no instance listeners and release late entry', async () => {
const { art, adapter, descriptor, change, listeners, events } = fixture()
let exits = 0
adapter.request = () => undefined
adapter.exit = () => {
exits++
adapter.element = null
}
const entering = descriptor.set(true)
getScope(art).dispose()
await entering
// One shared document guard remains; both per-instance listeners are gone.
assert.deepEqual(listeners(), [1, 0])
change({})
assert.equal(exits, 0)
change(adapter.target)
assert.equal(exits, 1)
assert.equal(adapter.element, null)
assert.deepEqual(events, [])
})
test('new native entry supersedes an abandoned void request for the same target', async () => {
const { art, adapter, descriptor, change } = fixture()
adapter.request = () => undefined
adapter.exit = () => assert.fail('new entry was mistaken for cancellation')
const first = descriptor.set(true)
await descriptor.set(false)
await first
const second = descriptor.set(true)
change(adapter.target)
await second
assert.equal(descriptor.get(), true)
adapter.element = null
getScope(art).dispose()
})
test('cancelled void entry never emits a stale true event or sets its fullscreen class', async () => {
const { art, adapter, descriptor, change, events, classes } = fixture()
adapter.request = () => undefined
adapter.exit = () => change(null)
const entering = descriptor.set(true)
await descriptor.set(false)
await entering
change(adapter.target)
assert.deepEqual(events, [])
assert.equal(classes.has('art-fullscreen'), false)
assert.equal(descriptor.get(), false)
getScope(art).dispose()
})
test('cancelled promise entry rejects a late change before its native promise resolves', async () => {
const { art, adapter, descriptor, change, events, classes } = fixture()
let complete
adapter.request = () => new Promise(resolve => complete = resolve)
adapter.exit = () => {
change(null)
return Promise.resolve()
}
const entering = descriptor.set(true)
await descriptor.set(false)
await entering
change(adapter.target)
complete()
await setImmediate()
assert.deepEqual(events, [])
assert.equal(classes.has('art-fullscreen'), false)
assert.equal(descriptor.get(), false)
getScope(art).dispose()
})
test('fullscreen rejection stays observable through the descriptor and reports notice', async () => {
const { art, adapter, descriptor, listeners } = fixture()
const failure = new Error('denied')
adapter.request = () => Promise.reject(failure)
await assert.rejects(descriptor.set(true), error => error === failure)
assert.equal(art.notice.show, failure)
assert.deepEqual(listeners(), [1, 1])
getScope(art).dispose()
})
test('fullscreen event precedes the class update and resize', async () => {
const { art, adapter, descriptor, classes, change } = fixture()
const observations = []
art.emit = (name, value) => observations.push([name, value, classes.has('art-fullscreen')])
adapter.request = () => {
change(adapter.target)
return Promise.resolve()
}
await descriptor.set(true)
assert.deepEqual(observations, [['fullscreen', true, false], ['resize', undefined, true]])
getScope(art).dispose()
})
test('reentrant true during fullscreen entry still updates its class and resize', async () => {
const { art, adapter, descriptor, classes, change, events } = fixture()
art.emit = (...args) => {
events.push(args)
if (args[0] === 'fullscreen' && args[1])
void descriptor.set(true)
}
adapter.request = () => {
change(adapter.target)
return Promise.resolve()
}
await descriptor.set(true)
assert.equal(classes.has('art-fullscreen'), true)
assert.deepEqual(events, [['fullscreen', true], ['resize']])
getScope(art).dispose()
})
test('reentrant exit during fullscreen entry prevents stale class and resize', async () => {
const { art, adapter, descriptor, classes, change, events } = fixture()
art.emit = (...args) => {
events.push(args)
if (args[0] === 'fullscreen' && args[1])
void descriptor.set(false)
}
adapter.request = () => {
change(adapter.target)
return Promise.resolve()
}
adapter.exit = () => {
change(null)
return Promise.resolve()
}
await descriptor.set(true)
assert.equal(classes.has('art-fullscreen'), false)
assert.deepEqual(events, [['fullscreen', true], ['fullscreen', false], ['resize']])
getScope(art).dispose()
})
+141
View File
@@ -0,0 +1,141 @@
import assert from 'node:assert/strict'
// 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 { nativeOrientation, beginLifecycle, getScope } = await loadModules({
nativeOrientation: { file: 'packages/artplayer/src/display/orientation-native', name: 'nativeOrientation' },
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
})
function fixture(shared, need = () => true) {
const calls = []
const locks = []
const orientation = shared || {
type: 'portrait-primary',
lock(mode) {
calls.push(mode)
return new Promise((resolve, reject) => locks.push({ resolve, reject }))
},
unlock() {
calls.push('unlock')
},
}
const classes = new Set()
const art = {
template: { $player: { classList: { add: name => classes.add(name), remove: name => classes.delete(name) }, ownerDocument: { defaultView: { screen: { orientation } } } } },
notice: { show: '' },
}
beginLifecycle(art)
return { art, orientation, calls, locks, classes, set: nativeOrientation(art, need), dispose: () => getScope(art).dispose() }
}
test('orientation invokes synchronously, marks after success, and unlocks on exit', async () => {
const f = fixture()
assert.equal(f.set(true), undefined)
assert.deepEqual(f.calls, ['landscape'])
assert.equal(f.classes.size, 0)
f.locks[0].resolve()
await setImmediate()
assert.deepEqual([...f.classes], ['art-auto-orientation-fullscreen'])
f.set(false)
assert.deepEqual(f.calls, ['landscape', 'unlock'])
assert.equal(f.classes.size, 0)
f.dispose()
})
test('orientation repeated entry uses one pending or active lock', async () => {
const f = fixture()
f.set(true)
f.set(true)
f.locks[0].resolve()
await setImmediate()
f.set(true)
assert.deepEqual(f.calls, ['landscape'])
f.dispose()
})
test('cancelled late orientation success is released without reactivating classes', async () => {
const f = fixture()
f.set(true)
f.set(false)
f.locks[0].resolve()
await setImmediate()
assert.deepEqual(f.calls, ['landscape', 'unlock', 'unlock'])
assert.equal(f.classes.size, 0)
assert.equal(f.art.notice.show, '')
f.dispose()
})
test('destroyed pending orientation ignores late rejection and further events', async () => {
const f = fixture()
f.set(true)
f.dispose()
f.art.notice.show = 'replacement notice'
f.locks[0].reject(new Error('aborted'))
await setImmediate()
f.set(true)
assert.deepEqual(f.calls, ['landscape', 'unlock'])
assert.equal(f.art.notice.show, 'replacement notice')
assert.equal(f.classes.size, 0)
})
test('orientation failure preserves original error identity and allows retry', async () => {
const f = fixture()
const error = new Error('unsupported')
f.set(true)
f.locks[0].reject(error)
await setImmediate()
assert.equal(f.art.notice.show, error)
f.orientation.type = 'landscape-primary'
f.set(true)
assert.deepEqual(f.calls, ['landscape', 'portrait'])
f.locks[1].resolve()
await setImmediate()
f.dispose()
})
test('orientation synchronous lock failures are reported and cleanup failure remains best effort', () => {
const f = fixture()
const error = new Error('sync denied')
f.orientation.lock = () => {
throw error
}
f.set(true)
assert.equal(f.art.notice.show, error)
f.orientation.lock = () => new Promise(() => {})
f.orientation.unlock = () => {
throw new Error('unlock denied')
}
f.set(true)
assert.doesNotThrow(f.dispose)
})
test('superseded orientation completion and old destruction cannot unlock a newer instance', async () => {
const first = fixture()
const second = fixture(first.orientation)
first.set(true)
second.set(true)
first.locks[1].resolve()
await setImmediate()
first.locks[0].resolve()
await setImmediate()
first.dispose()
assert.deepEqual(first.calls, ['landscape', 'landscape'])
assert.equal(first.classes.size, 0)
assert.equal(second.classes.size, 1)
second.dispose()
assert.deepEqual(first.calls, ['landscape', 'landscape', 'unlock'])
})
test('no rotation requirement and incomplete platform capabilities do not call lock', () => {
const f = fixture(undefined, () => false)
f.set(true)
assert.deepEqual(f.calls, [])
f.dispose()
const missing = fixture({ type: 'portrait-primary', lock: true })
assert.doesNotThrow(() => missing.set(true))
missing.dispose()
})
+215
View File
@@ -0,0 +1,215 @@
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()
})
+43
View File
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
import { test } from 'node:test'
import { loadModules } from './helpers/load.js'
const { containSize, proportionalHeight } = await loadModules({
containSize: { file: 'packages/artplayer/src/display/sizing', name: 'containSize' },
proportionalHeight: { file: 'packages/artplayer/src/display/sizing', name: 'proportionalHeight' },
})
test('contained media preserves its ratio, fits both bounds and fills one axis', () => {
for (const container of [{ width: 640, height: 480 }, { width: 160, height: 900 }, { width: 0.5, height: 0.25 }]) {
for (const ratio of [16 / 9, 9 / 16, 1, 4 / 3, 0.001, 1000]) {
const size = containSize(container, ratio)
assert.ok(size.width <= container.width && size.height <= container.height)
assert.ok(Math.abs(size.width / size.height - ratio) <= ratio * 1e-12)
assert.ok(size.width === container.width || size.height === container.height)
}
}
})
test('zero, negative and non-finite geometry cannot produce layout writes', () => {
for (const invalid of [0, -1, Number.NaN, Infinity, -Infinity]) {
assert.equal(containSize({ width: invalid, height: 480 }, 16 / 9), undefined)
assert.equal(containSize({ width: 640, height: invalid }, 16 / 9), undefined)
assert.equal(containSize({ width: 640, height: 480 }, invalid), undefined)
assert.equal(proportionalHeight(invalid, { width: 1920, height: 1080 }), undefined)
assert.equal(proportionalHeight(640, { width: invalid, height: 1080 }), undefined)
assert.equal(proportionalHeight(640, { width: 1920, height: invalid }), undefined)
}
})
test('auto height uses media proportions and recovers when dimensions become ready', () => {
assert.equal(proportionalHeight(640, { width: 0, height: 0 }), undefined)
assert.equal(proportionalHeight(640, { width: 1920, height: 1080 }), 360)
assert.equal(proportionalHeight(360, { width: 1080, height: 1920 }), 640)
})
test('overflow and underflow never escape as infinite or zero layout measurements', () => {
assert.equal(proportionalHeight(Number.MAX_VALUE, { width: Number.MIN_VALUE, height: 1 }), undefined)
assert.equal(proportionalHeight(Number.MIN_VALUE, { width: Number.MAX_VALUE, height: 1 }), undefined)
assert.equal(containSize({ width: Number.MIN_VALUE, height: Number.MIN_VALUE }, Number.MAX_VALUE), undefined)
})
+151
View File
@@ -0,0 +1,151 @@
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 { loadModules } from './helpers/load.js'
const { Emitter, videoFullscreen, beginLifecycle, getScope } = await loadModules({
Emitter: 'packages/artplayer/src/utils/emitter',
videoFullscreen: { file: 'packages/artplayer/src/display/video-fullscreen', name: 'videoFullscreen' },
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
})
function fixture() {
const video = new EventTarget()
video.ownerDocument = { fullscreenElement: null }
video.webkitDisplayingFullscreen = false
video.webkitEnterFullscreen = function () {
this.webkitDisplayingFullscreen = true
this.dispatchEvent(new Event('webkitbeginfullscreen'))
}
video.webkitExitFullscreen = function () {
this.webkitDisplayingFullscreen = false
this.dispatchEvent(new Event('webkitendfullscreen'))
}
const art = Object.assign(new Emitter(), { template: { $video: video }, state: 'standard' })
beginLifecycle(art)
Object.defineProperty(art, 'fullscreen', videoFullscreen(art))
const events = []
art.on('fullscreen', value => events.push(['fullscreen', value]))
art.on('resize', () => events.push(['resize']))
return { art, video, events }
}
test('video-only native callbacks preserve boolean state and normal event/resize order', () => {
const { art, video, events } = fixture()
art.fullscreen = true
art.emit('document:webkitfullscreenchange')
video.dispatchEvent(new Event('webkitpresentationmodechanged'))
assert.equal(art.fullscreen, true)
art.fullscreen = false
assert.equal(art.fullscreen, false)
assert.deepEqual(events, [['fullscreen', true], ['resize'], ['fullscreen', false], ['resize']])
getScope(art).dispose()
assert.equal(getEventListeners(video, 'webkitbeginfullscreen').length, 0)
assert.equal(getEventListeners(video, 'webkitendfullscreen').length, 0)
assert.equal(getEventListeners(video, 'webkitpresentationmodechanged').length, 0)
})
test('video presentation PiP is distinct from fullscreen even if displayingFullscreen is true', () => {
const { art, video, events } = fixture()
video.webkitPresentationMode = 'picture-in-picture'
video.webkitDisplayingFullscreen = true
video.dispatchEvent(new Event('webkitpresentationmodechanged'))
assert.equal(art.fullscreen, false)
assert.deepEqual(events, [])
getScope(art).dispose()
})
test('video fullscreen falls back to native events if a shim has no state property', () => {
const { art, video } = fixture()
delete video.webkitDisplayingFullscreen
video.dispatchEvent(new Event('webkitbeginfullscreen'))
assert.equal(art.fullscreen, true)
video.dispatchEvent(new Event('webkitendfullscreen'))
assert.equal(art.fullscreen, false)
getScope(art).dispose()
})
test('video fullscreen destruction during its entry event stops stale resize and exits', () => {
const { art, video, events } = fixture()
art.on('fullscreen', (value) => {
if (value)
getScope(art).dispose()
})
art.fullscreen = true
assert.equal(video.webkitDisplayingFullscreen, false)
assert.equal(art.fullscreen, false)
assert.deepEqual(events, [['fullscreen', true]])
})
test('video fullscreen same-state reentry preserves the one resize', () => {
const { art, events } = fixture()
art.on('fullscreen', (value) => {
if (value)
art.fullscreen = true
})
art.fullscreen = true
assert.equal(art.fullscreen, true)
assert.deepEqual(events, [['fullscreen', true], ['resize']])
getScope(art).dispose()
})
test('video fullscreen synchronous request errors keep their identity and allow retry', () => {
const { art, video } = fixture()
const enter = video.webkitEnterFullscreen
const error = new Error('denied')
video.webkitEnterFullscreen = () => {
throw error
}
assert.throws(() => {
art.fullscreen = true
}, actual => actual === error)
video.webkitEnterFullscreen = enter
art.fullscreen = true
assert.equal(art.fullscreen, true)
getScope(art).dispose()
})
test('cancelled video fullscreen entry is released without stale player events', () => {
const { art, video, events } = fixture()
const enter = video.webkitEnterFullscreen
video.webkitEnterFullscreen = () => {}
art.fullscreen = true
art.fullscreen = false
enter.call(video)
assert.equal(video.webkitDisplayingFullscreen, false)
assert.equal(art.fullscreen, false)
assert.deepEqual(events, [])
getScope(art).dispose()
})
test('new video fullscreen entry supersedes the previous cancellation', () => {
const { art, video, events } = fixture()
const enter = video.webkitEnterFullscreen
video.webkitEnterFullscreen = () => {}
art.fullscreen = true
art.fullscreen = false
video.webkitEnterFullscreen = enter
art.fullscreen = true
assert.equal(art.fullscreen, true)
assert.deepEqual(events, [['fullscreen', true], ['resize']])
getScope(art).dispose()
})
test('late entry on a video shim without state properties is released after destroy', () => {
const { art, video, events } = fixture()
delete video.webkitDisplayingFullscreen
let exits = 0
video.webkitEnterFullscreen = () => {}
video.webkitExitFullscreen = () => {
exits++
video.dispatchEvent(new Event('webkitendfullscreen'))
}
art.fullscreen = true
getScope(art).dispose()
video.dispatchEvent(new Event('webkitbeginfullscreen'))
assert.equal(exits, 1)
assert.deepEqual(events, [])
assert.equal(getEventListeners(video, 'webkitbeginfullscreen').length, 0)
})
+13
View File
@@ -0,0 +1,13 @@
import type { MiniHost } from '../../packages/artplayer/src/display/types'
import miniMix from '../../packages/artplayer/src/player/miniMix'
declare const host: MiniHost
miniMix(host)
const active: boolean = host.mini
host.mini = true
host.mini = false
// @ts-expect-error Mini retains boolean setters.
host.mini = 'true'
// @ts-expect-error Mini storage persists numeric coordinates.
host.storage.set('left', '25px')
export { active }
+14
View File
@@ -0,0 +1,14 @@
import type { OrientationHost, OrientationLock } from '../../packages/artplayer/src/display/orientation-types'
import autoOrientation from '../../packages/artplayer/src/plugins/autoOrientation'
declare const host: OrientationHost
declare const lock: OrientationLock
const plugin = autoOrientation(host)
const state: boolean = plugin.state
// @ts-expect-error The builtin exposes a getter, not a writable flag.
plugin.state = true
// @ts-expect-error Internal orientation requests use the opposite portrait/landscape mode.
lock.lock('upside-down')
// @ts-expect-error Fullscreen events carry boolean state.
host.on('fullscreen', (value: string) => value)
export { state }
+13
View File
@@ -0,0 +1,13 @@
import type { PipHost } from '../../packages/artplayer/src/display/types'
import pipMix from '../../packages/artplayer/src/player/pipMix'
declare const host: PipHost
pipMix(host)
const state: HTMLElement | null | boolean = host.pip
// @ts-expect-error Native PiP historically returns the element or null, not just boolean.
const booleanOnly: boolean = host.pip
host.pip = true
host.pip = false
// @ts-expect-error PiP setters accept a boolean input.
host.pip = 'picture-in-picture'
export { booleanOnly, state }
+21
View File
@@ -0,0 +1,21 @@
import type { AutoHeightHost, AutoSizeHost } from '../../packages/artplayer/src/display/sizing-types'
import { containSize, proportionalHeight } from '../../packages/artplayer/src/display/sizing'
import autoHeightMix from '../../packages/artplayer/src/player/autoHeightMix'
import autoSizeMix from '../../packages/artplayer/src/player/autoSizeMix'
declare const sizeHost: AutoSizeHost
declare const heightHost: AutoHeightHost
autoSizeMix(sizeHost)
autoHeightMix(heightHost)
const height: number | undefined = proportionalHeight(640, { width: 1920, height: 1080 })
const size = containSize({ width: 640, height: 480 }, 16 / 9)
if (size) {
const axis: 'width' | 'height' = size.axis
sizeHost.emit('autoSize', { width: size.width, height: size.height })
void axis
}
// @ts-expect-error The internal measurement cannot be a CSS string.
heightHost.emit('autoHeight', '360px')
// @ts-expect-error Dimensions must describe both axes.
proportionalHeight(640, { width: 1920 })
export { height }
+15
View File
@@ -0,0 +1,15 @@
import type { WebFullscreenHost } from '../../packages/artplayer/src/display/types'
import { capturePlacement, restorePlacement } from '../../packages/artplayer/src/display/placement'
import fullscreenWebMix from '../../packages/artplayer/src/player/fullscreenWebMix'
declare const host: WebFullscreenHost
fullscreenWebMix(host)
host.fullscreenWeb = true
const active: boolean = host.fullscreenWeb
const placement = capturePlacement(host.template.$player)
restorePlacement(placement)
// @ts-expect-error Display setters retain a boolean input.
host.fullscreenWeb = 'true'
// @ts-expect-error DOM placement cannot be constructed from a string.
capturePlacement('player')
export { active }