mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
241 lines
15 KiB
JavaScript
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)
|
|
})
|