mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-11 13:56:15 -08:00
refactor(thumbnail): [PKG-TOOL-THUMB-03] own extraction jobs and source cancellation
This commit is contained in:
1 parent
426c0c5d42
commit
efd9f42fb9
22 files changed
+9430
-295
No files matched your search
@@ -8,7 +8,7 @@ import { expect, test } from './fixtures.js'
|
||||
|
||||
const sample = fileURLToPath(new URL('./media/thumbnail-pattern.mp4', import.meta.url))
|
||||
|
||||
async function capabilityControl(page, testInfo) {
|
||||
async function capabilityControl(page, testInfo, lifecycle) {
|
||||
const native = await page.evaluate(() => window.thumbnailNative)
|
||||
if (native.blob === 'loaded')
|
||||
return false
|
||||
@@ -21,13 +21,13 @@ async function capabilityControl(page, testInfo) {
|
||||
tool.destroy()
|
||||
return state
|
||||
})
|
||||
expect(state).toEqual({ errors: 0, processing: false, duration: null })
|
||||
expect(state).toEqual({ errors: lifecycle ? 1 : 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
|
||||
}
|
||||
|
||||
const implementations = [...thumbnailHistorical(), { ...await thumbnailCandidate(), inputOwnership: true }]
|
||||
const implementations = [...thumbnailHistorical(), { ...await thumbnailCandidate(), inputOwnership: true, lifecycle: true }]
|
||||
for (const implementation of implementations) {
|
||||
test.describe(`Thumbnail tool ${implementation.name}`, () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
@@ -85,7 +85,7 @@ for (const implementation of implementations) {
|
||||
|
||||
test('native file selection, seek extraction, PNG pixels and repeat generation', async ({ page }, testInfo) => {
|
||||
test.setTimeout(60000)
|
||||
if (await capabilityControl(page, testInfo))
|
||||
if (await capabilityControl(page, testInfo, implementation.lifecycle))
|
||||
return
|
||||
await page.locator('#input').setInputFiles(sample)
|
||||
await expect.poll(() => page.evaluate(() => tool.video.readyState)).toBeGreaterThanOrEqual(2)
|
||||
@@ -111,8 +111,10 @@ for (const implementation of implementations) {
|
||||
const initialEvents = observed.slice()
|
||||
await tool.start()
|
||||
const second = tool.thumbnailUrl
|
||||
const sourceWidth = tool.video.videoWidth
|
||||
const sourceHeight = tool.video.videoHeight
|
||||
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 }
|
||||
return { width: image.width, height: image.height, sourceWidth, sourceHeight, cellHeight: tool.option.height, mime: blob.type, bytes: blob.size, nonBlack, initialEvents, events: observed, first, second, created, revoked, connected: tool.video.isConnected, retiredReadyState: tool.video.readyState, retiredSrc: tool.video.getAttribute('src') }
|
||||
})
|
||||
expect(result.width).toBe(240)
|
||||
expect(result.cellHeight).toBe(implementation.legacy ? 30 : result.sourceHeight / result.sourceWidth * 80)
|
||||
@@ -125,11 +127,15 @@ for (const implementation of implementations) {
|
||||
expect(result.first).not.toBe(result.second)
|
||||
expect([...result.created].sort()).toEqual([...result.revoked].sort())
|
||||
expect(result.connected).toBe(false)
|
||||
if (implementation.lifecycle) {
|
||||
expect(result.retiredReadyState).toBe(0)
|
||||
expect(result.retiredSrc).toBe(null)
|
||||
}
|
||||
await testInfo.attach('thumbnail-extraction', { contentType: 'application/json', body: JSON.stringify(result) })
|
||||
})
|
||||
|
||||
test(`DOM drop dispatch ${implementation.inputOwnership ? 'uses the registered handler' : 'exposes missing listener'} while callable ondrop works`, async ({ page }, testInfo) => {
|
||||
if (await capabilityControl(page, testInfo))
|
||||
if (await capabilityControl(page, testInfo, implementation.lifecycle))
|
||||
return
|
||||
const result = await page.evaluate(async () => {
|
||||
const bytes = await (await fetch('/test/thumbnail-pattern.mp4')).blob()
|
||||
@@ -149,12 +155,12 @@ for (const implementation of implementations) {
|
||||
})
|
||||
|
||||
test('corrupt media, replacement URLs and repeated destroy reproduce baseline gaps', async ({ page }, testInfo) => {
|
||||
if (await capabilityControl(page, testInfo))
|
||||
if (await capabilityControl(page, testInfo, implementation.lifecycle))
|
||||
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)
|
||||
expect(failed.toolErrors).toBe(implementation.lifecycle ? 1 : 0)
|
||||
await page.locator('#input').setInputFiles(sample)
|
||||
await expect.poll(() => page.evaluate(() => tool.video.readyState)).toBeGreaterThanOrEqual(2)
|
||||
const result = await page.evaluate(() => {
|
||||
@@ -168,15 +174,15 @@ for (const implementation of implementations) {
|
||||
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.revoked).toEqual(implementation.lifecycle ? result.created : [result.created[1]])
|
||||
expect(result.revoked.includes(failed.first)).toBe(!!implementation.lifecycle)
|
||||
expect(result.repeated).toBe(implementation.inputOwnership ? undefined : '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))
|
||||
test(`native PNG callback delivered after destroy ${implementation.lifecycle ? 'is ignored after cancellation' : 'still emits update and creates a URL'}`, async ({ page }, testInfo) => {
|
||||
if (await capabilityControl(page, testInfo, implementation.lifecycle))
|
||||
return
|
||||
await page.locator('#input').setInputFiles(sample)
|
||||
await expect.poll(() => page.evaluate(() => tool.video.readyState)).toBeGreaterThanOrEqual(2)
|
||||
@@ -204,13 +210,69 @@ for (const implementation of implementations) {
|
||||
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.created).toHaveLength(result.count + (implementation.lifecycle ? 0 : 1))
|
||||
if (implementation.lifecycle) {
|
||||
expect(result.revoked).toContain(result.created.at(-1))
|
||||
expect(result.events).not.toContain('update')
|
||||
}
|
||||
else {
|
||||
expect(result.revoked).not.toContain(result.created.at(-1))
|
||||
expect(result.events.indexOf('update')).toBeGreaterThan(result.events.indexOf('destroy'))
|
||||
}
|
||||
expect(result.pendingState).toBe(implementation.lifecycle ? 'rejected' : 'pending')
|
||||
expect(result.connected).toBe(false)
|
||||
await testInfo.attach('thumbnail-late-native-blob', { contentType: 'application/json', body: JSON.stringify(result) })
|
||||
})
|
||||
|
||||
if (implementation.lifecycle) {
|
||||
test('source replacement cancels held native encoding and completes only the latest job', async ({ page }, testInfo) => {
|
||||
if (await capabilityControl(page, testInfo, true))
|
||||
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
|
||||
let first = true
|
||||
HTMLCanvasElement.prototype.toBlob = function (callback, ...args) {
|
||||
if (first) {
|
||||
first = false
|
||||
original.call(this, (blob) => {
|
||||
window.firstEncodedBytes = blob.size
|
||||
window.releaseFirst = () => callback(blob)
|
||||
}, ...args)
|
||||
}
|
||||
else {
|
||||
original.call(this, callback, ...args)
|
||||
}
|
||||
}
|
||||
window.oldState = 'pending'
|
||||
window.updateSources = []
|
||||
tool.on('update', () => window.updateSources.push(tool.videoUrl))
|
||||
tool.start().then(() => {
|
||||
window.oldState = 'resolved'
|
||||
}, (error) => {
|
||||
window.oldState = error.name
|
||||
})
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => typeof window.releaseFirst)).toBe('function')
|
||||
await page.locator('#input').setInputFiles(sample)
|
||||
const result = await page.evaluate(async () => {
|
||||
const source = tool.videoUrl
|
||||
const next = tool.start()
|
||||
window.releaseFirst()
|
||||
await next
|
||||
tool.destroy()
|
||||
return { oldState: window.oldState, firstEncodedBytes: window.firstEncodedBytes, source, updateSources: window.updateSources, created, revoked, events: observed.map(item => item.event), connected: tool.video.isConnected }
|
||||
})
|
||||
expect(result.oldState).toBe('AbortError')
|
||||
expect(result.firstEncodedBytes).toBeGreaterThan(100)
|
||||
expect(result.updateSources).toEqual(Array.from({ length: 10 }).fill(result.source))
|
||||
expect(result.events.filter(event => event === 'done')).toHaveLength(1)
|
||||
expect([...result.created].sort()).toEqual([...result.revoked].sort())
|
||||
expect(result.connected).toBe(false)
|
||||
await testInfo.attach('thumbnail-native-source-cancellation', { contentType: 'application/json', body: JSON.stringify(result) })
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
/* global created, revoked, ArtplayerToolThumbnail, tool, observed, releaseBlob, pendingState, nativeBlob */
|
||||
@@ -0,0 +1,439 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Candidate regressions also run against frozen workspace main.
|
||||
import test from 'node:test'
|
||||
import { thumbnailCandidate, thumbnailEnvironment } from './helpers/thumbnail.js'
|
||||
|
||||
const implementation = await thumbnailCandidate()
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve))
|
||||
function dispatch(video, name) {
|
||||
for (const callback of [...(video.handlers.get(name) || [])])
|
||||
callback({ target: video, type: name })
|
||||
}
|
||||
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
|
||||
}
|
||||
function setup(ready = true) {
|
||||
const env = thumbnailEnvironment(implementation)
|
||||
env.instance = new env.Factory({ fileInput: new env.Element('input'), number: 10, width: 40, column: 3 })
|
||||
env.instance.loadVideo({ name: 'first.mp4', type: 'video/mp4' })
|
||||
if (ready) {
|
||||
env.instance.video.duration = 100
|
||||
dispatch(env.instance.video, 'loadedmetadata')
|
||||
}
|
||||
return env
|
||||
}
|
||||
async function firstFrame(env) {
|
||||
await flush()
|
||||
assert.equal(typeof env.instance.video.oncanplay, 'function')
|
||||
env.instance.video.oncanplay()
|
||||
await flush()
|
||||
}
|
||||
async function complete(env, state, count = 10) {
|
||||
for (let index = 0; index < count; index++)
|
||||
await firstFrame(env)
|
||||
assert.equal(state.status, 'resolved')
|
||||
assert.equal(state.value, undefined)
|
||||
}
|
||||
function cancelled(state) {
|
||||
assert.equal(state.status, 'rejected')
|
||||
assert.equal(state.error.name, 'AbortError')
|
||||
}
|
||||
|
||||
test('Thumbnail owned extraction preserves progress, event order, repeat runs and prior oncanplay', async () => {
|
||||
const env = setup()
|
||||
const previous = () => {}
|
||||
env.instance.video.oncanplay = previous
|
||||
const events = []
|
||||
for (const name of ['canvas', 'update', 'done'])
|
||||
env.instance.on(name, (value, progress) => events.push([name, progress, env.instance.processing]))
|
||||
const state = observe(env.instance.start())
|
||||
assert.deepEqual(events[0], ['canvas', undefined, false])
|
||||
await complete(env, state)
|
||||
assert.deepEqual(events.filter(item => item[0] === 'update').map(item => item.slice(1)), Array.from({ length: 10 }, (_, i) => [(i + 1) / 10, true]))
|
||||
assert.deepEqual(events.at(-1), ['done', undefined, false])
|
||||
assert.equal(env.instance.video.oncanplay, previous)
|
||||
assert.equal(env.instance.video.handlers.get('seeked')?.size || 0, 0)
|
||||
const second = observe(env.instance.start())
|
||||
await complete(env, second)
|
||||
env.instance.destroy()
|
||||
assert.deepEqual(env.operations.filter(item => item.name === 'createURL').map(item => item.url).sort(), env.operations.filter(item => item.name === 'revokeURL').map(item => item.url).sort())
|
||||
})
|
||||
|
||||
test('Thumbnail cancellation owns both metadata polling and a not-yet-started frame', async () => {
|
||||
for (const ready of [false, true]) {
|
||||
const env = setup(ready)
|
||||
const state = observe(env.instance.start())
|
||||
env.instance.destroy()
|
||||
await flush()
|
||||
cancelled(state)
|
||||
assert.equal(env.timers.size, 0)
|
||||
assert.equal(env.instance.processing, false)
|
||||
assert.equal(env.operations.filter(item => item.name === 'drawImage').length, 0)
|
||||
for (const callbacks of env.instance.video.handlers.values())
|
||||
assert.equal(callbacks.size, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('Thumbnail metadata event resumes extraction and duplicate pending start is rejected', async () => {
|
||||
const env = setup(false)
|
||||
const state = observe(env.instance.start())
|
||||
assert.throws(() => env.instance.start(), /task in progress/)
|
||||
env.instance.video.duration = 100
|
||||
dispatch(env.instance.video, 'loadedmetadata')
|
||||
assert.equal(env.timers.size, 0)
|
||||
await complete(env, state)
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test('Thumbnail native media errors settle waiting and active jobs once', async () => {
|
||||
for (const ready of [false, true]) {
|
||||
const env = setup(ready)
|
||||
const errors = []
|
||||
env.instance.on('error', message => errors.push(message))
|
||||
const state = observe(env.instance.start())
|
||||
await flush()
|
||||
env.instance.video.error = { code: 3 }
|
||||
dispatch(env.instance.video, 'error')
|
||||
await flush()
|
||||
assert.equal(state.status, 'rejected')
|
||||
assert.match(state.error.message, /media error 3/)
|
||||
assert.deepEqual(errors, ['Unable to load video: media error 3'])
|
||||
assert.equal(env.instance.processing, false)
|
||||
assert.equal(env.timers.size, 0)
|
||||
env.instance.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('Thumbnail idle native errors report without creating a task and stale source errors are ignored', () => {
|
||||
const env = setup(false)
|
||||
const oldError = [...(env.instance.video.handlers.get('error') || [])][0]
|
||||
const errors = []
|
||||
env.instance.on('error', message => errors.push(message))
|
||||
env.instance.loadVideo({ name: 'second.mp4', type: 'video/mp4' })
|
||||
assert.equal(typeof oldError, 'function')
|
||||
oldError()
|
||||
assert.deepEqual(errors, [])
|
||||
env.instance.video.error = { code: 4 }
|
||||
dispatch(env.instance.video, 'error')
|
||||
dispatch(env.instance.video, 'error')
|
||||
assert.deepEqual(errors, ['Unable to load video: media error 4'])
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test('Thumbnail destroy during native encoding rejects immediately and ignores the late Blob', async () => {
|
||||
const env = setup()
|
||||
env.controls.deferBlobs = true
|
||||
const updates = []
|
||||
env.instance.on('update', value => updates.push(value))
|
||||
const state = observe(env.instance.start())
|
||||
await firstFrame(env)
|
||||
env.instance.destroy()
|
||||
await flush()
|
||||
cancelled(state)
|
||||
const count = env.operations.filter(item => item.name === 'createURL').length
|
||||
env.blobs.shift()({ kind: 'image/png' })
|
||||
await flush()
|
||||
assert.equal(env.operations.filter(item => item.name === 'createURL').length, count)
|
||||
assert.deepEqual(updates, [])
|
||||
assert.equal(env.instance.video.handlers.get('seeked')?.size || 0, 0)
|
||||
})
|
||||
|
||||
test('Thumbnail switching source cancels the old encoding without cancelling the next job', async () => {
|
||||
const env = setup()
|
||||
env.controls.deferBlobs = true
|
||||
const oldUrl = env.instance.videoUrl
|
||||
const oldState = observe(env.instance.start())
|
||||
await firstFrame(env)
|
||||
const late = env.blobs.shift()
|
||||
env.instance.loadVideo({ name: 'second.mp4', type: 'video/mp4' })
|
||||
dispatch(env.instance.video, 'loadedmetadata')
|
||||
const next = observe(env.instance.start())
|
||||
await flush()
|
||||
cancelled(oldState)
|
||||
late({ kind: 'late-png' })
|
||||
assert.equal(env.instance.processing, true)
|
||||
assert.equal(env.instance.thumbnailUrl, undefined)
|
||||
assert(env.operations.some(item => item.name === 'revokeURL' && item.url === oldUrl))
|
||||
env.controls.deferBlobs = false
|
||||
await complete(env, next)
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
for (const failure of ['null-blob', 'draw', 'toBlob', 'seek', 'update-listener']) {
|
||||
test(`Thumbnail ${failure} failure rejects instead of abandoning the extraction promise`, async () => {
|
||||
const env = setup()
|
||||
env.controls.deferBlobs = true
|
||||
const error = new Error(failure)
|
||||
const errors = []
|
||||
env.instance.on('error', message => errors.push(message))
|
||||
if (failure === 'draw')
|
||||
env.context2D.drawImage = () => { throw error }
|
||||
if (failure === 'toBlob')
|
||||
env.Element.prototype.toBlob = () => { throw error }
|
||||
if (failure === 'seek')
|
||||
Object.defineProperty(env.instance.video, 'currentTime', { get: () => 0, set() { throw error } })
|
||||
if (failure === 'update-listener')
|
||||
env.instance.on('update', () => { throw error })
|
||||
const state = observe(env.instance.start())
|
||||
if (failure === 'seek')
|
||||
await flush()
|
||||
else
|
||||
await firstFrame(env)
|
||||
if (failure === 'null-blob' || failure === 'update-listener')
|
||||
env.blobs.shift()(failure === 'null-blob' ? null : { kind: 'image/png' })
|
||||
await flush()
|
||||
assert.equal(state.status, 'rejected')
|
||||
if (failure === 'null-blob')
|
||||
assert.match(state.error.message, /Unable to create thumbnail/)
|
||||
else
|
||||
assert.equal(state.error, error)
|
||||
assert.equal(errors.length, 1)
|
||||
assert.equal(env.instance.processing, false)
|
||||
env.instance.destroy()
|
||||
})
|
||||
}
|
||||
|
||||
test('Thumbnail duplicate readiness and duplicate Blob callback cannot duplicate a frame', async () => {
|
||||
const env = setup()
|
||||
env.controls.deferBlobs = true
|
||||
const state = observe(env.instance.start())
|
||||
await flush()
|
||||
const ready = env.instance.video.oncanplay
|
||||
ready()
|
||||
ready()
|
||||
assert.equal(env.blobs.length, 1)
|
||||
const blob = env.blobs.shift()
|
||||
const updates = []
|
||||
env.instance.on('update', (url, progress) => updates.push(progress))
|
||||
blob({ kind: 'image/png' })
|
||||
blob({ kind: 'image/png' })
|
||||
assert.deepEqual(updates, [0.1])
|
||||
env.controls.deferBlobs = false
|
||||
await complete(env, state, 9)
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test('Thumbnail canvas listener destruction never starts a frame', async () => {
|
||||
const env = setup()
|
||||
const updates = []
|
||||
env.instance.on('canvas', () => env.instance.destroy())
|
||||
env.instance.on('update', () => updates.push('update'))
|
||||
const state = observe(env.instance.start())
|
||||
await flush()
|
||||
cancelled(state)
|
||||
assert.deepEqual(updates, [])
|
||||
assert.equal(env.instance.processing, false)
|
||||
assert.equal(env.operations.filter(item => item.name === 'drawImage').length, 0)
|
||||
})
|
||||
|
||||
test('Thumbnail file callback destruction and nested load cannot overwrite the latest source', () => {
|
||||
for (const action of ['destroy', 'replace']) {
|
||||
const env = setup()
|
||||
const videos = []
|
||||
env.instance.on('video', () => videos.push(env.instance.file.name))
|
||||
env.instance.once('file', () => {
|
||||
if (action === 'destroy')
|
||||
env.instance.destroy()
|
||||
else
|
||||
env.instance.loadVideo({ name: 'latest.mp4', type: 'video/mp4' })
|
||||
})
|
||||
env.instance.loadVideo({ name: 'outer.mp4', type: 'video/mp4' })
|
||||
assert.deepEqual(videos, action === 'destroy' ? [] : ['latest.mp4'])
|
||||
if (action === 'replace')
|
||||
assert.equal(env.instance.video.src, env.instance.videoUrl)
|
||||
env.instance.destroy()
|
||||
const created = env.operations.filter(item => item.name === 'createURL').map(item => item.url)
|
||||
const revoked = new Set(env.operations.filter(item => item.name === 'revokeURL').map(item => item.url))
|
||||
assert(created.every(url => revoked.has(url)))
|
||||
}
|
||||
})
|
||||
|
||||
test('Thumbnail file callback start waits for metadata of the new source', async () => {
|
||||
const env = setup()
|
||||
let state
|
||||
env.instance.once('file', () => {
|
||||
state = observe(env.instance.start())
|
||||
})
|
||||
env.instance.loadVideo({ name: 'second.mp4', type: 'video/mp4' })
|
||||
await flush()
|
||||
assert.equal(env.instance.processing, false)
|
||||
assert.equal(env.operations.filter(item => item.name === 'drawImage').length, 0)
|
||||
dispatch(env.instance.video, 'loadedmetadata')
|
||||
await complete(env, state)
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test('Thumbnail error listener failure rejects with its error after resources are detached', async () => {
|
||||
const env = setup()
|
||||
env.controls.deferBlobs = true
|
||||
const error = new Error('error listener failed')
|
||||
env.instance.on('error', () => {
|
||||
throw error
|
||||
})
|
||||
const state = observe(env.instance.start())
|
||||
await firstFrame(env)
|
||||
env.blobs.shift()(null)
|
||||
await flush()
|
||||
assert.equal(state.error, error)
|
||||
assert.equal(env.instance.processing, false)
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test('Thumbnail done listener may start a new task without old completion clearing it', async () => {
|
||||
const env = setup()
|
||||
let second
|
||||
env.instance.once('done', () => {
|
||||
second = observe(env.instance.start())
|
||||
})
|
||||
const first = observe(env.instance.start())
|
||||
await complete(env, first)
|
||||
assert.equal(env.instance.processing, true)
|
||||
await complete(env, second)
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test('Thumbnail private URL ownership survives public URL replacement and closed calls allocate nothing', async () => {
|
||||
const env = setup()
|
||||
const first = observe(env.instance.start())
|
||||
await complete(env, first)
|
||||
const owned = env.operations.filter(item => item.name === 'createURL').map(item => item.url)
|
||||
env.instance.videoUrl = 'blob:external-video'
|
||||
env.instance.thumbnailUrl = 'blob:external-sheet'
|
||||
env.instance.destroy()
|
||||
const revoked = new Set(env.operations.filter(item => item.name === 'revokeURL').map(item => item.url))
|
||||
assert(owned.every(url => revoked.has(url)))
|
||||
const after = observe(env.instance.start())
|
||||
assert.equal(env.instance.download(), env.instance)
|
||||
await flush()
|
||||
cancelled(after)
|
||||
assert.equal(env.timers.size, 0)
|
||||
assert.equal(env.body.children.length, 0)
|
||||
})
|
||||
|
||||
test('Thumbnail input attachment reentrant destroy cannot publish new input resources', () => {
|
||||
const env = setup()
|
||||
const wrapper = new env.Element('div')
|
||||
const append = wrapper.appendChild
|
||||
wrapper.appendChild = function (child) {
|
||||
append.call(this, child)
|
||||
env.instance.destroy()
|
||||
return child
|
||||
}
|
||||
assert.throws(() => env.instance.setup({ fileInput: wrapper }), error => error.name === 'AbortError')
|
||||
assert.equal(wrapper.children.length, 0)
|
||||
assert.equal(env.body.children.length, 0)
|
||||
})
|
||||
|
||||
test('Thumbnail nested input setup wins without outer cleanup overwriting its option', () => {
|
||||
const env = setup()
|
||||
const target = env.instance.option.fileInput
|
||||
const second = new env.Element('input')
|
||||
const latest = new env.Element('input')
|
||||
const remove = target.removeEventListener
|
||||
let nested = false
|
||||
target.removeEventListener = function (name, callback) {
|
||||
remove.call(this, name, callback)
|
||||
if (!nested) {
|
||||
nested = true
|
||||
env.instance.setup({ fileInput: latest })
|
||||
}
|
||||
}
|
||||
env.instance.setup({ fileInput: second })
|
||||
assert.equal(env.instance.option.fileInput, latest)
|
||||
for (const callbacks of second.handlers.values())
|
||||
assert.equal(callbacks.size, 0)
|
||||
assert.equal(latest.handlers.get('change').size, 1)
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test('Thumbnail source and job listener registration cannot revive resources after reentrant destroy', async () => {
|
||||
for (const stage of ['source', 'job']) {
|
||||
const env = setup(false)
|
||||
const add = env.instance.video.addEventListener
|
||||
let once = false
|
||||
env.instance.video.addEventListener = function (name, callback) {
|
||||
if (!once) {
|
||||
once = true
|
||||
env.instance.destroy()
|
||||
}
|
||||
add.call(this, name, callback)
|
||||
}
|
||||
if (stage === 'source') {
|
||||
env.instance.loadVideo({ name: 'second.mp4', type: 'video/mp4' })
|
||||
}
|
||||
else {
|
||||
const state = observe(env.instance.start())
|
||||
await flush()
|
||||
cancelled(state)
|
||||
}
|
||||
for (const callbacks of env.instance.video.handlers.values())
|
||||
assert.equal(callbacks.size, 0)
|
||||
assert.equal(env.body.children.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('Thumbnail timer creation reentrant destroy clears the newly returned timer handle', async () => {
|
||||
const env = setup(false)
|
||||
const schedule = env.box.setTimeout
|
||||
env.box.setTimeout = (callback, delay) => {
|
||||
env.instance.destroy()
|
||||
return schedule(callback, delay)
|
||||
}
|
||||
const state = observe(env.instance.start())
|
||||
await flush()
|
||||
cancelled(state)
|
||||
assert.equal(env.timers.size, 0)
|
||||
})
|
||||
|
||||
test('Thumbnail URL creation reentrant load preserves the newer source and revokes the superseded URL', () => {
|
||||
const env = setup()
|
||||
const create = env.box.URL.createObjectURL
|
||||
let once = false
|
||||
env.box.URL.createObjectURL = (file) => {
|
||||
if (!once) {
|
||||
once = true
|
||||
env.instance.loadVideo({ name: 'latest.mp4', type: 'video/mp4' })
|
||||
}
|
||||
return create(file)
|
||||
}
|
||||
env.instance.loadVideo({ name: 'outer.mp4', type: 'video/mp4' })
|
||||
assert.equal(env.instance.file.name, 'latest.mp4')
|
||||
assert.equal(env.instance.video.src, env.instance.videoUrl)
|
||||
env.instance.destroy()
|
||||
const created = env.operations.filter(item => item.name === 'createURL').map(item => item.url)
|
||||
const revoked = new Set(env.operations.filter(item => item.name === 'revokeURL').map(item => item.url))
|
||||
assert(created.every(url => revoked.has(url)))
|
||||
})
|
||||
|
||||
test('Thumbnail start before the first file retains its legacy wait-for-selection behavior', async () => {
|
||||
const env = thumbnailEnvironment(implementation)
|
||||
env.instance = new env.Factory({ fileInput: new env.Element('input'), number: 10 })
|
||||
const state = observe(env.instance.start())
|
||||
env.instance.loadVideo({ name: 'first.mp4', type: 'video/mp4' })
|
||||
env.instance.video.duration = 100
|
||||
dispatch(env.instance.video, 'loadedmetadata')
|
||||
await complete(env, state)
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test('Thumbnail destroy clears the media source and resets the decoder even when pause throws', () => {
|
||||
const env = setup()
|
||||
const failure = new Error('pause failed')
|
||||
const events = []
|
||||
env.instance.video.pause = () => {
|
||||
events.push('pause')
|
||||
throw failure
|
||||
}
|
||||
env.instance.video.removeAttribute = (name) => {
|
||||
events.push(`remove:${name}`)
|
||||
delete env.instance.video[name]
|
||||
}
|
||||
env.instance.video.load = () => events.push('load')
|
||||
assert.throws(() => env.instance.destroy(), error => error === failure)
|
||||
assert.deepEqual(events, ['pause', 'remove:src', 'load'])
|
||||
assert.equal(env.instance.video.src, undefined)
|
||||
assert.equal(env.instance.video.parentNode, null)
|
||||
assert.equal(env.operations.filter(item => item.name === 'revokeURL').length, 1)
|
||||
})
|
||||
Reference in new issue
Block a user