mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
294 lines
16 KiB
JavaScript
294 lines
16 KiB
JavaScript
import process from 'node:process'
|
|
import { hash } from '../../refactor/scripts/releases.mjs'
|
|
import { browserCandidate } from '../helpers/browser-candidate.js'
|
|
import { expect, test } from './fixtures.js'
|
|
|
|
let candidate
|
|
let provenance
|
|
test.beforeAll(async () => {
|
|
;({ code: candidate, provenance } = await browserCandidate('artplayer-plugin-danmuku', process.env.ARTPLAYER_DANMUKU_ARTIFACT))
|
|
})
|
|
|
|
test.beforeEach(async ({ browserName }, testInfo) => {
|
|
await testInfo.attach('danmuku-selected-input', { contentType: 'application/json', body: JSON.stringify({ browserName, provenance }) })
|
|
})
|
|
|
|
async function setup(page, core, scenario, testInfo) {
|
|
const origin = new URL(testInfo.project.use.baseURL).origin
|
|
await page.route('**/*', (route) => {
|
|
return new URL(route.request().url()).origin === origin ? route.continue() : route.abort()
|
|
})
|
|
await page.goto(`/test/player.html?core=${core}`)
|
|
await page.addScriptTag({ content: candidate })
|
|
await page.evaluate(({ scenario }) => {
|
|
window.resourcePlayers = []
|
|
window.resourcePlugins = []
|
|
window.resourceOwners = []
|
|
window.resourceMessages = []
|
|
window.resourceSends = []
|
|
window.resourceErrors = []
|
|
window.resourceWorkers = []
|
|
window.resourceTimers = new Set()
|
|
const nativeSetTimeout = window.setTimeout.bind(window)
|
|
const nativeClearTimeout = window.clearTimeout.bind(window)
|
|
window.setTimeout = (callback, delay, ...args) => {
|
|
const id = nativeSetTimeout(() => {
|
|
window.resourceTimers.delete(id)
|
|
callback(...args)
|
|
}, delay)
|
|
if (window.captureNextResourceTimer && delay === 1000) {
|
|
window.resourceTimers.add(id)
|
|
window.captureNextResourceTimer = false
|
|
}
|
|
return id
|
|
}
|
|
window.clearTimeout = (id) => {
|
|
window.resourceTimers.delete(id)
|
|
nativeClearTimeout(id)
|
|
}
|
|
const NativeWorker = window.Worker
|
|
window.Worker = class extends NativeWorker {
|
|
constructor(...args) {
|
|
super(...args)
|
|
this.record = { terminated: 0 }
|
|
window.resourceWorkers.push(this.record)
|
|
}
|
|
|
|
terminate() {
|
|
this.record.terminated++
|
|
super.terminate()
|
|
}
|
|
}
|
|
const originalError = console.error
|
|
console.error = (...args) => {
|
|
window.resourceErrors.push(args.map(arg => arg?.message || String(arg)))
|
|
originalError(...args)
|
|
}
|
|
window.sendGate = new Promise((resolve, reject) => {
|
|
window.resolveSend = resolve
|
|
window.rejectSend = reject
|
|
})
|
|
window.createResourcePlayer = (index, extra = {}) => {
|
|
const container = index === 0 ? document.querySelector('.player') : document.body.appendChild(document.createElement('div'))
|
|
container.style.cssText = 'width:640px;height:360px'
|
|
const mount = document.body.appendChild(document.createElement('div'))
|
|
mount.id = `resource-mount-${index}`
|
|
const art = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true, fullscreenWeb: true, setting: true })
|
|
window.resourcePlayers[index] = art
|
|
art.on('artplayerPluginDanmuku:visible', item => window.resourceMessages.push(item.text))
|
|
const option = {
|
|
danmuku: [],
|
|
mount,
|
|
heatmap: scenario === 'heatmap' ? { sampling: 0 } : false,
|
|
lockTime: 10,
|
|
margin: [10, 10],
|
|
fontSize: 20,
|
|
speed: 2,
|
|
beforeEmit(danmu) {
|
|
window.resourceSends.push({ index, text: danmu.text, receiver: this === window.resourcePlugins[index].option })
|
|
// Capture the lock scheduled in this send's await continuation, not
|
|
// unrelated browser/core one-second timers while typing or clicking.
|
|
if (window.observeResourceTimers)
|
|
window.captureNextResourceTimer = true
|
|
return scenario === 'pending' || scenario === 'reject' ? window.sendGate : true
|
|
},
|
|
...extra,
|
|
}
|
|
art.plugins.add(window.artplayerPluginDanmuku(option))
|
|
const plugin = art.plugins.artplayerPluginDanmuku
|
|
window.resourcePlugins[index] = plugin
|
|
window.resourceOwners[index] = plugin.show()
|
|
return art
|
|
}
|
|
window.art = window.createResourcePlayer(0)
|
|
document.querySelector('#play').onclick = () => window.art.play()
|
|
}, { scenario })
|
|
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
|
await testInfo.attach('danmuku-resources-inputs', { contentType: 'application/json', body: JSON.stringify({ core, scenario, artifact: provenance.file || provenance.kind, sha256: hash(candidate), native: 'video, RAF, DOM, Worker; observe the first one-second timer after beforeEmit in the synchronous send continuation, no fake timer clock' }) })
|
|
}
|
|
|
|
test.afterEach(async ({ page }, testInfo) => {
|
|
await testInfo.attach('danmuku-resources-page', { contentType: 'image/png', body: await page.screenshot() })
|
|
const evidence = await page.evaluate(() => {
|
|
for (const art of window.resourcePlayers || []) {
|
|
if (!art.isDestroy)
|
|
art.destroy(false)
|
|
}
|
|
return { workers: window.resourceWorkers, sends: window.resourceSends, messages: window.resourceMessages, errors: window.resourceErrors, retainedSettings: document.querySelectorAll('[id^="resource-mount-"] .artplayer-plugin-danmuku').length, timers: window.resourceTimers?.size }
|
|
})
|
|
await testInfo.attach('danmuku-resources-evidence', { contentType: 'application/json', body: JSON.stringify(evidence) })
|
|
expect(evidence.retainedSettings).toBe(0)
|
|
for (const worker of evidence.workers || []) expect(worker.terminated).toBe(1)
|
|
})
|
|
|
|
async function renderResourceRow(page, text) {
|
|
await page.evaluate(async (text) => {
|
|
const owner = await window.resourcePlugins[0].emit({ text, time: 0.2, mode: 1 })
|
|
// Resource tests start with an eligible row, then require a real Worker and
|
|
// rendered DOM. Native timestamp delivery is covered by the scheduler spec.
|
|
owner.setState(owner.queue[owner.queue.length - 1], 'ready')
|
|
}, text)
|
|
await page.click('#play')
|
|
await expect.poll(() => page.evaluate(() => window.resourceMessages)).toContain(text)
|
|
}
|
|
|
|
for (const core of ['published', 'candidate']) {
|
|
test(`${core}: shared setting mount preserves the surviving instance and later user changes`, async ({ page }, testInfo) => {
|
|
await setup(page, core, 'normal', testInfo)
|
|
const result = await page.evaluate(() => {
|
|
const mount = document.querySelector('#resource-mount-0')
|
|
window.createResourcePlayer(1, { mount, theme: 'light' })
|
|
window.art.destroy(false)
|
|
const survivor = { panels: mount.querySelectorAll('.artplayer-plugin-danmuku').length, theme: mount.dataset.danmukuTheme }
|
|
mount.dataset.danmukuTheme = 'user-value'
|
|
window.resourcePlayers[1].destroy(false)
|
|
return { survivor, theme: mount.dataset.danmukuTheme, ownedKeys: Object.keys(mount.dataset).filter(key => key.startsWith('danmuku') && key !== 'danmukuTheme'), panels: mount.querySelectorAll('.artplayer-plugin-danmuku').length }
|
|
})
|
|
expect(result).toEqual({ survivor: { panels: 1, theme: 'light' }, theme: 'user-value', ownedKeys: [], panels: 0 })
|
|
})
|
|
|
|
test(`${core}: invalid setting mount rolls back plugin resources while preserving the original error`, async ({ page }, testInfo) => {
|
|
await setup(page, core, 'normal', testInfo)
|
|
const result = await page.evaluate(() => {
|
|
const container = document.body.appendChild(document.createElement('div'))
|
|
container.style.cssText = 'width:640px;height:360px'
|
|
const art = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true })
|
|
window.resourcePlayers.push(art)
|
|
art.template.$controlsCenter.style.setProperty('display', 'grid', 'important')
|
|
const display = art.template.$controlsCenter.style.display
|
|
let error
|
|
try {
|
|
art.plugins.add(window.artplayerPluginDanmuku({ danmuku: [], mount: '#missing-setting-mount' }))
|
|
}
|
|
catch (failure) {
|
|
error = failure.message
|
|
}
|
|
return { error, panels: container.querySelectorAll('.artplayer-plugin-danmuku').length, worker: window.resourceWorkers.at(-1), display: art.template.$controlsCenter.style.display, priority: art.template.$controlsCenter.style.getPropertyPriority('display'), previousDisplay: display }
|
|
})
|
|
expect(result.error).toContain('Can not find the mount point: #missing-setting-mount')
|
|
expect(result.panels).toBe(0)
|
|
expect(result.worker.terminated).toBe(1)
|
|
expect(result.display).toBe(result.previousDisplay)
|
|
expect(result.priority).toBe('important')
|
|
})
|
|
|
|
test(`${core}: destroy cancels pending setting send without clearing retained input or starting a lock`, async ({ page }, testInfo) => {
|
|
await setup(page, core, 'pending', testInfo)
|
|
const mount = page.locator('#resource-mount-0')
|
|
await mount.locator('.apd-input').fill('PENDING')
|
|
await mount.locator('.apd-send').click()
|
|
expect(await page.evaluate(() => window.resourceSends)).toEqual([{ index: 0, text: 'PENDING', receiver: true }])
|
|
const result = await page.evaluate(async () => {
|
|
const input = document.querySelector('#resource-mount-0 .apd-input')
|
|
const send = document.querySelector('#resource-mount-0 .apd-send')
|
|
window.art.destroy(false)
|
|
window.resolveSend(true)
|
|
await new Promise(resolve => setTimeout(resolve, 30))
|
|
return { value: input.value, locked: send.classList.contains('apd-lock'), queue: window.resourceOwners[0].queue.map(item => item.text), connected: input.isConnected }
|
|
})
|
|
expect(result).toEqual({ value: 'PENDING', locked: false, queue: [], connected: false })
|
|
expect(await page.evaluate(() => window.resourceErrors)).toEqual([])
|
|
})
|
|
|
|
test(`${core}: destroy clears active send timer and leaves another mounted instance usable`, async ({ page }, testInfo) => {
|
|
await setup(page, core, 'normal', testInfo)
|
|
await page.evaluate(() => window.observeResourceTimers = true)
|
|
await page.locator('#resource-mount-0 .apd-input').fill('LOCK')
|
|
await page.locator('#resource-mount-0 .apd-send').click()
|
|
await expect(page.locator('#resource-mount-0 .apd-send')).toHaveClass(/apd-lock/u)
|
|
expect(await page.evaluate(() => window.resourceTimers.size)).toBe(1)
|
|
await page.evaluate(() => {
|
|
window.observeResourceTimers = false
|
|
window.createResourcePlayer(1)
|
|
window.art.destroy(false)
|
|
})
|
|
expect(await page.evaluate(() => window.resourceTimers.size)).toBe(0)
|
|
await expect(page.locator('#resource-mount-0 .artplayer-plugin-danmuku')).toHaveCount(0)
|
|
await page.locator('#resource-mount-1 .apd-input').fill('SECOND')
|
|
await page.locator('#resource-mount-1 .apd-send').click()
|
|
await expect.poll(() => page.evaluate(() => window.resourceOwners[1].queue.map(row => row.text))).toEqual(['SECOND'])
|
|
expect(await page.locator('style#artplayer-plugin-danmuku').count()).toBe(1)
|
|
})
|
|
|
|
test(`${core}: rejected setting callback retains input and original console error without an unhandled rejection`, async ({ page }, testInfo) => {
|
|
await setup(page, core, 'reject', testInfo)
|
|
await page.locator('#resource-mount-0 .apd-input').fill('REJECT')
|
|
await page.locator('#resource-mount-0 .apd-send').click()
|
|
await page.evaluate(() => window.rejectSend(new Error('controlled setting rejection')))
|
|
await expect.poll(() => page.evaluate(() => window.resourceErrors)).toEqual([['Error emitting danmuku:', 'controlled setting rejection']])
|
|
await expect(page.locator('#resource-mount-0 .apd-input')).toHaveValue('REJECT')
|
|
await expect(page.locator('#resource-mount-0 .apd-send')).not.toHaveClass(/apd-lock/u)
|
|
expect(await page.evaluate(() => window.resourceOwners[0].queue.length)).toBe(0)
|
|
})
|
|
|
|
test(`${core}: destroy false releases actual renderer nodes while retaining the player shell`, async ({ page }, testInfo) => {
|
|
await setup(page, core, 'normal', testInfo)
|
|
await renderResourceRow(page, 'VISIBLE')
|
|
const result = await page.evaluate(() => {
|
|
const layer = window.art.template.$danmuku
|
|
const node = [...layer.children].find(item => item.textContent === 'VISIBLE')
|
|
const owner = window.resourceOwners[0]
|
|
window.art.destroy(false)
|
|
return { connected: node.isConnected, nodes: layer.children.length, refs: owner.$refs.length, dangling: owner.queue.filter(item => item.$ref).length, shell: !!window.art.template.$player }
|
|
})
|
|
expect(result).toEqual({ connected: false, nodes: 0, refs: 0, dangling: 0, shell: true })
|
|
})
|
|
|
|
test(`${core}: replacement load removes a moved renderer node and preserves unrelated body content`, async ({ page }, testInfo) => {
|
|
await setup(page, core, 'normal', testInfo)
|
|
await renderResourceRow(page, 'MOVED')
|
|
const result = await page.evaluate(async () => {
|
|
window.art.pause()
|
|
const layer = window.art.template.$danmuku
|
|
const node = [...layer.children].find(item => item.textContent === 'MOVED')
|
|
const foreign = document.createElement('aside')
|
|
document.body.append(node, foreign)
|
|
await window.resourcePlugins[0].load()
|
|
const replacement = { connected: node.isConnected, nodes: layer.childElementCount, foreign: foreign.isConnected, refs: window.resourceOwners[0].$refs.length }
|
|
window.art.destroy(false)
|
|
return { replacement, foreignAfterDestroy: foreign.isConnected }
|
|
})
|
|
expect(result).toEqual({ replacement: { connected: false, nodes: 0, foreign: true, refs: 0 }, foreignAfterDestroy: true })
|
|
})
|
|
|
|
test(`${core}: narrow heatmap invalid sampling stays responsive and two instances have independent gradients`, async ({ page }, testInfo) => {
|
|
await setup(page, core, 'heatmap', testInfo)
|
|
// A second evaluation has fresh module counters but shares this document.
|
|
await page.addScriptTag({ content: candidate })
|
|
await page.evaluate(() => {
|
|
window.createResourcePlayer(1)
|
|
const first = window.art.controls.heatmap
|
|
first.style.width = '99px'
|
|
window.art.emit('resize')
|
|
})
|
|
await expect.poll(() => page.evaluate(() => window.resourcePlayers[1].isReady)).toBe(true)
|
|
await expect.poll(() => page.locator('.art-control-heatmap svg').count()).toBe(2)
|
|
const result = await page.evaluate(() => {
|
|
const first = window.resourcePlayers[0].controls.heatmap
|
|
const second = window.resourcePlayers[1].controls.heatmap
|
|
const gradient = node => node.querySelector('linearGradient').id
|
|
window.resourcePlayers[0].emit('artplayerPluginDanmuku:points', [[0, 0], [49, 40], [99, 10]])
|
|
window.resourcePlayers[1].emit('artplayerPluginDanmuku:points', [[0, 20], [300, 0], [640, 35]])
|
|
window.resourcePlayers[0].emit('setBar', 'played', 0.25)
|
|
window.resourcePlayers[1].emit('setBar', 'played', 0.75)
|
|
return {
|
|
ids: [gradient(first), gradient(second)],
|
|
fills: [first.querySelector('path').getAttribute('fill'), second.querySelector('path').getAttribute('fill')],
|
|
offsets: [first.querySelector('#heatmap-start').getAttribute('offset'), second.querySelector('#heatmap-start').getAttribute('offset')],
|
|
validPaths: [first, second].every(node => !/NaN|Infinity/.test(node.querySelector('path').getAttribute('d'))),
|
|
}
|
|
})
|
|
expect(new Set(result.ids).size).toBe(2)
|
|
expect(result.ids[0]).toBe('heatmap-solids')
|
|
expect(result.fills).toEqual(result.ids.map(id => `url(#${id})`))
|
|
expect(result.offsets).toEqual(['25%', '75%'])
|
|
expect(result.validPaths).toBe(true)
|
|
await testInfo.attach('danmuku-resources-heatmap', { contentType: 'application/json', body: JSON.stringify(result) })
|
|
await testInfo.attach('danmuku-heatmap-live-page', { contentType: 'image/png', body: await page.screenshot({ fullPage: true }) })
|
|
await page.evaluate(() => window.art.destroy(false))
|
|
await expect(page.locator('.art-control-heatmap')).toHaveCount(1)
|
|
await page.evaluate(() => window.resourcePlayers[1].emit('resize'))
|
|
await expect(page.locator('.art-control-heatmap svg')).toHaveCount(1)
|
|
})
|
|
}
|