refactor(core): CORE-04 scope initialization and make destruction reentrant-safe

This commit is contained in:
Harvey Zhao committed 2026-09-11 00:43:49 +08:00
1 parent 3e38dd0d19
commit 4eec8112fa
32 files changed
+2779 -189

No files matched your search

+298
View File
@@ -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 })
})
+92
View File
@@ -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)
})
+15 -2
View File
@@ -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)
})