refactor(core): CORE-18 type builtins and own prompt lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 12:31:01 +08:00
1 parent a842058136
commit 98a1800032
55 files changed
+26511 -650

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, time = 1) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate((time) => {
window.Artplayer.AUTO_PLAYBACK_MIN = 0.5
window.Artplayer.AUTO_PLAYBACK_TIMEOUT = 24735
localStorage.setItem('artplayer_settings', JSON.stringify({ volume: 0.4, times: { example: time } }))
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', id: 'example', autoPlayback: true, muted: true })
window.resume = window.art.plugins['auto-playback']
window.promptLayer = window.art.layers['auto-playback']
}, time)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await expect.poll(() => page.evaluate(() => window.promptLayer.style.display)).toBe('flex')
}
for (const core of ['published', 'candidate']) {
test(`${core}: resume close and playback rejection preserve the UI contract`, async ({ page }, testInfo) => {
await setup(page, core)
await page.locator('.art-auto-playback-close').click()
expect(await page.evaluate(() => window.promptLayer.style.display)).toBe('none')
await page.evaluate((published) => {
window.art.emit('restart', window.art.url)
const failure = new Error('controlled play rejection')
window.resumeRejections = []
if (published) {
window.addEventListener('unhandledrejection', (event) => {
if (event.reason === failure) {
event.preventDefault()
window.resumeRejections.push({ name: event.reason.name, message: event.reason.message, same: true })
}
})
}
window.art.template.$video.play = () => Promise.reject(failure)
}, core === 'published')
await page.locator('.art-auto-playback-jump').click()
expect(await page.evaluate(() => ({ layer: window.promptLayer.style.display, poster: window.art.template.$poster.style.display }))).toEqual({ layer: 'none', poster: 'none' })
if (core === 'published')
await expect.poll(() => page.evaluate(() => window.resumeRejections.length)).toBe(2)
const rejections = await page.evaluate(() => window.resumeRejections)
expect(rejections).toEqual(core === 'published' ? Array.from({ length: 2 }, () => ({ name: 'Error', message: 'controlled play rejection', same: true })) : [])
await testInfo.attach('known-resume-rejections', { contentType: 'application/json', body: JSON.stringify({ core, rejections }) })
await page.evaluate(() => window.art.destroy())
})
test(`${core}: resume API retains storage envelope, ID lookup and deletion`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const plugin = window.resume
const before = plugin.times
const removed = plugin.delete('example')
const cleared = plugin.clear()
const envelope = JSON.parse(localStorage.getItem('artplayer_settings'))
window.art.destroy()
return { keys: Object.keys(plugin), name: plugin.name, before, removed, cleared, times: plugin.times, volume: envelope.volume, removedTimes: !Object.hasOwn(envelope, 'times') }
})).toEqual({ keys: ['name', 'times', 'clear', 'delete'], name: 'auto-playback', before: { example: 1 }, removed: {}, cleared: undefined, times: {}, volume: 0.4, removedTimes: true })
})
test(`${core}: resume button seeks and plays actual video`, async ({ page }) => {
await setup(page, core)
await page.locator('.art-auto-playback-jump').click()
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(1)
expect(await page.evaluate(() => ({ prompt: window.promptLayer.style.display, poster: window.art.template.$poster.style.display }))).toEqual({ prompt: 'none', poster: 'none' })
await expect.poll(() => page.evaluate(() => window.resume.times.example)).toBeGreaterThan(1)
await page.evaluate(() => window.art.destroy())
})
test(`${core}: repeated restart leaves one effective click for the current resume target`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(() => {
const art = window.art
const seeks = []
let plays = 0
art.on('seek', (_time, requested) => seeks.push(requested))
art.template.$video.play = () => {
plays++
return Promise.resolve()
}
art.storage.set('times', { example: 1.5 })
art.emit('restart', art.url)
art.storage.set('times', { example: 2 })
art.emit('restart', art.url)
window.promptLayer.querySelector('.art-auto-playback-jump').click()
const label = window.promptLayer.querySelector('.art-auto-playback-last').textContent
art.destroy()
return { seeks, plays, label }
})
expect(result.seeks).toEqual(core === 'candidate' ? [2] : [1, 1.5, 2])
expect(result.plays).toBe(core === 'candidate' ? 1 : 3)
expect(result.label).toBe('Last Seen 00:02')
})
test(`${core}: restart below threshold invalidates the old prompt click`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
let plays = 0
art.template.$video.play = () => {
plays++
return Promise.resolve()
}
art.storage.set('times', { example: 0.1 })
art.emit('restart', art.url)
window.promptLayer.querySelector('.art-auto-playback-jump').click()
const result = { hidden: window.promptLayer.style.display, plays }
art.destroy()
return result
})).toEqual({ hidden: 'none', plays: core === 'candidate' ? 0 : 1 })
})
}
for (const action of ['restart', 'source', 'remove', 'destroy']) {
test(`candidate: ${action} cancels obsolete resume timers and subscriptions`, async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate((action) => {
const art = window.art
const layer = window.promptLayer
const set = window.setTimeout
const clear = window.clearTimeout
const pending = new Set()
const callbacks = []
let plays = 0
art.template.$video.play = () => {
plays++
return Promise.resolve()
}
window.setTimeout = function (callback, delay, ...args) {
const timer = set.call(this, callback, delay, ...args)
if (delay === 24735) {
callbacks.push(callback)
pending.add(timer)
}
return timer
}
window.clearTimeout = function (timer) {
pending.delete(timer)
return clear.call(this, timer)
}
art.emit('video:timeupdate', new Event('timeupdate'))
const scheduled = pending.size
if (action === 'restart') {
art.storage.set('times', { example: 2 })
art.emit('restart', art.url)
}
else if (action === 'source') {
art.url = '/test/pattern.mp4?resume=2'
}
else if (action === 'remove') {
art.layers.remove('auto-playback')
}
else {
art.destroy(false)
}
for (const callback of callbacks)
callback()
if (action !== 'restart')
layer.querySelector('.art-auto-playback-jump').click()
const result = { scheduled, remaining: pending.size, display: layer.style.display, plays }
window.setTimeout = set
window.clearTimeout = clear
art.destroy()
return result
}, action)).toEqual({ scheduled: 1, remaining: 0, display: action === 'restart' ? 'flex' : 'none', plays: 0 })
})
}
test('candidate: destruction inside seek prevents resume playback continuation', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
let plays = 0
art.template.$video.play = () => {
plays++
return Promise.resolve()
}
art.on('seek', () => art.destroy(false))
window.promptLayer.querySelector('.art-auto-playback-jump').click()
return { destroyed: art.isDestroy, plays }
})).toEqual({ destroyed: true, plays: 0 })
})
+99
View File
@@ -0,0 +1,99 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, mobile = true, live = false) {
await page.addInitScript(mobile => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: mobile ? 'ArtPlayer Android builtin fixture' : 'ArtPlayer Desktop builtin fixture' }), mobile)
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate((live) => {
window.art = new window.Artplayer({
container: '.player',
url: '/test/pattern.mp4',
muted: true,
isLive: live,
miniProgressBar: true,
lock: true,
autoPlayback: true,
autoOrientation: true,
fastForward: true,
})
}, live)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
for (const mobile of [true, false]) {
for (const live of [true, false]) {
test(`${core}: builtin availability keeps mobile=${mobile} live=${live} conditions`, async ({ page }) => {
await setup(page, core, mobile, live)
expect(await page.evaluate(() => {
const names = ['mini-progress-bar', 'lock', 'auto-playback', 'autoOrientation', 'fastForward']
const result = names.map(name => Boolean(window.art.plugins[name]))
window.art.destroy()
return result
})).toEqual([!live, mobile, !live, mobile, mobile && !live])
})
}
}
test(`${core}: lock keeps public state, repeated events, DOM classes and icon switching`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const plugin = art.plugins.lock
const events = []
art.on('lock', state => events.push([state, art.isLock, plugin.state]))
plugin.state = true
plugin.state = true
art.layers.lock.click()
const result = {
keys: Object.keys(plugin),
events,
lock: art.layers.lock.querySelector('.art-icon-lock').style.display,
unlock: art.layers.lock.querySelector('.art-icon-unlock').style.display,
}
art.destroy()
return result
})).toEqual({ keys: ['name', 'state'], events: [[true, true, true], [true, true, true], [false, false, false]], lock: 'none', unlock: 'inline-flex' })
})
test(`${core}: removed lock icons stop observing the still-usable plugin state API`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const icon = art.layers.lock.querySelector('.art-icon-lock')
art.layers.remove('lock')
art.plugins.lock.state = true
const result = { flag: art.isLock, state: art.plugins.lock.state, icon: icon.style.display }
art.destroy()
return result
})).toEqual({ flag: true, state: true, icon: core === 'candidate' ? 'none' : 'inline-flex' })
})
test(`${core}: miniature progress follows the control event without adding public state`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const player = art.template.$player
art.emit('control', false)
const hiddenControls = player.classList.contains('art-mini-progress-bar')
art.emit('control', true)
const shownControls = player.classList.contains('art-mini-progress-bar')
const keys = Object.keys(art.plugins['mini-progress-bar'])
art.destroy()
return { hiddenControls, shownControls, keys }
})).toEqual({ hiddenControls: true, shownControls: false, keys: ['name'] })
})
}
test('candidate: retained builtin setters and events do not mutate a destroyed player', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
art.emit('control', true)
art.destroy(false)
let events = 0
art.on('lock', () => events++)
art.plugins.lock.state = true
art.emit('control', false)
return { lock: art.isLock, state: art.plugins.lock.state, mini: art.template.$player.classList.contains('art-mini-progress-bar'), events }
})).toEqual({ lock: false, state: false, mini: false, events: 0 })
})
+132
View File
@@ -0,0 +1,132 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android long-press fixture' }))
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
window.Artplayer.FAST_FORWARD_TIME = 20
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, fastForward: true })
window.touch = (type, count = 1) => {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'touches', { value: Array.from({ length: count }, (_, identifier) => ({ identifier, pageX: 50, pageY: 50, clientX: 50, clientY: 50 })) })
window.art.template.$video.dispatchEvent(event)
}
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.evaluate(() => window.art.play())
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.evaluate(() => {
window.art.playbackRate = 1.5
})
}
for (const core of ['published', 'candidate']) {
test(`${core}: native source reset retains its default playback rate behavior`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => {
window.art.url = '/test/pattern.mp4?reset=1'
})
await expect.poll(() => page.evaluate(() => window.art.playbackRate)).toBe(1)
expect(await page.evaluate(() => window.art.template.$video.defaultPlaybackRate)).toBe(1)
await page.evaluate(() => window.art.destroy())
})
test(`${core}: long press accelerates real playback and restores its prior rate`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => Object.keys(window.art.plugins.fastForward))).toEqual(['name', 'state'])
await page.evaluate(() => window.touch('touchstart'))
await expect.poll(() => page.evaluate(() => window.art.plugins.fastForward.state)).toBe(true)
expect(await page.evaluate(() => window.art.playbackRate)).toBe(3)
await page.evaluate(() => window.touch('touchend', 0))
expect(await page.evaluate(() => ({ state: window.art.plugins.fastForward.state, rate: window.art.playbackRate }))).toEqual({ state: false, rate: 1.5 })
await page.evaluate(() => window.art.destroy())
})
test(`${core}: touchcancel releases an active long press`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => window.touch('touchstart'))
await expect.poll(() => page.evaluate(() => window.art.plugins.fastForward.state)).toBe(true)
await page.evaluate(() => window.touch('touchcancel', 0))
expect(await page.evaluate(() => ({ state: window.art.plugins.fastForward.state, rate: window.art.playbackRate }))).toEqual({ state: core === 'published', rate: core === 'published' ? 3 : 1.5 })
await page.evaluate(() => {
window.touch('touchend', 0)
window.art.destroy()
})
})
}
for (const action of ['lock', 'pause', 'source', 'destroy']) {
test(`candidate: ${action} restores the active long-press rate`, async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => window.touch('touchstart'))
await expect.poll(() => page.evaluate(() => window.art.plugins.fastForward.state)).toBe(true)
await page.evaluate((action) => {
const art = window.art
if (action === 'lock') {
art.isLock = true
art.emit('lock', true)
}
else if (action === 'pause') {
art.pause()
}
else if (action === 'source') {
const video = art.template.$video
const rate = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'playbackRate')
window.restoredRates = []
Object.defineProperty(video, 'playbackRate', {
configurable: true,
get() {
return rate.get.call(this)
},
set(value) {
window.restoredRates.push(value)
rate.set.call(this, value)
},
})
art.url = '/test/pattern.mp4?replacement=1'
}
else {
art.destroy(false)
}
}, action)
await expect.poll(() => page.evaluate(() => ({ state: window.art.plugins.fastForward.state, rate: window.art.playbackRate }))).toEqual({ state: false, rate: action === 'source' ? 1 : 1.5 })
if (action === 'source')
expect(await page.evaluate(() => window.restoredRates)).toEqual([1.5])
await page.evaluate(() => window.art.destroy())
})
}
test('candidate: repeated pending touches own a single timer which destruction cancels', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
art.constructor.FAST_FORWARD_TIME = 24734
const set = window.setTimeout
const clear = window.clearTimeout
const pending = new Set()
const callbacks = []
window.setTimeout = function (callback, delay, ...args) {
const timer = set.call(this, callback, delay, ...args)
if (delay === 24734) {
pending.add(timer)
callbacks.push(callback)
}
return timer
}
window.clearTimeout = function (timer) {
pending.delete(timer)
return clear.call(this, timer)
}
window.touch('touchstart')
window.touch('touchstart')
const scheduled = pending.size
art.destroy(false)
const resetRate = art.playbackRate
for (const callback of callbacks)
callback()
const result = { scheduled, remaining: pending.size, state: art.plugins.fastForward.state, resetRate, rate: art.playbackRate }
window.setTimeout = set
window.clearTimeout = clear
return result
})).toEqual({ scheduled: 1, remaining: 0, state: false, resetRate: 1, rate: 1 })
})
+168
View File
@@ -0,0 +1,168 @@
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)
await page.evaluate(() => {
window.Artplayer.NOTICE_TIME = 24733
window.art.notice.destroy()
})
}
for (const core of ['published', 'candidate']) {
test(`${core}: notice updates do not call a consumer override of destroy`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const notice = window.art.notice
const original = notice.destroy
let calls = 0
notice.destroy = function () {
calls++
return original.call(this)
}
notice.show = 'first'
notice.show = 'second'
const implicit = calls
notice.destroy()
const explicit = calls
window.art.destroy()
return { implicit, explicit }
})).toEqual({ implicit: 0, explicit: 1 })
})
test(`${core}: notice preserves public shape, Error text and false hide semantics`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const notice = art.notice
notice.show = new Error(' trimmed ')
const visible = notice.show
const text = art.template.$noticeInner.textContent
const timer = notice.timer
notice.show = false
const result = {
fields: Object.keys(notice),
prototype: Object.getOwnPropertyNames(Object.getPrototypeOf(notice)),
visible,
text,
hidden: !notice.show,
retained: art.template.$noticeInner.textContent,
sameTimer: timer === notice.timer,
}
notice.destroy()
art.destroy()
return result
})).toEqual({ fields: ['art', 'timer'], prototype: ['constructor', 'destroy', 'show'], visible: true, text: 'trimmed', hidden: true, retained: 'trimmed', sameTimer: true })
})
test(`${core}: notice expires naturally during real playback`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => window.art.play())
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.evaluate(() => {
window.Artplayer.NOTICE_TIME = 20
window.art.notice.show = 'transient'
})
await expect.poll(() => page.evaluate(() => ({ visible: window.art.notice.show, text: window.art.template.$noticeInner.textContent }))).toEqual({ visible: false, text: '' })
expect(await page.evaluate(() => window.art.playing)).toBe(true)
await page.evaluate(() => window.art.destroy())
})
test(`${core}: manual notice destroy permits reuse and does not hide its DOM`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const notice = window.art.notice
notice.show = 'visible'
notice.destroy()
const visible = notice.show
const timer = notice.timer
notice.show = 'reused'
const reused = window.art.template.$noticeInner.textContent
notice.destroy()
window.art.destroy()
return { visible, timer, reused }
})).toEqual({ visible: true, timer: null, reused: 'reused' })
})
test(`${core}: destruction during notice text assignment cannot schedule a late timer`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const notice = art.notice
const inner = art.template.$noticeInner
const descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent')
Object.defineProperty(inner, 'textContent', {
configurable: true,
get() {
return descriptor.get.call(this)
},
set(value) {
descriptor.set.call(this, value)
art.destroy(false)
},
})
notice.show = 'destroy during write'
const result = { destroyed: art.isDestroy, timer: notice.timer !== null, visible: notice.show }
notice.destroy()
return result
})).toEqual({ destroyed: true, timer: core === 'published', visible: core === 'published' })
})
test(`${core}: expiry does not hide a notice installed during the clearing write`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const inner = art.template.$noticeInner
const descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent')
const original = window.setTimeout
let expire
window.setTimeout = function (callback, delay, ...args) {
if (delay === 24733)
expire = callback
return original.call(this, callback, delay, ...args)
}
art.notice.show = 'old'
Object.defineProperty(inner, 'textContent', {
configurable: true,
get() {
return descriptor.get.call(this)
},
set(value) {
descriptor.set.call(this, value)
if (value === '')
art.notice.show = 'nested'
},
})
expire()
const result = { text: inner.textContent, visible: art.notice.show }
window.setTimeout = original
art.notice.destroy()
art.destroy()
return result
})).toEqual({ text: 'nested', visible: core === 'candidate' })
})
}
test('candidate: retained callbacks and assignments cannot revive destroyed notice resources', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const original = window.setTimeout
const callbacks = []
window.setTimeout = function (callback, delay, ...args) {
if (delay === 24733)
callbacks.push(callback)
return original.call(this, callback, delay, ...args)
}
art.notice.show = 'old'
art.notice.show = 'new'
callbacks[0]()
const before = art.template.$noticeInner.textContent
art.destroy(false)
callbacks[1]()
art.notice.show = 'ignored'
window.setTimeout = original
return { before, after: art.template.$noticeInner.textContent, timer: art.notice.timer, calls: callbacks.length }
})).toEqual({ before: 'new', after: 'new', timer: null, calls: 2 })
})
+193
View File
@@ -0,0 +1,193 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, mobile = false) {
await page.addInitScript(mobile => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: mobile ? 'ArtPlayer Android info fixture' : 'ArtPlayer Desktop info fixture' }), mobile)
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}: mask preserves observations before and after its destroy handler`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
expect(await page.evaluate(() => {
const seen = []
const art = new window.Artplayer({ container: '.player', url: '', layers: [{
name: 'observer',
html: '',
mounted() {
this.on('destroy', () => seen.push(['before', this.template.$state.querySelector('.art-icon-error').style.display]))
},
}] })
art.on('destroy', () => seen.push(['after', art.template.$state.querySelector('.art-icon-error').style.display]))
art.destroy(false)
return seen
})).toEqual([['before', 'none'], ['after', '']])
})
test(`${core}: info preserves fields, native media text and close visibility`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.template.$video.volume = 0.4
art.info.init()
const volume = art.template.$infoPanel.querySelector('[data-video="volume"]').textContent
const source = art.template.$infoPanel.querySelector('[data-video="currentSrc"]').textContent === art.template.$video.currentSrc
art.info.show = true
art.template.$infoClose.click()
const result = { fields: Object.keys(art.info), prototype: Object.getOwnPropertyNames(Object.getPrototypeOf(art.info)), volume, source, visible: art.info.show }
art.destroy()
return result
})).toEqual({ fields: ['id', 'art', 'cache', 'add', 'remove', 'update', 'name'], prototype: ['constructor', 'init'], volume: '0.40', source: true, visible: false })
})
test(`${core}: info retains native nullable string conversion for custom media fields`, async ({ page }) => {
await setup(page, core, true)
expect(await page.evaluate(() => {
const art = window.art
const item = document.createElement('span')
item.dataset.video = 'fixtureValue'
art.template.$infoPanel.append(item)
const values = [null, undefined, true, 1.234, Number.NaN, Infinity, { toString: () => 'custom' }]
const result = values.map((value) => {
art.template.$video.fixtureValue = value
art.info.init()
return item.textContent
})
art.template.$video.fixtureValue = Symbol('not text')
let failed
try {
art.info.init()
}
catch (error) {
failed = error.name
}
art.destroy()
return { result, failed }
})).toEqual({ result: ['', '', 'true', '1.23', 'NaN', 'Infinity', 'custom'], failed: 'TypeError' })
})
test(`${core}: repeated info init owns only one new timer and close handler`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const set = window.setTimeout
const clear = window.clearTimeout
const pending = new Set()
window.setTimeout = function (callback, delay, ...args) {
const timer = set.call(this, callback, delay, ...args)
if (delay === 24736)
pending.add(timer)
return timer
}
window.clearTimeout = function (timer) {
pending.delete(timer)
return clear.call(this, timer)
}
art.constructor.INFO_LOOP_TIME = 24736
art.info.init()
art.info.init()
let closed = 0
art.on('info', state => !state && closed++)
art.info.show = true
art.template.$infoClose.click()
const timers = pending.size
art.destroy()
const result = { timers, closed, remaining: pending.size }
window.setTimeout = set
window.clearTimeout = clear
return result
})).toEqual({ timers: core === 'candidate' ? 1 : 2, closed: core === 'candidate' ? 1 : 3, remaining: 0 })
})
test(`${core}: info mobile construction waits for an explicit init`, async ({ page }) => {
await setup(page, core, true)
expect(await page.evaluate(() => {
const art = window.art
const node = art.template.$infoPanel.querySelector('[data-video="volume"]')
const before = node.textContent
art.template.$video.volume = 0.4
art.info.init()
const after = node.textContent
art.destroy()
return { before, after }
})).toEqual({ before: '', after: '0.40' })
})
test(`${core}: mask click plays native video and destruction displays the terminal icon`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => window.art.template.$state.click())
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
expect(await page.evaluate(() => {
const art = window.art
const state = art.template.$state.querySelector('.art-icon-state')
const error = art.template.$state.querySelector('.art-icon-error')
art.loading.show = true
const loading = art.loading.show
const icon = Boolean(art.template.$loading.querySelector('.art-icon-loading'))
art.destroy(false)
return { loading, icon, state: state.style.display, error: error.style.display }
})).toEqual({ loading: true, icon: true, state: 'none', error: '' })
})
}
test('candidate: info destruction during a media getter prevents text writes and late init', async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(() => {
const art = window.art
const node = art.template.$infoPanel.querySelector('[data-video="volume"]')
const before = node.textContent
Object.defineProperty(art.template.$video, 'volume', { configurable: true, get() {
art.destroy(false)
return 0.8
} })
art.info.init()
const after = node.textContent
art.info.init()
return { before, after, destroyed: art.isDestroy }
})
expect(result.after).toBe(result.before)
expect(result.destroyed).toBe(true)
})
test('candidate: reentrant info text conversion cannot write after player destruction', async ({ page }) => {
await setup(page, 'candidate', true)
expect(await page.evaluate(() => {
const art = window.art
const item = document.createElement('span')
item.dataset.video = 'fixtureValue'
item.textContent = 'before'
art.template.$infoPanel.append(item)
art.template.$video.fixtureValue = { toString() {
art.destroy(false)
return 'late'
} }
art.info.init()
return { text: item.textContent, destroyed: art.isDestroy }
})).toEqual({ text: 'before', destroyed: true })
})
test('candidate: mask finalization follows an earlier throwing observer without replacing its error', async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
expect(await page.evaluate(() => {
const failure = new Error('early observer failed')
const art = new window.Artplayer({ container: '.player', url: '', layers: [{
name: 'observer',
html: '',
mounted() {
this.on('destroy', () => {
throw failure
})
},
}] })
let original = false
try {
art.destroy(false)
}
catch (error) {
original = error === failure
}
return { original, destroyed: art.isDestroy, state: art.template.$state.querySelector('.art-icon-state').style.display, error: art.template.$state.querySelector('.art-icon-error').style.display }
})).toEqual({ original: true, destroyed: true, state: 'none', error: '' })
})