Files
ArtPlayer/test/multiple-subtitles-lifecycle.test.js

343 lines
12 KiB
JavaScript

import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Lifecycle regressions use the repository Node runner.
import test from 'node:test'
import { multipleSubtitlesCandidate, multipleSubtitlesEnvironment, subtitleVtt } from './helpers/multiple-subtitles.js'
const candidate = await multipleSubtitlesCandidate()
const create = options => multipleSubtitlesEnvironment(candidate, options)
const options = { subtitles: [{ url: 'a.vtt', name: 'a' }] }
function deferred() {
let resolve
let reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
async function flush() {
for (let index = 0; index < 20; index++)
await Promise.resolve()
}
function empty(env) {
assert.equal(env.liveBlobs.size, 0)
for (const listeners of env.listeners.values()) assert.equal(listeners.size, 0)
}
for (const script of [false, true]) {
test(`Multiple subtitles candidate: ${script ? 'script global' : 'CommonJS'} self alias preserves identity, registration and writable metadata`, async () => {
const env = create({ script })
assert.equal(typeof env.exported, 'function')
assert.equal(env.exported.default, env.exported)
assert.equal(env.factory.default.default, env.factory)
const descriptor = Object.getOwnPropertyDescriptor(env.factory, 'default')
assert.equal(descriptor.writable, true)
assert.equal(descriptor.configurable, true)
assert.equal(descriptor.enumerable, true)
const pending = env.factory.default({})(env.art)
assert.equal(typeof pending.then, 'function')
const result = await pending
assert.equal(result.name, 'multipleSubtitles')
assert.equal(result.tracks(), undefined)
assert.equal(result.reset(), undefined)
env.factory.default = () => 'replacement'
assert.equal(env.factory.default(), 'replacement')
env.emit('destroy')
empty(env)
})
}
for (const abortController of [true, false]) {
for (const phase of ['fetch', 'body']) {
test(`Multiple subtitles candidate: destroy cancels ${phase}, AbortController=${abortController}`, async () => {
const held = deferred()
const started = deferred()
const env = create({ abortController, fetchResponse: (_url, _options, responseFor) => {
if (phase === 'fetch') {
started.resolve()
return held.promise
}
return { ...responseFor('a.vtt'), arrayBuffer() {
started.resolve()
return held.promise
} }
} })
const pending = env.factory(options)(env.art)
await started.promise
env.emit('destroy')
const result = await pending
assert.equal(result.name, 'multipleSubtitles')
assert.equal(result.reset(), undefined)
assert.equal(result.tracks(['missing']), undefined)
assert.equal(env.requestOptions[0]?.signal.aborted, abortController ? true : undefined)
assert.equal(env.initialized.length, 0)
empty(env)
held.reject(new Error('late rejected operation'))
await flush()
assert.equal(env.initialized.length, 0)
assert.equal(env.warnings.length, 0)
})
}
}
test('Multiple subtitles candidate: already destroyed hosts do not request or subscribe', async () => {
const env = create()
env.emit('destroy')
const result = await env.factory(options)(env.art)
result.reset()
assert.equal(env.requests.length, 0)
assert.equal(env.initialized.length, 0)
empty(env)
})
test('Multiple subtitles candidate: failed request preserves error identity and cancels a sibling body', async () => {
const failed = deferred()
const body = deferred()
const started = deferred()
const error = new Error('network failed')
const env = create({ fetchResponse: url => url === 'a.vtt'
? failed.promise
: { ok: true, arrayBuffer() {
started.resolve()
return body.promise
} } })
const pending = env.factory({ subtitles: [...options.subtitles, { url: 'b.vtt', name: 'b' }] })(env.art)
await started.promise
failed.reject(error)
await assert.rejects(pending, caught => caught === error)
assert.equal(env.requestOptions[1].signal.aborted, true)
body.resolve(new TextEncoder().encode(subtitleVtt('late')).buffer)
await flush()
assert.equal(env.initialized.length, 0)
empty(env)
})
test('Multiple subtitles candidate: HTTP failure rejects without reading or installing its body', async () => {
let read = false
const env = create({ fetchResponse: () => ({ ok: false, status: 404, arrayBuffer() {
read = true
} }) })
await assert.rejects(env.factory(options)(env.art), { message: 'Failed to fetch multiple subtitles: HTTP 404' })
assert.equal(read, false)
assert.equal(env.requestOptions[0].signal.aborted, true)
assert.equal(env.initialized.length, 0)
empty(env)
})
test('Multiple subtitles candidate: body failure and bad encoding release registration resources', async () => {
const error = new Error('body failed')
const env = create({ fetchResponse: () => ({ ok: true, arrayBuffer: () => Promise.reject(error) }) })
await assert.rejects(env.factory(options)(env.art), caught => caught === error)
empty(env)
const invalid = create()
await assert.rejects(invalid.factory({ subtitles: [{ ...options.subtitles[0], encoding: 'invalid-label' }] })(invalid.art), { name: 'RangeError' })
empty(invalid)
})
test('Multiple subtitles candidate: destroy releases the last URL and makes retained methods inert', async () => {
const env = create()
const result = await env.factory(options)(env.art)
env.emit('destroy')
env.emit('destroy')
assert.equal(result.reset(), undefined)
assert.equal(result.tracks(), undefined)
assert.deepEqual(env.revoked, ['blob:subtitle-1'])
assert.equal(env.initialized.length, 1)
empty(env)
})
test('Multiple subtitles candidate: allocation failure preserves the old URL and reset recovers', async () => {
let calls = 0
const error = new Error('allocation failed')
const env = create({ onCreate() {
if (++calls === 2)
throw error
} })
const result = await env.factory(options)(env.art)
assert.throws(() => result.reset(), caught => caught === error)
assert.equal(env.liveBlobs.has('blob:subtitle-1'), true)
assert.equal(env.revoked.length, 0)
result.reset()
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-2'])
env.emit('destroy')
empty(env)
})
test('Multiple subtitles candidate: synchronous initial host failure rolls back escape and frees the new URL', async () => {
const error = new Error('init failed')
const env = create({ onInit() {
throw error
} })
await assert.rejects(env.factory(options)(env.art), caught => caught === error)
assert.equal(env.art.option.subtitle.escape, true)
empty(env)
})
test('Multiple subtitles candidate: a failed synchronous replacement retains the previous resource', async () => {
let calls = 0
const error = new Error('replacement failed')
const env = create({ onInit() {
if (++calls === 2)
throw error
} })
const result = await env.factory(options)(env.art)
assert.throws(() => result.reset(), caught => caught === error)
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-1'])
result.reset()
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-3'])
env.emit('destroy')
empty(env)
})
test('Multiple subtitles candidate: host media loading does not delay registration or change void selection', async () => {
const held = deferred()
const env = create({ onInit: () => held.promise })
const result = await env.factory(options)(env.art)
assert.equal(result.name, 'multipleSubtitles')
assert.equal(result.reset(), undefined)
assert.equal(env.initialized.length, 2)
held.resolve()
await flush()
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-2'])
env.emit('destroy')
empty(env)
})
test('Multiple subtitles candidate: asynchronous rejection is observed and only frees its own URL', async () => {
const operations = [deferred(), deferred()]
let calls = 0
const env = create({ onInit: () => operations[calls++].promise })
const result = await env.factory(options)(env.art)
result.reset()
operations[0].reject(new Error('stale failure'))
await flush()
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-2'])
operations[1].reject(new Error('current failure'))
await flush()
assert.equal(env.liveBlobs.size, 0)
assert.equal(env.warnings.length, 2)
env.emit('destroy')
empty(env)
})
test('Multiple subtitles candidate: destroy in host init does not install resources again', async () => {
const env = create({ onInit() {
env.emit('destroy')
} })
const result = await env.factory(options)(env.art)
result.reset()
assert.equal(env.initialized.length, 1)
assert.deepEqual(env.revoked, ['blob:subtitle-1'])
empty(env)
})
test('Multiple subtitles candidate: reentrant selection keeps only the newest resource', async () => {
let result
let reenter = false
const env = create({ onInit() {
if (reenter) {
reenter = false
result.tracks([])
}
} })
result = await env.factory(options)(env.art)
reenter = true
result.reset()
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-3'])
assert.equal(await env.latestText(), 'WEBVTT\n\n')
env.emit('destroy')
empty(env)
})
test('Multiple subtitles candidate: unknown names throw before allocation and do not corrupt later resets', async () => {
const env = create()
const result = await env.factory(options)(env.art)
assert.throws(() => result.tracks(['missing']), { name: 'TypeError' })
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-1'])
result.reset()
assert.equal((env.utils.unescape(await env.latestText()).match(/<div/g) || []).length, 1)
env.emit('destroy')
empty(env)
})
test('Multiple subtitles candidate: destroy during allocation releases the returned URL immediately', async () => {
const env = create({ onCreate() {
env.emit('destroy')
} })
await env.factory(options)(env.art)
assert.equal(env.initialized.length, 0)
empty(env)
})
test('Multiple subtitles candidate: throwing live configuration preserves the old resource', async () => {
const env = create()
const result = await env.factory(options)(env.art)
const old = env.art.option.subtitle
const error = new Error('configuration getter failed')
Object.defineProperty(env.art.option, 'subtitle', { configurable: true, get() {
throw error
} })
assert.throws(() => result.reset(), caught => caught === error)
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-1'])
Object.defineProperty(env.art.option, 'subtitle', { value: old })
result.reset()
env.emit('destroy')
empty(env)
})
test('Multiple subtitles candidate: destroy from live configuration stops the following init', async () => {
const env = create()
const result = await env.factory(options)(env.art)
Object.defineProperty(env.art.option.subtitle, 'style', { enumerable: true, get() {
env.emit('destroy')
return {}
} })
result.reset()
assert.equal(env.initialized.length, 1)
empty(env)
})
test('Multiple subtitles candidate: reentrant successful selection survives the outer host error', async () => {
let result
let reenter = false
const error = new Error('outer init failed')
const env = create({ onInit() {
if (reenter) {
reenter = false
result.tracks([])
throw error
}
} })
result = await env.factory(options)(env.art)
reenter = true
assert.throws(() => result.reset(), caught => caught === error)
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-3'])
env.emit('destroy')
empty(env)
})
test('Multiple subtitles candidate: late host rejection after destroy is handled without another write', async () => {
const host = deferred()
const env = create({ onInit: () => host.promise })
await env.factory(options)(env.art)
env.emit('destroy')
host.reject(new Error('late host failure'))
await flush()
assert.equal(env.warnings.length, 0)
assert.equal(env.initialized.length, 1)
assert.deepEqual(env.revoked, ['blob:subtitle-1'])
empty(env)
})
test('Multiple subtitles candidate: destroy from a URL getter stops the request before dispatch', async () => {
const env = create()
const entry = { name: 'a', get url() {
env.emit('destroy')
return 'a.vtt'
} }
await env.factory({ subtitles: [entry] })(env.art)
assert.equal(env.requests.length, 0)
assert.equal(env.initialized.length, 0)
empty(env)
})