mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
test(danmuku): [PKG-DANMUKU-01] freeze public contracts and release provenance
This commit is contained in:
1 parent
7d44bce06f
commit
d81b881085
11 files changed
+4229
-6
No files matched your search
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"task": "PKG-DANMUKU-01",
|
||||
"capturedAt": "2026-09-13T09:35:02.032Z",
|
||||
"sourceCommit": "b0cfbfe3a09a84a295a257e45e4577847588d1cb",
|
||||
"environment": {
|
||||
"node": "v24.21.0",
|
||||
"platform": "win32",
|
||||
"architecture": "x64",
|
||||
"executable": "refactor/.cache/toolchains/node-v24.21.0-win-x64/node.exe",
|
||||
"yarnPolicy": "1.22.22; no install or lockfile changes"
|
||||
},
|
||||
"inputs": {
|
||||
"refactor/baselines/danmuku-release.json": {
|
||||
"sha256LF": "82a0ab2e87f108cbb1d09ce1b6647cfefc896d974da1cb60b2668fded18a4b2f"
|
||||
},
|
||||
"refactor/scripts/danmuku-contract.mjs": {
|
||||
"sha256LF": "8ca0996605cfbcd732030708bd67ecedff5018d2b7436fa7247de7bbb39d7573"
|
||||
},
|
||||
"refactor/scripts/danmuku-contract.test.mjs": {
|
||||
"sha256LF": "37836083e849cd8b7d5d47a4e9d952949d3b28bd885225e3197fb6715febf44e"
|
||||
},
|
||||
"refactor/baselines/danmuku-contract.md": {
|
||||
"sha256LF": "ff51611322cfbb5ad744a37eebc555b74e4d9853b59c91bf13547889fa976bb0"
|
||||
},
|
||||
"refactor/changes/2026-09-13-PKG-DANMUKU-01-contracts.md": {
|
||||
"sha256LF": "fb7355058ee1b13688c2e8c713118f6b802aa086209cb8a6e236694f2764abd9"
|
||||
}
|
||||
},
|
||||
"registry": {
|
||||
"url": "https://registry.npmjs.org/artplayer-plugin-danmuku",
|
||||
"status": 200,
|
||||
"catalogStable": 81,
|
||||
"archivedReleases": 15,
|
||||
"unverifiedCatalogArchives": 66,
|
||||
"bodySha256": "6fccd0198b2322827b3271a807b60f73a3e4d6f46aeb5494a86aade409caf1d6"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"command": "node refactor/scripts/danmuku-contract.mjs",
|
||||
"log": "refactor/.cache/danmuku01/verify.log",
|
||||
"exitCode": 0,
|
||||
"logSha256": "655d90ee59df53dd2136c79f14886bd5b281ea142bc0032e4b7e52b13ee7f5bd"
|
||||
},
|
||||
{
|
||||
"command": "node --test refactor/scripts/danmuku-contract.test.mjs",
|
||||
"log": "refactor/.cache/danmuku01/contract-test.log",
|
||||
"exitCode": 0,
|
||||
"passed": 44,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"logSha256": "492c7313c98f197a8241e370854860a0980ada771d1c86b26e5e2303920fb6a0"
|
||||
},
|
||||
{
|
||||
"command": "node node_modules/eslint/bin/eslint.js refactor/scripts/danmuku-contract.mjs refactor/scripts/danmuku-contract.test.mjs",
|
||||
"log": "refactor/.cache/danmuku01/lint.log",
|
||||
"exitCode": 0,
|
||||
"logSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
}
|
||||
],
|
||||
"coverage": {
|
||||
"immutableSourceFiles": 28,
|
||||
"commonJsModulesExecuted": 28,
|
||||
"manifestEsmModulesExecuted": 3,
|
||||
"esmMethod": "Dynamic data URL imports of the actual archive bytes, not package-resolution tests",
|
||||
"runtimeFixtures": [
|
||||
"frozen-Git-source with controlled Worker/CSS loader",
|
||||
"real npm 5.3.0 main",
|
||||
"real npm 5.3.0 legacy"
|
||||
],
|
||||
"runtimeCasesPerFixture": 4,
|
||||
"parser": "Frozen actual Bilibili parser exercised through URL adapter with controlled fetch and absent-Worker fallback",
|
||||
"validator": {
|
||||
"name": "artplayer",
|
||||
"version": "5.4.0",
|
||||
"tarballSha256": "42b269a66b9658d53f9a92572bb36d656ea9af56254a387b3a69c7ddea43979e",
|
||||
"source": "Actual frozen published core bundle static validator, not a replacement validator"
|
||||
}
|
||||
},
|
||||
"reproduced": [
|
||||
"Actual CJS namespace.default/direct function changes and 12 enumerable writable static icons from 5.1.0.",
|
||||
"Latest and frozen source registration synchronous; enumerable facade/getter keys and defaults stable.",
|
||||
"emit returns Promise; chain methods and load resolve the internal Danmuku rather than the facade described by Result.",
|
||||
"Initial Promise danmuku is rejected by published core validator; load(Promise) works in the same realm.",
|
||||
"time:0 becomes currentTime+0.5 and input fields are mutated; public emit bypasses beforeEmit.",
|
||||
"load() replacement, load(target) append, filter this and loaded queue reference identity.",
|
||||
"Function-only filter config updates are ignored by JSON comparison; load rejection emits and rejects the same error.",
|
||||
"Empty-array synchronous show/config/reset/loaded ordering and video play/pause state hooks.",
|
||||
"Settings beforeEmit callback receives trimmed input and option this; border/time reset and lock timer observed.",
|
||||
"destroy leaves registered resize handlers and a started settings lock timer.",
|
||||
"Bilibili fallback parser mode/entity/metadata behavior and unpadded hexadecimal color."
|
||||
],
|
||||
"observedNotReproduced": [
|
||||
"id/icons declaration omissions; Slider value/hide and heatmap points declaration drift",
|
||||
"Bilibili async Promise executor/fetch rejection and unrevoked URL",
|
||||
"worker onmessage replacement and Date.now ids; late callback/duplicate-start cases",
|
||||
"heatmap zero sampling, option.points not consumed, repeated SVG ids",
|
||||
"12 SVG source authorization not separately established"
|
||||
],
|
||||
"limitations": [
|
||||
"No browser playback/layout/Worker/CSP/device validation",
|
||||
"No actual Bilibili request, media fetch, load/performance/long-running memory validation",
|
||||
"No package-installed NodeNext/Node10/TypeScript or AMD/global matrix",
|
||||
"Older selected archives have module-shape coverage, not full old-major runtime compatibility",
|
||||
"Historical defect assertions are observations and must not become candidate normal expectations",
|
||||
"Registry signatures and publisher authorization not independently verified"
|
||||
],
|
||||
"sourceChanged": false,
|
||||
"browserValidated": false,
|
||||
"installedConsumerValidated": false,
|
||||
"releaseReady": false
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
# Danmuku 公共契约基线
|
||||
|
||||
PKG-DANMUKU-01;冻结提交 `b0cfbfe3a09a84a295a257e45e4577847588d1cb`。
|
||||
实际输入及逐文件 SHA 见 [danmuku-release.json](danmuku-release.json),执行结果见
|
||||
[danmuku-contract-validation.json](danmuku-contract-validation.json)。本次不改生产代码。
|
||||
|
||||
## 发布依据与覆盖范围
|
||||
|
||||
2026-09-13 直接读取 [npm registry](https://registry.npmjs.org/artplayer-plugin-danmuku),
|
||||
HTTP 200,目录有 81 个稳定版,latest 为 5.3.0。冻结全部版本的发布时间、tarball、
|
||||
integrity、gitHead;实际下载并校验全部 13 个 5.x 版,另加前两代末版 4.4.11、3.5.31,
|
||||
共 15 个归档。其余 66 个版本只有目录证据,不能说其运行时已验证或全部兼容。
|
||||
这是代表性历史覆盖,不是新最低支持版本,也不把旧 major 的产品行为自动带入候选。
|
||||
|
||||
当前包全部跟踪文件、12 个 SVG、三个 dist、声明、README、可运行 demo 和 VitePress
|
||||
弹幕文档共 28 个输入冻结。最新归档中的 README、manifest、声明及三个 dist 与冻结
|
||||
Git 文件逐字节相同;源码行为另通过冻结 Git 模块受控执行对照,不把可读源码等同于
|
||||
历史打包过程已可重复构建。
|
||||
|
||||
| 归档版本 | registry gitHead 的插件版本 | 同提交核心版本 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 3.5.31 | 3.5.31 | 3.5.31 | 历史 major 对照 |
|
||||
| 4.4.11 | 4.4.10 | 4.5.8 | manifest 版本不一致 |
|
||||
| 5.0.0 / 5.0.1 | 5.0.0 / 5.0.1 | 5.0.0 / 5.0.5 | 分别冻结 |
|
||||
| 5.1.0 / 5.1.1 | 5.1.0 / 5.1.0 | 5.1.2 / 5.1.5 | 5.1.1 版本不一致 |
|
||||
| 5.1.2 / 5.1.3 / 5.1.4 | 同归档版本 | 5.1.6 | 分别冻结 |
|
||||
| 5.1.5 / 5.1.6 | 5.1.4 / 5.1.6 | 5.2.2 | 5.1.5 版本不一致 |
|
||||
| 5.1.7 | 缺 gitHead | 未知 | 不推测来源提交 |
|
||||
| 5.1.8 | 5.1.7 | 5.2.4 | manifest 版本不一致 |
|
||||
| 5.2.0 | 5.1.9 | 5.3.0-beta.3 | 核心是提交关联预发布号 |
|
||||
| 5.3.0 | 5.2.1 | 5.3.1 | 不宣称核心 5.3.1 是 npm 稳定版 |
|
||||
|
||||
真实 manifest 是归档身份依据;提交关联不是最低核心支持声明。归档与关联 Git 路径
|
||||
各成员的匹配/不匹配均在 JSON 中保存。registry 缺失的提交信息保持缺失。
|
||||
未独立验证 npm 签名及发布者授权。5.1.7 归档的目录头没有尾随斜线;专属 verifier
|
||||
用 tar 条目类型区分目录与普通文件,拒绝重复、越界和非普通文件,不改共享解包函数。
|
||||
|
||||
## 分发与静态属性
|
||||
|
||||
| 版本 | 实际 CommonJS | manifest 分发入口 | 静态 icons |
|
||||
| --- | --- | --- | --- |
|
||||
| 3.5.31 | 直接函数 | main;未声明 types/module/legacy | 无 |
|
||||
| 4.4.11 | 对象.default | main/types | 无 |
|
||||
| 5.0.0–5.0.1 | 对象.default | main/types/legacy | 无 |
|
||||
| 5.1.0–5.1.6、5.1.8 | 对象.default | main/types/legacy;无 module/exports | 12 项 |
|
||||
| 5.1.7 | 对象.default | module 是 `.esm.js`;平铺 import/require/default exports | 12 项 |
|
||||
| 5.2.0 | 对象.default | `.mjs`、root/legacy 条件 exports;types 条件在后 | 12 项 |
|
||||
| 5.3.0 | 直接函数 | `.mjs`、root/legacy 条件 exports;types 条件在前 | 12 项 |
|
||||
|
||||
main/legacy 共 28 个实际模块已在 Node VM 执行;三个 manifest ESM 的实际字节通过
|
||||
data URL 动态 import 执行。此项只证明模块代码能求值,不等于在消费者目录通过
|
||||
Node 包解析;真实安装、NodeNext/Node10、AMD/global/script、编辑器仍由 06/09 验证。
|
||||
|
||||
`artplayerPluginDanmuku.icons` 是可写、可配置、可枚举的对象值,顺序为:
|
||||
`$on/$off/$config/$style/$mode_0_off/$mode_0_on/$mode_1_off/$mode_1_on/`
|
||||
`$mode_2_off/$mode_2_on/$check_on/$check_off`。静态 icons 不是 getter;设置模板使用
|
||||
模块内 SVG 字符串,没有读取工厂 icons 的后续改写。这一读码事实尚未验证热替换用法。
|
||||
|
||||
## 最新版工厂与方法
|
||||
|
||||
`factory(option)(art)` 同步返回门面;字段按 `name/emit/load/config/hide/show/reset/`
|
||||
`mount/option/isHide/isStop` 顺序枚举,name 为 `artplayerPluginDanmuku`。
|
||||
后三项是可枚举、可配置、没有 setter 的 getter,读取内部当前值。方法已 bind,可抽取调用。
|
||||
工厂 option 没有默认对象;`{ danmuku: [] }` 是基准合法调用。初始化不等待异步载入完成。
|
||||
|
||||
| API | 最新真实行为 | 声明/兼容注意 |
|
||||
| --- | --- | --- |
|
||||
| `emit(danmu)` | async;验证并修改传入对象,filter 通过后同步入队;Promise 最终返回内部 Danmuku | 旧声明错误写同步 Result;不自动改旧根类型 |
|
||||
| `load()` | 读当前 option.danmuku,reset 后清空队列再逐条 emit | Promise 返回内部对象;不是门面 |
|
||||
| `load(target)` | 追加到现有队列,不自动改 option.danmuku | 包括数组、函数、同 realm Promise、XML URL |
|
||||
| `config(partial)` | 合并默认值、当前值与 partial,验证、钳位;变化比较用 JSON.stringify,单独更换函数会被忽略 | 旧类型要求完整 Option,实际 partial 有效 |
|
||||
| `hide/show` | 更新 isHide、层 opacity、option.visible,并发事件 | 返回内部对象;重复调用也发事件 |
|
||||
| `reset()` | 全队列回到 wait,回收已分配 DOM,再发 reset | 返回内部对象,不清空队列 |
|
||||
| `mount(target)` | selector 或元素;移动设置输入框并 reset 设置 UI | 返回 undefined;声明允许省略,但运行时无目标会抛错 |
|
||||
| `option` | 当前合并后的对象;config 可能更换其引用 | 声明可写,实际仅 getter |
|
||||
| `isHide/isStop` | 初始 false;show/hide 与 video start/stop 更新 | 不应解释为 video.paused 或 layer DOM 是否存在 |
|
||||
|
||||
内部 Danmuku 没有公开门面的 `name/mount`,但历史链式返回会将其暴露给调用方。
|
||||
后续整理必须先评估返回身份,不能为了匹配旧错误声明静默返回门面。
|
||||
`load(undefined)` 清空;其他显式 falsy 值通过 `target = argument || option.danmuku`
|
||||
回退但不走清空分支;这只是读码事实,不把未声明参数纳入必须支持的接口。
|
||||
|
||||
## 全部配置与弹幕项
|
||||
|
||||
| 配置 | 默认值/范围 |
|
||||
| --- | --- |
|
||||
| danmuku | `[]`;声明含数组、URL、返回 Promise 的函数、Promise;初始 Promise 被实际 validator 拒绝,load(Promise) 可用 |
|
||||
| speed / margin / opacity | `5`,钳位 1–10;`[10,'25%']`;`1`,钳位 0–1 |
|
||||
| color / mode / modes | `#FFFFFF`;`0`,钳位 0–2;`[0,1,2]`,显示模式由 dataset/CSS 控制 |
|
||||
| fontSize | `25`;数值或百分比,使用时钳位 12 到播放器高度 |
|
||||
| antiOverlap / synchronousPlayback | `true` / `false` |
|
||||
| mount / heatmap / width / points | controlsCenter / false / 512 / [] |
|
||||
| filter / beforeEmit / beforeVisible | 默认均返回 true;filter 同步,另外两个支持异步 |
|
||||
| visible / emitter | true / true |
|
||||
| maxLength / lockTime / theme | 200(1–1000)/ 5(1–60)/ dark |
|
||||
| OPACITY / FONT_SIZE / MARGIN / SPEED / COLOR | `{}` / `{}` / `{}` / `{}` / `[]`,设置组件再补默认滑块与颜色 |
|
||||
|
||||
Danmu 声明成员为 `text/mode/color/time/border/style`;runtime 还验证可选 string `id`,
|
||||
并传播额外字段。mode 只入队 0/1/2,空白 text 与 filter 拒绝项忽略。time 不存在或为 0
|
||||
时写为 currentTime + 0.5;负数钳到 0。入队时额外生成 `$state/$index/$ref/$restTime/`
|
||||
`$lastStartTime`,loaded/visible 事件会暴露这些字段和对象引用。
|
||||
|
||||
直接 emit/load 调用 filter,以当前 option 为 this,不经过 beforeEmit。设置 UI 的
|
||||
发送按钮/Enter 先 trim 输入,防锁定/重复提交,await beforeEmit;只有严格 true 继续,
|
||||
加 border、删除 time、再调用 emit,清输入并锁定。beforeVisible 在准备显示前调用,
|
||||
也以 option 为 this。三者不能合并成一个全局异步过滤器。
|
||||
|
||||
最新声明有六个命名类型 `Mode/Danmuku/Slider/Danmu/Option/Result`。
|
||||
4.4.11/5.0.x 旧声明中 Danmuku 表示结果,而 5.1.x 起表示输入,旧配置还出现
|
||||
useWorker/minWidth/maxWidth;不能将较早声明与最新声明拼成未经评估的根类型。
|
||||
06 按已批准的 [统一类型规则](../type-compatibility-policy.md) 处理:最新 npm 根声明的
|
||||
合法完整工厂赋值、Parameters/ReturnType、NodeNext 历史模块形态都需安装消费者证据;
|
||||
准确异步类型可用独立 runtime 入口,较早冲突写迁移说明。本次没有改类型或声称编译矩阵通过。
|
||||
|
||||
## 事件、DOM、设置与热力图
|
||||
|
||||
| 事件 | 参数/时机 |
|
||||
| --- | --- |
|
||||
| artplayerPluginDanmuku:config | 当前 option 引用;show/hide 在它之前发生 |
|
||||
| artplayerPluginDanmuku:loaded | 完成当前 load 后的实际 queue 引用;空数组初始化可在工厂返回前同步发生 |
|
||||
| artplayerPluginDanmuku:error | load 捕获的原错误,然后再次抛出;直接 emit 不自动发这个事件 |
|
||||
| artplayerPluginDanmuku:visible | worker 返回位置且允许显示后,实际内部 danmu |
|
||||
| artplayerPluginDanmuku:start / stop | 无参数,video:play/playing / pause/waiting 对应触发 |
|
||||
| artplayerPluginDanmuku:show / hide / reset | 无参数,对应方法更新状态之后 |
|
||||
| artplayerPluginDanmuku:destroy | stop、worker terminate 和解除部分监听之后 |
|
||||
| artplayerPluginDanmuku:points | 消费方发入的热力图数据事件,插件不主动 emit |
|
||||
|
||||
这些是十个输出事件和一个输入事件;不新增虚构的 `:emit` 事件。
|
||||
[] 初始化的已复现顺序为 show → config → reset → loaded;非空/异步输入顺序和错误
|
||||
重入仍需 02 扩展。Setting 还订阅 resize/fullscreen/fullscreenWeb/document:pointermove/
|
||||
document:pointerup/show/hide。热力图订阅 ready/resize/loaded/timeupdate/setBar/points。
|
||||
|
||||
设置入口是 `new Setting` 创建的独立 `.artplayer-plugin-danmuku` 输入框和弹出面板,
|
||||
不是核心 setting registry 的单个项目。内部使用 `.apd-*` 类、mode/color/state/id 数据,
|
||||
外部挂载可在 fullscreen/fullscreenWeb 时移回控制栏;默认挂载在宽度低于 width 时移到
|
||||
播放器底部。默认不透明度 0–100、字号 12–120;MARGIN 四档 `[10,'75%']/[10,'50%']/`
|
||||
`[10,'25%']/[10,10]`;SPEED 五档 10/7.5/5/2.5/1。滑块读取 step.hide,而声明写 show;
|
||||
MARGIN step.value 是数组,声明只有 number|string。
|
||||
|
||||
设置 style 在模块求值时插入 id=`artplayer-plugin-danmuku` 的全局 style;完整类名、
|
||||
选择器、SVG 和样式源码已整体哈希冻结。视觉、CSS 覆盖、重复 style 与外部节点回收
|
||||
未在本次 mock 中模拟,交 02/05/08 的真实页面验证。
|
||||
|
||||
heatmap=true/object 创建 name=`heatmap`、position=`top` 的控件。源码按 `[x,y]` 二元组
|
||||
使用 points 事件数据;`option.points` 当前只存储,未被 heatmap update 消费,声明却为
|
||||
`{time,value}[]`。初始化开启 heatmap 后 config 开关不会自动新增/移除控件。上面两项为
|
||||
读码事实,尚未浏览器复现。窄尺寸下默认 sampling=floor(width/100) 可为 0;需 02 用
|
||||
独立超时保护测试确认循环风险,不在 UI 主线程盲跑。
|
||||
|
||||
## Bilibili XML 与 worker
|
||||
|
||||
string 输入调用 fetch(url) → response.text(),没有显式 HTTP 状态判断。XML 用正则读
|
||||
`<d p="...">...</d>`,要求至少八个逗号分隔字段;模式 1/2/3→0、4→2、5→1,其他→0;
|
||||
trim 后按 quot/apos/lt/gt/amp 顺序解实体。输出 text/time/mode/fontSize/color/timestamp/
|
||||
pool/userID/rowID;color 用十六进制字符串,不补齐六位,数字异常也不额外过滤。
|
||||
|
||||
优先生成 Blob worker,message 含 `{xml,id}`,结果 `{danmus,id}`,成功后 terminate;
|
||||
Worker 构建同步失败时 console.error 并退回相同 parser。正则和 fallback 已执行;真实
|
||||
网络、Worker 消息和 CSP/Blob 能力未验证。源码用 async Promise executor,fetch/text
|
||||
拒绝发生在 try 之外,且未 revokeObjectURL;空 XML/worker error/销毁竞态是待 02 复现项。
|
||||
调度 worker 接口 `{type:'getDanmuTop',id,target,visibles,antiOverlap,clientWidth,clientHeight,marginBottom,marginTop}`
|
||||
→ `{result,id}`,当前 Date.now 作为 id 并覆盖 onmessage;并发/旧回复仍待测试。
|
||||
|
||||
## 已复现差异与后续责任
|
||||
|
||||
| 编号 | 证据级别 | 内容 | 后续任务 |
|
||||
| --- | --- | --- | --- |
|
||||
| DANMUKU-TYPE-01 | 运行时复现 + 逐字声明 | emit Promise、内部返回身份、getter、mount 省略、初始 Promise 和声明不一致 | 03/06 |
|
||||
| DANMUKU-INPUT-01 | 冻结源码和真实 5.3 main/legacy 复现 | time:0 被改为 current+0.5;输入对象被修改 | 02/03,分别评估修正和保留 |
|
||||
| DANMUKU-CONFIG-01 | 同上 | 单独 config({filter:新函数}) 被 JSON.stringify 比较判为未变化 | 02/03 |
|
||||
| DANMUKU-CLEANUP-01 | 同上 | resize 注册 this.resize,销毁 off(this.reset);设置锁定 timer 未清 | 02/05 |
|
||||
| DANMUKU-TYPE-02 | 读码/声明观察 | id/icons 缺失、Slider value/hide、points 类型与使用漂移、config partial | 05/06 |
|
||||
| DANMUKU-ASYNC-01 | 待复现 | Bilibili fetch/text/worker 错误、load 乱序、beforeVisible/worker 晚回调、重复 start | 02/03/04/05 |
|
||||
| DANMUKU-HEATMAP-01 | 待复现 | 小于 100px 时 sampling=0、事件 tuple/option.points、重复实例 SVG id | 02/05 |
|
||||
| DANMUKU-SOURCE-01 | 归档与 Git 核验 | gitHead 版本错位、5.1.7 无 gitHead、12 SVG 无单独来源授权证据 | 09 |
|
||||
|
||||
历史缺陷断言仅冻结观察,不要求候选保留。修复必须有独立候选正常断言与明确迁移影响。
|
||||
包 README 只有简介/demo/license,主要公开说明来自冻结 VitePress 文档和 demo。
|
||||
demo 使用本地 video.mp4/danmuku.xml,适合作为后续 8082/Monaco/Run 的本地输入。
|
||||
本次不证明实际播放、密集弹幕碰撞/时钟、mask/全屏/PiP、真实 WebKit/设备或 npm 发布可行。
|
||||
|
||||
## 重放
|
||||
|
||||
在固定 Node 24.21.0 / Yarn 1.22.22 与已安装根依赖下运行:
|
||||
|
||||
```text
|
||||
node refactor/scripts/danmuku-contract.mjs
|
||||
node --test refactor/scripts/danmuku-contract.test.mjs
|
||||
node node_modules/eslint/bin/eslint.js refactor/scripts/danmuku-contract.mjs refactor/scripts/danmuku-contract.test.mjs
|
||||
```
|
||||
|
||||
缺缓存时 verifier 仅从冻结 npm URL 下载并验证 SHA512/SHA256;不执行 registry 包脚本。
|
||||
完整目录可再次读取 `https://registry.npmjs.org/artplayer-plugin-danmuku`,但返回的新目录
|
||||
不应覆盖本次不可变基线。原始 registry body 的 SHA 保存在 JSON,原始响应在专属 cache。
|
||||
测试使用真实发布核心 5.4.0 的 validator、受控 DOM/计时器/Worker,仅源码编译在内存中
|
||||
完成,不生成或修改 dist/docs。仅访问 npm 元数据及归档,未请求媒体或 Bilibili 资源。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,52 @@
|
||||
# PKG-DANMUKU-01 弹幕公共契约与发布来源
|
||||
|
||||
冻结起点:`b0cfbfe3a09a84a295a257e45e4577847588d1cb`。
|
||||
|
||||
本任务只增加不可变基线、校验器和契约测试。未改生产源码、公开声明、构建产物、
|
||||
共享脚本或发布版本;整合代理维护任务状态并完成单独本地提交,不推送或发布。
|
||||
|
||||
## 交付与兼容范围
|
||||
|
||||
- [发布来源](../baselines/danmuku-release.json):81 个真实稳定版目录,15 个实际 tarball,
|
||||
SHA512/SHA256、全部成员、manifest 入口、可用 Git 关联及 28 个冻结工作区输入。
|
||||
- [契约](../baselines/danmuku-contract.md):emit/load/config/hide/show/reset/mount、三个
|
||||
getter、icons、27 配置、事件、设置 UI、热力图、Bilibili 与 Worker 协议。
|
||||
- [校验器](../scripts/danmuku-contract.mjs):返回 `{baseline,archives,sources}` 供后续
|
||||
行为测试重用;下载只用冻结 npm URL,Git 内容只读取冻结完整 SHA。
|
||||
- [44 项测试](../scripts/danmuku-contract.test.mjs) 与
|
||||
[实际验证记录](../baselines/danmuku-contract-validation.json):运行真实历史模块字节;
|
||||
最新 main/legacy 和冻结源码比对方法、返回身份、事件、输入和 settings 发送路径。
|
||||
|
||||
API-02/03/04/06/08/09/11/12 都有明确契约记录;其中 UI/Worker 的视觉与浏览器行为
|
||||
仍是待验收内容。3.5.31/4.4.11 是前代对照,不是自动承诺任意历史 major 的所有行为。
|
||||
|
||||
## 发现与处置
|
||||
|
||||
真实模块形态在 3.5.31、4.4.11–5.2.0、5.3.0 之间变化;后续保留已有合法 JS 入口。
|
||||
多数关联 Git manifest 与发布号不同,5.1.7 无 gitHead,不据此推测最低核心版本。
|
||||
5.1.7 的合法 tar 目录头使共享 archiveFiles 的旧假设不适用,专属读取器依据条目类型
|
||||
识别目录,同时保留路径/重复/非普通成员检查,没有扩大共享脚本影响。
|
||||
|
||||
已复现 emit 异步却声明同步、链式返回内部对象、初始 Promise validator 拒绝、
|
||||
mount 省略时报错、time:0 被替换、resize 解绑错误及设置 timer 未清。源码读码还观察到
|
||||
id/icons、Slider 和 heatmap points 类型漂移。根类型后续遵循已经批准的统一类型政策,
|
||||
不因修正异步描述破坏最新 npm 合法双向工厂赋值。缺陷正常/候选断言必须分离。
|
||||
|
||||
完整风险分类、责任任务与重放命令见契约,未验证问题没有伪装成已修复或已通过。
|
||||
测试使用已存在 esbuild/Node runner,不安装依赖、不改 Yarn 锁文件。
|
||||
|
||||
## 实际验证与限制
|
||||
|
||||
44 tests passed / 0 failed / 0 skipped;专属 lint 和归档 verifier 通过。
|
||||
Node 24.21.0,Windows;日志/命令/输入 SHA 见 validation JSON。
|
||||
没有运行真实浏览器、网络 Bilibili 请求、设备、负载/内存或安装消费者矩阵。
|
||||
编译测试只把冻结源码及受控依赖在内存打包,不能当作正式生产构建或重构完成。
|
||||
|
||||
## 下一步与回退
|
||||
|
||||
PKG-DANMUKU-02 从真实归档/冻结源码扩展浏览器、时钟、轨道、乱序/拒绝和销毁基线,
|
||||
优先为已观察的异步输入、重复 start、Worker 相关问题建立受控复现。03/04/05 分别处理
|
||||
输入、调度、渲染/设置/Worker,06 处理 TS 与旧根类型,07/08/09 做能力/组合/分发验收。
|
||||
|
||||
本批无消费者行为变更;如需回退,回退专属契约任务提交即可,保留旧 tarball 与日志
|
||||
用于后续对照。缓存可重新下载,冻结 SHA 与元数据不能随当前源码变动而更新。
|
||||
+4
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 228 项,范围 22 个包及工作区/示例。
|
||||
|
||||
状态:todo 74 / doing 16 / blocked 0 / done 138 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
状态:todo 73 / doing 16 / blocked 0 / done 139 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
|
||||
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
|
||||
|
||||
@@ -309,8 +309,8 @@
|
||||
|
||||
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| PKG-DANMUKU-01 | artplayer-plugin-danmuku<br>核对弹幕全部公开契约 | BASE-05 | emit/load/config/hide/show/reset/mount、option/isHide/isStop/icons 与事件清单 | 源码、声明、发布包、Bilibili 输入和设置入口对照 | H | doing |
|
||||
| PKG-DANMUKU-02 | artplayer-plugin-danmuku<br>建立弹幕算法与浏览器基线 | PKG-DANMUKU-01, ENG-05, ENG-08 | 密集/稀疏弹幕、过滤/异步输入、seek/倍率/重载测试 | 轨道选择、发射顺序、事件、内存和可视结果可重跑 | H | todo |
|
||||
| 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-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 |
|
||||
@@ -552,6 +552,7 @@
|
||||
- PKG-DPIP-03: [记录](changes/2026-09-12-PKG-DPIP-03-lifecycle.md) [记录](baselines/dpip-lifecycle-validation.json) [记录](dpip-validation.md)
|
||||
- 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-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,15 @@
|
||||
# 进度与证据
|
||||
|
||||
## PKG-DANMUKU-01 弹幕契约与归档完成
|
||||
|
||||
冻结81稳定版目录、15真实归档和28源码/文档/图标输入。44项测试及父代理复核通过;
|
||||
实际模块、完整方法与配置、设置、Bilibili/worker接口、返回身份和旧缺陷已登记。
|
||||
其他66版只有目录证据,没有扩张支持承诺。
|
||||
见[契约](baselines/danmuku-contract.md)和[变更](changes/2026-09-13-PKG-DANMUKU-01-contracts.md)。
|
||||
228项:139 done、16 doing、73 todo。02开始独立受控异步失败
|
||||
与真实浏览器基线;03至09的源码迁移、性能和最终分发仍待实施。无推送或发布。
|
||||
|
||||
|
||||
## PKG-MASK-01 弹幕遮罩契约完成
|
||||
|
||||
冻结全部两个真实稳定发布版、Git关联和导出差异;核对六个Yarn/SDK版本与12个已有
|
||||
|
||||
@@ -227,3 +227,11 @@
|
||||
| MASK-BACKEND-01 | open / 源码/产物事实 | Mask TensorFlow backend fallback does not establish MediaPipe fallback and failed initialization keeps scheduling | PKG-MASK-02, PKG-MASK-03, PKG-MASK-05 |
|
||||
| MASK-DOM-01 | open / 源码/产物事实 | Mask registration and frame processing assume Danmuku DOM, readable frames and a 2D context | PKG-MASK-02, PKG-MASK-03, PKG-MASK-05 |
|
||||
| MASK-NOTICE-01 | open / 源码/产物事实 | Published Mask bundles lack a separate third-party notice despite bundled SDK code | PKG-MASK-06 |
|
||||
| DANMUKU-TYPE-01 | open / 已复现 | Danmuku historical declared results, optional mount and initial Promise input disagree with runtime | PKG-DANMUKU-03, PKG-DANMUKU-06 |
|
||||
| DANMUKU-INPUT-01 | open / 已复现 | Danmuku replaces time zero with current time plus 0.5 and mutates supplied input objects | PKG-DANMUKU-02, PKG-DANMUKU-03 |
|
||||
| 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-SOURCE-01 | open / 已复现 | Danmuku registry Git associations differ from releases and static icon provenance remains incomplete | PKG-DANMUKU-09 |
|
||||
@@ -5006,6 +5006,143 @@
|
||||
],
|
||||
"compatibleResolution": "Trace included SDK assets and license notices against actual bundle sources; preserve required notices in candidate packages.",
|
||||
"closureCriteria": "Document incorporated SDK provenance and applicable notices; inspect built and packed artifacts, without treating manifest license claims as full clearance."
|
||||
},
|
||||
{
|
||||
"id": "DANMUKU-TYPE-01",
|
||||
"title": "Danmuku historical declared results, optional mount and initial Promise input disagree with runtime",
|
||||
"confirmation": "reproduced",
|
||||
"status": "open",
|
||||
"owners": [
|
||||
"PKG-DANMUKU-03",
|
||||
"PKG-DANMUKU-06"
|
||||
],
|
||||
"evidence": [
|
||||
"refactor/baselines/danmuku-contract.md",
|
||||
"refactor/baselines/danmuku-contract-validation.json",
|
||||
"refactor/baselines/danmuku-release.json"
|
||||
],
|
||||
"compatibleResolution": "Preserve latest published complete root type contracts and actual legal JS return identities; expose accurate types separately.",
|
||||
"closureCriteria": "Installed consumers verify latest root assignments and precise async/getter/mount contracts; initial Promise mismatch has an explicit tested resolution."
|
||||
},
|
||||
{
|
||||
"id": "DANMUKU-INPUT-01",
|
||||
"title": "Danmuku replaces time zero with current time plus 0.5 and mutates supplied input objects",
|
||||
"confirmation": "reproduced",
|
||||
"status": "open",
|
||||
"owners": [
|
||||
"PKG-DANMUKU-02",
|
||||
"PKG-DANMUKU-03"
|
||||
],
|
||||
"evidence": [
|
||||
"refactor/baselines/danmuku-contract.md",
|
||||
"refactor/baselines/danmuku-contract-validation.json",
|
||||
"refactor/baselines/danmuku-release.json"
|
||||
],
|
||||
"compatibleResolution": "Separate the time-zero defect from observable input object identity/mutation before changing parsing or queue ownership.",
|
||||
"closureCriteria": "Zero, absent, negative and explicit times plus object identity are covered with candidate expectations and compatibility decisions."
|
||||
},
|
||||
{
|
||||
"id": "DANMUKU-CONFIG-01",
|
||||
"title": "Danmuku JSON configuration comparison ignores a replacement filter function",
|
||||
"confirmation": "reproduced",
|
||||
"status": "open",
|
||||
"owners": [
|
||||
"PKG-DANMUKU-02",
|
||||
"PKG-DANMUKU-03"
|
||||
],
|
||||
"evidence": [
|
||||
"refactor/baselines/danmuku-contract.md",
|
||||
"refactor/baselines/danmuku-contract-validation.json",
|
||||
"refactor/baselines/danmuku-release.json"
|
||||
],
|
||||
"compatibleResolution": "Compare relevant configuration values without dropping callable properties; retain callback receivers and config event ordering.",
|
||||
"closureCriteria": "Function-only changes take effect, unchanged options remain stable and partial configuration/callback reentry has regression evidence."
|
||||
},
|
||||
{
|
||||
"id": "DANMUKU-CLEANUP-01",
|
||||
"title": "Danmuku removes a different resize callback and retains the settings lock timer on destroy",
|
||||
"confirmation": "reproduced",
|
||||
"status": "open",
|
||||
"owners": [
|
||||
"PKG-DANMUKU-02",
|
||||
"PKG-DANMUKU-05"
|
||||
],
|
||||
"evidence": [
|
||||
"refactor/baselines/danmuku-contract.md",
|
||||
"refactor/baselines/danmuku-contract-validation.json",
|
||||
"refactor/baselines/danmuku-release.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."
|
||||
},
|
||||
{
|
||||
"id": "DANMUKU-TYPE-02",
|
||||
"title": "Danmuku id, icons, Slider steps, heatmap points and partial configuration drift from declarations",
|
||||
"confirmation": "source-observed",
|
||||
"status": "open",
|
||||
"owners": [
|
||||
"PKG-DANMUKU-05",
|
||||
"PKG-DANMUKU-06"
|
||||
],
|
||||
"evidence": [
|
||||
"refactor/baselines/danmuku-contract.md",
|
||||
"refactor/baselines/danmuku-contract-validation.json",
|
||||
"refactor/baselines/danmuku-release.json"
|
||||
],
|
||||
"compatibleResolution": "Validate actual accepted data and static properties, retain root compatibility and document effective fields.",
|
||||
"closureCriteria": "Positive and negative installed consumers and source behavior tests cover these fields without widening away old factory contracts."
|
||||
},
|
||||
{
|
||||
"id": "DANMUKU-ASYNC-01",
|
||||
"title": "Danmuku asynchronous input, worker replies and pending visibility have unverified failure and cancellation paths",
|
||||
"confirmation": "source-observed",
|
||||
"status": "open",
|
||||
"owners": [
|
||||
"PKG-DANMUKU-02",
|
||||
"PKG-DANMUKU-03",
|
||||
"PKG-DANMUKU-04",
|
||||
"PKG-DANMUKU-05"
|
||||
],
|
||||
"evidence": [
|
||||
"refactor/baselines/danmuku-contract.md",
|
||||
"refactor/baselines/danmuku-contract-validation.json",
|
||||
"refactor/baselines/danmuku-release.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."
|
||||
},
|
||||
{
|
||||
"id": "DANMUKU-HEATMAP-01",
|
||||
"title": "Danmuku heatmap sampling can become zero and points/instance ownership need browser validation",
|
||||
"confirmation": "source-observed",
|
||||
"status": "open",
|
||||
"owners": [
|
||||
"PKG-DANMUKU-02",
|
||||
"PKG-DANMUKU-05"
|
||||
],
|
||||
"evidence": [
|
||||
"refactor/baselines/danmuku-contract.md",
|
||||
"refactor/baselines/danmuku-contract-validation.json",
|
||||
"refactor/baselines/danmuku-release.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."
|
||||
},
|
||||
{
|
||||
"id": "DANMUKU-SOURCE-01",
|
||||
"title": "Danmuku registry Git associations differ from releases and static icon provenance remains incomplete",
|
||||
"confirmation": "reproduced",
|
||||
"status": "open",
|
||||
"owners": [
|
||||
"PKG-DANMUKU-09"
|
||||
],
|
||||
"evidence": [
|
||||
"refactor/baselines/danmuku-contract.md",
|
||||
"refactor/baselines/danmuku-contract-validation.json",
|
||||
"refactor/baselines/danmuku-release.json"
|
||||
],
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { ensureArchive, hash, readMember, refactorDir } from './releases.mjs'
|
||||
|
||||
const workspace = path.dirname(refactorDir.replace(/[\\/]$/u, ''))
|
||||
const prefix = 'packages/artplayer-plugin-danmuku/'
|
||||
|
||||
export function danmukuArchiveFiles(archive) {
|
||||
const names = execFileSync('tar', ['-tzf', archive], { encoding: 'utf8' }).trim().split(/\r?\n/u)
|
||||
const descriptions = execFileSync('tar', ['-tvzf', archive], { encoding: 'utf8' }).trim().split(/\r?\n/u)
|
||||
assert.equal(names.length, descriptions.length)
|
||||
assert.equal(new Set(names).size, names.length, 'Duplicate archive entries')
|
||||
return names.filter((name, index) => {
|
||||
const kind = descriptions[index][0]
|
||||
assert(kind === 'd' || kind === '-', `Unsupported archive entry: ${name}`)
|
||||
// npm 5.1.7 contains directory headers without a trailing slash, including package.
|
||||
assert((name.startsWith('package/') || (name === 'package' && kind === 'd'))
|
||||
&& !name.includes('\\') && !name.split('/').includes('..'), `Invalid archive path: ${name}`)
|
||||
return kind === '-'
|
||||
}).sort()
|
||||
}
|
||||
|
||||
function gitText(commit, file) {
|
||||
return execFileSync('git', ['show', `${commit}:${file}`], { cwd: workspace, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).replaceAll('\r\n', '\n')
|
||||
}
|
||||
|
||||
export async function verifyDanmukuContract() {
|
||||
const baseline = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/danmuku-release.json'), 'utf8'))
|
||||
const releases = [baseline.release, ...baseline.previous]
|
||||
assert.deepEqual(releases.map(release => release.version).sort(), [...baseline.registrySnapshot.selectedVersions].sort())
|
||||
assert.deepEqual(baseline.registrySnapshot.catalog.map(item => item.version), baseline.registrySnapshot.stableVersions)
|
||||
const archives = new Map()
|
||||
for (const release of releases) {
|
||||
const metadata = baseline.registrySnapshot.catalog.find(item => item.version === release.version)
|
||||
for (const field of ['tarball', 'integrity', 'publishedAt'])
|
||||
assert.equal(release[field], metadata[field], `${release.version}/${field}`)
|
||||
assert.equal(release.registryGitHead, metadata.gitHead)
|
||||
const archive = await ensureArchive(release)
|
||||
assert.deepEqual(danmukuArchiveFiles(archive), Object.keys(release.files).sort())
|
||||
for (const [file, expected] of Object.entries(release.files))
|
||||
assert.equal(hash(readMember(archive, file)), expected, `${release.version}/${file}`)
|
||||
const manifest = JSON.parse(readMember(archive, 'package/package.json'))
|
||||
assert.deepEqual(manifest, release.manifest)
|
||||
assert.equal(manifest.name, release.name)
|
||||
assert.equal(manifest.version, release.version)
|
||||
const memberExists = target => assert(Object.hasOwn(release.files, `package/${target.replace(/^\.\//u, '')}`), `${release.version}/${target}`)
|
||||
for (const field of ['main', 'module', 'types', 'legacy']) {
|
||||
assert.equal(manifest[field] ?? null, release.entrypoints[field])
|
||||
if (manifest[field])
|
||||
memberExists(manifest[field])
|
||||
}
|
||||
assert.deepEqual(manifest.exports ?? null, release.entrypoints.exports)
|
||||
function checkExports(value) {
|
||||
if (typeof value === 'string')
|
||||
memberExists(value)
|
||||
else if (value && typeof value === 'object')
|
||||
Object.values(value).forEach(checkExports)
|
||||
}
|
||||
checkExports(manifest.exports)
|
||||
for (const association of [release.registrySourceManifest, release.historicalCore]) {
|
||||
if (association.status === 'verified-local-git-object') {
|
||||
assert.equal(association.commit, release.registryGitHead)
|
||||
const text = gitText(association.commit, association.file)
|
||||
assert.equal(hash(text), association.sha256LF)
|
||||
assert.equal(JSON.parse(text).version, association.version)
|
||||
}
|
||||
else {
|
||||
assert(['registry-gitHead-absent', 'git-object-or-path-unavailable'].includes(association.status))
|
||||
if (association.status === 'registry-gitHead-absent')
|
||||
assert.equal(release.registryGitHead, null)
|
||||
}
|
||||
}
|
||||
for (const comparison of release.registrySourceComparison) {
|
||||
if (comparison.sha256LF) {
|
||||
const digest = hash(gitText(release.registryGitHead, comparison.file))
|
||||
assert.equal(digest, comparison.sha256LF)
|
||||
assert.equal(comparison.matchesArchiveBytes, digest === release.files[`package/${comparison.file.slice(prefix.length)}`])
|
||||
}
|
||||
else {
|
||||
assert.equal(comparison.status, 'git-object-or-path-unavailable')
|
||||
}
|
||||
}
|
||||
archives.set(release.version, archive)
|
||||
}
|
||||
const sources = new Map()
|
||||
for (const [file, expected] of Object.entries(baseline.source)) {
|
||||
const source = gitText(baseline.sourceCommit, file)
|
||||
assert.equal(hash(source), expected, file)
|
||||
sources.set(file, source)
|
||||
}
|
||||
for (const file of baseline.observations.latestArchiveMatchesFrozenSourceFiles)
|
||||
assert.equal(hash(sources.get(file)), baseline.release.files[`package/${file.slice(prefix.length)}`])
|
||||
return { baseline, archives, sources }
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const { baseline, archives, sources } = await verifyDanmukuContract()
|
||||
console.log(JSON.stringify({ sourceCommit: baseline.sourceCommit, sourceFiles: sources.size, catalogStableVersions: baseline.registrySnapshot.stableVersions.length, releases: [...archives.keys()], validation: 'SHA512/SHA256, actual archive members and entrypoints, frozen Git source and available registry Git associations; no playback or publishing claim' }, null, 2))
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Immutable Node contract runner.
|
||||
import { test } from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
import { build } from 'esbuild'
|
||||
import { verifyDanmukuContract } from './danmuku-contract.mjs'
|
||||
import { ensureArchive, readMember, refactorDir } from './releases.mjs'
|
||||
|
||||
const { baseline, archives, sources } = await verifyDanmukuContract()
|
||||
const prefix = 'packages/artplayer-plugin-danmuku/'
|
||||
const icons = ['$on', '$off', '$config', '$style', '$mode_0_off', '$mode_0_on', '$mode_1_off', '$mode_1_on', '$mode_2_off', '$mode_2_on', '$check_on', '$check_off']
|
||||
const publicKeys = ['name', 'emit', 'load', 'config', 'hide', 'show', 'reset', 'mount', 'option', 'isHide', 'isStop']
|
||||
const optionKeys = ['danmuku', 'speed', 'margin', 'opacity', 'color', 'mode', 'modes', 'fontSize', 'antiOverlap', 'synchronousPlayback', 'mount', 'heatmap', 'width', 'points', 'filter', 'beforeEmit', 'beforeVisible', 'visible', 'emitter', 'maxLength', 'lockTime', 'theme', 'OPACITY', 'FONT_SIZE', 'MARGIN', 'SPEED', 'COLOR']
|
||||
const coreRelease = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/releases.json'), 'utf8')).releases.find(item => item.name === 'artplayer')
|
||||
const coreModule = { exports: {} }
|
||||
vm.runInNewContext(readMember(await ensureArchive(coreRelease), 'package/dist/artplayer.js').toString(), { module: coreModule, exports: coreModule.exports }, { timeout: 1000 })
|
||||
const core = coreModule.exports
|
||||
|
||||
function exported(code, globals = {}) {
|
||||
const module = { exports: {} }
|
||||
const context = vm.createContext({ console, ...globals, module, exports: module.exports })
|
||||
vm.runInContext(code, context, { timeout: 1000 })
|
||||
const factory = typeof module.exports === 'function' ? module.exports : module.exports.default
|
||||
return { module: module.exports, factory, context }
|
||||
}
|
||||
|
||||
for (const release of [baseline.release, ...baseline.previous]) {
|
||||
for (const field of ['main', 'legacy']) {
|
||||
if (!release.manifest[field])
|
||||
continue
|
||||
test(`danmuku published ${release.version} ${field}: actual CommonJS shape and static icons`, () => {
|
||||
const code = readMember(archives.get(release.version), `package/${release.manifest[field].replace(/^\.\//u, '')}`).toString()
|
||||
const result = exported(code)
|
||||
const direct = ['3.5.31', '5.3.0'].includes(release.version)
|
||||
assert.equal(typeof result.module, direct ? 'function' : 'object')
|
||||
assert.equal(typeof result.factory, 'function')
|
||||
const hasIcons = release.version.startsWith('5.') && !release.version.startsWith('5.0.')
|
||||
assert.deepEqual(Object.keys(result.factory.icons || {}), hasIcons ? icons : [])
|
||||
if (hasIcons) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(result.factory, 'icons')
|
||||
assert.equal(descriptor.writable, true)
|
||||
assert.equal(descriptor.enumerable, true)
|
||||
assert.equal(descriptor.configurable, true)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (release.manifest.module) {
|
||||
test(`danmuku published ${release.version}: manifest ESM bytes expose a default factory`, async () => {
|
||||
const code = readMember(archives.get(release.version), `package/${release.manifest.module.replace(/^\.\//u, '')}`)
|
||||
const module = await import(`data:text/javascript;base64,${code.toString('base64')}`)
|
||||
assert.equal(typeof module.default, 'function')
|
||||
assert.deepEqual(Object.keys(module.default.icons), icons)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Build only immutable Git modules in memory. Inline worker transport and CSS are
|
||||
// controlled dependencies here; real Worker/layout/visual behavior belongs to 02.
|
||||
const frozenSource = await build({ entryPoints: [`${prefix}src/index.js`], bundle: true, write: false, format: 'cjs', platform: 'browser', plugins: [{ name: 'frozen-danmuku', 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)
|
||||
if (args.path.includes('?'))
|
||||
return { contents: `export default ${JSON.stringify(found)}`, loader: 'js' }
|
||||
return { contents: found, loader: 'js' }
|
||||
})
|
||||
} }] })
|
||||
|
||||
class Element {
|
||||
constructor() {
|
||||
this.style = {}
|
||||
this.dataset = {}
|
||||
this.children = []
|
||||
this.nodes = new Map()
|
||||
this.clientWidth = 640
|
||||
this.clientHeight = 360
|
||||
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: 640, bottom: 360, width: 640, height: 360 } }
|
||||
setAttribute(key, value) { this[key] = value }
|
||||
addEventListener() {}
|
||||
}
|
||||
|
||||
function environment(code) {
|
||||
const events = []
|
||||
const listeners = new Map()
|
||||
const workers = []
|
||||
const frames = new Map()
|
||||
const timers = new Map()
|
||||
const proxies = []
|
||||
const host = {
|
||||
requestAnimationFrame(callback) {
|
||||
frames.set(frames.size + 1, callback)
|
||||
return frames.size
|
||||
},
|
||||
cancelAnimationFrame(id) { frames.delete(id) },
|
||||
}
|
||||
class Worker {
|
||||
constructor() { workers.push(this) }
|
||||
terminate() { this.terminated = true }
|
||||
postMessage() {}
|
||||
addEventListener() {}
|
||||
}
|
||||
const loaded = exported(code, {
|
||||
window: host,
|
||||
Worker,
|
||||
Blob,
|
||||
URL: { createObjectURL: () => 'blob:controlled-worker', revokeObjectURL() {} },
|
||||
atob: value => Buffer.from(value, 'base64').toString('binary'),
|
||||
setTimeout(callback) {
|
||||
timers.set(timers.size + 1, callback)
|
||||
return timers.size
|
||||
},
|
||||
clearTimeout(id) { timers.delete(id) },
|
||||
})
|
||||
const body = new Element()
|
||||
loaded.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: core.validator },
|
||||
template: { $danmuku: new Element(), $player: new Element(), $controlsCenter: new Element() },
|
||||
currentTime: 10,
|
||||
width: 640,
|
||||
playbackRate: 1,
|
||||
playing: false,
|
||||
plugins: {},
|
||||
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 { ...loaded, art, workers, events, listeners, frames, timers, proxies, external: body }
|
||||
}
|
||||
|
||||
const implementations = [
|
||||
{ name: 'frozen-source', code: frozenSource.outputFiles[0].text },
|
||||
...['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() })),
|
||||
]
|
||||
|
||||
for (const implementation of implementations) {
|
||||
test(`danmuku ${implementation.name}: synchronous facade, defaults, descriptors and actual method return identity`, async () => {
|
||||
const env = environment(implementation.code)
|
||||
const factory = env.factory({ danmuku: [] })
|
||||
const result = factory(env.art)
|
||||
assert.equal(typeof result.then, 'undefined')
|
||||
assert.deepEqual(Object.keys(result), publicKeys)
|
||||
assert.deepEqual(Object.keys(result.option), optionKeys)
|
||||
for (const key of ['option', 'isHide', 'isStop']) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(result, key)
|
||||
assert.equal(typeof descriptor.get, 'function')
|
||||
assert.equal(descriptor.set, undefined)
|
||||
assert.equal(descriptor.enumerable, true)
|
||||
}
|
||||
assert.equal(result.option.speed, 5)
|
||||
assert.deepEqual(Array.from(result.option.margin), [10, '25%'])
|
||||
assert.equal(result.option.mount, env.art.template.$controlsCenter)
|
||||
assert.equal(result.isStop, false)
|
||||
const internal = result.hide()
|
||||
assert.notEqual(internal, result, 'Historical methods return the internal Danmuku, not the public facade')
|
||||
assert.equal(internal.name, undefined)
|
||||
assert.equal(result.isHide, true)
|
||||
assert.equal(result.show(), internal)
|
||||
assert.equal(result.reset(), internal)
|
||||
assert.equal(result.config({ opacity: 0.6 }), internal)
|
||||
assert.equal(result.option.opacity, 0.6)
|
||||
const emitted = result.emit({ id: 'row', text: 'hello', time: 15 })
|
||||
assert.equal(typeof emitted.then, 'function', 'The old declaration incorrectly promises a synchronous Result')
|
||||
assert.equal(await emitted, internal)
|
||||
assert.equal(await result.load([{ text: 'appended', time: 16 }]), internal)
|
||||
assert.equal(internal.queue.length, 2)
|
||||
assert.equal(internal.queue[0].id, 'row')
|
||||
assert.equal(result.mount('#external'), undefined)
|
||||
assert.equal(env.external.children.length, 1)
|
||||
assert.throws(() => result.mount(), /Can not find the mount point/u)
|
||||
env.art.emit('destroy')
|
||||
assert(env.workers.every(worker => worker.terminated))
|
||||
})
|
||||
|
||||
test(`danmuku ${implementation.name}: load replacement/append, filters, input mutation and event argument identity`, async () => {
|
||||
const env = environment(implementation.code)
|
||||
let beforeEmit = 0
|
||||
let filterReceiver
|
||||
const result = env.factory({
|
||||
danmuku: [],
|
||||
beforeEmit() {
|
||||
beforeEmit++
|
||||
return true
|
||||
},
|
||||
filter(danmu) {
|
||||
filterReceiver = this
|
||||
return danmu.text !== 'filtered'
|
||||
},
|
||||
})(env.art)
|
||||
const initial = await result.load()
|
||||
const input = { text: 'zero', time: 0 }
|
||||
await result.emit(input)
|
||||
assert.equal(input.time, 10.5, 'Historical time:0 uses currentTime + 0.5')
|
||||
assert.equal(input.mode, 0)
|
||||
assert.equal(input.color, '#FFFFFF')
|
||||
assert.equal(filterReceiver, result.option)
|
||||
assert.equal(beforeEmit, 0, 'Public emit bypasses the setting beforeEmit callback')
|
||||
const originalFilter = result.option.filter
|
||||
result.config({ filter: () => false })
|
||||
assert.equal(result.option.filter, originalFilter, 'Historical JSON comparison ignores a function-only config change')
|
||||
await result.load([{ text: 'filtered' }, { text: 'two', time: 12 }])
|
||||
assert.equal(initial.queue.length, 2)
|
||||
const loaded = env.events.filter(event => event.name === 'artplayerPluginDanmuku:loaded').at(-1)
|
||||
assert.equal(loaded.args[0], initial.queue)
|
||||
await result.load()
|
||||
assert.equal(initial.queue.length, 0)
|
||||
await result.load(() => Promise.resolve([{ text: 'function' }]))
|
||||
assert.equal(initial.queue.length, 1)
|
||||
const promisedInput = vm.runInContext('Promise.resolve([{text:"promise"}])', env.context)
|
||||
await result.load(promisedInput)
|
||||
assert.equal(initial.queue.length, 2)
|
||||
const failure = new Error('controlled input failure')
|
||||
await assert.rejects(result.load(() => Promise.reject(failure)), error => error === failure)
|
||||
assert.equal(env.events.at(-1).name, 'artplayerPluginDanmuku:error')
|
||||
assert.equal(env.events.at(-1).args[0], failure)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test(`danmuku ${implementation.name}: initial Promise validation and resize cleanup historical discrepancies`, async () => {
|
||||
const env = environment(implementation.code)
|
||||
const promise = vm.runInContext('Promise.resolve([])', env.context)
|
||||
assert.throws(() => env.factory({ danmuku: promise })(env.art), /danmuku/iu)
|
||||
const result = env.factory({ danmuku: [] })(env.art)
|
||||
await result.load()
|
||||
const before = env.listeners.get('resize').length
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.listeners.get('resize').length, before, 'Historical destroy removes this.reset instead of registered this.resize; Setting listener also remains')
|
||||
})
|
||||
|
||||
test(`danmuku ${implementation.name}: synchronous initial events, video hooks and settings beforeEmit path`, async () => {
|
||||
const env = environment(implementation.code)
|
||||
const seen = []
|
||||
const result = env.factory({
|
||||
danmuku: [],
|
||||
beforeEmit(danmu) {
|
||||
seen.push({ danmu: { ...danmu }, receiver: this })
|
||||
return Promise.resolve(true)
|
||||
},
|
||||
})(env.art)
|
||||
assert.deepEqual(env.events.map(event => event.name), ['artplayerPluginDanmuku:show', 'artplayerPluginDanmuku:config', 'artplayerPluginDanmuku:reset', 'artplayerPluginDanmuku:loaded'])
|
||||
env.art.emit('video:play')
|
||||
assert.equal(result.isStop, false)
|
||||
env.art.emit('video:pause')
|
||||
assert.equal(result.isStop, true)
|
||||
const root = env.art.template.$controlsCenter.children[0]
|
||||
const input = root.querySelector('.apd-input')
|
||||
const send = root.querySelector('.apd-send')
|
||||
input.value = ' via settings '
|
||||
await env.proxies.find(proxy => proxy.target === send && proxy.name === 'click').callback()
|
||||
assert.equal(seen.length, 1)
|
||||
assert.equal(seen[0].receiver, result.option)
|
||||
assert.equal(seen[0].danmu.text, 'via settings')
|
||||
assert.equal(seen[0].danmu.time, 10)
|
||||
const internal = result.show()
|
||||
assert.equal(internal.queue[0].border, true)
|
||||
assert.equal(internal.queue[0].time, 10.5)
|
||||
assert.equal(input.value, '')
|
||||
assert.equal(env.timers.size, 1)
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.events.at(-1).name, 'artplayerPluginDanmuku:destroy')
|
||||
assert.equal(env.timers.size, 1, 'Historical settings lock timer is not cleared by destroy')
|
||||
})
|
||||
}
|
||||
|
||||
test('danmuku frozen Bilibili parser: mapped modes, entities, malformed attributes and URL fallback', async () => {
|
||||
const source = sources.get(`${prefix}src/bilibili.js`).replace('export function bilibiliDanmuParseFromUrl', 'function bilibiliDanmuParseFromUrl')
|
||||
const requests = []
|
||||
const errors = []
|
||||
const xml = '<i><d p="1,1,25,255,100,0,user,9"> <hi> & "x" </d><d p="2,4,24,16777215,101,1,u,10">bottom</d><d p="3,5,23,0,102,0,u,11">top</d><d p="4,7,22,10,103,0,u,12">special</d><d p="1,2">invalid</d></i>'
|
||||
const context = vm.createContext({
|
||||
fetch: async (url) => {
|
||||
requests.push(url)
|
||||
return { text: async () => xml }
|
||||
},
|
||||
console: { error: (...args) => errors.push(args) },
|
||||
})
|
||||
vm.runInContext(`${source}\nglobalThis.parse=bilibiliDanmuParseFromXml;globalThis.load=bilibiliDanmuParseFromUrl`, context)
|
||||
const data = await context.load('/fixture.xml')
|
||||
assert.deepEqual(requests, ['/fixture.xml'])
|
||||
assert.deepEqual(Array.from(data, item => item.mode), [0, 2, 1, 0])
|
||||
assert.equal(data[0].text, '<hi> & "x"')
|
||||
assert.equal(data[0].color, '#ff', 'Historical parser does not left-pad RGB')
|
||||
assert.deepEqual(Object.keys(data[0]), ['text', 'time', 'mode', 'fontSize', 'color', 'timestamp', 'pool', 'userID', 'rowID'])
|
||||
assert.equal(context.parse(null).length, 0)
|
||||
assert.equal(context.parse('<d p="1,2">x</d>').length, 0)
|
||||
assert.equal(errors.length, 1, 'Unavailable Worker falls back to the real parser')
|
||||
})
|
||||
+8
-3
@@ -3252,11 +3252,16 @@
|
||||
"dependsOn": [
|
||||
"BASE-05"
|
||||
],
|
||||
"status": "doing",
|
||||
"status": "done",
|
||||
"risk": "H",
|
||||
"deliverable": "emit/load/config/hide/show/reset/mount、option/isHide/isStop/icons 与事件清单",
|
||||
"acceptance": "源码、声明、发布包、Bilibili 输入和设置入口对照",
|
||||
"evidence": []
|
||||
"evidence": [
|
||||
"baselines/danmuku-release.json",
|
||||
"baselines/danmuku-contract.md",
|
||||
"baselines/danmuku-contract-validation.json",
|
||||
"changes/2026-09-13-PKG-DANMUKU-01-contracts.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "PKG-DANMUKU-02",
|
||||
@@ -3270,7 +3275,7 @@
|
||||
"ENG-05",
|
||||
"ENG-08"
|
||||
],
|
||||
"status": "todo",
|
||||
"status": "doing",
|
||||
"risk": "H",
|
||||
"deliverable": "密集/稀疏弹幕、过滤/异步输入、seek/倍率/重载测试",
|
||||
"acceptance": "轨道选择、发射顺序、事件、内存和可视结果可重跑",
|
||||
|
||||
Reference in new issue
Block a user