mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 10:56:15 -08:00
test(danmuku): [PKG-DANMUKU-02] establish failure browser and load baselines
This commit is contained in:
1 parent
ccf3d6ed14
commit
d3c91af92c
14 files changed
+8508
-15
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 test/asr-distribution.test.js",
|
||||
"typecheck": "node scripts/typecheck.mjs",
|
||||
"test:unit": "node --test test/danmuku-mask-failures.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js 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/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js",
|
||||
"test:unit": "node --test test/danmuku-failures.test.js test/danmuku-mask-failures.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js 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/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.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",
|
||||
@@ -99,7 +99,8 @@
|
||||
"test:asr-demo": "node refactor/scripts/asr-local-demo.mjs",
|
||||
"test:chromecast": "node --test test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js",
|
||||
"test:chromecast-types-package": "node refactor/scripts/chromecast-package-types.mjs",
|
||||
"test:danmuku-mask": "node --test test/danmuku-mask-failures.test.js refactor/scripts/danmuku-mask-contract.test.mjs"
|
||||
"test:danmuku-mask": "node --test test/danmuku-mask-failures.test.js refactor/scripts/danmuku-mask-contract.test.mjs",
|
||||
"test:danmuku": "node --test test/danmuku-failures.test.js refactor/scripts/danmuku-contract.test.mjs"
|
||||
},
|
||||
"browserslist": "last 1 Chrome version",
|
||||
"devDependencies": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,301 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"task": "PKG-DANMUKU-02",
|
||||
"scope": "Controlled historical failure subtask only; parent browser/clock/track/load baseline remains required",
|
||||
"wholeTaskComplete": false,
|
||||
"capturedAt": "2026-09-13T09:46:29.424Z",
|
||||
"sourceCommit": "b0cfbfe3a09a84a295a257e45e4577847588d1cb",
|
||||
"environment": {
|
||||
"node": "v24.21.0",
|
||||
"platform": "win32",
|
||||
"architecture": "x64",
|
||||
"executable": "refactor/.cache/toolchains/node-v24.21.0-win-x64/node.exe"
|
||||
},
|
||||
"inputs": {
|
||||
"test/helpers/danmuku.js": {
|
||||
"sha256LF": "4e4420d6fe7198bd91c7a444f3f82740991181c3a1b2cb8b91b1db6fc759046a"
|
||||
},
|
||||
"test/danmuku-failures.test.js": {
|
||||
"sha256LF": "1d7a4fc988f75239bc33453fe4334c17ab34420b58ae26a33d7fc7471be8bf8f"
|
||||
},
|
||||
"refactor/baselines/danmuku-release.json": {
|
||||
"sha256LF": "82a0ab2e87f108cbb1d09ce1b6647cfefc896d974da1cb60b2668fded18a4b2f"
|
||||
},
|
||||
"refactor/scripts/danmuku-contract.mjs": {
|
||||
"sha256LF": "8ca0996605cfbcd732030708bd67ecedff5018d2b7436fa7247de7bbb39d7573"
|
||||
},
|
||||
"refactor/changes/2026-09-13-PKG-DANMUKU-02-controlled-failures.md": {
|
||||
"sha256LF": "e4373ee55bcd1f96987d6a647663bef9d646e7caa30ba9abf5416202b3873169"
|
||||
}
|
||||
},
|
||||
"tests": {
|
||||
"command": "node --test test/danmuku-failures.test.js",
|
||||
"exitCode": 0,
|
||||
"passed": 42,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"log": "refactor/.cache/danmuku02-controlled.log",
|
||||
"logSha256": "5e17966b1cd19f045b1d74d2ac065271ddbaeec44b7c5b533d625f3bd5b05670",
|
||||
"implementationCount": 3,
|
||||
"scenariosPerImplementation": 14
|
||||
},
|
||||
"lint": {
|
||||
"command": "node node_modules/eslint/bin/eslint.js test/helpers/danmuku.js test/danmuku-failures.test.js",
|
||||
"exitCode": 0,
|
||||
"log": "refactor/.cache/danmuku02-controlled-lint.log",
|
||||
"logSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"execution": {
|
||||
"source": "Immutable Git modules bundled only in memory; Worker import transport controlled, parser actual generated Blob executed in a VM",
|
||||
"main": "7c8749288711ef97e80b6f9f00981170aa73f62a8ae25d6d663749a4aef62a8f",
|
||||
"legacy": "62db96685c48b8573f0480cead6ac934b1d0382b9fdd0046d17d15d2549c56dc",
|
||||
"validator": "Actual frozen published Artplayer 5.4.0 static validator",
|
||||
"domAndClock": "Controlled, not a browser/layout/playback claim"
|
||||
},
|
||||
"isolatedProcesses": {
|
||||
"directory": "refactor/.cache/danmuku02-controlled/run-9m9V8f",
|
||||
"count": 15,
|
||||
"rejectionProcesses": 9,
|
||||
"rejectionFlags": "--unhandled-rejections=strict",
|
||||
"observation": "uncaughtExceptionMonitor only; errors remain fatal with exit 1 and origin unhandledRejection",
|
||||
"heatmapProcesses": 6,
|
||||
"vmTimeoutMilliseconds": 100,
|
||||
"processTimeoutMilliseconds": 3000,
|
||||
"timeoutScope": "Expected VM timeout only for default sampling at width99; explicit sampling1 completes; outer process timeout fails the test",
|
||||
"reports": [
|
||||
{
|
||||
"file": "refactor/.cache/danmuku02-controlled/run-9m9V8f/frozen-source-beforeVisible-reject.json",
|
||||
"sha256": "41748d818ad4b20b916140bec42b58687943dec7f1f7faa42e01ef7d57f76b42",
|
||||
"implementation": "frozen-source",
|
||||
"scenario": "beforeVisible-reject",
|
||||
"status": 1,
|
||||
"signal": null,
|
||||
"errorCode": null,
|
||||
"stdout": "{\"stage\":\"fatal\",\"origin\":\"unhandledRejection\",\"error\":\"beforeVisible-original\",\"settled\":false,\"errors\":0}\n"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/danmuku02-controlled/run-9m9V8f/frozen-source-fetch-reject.json",
|
||||
"sha256": "8f05b57fe32605ff3233394304d7851760ed8335e7cb2cd4314e3399e54f6efa",
|
||||
"implementation": "frozen-source",
|
||||
"scenario": "fetch-reject",
|
||||
"status": 1,
|
||||
"signal": null,
|
||||
"errorCode": null,
|
||||
"stdout": "{\"stage\":\"fatal\",\"origin\":\"unhandledRejection\",\"error\":\"fetch-original\",\"settled\":false,\"errors\":0}\n"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/danmuku02-controlled/run-9m9V8f/frozen-source-heatmap-positive.json",
|
||||
"sha256": "faf1edb2e96a800a7605db93e1266d3cb7304672609843f061ee09484251e841",
|
||||
"implementation": "frozen-source",
|
||||
"scenario": "heatmap-positive",
|
||||
"status": 0,
|
||||
"signal": null,
|
||||
"errorCode": null,
|
||||
"stdout": "heatmap-ready\n{\"stage\":\"complete\"}\n"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/danmuku02-controlled/run-9m9V8f/frozen-source-heatmap-zero.json",
|
||||
"sha256": "5d3f166e103cccd2e6ba6b81e6cbcf95454f11c2f15427b41763a73ff4099dd8",
|
||||
"implementation": "frozen-source",
|
||||
"scenario": "heatmap-zero",
|
||||
"status": 0,
|
||||
"signal": null,
|
||||
"errorCode": null,
|
||||
"stdout": "heatmap-ready\n{\"stage\":\"bounded-timeout\",\"code\":\"ERR_SCRIPT_EXECUTION_TIMEOUT\"}\n"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/danmuku02-controlled/run-9m9V8f/frozen-source-text-reject.json",
|
||||
"sha256": "261c3206b5180ff0e093b21a842e1bcd1dbcbccea637574c2a15416a75a706ba",
|
||||
"implementation": "frozen-source",
|
||||
"scenario": "text-reject",
|
||||
"status": 1,
|
||||
"signal": null,
|
||||
"errorCode": null,
|
||||
"stdout": "{\"stage\":\"fatal\",\"origin\":\"unhandledRejection\",\"error\":\"text-original\",\"settled\":false,\"errors\":0}\n"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/danmuku02-controlled/run-9m9V8f/published-5.3.0-legacy-beforeVisible-reject.json",
|
||||
"sha256": "6f2c59251bcb37d029eac4e1edd68fb0bedd6499781f045a384e40e0bf2810ef",
|
||||
"implementation": "published-5.3.0-legacy",
|
||||
"scenario": "beforeVisible-reject",
|
||||
"status": 1,
|
||||
"signal": null,
|
||||
"errorCode": null,
|
||||
"stdout": "{\"stage\":\"fatal\",\"origin\":\"unhandledRejection\",\"error\":\"beforeVisible-original\",\"settled\":false,\"errors\":0}\n"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/danmuku02-controlled/run-9m9V8f/published-5.3.0-legacy-fetch-reject.json",
|
||||
"sha256": "76e39a61d50e7a2722ae8fef16672764b91e28d6b8496b7bbd13cdf908418e13",
|
||||
"implementation": "published-5.3.0-legacy",
|
||||
"scenario": "fetch-reject",
|
||||
"status": 1,
|
||||
"signal": null,
|
||||
"errorCode": null,
|
||||
"stdout": "{\"stage\":\"fatal\",\"origin\":\"unhandledRejection\",\"error\":\"fetch-original\",\"settled\":false,\"errors\":0}\n"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/danmuku02-controlled/run-9m9V8f/published-5.3.0-legacy-heatmap-positive.json",
|
||||
"sha256": "3b672dbb6af585b5f260ab3cf2ff294cf52026de192f64dcaa4202b4ae914dd3",
|
||||
"implementation": "published-5.3.0-legacy",
|
||||
"scenario": "heatmap-positive",
|
||||
"status": 0,
|
||||
"signal": null,
|
||||
"errorCode": null,
|
||||
"stdout": "heatmap-ready\n{\"stage\":\"complete\"}\n"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/danmuku02-controlled/run-9m9V8f/published-5.3.0-legacy-heatmap-zero.json",
|
||||
"sha256": "817e3f4f61eb3c5f19096e9a6cc7fe01d5fb4be6756e52ab305de2e51fef7485",
|
||||
"implementation": "published-5.3.0-legacy",
|
||||
"scenario": "heatmap-zero",
|
||||
"status": 0,
|
||||
"signal": null,
|
||||
"errorCode": null,
|
||||
"stdout": "heatmap-ready\n{\"stage\":\"bounded-timeout\",\"code\":\"ERR_SCRIPT_EXECUTION_TIMEOUT\"}\n"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/danmuku02-controlled/run-9m9V8f/published-5.3.0-legacy-text-reject.json",
|
||||
"sha256": "63c38503aad6fc9c0eafecc59e906c441827333d7fa102cae370511aadcd077b",
|
||||
"implementation": "published-5.3.0-legacy",
|
||||
"scenario": "text-reject",
|
||||
"status": 1,
|
||||
"signal": null,
|
||||
"errorCode": null,
|
||||
"stdout": "{\"stage\":\"fatal\",\"origin\":\"unhandledRejection\",\"error\":\"text-original\",\"settled\":false,\"errors\":0}\n"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/danmuku02-controlled/run-9m9V8f/published-5.3.0-main-beforeVisible-reject.json",
|
||||
"sha256": "338d99844c2e05ac121beef206550a0f590c11e6b4caf3f702cce62c68d3c79d",
|
||||
"implementation": "published-5.3.0-main",
|
||||
"scenario": "beforeVisible-reject",
|
||||
"status": 1,
|
||||
"signal": null,
|
||||
"errorCode": null,
|
||||
"stdout": "{\"stage\":\"fatal\",\"origin\":\"unhandledRejection\",\"error\":\"beforeVisible-original\",\"settled\":false,\"errors\":0}\n"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/danmuku02-controlled/run-9m9V8f/published-5.3.0-main-fetch-reject.json",
|
||||
"sha256": "97428c512f7eb613a1102532170b92a395f6845b83e03d4242084ffd69617a45",
|
||||
"implementation": "published-5.3.0-main",
|
||||
"scenario": "fetch-reject",
|
||||
"status": 1,
|
||||
"signal": null,
|
||||
"errorCode": null,
|
||||
"stdout": "{\"stage\":\"fatal\",\"origin\":\"unhandledRejection\",\"error\":\"fetch-original\",\"settled\":false,\"errors\":0}\n"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/danmuku02-controlled/run-9m9V8f/published-5.3.0-main-heatmap-positive.json",
|
||||
"sha256": "a44cbec41648ea681eb4745561e704596aa0c2e4bc4c4024084701434a0a0fe1",
|
||||
"implementation": "published-5.3.0-main",
|
||||
"scenario": "heatmap-positive",
|
||||
"status": 0,
|
||||
"signal": null,
|
||||
"errorCode": null,
|
||||
"stdout": "heatmap-ready\n{\"stage\":\"complete\"}\n"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/danmuku02-controlled/run-9m9V8f/published-5.3.0-main-heatmap-zero.json",
|
||||
"sha256": "5677249e3b1d26932b27ff8f2a8749c1c6e2d1e409ff090e6d3722ff0660255a",
|
||||
"implementation": "published-5.3.0-main",
|
||||
"scenario": "heatmap-zero",
|
||||
"status": 0,
|
||||
"signal": null,
|
||||
"errorCode": null,
|
||||
"stdout": "heatmap-ready\n{\"stage\":\"bounded-timeout\",\"code\":\"ERR_SCRIPT_EXECUTION_TIMEOUT\"}\n"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/danmuku02-controlled/run-9m9V8f/published-5.3.0-main-text-reject.json",
|
||||
"sha256": "f56b3c1f0d07f269f92a23d7751fc18c6b0b881c5458ddf98a33cdfe1c2c5401",
|
||||
"implementation": "published-5.3.0-main",
|
||||
"scenario": "text-reject",
|
||||
"status": 1,
|
||||
"signal": null,
|
||||
"errorCode": null,
|
||||
"stdout": "{\"stage\":\"fatal\",\"origin\":\"unhandledRejection\",\"error\":\"text-original\",\"settled\":false,\"errors\":0}\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reproduced": [
|
||||
{
|
||||
"id": "DMK-FAIL-01",
|
||||
"issue": "fetch rejection leaves public load pending and skips error event",
|
||||
"next": "PKG-DANMUKU-03"
|
||||
},
|
||||
{
|
||||
"id": "DMK-FAIL-02",
|
||||
"issue": "response.text rejection leaves public load pending and skips error event",
|
||||
"next": "PKG-DANMUKU-03"
|
||||
},
|
||||
{
|
||||
"id": "DMK-FAIL-03",
|
||||
"issue": "beforeVisible rejection becomes an ignored animation callback rejection",
|
||||
"next": "PKG-DANMUKU-04"
|
||||
},
|
||||
{
|
||||
"id": "DMK-FAIL-04",
|
||||
"issue": "width99 default sampling0 does not advance heatmap loop; positive control succeeds",
|
||||
"next": "PKG-DANMUKU-05"
|
||||
},
|
||||
{
|
||||
"id": "DMK-FAIL-05",
|
||||
"issue": "parser Worker error has no handler/settlement or destruction cleanup",
|
||||
"next": "PKG-DANMUKU-03"
|
||||
},
|
||||
{
|
||||
"id": "DMK-FAIL-06",
|
||||
"issue": "actual generated parser ignores empty XML and load remains pending",
|
||||
"next": "PKG-DANMUKU-03"
|
||||
},
|
||||
{
|
||||
"id": "DMK-FAIL-07",
|
||||
"issue": "successful parser does not revoke its Blob URL",
|
||||
"next": "PKG-DANMUKU-05"
|
||||
},
|
||||
{
|
||||
"id": "DMK-FAIL-08",
|
||||
"issue": "parser completion can populate queue and emit loaded after destroy",
|
||||
"next": "PKG-DANMUKU-03"
|
||||
},
|
||||
{
|
||||
"id": "DMK-FAIL-09",
|
||||
"issue": "old replacement load overwrites the newer completed queue",
|
||||
"next": "PKG-DANMUKU-03"
|
||||
},
|
||||
{
|
||||
"id": "DMK-FAIL-10",
|
||||
"issue": "function input resolves and emits loaded after destroy",
|
||||
"next": "PKG-DANMUKU-03"
|
||||
},
|
||||
{
|
||||
"id": "DMK-FAIL-11",
|
||||
"issue": "beforeVisible continuation allocates DOM and attempts terminated-worker post after destroy",
|
||||
"next": "PKG-DANMUKU-04"
|
||||
},
|
||||
{
|
||||
"id": "DMK-FAIL-12",
|
||||
"issue": "already delivered Worker response continuation changes state after destroy",
|
||||
"next": "PKG-DANMUKU-04"
|
||||
},
|
||||
{
|
||||
"id": "DMK-FAIL-13",
|
||||
"issue": "play/playing duplicate loops overwrite response handlers and strand an earlier Promise",
|
||||
"next": "PKG-DANMUKU-04"
|
||||
},
|
||||
{
|
||||
"id": "DMK-FAIL-14",
|
||||
"issue": "pause/destroy leaves a previously scheduled duplicate frame",
|
||||
"next": "PKG-DANMUKU-04"
|
||||
}
|
||||
],
|
||||
"limitations": [
|
||||
"Historical defect observations must not become candidate normal expectations",
|
||||
"No native browser or server started",
|
||||
"No actual Bilibili/media request, native Worker/CSP, video clock or layout tested",
|
||||
"Late Worker continuation test delivers before terminate and resumes after it; no post-terminate native event guarantee assumed",
|
||||
"Real track/clock/load/performance and visual/browser evidence required for whole task",
|
||||
"No production source/declaration/artifact edits and no publication readiness conclusion"
|
||||
],
|
||||
"productionChanged": false,
|
||||
"browserValidated": false,
|
||||
"releaseReady": false
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
# PKG-DANMUKU-02 浏览器与负载基线
|
||||
|
||||
本记录整合先前的[受控失败子项](2026-09-13-PKG-DANMUKU-02-controlled-failures.md):
|
||||
42项旧失败观察及父代理复跑通过,新增真实浏览器的行为、布局和负载记录。
|
||||
这里完成的是重构前基线,生产代码、类型及dist均未修改;没有把已复现缺陷当作
|
||||
候选必须保持的行为,也不代表弹幕能力或整包已经达到发布条件。
|
||||
|
||||
## 实际浏览器范围
|
||||
|
||||
使用真实npm Danmuku 5.3.0 main/legacy归档字节,每次核验SHA;真实已发布核心
|
||||
5.4.0及当前候选5.4.1。Chromium 153.0.8010.12、Firefox 155.0、Windows WebKit26.6,
|
||||
后者不是Safari真机。页面运行本地八秒视频、原生Blob Worker、实际DOM/设置操作。
|
||||
Worker子类只记录创建/消息/终止,不替换回复、算法、RAF或媒体时钟。外网被拒绝,
|
||||
同源Blob可用。共2核心×2插件格式×4场景×3引擎,48项通过、无跳过或未处理页面错误。
|
||||
|
||||
- 三种弹幕模式、同步filter与异步beforeVisible,真实居中/边界布局与Worker工作。
|
||||
- 六条同时固定弹幕的顺序、严格非重叠几何与暂停状态。
|
||||
- 原生play/pause/seek、两倍播放及四秒CSS运动时长,reset节点复用和load替换/追加。
|
||||
- 实际设置面板、异步发送去重和锁定、外挂输入框在网页全屏时移入并回位,双实例
|
||||
销毁隔离;旧版外部设置DOM残留如实记录为后续清理问题。
|
||||
|
||||
截图、原始几何、事件/Worker记录和运行输入在
|
||||
[机器报告](../baselines/danmuku-browser-validation.json)引用的不可变归档中。
|
||||
|
||||
## 初轮失败及处置
|
||||
|
||||
首轮Chromium为7通过、9失败,原spec、报告、截图和trace完整保留。
|
||||
八项来自两个错误测试假设:网页全屏会把播放器移到body,原容器选择器失效;
|
||||
旧版可能复用一个节点后又生成额外节点,不能要求最终可见节点必须是原节点。
|
||||
测试改为验证实际控件引用归属,以及原节点确实被再次使用,同时记录额外/隐藏节点。
|
||||
没有修改生产行为来满足测试。
|
||||
|
||||
另一次是**实际轨道重叠**:dense0和dense1的y都为10,宽70、高22.5,并观察到
|
||||
两次start及重复Worker消息id。截图也显示两条文字重叠。DANMUKU-TRACK-01保持开放,
|
||||
与已受控复现的双RAF/请求覆盖问题一同由04/05/07修复验收。严格非重叠断言仍保留;
|
||||
后次48通过只能说明那一轮没有触发,不能说明竞态已修复,后续运行仍可能再次报红。
|
||||
|
||||
负载首轮六项也保留:读取不存在的art.paused导致等待失败。改为原生
|
||||
art.template.$video.paused后重跑通过;这属于测试错误,不记录成生产缺陷。
|
||||
|
||||
## 300条弹幕负载观察
|
||||
|
||||
两种实际插件格式、已发布核心、三个引擎,共六个配置;各载入300个独立ID混合模式
|
||||
弹幕,确认完整队列、真实播放到至少四秒、可见输出、Worker工作和销毁。保存原始
|
||||
帧时间戳/间隔、实际DOM池与几何、可见ID及各阶段performance.memory。独立RAF
|
||||
观察者不更改插件时钟,但其自身也有测量开销。
|
||||
|
||||
初次三worker运行全部通过。WebKit帧间隔较长,因此再以单worker串行复测六个配置,
|
||||
减少并行测试争用;两份报告均保留,不能混为相同条件或增加六种功能覆盖。
|
||||
初次并行样本的可见唯一ID为51–129,DOM节点53–131;300载入成功不代表300条都
|
||||
在短窗口显示。真实排队/轨道与显示窗口仍受当前调度器影响。
|
||||
|
||||
串行六项也通过。该主机上的帧间隔中位数:Chromium两格式约16.7ms,Firefox约
|
||||
16.66ms,Windows WebKit为78/80ms;WebKit的p95为101/104ms。其样本可见51个ID、
|
||||
DOM53个节点;记录保留这个差异,不因测试通过就称流畅,也不外推为Safari设备结论。
|
||||
实际暂停时间约4.61–5.06秒,原始时间戳保留,后续比较须使用相同输入/观察方式。
|
||||
|
||||
Chromium提供堆读数,Firefox/WebKit为空;没有强制GC、跨引擎堆值比较、短样本
|
||||
泄漏结论或凭空设置性能阈值。串行结果是后续同条件对照,07仍需更长负载、重复
|
||||
装卸和资源稳定性验收。真实设备/CSP、Bilibili网络、Mask模型和最终新旧组合仍归后续。
|
||||
|
||||
## 重放、提交与下一步
|
||||
|
||||
```sh
|
||||
yarn test:danmuku
|
||||
yarn exec playwright test test/browser/danmuku-baseline.spec.js --workers=3
|
||||
yarn exec playwright test test/browser/danmuku-load-baseline.spec.js --workers=1
|
||||
```
|
||||
|
||||
固定Node24.21.0/Yarn1.22.22。受控测试接入test:unit,契约由test:baseline收集,
|
||||
浏览器文件由既有Playwright发现。没有安装依赖、修改锁文件或使用全局吞错处理。
|
||||
lint及任务/风险/契约索引校验随本次提交;本任务独立commit,无推送、标签或npm发布。
|
||||
|
||||
03接着整理加载、解析与config,保留数组同步初始化顺序、内部链式返回身份、
|
||||
追加/替换区别、逐条await emit以及live option回调接收者;优先修复网络拒绝悬挂、
|
||||
空XML、解析Worker/Blob释放、过期替换、time0和函数配置更新。轨道Worker/RAF、
|
||||
设置/heatmap和完整TS公开类型分别由04/05/06接续。
|
||||
@@ -0,0 +1,88 @@
|
||||
# PKG-DANMUKU-02 受控失败基线子项
|
||||
|
||||
本记录只交付 02 的受控测试部分,**不代表 PKG-DANMUKU-02 整体完成**。
|
||||
真实浏览器的时钟、轨道、密集/稀疏负载和可视结果由主任务另行验收。
|
||||
没有修改生产源码、声明、构建产物、共享元数据或 root scripts;没有启动服务或浏览器。
|
||||
|
||||
## 冻结输入与测试方法
|
||||
|
||||
复用 [01 发布基线](../baselines/danmuku-release.json) 与
|
||||
[不可变校验器](../scripts/danmuku-contract.mjs),执行三个实际实现:
|
||||
|
||||
- Git `b0cfbfe3a09a84a295a257e45e4577847588d1cb` 的冻结源码,只在内存打包。
|
||||
- 真正 npm artplayer-plugin-danmuku@5.3.0 main 字节。
|
||||
- 同一发布版的 legacy 字节。
|
||||
|
||||
[test/helpers/danmuku.js](../../test/helpers/danmuku.js) 提供受控 DOM、时钟、rAF、
|
||||
timer、fetch 和 Worker transport;配置验证使用实际发布核心 5.4.0 的 validator。
|
||||
[`danmukuHistorical()`](../../test/helpers/danmuku.js) 返回 `{name,code,coreCode}` 数组;
|
||||
`danmukuEnvironment(implementation, settings)` 控制请求、帧、回应和资源观察。
|
||||
其捕获的 Bilibili Blob 内容在独立 VM 中实际执行,XML 的空输入和正常解析不是手写替身。
|
||||
框架只模拟传输/布局,不把它说成原生 Worker 或真实 DOM/播放验证。
|
||||
|
||||
[test/danmuku-failures.test.js](../../test/danmuku-failures.test.js) 每个实现有 14 个场景,
|
||||
共 42 项历史观察全部通过。测试中的“失败”指真实历史缺陷;这些断言不能用于要求候选
|
||||
保留缺陷。候选修复必须在后续任务中另加正确行为断言。
|
||||
|
||||
## 已复现观察
|
||||
|
||||
| 测试场景 | 精确结果 | 后续责任 |
|
||||
| --- | --- | --- |
|
||||
| DMK-FAIL-01 fetch rejection | fetch 原拒绝成为未处理拒绝;公开 load 不 settle,未发 :error | 03 |
|
||||
| DMK-FAIL-02 text rejection | response.text 原拒绝同样脱离公开 load/error 路径 | 03 |
|
||||
| DMK-FAIL-03 beforeVisible rejection | 异步 rAF 回调 Promise 被忽略,原错成为未处理拒绝;无 :error | 04 |
|
||||
| DMK-FAIL-04 narrow heatmap | offsetWidth=99 的默认 sampling=0 耗尽 VM 时间预算;显式 sampling=1 正常完成 | 05 |
|
||||
| DMK-FAIL-05 parser worker error | 未装错误处理;错误投递后 load 不 settle,destroy 也未终止这个 parser worker | 03/05 |
|
||||
| DMK-FAIL-06 empty XML | 实际 Blob parser 忽略空 XML 而不回消息;load 一直等待,parser worker 活着 | 03 |
|
||||
| DMK-FAIL-07 Blob URL | 真实 parser 成功后 worker terminate,但对应 URL 没有 revoke | 03/05 |
|
||||
| DMK-FAIL-08 late parser | destroy 时 parser worker 未终止;之后实际 parser 完成仍向 queue 添加并发 :loaded | 03/05 |
|
||||
| DMK-FAIL-09 out-of-order load | 两次无参数替换 load,第二次先完成,第一次晚完成将新队列覆盖为旧数据 | 03 |
|
||||
| DMK-FAIL-10 late function input | destroy 后异步输入继续入队并触发 :loaded | 03 |
|
||||
| DMK-FAIL-11 late beforeVisible | destroy 后用户 Promise 成功继续创建弹幕 DOM,尝试向已 terminate worker postMessage | 04/05 |
|
||||
| DMK-FAIL-12 late continuation | worker 消息在 destroy 前送达,await continuation 在 destroy 后将 wait 改成 ready | 04/05 |
|
||||
| DMK-FAIL-13 competing loops | play + playing 建两条循环;不同 id 的第二请求覆盖 onmessage,第一请求永不 settle | 04 |
|
||||
| DMK-FAIL-14 remaining frame | 双 start 后 pause/destroy 只取消最后一帧,另一已排定帧仍存在 | 04 |
|
||||
|
||||
DMK-FAIL-12 不依赖浏览器在 terminate 后再派发 Worker 消息:测试先完成消息投递,
|
||||
同步 destroy,再让 Promise continuation 恢复;不会夸大原生 Worker 终止语义。
|
||||
DMK-FAIL-11 的证据是 DOM 分配与 postMessage 调用尝试,不宣称原生 worker 已终止后
|
||||
仍执行代码或真的显示弹幕。测试也确认 late worker 分支没有发新的 :visible。
|
||||
|
||||
03 需要明确异步取消如何 settle 公开 load Promise、如何保持原错误通知/返回关系;
|
||||
04/05 需要单一帧循环、每请求关联、generation/销毁防护和 parser/Blob/设置的资源所有权。
|
||||
这只是后续设计边界,不在本任务提前实现或改变接口。
|
||||
|
||||
## 进程隔离与可重复证据
|
||||
|
||||
三类未处理拒绝各实现分别运行独立 Node 子进程,共 9 次,固定
|
||||
`--unhandled-rejections=strict`。只用 `uncaughtExceptionMonitor` 记录原错误、origin、
|
||||
公开 Promise settled 标记和 :error 数量,不注册吞掉错误的 unhandledRejection 或
|
||||
uncaughtException 处理器。实际进程 exit 1,monitor 记录 origin=unhandledRejection;
|
||||
不是把未处理拒绝隐藏到通过结果中。
|
||||
|
||||
热力图另有 6 次独立进程:三个 sampling=0,三个 sampling=1 对照。内部 VM 100ms
|
||||
执行预算明确产生 `ERR_SCRIPT_EXECUTION_TIMEOUT`;对照完成且没有超时。外层进程
|
||||
还有 3000ms 最终保护,外层超时本身会使测试失败,不能被算作正常观察。
|
||||
整个重放不会把零步长循环交给真实浏览器页面。
|
||||
|
||||
每次测试写独立 `refactor/.cache/danmuku02-controlled/run-*/`,保留 15 个子进程
|
||||
stdout/stderr/status JSON。最终目录、文件 SHA、脚本 SHA、命令与汇总见
|
||||
[danmuku-failures-validation.json](../baselines/danmuku-failures-validation.json)。
|
||||
|
||||
```text
|
||||
node --test test/danmuku-failures.test.js
|
||||
node node_modules/eslint/bin/eslint.js test/helpers/danmuku.js test/danmuku-failures.test.js
|
||||
```
|
||||
|
||||
实际 Node 24.21.0 / Windows,42 passed / 0 failed / 0 skipped;专属 lint 通过。
|
||||
没有新依赖、没有修改 Yarn lock。缺少归档缓存时,01 verifier 只向冻结 npm URL 请求,
|
||||
测试本身不会请求 Bilibili、媒体或任何识别服务。
|
||||
|
||||
## 未完成范围与回退
|
||||
|
||||
尚未覆盖原生 Worker/CSP、真实媒体时钟和 seek/倍率、实际测量的防重叠/轨道顺序、
|
||||
长时间负载、内存、设置触摸/旋转、mask/fullscreen/PiP/外部挂载、浏览器错误表现。
|
||||
这些内容仍是 PKG-DANMUKU-02 及后续组合验收的门槛。本报告不标包重构完成或 npm 可发布。
|
||||
|
||||
此批只有历史测试与证据,可独立撤销,不影响发布包。整合代理将本子项与 02 的其他
|
||||
必要证据一起管理状态;不要仅依据这 42 个观察把整个任务标为 done。
|
||||
+4
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 228 项,范围 22 个包及工作区/示例。
|
||||
|
||||
状态:todo 73 / doing 15 / blocked 0 / done 140 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
状态:todo 72 / doing 15 / blocked 0 / done 141 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
|
||||
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
|
||||
|
||||
@@ -310,8 +310,8 @@
|
||||
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| PKG-DANMUKU-01 | artplayer-plugin-danmuku<br>核对弹幕全部公开契约 | BASE-05 | emit/load/config/hide/show/reset/mount、option/isHide/isStop/icons 与事件清单 | 源码、声明、发布包、Bilibili 输入和设置入口对照 | H | done |
|
||||
| PKG-DANMUKU-02 | artplayer-plugin-danmuku<br>建立弹幕算法与浏览器基线 | PKG-DANMUKU-01, ENG-05, ENG-08 | 密集/稀疏弹幕、过滤/异步输入、seek/倍率/重载测试 | 轨道选择、发射顺序、事件、内存和可视结果可重跑 | H | doing |
|
||||
| PKG-DANMUKU-03 | artplayer-plugin-danmuku<br>整理加载、解析与配置 | PKG-DANMUKU-02, CORE-08 | bilibili/input/parser/config 的分层与取消 | 旧格式、回调、过滤、追加/替换语义保持 | M | todo |
|
||||
| PKG-DANMUKU-02 | artplayer-plugin-danmuku<br>建立弹幕算法与浏览器基线 | PKG-DANMUKU-01, ENG-05, ENG-08 | 密集/稀疏弹幕、过滤/异步输入、seek/倍率/重载测试 | 轨道选择、发射顺序、事件、内存和可视结果可重跑 | H | done |
|
||||
| PKG-DANMUKU-03 | artplayer-plugin-danmuku<br>整理加载、解析与配置 | PKG-DANMUKU-02, CORE-08 | bilibili/input/parser/config 的分层与取消 | 旧格式、回调、过滤、追加/替换语义保持 | M | doing |
|
||||
| PKG-DANMUKU-04 | artplayer-plugin-danmuku<br>整理时钟、队列与轨道调度 | PKG-DANMUKU-03, CORE-10 | danmuku 调度器和确定性时钟测试 | seek/暂停/倍率/长时间运行无顺序和碰撞回归 | H | todo |
|
||||
| PKG-DANMUKU-05 | artplayer-plugin-danmuku<br>整理 DOM 渲染、设置、热力图与 worker | PKG-DANMUKU-04, CORE-14, CORE-18 | renderer/setting/heatmap/worker 职责及资源归属 | mount/icons/设置和 worker 协议保持,销毁无后台工作 | H | todo |
|
||||
| PKG-DANMUKU-06 | artplayer-plugin-danmuku<br>迁移 TS 与公开声明 | PKG-DANMUKU-05, ENG-04, ENG-06 | 自有模块、worker 消息、option/item/result 类型 | 旧参数、扩展字段和事件消费通过,vendored 边界清楚 | H | todo |
|
||||
@@ -554,6 +554,7 @@
|
||||
- PKG-DPIP-04: [记录](changes/2026-09-12-PKG-DPIP-04-types.md) [记录](baselines/dpip-types-validation.json) [记录](dpip-validation.md)
|
||||
- PKG-DPIP-05: [记录](changes/2026-09-12-PKG-DPIP-05-native-checkpoint.md) [记录](baselines/dpip-native-validation.json)
|
||||
- PKG-DANMUKU-01: [记录](baselines/danmuku-release.json) [记录](baselines/danmuku-contract.md) [记录](baselines/danmuku-contract-validation.json) [记录](changes/2026-09-13-PKG-DANMUKU-01-contracts.md)
|
||||
- PKG-DANMUKU-02: [记录](changes/2026-09-13-PKG-DANMUKU-02-controlled-failures.md) [记录](baselines/danmuku-failures-validation.json) [记录](changes/2026-09-13-PKG-DANMUKU-02-browser-baseline.md) [记录](baselines/danmuku-browser-validation.json)
|
||||
- PKG-CANVAS-01: [记录](changes/2026-09-12-PKG-CANVAS-01-contract.md) [记录](baselines/canvas-contract.md) [记录](baselines/canvas-release.json) [记录](canvas-validation.md) [记录](baselines/canvas-contract-validation.json)
|
||||
- PKG-CANVAS-02: [记录](changes/2026-09-12-PKG-CANVAS-02-tests.md) [记录](baselines/canvas-behavior-validation.json) [记录](canvas-validation.md)
|
||||
- PKG-CANVAS-03: [记录](changes/2026-09-12-PKG-CANVAS-03-lifecycle.md) [记录](baselines/canvas-lifecycle-validation.json) [记录](canvas-validation.md)
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# 进度与证据
|
||||
|
||||
## PKG-DANMUKU-02 历史错误和真实浏览器基线完成
|
||||
|
||||
42项受控旧缺陷、48项原生Worker/视频/布局/设置及6项300弹幕负载观察通过;所有
|
||||
初轮失败留档。纠正两个测试假设,保留严格非重叠断言;首次测到的实际轨道重叠
|
||||
仍为DANMUKU-TRACK-01开放问题,后次48通过不能替代修复。短时heap/帧间隔不是
|
||||
长期内存或性能验收。见[变更](changes/2026-09-13-PKG-DANMUKU-02-browser-baseline.md)与[机器证据](baselines/danmuku-browser-validation.json)。
|
||||
根test:unit接入受控测试,浏览器文件由已有Playwright流程收集。
|
||||
228项:141 done、15 doing、72 todo。03开始输入/解析/config
|
||||
实施;04调度/05DOM和07负载稳定性继续。无推送或发布。
|
||||
|
||||
|
||||
## PKG-MASK-02 生命周期与错误基线完成
|
||||
|
||||
54项冻结历史测试及父代理复核通过,覆盖源码和两旧版本三格式的关键生命周期;
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
| DANMUKU-CONFIG-01 | open / 已复现 | Danmuku JSON configuration comparison ignores a replacement filter function | PKG-DANMUKU-02, PKG-DANMUKU-03 |
|
||||
| DANMUKU-CLEANUP-01 | open / 已复现 | Danmuku removes a different resize callback and retains the settings lock timer on destroy | PKG-DANMUKU-02, PKG-DANMUKU-05 |
|
||||
| DANMUKU-TYPE-02 | open / 源码/产物事实 | Danmuku id, icons, Slider steps, heatmap points and partial configuration drift from declarations | PKG-DANMUKU-05, PKG-DANMUKU-06 |
|
||||
| DANMUKU-ASYNC-01 | open / 源码/产物事实 | Danmuku asynchronous input, worker replies and pending visibility have unverified failure and cancellation paths | PKG-DANMUKU-02, PKG-DANMUKU-03, PKG-DANMUKU-04, PKG-DANMUKU-05 |
|
||||
| DANMUKU-HEATMAP-01 | open / 源码/产物事实 | Danmuku heatmap sampling can become zero and points/instance ownership need browser validation | PKG-DANMUKU-02, PKG-DANMUKU-05 |
|
||||
| DANMUKU-ASYNC-01 | open / 已复现 | Danmuku asynchronous input, worker replies and pending visibility have unverified failure and cancellation paths | PKG-DANMUKU-02, PKG-DANMUKU-03, PKG-DANMUKU-04, PKG-DANMUKU-05 |
|
||||
| DANMUKU-HEATMAP-01 | open / 已复现 | Danmuku heatmap sampling can become zero and points/instance ownership need browser validation | PKG-DANMUKU-02, PKG-DANMUKU-05 |
|
||||
| DANMUKU-SOURCE-01 | open / 已复现 | Danmuku registry Git associations differ from releases and static icon provenance remains incomplete | PKG-DANMUKU-09 |
|
||||
| DANMUKU-TRACK-01 | open / 已复现 | Native simultaneous fixed Danmuku comments can overlap with duplicate start loops and repeated Worker IDs | PKG-DANMUKU-04, PKG-DANMUKU-05, PKG-DANMUKU-07 |
|
||||
+34
-5
@@ -5082,7 +5082,9 @@
|
||||
"evidence": [
|
||||
"refactor/baselines/danmuku-contract.md",
|
||||
"refactor/baselines/danmuku-contract-validation.json",
|
||||
"refactor/baselines/danmuku-release.json"
|
||||
"refactor/baselines/danmuku-release.json",
|
||||
"refactor/changes/2026-09-13-PKG-DANMUKU-02-browser-baseline.md",
|
||||
"refactor/baselines/danmuku-browser-validation.json"
|
||||
],
|
||||
"compatibleResolution": "Track and release exact subscription and timer ownership across settings, renderer and instances.",
|
||||
"closureCriteria": "Repeated lifecycle and pending settings actions leave no owned subscriptions, timers or late DOM writes."
|
||||
@@ -5107,7 +5109,7 @@
|
||||
{
|
||||
"id": "DANMUKU-ASYNC-01",
|
||||
"title": "Danmuku asynchronous input, worker replies and pending visibility have unverified failure and cancellation paths",
|
||||
"confirmation": "source-observed",
|
||||
"confirmation": "reproduced",
|
||||
"status": "open",
|
||||
"owners": [
|
||||
"PKG-DANMUKU-02",
|
||||
@@ -5118,7 +5120,11 @@
|
||||
"evidence": [
|
||||
"refactor/baselines/danmuku-contract.md",
|
||||
"refactor/baselines/danmuku-contract-validation.json",
|
||||
"refactor/baselines/danmuku-release.json"
|
||||
"refactor/baselines/danmuku-release.json",
|
||||
"refactor/baselines/danmuku-failures-validation.json",
|
||||
"test/danmuku-failures.test.js",
|
||||
"refactor/changes/2026-09-13-PKG-DANMUKU-02-browser-baseline.md",
|
||||
"refactor/baselines/danmuku-browser-validation.json"
|
||||
],
|
||||
"compatibleResolution": "Reproduce load ordering, Bilibili rejection and late worker/visibility paths before defining operation ownership.",
|
||||
"closureCriteria": "Failures settle, obsolete results cannot alter new queues or destroyed instances, and worker resources are released with browser evidence."
|
||||
@@ -5126,7 +5132,7 @@
|
||||
{
|
||||
"id": "DANMUKU-HEATMAP-01",
|
||||
"title": "Danmuku heatmap sampling can become zero and points/instance ownership need browser validation",
|
||||
"confirmation": "source-observed",
|
||||
"confirmation": "reproduced",
|
||||
"status": "open",
|
||||
"owners": [
|
||||
"PKG-DANMUKU-02",
|
||||
@@ -5135,7 +5141,11 @@
|
||||
"evidence": [
|
||||
"refactor/baselines/danmuku-contract.md",
|
||||
"refactor/baselines/danmuku-contract-validation.json",
|
||||
"refactor/baselines/danmuku-release.json"
|
||||
"refactor/baselines/danmuku-release.json",
|
||||
"refactor/baselines/danmuku-failures-validation.json",
|
||||
"test/danmuku-failures.test.js",
|
||||
"refactor/changes/2026-09-13-PKG-DANMUKU-02-browser-baseline.md",
|
||||
"refactor/baselines/danmuku-browser-validation.json"
|
||||
],
|
||||
"compatibleResolution": "Use isolated timeout-protected reproduction for narrow dimensions, then define finite sampling and per-instance rendering.",
|
||||
"closureCriteria": "Narrow widths terminate, points/config behavior is explicit and multiple SVG/control instances remain independent."
|
||||
@@ -5155,6 +5165,25 @@
|
||||
],
|
||||
"compatibleResolution": "Use actual archive identity, preserve missing historical Git information and audit icon source/notice obligations.",
|
||||
"closureCriteria": "Candidate tarball paths, complete module forms and icon/source provenance are explicitly reviewed; unresolved history is not invented."
|
||||
},
|
||||
{
|
||||
"id": "DANMUKU-TRACK-01",
|
||||
"title": "Native simultaneous fixed Danmuku comments can overlap with duplicate start loops and repeated Worker IDs",
|
||||
"confirmation": "reproduced",
|
||||
"status": "open",
|
||||
"owners": [
|
||||
"PKG-DANMUKU-04",
|
||||
"PKG-DANMUKU-05",
|
||||
"PKG-DANMUKU-07"
|
||||
],
|
||||
"evidence": [
|
||||
"refactor/changes/2026-09-13-PKG-DANMUKU-02-browser-baseline.md",
|
||||
"refactor/baselines/danmuku-browser-validation.json",
|
||||
"test/browser/danmuku-baseline.spec.js",
|
||||
"test/danmuku-failures.test.js"
|
||||
],
|
||||
"compatibleResolution": "Enforce one owned scheduler loop and request correlation before claiming simultaneous native anti-overlap stability; keep strict track geometry tests.",
|
||||
"closureCriteria": "Deterministic duplicate-loop/worker regressions and repeated candidate native simultaneous-load geometry pass without extra nodes or stale state; a later passing old snapshot alone does not close the race."
|
||||
}
|
||||
]
|
||||
}
|
||||
+8
-3
@@ -3278,11 +3278,16 @@
|
||||
"ENG-05",
|
||||
"ENG-08"
|
||||
],
|
||||
"status": "doing",
|
||||
"status": "done",
|
||||
"risk": "H",
|
||||
"deliverable": "密集/稀疏弹幕、过滤/异步输入、seek/倍率/重载测试",
|
||||
"acceptance": "轨道选择、发射顺序、事件、内存和可视结果可重跑",
|
||||
"evidence": []
|
||||
"evidence": [
|
||||
"changes/2026-09-13-PKG-DANMUKU-02-controlled-failures.md",
|
||||
"baselines/danmuku-failures-validation.json",
|
||||
"changes/2026-09-13-PKG-DANMUKU-02-browser-baseline.md",
|
||||
"baselines/danmuku-browser-validation.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "PKG-DANMUKU-03",
|
||||
@@ -3295,7 +3300,7 @@
|
||||
"PKG-DANMUKU-02",
|
||||
"CORE-08"
|
||||
],
|
||||
"status": "todo",
|
||||
"status": "doing",
|
||||
"risk": "M",
|
||||
"deliverable": "bilibili/input/parser/config 的分层与取消",
|
||||
"acceptance": "旧格式、回调、过滤、追加/替换语义保持",
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const baseline = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/danmuku-release.json', import.meta.url), 'utf8'))
|
||||
const release = baseline.release
|
||||
assert.equal(release.version, '5.3.0')
|
||||
const implementations = new Map()
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const archive = await ensureArchive(release)
|
||||
for (const format of ['main', 'legacy']) {
|
||||
const member = `package/${release.manifest[format].replace(/^\.\//u, '')}`
|
||||
const bytes = readMember(archive, member)
|
||||
assert.equal(hash(bytes), release.files[member], `Published Danmuku ${format} bytes drifted`)
|
||||
implementations.set(format, { member, sha256: hash(bytes), code: bytes.toString() })
|
||||
}
|
||||
})
|
||||
|
||||
async function setup(page, core, format, scenario, testInfo) {
|
||||
const external = []
|
||||
const origin = new URL(testInfo.project.use.baseURL).origin
|
||||
await page.route('**/*', (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== origin) {
|
||||
external.push(url.href)
|
||||
return route.abort()
|
||||
}
|
||||
// Same-origin blob URLs are actual inline Worker/media resources, not external requests.
|
||||
return route.continue()
|
||||
})
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
await page.evaluate(() => {
|
||||
window.danmukuEvidence = { workers: [], events: [], gates: [], sends: [], failures: [] }
|
||||
const NativeWorker = window.Worker
|
||||
window.Worker = class ObservedNativeWorker extends NativeWorker {
|
||||
constructor(url, options) {
|
||||
super(url, options)
|
||||
this.evidence = { url: String(url), replies: [], terminated: 0, errors: [] }
|
||||
window.danmukuEvidence.workers.push(this.evidence)
|
||||
this.addEventListener('message', ({ data }) => this.evidence.replies.push({ id: data.id, result: data.result ?? null }))
|
||||
this.addEventListener('error', event => this.evidence.errors.push(event.message))
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.evidence.terminated++
|
||||
return super.terminate()
|
||||
}
|
||||
}
|
||||
window.danmukuPlayers = []
|
||||
window.danmukuPlugins = []
|
||||
window.danmukuNodes = []
|
||||
window.danmukuVisibility = new Promise((resolve) => {
|
||||
window.releaseDanmukuVisibility = resolve
|
||||
})
|
||||
window.danmukuSending = new Promise((resolve) => {
|
||||
window.releaseDanmukuSending = resolve
|
||||
})
|
||||
})
|
||||
const implementation = implementations.get(format)
|
||||
await page.addScriptTag({ content: implementation.code })
|
||||
await page.evaluate(({ scenario }) => {
|
||||
const first = document.querySelector('.player')
|
||||
first.id = 'danmuku-player-0'
|
||||
first.style.cssText = 'width:640px;height:360px'
|
||||
window.createDanmukuPlayer = (index) => {
|
||||
const container = index === 0 ? first : document.body.appendChild(document.createElement('div'))
|
||||
container.id = `danmuku-player-${index}`
|
||||
container.style.cssText = 'width:640px;height:360px'
|
||||
const mount = document.createElement('div')
|
||||
mount.id = `danmuku-mount-${index}`
|
||||
mount.style.cssText = 'width:640px;height:36px;background:#333'
|
||||
container.after(mount)
|
||||
const art = new window.Artplayer({ container, url: `/test/pattern.mp4?danmuku=${index}`, muted: true, mutex: false, fullscreenWeb: true })
|
||||
window.danmukuPlayers[index] = art
|
||||
for (const event of ['loaded', 'visible', 'start', 'stop', 'reset', 'destroy', 'config', 'error']) {
|
||||
art.on(`artplayerPluginDanmuku:${event}`, (value) => {
|
||||
window.danmukuEvidence.events.push({ index, event, time: art.currentTime, id: value?.id, text: value?.text, mode: value?.mode, count: Array.isArray(value) ? value.length : undefined })
|
||||
if (event === 'error')
|
||||
window.danmukuEvidence.failures.push(String(value))
|
||||
})
|
||||
}
|
||||
const option = {
|
||||
danmuku: [],
|
||||
heatmap: false,
|
||||
speed: 8,
|
||||
margin: [10, 10],
|
||||
fontSize: 20,
|
||||
antiOverlap: true,
|
||||
synchronousPlayback: true,
|
||||
lockTime: 1,
|
||||
...(scenario === 'settings' ? { mount } : {}),
|
||||
filter: danmu => danmu.text !== 'FILTERED',
|
||||
beforeVisible: async (danmu) => {
|
||||
window.danmukuEvidence.gates.push({ index, id: danmu.id, text: danmu.text })
|
||||
if (danmu.text === 'GATED')
|
||||
return window.danmukuVisibility
|
||||
return danmu.text !== 'DENIED'
|
||||
},
|
||||
beforeEmit: async (danmu) => {
|
||||
window.danmukuEvidence.sends.push({ index, text: danmu.text, mode: danmu.mode })
|
||||
return scenario === 'settings' && index === 0 ? window.danmukuSending : true
|
||||
},
|
||||
}
|
||||
art.plugins.add(window.artplayerPluginDanmuku(option))
|
||||
window.danmukuPlugins[index] = art.plugins.artplayerPluginDanmuku
|
||||
return art
|
||||
}
|
||||
window.art = window.createDanmukuPlayer(0)
|
||||
document.querySelector('#play').onclick = () => window.danmukuPlayers[0].play()
|
||||
document.querySelector('#pause').onclick = () => window.danmukuPlayers[0].pause()
|
||||
}, { scenario })
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady && window.art.template.$video.videoWidth > 0)).toBe(true)
|
||||
await testInfo.attach('danmuku-browser-inputs', { contentType: 'application/json', body: JSON.stringify({ core, plugin: release.name, version: release.version, format, member: implementation.member, sha256: implementation.sha256, integrity: release.integrity, media: '/test/pattern.mp4', scenario, worker: 'Native Blob Worker, only constructor/message/termination observation; no Worker responses, media clocks, layout or RAF mocked', heatmap: false }) })
|
||||
return external
|
||||
}
|
||||
|
||||
async function geometry(page) {
|
||||
return page.evaluate(() => (window.danmukuPlayers || []).map((art, index) => {
|
||||
const player = art.template.$player.getBoundingClientRect()
|
||||
return {
|
||||
index,
|
||||
playing: art.playing,
|
||||
time: art.currentTime,
|
||||
rate: art.playbackRate,
|
||||
player: { x: player.x, y: player.y, width: player.width, height: player.height },
|
||||
nodes: [...art.template.$danmuku.children].map((node) => {
|
||||
const rect = node.getBoundingClientRect()
|
||||
const style = getComputedStyle(node)
|
||||
return { id: node.dataset.id, mode: node.dataset.mode, state: node.dataset.state, text: node.textContent, x: rect.x - player.x, y: rect.y - player.y, width: rect.width, height: rect.height, visibility: style.visibility, display: style.display, transition: node.style.transition, opacity: style.opacity }
|
||||
}),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
const visibleIds = page => page.evaluate(() => [...new Set(window.danmukuEvidence.events.filter(item => item.event === 'visible' && item.id).map(item => item.id))])
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
await testInfo.attach('danmuku-layout', { contentType: 'application/json', body: JSON.stringify(await geometry(page)) })
|
||||
await testInfo.attach('danmuku-native-page', { contentType: 'image/png', body: await page.screenshot() })
|
||||
await page.evaluate(() => {
|
||||
for (const art of window.danmukuPlayers || []) {
|
||||
if (!art.isDestroy)
|
||||
art.destroy()
|
||||
}
|
||||
})
|
||||
const evidence = await page.evaluate(() => ({ workers: [], failures: [], ...window.danmukuEvidence, version: window.Artplayer?.version, retainedExternalSettings: [...document.querySelectorAll('[id^="danmuku-mount-"]')].map(node => node.querySelectorAll('.artplayer-plugin-danmuku').length) }))
|
||||
await testInfo.attach('danmuku-worker-and-lifecycle', { contentType: 'application/json', body: JSON.stringify(evidence) })
|
||||
expect(evidence.failures).toEqual([])
|
||||
for (const worker of evidence.workers) {
|
||||
expect(worker.url).toMatch(/^blob:/u)
|
||||
expect(worker.errors).toEqual([])
|
||||
expect(worker.terminated).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
for (const format of ['main', 'legacy']) {
|
||||
const label = `${core} / Danmuku 5.3.0 ${format}`
|
||||
|
||||
test(`${label}: native Worker renders three modes with synchronous filter and asynchronous beforeVisible`, async ({ page }, testInfo) => {
|
||||
const external = await setup(page, core, format, 'modes', testInfo)
|
||||
await page.evaluate(async () => {
|
||||
await window.danmukuPlugins[0].load([
|
||||
{ id: 'scroll', text: 'SCROLL', mode: 0, time: 0.5 },
|
||||
{ id: 'top', text: 'TOP', mode: 1, time: 0.5 },
|
||||
{ id: 'bottom', text: 'BOTTOM', mode: 2, time: 0.5 },
|
||||
{ id: 'filtered', text: 'FILTERED', mode: 1, time: 0.5 },
|
||||
{ id: 'denied', text: 'DENIED', mode: 1, time: 0.5 },
|
||||
{ id: 'gated', text: 'GATED', mode: 1, time: 0.5 },
|
||||
])
|
||||
})
|
||||
await page.click('#play')
|
||||
await expect.poll(() => visibleIds(page)).toEqual(expect.arrayContaining(['scroll', 'top', 'bottom']))
|
||||
await expect.poll(() => page.evaluate(() => window.danmukuEvidence.gates.some(item => item.id === 'gated'))).toBe(true)
|
||||
expect(await visibleIds(page)).not.toContain('gated')
|
||||
expect(await visibleIds(page)).not.toContain('filtered')
|
||||
expect(await visibleIds(page)).not.toContain('denied')
|
||||
expect(await page.evaluate(() => window.danmukuEvidence.gates.some(item => item.id === 'filtered'))).toBe(false)
|
||||
await page.evaluate(() => window.releaseDanmukuVisibility(true))
|
||||
await expect.poll(() => visibleIds(page)).toContain('gated')
|
||||
const [layout] = await geometry(page)
|
||||
for (const id of ['top', 'bottom']) {
|
||||
const item = layout.nodes.find(node => node.id === id && node.visibility === 'visible')
|
||||
expect(item).toBeTruthy()
|
||||
expect(Math.abs(item.x + item.width / 2 - layout.player.width / 2)).toBeLessThanOrEqual(2)
|
||||
expect(item.y).toBeGreaterThanOrEqual(9)
|
||||
expect(item.y + item.height).toBeLessThanOrEqual(layout.player.height - 8)
|
||||
}
|
||||
expect(layout.nodes.find(node => node.id === 'top').y).toBeLessThan(layout.nodes.find(node => node.id === 'bottom').y)
|
||||
expect(await page.evaluate(() => window.danmukuEvidence.workers[0].replies.length)).toBeGreaterThanOrEqual(4)
|
||||
expect(external).toEqual([])
|
||||
})
|
||||
|
||||
test(`${label}: six simultaneous fixed comments preserve usable track geometry and input order`, async ({ page }, testInfo) => {
|
||||
const external = await setup(page, core, format, 'density', testInfo)
|
||||
await page.evaluate(async () => {
|
||||
await window.danmukuPlugins[0].load(Array.from({ length: 6 }, (_, index) => ({ id: `dense-${index}`, text: `TRACK ${index}`, mode: 1, time: 0.5 })))
|
||||
})
|
||||
await page.click('#play')
|
||||
await expect.poll(() => visibleIds(page)).toEqual(expect.arrayContaining(Array.from({ length: 6 }, (_, index) => `dense-${index}`)))
|
||||
await page.click('#pause')
|
||||
await expect.poll(() => page.evaluate(() => window.danmukuPlugins[0].isStop)).toBe(true)
|
||||
const [layout] = await geometry(page)
|
||||
const rows = Array.from({ length: 6 }, (_, index) => layout.nodes.find(node => node.id === `dense-${index}` && node.visibility === 'visible'))
|
||||
expect(rows.every(Boolean)).toBe(true)
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
expect(rows[index].y).toBeGreaterThanOrEqual(9)
|
||||
expect(rows[index].y + rows[index].height).toBeLessThanOrEqual(layout.player.height - 8)
|
||||
if (index)
|
||||
expect(rows[index].y).toBeGreaterThanOrEqual(rows[index - 1].y + rows[index - 1].height - 1)
|
||||
}
|
||||
const order = await visibleIds(page)
|
||||
expect(order).toEqual(Array.from({ length: 6 }, (_, index) => `dense-${index}`))
|
||||
await testInfo.attach('danmuku-dense-tracks', { contentType: 'application/json', body: JSON.stringify(rows) })
|
||||
expect(external).toEqual([])
|
||||
})
|
||||
|
||||
test(`${label}: native pause seek rate and public reset/load retain historical pool semantics`, async ({ page }, testInfo) => {
|
||||
const external = await setup(page, core, format, 'playback', testInfo)
|
||||
await page.evaluate(async () => {
|
||||
await window.danmukuPlugins[0].emit({ id: 'moving', text: 'MOVING', mode: 0, time: 0.5 })
|
||||
})
|
||||
await page.click('#play')
|
||||
await expect.poll(() => visibleIds(page)).toContain('moving')
|
||||
await page.click('#pause')
|
||||
await expect.poll(() => page.evaluate(() => window.danmukuPlugins[0].isStop)).toBe(true)
|
||||
const frozen = await page.evaluate(async () => {
|
||||
const node = window.art.template.$danmuku.querySelector('[data-id="moving"]')
|
||||
window.danmukuNodes = [node]
|
||||
const left = node.getBoundingClientRect().left
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
return { left, next: node.getBoundingClientRect().left, state: node.dataset.state }
|
||||
})
|
||||
expect(frozen.state).toBe('stop')
|
||||
expect(Math.abs(frozen.left - frozen.next)).toBeLessThanOrEqual(1)
|
||||
await page.evaluate(async () => {
|
||||
window.art.playbackRate = 2
|
||||
const sought = new Promise(resolve => window.art.once('video:seeked', resolve))
|
||||
window.art.currentTime = 2
|
||||
await sought
|
||||
})
|
||||
expect(await page.evaluate(() => window.danmukuNodes[0].dataset.state)).toBe('stop')
|
||||
await page.evaluate(async () => {
|
||||
window.danmukuPlugins[0].reset()
|
||||
await window.danmukuPlugins[0].emit({ id: 'reused', text: 'REUSED', mode: 0, time: window.art.currentTime + 0.5 })
|
||||
})
|
||||
await page.click('#play')
|
||||
await expect.poll(() => visibleIds(page)).toContain('reused')
|
||||
const reused = await page.evaluate(() => {
|
||||
const node = window.art.template.$danmuku.querySelector('[data-id="reused"]')
|
||||
const original = window.danmukuNodes[0]
|
||||
const nodes = [...window.art.template.$danmuku.children]
|
||||
return {
|
||||
poolReused: original.textContent === 'REUSED' && nodes.includes(original),
|
||||
visibleSameNode: node === original,
|
||||
original: { id: original.dataset.id, state: original.dataset.state, visibility: getComputedStyle(original).visibility, text: original.textContent },
|
||||
extraNodes: nodes.filter(item => item !== original).map(item => ({ id: item.dataset.id, state: item.dataset.state, visibility: getComputedStyle(item).visibility, text: item.textContent })),
|
||||
transition: node.style.transitionDuration,
|
||||
rate: window.art.playbackRate,
|
||||
}
|
||||
})
|
||||
// Native initial evidence reused the pooled node but a competing old RAF
|
||||
// loop left it hidden and allocated another visible node. Record both.
|
||||
expect(reused.poolReused).toBe(true)
|
||||
expect(reused.transition).toBe('4s')
|
||||
expect(reused.rate).toBe(2)
|
||||
await page.evaluate(async () => {
|
||||
await window.danmukuPlugins[0].load([{ id: 'appended', text: 'APPENDED', mode: 1, time: window.art.currentTime + 0.5 }])
|
||||
})
|
||||
await expect.poll(() => visibleIds(page)).toContain('appended')
|
||||
await page.click('#pause')
|
||||
await page.evaluate(async () => {
|
||||
await window.danmukuPlugins[0].load()
|
||||
})
|
||||
expect(await page.locator('#danmuku-player-0 .art-danmuku > div').count()).toBe(0)
|
||||
await testInfo.attach('danmuku-pool-and-rate', { contentType: 'application/json', body: JSON.stringify({ frozen, reused, loadWithoutArgument: 'replaces from original empty option; explicit array load appends' }) })
|
||||
expect(external).toEqual([])
|
||||
})
|
||||
|
||||
test(`${label}: actual settings, asynchronous send lock, external fullscreen mount and two-player destruction`, async ({ page }, testInfo) => {
|
||||
const external = await setup(page, core, format, 'settings', testInfo)
|
||||
const mount = page.locator('#danmuku-mount-0')
|
||||
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(1)
|
||||
await mount.locator('.apd-config').hover()
|
||||
await mount.locator('.apd-config-mode [data-mode="2"]').click()
|
||||
expect(await page.evaluate(() => window.danmukuPlugins[0].option.modes)).toEqual([0, 1])
|
||||
await mount.locator('.apd-style').hover()
|
||||
await mount.locator('.apd-style-mode [data-mode="1"]').click()
|
||||
expect(await page.evaluate(() => window.danmukuPlugins[0].option.mode)).toBe(1)
|
||||
await mount.locator('.apd-input').fill('FIRST')
|
||||
await mount.locator('.apd-send').click()
|
||||
await expect.poll(() => page.evaluate(() => window.danmukuEvidence.sends.length)).toBe(1)
|
||||
await mount.locator('.apd-input').press('Enter')
|
||||
expect(await page.evaluate(() => window.danmukuEvidence.sends.length)).toBe(1)
|
||||
await page.evaluate(() => window.releaseDanmukuSending(true))
|
||||
await expect(mount.locator('.apd-input')).toHaveValue('')
|
||||
await expect(mount.locator('.apd-send')).toHaveClass(/apd-lock/u)
|
||||
await mount.locator('.apd-input').fill('LOCKED')
|
||||
await mount.locator('.apd-input').press('Enter')
|
||||
expect(await page.evaluate(() => window.danmukuEvidence.sends.length)).toBe(1)
|
||||
await expect(mount.locator('.apd-send')).not.toHaveClass(/apd-lock/u)
|
||||
await page.evaluate(() => {
|
||||
window.danmukuEmitter = document.querySelector('#danmuku-mount-0 .artplayer-plugin-danmuku')
|
||||
window.art.fullscreenWeb = true
|
||||
})
|
||||
// FULLSCREEN_WEB_IN_BODY can move the player out of its original container.
|
||||
await expect.poll(() => page.evaluate(() => window.art.template.$controlsCenter.contains(window.danmukuEmitter))).toBe(true)
|
||||
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(0)
|
||||
await page.evaluate(() => {
|
||||
window.art.fullscreenWeb = false
|
||||
})
|
||||
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(1)
|
||||
expect(await page.evaluate(() => document.querySelector('#danmuku-mount-0').contains(window.danmukuEmitter))).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
const art = window.createDanmukuPlayer(1)
|
||||
const button = document.createElement('button')
|
||||
button.id = 'play-second-danmuku'
|
||||
button.textContent = 'Play second'
|
||||
button.onclick = () => art.play()
|
||||
document.body.appendChild(button)
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.danmukuPlayers[1].isReady)).toBe(true)
|
||||
await page.evaluate(() => window.danmukuPlayers[0].destroy())
|
||||
expect(await page.evaluate(() => window.danmukuEvidence.workers.map(worker => worker.terminated))).toEqual([1, 0])
|
||||
// The released plugin does not dispose external Setting DOM; retain this observation.
|
||||
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(1)
|
||||
await page.locator('#danmuku-mount-1 .apd-input').fill('SECOND')
|
||||
await page.locator('#danmuku-mount-1 .apd-send').click()
|
||||
await page.click('#play-second-danmuku')
|
||||
await expect.poll(() => page.evaluate(() => window.danmukuEvidence.events.some(item => item.index === 1 && item.event === 'visible' && item.text === 'SECOND'))).toBe(true)
|
||||
expect(await page.evaluate(() => window.danmukuPlayers[1].isDestroy)).toBe(false)
|
||||
expect(external).toEqual([])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const baseline = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/danmuku-release.json', import.meta.url), 'utf8'))
|
||||
const release = baseline.release
|
||||
const mediaSha256 = hash(fs.readFileSync(new URL('./media/pattern.mp4', import.meta.url)))
|
||||
const implementations = new Map()
|
||||
assert.equal(release.version, '5.3.0')
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const archive = await ensureArchive(release)
|
||||
for (const format of ['main', 'legacy']) {
|
||||
const member = `package/${release.manifest[format].replace(/^\.\//u, '')}`
|
||||
const bytes = readMember(archive, member)
|
||||
assert.equal(hash(bytes), release.files[member])
|
||||
implementations.set(format, { member, sha256: hash(bytes), code: bytes.toString() })
|
||||
}
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
await page.evaluate(() => {
|
||||
if (window.loadObserverFrame !== undefined)
|
||||
cancelAnimationFrame(window.loadObserverFrame)
|
||||
if (window.loadArt && !window.loadArt.isDestroy)
|
||||
window.loadArt.destroy()
|
||||
})
|
||||
await testInfo.attach('danmuku-load-final-observations', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({
|
||||
evidence: window.loadEvidence,
|
||||
destroyed: window.loadArt?.isDestroy,
|
||||
retainedPlayerRoots: document.querySelectorAll('#danmuku-load-player .art-video-player').length,
|
||||
retainedExternalSettings: document.querySelectorAll('#danmuku-load-mount .artplayer-plugin-danmuku').length,
|
||||
}))) })
|
||||
})
|
||||
|
||||
for (const format of ['main', 'legacy']) {
|
||||
test(`published core / Danmuku 5.3.0 ${format}: 300-comment native load and frame/heap observation`, async ({ page }, testInfo) => {
|
||||
const external = []
|
||||
const origin = new URL(testInfo.project.use.baseURL).origin
|
||||
await page.route('**/*', (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== origin) {
|
||||
external.push(url.href)
|
||||
return route.abort()
|
||||
}
|
||||
return route.continue()
|
||||
})
|
||||
await page.goto('/test/player.html?core=published')
|
||||
await page.evaluate(() => {
|
||||
window.loadEvidence = { workers: [], loaded: [], visible: [], frameTimes: [], filtered: 0, heaps: [], failures: [], destroyEvents: 0 }
|
||||
window.readLoadHeap = (phase) => {
|
||||
const memory = performance.memory
|
||||
const snapshot = memory && [memory.usedJSHeapSize, memory.totalJSHeapSize, memory.jsHeapSizeLimit].every(Number.isFinite)
|
||||
? { usedJSHeapSize: memory.usedJSHeapSize, totalJSHeapSize: memory.totalJSHeapSize, jsHeapSizeLimit: memory.jsHeapSizeLimit }
|
||||
: null
|
||||
window.loadEvidence.heaps.push({ phase, timestamp: performance.now(), memory: snapshot })
|
||||
}
|
||||
window.readLoadHeap('before-plugin-script')
|
||||
const NativeWorker = window.Worker
|
||||
window.Worker = class ObservedLoadWorker extends NativeWorker {
|
||||
constructor(url, options) {
|
||||
super(url, options)
|
||||
this.observation = { url: String(url), posts: 0, replies: 0, terminated: 0, errors: [] }
|
||||
window.loadEvidence.workers.push(this.observation)
|
||||
this.addEventListener('message', () => this.observation.replies++)
|
||||
this.addEventListener('error', event => this.observation.errors.push(event.message))
|
||||
}
|
||||
|
||||
postMessage(message, ...options) {
|
||||
this.observation.posts++
|
||||
return super.postMessage(message, ...options)
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.observation.terminated++
|
||||
return super.terminate()
|
||||
}
|
||||
}
|
||||
// Independent observer: never replace the plugin's RAF or mutate its clock.
|
||||
function frame(timestamp) {
|
||||
if (window.loadArt?.playing)
|
||||
window.loadEvidence.frameTimes.push(timestamp)
|
||||
window.loadObserverFrame = requestAnimationFrame(frame)
|
||||
}
|
||||
window.loadObserverFrame = requestAnimationFrame(frame)
|
||||
})
|
||||
const implementation = implementations.get(format)
|
||||
await page.addScriptTag({ content: implementation.code })
|
||||
await testInfo.attach('danmuku-load-inputs', { contentType: 'application/json', body: JSON.stringify({ core: 'published', coreVersion: await page.evaluate(() => window.Artplayer.version), pluginVersion: release.version, format, member: implementation.member, sha256: implementation.sha256, integrity: release.integrity, mediaSha256, comments: 300, modes: [0, 1, 2], scheduledTime: 0.5, heatmap: false, scope: 'Single bounded native sample; independent RAF observer and optional performance.memory readings. No forced GC, leak diagnosis, cross-browser heap equivalence or performance threshold.' }) })
|
||||
await page.evaluate(async () => {
|
||||
const container = document.querySelector('.player')
|
||||
container.id = 'danmuku-load-player'
|
||||
container.style.cssText = 'width:640px;height:360px'
|
||||
const mount = document.createElement('div')
|
||||
mount.id = 'danmuku-load-mount'
|
||||
mount.style.cssText = 'width:640px;height:36px;background:#333'
|
||||
container.after(mount)
|
||||
const art = window.loadArt = new window.Artplayer({ container, url: '/test/pattern.mp4?case=danmuku-load-300', muted: true })
|
||||
art.on('artplayerPluginDanmuku:loaded', queue => window.loadEvidence.loaded.push({ count: queue.length, ids: queue.map(item => item.id) }))
|
||||
art.on('artplayerPluginDanmuku:visible', item => window.loadEvidence.visible.push({ id: item.id, mode: item.mode, mediaTime: art.currentTime, timestamp: performance.now() }))
|
||||
art.on('artplayerPluginDanmuku:error', error => window.loadEvidence.failures.push(String(error)))
|
||||
art.on('artplayerPluginDanmuku:destroy', () => window.loadEvidence.destroyEvents++)
|
||||
art.plugins.add(window.artplayerPluginDanmuku({
|
||||
danmuku: [],
|
||||
mount,
|
||||
heatmap: false,
|
||||
speed: 8,
|
||||
fontSize: 18,
|
||||
margin: [10, 10],
|
||||
antiOverlap: true,
|
||||
filter(item) {
|
||||
window.loadEvidence.filtered++
|
||||
return item.id.startsWith('load-')
|
||||
},
|
||||
}))
|
||||
const plugin = window.loadPlugin = art.plugins.artplayerPluginDanmuku
|
||||
const input = Array.from({ length: 300 }, (_, index) => ({ id: `load-${String(index).padStart(3, '0')}`, text: `Load ${index}`, mode: index % 3, time: 0.5 }))
|
||||
await plugin.load(input)
|
||||
window.readLoadHeap('after-300-loaded')
|
||||
document.querySelector('#play').onclick = () => art.play()
|
||||
document.querySelector('#pause').onclick = () => art.pause()
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.loadArt.isReady && window.loadArt.template.$video.videoWidth > 0)).toBe(true)
|
||||
const loaded = await page.evaluate(() => ({ latest: window.loadEvidence.loaded.at(-1), filtered: window.loadEvidence.filtered }))
|
||||
expect(loaded.latest.count).toBe(300)
|
||||
expect(new Set(loaded.latest.ids).size).toBe(300)
|
||||
expect(loaded.filtered).toBe(300)
|
||||
await page.click('#play')
|
||||
// A state deadline, not a performance pass/fail budget or a fixed sleep.
|
||||
await expect.poll(() => page.evaluate(() => window.loadArt.currentTime), { timeout: 15000 }).toBeGreaterThanOrEqual(4)
|
||||
await page.click('#pause')
|
||||
await expect.poll(() => page.evaluate(() => window.loadArt.template.$video.paused && window.loadPlugin.isStop)).toBe(true)
|
||||
const sample = await page.evaluate(() => {
|
||||
cancelAnimationFrame(window.loadObserverFrame)
|
||||
window.loadObserverFrame = undefined
|
||||
window.readLoadHeap('paused-after-four-media-seconds')
|
||||
const art = window.loadArt
|
||||
const rect = art.template.$player.getBoundingClientRect()
|
||||
const nodes = [...art.template.$danmuku.children].map((node) => {
|
||||
const position = node.getBoundingClientRect()
|
||||
const style = getComputedStyle(node)
|
||||
return { id: node.dataset.id, state: node.dataset.state, mode: node.dataset.mode, text: node.textContent, x: position.x - rect.x, y: position.y - rect.y, width: position.width, height: position.height, visibility: style.visibility, display: style.display }
|
||||
})
|
||||
const times = window.loadEvidence.frameTimes
|
||||
const frameIntervals = times.slice(1).map((timestamp, index) => timestamp - times[index])
|
||||
const sorted = [...frameIntervals].sort((a, b) => a - b)
|
||||
return {
|
||||
coreVersion: window.Artplayer.version,
|
||||
mediaTime: art.currentTime,
|
||||
video: { width: art.template.$video.videoWidth, height: art.template.$video.videoHeight, duration: art.duration },
|
||||
loadedCount: window.loadEvidence.loaded.at(-1).count,
|
||||
visibleEvents: window.loadEvidence.visible.length,
|
||||
uniqueVisible: new Set(window.loadEvidence.visible.map(item => item.id)).size,
|
||||
nodeCount: nodes.length,
|
||||
visibleNodeCount: nodes.filter(node => node.visibility === 'visible' && node.display !== 'none').length,
|
||||
hiddenNodeCount: nodes.filter(node => node.visibility === 'hidden' || node.display === 'none').length,
|
||||
nodes,
|
||||
frameTimes: times,
|
||||
frameIntervals,
|
||||
frameSummary: { count: frameIntervals.length, median: sorted[Math.floor(sorted.length / 2)] ?? null, p95: sorted[Math.floor(sorted.length * 0.95)] ?? null, max: sorted.at(-1) ?? null },
|
||||
heaps: window.loadEvidence.heaps,
|
||||
workers: window.loadEvidence.workers,
|
||||
}
|
||||
})
|
||||
await testInfo.attach('danmuku-load-sample', { contentType: 'application/json', body: JSON.stringify(sample) })
|
||||
await testInfo.attach('danmuku-load-screenshot', { contentType: 'image/png', body: await page.screenshot() })
|
||||
expect(sample.mediaTime).toBeGreaterThanOrEqual(4)
|
||||
expect(sample.uniqueVisible).toBeGreaterThan(0)
|
||||
expect(sample.visibleNodeCount).toBeGreaterThan(0)
|
||||
expect(sample.frameIntervals.length).toBeGreaterThan(1)
|
||||
expect(sample.workers).toHaveLength(1)
|
||||
expect(sample.workers[0].url).toMatch(/^blob:/u)
|
||||
expect(sample.workers[0].posts).toBeGreaterThan(0)
|
||||
expect(sample.workers[0].replies).toBeGreaterThan(0)
|
||||
expect(sample.workers[0].errors).toEqual([])
|
||||
await page.evaluate(() => {
|
||||
window.loadArt.destroy()
|
||||
window.readLoadHeap('immediately-after-destroy')
|
||||
})
|
||||
expect(await page.evaluate(() => window.loadArt.isDestroy)).toBe(true)
|
||||
expect(await page.evaluate(() => window.loadEvidence.destroyEvents)).toBe(1)
|
||||
expect(await page.evaluate(() => window.loadEvidence.workers[0].terminated)).toBe(1)
|
||||
expect(external).toEqual([])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Historical failures are explicit Node observations.
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { danmukuEnvironment, danmukuHistorical, deferred } from './helpers/danmuku.js'
|
||||
|
||||
const implementations = await danmukuHistorical()
|
||||
const cache = fileURLToPath(new URL('../refactor/.cache/danmuku02-controlled/', import.meta.url))
|
||||
fs.mkdirSync(cache, { recursive: true })
|
||||
const evidenceDirectory = fs.mkdtempSync(path.join(cache, 'run-'))
|
||||
|
||||
function isolated(implementation, scenario) {
|
||||
const program = `
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { danmukuEnvironment } from './test/helpers/danmuku.js';
|
||||
const implementation = JSON.parse(fs.readFileSync(0, 'utf8'));
|
||||
const scenario = ${JSON.stringify(scenario)};
|
||||
let settled = false;
|
||||
let env;
|
||||
process.on('uncaughtExceptionMonitor', (error, origin) => {
|
||||
process.stdout.write(JSON.stringify({ stage: 'fatal', origin, error: error.message, settled, errors: env?.events.filter(e => e.name === 'artplayerPluginDanmuku:error').length }) + '\\n');
|
||||
});
|
||||
if (scenario === 'heatmap-zero' || scenario === 'heatmap-positive') {
|
||||
env = danmukuEnvironment(implementation, { heatmapWidth: 99 });
|
||||
env.factory({ danmuku: [], heatmap: scenario === 'heatmap-positive' ? { sampling: 1 } : true })(env.art);
|
||||
const sandbox = vm.createContext({ run: () => env.art.emit('ready') });
|
||||
process.stdout.write('heatmap-ready\\n');
|
||||
try {
|
||||
vm.runInContext('run()', sandbox, { timeout: 100 });
|
||||
process.stdout.write(JSON.stringify({ stage: 'complete' }) + '\\n');
|
||||
} catch (error) {
|
||||
if (error.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT') throw error;
|
||||
process.stdout.write(JSON.stringify({ stage: 'bounded-timeout', code: error.code }) + '\\n');
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
if (scenario === 'beforeVisible-reject') {
|
||||
env = danmukuEnvironment(implementation);
|
||||
const plugin = env.factory({ danmuku: [], beforeVisible: () => Promise.reject(new Error('beforeVisible-original')) })(env.art);
|
||||
await plugin.emit({ text: 'visible', time: 10 });
|
||||
env.art.playing = true;
|
||||
env.art.emit('video:play');
|
||||
// Browser rAF ignores a callback's returned Promise. Do not observe it here.
|
||||
env.frame();
|
||||
} else {
|
||||
env = danmukuEnvironment(implementation, { fetch: async () => {
|
||||
if (scenario === 'fetch-reject') throw new Error('fetch-original');
|
||||
return { text: async () => { throw new Error('text-original'); } };
|
||||
} });
|
||||
const plugin = env.factory({ danmuku: [] })(env.art);
|
||||
plugin.load('/controlled.xml').then(() => settled = true, () => settled = true);
|
||||
}
|
||||
setTimeout(() => process.stdout.write('unexpected-survival\\n'), 200);
|
||||
`
|
||||
const child = spawnSync(process.execPath, ['--unhandled-rejections=strict', '--input-type=module', '-e', program], {
|
||||
cwd: fileURLToPath(new URL('../', import.meta.url)),
|
||||
input: JSON.stringify(implementation),
|
||||
encoding: 'utf8',
|
||||
timeout: 3000,
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
})
|
||||
const evidence = { implementation: implementation.name, scenario, status: child.status, signal: child.signal, errorCode: child.error?.code ?? null, stdout: child.stdout, stderr: child.stderr }
|
||||
fs.writeFileSync(path.join(evidenceDirectory, `${implementation.name}-${scenario}.json`), `${JSON.stringify(evidence, null, 2)}\n`)
|
||||
assert.equal(child.error, undefined, `Isolated process infrastructure failed: ${child.error?.message}`)
|
||||
return evidence
|
||||
}
|
||||
|
||||
for (const implementation of implementations) {
|
||||
for (const scenario of ['fetch-reject', 'text-reject']) {
|
||||
test(`danmuku ${implementation.name}: historical ${scenario} crashes the isolated process while public load stays pending`, () => {
|
||||
const evidence = isolated(implementation, scenario)
|
||||
assert.equal(evidence.status, 1)
|
||||
const record = JSON.parse(evidence.stdout.trim())
|
||||
assert.equal(record.stage, 'fatal')
|
||||
assert.equal(record.origin, 'unhandledRejection')
|
||||
assert.equal(record.settled, false)
|
||||
assert.equal(record.errors, 0)
|
||||
assert.equal(record.error, scenario === 'fetch-reject' ? 'fetch-original' : 'text-original')
|
||||
})
|
||||
}
|
||||
|
||||
test(`danmuku ${implementation.name}: historical beforeVisible rejection is an unobserved animation callback Promise`, () => {
|
||||
const evidence = isolated(implementation, 'beforeVisible-reject')
|
||||
assert.equal(evidence.status, 1)
|
||||
const record = JSON.parse(evidence.stdout.trim())
|
||||
assert.equal(record.error, 'beforeVisible-original')
|
||||
assert.equal(record.origin, 'unhandledRejection')
|
||||
assert.equal(record.errors, 0)
|
||||
})
|
||||
|
||||
test(`danmuku ${implementation.name}: historical heatmap width 99 blocks at default sampling zero but sampling 1 completes`, () => {
|
||||
const zero = isolated(implementation, 'heatmap-zero')
|
||||
const positive = isolated(implementation, 'heatmap-positive')
|
||||
assert.equal(zero.status, 0)
|
||||
assert.equal(positive.status, 0)
|
||||
assert.match(zero.stdout, /heatmap-ready\n\{"stage":"bounded-timeout","code":"ERR_SCRIPT_EXECUTION_TIMEOUT"\}/u)
|
||||
assert.match(positive.stdout, /heatmap-ready\n\{"stage":"complete"\}/u)
|
||||
})
|
||||
|
||||
for (const event of ['error', 'empty-xml']) {
|
||||
test(`danmuku ${implementation.name}: historical Bilibili ${event} leaves loading pending and parser worker alive`, async () => {
|
||||
const env = danmukuEnvironment(implementation, event === 'empty-xml' ? { fetch: async () => ({ text: async () => '' }) } : {})
|
||||
const plugin = env.factory({ danmuku: [] })(env.art)
|
||||
let settled = false
|
||||
plugin.load('/controlled.xml').then(() => settled = true, () => settled = true)
|
||||
await env.flush()
|
||||
assert.equal(env.workers.length, 2)
|
||||
const worker = env.workers[1]
|
||||
assert.equal(worker.messages.length, 1)
|
||||
if (event === 'error') {
|
||||
assert.equal(worker.onerror, undefined)
|
||||
worker.fail(new Error('parser worker failure'))
|
||||
}
|
||||
else {
|
||||
assert.equal(worker.messages[0].message.xml, '')
|
||||
// Execute the actual Blob, not a reimplementation of its empty-input branch.
|
||||
await worker.execute(worker.messages[0].message)
|
||||
}
|
||||
await env.flush()
|
||||
assert.equal(settled, false)
|
||||
env.destroy()
|
||||
assert.equal(worker.terminated, false, 'Parser worker is not owned by the Danmuku destroy path')
|
||||
})
|
||||
}
|
||||
|
||||
test(`danmuku ${implementation.name}: historical successful parser leaves its Blob URL allocated`, async () => {
|
||||
const env = danmukuEnvironment(implementation)
|
||||
const plugin = env.factory({ danmuku: [] })(env.art)
|
||||
const pending = plugin.load('/controlled.xml')
|
||||
await env.flush()
|
||||
const worker = env.workers[1]
|
||||
await worker.execute(worker.messages[0].message)
|
||||
const internal = await pending
|
||||
assert.equal(internal.queue.length, 1)
|
||||
assert.equal(internal.queue[0].text, 'xml')
|
||||
assert.equal(worker.terminated, true)
|
||||
assert.equal(env.urls.has(worker.url), true)
|
||||
assert.equal(env.revoked.includes(worker.url), false)
|
||||
env.destroy()
|
||||
})
|
||||
|
||||
test(`danmuku ${implementation.name}: historical parser worker completion emits new data after player destruction`, async () => {
|
||||
const env = danmukuEnvironment(implementation)
|
||||
const plugin = env.factory({ danmuku: [] })(env.art)
|
||||
const pending = plugin.load('/controlled.xml')
|
||||
await env.flush()
|
||||
const worker = env.workers[1]
|
||||
env.destroy()
|
||||
const count = env.events.length
|
||||
assert.equal(worker.terminated, false)
|
||||
await worker.execute(worker.messages[0].message)
|
||||
const internal = await pending
|
||||
assert.equal(internal.queue[0].text, 'xml')
|
||||
assert.deepEqual(env.events.slice(count).map(event => event.name), ['artplayerPluginDanmuku:loaded'])
|
||||
assert.equal(worker.terminated, true)
|
||||
})
|
||||
|
||||
test(`danmuku ${implementation.name}: historical overlapping replacement loads let the oldest completion overwrite newer data`, async () => {
|
||||
const env = danmukuEnvironment(implementation)
|
||||
const plugin = env.factory({ danmuku: [] })(env.art)
|
||||
const first = deferred()
|
||||
const second = deferred()
|
||||
const jobs = [first, second]
|
||||
// Change an additional scalar because the historical function-only config bug is separate.
|
||||
plugin.config({ danmuku: () => jobs.shift().promise, opacity: 0.9 })
|
||||
const older = plugin.load()
|
||||
const newer = plugin.load()
|
||||
second.resolve([{ text: 'newer', time: 10 }])
|
||||
const internal = await newer
|
||||
assert.deepEqual(Array.from(internal.queue, item => item.text), ['newer'])
|
||||
first.resolve([{ text: 'older', time: 10 }])
|
||||
await older
|
||||
assert.deepEqual(Array.from(internal.queue, item => item.text), ['older'])
|
||||
env.destroy()
|
||||
})
|
||||
|
||||
test(`danmuku ${implementation.name}: historical asynchronous input mutates queue and emits loaded after destroy`, async () => {
|
||||
const env = danmukuEnvironment(implementation)
|
||||
const plugin = env.factory({ danmuku: [] })(env.art)
|
||||
const input = deferred()
|
||||
const pending = plugin.load(() => input.promise)
|
||||
env.destroy()
|
||||
const count = env.events.length
|
||||
input.resolve([{ text: 'late', time: 10 }])
|
||||
const internal = await pending
|
||||
assert.deepEqual(Array.from(internal.queue, item => item.text), ['late'])
|
||||
assert.deepEqual(env.events.slice(count).map(event => event.name), ['artplayerPluginDanmuku:loaded'])
|
||||
})
|
||||
|
||||
test(`danmuku ${implementation.name}: historical pending beforeVisible allocates DOM and posts to a terminated worker after destroy`, async () => {
|
||||
const visible = deferred()
|
||||
const env = danmukuEnvironment(implementation)
|
||||
const plugin = env.factory({ danmuku: [], beforeVisible: () => visible.promise })(env.art)
|
||||
await plugin.emit({ text: 'late visible', time: 10 })
|
||||
env.art.playing = true
|
||||
env.art.emit('video:play')
|
||||
env.frame()
|
||||
await env.flush()
|
||||
assert.equal(env.art.template.$danmuku.children.length, 0)
|
||||
env.destroy()
|
||||
visible.resolve(true)
|
||||
await env.flush()
|
||||
assert.equal(env.art.template.$danmuku.children.length, 1)
|
||||
assert.equal(env.workers[0].messages.length, 1)
|
||||
assert.equal(env.workers[0].messages[0].afterTerminate, true)
|
||||
assert.equal(env.events.filter(event => event.name === 'artplayerPluginDanmuku:visible').length, 0)
|
||||
})
|
||||
|
||||
test(`danmuku ${implementation.name}: historical worker response continuation still mutates state after destroy`, async () => {
|
||||
const env = danmukuEnvironment(implementation)
|
||||
const plugin = env.factory({ danmuku: [] })(env.art)
|
||||
await plugin.emit({ text: 'pending position', time: 10 })
|
||||
env.art.playing = true
|
||||
env.art.emit('video:play')
|
||||
const frame = env.frame()
|
||||
await env.flush()
|
||||
const worker = env.workers[0]
|
||||
const { id } = worker.messages[0].message
|
||||
const internal = plugin.show()
|
||||
// Delivery starts before terminate; its awaiting rAF continuation runs afterward.
|
||||
// This does not require a native Worker to dispatch a new event after terminate.
|
||||
worker.deliver({ id, result: 10 })
|
||||
env.destroy()
|
||||
assert.equal(internal.queue[0].$state, 'wait')
|
||||
await frame
|
||||
assert.equal(internal.queue[0].$state, 'ready')
|
||||
assert.equal(internal.queue[0].$ref, null)
|
||||
assert.equal(env.frames.size, 0)
|
||||
assert.equal(env.events.filter(event => event.name === 'artplayerPluginDanmuku:visible').length, 0)
|
||||
})
|
||||
|
||||
test(`danmuku ${implementation.name}: historical play and playing schedule duplicate loops and overwrite pending worker response handlers`, async () => {
|
||||
const env = danmukuEnvironment(implementation)
|
||||
const plugin = env.factory({ danmuku: [] })(env.art)
|
||||
await plugin.emit({ text: 'two loops', time: 10 })
|
||||
env.art.playing = true
|
||||
env.art.emit('video:play')
|
||||
env.art.emit('video:playing')
|
||||
assert.equal(env.frames.size, 2)
|
||||
let firstSettled = false
|
||||
env.frame().then(() => firstSettled = true)
|
||||
await env.flush()
|
||||
env.tick(1)
|
||||
const second = env.frame()
|
||||
await env.flush()
|
||||
const worker = env.workers[0]
|
||||
assert.equal(worker.messages.length, 2)
|
||||
const firstId = worker.messages[0].message.id
|
||||
const secondId = worker.messages[1].message.id
|
||||
assert.notEqual(firstId, secondId)
|
||||
worker.deliver({ id: firstId, result: 10 })
|
||||
await env.flush()
|
||||
assert.equal(firstSettled, false)
|
||||
worker.deliver({ id: secondId, result: 10 })
|
||||
await second
|
||||
assert.equal(firstSettled, false)
|
||||
assert.equal(env.events.filter(event => event.name === 'artplayerPluginDanmuku:visible').length, 1)
|
||||
env.destroy()
|
||||
})
|
||||
|
||||
test(`danmuku ${implementation.name}: historical duplicate start then pause cancels only the latest scheduled frame`, () => {
|
||||
const env = danmukuEnvironment(implementation)
|
||||
env.factory({ danmuku: [] })(env.art)
|
||||
env.art.emit('video:play')
|
||||
env.art.emit('video:playing')
|
||||
env.art.emit('video:pause')
|
||||
assert.equal(env.frames.size, 1)
|
||||
env.destroy()
|
||||
assert.equal(env.frames.size, 1)
|
||||
})
|
||||
}
|
||||
|
||||
process.stdout.write(`Danmuku controlled subprocess evidence: ${evidenceDirectory}\n`)
|
||||
@@ -0,0 +1,239 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import vm from 'node:vm'
|
||||
import { build } from 'esbuild'
|
||||
import { verifyDanmukuContract } from '../../refactor/scripts/danmuku-contract.mjs'
|
||||
import { ensureArchive, readMember, refactorDir } from '../../refactor/scripts/releases.mjs'
|
||||
|
||||
export function deferred() {
|
||||
let resolve
|
||||
let reject
|
||||
const promise = new Promise((yes, no) => {
|
||||
resolve = yes
|
||||
reject = no
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
export async function danmukuHistorical() {
|
||||
const { baseline, archives, sources } = await verifyDanmukuContract()
|
||||
const prefix = 'packages/artplayer-plugin-danmuku/'
|
||||
const compiled = await build({ entryPoints: [`${prefix}src/index.js`], bundle: true, write: false, format: 'cjs', platform: 'browser', plugins: [{ name: 'frozen-danmuku-failures', setup(build) {
|
||||
build.onResolve({ filter: /.*/ }, args => ({ path: path.posix.normalize(args.importer ? path.posix.join(path.posix.dirname(args.importer), args.path) : args.path), namespace: 'frozen' }))
|
||||
build.onLoad({ filter: /.*/, namespace: 'frozen' }, (args) => {
|
||||
if (args.path.includes('?worker'))
|
||||
return { contents: 'export default Worker', loader: 'js' }
|
||||
const file = args.path.split('?')[0]
|
||||
const found = sources.get(file) ?? sources.get(`${file}.js`)
|
||||
assert.notEqual(found, undefined, file)
|
||||
return { contents: args.path.includes('?') ? `export default ${JSON.stringify(found)}` : found, loader: 'js' }
|
||||
})
|
||||
} }] })
|
||||
const coreRelease = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/releases.json'), 'utf8')).releases.find(item => item.name === 'artplayer')
|
||||
const coreCode = readMember(await ensureArchive(coreRelease), 'package/dist/artplayer.js').toString()
|
||||
return [
|
||||
{ name: 'frozen-source', code: compiled.outputFiles[0].text, coreCode },
|
||||
...['main', 'legacy'].map(field => ({ name: `published-5.3.0-${field}`, code: readMember(archives.get('5.3.0'), `package/${baseline.release.manifest[field].replace(/^\.\//u, '')}`).toString(), coreCode })),
|
||||
]
|
||||
}
|
||||
|
||||
class Element {
|
||||
constructor() {
|
||||
this.style = {}
|
||||
this.dataset = {}
|
||||
this.children = []
|
||||
this.nodes = new Map()
|
||||
this.clientWidth = 640
|
||||
this.clientHeight = 360
|
||||
this.offsetWidth = 640
|
||||
this.offsetHeight = 100
|
||||
this.offsetTop = 0
|
||||
this.className = ''
|
||||
this.textContent = ''
|
||||
this.value = ''
|
||||
}
|
||||
|
||||
get [Symbol.toStringTag]() { return 'HTMLDivElement' }
|
||||
appendChild(child) {
|
||||
child.parentElement = this
|
||||
this.children.push(child)
|
||||
return child
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
if (!this.nodes.has(selector))
|
||||
this.nodes.set(selector, new Element())
|
||||
return this.nodes.get(selector)
|
||||
}
|
||||
|
||||
getBoundingClientRect() { return { top: 0, left: 0, right: this.clientWidth, bottom: this.clientHeight, width: this.clientWidth, height: this.clientHeight } }
|
||||
setAttribute(key, value) { this[key] = value }
|
||||
addEventListener() {}
|
||||
}
|
||||
|
||||
export function danmukuEnvironment(implementation, settings = {}) {
|
||||
const coreModule = { exports: {} }
|
||||
vm.runInNewContext(implementation.coreCode, { module: coreModule, exports: coreModule.exports }, { timeout: 1000 })
|
||||
const events = []
|
||||
const listeners = new Map()
|
||||
const workers = []
|
||||
const frames = new Map()
|
||||
const timers = new Map()
|
||||
const proxies = []
|
||||
const controls = []
|
||||
const requests = []
|
||||
const urls = new Map()
|
||||
const revoked = []
|
||||
const consoleErrors = []
|
||||
let id = 0
|
||||
let now = 1000
|
||||
const host = {
|
||||
requestAnimationFrame(callback) {
|
||||
frames.set(++id, callback)
|
||||
return id
|
||||
},
|
||||
cancelAnimationFrame(id) { frames.delete(id) },
|
||||
}
|
||||
class Worker {
|
||||
constructor(url) {
|
||||
this.url = url
|
||||
this.messages = []
|
||||
this.listeners = new Map()
|
||||
this.terminated = false
|
||||
workers.push(this)
|
||||
}
|
||||
|
||||
terminate() { this.terminated = true }
|
||||
postMessage(message) { this.messages.push({ message, afterTerminate: this.terminated }) }
|
||||
addEventListener(name, callback) { this.listeners.set(name, [...this.listeners.get(name) || [], callback]) }
|
||||
deliver(data) { this.onmessage?.({ data }) }
|
||||
async execute(message) {
|
||||
const blob = urls.get(this.url)
|
||||
assert(blob, 'Expected the parser actual Blob source')
|
||||
const workerContext = vm.createContext({ postMessage: data => this.deliver(data) })
|
||||
vm.runInContext(await blob.text(), workerContext, { timeout: 1000 })
|
||||
workerContext.input = message
|
||||
vm.runInContext('onmessage({ data: input })', workerContext, { timeout: 1000 })
|
||||
}
|
||||
|
||||
fail(error) {
|
||||
this.onerror?.(error)
|
||||
for (const callback of this.listeners.get('error') || [])
|
||||
callback(error)
|
||||
}
|
||||
}
|
||||
class Clock extends Date {
|
||||
static now() { return now }
|
||||
}
|
||||
const module = { exports: {} }
|
||||
const context = vm.createContext({
|
||||
window: host,
|
||||
Worker,
|
||||
Blob,
|
||||
Date: Clock,
|
||||
URL: {
|
||||
createObjectURL(blob) {
|
||||
const url = `blob:danmuku-${++id}`
|
||||
urls.set(url, blob)
|
||||
return url
|
||||
},
|
||||
revokeObjectURL(url) { revoked.push(url) },
|
||||
},
|
||||
atob: value => Buffer.from(value, 'base64').toString('binary'),
|
||||
setTimeout(callback, delay) {
|
||||
timers.set(++id, { callback, delay })
|
||||
return id
|
||||
},
|
||||
clearTimeout(id) { timers.delete(id) },
|
||||
fetch: async (url) => {
|
||||
requests.push(url)
|
||||
if (settings.fetch)
|
||||
return settings.fetch(url)
|
||||
return { text: async () => '<i><d p="10,1,25,16777215,1,0,u,1">xml</d></i>' }
|
||||
},
|
||||
console: { error: (...args) => consoleErrors.push(args) },
|
||||
module,
|
||||
exports: module.exports,
|
||||
})
|
||||
vm.runInContext(implementation.code, context, { timeout: 1000 })
|
||||
const factory = typeof module.exports === 'function' ? module.exports : module.exports.default
|
||||
const body = new Element()
|
||||
context.document = { querySelector: selector => selector === '#external' ? body : null, createElement: () => new Element() }
|
||||
const utils = {
|
||||
clamp: (value, min, max) => Math.min(Math.max(value, min), max),
|
||||
setStyle: (element, key, value) => element.style[key] = value,
|
||||
setStyles: (element, style) => Object.assign(element.style, style),
|
||||
errorHandle: (value, message) => {
|
||||
if (!value)
|
||||
throw new Error(message)
|
||||
return value
|
||||
},
|
||||
createElement: () => new Element(),
|
||||
query: (selector, element) => element.querySelector(selector),
|
||||
append: (element, child) => element.appendChild(child),
|
||||
tooltip() {},
|
||||
inverseClass() {},
|
||||
addClass() {},
|
||||
removeClass() {},
|
||||
}
|
||||
const art = {
|
||||
constructor: { utils, validator: coreModule.exports.validator },
|
||||
template: { $danmuku: new Element(), $player: new Element(), $controlsCenter: new Element() },
|
||||
currentTime: 10,
|
||||
duration: 60,
|
||||
played: 0,
|
||||
option: { isLive: false },
|
||||
width: 640,
|
||||
playbackRate: 1,
|
||||
playing: false,
|
||||
plugins: {},
|
||||
controls: { add(option) {
|
||||
const element = new Element()
|
||||
element.offsetWidth = settings.heatmapWidth ?? 640
|
||||
controls.push({ option, element })
|
||||
option.mounted?.(element)
|
||||
return undefined
|
||||
} },
|
||||
on(name, callback) { listeners.set(name, [...listeners.get(name) || [], callback]) },
|
||||
off(name, callback) { listeners.set(name, (listeners.get(name) || []).filter(item => item !== callback)) },
|
||||
emit(name, ...args) {
|
||||
events.push({ name, args })
|
||||
for (const callback of listeners.get(name) || [])
|
||||
callback(...args)
|
||||
},
|
||||
proxy(target, name, callback) { proxies.push({ target, name, callback }) },
|
||||
}
|
||||
return {
|
||||
context,
|
||||
factory,
|
||||
art,
|
||||
workers,
|
||||
events,
|
||||
listeners,
|
||||
frames,
|
||||
timers,
|
||||
proxies,
|
||||
controls,
|
||||
requests,
|
||||
urls,
|
||||
revoked,
|
||||
consoleErrors,
|
||||
async flush() {
|
||||
for (let step = 0; step < 12; step++)
|
||||
await Promise.resolve()
|
||||
},
|
||||
frame(frameId = frames.keys().next().value) {
|
||||
const callback = frames.get(frameId)
|
||||
assert.equal(typeof callback, 'function', 'Expected one scheduled animation frame')
|
||||
frames.delete(frameId)
|
||||
return callback(now)
|
||||
},
|
||||
tick(milliseconds) { now += milliseconds },
|
||||
destroy() {
|
||||
art.isDestroy = true
|
||||
art.emit('destroy')
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user