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

+3 -2
View File
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:unit": "node --test test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
@@ -82,7 +82,8 @@
"test:mediabunny-soak": "playwright test --config playwright.soak.config.js",
"generate:media:hls": "node scripts/generate-hls-fixture.mjs",
"test:iframe": "node --test test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe.test.js test/iframe-lifecycle.test.js refactor/scripts/iframe-contract.test.mjs refactor/scripts/iframe-types.test.mjs",
"test:iframe-types-package": "node refactor/scripts/iframe-package-types.mjs"
"test:iframe-types-package": "node refactor/scripts/iframe-package-types.mjs",
"test:thumbnail": "node --test test/thumbnail.test.js refactor/scripts/thumbnail-contract.test.mjs"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,79 @@
# PKG-TOOL-THUMB-02 extraction and lifecycle baseline
Started from 0338e0b8b1d9814ebe1628c7b55d08003ffa6347. No production runtime or
distribution changes. This task reproduces behavior before the resource/module
work in 03 and TS migration in 04. The old default-semantics conflict remains open.
## Repeatable controlled tests
`yarn test:thumbnail` runs the 26 frozen contract assertions and 36 extraction
assertions against recovered CDN 3.5.31 and frozen workspace main/legacy.
The new behavior file also runs in the normal unit/CI command.
Controlled timers and deferred Blob callbacks allow assertions without arbitrary
waiting: serial frames, interval clamps/density guards, progress/done ordering,
repeat generation, duplicate start/download, pending callback gating, null Blob,
late callback after destroy, replacement URLs, missing metadata, changed input,
seek setter failure, throwing done/update listeners. These are baseline tests,
not assertions that the defects are fixed. Future candidate regression tests
must assert corrected resource ownership separately while preserving old evidence.
The old and current implementations leak the first source URL on replacement,
retain changed-input listeners and generated wrapper inputs, permit late update
after destroy, and leave several callback-failure promises unsettled. Missing
metadata rejects with an unbound receiver in 3.5.31; workspace keeps polling even
after destruction. Seek-assignment failure rejects correctly in both; a throwing
done listener emits error twice in 3.5.31 and once in workspace. Normal progress
and PNG URL replacement remain ordered. These distinctions guide 03 fixes.
## Actual browser evidence and limitations
Run `yarn test:browser test/browser/thumbnail-tool.spec.js test/browser/thumbnail-native.spec.js`.
The final 39 rows have zero retries/skips: **24 actual tool scenarios** in
Chromium/Firefox, **12 Windows WebKit Blob-unavailable controls**, and **3 native
media comparison tests**. The 12 controls do not establish successful extraction.
Every control proves that the same file loads in native HTTP video but fails in
native Blob video with media error 4, then reproduces the tool's corresponding
failure and cleanup. If native Blob starts working, the full scenario runs.
Actual scenarios use native file selection, metadata/seek, real canvas.toBlob,
PNG decode and non-black opaque pixels. They verify ten updates, two complete
generations and normal final URL cleanup. DOM drop dispatch with a real File
reproduces the missing listener; calling the existing ondrop method does load.
Corrupt media raises a native error without a tool error event; replacement leaks
the first URL and a second destroy throws NotFoundError. A captured **native PNG
callback**, delivered after destroy, still emits update and creates an unreleased
URL. Only callback delivery is controlled; encoding and media are native.
Initial docs sample testing passed 24 Chromium/Firefox cases and failed all 12
WebKit cases. A second 24-second video-only MP4 probe still failed WebKit. The
independent native control then demonstrated HTTP success and Blob failure for
the original pattern, looped pattern and docs sample. An exploratory WebM HTTP
control did not settle within its 1.5-second observation and is not used to claim
codec support. All failed reports and trace directories remain archived. No
production media workaround or claimed physical Safari result was introduced.
`test/browser/media/thumbnail-pattern.mp4` is a 24-second stream-copy loop of the
existing generated 8-second pattern, needed for the tool's minimum ten samples
and maximum one-sample-per-second density. Reproduce with FFmpeg:
```sh
ffmpeg -stream_loop 2 -i test/browser/media/pattern.mp4 -t 24 -an -c:v copy -movflags +faststart test/browser/media/thumbnail-pattern.mp4
```
The local test server exposes this fixture; no dist or compiled docs files were
edited. Hashes, individual outcomes, measurements, browser versions and CI counts
are in `refactor/baselines/thumbnail-behavior-validation.json`. The ordinary
browser job discovers the new spec files automatically. No hosted CI run,
coverage improvement, successful WebKit Blob extraction or npm publication is claimed.
## Handoff and rollback
03 should own source URLs, generated inputs/listeners, metadata waiting and
extraction callbacks; settle pending work on cancellation/error and stop late
effects. Keep historical public methods/events and use a documented compatibility
policy for delay/height defaults. 04 covers emitter provenance and strict TS;
05 keeps actual Safari/WebKit Blob extraction and old/new core sheet integration
open; 06 owns complete installed-package/demo checks. Reverting this task's
dedicated commit removes only tests, fixture/server registration, scripts and
refactor evidence; it does not roll back production code.
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 222 项,范围 22 个包及工作区/示例。
状态:todo 102 / doing 10 / blocked 0 / done 110 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 101 / doing 10 / blocked 0 / done 111 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -356,7 +356,7 @@
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| PKG-TOOL-THUMB-01 | artplayer-tool-thumbnail<br>核对包契约与历史用法 | BASE-05 | 构造/defaults/事件/方法/历史拼写、.esm.js 入口和缺失 types | 源码/声明/README/demo/可恢复发布内容差异已登记;公开形状和版本范围冻结;区分旧 npm 的 main/CSS 与当前工作区无 main、.esm.js/类型缺失,不把工作区状态冒称旧 npm 发布事实 | H | done |
| PKG-TOOL-THUMB-02 | artplayer-tool-thumbnail<br>建立特有行为与错误测试 | PKG-TOOL-THUMB-01, ENG-03, ENG-05 | 文件输入/拖放、抽帧网格、begin/end、进度/失败/重复任务 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | todo |
| PKG-TOOL-THUMB-02 | artplayer-tool-thumbnail<br>建立特有行为与错误测试 | PKG-TOOL-THUMB-01, ENG-03, ENG-05 | 文件输入/拖放、抽帧网格、begin/end、进度/失败/重复任务 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
| PKG-TOOL-THUMB-03 | artplayer-tool-thumbnail<br>整理内部职责与资源 | PKG-TOOL-THUMB-02, CORE-01 | 输入、抽帧队列、网格导出、URL/监听清理分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo |
| PKG-TOOL-THUMB-04 | artplayer-tool-thumbnail<br>迁移自有源码和公开类型 | PKG-TOOL-THUMB-03, ENG-04, ENG-06, CORE-07 | 补齐真实 API 声明,自有 emitter/utils TS 化 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容;为 BASE-DIST-01 提供兼容入口/声明及消费者验证 | H | todo |
| PKG-TOOL-THUMB-05 | artplayer-tool-thumbnail<br>验证新旧核心和组合 | PKG-TOOL-THUMB-04, CORE-22 | 工具独立浏览器使用,生成缩略图在核心中显示 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
@@ -536,4 +536,5 @@
- PKG-IFRAME-04: [记录](changes/2026-09-13-PKG-IFRAME-04-types.md) [记录](baselines/iframe-types-validation.json)
- PKG-IFRAME-05: [记录](changes/2026-09-13-PKG-IFRAME-05-integration.md) [记录](baselines/iframe-integration-validation.json) [记录](changes/2026-09-13-PKG-IFRAME-05-history.md) [记录](baselines/iframe-history-validation.json)
- PKG-TOOL-THUMB-01: [记录](baselines/thumbnail-release.json) [记录](baselines/thumbnail-contract.md) [记录](changes/2026-09-13-PKG-TOOL-THUMB-01-contract.md) [记录](baselines/thumbnail-contract-validation.json)
- PKG-TOOL-THUMB-02: [记录](baselines/thumbnail-behavior-validation.json) [记录](changes/2026-09-13-PKG-TOOL-THUMB-02-behavior.md)
- PKG-FACTORY-01: [记录](baselines/factory-assignment-gaps.json) [记录](baselines/factory-compatibility-proposals.json) [记录](factory-compatibility-decision.md) [记录](changes/2026-09-12-PKG-FACTORY-01-decision.md)
+12
View File
@@ -1,5 +1,17 @@
# 进度与证据
## PKG-TOOL-THUMB-02 完成:抽帧、失败与资源基线
新增36项历史抽帧/生命周期测试,与26项契约共同通过;普通CI已接入新测试。
真实浏览器39项记录分为24个Chromium/Firefox工具场景、12个Windows WebKit
Blob不可用对照、3个原生媒体对照。验证实际PNG像素/重复生成/正常URL清理,并复现
迟到原生Blob回调在destroy后继续update和泄漏、替换文件URL泄漏、drop监听缺失。
原生HTTP能播放而Blob失败,未将WebKit对照冒称抽帧成功;原始失败报告完整保留。
完整CI1606、330生产TS检查通过。生产源码未改,03负责资源修复/拆分,04负责TS,
05/06保留WebKit/Safari实际文件抽帧和分发验收。见[变更](changes/2026-09-13-PKG-TOOL-THUMB-02-behavior.md)
与[验证](baselines/thumbnail-behavior-validation.json)。222项:111 done、10 doing、101 todo。
## PKG-TOOL-THUMB-01 完成:历史契约与发布证据
冻结11条HTTP观察、12个工作区及8个历史Git输入;恢复3.5.31 CDN主文件并逐字节
+1
View File
@@ -180,3 +180,4 @@
| THUMB-COMPAT-01 | open / 已复现 | Thumbnail recovered 3.5.31 and workspace 4.4.0 have conflicting height and event timing defaults | PKG-TOOL-THUMB-02, PKG-TOOL-THUMB-03, PKG-TOOL-THUMB-04 |
| THUMB-LIFE-01 | open / 已复现 | Thumbnail drop binding and repeated destroy fail; pending work and replacement resource ownership need repair | PKG-TOOL-THUMB-02, PKG-TOOL-THUMB-03, PKG-TOOL-THUMB-05 |
| THUMB-PROVENANCE-01 | open / 源码/产物事实 | Thumbnail local emitter attribution and historical distribution completeness remain unverified | PKG-TOOL-THUMB-04, PKG-TOOL-THUMB-06 |
| THUMB-MEDIA-01 | open / 已复现 | Windows WebKit native Blob URLs cannot load tested MP4 media despite successful HTTP controls | PKG-TOOL-THUMB-05, PKG-TOOL-THUMB-06 |
+29 -3
View File
@@ -3863,7 +3863,11 @@
"evidence": [
"refactor/baselines/thumbnail-contract.md",
"refactor/baselines/thumbnail-release.json",
"refactor/scripts/thumbnail-contract.test.mjs"
"refactor/scripts/thumbnail-contract.test.mjs",
"refactor/baselines/thumbnail-behavior-validation.json",
"refactor/changes/2026-09-13-PKG-TOOL-THUMB-02-behavior.md",
"test/thumbnail.test.js",
"test/browser/thumbnail-tool.spec.js"
]
},
{
@@ -3875,13 +3879,17 @@
"PKG-TOOL-THUMB-03",
"PKG-TOOL-THUMB-05"
],
"compatibleResolution": "Drop and duplicate-destroy defects are reproduced; reproduce source-observed extraction/timer/blob/input ownership gaps before fixes, then introduce owned cleanup without changing public shape.",
"compatibleResolution": "36 frozen behavior tests reproduce timer/input/source/callback ownership gaps and historical error ordering. Actual Chromium/Firefox native Blob callbacks prove late update and URL leaks after destroy. Task 03 must add corrected candidate regressions and owned cleanup; 05 retains real integration acceptance.",
"closureCriteria": "Deterministic old failure/candidate success for replacement, error and destruction plus real media/browser resource validation.",
"status": "open",
"evidence": [
"refactor/baselines/thumbnail-contract.md",
"refactor/baselines/thumbnail-release.json",
"refactor/scripts/thumbnail-contract.test.mjs"
"refactor/scripts/thumbnail-contract.test.mjs",
"refactor/baselines/thumbnail-behavior-validation.json",
"refactor/changes/2026-09-13-PKG-TOOL-THUMB-02-behavior.md",
"test/thumbnail.test.js",
"test/browser/thumbnail-tool.spec.js"
]
},
{
@@ -3900,6 +3908,24 @@
"refactor/baselines/thumbnail-release.json",
"refactor/scripts/thumbnail-contract.test.mjs"
]
},
{
"id": "THUMB-MEDIA-01",
"title": "Windows WebKit native Blob URLs cannot load tested MP4 media despite successful HTTP controls",
"status": "open",
"confirmation": "reproduced",
"owners": [
"PKG-TOOL-THUMB-05",
"PKG-TOOL-THUMB-06"
],
"evidence": [
"refactor/baselines/thumbnail-behavior-validation.json",
"refactor/changes/2026-09-13-PKG-TOOL-THUMB-02-behavior.md",
"test/thumbnail.test.js",
"test/browser/thumbnail-tool.spec.js"
],
"compatibleResolution": "Preserve independent HTTP/Blob capability controls; obtain actual supported WebKit/Safari file extraction evidence before release acceptance. Do not count the 12 unavailable controls as successful thumbnail generation or silently replace local files with HTTP media.",
"closureCriteria": "Actual generated PNG/extraction and lifecycle proof in supported Safari/WebKit environments, or explicit supported-environment decision with migration documentation; Windows limitation stays identifiable."
}
]
}
+5 -2
View File
@@ -3765,11 +3765,14 @@
"ENG-03",
"ENG-05"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "文件输入/拖放、抽帧网格、begin/end、进度/失败/重复任务",
"acceptance": "旧版本行为可重跑,成功/失败/切源/销毁有必要断言",
"evidence": []
"evidence": [
"baselines/thumbnail-behavior-validation.json",
"changes/2026-09-13-PKG-TOOL-THUMB-02-behavior.md"
]
},
{
"id": "PKG-TOOL-THUMB-03",
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')
})
}