Files
ArtPlayer/test/helpers/dpip.js
T

266 lines
9.7 KiB
JavaScript

import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import vm from 'node:vm'
import { build, transform } from 'esbuild'
import less from 'less'
import { verifyDpipContract } from '../../refactor/scripts/dpip-contract.mjs'
import { readMember } from '../../refactor/scripts/releases.mjs'
import { verifyInstalledArtifacts } from '../../scripts/installed-artifacts.mjs'
import { getEntryFile } from '../../scripts/projects.js'
export async function dpipCandidate() {
const root = fileURLToPath(new URL('../../', import.meta.url))
if (process.env.ARTPLAYER_BROWSER_ARTIFACTS) {
assert.notEqual(process.env.ARTPLAYER_DPIP_BASELINE, '1', 'Installed candidate cannot use the frozen workspace')
assert(!process.env.ARTPLAYER_DPIP_ARTIFACT, 'Installed candidate cannot override Document PiP artifact')
const { inputs } = verifyInstalledArtifacts(root, process.env.ARTPLAYER_BROWSER_ARTIFACTS, ['artplayer', 'artplayer-plugin-chapter', 'artplayer-plugin-document-pip'])
const input = inputs.find(item => item.name === 'artplayer-plugin-document-pip')
return { name: 'candidate-installed', code: await fs.readFile(input.file, 'utf8'), provenance: input }
}
if (process.env.ARTPLAYER_DPIP_BASELINE === '1')
return (await dpipHistorical()).find(item => item.name === 'frozen-workspace')
if (process.env.ARTPLAYER_DPIP_ARTIFACT)
return { name: 'candidate-artifact', code: await fs.readFile(process.env.ARTPLAYER_DPIP_ARTIFACT, 'utf8') }
const output = await build({ entryPoints: [getEntryFile(path.join(root, 'packages/artplayer-plugin-document-pip'))], bundle: true, write: false, format: 'cjs', target: 'es2020', plugins: [{
name: 'dpip-less-inline',
setup(build) {
build.onLoad({ filter: /\.less$/ }, async ({ path: filename }) => {
const source = await fs.readFile(filename.replace(/\?inline$/, ''), 'utf8')
return { contents: (await less.render(source)).css, loader: 'text' }
})
},
}] })
return { name: 'candidate-source', code: output.outputFiles[0].text }
}
export async function dpipHistorical() {
const contract = await verifyDpipContract()
const css = (await less.render(contract.sources.get('packages/artplayer-plugin-document-pip/src/style.less'))).css
const source = contract.sources.get('packages/artplayer-plugin-document-pip/src/index.js').replace('import style from \'./style.less?inline\'', `const style = ${JSON.stringify(css)}`)
return [
...[contract.baseline.release, ...contract.baseline.previous].filter(release => !release.missingEntrypoints.main).map(release => ({ name: `published-${release.version}`, code: readMember(contract.archives.get(release.version), `package/${release.manifest.main.replace(/^\.\//, '')}`).toString() })),
{ name: 'frozen-workspace', code: (await transform(source, { format: 'cjs', target: 'es2020' })).code },
]
}
function eventTarget() {
const listeners = new Map()
return {
listeners,
addEventListener(name, callback) {
if (!listeners.has(name))
listeners.set(name, new Set())
listeners.get(name).add(callback)
},
removeEventListener(name, callback) { listeners.get(name)?.delete(callback) },
dispatchEvent(event) {
for (const callback of [...(listeners.get(event.type) || [])]) callback(event)
},
}
}
function adopt(node, document) {
node.ownerDocument = document
for (const child of node.children) adopt(child, document)
}
function createNode(tag, document) {
return {
...eventTarget(),
nodeName: tag.toUpperCase(),
ownerDocument: document,
parentNode: null,
children: [],
style: {},
className: '',
textContent: '',
get nextSibling() {
return this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] || null
},
appendChild(child) { return this.insertBefore(child, null) },
insertBefore(child, before) {
assert(before === null || before.parentNode === this, 'Reference node must belong to this parent')
child.remove()
const index = before === null ? this.children.length : this.children.indexOf(before)
this.children.splice(index, 0, child)
child.parentNode = this
adopt(child, this.ownerDocument)
return child
},
removeChild(child) {
assert.equal(child.parentNode, this)
this.children.splice(this.children.indexOf(child), 1)
child.parentNode = null
return child
},
remove() { this.parentNode?.removeChild(this) },
}
}
function createDocument(readyState = 'complete') {
const doc = { ...eventTarget(), readyState }
doc.createElement = tag => createNode(tag, doc)
doc.documentElement = doc.createElement('html')
doc.head = doc.createElement('head')
doc.body = doc.createElement('body')
doc.documentElement.appendChild(doc.head)
doc.documentElement.appendChild(doc.body)
const nodes = () => {
const result = []
const walk = (node) => {
result.push(node)
for (const child of node.children) walk(child)
}
walk(doc.documentElement)
return result
}
doc.getElementById = id => nodes().find(node => node.id === id) || null
doc.querySelectorAll = (selector) => {
if (selector === 'style')
return nodes().filter(node => node.nodeName === 'STYLE')
if (selector === 'link[rel="stylesheet"]')
return nodes().filter(node => node.nodeName === 'LINK' && node.rel === 'stylesheet')
if (selector === 'meta[name="viewport"]')
return nodes().filter(node => node.nodeName === 'META' && node.name === 'viewport')
throw new Error(`Unimplemented controlled selector: ${selector}`)
}
doc.querySelector = selector => doc.querySelectorAll(selector)[0] || null
doc.adoptNode = (node) => {
node.remove()
adopt(node, doc)
return node
}
return doc
}
export function dpipEnvironment(implementation, settings = {}) {
const document = createDocument(settings.readyState)
const windows = []
const requests = []
const sleeps = []
const emitted = []
const warnings = []
const subscriptions = new Map()
const controls = new Map()
const proxies = []
const rebinds = []
const timers = new Map()
let timerId = 0
const window = { ...eventTarget(), document }
document.defaultView = window
function createWindow() {
const win = { ...eventTarget(), document: createDocument(), closed: false, close() {
this.closed = true
} }
win.document.defaultView = win
windows.push(win)
return win
}
if (settings.supported !== false) {
window.documentPictureInPicture = { requestWindow(option) {
requests.push(option)
return settings.requestWindow ? settings.requestWindow(option, createWindow) : Promise.resolve(createWindow())
} }
}
const parent = document.createElement('div')
const player = document.createElement('div')
player.className = 'art-video-player'
const sibling = document.createElement('span')
document.body.appendChild(parent)
parent.appendChild(player)
parent.appendChild(sibling)
const utils = {
append(node, content) { node.content = content },
tooltip(node, value) { node.tooltip = value },
addClass(node, name) { node.className = `${node.className} ${name}`.trim() },
removeClass(node, name) { node.className = node.className.split(' ').filter(value => value !== name).join(' ') },
sleep(ms) {
sleeps.push(ms)
return settings.sleep ? settings.sleep(ms) : Promise.resolve()
},
}
const art = {
template: { $player: player },
constructor: { utils },
icons: { pip: 'pip-icon' },
i18n: { get: name => name },
notice: { show: '' },
pip: false,
isDestroy: false,
proxy(target, name, callback) {
target.addEventListener(name, callback)
const dispose = () => target.removeEventListener(name, callback)
proxies.push(dispose)
return dispose
},
controls: { add(spec) {
const node = document.createElement('button')
controls.set(spec.name, { spec, node })
spec.mounted?.(node)
return node
}, remove(name) { controls.delete(name) } },
events: { bindGlobalEvents: (...args) => rebinds.push({ args, document: player.ownerDocument }) },
on(name, callback) {
if (!subscriptions.has(name))
subscriptions.set(name, new Set())
subscriptions.get(name).add(callback)
return art
},
off(name, callback) { subscriptions.get(name)?.delete(callback) },
emit(name, ...args) {
emitted.push({ name, args })
for (const callback of [...(subscriptions.get(name) || [])]) callback(...args)
},
destroy() {
art.isDestroy = true
art.emit('destroy')
},
}
const context = vm.createContext({ window, document, console: { warn: (...args) => warnings.push(args) }, module: { exports: {} }, setTimeout(callback, ms) {
const id = timerId++
timers.set(id, callback)
sleeps.push(ms)
const delay = settings.sleep ? settings.sleep(ms) : Promise.resolve()
void delay.then(() => {
const run = timers.get(id)
timers.delete(id)
run?.()
})
return id
}, clearTimeout(id) { timers.delete(id) } })
context.exports = context.module.exports
const evaluate = () => vm.runInContext(`(() => { ${implementation.code}\n })()`, context, { timeout: 5000 })
evaluate()
return {
factory: context.module.exports.default || context.module.exports,
exported: context.module.exports,
document,
window,
art,
parent,
player,
sibling,
controls,
windows,
requests,
sleeps,
emitted,
warnings,
proxies,
subscriptions,
rebinds,
timers,
evaluate,
createWindow,
async flush() {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
},
}
}