diff --git a/docs/assets/ts/artplayer-plugin-chapter.d.ts b/docs/assets/ts/artplayer-plugin-chapter.d.ts
index 5d6f7b378..950d9cbc3 100644
--- a/docs/assets/ts/artplayer-plugin-chapter.d.ts
+++ b/docs/assets/ts/artplayer-plugin-chapter.d.ts
@@ -13,7 +13,7 @@ interface Result {
update: (option: Option) => void
}
-declare const artplayerPluginChapter: (option: Option) => (art: Artplayer) => Result
+declare const artplayerPluginChapter: (option?: Option) => (art: Artplayer) => Result
export default artplayerPluginChapter
diff --git a/packages/artplayer-plugin-chapter/README.md b/packages/artplayer-plugin-chapter/README.md
index 65ffbbbb8..4a5e3a0c8 100644
--- a/packages/artplayer-plugin-chapter/README.md
+++ b/packages/artplayer-plugin-chapter/README.md
@@ -6,6 +6,36 @@ chapter plugin for ArtPlayer
[https://artplayer.org](https://artplayer.org/?libs=./uncompiled/artplayer-plugin-chapter/index.js&example=chapter)
+## API
+
+```js
+import artplayerPluginChapter from 'artplayer-plugin-chapter'
+
+const plugin = artplayerPluginChapter({
+ chapters: [{ start: 0, end: Infinity, title: 'Introduction' }],
+})
+// Pass plugin to Artplayer's plugins array. Options may also be omitted.
+```
+
+The registered name is `artplayerPluginChapter`. After metadata loads, use
+`art.plugins.artplayerPluginChapter.update({ chapters })` to replace the chapters, or
+`update({})` to clear them. Update is synchronous and requires an options object.
+Chapter times are seconds; ranges must not overlap. `Infinity` as an end value uses the current duration.
+
+The existing implementation sorts and fills the supplied array in place and replaces its Infinity end
+values. Pass a fresh array when you need to retain the original configuration. After switching media,
+call update with chapters appropriate to the new duration; initialization listens to metadata once.
+
+## Maintenance
+
+The implementation is currently in `src/index.js`, styles in `src/style.less`, and public declarations
+in `types/artplayer-plugin-chapter.d.ts`. Its migration contracts and known defects are recorded in
+[the chapter contract](../../refactor/baselines/chapter-contract.md).
+
+Run `yarn test:browser chapter.spec.js` for published/current plugin behavior on the published core.
+Run `yarn typecheck` and `yarn test:baseline` for strict consumer checks, including optional factory
+arguments and invalid data. `yarn build:ts` regenerates the online editor declarations.
+
## License
MIT © Harvey Zhao
diff --git a/packages/artplayer-plugin-chapter/types/artplayer-plugin-chapter.d.ts b/packages/artplayer-plugin-chapter/types/artplayer-plugin-chapter.d.ts
index ea1d72f2f..73f8a2611 100644
--- a/packages/artplayer-plugin-chapter/types/artplayer-plugin-chapter.d.ts
+++ b/packages/artplayer-plugin-chapter/types/artplayer-plugin-chapter.d.ts
@@ -15,6 +15,6 @@ interface Result {
update: (option: Option) => void
}
-declare const artplayerPluginChapter: (option: Option) => (art: Artplayer) => Result
+declare const artplayerPluginChapter: (option?: Option) => (art: Artplayer) => Result
export default artplayerPluginChapter
diff --git a/refactor/baselines/chapter-validation.json b/refactor/baselines/chapter-validation.json
new file mode 100644
index 000000000..b46f1cdc6
--- /dev/null
+++ b/refactor/baselines/chapter-validation.json
@@ -0,0 +1,329 @@
+{
+ "task": "PKG-CHAPTER-02",
+ "capturedAt": "2026-09-10T14:39:42.386Z",
+ "node": "24.21.0",
+ "platform": "win32",
+ "sourceHashAlgorithm": "sha256-lf",
+ "hashes": {
+ "packages/artplayer-plugin-chapter/src/index.js": "fcd732e4257597a8cbb192fa4d715488b344df361a3a5b7d0e7c70f7554a73e6",
+ "packages/artplayer-plugin-chapter/types/artplayer-plugin-chapter.d.ts": "15856abc93ef140cf0c589c30f80335e5395b30b4604bc040e7e241281882127",
+ "docs/assets/ts/artplayer-plugin-chapter.d.ts": "2931630e9fca67ddf3b3b4018b7cce8df880c6528a4d34140bbacc15abab54d8",
+ "test/browser/chapter.spec.js": "a13aca482cf25eea4c240782262c597192781b763425d9da233275bd1d02c3c2",
+ "test/browser/player.html": "4037c6eadc5da176f9dd67b2b517acb06c8cb2246c841316df26c660e55584b4",
+ "test/browser/playback.spec.js": "5cc5df6f3968f3e834a6b2bc2c76c3c1c4ec5e7bda5ee3e99aa7394d24ac7e18",
+ "test/types/chapter-options.ts": "8ae16ff704befdcf5303012d15d108bad4aa7dab4d62da9c02cff4776680fe60",
+ "refactor/scripts/types.test.mjs": "17a4c9a2c2ae7cebe9c3007aad090f01371d8c7f65feb98662db08fc7cf619d8"
+ },
+ "resources": {
+ "/candidate/artplayer.js": {
+ "source": {
+ "kind": "workspace-build",
+ "entry": "packages\\artplayer\\src\\index.js"
+ },
+ "sha256": "c6f564431e03a198c81fc026af75ead9f7c624e5c7f2f23cba5ce984ec045290",
+ "bytes": 220281
+ },
+ "/compiled/artplayer.js": {
+ "source": {
+ "kind": "workspace-build",
+ "entry": "packages\\artplayer\\src\\index.js"
+ },
+ "sha256": "c6f564431e03a198c81fc026af75ead9f7c624e5c7f2f23cba5ce984ec045290",
+ "bytes": 220281,
+ "aliasOf": "/candidate/artplayer.js"
+ },
+ "/uncompiled/artplayer/index.js": {
+ "source": {
+ "kind": "workspace-build",
+ "entry": "packages\\artplayer\\src\\index.js"
+ },
+ "sha256": "c6f564431e03a198c81fc026af75ead9f7c624e5c7f2f23cba5ce984ec045290",
+ "bytes": 220281,
+ "aliasOf": "/candidate/artplayer.js"
+ },
+ "/candidate/artplayer-plugin-chapter.js": {
+ "source": {
+ "kind": "workspace-build",
+ "entry": "packages\\artplayer-plugin-chapter\\src\\index.js"
+ },
+ "sha256": "833b81bf3334443531ed1822e28a38a241484056c6dbbdd50cd1ec093303ab94",
+ "bytes": 8178
+ },
+ "/compiled/artplayer-plugin-chapter.js": {
+ "source": {
+ "kind": "workspace-build",
+ "entry": "packages\\artplayer-plugin-chapter\\src\\index.js"
+ },
+ "sha256": "833b81bf3334443531ed1822e28a38a241484056c6dbbdd50cd1ec093303ab94",
+ "bytes": 8178,
+ "aliasOf": "/candidate/artplayer-plugin-chapter.js"
+ },
+ "/uncompiled/artplayer-plugin-chapter/index.js": {
+ "source": {
+ "kind": "workspace-build",
+ "entry": "packages\\artplayer-plugin-chapter\\src\\index.js"
+ },
+ "sha256": "833b81bf3334443531ed1822e28a38a241484056c6dbbdd50cd1ec093303ab94",
+ "bytes": 8178,
+ "aliasOf": "/candidate/artplayer-plugin-chapter.js"
+ },
+ "/published/artplayer.js": {
+ "source": {
+ "kind": "npm-release",
+ "version": "5.4.0",
+ "integrity": "sha512-2B+plbx8N2yNsjK4nJU3+EOG8TULm1LRZk/QPkWRAMEX2Ee/MSnZG/WJYz8kcoZxZuLKcQ3uXifqLuPxZOH29A==",
+ "member": "package/dist/artplayer.js"
+ },
+ "sha256": "f1db1fa3406aa6d41baa7c554749429a8e79f99d56a794bde23345eb9c07573f",
+ "bytes": 134037
+ },
+ "/published/artplayer-plugin-chapter.js": {
+ "source": {
+ "kind": "npm-release",
+ "version": "1.1.0",
+ "integrity": "sha512-ji66Lr2K2S+OjFMuMby3yq+pefWlOC51Sbpop/DIr2N6w3F0smrF+YZbshN1Dy8/Tx1TYpT7N6WkkZWy5Jix1w==",
+ "member": "package/dist/artplayer-plugin-chapter.js"
+ },
+ "sha256": "c2aaee11bf1db765433b01d9f8dd3978e41fa4d39655db00ebd03fc0be3e35e0",
+ "bytes": 4973
+ },
+ "/test/pattern.mp4": {
+ "source": {
+ "kind": "generated-media",
+ "file": "test/browser/media/pattern.mp4"
+ },
+ "sha256": "0ad1d7ad286aec6e0fa9e27fc1e487e7277df2e603e30040fb942365763d0233",
+ "bytes": 251538
+ },
+ "/test/pattern.webm": {
+ "source": {
+ "kind": "generated-media",
+ "file": "test/browser/media/pattern.webm"
+ },
+ "sha256": "9d3c2b3dec9581a599de2d6732992ef7846790e0d1368e6e131bd339b39e984f",
+ "bytes": 162073
+ },
+ "/assets/sample/video.mp4": {
+ "source": {
+ "kind": "docs-media",
+ "file": "docs/assets/sample/video.mp4"
+ },
+ "sha256": "30a2de667ba267b6bc605edcee1c4bd728c048ed76f500ea0e73d1f1a6f8f980",
+ "bytes": 5818706
+ }
+ },
+ "fullBrowserSuite": {
+ "startTime": "2026-09-10T14:39:42.386Z",
+ "duration": 21337.515,
+ "expected": 39,
+ "skipped": 0,
+ "unexpected": 0,
+ "flaky": 0
+ },
+ "tests": [
+ {
+ "scope": "chapter.spec.js / published-only observations: blank title, NaN and once-only metadata are historical defects",
+ "project": "chromium",
+ "status": "passed",
+ "browser": "153.0.8010.12",
+ "errors": [],
+ "observation": {
+ "staleTitle": "Previous",
+ "endAfterSourceChange": 8,
+ "acceptsNaN": true
+ }
+ },
+ {
+ "scope": "chapter.spec.js / published-only observations: blank title, NaN and once-only metadata are historical defects",
+ "project": "firefox",
+ "status": "passed",
+ "browser": "155.0",
+ "errors": [],
+ "observation": {
+ "staleTitle": "Previous",
+ "endAfterSourceChange": 8,
+ "acceptsNaN": true
+ }
+ },
+ {
+ "scope": "chapter.spec.js / published-only observations: blank title, NaN and once-only metadata are historical defects",
+ "project": "webkit",
+ "status": "passed",
+ "browser": "26.6",
+ "errors": [],
+ "observation": {
+ "staleTitle": "Previous",
+ "endAfterSourceChange": 8,
+ "acceptsNaN": true
+ }
+ },
+ {
+ "scope": "chapter.spec.js / published chapter on published core / factory, registration, input normalization and safe title text",
+ "project": "chromium",
+ "status": "passed",
+ "browser": "153.0.8010.12",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / published chapter on published core / progress bars, real hover, title edges and boundary seek",
+ "project": "chromium",
+ "status": "passed",
+ "browser": "153.0.8010.12",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / published chapter on published core / sync update, Infinity, object identity, empty lists and invalid data",
+ "project": "chromium",
+ "status": "passed",
+ "browser": "153.0.8010.12",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / published chapter on published core / explicit source update and core-owned destruction",
+ "project": "chromium",
+ "status": "passed",
+ "browser": "153.0.8010.12",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / published chapter on published core / factory, registration, input normalization and safe title text",
+ "project": "firefox",
+ "status": "passed",
+ "browser": "155.0",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / published chapter on published core / progress bars, real hover, title edges and boundary seek",
+ "project": "firefox",
+ "status": "passed",
+ "browser": "155.0",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / published chapter on published core / sync update, Infinity, object identity, empty lists and invalid data",
+ "project": "firefox",
+ "status": "passed",
+ "browser": "155.0",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / published chapter on published core / explicit source update and core-owned destruction",
+ "project": "firefox",
+ "status": "passed",
+ "browser": "155.0",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / published chapter on published core / factory, registration, input normalization and safe title text",
+ "project": "webkit",
+ "status": "passed",
+ "browser": "26.6",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / published chapter on published core / progress bars, real hover, title edges and boundary seek",
+ "project": "webkit",
+ "status": "passed",
+ "browser": "26.6",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / published chapter on published core / sync update, Infinity, object identity, empty lists and invalid data",
+ "project": "webkit",
+ "status": "passed",
+ "browser": "26.6",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / published chapter on published core / explicit source update and core-owned destruction",
+ "project": "webkit",
+ "status": "passed",
+ "browser": "26.6",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / candidate chapter on published core / factory, registration, input normalization and safe title text",
+ "project": "chromium",
+ "status": "passed",
+ "browser": "153.0.8010.12",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / candidate chapter on published core / progress bars, real hover, title edges and boundary seek",
+ "project": "chromium",
+ "status": "passed",
+ "browser": "153.0.8010.12",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / candidate chapter on published core / sync update, Infinity, object identity, empty lists and invalid data",
+ "project": "chromium",
+ "status": "passed",
+ "browser": "153.0.8010.12",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / candidate chapter on published core / explicit source update and core-owned destruction",
+ "project": "chromium",
+ "status": "passed",
+ "browser": "153.0.8010.12",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / candidate chapter on published core / factory, registration, input normalization and safe title text",
+ "project": "firefox",
+ "status": "passed",
+ "browser": "155.0",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / candidate chapter on published core / progress bars, real hover, title edges and boundary seek",
+ "project": "firefox",
+ "status": "passed",
+ "browser": "155.0",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / candidate chapter on published core / sync update, Infinity, object identity, empty lists and invalid data",
+ "project": "firefox",
+ "status": "passed",
+ "browser": "155.0",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / candidate chapter on published core / explicit source update and core-owned destruction",
+ "project": "firefox",
+ "status": "passed",
+ "browser": "155.0",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / candidate chapter on published core / factory, registration, input normalization and safe title text",
+ "project": "webkit",
+ "status": "passed",
+ "browser": "26.6",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / candidate chapter on published core / progress bars, real hover, title edges and boundary seek",
+ "project": "webkit",
+ "status": "passed",
+ "browser": "26.6",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / candidate chapter on published core / sync update, Infinity, object identity, empty lists and invalid data",
+ "project": "webkit",
+ "status": "passed",
+ "browser": "26.6",
+ "errors": []
+ },
+ {
+ "scope": "chapter.spec.js / candidate chapter on published core / explicit source update and core-owned destruction",
+ "project": "webkit",
+ "status": "passed",
+ "browser": "26.6",
+ "errors": []
+ }
+ ]
+}
diff --git a/refactor/changes/2026-09-10-PKG-CHAPTER-02-tests.md b/refactor/changes/2026-09-10-PKG-CHAPTER-02-tests.md
new file mode 100644
index 000000000..213a8e087
--- /dev/null
+++ b/refactor/changes/2026-09-10-PKG-CHAPTER-02-tests.md
@@ -0,0 +1,42 @@
+# PKG-CHAPTER-02:章节回归与可选参数声明
+
+## 改动
+
+新增 [chapter.spec.js](../../test/browser/chapter.spec.js),在已发布核心 5.4.0 上分别运行发布与当前
+chapter:每浏览器 8 项正常/边界/失败/生命周期测试,另加 1 项只针对发布包的历史缺陷观察。
+实际鼠标 hover、进度点击 seek、metadata 与切源由真实浏览器执行;没有替换 HTMLMediaElement。
+
+覆盖无参工厂完整注册、name/结果 key、同步 update、数组原地排序/补齐/对象引用、Infinity、
+标题 trim 和 HTML 纯文本、gap、三类进度条、标题两侧夹紧、边界 seek、空配置、六类非法数据、
+无参 update 错误、切源后显式 update、destroy 和全局 style 保留。
+
+生产声明的工厂参数从必填扩展为可选,运行时已有默认值,不改变旧调用;update 的对象仍必填。
+[消费者夹具](../../test/types/chapter-options.ts) 验证无参/undefined/空对象/正常调用及三个反例。
+运行 build:ts 再生成编辑器声明,仅 chapter 文件有实际差异。README 补调用说明与测试入口。
+没有迁移或修改插件 JS 运行时;生产源码 TS 迁移仍为零。
+
+## 验证和发现
+
+- Chromium 153.0.8010.12、Firefox 155.0、WebKit 26.6:chapter 27 项通过;连同原 smoke 总计 39 项通过。
+- TS 5.9.3 的 Node10 CommonJS / NodeNext CommonJS / Bundler ESM 与 TS 4.3.5 旧消费通过,
+ 错误 update/标题/null 参数仍被拒绝。关闭 BASE-TYPE-02;BASE-TYPE-01/03 不受本修复影响。
+- `yarn ci:check` 55 项检查通过;build:ts、计划/风险台账、Git 差异检查通过。
+- [运行记录](../baselines/chapter-validation.json) 保存源码/声明指纹、浏览器版本、结果及历史观察。
+
+实际复现空白 gap 沿用上一标题、NaN 区间进入 DOM、切源后保留旧区间。历史观察仅对发布版本
+固定这些结果,不要求候选永久保持缺陷。PKG-CHAPTER-03 负责标题/非法时间修正、生命周期与
+模块拆分;切源后的显式更新责任和已有调用方数组变化保持可解释,不能意外改变公开语义。
+
+测试使用真实鼠标来提供核心 hover 事件需要的原生 event;整数坐标决定实际百分比。同步
+setBar 断言在一次 page.evaluate 中发出并读取,避免与原生 timeupdate 交错导致测试误报。
+完整套件另复现 Firefox 已缓存媒体 Range 的 NS_ERROR_PARSED_DATA_CACHED;按
+[Mozilla 的说明](https://bugzilla.mozilla.org/show_bug.cgi?id=1347174#c4) 增加精确引擎/媒体类型/同源/
+用例 URL 限定,保留原始记录和全部播放断言,不全局忽略请求失败。
+
+## 边界和下一步
+
+仍未验收完整编辑器、移动真机、所有核心历史版本或最终 tarball;旧核心/新插件的范围以当前
+基线为准。类型消费者的 NodeNext ESM/legacy 解析归 PKG-CHAPTER-04。先完成 ENG-07 候选消费,
+再进入 PKG-CHAPTER-03 源码拆分;可直接用 TS 实现模块,04 继续完成公开类型/分发消费闭环。
+
+回退本提交恢复原必填声明及编辑器输出,并移除新增测试/文档;旧合法调用和运行时代码不变。
diff --git a/refactor/plan.md b/refactor/plan.md
index a060f7429..203afeff3 100644
--- a/refactor/plan.md
+++ b/refactor/plan.md
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
-状态:todo 184 / doing 0 / blocked 0 / done 30 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
+状态:todo 183 / doing 0 / blocked 0 / done 31 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -133,7 +133,7 @@
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| PKG-CHAPTER-01 | artplayer-plugin-chapter
核对包契约与历史用法 | BASE-05 | chapters 时间区间、update、name 和进度 DOM 清单 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | M | done |
-| PKG-CHAPTER-02 | artplayer-plugin-chapter
建立特有行为与错误测试 | PKG-CHAPTER-01, ENG-03, ENG-05 | 区间重叠/空列表/边界 seek、hover 标题和 update 用例 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言;修正 BASE-TYPE-02 的可选参数声明并补正反例 | M | todo |
+| PKG-CHAPTER-02 | artplayer-plugin-chapter
建立特有行为与错误测试 | PKG-CHAPTER-01, ENG-03, ENG-05 | 区间重叠/空列表/边界 seek、hover 标题和 update 用例 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言;修正 BASE-TYPE-02 的可选参数声明并补正反例 | M | done |
| PKG-CHAPTER-03 | artplayer-plugin-chapter
整理内部职责与资源 | PKG-CHAPTER-02, ENG-07 | 在已有核心上整理时间区间计算、进度 DOM、事件/布局,合理简单代码可保持 | 不依赖新核心方法或大范围设置重构;旧 API/事件和资源通过,必要适配限包内 | M | todo |
| PKG-CHAPTER-04 | artplayer-plugin-chapter
迁移自有源码和公开类型 | PKG-CHAPTER-03, ENG-04, ENG-06 | 基于既有公开核心类型的 Chapters/Option/Result 与 update 推导;JS/TS 工具链试点 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容;处理 BASE-TYPE-01/03,保持运行时和新旧 TS 模块解析消费 | M | todo |
| PKG-CHAPTER-05 | artplayer-plugin-chapter
验证新旧核心和组合 | PKG-CHAPTER-04, CORE-22 | chapter + quality/thumbnail、移动和全屏进度场景 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | M | todo |
@@ -433,3 +433,4 @@
- ENG-05: [记录](changes/2026-09-10-ENG-05-browser-tests.md) [记录](baselines/browser-validation.json)
- ENG-06: [记录](changes/2026-09-10-ENG-06-build-development.md) [记录](build-development.md) [记录](baselines/build-validation.json)
- PKG-CHAPTER-01: [记录](changes/2026-09-10-PKG-CHAPTER-01-contract.md) [记录](baselines/chapter-contract.md)
+- PKG-CHAPTER-02: [记录](changes/2026-09-10-PKG-CHAPTER-02-tests.md) [记录](baselines/chapter-validation.json)
diff --git a/refactor/progress.md b/refactor/progress.md
index 4593519a4..5c3a93321 100644
--- a/refactor/progress.md
+++ b/refactor/progress.md
@@ -1,5 +1,9 @@
# 进度与证据
+## 当前实施:PKG-CHAPTER-02 测试与声明修正完成
+
+chapter 27 项真实浏览器回归通过,合并原 smoke 共 39 项;工厂 option 声明已扩展为可选并重新生成编辑器声明,新旧 TS 正反例通过,BASE-TYPE-02 关闭。见 [交付](changes/2026-09-10-PKG-CHAPTER-02-tests.md)。当前 214 项,31 完成、183 待办。PKG-CHAPTER-01 提交 70174952,本任务单独提交;下一项 ENG-07,随后直接以 TS 拆分 chapter 源码。运行时源码尚未迁移。
+
## 当前实施:PKG-CHAPTER-01 契约完成
源码/发布插件逻辑和元数据对照完成,冻结 chapter 调用、数组变更、DOM/事件和入口,登记待修问题;见 [契约](baselines/chapter-contract.md)。当前 214 项,30 完成、184 待办。ENG-05 提交 7318d338,下一项 PKG-CHAPTER-02 包特有测试与声明修正。生产源码 TS 迁移仍为零。
diff --git a/refactor/risk-guide.md b/refactor/risk-guide.md
index e66890c55..cfbbd8c58 100644
--- a/refactor/risk-guide.md
+++ b/refactor/risk-guide.md
@@ -4,7 +4,7 @@ BASE-07 将先前基线发现和第三方来源缺口纳入同一份责任台账
## 如何读取状态
-BASE-07 初始登记 39 项,目前仍为 open;ENG-06 新增并修复 BUILD-AMD-01,共 40 项。reproduced 表示已保存复现结果;source-observed 表示在源码、声明或产物中看到事实;unverified 表示来源、环境或行为仍需取证。证据等级与处理状态互相独立,不能把全部事项称为缺陷,也不能因建立了测试基线而自动接受旧缺陷。
+BASE-07 初始登记 39 项;后续新增和关闭状态以 risks.json 为准。ENG-06 修复 BUILD-AMD-01,PKG-CHAPTER-02 修复 BASE-TYPE-02,均有独立回归证据。reproduced 表示已保存复现结果;source-observed 表示在源码、声明或产物中看到事实;unverified 表示来源、环境或行为仍需取证。证据等级与处理状态互相独立,不能把全部事项称为缺陷,也不能因建立了测试基线而自动接受旧缺陷。
其中 15 项来自生命周期、DOM/示例、消费者类型/分发和性能基线;其余包含发布来源、站点分发、素材来源、8 组内嵌资源和 12 个集成边界。每项都列出负责的任务 ID、证据路径、兼容处理路线与关闭条件。具体复现仍以原基线报告为准。
diff --git a/refactor/risk-table.md b/refactor/risk-table.md
index cd86465f7..383d87f5d 100644
--- a/refactor/risk-table.md
+++ b/refactor/risk-table.md
@@ -14,7 +14,7 @@
| BASE-DOM-02 | open / 已复现 | 窄容器中控件裁切 | CORE-13, CORE-14 |
| BASE-DEMO-01 | open / 源码/产物事实 | 旧 thumbnail 插件示例不对应当前 workspace 包 | SITE-01, EX-03 |
| BASE-TYPE-01 | open / 已复现 | NodeNext ESM 默认导出与声明互操作失配 | ENG-04, CORE-07, PKG-CHAPTER-04 |
-| BASE-TYPE-02 | open / 已复现 | chapter 可选运行时参数被声明为必填 | PKG-CHAPTER-02 |
+| BASE-TYPE-02 | resolved / 已复现 | chapter 可选运行时参数被声明为必填 | PKG-CHAPTER-02 |
| BASE-TYPE-03 | open / 已复现 | chapter legacy 缺旧模块解析的声明回退 | ENG-04, PKG-CHAPTER-04 |
| BASE-TYPE-04 | open / 源码/产物事实 | plugins.add / toggle 的旧声明返回值与运行时不同 | CORE-07 |
| BASE-DIST-01 | open / 源码/产物事实 | thumbnail tool 分发入口与类型文件缺失 | PKG-TOOL-THUMB-01, PKG-TOOL-THUMB-04 |
diff --git a/refactor/risks.json b/refactor/risks.json
index 9964f2868..75d9c0f7f 100644
--- a/refactor/risks.json
+++ b/refactor/risks.json
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"task": "BASE-07",
"sourceCommit": "e01f39ff",
- "updatedAt": "2026-09-10T13:46:01.011Z",
+ "updatedAt": "2026-09-10T14:41:52.219Z",
"note": "Open means closure evidence is outstanding; source-observed and unverified are not reproduced runtime bugs. A source issue already mitigated in the workspace still requires candidate regression evidence. No accepted-with-scope item is implied by baseline task completion.",
"items": [
{
@@ -163,7 +163,7 @@
"id": "BASE-TYPE-02",
"title": "chapter 可选运行时参数被声明为必填",
"confirmation": "reproduced",
- "status": "open",
+ "status": "resolved",
"owners": [
"PKG-CHAPTER-02"
],
@@ -172,7 +172,15 @@
"refactor/fixtures/api.js"
],
"compatibleResolution": "补充兼容可选参数,不删除已接受的配置。",
- "closureCriteria": "无参数和正确参数编译,错误参数依旧被拒绝,运行时一致。"
+ "closureCriteria": "无参数和正确参数编译,错误参数依旧被拒绝,运行时一致。",
+ "resolutionEvidence": [
+ "packages/artplayer-plugin-chapter/types/artplayer-plugin-chapter.d.ts",
+ "test/types/chapter-options.ts",
+ "refactor/scripts/types.test.mjs",
+ "test/browser/chapter.spec.js",
+ "refactor/changes/2026-09-10-PKG-CHAPTER-02-tests.md"
+ ],
+ "resolutionRationale": "工厂 option 扩展为可选,旧/新 TS 消费与实际无参插件注册通过;update 及非法输入仍受严格约束。编辑器声明已重新生成。NodeNext ESM 与 legacy 分发问题仍由其他条目跟踪。"
},
{
"id": "BASE-TYPE-03",
diff --git a/refactor/scripts/types.test.mjs b/refactor/scripts/types.test.mjs
index d54603901..1374f63cf 100644
--- a/refactor/scripts/types.test.mjs
+++ b/refactor/scripts/types.test.mjs
@@ -35,3 +35,14 @@ test('Browser implementation configuration catches source errors without emittin
fs.rmSync(directory, { recursive: true, force: true })
}
})
+
+test('Chapter optional factory parameters preserve strict update and data types in new and old compilers', () => {
+ const source = fs.readFileSync(path.join(refactorDir, '../test/types/chapter-options.ts'), 'utf8')
+ for (const mode of ['node10-commonjs', 'nodenext-cjs', 'bundler-esm']) {
+ assert.deepEqual(checkConsumer(ts, mode, source), [])
+ }
+ assert.deepEqual(checkConsumer(compat, 'node10-commonjs', source), [])
+ const unguarded = source.replaceAll(/\/\/ @ts-expect-error[^\n]*\n/g, '')
+ const diagnostics = checkConsumer(ts, 'bundler-esm', unguarded)
+ assert.equal(diagnostics.length, 3, 'Invalid update, title and null option must each fail')
+})
diff --git a/refactor/tasks.json b/refactor/tasks.json
index 3ab2a53d5..1a7435422 100644
--- a/refactor/tasks.json
+++ b/refactor/tasks.json
@@ -1161,11 +1161,14 @@
"ENG-03",
"ENG-05"
],
- "status": "todo",
+ "status": "done",
"risk": "M",
"deliverable": "区间重叠/空列表/边界 seek、hover 标题和 update 用例",
"acceptance": "旧版本行为可重跑,成功/失败/切源/销毁有必要断言;修正 BASE-TYPE-02 的可选参数声明并补正反例",
- "evidence": []
+ "evidence": [
+ "changes/2026-09-10-PKG-CHAPTER-02-tests.md",
+ "baselines/chapter-validation.json"
+ ]
},
{
"id": "PKG-CHAPTER-03",
diff --git a/test/browser/README.md b/test/browser/README.md
index 0ea89f9f4..864a20d86 100644
--- a/test/browser/README.md
+++ b/test/browser/README.md
@@ -23,6 +23,10 @@ failed requests and final state. Tests attach decoded pixels and server-side med
Windows WebKit media requests are not reliably visible to Playwright's page network events;
use the loopback request log to prove that media was fetched. Its `videoWidth` can reflect layout
size, so intrinsic-size compatibility is a separate test, not the playback-success predicate.
+Chromium's `net::ERR_ABORTED` and Firefox's `NS_ERROR_PARSED_DATA_CACHED` may cancel media range
+requests after buffering. The successful-playback tests allow only the exact engine-specific code,
+same-origin media type and two URLs bearing the current test ID; raw failures remain in reports.
+See [Mozilla's media-cache explanation](https://bugzilla.mozilla.org/show_bug.cgi?id=1347174#c4).
Reports, failure screenshots and traces are under `refactor/.cache/browser/`. They contain local
test URLs and paths. JSON and HTML are regenerated each run; archive them before another run
diff --git a/test/browser/chapter.spec.js b/test/browser/chapter.spec.js
new file mode 100644
index 000000000..639aa3fe2
--- /dev/null
+++ b/test/browser/chapter.spec.js
@@ -0,0 +1,155 @@
+import { expect, test } from './fixtures.js'
+
+async function openChapter(page, chapter, chapters = []) {
+ await page.goto(`/test/player.html?core=published&chapter=${chapter}`)
+ await page.evaluate((chapters) => {
+ window.chapterInput = chapters
+ window.createPlayer('/test/pattern.mp4', { chapters })
+ }, chapters)
+ await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
+}
+
+function segments(page) {
+ return page.locator('.art-chapter').evaluateAll(nodes => nodes.map(node => ({
+ start: node.dataset.start,
+ end: node.dataset.end,
+ title: node.dataset.title,
+ width: node.style.width,
+ })))
+}
+
+async function hover(page, percentage) {
+ const box = await page.locator('.art-control-progress-inner').boundingBox()
+ const x = Math.round(box.x + box.width * percentage)
+ await page.mouse.move(x, box.y + box.height / 2)
+ return (x - box.x) / box.width
+}
+
+for (const chapter of ['published', 'candidate']) {
+ test.describe(`${chapter} chapter on published core`, () => {
+ test('factory, registration, input normalization and safe title text', async ({ page }) => {
+ await openChapter(page, chapter, [{ start: 4, end: 6, title: ' Second ' }, { start: 1, end: 2, title: 'First' }])
+ expect(await page.evaluate(() => {
+ const plugin = window.art.plugins.artplayerPluginChapter
+ return { factory: typeof window.artplayerPluginChapter(), name: plugin.name, keys: Object.keys(plugin).sort(), input: window.chapterInput }
+ })).toEqual({
+ factory: 'function',
+ name: 'artplayerPluginChapter',
+ keys: ['name', 'update'],
+ input: [{ start: 0, end: 1, title: '' }, { start: 1, end: 2, title: 'First' }, { start: 2, end: 4, title: '' }, { start: 4, end: 6, title: ' Second ' }, { start: 6, end: 8, title: '' }],
+ })
+ expect(await segments(page)).toEqual([
+ { start: '0', end: '1', title: '', width: '12.5%' },
+ { start: '1', end: '2', title: 'First', width: '12.5%' },
+ { start: '2', end: '4', title: '', width: '25%' },
+ { start: '4', end: '6', title: 'Second', width: '25%' },
+ { start: '6', end: '8', title: '', width: '25%' },
+ ])
+ await hover(page, 0.2)
+ await expect(page.locator('.art-chapter-title')).toHaveText('First')
+ await expect(page.locator('.art-chapter-title b')).toHaveCount(0)
+ await expect(page.locator('style#artplayer-plugin-chapter')).toHaveCount(1)
+ await page.evaluate(() => {
+ window.art.destroy()
+ window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, plugins: [window.artplayerPluginChapter()] })
+ })
+ await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
+ await expect(page.locator('.art-chapter')).toHaveCount(0)
+ })
+
+ test('progress bars, real hover, title edges and boundary seek', async ({ page }) => {
+ await openChapter(page, chapter, [{ start: 0, end: 2, title: 'First chapter' }, { start: 2, end: 6, title: 'Middle' }, { start: 6, end: 8, title: 'Last chapter' }])
+ for (const type of ['played', 'loaded']) {
+ const widths = await page.evaluate((type) => {
+ window.art.emit('setBar', type, 0.375)
+ return [...document.querySelectorAll(`.art-chapter .art-progress-${type}`)].map(node => node.style.width)
+ }, type)
+ expect(widths).toEqual(['100%', '25%', '0px'])
+ }
+ const percentage = await hover(page, 0.375)
+ const widths = await page.locator('.art-chapter .art-progress-hover').evaluateAll(nodes => nodes.map(node => Number.parseFloat(node.style.width)))
+ for (const [index, expected] of [100, (percentage * 8 - 2) / 4 * 100, 0].entries())
+ expect(widths[index]).toBeCloseTo(expected, 1)
+ const box = await page.locator('.art-control-progress-inner').boundingBox()
+ for (const [percentage, title] of [[0.01, 'First chapter'], [0.5, 'Middle'], [0.99, 'Last chapter']]) {
+ await page.mouse.move(box.x + box.width * percentage, box.y + box.height / 2)
+ await expect(page.locator('.art-chapter-title')).toHaveText(title)
+ await expect(page.locator('.art-video-player')).toHaveClass(/art-progress-hover/)
+ const layout = await page.locator('.art-chapter-title').evaluate(title => ({ left: Number.parseFloat(title.style.left), width: title.clientWidth, container: title.parentElement.clientWidth }))
+ expect(layout.left).toBeGreaterThanOrEqual(0)
+ expect(layout.left + layout.width).toBeLessThanOrEqual(layout.container)
+ }
+ await page.mouse.click(box.x + box.width / 4, box.y + box.height / 2)
+ await expect.poll(() => page.locator('video').evaluate(video => video.currentTime)).toBeCloseTo(2, 1)
+ expect(await page.evaluate(() => {
+ window.art.emit('setBar', 'played', 1)
+ return [...document.querySelectorAll('.art-chapter .art-progress-played')].map(node => node.style.width)
+ })).toEqual(['100%', '100%', '100%'])
+ })
+
+ test('sync update, Infinity, object identity, empty lists and invalid data', async ({ page }) => {
+ await openChapter(page, chapter)
+ await expect(page.locator('.art-chapter')).toHaveCount(0)
+ expect(await page.evaluate(() => {
+ const entry = { start: 0, end: Infinity, title: 'All' }
+ const chapters = [entry]
+ const result = window.art.plugins.artplayerPluginChapter.update({ chapters })
+ return { result, same: chapters[0] === entry, end: entry.end }
+ })).toEqual({ result: undefined, same: true, end: 8 })
+ await expect(page.locator('.art-chapter')).toHaveCount(1)
+ await page.evaluate(() => window.art.plugins.artplayerPluginChapter.update({}))
+ await expect(page.locator('.art-chapter')).toHaveCount(0)
+ await expect(page.locator('.art-video-player')).not.toHaveClass(/artplayer-plugin-chapter/)
+ const cases = [
+ [[{ start: '0', end: 2, title: 'Bad' }], 'TypeError', 'Illegal chapter data type'],
+ [[{ start: 0, end: 2, title: 12 }], 'TypeError', 'Illegal chapter data type'],
+ [[{ start: -1, end: 2, title: 'Bad' }], 'Error', 'Illegal chapter time point'],
+ [[{ start: 2, end: 2, title: 'Bad' }], 'Error', 'Illegal chapter time point'],
+ [[{ start: 0, end: 9, title: 'Bad' }], 'Error', 'Illegal chapter time point'],
+ [[{ start: 0, end: 4, title: 'A' }, { start: 3, end: 6, title: 'B' }], 'Error', 'Illegal chapter time point'],
+ ]
+ for (const [chapters, name, message] of cases) {
+ expect(await page.evaluate((chapters) => {
+ const update = window.art.plugins.artplayerPluginChapter.update
+ update({ chapters: [{ start: 0, end: Infinity, title: 'Valid before failure' }] })
+ try {
+ update({ chapters })
+ }
+ catch (error) { return { name: error.name, message: error.message } }
+ }, chapters)).toEqual({ name, message })
+ await expect(page.locator('.art-chapter')).toHaveCount(0)
+ }
+ expect(await page.evaluate(() => {
+ try {
+ window.art.plugins.artplayerPluginChapter.update()
+ }
+ catch (error) { return error.name }
+ })).toBe('TypeError')
+ })
+
+ test('explicit source update and core-owned destruction', async ({ page }) => {
+ await openChapter(page, chapter, [{ start: 0, end: 8, title: 'Initial' }])
+ await page.evaluate(() => window.art.switchUrl('/assets/sample/video.mp4'))
+ expect(await page.locator('video').evaluate(video => video.duration)).toBeGreaterThan(90)
+ await page.evaluate(() => window.art.plugins.artplayerPluginChapter.update({ chapters: [{ start: 0, end: Infinity, title: 'New source' }] }))
+ expect(await page.locator('.art-chapter').evaluate(node => Number(node.dataset.end))).toBeGreaterThan(90)
+ await page.evaluate(() => window.art.destroy())
+ await expect(page.locator('.player')).toBeEmpty()
+ expect(await page.evaluate(() => window.Artplayer.instances.length)).toBe(0)
+ await expect(page.locator('style#artplayer-plugin-chapter')).toHaveCount(1)
+ })
+ })
+}
+
+test('published-only observations: blank title, NaN and once-only metadata are historical defects', async ({ page }, testInfo) => {
+ await openChapter(page, 'published', [{ start: 0, end: 2, title: 'Previous' }, { start: 4, end: 8, title: 'Next' }])
+ await hover(page, 0.1)
+ await hover(page, 0.375)
+ const staleTitle = await page.locator('.art-chapter-title').textContent()
+ expect(staleTitle).toBe('Previous')
+ await page.evaluate(() => window.art.switchUrl('/assets/sample/video.mp4'))
+ expect(await page.locator('.art-chapter').last().evaluate(node => node.dataset.end)).toBe('8')
+ await page.evaluate(() => window.art.plugins.artplayerPluginChapter.update({ chapters: [{ start: Number.NaN, end: 3, title: 'Invalid' }] }))
+ expect(await page.locator('.art-chapter').first().evaluate(node => node.dataset.start)).toBe('NaN')
+ await testInfo.attach('chapter-known-defects', { body: JSON.stringify({ staleTitle, endAfterSourceChange: 8, acceptsNaN: true }), contentType: 'application/json' })
+})
diff --git a/test/browser/playback.spec.js b/test/browser/playback.spec.js
index 4838ed1e4..ef4d1b540 100644
--- a/test/browser/playback.spec.js
+++ b/test/browser/playback.spec.js
@@ -1,7 +1,7 @@
import { expect, test } from './fixtures.js'
for (const core of ['published', 'candidate']) {
- test(`${core}: real playback, pause, seek, switch and destroy`, async ({ page, request, diagnostics }, testInfo) => {
+ test(`${core}: real playback, pause, seek, switch and destroy`, async ({ page, request, diagnostics, browserName }, testInfo) => {
const caseId = encodeURIComponent(testInfo.testId)
await page.goto(`/test/player.html?core=${core}`)
await page.evaluate(url => window.createPlayer(url, true), `/test/pattern.mp4?case=${caseId}`)
@@ -55,11 +55,13 @@ for (const core of ['published', 'candidate']) {
await expect(page.locator('.player')).toBeEmpty()
expect(await page.evaluate(() => window.Artplayer.instances.length)).toBe(0)
expect(diagnostics.consoleErrors).toEqual([])
- // Chromium cancels media range requests as buffering, seeking and source ownership change.
+ // Engines cancel media range requests as buffering, seeking and source ownership change.
// Only exact cancellations of this test's two media URLs are expected; retain all in evidence.
const unexpectedRequests = diagnostics.failedRequests.filter((item) => {
const url = new URL(item.url)
- return !(item.resourceType === 'media' && item.failure?.errorText === 'net::ERR_ABORTED'
+ const expectedCancellation = (browserName === 'chromium' && item.failure?.errorText === 'net::ERR_ABORTED')
+ || (browserName === 'firefox' && item.failure?.errorText === 'NS_ERROR_PARSED_DATA_CACHED')
+ return !(item.resourceType === 'media' && expectedCancellation
&& url.origin === new URL(page.url()).origin
&& url.searchParams.get('case') === testInfo.testId
&& ['/test/pattern.mp4', '/assets/sample/video.mp4'].includes(url.pathname))
diff --git a/test/browser/player.html b/test/browser/player.html
index 52f0901c0..fd2beab41 100644
--- a/test/browser/player.html
+++ b/test/browser/player.html
@@ -24,7 +24,11 @@
url,
muted: true,
plugins: chapter
- ? [artplayerPluginChapter({ chapters: [{ start: 0, end: Infinity, title: 'Pattern' }] })]
+ ? [
+ artplayerPluginChapter(
+ chapter === true ? { chapters: [{ start: 0, end: Infinity, title: 'Pattern' }] } : chapter,
+ ),
+ ]
: [],
})
for (const name of [
diff --git a/test/types/chapter-options.ts b/test/types/chapter-options.ts
new file mode 100644
index 000000000..938860ee2
--- /dev/null
+++ b/test/types/chapter-options.ts
@@ -0,0 +1,16 @@
+import type Artplayer from 'artplayer'
+import chapter from 'artplayer-plugin-chapter'
+
+declare const art: Artplayer
+chapter()
+chapter(undefined)
+chapter({})
+chapter({ chapters: [] })(art).update({})
+const name: 'artplayerPluginChapter' = chapter()(art).name
+void name
+// @ts-expect-error The update object remains required.
+chapter()(art).update()
+// @ts-expect-error A chapter title remains a string.
+chapter({ chapters: [{ start: 0, end: 1, title: 12 }] })
+// @ts-expect-error null is not an options object.
+chapter(null)