mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-12 23:36:15 -08:00
refactor(core): CORE-04 scope initialization and make destruction reentrant-safe
This commit is contained in:
1 parent
3e38dd0d19
commit
4eec8112fa
32 files changed
+2779
-189
No files matched your search
@@ -0,0 +1,298 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: repeated destroy preserves other live instances`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(() => {
|
||||
const { Artplayer } = window
|
||||
const create = () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
return new Artplayer({ container, url: '/test/pattern.mp4', muted: true })
|
||||
}
|
||||
const first = create()
|
||||
const second = create()
|
||||
let destroys = 0
|
||||
first.on('destroy', () => destroys++)
|
||||
first.destroy(false)
|
||||
first.destroy(true)
|
||||
const result = {
|
||||
secondRegistered: Artplayer.instances.includes(second),
|
||||
retained: !!first.template.$container.querySelector('.art-destroy'),
|
||||
destroys,
|
||||
firstRemoved: !Artplayer.instances.includes(first),
|
||||
}
|
||||
second.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ secondRegistered: core === 'candidate', retained: core === 'candidate', destroys: core === 'candidate' ? 1 : 2, firstRemoved: true })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: constructor plugin failure restores original DOM and clears resources', async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate(async () => {
|
||||
const { Artplayer } = window
|
||||
const container = document.createElement('div')
|
||||
container.dataset.artId = 'original'
|
||||
container.innerHTML = '<button>original</button>'
|
||||
document.body.append(container)
|
||||
const original = container.firstChild
|
||||
let originalClicks = 0
|
||||
original.addEventListener('click', () => originalClicks++)
|
||||
const error = new Error('plugin init failure')
|
||||
let failed
|
||||
let observed
|
||||
let destroys = 0
|
||||
let mounted = 0
|
||||
let customTypes = 0
|
||||
let reservedDuringRollback = false
|
||||
try {
|
||||
void new Artplayer({
|
||||
container,
|
||||
url: '/test/pattern.mp4',
|
||||
customType: { mp4() { customTypes++ } },
|
||||
settings: [{ name: 'delayed', html: 'Delayed', mounted() { mounted++ } }],
|
||||
plugins: [(art) => {
|
||||
failed = art
|
||||
art.notice.show = 'temporary'
|
||||
art.on('destroy', () => {
|
||||
destroys++
|
||||
try {
|
||||
void new Artplayer({ container, url: '/test/pattern.mp4' })
|
||||
}
|
||||
catch (error) { reservedDuringRollback = error.message === 'Cannot mount multiple instances on the same dom element' }
|
||||
throw new Error('controlled plugin cleanup failure')
|
||||
})
|
||||
throw error
|
||||
}],
|
||||
})
|
||||
}
|
||||
catch (actual) { observed = actual === error }
|
||||
// A timer turn exposes cancelled mount/custom-type continuations.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
original.click()
|
||||
const result = {
|
||||
observed,
|
||||
sameNode: container.firstChild === original,
|
||||
html: container.innerHTML,
|
||||
artId: container.dataset.artId,
|
||||
originalClicks,
|
||||
destroyed: failed.isDestroy,
|
||||
registered: Artplayer.instances.includes(failed),
|
||||
listeners: failed.events.destroyEvents.size,
|
||||
timer: failed.notice.timer,
|
||||
destroys,
|
||||
mounted,
|
||||
customTypes,
|
||||
reservedDuringRollback,
|
||||
}
|
||||
const replacement = new Artplayer({ container, url: '/test/pattern.mp4' })
|
||||
replacement.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ observed: true, sameNode: true, html: '<button>original</button>', artId: 'original', originalClicks: 1, destroyed: true, registered: false, listeners: 0, timer: null, destroys: 1, mounted: 0, customTypes: 0, reservedDuringRollback: true })
|
||||
})
|
||||
|
||||
test('candidate: early proxy failure restores SSR nodes, attributes and text', async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate(() => {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = window.Artplayer.html
|
||||
container.setAttribute('data-test', 'SSR')
|
||||
document.body.append(container)
|
||||
const original = container.innerHTML
|
||||
const video = container.querySelector('video')
|
||||
const player = container.querySelector('.art-video-player')
|
||||
const error = new Error('proxy failure')
|
||||
let sameError = false
|
||||
try {
|
||||
void new window.Artplayer({ container, useSSR: true, url: '/test/pattern.mp4', proxy() {
|
||||
player.setAttribute('data-modified', 'yes')
|
||||
player.append(document.createTextNode('changed'))
|
||||
throw error
|
||||
} })
|
||||
}
|
||||
catch (actual) { sameError = actual === error }
|
||||
return {
|
||||
sameError,
|
||||
sameHTML: original === container.innerHTML,
|
||||
sameVideo: video === container.querySelector('video'),
|
||||
artId: container.getAttribute('data-art-id'),
|
||||
data: container.getAttribute('data-test'),
|
||||
count: window.Artplayer.instances.length,
|
||||
}
|
||||
})
|
||||
expect(result).toEqual({ sameError: true, sameHTML: true, sameVideo: true, artId: null, data: 'SSR', count: 0 })
|
||||
})
|
||||
|
||||
test('candidate: reentrant destroy keeps sequence and does not repeat callbacks', async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate(() => {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const art = new window.Artplayer({ container, url: '/test/pattern.mp4' })
|
||||
const calls = []
|
||||
let replacement
|
||||
const reset = art.reset.bind(art)
|
||||
art.reset = () => {
|
||||
calls.push(['reset', art.isDestroy])
|
||||
art.destroy(true)
|
||||
reset()
|
||||
}
|
||||
art.on('destroy', () => {
|
||||
calls.push(['destroy', art.isDestroy, window.Artplayer.instances.includes(art), art.events.destroyEvents.size, !!container.querySelector('.art-destroy')])
|
||||
art.destroy(true)
|
||||
replacement = new window.Artplayer({ container, url: '/test/pattern.mp4' })
|
||||
})
|
||||
art.destroy(false)
|
||||
art.destroy(true)
|
||||
const result = { calls, replacementRegistered: window.Artplayer.instances.includes(replacement), players: container.querySelectorAll('.art-video-player').length }
|
||||
replacement.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ calls: [['reset', false], ['destroy', true, false, 0, true]], replacementRegistered: true, players: 1 })
|
||||
})
|
||||
|
||||
test('candidate: destroying cancels queued resize, notice, RAF, reconnect and mounted work', async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate(async () => {
|
||||
const { Artplayer } = window
|
||||
Artplayer.USE_RAF = true
|
||||
Artplayer.RESIZE_TIME = 0
|
||||
Artplayer.RECONNECT_SLEEP_TIME = 0
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
let mounted = 0
|
||||
const art = new Artplayer({ container, url: '/test/pattern.mp4', settings: [{ html: 'Delayed', mounted() {
|
||||
mounted++
|
||||
} }] })
|
||||
let resize = 0
|
||||
let reconnect = 0
|
||||
let raf = 0
|
||||
art.on('resize', () => resize++)
|
||||
art.on('error', () => reconnect++)
|
||||
art.on('raf', () => raf++)
|
||||
art.notice.show = 'before'
|
||||
art.emit('window:resize')
|
||||
art.emit('video:error', new Error('controlled retry'))
|
||||
art.destroy(false)
|
||||
const html = container.innerHTML
|
||||
await new Promise(resolve => requestAnimationFrame(resolve))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
return {
|
||||
resize,
|
||||
reconnect,
|
||||
raf,
|
||||
mounted,
|
||||
timer: art.notice.timer,
|
||||
sameHTML: container.innerHTML === html,
|
||||
src: art.video.getAttribute('src'),
|
||||
listeners: art.events.destroyEvents.size,
|
||||
}
|
||||
})
|
||||
expect(result).toEqual({ resize: 0, reconnect: 0, raf: 0, mounted: 0, timer: null, sameHTML: true, src: null, listeners: 0 })
|
||||
})
|
||||
|
||||
test('candidate: constructor can synchronously destroy without entering the registry', async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate(() => {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const art = new window.Artplayer({ container, url: '/test/pattern.mp4', plugins: [(art) => {
|
||||
art.destroy()
|
||||
return { name: 'closed' }
|
||||
}] })
|
||||
return { destroyed: art.isDestroy, count: window.Artplayer.instances.length, children: container.children.length }
|
||||
})
|
||||
expect(result).toEqual({ destroyed: true, count: 0, children: 0 })
|
||||
})
|
||||
|
||||
test('candidate: partial Events initialization releases listeners before its property is assigned', async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate(() => {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = '<span>before</span>'
|
||||
document.body.append(container)
|
||||
const add = EventTarget.prototype.addEventListener
|
||||
const remove = EventTarget.prototype.removeEventListener
|
||||
const active = []
|
||||
const error = new Error('controlled global listener failure')
|
||||
let failed
|
||||
let sameError = false
|
||||
EventTarget.prototype.addEventListener = function (name, callback, options) {
|
||||
if (this === window && name === 'orientationchange')
|
||||
throw error
|
||||
const result = add.call(this, name, callback, options)
|
||||
if (this === window || this === document)
|
||||
active.push({ target: this, name, callback })
|
||||
return result
|
||||
}
|
||||
EventTarget.prototype.removeEventListener = function (name, callback, options) {
|
||||
const result = remove.call(this, name, callback, options)
|
||||
const index = active.findIndex(item => item.target === this && item.name === name && item.callback === callback)
|
||||
if (index !== -1)
|
||||
active.splice(index, 1)
|
||||
return result
|
||||
}
|
||||
try {
|
||||
void new window.Artplayer({ container, url: '/test/pattern.mp4', proxy(art) {
|
||||
failed = art
|
||||
return document.createElement('video')
|
||||
} })
|
||||
}
|
||||
catch (actual) { sameError = actual === error }
|
||||
finally {
|
||||
EventTarget.prototype.addEventListener = add
|
||||
EventTarget.prototype.removeEventListener = remove
|
||||
}
|
||||
return { sameError, assigned: Object.hasOwn(failed, 'events'), active: active.length, destroyed: failed.isDestroy, html: container.innerHTML, count: window.Artplayer.instances.length }
|
||||
})
|
||||
expect(result).toEqual({ sameError: true, assigned: false, active: 0, destroyed: true, html: '<span>before</span>', count: 0 })
|
||||
})
|
||||
|
||||
test('candidate: live resize still coalesces and destroy cancels the next burst', async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
await page.evaluate(() => {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
window.Artplayer.RESIZE_TIME = 0
|
||||
window.art = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true })
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
const result = await page.evaluate(async () => {
|
||||
const art = window.art
|
||||
let resized = 0
|
||||
art.on('resize', () => resized++)
|
||||
const first = new Promise(resolve => art.once('resize', resolve))
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
await first
|
||||
const beforeDestroy = resized
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
art.destroy()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
return { beforeDestroy, resized, timer: art.notice.timer }
|
||||
})
|
||||
expect(result).toEqual({ beforeDestroy: 1, resized: 1, timer: null })
|
||||
})
|
||||
|
||||
test('candidate: a container is reserved during reentrant initialization', async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate(() => {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
let message
|
||||
const art = new window.Artplayer({ container, url: '/test/pattern.mp4', proxy() {
|
||||
try {
|
||||
void new window.Artplayer({ container, url: '/test/pattern.mp4' })
|
||||
}
|
||||
catch (error) { message = error.message }
|
||||
return document.createElement('video')
|
||||
} })
|
||||
const result = { message, registered: window.Artplayer.instances.includes(art), players: container.querySelectorAll('.art-video-player').length, count: window.Artplayer.instances.length }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ message: 'Cannot mount multiple instances on the same dom element', registered: true, players: 1, count: 1 })
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const file = 'packages/artplayer/src/lifecycle/instance'
|
||||
const { beginLifecycle, destroyInstance, finishLifecycle, getScope, urlMix } = await loadModules({
|
||||
...Object.fromEntries(['beginLifecycle', 'destroyInstance', 'finishLifecycle', 'getScope'].map(name => [name, { file, name }])),
|
||||
urlMix: 'packages/artplayer/src/player/urlMix',
|
||||
})
|
||||
|
||||
test('instance teardown preserves order, public state and first-call removeHtml across reentry', () => {
|
||||
const calls = []
|
||||
const instances = []
|
||||
const owner = {
|
||||
isDestroy: false,
|
||||
reset() {
|
||||
calls.push(['reset', this.isDestroy])
|
||||
destroyInstance(this, instances, true, true)
|
||||
},
|
||||
template: { $video: {}, destroy(removeHtml) {
|
||||
calls.push(['template', removeHtml])
|
||||
} },
|
||||
emit(name) {
|
||||
calls.push([name, this.isDestroy, instances.includes(this)])
|
||||
},
|
||||
}
|
||||
const keys = Object.keys(owner)
|
||||
beginLifecycle(owner)
|
||||
assert.deepEqual(Object.keys(owner), keys)
|
||||
getScope(owner).add(() => {
|
||||
calls.push(['resources', owner.isDestroy])
|
||||
})
|
||||
assert(finishLifecycle(owner))
|
||||
instances.push(owner)
|
||||
destroyInstance(owner, instances, false, true)
|
||||
destroyInstance(owner, instances, true, true)
|
||||
assert.deepEqual(calls, [['reset', false], ['resources', false], ['template', false], ['destroy', true, false]])
|
||||
})
|
||||
|
||||
test('reset failure still releases resources, removes the instance and dispatches destroy', () => {
|
||||
const error = new Error('reset failed')
|
||||
let released = false
|
||||
let emitted = false
|
||||
const owner = { isDestroy: false, reset() {
|
||||
throw error
|
||||
}, template: { $video: {}, destroy() {} }, emit() { emitted = true } }
|
||||
const instances = [owner]
|
||||
beginLifecycle(owner)
|
||||
getScope(owner).add(() => {
|
||||
released = true
|
||||
})
|
||||
assert.throws(() => destroyInstance(owner, instances, true, true), value => value === error)
|
||||
assert(released && emitted && owner.isDestroy)
|
||||
assert.deepEqual(instances, [])
|
||||
destroyInstance(owner, instances, true, true)
|
||||
})
|
||||
|
||||
test('destroy listener error propagates unchanged after internal cleanup and remains consumed', () => {
|
||||
const error = new Error('listener failed')
|
||||
const sibling = {}
|
||||
const owner = { isDestroy: false, reset() {}, emit() {
|
||||
throw error
|
||||
} }
|
||||
const instances = [owner, sibling]
|
||||
beginLifecycle(owner)
|
||||
assert.throws(() => destroyInstance(owner, instances, true, false), value => value === error)
|
||||
assert(getScope(owner).closed)
|
||||
destroyInstance(owner, instances, true, false)
|
||||
assert.deepEqual(instances, [sibling])
|
||||
})
|
||||
|
||||
test('custom-type continuation cannot start after its timer resolves but before its owner is destroyed', async (context) => {
|
||||
context.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
let calls = 0
|
||||
const owner = {
|
||||
isDestroy: false,
|
||||
reset() {},
|
||||
emit() {},
|
||||
option: { type: 'custom', customType: { custom() { calls++ } } },
|
||||
template: { $video: { src: '' }, destroy() {} },
|
||||
loading: { show: false },
|
||||
}
|
||||
beginLifecycle(owner)
|
||||
urlMix(owner)
|
||||
owner.url = 'test.custom'
|
||||
context.mock.timers.tick(0)
|
||||
destroyInstance(owner, [], true, false)
|
||||
await Promise.resolve()
|
||||
assert.equal(calls, 0)
|
||||
assert.equal(owner.loading.show, false)
|
||||
})
|
||||
@@ -4,10 +4,10 @@ import { test } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const source = 'packages/artplayer/src/lifecycle/'
|
||||
const { ResourceScope, ResourceCleanupError, timeout, animationFrame, listen, requestController, objectURL, Emitter } = await loadModules({
|
||||
const { ResourceScope, ResourceCleanupError, timeout, animationFrame, listen, requestController, objectURL, wait, Emitter } = await loadModules({
|
||||
ResourceScope: `${source}scope`,
|
||||
ResourceCleanupError: { file: `${source}scope`, name: 'ResourceCleanupError' },
|
||||
...Object.fromEntries(['timeout', 'animationFrame', 'listen', 'requestController', 'objectURL'].map(name => [name, { file: `${source}resources`, name }])),
|
||||
...Object.fromEntries(['timeout', 'animationFrame', 'listen', 'requestController', 'objectURL', 'wait'].map(name => [name, { file: `${source}resources`, name }])),
|
||||
Emitter: 'packages/artplayer/src/utils/emitter',
|
||||
})
|
||||
|
||||
@@ -194,3 +194,16 @@ test('request controller capability absence does not prevent synchronous cleanup
|
||||
assert.equal(requestController(scope), undefined)
|
||||
scope.dispose()
|
||||
})
|
||||
|
||||
test('owned waits resolve on cancellation and preserve normal timer-to-microtask completion', async (context) => {
|
||||
context.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
const scope = new ResourceScope()
|
||||
const completed = wait(scope, 5)
|
||||
const cancelled = wait(scope, 10)
|
||||
context.mock.timers.tick(5)
|
||||
assert.equal(await completed, true)
|
||||
scope.dispose()
|
||||
assert.equal(await cancelled, false)
|
||||
assert.equal(await wait(scope), false)
|
||||
context.mock.timers.tick(20)
|
||||
})
|
||||
Reference in new issue
Block a user