Files
ArtPlayer/refactor/fixtures/lifecycle.js
T

241 lines
15 KiB
JavaScript

/* global Artplayer, artplayerPluginChapter */
document.getElementById('run').addEventListener('click', async (event) => {
event.currentTarget.disabled = true
document.getElementById('status').textContent = 'Running real-media checks...'
const report = { kind: 'lifecycle', environment: { userAgent: navigator.userAgent, language: navigator.language }, scripts: [...document.scripts].map(script => new URL(script.src).pathname), checks: [], observations: {}, findings: [], trace: [], errors: [], unhandled: [] }
const players = []
let phase = 'emitter'
const expectedRejections = new Set()
report.observations.expectedUnhandled = []
const check = (id, passed) => {
report.checks.push({ id, passed: Boolean(passed) })
if (!passed) throw new Error(id)
}
const unhandled = event => {
if (expectedRejections.has(event.reason)) {
report.observations.expectedUnhandled.push({ phase, message: event.reason.message })
event.preventDefault()
}
else report.unhandled.push(String(event.reason?.message || event.reason))
}
window.addEventListener('unhandledrejection', unhandled)
const bounded = (promise, label, ms = 12000) => {
let timer
return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`Timeout: ${label}`)), ms) })])
.finally(() => clearTimeout(timer))
}
const eventAfter = (art, name, action = () => {}) => {
let listener
const promise = new Promise((resolve, reject) => {
listener = resolve
art.on(name, listener)
try { action() } catch (error) { reject(error) }
})
return bounded(promise, name).finally(() => art.off(name, listener))
}
const advance = (art, start) => {
let listener
const promise = new Promise((resolve) => {
listener = () => { if (art.currentTime >= start + 0.1) resolve(art.currentTime) }
art.on('video:timeupdate', listener)
listener()
})
return bounded(promise, 'media time advance').finally(() => art.off('video:timeupdate', listener))
}
const observeFrames = callback => bounded(new Promise((resolve) => {
const start = performance.now()
const frame = () => {
callback()
if (performance.now() - start >= 250) resolve(performance.now() - start)
else requestAnimationFrame(frame)
}
requestAnimationFrame(frame)
}), 'frame observation')
const create = (option = {}, ready) => {
const container = document.createElement('div')
container.className = 'player'
document.getElementById('players').append(container)
const art = new Artplayer({ container, url: '/assets/sample/video.mp4', muted: true, mutex: false, plugins: [artplayerPluginChapter()], ...option }, ready)
players.push(art)
for (const name of ['ready', 'restart', 'play', 'pause', 'destroy', 'video:loadstart', 'video:loadedmetadata', 'video:loadeddata', 'video:canplay', 'video:play', 'video:playing', 'video:pause', 'video:seeking', 'video:seeked', 'video:error']) {
art.on(name, (...args) => report.trace.push({ phase, event: name, time: art.currentTime, paused: art.video.paused, ready: art.isReady, source: art.video.getAttribute('src'), argument: typeof args[0] === 'string' ? args[0] : args[0]?.type || null }))
}
return art
}
try {
check('LIFE.version', Artplayer.version === '5.4.0')
const emitter = new Artplayer.Emitter()
const ctx = {}
let correctContext = false
check('EVENT.chain', emitter.on('ctx', function (a, b) { correctContext = this === ctx && a === 3 && b === 'value' }, ctx).emit('ctx', 3, 'value').off('ctx') === emitter)
check('EVENT.context-arguments', correctContext)
let onceCount = 0
emitter.once('once', () => { onceCount += 1; emitter.emit('once') })
emitter.emit('once')
check('EVENT.once-reentry', onceCount === 1)
const order = []
const second = () => order.push('second')
const later = () => order.push('later')
emitter.on('order', () => { order.push('first'); emitter.off('order', second); emitter.on('order', later) }).on('order', second)
emitter.emit('order').emit('order')
report.observations.emitterOrder = order
check('EVENT.dispatch-snapshot', JSON.stringify(order) === JSON.stringify(['first', 'second', 'first', 'later']))
let removedOnce = 0
const original = () => { removedOnce += 1 }
emitter.once('removed', original).off('removed', original).emit('removed')
check('EVENT.off-original-once', removedOnce === 0)
const marker = new Error('expected emitter probe')
let caught
let afterThrow = 0
emitter.on('throw', () => { throw marker }).on('throw', () => { afterThrow += 1 })
try { emitter.emit('throw') } catch (error) { caught = error }
check('EVENT.throw-propagation', caught === marker && afterThrow === 0)
phase = 'initial'
let readyCount = 0
let callbackArt
let callbackContext
const art = create({}, function (value) { readyCount += 1; callbackArt = value; callbackContext = this })
await eventAfter(art, 'ready')
check('LIFE.ready-callback', readyCount === 1 && callbackArt === art && callbackContext === art && art.isReady)
check('MEDIA.metadata', art.duration > 2 && art.video.videoWidth > 0 && art.video.readyState >= 2)
phase = 'play'
const playStart = art.currentTime
const playResult = art.play()
check('MEDIA.play-promise', playResult instanceof Promise)
check('MEDIA.play-result', await bounded(playResult, 'play') === undefined)
const progressed = await advance(art, playStart)
report.observations.playback = { start: playStart, progressed, duration: art.duration, width: art.video.videoWidth, height: art.video.videoHeight }
check('MEDIA.time-advances', progressed > playStart && !art.video.paused)
phase = 'pause'
let pauseResult
await eventAfter(art, 'video:pause', () => { pauseResult = art.pause() })
const pausedAt = art.currentTime
let maxDelta = 0
const pausedWindow = await observeFrames(() => { maxDelta = Math.max(maxDelta, Math.abs(art.currentTime - pausedAt)) })
report.observations.pause = { at: pausedAt, observedMs: pausedWindow, maxDelta }
check('MEDIA.pause-sync-stable', pauseResult === undefined && art.video.paused && maxDelta < 0.02)
phase = 'seek'
const target = Math.min(2, art.duration / 3)
await eventAfter(art, 'video:seeked', () => { art.seek = target })
check('MEDIA.seek', Math.abs(art.currentTime - target) < 0.1)
phase = 'quality-paused'
const qualityResult = await bounded(art.switchQuality('/assets/sample/video2.mp4'), 'paused quality switch')
check('MEDIA.quality-preserves-time', qualityResult === undefined && art.video.paused && Math.abs(art.currentTime - target) < 0.1)
phase = 'url-playing'
await bounded(art.play(), 'resume')
await advance(art, art.currentTime)
const switchResult = await bounded(art.switchUrl('/assets/sample/video.mp4?normal-switch'), 'playing URL switch')
check('MEDIA.url-resets-resumes', switchResult === undefined && !art.video.paused && art.currentTime < 1)
await advance(art, art.currentTime)
art.pause()
check('LIFE.ready-once-after-switches', readyCount === 1)
report.observations.readyCallbacks = readyCount
report.observations.restarts = report.trace.filter(item => item.event === 'restart').map(item => item.argument)
check('LIFE.restart-url', report.observations.restarts.includes('/assets/sample/video2.mp4') && report.observations.restarts.includes('/assets/sample/video.mp4?normal-switch'))
phase = 'controlled-play-rejection'
const playError = new Error('controlled media play rejection')
let publicRejection
art.video.play = () => Promise.reject(playError)
try { await bounded(art.play(), 'controlled public rejection') } catch (error) { publicRejection = error }
finally { delete art.video.play }
check('MEDIA.play-rejects-original', publicRejection === playError)
phase = 'controlled-resume-rejection'
await bounded(art.play(), 'play before rejected resume')
await advance(art, art.currentTime)
const resumeError = new Error('controlled switch resume rejection')
expectedRejections.add(resumeError)
let resumeSwitchState = 'pending'
art.video.play = () => Promise.reject(resumeError)
try {
await eventAfter(art, 'video:canplay', () => {
art.switchUrl('/assets/sample/video2.mp4?resume-rejection').then(() => { resumeSwitchState = 'fulfilled' }, () => { resumeSwitchState = 'rejected' })
})
const observedMs = await observeFrames(() => {})
report.observations.rejectedResume = { mode: 'controlled video.play rejection after real canplay', switchState: resumeSwitchState, observedMs, expectedUnhandled: report.observations.expectedUnhandled.length }
if (resumeSwitchState === 'pending' && report.observations.expectedUnhandled.length) report.findings.push({ id: 'BASE-LIFE-06', observation: 'Published switch leaves an unhandled rejection and an unsettled promise when resume fails', owner: 'CORE-09' })
}
finally { delete art.video.play }
art.pause()
phase = 'plugins'
let pluginContext
let pluginArgument
const pluginPromise = art.plugins.add(function (value) { pluginContext = this; pluginArgument = value; return Promise.resolve({ name: 'asyncProbe' }) })
check('PLUGIN.async-promise', pluginPromise instanceof Promise && !Object.hasOwn(art.plugins, 'asyncProbe'))
check('PLUGIN.async-registry', await bounded(pluginPromise, 'async plugin') === art.plugins && pluginContext === art && pluginArgument === art)
let duplicate
try { art.plugins.add(() => ({ name: 'asyncProbe' })) } catch (error) { duplicate = error }
check('PLUGIN.duplicate-throws', duplicate instanceof Error && art.plugins.asyncProbe.name === 'asyncProbe')
phase = 'concurrent-switch'
const first = art.switchUrl('/assets/sample/video.mp4?race-first')
const last = art.switchUrl('/assets/sample/video2.mp4?race-last')
const outcomes = await bounded(Promise.allSettled([first, last]), 'concurrent switches')
report.observations.concurrentSwitch = { outcomes: outcomes.map(item => item.status), finalSource: art.video.getAttribute('src'), canplayEvents: report.trace.filter(item => item.phase === phase && item.event === 'video:canplay').length }
check('MEDIA.latest-source', outcomes[1].status === 'fulfilled' && art.url.endsWith('video2.mp4?race-last'))
if (outcomes[0].status === 'fulfilled') report.findings.push({ id: 'BASE-LIFE-01', observation: 'Superseded switch resolves along with the latest source', owner: 'CORE-09' })
phase = 'destroy-pending'
let pendingState = 'pending'
art.switchUrl('/assets/sample/video.mp4?destroy-pending').then(() => { pendingState = 'fulfilled' }, () => { pendingState = 'rejected' })
let finishPlugin
const latePlugin = art.plugins.add(() => new Promise(resolve => { finishPlugin = resolve }))
let listenerCalls = 0
art.proxy(art.video, 'baseline-listener', () => { listenerCalls += 1 })
art.video.dispatchEvent(new Event('baseline-listener'))
const retainedNode = art.template.$player
art.destroy(false)
art.video.dispatchEvent(new Event('baseline-listener'))
check('LIFE.destroy-retains-markup', retainedNode.isConnected && retainedNode.classList.contains('art-destroy'))
check('LIFE.destroy-cleans-proxy', listenerCalls === 1 && art.events.destroyEvents.size === 0 && !Artplayer.instances.includes(art) && art.isDestroy)
finishPlugin({ name: 'lateProbe' })
await bounded(latePlugin, 'late plugin')
const pendingWindow = await observeFrames(() => {})
report.observations.destroyPending = { switchState: pendingState, observedMs: pendingWindow, latePluginRegistered: Object.hasOwn(art.plugins, 'lateProbe') }
if (pendingState === 'pending') report.findings.push({ id: 'BASE-LIFE-02', observation: 'Switch remains unsettled during the bounded observation after destroy', owner: 'CORE-09' })
if (report.observations.destroyPending.latePluginRegistered) report.findings.push({ id: 'BASE-LIFE-03', observation: 'Async plugin registers its result after destroy', owner: 'CORE-08' })
phase = 'duplicate-destroy'
const removed = create()
const survivor = create()
removed.destroy(true)
check('LIFE.destroy-removes-markup', removed.template.$container.childElementCount === 0)
const survivorBefore = Artplayer.instances.includes(survivor)
removed.destroy(true)
const survivorAfter = Artplayer.instances.includes(survivor)
report.observations.repeatedDestroy = { survivorBefore, survivorAfter }
if (survivorBefore && !survivorAfter) report.findings.push({ id: 'BASE-LIFE-04', observation: 'Destroying an instance twice unregisters a different live instance', owner: 'CORE-04' })
survivor.destroy(true)
phase = 'constructor-failure'
let partial
const failedContainer = document.createElement('div')
document.getElementById('players').append(failedContainer)
let initializationError
try {
new Artplayer({ container: failedContainer, url: '/assets/sample/video.mp4', muted: true, plugins: [value => { partial = value; throw new Error('expected initialization probe') }] })
}
catch (error) { initializationError = error }
check('LIFE.constructor-plugin-error', initializationError?.message === 'expected initialization probe')
report.observations.failedConstructor = { hasMarkup: Boolean(failedContainer.querySelector('.art-video-player')), listeners: partial?.events.destroyEvents.size, registered: Artplayer.instances.includes(partial) }
if (partial) { players.push(partial); partial.destroy(true) }
if (report.observations.failedConstructor.hasMarkup || report.observations.failedConstructor.listeners) report.findings.push({ id: 'BASE-LIFE-05', observation: 'Failed constructor leaves initialized markup/listeners until explicit probe cleanup', owner: 'CORE-04' })
}
catch (error) { report.errors.push({ phase, message: error.message }) }
finally {
for (const art of players) {
if (!art.isDestroy) {
try { art.destroy(true) } catch (error) { report.errors.push({ phase: 'cleanup', message: error.message }) }
}
}
window.removeEventListener('unhandledrejection', unhandled)
}
report.observations.remainingInstances = Artplayer.instances.length
const response = await fetch('/reports/lifecycle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(report) })
document.getElementById('status').textContent = response.ok && !report.errors.length && !report.unhandled.length && report.checks.every(item => item.passed) ? `CAPTURE COMPLETE: ${report.checks.length} checks, ${report.findings.length} historical findings` : 'CAPTURE FAILED: inspect report'
document.getElementById('result').textContent = JSON.stringify({ checks: report.checks, observations: report.observations, findings: report.findings, errors: report.errors, unhandled: report.unhandled }, null, 2)
})