mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-09 03:46:16 -08:00
216 lines
7.3 KiB
JavaScript
216 lines
7.3 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
// eslint-disable-next-line test/no-import-node-test -- Control timers and native API failures.
|
|
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, wait, Emitter } = await loadModules({
|
|
ResourceScope: `${source}scope`,
|
|
ResourceCleanupError: { file: `${source}scope`, name: 'ResourceCleanupError' },
|
|
...Object.fromEntries(['timeout', 'animationFrame', 'listen', 'requestController', 'objectURL', 'wait'].map(name => [name, { file: `${source}resources`, name }])),
|
|
Emitter: 'packages/artplayer/src/utils/emitter',
|
|
})
|
|
|
|
test('scope closes before cleanup, unwinds children and collects failures without retaining registrations', () => {
|
|
const scope = new ResourceScope()
|
|
const calls = []
|
|
const first = new Error('first')
|
|
const second = new Error('second')
|
|
scope.add(() => {
|
|
calls.push('oldest')
|
|
throw first
|
|
})
|
|
const child = scope.child()
|
|
child.add(() => {
|
|
calls.push('child')
|
|
throw second
|
|
})
|
|
scope.add(() => {
|
|
assert(scope.closed)
|
|
scope.dispose()
|
|
scope.add(() => {
|
|
calls.push('late')
|
|
})
|
|
calls.push('newest')
|
|
})
|
|
assert.throws(() => scope.dispose(), error => error instanceof ResourceCleanupError && assert.deepEqual(error.errors, [second, first]) === undefined)
|
|
assert.deepEqual(calls, ['late', 'newest', 'child', 'oldest'])
|
|
assert(child.closed)
|
|
scope.dispose()
|
|
assert.equal(calls.length, 4)
|
|
})
|
|
|
|
test('operation completion detaches from the live instance and siblings remain usable', () => {
|
|
const instance = new ResourceScope()
|
|
const operation = instance.child()
|
|
const sibling = instance.child()
|
|
let calls = 0
|
|
const release = operation.add(() => {
|
|
calls++
|
|
})
|
|
release()
|
|
release()
|
|
operation.dispose()
|
|
assert.equal(calls, 1)
|
|
assert.equal(instance.closed, false)
|
|
assert.equal(sibling.closed, false)
|
|
// A completed operation must no longer be invoked by its former owner.
|
|
operation.dispose = () => {
|
|
throw new Error('retained operation')
|
|
}
|
|
instance.dispose()
|
|
assert(sibling.closed)
|
|
assert(instance.child().closed)
|
|
})
|
|
|
|
test('failed explicit release is consumed and cannot prevent later scope cleanup', () => {
|
|
const scope = new ResourceScope()
|
|
const error = new Error('release')
|
|
let calls = 0
|
|
scope.add(() => {
|
|
calls++
|
|
})
|
|
const release = scope.add(() => {
|
|
throw error
|
|
})
|
|
assert.throws(release, value => value === error)
|
|
release()
|
|
scope.dispose()
|
|
assert.equal(calls, 1)
|
|
})
|
|
|
|
test('scope owns subscriptions without removing unrelated emitter listeners', () => {
|
|
const emitter = new Emitter()
|
|
const scope = new ResourceScope()
|
|
const calls = []
|
|
const owned = value => calls.push(value)
|
|
emitter.on('value', owned).on('value', () => calls.push('external'))
|
|
scope.add(() => {
|
|
emitter.off('value', owned)
|
|
})
|
|
emitter.emit('value', 1)
|
|
scope.dispose()
|
|
emitter.emit('value', 2)
|
|
assert.deepEqual(calls, [1, 'external', 'external'])
|
|
})
|
|
|
|
test('timers finish or cancel independently and reject work after disposal', (context) => {
|
|
context.mock.timers.enable({ apis: ['setTimeout'] })
|
|
const scope = new ResourceScope()
|
|
const calls = []
|
|
timeout(scope, () => calls.push('finished'), 5)
|
|
timeout(scope, () => calls.push('cancelled'), 10)()
|
|
timeout(scope, () => calls.push('disposed'), 20)
|
|
context.mock.timers.tick(5)
|
|
scope.dispose()
|
|
const release = timeout(scope, () => calls.push('late'), 0)
|
|
release()
|
|
release()
|
|
context.mock.timers.tick(100)
|
|
assert.deepEqual(calls, ['finished'])
|
|
})
|
|
|
|
test('queued RAF is guarded after cancellation, timestamp is forwarded and completed frames detach', (context) => {
|
|
const callbacks = []
|
|
const cancelled = []
|
|
const descriptors = Object.getOwnPropertyDescriptors(globalThis)
|
|
globalThis.requestAnimationFrame = callback => callbacks.push(callback)
|
|
globalThis.cancelAnimationFrame = id => cancelled.push(id)
|
|
context.after(() => {
|
|
for (const key of ['requestAnimationFrame', 'cancelAnimationFrame']) {
|
|
if (descriptors[key])
|
|
Object.defineProperty(globalThis, key, descriptors[key])
|
|
else
|
|
delete globalThis[key]
|
|
}
|
|
})
|
|
const scope = new ResourceScope()
|
|
const calls = []
|
|
animationFrame(scope, time => calls.push(time))
|
|
callbacks[0](12)
|
|
animationFrame(scope, time => calls.push(time))()
|
|
callbacks[1](18)
|
|
animationFrame(scope, time => calls.push(time))
|
|
scope.dispose()
|
|
callbacks[2](24)
|
|
const release = animationFrame(scope, () => calls.push('late'))
|
|
release()
|
|
release()
|
|
assert.deepEqual(calls, [12])
|
|
assert.deepEqual(cancelled, [1, 2, 3])
|
|
assert.equal(callbacks.length, 3)
|
|
})
|
|
|
|
test('DOM once reentry, receiver, object listener, external abort and captured options', () => {
|
|
const scope = new ResourceScope()
|
|
const target = new EventTarget()
|
|
let calls = 0
|
|
listen(scope, target, 'value', function () {
|
|
assert.equal(this, target)
|
|
calls++
|
|
target.dispatchEvent(new Event('value'))
|
|
}, { once: true })
|
|
target.dispatchEvent(new Event('value'))
|
|
assert.equal(calls, 1)
|
|
const controller = new AbortController()
|
|
const options = { signal: controller.signal, capture: false }
|
|
const listener = { handleEvent() {
|
|
assert.equal(this, listener)
|
|
calls++
|
|
} }
|
|
listen(scope, target, 'object', listener, options)
|
|
options.capture = true
|
|
target.dispatchEvent(new Event('object'))
|
|
controller.abort()
|
|
target.dispatchEvent(new Event('object'))
|
|
scope.dispose()
|
|
const release = listen(scope, target, 'object', listener)
|
|
release()
|
|
release()
|
|
target.dispatchEvent(new Event('object'))
|
|
assert.equal(calls, 2)
|
|
})
|
|
|
|
test('request cancellation and URL ownership are scoped; caller URLs stay available', async () => {
|
|
const instance = new ResourceScope()
|
|
const operation = instance.child()
|
|
const controller = requestController(operation)
|
|
const url = objectURL(operation, new Blob(['owned']))
|
|
const external = URL.createObjectURL(new Blob(['external']))
|
|
try {
|
|
assert.equal(await (await fetch(url)).text(), 'owned')
|
|
operation.dispose()
|
|
assert(controller.signal.aborted)
|
|
await assert.rejects(fetch(url))
|
|
assert.equal(await (await fetch(external)).text(), 'external')
|
|
assert.equal(instance.closed, false)
|
|
assert(requestController(operation).signal.aborted)
|
|
}
|
|
finally {
|
|
URL.revokeObjectURL(external)
|
|
instance.dispose()
|
|
}
|
|
})
|
|
|
|
test('request controller capability absence does not prevent synchronous cleanup', (context) => {
|
|
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'AbortController')
|
|
Object.defineProperty(globalThis, 'AbortController', { configurable: true, value: undefined })
|
|
context.after(() => Object.defineProperty(globalThis, 'AbortController', descriptor))
|
|
const scope = new ResourceScope()
|
|
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)
|
|
})
|