test(multiple-subtitles): [PKG-MULTI-SUB-02] reproduce failures and native resource leaks

This commit is contained in:
Harvey Zhao committed 2026-09-13 10:38:32 +08:00
1 parent 5a0d94cb89
commit 36cd151095
12 files changed
+5399 -19

No files matched your search

@@ -0,0 +1,136 @@
import { hash } from '../../refactor/scripts/releases.mjs'
import { multipleSubtitlesHistorical } from '../helpers/multiple-subtitles.js'
import { expect, test } from './fixtures.js'
const implementation = (await multipleSubtitlesHistorical()).find(item => item.name === 'published-1.2.0-main')
const srt = text => `1\n00:00:00,000 --> 00:00:08,000\n${text}\n`
const vtt = 'WEBVTT\n\n00:00.000 --> 00:08.000\nCached caption\n'
async function prepare(page, core) {
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: implementation.code })
await page.evaluate(() => {
window.createPlayer('/test/pattern.mp4')
window.multipleProbe = { initialized: [], revoked: [], errors: [], signal: false, settled: false }
window.multipleNativeFetch = window.fetch.bind(window)
window.fetch = (url, options) => {
if (url === '/test/multiple-held.vtt')
window.multipleProbe.signal = Boolean(options?.signal)
return window.multipleNativeFetch(url, options)
}
const revoke = URL.revokeObjectURL.bind(URL)
URL.revokeObjectURL = (url) => {
window.multipleProbe.revoked.push(url)
revoke(url)
}
const init = window.art.subtitle.init.bind(window.art.subtitle)
window.art.subtitle.init = (option) => {
window.multipleProbe.initialized.push({ url: option.url, destroyed: window.art.isDestroy })
const pending = init(option)
// Observe the real host rejection without replacing its behavior or return value.
Promise.resolve(pending).catch(error => window.multipleProbe.errors.push({ name: error.name, message: error.message }))
return pending
}
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
async function evidence(page, testInfo, core, observation) {
const probe = await page.evaluate(() => window.multipleProbe)
expect(probe.errors).toEqual([])
await testInfo.attach('multiple-subtitles-history', { body: JSON.stringify({ core, implementation: { name: implementation.name, sha256: hash(implementation.code) }, observation, probe }), contentType: 'application/json' })
}
for (const core of ['published', 'candidate']) {
test(`Multiple subtitles historical ${core} core: real SRT captions select, clear and reset`, async ({ page }, testInfo) => {
await page.route('**/test/multiple-en.srt', route => route.fulfill({ body: srt('English'), contentType: 'text/plain' }))
await page.route('**/test/multiple-jp.srt', route => route.fulfill({ body: srt('日本語'), contentType: 'text/plain' }))
await prepare(page, core)
await page.evaluate(async () => {
window.multipleResult = await window.art.plugins.add(window.artplayerPluginMultipleSubtitles({ subtitles: [{ name: 'en', url: '/test/multiple-en.srt' }, { name: 'jp', url: '/test/multiple-jp.srt' }] }))
})
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(2)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.locator('#pause').click()
await expect(page.locator('.art-subtitle-en')).toHaveText('English')
await expect(page.locator('.art-subtitle-jp')).toHaveText('日本語')
await page.evaluate(() => window.art.plugins.multipleSubtitles.tracks(['jp']))
await expect(page.locator('.art-subtitle-en')).toHaveCount(0)
await expect(page.locator('.art-subtitle-jp')).toHaveText('日本語')
await page.evaluate(() => window.art.plugins.multipleSubtitles.tracks([]))
await expect(page.locator('.art-subtitle-jp')).toHaveCount(0)
await page.evaluate(() => window.art.plugins.multipleSubtitles.reset())
await expect(page.locator('.art-subtitle-en')).toHaveText('English')
await expect(page.locator('.art-subtitle-jp')).toHaveText('日本語')
await page.evaluate(() => window.art.destroy(false))
await evidence(page, testInfo, core, 'Native SRT conversion, track cue loading, real playback and DOM text selection/reset passed; this does not verify all CSS/device combinations.')
})
test(`Multiple subtitles historical ${core} core: pending native fetch survives destroy and creates a retained Blob URL`, async ({ page }, testInfo) => {
let release
let requested
const started = new Promise((resolve) => {
requested = resolve
})
const pending = new Promise((resolve) => {
release = resolve
})
await page.route('**/test/multiple-held.vtt', async (route) => {
requested()
await pending
await route.fulfill({ body: vtt, contentType: 'text/vtt' })
})
try {
await prepare(page, core)
await page.evaluate(() => {
window.multiplePending = window.artplayerPluginMultipleSubtitles({ subtitles: [{ url: '/test/multiple-held.vtt', name: 'held' }] })(window.art).then((result) => {
window.multipleResult = result
window.multipleProbe.settled = true
})
})
await started
await page.evaluate(() => window.art.destroy(false))
expect(await page.evaluate(() => ({ settled: window.multipleProbe.settled, signal: window.multipleProbe.signal, initialized: window.multipleProbe.initialized.length }))).toEqual({ settled: false, signal: false, initialized: 0 })
release()
await page.evaluate(() => window.multiplePending)
const state = await page.evaluate(async () => {
const [late] = window.multipleProbe.initialized
const text = await (await window.multipleNativeFetch(late.url)).text()
return { destroyedAtInit: late.destroyed, retained: !window.multipleProbe.revoked.includes(late.url), text }
})
expect(state.destroyedAtInit).toBe(true)
expect(state.retained).toBe(true)
expect(state.text).toContain('Cached caption')
await evidence(page, testInfo, core, state)
}
finally { release() }
})
test(`Multiple subtitles historical ${core} core: destroy retains the plugin URL and reset allocates another one`, async ({ page }, testInfo) => {
await page.route('**/test/multiple-ready.vtt', route => route.fulfill({ body: vtt, contentType: 'text/vtt' }))
await prepare(page, core)
await page.evaluate(async () => {
window.multipleResult = await window.artplayerPluginMultipleSubtitles({ subtitles: [{ url: '/test/multiple-ready.vtt', name: 'ready' }] })(window.art)
})
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(1)
const state = await page.evaluate(async () => {
const first = window.multipleProbe.initialized[0].url
window.art.destroy(false)
const retainedText = await (await window.multipleNativeFetch(first)).text()
const beforeReset = !window.multipleProbe.revoked.includes(first)
const result = window.multipleResult.reset()
const late = window.multipleProbe.initialized.at(-1)
const lateText = await (await window.multipleNativeFetch(late.url)).text()
return { beforeReset, retainedText, returnsUndefined: result === undefined, lateAfterDestroy: late.destroyed, newURL: late.url !== first, priorRevokedByReset: window.multipleProbe.revoked.includes(first), lateText }
})
expect(state.beforeReset).toBe(true)
expect(state.retainedText).toContain('Cached caption')
expect(state.returnsUndefined).toBe(true)
expect(state.lateAfterDestroy).toBe(true)
expect(state.newURL).toBe(true)
expect(state.priorRevokedByReset).toBe(true)
expect(state.lateText).toContain('Cached caption')
await evidence(page, testInfo, core, state)
})
}
+43 -7
View File
@@ -28,12 +28,16 @@ export async function multipleSubtitlesHistorical() {
return implementations
}
export function multipleSubtitlesEnvironment(implementation, { script = false, responses = {} } = {}) {
export function multipleSubtitlesEnvironment(implementation, { script = false, responses = {}, fetchResponse, coreUtils, onInit, onCreate } = {}) {
const requests = []
const requestOptions = []
const blobs = new Map()
const liveBlobs = new Map()
const revoked = []
const initialized = []
const initStates = []
const converted = []
const listeners = new Map()
const module = { exports: {} }
const utils = {
getExt: url => url.split('.').pop(),
@@ -46,11 +50,32 @@ export function multipleSubtitlesEnvironment(implementation, { script = false, r
converted.push(['ass', text])
return subtitleVtt('ASS')
},
...coreUtils,
}
const art = {
isDestroy: false,
constructor: { utils },
option: { subtitle: { escape: true, style: { color: 'red' } } },
subtitle: { init(option) { initialized.push(option) } },
subtitle: { init(option) {
initialized.push(option)
initStates.push({ destroyed: art.isDestroy })
return onInit?.(option)
} },
on(name, callback) {
if (!listeners.has(name))
listeners.set(name, new Set())
listeners.get(name).add(callback)
return art
},
off(name, callback) {
listeners.get(name)?.delete(callback)
return art
},
}
function responseFor(url) {
const value = responses[url] ?? subtitleVtt(url)
const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value
return { ok: true, status: 200, arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) }
}
const context = {
Blob,
@@ -58,16 +83,21 @@ export function multipleSubtitlesEnvironment(implementation, { script = false, r
console: { log() {}, warn() {}, error() {} },
URL: {
createObjectURL(blob) {
onCreate?.(blob)
const url = `blob:subtitle-${blobs.size + 1}`
blobs.set(url, blob)
liveBlobs.set(url, blob)
return url
},
revokeObjectURL(url) { revoked.push(url) },
revokeObjectURL(url) {
revoked.push(url)
liveBlobs.delete(url)
},
},
fetch: async (url) => {
fetch: async (url, options) => {
requests.push(url)
const bytes = new TextEncoder().encode(responses[url] ?? subtitleVtt(url))
return { ok: true, arrayBuffer: async () => bytes.buffer }
requestOptions.push(options)
return fetchResponse ? fetchResponse(url, options, responseFor) : responseFor(url)
},
}
context.window = context
@@ -79,5 +109,11 @@ export function multipleSubtitlesEnvironment(implementation, { script = false, r
const exported = script ? context.artplayerPluginMultipleSubtitles : module.exports
const factory = exported?.default || exported
const latestText = async () => blobs.get(initialized.at(-1).url).text()
return { factory, exported, art, utils, requests, blobs, revoked, initialized, converted, latestText }
function emit(name, ...args) {
if (name === 'destroy')
art.isDestroy = true
for (const callback of [...(listeners.get(name) || [])])
callback(...args)
}
return { factory, exported, art, utils, requests, requestOptions, blobs, liveBlobs, revoked, initialized, initStates, converted, listeners, emit, responseFor, latestText }
}
+260
View File
@@ -0,0 +1,260 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
// eslint-disable-next-line test/no-import-node-test -- Historical failures use the repository Node runner.
import test from 'node:test'
import { loadModules } from './helpers/load.js'
import { multipleSubtitlesEnvironment, multipleSubtitlesHistorical, subtitleVtt } from './helpers/multiple-subtitles.js'
const implementations = await multipleSubtitlesHistorical()
const coreUtils = await loadModules({
getExt: { file: 'packages/artplayer/src/utils/file', name: 'getExt' },
srtToVtt: { file: 'packages/artplayer/src/utils/subtitle', name: 'srtToVtt' },
assToVtt: { file: 'packages/artplayer/src/utils/subtitle', name: 'assToVtt' },
})
const subtitle = (url = 'a.vtt', name = 'a') => ({ url, name })
const options = (...subtitles) => ({ subtitles })
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 step = 0; step < 6; step++)
await Promise.resolve()
}
const cues = rows => `WEBVTT\n\n${rows.map(([start, end, text]) => `00:0${start}.000 --> 00:0${end}.000\n${text}\n`).join('\n')}`
const starts = text => [...text.matchAll(/^(\d{2}:\d{2}\.\d{3}) -->/gm)].map(match => match[1])
for (const implementation of implementations) {
const create = setup => multipleSubtitlesEnvironment(implementation, setup)
const oldMerge = implementation.version === '1.0.0'
test(`Multiple-subtitles ${implementation.name} failure baseline: actual core converters accept SRT, ASS and encoded VTT`, async () => {
const responses = {
'srt.SRT?track=1': '1\n00:00:00,000 --> 00:00:02,000\nSRT text\n',
'a.ass': 'Dialogue: 0,0:00:00.00,0:00:02.00,Default,,0,0,0,,ASS text',
'latin.vtt': Buffer.from(subtitleVtt('café'), 'latin1'),
'utf16.vtt': Buffer.from(subtitleVtt('日本語'), 'utf16le'),
}
const env = create({ responses, coreUtils })
await env.factory(options(subtitle('srt.SRT?track=1', 'srt'), subtitle('a.ass', 'ass'), { ...subtitle('latin.vtt', 'latin'), encoding: 'windows-1252' }, { ...subtitle('utf16.vtt', 'utf16'), encoding: 'utf-16le' }))(env.art)
const text = env.utils.unescape(await env.latestText())
for (const expected of ['SRT text', 'ASS text', 'café', '日本語'])
assert(text.includes(expected))
assert.equal(starts(text).length, oldMerge ? 1 : 4)
})
test(`Multiple-subtitles ${implementation.name} failure baseline: response order does not reorder tracks`, async () => {
const first = deferred()
const second = deferred()
const env = create({ fetchResponse: url => url === 'a.vtt' ? first.promise : second.promise })
const pending = env.factory(options(subtitle(), subtitle('b.vtt', 'b')))(env.art)
second.resolve(env.responseFor('b.vtt'))
await flush()
assert.equal(env.initialized.length, 0)
first.resolve(env.responseFor('a.vtt'))
await pending
const text = env.utils.unescape(await env.latestText())
assert(text.indexOf('art-subtitle-a') < text.indexOf('art-subtitle-b'))
})
test(`Multiple-subtitles ${implementation.name} failure baseline: fetch rejection preserves error while sibling work continues`, async () => {
const error = new Error('fetch failed')
const sibling = deferred()
let bodyRead = false
const env = create({ fetchResponse: url => url === 'a.vtt' ? Promise.reject(error) : sibling.promise })
await assert.rejects(env.factory(options(subtitle(), subtitle('b.vtt', 'b')))(env.art), caught => caught === error)
assert.equal(env.initialized.length, 0)
assert.deepEqual(env.requestOptions, [undefined, undefined])
sibling.resolve({ arrayBuffer: async () => {
bodyRead = true
return new TextEncoder().encode(subtitleVtt('late')).buffer
} })
await flush()
assert.equal(bodyRead, true)
assert.equal(env.blobs.size, 0)
})
test(`Multiple-subtitles ${implementation.name} failure baseline: HTTP errors with parseable bodies still mount`, async () => {
const env = create({ fetchResponse: (_url, _options, responseFor) => ({ ...responseFor('a.vtt'), ok: false, status: 404 }) })
await env.factory(options(subtitle()))(env.art)
assert.equal(env.initialized.length, 1)
assert((await env.latestText()).includes('a.vtt'))
})
test(`Multiple-subtitles ${implementation.name} failure baseline: body and decoder errors reject before allocating URLs`, async () => {
const error = new Error('body failed')
const env = create({ fetchResponse: () => ({ arrayBuffer: async () => {
throw error
} }) })
await assert.rejects(env.factory(options(subtitle()))(env.art), caught => caught === error)
assert.equal(env.blobs.size, 0)
const invalid = create()
await assert.rejects(invalid.factory(options({ ...subtitle(), encoding: 'invalid-encoding-label' }))(invalid.art), { name: 'RangeError' })
assert.equal(invalid.blobs.size, 0)
assert.equal(invalid.initialized.length, 0)
})
test(`Multiple-subtitles ${implementation.name} failure baseline: missing names throw before replacing the active URL`, async () => {
const env = create()
const result = await env.factory(options(subtitle()))(env.art)
assert.throws(() => result.tracks(['missing']), { name: 'TypeError' })
assert.deepEqual(env.revoked, [''])
assert.equal(env.liveBlobs.has('blob:subtitle-1'), true)
result.reset()
assert.equal(env.initialized.length, 2)
})
test(`Multiple-subtitles ${implementation.name} failure baseline: duplicate names select the first parsed track`, async () => {
const env = create({ responses: { 'a.vtt': subtitleVtt('First text'), 'b.vtt': subtitleVtt('Second text') } })
const result = await env.factory(options(subtitle('a.vtt', 'same'), subtitle('b.vtt', 'same')))(env.art)
result.tracks(['same'])
const text = await env.latestText()
assert(text.includes('First text'))
assert(!text.includes('Second text'))
result.reset()
assert((await env.latestText()).includes('Second text'))
})
test(`Multiple-subtitles ${implementation.name} failure baseline: a shorter later track exposes the old index merge failure`, async () => {
const env = create({ responses: { 'a.vtt': cues([[0, 2, 'A'], [3, 5, 'B']]), 'b.vtt': subtitleVtt('C') } })
const pending = env.factory(options(subtitle(), subtitle('b.vtt', 'b')))(env.art)
if (oldMerge) {
await assert.rejects(pending, { name: 'TypeError' })
assert.equal(env.blobs.size, 0)
}
else {
await pending
assert.equal(starts(await env.latestText()).length, 3)
}
})
test(`Multiple-subtitles ${implementation.name} failure baseline: a longer later track used to lose its tail`, async () => {
const env = create({ responses: { 'a.vtt': subtitleVtt('A'), 'b.vtt': cues([[0, 2, 'B'], [3, 5, 'Tail']]) } })
await env.factory(options(subtitle(), subtitle('b.vtt', 'b')))(env.art)
const text = await env.latestText()
assert.equal(text.includes('Tail'), !oldMerge)
assert.equal(starts(text).length, oldMerge ? 1 : 3)
})
test(`Multiple-subtitles ${implementation.name} failure baseline: each track sorts internally without globally sorting the merge`, async () => {
const env = create({ responses: { 'a.vtt': cues([[2, 6, 'A2'], [0, 4, 'A0']]), 'b.vtt': cues([[5, 7, 'B5'], [1, 3, 'B1']]) } })
await env.factory(options(subtitle(), subtitle('b.vtt', 'b')))(env.art)
assert.deepEqual(starts(await env.latestText()), oldMerge
? ['00:00.000', '00:02.000']
: ['00:00.000', '00:02.000', '00:01.000', '00:05.000'])
})
test(`Multiple-subtitles ${implementation.name} failure baseline: parser diagnostics are discarded but usable cues survive`, async () => {
const env = create({ responses: { 'a.vtt': subtitleVtt('usable').replace('WEBVTT', 'BAD HEADER') } })
await env.factory(options(subtitle()))(env.art)
assert((await env.latestText()).includes('usable'))
const empty = create({ responses: { 'a.vtt': 'WEBVTT\n\n', 'unknown.bin': 'not subtitles' } })
await empty.factory(options(subtitle(), subtitle('unknown.bin', 'unknown')))(empty.art)
assert.equal(await empty.latestText(), 'WEBVTT\n\n')
})
test(`Multiple-subtitles ${implementation.name} failure baseline: destroy while fetching still permits late registration and leaks its URL`, async () => {
const request = deferred()
const env = create({ fetchResponse: () => request.promise })
let settled = false
const pending = env.factory(options(subtitle()))(env.art).then((result) => {
settled = true
return result
})
env.emit('destroy')
await flush()
assert.equal(settled, false)
assert.equal(env.requestOptions[0], undefined)
request.resolve(env.responseFor('a.vtt'))
assert.equal((await pending).name, 'multipleSubtitles')
assert.deepEqual(env.initStates, [{ destroyed: true }])
assert.equal(env.liveBlobs.size, 1)
})
test(`Multiple-subtitles ${implementation.name} failure baseline: destroy during body decoding also permits late installation`, async () => {
const body = deferred()
const started = deferred()
const env = create({ fetchResponse: () => ({ arrayBuffer: () => {
started.resolve()
return body.promise
} }) })
const pending = env.factory(options(subtitle()))(env.art)
await started.promise
env.emit('destroy')
body.resolve(new TextEncoder().encode(subtitleVtt('late body')).buffer)
await pending
assert.deepEqual(env.initStates, [{ destroyed: true }])
assert.equal(env.liveBlobs.size, 1)
})
test(`Multiple-subtitles ${implementation.name} failure baseline: retained methods allocate new URLs after destroy`, async () => {
const env = create()
const result = await env.factory(options(subtitle()))(env.art)
env.emit('destroy')
assert.equal(env.liveBlobs.has('blob:subtitle-1'), true)
assert.equal(result.tracks([]), undefined)
result.reset()
assert.deepEqual(env.initStates, [{ destroyed: false }, { destroyed: true }, { destroyed: true }])
assert.equal(env.liveBlobs.has('blob:subtitle-3'), true)
})
test(`Multiple-subtitles ${implementation.name} failure baseline: synchronous subtitle init failure leaves its new URL owned by nobody`, async () => {
const error = new Error('init failed')
const env = create({ onInit: () => {
throw error
} })
await assert.rejects(env.factory(options(subtitle()))(env.art), caught => caught === error)
assert.equal(env.liveBlobs.size, 1)
assert.equal(env.art.option.subtitle.escape, false)
})
test(`Multiple-subtitles ${implementation.name} failure baseline: registration ignores an observed asynchronous init rejection`, async () => {
const error = new Error('async init failed')
const errors = []
const env = create({ onInit: () => {
const rejected = Promise.reject(error)
rejected.catch(caught => errors.push(caught))
return rejected
} })
assert.equal((await env.factory(options(subtitle()))(env.art)).name, 'multipleSubtitles')
await flush()
assert.deepEqual(errors, [error])
assert.equal(env.liveBlobs.size, 1)
})
test(`Multiple-subtitles ${implementation.name} failure baseline: URL allocation failure happens after revoking the prior active URL`, async () => {
const error = new Error('allocation failed')
let calls = 0
const env = create({ onCreate: () => {
if (++calls === 2)
throw error
} })
const result = await env.factory(options(subtitle()))(env.art)
assert.throws(() => result.reset(), caught => caught === error)
assert.equal(env.liveBlobs.size, 0)
assert.equal(env.initialized.length, 1)
assert.deepEqual(env.revoked, ['', 'blob:subtitle-1'])
result.reset()
assert.equal(env.liveBlobs.size, 1)
})
test(`Multiple-subtitles ${implementation.name} failure baseline: metadata remains live after download and restart does not reload`, async () => {
const request = deferred()
const env = create({ fetchResponse: () => request.promise })
const entry = subtitle()
const pending = env.factory(options(entry))(env.art)
entry.name = 'renamed'
request.resolve(env.responseFor('a.vtt'))
const result = await pending
env.art.option.url = 'next.mp4'
env.emit('restart')
result.tracks(['renamed'])
assert(env.utils.unescape(await env.latestText()).includes('art-subtitle-renamed'))
assert.deepEqual(env.requests, ['a.vtt'])
})
}