mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-11 05:26:15 -08:00
refactor(core): CORE-11 split media events and scope reconnect lifetimes
This commit is contained in:
1 parent
5e31274f1d
commit
b8039ad1f1
27 files changed
+4844
-475
No files matched your search
@@ -0,0 +1,131 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: HTTP failure retries the updated option URL with the original event and one ready`, async ({ page, request }, testInfo) => {
|
||||
const caseId = encodeURIComponent(testInfo.testId)
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate((id) => {
|
||||
window.Artplayer.RECONNECT_SLEEP_TIME = 0
|
||||
window.Artplayer.RECONNECT_TIME_MAX = 1
|
||||
window.createPlayer(`/test/fail.mp4?case=${id}`)
|
||||
const { art } = window
|
||||
window.recovery = { attempts: [], ready: 0, identities: [], order: [] }
|
||||
let original
|
||||
art.on('video:error', (event) => {
|
||||
original = event
|
||||
window.recovery.order.push('native-error')
|
||||
art.option.url = `/test/pattern.mp4?case=${id}`
|
||||
})
|
||||
art.on('error', (event, attempt) => {
|
||||
window.recovery.attempts.push(attempt)
|
||||
window.recovery.identities.push(event === original && event.target === art.video)
|
||||
window.recovery.order.push('retry')
|
||||
})
|
||||
art.on('ready', () => {
|
||||
window.recovery.ready++
|
||||
window.recovery.order.push('ready')
|
||||
})
|
||||
}, caseId)
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
const result = await page.evaluate(() => ({ ...window.recovery, path: new URL(window.art.video.currentSrc).pathname, duration: window.art.duration, mediaError: window.art.video.error?.code || 0 }))
|
||||
expect(result.attempts).toEqual([1])
|
||||
expect(result.identities).toEqual([true])
|
||||
expect(result.ready).toBe(1)
|
||||
expect(result.order).toEqual(['native-error', 'retry', 'ready'])
|
||||
expect(result.path).toBe('/test/pattern.mp4')
|
||||
expect(result.duration).toBeGreaterThan(0)
|
||||
expect(result.mediaError).toBe(0)
|
||||
const requests = await (await request.get(`/test/requests.json?case=${caseId}`)).json()
|
||||
expect(requests.some(item => item.path === '/test/fail.mp4')).toBe(true)
|
||||
expect(requests.some(item => item.path === '/test/pattern.mp4')).toBe(true)
|
||||
await testInfo.attach('reconnect-http', { body: JSON.stringify({ result, requests }), contentType: 'application/json' })
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test(`${core}: replacing a failed source cancels candidate retries`, async ({ page }, testInfo) => {
|
||||
const caseId = encodeURIComponent(testInfo.testId)
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate((id) => {
|
||||
window.Artplayer.RECONNECT_SLEEP_TIME = 0
|
||||
window.Artplayer.RECONNECT_TIME_MAX = 1
|
||||
window.createPlayer(`/test/fail.mp4?case=${id}`)
|
||||
window.retries = 0
|
||||
window.art.on('error', () => window.retries++)
|
||||
window.art.once('video:error', () => {
|
||||
window.art.url = `/test/pattern.mp4?case=${id}`
|
||||
})
|
||||
}, caseId)
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
// The zero-delay retry's timer queue is observed after the replacement canplay.
|
||||
const retries = await page.evaluate(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
return window.retries
|
||||
})
|
||||
expect(retries).toBe(core === 'candidate' ? 0 : 1)
|
||||
expect(await page.evaluate(() => window.art.video.error)).toBeNull()
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: rejected real-media switch still allows current-source reconnect', async ({ page }, testInfo) => {
|
||||
const caseId = encodeURIComponent(testInfo.testId)
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
await page.evaluate(id => window.createPlayer(`/test/pattern.mp4?initial=${id}`), caseId)
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await page.evaluate((id) => {
|
||||
const { art, Artplayer } = window
|
||||
Artplayer.RECONNECT_SLEEP_TIME = 0
|
||||
Artplayer.RECONNECT_TIME_MAX = 1
|
||||
window.switchRecovery = { rejected: false, sameError: false, attempts: [], restart: [] }
|
||||
let original
|
||||
art.on('video:error', (event) => {
|
||||
original = event
|
||||
art.option.url = `/test/pattern.mp4?recovered=${id}`
|
||||
})
|
||||
art.on('error', (event, count) => window.switchRecovery.attempts.push([event === original, count]))
|
||||
art.on('restart', url => window.switchRecovery.restart.push(url))
|
||||
art.switchUrl(`/test/fail.mp4?case=${id}`).catch((error) => {
|
||||
window.switchRecovery.rejected = true
|
||||
window.switchRecovery.sameError = error === original
|
||||
})
|
||||
}, caseId)
|
||||
await expect.poll(() => page.evaluate(() => window.switchRecovery.restart.length)).toBe(1)
|
||||
expect(await page.evaluate(() => window.switchRecovery)).toEqual({ rejected: true, sameError: true, attempts: [[true, 1]], restart: [`/test/pattern.mp4?recovered=${caseId}`] })
|
||||
expect(await page.evaluate(() => window.art.video.error)).toBeNull()
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: destroying from first-ready controls prevents a candidate ghost ready event`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(() => {
|
||||
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||
let ready = 0
|
||||
art.on('ready', () => ready++)
|
||||
art.on('control', () => art.destroy(false))
|
||||
art.emit('video:canplay')
|
||||
return { ready, isReady: art.isReady, isDestroy: art.isDestroy, instances: window.Artplayer.instances.length }
|
||||
})
|
||||
expect(result).toEqual({ ready: core === 'candidate' ? 0 : 1, isReady: core !== 'candidate', isDestroy: true, instances: 0 })
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('mobile UA metadata path (not physical-device validation)', () => {
|
||||
test.use({ userAgent: 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Chrome/120.0.0.0 Mobile Safari/537.36' })
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: metadata emits resize before mobile visibility changes`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const order = await page.evaluate(() => {
|
||||
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||
const seen = []
|
||||
art.on('resize', () => seen.push('resize'))
|
||||
art.on('control', value => seen.push(['control', value]))
|
||||
art.on('video:loadedmetadata', () => seen.push('metadata'))
|
||||
art.emit('video:loadedmetadata')
|
||||
art.destroy()
|
||||
return seen
|
||||
})
|
||||
expect(order).toEqual(['resize', ['control', true], 'metadata'])
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,318 @@
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { MediaEventHost } from '../../packages/artplayer/src/media/events/types'
|
||||
import eventInit from '../../packages/artplayer/src/player/eventInit'
|
||||
|
||||
declare const art: MediaEventHost
|
||||
eventInit(art)
|
||||
art.emit('ready')
|
||||
art.emit('resize')
|
||||
art.emit('video:custom', new Event('custom'))
|
||||
art.emit('error', new Error('source'), 1)
|
||||
// @ts-expect-error Media forwarding retains the Event object, not an arbitrary payload.
|
||||
art.emit('video:canplay', 'canplay')
|
||||
// @ts-expect-error Retry errors include the attempt count.
|
||||
art.emit('error', new Error('source'))
|
||||
// @ts-expect-error UI visibility does not accept a source URL.
|
||||
art.loading.show = 'video.mp4'
|
||||
// @ts-expect-error A structural proxy still needs EventTarget listener capabilities.
|
||||
art.template.$video = { src: 'video.mp4' }
|
||||
Reference in new issue
Block a user