mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-11 05:26:15 -08:00
refactor(vtt-thumbnail): [PKG-VTT-THUMB-03] own pending requests and preview resources
This commit is contained in:
1 parent
05244d1104
commit
524ddf784f
23 files changed
+3045
-262
No files matched your search
@@ -0,0 +1,91 @@
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { vttThumbnailCandidate } from '../helpers/vtt-thumbnail.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const implementation = await vttThumbnailCandidate()
|
||||
const cues = 'WEBVTT\n\n00:00.000 --> 00:04.000\nvtt-sprite.svg#xywh=0,0,80,45\n\n00:04.000 --> 00:08.000\nvtt-sprite.svg#xywh=80,0,80,45\n'
|
||||
const sprite = '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="45"><path fill="red" d="M0 0h80v45H0z"/><path fill="blue" d="M80 0h80v45H80z"/></svg>'
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`VTT native ${core} core: real progress hover selects sprite regions and destroy removes owned UI`, async ({ page }, testInfo) => {
|
||||
await page.route('**/test/vtt-cues.vtt', route => route.fulfill({ contentType: 'text/vtt', body: cues }))
|
||||
await page.route('**/test/vtt-sprite.svg', route => route.fulfill({ contentType: 'image/svg+xml', body: sprite }))
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
await page.addScriptTag({ content: implementation.code })
|
||||
await page.evaluate(async () => {
|
||||
window.createPlayer('/test/pattern.mp4')
|
||||
await window.art.plugins.add(window.artplayerPluginVttThumbnail({ vtt: '/test/vtt-cues.vtt' }))
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
const progress = page.locator('.art-control-progress')
|
||||
const preview = page.locator('.art-control-vtt-thumbnail')
|
||||
const bounds = await progress.boundingBox()
|
||||
await page.mouse.move(bounds.x + bounds.width * 0.25, bounds.y + bounds.height / 2)
|
||||
await expect(preview).toHaveCSS('display', 'flex')
|
||||
await expect(preview).toHaveCSS('width', '80px')
|
||||
await expect(preview).toHaveCSS('height', '45px')
|
||||
await expect(preview).toHaveCSS('background-position', '0px 0px')
|
||||
await page.mouse.move(bounds.x + bounds.width * 0.75, bounds.y + bounds.height / 2)
|
||||
await expect(preview).toHaveCSS('background-position', '-80px 0px')
|
||||
const pixels = await page.evaluate(async () => {
|
||||
const image = new Image()
|
||||
image.src = '/test/vtt-sprite.svg'
|
||||
await image.decode()
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 160
|
||||
canvas.height = 45
|
||||
const context = canvas.getContext('2d')
|
||||
context.drawImage(image, 0, 0)
|
||||
return [Array.from(context.getImageData(40, 20, 1, 1).data), Array.from(context.getImageData(120, 20, 1, 1).data)]
|
||||
})
|
||||
expect(pixels).toEqual([[255, 0, 0, 255], [0, 0, 255, 255]])
|
||||
await testInfo.attach('selected-sprite', { body: await preview.screenshot(), contentType: 'image/png' })
|
||||
await page.evaluate(() => window.art.destroy(false))
|
||||
await expect(preview).toHaveCount(0)
|
||||
await testInfo.attach('vtt-candidate', { body: JSON.stringify({ core, implementation: hash(implementation.code), pixels }), contentType: 'application/json' })
|
||||
})
|
||||
|
||||
test(`VTT native ${core} core: destroy aborts a pending native fetch and settles registration`, async ({ page }, testInfo) => {
|
||||
let release
|
||||
let requested
|
||||
const started = new Promise((resolve) => {
|
||||
requested = resolve
|
||||
})
|
||||
const pending = new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
await page.route('**/test/vtt-held.vtt', async (route) => {
|
||||
requested()
|
||||
await pending
|
||||
await route.fulfill({ contentType: 'text/vtt', body: cues }).catch(() => {})
|
||||
})
|
||||
try {
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
await page.addScriptTag({ content: implementation.code })
|
||||
await page.evaluate(() => {
|
||||
window.createPlayer('/test/pattern.mp4')
|
||||
const fetch = window.fetch.bind(window)
|
||||
window.fetch = (url, options) => {
|
||||
if (url === '/test/vtt-held.vtt')
|
||||
window.vttSignal = options?.signal
|
||||
return fetch(url, options)
|
||||
}
|
||||
window.vttSettled = false
|
||||
window.vttPending = window.artplayerPluginVttThumbnail({ vtt: '/test/vtt-held.vtt' })(window.art).then((result) => {
|
||||
window.vttSettled = true
|
||||
window.vttResult = result
|
||||
})
|
||||
})
|
||||
await started
|
||||
await page.evaluate(() => window.art.destroy(false))
|
||||
await expect.poll(() => page.evaluate(() => window.vttSettled)).toBe(true)
|
||||
expect(await page.evaluate(() => window.vttSignal.aborted)).toBe(true)
|
||||
release()
|
||||
await page.evaluate(() => window.vttPending)
|
||||
await expect(page.locator('.art-control-vtt-thumbnail')).toHaveCount(0)
|
||||
expect(await page.evaluate(() => window.vttResult)).toEqual({ name: 'artplayerPluginVttThumbnail' })
|
||||
await testInfo.attach('vtt-candidate', { body: JSON.stringify({ core, implementation: hash(implementation.code), nativeAbort: true }), contentType: 'application/json' })
|
||||
}
|
||||
finally { release() }
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
import fs from 'node:fs'
|
||||
import process from 'node:process'
|
||||
import vm from 'node:vm'
|
||||
import { transform } from 'esbuild'
|
||||
import { readMember } from '../../refactor/scripts/releases.mjs'
|
||||
import { verifyVttThumbnailContract } from '../../refactor/scripts/vtt-thumbnail-contract.mjs'
|
||||
import { compilePackage } from './load.js'
|
||||
|
||||
export async function vttThumbnailCandidate() {
|
||||
if (process.env.ARTPLAYER_VTT_THUMBNAIL_BASELINE === '1')
|
||||
return (await vttThumbnailHistorical()).find(item => item.name === 'frozen-workspace-js')
|
||||
return { name: 'candidate', profile: 'current', code: process.env.ARTPLAYER_VTT_THUMBNAIL_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_VTT_THUMBNAIL_ARTIFACT, 'utf8') : await compilePackage('artplayer-plugin-vtt-thumbnail', 'umd') }
|
||||
}
|
||||
|
||||
export async function vttThumbnailHistorical() {
|
||||
const { baseline, archives, sources } = await verifyVttThumbnailContract()
|
||||
@@ -41,6 +50,8 @@ export function vttThumbnailEnvironment(implementation, { script = false, mobile
|
||||
const timers = new Map()
|
||||
const classes = new Set()
|
||||
const styles = {}
|
||||
const removed = []
|
||||
const warnings = []
|
||||
let nextTimer = 0
|
||||
let resolveFetch
|
||||
let rejectFetch
|
||||
@@ -69,7 +80,12 @@ export function vttThumbnailEnvironment(implementation, { script = false, mobile
|
||||
} },
|
||||
controls: { add(option) {
|
||||
controls.push(option)
|
||||
this[option.name] = control
|
||||
option.mounted(control)
|
||||
return control
|
||||
}, remove(name) {
|
||||
removed.push(name)
|
||||
delete this[name]
|
||||
} },
|
||||
on(name, callback) {
|
||||
if (!listeners.has(name))
|
||||
@@ -81,6 +97,8 @@ export function vttThumbnailEnvironment(implementation, { script = false, mobile
|
||||
}
|
||||
const box = {
|
||||
window: {},
|
||||
AbortController,
|
||||
console: { warn(...args) { warnings.push(args) } },
|
||||
fetch(...args) {
|
||||
requests.push(args)
|
||||
return pending || Promise.resolve(response(text))
|
||||
@@ -111,6 +129,8 @@ export function vttThumbnailEnvironment(implementation, { script = false, mobile
|
||||
timers,
|
||||
requests,
|
||||
progress,
|
||||
removed,
|
||||
warnings,
|
||||
resolve(source = text) { resolveFetch(response(source)) },
|
||||
reject(error) { rejectFetch(error) },
|
||||
async emit(name, ...args) { return Promise.all((listeners.get(name) || []).map(callback => callback(...args))) },
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Candidate resource regressions use the Node runner.
|
||||
import test from 'node:test'
|
||||
import { vttText, vttThumbnailCandidate, vttThumbnailEnvironment } from './helpers/vtt-thumbnail.js'
|
||||
|
||||
const implementation = await vttThumbnailCandidate()
|
||||
const create = options => vttThumbnailEnvironment(implementation, options)
|
||||
async function flush() {
|
||||
for (let index = 0; index < 8; index++)
|
||||
await Promise.resolve()
|
||||
}
|
||||
const count = env => [...env.listeners.values()].reduce((sum, items) => sum + items.length, 0)
|
||||
|
||||
test('VTT candidate retains asynchronous registration, live style, floored bounds and literal image paths', async () => {
|
||||
const env = create({ deferred: true })
|
||||
const option = { vtt: '/folder/cues.vtt', style: {} }
|
||||
const pending = env.factory(option)(env.art)
|
||||
assert.equal(env.requests[0][0], option.vtt)
|
||||
assert.equal(env.controls.length, 0)
|
||||
option.style = { opacity: '0.8' }
|
||||
env.resolve()
|
||||
assert.equal((await pending).name, 'artplayerPluginVttThumbnail')
|
||||
assert.equal(env.controls[0].style, option.style)
|
||||
await env.hover(0.5)
|
||||
assert.equal(env.styles.backgroundImage, 'url(/folder/sheet.jpg)')
|
||||
assert.equal(env.styles.backgroundPosition, '-10px -20px')
|
||||
assert.equal(env.styles.left, '60px')
|
||||
await env.hover(0.51)
|
||||
assert.equal(env.styles.backgroundImage, 'url(/folder/second.jpg)')
|
||||
})
|
||||
|
||||
for (const abort of [true, false]) {
|
||||
test(`VTT candidate settles destroyed pending registration without late mounting; AbortController=${abort}`, async () => {
|
||||
const env = create({ deferred: true })
|
||||
if (!abort)
|
||||
env.box.AbortController = undefined
|
||||
let settled = false
|
||||
const pending = env.factory({ vtt: '/cues.vtt' })(env.art).then((result) => {
|
||||
settled = true
|
||||
return result
|
||||
})
|
||||
await env.emit('destroy')
|
||||
await flush()
|
||||
assert.equal(settled, true, 'Destroy settles even a fetch that ignores cancellation')
|
||||
if (abort)
|
||||
assert.equal(env.requests[0][1].signal.aborted, true)
|
||||
env.resolve()
|
||||
await pending
|
||||
await flush()
|
||||
assert.equal(env.controls.length, 0)
|
||||
assert.equal(count(env), 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('VTT candidate observes late rejection after destroy without exposing an unhandled Promise', async () => {
|
||||
const env = create({ deferred: true })
|
||||
const pending = env.factory({})(env.art)
|
||||
await env.emit('destroy')
|
||||
env.reject(new Error('late network error'))
|
||||
await pending
|
||||
await flush()
|
||||
assert.equal(count(env), 0)
|
||||
assert.equal(env.controls.length, 0)
|
||||
})
|
||||
|
||||
test('VTT candidate cancels while response body is pending', async () => {
|
||||
const env = create()
|
||||
let finish
|
||||
env.box.fetch = async () => ({ ok: true, text: () => new Promise((resolve) => {
|
||||
finish = resolve
|
||||
}) })
|
||||
const pending = env.factory({})(env.art)
|
||||
await flush()
|
||||
assert.equal(typeof finish, 'function')
|
||||
await env.emit('destroy')
|
||||
finish(vttText)
|
||||
await pending
|
||||
assert.equal(env.controls.length, 0)
|
||||
assert.equal(count(env), 0)
|
||||
})
|
||||
|
||||
test('VTT candidate clears timer zero, removes its control and ignores captured stale callbacks', async () => {
|
||||
const env = create({ mobile: true })
|
||||
await env.factory({})(env.art)
|
||||
const listener = env.listeners.get('setBar')[0]
|
||||
await env.hover(0.3, 'played', {})
|
||||
assert.equal(env.timers.has(0), true)
|
||||
const timer = env.timers.get(0)
|
||||
await env.emit('destroy')
|
||||
assert.equal(env.timers.size, 0)
|
||||
assert.equal(count(env), 0)
|
||||
assert.deepEqual(env.removed, ['vtt-thumbnail'])
|
||||
env.styles.display = 'sentinel'
|
||||
timer.callback()
|
||||
await listener('hover', 0.7, {})
|
||||
assert.equal(env.styles.display, 'sentinel')
|
||||
await env.emit('destroy')
|
||||
assert.equal(env.removed.length, 1)
|
||||
})
|
||||
|
||||
test('VTT candidate never removes a replacement control owned by another registration', async () => {
|
||||
const env = create()
|
||||
await env.factory({})(env.art)
|
||||
const replacement = {}
|
||||
env.art.controls['vtt-thumbnail'] = replacement
|
||||
await env.emit('destroy')
|
||||
assert.equal(env.art.controls['vtt-thumbnail'], replacement)
|
||||
assert.deepEqual(env.removed, [])
|
||||
assert.equal(count(env), 0)
|
||||
})
|
||||
|
||||
for (const phase of ['fetch', 'text', 'parse', 'mount']) {
|
||||
test(`VTT candidate releases registration resources after ${phase} failure`, async () => {
|
||||
const env = create({ text: phase === 'parse' ? 'WEBVTT\ninvalid\npayload' : vttText })
|
||||
const failure = new Error(`${phase} failure`)
|
||||
if (phase === 'fetch')
|
||||
env.box.fetch = () => { throw failure }
|
||||
if (phase === 'text')
|
||||
env.box.fetch = async () => ({ text: async () => { throw failure } })
|
||||
if (phase === 'mount')
|
||||
env.art.constructor.utils.addClass = () => { throw failure }
|
||||
await assert.rejects(env.factory({})(env.art), error => phase === 'parse' ? error.name === 'TypeError' : error === failure)
|
||||
assert.equal(count(env), 0)
|
||||
assert.equal(env.timers.size, 0)
|
||||
if (phase === 'mount')
|
||||
assert.deepEqual(env.removed, ['vtt-thumbnail'])
|
||||
})
|
||||
}
|
||||
|
||||
test('VTT candidate rejects HTTP failure before parsing its body', async () => {
|
||||
const env = create()
|
||||
let read = false
|
||||
env.box.fetch = async () => ({ ok: false, status: 404, text: async () => {
|
||||
read = true
|
||||
return vttText
|
||||
} })
|
||||
await assert.rejects(env.factory({})(env.art), /404/)
|
||||
assert.equal(read, false)
|
||||
assert.equal(count(env), 0)
|
||||
})
|
||||
|
||||
test('VTT candidate cleans up a listener installed by an on implementation that throws', async () => {
|
||||
const env = create()
|
||||
const original = env.art.on
|
||||
const failure = new Error('partial listener registration')
|
||||
env.art.on = function (name, callback) {
|
||||
original.call(this, name, callback)
|
||||
if (name === 'setBar')
|
||||
throw failure
|
||||
return this
|
||||
}
|
||||
await assert.rejects(env.factory({})(env.art), error => error === failure)
|
||||
assert.equal(count(env), 0)
|
||||
assert.deepEqual(env.removed, ['vtt-thumbnail'])
|
||||
})
|
||||
|
||||
test('VTT candidate does not install when style access destroys the player', async () => {
|
||||
const env = create()
|
||||
await env.factory({ get style() {
|
||||
env.emit('destroy')
|
||||
return {}
|
||||
} })(env.art)
|
||||
assert.equal(env.controls.length, 0)
|
||||
assert.equal(count(env), 0)
|
||||
})
|
||||
|
||||
test('VTT candidate stops subsequent style writes and scheduling after reentrant destroy', async () => {
|
||||
const env = create({ mobile: true })
|
||||
await env.factory({})(env.art)
|
||||
const writes = []
|
||||
env.art.constructor.utils.setStyle = () => {
|
||||
throw new Error('Captured utility should remain unchanged')
|
||||
}
|
||||
Object.defineProperty(env.styles, 'display', {
|
||||
get() { return writes.at(-1) },
|
||||
set(value) {
|
||||
writes.push(value)
|
||||
env.emit('destroy')
|
||||
},
|
||||
configurable: true,
|
||||
})
|
||||
await env.hover(0.3, 'played', {})
|
||||
assert.deepEqual(Object.keys(env.styles), [])
|
||||
assert.deepEqual(writes, ['flex'])
|
||||
assert.equal(env.timers.size, 0)
|
||||
assert.equal(count(env), 0)
|
||||
})
|
||||
|
||||
test('VTT candidate continues other cleanup when a timer cleanup throws', async () => {
|
||||
const env = create({ mobile: true })
|
||||
await env.factory({})(env.art)
|
||||
await env.hover(0.3, 'played', {})
|
||||
env.box.clearTimeout = () => {
|
||||
throw new Error('timer cleanup failed')
|
||||
}
|
||||
await env.emit('destroy')
|
||||
assert.equal(count(env), 0)
|
||||
assert.deepEqual(env.removed, ['vtt-thumbnail'])
|
||||
assert.equal(env.warnings.length, 1)
|
||||
})
|
||||
|
||||
test('VTT candidate starts no work on an already destroyed player', async () => {
|
||||
const env = create()
|
||||
env.art.isDestroy = true
|
||||
assert.equal((await env.factory({})(env.art)).name, 'artplayerPluginVttThumbnail')
|
||||
assert.equal(env.requests.length, 0)
|
||||
assert.equal(count(env), 0)
|
||||
})
|
||||
|
||||
test('VTT candidate removes a partially installed destroy listener before rejecting', async () => {
|
||||
const env = create()
|
||||
const original = env.art.on
|
||||
const error = new Error('destroy registration failed')
|
||||
env.art.on = function (name, callback) {
|
||||
original.call(this, name, callback)
|
||||
throw error
|
||||
}
|
||||
await assert.rejects(env.factory({})(env.art), value => value === error)
|
||||
assert.equal(count(env), 0)
|
||||
assert.equal(env.requests.length, 0)
|
||||
})
|
||||
|
||||
test('VTT candidate invalidates replaced mobile callbacks and preserves the 500ms delay', async () => {
|
||||
const env = create({ mobile: true })
|
||||
await env.factory({})(env.art)
|
||||
await env.hover(0.3, 'played', {})
|
||||
const old = env.timers.get(0)
|
||||
await env.hover(0.7, 'played', {})
|
||||
assert.equal(env.timers.size, 1)
|
||||
old.callback()
|
||||
assert.equal(env.styles.display, 'flex')
|
||||
const latest = [...env.timers.values()][0]
|
||||
assert.equal(latest.delay, 500)
|
||||
latest.callback()
|
||||
assert.equal(env.styles.display, 'none')
|
||||
})
|
||||
|
||||
test('VTT candidate cleans up timer handles returned after reentrant destruction', async () => {
|
||||
const env = create({ mobile: true })
|
||||
await env.factory({})(env.art)
|
||||
const schedule = env.box.setTimeout
|
||||
env.box.setTimeout = (...args) => {
|
||||
env.emit('destroy')
|
||||
return schedule(...args)
|
||||
}
|
||||
await env.hover(0.3, 'played', {})
|
||||
assert.equal(env.timers.size, 0)
|
||||
assert.equal(count(env), 0)
|
||||
})
|
||||
|
||||
test('VTT candidate keeps registrations isolated and does not reload VTT on restart', async () => {
|
||||
const left = create({ mobile: true })
|
||||
const right = create({ mobile: true })
|
||||
await left.factory({ vtt: '/left/cues.vtt' })(left.art)
|
||||
const option = { vtt: '/right/cues.vtt' }
|
||||
await right.factory(option)(right.art)
|
||||
await right.hover(0.3, 'played', {})
|
||||
await left.emit('destroy')
|
||||
option.vtt = '/replacement.vtt'
|
||||
await right.emit('restart')
|
||||
await right.hover(0.7)
|
||||
assert.equal(right.requests.length, 1)
|
||||
assert.equal(right.styles.backgroundImage, 'url(/right/second.jpg)')
|
||||
assert.equal(right.timers.size, 1)
|
||||
assert.equal(right.removed.length, 0)
|
||||
await right.emit('destroy')
|
||||
assert.equal(right.timers.size, 0)
|
||||
})
|
||||
Reference in new issue
Block a user