refactor(jassub): [PKG-JASSUB-04] migrate owned TypeScript and preserve public types

This commit is contained in:
Harvey Zhao committed 2026-09-14 01:41:31 +08:00
1 parent 38e7b37878
commit 5e445b1af6
33 files changed
+3636 -61

No files matched your search

+236
View File
@@ -0,0 +1,236 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
// eslint-disable-next-line test/no-import-node-test -- Accurate runtime contracts use the repository runner.
import test from 'node:test'
import { isPromise } from 'node:util/types'
import vm from 'node:vm'
import { jassubCandidate, jassubEnvironment, jassubHistorical } from './helpers/jassub.js'
const candidate = await jassubCandidate()
const frozen = (await jassubHistorical()).find(item => item.name === 'frozen-workspace-source')
function environment(implementation) {
const code = `${implementation.code}\nObject.defineProperty(module.exports.default || module.exports, '__runtimePlatform', { value: { ErrorEvent, setGlobal: (name, value) => { globalThis[name] = value } } });`
const env = jassubEnvironment({ ...implementation, code })
env.platform = env.factory.__runtimePlatform
return env
}
function requestListeners(worker) {
const listeners = new Map()
worker.addEventListener = (type, callback) => {
if (!listeners.has(type))
listeners.set(type, new Set())
listeners.get(type).add(callback)
}
worker.removeEventListener = (type, callback) => listeners.get(type)?.delete(callback)
return {
size: () => [...listeners.values()].reduce((sum, entries) => sum + entries.size, 0),
emit(type, event) {
for (const callback of [...listeners.get(type) || []]) callback(event)
},
}
}
test('JASSUB accurate runtime resize uses width/height/top/left/force and lifecycle methods return synchronously', async () => {
const env = environment(candidate)
const { instance } = env.factory()(env.art)
await env.ready()
assert.equal(instance.resize(320, 180, 4, 7, false), undefined)
assert.equal(instance._canvas.style.top, '4px')
assert.equal(instance._canvas.style.left, '7px')
await env.flush()
const resize = env.workers[0].messages.at(-1)
assert.equal(resize.target, 'canvas')
assert.equal(resize.width, 320)
assert.equal(resize.height, 180)
assert.equal(resize.force, false)
const video = env.createVideo()
assert.equal(instance.setVideo(video), undefined)
assert.equal(instance._video, video)
assert.equal(instance.destroy(), undefined)
assert.equal(env.workers[0].terminated, 1)
assert.equal(env.parent.children.some(child => child.className === 'JASSUB'), false)
})
test('JASSUB accurate sendMessage returns a Promise waiting for ready and resolving after the actual post', async () => {
const env = environment(candidate)
const { instance } = env.factory()(env.art)
const payload = { value: 42 }
const pending = instance.sendMessage('runtime-probe', payload)
assert(isPromise(pending))
let settled = false
pending.then(() => {
settled = true
})
await env.flush()
assert.equal(settled, false)
assert.deepEqual(env.workers[0].messages.map(message => message.target), ['init'])
await env.ready()
assert.equal(await pending, undefined)
assert.equal(settled, true)
assert.equal(env.workers[0].messages.at(-1).target, 'runtime-probe')
assert.equal(env.workers[0].messages.at(-1).value, 42)
instance.destroy()
})
test('JASSUB accurate font options preserve mixed sources and font methods return synchronously', async () => {
const env = environment(candidate)
const bytes = new Uint8Array([1, 2, 3])
const fonts = ['fixture.woff2', bytes]
const availableFonts = { fixture: bytes, fallback: 'fallback.woff2' }
const { instance } = env.factory({ fonts, availableFonts })(env.art)
await env.ready()
const init = env.workers[0].messages[0]
assert.equal(init.fonts, fonts)
assert.equal(init.fonts[1], bytes)
assert.equal(init.availableFonts, availableFonts)
assert.equal(instance.addFont(bytes), undefined)
assert.equal(instance.setDefaultFont('fixture'), undefined)
await env.flush()
const added = env.workers[0].messages.find(message => message.target === 'addFont')
assert.equal(added.font, bytes)
assert.equal(env.workers[0].messages.at(-1).target, 'defaultFont')
assert.equal(env.workers[0].messages.at(-1).font, 'fixture')
instance.destroy()
})
const subtitles = `[Script Info]
ScriptType: v4.00+
PlayResX: 640
PlayResY: 360
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Runtime fixture`
test('JASSUB real Worker JS/WASM queries return numeric style indices and full event/style data through controlled transport', { timeout: 10000 }, async (context) => {
const env = environment(candidate)
env.platform.setGlobal('setTimeout', setTimeout)
env.platform.setGlobal('clearTimeout', clearTimeout)
const { instance } = env.factory({ subContent: subtitles, availableFonts: {}, fallbackFont: 'fixture', wasmUrl: '/runtime-fixture.wasm', onDemandRender: false, offscreenRender: false, asyncRender: false })(env.art)
const worker = env.workers[0]
const listeners = requestListeners(worker)
const wasm = fs.readFileSync('packages/artplayer-plugin-jassub/worker/jassub-worker.wasm')
const source = fs.readFileSync('packages/artplayer-plugin-jassub/worker/jassub-worker.js', 'utf8')
const requests = []
const messages = []
let initialized = false
let stopped = false
const workerContext = {
WebAssembly,
TextDecoder,
TextEncoder,
console,
onmessage: null,
async fetch(url) {
assert.equal(url, '/runtime-fixture.wasm')
requests.push(url)
return new Response(wasm, { headers: { 'Content-Type': 'application/wasm' } })
},
postMessage(message) {
const data = structuredClone(message)
messages.push(data)
if (data.target === 'ready')
initialized = true
queueMicrotask(() => {
if (stopped)
return
worker.onmessage({ data })
listeners.emit('message', { data })
})
},
}
workerContext.self = workerContext
vm.createContext(workerContext)
// Execute complete production Worker JS and actual WASM. Only transport and fetch are controlled.
vm.runInContext(source, workerContext, { timeout: 5000 })
const post = worker.postMessage
worker.postMessage = function (message) {
post.call(this, message)
if (!stopped)
workerContext.onmessage({ data: structuredClone(message) })
}
const terminate = worker.terminate
worker.terminate = function () {
if (initialized && !stopped)
workerContext.onmessage({ data: { target: 'destroy' } })
stopped = true
terminate.call(this)
}
context.after(() => instance.destroy())
let readyEvents = 0
instance.addEventListener('ready', (event) => {
readyEvents++
assert.equal(event.detail, null)
assert.equal(event.target, instance)
})
assert(instance instanceof EventTarget)
await new Promise(resolve => instance.addEventListener('ready', resolve, { once: true }))
assert.equal(readyEvents, 1)
assert.deepEqual(requests, ['/runtime-fixture.wasm'])
const query = method => new Promise((resolve, reject) => {
assert.equal(instance[method]((error, rows) => {
if (error !== null) {
reject(error ?? new Error('Successful queries must pass null as their error argument'))
return
}
resolve(rows)
}), undefined)
})
const events = await query('getEvents')
const styles = await query('getStyles')
assert.equal(events.length, 1)
assert.equal(events[0].Start, 1000)
assert.equal(events[0].Duration, 2000)
assert.equal(events[0].Text, 'Runtime fixture')
assert.equal(typeof events[0].Style, 'number')
assert.equal(Object.hasOwn(events[0], '_index'), false)
assert.equal(styles[events[0].Style].FontName, 'Arial')
assert.equal(styles[events[0].Style].FontSize, 24)
assert.equal(typeof styles[events[0].Style].PrimaryColour, 'number')
assert.equal(listeners.size(), 0)
assert.deepEqual(messages.find(message => message.target === 'getEvents').events, events)
assert.deepEqual(messages.find(message => message.target === 'getStyles').styles, styles)
assert.equal(instance.setEvent({ Text: 'Updated through Worker', Style: events[0].Style }, 0), undefined)
await env.flush()
assert.equal((await query('getEvents'))[0].Text, 'Updated through Worker')
assert.equal(instance.setStyle({ FontSize: 32 }, events[0].Style), undefined)
await env.flush()
assert.equal((await query('getStyles'))[events[0].Style].FontSize, 32)
})
for (const method of ['getEvents', 'getStyles']) {
for (const mode of ['timeout', 'worker-error']) {
test(`JASSUB frozen PKG-JASSUB-07 defect: ${method} ${mode} throws before the public error callback`, async () => {
const env = environment(frozen)
const timers = new Map()
let next = 0
env.platform.setGlobal('setTimeout', (callback) => {
timers.set(++next, callback)
return next
})
env.platform.setGlobal('clearTimeout', id => timers.delete(id))
const { instance } = env.factory()(env.art)
await env.ready()
const listeners = requestListeners(env.workers[0])
let calls = 0
instance[method](() => calls++)
assert.equal(listeners.size(), 2)
const trigger = mode === 'timeout'
? () => {
const [id, callback] = [...timers][0]
timers.delete(id)
callback()
}
: () => listeners.emit('error', new env.platform.ErrorEvent('error', { error: new Error('controlled Worker error') }))
assert.throws(trigger, { name: 'TypeError', message: /Cannot destructure property/ })
assert.equal(calls, 0)
assert.equal(listeners.size(), 2)
assert.equal(timers.size, mode === 'timeout' ? 0 : 1)
instance.destroy()
})
}
}
+28
View File
@@ -0,0 +1,28 @@
import type Artplayer from 'artplayer'
import type { JassubInstance, JassubOption } from 'artplayer-plugin-jassub'
import jassub from 'artplayer-plugin-jassub'
import legacy from 'artplayer-plugin-jassub/legacy'
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false
type Assert<T extends true> = T
interface OldResult { name: 'artplayerPluginJassub', instance: JassubInstance }
type OldFactory = (option: JassubOption) => (art: Artplayer) => OldResult
type FactoryCheck = Assert<Equal<typeof jassub, OldFactory>>
type ArgumentCheck = Assert<Equal<Parameters<typeof jassub>, [option: JassubOption]>>
type ReturnCheck = Assert<Equal<ReturnType<ReturnType<typeof jassub>>, OldResult>>
type ResizeCheck = Assert<Equal<Parameters<JassubInstance['resize']>, [force?: boolean, width?: number, height?: number, top?: number, left?: number]>>
declare const art: Artplayer
declare const instance: JassubInstance
const option: JassubOption = { workerUrl: '/worker.js', wasmUrl: '/worker.wasm', modernWasmUrl: '/modern.wasm', futureOption: true }
const replacement: typeof jassub = (_option: JassubOption) => (_art: Artplayer) => ({ name: 'artplayerPluginJassub', instance })
const reverse: OldFactory = jassub
const legacyReplacement: typeof legacy = replacement
const result: OldResult = jassub(option)(art)
legacy(option)(art)
const resize: Promise<void> = result.instance.resize(true, 640, 360, 0, 0)
const video: Promise<void> = result.instance.setVideo(art.video)
const destroy: Promise<void> = result.instance.destroy()
const extension: string = result.instance.futureMethod(option.futureOption)
export type { ArgumentCheck, FactoryCheck, ResizeCheck, ReturnCheck }
export { destroy, extension, legacyReplacement, replacement, resize, reverse, video }
+101
View File
@@ -0,0 +1,101 @@
import type Artplayer from 'artplayer'
import type { AssEvent, AssStyle, RuntimeFactory, RuntimeInstance, RuntimeOption, RuntimeResult } from 'artplayer-plugin-jassub/runtime'
import runtime from 'artplayer-plugin-jassub/runtime'
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false
type Assert<T extends true> = T
export type HostCheck = Assert<Equal<Parameters<ReturnType<RuntimeFactory>>, [art: Artplayer]>>
declare const art: Artplayer
declare const canvas: HTMLCanvasElement
const option: RuntimeOption = { canvas, fonts: ['/font.woff2', new Uint8Array([0])], availableFonts: { fallback: new Uint8Array([0]) }, onDemandRender: false }
const factory: RuntimeFactory = runtime
const result: RuntimeResult = runtime(option)(art)
const replacement: typeof runtime = (_option?: RuntimeOption) => (_art: Artplayer) => result
const defaults: RuntimeResult = runtime()(art)
runtime(undefined)(art)
runtime({ video: null, canvas })(art)
const instance: RuntimeInstance = result.instance
const target: EventTarget = instance
const resize: void = instance.resize(640, 360, 0, 0, true)
const video: void = instance.setVideo(art.video)
const destroyed: void = instance.destroy()
const error: Error = instance.destroy(new Error('test'))
const errorOrEmpty: Error | '' = instance.destroy('failure')
const sent: Promise<void> = instance.sendMessage('custom', { payload: new Uint8Array([1]) }, [])
instance.setCurrentTime(false, 10, 2)
instance.setTrackByUrl('/sub.ass')
instance.setTrack('[Script Info]')
instance.freeTrack()
instance.setIsPaused(true)
instance.setRate(2)
instance.createEvent({ Start: 1000, Duration: 2000, Style: 0, Text: 'Subtitle' })
instance.setEvent({ Text: 'Replacement' }, 0)
instance.removeEvent(0)
instance.createStyle({ Name: 'Default', FontSize: 24 })
instance.setStyle({ Bold: 1 }, 0)
instance.removeStyle(0)
instance.styleOverride({ FontSize: 30 })
instance.disableStyleOverride()
instance.addFont(new Uint8Array([0]))
instance.setDefaultFont('fallback')
instance.runBenchmark()
instance.getEvents((failure, events) => {
const caught: Error | ErrorEvent | null = failure
const values: AssEvent[] | undefined = events
if (events) {
for (const event of events) {
const style: number = event.Style
const start: number = event.Start
void [style, start]
}
}
void [caught, values]
})
instance.getStyles((_failure, styles) => {
const values: AssStyle[] | undefined = styles
void values
})
instance.addEventListener('ready', function (event) {
function acceptsInstance(_instance: RuntimeInstance) {}
acceptsInstance(this)
const detail: null = event.detail
void detail
})
instance.addEventListener('error', (event) => {
const message: string = event.message
void message
})
instance.addEventListener('custom', { handleEvent(event) {
const type: string = event.type
void type
} })
// @ts-expect-error Runtime URL fields are optional strings, not numbers.
runtime({ workerUrl: 42 })
// @ts-expect-error Accurate entry does not silently accept unknown options.
runtime({ typoOption: true })
// @ts-expect-error Resize starts with width; force is the fifth parameter.
instance.resize(true)
// @ts-expect-error Actual destroy is synchronous.
const asyncDestroy: Promise<void> = instance.destroy()
// @ts-expect-error Actual setVideo is synchronous.
const asyncVideo: Promise<void> = instance.setVideo(art.video)
// @ts-expect-error Actual resize is synchronous.
const asyncResize: Promise<void> = instance.resize()
// @ts-expect-error Registration is synchronous.
runtime()(art).then(() => {})
// @ts-expect-error sendMessage waits for initialization and returns a Promise.
const syncMessage: void = instance.sendMessage('custom')
// @ts-expect-error Style is the numeric libass index.
instance.createEvent({ Style: 'Default' })
// @ts-expect-error Worker responses do not contain _index.
const index: number = ({} as AssEvent)._index
// @ts-expect-error Callback errors may omit the event list.
instance.getEvents((_error, events) => { const required: AssEvent[] = events; void required }) // eslint-disable-line style/max-statements-per-line -- Keep this rejected callback on one diagnostic line.
// @ts-expect-error Font data must be a URL or bytes.
instance.addFont(1)
// @ts-expect-error Unknown instance methods are not hidden by an any index.
instance.nonexistentMethod()
// @ts-expect-error Only two blend modes are supported.
runtime({ blendMode: 'gpu' })
void [asyncDestroy, asyncVideo, asyncResize, defaults, destroyed, error, errorOrEmpty, factory, index, replacement, resize, sent, syncMessage, target, video]