mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
test(thumbnail): [PKG-TOOL-THUMB-02] reproduce extraction and resource lifecycle gaps
This commit is contained in:
1 parent
0338e0b8b1
commit
1cb7779192
14 files changed
+3907
-11
No files matched your search
+3
-2
@@ -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
@@ -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)
|
||||
@@ -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主文件并逐字节
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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 */
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user