refactor(core): CORE-09 isolate source operations and settle cancelled switches

This commit is contained in:
Harvey Zhao committed 2026-09-11 02:34:26 +08:00
1 parent 874dac9f22
commit 44f4e083a9
29 files changed
+4512 -255

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
import { expect, test } from './fixtures.js'
for (const core of ['published', 'candidate']) {
test(`${core}: rapid real-media switches identify the last restart and settle superseded work`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?source=initial'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
const result = await page.evaluate(async () => {
const { art } = window
const restarts = []
const order = []
art.on('restart', url => restarts.push(url))
art.on('video:canplay', () => order.push('canplay'))
art.on('restart', () => order.push('restart'))
let firstSettled = false
const first = art.switchUrl('/test/pattern.mp4?source=first').then(() => {
firstSettled = true
})
const second = art.switchUrl('/test/pattern.mp4?source=second')
await Promise.resolve()
const early = firstSettled
await Promise.all([first, second])
const result = { early, restarts, order, current: new URL(art.video.currentSrc).search, error: art.video.error?.code || 0, time: art.currentTime }
art.destroy()
return result
})
expect(result.early).toBe(core === 'candidate')
expect(result.restarts).toEqual(core === 'candidate'
? ['/test/pattern.mp4?source=second']
: ['/test/pattern.mp4?source=first', '/test/pattern.mp4?source=second'])
expect(result.order[0]).toBe('canplay')
expect(result.current).toBe('?source=second')
expect(result.error).toBe(0)
expect(result.time).toBeCloseTo(0, 1)
})
test(`${core}: destroy settles the candidate switch without waiting for detached media`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?destroy=initial'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
const result = await page.evaluate(async () => {
const { art } = window
let settled = false
let rejected = false
art.switchUrl('/test/pattern.mp4?destroy=pending').then(() => {
settled = true
}, () => {
rejected = true
})
art.destroy()
// Retain the published baseline's bounded pending observation, not an infinite-pending claim.
await new Promise(resolve => setTimeout(resolve, 250))
return { settled, rejected, destroyed: art.isDestroy, instances: window.Artplayer.instances.length }
})
expect(result).toEqual({ settled: core === 'candidate', rejected: false, destroyed: true, instances: 0 })
})
}
test('candidate: quality switch restores position/rate and preserves real customType identities', async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?custom=initial'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.evaluate(() => {
window.art.currentTime = 3
})
await expect.poll(() => page.evaluate(() => window.art.video.seeking)).toBe(false)
const result = await page.evaluate(async () => {
const { art } = window
const calls = []
art.option.type = 'controlled'
art.option.customType.controlled = function (video, url, owner) {
calls.push({ thisIdentity: this === art, ownerIdentity: owner === art, videoIdentity: video === art.video, count: arguments.length, url })
video.src = url
}
art.playbackRate = 1.5
art.aspectRatio = '16:9'
const restart = []
art.on('restart', url => restart.push(url))
await art.switchQuality('/test/pattern.mp4?custom=next')
const result = { calls, restart, time: art.currentTime, rate: art.playbackRate, ratio: art.aspectRatio, paused: art.video.paused, error: art.video.error?.code || 0 }
art.destroy()
return result
})
expect(result.calls).toEqual([{ thisIdentity: true, ownerIdentity: true, videoIdentity: true, count: 3, url: '/test/pattern.mp4?custom=next' }])
expect(result.restart).toEqual(['/test/pattern.mp4?custom=next'])
expect(result.time).toBeCloseTo(3, 1)
expect(result.rate).toBe(1.5)
expect(result.ratio).toBe('16:9')
expect(result.paused).toBe(true)
expect(result.error).toBe(0)
})
test('candidate: real switch settles when the controlled native resume rejects', async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?resume=initial'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
const result = await page.evaluate(async () => {
const { art } = window
const error = new DOMException('controlled resume rejection', 'NotAllowedError')
let calls = 0
Object.defineProperty(art.video, 'play', { configurable: true, value() {
calls++
return Promise.reject(error)
} })
const value = await art.switchUrl('/test/pattern.mp4?resume=next')
const result = { settledUndefined: value === undefined, calls, source: new URL(art.video.currentSrc).search, paused: art.video.paused }
delete art.video.play
art.destroy()
return result
})
expect(result).toEqual({ settledUndefined: true, calls: 1, source: '?resume=next', paused: true })
})
test('candidate: late native play completion cannot publish stale player effects', async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?late=initial'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
const result = await page.evaluate(async () => {
const { art } = window
const plays = []
art.on('play', () => plays.push('play'))
let resolveNative
let started
const invoked = new Promise(resolve => started = resolve)
Object.defineProperty(art.video, 'play', { configurable: true, value() {
started()
return new Promise(resolve => resolveNative = resolve)
} })
const switching = art.switchUrl('/test/pattern.mp4?late=resume')
await invoked
art.url = '/test/pattern.mp4?late=latest'
await switching
art.notice.show = 'Latest source notice'
resolveNative(42)
await new Promise(resolve => setTimeout(resolve, 0))
const result = { plays, notice: art.template.$noticeInner.textContent }
delete art.video.play
art.destroy()
return result
})
expect(result).toEqual({ plays: [], notice: 'Latest source notice' })
})
+2 -1
View File
@@ -1,6 +1,6 @@
import { mock } from 'node:test'
export function playbackFactory({ Emitter, playMix, pauseMix, toggleMix, switchMix }) {
export function playbackFactory({ Emitter, playMix, pauseMix, toggleMix, switchMix, beginLifecycle }) {
return function createArt(play = () => Promise.resolve(), playing = false) {
const art = Object.assign(new Emitter(), {
url: 'old.mp4',
@@ -19,6 +19,7 @@ export function playbackFactory({ Emitter, playMix, pauseMix, toggleMix, switchM
},
},
})
beginLifecycle(art)
Object.defineProperty(art, 'playing', { get: () => !art.template.$video.paused })
for (const mix of [playMix, pauseMix, toggleMix, switchMix])
mix(art)
+2
View File
@@ -13,6 +13,8 @@ const modules = await loadModules({
pauseMix: 'packages/artplayer/src/player/pauseMix',
toggleMix: 'packages/artplayer/src/player/toggleMix',
switchMix: 'packages/artplayer/src/player/switchMix',
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
})
const { silencePromise, Hotkey } = modules
const createArt = playbackFactory(modules)
+364
View File
@@ -0,0 +1,364 @@
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, urlMix, switchMix, playMix, beginLifecycle, getScope } = await loadModules({
Emitter: 'packages/artplayer/src/utils/emitter',
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' },
})
function createArt(customType) {
const art = Object.assign(new Emitter(), {
template: { $video: { src: 'old.mp4' } },
option: { url: 'old.mp4', type: customType ? 'custom' : '', customType: { custom: customType } },
isReady: true,
playing: false,
currentTime: 37,
playbackRate: 1.5,
aspectRatio: '16:9',
loading: { show: false },
notice: { show: '' },
pause: mock.fn(),
play: mock.fn(() => Promise.resolve()),
})
beginLifecycle(art)
urlMix(art)
switchMix(art)
return art
}
test('superseded switches settle before readiness and only the latest restores state/restarts', async () => {
const art = createArt()
const restarts = []
art.on('restart', url => restarts.push(url))
const first = art.switchQuality('first.mp4')
art.currentTime = 9
const second = art.switchQuality('second.mp4')
assert.equal(await first, undefined)
art.currentTime = 0
art.emit('video:loadedmetadata')
assert.equal(art.currentTime, 9)
art.emit('video:canplay')
assert.equal(await second, undefined)
assert.deepEqual(restarts, ['second.mp4'])
assert.deepEqual(Object.keys(art.e), ['restart'])
})
test('direct URL assignment cancels an outstanding switch and stale restart listeners', async () => {
const art = createArt()
const restarts = []
art.on('restart', url => restarts.push(url))
const switching = art.switchQuality('switch.mp4')
art.url = 'direct.mp4'
await switching
art.currentTime = 4
art.emit('video:loadedmetadata')
art.emit('video:canplay')
assert.equal(art.currentTime, 4)
assert.deepEqual(restarts, ['direct.mp4'])
})
test('scope disposal settles a pending switch and prevents post-destroy work', async () => {
const art = createArt()
const switching = art.switchUrl('pending.mp4')
getScope(art).dispose()
assert.equal(await switching, undefined)
assert.deepEqual(Object.keys(art.e), [])
art.url = 'closed.mp4'
await art.switchUrl('also-closed.mp4')
assert.equal(art.url, 'pending.mp4')
assert.equal(art.pause.mock.callCount(), 1)
})
test('same URL remains a no-op and switch setter retains the public function identity', async () => {
const art = createArt()
art.notice.show = 'keep'
assert.equal(await art.switchUrl('old.mp4'), undefined)
assert.equal(art.pause.mock.callCount(), 0)
assert.equal(art.notice.show, 'keep')
assert.equal(Object.getOwnPropertyDescriptor(art, 'switch').set, art.switchUrl)
for (const name of ['url', 'switch', 'switchUrl', 'switchQuality']) {
const descriptor = Object.getOwnPropertyDescriptor(art, name)
assert.equal(descriptor.enumerable, false)
assert.equal(descriptor.configurable, false)
}
})
test('a reentrant pause cannot make its superseded switch overwrite the latest URL', async () => {
const art = createArt()
art.pause = () => {
art.url = 'reentrant.mp4'
}
await art.switchUrl('stale.mp4')
assert.equal(art.url, 'reentrant.mp4')
})
test('synchronous media setters cannot lose metadata, readiness or original errors', async () => {
for (const fails of [false, true]) {
const art = createArt()
const error = new Error('synchronous source failure')
let src = 'old.mp4'
Object.defineProperty(art.template.$video, 'src', {
get: () => src,
set(value) {
src = value
if (fails) {
art.emit('video:error', error)
}
else {
art.currentTime = 0
art.emit('video:loadedmetadata')
art.emit('video:canplay')
}
},
})
const switching = art.switchQuality('sync.mp4')
if (fails) {
await assert.rejects(switching, actual => actual === error)
}
else {
await switching
assert.equal(art.currentTime, 37)
}
assert.deepEqual(Object.keys(art.e), [])
}
})
test('late resume fulfillment or rejection cannot clear a newer operation notice', async () => {
for (const rejects of [false, true]) {
const art = createArt()
art.playing = true
let finish
art.play = () => new Promise((resolve, reject) => {
finish = rejects ? reject : resolve
})
const first = art.switchUrl('first.mp4')
art.emit('video:canplay')
art.playing = false
const second = art.switchUrl('second.mp4')
await first
art.notice.show = 'new source notice'
finish(new Error('late play'))
await setImmediate()
assert.equal(art.notice.show, 'new source notice')
art.emit('video:canplay')
await second
assert.deepEqual(Object.keys(art.e), [])
}
})
test('state restoration errors reject without leaking handlers or unhandled promises', async () => {
for (const property of ['currentTime', 'playbackRate', 'aspectRatio']) {
const art = createArt()
const error = new Error(property)
const switching = art.switchQuality('new.mp4')
const previous = art[property]
Object.defineProperty(art, property, {
get: () => previous,
set() {
throw error
},
})
const rejected = assert.rejects(switching, actual => actual === error)
art.emit(property === 'currentTime' ? 'video:loadedmetadata' : 'video:canplay')
await rejected
assert.deepEqual(Object.keys(art.e), [])
}
})
test('customType deferral ignores the old resource and preserves callback identities', async () => {
let called
const art = createArt(function (video, url, owner) {
called = [this, video, url, owner, arguments.length]
video.src = url
owner.emit('video:loadedmetadata')
owner.emit('video:canplay')
})
const switching = art.switchQuality('custom.mp4')
art.currentTime = 0
art.emit('video:canplay')
assert.equal(called, undefined)
await switching
assert.deepEqual(called, [art, art.template.$video, 'custom.mp4', art, 3])
assert.equal(art.currentTime, 37)
assert.equal(art.option.url, 'custom.mp4')
})
test('customType failures reject switches with the original thrown/rejected value', async () => {
for (const asynchronous of [false, true]) {
const error = new Error('customType failed')
const art = createArt(() => {
if (asynchronous)
return Promise.reject(error)
throw error
})
await assert.rejects(art.switchUrl('failed.mp4'), actual => actual === error)
assert.deepEqual(Object.keys(art.e), [])
}
})
test('superseded customType callbacks never start and empty URL switches settle', async () => {
const callback = mock.fn((video, url, art) => {
video.src = url
art.emit('video:canplay')
})
const art = createArt(callback)
const first = art.switchUrl('first.mp4')
const second = art.switchUrl('second.mp4')
await Promise.all([first, second])
assert.equal(callback.mock.callCount(), 1)
assert.equal(callback.mock.calls[0].arguments[1], 'second.mp4')
await art.switchUrl('')
assert.equal(art.loading.show, true)
assert.equal(art.url, 'second.mp4')
})
test('reentrant media listeners and property setters cannot restore stale state', async () => {
for (const boundary of ['metadata', 'rate']) {
const art = createArt()
if (boundary === 'metadata') {
art.once('video:loadedmetadata', () => {
art.url = 'reentrant.mp4'
art.currentTime = 6
})
}
const switching = art.switchQuality('stale.mp4')
art.currentTime = 0
art.aspectRatio = 'default'
if (boundary === 'rate') {
Object.defineProperty(art, 'playbackRate', {
get: () => 1,
set() {
art.url = 'reentrant.mp4'
},
})
}
art.emit(boundary === 'metadata' ? 'video:loadedmetadata' : 'video:canplay')
await switching
assert.equal(art.url, 'reentrant.mp4')
assert.equal(art.aspectRatio, 'default')
if (boundary === 'metadata')
assert.equal(art.currentTime, 6)
getScope(art).dispose()
assert.deepEqual(Object.keys(art.e), [])
}
})
test('direct customType rejection is reported once; obsolete returned failures are ignored', async (t) => {
const warning = t.mock.method(console, 'warn', () => {})
const error = new Error('direct custom source')
let rejectLate
let started
const invoked = new Promise(resolve => started = resolve)
const art = createArt(() => new Promise((resolve, reject) => {
rejectLate = reject
started()
}))
art.url = 'late.mp4'
await invoked
art.option.type = ''
art.url = 'current.mp4'
rejectLate(error)
await setImmediate()
assert.equal(warning.mock.callCount(), 0)
art.option.type = 'custom'
art.option.customType.custom = () => Promise.reject(error)
await Object.getOwnPropertyDescriptor(art, 'url').set.call(art, 'failed.mp4')
await setImmediate()
assert.equal(warning.mock.callCount(), 1)
assert.deepEqual(warning.mock.calls[0].arguments, ['Failed to initialize ArtPlayer source:', error])
assert.deepEqual(Object.keys(art.e), [])
})
test('same-URL calls do not cancel the still-loading original operation', async () => {
const art = createArt()
let firstSettled = false
const first = art.switchQuality('pending.mp4').then(() => firstSettled = true)
await art.switchUrl('pending.mp4')
assert.equal(firstSettled, false)
art.currentTime = 0
art.emit('video:loadedmetadata')
art.emit('video:canplay')
await first
assert.equal(art.currentTime, 37)
assert.equal(art.pause.mock.callCount(), 1)
})
test('synchronous readiness replay consumes metadata once and emits restart once', async () => {
const art = createArt()
const restarts = []
art.on('restart', url => restarts.push(url))
let src = 'old.mp4'
Object.defineProperty(art.template.$video, 'src', {
get: () => src,
set(value) {
src = value
art.emit('video:loadedmetadata')
art.emit('video:loadedmetadata')
},
})
const switching = art.switchQuality('sync.mp4')
assert.equal(art.currentTime, 37)
art.currentTime = 4
art.emit('video:loadedmetadata')
assert.equal(art.currentTime, 4)
art.emit('video:canplay')
art.emit('video:canplay')
await switching
assert.deepEqual(restarts, ['sync.mp4'])
assert.deepEqual(Object.keys(art.e), ['restart'])
})
test('the real play mixin cannot publish stale resume effects after source replacement', async () => {
const art = createArt()
art.i18n = { get: key => key }
art.constructor = { instances: [] }
let resolveNative
art.template.$video.play = () => new Promise(resolve => resolveNative = resolve)
playMix(art)
art.playing = true
const plays = []
art.on('play', () => plays.push('play'))
const switching = art.switchUrl('pending-resume.mp4')
art.emit('video:canplay')
art.url = 'latest.mp4'
await switching
art.notice.show = 'latest notice'
resolveNative(42)
await setImmediate()
assert.equal(art.notice.show, 'latest notice')
assert.deepEqual(plays, [])
})
test('public pending play preserves its result while obsolete notification/event/mutex work stops', async () => {
for (const destroy of [false, true]) {
const art = createArt()
art.i18n = { get: key => key }
const other = { pause: mock.fn() }
art.constructor = { instances: [other] }
art.option.mutex = true
art.url = 'initial.mp4'
let resolveNative
art.template.$video.play = () => new Promise(resolve => resolveNative = resolve)
playMix(art)
const plays = mock.fn()
art.on('play', plays)
const playing = art.play()
if (destroy)
getScope(art).dispose()
else
art.url = 'replacement.mp4'
art.notice.show = 'preserve'
resolveNative(42)
assert.equal(await playing, 42)
assert.equal(art.notice.show, 'preserve')
assert.equal(plays.mock.callCount(), 0)
assert.equal(other.pause.mock.callCount(), 0)
}
})
+36
View File
@@ -0,0 +1,36 @@
import type { CanvasMedia, NativeMedia } from '../../packages/artplayer/src/media/types'
import type { SourceMedia, SwitchHost, UrlHost } from '../../packages/artplayer/src/source/types'
import switchMix from '../../packages/artplayer/src/player/switchMix'
import urlMix from '../../packages/artplayer/src/player/urlMix'
declare const native: NativeMedia
declare const canvas: CanvasMedia
const sourceMedia: SourceMedia[] = [native, canvas]
void sourceMedia
declare const switching: SwitchHost
switchMix(switching)
const pending: Promise<void> = switching.switchQuality('movie.mp4')
void pending
switching.switch = 'movie.mp4'
// @ts-expect-error Source URLs remain strings.
switching.switchUrl(42)
// @ts-expect-error Switching does not resolve with a media element or success boolean.
const wrong: Promise<boolean> = switching.switchUrl('movie.mp4')
void wrong
interface CustomHost extends UrlHost<CanvasMedia, CustomHost> {
extension: number
}
declare const custom: CustomHost
custom.option.customType.canvas = function (video, url, art) {
this satisfies CustomHost
const owner: CustomHost = art
const surface: HTMLCanvasElement = video
const source: string = url
void [owner, surface, source]
// @ts-expect-error A canvas proxy is not a native video element.
const wrongMedia: HTMLVideoElement = video
void wrongMedia
}
urlMix<CanvasMedia, CustomHost>(custom)