test(BASE-04): freeze published DOM input and demo baselines

This commit is contained in:
Harvey Zhao committed 2026-09-10 20:16:58 +08:00
1 parent 9489392905
commit 2b7054d8a5
16 files changed
+3193 -17

No files matched your search

+4
View File
@@ -36,3 +36,7 @@ BASE-02 已在内置浏览器取得核心/chapter 的同步 API 和描述符快
## 事件与生命周期报告
BASE-03 已在内置浏览器实测播放推进、暂停/seek/切源、事件及销毁;两次 27 项断言通过,并保存 6 项历史问题。见 [覆盖与复现](lifecycle-coverage.md) 和 [原始报告](lifecycle.json)。受控拒绝与真正媒体播放分别标注,不代替全环境验收。
## DOM 与官方示例路径
BASE-04 已在内置浏览器完成两轮 16 项 DOM/样式/实际输入断言。见 [覆盖/复现/历史问题](dom-coverage.md)、[冻结报告](dom.json) 和 [29 示例/36 HTML/22 包路径](demo-inventory.json)。轻量夹具通过不代表 Monaco 或所有官方示例已验证。
File diff suppressed because it is too large. Load diff
+46
View File
@@ -0,0 +1,46 @@
# DOM、CSS、输入与官方 demo 基线
BASE-04 使用固定 npm artplayer 5.4.0 / chapter 1.1.0,在 Codex 内置浏览器最终两次独立 reload 后真实点击和按键。16 项断言通过,DOM 语义快照相同,errors/unhandled 和浏览器 error/warn 日志为空。来源和时间见 [dom.json](dom.json),不是当前工作区候选或外部 Chrome 扩展验收。
## 已记录的扩展契约
| 契约 | 固定测试 ID / 数据 | 后续责任 |
| --- | --- | --- |
| API-08 模板/样式 | DOM.version-template、user-style;snapshot.template/tree/cssClasses/cssVariables | CORE-12:模板、88 个核心 CSS 类名匹配、43 个 CSS 变量;保留用户选择器和覆盖入口 |
| API-06/08 chapter | DOM.chapter-markup;chapters、chapterStylePresent | PKG-CHAPTER-01/02:两个章节、start/end/title 数据及样式注入,其他参数/悬停/销毁行为继续补测 |
| API-07 控件/图层 | DOM.control-click/update/remove、layer-remove、destroy | CORE-13:真实 click 的 this/参数、按 name 替换和清理;controls.update 返回 undefined,元素通过 controls[name] 获取 |
| API-07 设置 | DOM.setting-select/update/remove;selected | CORE-14:选择 High,当前节点 art-current,默认值 false/true,回调 this/参数和移除 |
| API-08/10 网页全屏 | DOM.fullscreen-web-enter/exit;Escape interaction | CORE-16:点击后挂到 body,Escape 恢复原容器/class;未测原生全屏或 PiP 权限 |
| API-10 输入/焦点 | DOM.keyboard-seek、input-ignores-hotkey;interactions | CORE-17/23:ArrowRight 从 0 到 5 秒,输入框相同键不 seek;Tab/焦点、可信事件来源有记录 |
| API-08 布局 | DOM.narrow-container;wide/narrow | CORE-13:640x360 和 320x180 容器边界及控件测量;不是移动 viewport/触摸/真机验证 |
| API-08/10 可访问属性 | controlAttributes、tree 的 role/aria/tabindex;实际 Tab 两次 | CORE-23:DOM 属性不是完整 accessibility tree 审计;浏览器 snapshot 中按钮为 generic,用户输入层 textbox 可用 |
上述短 ID 均使用 DOM. 前缀,完整列表由 dom.mjs 的 domChecks 固定。模板是发布版 Artplayer.html 原文;运行时树保留节点嵌套、class、role、aria、tabindex、type,省略 SVG 内部路径和变动文本。CSS 清单从发布 STYLE 提取,不是全部伪类/组合选择器/所有主题的视觉回归。用户规则 `.player .user-hook` 和 `art.cssVar('--art-theme', ...)` 通过计算样式验证。章节样式依赖固定发布成员哈希,未另做全部插件 CSS 清单。
## 历史问题与界限
| ID | 已观察事实 | 责任与候选要求 |
| --- | --- | --- |
| BASE-DOM-01 | 实际 Tab 从外部按钮到用户 input,再到播放器后的按钮;8 个 .art-control 根节点均为 DIV / tabIndex -1 / 无 role。部分控件及子节点已有 aria-label,不能概括为完全没有名称 | CORE-23,关联 CORE-17:补主要控件/设置可达性与名称,保持旧快捷键/DOM 钩子;不要求候选重复不可达状态 |
| BASE-DOM-02 | 默认桌面控件加 Probe control,在 320x180 容器中设置和网页全屏控件越过右边界;截图目视确认裁切,player.scrollWidth 仍为 320 | CORE-13:补窄容器布局回归;CORE-14 同时检查设置面板定位;不把截图当移动端通过 |
| BASE-DEMO-01 | docs/assets/example/thumbnail.js 引用 artplayer-plugin-thumbnail,当前 22 包不存在它,首页也无该示例菜单链接 | SITE-01 / EX-03:核实历史外部包与迁移路径,不自动改成 auto-thumbnail 或删除旧示例 |
问题只登记与分配,不在基线任务修改生产代码。dom.mjs --compare 比较同一发布基线;候选修复产生差异时应新增正向测试和解释,不能要求修复后仍保留错误,也不能覆盖历史报告消除差异。
## 重跑
1. 按 toolchain-setup.md 使用固定 Node/Yarn。运行 `node refactor/scripts/browser-server.mjs`,打开 `http://127.0.0.1:8083/fixtures/dom.html`。独立 origin 使用固定发布脚本和本地 MP4,未运行 Monaco 或读取编辑器四个开关。
2. 等待 DOM.version-template / user-style / chapter-markup 出现。点击 Tab start,按 Tab 到 Typing probe,再按 Tab 到 Inspect narrow container,观察原始可达性。
3. 点击 Probe control,再按 ArrowRight;点击 Typing probe,再按 ArrowRight。依次点击设置、Baseline selector、High;点击网页全屏,再按 Escape。
4. 点击 Inspect narrow container,检查右侧裁切和章节条;点击 Save DOM baseline。报告写到 refactor/.cache/reports/dom.json,必须显示 16 checks / errors 0。
5. 运行 `node refactor/scripts/dom.mjs --compare refactor/.cache/reports/dom.json`。重载重复实际操作,不用 dispatchEvent 代替 trusted 输入。
`node refactor/scripts/dom.mjs --check` 只校验冻结报告,`node --test refactor/scripts/dom.test.mjs` 验证缺输入、trusted=false、错误媒体/热键、类名/变量/布局差异等拒绝机制。报告不是抗恶意篡改证明;操作与来源记录须一起审查。小数像素宽度不作跨环境精确比较,容器边界、类名与可访问属性变化会报告差异。
## 全量路径台账
[demo-inventory.json](demo-inventory.json) 覆盖 29 个 example JS、36 个 HTML 路径与 22 包。包含 25 条首页菜单链接及 libs、4 个无菜单示例、6 个 demo/编辑器入口、28 个生成文档页面、独立 upscaler 工具页和站点验证页。核心通过所有示例使用,vitepress 通过 document 页面覆盖;两者没有插件式 libs 菜单不是缺包。
所有官方 docs 页面仍标 not-run,只有独立轻量夹具已实测。没有把 core/chapter 夹具通过改写成官方 chapter 示例或 Monaco 已通过。外部 SDK、媒体 URL、动态 import/脚本和无菜单示例依赖仍由 SITE-01 / EX-03 核实;scriptSources 仅列非注释的静态 script src,动态来源见 docs-browser-testing.md。
`node refactor/scripts/demos.mjs --check` 检查文件/HTML/菜单/包覆盖,不代表网络或浏览器通过。源码 LF 哈希描述历史采集,内容演进不自动覆盖旧值;路径增删需要显式补充和记录。负例进入 test:baseline。生成 document 页面依旧不手改。
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,24 @@
# BASE-04:DOM、样式、输入与 demo 路径
日期 2026-09-10;分支 codex/compatible-modernization;起点 94893929。
新增 dom.html/js 及 /reports/dom 白名单,使用固定 core 5.4.0 / chapter 1.1.0 和本地视频。保存模板、运行时节点树、88 个核心 CSS 类名、43 个变量、控件可访问属性、章节数据、布局及可信键盘交互,绑定来源、媒体和夹具哈希。
新增 dom.mjs 验证和比较器,两次最终页面重载、真实 Tab/点击/ArrowRight/Escape 操作得到相同 profile,16 项检查通过。无意外错误/拒绝,浏览器 error/warn 日志为空;320px 容器已经截图目视检查。比较忽略小数像素宽度,保留节点/类名/变量和布局边界差异。
新增 demo-inventory.json/demos.mjs,覆盖 29 示例、36 HTML 和 22 包,保留首页 25 条菜单链接、插件 libs、静态脚本与历史 LF 指纹。补充 upscaler、站点验证页、无菜单示例和生成文档路径;所有官方页面仍为 not-run。当前开发命令同步为 Yarn。
## 发现与兼容决定
- controls.update 返回 undefined,必须保留;替换元素通过 controls[name] 访问。早期夹具错误假设已修正后重新完成两次最终采集,没有修改播放器来迁就测试。
- BASE-DOM-01:Tab 跳过内部控件,分配 CORE-23/17;已有部分 aria-label 仍记录。
- BASE-DOM-02:带自定义文字控件的 320px 容器裁切设置/网页全屏按钮,分配 CORE-13/14;不是移动设备结论。
- BASE-DEMO-01:thumbnail 示例依赖 workspace 不存在的历史包,分配 SITE-01/EX-03。
详见 [覆盖与复现](../baselines/dom-coverage.md)、[报告](../baselines/dom.json) 和 [路径清单](../baselines/demo-inventory.json)。历史缺陷不成为候选必须复现的兼容行为。未测原生全屏/PiP、屏幕阅读器、触摸/拖动、Monaco Run、全部插件及远端 SDK。
## 验证与回退
新增 3 项 Node 测试,负例包括缺路径/包/菜单、虚假输入、错误媒体/热键结果、DOM/CSS/布局差异。完整 ci:check、计划/链接和 diff 检查通过。未修改生产代码、依赖、类型或生成分发;服务可撤销此提交回到上版,历史报告保留于 Git。
本任务独立本地提交,主题含 BASE-04;不推送或发布。下一任务 BASE-05:发布入口、资源与类型消费者基线,再继续最小试点所需工具及类型基础。
+7 -1
View File
@@ -13,7 +13,7 @@
| `/iframe.html` | [iframe.html](../docs/iframe.html)、[iframe 示例](../docs/assets/example/iframe.js) | uncompiled 核心与 iframe tool,子页面 inject;父示例创建 iframe 并通信 | 父子通信、全屏及卸载;只打开子页面不能完成集成验收 |
| `/test/` | [test/index.html](../docs/test/index.html)、[build-test.js](../scripts/build-test.js) | Mocha/Chai,compiled 核心及部分插件,生成的文档用例与外部播放依赖 | 文档 smoke 的现有入口,补充状态断言与自动化结果读取 |
开发入口继续采用仓库脚本:`npm run dev` 交互选择包,在 `http://localhost:8082` 验证。[dev.js](../scripts/dev.js) 当前只构建所选包到 `docs/uncompiled/<name>/index.js`,不能假设所有插件同时更新。正常构建产生分发文件,不手改 compiled/uncompiled/dist 或生成的 test.js。
开发入口继续采用仓库脚本:`yarn dev` 交互选择包,在 `http://localhost:8082` 验证。[dev.js](../scripts/dev.js) 当前只构建所选包到 `docs/uncompiled/<name>/index.js`,不能假设所有插件同时更新。正常构建产生分发文件,不手改 compiled/uncompiled/dist 或生成的 test.js。
chapter 试点可使用 `http://localhost:8082/?libs=./uncompiled/artplayer-plugin-chapter/index.js&example=chapter`,测试前确认核心及插件已构建。这是已有路由的用法,不表示当前服务已启动或页面已通过。
@@ -49,3 +49,9 @@ chapter 试点可使用 `http://localhost:8082/?libs=./uncompiled/artplayer-plug
| REVIEW-02/03 | 分别复查真实浏览器覆盖与待发布内容的一致性 |
页面行为和媒体验证按 [测试规范](testing.md) 留证,发布结论按 [多轮复盘](release-reviews.md) 核对。该清单不新增重复实施任务;真实状态以 tasks.json 为准。
## BASE-04 已落地的记录
[完整路径台账](baselines/demo-inventory.json) 已覆盖 29 个示例、36 个 HTML 和 22 包,包括生成文档、独立 upscaler 与站点验证页面。源脚本/菜单参数与 editor prod/ts/code/log 规则已有记录;这些官方入口尚未运行,运行时实际存储值和网络来源仍待 EX-03 采集。
独立发布包夹具的 DOM/CSS、Tab/热键、设置与网页全屏已在内置浏览器两次实测,见 [覆盖及问题](baselines/dom-coverage.md)。它没有运行 Monaco,不代表编辑器 Run 或官方 chapter demo 通过。thumbnail 无菜单示例依赖不在当前 workspace 的旧插件,已交 SITE-01/EX-03 核实。
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>ArtPlayer published DOM baseline</title>
<style>
body { font: 14px sans-serif; }
.player { width: 640px; height: 360px; margin: 10px 0; }
.player .user-hook { color: rgb(12, 34, 56); background: white; padding: 4px; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
</style>
<h1>Published DOM and input baseline</h1>
<p>Use Probe control, ArrowRight, Typing probe + ArrowRight, settings Baseline selector / High, and web fullscreen + Escape. Then inspect narrow layout and save.</p>
<button id="tab-start">Tab start</button>
<div class="player"></div>
<button id="narrow">Inspect narrow container</button>
<button id="finish">Save DOM baseline</button>
<p id="status">Loading published player</p>
<pre id="result"></pre>
<script src="/releases/artplayer/dist/artplayer.js"></script>
<script src="/releases/artplayer-plugin-chapter/dist/artplayer-plugin-chapter.js"></script>
<script src="/fixtures/dom.js"></script>
</html>
+119
View File
@@ -0,0 +1,119 @@
/* global Artplayer, artplayerPluginChapter */
(() => {
const report = {
kind: 'dom', environment: { userAgent: navigator.userAgent, language: navigator.language },
scripts: [...document.scripts].map(script => new URL(script.src).pathname),
checks: [], errors: [], unhandled: [], interactions: [], observations: {},
}
const status = document.querySelector('#status')
const check = (id, passed) => {
if (report.checks.some(item => item.id === id)) return
report.checks.push({ id, passed: Boolean(passed) })
if (!passed) throw new Error(id)
status.textContent = report.checks.map(item => item.id).join(' | ')
}
addEventListener('error', event => report.errors.push(event.message))
addEventListener('unhandledrejection', event => report.unhandled.push(String(event.reason)))
function tree(element) {
return {
tag: element.tagName.toLowerCase(),
attributes: Object.fromEntries([...element.attributes].filter(attr => /^(class|role|aria-.+|tabindex|data-tooltip|type)$/.test(attr.name)).map(attr => [attr.name, attr.value])),
children: element.tagName.toLowerCase() === 'svg' ? [] : [...element.children].map(tree),
}
}
function layout() {
const player = art.template.$player
const rect = player.getBoundingClientRect()
return {
width: rect.width, height: rect.height, scrollWidth: player.scrollWidth,
controls: [...player.querySelectorAll('.art-control')].map(el => {
const r = el.getBoundingClientRect()
return { class: el.className, width: Math.round(r.width * 100) / 100, withinPlayer: r.left >= rect.left - 1 && r.right <= rect.right + 1, display: getComputedStyle(el).display }
}),
}
}
const art = new Artplayer({
container: '.player', url: '/assets/sample/video.mp4', muted: true, lang: 'en',
setting: true, playbackRate: true, aspectRatio: true, fullscreenWeb: true, hotkey: true,
controls: [{ name: 'compat', position: 'right', html: 'Probe control', tooltip: 'Probe control', click(component, event) {
check('DOM.control-click', this === art && component === art.controls && event.isTrusted)
} }],
layers: [{ name: 'compat', html: '<label class="user-hook">User layer <input aria-label="Typing probe" value="abc"></label>', style: { top: '12px', left: '12px' } }],
settings: [{ name: 'compat-setting', html: 'Baseline selector', selector: [{ html: 'Low', default: true }, { html: 'High' }], onSelect(item, element, event) {
check('DOM.setting-select', this === art && item.html === 'High' && element.classList.contains('art-current') && event.isTrusted)
report.observations.selected = { html: item.html, defaults: item.$parent.selector.map(value => value.default) }
return item.html
} }],
plugins: [artplayerPluginChapter({ chapters: [{ start: 0, end: 10, title: 'First' }, { start: 10, end: Infinity, title: 'Second' }] })],
})
let ready = false
art.once('ready', () => {
check('DOM.version-template', Artplayer.version === '5.4.0' && art.query('.art-video') === art.video)
art.cssVar('--art-theme', 'rgb(1, 2, 3)')
check('DOM.user-style', getComputedStyle(art.query('.user-hook')).color === 'rgb(12, 34, 56)' && art.cssVar('--art-theme').trim() === 'rgb(1, 2, 3)')
const chapters = [...art.query('.art-chapters').children].map(el => ({ class: el.className, start: Number(el.dataset.start), end: Number(el.dataset.end), title: el.dataset.title }))
check('DOM.chapter-markup', chapters.length === 2 && chapters[0].title === 'First' && chapters[1].title === 'Second')
report.snapshot = {
template: Artplayer.html, tree: tree(art.template.$player),
cssVariables: [...new Set(Artplayer.STYLE.match(/--art-[\w-]+/g))].sort(),
cssClasses: [...new Set(Artplayer.STYLE.match(/\.art-[\w-]+/g))].sort(),
chapters,
controlAttributes: [...art.template.$player.querySelectorAll('.art-control')].map(el => ({ class: el.className, tag: el.tagName, tabIndex: el.tabIndex, role: el.getAttribute('role'), ariaLabel: el.getAttribute('aria-label'), tooltip: el.getAttribute('data-tooltip'), title: el.getAttribute('title') })),
chapterStylePresent: Boolean(document.getElementById('artplayer-plugin-chapter')),
}
report.observations.wide = layout()
ready = true
})
let beforeKey
let hotkeys = 0
document.addEventListener('keydown', event => {
beforeKey = { time: art.currentTime, hotkeys, focused: art.isFocus, target: event.target.tagName, code: event.code, trusted: event.isTrusted }
}, true)
art.on('hotkey', () => hotkeys++)
document.addEventListener('keydown', event => {
if (!ready) return
const after = { ...beforeKey, afterTime: art.currentTime, afterHotkeys: hotkeys, activeTag: document.activeElement.tagName, activeLabel: document.activeElement.getAttribute('aria-label') }
report.interactions.push(after)
if (event.code === 'ArrowRight' && beforeKey.target !== 'INPUT') check('DOM.keyboard-seek', beforeKey.trusted && beforeKey.focused && hotkeys === beforeKey.hotkeys + 1 && Math.abs(art.currentTime - beforeKey.time - Artplayer.SEEK_STEP) < 0.05)
if (event.code === 'ArrowRight' && beforeKey.target === 'INPUT') check('DOM.input-ignores-hotkey', beforeKey.trusted && beforeKey.focused && hotkeys === beforeKey.hotkeys && art.currentTime === beforeKey.time)
})
document.addEventListener('focusin', event => report.interactions.push({ focus: event.target.tagName, label: event.target.getAttribute('aria-label'), id: event.target.id }))
art.on('fullscreenWeb', value => {
if (value) check('DOM.fullscreen-web-enter', art.template.$player.parentElement === document.body && art.template.$player.classList.contains('art-fullscreen-web'))
else check('DOM.fullscreen-web-exit', art.template.$player.parentElement === art.template.$container && !art.template.$player.classList.contains('art-fullscreen-web'))
})
document.querySelector('#narrow').onclick = () => {
document.querySelector('.player').style.cssText = 'width:320px;height:180px'
requestAnimationFrame(() => {
report.observations.narrow = layout()
check('DOM.narrow-container', report.observations.narrow.width === 320 && report.observations.narrow.height === 180)
})
}
document.querySelector('#finish').onclick = async () => {
try {
const previous = art.controls.compat
const updateResult = art.controls.update({ name: 'compat', html: 'Updated control' })
const replacement = art.controls.compat
check('DOM.control-update', updateResult === undefined && replacement instanceof HTMLElement && !previous.isConnected && art.template.$player.querySelectorAll('.art-control-compat').length === 1)
art.controls.remove('compat')
check('DOM.control-remove', !replacement.isConnected && !art.controls.compat)
art.setting.update({ name: 'compat-setting', html: 'Updated selector' })
check('DOM.setting-update', art.setting.find('compat-setting').html === 'Updated selector')
art.setting.remove('compat-setting')
check('DOM.setting-remove', !art.setting.find('compat-setting'))
art.layers.remove('compat')
check('DOM.layer-remove', !art.query('.user-hook'))
art.destroy()
check('DOM.destroy', document.querySelector('.player').children.length === 0 && Artplayer.instances.length === 0)
}
catch (error) {
report.errors.push(error.message)
if (Artplayer.instances.includes(art)) art.destroy()
}
report.checks.sort((a, b) => a.id.localeCompare(b.id))
document.querySelector('#result').textContent = JSON.stringify(report, null, 2)
const response = await fetch('/reports/dom', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(report) })
status.textContent = response.ok ? `Saved ${report.checks.length} checks; errors ${report.errors.length}` : `Save failed: ${response.status}`
document.querySelector('#finish').disabled = true
}
})()
+8 -7
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
状态:todo 194 / doing 0 / blocked 0 / done 20 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 193 / doing 0 / blocked 0 / done 21 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -63,7 +63,7 @@
| BASE-HARNESS-01 | workspace<br>建立最小发布包浏览器采集夹具与服务 | BASE-01 | 固定 tarball 静态服务、本地媒体 Range、公开 API 采集页面及受控报告路径 | HTTP 精确内容/范围和路由隔离测试、夹具语法通过;浏览器采集仍由 BASE-02 负责 | M | done |
| BASE-02 | workspace<br>捕获公共 API 和属性描述符 | BASE-01, BASE-HARNESS-01 | 核心与试点的构造/默认值/静态和实例 API 快照,其余包由 01/02 步补齐;登记契约/版本/固定测试 ID/命令/报告与负责任务 | 真实发布包反射与旧用户调用可重跑,未验证项明确 | H | done |
| BASE-03 | workspace<br>捕获事件、异步和生命周期 | BASE-02 | ready/restart/destroy、Promise、切源/插件事件 trace;登记契约/版本/固定测试 ID/命令/报告与负责任务 | 成功、失败、重入和多实例有断言,历史缺陷有标记;使用已发布包和最小可重跑探针启动,不等待后续 ENG 服务 | H | done |
| BASE-04 | workspace<br>捕获 DOM、CSS 和官方 demo | BASE-02 | 核心模板/类名/变量、键盘/焦点/可访问名称基线及全 demo 路径映射;docs-browser-testing.md 中 HTML/编辑器的脚本来源与存储状态;登记契约/版本/固定测试 ID/命令/报告与负责任务 | 核心/试点用户样式和输入用法已记录,其他 demo 运行状态与未知项明确;使用已发布包和最小可重跑探针启动,不等待后续 ENG 服务 | M | todo |
| BASE-04 | workspace<br>捕获 DOM、CSS 和官方 demo | BASE-02 | 核心模板/类名/变量、键盘/焦点/可访问名称基线及全 demo 路径映射;docs-browser-testing.md 中 HTML/编辑器的脚本来源与存储状态;登记契约/版本/固定测试 ID/命令/报告与负责任务 | 核心/试点用户样式和输入用法已记录,其他 demo 运行状态与未知项明确;使用已发布包和最小可重跑探针启动,不等待后续 ENG 服务 | M | done |
| BASE-05 | workspace<br>捕获包入口、资源和类型消费 | BASE-01 | 全包 manifest/资源路径清单;核心/试点的 UMD/AMD/ESM/CJS/legacy/i18n 和类型消费者基线;登记契约/版本/固定测试 ID/命令/报告与负责任务;区分库/npm 与站点实际分发类别,SSR import/模板/非浏览器构造错误分别捕获 | 核心/试点隔离消费可运行,其他包由各自契约任务验证,thumbnail tool 差异已登记 | H | todo |
| BASE-06 | workspace<br>记录性能与资源基线 | BASE-03, BASE-04 | 固定媒体/设备测量、包体积、反复装卸资源报告 | 多次采样可重跑,指标和审查阈值确定,不承诺未经测量的收益;使用已发布包和最小可重跑探针启动,不等待后续 ENG 服务 | M | todo |
| BASE-07 | workspace<br>建立差异和风险台账 | BASE-03, BASE-05 | 销毁/切源/类型差异、vendored 许可和外部 SDK 清单 | 已确认与待复现分开,每项有负责任务及兼容处理路线 | H | todo |
@@ -116,8 +116,8 @@
| CORE-10 | artplayer<br>迁移播放与时间/状态属性 | CORE-09 | play/pause/toggle/seek/volume/rate/duration 等模块 | 旧返回值、拒绝、mutex、位置恢复、属性描述符与事件通过 | H | todo |
| CORE-11 | artplayer<br>整理媒体事件、ready 与重连 | CORE-10 | 媒体转发、重连、UI 响应职责分离 | readiness 次数/顺序、过期重连、原错误参数有回归 | H | todo |
| CORE-12 | artplayer<br>迁移模板及公开资源入口 | CORE-06, CORE-04, BASE-04 | template/icons/i18n/style、SSR 与 proxy 挂载 | DOM/CSS/global/样式注入、i18n 子路径及 SSR import 保持;不把 SSR import/useSSR 保持扩大为服务端构造播放器 | H | todo |
| CORE-13 | artplayer<br>迁移组件注册与控件 | CORE-12, CORE-08 | Component/control/layer/contextmenu 的资源与 TS 边界 | add/update/remove、name、selector、mounted/beforeUnmount 通过 | H | todo |
| CORE-14 | artplayer<br>拆分设置树、渲染和布局 | CORE-13 | setting model/selection/render/layout 的分批实现 | 不强行统一控件返回值,嵌套选择、默认高亮、更新清理通过 | H | todo |
| CORE-13 | artplayer<br>迁移组件注册与控件 | CORE-12, CORE-08 | Component/control/layer/contextmenu 的资源与 TS 边界 | add/update/remove、name、selector、mounted/beforeUnmount 通过;处理 BASE-DOM-02 窄容器控件裁切,保留 controls.update 的旧 undefined 返回值 | H | todo |
| CORE-14 | artplayer<br>拆分设置树、渲染和布局 | CORE-13 | setting model/selection/render/layout 的分批实现 | 不强行统一控件返回值,嵌套选择、默认高亮、更新清理通过;结合 BASE-DOM-02 验证窄容器设置定位 | H | todo |
| CORE-15 | artplayer<br>迁移字幕获取、解析和渲染 | CORE-11, CORE-13 | subtitle 请求/track/DOM/URL 生命周期与类型 | 过期结果、偏移、cue 数组、原生全屏及错误路径通过 | H | todo |
| CORE-16 | artplayer<br>迁移全屏、PiP、mini 与尺寸模式 | CORE-11, CORE-12 | 显示模式服务和原公开属性门面 | 模式互斥/恢复位置、手势权限、移动方向和退出失败通过 | H | todo |
| CORE-17 | artplayer<br>迁移键盘、手势、焦点和全局事件 | CORE-13, CORE-16 | events/hotkey 的明确 document 与资源归属 | 跨 document 重绑、触摸/键盘、快捷键及销毁无重复响应 | H | todo |
@@ -125,7 +125,7 @@
| CORE-19 | artplayer<br>整理进度、质量、缩略图和截图 | CORE-10, CORE-13, CORE-16 | 相关 player/control 功能与纯计算分离 | 截图跨域失败、缩略图布局、quality/进度边界保持 | M | todo |
| CORE-20 | artplayer<br>收敛核心入口与依赖方向 | CORE-14, CORE-15, CORE-18, CORE-19 | Artplayer 门面、初始化依赖与最终目录 | 所有核心自有源码迁移,公开描述符/静态接口/事件差分通过 | H | todo |
| CORE-21 | artplayer<br>生成核心声明并校验包内容 | CORE-20, ENG-07 | 由 TS 生成的既有入口声明与三种发布产物 | 内部类型不泄漏,旧 JS/TS 消费者与 i18n 通过 | H | todo |
| CORE-23 | artplayer<br>检查并补齐键盘、焦点与可访问名称 | CORE-13, CORE-14, CORE-17, ENG-05, BASE-04 | 主要控件/设置/模式退出的键盘与焦点回归、名称和字幕可用性检查及必要兼容修正 | 保持旧快捷键和 DOM/CSS 钩子;真实浏览器验证,不以静态属性检查代替交互 | H | todo |
| CORE-23 | artplayer<br>检查并补齐键盘、焦点与可访问名称 | CORE-13, CORE-14, CORE-17, ENG-05, BASE-04 | 主要控件/设置/模式退出的键盘与焦点回归、名称和字幕可用性检查及必要兼容修正 | 保持旧快捷键和 DOM/CSS 钩子;真实浏览器验证,不以静态属性检查代替交互;处理 BASE-DOM-01 的主要控件 Tab 不可达,保留既有名称与快捷键 | H | todo |
| CORE-22 | artplayer<br>核心阶段完整验收 | CORE-21, CORE-23, PILOT-01, ENG-08, ENG-10 | 核心与旧插件的可自动化完整回归、资源/性能及明确外部验证缺口 | 核心自动化和公开差异处置通过;真实环境缺口链接包集成/REL 门槛,阶段完成不代表可公开发布 | H | todo |
## 5 包迁移:artplayer-plugin-chapter
@@ -359,7 +359,7 @@
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| SITE-01 | artplayer-vitepress<br>清点文档/示例/生成链 | BASE-04, BASE-05 | 中英文文档、插件页面、demo URL、编辑器声明与生成目录清单;六类 HTML 入口、prod/libs/code/example 加载行为及移动重定向 | 所有公开 API/插件有对应页面或明确补充任务 | M | todo |
| SITE-01 | artplayer-vitepress<br>清点文档/示例/生成链 | BASE-04, BASE-05 | 中英文文档、插件页面、demo URL、编辑器声明与生成目录清单;六类 HTML 入口、prod/libs/code/example 加载行为及移动重定向 | 所有公开 API/插件有对应页面或明确补充任务;核实 BASE-DEMO-01 的历史 thumbnail 插件来源及 29 示例/36 HTML 路径 | M | todo |
| SITE-02 | artplayer-vitepress<br>整理声明与示例生成器 | SITE-01, ENG-04, ENG-06 | build-ts/build-test 生成链的可验证 TS 脚本 | 不靠字符串拼接掩盖声明错误,生成示例有真实断言或仅标 smoke;替换固定 100ms 成功判定,明确异步错误、清理和生成覆盖限制 | M | todo |
| SITE-03 | artplayer-vitepress<br>整理 i18n/文档/LLM 生成流程 | SITE-02 | build-i18n/build-docs/build-llm/trans-docs 的任务边界和错误处理 | 原命令兼容、生成可复现,翻译步骤不隐式运行远程服务 | M | todo |
| 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 |
@@ -367,7 +367,7 @@
| 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-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,记录实际候选脚本及状态隔离证据 | H | 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 |
## 7 工具链与性能
@@ -420,6 +420,7 @@
- BASE-HARNESS-01: [记录](changes/2026-09-10-BASE-HARNESS-01-browser-fixture.md)
- BASE-02: [记录](changes/2026-09-10-BASE-02-public-api.md) [记录](baselines/public-api.json) [记录](baselines/api-coverage.md)
- BASE-03: [记录](changes/2026-09-10-BASE-03-lifecycle.md) [记录](baselines/lifecycle.json) [记录](baselines/lifecycle-coverage.md)
- BASE-04: [记录](changes/2026-09-10-BASE-04-dom.md) [记录](baselines/dom.json) [记录](baselines/dom-coverage.md) [记录](baselines/demo-inventory.json)
- ENG-01: [记录](changes/2026-09-10-ENG-01-reproducible-toolchain.md) [记录](baselines/toolchain-validation.json)
- ENG-PM-01: [记录](changes/2026-09-10-ENG-PM-01-yarn-toolchain.md) [记录](baselines/yarn-validation.json)
- ENG-02: [记录](changes/2026-09-10-ENG-02-readonly-ci.md) [记录](baselines/ci-validation.json)
+6
View File
@@ -1,5 +1,11 @@
# 进度与证据
## 当前实施:BASE-04 DOM 与输入基线完成
内置浏览器最终两轮 16 项检查通过,模板/类名/变量、用户样式、控件/设置、Tab/热键与网页全屏已留证。320px 裁切和控件 Tab 不可达已分配 CORE-13/14/23,未把历史问题当成正常兼容要求。29 示例、36 HTML 和 22 包的路径已映射,官方 docs 页面仍待真实运行。见 [BASE-04](changes/2026-09-10-BASE-04-dom.md)。
当前 214 项任务,21 完成、193 待办。BASE-03 已提交 94893929;本轮独立提交 BASE-04。没有生产 TS 迁移、推送或发布。下一任务 BASE-05:入口、资源与类型消费者基线。
## 当前实施:BASE-03 生命周期基线完成
固定发布包在内置浏览器最终两次执行 27 项断言、真实媒体时间推进与关键事件顺序均通过,语义观察相同,保存 64 条事件 trace 和六项历史问题。受控恢复拒绝单独登记,其余 errors/unhandled 为空;无把历史缺陷当作正常兼容要求。见 [BASE-03](changes/2026-09-10-BASE-03-lifecycle.md) 和 [问题/覆盖台账](baselines/lifecycle-coverage.md)。CORE-04/08/09 验收已链接相应复现。
+3 -2
View File
@@ -37,7 +37,7 @@ function sendBytes(req, res, bytes, filename) {
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://127.0.0.1:${port}`)
const reportName = { '/reports/api': 'api', '/reports/lifecycle': 'lifecycle' }[url.pathname]
const reportName = { '/reports/api': 'api', '/reports/lifecycle': 'lifecycle', '/reports/dom': 'dom' }[url.pathname]
if (req.method === 'POST' && reportName) {
assert.equal(req.headers.origin, `http://127.0.0.1:${port}`, 'Unexpected report origin')
const chunks = []
@@ -48,7 +48,7 @@ const server = http.createServer(async (req, res) => {
chunks.push(chunk)
}
const report = JSON.parse(Buffer.concat(chunks).toString('utf8'))
assert.equal(report.kind, reportName === 'api' ? 'public-api' : 'lifecycle')
assert.equal(report.kind, reportName === 'api' ? 'public-api' : reportName)
report.capture = {
capturedAt: new Date().toISOString(),
releases: baseline.releases.map(release => ({
@@ -62,6 +62,7 @@ const server = http.createServer(async (req, res) => {
if (reportName === 'lifecycle') {
report.capture.media = Object.fromEntries(['video.mp4', 'video2.mp4'].map(name => [name, hash(fs.readFileSync(path.join(root, 'docs/assets/sample', name)))]))
}
if (reportName === 'dom') report.capture.media = { 'video.mp4': hash(fs.readFileSync(path.join(root, 'docs/assets/sample/video.mp4'))) }
const output = path.join(refactorDir, '.cache/reports')
fs.mkdirSync(output, { recursive: true })
fs.writeFileSync(path.join(output, `${reportName}.json`), `${JSON.stringify(report, null, 2)}\n`)
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { hash, refactorDir } from './releases.mjs'
const root = path.resolve(refactorDir, '..')
const read = name => fs.readFileSync(path.join(root, name), 'utf8').replaceAll('\r\n', '\n')
function htmlFiles(dir = 'docs') {
return fs.readdirSync(path.join(root, dir), { withFileTypes: true }).flatMap(entry => {
const name = `${dir}/${entry.name}`
if (entry.isDirectory()) return ['compiled', 'uncompiled', 'node_modules'].includes(entry.name) ? [] : htmlFiles(name)
return entry.name.endsWith('.html') ? [name] : []
}).sort()
}
function menuLinks() {
return [...read('docs/index.html').matchAll(/href="([^"\n]*[?&]example=[^"\n]*)"/g)].map(match => match[1].replaceAll('&amp;', '&'))
}
export function captureDemoInventory() {
const links = menuLinks()
const inventory = JSON.parse(read('refactor/package-inventory.json'))
const examples = fs.readdirSync(path.join(root, 'docs/assets/example')).filter(name => name.endsWith('.js')).sort().map(file => {
const name = file.slice(0, -3)
const source = `docs/assets/example/${file}`
const linked = links.filter(link => new URL(link, 'http://localhost:8082').searchParams.get('example') === name)
const libs = [...new Set(linked.flatMap(link => (new URL(link, 'http://localhost:8082').searchParams.get('libs') || '').split('\n').filter(Boolean)))]
return { name, source, sha256Lf: hash(read(source)), menuLinks: linked, route: linked[0] || `/?example=${name}`, libs,
status: 'not-run', owner: 'EX-03',
note: name === 'thumbnail' ? 'References artplayer-plugin-thumbnail, which is absent from the 22 current workspace packages; dependency and route need reconciliation.' : linked.length ? 'Existing menu route; SDK and media loading have not been validated.' : 'No menu link; example route follows common.js loader. Required globals are not automatically loaded.' }
})
const pages = htmlFiles().map(source => {
const text = read(source)
const uncommented = text.replace(/<!--[\s\S]*?-->/g, '')
return { source, route: `/${source.slice(5)}`.replace(/index\.html$/, ''), sha256Lf: hash(text),
kind: source.startsWith('docs/document/') ? 'generated-docs' : source.includes('googlebc') ? 'site-verification' : source === 'docs/upscaler/index.html' ? 'standalone-tool' : 'demo-or-editor',
scriptSources: [...uncommented.matchAll(/<script\b[^>]*\bsrc=["']([^"']+)["']/g)].map(match => match[1]),
status: 'not-run', owner: source.includes('googlebc') ? 'SITE-01' : 'EX-03' }
})
return { schemaVersion: 1, task: 'BASE-04', sourceCommit: '94893929', capturedAt: new Date().toISOString(),
note: 'Historical source inventory, not browser or network evidence. ScriptSources excludes comments but does not resolve inline scripts, import maps or dynamic SDK loading. Hashes use LF and describe this capture; check validates coverage, not current content identity.',
examples, pages,
packages: inventory.packages.map(pkg => ({ name: pkg.name, examples: examples.filter(example => example.libs.some(lib => lib.includes(`/${pkg.name}/`))).map(example => example.name), status: 'not-run', owner: pkg.name === 'artplayer-vitepress' ? 'SITE-01' : 'EX-03' })),
editor: { source: 'docs/assets/js/common.js', sha256Lf: hash(read('docs/assets/js/common.js')), storage: ['prod', 'ts', 'code', 'log'], isolation: 'Use separate test origin; record keys, do not erase user browser data.', core: { prodTrue: './compiled/artplayer.js', otherwise: './uncompiled/artplayer/index.js' }, pluginSources: 'URL libs parameter; independent of prod toggle', execution: 'Run evaluates editor text; TS mode alone is not strict tsc validation.', runtimeStatus: 'not-run' },
}
}
export function validateDemoInventory(inventory) {
assert.equal(inventory.schemaVersion, 1)
assert.deepEqual(inventory.examples.map(item => item.source).sort(), fs.readdirSync(path.join(root, 'docs/assets/example')).filter(name => name.endsWith('.js')).map(name => `docs/assets/example/${name}`).sort(), 'Demo file coverage drift')
assert.deepEqual(inventory.pages.map(item => item.source).sort(), htmlFiles(), 'HTML route coverage drift')
assert.deepEqual(inventory.examples.flatMap(item => item.menuLinks).sort(), menuLinks().sort(), 'Editor link coverage drift')
assert.deepEqual(inventory.packages.map(item => item.name).sort(), JSON.parse(read('refactor/package-inventory.json')).packages.map(item => item.name).sort(), 'Package demo coverage drift')
const names = new Set(inventory.examples.map(item => item.name))
for (const pkg of inventory.packages) for (const name of pkg.examples) assert(names.has(name), `Unknown example: ${name}`)
assert(inventory.examples.find(item => item.name === 'thumbnail').note.includes('absent'), 'Missing historical plugin exception')
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const filename = path.join(refactorDir, 'baselines/demo-inventory.json')
if (process.argv.includes('--capture')) {
assert(!fs.existsSync(filename), 'Do not overwrite the historical inventory; reconcile changes explicitly')
const inventory = captureDemoInventory()
validateDemoInventory(inventory)
fs.writeFileSync(filename, `${JSON.stringify(inventory, null, 2)}\n`)
}
else {
assert(process.argv.slice(2).every(arg => arg === '--check'), 'Use --capture or --check')
const inventory = JSON.parse(fs.readFileSync(filename, 'utf8'))
validateDemoInventory(inventory)
console.log(`Demo coverage: ${inventory.examples.length} examples, ${inventory.pages.length} HTML paths, ${inventory.packages.length} packages; no browser pass implied`)
}
}
+21
View File
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
import { validateDemoInventory } from './demos.mjs'
import { refactorDir } from './releases.mjs'
test('Demo inventory rejects missing files, routes and ecosystem packages', () => {
const inventory = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/demo-inventory.json'), 'utf8'))
validateDemoInventory(inventory)
for (const mutate of [
value => value.examples.pop(),
value => value.pages.pop(),
value => value.examples.find(item => item.menuLinks.length).menuLinks.pop(),
value => value.packages.pop(),
]) {
const changed = structuredClone(inventory)
mutate(changed)
assert.throws(() => validateDemoInventory(changed))
}
})
+78
View File
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { validatePublishedCapture } from './api.mjs'
import { hash, refactorDir } from './releases.mjs'
export const domChecks = [
'DOM.chapter-markup', 'DOM.control-click', 'DOM.control-remove', 'DOM.control-update',
'DOM.destroy', 'DOM.fullscreen-web-enter', 'DOM.fullscreen-web-exit', 'DOM.input-ignores-hotkey',
'DOM.keyboard-seek', 'DOM.layer-remove', 'DOM.narrow-container', 'DOM.setting-remove',
'DOM.setting-select', 'DOM.setting-update', 'DOM.user-style', 'DOM.version-template',
]
export function validateDomReport(report) {
assert.equal(report.kind, 'dom')
assert.deepEqual(report.errors, [], 'DOM capture failed')
assert.deepEqual(report.unhandled, [], 'Unexpected DOM rejection')
assert.deepEqual(report.checks, domChecks.map(id => ({ id, passed: true })), 'Missing DOM checks')
validatePublishedCapture(report, 'dom')
assert.equal(report.capture.media['video.mp4'], hash(fs.readFileSync(path.resolve(refactorDir, '../docs/assets/sample/video.mp4'))), 'DOM media changed')
const s = report.snapshot
assert(s.template.includes('art-video-player') && s.tree.children.length && s.cssVariables.length > 0 && s.cssClasses.length > 0, 'Incomplete DOM/CSS snapshot')
assert.equal(s.chapterStylePresent, true)
assert(s.controlAttributes.length >= 8)
assert.deepEqual(report.observations.selected, { html: 'High', defaults: [false, true] })
for (const [name, width, height] of [['wide', 640, 360], ['narrow', 320, 180]]) {
assert.equal(report.observations[name].width, width)
assert.equal(report.observations[name].height, height)
assert(report.observations[name].controls.length >= 8, 'Missing layout measurement')
}
const keys = report.interactions.filter(item => item.code)
assert(keys.every(item => item.trusted), 'Input was not trusted browser interaction')
const seek = keys.find(item => item.code === 'ArrowRight' && item.target !== 'INPUT')
assert(seek?.focused && seek.afterTime === seek.time + 5 && seek.afterHotkeys === seek.hotkeys + 1, 'Missing real keyboard seek')
const input = keys.find(item => item.code === 'ArrowRight' && item.target === 'INPUT')
assert(input?.focused && input.afterTime === input.time && input.afterHotkeys === input.hotkeys, 'Input consumed a player hotkey')
assert(keys.some(item => item.code === 'Escape' && item.focused && item.afterHotkeys === item.hotkeys + 1), 'Missing Escape interaction')
assert.equal(keys.filter(item => item.code === 'Tab').length, 2, 'Missing Tab traversal')
const focus = report.interactions.filter(item => item.focus).slice(0, 3)
assert.deepEqual(focus, [{ focus: 'BUTTON', label: null, id: 'tab-start' }, { focus: 'INPUT', label: 'Typing probe', id: '' }, { focus: 'BUTTON', label: null, id: 'narrow' }], 'Published focus traversal changed; assess accessibility improvements explicitly')
}
export function domProfile(report) {
return {
snapshot: report.snapshot,
checks: report.checks,
selected: report.observations.selected,
layouts: Object.fromEntries(['wide', 'narrow'].map(name => {
const { width, height, controls } = report.observations[name]
return [name, { width, height, controls: controls.map(({ class: className, withinPlayer, display }) => ({ class: className, withinPlayer, display })) }]
})),
}
}
export function verifyFrozenDom() {
const frozen = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/dom.json'), 'utf8'))
assert.equal(frozen.schemaVersion, 1)
validateDomReport(frozen.report)
assert.equal(hash(JSON.stringify(domProfile(frozen.report))), frozen.verification.profileSha256, 'Frozen DOM profile changed')
return frozen
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const frozen = verifyFrozenDom()
const index = process.argv.indexOf('--compare')
if (index !== -1) {
assert(process.argv[index + 1], '--compare requires a report path')
const captured = JSON.parse(fs.readFileSync(process.argv[index + 1], 'utf8'))
validateDomReport(captured)
assert.deepEqual(domProfile(captured), domProfile(frozen.report), 'Published DOM profile differs; review the change')
console.log('Published DOM capture matches baseline; this comparison is not a candidate accessibility gate')
}
else {
assert(process.argv.slice(2).every(arg => arg === '--check'), 'Use --check or --compare <report>')
console.log(`Frozen DOM verified: ${domChecks.length} checks; this command does not rerun the browser`)
}
}
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { domProfile, validateDomReport, verifyFrozenDom } from './dom.mjs'
test('DOM baseline rejects absent interaction, fake input and wrong media', () => {
const frozen = verifyFrozenDom().report
for (const mutate of [
report => report.checks.pop(),
report => report.interactions.find(item => item.code === 'ArrowRight').trusted = false,
report => report.interactions.find(item => item.code === 'ArrowRight').afterTime = 0,
report => report.interactions.find(item => item.code === 'ArrowRight' && item.target === 'INPUT').afterHotkeys++,
report => report.capture.media['video.mp4'] = 'wrong',
report => report.unhandled.push('unexpected'),
]) {
const changed = structuredClone(frozen)
mutate(changed)
assert.throws(() => validateDomReport(changed))
}
})
test('DOM comparison detects class, variable and layout changes without freezing subpixel widths', () => {
const frozen = verifyFrozenDom().report
const expected = domProfile(frozen)
const jitter = structuredClone(frozen)
jitter.observations.wide.controls[0].width += 0.01
assert.deepEqual(domProfile(jitter), expected)
for (const mutate of [
report => report.snapshot.tree.attributes.class = 'renamed',
report => report.snapshot.cssVariables.pop(),
report => report.snapshot.controlAttributes[0].role = 'button',
report => report.observations.narrow.controls[0].withinPlayer = false,
]) {
const changed = structuredClone(frozen)
mutate(changed)
assert.notDeepEqual(domProfile(changed), expected)
}
})
+12 -7
View File
@@ -325,11 +325,16 @@
"dependsOn": [
"BASE-02"
],
"status": "todo",
"status": "done",
"risk": "M",
"deliverable": "核心模板/类名/变量、键盘/焦点/可访问名称基线及全 demo 路径映射;docs-browser-testing.md 中 HTML/编辑器的脚本来源与存储状态;登记契约/版本/固定测试 ID/命令/报告与负责任务",
"acceptance": "核心/试点用户样式和输入用法已记录,其他 demo 运行状态与未知项明确;使用已发布包和最小可重跑探针启动,不等待后续 ENG 服务",
"evidence": []
"evidence": [
"changes/2026-09-10-BASE-04-dom.md",
"baselines/dom.json",
"baselines/dom-coverage.md",
"baselines/demo-inventory.json"
]
},
{
"id": "BASE-05",
@@ -911,7 +916,7 @@
"status": "todo",
"risk": "H",
"deliverable": "Component/control/layer/contextmenu 的资源与 TS 边界",
"acceptance": "add/update/remove、name、selector、mounted/beforeUnmount 通过",
"acceptance": "add/update/remove、name、selector、mounted/beforeUnmount 通过;处理 BASE-DOM-02 窄容器控件裁切,保留 controls.update 的旧 undefined 返回值",
"evidence": []
},
{
@@ -927,7 +932,7 @@
"status": "todo",
"risk": "H",
"deliverable": "setting model/selection/render/layout 的分批实现",
"acceptance": "不强行统一控件返回值,嵌套选择、默认高亮、更新清理通过",
"acceptance": "不强行统一控件返回值,嵌套选择、默认高亮、更新清理通过;结合 BASE-DOM-02 验证窄容器设置定位",
"evidence": []
},
{
@@ -1071,7 +1076,7 @@
"BASE-04"
],
"deliverable": "主要控件/设置/模式退出的键盘与焦点回归、名称和字幕可用性检查及必要兼容修正",
"acceptance": "保持旧快捷键和 DOM/CSS 钩子;真实浏览器验证,不以静态属性检查代替交互"
"acceptance": "保持旧快捷键和 DOM/CSS 钩子;真实浏览器验证,不以静态属性检查代替交互;处理 BASE-DOM-01 的主要控件 Tab 不可达,保留既有名称与快捷键"
},
{
"id": "CORE-22",
@@ -3318,7 +3323,7 @@
"status": "todo",
"risk": "M",
"deliverable": "中英文文档、插件页面、demo URL、编辑器声明与生成目录清单;六类 HTML 入口、prod/libs/code/example 加载行为及移动重定向",
"acceptance": "所有公开 API/插件有对应页面或明确补充任务",
"acceptance": "所有公开 API/插件有对应页面或明确补充任务;核实 BASE-DEMO-01 的历史 thumbnail 插件来源及 29 示例/36 HTML 路径",
"evidence": []
},
{
@@ -3494,7 +3499,7 @@
"status": "todo",
"risk": "H",
"deliverable": "8082 全 demo、HLS/DASH/FLV/MPEGTS/WebTorrent 集成记录",
"acceptance": "旧 URL/参数/脚本加载保持;网络/SDK 限制明确,不静默跳过;覆盖编辑器重复 Run 和 ESM/i18n/mobile/iframe,记录实际候选脚本及状态隔离证据",
"acceptance": "旧 URL/参数/脚本加载保持;网络/SDK 限制明确,不静默跳过;覆盖编辑器重复 Run 和 ESM/i18n/mobile/iframe,记录实际候选脚本及状态隔离证据;依据 BASE-04 路径台账逐项消除 not-run,处理 BASE-DEMO-01",
"evidence": []
},
{