mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 10:56:15 -08:00
319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
|
import { mock, test } from 'node:test'
|
|
import { setImmediate } from 'node:timers/promises'
|
|
import { loadModules } from './helpers/load.js'
|
|
|
|
const { Emitter, config, eventInit, urlMix, switchMix, playMix, beginLifecycle, getScope, getSourceScope } = await loadModules({
|
|
Emitter: 'packages/artplayer/src/utils/emitter',
|
|
config: 'packages/artplayer/src/config/index',
|
|
eventInit: 'packages/artplayer/src/player/eventInit',
|
|
urlMix: 'packages/artplayer/src/player/urlMix',
|
|
switchMix: 'packages/artplayer/src/player/switchMix',
|
|
playMix: 'packages/artplayer/src/player/playMix',
|
|
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
|
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
|
getSourceScope: { file: 'packages/artplayer/src/source/operation', name: 'getSourceScope' },
|
|
})
|
|
|
|
function createArt() {
|
|
const ui = []
|
|
const writes = []
|
|
const forwarded = []
|
|
const classes = new Set()
|
|
const video = new EventTarget()
|
|
let src = ''
|
|
Object.defineProperty(video, 'src', { configurable: true, get: () => src, set(value) {
|
|
src = value
|
|
writes.push(value)
|
|
} })
|
|
const visibility = name => ({ get show() {
|
|
return false
|
|
}, set show(value) {
|
|
ui.push([name, value])
|
|
} })
|
|
const art = Object.assign(new Emitter(), {
|
|
template: { $video: video, $poster: { style: {} }, $player: { classList: { add: value => classes.add(value), remove: value => classes.delete(value) } } },
|
|
option: { url: 'initial.mp4', type: '', customType: {}, loop: false },
|
|
constructor: { RECONNECT_TIME_MAX: 2, RECONNECT_SLEEP_TIME: 10 },
|
|
notice: { show: '' },
|
|
i18n: { get: key => key },
|
|
isReady: false,
|
|
playing: false,
|
|
currentTime: 3,
|
|
aspectRatio: '16:9',
|
|
playbackRate: 1,
|
|
pause: () => {},
|
|
play: mock.fn(() => Promise.resolve()),
|
|
loading: visibility('loading'),
|
|
controls: visibility('controls'),
|
|
mask: visibility('mask'),
|
|
proxy(target, name, callback) {
|
|
forwarded.push([target, name, callback])
|
|
target.addEventListener(name, callback)
|
|
getScope(art).add(() => {
|
|
target.removeEventListener(name, callback)
|
|
})
|
|
},
|
|
})
|
|
beginLifecycle(art)
|
|
urlMix(art)
|
|
switchMix(art)
|
|
eventInit(art)
|
|
art.url = art.option.url
|
|
return { art, video, ui, writes, forwarded, classes, close: () => getScope(art).dispose() }
|
|
}
|
|
|
|
async function tick(t, milliseconds = 10) {
|
|
t.mock.timers.tick(milliseconds)
|
|
await setImmediate()
|
|
}
|
|
|
|
test('media forwarding uses configured names, original targets and exact native event objects', () => {
|
|
const fixture = createArt()
|
|
const { art, video, forwarded } = fixture
|
|
assert.deepEqual(forwarded.map(item => item[1]), config.events)
|
|
assert(forwarded.every(item => item[0] === video))
|
|
const event = new Event('volumechange')
|
|
const observed = []
|
|
art.on('video:volumechange', value => observed.push(value))
|
|
video.dispatchEvent(event)
|
|
assert.deepEqual(observed, [event])
|
|
fixture.close()
|
|
// Even an externally retained forwarding callback must respect the closed owner.
|
|
forwarded.find(item => item[1] === 'volumechange')[2](event)
|
|
assert.deepEqual(observed, [event])
|
|
})
|
|
|
|
test('readiness and media UI retain callback order and ready is emitted once', () => {
|
|
const { art, ui, close } = createArt()
|
|
const order = []
|
|
art.on('resize', () => order.push('resize'))
|
|
art.on('ready', () => order.push(['ready', art.isReady]))
|
|
art.on('video:canplay', () => order.push('canplay'))
|
|
art.emit('video:loadstart')
|
|
assert.deepEqual(ui, [['loading', true], ['mask', false], ['controls', true]])
|
|
ui.length = 0
|
|
art.emit('video:loadedmetadata')
|
|
art.emit('video:canplay')
|
|
art.emit('video:canplay')
|
|
assert.deepEqual(order, ['resize', ['ready', true], 'canplay', 'canplay'])
|
|
assert.deepEqual(ui, [['loading', false], ['loading', false], ['controls', true], ['mask', true], ['loading', false]])
|
|
close()
|
|
})
|
|
|
|
test('reconnect coalesces errors, preserves the first error identity and respects the attempt limit', async (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
const { art, writes, classes, close } = createArt()
|
|
const errors = []
|
|
art.on('error', (...args) => errors.push(args))
|
|
const first = new Event('error')
|
|
art.emit('video:error', first)
|
|
art.emit('video:error', new Event('error'))
|
|
await tick(t)
|
|
assert.deepEqual(writes, ['initial.mp4', 'initial.mp4'])
|
|
assert.deepEqual(errors, [[first, 1]])
|
|
assert.equal(art.notice.show, 'Reconnect: 1')
|
|
const second = new Event('error')
|
|
art.emit('video:error', second)
|
|
await tick(t)
|
|
assert.deepEqual(errors, [[first, 1], [second, 2]])
|
|
art.emit('video:error', new Event('error'))
|
|
assert(classes.has('art-error'))
|
|
await tick(t)
|
|
assert.equal(writes.length, 3)
|
|
assert.equal(errors.length, 2)
|
|
assert.equal(art.notice.show, 'Video Load Failed')
|
|
close()
|
|
})
|
|
|
|
test('source replacement cancels the old retry and starts a fresh attempt budget', async (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
const { art, writes, close } = createArt()
|
|
const attempts = []
|
|
art.on('error', (_error, count) => attempts.push(count))
|
|
art.emit('video:error', new Event('error'))
|
|
await tick(t)
|
|
const old = getSourceScope(art)
|
|
art.emit('video:error', new Event('error'))
|
|
art.url = 'replacement.mp4'
|
|
art.notice.show = 'new source'
|
|
assert.equal(old.closed, true)
|
|
await tick(t)
|
|
assert.deepEqual(writes, ['initial.mp4', 'initial.mp4', 'replacement.mp4'])
|
|
assert.equal(art.notice.show, 'new source')
|
|
art.emit('video:error', new Event('error'))
|
|
await tick(t)
|
|
assert.deepEqual(attempts, [1, 1])
|
|
assert.equal(writes.at(-1), 'replacement.mp4')
|
|
close()
|
|
})
|
|
|
|
test('a failed switch rejects while its current source can still reconnect', async (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
const { art, writes, close } = createArt()
|
|
const error = new Event('error')
|
|
const switching = art.switchUrl('failed.mp4')
|
|
const source = getSourceScope(art)
|
|
const rejected = assert.rejects(switching, actual => actual === error)
|
|
art.emit('video:error', error)
|
|
await rejected
|
|
assert.equal(source.closed, false)
|
|
await tick(t)
|
|
assert.deepEqual(writes, ['initial.mp4', 'failed.mp4', 'failed.mp4'])
|
|
assert.equal(art.notice.show, 'Reconnect: 1')
|
|
close()
|
|
})
|
|
|
|
test('canplay cancels pending failure notices and clears recovered error UI', async (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
const { art, writes, classes, close } = createArt()
|
|
art.constructor.RECONNECT_TIME_MAX = 0
|
|
art.emit('video:error', new Event('error'))
|
|
assert(classes.has('art-error'))
|
|
art.emit('video:loadstart')
|
|
assert.equal(classes.has('art-error'), false)
|
|
art.notice.show = 'recovered'
|
|
art.emit('video:canplay')
|
|
await tick(t)
|
|
assert.equal(art.notice.show, 'recovered')
|
|
assert.equal(writes.length, 1)
|
|
close()
|
|
})
|
|
|
|
test('destroy during readiness stops remaining UI work and removes only owned media handlers', () => {
|
|
const { art, ui, close } = createArt()
|
|
const ready = mock.fn()
|
|
const userCanplay = mock.fn()
|
|
art.on('ready', ready)
|
|
art.on('video:canplay', userCanplay)
|
|
art.controls = { get show() {
|
|
return false
|
|
}, set show(value) {
|
|
ui.push(['controls', value])
|
|
close()
|
|
} }
|
|
art.emit('video:canplay')
|
|
assert.equal(art.isReady, false)
|
|
assert.equal(ready.mock.callCount(), 0)
|
|
assert.equal(userCanplay.mock.callCount(), 1)
|
|
assert.deepEqual(ui, [['loading', false], ['loading', false], ['controls', true]])
|
|
art.emit('video:canplay')
|
|
assert.equal(userCanplay.mock.callCount(), 2)
|
|
assert.deepEqual(Object.keys(art.e).sort(), ['ready', 'video:canplay'])
|
|
})
|
|
|
|
test('destroy cancels a pending reconnect and exhausted failure notice', async (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
for (const maximum of [0, 2]) {
|
|
const { art, writes, close } = createArt()
|
|
art.constructor.RECONNECT_TIME_MAX = maximum
|
|
art.emit('video:error', new Event('error'))
|
|
close()
|
|
art.notice.show = 'closed'
|
|
await tick(t)
|
|
assert.deepEqual(writes, ['initial.mp4'])
|
|
assert.equal(art.notice.show, 'closed')
|
|
assert.deepEqual(Object.keys(art.e), [])
|
|
}
|
|
})
|
|
|
|
test('owned reconnect listener failures are reported with the original error and never left unhandled', async (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
const warning = t.mock.method(console, 'warn', () => {})
|
|
const { art, close } = createArt()
|
|
const failure = new Error('user retry observer')
|
|
art.on('error', () => {
|
|
throw failure
|
|
})
|
|
art.emit('video:error', new Event('error'))
|
|
await tick(t)
|
|
assert.equal(warning.mock.callCount(), 1)
|
|
assert.deepEqual(warning.mock.calls[0].arguments, ['ArtPlayer reconnect failed:', failure])
|
|
close()
|
|
})
|
|
|
|
test('loop playback handles rejection and stops when seek reentrantly replaces the source', async () => {
|
|
const { art, ui, close } = createArt()
|
|
art.option.loop = true
|
|
art.play = mock.fn(() => Promise.reject(new DOMException('interrupted', 'AbortError')))
|
|
art.emit('video:ended')
|
|
await setImmediate()
|
|
assert.equal(art.play.mock.callCount(), 1)
|
|
assert.deepEqual(ui, [['controls', false], ['mask', false]])
|
|
ui.length = 0
|
|
Object.defineProperty(art, 'seek', { get: () => undefined, set() {
|
|
art.url = 'reentrant.mp4'
|
|
} })
|
|
art.emit('video:ended')
|
|
assert.equal(art.play.mock.callCount(), 1)
|
|
assert.deepEqual(ui, [])
|
|
close()
|
|
})
|
|
|
|
test('retry count remains stable when a proxy synchronously reports canplay during assignment', async (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
const { art, video, close } = createArt()
|
|
const counts = []
|
|
art.on('error', (_error, count) => counts.push(count))
|
|
const descriptor = Object.getOwnPropertyDescriptor(video, 'src')
|
|
Object.defineProperty(video, 'src', {
|
|
get: descriptor.get,
|
|
set(value) {
|
|
descriptor.set.call(video, value)
|
|
video.dispatchEvent(new Event('canplay'))
|
|
},
|
|
})
|
|
art.emit('video:error', new Event('error'))
|
|
await tick(t)
|
|
assert.deepEqual(counts, [1])
|
|
assert.equal(art.isReady, true)
|
|
close()
|
|
})
|
|
|
|
test('an option URL getter that replaces the source cannot let an obsolete retry overwrite it', async (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
const { art, writes, close } = createArt()
|
|
let requested = 'initial.mp4'
|
|
let reentered = false
|
|
Object.defineProperty(art.option, 'url', {
|
|
get() {
|
|
if (!reentered) {
|
|
reentered = true
|
|
art.url = 'newest.mp4'
|
|
}
|
|
return requested
|
|
},
|
|
set(value) { requested = value },
|
|
})
|
|
art.emit('video:error', new Event('error'))
|
|
await tick(t)
|
|
assert.deepEqual(writes, ['initial.mp4', 'newest.mp4'])
|
|
close()
|
|
})
|
|
|
|
test('source lifetime guards late play after a failed switch has removed its operation', async (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
const { art, video, close } = createArt()
|
|
const error = new Event('error')
|
|
const rejected = assert.rejects(art.switchUrl('failed.mp4'), actual => actual === error)
|
|
art.emit('video:error', error)
|
|
await rejected
|
|
let resolveNative
|
|
video.play = () => new Promise(resolve => resolveNative = resolve)
|
|
const pauseOther = mock.fn()
|
|
art.constructor.instances = [{ pause: pauseOther }]
|
|
art.option.mutex = true
|
|
playMix(art)
|
|
const plays = mock.fn()
|
|
art.on('play', plays)
|
|
const pending = art.play()
|
|
close()
|
|
art.notice.show = 'closed'
|
|
resolveNative(42)
|
|
assert.equal(await pending, 42)
|
|
assert.equal(art.notice.show, 'closed')
|
|
assert.equal(plays.mock.callCount(), 0)
|
|
assert.equal(pauseOther.mock.callCount(), 0)
|
|
})
|