mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
335 lines
16 KiB
JavaScript
335 lines
16 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { Buffer } from 'node:buffer'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
// eslint-disable-next-line test/no-import-node-test -- Immutable Node contract runner.
|
|
import { test } from 'node:test'
|
|
import vm from 'node:vm'
|
|
import { build } from 'esbuild'
|
|
import { verifyDanmukuContract } from './danmuku-contract.mjs'
|
|
import { ensureArchive, readMember, refactorDir } from './releases.mjs'
|
|
|
|
const { baseline, archives, sources } = await verifyDanmukuContract()
|
|
const prefix = 'packages/artplayer-plugin-danmuku/'
|
|
const icons = ['$on', '$off', '$config', '$style', '$mode_0_off', '$mode_0_on', '$mode_1_off', '$mode_1_on', '$mode_2_off', '$mode_2_on', '$check_on', '$check_off']
|
|
const publicKeys = ['name', 'emit', 'load', 'config', 'hide', 'show', 'reset', 'mount', 'option', 'isHide', 'isStop']
|
|
const optionKeys = ['danmuku', 'speed', 'margin', 'opacity', 'color', 'mode', 'modes', 'fontSize', 'antiOverlap', 'synchronousPlayback', 'mount', 'heatmap', 'width', 'points', 'filter', 'beforeEmit', 'beforeVisible', 'visible', 'emitter', 'maxLength', 'lockTime', 'theme', 'OPACITY', 'FONT_SIZE', 'MARGIN', 'SPEED', 'COLOR']
|
|
const coreRelease = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/releases.json'), 'utf8')).releases.find(item => item.name === 'artplayer')
|
|
const coreModule = { exports: {} }
|
|
vm.runInNewContext(readMember(await ensureArchive(coreRelease), 'package/dist/artplayer.js').toString(), { module: coreModule, exports: coreModule.exports }, { timeout: 1000 })
|
|
const core = coreModule.exports
|
|
|
|
function exported(code, globals = {}) {
|
|
const module = { exports: {} }
|
|
const context = vm.createContext({ console, ...globals, module, exports: module.exports })
|
|
vm.runInContext(code, context, { timeout: 1000 })
|
|
const factory = typeof module.exports === 'function' ? module.exports : module.exports.default
|
|
return { module: module.exports, factory, context }
|
|
}
|
|
|
|
for (const release of [baseline.release, ...baseline.previous]) {
|
|
for (const field of ['main', 'legacy']) {
|
|
if (!release.manifest[field])
|
|
continue
|
|
test(`danmuku published ${release.version} ${field}: actual CommonJS shape and static icons`, () => {
|
|
const code = readMember(archives.get(release.version), `package/${release.manifest[field].replace(/^\.\//u, '')}`).toString()
|
|
const result = exported(code)
|
|
const direct = ['3.5.31', '5.3.0'].includes(release.version)
|
|
assert.equal(typeof result.module, direct ? 'function' : 'object')
|
|
assert.equal(typeof result.factory, 'function')
|
|
const hasIcons = release.version.startsWith('5.') && !release.version.startsWith('5.0.')
|
|
assert.deepEqual(Object.keys(result.factory.icons || {}), hasIcons ? icons : [])
|
|
if (hasIcons) {
|
|
const descriptor = Object.getOwnPropertyDescriptor(result.factory, 'icons')
|
|
assert.equal(descriptor.writable, true)
|
|
assert.equal(descriptor.enumerable, true)
|
|
assert.equal(descriptor.configurable, true)
|
|
}
|
|
})
|
|
}
|
|
if (release.manifest.module) {
|
|
test(`danmuku published ${release.version}: manifest ESM bytes expose a default factory`, async () => {
|
|
const code = readMember(archives.get(release.version), `package/${release.manifest.module.replace(/^\.\//u, '')}`)
|
|
const module = await import(`data:text/javascript;base64,${code.toString('base64')}`)
|
|
assert.equal(typeof module.default, 'function')
|
|
assert.deepEqual(Object.keys(module.default.icons), icons)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Build only immutable Git modules in memory. Inline worker transport and CSS are
|
|
// controlled dependencies here; real Worker/layout/visual behavior belongs to 02.
|
|
const frozenSource = await build({ entryPoints: [`${prefix}src/index.js`], bundle: true, write: false, format: 'cjs', platform: 'browser', plugins: [{ name: 'frozen-danmuku', setup(build) {
|
|
build.onResolve({ filter: /.*/ }, args => ({ path: path.posix.normalize(args.importer ? path.posix.join(path.posix.dirname(args.importer), args.path) : args.path), namespace: 'frozen' }))
|
|
build.onLoad({ filter: /.*/, namespace: 'frozen' }, (args) => {
|
|
if (args.path.includes('?worker'))
|
|
return { contents: 'export default Worker', loader: 'js' }
|
|
const file = args.path.split('?')[0]
|
|
const found = sources.get(file) ?? sources.get(`${file}.js`)
|
|
assert.notEqual(found, undefined, file)
|
|
if (args.path.includes('?'))
|
|
return { contents: `export default ${JSON.stringify(found)}`, loader: 'js' }
|
|
return { contents: found, loader: 'js' }
|
|
})
|
|
} }] })
|
|
|
|
class Element {
|
|
constructor() {
|
|
this.style = {}
|
|
this.dataset = {}
|
|
this.children = []
|
|
this.nodes = new Map()
|
|
this.clientWidth = 640
|
|
this.clientHeight = 360
|
|
this.className = ''
|
|
this.textContent = ''
|
|
this.value = ''
|
|
}
|
|
|
|
get [Symbol.toStringTag]() { return 'HTMLDivElement' }
|
|
appendChild(child) {
|
|
child.parentElement = this
|
|
this.children.push(child)
|
|
return child
|
|
}
|
|
|
|
querySelector(selector) {
|
|
if (!this.nodes.has(selector))
|
|
this.nodes.set(selector, new Element())
|
|
return this.nodes.get(selector)
|
|
}
|
|
|
|
getBoundingClientRect() { return { top: 0, left: 0, right: 640, bottom: 360, width: 640, height: 360 } }
|
|
setAttribute(key, value) { this[key] = value }
|
|
addEventListener() {}
|
|
}
|
|
|
|
function environment(code) {
|
|
const events = []
|
|
const listeners = new Map()
|
|
const workers = []
|
|
const frames = new Map()
|
|
const timers = new Map()
|
|
const proxies = []
|
|
const host = {
|
|
requestAnimationFrame(callback) {
|
|
frames.set(frames.size + 1, callback)
|
|
return frames.size
|
|
},
|
|
cancelAnimationFrame(id) { frames.delete(id) },
|
|
}
|
|
class Worker {
|
|
constructor() { workers.push(this) }
|
|
terminate() { this.terminated = true }
|
|
postMessage() {}
|
|
addEventListener() {}
|
|
}
|
|
const loaded = exported(code, {
|
|
window: host,
|
|
Worker,
|
|
Blob,
|
|
URL: { createObjectURL: () => 'blob:controlled-worker', revokeObjectURL() {} },
|
|
atob: value => Buffer.from(value, 'base64').toString('binary'),
|
|
setTimeout(callback) {
|
|
timers.set(timers.size + 1, callback)
|
|
return timers.size
|
|
},
|
|
clearTimeout(id) { timers.delete(id) },
|
|
})
|
|
const body = new Element()
|
|
loaded.context.document = { querySelector: selector => selector === '#external' ? body : null, createElement: () => new Element() }
|
|
const utils = {
|
|
clamp: (value, min, max) => Math.min(Math.max(value, min), max),
|
|
setStyle: (element, key, value) => element.style[key] = value,
|
|
setStyles: (element, style) => Object.assign(element.style, style),
|
|
errorHandle: (value, message) => {
|
|
if (!value)
|
|
throw new Error(message)
|
|
return value
|
|
},
|
|
createElement: () => new Element(),
|
|
query: (selector, element) => element.querySelector(selector),
|
|
append: (element, child) => element.appendChild(child),
|
|
tooltip() {},
|
|
inverseClass() {},
|
|
addClass() {},
|
|
removeClass() {},
|
|
}
|
|
const art = {
|
|
constructor: { utils, validator: core.validator },
|
|
template: { $danmuku: new Element(), $player: new Element(), $controlsCenter: new Element() },
|
|
currentTime: 10,
|
|
width: 640,
|
|
playbackRate: 1,
|
|
playing: false,
|
|
plugins: {},
|
|
on(name, callback) { listeners.set(name, [...listeners.get(name) || [], callback]) },
|
|
off(name, callback) { listeners.set(name, (listeners.get(name) || []).filter(item => item !== callback)) },
|
|
emit(name, ...args) {
|
|
events.push({ name, args })
|
|
for (const callback of listeners.get(name) || [])
|
|
callback(...args)
|
|
},
|
|
proxy(target, name, callback) { proxies.push({ target, name, callback }) },
|
|
}
|
|
return { ...loaded, art, workers, events, listeners, frames, timers, proxies, external: body }
|
|
}
|
|
|
|
const implementations = [
|
|
{ name: 'frozen-source', code: frozenSource.outputFiles[0].text },
|
|
...['main', 'legacy'].map(field => ({ name: `published-5.3.0-${field}`, code: readMember(archives.get('5.3.0'), `package/${baseline.release.manifest[field].replace(/^\.\//u, '')}`).toString() })),
|
|
]
|
|
|
|
for (const implementation of implementations) {
|
|
test(`danmuku ${implementation.name}: synchronous facade, defaults, descriptors and actual method return identity`, async () => {
|
|
const env = environment(implementation.code)
|
|
const factory = env.factory({ danmuku: [] })
|
|
const result = factory(env.art)
|
|
assert.equal(typeof result.then, 'undefined')
|
|
assert.deepEqual(Object.keys(result), publicKeys)
|
|
assert.deepEqual(Object.keys(result.option), optionKeys)
|
|
for (const key of ['option', 'isHide', 'isStop']) {
|
|
const descriptor = Object.getOwnPropertyDescriptor(result, key)
|
|
assert.equal(typeof descriptor.get, 'function')
|
|
assert.equal(descriptor.set, undefined)
|
|
assert.equal(descriptor.enumerable, true)
|
|
}
|
|
assert.equal(result.option.speed, 5)
|
|
assert.deepEqual(Array.from(result.option.margin), [10, '25%'])
|
|
assert.equal(result.option.mount, env.art.template.$controlsCenter)
|
|
assert.equal(result.isStop, false)
|
|
const internal = result.hide()
|
|
assert.notEqual(internal, result, 'Historical methods return the internal Danmuku, not the public facade')
|
|
assert.equal(internal.name, undefined)
|
|
assert.equal(result.isHide, true)
|
|
assert.equal(result.show(), internal)
|
|
assert.equal(result.reset(), internal)
|
|
assert.equal(result.config({ opacity: 0.6 }), internal)
|
|
assert.equal(result.option.opacity, 0.6)
|
|
const emitted = result.emit({ id: 'row', text: 'hello', time: 15 })
|
|
assert.equal(typeof emitted.then, 'function', 'The old declaration incorrectly promises a synchronous Result')
|
|
assert.equal(await emitted, internal)
|
|
assert.equal(await result.load([{ text: 'appended', time: 16 }]), internal)
|
|
assert.equal(internal.queue.length, 2)
|
|
assert.equal(internal.queue[0].id, 'row')
|
|
assert.equal(result.mount('#external'), undefined)
|
|
assert.equal(env.external.children.length, 1)
|
|
assert.throws(() => result.mount(), /Can not find the mount point/u)
|
|
env.art.emit('destroy')
|
|
assert(env.workers.every(worker => worker.terminated))
|
|
})
|
|
|
|
test(`danmuku ${implementation.name}: load replacement/append, filters, input mutation and event argument identity`, async () => {
|
|
const env = environment(implementation.code)
|
|
let beforeEmit = 0
|
|
let filterReceiver
|
|
const result = env.factory({
|
|
danmuku: [],
|
|
beforeEmit() {
|
|
beforeEmit++
|
|
return true
|
|
},
|
|
filter(danmu) {
|
|
filterReceiver = this
|
|
return danmu.text !== 'filtered'
|
|
},
|
|
})(env.art)
|
|
const initial = await result.load()
|
|
const input = { text: 'zero', time: 0 }
|
|
await result.emit(input)
|
|
assert.equal(input.time, 10.5, 'Historical time:0 uses currentTime + 0.5')
|
|
assert.equal(input.mode, 0)
|
|
assert.equal(input.color, '#FFFFFF')
|
|
assert.equal(filterReceiver, result.option)
|
|
assert.equal(beforeEmit, 0, 'Public emit bypasses the setting beforeEmit callback')
|
|
const originalFilter = result.option.filter
|
|
result.config({ filter: () => false })
|
|
assert.equal(result.option.filter, originalFilter, 'Historical JSON comparison ignores a function-only config change')
|
|
await result.load([{ text: 'filtered' }, { text: 'two', time: 12 }])
|
|
assert.equal(initial.queue.length, 2)
|
|
const loaded = env.events.filter(event => event.name === 'artplayerPluginDanmuku:loaded').at(-1)
|
|
assert.equal(loaded.args[0], initial.queue)
|
|
await result.load()
|
|
assert.equal(initial.queue.length, 0)
|
|
await result.load(() => Promise.resolve([{ text: 'function' }]))
|
|
assert.equal(initial.queue.length, 1)
|
|
const promisedInput = vm.runInContext('Promise.resolve([{text:"promise"}])', env.context)
|
|
await result.load(promisedInput)
|
|
assert.equal(initial.queue.length, 2)
|
|
const failure = new Error('controlled input failure')
|
|
await assert.rejects(result.load(() => Promise.reject(failure)), error => error === failure)
|
|
assert.equal(env.events.at(-1).name, 'artplayerPluginDanmuku:error')
|
|
assert.equal(env.events.at(-1).args[0], failure)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test(`danmuku ${implementation.name}: initial Promise validation and resize cleanup historical discrepancies`, async () => {
|
|
const env = environment(implementation.code)
|
|
const promise = vm.runInContext('Promise.resolve([])', env.context)
|
|
assert.throws(() => env.factory({ danmuku: promise })(env.art), /danmuku/iu)
|
|
const result = env.factory({ danmuku: [] })(env.art)
|
|
await result.load()
|
|
const before = env.listeners.get('resize').length
|
|
env.art.emit('destroy')
|
|
assert.equal(env.listeners.get('resize').length, before, 'Historical destroy removes this.reset instead of registered this.resize; Setting listener also remains')
|
|
})
|
|
|
|
test(`danmuku ${implementation.name}: synchronous initial events, video hooks and settings beforeEmit path`, async () => {
|
|
const env = environment(implementation.code)
|
|
const seen = []
|
|
const result = env.factory({
|
|
danmuku: [],
|
|
beforeEmit(danmu) {
|
|
seen.push({ danmu: { ...danmu }, receiver: this })
|
|
return Promise.resolve(true)
|
|
},
|
|
})(env.art)
|
|
assert.deepEqual(env.events.map(event => event.name), ['artplayerPluginDanmuku:show', 'artplayerPluginDanmuku:config', 'artplayerPluginDanmuku:reset', 'artplayerPluginDanmuku:loaded'])
|
|
env.art.emit('video:play')
|
|
assert.equal(result.isStop, false)
|
|
env.art.emit('video:pause')
|
|
assert.equal(result.isStop, true)
|
|
const root = env.art.template.$controlsCenter.children[0]
|
|
const input = root.querySelector('.apd-input')
|
|
const send = root.querySelector('.apd-send')
|
|
input.value = ' via settings '
|
|
await env.proxies.find(proxy => proxy.target === send && proxy.name === 'click').callback()
|
|
assert.equal(seen.length, 1)
|
|
assert.equal(seen[0].receiver, result.option)
|
|
assert.equal(seen[0].danmu.text, 'via settings')
|
|
assert.equal(seen[0].danmu.time, 10)
|
|
const internal = result.show()
|
|
assert.equal(internal.queue[0].border, true)
|
|
assert.equal(internal.queue[0].time, 10.5)
|
|
assert.equal(input.value, '')
|
|
assert.equal(env.timers.size, 1)
|
|
env.art.emit('destroy')
|
|
assert.equal(env.events.at(-1).name, 'artplayerPluginDanmuku:destroy')
|
|
assert.equal(env.timers.size, 1, 'Historical settings lock timer is not cleared by destroy')
|
|
})
|
|
}
|
|
|
|
test('danmuku frozen Bilibili parser: mapped modes, entities, malformed attributes and URL fallback', async () => {
|
|
const source = sources.get(`${prefix}src/bilibili.js`).replace('export function bilibiliDanmuParseFromUrl', 'function bilibiliDanmuParseFromUrl')
|
|
const requests = []
|
|
const errors = []
|
|
const xml = '<i><d p="1,1,25,255,100,0,user,9"> <hi> & "x" </d><d p="2,4,24,16777215,101,1,u,10">bottom</d><d p="3,5,23,0,102,0,u,11">top</d><d p="4,7,22,10,103,0,u,12">special</d><d p="1,2">invalid</d></i>'
|
|
const context = vm.createContext({
|
|
fetch: async (url) => {
|
|
requests.push(url)
|
|
return { text: async () => xml }
|
|
},
|
|
console: { error: (...args) => errors.push(args) },
|
|
})
|
|
vm.runInContext(`${source}\nglobalThis.parse=bilibiliDanmuParseFromXml;globalThis.load=bilibiliDanmuParseFromUrl`, context)
|
|
const data = await context.load('/fixture.xml')
|
|
assert.deepEqual(requests, ['/fixture.xml'])
|
|
assert.deepEqual(Array.from(data, item => item.mode), [0, 2, 1, 0])
|
|
assert.equal(data[0].text, '<hi> & "x"')
|
|
assert.equal(data[0].color, '#ff', 'Historical parser does not left-pad RGB')
|
|
assert.deepEqual(Object.keys(data[0]), ['text', 'time', 'mode', 'fontSize', 'color', 'timestamp', 'pool', 'userID', 'rowID'])
|
|
assert.equal(context.parse(null).length, 0)
|
|
assert.equal(context.parse('<d p="1,2">x</d>').length, 0)
|
|
assert.equal(errors.length, 1, 'Unavailable Worker falls back to the real parser')
|
|
})
|