test(jassub): [PKG-JASSUB-02] establish lifecycle failures and native render baseline

This commit is contained in:
Harvey Zhao committed 2026-09-14 01:00:06 +08:00
1 parent 0cf56b64d2
commit 944e2cc9d0
14 files changed
+4311 -11

No files matched your search

+9
View File
@@ -169,3 +169,12 @@ The startup time=6 row remains diagnostic because initial model work can coincid
with a missed narrow timestamp window. A passing post-ready combination is not
proof of startup losslessness, private GPU/WASM closure, OS fullscreen or devices.
See refactor/changes/2026-09-14-PKG-MASK-05-native-checkpoint.md for exact limits.
`jassub-native.spec.js` runs actual published JASSUB 1.1.0, its native Worker,
fixed local WASM/font and authored ASS cues with offscreenRender=false. It checks
real canvas pixels before/after seek, CSS web fullscreen and normal destruction.
Windows WebKit currently fails the default frame-clock path; the explicitly
selected ARTPLAYER_JASSUB_ON_DEMAND=false diagnostic passes on the candidate core.
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.
+150
View File
@@ -0,0 +1,150 @@
import fs from 'node:fs'
import process from 'node:process'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { expect, test } from './fixtures.js'
const baseline = JSON.parse(fs.readFileSync('refactor/baselines/jassub-release.json', 'utf8'))
const release = baseline.release
const archive = await ensureArchive(release)
const member = `package/${release.manifest.main.replace(/^\.\//, '')}`
const code = readMember(archive, member).toString()
const onDemandRender = process.env.ARTPLAYER_JASSUB_ON_DEMAND !== 'false'
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,Liberation Sans,36,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,20,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.00,0:00:04.00,Default,,0,0,0,,Before seek
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) => {
const resources = []
const external = []
const origin = new URL(testInfo.project.use.baseURL).origin
await context.route('**/*', (route) => {
if (new URL(route.request().url()).origin !== origin) {
external.push(route.request().url())
return route.abort()
}
return route.continue()
})
context.on('response', (response) => {
if (response.url().includes('/assets/jassub/'))
resources.push({ url: response.url(), status: response.status(), contentType: response.headers()['content-type'] })
})
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: code })
await page.evaluate(() => {
window.jassubNative = { workers: [], snapshots: [], errors: [] }
const NativeWorker = window.Worker
window.Worker = class extends NativeWorker {
constructor(...args) {
super(...args)
this.observation = { url: String(args[0]), terminated: 0, sent: [], received: [] }
window.jassubNative.workers.push(this.observation)
this.addEventListener('message', ({ data }) => this.observation.received.push({ target: data.target, images: data.images?.length, width: data.width, height: data.height }))
}
postMessage(message, ...rest) {
this.observation.sent.push({ target: message.target, time: message.time, width: message.width, height: message.height })
return super.postMessage(message, ...rest)
}
terminate() {
this.observation.terminated++
return super.terminate()
}
}
window.createPlayer('/assets/sample/steve-jobs.mp4')
})
try {
await expect.poll(() => page.evaluate(() => window.art.duration)).toBeGreaterThan(30)
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 }) => {
const register = window.artplayerPluginJassub.default || window.artplayerPluginJassub
window.art.plugins.add(register({
subContent,
workerUrl: '/assets/jassub/jassub-worker.js',
wasmUrl: '/assets/jassub/jassub-worker.wasm',
modernWasmUrl: '/assets/jassub/jassub-worker-modern.wasm',
availableFonts: { 'liberation sans': '/assets/jassub/default.woff2' },
fonts: ['/assets/jassub/default.woff2'],
offscreenRender: false,
onDemandRender,
}))
window.jassub = window.art.plugins.artplayerPluginJassub.instance
window.jassub.addEventListener('error', event => window.jassubNative.errors.push(String(event.error)))
await new Promise((resolve, reject) => {
window.jassub.addEventListener('ready', resolve, { once: true })
window.jassub.addEventListener('error', event => reject(event.error), { once: true })
})
window.jassubPixels = (phase) => {
const canvas = window.jassub._canvas
const rect = canvas.getBoundingClientRect()
const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data
let visible = 0
let signature = 0
for (let i = 3; i < pixels.length; i += 4) {
if (pixels[i] > 0) {
visible++
signature = (signature + i * pixels[i]) >>> 0
}
}
const state = { phase, visible, signature, width: canvas.width, height: canvas.height, cssWidth: rect.width, cssHeight: rect.height, time: window.art.currentTime, connected: canvas.isConnected }
window.jassubNative.lastPixel = state
if (phase)
window.jassubNative.snapshots.push(state)
return state
}
}, { subContent: subtitles, onDemandRender })
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.jassubPixels().visible)).toBeGreaterThan(100)
const before = await page.evaluate(() => window.jassubPixels('before-seek'))
expect(before.connected).toBe(true)
await page.locator('#pause').click()
await page.evaluate(async () => {
const seeked = new Promise(resolve => window.art.video.addEventListener('seeked', resolve, { once: true }))
window.art.currentTime = 10
await seeked
})
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.jassubPixels().signature)).not.toBe(before.signature)
await expect.poll(() => page.evaluate(() => window.jassubPixels().visible)).toBeGreaterThan(100)
const after = await page.evaluate(() => window.jassubPixels('after-seek'))
expect(after.time).toBeGreaterThanOrEqual(10)
expect(after.signature).not.toBe(before.signature)
await page.evaluate(() => {
window.art.fullscreenWeb = true
})
await expect.poll(() => page.evaluate(() => window.jassubPixels().cssWidth)).toBeGreaterThan(before.cssWidth)
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' })
await page.evaluate(() => window.art.destroy(false))
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([])
expect(external).toEqual([])
expect(resources.some(item => item.url.endsWith('.wasm') && item.status === 200 && item.contentType === 'application/wasm')).toBe(true)
expect(resources.some(item => item.url.endsWith('.woff2') && item.status === 200)).toBe(true)
}
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' })
if (!page.isClosed()) {
await page.evaluate(() => {
if (window.art && !window.art.isDestroy)
window.art.destroy()
})
}
}
})
}
+1 -1
View File
@@ -83,7 +83,7 @@ add('/test/audio-tone.m4a', fs.readFileSync(path.join(workspace, 'test/browser/m
add('/test/thumbnail-pattern.mp4', fs.readFileSync(path.join(workspace, 'test/browser/media/thumbnail-pattern.mp4')), { kind: 'generated-media', file: 'test/browser/media/thumbnail-pattern.mp4' })
add('/test/auto-thumbnail-timeline.mp4', fs.readFileSync(path.join(workspace, 'test/browser/media/auto-thumbnail-timeline.mp4')), { kind: 'generated-media', file: 'test/browser/media/auto-thumbnail-timeline.mp4' })
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.mp4': 'video/mp4', '.m4a': 'audio/mp4', '.webm': 'video/webm', '.vtt': 'text/vtt', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg' }
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.mp4': 'video/mp4', '.m4a': 'audio/mp4', '.webm': 'video/webm', '.vtt': 'text/vtt', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.wasm': 'application/wasm', '.woff2': 'font/woff2' }
add('/test/declaration-cues.vtt', fs.readFileSync(path.join(workspace, 'test/browser/media/declaration-cues.vtt')), { kind: 'test-subtitles', file: 'test/browser/media/declaration-cues.vtt' })
add('/test/thumbnail-grid.svg', fs.readFileSync(path.join(workspace, 'test/browser/media/thumbnail-grid.svg')), { kind: 'test-thumbnail-grid', file: 'test/browser/media/thumbnail-grid.svg' })
add('/test/legacy-safe-area.js', fs.readFileSync(path.join(workspace, 'test/helpers/legacy-safe-area.js')), { kind: 'frozen-own-source', file: 'test/helpers/legacy-safe-area.js', commit: 'ccf77c4e' })
+26
View File
@@ -76,4 +76,30 @@ for (const implementation of implementations) {
assert.equal(init.availableFonts['liberation sans'], './default.woff2')
result.instance.destroy()
})
test(`JASSUB ${implementation.name}: fallback clock keeps offsets and synchronous track replacement preserves message order`, async () => {
const env = jassubEnvironment(implementation)
const { instance } = env.factory({ timeOffset: -0.25 })(env.art)
await env.ready()
env.art.video.currentTime = 12
env.art.video.paused = false
env.art.video.dispatchEvent(new Event('playing'))
await env.flush()
const playing = env.workers[0].messages.at(-1)
assert.equal(playing.target, 'video')
assert.equal(playing.currentTime, 11.75)
assert.equal(playing.isPaused, false)
env.art.video.dispatchEvent(new Event('seeking'))
await env.flush()
assert.equal(env.workers[0].messages.at(-1).isPaused, true)
const start = env.workers[0].messages.length
assert.equal(instance.setTrackByUrl('/replacement.ass'), undefined)
assert.equal(instance.setTrack('[Script Info]\nTitle: Replacement'), undefined)
assert.equal(instance.freeTrack(), undefined)
await env.flush()
const messages = env.workers[0].messages.slice(start)
assert.deepEqual(messages.map(message => message.target), ['setTrackByUrl', 'setTrack', 'freeTrack'])
assert.equal(messages[0].url, '/replacement.ass')
assert.equal(messages[1].content, '[Script Info]\nTitle: Replacement')
instance.destroy()
})
}