mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -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
+1
-1
@@ -49,7 +49,7 @@
|
||||
"ci:build": "yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
|
||||
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
|
||||
"typecheck": "node scripts/typecheck.mjs",
|
||||
"test:unit": "node --test test/playback.test.js test/dash-control.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js",
|
||||
"test:unit": "node --test test/playback.test.js test/dash-control.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js",
|
||||
"test": "yarn test:node && yarn test:baseline",
|
||||
"test:browser": "playwright test",
|
||||
"test:browser:install": "playwright install chromium firefox webkit",
|
||||
|
||||
@@ -48,7 +48,50 @@ installed versions. Candidate corrections have separate published-defect observa
|
||||
in test/public-behavior.test.js and test/browser/emitter.spec.js. The source type fixture
|
||||
checks event name/payload correlation, receiver types, custom channels and chain typing.
|
||||
Emitter owns registrations only; instance teardown must remove the relevant callbacks,
|
||||
not clear unrelated subscribers before destroy dispatch. Resource scopes follow in CORE-03.
|
||||
not clear unrelated subscribers before destroy dispatch. Internal resource scopes are described below.
|
||||
|
||||
## Internal resource ownership
|
||||
|
||||
`src/lifecycle/scope.ts` owns synchronous cleanup registrations. `resources.ts` supplies
|
||||
DOM listener, timeout, animation frame, request-controller and Blob URL adapters;
|
||||
it depends only on the scope type and native browser APIs. Neither file is exported
|
||||
from Artplayer or Artplayer.utils. CORE-03 introduces and tests this boundary;
|
||||
CORE-04 will connect instance construction/destruction. Existing Events, timers and
|
||||
requests have not yet been transferred, so their historical lifecycle findings remain open.
|
||||
|
||||
Create one ResourceScope for an owner and child() for a replaceable operation.
|
||||
Call operation.dispose() on completion or replacement; it detaches from its parent,
|
||||
while sibling operations stay live. Disposing an instance closes all remaining
|
||||
children. add(cleanup) returns an idempotent release function. Registrations are removed
|
||||
before invoking cleanup; disposal closes the scope first and unwinds in reverse order.
|
||||
Late registrations are released immediately. Failures do not interrupt other cleanup:
|
||||
dispose throws one ResourceCleanupError containing the original errors after unwinding.
|
||||
Explicit release throws its own error immediately and is still consumed. Owners must
|
||||
handle these errors at their lifecycle boundary without replacing a primary operation error.
|
||||
|
||||
Cleanup returns undefined, deliberately rejecting async callbacks in strict TS. An
|
||||
asynchronous media/SDK shutdown needs a separately awaited owner protocol; do not cast
|
||||
its Promise to a synchronous disposer. Emitter subscriptions use a per-owner callback
|
||||
and add(() => { emitter.off(name, callback) }); do not remove unrelated subscriptions.
|
||||
|
||||
DOM adapters preserve function receivers and object handleEvent receivers, snapshot
|
||||
capture, and release once/aborted subscriptions. They are internal registrations with
|
||||
their own wrappers, not a replacement for the public Events.proxy identity contract.
|
||||
Timer/RAF callbacks detach before invoking work and guard queued callbacks after
|
||||
cancellation. They allocate nothing when closed. Request controllers are optional on
|
||||
engines lacking AbortController: consumers still need closed/generation checks and
|
||||
must own rejected fetch Promises. Release a completed request's operation scope to
|
||||
avoid retaining its controller. objectURL only accepts a Blob it creates a URL for;
|
||||
it never adopts a caller URL. A closed scope immediately revokes the newly created URL.
|
||||
|
||||
Run `yarn test:unit` and `yarn typecheck` for scope failures, reentry, ownership,
|
||||
controlled timers, queued RAF and type rejection cases. `resource-scope.spec.js` runs
|
||||
an explicitly identified es2015 internal-source fixture in all three Playwright
|
||||
engines with native DOM, RAF, timers, fetch abort and URL access. It is not evidence
|
||||
that the player has adopted scopes or that an installed tarball exposes this API.
|
||||
Fresh fetches test URL revocation; decoded image caches are not a reliable revocation
|
||||
oracle. Keep adapter tests and owner integration tests separate and extend both when
|
||||
moving an existing resource into this boundary.
|
||||
|
||||
## Observable utility behavior
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type ResourceScope from './scope'
|
||||
|
||||
export function listen(scope: ResourceScope, target: EventTarget, name: string, callback: EventListenerOrEventListenerObject, options: AddEventListenerOptions = {}): () => void {
|
||||
const { capture = false, once = false, signal } = options
|
||||
if (scope.closed || signal?.aborted)
|
||||
return () => {}
|
||||
let release = () => {}
|
||||
function listener(this: EventTarget, event: Event) {
|
||||
if (once)
|
||||
release()
|
||||
if (scope.closed)
|
||||
return
|
||||
if (typeof callback === 'function')
|
||||
callback.call(this, event)
|
||||
else
|
||||
callback.handleEvent(event)
|
||||
}
|
||||
target.addEventListener(name, listener, options)
|
||||
release = scope.add(() => {
|
||||
try {
|
||||
target.removeEventListener(name, listener, capture)
|
||||
}
|
||||
finally {
|
||||
signal?.removeEventListener('abort', release)
|
||||
}
|
||||
})
|
||||
signal?.addEventListener('abort', release, { once: true })
|
||||
return release
|
||||
}
|
||||
|
||||
export function timeout(scope: ResourceScope, callback: () => void, delay: number): () => void {
|
||||
if (scope.closed)
|
||||
return () => {}
|
||||
let pending = true
|
||||
let release = () => {}
|
||||
const timer = setTimeout(() => {
|
||||
if (!pending)
|
||||
return
|
||||
release()
|
||||
if (!scope.closed)
|
||||
callback()
|
||||
}, delay)
|
||||
release = scope.add(() => {
|
||||
pending = false
|
||||
clearTimeout(timer)
|
||||
})
|
||||
return release
|
||||
}
|
||||
|
||||
export function animationFrame(scope: ResourceScope, callback: FrameRequestCallback): () => void {
|
||||
if (scope.closed)
|
||||
return () => {}
|
||||
let pending = true
|
||||
let release = () => {}
|
||||
const frame = requestAnimationFrame((time) => {
|
||||
if (!pending)
|
||||
return
|
||||
release()
|
||||
if (!scope.closed)
|
||||
callback(time)
|
||||
})
|
||||
release = scope.add(() => {
|
||||
pending = false
|
||||
cancelAnimationFrame(frame)
|
||||
})
|
||||
return release
|
||||
}
|
||||
|
||||
export function requestController(scope: ResourceScope): AbortController | undefined {
|
||||
if (typeof AbortController === 'undefined')
|
||||
return undefined
|
||||
const controller = new AbortController()
|
||||
scope.add(() => {
|
||||
controller.abort()
|
||||
})
|
||||
return controller
|
||||
}
|
||||
|
||||
export function objectURL(scope: ResourceScope, blob: Blob): string {
|
||||
const url = URL.createObjectURL(blob)
|
||||
scope.add(() => {
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
return url
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export type Cleanup = () => undefined
|
||||
|
||||
export class ResourceCleanupError extends Error {
|
||||
constructor(readonly errors: unknown[]) {
|
||||
super('Failed to release ArtPlayer resources')
|
||||
this.name = 'ResourceCleanupError'
|
||||
}
|
||||
}
|
||||
|
||||
export default class ResourceScope {
|
||||
private cleanups = new Set<() => void>()
|
||||
private disposed = false
|
||||
|
||||
get closed(): boolean {
|
||||
return this.disposed
|
||||
}
|
||||
|
||||
add(cleanup: Cleanup): () => void {
|
||||
let active = true
|
||||
const release = () => {
|
||||
if (!active)
|
||||
return
|
||||
active = false
|
||||
this.cleanups.delete(release)
|
||||
cleanup()
|
||||
}
|
||||
if (this.closed)
|
||||
release()
|
||||
else
|
||||
this.cleanups.add(release)
|
||||
return release
|
||||
}
|
||||
|
||||
child(): ResourceScope {
|
||||
const child = new ResourceScope()
|
||||
const release = this.add(() => {
|
||||
child.dispose()
|
||||
})
|
||||
child.add(() => {
|
||||
release()
|
||||
})
|
||||
return child
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.closed)
|
||||
return
|
||||
this.disposed = true
|
||||
const errors: unknown[] = []
|
||||
// Unwind dependencies; release removes each registration before invoking it.
|
||||
for (const release of Array.from(this.cleanups).reverse()) {
|
||||
try {
|
||||
release()
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof ResourceCleanupError)
|
||||
errors.push(...error.errors)
|
||||
else
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
if (errors.length)
|
||||
throw new ResourceCleanupError(errors)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
{
|
||||
"task": "CORE-03",
|
||||
"date": "2026-09-11",
|
||||
"node": "v24.21.0",
|
||||
"parentCommit": "362c6169987bb32c120e81ea267eeaa3bd8f63b8",
|
||||
"source": {
|
||||
"packages/artplayer/src/lifecycle/scope.ts": "e2188e8544b27cf8c7ac149e7146af628a2d6166c82b719a247adef5d85be5db",
|
||||
"packages/artplayer/src/lifecycle/resources.ts": "2ff9cb71035ac5694c92a6b01f7454890bd652ba4b7774e8973500de7f7d204c",
|
||||
"test/resource-scope.test.js": "f1b5bfb3fd863bee9493a26152aad6d110d47a8fc1f369da7e94c57d0538a123",
|
||||
"test/types/resource-scope.ts": "a8c056914e8529c871891ed672030248d359a19f8571a5ac5a5f18952ea5def0",
|
||||
"test/browser/resource-scope.spec.js": "0196bd9943c1ce2f6560f87156cd0de47606579c1a736bd46d4943179a121d3f"
|
||||
},
|
||||
"ci": {
|
||||
"command": "yarn ci:check",
|
||||
"passed": 94,
|
||||
"unit": 66,
|
||||
"engineering": 4,
|
||||
"baseline": 24,
|
||||
"sourceTypes": 15,
|
||||
"logSha256": "e4982bd5055665e0bb40dc41c2c1ba319a935bff22ef6565ad102c4403ce09b6"
|
||||
},
|
||||
"build": {
|
||||
"commands": [
|
||||
"yarn build artplayer",
|
||||
"yarn build:i18n"
|
||||
],
|
||||
"generatedGitDifference": false,
|
||||
"logs": [
|
||||
{
|
||||
"file": "core03-build.log",
|
||||
"sha256": "21ffc4ba5064d496a5c5da4a3f5eaebe9bb6de240fdb44938f2858120e52d934"
|
||||
},
|
||||
{
|
||||
"file": "core03-i18n.log",
|
||||
"sha256": "4b5a3b6f03affadff34eb605405c16cad36f9fb9c7b61e54a446148140048490"
|
||||
}
|
||||
]
|
||||
},
|
||||
"browser": {
|
||||
"command": "yarn test:browser",
|
||||
"candidateKind": "workspace-build",
|
||||
"internalFixtureKind": "internal-source-fixture",
|
||||
"stats": {
|
||||
"startTime": "2026-09-10T16:19:36.559Z",
|
||||
"duration": 28138.681,
|
||||
"expected": 78,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
},
|
||||
"internal": [
|
||||
{
|
||||
"engine": "chromium",
|
||||
"version": "153.0.8010.12",
|
||||
"platform": "win32",
|
||||
"fixture": {
|
||||
"kind": "internal-source-fixture",
|
||||
"target": "es2015",
|
||||
"sha256": "032ba327c57b8d94d005558c64bc153dfb2e1d6fcb80cf50adca0ae5cdd7f471"
|
||||
},
|
||||
"pageErrors": [],
|
||||
"controlledConsole": [
|
||||
"Failed to load resource: net::ERR_FILE_NOT_FOUND"
|
||||
],
|
||||
"controlledFailedRequests": [
|
||||
{
|
||||
"url": "http://127.0.0.1:8084/test/pattern.mp4",
|
||||
"resourceType": "fetch",
|
||||
"failure": {
|
||||
"errorText": "net::ERR_ABORTED"
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "blob:http://127.0.0.1:8084/bb671d68-fcc1-4374-ac45-06ea586b13fd",
|
||||
"resourceType": "fetch",
|
||||
"failure": {
|
||||
"errorText": "net::ERR_FILE_NOT_FOUND"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"engine": "firefox",
|
||||
"version": "155.0",
|
||||
"platform": "win32",
|
||||
"fixture": {
|
||||
"kind": "internal-source-fixture",
|
||||
"target": "es2015",
|
||||
"sha256": "032ba327c57b8d94d005558c64bc153dfb2e1d6fcb80cf50adca0ae5cdd7f471"
|
||||
},
|
||||
"pageErrors": [],
|
||||
"controlledConsole": [],
|
||||
"controlledFailedRequests": []
|
||||
},
|
||||
{
|
||||
"engine": "webkit",
|
||||
"version": "26.6",
|
||||
"platform": "win32",
|
||||
"fixture": {
|
||||
"kind": "internal-source-fixture",
|
||||
"target": "es2015",
|
||||
"sha256": "032ba327c57b8d94d005558c64bc153dfb2e1d6fcb80cf50adca0ae5cdd7f471"
|
||||
},
|
||||
"pageErrors": [],
|
||||
"controlledConsole": [
|
||||
"Failed to load resource"
|
||||
],
|
||||
"controlledFailedRequests": [
|
||||
{
|
||||
"url": "http://127.0.0.1:8084/test/pattern.mp4",
|
||||
"resourceType": "fetch",
|
||||
"failure": {
|
||||
"errorText": "Load request cancelled"
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "blob:http://127.0.0.1:8084/884f53c0-917f-4730-be3f-32b8f0d5e2b8",
|
||||
"resourceType": "fetch",
|
||||
"failure": null
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "published-only observations: blank title, NaN and once-only metadata are historical defects",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "candidate clears titles on blank chapters, zero hover and replacement",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "candidate rejects NaN and can recover after an invalid update",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "candidate releases only its listeners and DOM across multiple instances and early destruction",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "candidate ignores pending metadata initialization after immediate destroy",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "candidate deduplicates stylesheet injection while the document is loading",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "published-only observations: blank title, NaN and once-only metadata are historical defects",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "candidate clears titles on blank chapters, zero hover and replacement",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "candidate rejects NaN and can recover after an invalid update",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "candidate releases only its listeners and DOM across multiple instances and early destruction",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "candidate ignores pending metadata initialization after immediate destroy",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "candidate deduplicates stylesheet injection while the document is loading",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "published-only observations: blank title, NaN and once-only metadata are historical defects",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "candidate clears titles on blank chapters, zero hover and replacement",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "candidate rejects NaN and can recover after an invalid update",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "candidate releases only its listeners and DOM across multiple instances and early destruction",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "candidate ignores pending metadata initialization after immediate destroy",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "candidate deduplicates stylesheet injection while the document is loading",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "factory, registration, input normalization and safe title text",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "progress bars, real hover, title edges and boundary seek",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "sync update, Infinity, object identity, empty lists and invalid data",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "explicit source update and core-owned destruction",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "factory, registration, input normalization and safe title text",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "progress bars, real hover, title edges and boundary seek",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "sync update, Infinity, object identity, empty lists and invalid data",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "explicit source update and core-owned destruction",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "factory, registration, input normalization and safe title text",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "progress bars, real hover, title edges and boundary seek",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "sync update, Infinity, object identity, empty lists and invalid data",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "explicit source update and core-owned destruction",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "factory, registration, input normalization and safe title text",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "progress bars, real hover, title edges and boundary seek",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "sync update, Infinity, object identity, empty lists and invalid data",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "explicit source update and core-owned destruction",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "factory, registration, input normalization and safe title text",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "progress bars, real hover, title edges and boundary seek",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "sync update, Infinity, object identity, empty lists and invalid data",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "explicit source update and core-owned destruction",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "factory, registration, input normalization and safe title text",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "progress bars, real hover, title edges and boundary seek",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "sync update, Infinity, object identity, empty lists and invalid data",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "chapter.spec.js",
|
||||
"title": "explicit source update and core-owned destruction",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "emitter.spec.js",
|
||||
"title": "published: emitter event names and once across nested dispatch",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "emitter.spec.js",
|
||||
"title": "candidate: emitter event names and once across nested dispatch",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "emitter.spec.js",
|
||||
"title": "published: emitter event names and once across nested dispatch",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "emitter.spec.js",
|
||||
"title": "candidate: emitter event names and once across nested dispatch",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "emitter.spec.js",
|
||||
"title": "published: emitter event names and once across nested dispatch",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "emitter.spec.js",
|
||||
"title": "candidate: emitter event names and once across nested dispatch",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "playback.spec.js",
|
||||
"title": "published: real playback, pause, seek, switch and destroy",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "playback.spec.js",
|
||||
"title": "candidate: real playback, pause, seek, switch and destroy",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "playback.spec.js",
|
||||
"title": "controlled HTTP media failure emits a real video error",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "playback.spec.js",
|
||||
"title": "Range, media identity and docs candidate routes",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "playback.spec.js",
|
||||
"title": "published: real playback, pause, seek, switch and destroy",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "playback.spec.js",
|
||||
"title": "candidate: real playback, pause, seek, switch and destroy",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "playback.spec.js",
|
||||
"title": "controlled HTTP media failure emits a real video error",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "playback.spec.js",
|
||||
"title": "Range, media identity and docs candidate routes",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "playback.spec.js",
|
||||
"title": "published: real playback, pause, seek, switch and destroy",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "playback.spec.js",
|
||||
"title": "candidate: real playback, pause, seek, switch and destroy",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "playback.spec.js",
|
||||
"title": "controlled HTTP media failure emits a real video error",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "playback.spec.js",
|
||||
"title": "Range, media identity and docs candidate routes",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "resource-scope.spec.js",
|
||||
"title": "internal resource scope: native DOM, RAF, timer, fetch abort and URL release",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "resource-scope.spec.js",
|
||||
"title": "internal resource scope: native DOM, RAF, timer, fetch abort and URL release",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "resource-scope.spec.js",
|
||||
"title": "internal resource scope: native DOM, RAF, timer, fetch abort and URL release",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "utils.spec.js",
|
||||
"title": "published: subtitle object URL contains real VTT data",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "utils.spec.js",
|
||||
"title": "published: download starts and removes its temporary anchor",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "utils.spec.js",
|
||||
"title": "candidate: subtitle object URL contains real VTT data",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "utils.spec.js",
|
||||
"title": "candidate: download starts and removes its temporary anchor",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "utils.spec.js",
|
||||
"title": "candidate download releases the anchor even when click fails",
|
||||
"engine": "chromium",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "utils.spec.js",
|
||||
"title": "published: subtitle object URL contains real VTT data",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "utils.spec.js",
|
||||
"title": "published: download starts and removes its temporary anchor",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "utils.spec.js",
|
||||
"title": "candidate: subtitle object URL contains real VTT data",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "utils.spec.js",
|
||||
"title": "candidate: download starts and removes its temporary anchor",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "utils.spec.js",
|
||||
"title": "candidate download releases the anchor even when click fails",
|
||||
"engine": "firefox",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "utils.spec.js",
|
||||
"title": "published: subtitle object URL contains real VTT data",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "utils.spec.js",
|
||||
"title": "published: download starts and removes its temporary anchor",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "utils.spec.js",
|
||||
"title": "candidate: subtitle object URL contains real VTT data",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "utils.spec.js",
|
||||
"title": "candidate: download starts and removes its temporary anchor",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
},
|
||||
{
|
||||
"file": "utils.spec.js",
|
||||
"title": "candidate download releases the anchor even when click fails",
|
||||
"engine": "webkit",
|
||||
"status": "passed",
|
||||
"retry": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"firstFailure": {
|
||||
"stats": {
|
||||
"startTime": "2026-09-10T16:15:26.658Z",
|
||||
"duration": 33097.064,
|
||||
"expected": 77,
|
||||
"skipped": 0,
|
||||
"unexpected": 1,
|
||||
"flaky": 0
|
||||
},
|
||||
"engine": "firefox",
|
||||
"assertion": "revokedRejected expected true, received false using an already decoded image",
|
||||
"resolution": "Replace image decoding with fresh fetch before and after revocation; assert exact TypeError. Also abort an in-flight native fetch.",
|
||||
"reportSha256": "8e2bbafe70e81957f37501901957598b3a88d4e5f272dcdaaedb56839f59118a"
|
||||
},
|
||||
"limits": [
|
||||
"No player constructor/destroy integration until CORE-04",
|
||||
"No package install rerun for this currently unreferenced internal module",
|
||||
"No remote CI, device or SDK validation in this task"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# CORE-03:内部资源作用域
|
||||
|
||||
## 结构与契约
|
||||
|
||||
新增核心 lifecycle/scope.ts 与 resources.ts:前者管理同步清理、父子作用域及异常汇总;
|
||||
后者封装 DOM 监听、定时器、RAF、AbortController 与自有 Blob URL。没有新依赖,
|
||||
没有把内部能力导出到 Artplayer/utils 或要求旧插件依赖它。
|
||||
|
||||
实例可持有长期 scope,切源、字幕请求等单次操作使用 child。子作用域结束会脱离父登记,
|
||||
不会释放兄弟操作。清理先关闭作用域,再逆序释放;释放登记在调用前移除,重复和重入安全。
|
||||
单项失败不中断其他清理,最后统一抛出 ResourceCleanupError 并保留原始错误。
|
||||
显式 release 的错误直接传播,该登记仍只消费一次;后续所有者需在生命周期边界处理。
|
||||
|
||||
Cleanup 的返回类型为 undefined,严格拒绝 async/Promise 清理,避免同步 destroy 丢失异步失败。
|
||||
异步媒体/SDK 关闭将由对应包明确等待,不伪装成同步回收。Emitter 订阅用独立 callback 加入
|
||||
scope,并通过原 off API 释放;实例和操作各自只清理拥有的资源。
|
||||
|
||||
监听保留函数/对象接收者、固定 capture、处理 once 重入和外部 abort;计时器/RAF 完成后脱离
|
||||
登记,取消后即使已排队的回调被调用也不会工作。缺少 AbortController 时返回 undefined,
|
||||
请求所有者必须继续防止过期回调。Blob URL 只释放本 helper 创建的 URL,不接管用户传入 URL。
|
||||
|
||||
对应 API-04/05/07/10:这是内部边界建立,不改变现有公开行为。CORE-04 才接入播放器的
|
||||
构造/销毁;当前 Events、历史 timer/request 尚未迁移,BASE-LIFE-04/05 与 BASE-PERF-01
|
||||
继续开放,不能把本任务的 scope 测试写成播放器泄漏已修复。
|
||||
|
||||
## 验证与维护
|
||||
|
||||
- test:unit 加入 resource-scope.test.js,共九项资源测试:异常汇总、重入、late add、
|
||||
子作用域脱离、独立释放失败、订阅归属、受控 timer、已排队 RAF、DOM/abort/URL 与能力缺失。
|
||||
- test/types/resource-scope.ts 含四个必须拒绝的类型用例;全部严格源类型和消费者检查通过。
|
||||
- yarn ci:check:66 项单元、4 项工程、24 项冻结基线,共 94 项通过,无跳过。
|
||||
- 三浏览器 78 项通过;其中 3 项通过明确标注的 es2015 内部源码 fixture 验证原生 DOM、
|
||||
RAF、timer、进行中的 fetch 取消和 Blob URL 撤销。其余为既有发布/工作区播放与插件回归。
|
||||
源码 fixture 不冒充安装 tarball;内部代码尚未由公开入口引用。
|
||||
- 负向控制只允许本 case 的精确 URL、fetch 类型及已观察的引擎错误;其他网络错误不被忽略。
|
||||
- yarn build artplayer 与 yarn build:i18n 通过,生成产物无 Git 差异。本次未重复执行安装打包;
|
||||
没有把上一任务的打包证据冒充本次新执行。
|
||||
|
||||
首次浏览器运行 77 通过、Firefox 1 失败:撤销后的 URL 仍能复用已解码图片,说明图片缓存
|
||||
不是 URL 可用性的可靠判断。测试改为撤销前读出内容、撤销后 fresh fetch 精确 TypeError;
|
||||
同时验证进行中的 fetch 返回 AbortError。没有修改生产实现去迎合缓存,也没有重试或跳过。
|
||||
最初 Node RAF mock 误用了不存在的方法创建选项,已改为显式安装并恢复测试全局;生产不打补丁。
|
||||
执行指纹、首次失败摘要和最终状态见 [验证报告](../baselines/resource-scope-validation.json)。
|
||||
|
||||
[核心架构](../../packages/artplayer/ARCHITECTURE.md) 维护所有权、错误协议、内部/公开边界与修改入口。
|
||||
下一项 CORE-04 需接入实例登记、处理构造失败/重复销毁并补真实播放器回归,再按各任务迁移
|
||||
具体操作资源。本任务独立提交;回退提交即可恢复文件、脚本和文档。无推送、版本变更或发布。
|
||||
+3
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
|
||||
|
||||
状态:todo 176 / doing 0 / blocked 0 / done 38 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
状态:todo 175 / doing 0 / blocked 0 / done 39 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
|
||||
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| CORE-01 | artplayer<br>迁移纯工具并冻结导出 | ENG-03, ENG-04, ENG-06, PILOT-01 | utils/time/property/format/file/error/subtitle 的分批 TS 迁移 | Artplayer.utils 导出/参数/绑定保持,纯逻辑与类型测试通过 | M | done |
|
||||
| CORE-02 | artplayer<br>迁移内部 Emitter | CORE-01, BASE-03 | 带类型事件映射的原 Emitter 实现 | ctx、once/off、重入/异常和链式返回保持 | H | done |
|
||||
| CORE-03 | artplayer<br>建立内部资源作用域 | CORE-02 | 监听/订阅/定时器/RAF/请求/URL 的资源登记 | 实例与操作作用域分离,单项清理失败不阻断其他资源 | H | todo |
|
||||
| CORE-03 | artplayer<br>建立内部资源作用域 | CORE-02 | 监听/订阅/定时器/RAF/请求/URL 的资源登记 | 实例与操作作用域分离,单项清理失败不阻断其他资源 | H | done |
|
||||
| CORE-04 | artplayer<br>修复初始化与销毁生命周期 | CORE-03, ENG-05, BASE-07 | 构造失败回收、重复/重入 destroy、instances 修复 | 事件顺序、removeHtml、多实例和销毁中异步有真实回归;处理 baselines/lifecycle-coverage.md 的 BASE-LIFE-04/05 并增加候选正向回归;协调 BASE-PERF-01 销毁后定时器与回调归属 | H | todo |
|
||||
| CORE-05 | artplayer<br>区分输入配置和内部配置 | CORE-01, ENG-04 | Option/ResolvedOption、默认值与校验整理 | 配置合并/错误时机不变,JS 输入仍运行时校验 | M | todo |
|
||||
| CORE-06 | artplayer<br>建立内部媒体与宿主类型 | CORE-05 | 原生 video、canvas shim、UI host 类型 | 不更改公开 art.video 用法,不以 HTMLVideoElement 断言掩盖 shim | M | todo |
|
||||
@@ -437,6 +437,7 @@
|
||||
- PILOT-01: [记录](changes/2026-09-10-PILOT-01-chapter.md) [记录](baselines/pilot-validation.json)
|
||||
- CORE-01: [记录](changes/2026-09-10-CORE-01-typed-utils.md) [记录](baselines/core-utils-validation.json)
|
||||
- CORE-02: [记录](changes/2026-09-11-CORE-02-typed-emitter.md) [记录](baselines/emitter-validation.json)
|
||||
- CORE-03: [记录](changes/2026-09-11-CORE-03-resource-scope.md) [记录](baselines/resource-scope-validation.json)
|
||||
- PKG-CHAPTER-01: [记录](changes/2026-09-10-PKG-CHAPTER-01-contract.md) [记录](baselines/chapter-contract.md)
|
||||
- PKG-CHAPTER-02: [记录](changes/2026-09-10-PKG-CHAPTER-02-tests.md) [记录](baselines/chapter-validation.json)
|
||||
- PKG-CHAPTER-03: [记录](changes/2026-09-10-PKG-CHAPTER-03-typescript-modules.md) [记录](baselines/chapter-migration-validation.json)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 进度与证据
|
||||
|
||||
## 当前实施:CORE-03 内部资源作用域完成
|
||||
|
||||
新增两个核心 TS 文件,明确同步清理、实例/操作作用域、异常汇总及原生资源适配。94 项 Node/基线与 78 项三浏览器检查通过;首次 Firefox 图片缓存误判已保留并改为 fresh fetch 验证。见 [交付](changes/2026-09-11-CORE-03-resource-scope.md)。当前 214 项,39 完成、175 待办;核心十个 TS 文件、chapter 五个。作用域尚未接入播放器,旧生命周期风险仍开放;下一项 CORE-04 直接改构造与销毁。
|
||||
|
||||
## 当前实施:CORE-02 Emitter TS 迁移完成
|
||||
|
||||
事件映射、payload/ctx 和链式类型已加入;九组普通契约保持,另修复原型事件名与嵌套快照重复 once。85 项 Node/基线、75 项三浏览器、三格式各 29 项 Emitter 检查通过;严格打包 27 项运行时/五组类型零诊断。见 [交付](changes/2026-09-11-CORE-02-typed-emitter.md)。当前 214 项,38 完成、176 待办。核心八个 TS 文件、chapter 五个 TS 文件;下一项 CORE-03 内部资源作用域。
|
||||
|
||||
+5
-2
@@ -789,11 +789,14 @@
|
||||
"dependsOn": [
|
||||
"CORE-02"
|
||||
],
|
||||
"status": "todo",
|
||||
"status": "done",
|
||||
"risk": "H",
|
||||
"deliverable": "监听/订阅/定时器/RAF/请求/URL 的资源登记",
|
||||
"acceptance": "实例与操作作用域分离,单项清理失败不阻断其他资源",
|
||||
"evidence": []
|
||||
"evidence": [
|
||||
"changes/2026-09-11-CORE-03-resource-scope.md",
|
||||
"baselines/resource-scope-validation.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "CORE-04",
|
||||
|
||||
@@ -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