fix(react): [EX-01] verify installed consumers and clean up callback failures

This commit is contained in:
Harvey Zhao committed 2026-09-14 05:17:51 +08:00
1 parent 3e39fc745f
commit e0d785662d
25 files changed
+3539 -98

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,95 @@
# EX-01 React 消费者、异常清理与实际打包验收
起点 `3e39fc745f4dc98eba678ca081e86c1d3cc965cd`。本任务对应 React 示例,
不将其他插件的组合、设备和分发任务一并标为完成。
## 已复现问题和实现
1. `index.html` 仍引用不存在的 `/src/main.jsx`,实际入口为 `main.tsx`。
改为实际入口,原 App 经 Vite 构建和三引擎实际加载媒体通过。
2. 原 Player effect 在调用 `getInstance` 后才返回清理函数。消费者回调抛错时,
React 无法取得该清理函数,已创建实例残留。最终 `--before` 控制使用原始
Player 源码及相同候选 tarball,在 Chromium 开发模式复现:错误边界已经
显示,注册表仍有 1 个播放器。修复在重新抛出原错误前调用 `destroy(false)`;
即使清理也抛错,保留最初的消费者异常。六组最终回归均无实例/DOM 残留。
3. 原 TSX 示例没有 tsconfig,局部 ESLint 配置仅覆盖 JS/JSX。补充严格 TSX
配置,复用仓库的 TS lint 配置,修复实际源码中的类型导入和配置格式。
文件职责:`Player.tsx` 负责 React effect、引用交付和资源归属;
`player-options.ts` 隔离示例特定配置和插件工厂;`App.tsx` 保持调用样例;
`main.tsx` 保持 StrictMode;README 说明真实维护入口、行为和验证边界。
没有为简单包装增加通用生命周期框架。
## 兼容性
- 保留 `option: Partial<Option>`、可选 `getInstance(art)` 和 div 属性。
- 保留 `[option, getInstance]` 依赖:同一引用不重建,任一引用改变则先清理后重建。
- 保留 callback 在构造之后同步交付实例,不改为等待 ready,不新增 null 回调。
- 保留 `destroy(false)`、React 外层容器归属及现有法语/词典/两个插件覆盖规则。
这些是示例原有行为;没有借通用化而静默改变用户传入 plugins/lang 的优先级。
- `url as string` 仅适配历史 Partial 输入,继续由核心验证无效/缺失 URL;
不把可编译的 `option={{}}` 宣称为可成功运行。
- 新导出的 PlayerProps 是补充类型入口;核心/插件公开声明和运行产物未修改。
React 的 [effect 文档](https://react.dev/reference/react/useEffect) 说明开发
StrictMode 的额外 setup/cleanup。真实受测 React 19.1.1 的开发模式抛错路径
交付两个相同原始错误,生产模式一个,逐模式精确断言;每个创建实例只销毁一次。
初版测试错误地统一要求一个异常,已纠正且保留失败报告,未放松清理断言。
## 工具、测试与 CI
根 devDependencies 新增固定 React/React DOM 19.1.1、@types/react 19.1.10、
@types/react-dom 19.1.7、@vitejs/plugin-react 5.0.0;复用 Vite 7.3.6、TS 5.9.3。
用于 TSX 示例、React 模式行为和 Vite 构建,不增加 Artplayer 生产依赖。
只更新根 yarn.lock;示例 manifest 同步已验证版本和实际使用的开发工具。
新增根命令 `dev:react`、`typecheck:react`、`lint:react`、`build:react`、
`test:react-consumer`。后者由 `refactor/scripts/react-consumer.mjs` 编排:
1. Yarn pack 核心/Danmuku/Document PiP,记录 SHA256;仓库外安装,再 frozen、
offline、force 安装并逐文件对照 tarball;缓存中的消费者锁是测试证据。
2. 复制真实示例及 `test/react/` 夹具,严格编译全部示例、真实浏览器 TSX 夹具和三个 @ts-expect-error
反例。检查所有声明只来自隔离消费者和 TS 标准库,禁止工作区类型逃逸。
初版 compiler host 使用仓库 cwd 导致 @types/react 逃逸,被验证器正确拒绝;
已将 host 的 currentDirectory 设为隔离消费者,保留此隔离检查。
3. Vite 构建 development/production,实际使用安装后的包,不 alias 生产源码。
4. 三引擎串行执行,每组 12 类检查:StrictMode/插件注册、稳定引用、回调变化、
option 变化、真实 play/pause/seek/非黑像素、兄弟实例身份保持、可选回调、
卸载清理、重挂载、回调异常、清理次生异常、原 App 媒体加载。
5. `--before` 使用固定 Git 版本中的原 Player,预期在回调异常清理处失败。
在现有 GitHub 三操作系统 browser-smoke 中加入此命令和 always 上传的证据目录;
ci:check 加入 React 类型/lint。这里已验证本地配置和 47 项 CI 回归,未执行远端
GitHub jobs,不能称远端三操作系统已通过。
## 验证结果和限制
最终证据见 [react-consumer-validation.json](../baselines/react-consumer-validation.json)。
Windows、Node 24.21.0、Yarn 1.22.22;Chromium 153.0.8010.12、Firefox 155.0、
WebKit 26.6:六组全部通过,无 retry/skip。旧包装精确复现 1 个残留实例。
严格隔离 TS 5.9.3 无诊断,三个无效输入被拒绝;全仓 typecheck 的 406 个生产
TS 文件和历史/准确类型矩阵通过。示例构建、lint、frozen 安装、严格工具链通过。
开发 React bundle 的 >500kB Vite 提示保留,没有提高阈值;生产共享 chunk
约 477.53kB,整体包体复盘仍属 MOD-03/REVIEW-01。
前期失败报告保留:`react-consumer-2B2QCR` 是类型逃逸,`dmu7pS` 是开发异常
计数断言错误;最终候选 `x9KPB2`,最终旧源码对照 `cgxKhm`。
这是原包装 API 在实际候选包中的消费验收,不是所有旧核心/旧插件组合验收。
受控 MP4/XML 不证明公网 CDN;插件注册不证明 PiP 真窗口或完整弹幕调度。
Playwright Windows WebKit 不等于 macOS/iOS Safari,未声称连接 Chrome 或真机。
这些包专项和发布复盘门槛继续保留。
## 回退
回退 EX-01 提交可恢复包装/HTML/工具配置及根锁;也会恢复已复现的回调泄漏和
入口错误。无生产包版本/产物变更,无推送、发布。候选生产内容改变后重跑此消费
脚本;不要将本次 tarball 哈希当成未来 npm 候选的免检依据。
## 扩大检查发现的独立 lint 问题
本任务受影响的 TSX/脚本 lint 通过,但最后全仓 yarn lint 被未改动的
packages/artplayer-plugin-danmuku-mask/package.json 字段排序阻断;已逐字对照
HEAD 确认该 manifest 不属于本任务改动。另有旧编辑器声明的 unused-disable
警告。全仓 lint 未计为通过;新增 ENG-LINT-01 独立修复,仍接入最终完成门槛。
日志 refactor/.cache/react-root-lint.log。
+12 -5
View File
@@ -2,9 +2,9 @@
> 由 tasks.json 生成。请修改数据后运行 `node refactor/scripts/plan.mjs --write`,不要手改本表。
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 237 项,范围 22 个包及工作区/示例。
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 238 项,范围 22 个包及工作区/示例。
状态:todo 62 / doing 15 / blocked 0 / done 160 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 62 / doing 15 / blocked 0 / done 161 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -23,7 +23,7 @@
| artplayer-plugin-chapter | 1.1.0 | PILOT-01, PKG-CHAPTER-01, PKG-CHAPTER-02, PKG-CHAPTER-03, PKG-CHAPTER-04, PKG-CHAPTER-05, PKG-CHAPTER-06 |
| artplayer-plugin-chromecast | 1.1.0 | PKG-CAST-01, PKG-CAST-02, PKG-CAST-03, PKG-CAST-04, PKG-CAST-05, PKG-CAST-06 |
| artplayer-plugin-danmuku | 5.3.0 | PKG-DANMUKU-01, PKG-DANMUKU-02, PKG-DANMUKU-03, PKG-DANMUKU-04, PKG-DANMUKU-05, PKG-DANMUKU-06, PKG-DANMUKU-07, PKG-DANMUKU-08, PKG-DANMUKU-09, PKG-DANMUKU-10, PKG-DANMUKU-11, PKG-DANMUKU-12 |
| artplayer-plugin-danmuku-mask | 1.1.0 | PKG-MASK-01, PKG-MASK-02, PKG-MASK-03, PKG-MASK-04, PKG-MASK-05, PKG-MASK-06 |
| artplayer-plugin-danmuku-mask | 1.1.0 | PKG-MASK-01, PKG-MASK-02, PKG-MASK-03, PKG-MASK-04, PKG-MASK-05, PKG-MASK-06, ENG-LINT-01 |
| artplayer-plugin-dash-control | 1.1.0 | PKG-DASH-01, PKG-DASH-02, PKG-DASH-03, PKG-DASH-04, PKG-DASH-05, PKG-DASH-06 |
| artplayer-plugin-document-pip | 1.1.0 | PKG-DPIP-01, PKG-DPIP-02, PKG-DPIP-03, PKG-DPIP-04, PKG-DPIP-05, PKG-DPIP-06 |
| artplayer-plugin-hls-control | 1.1.0 | PKG-HLS-01, PKG-HLS-02, PKG-HLS-03, PKG-HLS-04, PKG-HLS-SDK-01, PKG-HLS-05, PKG-HLS-06 |
@@ -386,7 +386,7 @@
| SITE-04 | artplayer-vitepress<br>交叉核对逐包持续维护的文档 | CORE-21, SITE-03, PKG-CHAPTER-04, PKG-AMBILIGHT-04, PKG-AUDIO-04, PKG-AUTO-THUMB-04, PKG-VTT-THUMB-04, PKG-HLS-04, PKG-DASH-04, PKG-MULTI-SUB-04, PKG-JASSUB-04, PKG-MASK-04, PKG-ASR-04, PKG-ADS-04, PKG-VAST-04, PKG-CAST-04, PKG-DPIP-04, PKG-CANVAS-04, PKG-IFRAME-04, PKG-TOOL-THUMB-04, PKG-DANMUKU-06, PKG-MB-08 | 已随实现更新的中文/英文 API、包内实现地图、旧 JS 示例及已知能力限制的全包核对 | 未把缺环境的能力写成已验证,静态核对不等待设备任务;最终 demo 仍由 EX-03 验收 | M | todo |
| SITE-05 | artplayer-vitepress<br>构建文档站和验证链接/示例 | SITE-04, EX-01, EX-02 | VitePress 构建、链接与嵌入 demo 检查 | 文档构建、链接、嵌入路径与声明注入通过;真实完整 demo 保留 EX-03 独立门槛;核对 ENG-PM-01 登记的搜索 peer 范围和真实搜索行为 | M | todo |
| SITE-06 | artplayer-vitepress<br>文档站交付验收 | SITE-05 | 维护指南和站点变更记录 | 未手改 generated 目录,旧 URL 可用、部署与检查分离 | M | todo |
| EX-01 | example/react.js<br>验证 React 消费者与 TS | CORE-22, ENG-07 | React 挂载/卸载/重挂载、引用和插件样例 | 真实 tarball + TS 消费通过,保留既有 React 集成 API | M | todo |
| EX-01 | example/react.js, workspace<br>验证 React 消费者与 TS | CORE-22, ENG-07 | React 挂载/卸载/重挂载、引用和插件样例 | 真实 tarball + TS 消费通过,保留既有 React 集成 API | M | done |
| EX-02 | example/vue.js<br>验证 Vue 消费者与更新卸载 | CORE-22, ENG-07 | Vue 实例/ref、参数更新、卸载及插件样例 | 旧 JS 组件用法无需修改,重复挂载不泄漏 | M | todo |
| EX-03 | workspace<br>验证全部原生 demo 与外部播放集成 | EX-01, EX-02, PKG-CHAPTER-06, PKG-AMBILIGHT-06, PKG-AUDIO-06, PKG-AUTO-THUMB-06, PKG-VTT-THUMB-06, PKG-HLS-06, PKG-DASH-06, PKG-MULTI-SUB-06, PKG-JASSUB-06, PKG-MASK-06, PKG-ASR-06, PKG-ADS-06, PKG-VAST-06, PKG-CAST-06, PKG-DPIP-06, PKG-DANMUKU-09, PKG-CANVAS-06, PKG-MB-10, PKG-IFRAME-06, PKG-TOOL-THUMB-06 | 8082 全 demo、HLS/DASH/FLV/MPEGTS/WebTorrent 集成记录 | 旧 URL/参数/脚本加载保持;网络/SDK 限制明确,不静默跳过;覆盖编辑器重复 Run 和 ESM/i18n/mobile/iframe,记录实际候选脚本及状态隔离证据;依据 BASE-04 路径台账逐项消除 not-run,处理 BASE-DEMO-01 | H | todo |
@@ -412,7 +412,7 @@
| REL-04 | workspace<br>提前演练回退与主线修复同步 | REL-08, ENG-07 | 分包旧版本/tag/依赖回退方案与 master 差异 | 隔离包演练可回退,主线修复同步流程可执行;正式每批再核对其实际回退产物 | H | todo |
| REL-05 | workspace<br>经授权发布候选并收集反馈 | REL-03, REL-04, REVIEW-03 | 候选 tag、完整产物 integrity、反馈与复现记录 | 实际发布授权/操作/版本可追溯;本计划不自动执行发布 | H | todo |
| REL-06 | workspace<br>经授权分批正式发布 | REL-05 | 正式分包版本/tag/站点文档及兼容公告 | 候选验收通过,安装/浏览器复核和回退入口就绪;候选反馈修复须独立建任务并复验三轮受影响结论,正式内容与最终验证产物一致 | H | todo |
| REL-07 | workspace<br>关闭重构里程碑并维护后续队列 | REL-06 | 最终任务/设计/证据归档和维护指南 | 全范围任务有结论、无未解释兼容缺口;遗留项有明确后续责任 | L | todo |
| REL-07 | workspace<br>关闭重构里程碑并维护后续队列 | REL-06, ENG-LINT-01 | 最终任务/设计/证据归档和维护指南 | 全范围任务有结论、无未解释兼容缺口;遗留项有明确后续责任 | L | todo |
## 8.1 多轮复盘
@@ -434,6 +434,12 @@
| --- | --- | --- | --- | --- | --- | --- |
| ENG-12 | workspace, artplayer<br>隔离库构建的 public 源码与分发内容 | ENG-03, CORE-21 | 禁用库的隐式 public 复制,拒绝误入包内的 TS 源码,清理生成污染并验证真实构建 | 三格式真实构建和严格 tarball 检查通过;已有声明和所有剩余成员字节不变;文档站使用独立构建 | M | done |
## 2 工程基础
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| ENG-LINT-01 | artplayer-plugin-danmuku-mask, workspace<br>修复 Mask manifest 排序并恢复全仓 lint | PKG-MASK-04 | 仅调整 manifest 字段顺序,独立验证全仓只读 lint 与元数据语义不变 | 根 lint 通过;解析后的 manifest 深度相等;不改包版本、依赖或公开入口 | L | todo |
## 完成证据与阻塞
- DOC-01: [记录](progress.md)
@@ -598,6 +604,7 @@
- PKG-TOOL-THUMB-02: [记录](baselines/thumbnail-behavior-validation.json) [记录](changes/2026-09-13-PKG-TOOL-THUMB-02-behavior.md)
- PKG-TOOL-THUMB-03: [记录](changes/2026-09-13-PKG-TOOL-THUMB-03-input-checkpoint.md) [记录](baselines/thumbnail-input-checkpoint.json) [记录](changes/2026-09-13-PKG-TOOL-THUMB-03-lifecycle.md) [记录](baselines/thumbnail-lifecycle-validation.json)
- PKG-TOOL-THUMB-04: [记录](changes/2026-09-13-PKG-TOOL-THUMB-04-runtime-types.md) [记录](baselines/thumbnail-runtime-types-validation.json) [记录](changes/2026-09-13-PKG-TOOL-THUMB-04-public-types.md) [记录](baselines/thumbnail-public-types-validation.json) [记录](changes/2026-09-13-PKG-TOOL-THUMB-04-emitter.md) [记录](baselines/thumbnail-emitter-validation.json)
- EX-01: [记录](changes/2026-09-14-EX-01-react-consumer.md) [记录](baselines/react-consumer-validation.json) [记录](scripts/react-consumer.mjs)
- PKG-FACTORY-01: [记录](baselines/factory-assignment-gaps.json) [记录](baselines/factory-compatibility-proposals.json) [记录](factory-compatibility-decision.md) [记录](changes/2026-09-12-PKG-FACTORY-01-decision.md) [记录](type-compatibility-policy.md) [记录](baselines/factory-compatibility-validation.json) [记录](changes/2026-09-13-PKG-FACTORY-01-compatible-types.md)
- CORE-25: [记录](changes/2026-09-13-CORE-25-defaults-ssr.md) [记录](baselines/defaults-ssr-validation.json)
- ENG-12: [记录](changes/2026-09-13-ENG-12-library-public.md) [记录](baselines/library-public-validation.json)
+15
View File
@@ -1,5 +1,20 @@
# 进度与证据
## EX-01 React 实际消费者完成
修复 React HTML 错误入口,以及 getInstance 抛错时实例泄漏;保留 Partial<Option>、
可选回调、div 属性、引用变化重建和示例插件/语言覆盖规则。拆出示例配置模块,
补齐严格 TSX、维护说明和根 Yarn 命令。实际打包安装核心/Danmuku/Document PiP,
逐文件对照并 frozen 重装;类型不允许从工作区补齐。最终开发/生产 × 三引擎六组
各 12 类检查通过,包括真实播放/暂停/seek/像素、兄弟实例、卸载重挂载和异常
清理。固定旧包装控制复现 1 个残留实例。全仓 406 个生产 TS 文件检查、示例
构建/lint、严格工具链和 frozen 安装通过;47 项 CI 回归通过。GitHub 增加消费
检查和失败证据上传,远端运行未验证。见[变更](changes/2026-09-14-EX-01-react-consumer.md)
和[证据](baselines/react-consumer-validation.json)。另有全仓 lint 被未改动 Mask manifest 排序阻断,已登记独立 ENG-LINT-01。
任务现为 238 项:161 done、15 doing、62 todo;未关闭插件专项任务。
下一步推进已就绪的 Vue 消费者 EX-02;Auto Thumbnail 首帧与 VAST 兼容选择仍未关闭。
## PKG-AUTO-THUMB-03 渲染/等待诊断检查点(仍 doing)
新增可重跑的 `yarn probe:auto-thumbnail-rendering`,用固定实际 ESM/时间轴媒体
+2
View File
@@ -253,3 +253,5 @@
| JASSUB-HYBRID-01 | open / 已复现 | Late hybrid frames mutate the new offscreen canvas or throw; terminal track calls recreate transferred canvases | PKG-JASSUB-09, PKG-JASSUB-05 |
| JASSUB-FIREFOX-OFFSCREEN-01 | open / 已复现 | Windows Firefox offscreen readback and asynchronous ImageBitmap creation can stall through seek/layout, including a minimal Worker control | PKG-JASSUB-09, PKG-JASSUB-05 |
| AUTO-THUMB-CANVAS-01 | resolved / 已复现 | Private thumbnail canvas retains sheet dimensions after completion or cancellation while callbacks retain the element | PKG-AUTO-THUMB-07 |
| EX-REACT-LIFE-01 | resolved / 已复现 | React getInstance exception leaks the constructed player | EX-01 |
| EX-REACT-ENTRY-01 | resolved / 源码/产物事实 | React HTML references absent main.jsx instead of the existing TSX entry | EX-01 |
+42
View File
@@ -5694,6 +5694,48 @@
"test/browser/auto-thumbnail-lifecycle.spec.js"
],
"resolutionRationale": "Six controlled regressions fail before and pass after. The frozen main native complete control fails dimensions; candidate main/legacy native lifecycle checks reset the retained canvas and decode final JPEGs after reset. No public options, types, sample positions or caller URLs changed; WebKit early-frame pixels remain separately open."
},
{
"id": "EX-REACT-LIFE-01",
"title": "React getInstance exception leaks the constructed player",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"EX-01"
],
"evidence": [
"refactor/changes/2026-09-14-EX-01-react-consumer.md",
"refactor/baselines/react-consumer-validation.json"
],
"resolutionEvidence": [
"refactor/changes/2026-09-14-EX-01-react-consumer.md",
"refactor/baselines/react-consumer-validation.json",
"refactor/scripts/react-consumer.mjs"
],
"compatibleResolution": "The old wrapper leaves one active instance after the React error boundary; the candidate cleans each instance exactly once and propagates the original error in six real browser profiles.",
"closureCriteria": "Actual installed consumer and original App entry pass without changing the wrapper props, default configuration or effect dependency contract.",
"resolutionRationale": "The old wrapper leaves one active instance after the React error boundary; the candidate cleans each instance exactly once and propagates the original error in six real browser profiles."
},
{
"id": "EX-REACT-ENTRY-01",
"title": "React HTML references absent main.jsx instead of the existing TSX entry",
"confirmation": "source-observed",
"status": "resolved",
"owners": [
"EX-01"
],
"evidence": [
"refactor/changes/2026-09-14-EX-01-react-consumer.md",
"refactor/baselines/react-consumer-validation.json"
],
"resolutionEvidence": [
"refactor/changes/2026-09-14-EX-01-react-consumer.md",
"refactor/baselines/react-consumer-validation.json",
"refactor/scripts/react-consumer.mjs"
],
"compatibleResolution": "The HTML now loads main.tsx; the unchanged App call builds and loads controlled media in all six installed consumer browser profiles.",
"closureCriteria": "Actual installed consumer and original App entry pass without changing the wrapper props, default configuration or effect dependency contract.",
"resolutionRationale": "The HTML now loads main.tsx; the unchanged App call builds and loads controlled media in all six installed consumer browser profiles."
}
]
}
+3
View File
@@ -84,6 +84,9 @@ export function validateCIWorkflow(source) {
consumerIndex = index + 1
}
assert(browser.steps.findIndex(step => step.run?.startsWith('yarn test:browser ')) > consumerIndex, 'Browser tools must run after canonical Node restoration')
const reactIndex = browser.steps.findIndex(step => step.run === 'yarn test:react-consumer 2>&1 | tee refactor/.cache/ci/react-consumer.log')
assert(reactIndex > consumerIndex && !Object.hasOwn(browser.steps[reactIndex], 'if'), 'Run React installed consumers after restoring canonical Node')
assert(browser.steps.some(step => step.uses?.startsWith('actions/upload-artifact@') && step.if === 'always()' && step.with.path.split('\n').includes('refactor/.cache/react-consumer-*/')), 'Retain React consumer failure evidence')
const pages = workflow.jobs.checks.steps.find(step => step.uses?.startsWith('actions/upload-pages-artifact@'))
assert.equal(pages?.if, 'inputs.pages-artifact && github.ref == \'refs/heads/master\' && matrix.os == \'ubuntu-latest\'', 'Only one trusted matrix leg can prepare Pages')
return { jobs: requiredJobs, systems, summary: summary.name }
+3
View File
@@ -34,6 +34,9 @@ for (const [name, mutate] of [
['wrong consumer Node version', w => w.jobs['browser-smoke'].steps.find(s => s.id === 'consumer-node-20').with['node-version'] = '24'],
['skipped installed consumer', w => w.jobs['browser-smoke'].steps.find(s => s.run?.includes('--expected-node 22.12.0')).if = 'false'],
['browser runtime not restored', w => w.jobs['browser-smoke'].steps.find(s => s.id === 'restore-canonical-node').with = { 'node-version': '22.12.0', 'package-manager-cache': false }],
['skipped React consumer', w => w.jobs['browser-smoke'].steps.find(s => s.run?.startsWith('yarn test:react-consumer')).if = 'false'],
['removed React consumer', w => w.jobs['browser-smoke'].steps = w.jobs['browser-smoke'].steps.filter(s => !s.run?.startsWith('yarn test:react-consumer'))],
['missing React evidence', w => w.jobs['browser-smoke'].steps.find(s => s.uses?.startsWith('actions/upload-artifact@')).with.path = 'refactor/.cache/ci/'],
['Pages uploaded by both matrix legs', w => w.jobs.checks.steps.find(s => s.uses?.startsWith('actions/upload-pages-artifact@')).if = 'inputs.pages-artifact && github.ref == \'refs/heads/master\''],
]) {
test(`CI rejects ${name}`, () => {
+206
View File
@@ -0,0 +1,206 @@
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 { chromium, firefox, webkit } from '@playwright/test'
import react from '@vitejs/plugin-react'
import ts from 'typescript'
import { build, preview } from 'vite'
import { packedFiles } from '../../scripts/package-check.mjs'
import { consumerDirectory, removeConsumer, run, workspace, writeJson } from '../../scripts/package-consumer.mjs'
import { hash, readMember } from './releases.mjs'
assert.equal(process.version.slice(1), fs.readFileSync(path.join(workspace, '.node-version'), 'utf8').trim())
assert.equal(process.env.npm_config_user_agent?.split(' ')[0], 'yarn/1.22.22')
const yarn = process.env.npm_execpath
assert(yarn && fs.existsSync(yarn))
const before = process.argv.includes('--before')
const output = fs.mkdtempSync(path.join(workspace, 'refactor/.cache/react-consumer-'))
const consumer = consumerDirectory()
const report = { task: 'EX-01', before, node: process.version, source: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: workspace, encoding: 'utf8' }).trim(), packages: [], browsers: [], passed: false }
console.log(`React consumer evidence: ${output}`)
try {
const dependencies = {}
for (const name of ['artplayer', 'artplayer-plugin-danmuku', 'artplayer-plugin-document-pip']) {
const archive = path.join(output, `${name}.tgz`)
fs.writeFileSync(path.join(output, `${name}-pack.log`), run([yarn, 'pack', '--filename', archive], path.join(workspace, 'packages', name)))
dependencies[name] = `file:${archive.replaceAll('\\', '/')}`
report.packages.push({ name, archive, sha256: hash(fs.readFileSync(archive)) })
}
const rootManifest = JSON.parse(fs.readFileSync(path.join(workspace, 'package.json'), 'utf8'))
for (const name of ['react', 'react-dom', '@types/react', '@types/react-dom']) dependencies[name] = rootManifest.devDependencies[name]
writeJson(path.join(consumer, 'package.json'), { name: 'react-artplayer-isolated-consumer', private: true, type: 'module', dependencies })
fs.writeFileSync(path.join(output, 'install.log'), run([yarn, 'install', '--offline', '--ignore-scripts', '--non-interactive'], consumer))
const lock = fs.readFileSync(path.join(consumer, 'yarn.lock'))
fs.writeFileSync(path.join(output, 'consumer-yarn.lock'), lock)
fs.writeFileSync(path.join(output, 'frozen.log'), run([yarn, 'install', '--offline', '--frozen-lockfile', '--force', '--ignore-scripts', '--non-interactive'], consumer))
assert.deepEqual(fs.readFileSync(path.join(consumer, 'yarn.lock')), lock)
for (const pkg of report.packages) {
for (const member of packedFiles(pkg.archive)) {
assert.deepEqual(fs.readFileSync(path.join(consumer, 'node_modules', pkg.name, member.replace(/^package\//, ''))), readMember(pkg.archive, member))
}
}
const example = path.join(workspace, 'example/react.js')
fs.cpSync(path.join(example, 'src'), path.join(consumer, 'src'), { recursive: true })
fs.copyFileSync(path.join(example, 'index.html'), path.join(consumer, 'index.html'))
fs.copyFileSync(path.join(example, 'tsconfig.json'), path.join(consumer, 'tsconfig.json'))
if (before) {
const old = execFileSync('git', ['show', '3e39fc745f4dc98eba678ca081e86c1d3cc965cd:example/react.js/src/Player.tsx'], { cwd: workspace })
fs.writeFileSync(path.join(consumer, 'src/Player.tsx'), old)
report.beforeWrapper = hash(old)
}
for (const file of ['lifecycle.tsx', 'consumer.tsx']) fs.copyFileSync(path.join(workspace, 'test/react', file), path.join(consumer, file))
fs.writeFileSync(path.join(consumer, 'harness.html'), '<!doctype html><html><body><div id="root"></div><script type="module" src="/lifecycle.tsx"></script></body></html>')
fs.mkdirSync(path.join(consumer, 'public'))
const media = fs.readFileSync(path.join(workspace, 'test/browser/media/pattern.mp4'))
fs.writeFileSync(path.join(consumer, 'public/pattern.mp4'), media)
fs.copyFileSync(path.join(example, 'public/vite.svg'), path.join(consumer, 'public/vite.svg'))
report.media = { file: 'test/browser/media/pattern.mp4', sha256: hash(media) }
report.inputs = Object.fromEntries(['src/Player.tsx', 'src/player-options.ts', 'src/App.tsx', 'src/main.tsx', 'index.html', 'lifecycle.tsx', 'consumer.tsx'].map(file => [file, hash(fs.readFileSync(path.join(consumer, file)))]))
const config = ts.readConfigFile(path.join(consumer, 'tsconfig.json'), ts.sys.readFile)
assert.equal(config.error, undefined)
const parsed = ts.parseJsonConfigFileContent({ ...config.config, include: ['src', 'consumer.tsx', 'lifecycle.tsx'] }, ts.sys, consumer)
assert.deepEqual(parsed.errors, [])
const host = ts.createCompilerHost(parsed.options)
host.getCurrentDirectory = () => consumer
const program = ts.createProgram(parsed.fileNames, parsed.options, host)
const diagnostics = ts.getPreEmitDiagnostics(program).map(d => ({ code: d.code, file: d.file?.fileName, text: ts.flattenDiagnosticMessageText(d.messageText, '\n') }))
report.types = { version: ts.version, diagnostics }
assert.deepEqual(diagnostics, [])
const library = fs.realpathSync(path.dirname(ts.sys.getExecutingFilePath()))
for (const file of program.getSourceFiles()) {
const actual = fs.realpathSync(file.fileName)
assert(actual.startsWith(fs.realpathSync(consumer) + path.sep) || (path.dirname(actual) === library && /^lib\..*\.d\.ts$/.test(path.basename(actual))), `Types escaped consumer: ${actual}`)
}
for (const mode of ['development', 'production']) {
const outDir = path.join(output, mode)
await build({ root: consumer, configFile: false, plugins: [react()], mode, define: { 'process.env.NODE_ENV': JSON.stringify(mode) }, build: { outDir, emptyOutDir: false, rollupOptions: { input: { app: path.join(consumer, 'index.html'), harness: path.join(consumer, 'harness.html') } } } })
const server = await preview({ root: consumer, configFile: false, build: { outDir }, preview: { host: '127.0.0.1', port: 0, open: false } })
const address = server.httpServer.address()
const base = `http://127.0.0.1:${address.port}`
try {
for (const [engine, type] of Object.entries({ chromium, firefox, webkit })) {
const browser = await type.launch()
const row = { mode, engine, version: browser.version(), checks: [], passed: false }
report.browsers.push(row)
const page = await browser.newPage()
const unexpected = []
page.on('pageerror', error => unexpected.push(error.message))
await page.route('https://artplayer.org/assets/sample/danmuku.xml', route => route.fulfill({ contentType: 'text/xml', body: '<i><d p="0,1,25,16777215,0,0,0,0">React lifecycle sample</d></i>' }))
await page.route('https://artplayer.org/assets/sample/video.mp4', route => route.fulfill({ contentType: 'video/mp4', body: media }))
const snapshot = () => page.evaluate(() => window.probe.snapshot())
const wait = async (count) => {
await page.waitForFunction(count => window.probe?.snapshot().active === count, count)
return snapshot()
}
try {
await page.goto(`${base}/harness.html`)
let state = await wait(1)
assert.equal(state.created, mode === 'development' ? 2 : 1)
assert.equal(state.players, 1)
assert.equal(await page.locator('#player').getAttribute('data-consumer'), 'preserved')
await page.waitForFunction(() => window.probe.current().plugins.artplayerPluginDanmuku && window.probe.current().plugins.artplayerPluginDocumentPip)
row.checks.push('strict-mode-and-plugin-registration')
const original = state.created
await page.evaluate(() => window.probe.render())
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
assert.equal((await snapshot()).created, original)
row.checks.push('stable-props-retain-instance')
for (const mode of ['callback', 'option']) {
const prior = (await snapshot()).created
await page.evaluate(mode => window.probe.render(mode), mode)
await page.waitForFunction(prior => window.probe.snapshot().created === prior + 1, prior)
state = await wait(1)
assert(state.records.slice(0, -1).every(record => record.isDestroy && record.destroyed === 1))
assert.equal(state.players, 1)
row.checks.push(`${mode}-change-cleans-old-instance`)
}
await page.waitForFunction(() => window.probe.current().duration > 1)
await page.evaluate(() => window.probe.current().play())
await page.waitForFunction(() => window.probe.current().currentTime > 0.15)
row.media = await page.evaluate(async () => {
const art = window.probe.current()
art.pause()
const time = art.currentTime
const seeked = new Promise(resolve => art.video.addEventListener('seeked', resolve, { once: true }))
art.currentTime = 1
await seeked
const canvas = document.createElement('canvas')
canvas.width = 8
canvas.height = 8
const context = canvas.getContext('2d')
context.drawImage(art.video, 0, 0, 8, 8)
return { time, paused: art.playing === false, seek: art.currentTime, width: art.video.videoWidth, rgba: Array.from(context.getImageData(0, 0, 8, 8).data) }
})
assert(row.media.time > 0.15 && row.media.paused && Math.abs(row.media.seek - 1) < 0.1 && row.media.width > 0)
assert(row.media.rgba.some((value, index) => index % 4 !== 3 && value > 50))
row.checks.push('native-play-pause-seek-and-decoded-pixels')
await page.evaluate(() => window.probe.render('siblings'))
await wait(2)
const retainedId = await page.evaluate(() => window.probe.current().id)
await page.evaluate(() => window.probe.render())
state = await wait(1)
assert.equal(await page.evaluate(() => window.probe.current().id), retainedId)
assert.equal(state.players, 1)
row.checks.push('sibling-unmount-preserves-other-instance')
await page.evaluate(() => window.probe.render('optional'))
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
await wait(1)
row.checks.push('optional-getInstance')
await page.evaluate(() => window.probe.render('empty'))
state = await wait(0)
assert.equal(state.players, 0)
assert.equal(state.videos, 0)
assert(state.records.every(record => record.isDestroy && record.destroyed === 1))
row.checks.push('unmount-cleans-recorded-instances-and-dom')
await page.evaluate(() => window.probe.render())
await wait(1)
row.checks.push('remount-after-empty')
await page.evaluate(() => window.probe.render('throw'))
await page.locator('#caught').waitFor()
state = await snapshot()
row.callbackFailure = state
assert.equal(state.active, 0, 'Throwing getInstance leaked a player')
assert(state.records.every(record => record.isDestroy && record.destroyed === 1))
assert.equal(state.errors.length, mode === 'development' ? 2 : 1)
assert(state.errors.every(error => error.same && error.message === 'react-consumer-callback-failure'))
row.checks.push('callback-exception-cleanup-and-original-error')
await page.evaluate(() => window.probe.render('throw-cleanup'))
await page.waitForFunction(count => window.probe.snapshot().errors.length === count, mode === 'development' ? 4 : 2)
state = await snapshot()
assert.equal(state.active, 0)
assert.equal(state.players, 0)
assert(state.records.every(record => record.isDestroy && record.destroyed === 1))
assert(state.errors.every(error => error.same && error.message === 'react-consumer-callback-failure'))
row.checks.push('cleanup-error-preserves-primary-callback-error')
assert.deepEqual(unexpected, [])
await page.goto(base)
await page.locator('.art-video-player').waitFor()
await page.waitForFunction(() => document.querySelector('video')?.readyState >= 2)
row.checks.push('original-app-entry-loads-media')
assert.deepEqual(unexpected, [])
row.passed = true
}
catch (error) {
row.error = error.stack
await page.screenshot({ path: path.join(output, `${mode}-${engine}-failure.png`) })
throw error
}
finally {
await browser.close()
writeJson(path.join(output, 'report.json'), report)
}
}
}
finally {
await new Promise(resolve => server.httpServer.close(resolve))
}
}
report.passed = true
}
finally {
writeJson(path.join(output, 'report.json'), report)
removeConsumer(consumer)
}
console.log('React installed consumer types and six native browser profiles passed.')
+27 -4
View File
@@ -4206,17 +4206,22 @@
"phase": "6 文档与消费者",
"title": "验证 React 消费者与 TS",
"scope": [
"example/react.js"
"example/react.js",
"workspace"
],
"dependsOn": [
"CORE-22",
"ENG-07"
],
"status": "todo",
"status": "done",
"risk": "M",
"deliverable": "React 挂载/卸载/重挂载、引用和插件样例",
"acceptance": "真实 tarball + TS 消费通过,保留既有 React 集成 API",
"evidence": []
"evidence": [
"changes/2026-09-14-EX-01-react-consumer.md",
"baselines/react-consumer-validation.json",
"scripts/react-consumer.mjs"
]
},
{
"id": "EX-02",
@@ -4588,7 +4593,8 @@
"workspace"
],
"dependsOn": [
"REL-06"
"REL-06",
"ENG-LINT-01"
],
"status": "todo",
"risk": "L",
@@ -4884,6 +4890,23 @@
"changes/2026-09-14-PKG-AUTO-THUMB-09-alias.md",
"baselines/auto-thumbnail-alias-validation.json"
]
},
{
"id": "ENG-LINT-01",
"phase": "2 工程基础",
"title": "修复 Mask manifest 排序并恢复全仓 lint",
"scope": [
"artplayer-plugin-danmuku-mask",
"workspace"
],
"dependsOn": [
"PKG-MASK-04"
],
"status": "todo",
"risk": "L",
"deliverable": "仅调整 manifest 字段顺序,独立验证全仓只读 lint 与元数据语义不变",
"acceptance": "根 lint 通过;解析后的 manifest 深度相等;不改包版本、依赖或公开入口",
"evidence": []
}
]
}