refactor(core): CORE-11 split media events and scope reconnect lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 03:19:52 +08:00
1 parent 5e31274f1d
commit b8039ad1f1
27 files changed
+4844 -475

No files matched your search

+131
View File
@@ -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'])
})
}
})
+318
View File
@@ -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)
})
+17
View File
@@ -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' }