Files
ArtPlayer/test/resource-scope.test.js

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)
})