test(auto-thumbnail): [PKG-AUTO-THUMB-02] reproduce extraction and lifecycle failures

This commit is contained in:
Harvey Zhao committed 2026-09-13 06:07:37 +08:00
1 parent cdc252b158
commit 9cd6659ab4
11 files changed
+2441 -14

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Frozen historical regressions use the repository Node runner.
import test from 'node:test'
import { autoThumbnailEnvironment, autoThumbnailHistorical } from './helpers/auto-thumbnail.js'
for (const implementation of await autoThumbnailHistorical()) {
test(`Auto-thumbnail ${implementation.name} historical: destroy leaves pending encode and final URL alive`, async () => {
const env = autoThumbnailEnvironment(implementation)
await env.factory({ number: 2 })(env.art)
env.art.emit('video:loadedmetadata')
env.metadata()
const video = env.videos[0]
env.art.emit('destroy')
env.finish()
assert.equal(env.updates.length, 1)
assert.equal(env.urls.size, 1)
assert.equal(env.listeners.get('video:loadedmetadata').size, 1)
assert.equal(typeof video.onloadedmetadata, 'function')
assert.equal(typeof video.onseeked, 'function')
assert.equal(video.src, 'original.mp4')
assert.equal(env.operations.filter(item => item.name === 'pause' || item.name === 'load').length, 0)
env.art.emit('destroy')
assert.equal(env.urls.size, 1)
})
test(`Auto-thumbnail ${implementation.name} historical: delayed old metadata overwrites a newer source sheet`, async () => {
const env = autoThumbnailEnvironment(implementation)
const options = { number: 2, width: 80 }
await env.factory(options)(env.art)
env.art.emit('video:loadedmetadata')
const oldVideo = env.videos[0]
env.art.option.url = 'replacement.mp4'
options.width = 160
env.art.emit('restart')
env.art.emit('video:loadedmetadata')
env.metadata(env.videos[1])
env.finish(0, { source: 'new' })
assert.equal(env.updates.at(-1).width, 160)
env.metadata(oldVideo)
env.finish(1, { source: 'old' })
assert.equal(env.updates.at(-1).width, 80)
assert.equal(env.urls.get(env.updates.at(-1).url).source, 'old')
assert.equal(env.urls.size, 2, 'Separate jobs never revoke each other or release the latest stale sheet')
})
test(`Auto-thumbnail ${implementation.name} historical: repeated metadata opens independent decoders without closing previous jobs`, async () => {
const env = autoThumbnailEnvironment(implementation)
await env.factory({ number: 1 })(env.art)
for (let index = 0; index < 3; index++) {
env.art.emit('video:loadedmetadata')
env.metadata()
env.finish(index)
}
assert.equal(env.videos.length, 3)
assert.equal(env.urls.size, 3)
assert.equal(env.operations.some(item => item.name === 'pause' || item.name === 'load'), false)
assert.equal(env.videos.every(video => typeof video.onseeked === 'function'), true)
})
test(`Auto-thumbnail ${implementation.name} historical: reverse Blob delivery replaces a complete sheet with its initial blank snapshot`, async () => {
const env = autoThumbnailEnvironment(implementation)
await env.factory({ number: 2 })(env.art)
env.art.emit('video:loadedmetadata')
env.metadata()
env.seeked()
env.seeked()
assert.equal(env.blobs.length, 3, 'All native encodes can be pending together')
env.finish(2, { frames: 2 })
const completed = env.updates.at(-1).url
env.finish(0, { frames: 0 })
assert.equal(env.urls.has(completed), false)
assert.equal(env.urls.get(env.updates.at(-1).url).frames, 0)
env.finish(1, { frames: 1 })
assert.equal(env.urls.get(env.updates.at(-1).url).frames, 1)
})
test(`Auto-thumbnail ${implementation.name} historical: null Blob revokes the previous usable URL before throwing`, async () => {
const env = autoThumbnailEnvironment(implementation)
await env.factory({ number: 2 })(env.art)
env.art.emit('video:loadedmetadata')
env.metadata()
env.finish()
const sheet = env.art.thumbnails
env.seeked()
assert.throws(() => env.finish(1, null), /Blob required/)
assert.equal(env.urls.has(sheet.url), false)
assert.equal(env.art.thumbnails, sheet, 'Public state still references the revoked URL')
assert.equal(typeof env.videos[0].onseeked, 'function')
})
test(`Auto-thumbnail ${implementation.name} historical: draw and encode exceptions escape and retain decoder callbacks`, async () => {
for (const phase of ['draw', 'encode']) {
const env = autoThumbnailEnvironment(implementation)
const failure = new Error(`${phase} failed`)
await env.factory({ number: 2 })(env.art)
env.art.emit('video:loadedmetadata')
if (phase === 'draw') {
env.metadata()
env.controls.drawError = failure
assert.throws(() => env.seeked(), error => error === failure)
}
else {
env.controls.encodeError = failure
assert.throws(() => env.metadata(), error => error === failure)
}
assert.equal(typeof env.videos[0].onloadedmetadata, 'function')
assert.equal(env.videos[0].src, 'original.mp4')
assert.equal(env.operations.some(item => item.name === 'pause' || item.name === 'load'), false)
}
})
test(`Auto-thumbnail ${implementation.name} historical: missing context and native media errors have no failure owner`, async () => {
const env = autoThumbnailEnvironment(implementation)
await env.factory({ number: 2 })(env.art)
env.art.emit('video:loadedmetadata')
env.controls.nullContext = true
env.metadata()
assert.throws(() => env.seeked(), /drawImage/)
assert.equal(env.videos[0].onerror, null)
assert.equal(env.operations.some(item => item.name === 'pause' || item.name === 'load'), false)
})
test(`Auto-thumbnail ${implementation.name} historical: thumbnail setter failure leaks the new URL and does not cancel later work`, async () => {
const env = autoThumbnailEnvironment(implementation)
const failure = new Error('thumbnail setter failed')
await env.factory({ number: 2 })(env.art)
env.art.emit('video:loadedmetadata')
env.metadata()
env.controls.updateError = failure
assert.throws(() => env.finish(), error => error === failure)
assert.equal(env.urls.size, 1)
assert.equal(env.updates.length, 0)
env.controls.updateError = null
env.seeked()
env.finish(1)
assert.equal(env.updates.length, 1)
env.art.emit('destroy')
assert.equal(env.urls.size, 1)
})
}
+195
View File
@@ -0,0 +1,195 @@
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { autoThumbnailHistorical } from '../helpers/auto-thumbnail.js'
import { expect, test } from './fixtures.js'
const implementations = (await autoThumbnailHistorical()).filter(item => ['published-1.0.1-main', 'published-1.1.0-main', 'frozen-workspace-js'].includes(item.name))
for (const implementation of implementations) {
test(`Auto-thumbnail ${implementation.name}: native JPEG callbacks outlive destroy and can restore the blank sheet`, async ({ page, browserName }, testInfo) => {
await page.goto('/test/player.html?core=published')
await page.setContent('<!doctype html><div id="probe"></div>')
await page.addScriptTag({ content: implementation.code })
await page.evaluate(async () => {
const createElement = document.createElement.bind(document)
const createUrl = URL.createObjectURL.bind(URL)
const revokeUrl = URL.revokeObjectURL.bind(URL)
const toBlob = HTMLCanvasElement.prototype.toBlob
const probe = window.probe = { videos: [], canvases: [], encodes: [], updates: [], urls: new Map(), revoked: [], seeks: [], destroyed: false }
document.createElement = function (name, ...args) {
const element = createElement(name, ...args)
if (name === 'video') {
probe.videos.push(element)
element.addEventListener('seeked', () => probe.seeks.push(element.currentTime))
}
if (name === 'canvas')
probe.canvases.push(element)
return element
}
HTMLCanvasElement.prototype.toBlob = function (callback, type, quality) {
const index = probe.encodes.length
const record = { index, blob: null, callback, type, width: this.width, height: this.height }
probe.encodes.push(record)
return toBlob.call(this, blob => record.blob = blob, type, quality)
}
URL.createObjectURL = (blob) => {
const url = createUrl(blob)
probe.urls.set(url, blob)
return url
}
URL.revokeObjectURL = (url) => {
probe.revoked.push(url)
probe.urls.delete(url)
revokeUrl(url)
}
const listeners = new Map()
const art = {
option: { url: '/test/pattern.mp4' },
on(name, callback) {
if (!listeners.has(name))
listeners.set(name, new Set())
listeners.get(name).add(callback)
},
emit(name) {
for (const callback of listeners.get(name) || []) callback()
},
get thumbnails() { return probe.updates.at(-1) },
set thumbnails(value) { probe.updates.push({ ...value, afterDestroy: probe.destroyed }) },
}
probe.art = art
probe.nativeToBlob = toBlob
probe.restore = () => {
document.createElement = createElement
HTMLCanvasElement.prototype.toBlob = toBlob
URL.createObjectURL = createUrl
URL.revokeObjectURL = revokeUrl
}
probe.result = await window.artplayerPluginAutoThumbnail({ width: 80, number: 4 })(art)
art.emit('video:loadedmetadata')
})
await expect.poll(() => page.evaluate(() => window.probe.encodes.filter(item => item.blob).length)).toBe(5)
const evidence = await page.evaluate(async () => {
const probe = window.probe
async function pixels(blob) {
const image = document.createElement('img')
const url = URL.createObjectURL(blob)
image.src = url
await image.decode()
const canvas = document.createElement('canvas')
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const ctx = canvas.getContext('2d')
ctx.drawImage(image, 0, 0)
const samples = Array.from({ length: 4 }, (_, index) => [...ctx.getImageData(40 + index * 80, 22, 1, 1).data])
URL.revokeObjectURL(url)
return { width: canvas.width, height: canvas.height, pixel: samples[0], samples, type: blob.type }
}
const blanks = await pixels(probe.encodes[0].blob)
const final = await pixels(probe.encodes[4].blob)
const controlVideo = document.createElement('video')
controlVideo.crossOrigin = 'anonymous'
controlVideo.muted = true
document.body.append(controlVideo)
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Native control did not load')), 5000)
controlVideo.onloadeddata = () => {
clearTimeout(timeout)
resolve()
}
controlVideo.onerror = () => {
clearTimeout(timeout)
reject(new Error(`Native control media error ${controlVideo.error?.code}`))
}
controlVideo.src = '/test/pattern.mp4'
})
await controlVideo.play()
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Native control playback did not advance')), 5000)
const check = () => {
if (controlVideo.currentTime >= 0.25) {
clearTimeout(timeout)
resolve()
}
else {
requestAnimationFrame(check)
}
}
check()
})
controlVideo.pause()
const controlCanvas = document.createElement('canvas')
controlCanvas.width = 800
controlCanvas.height = 45
const controlContext = controlCanvas.getContext('2d')
controlContext.drawImage(controlVideo, 0, 0, 80, 45)
const rawPixel = [...controlContext.getImageData(40, 22, 1, 1).data]
const controlBlob = await new Promise(resolve => probe.nativeToBlob.call(controlCanvas, resolve, 'image/jpeg'))
const native = { readyState: controlVideo.readyState, currentTime: controlVideo.currentTime, rawPixel, ...await pixels(controlBlob) }
controlContext.fillStyle = 'red'
controlContext.fillRect(0, 0, 80, 45)
const staticBlob = await new Promise(resolve => probe.nativeToBlob.call(controlCanvas, resolve, 'image/jpeg'))
const staticControl = await pixels(staticBlob)
probe.encodes[4].callback(probe.encodes[4].blob)
const good = probe.updates.at(-1).url
probe.destroyed = true
probe.art.emit('destroy')
const outstandingAfterDestroy = probe.urls.size
probe.encodes[0].callback(probe.encodes[0].blob)
const bad = probe.updates.at(-1).url
const result = {
blanks,
final,
native,
staticControl,
seeks: probe.seeks,
updates: probe.updates.map(({ url, ...rest }) => rest),
outstandingAfterDestroy,
goodRevoked: !probe.urls.has(good),
staleUrlLive: probe.urls.has(bad),
result: probe.result,
videoState: { src: probe.videos[0].getAttribute('src'), readyState: probe.videos[0].readyState, width: probe.videos[0].videoWidth, seekHandler: typeof probe.videos[0].onseeked, metadataHandler: typeof probe.videos[0].onloadedmetadata },
}
// The test owns the cleanup because the historical plugin does not.
for (const video of probe.videos) {
video.pause()
video.removeAttribute('src')
video.load()
video.remove()
}
for (const url of [...probe.urls.keys()]) URL.revokeObjectURL(url)
probe.restore()
return result
})
await testInfo.attach('auto-thumbnail-native-historical', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), scope: 'Native HTTP video, seeking and JPEG pixels with controlled delivery of native toBlob results; player-facing host is a stub, not a core integration result.', ...evidence }) })
expect(evidence.result).toEqual({ name: 'artplayerPluginAutoThumbnail' })
expect(evidence.blanks.type).toBe('image/jpeg')
expect(evidence.final.width).toBe(800)
expect(evidence.final.height).toBe(45)
expect(evidence.blanks.pixel.slice(0, 3).every(value => value < 5)).toBe(true)
expect(evidence.staticControl.pixel[0]).toBeGreaterThan(240)
expect(evidence.staticControl.pixel[1]).toBeLessThan(10)
expect(evidence.staticControl.pixel[2]).toBeLessThan(10)
expect(evidence.native.currentTime).toBeGreaterThanOrEqual(0.25)
expect(evidence.native.rawPixel.slice(0, 3).some(value => value > 30)).toBe(true)
expect(evidence.native.pixel.slice(0, 3).some(value => value > 30)).toBe(true)
const blackSamples = evidence.final.samples.every(pixel => pixel.slice(0, 3).every(value => value < 5))
if (browserName === 'webkit' && process.platform === 'win32')
expect(blackSamples).toBe(true)
if (blackSamples) {
expect(browserName).toBe('webkit')
testInfo.annotations.push({ type: 'historical-media-defect', description: 'Historical plugin JPEG samples are black, while independent played HTTP video and static red canvas yield colored JPEGs. This is an extraction-path defect, not a waived browser capability.' })
}
else {
expect(evidence.final.samples.every(pixel => pixel.slice(0, 3).some(value => value > 30))).toBe(true)
}
expect(evidence.seeks).toHaveLength(4)
expect(evidence.outstandingAfterDestroy).toBe(1)
expect(evidence.updates.map(item => item.afterDestroy)).toEqual([false, true])
expect(evidence.goodRevoked).toBe(true)
expect(evidence.staleUrlLive).toBe(true)
expect(evidence.videoState.src).toBe('/test/pattern.mp4')
expect(evidence.videoState.width).toBeGreaterThan(0)
expect(evidence.videoState.seekHandler).toBe('function')
expect(evidence.videoState.metadataHandler).toBe('function')
})
}