test(multiple-subtitles): [PKG-MULTI-SUB-01] freeze contracts and verify parser provenance

This commit is contained in:
Harvey Zhao committed 2026-09-13 10:12:32 +08:00
1 parent 0c5bb2bd7a
commit 5a0d94cb89
33 files changed
+2375 -22

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
import fs from 'node:fs'
import process from 'node:process'
import vm from 'node:vm'
import { transform } from 'esbuild'
import { verifyMultipleSubtitlesContract } from '../../refactor/scripts/multiple-subtitles-contract.mjs'
import { readMember } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from './load.js'
export const subtitleVtt = text => `WEBVTT\n\n00:00.000 --> 00:02.000\n${text}\n`
export async function multipleSubtitlesCandidate() {
return { name: 'candidate', version: 'candidate', code: process.env.ARTPLAYER_MULTIPLE_SUBTITLES_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_MULTIPLE_SUBTITLES_ARTIFACT, 'utf8') : await compilePackage('artplayer-plugin-multiple-subtitles', 'umd') }
}
export async function multipleSubtitlesHistorical() {
const { baseline, archives, sources } = await verifyMultipleSubtitlesContract()
const name = 'artplayer-plugin-multiple-subtitles'
const implementations = []
for (const release of [baseline.release, ...baseline.previous]) {
for (const field of ['main', 'legacy'])
implementations.push({ name: `published-${release.version}-${field}`, version: release.version, code: readMember(archives.get(release.version), `package/${release.manifest[field].replace(/^\.\//, '')}`).toString() })
}
const source = sources.get(`packages/${name}/src/parser.js`)
+ sources.get(`packages/${name}/src/index.js`).replace(/^import .* from ['"].\/parser['"];?\s*$/m, '')
implementations.push({ name: 'frozen-workspace-source', version: 'workspace', code: (await transform(source, { format: 'cjs', target: 'es2020' })).code })
for (const suffix of ['js', 'legacy.js'])
implementations.push({ name: `frozen-workspace-${suffix}`, version: 'workspace', code: sources.get(`packages/${name}/dist/${name}.${suffix}`) })
return implementations
}
export function multipleSubtitlesEnvironment(implementation, { script = false, responses = {} } = {}) {
const requests = []
const blobs = new Map()
const revoked = []
const initialized = []
const converted = []
const module = { exports: {} }
const utils = {
getExt: url => url.split('.').pop(),
unescape: text => text.replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&'),
srtToVtt(text) {
converted.push(['srt', text])
return subtitleVtt('SRT')
},
assToVtt(text) {
converted.push(['ass', text])
return subtitleVtt('ASS')
},
}
const art = {
constructor: { utils },
option: { subtitle: { escape: true, style: { color: 'red' } } },
subtitle: { init(option) { initialized.push(option) } },
}
const context = {
Blob,
TextDecoder,
console: { log() {}, warn() {}, error() {} },
URL: {
createObjectURL(blob) {
const url = `blob:subtitle-${blobs.size + 1}`
blobs.set(url, blob)
return url
},
revokeObjectURL(url) { revoked.push(url) },
},
fetch: async (url) => {
requests.push(url)
const bytes = new TextEncoder().encode(responses[url] ?? subtitleVtt(url))
return { ok: true, arrayBuffer: async () => bytes.buffer }
},
}
context.window = context
if (!script) {
context.module = module
context.exports = module.exports
}
vm.runInNewContext(implementation.code, context, { timeout: 5000 })
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 }
}
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
// eslint-disable-next-line test/no-import-node-test -- Vendor parity uses the repository Node runner.
import test from 'node:test'
import vm from 'node:vm'
import { transformSync } from 'esbuild'
import { checkMultipleSubtitlesVendor } from '../refactor/scripts/multiple-subtitles-vendor.mjs'
test('Multiple-subtitles vendor retains pinned executable content and complete standalone distribution notices', () => {
const result = checkMultipleSubtitlesVendor()
const notices = fs.readFileSync('packages/artplayer-plugin-multiple-subtitles/THIRD_PARTY_NOTICES', 'utf8').trim()
const block = notices.split(/\r?\n/).map(line => line.trimEnd() ? ` * ${line.trimEnd()}` : ' *').join('\n')
for (const suffix of ['js', 'legacy.js', 'mjs']) {
const built = fs.readFileSync(`packages/artplayer-plugin-multiple-subtitles/dist/artplayer-plugin-multiple-subtitles.${suffix}`, 'utf8').replaceAll('\r\n', '\n')
assert(built.includes(block), `Missing complete notice in ${suffix}`)
assert(built.startsWith('/*!'))
}
assert.equal(result.provenance.revision, '380cfcce34ba8b472d3a31474874eb72a0e5f460')
})
test('Multiple-subtitles vendor parser and serializer match the pinned upstream on metadata and malformed inputs', () => {
const upstream = { exports: {}, Date: { now: () => 123 } }
vm.runInNewContext(fs.readFileSync('refactor/baselines/multiple-subtitles-vendor/parser.txt', 'utf8'), upstream)
const local = { exports: {}, Date: upstream.Date }
local.module = { exports: local.exports }
vm.runInNewContext(transformSync(fs.readFileSync('packages/artplayer-plugin-multiple-subtitles/src/parser.js', 'utf8'), { format: 'cjs' }).code, local)
for (const input of [
'',
'WEBVTT\n\n0\n00:00.000 --> 00:01.000\nhello &amp; &lt;world&gt;\n',
'\uFEFFWEBVTT\r\n\r\nNOTE ignored\r\n\r\n00:01.000 --> 00:04.000 align:start\r\n<c.red>日本語</c>\r\n',
'WEBVTT\n\nSTYLE\n::cue {color: red}\n\n00:01.000 --> 00:03.000\nB\n\n00:00.000 --> 00:04.000\nA\n',
'WEBVTT\n\nbad --> timing\ntext\n',
]) {
const parse = exports => new exports.WebVTTParser().parse(input, 'metadata')
const expected = parse(upstream.exports)
const actual = parse(local.module.exports)
assert.deepEqual(JSON.parse(JSON.stringify(actual)), JSON.parse(JSON.stringify(expected)))
const serialize = (exports, tree) => new exports.WebVTTSerializer().serialize(tree.cues, tree.styles)
assert.equal(serialize(local.module.exports, actual), serialize(upstream.exports, expected))
}
})
+89
View File
@@ -0,0 +1,89 @@
import assert from 'node:assert/strict'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Historical contracts use the repository Node runner.
import test from 'node:test'
import { multipleSubtitlesCandidate, multipleSubtitlesEnvironment, multipleSubtitlesHistorical, subtitleVtt } from './helpers/multiple-subtitles.js'
const implementations = process.env.ARTPLAYER_MULTIPLE_SUBTITLES_CANDIDATE === '1' || process.env.ARTPLAYER_MULTIPLE_SUBTITLES_ARTIFACT
? [await multipleSubtitlesCandidate()]
: await multipleSubtitlesHistorical()
const plain = value => JSON.parse(JSON.stringify(value))
for (const implementation of implementations) {
const create = options => multipleSubtitlesEnvironment(implementation, options)
test(`Multiple-subtitles ${implementation.name}: required factory input and async named methods remain explicit`, async () => {
const env = create()
assert.throws(() => env.factory(), { name: 'TypeError' })
const register = env.factory({})
assert.equal(typeof register, 'function')
const pending = register(env.art)
assert.equal(typeof pending.then, 'function')
assert.equal(pending.name, undefined)
assert.equal(env.initialized.length, 0)
const result = await pending
assert.deepEqual(Object.keys(result), ['name', 'tracks', 'reset'])
assert.equal(result.name, 'multipleSubtitles')
assert.equal(result.tracks(), undefined)
assert.equal(result.reset(), undefined)
assert.equal(await env.latestText(), 'WEBVTT\n\n')
})
test(`Multiple-subtitles ${implementation.name}: all tracks load concurrently and retain historical merge topology`, async () => {
const env = create({ responses: { 'en.vtt': subtitleVtt('English'), 'jp.vtt': subtitleVtt('Japanese') } })
const pending = env.factory({ subtitles: [{ url: 'en.vtt', name: 'en' }, { url: 'jp.vtt', name: 'jp' }] })(env.art)
assert.deepEqual(env.requests, ['en.vtt', 'jp.vtt'])
await pending
const text = env.utils.unescape(await env.latestText())
assert(text.includes('<div class="art-subtitle-en">English</div>'))
assert(text.includes('<div class="art-subtitle-jp">Japanese</div>'))
assert.equal(text.match(/-->/g).length, implementation.version === '1.0.0' ? 1 : 2)
assert(text.indexOf('art-subtitle-en') < text.indexOf('art-subtitle-jp'))
})
test(`Multiple-subtitles ${implementation.name}: tracks select named entries, reorder, clear and reset without another fetch`, async () => {
const env = create({ responses: { 'en.vtt': subtitleVtt('English'), 'jp.vtt': subtitleVtt('Japanese') } })
const result = await env.factory({ subtitles: [{ url: 'en.vtt', name: 'en' }, { url: 'jp.vtt', name: 'jp' }] })(env.art)
result.tracks(['jp'])
assert(!env.utils.unescape(await env.latestText()).includes('art-subtitle-en'))
result.tracks(['jp', 'en'])
const reversed = env.utils.unescape(await env.latestText())
assert(reversed.indexOf('art-subtitle-jp') < reversed.indexOf('art-subtitle-en'))
result.tracks([])
assert.equal(await env.latestText(), 'WEBVTT\n\n')
result.reset()
const reset = env.utils.unescape(await env.latestText())
assert(reset.indexOf('art-subtitle-en') < reset.indexOf('art-subtitle-jp'))
assert.equal(reset.match(/<div/g).length, 2, 'Wrapper must not accumulate on reset')
assert.deepEqual(env.requests, ['en.vtt', 'jp.vtt'])
})
test(`Multiple-subtitles ${implementation.name}: subtitle init replaces object URLs and retains live configuration`, async () => {
const env = create()
const result = await env.factory({ subtitles: [] })(env.art)
assert.equal(env.art.option.subtitle.escape, false)
assert.equal(env.initialized[0].onVttLoad, env.utils.unescape)
assert.equal(env.initialized[0].style, env.art.option.subtitle.style)
assert.equal(env.initialized[0].type, 'vtt')
assert.deepEqual(env.revoked, [''])
env.art.option.subtitle.encoding = 'utf-16le'
result.reset()
assert.equal(env.initialized[1].encoding, 'utf-16le')
assert.deepEqual(env.revoked, ['', 'blob:subtitle-1'])
assert.equal(env.blobs.get('blob:subtitle-2').type, 'text/vtt')
})
test(`Multiple-subtitles ${implementation.name}: type selection dispatches existing core converters and ignores onParser`, async () => {
let parsed = 0
const env = create({ responses: { 'a.srt': 'source srt', 'b.ass': 'source ass', 'c.bin': subtitleVtt('Override') } })
const onParser = () => {
parsed++
return {}
}
await env.factory({ subtitles: [{ url: 'a.srt', name: 'srt', onParser }, { url: 'b.ass', name: 'ass' }, { url: 'c.bin', name: 'override', type: 'vtt' }] })(env.art)
assert.deepEqual(plain(env.converted), [['srt', 'source srt'], ['ass', 'source ass']])
const text = env.utils.unescape(await env.latestText())
for (const value of ['SRT', 'ASS', 'Override'])
assert(text.includes(value))
assert.equal(parsed, 0)
})
}