refactor(jassub): [PKG-JASSUB-03] isolate registration and fix adapter cleanup

This commit is contained in:
Harvey Zhao committed 2026-09-14 01:12:03 +08:00
1 parent 944e2cc9d0
commit 38e7b37878
21 files changed
+6553 -70

No files matched your search

+7
View File
@@ -178,3 +178,10 @@ selected ARTPLAYER_JASSUB_ON_DEMAND=false diagnostic passes on the candidate cor
This is a retained historical failure, not a waived browser gate. The server must
serve WASM as application/wasm for native streaming compilation. Archive failed
and successful runs independently. See the PKG-JASSUB-02 change record.
For candidate registration regressions, set ARTPLAYER_JASSUB_ARTIFACT to a built
main/legacy global file. Those runs also destroy the exposed instance before host
cleanup. ARTPLAYER_JASSUB_CUSTOM_CANVAS=true supplies a caller-owned canvas and
asserts it remains connected after destroy(false). The native report records the
artifact hash and both mode choices. This does not change the default published
baseline or establish ESM/offscreen/device acceptance.
+21 -5
View File
@@ -7,7 +7,9 @@ const baseline = JSON.parse(fs.readFileSync('refactor/baselines/jassub-release.j
const release = baseline.release
const archive = await ensureArchive(release)
const member = `package/${release.manifest.main.replace(/^\.\//, '')}`
const code = readMember(archive, member).toString()
const artifact = process.env.ARTPLAYER_JASSUB_ARTIFACT
const customCanvas = process.env.ARTPLAYER_JASSUB_CUSTOM_CANVAS === 'true'
const code = artifact ? fs.readFileSync(artifact, 'utf8') : readMember(archive, member).toString()
const onDemandRender = process.env.ARTPLAYER_JASSUB_ON_DEMAND !== 'false'
const subtitles = `[Script Info]
ScriptType: v4.00+
@@ -23,7 +25,7 @@ Dialogue: 0,0:00:05.00,0:00:30.00,Default,,0,0,0,,After seek with actual WASM
`
for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
test(`JASSUB published ${release.version} renders actual WASM subtitles through seek and layout with ${core} core`, async ({ page, context }, testInfo) => {
test(`JASSUB ${artifact ? 'candidate artifact' : `published ${release.version}`} renders actual WASM subtitles through seek and layout with ${core} core, customCanvas=${customCanvas}`, async ({ page, context }, testInfo) => {
const resources = []
const external = []
const origin = new URL(testInfo.project.use.baseURL).origin
@@ -68,8 +70,17 @@ for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.locator('#pause').click()
await page.evaluate(async ({ subContent, onDemandRender }) => {
await page.evaluate(async ({ subContent, onDemandRender, customCanvas }) => {
const register = window.artplayerPluginJassub.default || window.artplayerPluginJassub
let canvas
if (customCanvas) {
canvas = document.createElement('canvas')
canvas.style.position = 'absolute'
canvas.style.pointerEvents = 'none'
canvas.style.zIndex = '20'
window.art.video.insertAdjacentElement('afterend', canvas)
window.jassubUserCanvas = canvas
}
window.art.plugins.add(register({
subContent,
workerUrl: '/assets/jassub/jassub-worker.js',
@@ -79,6 +90,7 @@ for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
fonts: ['/assets/jassub/default.woff2'],
offscreenRender: false,
onDemandRender,
...(canvas ? { canvas } : {}),
}))
window.jassub = window.art.plugins.artplayerPluginJassub.instance
window.jassub.addEventListener('error', event => window.jassubNative.errors.push(String(event.error)))
@@ -104,7 +116,7 @@ for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
window.jassubNative.snapshots.push(state)
return state
}
}, { subContent: subtitles, onDemandRender })
}, { subContent: subtitles, onDemandRender, customCanvas })
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.jassubPixels().visible)).toBeGreaterThan(100)
const before = await page.evaluate(() => window.jassubPixels('before-seek'))
@@ -128,7 +140,11 @@ for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
await expect.poll(() => page.evaluate(() => window.jassubPixels().visible)).toBeGreaterThan(100)
await page.evaluate(() => window.jassubPixels('fullscreen'))
await testInfo.attach('actual-jassub-page', { body: await page.screenshot(), contentType: 'image/png' })
if (artifact)
await page.evaluate(() => window.jassub.destroy())
await page.evaluate(() => window.art.destroy(false))
if (customCanvas)
expect(await page.evaluate(() => window.jassubUserCanvas.isConnected)).toBe(true)
expect(await page.evaluate(() => document.querySelectorAll('.JASSUB').length)).toBe(0)
expect(await page.evaluate(() => window.jassubNative.workers.map(worker => worker.terminated))).toEqual([1])
expect(await page.evaluate(() => window.jassubNative.errors)).toEqual([])
@@ -138,7 +154,7 @@ for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
}
finally {
const state = await page.evaluate(() => window.jassubNative).catch(error => ({ unavailable: error.message }))
await testInfo.attach('actual-jassub-evidence', { body: JSON.stringify({ core, onDemandRender, release: { version: release.version, integrity: release.integrity, member, sha256: hash(code) }, subtitleSha256: hash(subtitles), resources, external, state, limitation: 'Actual published plugin, worker, WASM and local font with offscreenRender=false. No default offscreen path, failure recovery, repeated destruction or device acceptance.' }, null, 2), contentType: 'application/json' })
await testInfo.attach('actual-jassub-evidence', { body: JSON.stringify({ core, onDemandRender, customCanvas, source: artifact ? { artifact, sha256: hash(code) } : { version: release.version, integrity: release.integrity, member, sha256: hash(code) }, subtitleSha256: hash(subtitles), resources, external, state, limitation: 'Actual worker, WASM and local font with offscreenRender=false. No default offscreen path, failure recovery or device acceptance; candidate adds direct-then-host destruction.' }, null, 2), contentType: 'application/json' })
if (!page.isClosed()) {
await page.evaluate(() => {
if (window.art && !window.art.isDestroy)
+7 -1
View File
@@ -141,6 +141,12 @@ export function jassubEnvironment(implementation, { script = false, simd = false
listeners.set(name, [])
listeners.get(name).push(callback)
return art
}, off(name, callback) {
const callbacks = listeners.get(name)
const index = callbacks?.indexOf(callback) ?? -1
if (index >= 0)
callbacks.splice(index, 1)
return art
} }
const emit = name => listeners.get(name)?.forEach(callback => callback())
const flush = async () => {
@@ -154,5 +160,5 @@ export function jassubEnvironment(implementation, { script = false, simd = false
workers.at(-1).onmessage({ data: { target: 'ready' } })
await flush()
}
return { factory, exported, art, workers, parent, errors, listeners, emit, ready, flush, createVideo: () => parent.appendChild(new Video()) }
return { factory, exported, art, workers, parent, errors, listeners, emit, ready, flush, createVideo: () => parent.appendChild(new Video()), createCanvas: () => new Canvas() }
}
+121
View File
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Candidate adapter regressions use the repository runner.
import test from 'node:test'
import { jassubCandidate, jassubEnvironment } from './helpers/jassub.js'
const implementation = await jassubCandidate()
test('JASSUB custom canvas registration keeps caller DOM while releasing its actual vendor instance', async () => {
const env = jassubEnvironment(implementation)
const canvas = env.parent.appendChild(env.createCanvas())
const result = env.factory({ canvas })(env.art)
assert.equal(result.instance._canvas, canvas)
assert.equal(result.instance._canvasParent, undefined)
assert.equal(env.listeners.get('destroy').length, 1)
await env.ready()
assert.equal(env.workers[0].messages[0].target, 'init')
env.emit('destroy')
assert.equal(env.workers[0].terminated, 1)
assert.equal(canvas.parentNode, env.parent)
assert.equal(env.parent.children.length, 2)
})
test('JASSUB host cleanup tolerates an already destroyed exposed instance', async () => {
const env = jassubEnvironment(implementation)
const { instance } = env.factory()(env.art)
await env.ready()
instance.destroy()
assert.doesNotThrow(() => env.emit('destroy'))
assert.doesNotThrow(() => env.emit('destroy'))
assert.equal(env.workers[0].terminated, 1)
assert.equal(env.parent.children.length, 1)
})
test('JASSUB host cleanup before ready terminates once and retains the vendor destroyed-frame guard', async () => {
const env = jassubEnvironment(implementation)
const { instance } = env.factory()(env.art)
env.emit('destroy')
env.emit('destroy')
await env.flush()
assert.equal(instance._destroyed, true)
assert.equal(env.workers[0].terminated, 1)
assert.equal(env.parent.children.length, 1)
})
test('JASSUB host cleanup guards synchronous reentry without replacing the exposed destroy method', async () => {
const env = jassubEnvironment(implementation)
const { instance } = env.factory()(env.art)
await env.ready()
const original = instance.destroy
let calls = 0
const custom = function () {
calls++
env.emit('destroy')
return original.call(this)
}
instance.destroy = custom
env.emit('destroy')
assert.equal(calls, 1)
assert.equal(instance.destroy, custom)
assert.equal(env.workers[0].terminated, 1)
})
test('JASSUB failing host disposal preserves its error and allows another cleanup attempt', async () => {
const env = jassubEnvironment(implementation)
const { instance } = env.factory()(env.art)
await env.ready()
const original = instance.destroy
const failure = new Error('consumer cleanup failure')
instance.destroy = () => {
throw failure
}
assert.throws(() => env.emit('destroy'), error => error === failure)
assert.equal(env.workers[0].terminated, 0)
instance.destroy = original
env.emit('destroy')
assert.equal(env.workers[0].terminated, 1)
})
for (const offThrows of [false, true]) {
test(`JASSUB registration failure rolls back its instance and retains the original error when offThrows=${offThrows}`, async () => {
const env = jassubEnvironment(implementation)
const unrelated = () => {}
env.art.on('destroy', unrelated)
const on = env.art.on
const failure = new Error('host listener registration failed')
env.art.on = (name, listener) => {
on(name, listener)
throw failure
}
if (offThrows)
env.art.off = () => { throw new Error('secondary rollback failure') }
assert.throws(() => env.factory()(env.art), error => error === failure)
await env.flush()
assert.equal(env.workers[0].terminated, 1)
assert.equal(env.parent.children.length, 1)
if (!offThrows)
assert.deepEqual(env.listeners.get('destroy'), [unrelated])
assert.doesNotThrow(() => env.emit('destroy'))
assert.equal(env.workers[0].terminated, 1)
})
}
test('JASSUB style setup failure releases the created instance before returning the original exception', async () => {
const env = jassubEnvironment(implementation)
const insert = env.art.video.insertAdjacentElement
const failure = new Error('style setter failed')
env.art.video.insertAdjacentElement = function (position, child) {
insert.call(this, position, child)
Object.defineProperty(child.style, 'zIndex', {
get() { return undefined },
set() {
throw failure
},
})
}
assert.throws(() => env.factory()(env.art), error => error === failure)
await env.flush()
assert.equal(env.workers[0].terminated, 1)
assert.equal(env.parent.children.length, 1)
assert.equal(env.listeners.get('destroy'), undefined)
})