diff --git a/package.json b/package.json index 4b6128ab2..6773a62b7 100644 --- a/package.json +++ b/package.json @@ -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/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js 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:unit": "node --test test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js 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", @@ -84,7 +84,8 @@ "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:thumbnail": "node --test test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js refactor/scripts/thumbnail-contract.test.mjs refactor/scripts/thumbnail-types.test.mjs", - "test:thumbnail-types-package": "node refactor/scripts/thumbnail-package-types.mjs" + "test:thumbnail-types-package": "node refactor/scripts/thumbnail-package-types.mjs", + "test:auto-thumbnail": "node --test test/auto-thumbnail.test.js refactor/scripts/auto-thumbnail-contract.test.mjs" }, "browserslist": "last 1 Chrome version", "devDependencies": { diff --git a/refactor/baselines/auto-thumbnail-failures-validation.json b/refactor/baselines/auto-thumbnail-failures-validation.json new file mode 100644 index 000000000..840134b6d --- /dev/null +++ b/refactor/baselines/auto-thumbnail-failures-validation.json @@ -0,0 +1,1947 @@ +{ + "schemaVersion": 1, + "task": "PKG-AUTO-THUMB-02", + "status": "done", + "parentCommit": "cdc252b158af25836e7f0a5c07de78ee57eb0cf4", + "checks": [ + { + "command": "node --test test/auto-thumbnail.test.js", + "result": "pass", + "tests": 64, + "scope": "Eight historical failure behaviors across eight frozen implementations; defects are reproduced, not fixed.", + "log": "refactor/.cache/auto-thumbnail02-unit.log" + }, + { + "command": "yarn test:browser test/browser/auto-thumbnail.spec.js", + "result": "pass", + "tests": 9, + "retries": 0, + "skipped": 0, + "report": "refactor/.cache/auto-thumbnail02-browser-verified.json", + "results": "refactor/.cache/auto-thumbnail02-browser-verified-results", + "reportSha256": "e38a4478260d922d70389801ae230e6d5c30e439d108baeb8825a6ab3611b744" + }, + { + "command": "yarn ci:check", + "result": "pass", + "tests": { + "unit": 1520, + "engineering": 14, + "baseline": 232, + "total": 1766 + }, + "strictProductionTypeScriptFiles": 339, + "log": "refactor/.cache/auto-thumbnail02-ci.log", + "logSha256": "f531bdcf2b73f49c81e994c3913ce340e3599620b91281a32feb6127a9749dec", + "warnings": [ + "Existing generated core editor declaration unused eslint suppression", + "Existing Yarn DEP0169 url.parse warning" + ] + }, + { + "command": "focused eslint --no-fix; yarn test:browser test/browser/auto-thumbnail.spec.js", + "result": "pass", + "tests": 9, + "retries": 0, + "skipped": 0, + "report": "refactor/.cache/auto-thumbnail02-browser-portable.json", + "reportSha256": "c48b511f69f0bad94956ff81de83a16a5d0dda17072775aa95ff7335a72b90c4", + "results": "refactor/.cache/auto-thumbnail02-browser-portable-results", + "scope": "Final platform assertion guard and all-cell color check verified after full CI. Production sources, package scripts and Node tests did not change after full CI; Linux hosted execution is not claimed." + }, + { + "command": "yarn test:auto-thumbnail", + "result": "pass", + "tests": 102, + "log": "refactor/.cache/auto-thumbnail02-combined.log" + } + ], + "diagnosticRuns": [ + { + "report": "refactor/.cache/auto-thumbnail02-browser-initial.json", + "results": "refactor/.cache/auto-thumbnail02-browser-initial-results", + "expected": 6, + "unexpected": 3, + "note": "Intermediate WebKit pixel hypothesis was tested and refined using independent native controls; not silently discarded." + }, + { + "report": "refactor/.cache/auto-thumbnail02-browser-control.json", + "results": "refactor/.cache/auto-thumbnail02-browser-control-results", + "expected": 6, + "unexpected": 3, + "note": "Intermediate WebKit pixel hypothesis was tested and refined using independent native controls; not silently discarded." + }, + { + "report": "refactor/.cache/auto-thumbnail02-browser-played-control.json", + "results": "refactor/.cache/auto-thumbnail02-browser-played-control-results", + "expected": 6, + "unexpected": 3, + "note": "Intermediate WebKit pixel hypothesis was tested and refined using independent native controls; not silently discarded." + } + ], + "measurements": [ + { + "browser": "chromium", + "browserVersion": "153.0.8010.12", + "retry": 0, + "implementation": "published-1.1.0-main", + "sha256": "ce1f1472bf02b836dd85b92eadf2f00786c02cde11a20ac2801136a0f675d12a", + "scope": "Native HTTP video, seeking and JPEG pixels with controlled delivery of native toBlob results; player-facing host is a stub, not a core integration result.", + "blanks": { + "width": 800, + "height": 45, + "pixel": [ + 0, + 0, + 0, + 255 + ], + "samples": [ + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "final": { + "width": 800, + "height": 45, + "pixel": [ + 15, + 13, + 138, + 255 + ], + "samples": [ + [ + 15, + 13, + 138, + 255 + ], + [ + 12, + 16, + 139, + 255 + ], + [ + 11, + 16, + 136, + 255 + ], + [ + 14, + 15, + 141, + 255 + ] + ], + "type": "image/jpeg" + }, + "native": { + "readyState": 4, + "currentTime": 0.267285, + "rawPixel": [ + 0, + 0, + 253, + 255 + ], + "width": 800, + "height": 45, + "pixel": [ + 4, + 17, + 131, + 255 + ], + "samples": [ + [ + 4, + 17, + 131, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "staticControl": { + "width": 800, + "height": 45, + "pixel": [ + 254, + 0, + 0, + 255 + ], + "samples": [ + [ + 254, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "seeks": [ + 0, + 2, + 4, + 6 + ], + "updates": [ + { + "height": 45, + "column": 10, + "number": 4, + "width": 80, + "scale": 1, + "afterDestroy": false + }, + { + "height": 45, + "column": 10, + "number": 4, + "width": 80, + "scale": 1, + "afterDestroy": true + } + ], + "outstandingAfterDestroy": 1, + "goodRevoked": true, + "staleUrlLive": true, + "result": { + "name": "artplayerPluginAutoThumbnail" + }, + "videoState": { + "src": "/test/pattern.mp4", + "readyState": 4, + "width": 320, + "seekHandler": "function", + "metadataHandler": "function" + } + }, + { + "browser": "chromium", + "browserVersion": "153.0.8010.12", + "retry": 0, + "implementation": "published-1.0.1-main", + "sha256": "3d9756b7437f24372d34138002b46937bb09e7392b82fb1caf5f09460f151c46", + "scope": "Native HTTP video, seeking and JPEG pixels with controlled delivery of native toBlob results; player-facing host is a stub, not a core integration result.", + "blanks": { + "width": 800, + "height": 45, + "pixel": [ + 0, + 0, + 0, + 255 + ], + "samples": [ + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "final": { + "width": 800, + "height": 45, + "pixel": [ + 15, + 13, + 138, + 255 + ], + "samples": [ + [ + 15, + 13, + 138, + 255 + ], + [ + 12, + 16, + 139, + 255 + ], + [ + 11, + 16, + 136, + 255 + ], + [ + 14, + 15, + 141, + 255 + ] + ], + "type": "image/jpeg" + }, + "native": { + "readyState": 4, + "currentTime": 0.253631, + "rawPixel": [ + 0, + 0, + 253, + 255 + ], + "width": 800, + "height": 45, + "pixel": [ + 4, + 17, + 131, + 255 + ], + "samples": [ + [ + 4, + 17, + 131, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "staticControl": { + "width": 800, + "height": 45, + "pixel": [ + 254, + 0, + 0, + 255 + ], + "samples": [ + [ + 254, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "seeks": [ + 0, + 2, + 4, + 6 + ], + "updates": [ + { + "height": 45, + "column": 10, + "number": 4, + "width": 80, + "scale": 1, + "afterDestroy": false + }, + { + "height": 45, + "column": 10, + "number": 4, + "width": 80, + "scale": 1, + "afterDestroy": true + } + ], + "outstandingAfterDestroy": 1, + "goodRevoked": true, + "staleUrlLive": true, + "result": { + "name": "artplayerPluginAutoThumbnail" + }, + "videoState": { + "src": "/test/pattern.mp4", + "readyState": 4, + "width": 320, + "seekHandler": "function", + "metadataHandler": "function" + } + }, + { + "browser": "chromium", + "browserVersion": "153.0.8010.12", + "retry": 0, + "implementation": "frozen-workspace-js", + "sha256": "ce1f1472bf02b836dd85b92eadf2f00786c02cde11a20ac2801136a0f675d12a", + "scope": "Native HTTP video, seeking and JPEG pixels with controlled delivery of native toBlob results; player-facing host is a stub, not a core integration result.", + "blanks": { + "width": 800, + "height": 45, + "pixel": [ + 0, + 0, + 0, + 255 + ], + "samples": [ + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "final": { + "width": 800, + "height": 45, + "pixel": [ + 15, + 13, + 138, + 255 + ], + "samples": [ + [ + 15, + 13, + 138, + 255 + ], + [ + 12, + 16, + 139, + 255 + ], + [ + 11, + 16, + 136, + 255 + ], + [ + 14, + 15, + 141, + 255 + ] + ], + "type": "image/jpeg" + }, + "native": { + "readyState": 4, + "currentTime": 0.252551, + "rawPixel": [ + 0, + 0, + 253, + 255 + ], + "width": 800, + "height": 45, + "pixel": [ + 4, + 17, + 131, + 255 + ], + "samples": [ + [ + 4, + 17, + 131, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "staticControl": { + "width": 800, + "height": 45, + "pixel": [ + 254, + 0, + 0, + 255 + ], + "samples": [ + [ + 254, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "seeks": [ + 0, + 2, + 4, + 6 + ], + "updates": [ + { + "height": 45, + "column": 10, + "number": 4, + "width": 80, + "scale": 1, + "afterDestroy": false + }, + { + "height": 45, + "column": 10, + "number": 4, + "width": 80, + "scale": 1, + "afterDestroy": true + } + ], + "outstandingAfterDestroy": 1, + "goodRevoked": true, + "staleUrlLive": true, + "result": { + "name": "artplayerPluginAutoThumbnail" + }, + "videoState": { + "src": "/test/pattern.mp4", + "readyState": 4, + "width": 320, + "seekHandler": "function", + "metadataHandler": "function" + } + }, + { + "browser": "firefox", + "browserVersion": "155.0", + "retry": 0, + "implementation": "published-1.1.0-main", + "sha256": "ce1f1472bf02b836dd85b92eadf2f00786c02cde11a20ac2801136a0f675d12a", + "scope": "Native HTTP video, seeking and JPEG pixels with controlled delivery of native toBlob results; player-facing host is a stub, not a core integration result.", + "blanks": { + "width": 800, + "height": 45, + "pixel": [ + 0, + 0, + 0, + 255 + ], + "samples": [ + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "final": { + "width": 800, + "height": 45, + "pixel": [ + 5, + 15, + 250, + 255 + ], + "samples": [ + [ + 5, + 15, + 250, + 255 + ], + [ + 1, + 14, + 253, + 255 + ], + [ + 7, + 10, + 247, + 255 + ], + [ + 1, + 14, + 253, + 255 + ] + ], + "type": "image/jpeg" + }, + "native": { + "readyState": 4, + "currentTime": 0.253576, + "rawPixel": [ + 0, + 14, + 253, + 255 + ], + "width": 800, + "height": 45, + "pixel": [ + 0, + 11, + 255, + 255 + ], + "samples": [ + [ + 0, + 11, + 255, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "staticControl": { + "width": 800, + "height": 45, + "pixel": [ + 254, + 0, + 0, + 255 + ], + "samples": [ + [ + 254, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "seeks": [ + 0, + 2, + 4, + 6 + ], + "updates": [ + { + "height": 45, + "column": 10, + "number": 4, + "width": 80, + "scale": 1, + "afterDestroy": false + }, + { + "height": 45, + "column": 10, + "number": 4, + "width": 80, + "scale": 1, + "afterDestroy": true + } + ], + "outstandingAfterDestroy": 1, + "goodRevoked": true, + "staleUrlLive": true, + "result": { + "name": "artplayerPluginAutoThumbnail" + }, + "videoState": { + "src": "/test/pattern.mp4", + "readyState": 4, + "width": 320, + "seekHandler": "function", + "metadataHandler": "function" + } + }, + { + "browser": "firefox", + "browserVersion": "155.0", + "retry": 0, + "implementation": "published-1.0.1-main", + "sha256": "3d9756b7437f24372d34138002b46937bb09e7392b82fb1caf5f09460f151c46", + "scope": "Native HTTP video, seeking and JPEG pixels with controlled delivery of native toBlob results; player-facing host is a stub, not a core integration result.", + "blanks": { + "width": 800, + "height": 45, + "pixel": [ + 0, + 0, + 0, + 255 + ], + "samples": [ + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "final": { + "width": 800, + "height": 45, + "pixel": [ + 5, + 15, + 250, + 255 + ], + "samples": [ + [ + 5, + 15, + 250, + 255 + ], + [ + 1, + 14, + 253, + 255 + ], + [ + 7, + 10, + 247, + 255 + ], + [ + 1, + 14, + 253, + 255 + ] + ], + "type": "image/jpeg" + }, + "native": { + "readyState": 4, + "currentTime": 0.254526, + "rawPixel": [ + 0, + 14, + 253, + 255 + ], + "width": 800, + "height": 45, + "pixel": [ + 0, + 11, + 255, + 255 + ], + "samples": [ + [ + 0, + 11, + 255, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "staticControl": { + "width": 800, + "height": 45, + "pixel": [ + 254, + 0, + 0, + 255 + ], + "samples": [ + [ + 254, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "seeks": [ + 0, + 2, + 4, + 6 + ], + "updates": [ + { + "height": 45, + "column": 10, + "number": 4, + "width": 80, + "scale": 1, + "afterDestroy": false + }, + { + "height": 45, + "column": 10, + "number": 4, + "width": 80, + "scale": 1, + "afterDestroy": true + } + ], + "outstandingAfterDestroy": 1, + "goodRevoked": true, + "staleUrlLive": true, + "result": { + "name": "artplayerPluginAutoThumbnail" + }, + "videoState": { + "src": "/test/pattern.mp4", + "readyState": 4, + "width": 320, + "seekHandler": "function", + "metadataHandler": "function" + } + }, + { + "browser": "firefox", + "browserVersion": "155.0", + "retry": 0, + "implementation": "frozen-workspace-js", + "sha256": "ce1f1472bf02b836dd85b92eadf2f00786c02cde11a20ac2801136a0f675d12a", + "scope": "Native HTTP video, seeking and JPEG pixels with controlled delivery of native toBlob results; player-facing host is a stub, not a core integration result.", + "blanks": { + "width": 800, + "height": 45, + "pixel": [ + 0, + 0, + 0, + 255 + ], + "samples": [ + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "final": { + "width": 800, + "height": 45, + "pixel": [ + 5, + 15, + 250, + 255 + ], + "samples": [ + [ + 5, + 15, + 250, + 255 + ], + [ + 1, + 14, + 253, + 255 + ], + [ + 7, + 10, + 247, + 255 + ], + [ + 1, + 14, + 253, + 255 + ] + ], + "type": "image/jpeg" + }, + "native": { + "readyState": 4, + "currentTime": 0.290804, + "rawPixel": [ + 0, + 15, + 253, + 255 + ], + "width": 800, + "height": 45, + "pixel": [ + 0, + 18, + 255, + 255 + ], + "samples": [ + [ + 0, + 18, + 255, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "staticControl": { + "width": 800, + "height": 45, + "pixel": [ + 254, + 0, + 0, + 255 + ], + "samples": [ + [ + 254, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "seeks": [ + 0, + 2, + 4, + 6 + ], + "updates": [ + { + "height": 45, + "column": 10, + "number": 4, + "width": 80, + "scale": 1, + "afterDestroy": false + }, + { + "height": 45, + "column": 10, + "number": 4, + "width": 80, + "scale": 1, + "afterDestroy": true + } + ], + "outstandingAfterDestroy": 1, + "goodRevoked": true, + "staleUrlLive": true, + "result": { + "name": "artplayerPluginAutoThumbnail" + }, + "videoState": { + "src": "/test/pattern.mp4", + "readyState": 4, + "width": 320, + "seekHandler": "function", + "metadataHandler": "function" + } + }, + { + "browser": "webkit", + "browserVersion": "26.6", + "retry": 0, + "implementation": "published-1.1.0-main", + "sha256": "ce1f1472bf02b836dd85b92eadf2f00786c02cde11a20ac2801136a0f675d12a", + "scope": "Native HTTP video, seeking and JPEG pixels with controlled delivery of native toBlob results; player-facing host is a stub, not a core integration result.", + "blanks": { + "width": 800, + "height": 45, + "pixel": [ + 0, + 0, + 0, + 255 + ], + "samples": [ + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "final": { + "width": 800, + "height": 45, + "pixel": [ + 0, + 0, + 0, + 255 + ], + "samples": [ + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "native": { + "readyState": 4, + "currentTime": 0.2616568, + "rawPixel": [ + 13, + 16, + 239, + 255 + ], + "width": 800, + "height": 45, + "pixel": [ + 6, + 41, + 133, + 255 + ], + "samples": [ + [ + 6, + 41, + 133, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "staticControl": { + "width": 800, + "height": 45, + "pixel": [ + 254, + 0, + 0, + 255 + ], + "samples": [ + [ + 254, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "seeks": [ + 0.0007911, + 2.0006032, + 4.0010776, + 6.0003922 + ], + "updates": [ + { + "height": 45, + "column": 10, + "number": 4, + "width": 80, + "scale": 1, + "afterDestroy": false + }, + { + "height": 45, + "column": 10, + "number": 4, + "width": 80, + "scale": 1, + "afterDestroy": true + } + ], + "outstandingAfterDestroy": 1, + "goodRevoked": true, + "staleUrlLive": true, + "result": { + "name": "artplayerPluginAutoThumbnail" + }, + "videoState": { + "src": "/test/pattern.mp4", + "readyState": 4, + "width": 320, + "seekHandler": "function", + "metadataHandler": "function" + } + }, + { + "browser": "webkit", + "browserVersion": "26.6", + "retry": 0, + "implementation": "published-1.0.1-main", + "sha256": "3d9756b7437f24372d34138002b46937bb09e7392b82fb1caf5f09460f151c46", + "scope": "Native HTTP video, seeking and JPEG pixels with controlled delivery of native toBlob results; player-facing host is a stub, not a core integration result.", + "blanks": { + "width": 800, + "height": 45, + "pixel": [ + 0, + 0, + 0, + 255 + ], + "samples": [ + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "final": { + "width": 800, + "height": 45, + "pixel": [ + 0, + 0, + 0, + 255 + ], + "samples": [ + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "native": { + "readyState": 4, + "currentTime": 0.2623449, + "rawPixel": [ + 13, + 16, + 239, + 255 + ], + "width": 800, + "height": 45, + "pixel": [ + 6, + 41, + 133, + 255 + ], + "samples": [ + [ + 6, + 41, + 133, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "staticControl": { + "width": 800, + "height": 45, + "pixel": [ + 254, + 0, + 0, + 255 + ], + "samples": [ + [ + 254, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "seeks": [ + 0, + 2.0004229, + 4.0015636, + 6.0015607 + ], + "updates": [ + { + "height": 45, + "column": 10, + "number": 4, + "width": 80, + "scale": 1, + "afterDestroy": false + }, + { + "height": 45, + "column": 10, + "number": 4, + "width": 80, + "scale": 1, + "afterDestroy": true + } + ], + "outstandingAfterDestroy": 1, + "goodRevoked": true, + "staleUrlLive": true, + "result": { + "name": "artplayerPluginAutoThumbnail" + }, + "videoState": { + "src": "/test/pattern.mp4", + "readyState": 4, + "width": 320, + "seekHandler": "function", + "metadataHandler": "function" + } + }, + { + "browser": "webkit", + "browserVersion": "26.6", + "retry": 0, + "implementation": "frozen-workspace-js", + "sha256": "ce1f1472bf02b836dd85b92eadf2f00786c02cde11a20ac2801136a0f675d12a", + "scope": "Native HTTP video, seeking and JPEG pixels with controlled delivery of native toBlob results; player-facing host is a stub, not a core integration result.", + "blanks": { + "width": 800, + "height": 45, + "pixel": [ + 0, + 0, + 0, + 255 + ], + "samples": [ + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "final": { + "width": 800, + "height": 45, + "pixel": [ + 0, + 0, + 0, + 255 + ], + "samples": [ + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "native": { + "readyState": 4, + "currentTime": 0.2557929, + "rawPixel": [ + 16, + 15, + 239, + 255 + ], + "width": 800, + "height": 45, + "pixel": [ + 19, + 35, + 133, + 255 + ], + "samples": [ + [ + 19, + 35, + 133, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "staticControl": { + "width": 800, + "height": 45, + "pixel": [ + 254, + 0, + 0, + 255 + ], + "samples": [ + [ + 254, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ], + [ + 0, + 0, + 0, + 255 + ] + ], + "type": "image/jpeg" + }, + "seeks": [ + 0, + 0.0015887, + 4.0007806, + 6.001757 + ], + "updates": [ + { + "height": 45, + "column": 10, + "number": 4, + "width": 80, + "scale": 1, + "afterDestroy": false + }, + { + "height": 45, + "column": 10, + "number": 4, + "width": 80, + "scale": 1, + "afterDestroy": true + } + ], + "outstandingAfterDestroy": 1, + "goodRevoked": true, + "staleUrlLive": true, + "result": { + "name": "artplayerPluginAutoThumbnail" + }, + "videoState": { + "src": "/test/pattern.mp4", + "readyState": 4, + "width": 320, + "seekHandler": "function", + "metadataHandler": "function" + } + } + ], + "limitations": [ + "Tests assert known historical defects, not successful candidate repair.", + "Browser player-facing host is a stub; actual core matrix/demo integration remains task05/06.", + "Windows WebKit black plugin samples coexist with colored played-video and static JPEG controls; this is not a waived capability.", + "Invalid numeric input, reentrancy, cross-instance and cleanup-failure candidate boundaries remain for task03.", + "No physical Safari, mobile, CORS matrix, arbitrary formats or sustained-load acceptance is claimed." + ], + "completedAt": "2026-09-12T22:04:39.411Z", + "inputs": [ + { + "file": "package.json", + "sha256LF": "38c96d1cbfef31b38506465c71c06c3d8408ef8e48bf4cd7b3106fd112d9791f" + }, + { + "file": "test/auto-thumbnail.test.js", + "sha256LF": "ce8ff64895b3e89648324ed5d9b16787ac397ed9f1076488361ab93e1b2681d9" + }, + { + "file": "test/browser/auto-thumbnail.spec.js", + "sha256LF": "ec9fcbedc9ee12d61a82e2edff84f677d9474d38c111c7bd295bf880277ccc11" + }, + { + "file": "test/helpers/auto-thumbnail.js", + "sha256LF": "6c4fb8a27d5495578fb18df813458b2cb1ae467f0a182de14d75b72f2f760c3a" + }, + { + "file": "test/browser/media/pattern.mp4", + "sha256": "0ad1d7ad286aec6e0fa9e27fc1e487e7277df2e603e30040fb942365763d0233" + }, + { + "file": "refactor/baselines/auto-thumbnail-failures.md", + "sha256LF": "2d7b49ba6711a6403a3e16011001019384cd54a4df3bcff08d51ba2fd57ab1de" + } + ], + "exploratoryReadinessProbes": [ + { + "scope": "Exploratory conditions, includes unsupported and timeout outcomes; not counted as candidate acceptance.", + "source": "refactor/.cache/auto-thumbnail-readiness-initial.mjs", + "sourceSha256": "18ec3c5472d298c4bde1f0fc5e703a91d2028f1f8eed5cc45391c9d28d81c3e7", + "results": "refactor/.cache/auto-thumbnail-readiness-initial.json", + "resultsSha256": "0f77c28f7e60b07abde56ce29db597125ed1ed42dc34ef2e5000e30dfc7af43d" + }, + { + "scope": "Exploratory conditions, includes unsupported and timeout outcomes; not counted as candidate acceptance.", + "source": "refactor/.cache/auto-thumbnail-readiness-probe.mjs", + "sourceSha256": "93cf64c7b1cbad915f5c70266f882ad7ad3e4b36aa21b2bc0f0b69d957e3141f", + "results": "refactor/.cache/auto-thumbnail-readiness-probe.json", + "resultsSha256": "bf8e252fddc77de84f54183d4329fe3b472c6d3ed62cce6957fcc14a3d6fc30a" + } + ], + "fullCiBrowserTestInput": { + "file": "test/browser/auto-thumbnail.spec.js", + "sha256LF": "5a8f20a77c145d534c624b23cb407bc6f50f21de365d9e5fb3c6e5bf0ba329c3" + } +} diff --git a/refactor/baselines/auto-thumbnail-failures.md b/refactor/baselines/auto-thumbnail-failures.md new file mode 100644 index 000000000..49fd0b933 --- /dev/null +++ b/refactor/baselines/auto-thumbnail-failures.md @@ -0,0 +1,78 @@ +# Auto-thumbnail 失败与原生对照 + +`PKG-AUTO-THUMB-02` 补64项旧行为回归,八份固定夹具各执行八类问题。 +配合01的38项正常/分发契约,成功、失败、切源和销毁有可重跑对照。 +这里测试通过意味着 **成功复现旧缺陷**,不是候选已经修复。 +生产源码和包版本本步仍未变化,下一步03负责模块和生命周期修复。 + +## 受控故障 + +| 情况 | 实际旧行为 | 03应建立的候选断言 | +| --- | --- | --- | +| destroy期间等待Blob | 迟到回调继续写thumbnails,最终URL存活,decoder src/handlers未清 | 取消后不写UI、不创建URL;清理decoder/监听器/本任务URL | +| 切源后旧metadata迟到 | 旧width/旧源覆盖新结果,两个任务URL同时留存 | 代际检查覆盖metadata/seek/Blob,只有当前任务可发布 | +| 重复metadata | 创建三个独立decoder和三个未释放URL | 新任务替换旧任务,并明确保留预览与释放的时机 | +| 编码回调逆序到达 | 完整→空白→半成品倒退,每次撤销前一可用URL | 串行或按代际/序号提交可用帧,不能倒退 | +| toBlob返回null | 先撤销可用URL,再抛错;核心配置仍指向已失效URL | 验证Blob后才替换;失败有归属并释放临时资源 | +| drawImage/toBlob同步抛错 | 异常逃逸,decoder和回调未清 | 出错后停止任务,继续清理,即使个别清理动作再抛错 | +| getContext为null/原生media error | 前者稍后在drawImage抛错,后者没有处理者 | 失败不能静默挂起,不能冒充可用输出 | +| 核心thumbnails setter抛错 | 新URL泄漏,后续帧继续工作 | 保留错误身份、收回新URL、停止失效任务及迟到副作用 | + +这些测试控制的是事件/编码回调,媒体对象不是原生解码器。无效数值、重入、跨实例、 +监听器安装/清理失败还需03的候选测试补齐,不能从上述八类推断穷尽覆盖。 +01中的旧高度参数、异步注册、CJS形状与类型推导问题保持独立处理。 + +## 三浏览器真实证据 + +最终9项原生测试覆盖1.0.1 main、1.1.0 main和冻结工作区main,分别运行 +Chromium153.0.8010.12、Firefox155.0、Windows WebKit26.6,均无重试/跳过。 +使用真实HTTP `/test/pattern.mp4`、原生seek、canvas.toBlob JPEG编码、BlobURL及img.decode +读取像素。仅 **延迟交付原生编码结果** 来确定性验证竞争,没有替换视频帧或Blob内容。 +核心一侧是最小宿主替身,故这不是05的新旧核心联调、8082示例或真实Safari验收。 + +每项原生证据都记录:0/2/4/6秒附近四次seek,五份编码(第一份空白),最终拼图 +800×45,四个已填格子的采样点,完整/空白两份回调的交付顺序,destroy前后更新及 +URL存活状态。最终URL在destroy后仍存活,迟到空白回调仍写入并撤销前一份URL。 +测试结束由测试方清理decoder和URL,明确不是旧插件完成了清理。 + +### WebKit黑色采样没有作为能力豁免 + +首次运行6项通过、3项WebKit像素断言失败;后续独立loadeddata+两次动画帧对照显示, +不播放就draw也可能黑色(包括Chromium对照)。这不足以认定平台不支持。 +再加入可见、静音的独立video,等待播放前进至少0.25秒后暂停和draw,三个浏览器 +均得到彩色原始像素及JPEG;红色静态canvas→JPEG也通过。 + +WebKit旧插件仍在四个采样点得到黑色JPEG,原生播放对照的原始/编码像素均有颜色。 +因此登记`AUTO-THUMB-PIXEL-01`为**旧抽帧路径缺陷**,根因仍需03用解码/帧就绪证据细化。 +没有将它归为不可用或跳过,也没有把“返回了JPEG”当成抽帧成功;不以四个采样点 +声称整幅图每个像素都是黑色。Firefox/Chromium旧插件的四格均采到彩色像素。 + +最终测试严格断言这些旧版差异以及原生阳性对照,不是删除像素断言来通过。 +三个中间诊断运行(各6通过/3失败)和完整最终9通过报告/附件均独立归档。 +未来候选须验证可用像素,不能继承历史黑色结果作为成功标准。 +Windows的黑色历史断言明确限制在win32/WebKit,不将该平台现象硬编码成所有系统的 +WebKit行为;其他系统接受有正常采样的输出,若仍黑则必须是WebKit并保留同一原生阳性 +对照。最后这项平台边界调整经过定向lint和整组9项原生重跑;Linux托管CI本步未执行。 + +### 03前置实验线索(不是候选验收) + +另用独立本地HTTP/原生video探测DOM挂载、loadeddata、播放、帧回调和seek条件。 +Windows WebKit26.6的该运行没有requestVideoFrameCallback;脱离DOM的等待/播放 +仍产生透明原始像素,挂载后追加seek可出现有效像素。display:none仍透明; +将video渲染为1×1会把正常蓝色采样变成灰色平均值,**不能以非黑判定内容正确**。 +visibility:hidden且保留正常尺寸的探测保留了正常采样颜色,可作为03验证方向。 +首次seek位置还有偏离目标的记录,需要验证精确帧条件,不能直接把实验代码作为实现。 +原始记录在`.cache/auto-thumbnail-readiness-initial.{mjs,json,log}`和 +`.cache/auto-thumbnail-readiness-probe.{mjs,json,log}`;实验有明确不支持/超时结果, +不计入上面9项验收,也不据此关闭像素风险。 + +## 执行与接续 + +- `yarn test:auto-thumbnail`:64项旧失败对照+38项冻结契约。 +- `yarn test:browser test/browser/auto-thumbnail.spec.js`:9项固定历史原生对照。 +- Node64项纳入`test:unit`;基线38项由`test:baseline`继续执行。 +- 03按入口注册、任务/源所有权、抽帧/编码、错误清理拆分,并保留有效旧调用; + 新增的候选回归先在冻结旧版失败,再在源码/构建产物/原生浏览器通过。 +- 04迁严格TS与声明,05最终核心/真实设备,06实际tarball和完整demo,不合并冒充完成。 + +详见[机器验证记录](auto-thumbnail-failures-validation.json)。本步不推送、不发布。 diff --git a/refactor/changes/2026-09-13-PKG-AUTO-THUMB-02-failures.md b/refactor/changes/2026-09-13-PKG-AUTO-THUMB-02-failures.md new file mode 100644 index 000000000..7ac64343d --- /dev/null +++ b/refactor/changes/2026-09-13-PKG-AUTO-THUMB-02-failures.md @@ -0,0 +1,23 @@ +# PKG-AUTO-THUMB-02 旧失败回归与原生像素 + +在八份冻结夹具上增加64项失败行为对照,覆盖销毁/切源/重复metadata/逆序编码/ +null Blob/draw与encode异常/缺context与media错误/核心setter异常。 +保留01正常契约,根脚本新增test:auto-thumbnail并将64项纳入test:unit,无新依赖。 + +新增9项三浏览器原生HTTP/video/seek/JPEG/URL/像素测试,手动延迟原生编码结果交付以 +确定性复现销毁后更新和逆序覆盖。没有将宿主替身包装成核心联调,原生媒体内容未伪造。 +WebKit首轮像素失败后增加四格采样、独立loadeddata、可见静音播放和静态红色JPEG对照。 +播放/静态阳性对照均成功,旧抽帧路径四点仍黑,故作为待修缺陷而不是能力豁免。 +诊断失败运行完整保留,最终历史断言9项通过并不代表候选已修复。 + +AUTO-THUMB-LIFE-01和AUTO-THUMB-ENCODE-01从source-observed变为reproduced, +新增AUTO-THUMB-PIXEL-01;均保持open。无效输入/重入/跨实例等候选边界继续03补齐。 +下一步修改生产源码并建立候选成功标准;04保留独立TS/类型工作。 + +见[故障表](../baselines/auto-thumbnail-failures.md)及 +[验证](../baselines/auto-thumbnail-failures-validation.json)。 +回退此提交只移除本步测试/脚本/记录;生产代码、发布版本、dist均未变。独立本地提交, +不推送、不发布。 + +最终完整CI1766项(1520单元+14工程+232基线)、339生产TS检查通过;历史原生9项通过, +没有重试/跳过/未处理浏览器错误。源码修复是独立03任务,不把历史缺陷复现记为修复完成。 diff --git a/refactor/plan.md b/refactor/plan.md index bb4ef5c0b..a7643132a 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -4,7 +4,7 @@ 基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 222 项,范围 22 个包及工作区/示例。 -状态:todo 98 / doing 11 / blocked 0 / done 113 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 +状态:todo 97 / doing 11 / blocked 0 / done 114 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。 @@ -172,7 +172,7 @@ | ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 | | --- | --- | --- | --- | --- | --- | --- | | PKG-AUTO-THUMB-01 | artplayer-plugin-auto-thumbnail
核对包契约与历史用法 | BASE-05 | url/width/number/scale、异步工厂和渐进缩略图更新 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done | -| PKG-AUTO-THUMB-02 | artplayer-plugin-auto-thumbnail
建立特有行为与错误测试 | PKG-AUTO-THUMB-01, ENG-03, ENG-05 | 抽帧边界、短视频、失败、连续来源和销毁中的抽帧 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | todo | +| PKG-AUTO-THUMB-02 | artplayer-plugin-auto-thumbnail
建立特有行为与错误测试 | PKG-AUTO-THUMB-01, ENG-03, ENG-05 | 抽帧边界、短视频、失败、连续来源和销毁中的抽帧 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done | | PKG-AUTO-THUMB-03 | artplayer-plugin-auto-thumbnail
整理内部职责与资源 | PKG-AUTO-THUMB-02, CORE-09, CORE-19 | 隐藏 video、seek 队列、canvas 编码与 Blob URL 清理 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo | | PKG-AUTO-THUMB-04 | artplayer-plugin-auto-thumbnail
迁移自有源码和公开类型 | PKG-AUTO-THUMB-03, ENG-04, ENG-06, CORE-07 | 抽帧结果和真实异步插件返回类型兼容 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo | | PKG-AUTO-THUMB-05 | artplayer-plugin-auto-thumbnail
验证新旧核心和组合 | PKG-AUTO-THUMB-04, CORE-22 | 旧核心/候选核心预览、多次加载无过期缩略图覆盖 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo | @@ -495,6 +495,7 @@ - PKG-AUDIO-04: [记录](changes/2026-09-12-PKG-AUDIO-04-types.md) [记录](baselines/audio-types-validation.json) - PKG-AUDIO-05: [记录](changes/2026-09-12-PKG-AUDIO-05-combinations.md) [记录](baselines/audio-combinations-first.json) [记录](baselines/audio-combinations-checkpoint.json) [记录](audio-validation.md) - PKG-AUTO-THUMB-01: [记录](baselines/auto-thumbnail-release.json) [记录](baselines/auto-thumbnail-contract.md) [记录](changes/2026-09-13-PKG-AUTO-THUMB-01-contract.md) [记录](baselines/auto-thumbnail-contract-validation.json) +- PKG-AUTO-THUMB-02: [记录](baselines/auto-thumbnail-failures.md) [记录](baselines/auto-thumbnail-failures-validation.json) [记录](changes/2026-09-13-PKG-AUTO-THUMB-02-failures.md) - PKG-HLS-01: [记录](baselines/hls-control-contract.md) [记录](baselines/hls-control-release.json) [记录](changes/2026-09-11-PKG-HLS-01-contract.md) - PKG-HLS-02: [记录](hls-validation.md) [记录](baselines/hls-sdk.json) [记录](baselines/hls-validation.json) [记录](changes/2026-09-11-PKG-HLS-02-tests.md) - PKG-HLS-03: [记录](changes/2026-09-12-PKG-HLS-03-modules.md) [记录](baselines/hls-modules-validation.json) [记录](hls-validation.md) diff --git a/refactor/progress.md b/refactor/progress.md index 10c0a4245..0666437a2 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -1,5 +1,17 @@ # 进度与证据 +## PKG-AUTO-THUMB-02 完成:失败回归和原生抽帧对照 + +64项旧行为测试覆盖8份冻结夹具的销毁/切源/重复metadata/逆序Blob/null Blob/ +draw与encode异常/缺context和media错误/setter异常。9项三浏览器原生HTTP/video/ +seek/JPEG/像素对照复现迟到更新;WebKit旧插件四点黑色,而播放及静态JPEG对照有色, +登记为待修抽帧路径缺陷,不作为能力豁免。保留3次中间诊断失败报告和最终完整报告。 +完整CI1766项、339生产TS通过。新增test:auto-thumbnail,Node64项纳入test:unit。 +问题已复现并记录但源码尚未修复,03负责资源/抽帧重构;独立隐藏渲染实验仅作03线索。 +见[故障表](baselines/auto-thumbnail-failures.md)和[验证](baselines/auto-thumbnail-failures-validation.json)。 +222项:114 done、11 doing、97 todo;185风险,新增像素风险与已有相关风险均open。 + + ## PKG-AUTO-THUMB-01 完成:自动缩略图发布与行为基线 核实全部三个npm版本及16成员,冻结8份工作区输入。明确1.0.0缺main/legacy, diff --git a/refactor/risk-table.md b/refactor/risk-table.md index 2e79823de..749f33a1e 100644 --- a/refactor/risk-table.md +++ b/refactor/risk-table.md @@ -186,5 +186,6 @@ | AUTO-THUMB-TYPE-01 | open / 已复现 | Published declarations misdescribe async registration and switch height/number option fields across generations | PKG-AUTO-THUMB-04 | | AUTO-THUMB-EXPORT-01 | open / 已复现 | Published 1.0.1 CommonJS default namespace differs from the direct function exported by 1.1.0 | PKG-AUTO-THUMB-04, PKG-AUTO-THUMB-06 | | AUTO-THUMB-DIST-01 | open / 已复现 | Published 1.0.0 archive lacks declared main and legacy runtimes | PKG-AUTO-THUMB-06 | -| AUTO-THUMB-LIFE-01 | open / 源码/产物事实 | Extraction tasks, decoder handlers and final object URLs lack source-switch and destroy ownership | PKG-AUTO-THUMB-02, PKG-AUTO-THUMB-03, PKG-AUTO-THUMB-05 | -| AUTO-THUMB-ENCODE-01 | open / 源码/产物事实 | Unserialized encodes include blank output and lack media/canvas/input failure handling | PKG-AUTO-THUMB-02, PKG-AUTO-THUMB-03, PKG-AUTO-THUMB-05 | +| AUTO-THUMB-LIFE-01 | open / 已复现 | Extraction tasks, decoder handlers and final object URLs lack source-switch and destroy ownership | PKG-AUTO-THUMB-02, PKG-AUTO-THUMB-03, PKG-AUTO-THUMB-05 | +| AUTO-THUMB-ENCODE-01 | open / 已复现 | Unserialized encodes include blank output and lack media/canvas/input failure handling | PKG-AUTO-THUMB-02, PKG-AUTO-THUMB-03, PKG-AUTO-THUMB-05 | +| AUTO-THUMB-PIXEL-01 | open / 已复现 | Historical auto-thumbnail yields black sampled JPEG cells on Windows WebKit while played native video and static JPEG controls yield color | PKG-AUTO-THUMB-03, PKG-AUTO-THUMB-05 | diff --git a/refactor/risks.json b/refactor/risks.json index 34c255921..cb189145e 100644 --- a/refactor/risks.json +++ b/refactor/risks.json @@ -4051,7 +4051,7 @@ { "id": "AUTO-THUMB-LIFE-01", "title": "Extraction tasks, decoder handlers and final object URLs lack source-switch and destroy ownership", - "confirmation": "source-observed", + "confirmation": "reproduced", "status": "open", "owners": [ "PKG-AUTO-THUMB-02", @@ -4061,15 +4061,19 @@ "evidence": [ "refactor/baselines/auto-thumbnail-release.json", "refactor/baselines/auto-thumbnail-contract.md", - "refactor/scripts/auto-thumbnail-contract.test.mjs" + "refactor/scripts/auto-thumbnail-contract.test.mjs", + "test/auto-thumbnail.test.js", + "refactor/baselines/auto-thumbnail-failures.md", + "refactor/baselines/auto-thumbnail-failures-validation.json" ], "compatibleResolution": "Cancel obsolete work and release owned resources while retaining live options and progressive usable sheet updates.", - "closureCriteria": "Delayed callbacks, repeated metadata, switching, destroy, multiple instances and failure cleanup are reproduced then pass source/artifacts and native browser checks." + "closureCriteria": "Delayed callbacks, repeated metadata, switching, destroy, multiple instances and failure cleanup are reproduced then pass source/artifacts and native browser checks.", + "workspaceState": "Task02 reproduces old failures with 64 controlled tests and native delayed JPEG callbacks. Candidate fixes, numeric validation, reentrancy and complete cleanup boundaries remain task03; status stays open." }, { "id": "AUTO-THUMB-ENCODE-01", "title": "Unserialized encodes include blank output and lack media/canvas/input failure handling", - "confirmation": "source-observed", + "confirmation": "reproduced", "status": "open", "owners": [ "PKG-AUTO-THUMB-02", @@ -4079,10 +4083,31 @@ "evidence": [ "refactor/baselines/auto-thumbnail-release.json", "refactor/baselines/auto-thumbnail-contract.md", - "refactor/scripts/auto-thumbnail-contract.test.mjs" + "refactor/scripts/auto-thumbnail-contract.test.mjs", + "test/auto-thumbnail.test.js", + "refactor/baselines/auto-thumbnail-failures.md", + "refactor/baselines/auto-thumbnail-failures-validation.json" ], "compatibleResolution": "Preserve JPEG grid/time/scale while bounding jobs, sequencing valid updates and handling null Blob, invalid media and thrown callbacks.", - "closureCriteria": "Fault controls and native valid/corrupt media prove no stale sheet replacement, leaked URLs or unhandled async callbacks; capability limitations remain explicit." + "closureCriteria": "Fault controls and native valid/corrupt media prove no stale sheet replacement, leaked URLs or unhandled async callbacks; capability limitations remain explicit.", + "workspaceState": "Task02 reproduces old failures with 64 controlled tests and native delayed JPEG callbacks. Candidate fixes, numeric validation, reentrancy and complete cleanup boundaries remain task03; status stays open." + }, + { + "id": "AUTO-THUMB-PIXEL-01", + "title": "Historical auto-thumbnail yields black sampled JPEG cells on Windows WebKit while played native video and static JPEG controls yield color", + "confirmation": "reproduced", + "status": "open", + "owners": [ + "PKG-AUTO-THUMB-03", + "PKG-AUTO-THUMB-05" + ], + "evidence": [ + "test/browser/auto-thumbnail.spec.js", + "refactor/baselines/auto-thumbnail-failures.md", + "refactor/baselines/auto-thumbnail-failures-validation.json" + ], + "compatibleResolution": "Establish actual decoded-frame readiness for the existing timeline/grid and preserve silent independent extraction; do not waive colored pixel checks as unsupported media.", + "closureCriteria": "Candidate source and artifacts generate usable sampled pixels on native browser controls with lifecycle checks; supported Safari and core combinations remain explicit final gates." } ] } diff --git a/refactor/tasks.json b/refactor/tasks.json index 8b89f74d6..294621054 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -1755,11 +1755,15 @@ "ENG-03", "ENG-05" ], - "status": "todo", + "status": "done", "risk": "H", "deliverable": "抽帧边界、短视频、失败、连续来源和销毁中的抽帧", "acceptance": "旧版本行为可重跑,成功/失败/切源/销毁有必要断言", - "evidence": [] + "evidence": [ + "baselines/auto-thumbnail-failures.md", + "baselines/auto-thumbnail-failures-validation.json", + "changes/2026-09-13-PKG-AUTO-THUMB-02-failures.md" + ] }, { "id": "PKG-AUTO-THUMB-03", diff --git a/test/auto-thumbnail.test.js b/test/auto-thumbnail.test.js new file mode 100644 index 000000000..16bce303d --- /dev/null +++ b/test/auto-thumbnail.test.js @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict' +// eslint-disable-next-line test/no-import-node-test -- Frozen historical regressions use the repository Node runner. +import test from 'node:test' +import { autoThumbnailEnvironment, autoThumbnailHistorical } from './helpers/auto-thumbnail.js' + +for (const implementation of await autoThumbnailHistorical()) { + test(`Auto-thumbnail ${implementation.name} historical: destroy leaves pending encode and final URL alive`, async () => { + const env = autoThumbnailEnvironment(implementation) + await env.factory({ number: 2 })(env.art) + env.art.emit('video:loadedmetadata') + env.metadata() + const video = env.videos[0] + env.art.emit('destroy') + env.finish() + assert.equal(env.updates.length, 1) + assert.equal(env.urls.size, 1) + assert.equal(env.listeners.get('video:loadedmetadata').size, 1) + assert.equal(typeof video.onloadedmetadata, 'function') + assert.equal(typeof video.onseeked, 'function') + assert.equal(video.src, 'original.mp4') + assert.equal(env.operations.filter(item => item.name === 'pause' || item.name === 'load').length, 0) + env.art.emit('destroy') + assert.equal(env.urls.size, 1) + }) + + test(`Auto-thumbnail ${implementation.name} historical: delayed old metadata overwrites a newer source sheet`, async () => { + const env = autoThumbnailEnvironment(implementation) + const options = { number: 2, width: 80 } + await env.factory(options)(env.art) + env.art.emit('video:loadedmetadata') + const oldVideo = env.videos[0] + env.art.option.url = 'replacement.mp4' + options.width = 160 + env.art.emit('restart') + env.art.emit('video:loadedmetadata') + env.metadata(env.videos[1]) + env.finish(0, { source: 'new' }) + assert.equal(env.updates.at(-1).width, 160) + env.metadata(oldVideo) + env.finish(1, { source: 'old' }) + assert.equal(env.updates.at(-1).width, 80) + assert.equal(env.urls.get(env.updates.at(-1).url).source, 'old') + assert.equal(env.urls.size, 2, 'Separate jobs never revoke each other or release the latest stale sheet') + }) + + test(`Auto-thumbnail ${implementation.name} historical: repeated metadata opens independent decoders without closing previous jobs`, async () => { + const env = autoThumbnailEnvironment(implementation) + await env.factory({ number: 1 })(env.art) + for (let index = 0; index < 3; index++) { + env.art.emit('video:loadedmetadata') + env.metadata() + env.finish(index) + } + assert.equal(env.videos.length, 3) + assert.equal(env.urls.size, 3) + assert.equal(env.operations.some(item => item.name === 'pause' || item.name === 'load'), false) + assert.equal(env.videos.every(video => typeof video.onseeked === 'function'), true) + }) + + test(`Auto-thumbnail ${implementation.name} historical: reverse Blob delivery replaces a complete sheet with its initial blank snapshot`, async () => { + const env = autoThumbnailEnvironment(implementation) + await env.factory({ number: 2 })(env.art) + env.art.emit('video:loadedmetadata') + env.metadata() + env.seeked() + env.seeked() + assert.equal(env.blobs.length, 3, 'All native encodes can be pending together') + env.finish(2, { frames: 2 }) + const completed = env.updates.at(-1).url + env.finish(0, { frames: 0 }) + assert.equal(env.urls.has(completed), false) + assert.equal(env.urls.get(env.updates.at(-1).url).frames, 0) + env.finish(1, { frames: 1 }) + assert.equal(env.urls.get(env.updates.at(-1).url).frames, 1) + }) + + test(`Auto-thumbnail ${implementation.name} historical: null Blob revokes the previous usable URL before throwing`, async () => { + const env = autoThumbnailEnvironment(implementation) + await env.factory({ number: 2 })(env.art) + env.art.emit('video:loadedmetadata') + env.metadata() + env.finish() + const sheet = env.art.thumbnails + env.seeked() + assert.throws(() => env.finish(1, null), /Blob required/) + assert.equal(env.urls.has(sheet.url), false) + assert.equal(env.art.thumbnails, sheet, 'Public state still references the revoked URL') + assert.equal(typeof env.videos[0].onseeked, 'function') + }) + + test(`Auto-thumbnail ${implementation.name} historical: draw and encode exceptions escape and retain decoder callbacks`, async () => { + for (const phase of ['draw', 'encode']) { + const env = autoThumbnailEnvironment(implementation) + const failure = new Error(`${phase} failed`) + await env.factory({ number: 2 })(env.art) + env.art.emit('video:loadedmetadata') + if (phase === 'draw') { + env.metadata() + env.controls.drawError = failure + assert.throws(() => env.seeked(), error => error === failure) + } + else { + env.controls.encodeError = failure + assert.throws(() => env.metadata(), error => error === failure) + } + assert.equal(typeof env.videos[0].onloadedmetadata, 'function') + assert.equal(env.videos[0].src, 'original.mp4') + assert.equal(env.operations.some(item => item.name === 'pause' || item.name === 'load'), false) + } + }) + + test(`Auto-thumbnail ${implementation.name} historical: missing context and native media errors have no failure owner`, async () => { + const env = autoThumbnailEnvironment(implementation) + await env.factory({ number: 2 })(env.art) + env.art.emit('video:loadedmetadata') + env.controls.nullContext = true + env.metadata() + assert.throws(() => env.seeked(), /drawImage/) + assert.equal(env.videos[0].onerror, null) + assert.equal(env.operations.some(item => item.name === 'pause' || item.name === 'load'), false) + }) + + test(`Auto-thumbnail ${implementation.name} historical: thumbnail setter failure leaks the new URL and does not cancel later work`, async () => { + const env = autoThumbnailEnvironment(implementation) + const failure = new Error('thumbnail setter failed') + await env.factory({ number: 2 })(env.art) + env.art.emit('video:loadedmetadata') + env.metadata() + env.controls.updateError = failure + assert.throws(() => env.finish(), error => error === failure) + assert.equal(env.urls.size, 1) + assert.equal(env.updates.length, 0) + env.controls.updateError = null + env.seeked() + env.finish(1) + assert.equal(env.updates.length, 1) + env.art.emit('destroy') + assert.equal(env.urls.size, 1) + }) +} diff --git a/test/browser/auto-thumbnail.spec.js b/test/browser/auto-thumbnail.spec.js new file mode 100644 index 000000000..e04b28773 --- /dev/null +++ b/test/browser/auto-thumbnail.spec.js @@ -0,0 +1,195 @@ +import process from 'node:process' +import { hash } from '../../refactor/scripts/releases.mjs' +import { autoThumbnailHistorical } from '../helpers/auto-thumbnail.js' +import { expect, test } from './fixtures.js' + +const implementations = (await autoThumbnailHistorical()).filter(item => ['published-1.0.1-main', 'published-1.1.0-main', 'frozen-workspace-js'].includes(item.name)) + +for (const implementation of implementations) { + test(`Auto-thumbnail ${implementation.name}: native JPEG callbacks outlive destroy and can restore the blank sheet`, async ({ page, browserName }, testInfo) => { + await page.goto('/test/player.html?core=published') + await page.setContent('
') + await page.addScriptTag({ content: implementation.code }) + await page.evaluate(async () => { + const createElement = document.createElement.bind(document) + const createUrl = URL.createObjectURL.bind(URL) + const revokeUrl = URL.revokeObjectURL.bind(URL) + const toBlob = HTMLCanvasElement.prototype.toBlob + const probe = window.probe = { videos: [], canvases: [], encodes: [], updates: [], urls: new Map(), revoked: [], seeks: [], destroyed: false } + document.createElement = function (name, ...args) { + const element = createElement(name, ...args) + if (name === 'video') { + probe.videos.push(element) + element.addEventListener('seeked', () => probe.seeks.push(element.currentTime)) + } + if (name === 'canvas') + probe.canvases.push(element) + return element + } + HTMLCanvasElement.prototype.toBlob = function (callback, type, quality) { + const index = probe.encodes.length + const record = { index, blob: null, callback, type, width: this.width, height: this.height } + probe.encodes.push(record) + return toBlob.call(this, blob => record.blob = blob, type, quality) + } + URL.createObjectURL = (blob) => { + const url = createUrl(blob) + probe.urls.set(url, blob) + return url + } + URL.revokeObjectURL = (url) => { + probe.revoked.push(url) + probe.urls.delete(url) + revokeUrl(url) + } + const listeners = new Map() + const art = { + option: { url: '/test/pattern.mp4' }, + on(name, callback) { + if (!listeners.has(name)) + listeners.set(name, new Set()) + listeners.get(name).add(callback) + }, + emit(name) { + for (const callback of listeners.get(name) || []) callback() + }, + get thumbnails() { return probe.updates.at(-1) }, + set thumbnails(value) { probe.updates.push({ ...value, afterDestroy: probe.destroyed }) }, + } + probe.art = art + probe.nativeToBlob = toBlob + probe.restore = () => { + document.createElement = createElement + HTMLCanvasElement.prototype.toBlob = toBlob + URL.createObjectURL = createUrl + URL.revokeObjectURL = revokeUrl + } + probe.result = await window.artplayerPluginAutoThumbnail({ width: 80, number: 4 })(art) + art.emit('video:loadedmetadata') + }) + await expect.poll(() => page.evaluate(() => window.probe.encodes.filter(item => item.blob).length)).toBe(5) + const evidence = await page.evaluate(async () => { + const probe = window.probe + async function pixels(blob) { + const image = document.createElement('img') + const url = URL.createObjectURL(blob) + image.src = url + await image.decode() + const canvas = document.createElement('canvas') + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + const ctx = canvas.getContext('2d') + ctx.drawImage(image, 0, 0) + const samples = Array.from({ length: 4 }, (_, index) => [...ctx.getImageData(40 + index * 80, 22, 1, 1).data]) + URL.revokeObjectURL(url) + return { width: canvas.width, height: canvas.height, pixel: samples[0], samples, type: blob.type } + } + const blanks = await pixels(probe.encodes[0].blob) + const final = await pixels(probe.encodes[4].blob) + const controlVideo = document.createElement('video') + controlVideo.crossOrigin = 'anonymous' + controlVideo.muted = true + document.body.append(controlVideo) + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Native control did not load')), 5000) + controlVideo.onloadeddata = () => { + clearTimeout(timeout) + resolve() + } + controlVideo.onerror = () => { + clearTimeout(timeout) + reject(new Error(`Native control media error ${controlVideo.error?.code}`)) + } + controlVideo.src = '/test/pattern.mp4' + }) + await controlVideo.play() + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Native control playback did not advance')), 5000) + const check = () => { + if (controlVideo.currentTime >= 0.25) { + clearTimeout(timeout) + resolve() + } + else { + requestAnimationFrame(check) + } + } + check() + }) + controlVideo.pause() + const controlCanvas = document.createElement('canvas') + controlCanvas.width = 800 + controlCanvas.height = 45 + const controlContext = controlCanvas.getContext('2d') + controlContext.drawImage(controlVideo, 0, 0, 80, 45) + const rawPixel = [...controlContext.getImageData(40, 22, 1, 1).data] + const controlBlob = await new Promise(resolve => probe.nativeToBlob.call(controlCanvas, resolve, 'image/jpeg')) + const native = { readyState: controlVideo.readyState, currentTime: controlVideo.currentTime, rawPixel, ...await pixels(controlBlob) } + controlContext.fillStyle = 'red' + controlContext.fillRect(0, 0, 80, 45) + const staticBlob = await new Promise(resolve => probe.nativeToBlob.call(controlCanvas, resolve, 'image/jpeg')) + const staticControl = await pixels(staticBlob) + probe.encodes[4].callback(probe.encodes[4].blob) + const good = probe.updates.at(-1).url + probe.destroyed = true + probe.art.emit('destroy') + const outstandingAfterDestroy = probe.urls.size + probe.encodes[0].callback(probe.encodes[0].blob) + const bad = probe.updates.at(-1).url + const result = { + blanks, + final, + native, + staticControl, + seeks: probe.seeks, + updates: probe.updates.map(({ url, ...rest }) => rest), + outstandingAfterDestroy, + goodRevoked: !probe.urls.has(good), + staleUrlLive: probe.urls.has(bad), + result: probe.result, + videoState: { src: probe.videos[0].getAttribute('src'), readyState: probe.videos[0].readyState, width: probe.videos[0].videoWidth, seekHandler: typeof probe.videos[0].onseeked, metadataHandler: typeof probe.videos[0].onloadedmetadata }, + } + // The test owns the cleanup because the historical plugin does not. + for (const video of probe.videos) { + video.pause() + video.removeAttribute('src') + video.load() + video.remove() + } + for (const url of [...probe.urls.keys()]) URL.revokeObjectURL(url) + probe.restore() + return result + }) + await testInfo.attach('auto-thumbnail-native-historical', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), scope: 'Native HTTP video, seeking and JPEG pixels with controlled delivery of native toBlob results; player-facing host is a stub, not a core integration result.', ...evidence }) }) + expect(evidence.result).toEqual({ name: 'artplayerPluginAutoThumbnail' }) + expect(evidence.blanks.type).toBe('image/jpeg') + expect(evidence.final.width).toBe(800) + expect(evidence.final.height).toBe(45) + expect(evidence.blanks.pixel.slice(0, 3).every(value => value < 5)).toBe(true) + expect(evidence.staticControl.pixel[0]).toBeGreaterThan(240) + expect(evidence.staticControl.pixel[1]).toBeLessThan(10) + expect(evidence.staticControl.pixel[2]).toBeLessThan(10) + expect(evidence.native.currentTime).toBeGreaterThanOrEqual(0.25) + expect(evidence.native.rawPixel.slice(0, 3).some(value => value > 30)).toBe(true) + expect(evidence.native.pixel.slice(0, 3).some(value => value > 30)).toBe(true) + const blackSamples = evidence.final.samples.every(pixel => pixel.slice(0, 3).every(value => value < 5)) + if (browserName === 'webkit' && process.platform === 'win32') + expect(blackSamples).toBe(true) + if (blackSamples) { + expect(browserName).toBe('webkit') + testInfo.annotations.push({ type: 'historical-media-defect', description: 'Historical plugin JPEG samples are black, while independent played HTTP video and static red canvas yield colored JPEGs. This is an extraction-path defect, not a waived browser capability.' }) + } + else { + expect(evidence.final.samples.every(pixel => pixel.slice(0, 3).some(value => value > 30))).toBe(true) + } + expect(evidence.seeks).toHaveLength(4) + expect(evidence.outstandingAfterDestroy).toBe(1) + expect(evidence.updates.map(item => item.afterDestroy)).toEqual([false, true]) + expect(evidence.goodRevoked).toBe(true) + expect(evidence.staleUrlLive).toBe(true) + expect(evidence.videoState.src).toBe('/test/pattern.mp4') + expect(evidence.videoState.width).toBeGreaterThan(0) + expect(evidence.videoState.seekHandler).toBe('function') + expect(evidence.videoState.metadataHandler).toBe('function') + }) +}