mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-21 12:41:20 -08:00
refactor(core): CORE-03 add typed resource scopes and cleanup adapters
This commit is contained in:
1 parent
362c616998
commit
3e38dd0d19
12 files changed
+1242
-6
No files matched your search
@@ -0,0 +1,75 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { build } from 'esbuild'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
// Explicit internal-source fixture, not evidence of an installed public API.
|
||||
const fixture = await build({
|
||||
stdin: { contents: 'export { default as ResourceScope } from \'./packages/artplayer/src/lifecycle/scope\'; export * from \'./packages/artplayer/src/lifecycle/resources\'', resolveDir: process.cwd() },
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: 'iife',
|
||||
globalName: 'ResourceFixture',
|
||||
target: 'es2015',
|
||||
})
|
||||
|
||||
test('internal resource scope: native DOM, RAF, timer, fetch abort and URL release', async ({ page, browserName, diagnostics }, testInfo) => {
|
||||
await testInfo.attach('internal-source-fixture', { contentType: 'application/json', body: JSON.stringify({ kind: 'internal-source-fixture', target: 'es2015', sha256: createHash('sha256').update(fixture.outputFiles[0].text).digest('hex') }) })
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
await page.addScriptTag({ content: fixture.outputFiles[0].text })
|
||||
const result = await page.evaluate(async () => {
|
||||
const { ResourceScope, listen, timeout, animationFrame, requestController, objectURL } = window.ResourceFixture
|
||||
const instance = new ResourceScope()
|
||||
const operation = instance.child()
|
||||
const button = document.createElement('button')
|
||||
document.body.append(button)
|
||||
const calls = []
|
||||
const options = { capture: true }
|
||||
listen(operation, button, 'click', function () {
|
||||
calls.push(this === button ? 'click' : 'wrong-receiver')
|
||||
}, options)
|
||||
options.capture = false
|
||||
button.click()
|
||||
const text = await (await fetch(objectURL(operation, new Blob(['scoped'])))).text()
|
||||
const revoked = objectURL(operation, new Blob(['revoked']))
|
||||
const before = await (await fetch(revoked)).text()
|
||||
const controller = requestController(operation)
|
||||
await new Promise(resolve => animationFrame(operation, (time) => {
|
||||
calls.push(typeof time)
|
||||
resolve()
|
||||
}))
|
||||
await new Promise(resolve => timeout(operation, () => {
|
||||
calls.push('timer')
|
||||
resolve()
|
||||
}, 0))
|
||||
animationFrame(operation, () => calls.push('cancelled-frame'))
|
||||
timeout(operation, () => calls.push('cancelled-timer'), 0)
|
||||
const pendingRequest = fetch('/test/pattern.mp4', { signal: controller.signal }).then(() => 'resolved', error => error.name)
|
||||
operation.dispose()
|
||||
button.click()
|
||||
// Await independent native scheduling turns so cancelled callbacks would be observable.
|
||||
await new Promise(resolve => requestAnimationFrame(resolve))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const abortName = await pendingRequest
|
||||
// Image caches may retain decoded pixels; a fresh fetch tests URL availability.
|
||||
let revokedError
|
||||
try {
|
||||
await fetch(revoked)
|
||||
}
|
||||
catch (error) { revokedError = error.name }
|
||||
button.remove()
|
||||
const instanceOpen = !instance.closed
|
||||
instance.dispose()
|
||||
return { text, before, calls, aborted: controller.signal.aborted, abortName, revokedError, instanceOpen, revoked, requestURL: new URL('/test/pattern.mp4', location.href).href }
|
||||
})
|
||||
const { revoked, requestURL, ...behavior } = result
|
||||
expect(behavior).toEqual({ text: 'scoped', before: 'revoked', calls: ['click', 'number', 'timer'], aborted: true, abortName: 'AbortError', revokedError: 'TypeError', instanceOpen: true })
|
||||
const expectedFailures = {
|
||||
chromium: [{ url: requestURL, resourceType: 'fetch', failure: { errorText: 'net::ERR_ABORTED' } }, { url: revoked, resourceType: 'fetch', failure: { errorText: 'net::ERR_FILE_NOT_FOUND' } }],
|
||||
firefox: [],
|
||||
webkit: [{ url: requestURL, resourceType: 'fetch', failure: { errorText: 'Load request cancelled' } }, { url: revoked, resourceType: 'fetch', failure: null }],
|
||||
}
|
||||
for (const failure of diagnostics.failedRequests)
|
||||
expect(expectedFailures[browserName]).toContainEqual(failure)
|
||||
const expectedConsole = { chromium: ['Failed to load resource: net::ERR_FILE_NOT_FOUND'], firefox: [], webkit: ['Failed to load resource'] }
|
||||
expect(diagnostics.consoleErrors).toEqual(expectedConsole[browserName])
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
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, 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 }])),
|
||||
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()
|
||||
timeout(scope, () => calls.push('late'), 0)
|
||||
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)
|
||||
animationFrame(scope, () => calls.push('late'))
|
||||
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()
|
||||
listen(scope, target, 'object', listener)
|
||||
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()
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { animationFrame, listen, objectURL, requestController, timeout } from '../../packages/artplayer/src/lifecycle/resources'
|
||||
import ResourceScope from '../../packages/artplayer/src/lifecycle/scope'
|
||||
|
||||
const instance = new ResourceScope()
|
||||
const operation = instance.child()
|
||||
operation.add(() => {
|
||||
document.body.removeAttribute('data-test')
|
||||
})
|
||||
timeout(operation, () => {}, 10)
|
||||
animationFrame(operation, time => time.toFixed())
|
||||
listen(operation, document, 'click', { handleEvent(event) {
|
||||
event.preventDefault()
|
||||
} })
|
||||
const url: string = objectURL(operation, new Blob())
|
||||
const signal: AbortSignal | undefined = requestController(operation)?.signal
|
||||
void [url, signal]
|
||||
// @ts-expect-error Cleanup is synchronous; async work needs a separately awaited owner.
|
||||
operation.add(async () => {})
|
||||
// @ts-expect-error A cleanup cannot return a timer handle.
|
||||
operation.add(() => 1)
|
||||
// @ts-expect-error Delays must be numeric.
|
||||
timeout(operation, () => {}, '10')
|
||||
// @ts-expect-error Object URLs require a Blob.
|
||||
objectURL(operation, 'existing-url')
|
||||
instance.dispose()
|
||||
Reference in new issue
Block a user