test(thumbnail): [PKG-TOOL-THUMB-02] reproduce extraction and resource lifecycle gaps

This commit is contained in:
Harvey Zhao committed 2026-09-13 03:33:22 +08:00
1 parent 0338e0b8b1
commit 1cb7779192
14 files changed
+3907 -11

No files matched your search

Binary file not shown.
+1
View File
@@ -75,6 +75,7 @@ const iframeCoreBytes = readMember(await ensureArchive(iframeCore), iframeCoreMe
assert.equal(hash(iframeCoreBytes), iframeCore.files[iframeCoreMember], 'Historical Iframe core differs from frozen release')
add('/published-4.5.9/artplayer.js', iframeCoreBytes, { kind: 'npm-release', version: iframeCore.version, integrity: iframeCore.integrity, member: iframeCoreMember })
add('/test/audio-tone.m4a', fs.readFileSync(path.join(workspace, 'test/browser/media/audio-tone.m4a')), { kind: 'generated-media', file: 'test/browser/media/audio-tone.m4a' })
add('/test/thumbnail-pattern.mp4', fs.readFileSync(path.join(workspace, 'test/browser/media/thumbnail-pattern.mp4')), { kind: 'generated-media', file: 'test/browser/media/thumbnail-pattern.mp4' })
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.mp4': 'video/mp4', '.m4a': 'audio/mp4', '.webm': 'video/webm', '.vtt': 'text/vtt', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg' }
add('/test/declaration-cues.vtt', fs.readFileSync(path.join(workspace, 'test/browser/media/declaration-cues.vtt')), { kind: 'test-subtitles', file: 'test/browser/media/declaration-cues.vtt' })
+49
View File
@@ -0,0 +1,49 @@
import { expect, test } from './fixtures.js'
test('Thumbnail native HTTP and Blob media controls', async ({ page, browserName }, testInfo) => {
await page.goto('/test/player.html?core=published')
const results = await page.evaluate(async () => {
const result = []
for (const url of ['/test/pattern.mp4', '/test/thumbnail-pattern.mp4', '/assets/sample/video.mp4']) {
const blob = await (await fetch(url)).blob()
for (const transport of ['http', 'blob']) {
const video = document.createElement('video')
video.muted = true
video.controls = true
document.body.append(video)
const source = transport === 'blob' ? URL.createObjectURL(blob) : url
const state = await new Promise((resolve) => {
const timeout = setTimeout(() => finish('timeout'), 1500)
function finish(outcome) {
clearTimeout(timeout)
resolve({ outcome, readyState: video.readyState, error: video.error?.code || 0, duration: video.duration, width: video.videoWidth })
}
video.onloadeddata = () => finish('loaded')
video.onerror = () => finish('error')
video.src = source
})
result.push({ url, type: blob.type, transport, ...state })
video.removeAttribute('src')
video.load()
video.remove()
if (transport === 'blob')
URL.revokeObjectURL(source)
}
}
return result
})
await testInfo.attach('thumbnail-native-media-controls', { contentType: 'application/json', body: JSON.stringify(results) })
expect(results).toHaveLength(6)
expect(results.every(item => item.outcome !== 'timeout')).toBe(true)
for (const item of results) {
if (item.transport === 'blob' && item.outcome === 'error') {
expect(browserName).toBe('webkit')
expect(item.error).toBe(4)
testInfo.annotations.push({ type: 'capability-control', description: `Native Blob URL unavailable for ${item.url}; HTTP comparison loads. This is not successful extraction.` })
}
else {
expect(item.outcome).toBe('loaded')
expect(item.width).toBeGreaterThan(0)
}
}
})
+215
View File
@@ -0,0 +1,215 @@
import { Buffer } from 'node:buffer'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { hash } from '../../refactor/scripts/releases.mjs'
import { thumbnailHistorical } from '../helpers/thumbnail.js'
import { expect, test } from './fixtures.js'
const sample = fileURLToPath(new URL('./media/thumbnail-pattern.mp4', import.meta.url))
async function capabilityControl(page, testInfo) {
const native = await page.evaluate(() => window.thumbnailNative)
if (native.blob === 'loaded')
return false
expect(testInfo.project.name).toBe('webkit')
expect(native).toEqual({ http: 'loaded', blob: 'error', error: 4 })
await page.locator('#input').setInputFiles(sample)
await expect.poll(() => page.evaluate(() => tool.video.error?.code || 0)).toBe(4)
const state = await page.evaluate(() => {
const state = { errors: observed.filter(item => item.event === 'error').length, processing: tool.processing, duration: Number.isNaN(tool.video.duration) ? null : tool.video.duration }
tool.destroy()
return state
})
expect(state).toEqual({ errors: 0, processing: false, duration: null })
testInfo.annotations.push({ type: 'capability-control', description: 'Native HTTP succeeds but native Blob and tool Blob return media error 4. Extraction scenario unavailable; no successful extraction claimed.' })
await testInfo.attach('thumbnail-blob-unavailable', { contentType: 'application/json', body: JSON.stringify({ native, tool: state }) })
return true
}
for (const implementation of thumbnailHistorical()) {
test.describe(`Thumbnail tool ${implementation.name}`, () => {
test.beforeEach(async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=published')
await page.setContent('<!doctype html><input id="input" type="file"><div id="wrapper"></div>')
await page.addScriptTag({ content: implementation.code })
await page.evaluate(async () => {
const blob = await (await fetch('/test/thumbnail-pattern.mp4')).blob()
const blobUrl = URL.createObjectURL(blob)
window.thumbnailNative = {}
for (const [kind, src] of [['http', '/test/thumbnail-pattern.mp4'], ['blob', blobUrl]]) {
const video = document.createElement('video')
video.muted = true
document.body.append(video)
window.thumbnailNative[kind] = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Native media control did not settle')), 5000)
video.onloadeddata = () => {
clearTimeout(timeout)
resolve('loaded')
}
video.onerror = () => {
clearTimeout(timeout)
window.thumbnailNative.error = video.error.code
resolve('error')
}
video.src = src
})
video.removeAttribute('src')
video.load()
video.remove()
}
URL.revokeObjectURL(blobUrl)
})
await page.evaluate(() => {
window.created = []
window.revoked = []
const create = URL.createObjectURL.bind(URL)
const revoke = URL.revokeObjectURL.bind(URL)
URL.createObjectURL = (value) => {
const url = create(value)
created.push(url)
return url
}
URL.revokeObjectURL = (url) => {
revoked.push(url)
revoke(url)
}
window.tool = new ArtplayerToolThumbnail({ fileInput: document.querySelector('#input'), number: 10, width: 80, height: 30, column: 3, begin: 0, end: 20 })
window.observed = []
for (const event of ['file', 'video', 'canvas', 'update', 'done', 'error', 'destroy'])
tool.on(event, (...args) => observed.push({ event, progress: event === 'update' ? args[1] : null, processing: tool.processing }))
})
await testInfo.attach('thumbnail-runtime', { contentType: 'application/json', body: JSON.stringify({ name: implementation.name, sha256: hash(implementation.code), recoveredIndividualFile: implementation.legacy, originalNpmArchive: false, media: path.relative(process.cwd(), sample) }) })
})
test('native file selection, seek extraction, PNG pixels and repeat generation', async ({ page }, testInfo) => {
test.setTimeout(60000)
if (await capabilityControl(page, testInfo))
return
await page.locator('#input').setInputFiles(sample)
await expect.poll(() => page.evaluate(() => tool.video.readyState)).toBeGreaterThanOrEqual(2)
await expect.poll(() => page.evaluate(() => observed.some(item => item.event === 'video'))).toBe(true)
const result = await page.evaluate(async () => {
await tool.start()
const first = tool.thumbnailUrl
const blob = await (await fetch(first)).blob()
const image = new Image()
image.src = first
await image.decode()
const canvas = document.createElement('canvas')
canvas.width = image.width
canvas.height = image.height
const context = canvas.getContext('2d')
context.drawImage(image, 0, 0)
const pixels = context.getImageData(0, 0, 80, tool.option.height).data
let nonBlack = 0
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i] + pixels[i + 1] + pixels[i + 2] > 30 && pixels[i + 3] === 255)
nonBlack++
}
const initialEvents = observed.slice()
await tool.start()
const second = tool.thumbnailUrl
tool.destroy()
return { width: image.width, height: image.height, sourceWidth: tool.video.videoWidth, sourceHeight: tool.video.videoHeight, cellHeight: tool.option.height, mime: blob.type, bytes: blob.size, nonBlack, initialEvents, events: observed, first, second, created, revoked, connected: tool.video.isConnected }
})
expect(result.width).toBe(240)
expect(result.cellHeight).toBe(implementation.legacy ? 30 : result.sourceHeight / result.sourceWidth * 80)
expect(result.height).toBe(Math.trunc(result.cellHeight * 4 + 30))
expect(result.mime).toBe('image/png')
expect(result.bytes).toBeGreaterThan(100)
expect(result.nonBlack).toBeGreaterThan(100)
expect(result.initialEvents.filter(item => item.event === 'update').map(item => item.progress)).toEqual(Array.from({ length: 10 }, (_, i) => (i + 1) / 10))
expect(result.events.filter(item => item.event === 'done')).toHaveLength(2)
expect(result.first).not.toBe(result.second)
expect([...result.created].sort()).toEqual([...result.revoked].sort())
expect(result.connected).toBe(false)
await testInfo.attach('thumbnail-extraction', { contentType: 'application/json', body: JSON.stringify(result) })
})
test('DOM drop dispatch exposes missing listener while callable ondrop works', async ({ page }, testInfo) => {
if (await capabilityControl(page, testInfo))
return
const result = await page.evaluate(async () => {
const bytes = await (await fetch('/test/thumbnail-pattern.mp4')).blob()
const file = new File([bytes], 'dropped.mp4', { type: 'video/mp4' })
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const event = new Event('drop', { cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer })
tool.option.fileInput.dispatchEvent(event)
const fromListener = { files: observed.filter(item => item.event === 'file').length, prevented: event.defaultPrevented }
tool.ondrop(event)
return { fromListener, afterDirect: observed.filter(item => item.event === 'file').length, prevented: event.defaultPrevented }
})
expect(result).toEqual({ fromListener: { files: 0, prevented: false }, afterDirect: 1, prevented: true })
await expect.poll(() => page.evaluate(() => tool.video.readyState)).toBeGreaterThanOrEqual(2)
await page.evaluate(() => tool.destroy())
})
test('corrupt media, replacement URLs and repeated destroy reproduce baseline gaps', async ({ page }, testInfo) => {
if (await capabilityControl(page, testInfo))
return
await page.locator('#input').setInputFiles({ name: 'corrupt.mp4', mimeType: 'video/mp4', buffer: Buffer.from('not a media container') })
await expect.poll(() => page.evaluate(() => tool.video.error?.code || 0)).toBeGreaterThan(0)
const failed = await page.evaluate(() => ({ code: tool.video.error.code, toolErrors: observed.filter(item => item.event === 'error').length, first: tool.videoUrl }))
expect(failed.toolErrors).toBe(0)
await page.locator('#input').setInputFiles(sample)
await expect.poll(() => page.evaluate(() => tool.video.readyState)).toBeGreaterThanOrEqual(2)
const result = await page.evaluate(() => {
const value = tool.option.fileInput.value
tool.destroy()
let repeated
try {
tool.destroy()
}
catch (error) { repeated = error.name }
return { value, created, revoked, repeated }
})
expect(result.created).toHaveLength(2)
expect(result.revoked).toEqual([result.created[1]])
expect(result.revoked).not.toContain(failed.first)
expect(result.repeated).toBe('NotFoundError')
expect(result.value === '').toBe(!implementation.legacy)
await testInfo.attach('thumbnail-media-failure', { contentType: 'application/json', body: JSON.stringify({ failed, result }) })
})
test('native PNG callback delivered after destroy still emits update and creates a URL', async ({ page }, testInfo) => {
if (await capabilityControl(page, testInfo))
return
await page.locator('#input').setInputFiles(sample)
await expect.poll(() => page.evaluate(() => tool.video.readyState)).toBeGreaterThanOrEqual(2)
await page.evaluate(() => {
const original = HTMLCanvasElement.prototype.toBlob
HTMLCanvasElement.prototype.toBlob = function (callback, ...args) {
original.call(this, (blob) => {
window.nativeBlob = blob
window.releaseBlob = () => callback(blob)
}, ...args)
}
window.pendingState = 'pending'
tool.start().then(() => {
window.pendingState = 'resolved'
}, () => {
window.pendingState = 'rejected'
})
})
await expect.poll(() => page.evaluate(() => typeof window.releaseBlob)).toBe('function')
const result = await page.evaluate(async () => {
tool.destroy()
const count = created.length
releaseBlob()
await new Promise(resolve => setTimeout(resolve, 0))
return { count, created, revoked, pendingState, events: observed.map(item => item.event), connected: tool.video.isConnected, bytes: nativeBlob.size }
})
expect(result.bytes).toBeGreaterThan(100)
expect(result.created).toHaveLength(result.count + 1)
expect(result.revoked).not.toContain(result.created.at(-1))
expect(result.events.indexOf('update')).toBeGreaterThan(result.events.indexOf('destroy'))
expect(result.pendingState).toBe('pending')
expect(result.connected).toBe(false)
await testInfo.attach('thumbnail-late-native-blob', { contentType: 'application/json', body: JSON.stringify(result) })
})
})
}
/* global created, revoked, ArtplayerToolThumbnail, tool, observed, releaseBlob, pendingState, nativeBlob */
+11 -2
View File
@@ -12,6 +12,8 @@ export function thumbnailHistorical() {
export function thumbnailEnvironment(implementation, script = false) {
const operations = []
const timers = new Map()
const blobs = []
const controls = { deferBlobs: false }
let nextTimer = 0
let nextUrl = 0
const context2D = Object.fromEntries(['fillRect', 'fillText', 'drawImage'].map(name => [name, (...args) => operations.push({ name, args })]))
@@ -60,7 +62,12 @@ export function thumbnailEnvironment(implementation, script = false) {
removeEventListener(name, callback) { this.handlers.get(name)?.delete(callback) }
getContext() { return context2D }
click() { operations.push({ name: 'click', download: this.download, href: this.href }) }
toBlob(callback) { callback({ kind: 'image/png' }) }
toBlob(callback) {
if (controls.deferBlobs)
blobs.push(callback)
else
callback({ kind: 'image/png' })
}
}
const body = new Element('body')
const module = { exports: {} }
@@ -69,6 +76,8 @@ export function thumbnailEnvironment(implementation, script = false) {
document: { body, createElement: tag => new Element(tag) },
URL: {
createObjectURL(value) {
if (value == null)
throw new TypeError('Blob required')
const url = `blob:thumbnail-${++nextUrl}`
operations.push({ name: 'createURL', value, url })
return url
@@ -91,5 +100,5 @@ export function thumbnailEnvironment(implementation, script = false) {
vm.runInNewContext(implementation.code, box)
const exported = script ? box.ArtplayerToolThumbnail : module.exports
const Factory = exported.default || exported
return { box, Element, body, Factory, exported, operations, context2D, timers }
return { box, Element, body, Factory, exported, operations, context2D, timers, blobs, controls }
}
+259
View File
@@ -0,0 +1,259 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Historical extraction and resource baseline.
import test from 'node:test'
import { thumbnailEnvironment, thumbnailHistorical } from './helpers/thumbnail.js'
const flush = () => new Promise(resolve => setImmediate(resolve))
function observe(promise) {
const state = { status: 'pending' }
promise.then(value => Object.assign(state, { status: 'resolved', value }), error => Object.assign(state, { status: 'rejected', error }))
return state
}
async function tick(env) {
const next = env.timers.entries().next().value
assert(next, 'Expected a scheduled timer')
env.timers.delete(next[0])
next[1].callback()
await flush()
return next[1].delay
}
async function frame(env, legacy) {
await flush()
if (legacy) {
await tick(env)
}
else {
assert.equal(typeof env.instance.video.oncanplay, 'function')
env.instance.video.oncanplay()
await flush()
}
}
for (const implementation of thumbnailHistorical()) {
async function setup(options = {}) {
const env = thumbnailEnvironment(implementation)
env.instance = new env.Factory({ fileInput: new env.Element('input'), number: 10, width: 40, height: 30, column: 3, ...options })
env.instance.loadVideo({ name: 'first.mp4', type: 'video/mp4' })
if (implementation.legacy)
await tick(env)
env.instance.video.duration = 100
return env
}
async function complete(env, state, count = 10) {
for (let index = 0; index < count; index++)
await frame(env, implementation.legacy)
if (implementation.legacy) {
assert.equal(state.status, 'pending')
assert.equal(await tick(env), env.instance.option.delay * 2)
}
await flush()
assert.equal(state.status, 'resolved')
assert.equal(state.value, undefined)
}
test(`Thumbnail ${implementation.name}: sequential extraction, progress, done and rerun`, async () => {
const env = await setup({ begin: 10, end: 90 })
const seen = []
env.instance.on('canvas', canvas => seen.push(['canvas', canvas.width, canvas.height, env.instance.processing]))
env.instance.on('update', (url, progress) => seen.push(['update', url, progress, env.instance.processing]))
env.instance.on('done', () => seen.push(['done', env.instance.processing]))
const state = observe(env.instance.start())
assert.equal(env.instance.duration, 80)
assert.equal(env.instance.density, 0.125)
const height = implementation.legacy ? 30 : 22.5
assert.deepEqual(seen, [['canvas', 120, height * 4 + 30, false]])
await complete(env, state)
assert.deepEqual(seen.filter(item => item[0] === 'update').map(item => [item[2], item[3]]), Array.from({ length: 10 }, (_, i) => [(i + 1) / 10, true]))
assert.deepEqual(seen.at(-1), ['done', false])
const draws = env.operations.filter(item => item.name === 'drawImage')
assert.equal(draws.length, 10)
assert.deepEqual(draws.map(item => item.args.slice(1)), Array.from({ length: 10 }, (_, i) => [i % 3 * 40, Math.floor(i / 3) * height, 40, height]))
assert.equal(env.instance.video.currentTime, 86)
assert.equal(env.operations.filter(item => item.name === 'revokeURL').length, 9)
const second = observe(env.instance.start())
await complete(env, second)
assert.equal(seen.filter(item => item[0] === 'done').length, 2)
env.instance.destroy()
assert.equal(env.operations.filter(item => item.name === 'revokeURL').length, 21)
})
test(`Thumbnail ${implementation.name}: range and density guards emit before throwing`, async () => {
for (const [options, pattern] of [[{ begin: 50, end: 20 }, /End time/], [{ begin: 1000 }, /End time/], [{ begin: 0, end: 5 }, /density/]]) {
const env = await setup(options)
const errors = []
env.instance.on('error', message => errors.push(message))
assert.throws(() => env.instance.start(), pattern)
assert.equal(errors.length, 1)
assert.equal(env.instance.processing, false)
assert.equal(env.operations.filter(item => item.name === 'drawImage').length, 0)
env.instance.destroy()
}
const env = await setup({ begin: -20, end: 200 })
const state = observe(env.instance.start())
assert.equal(env.instance.option.begin, 0)
assert.equal(env.instance.option.end, 100)
await complete(env, state)
})
test(`Thumbnail ${implementation.name}: active duplicate start/download reject without replacing extraction`, async () => {
const env = await setup()
const state = observe(env.instance.start())
await frame(env, implementation.legacy)
assert.throws(() => env.instance.start(), /task in progress/)
assert.throws(() => env.instance.download(), /task in progress/)
await complete(env, state, 9)
})
test(`Thumbnail ${implementation.name}: blob callback gates subsequent frames`, async () => {
const env = await setup()
env.controls.deferBlobs = true
const state = observe(env.instance.start())
await frame(env, implementation.legacy)
assert.equal(env.blobs.length, 1)
assert.equal(env.operations.filter(item => item.name === 'drawImage').length, 1)
assert.equal(env.instance.video.currentTime, 5)
assert.equal(state.status, 'pending')
env.controls.deferBlobs = false
env.blobs.shift()({ kind: 'image/png' })
await flush()
await complete(env, state, 9)
})
test(`Thumbnail ${implementation.name}: null blob callback throws outside the unsettled extraction promise`, async () => {
const env = await setup()
env.controls.deferBlobs = true
const state = observe(env.instance.start())
await frame(env, implementation.legacy)
assert.throws(() => env.blobs.shift()(null), /Blob required/)
await flush()
assert.equal(state.status, 'pending')
assert.equal(env.instance.processing, true)
env.instance.destroy()
await flush()
assert.equal(state.status, 'pending')
})
test(`Thumbnail ${implementation.name}: late blob after destroy creates an unowned URL and continues work`, async () => {
const env = await setup()
env.controls.deferBlobs = true
const events = []
env.instance.on('destroy', () => events.push('destroy'))
env.instance.on('update', () => events.push('update'))
const state = observe(env.instance.start())
await frame(env, implementation.legacy)
env.instance.destroy()
env.blobs.shift()({ kind: 'image/png' })
await flush()
assert.deepEqual(events, ['destroy', 'update'])
assert.equal(state.status, 'pending')
assert.equal(env.instance.video.parentNode, null)
assert.equal(env.instance.processing, true)
const last = env.operations.filter(item => item.name === 'createURL').at(-1).url
assert.equal(env.operations.some(item => item.name === 'revokeURL' && item.url === last), false)
assert(implementation.legacy ? env.timers.size > 0 : typeof env.instance.video.oncanplay === 'function')
})
test(`Thumbnail ${implementation.name}: file replacement leaks old URL and preserves the running task`, async () => {
const env = await setup()
const first = env.instance.videoUrl
const state = observe(env.instance.start())
await frame(env, implementation.legacy)
const next = { name: 'second.mp4', type: 'video/mp4' }
env.instance.loadVideo(next)
assert.notEqual(env.instance.videoUrl, first)
assert.equal(env.instance.file, next)
assert.equal(env.instance.processing, true)
assert.equal(state.status, 'pending')
env.instance.destroy()
assert.equal(env.operations.some(item => item.name === 'revokeURL' && item.url === first), false)
assert.equal(env.instance.processing, true)
})
test(`Thumbnail ${implementation.name}: missing metadata start and destruction do not have owned cancellation`, async () => {
const env = await setup()
env.instance.video.duration = 0
const state = observe(env.instance.start())
assert.equal(env.timers.size, 1)
env.instance.destroy()
assert.equal(env.timers.size, 1)
assert.equal(await tick(env), 1000)
assert.equal(state.status, implementation.legacy ? 'rejected' : 'pending')
if (implementation.legacy)
assert.equal(state.error.name, 'TypeError')
else
assert.equal(env.timers.size, 1)
})
test(`Thumbnail ${implementation.name}: changed input and generated wrapper remain unowned`, async () => {
const env = await setup()
const first = env.instance.option.fileInput
const wrapper = new env.Element('div')
env.instance.setup({ fileInput: wrapper })
assert.equal(wrapper.children.length, 1)
assert.equal(env.instance.option.fileInput.handlers.size, 0)
assert.equal(first.handlers.get('change').size, 1)
env.instance.destroy()
assert.equal(first.handlers.get('change').size, 1)
assert.equal(wrapper.children.length, 1)
assert.equal(wrapper.style.position, 'relative')
})
test(`Thumbnail ${implementation.name}: currentTime setter failure rejects and releases processing`, async () => {
const env = await setup()
const failure = new Error('seek assignment failed')
const errors = []
env.instance.on('error', message => errors.push(message))
Object.defineProperty(env.instance.video, 'currentTime', {
get() { return 0 },
set() {
throw failure
},
})
const state = observe(env.instance.start())
await flush()
assert.equal(state.status, 'rejected')
assert.equal(state.error, failure)
assert.equal(env.instance.processing, false)
assert.deepEqual(errors, ['seek assignment failed'])
env.instance.destroy()
})
test(`Thumbnail ${implementation.name}: throwing done listener rejects with historical error multiplicity`, async () => {
const env = await setup()
const failure = new Error('done listener failed')
const errors = []
env.instance.on('error', message => errors.push(message))
env.instance.on('done', () => {
throw failure
})
const state = observe(env.instance.start())
for (let index = 0; index < 10; index++)
await frame(env, implementation.legacy)
if (implementation.legacy)
await tick(env)
await flush()
assert.equal(state.status, 'rejected')
assert.equal(state.error, failure)
assert.equal(env.instance.processing, false)
assert.deepEqual(errors, Array.from({ length: implementation.legacy ? 2 : 1 }).fill('done listener failed'))
env.instance.destroy()
})
test(`Thumbnail ${implementation.name}: update listener failure in deferred callback leaves work pending`, async () => {
const env = await setup()
env.controls.deferBlobs = true
const failure = new Error('update listener failed')
env.instance.on('update', () => {
throw failure
})
const state = observe(env.instance.start())
await frame(env, implementation.legacy)
assert.throws(() => env.blobs.shift()({ kind: 'image/png' }), error => error === failure)
await flush()
assert.equal(state.status, 'pending')
assert.equal(env.instance.processing, true)
assert.equal(typeof env.instance.thumbnailUrl, 'string')
env.instance.destroy()
assert.equal(state.status, 'pending')
})
}