From 33a57f27c0b52c745a94878bed503c70ee5f4232 Mon Sep 17 00:00:00 2001 From: Rui-Sun Date: Tue, 22 Sep 2026 11:31:05 +0800 Subject: [PATCH 1/7] feat: add visual testing framework with build and runner scripts --- .gitignore | 3 + common/config/rush/pnpm-lock.yaml | 33 +- packages/vchart/__tests__/visual/DESIGN.md | 219 ++++++++ packages/vchart/__tests__/visual/README.md | 185 +++++++ .../__tests__/visual/cases/axis-label.mjs | 27 + .../__tests__/visual/cases/bar-stack.mjs | 10 + .../__tests__/visual/cases/datazoom-drag.mjs | 39 ++ .../vchart/__tests__/visual/cases/index.mjs | 76 +++ .../__tests__/visual/cases/legend-filter.mjs | 26 + .../__tests__/visual/cases/line-gap.mjs | 21 + .../__tests__/visual/cases/pie-label.mjs | 21 + .../__tests__/visual/cases/scatter-symbol.mjs | 28 + .../__tests__/visual/cases/tooltip-hover.mjs | 40 ++ .../__tests__/visual/cases/update-resize.mjs | 33 ++ .../__tests__/visual/cases/waterfall.mjs | 29 + packages/vchart/__tests__/visual/helpers.mjs | 51 ++ packages/vchart/__tests__/visual/page.html | 71 +++ .../__tests__/visual/playwright.config.mjs | 37 ++ packages/vchart/__tests__/visual/report.mjs | 380 ++++++++++++++ packages/vchart/__tests__/visual/reporter.mjs | 90 ++++ packages/vchart/__tests__/visual/settings.mjs | 12 + .../vchart/__tests__/visual/visual.spec.mjs | 134 +++++ packages/vchart/package.json | 6 +- packages/vchart/scripts/visual-test.mjs | 76 +++ packages/vchart/scripts/visual-test.test.mjs | 495 ++++++++++++++++++ packages/vchart/scripts/visual/build.mjs | 156 ++++++ packages/vchart/scripts/visual/runner.mjs | 320 +++++++++++ packages/vchart/scripts/visual/runtime.mjs | 173 ++++++ 28 files changed, 2787 insertions(+), 4 deletions(-) create mode 100644 packages/vchart/__tests__/visual/DESIGN.md create mode 100644 packages/vchart/__tests__/visual/README.md create mode 100644 packages/vchart/__tests__/visual/cases/axis-label.mjs create mode 100644 packages/vchart/__tests__/visual/cases/bar-stack.mjs create mode 100644 packages/vchart/__tests__/visual/cases/datazoom-drag.mjs create mode 100644 packages/vchart/__tests__/visual/cases/index.mjs create mode 100644 packages/vchart/__tests__/visual/cases/legend-filter.mjs create mode 100644 packages/vchart/__tests__/visual/cases/line-gap.mjs create mode 100644 packages/vchart/__tests__/visual/cases/pie-label.mjs create mode 100644 packages/vchart/__tests__/visual/cases/scatter-symbol.mjs create mode 100644 packages/vchart/__tests__/visual/cases/tooltip-hover.mjs create mode 100644 packages/vchart/__tests__/visual/cases/update-resize.mjs create mode 100644 packages/vchart/__tests__/visual/cases/waterfall.mjs create mode 100644 packages/vchart/__tests__/visual/helpers.mjs create mode 100644 packages/vchart/__tests__/visual/page.html create mode 100644 packages/vchart/__tests__/visual/playwright.config.mjs create mode 100644 packages/vchart/__tests__/visual/report.mjs create mode 100644 packages/vchart/__tests__/visual/reporter.mjs create mode 100644 packages/vchart/__tests__/visual/settings.mjs create mode 100644 packages/vchart/__tests__/visual/visual.spec.mjs create mode 100644 packages/vchart/scripts/visual-test.mjs create mode 100644 packages/vchart/scripts/visual-test.test.mjs create mode 100644 packages/vchart/scripts/visual/build.mjs create mode 100644 packages/vchart/scripts/visual/runner.mjs create mode 100644 packages/vchart/scripts/visual/runtime.mjs diff --git a/.gitignore b/.gitignore index b25bde8401..4696b98cac 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,6 @@ packages/vchart/__tests__/runtime/node/**.png *.tsbuildinfo .github/hooks/copilot-hooks.json .omx/ + +# Local visual regression artifacts +.vchart-visual/ diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 917bef60ae..d15831adf6 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -571,6 +571,9 @@ importers: '@internal/typescript-json-schema': specifier: workspace:* version: link:../../tools/typescript-json-schema + '@playwright/test': + specifier: 1.63.0 + version: 1.63.0 '@rushstack/eslint-patch': specifier: ~1.1.4 version: 1.1.4 @@ -1255,7 +1258,7 @@ importers: version: 4.9.5 vitest: specifier: 0.30.1 - version: 0.30.1(jsdom@16.7.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(sass@1.32.11)(stylus@0.54.8)(sugarss@2.0.0)(terser@5.17.1) + version: 0.30.1(jsdom@16.7.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(playwright@1.63.0)(sass@1.32.11)(stylus@0.54.8)(sugarss@2.0.0)(terser@5.17.1) ../../tools/story-player: dependencies: @@ -2352,6 +2355,11 @@ packages: engines: {node: '>=10'} deprecated: This functionality has been moved to @npmcli/fs + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + '@pmmmwh/react-refresh-webpack-plugin@0.4.3': resolution: {integrity: sha512-br5Qwvh8D2OQqSXpd1g/xqXKnK0r+Jz6qVKBbWmpUcrbGOxUrf39V5oZ1876084CGn18uMdR5uvPqBv9UqtBjQ==} engines: {node: '>= 10.x'} @@ -9440,6 +9448,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + please-upgrade-node@3.2.0: resolution: {integrity: sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==} @@ -13892,6 +13910,10 @@ snapshots: mkdirp: 1.0.4 rimraf: 3.0.2 + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + '@pmmmwh/react-refresh-webpack-plugin@0.4.3(react-refresh@0.9.0)(sockjs-client@1.4.0)(type-fest@0.13.1)(webpack-dev-server@3.11.0(webpack@4.46.0))(webpack@4.46.0)': dependencies: ansi-html: 0.0.7 @@ -23475,6 +23497,12 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + please-upgrade-node@3.2.0: dependencies: semver-compare: 1.0.0 @@ -26589,7 +26617,7 @@ snapshots: sugarss: 2.0.0 terser: 5.17.1 - vitest@0.30.1(jsdom@16.7.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(sass@1.32.11)(stylus@0.54.8)(sugarss@2.0.0)(terser@5.17.1): + vitest@0.30.1(jsdom@16.7.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(playwright@1.63.0)(sass@1.32.11)(stylus@0.54.8)(sugarss@2.0.0)(terser@5.17.1): dependencies: '@types/chai': 4.3.20 '@types/chai-subset': 1.3.6(@types/chai@4.3.20) @@ -26619,6 +26647,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: jsdom: 16.7.0(canvas@2.11.2(encoding@0.1.13)) + playwright: 1.63.0 transitivePeerDependencies: - less - sass diff --git a/packages/vchart/__tests__/visual/DESIGN.md b/packages/vchart/__tests__/visual/DESIGN.md new file mode 100644 index 0000000000..4fcf610851 --- /dev/null +++ b/packages/vchart/__tests__/visual/DESIGN.md @@ -0,0 +1,219 @@ +# VChart 本地视觉测试工具设计 v1 + +状态:规范化实现已接入,平台验收记录以 README 为准;Linux 待验收。日期:2026-09-21。 + +本文将已验证的原型整理为可维护的仓库工具。本文记录设计决策与验收目标;当前实现和实际完成的验证以 README 及对应运行报告为准。 + +## 1. 产品边界与完成定义 + +第一版服务 VChart 核心包的本地开发:以同一套仓库内用例,在同一台机器分别运行当前工作区产物与官方 develop 固定提交的产物,输出可以供开发者及编码 Agent 检查的证据。 + +使用现有 10 个本地精简用例跑通正式工具的全流程。用例与线上 BugServer 没有运行时依赖,不读取、不导出、不同步线上 case。默认比较仍需要访问公开 GitHub,以及冷启动时的公开包和浏览器下载源;“不依赖内网”不等于“所有命令完全离线”。依赖准备完成后的自比较不获取远端基线;HTML 报告可离线打开。 + +本阶段包含:用例发现与校验、环境预检、基线解析、双侧构建与隔离、浏览器渲染和交互验证、截图比较、三图 HTML、结构化 JSON、Agent 摘要、异常退出与资源清理。 + +沿用 macOS/Linux、Node.js 22、锁定版本的 Playwright/Chromium。暂不增加多产品抽象、独立 npm 工具包、扩展包、浏览器矩阵、Web 管理后台、GitHub CI、永久图片基线或接受差异机制。后续出现第二个产品的真实接入需求,再评估提取共享代码。 + +第一版完成的含义是:新贡献者按文档准备环境后,能够添加一个本地 case、执行双侧对比、定位差异、按指定 SHA 重跑;所有异常均有明确退出码和诊断;macOS 与 Linux 分别完成验收。 + +## 2. 已验证部分与需要规范化的部分 + +| 范围 | 原型已具备 | 第一版需要补齐 | +| -------- | ------------------------------------------------------- | ------------------------------------------------------------------ | +| 执行入口 | 默认 develop、指定 SHA、单 case、自比较、0/1/2 退出码 | 明确预检与列举入口,错误输出统一,导入模块不注册进程级副作用 | +| 用例 | 10 个本地 spec,4 类交互断言 | 一用例一文件、显式注册、契约校验、精确来源、每个用例的语义检查 | +| 构建 | 最小依赖链、detached worktree、当前未提交修改、基线缓存 | 校验新产物、依赖准备诊断、构建配方独立摘要、缓存损坏恢复 | +| 输入冻结 | 复制用例目录,两侧共用 | 从冻结目录读取清单,递归记录相对路径与摘要,发现集合与执行集合一致 | +| 结果 | 三图、JSON、Markdown,缺图转执行错误 | 固定结果协议、精确 ID 集合校验、稳定错误码、原始结果增量落盘 | +| 清理 | 普通结束和信号路径已验证 | 清理步骤互不阻断、锁所有权、报告失败兜底、阶段失败耗时 | +| 验收 | macOS 自比较与故障注入通过 | 重构后重新验收;外部贡献者准备环境检查;Linux 实测 | + +设计前原型的问题包括:入口文件同时承担 Git、构建、缓存、服务、进程和编排;缓存配方摘要直接使用整个入口文件;用例来源行号通过 `id` 字符串查找;顶层文件摘要未覆盖未来嵌套资源;准备失败发生在运行目录建立之前时没有文件报告;阶段完整性目前主要检查数量而非准确的用例 ID 集合。规范化应解决这些边界,而不是简单移动文件。 + +## 3. 命令与配置 + +保留现有命令,不要求贡献者迁移已有用法: + +```sh +node packages/vchart/scripts/visual-test.mjs +node packages/vchart/scripts/visual-test.mjs --baseline <40位SHA> +node packages/vchart/scripts/visual-test.mjs --case pie-label +node packages/vchart/scripts/visual-test.mjs --self-compare +``` + +包内 `test:visual` 继续指向相同入口。`--case` 与默认基线、指定 SHA 或自比较组合使用。未知选项、空字符串、未知 case 和互斥参数均失败,不静默忽略。 + +设计新增两个只读入口: + +- `--list`:列出本地用例的 ID、目的、文件和来源示例,不构建、不拉取基线、不启动浏览器。 +- `--check`:检查 Node、Git、受支持平台、用例契约、Playwright/Chromium 安装与当前工作区依赖。用最小浏览器启动/关闭检查系统库;不截图、不构建、不自动安装依赖。 + +`--list`、`--check` 为独立模式,仅可与 `--help` 的优先帮助行为共存;其他运行参数组合报错。`--help` 不要求先安装 Playwright。`--check` 只验证本机准备状态,不声称基线可下载或未来构建必定成功。 + +环境、截图阈值和超时集中在仓库内配置文件,纳入代码审查,不开放任意 Playwright 参数透传。第一版不增加并发数、自动重试、阈值覆盖或更新基线的 CLI 开关。安装沿用现有 Rush 和 Chromium 准备命令;缺依赖时给出命令,工具不修改依赖版本。 + +## 4. 代码组织与职责 + +建议在现有入口旁按真实职责拆分,不创建通用框架: + +```text +packages/vchart/ + scripts/ + visual-test.mjs # CLI、参数解析、运行入口 + visual/ + runner.mjs # 冻结输入、阶段编排、最终状态 + build.mjs # 官方基线、worktree、构建与缓存 + runtime.mjs # 子进程、回环 HTTP 服务、资源清理 + visual-test.test.mjs # 工具契约和故障路径检查 + __tests__/visual/ + cases/ + index.mjs # 显式清单:ID、文件、来源与加载函数 + bar-stack.mjs # 其余 9 个 case 同级 + ... + helpers.mjs # 已有重复定位与状态等待逻辑 + page.html # 注入指定产物、创建/释放实例 + visual.spec.mjs # 通用生命周期与截图断言 + playwright.config.mjs # 唯一环境与比较策略 + reporter.mjs # Playwright 事件与附件转换 + report.mjs # 汇总与 HTML/Markdown 输出 + README.md # 使用与新增 case 教程 + DESIGN.md # 本设计 +``` + +`.mjs` 沿用 Node 直接运行,核心对象以 JSDoc 描述,避免为测试工具增加独立编译链。CLI 不被浏览器导入;用例文件不导入 Node API 或测试框架运行时代码,允许两侧页面共用。用例执行函数中通过 `page` 操作页面,不直接引用仓库 VChart 源码。 + +仅在函数需要独立验证或有真实调用方时导出。`runtime.mjs` 采用本次运行的资源集合;信号处理器在入口注册、结束后移除,不在模块导入时安装。保留中文核心函数说明。 + +## 5. 用例契约与首批覆盖 + +清单负责元数据和模块路径;每个文件默认导出 `createSpec()`、可选 `exercise(page)` 和必需的 `verify(page)`。 + +| 字段 | 规则 | +| ---------------- | -------------------------------------------------------- | +| `id` | 清单内唯一,`^[a-z][a-z0-9-]*$`,已有 10 个 ID 保持不变 | +| `purpose` | 一句话说明要观察的行为,而不是只写图表类型 | +| `file` | `cases/` 内明确的相对模块路径;不接受越界路径 | +| `sourceExample` | 原始本地调试示例的仓库相对路径,便于追溯精简来源 | +| `createSpec()` | 返回新的确定性 spec;固定数据,不导入当前源码、不读网络 | +| `exercise(page)` | 可选,执行真实鼠标动作或公开 API;操作成功不代表用例通过 | +| `verify(page)` | 验证目标状态;条件不满足抛出错误,不以截图生成替代断言 | + +不把元数据在清单与用例文件中重复维护。清单校验覆盖重复 ID、文件缺失、路径越界、非法导出及空选择;无效清单在构建前失败。实现时采用明确模块映射,并确保 Node 端验证与浏览器加载使用同一冻结清单。 + +每个用例只输出最终一张图。公共检查确保实例、Canvas、数据图元及有效像素存在;用例检查确保其目标行为发生。不能依赖图元内部结构完成的稳定语义检查,不强行编写内部节点精确断言;可以检查公开 spec/API 可观测状态,并用截图覆盖布局。确实需要 VRender 场景树定位鼠标目标的交互,统一放在 `helpers.mjs`,定位失败必须显式报错。 + +下表来源均为 `packages/vchart/__tests__/runtime/browser/test-page/` 内现存文件: + +| ID | 来源文件 | 最终状态与验证要求 | +| ---------------- | --------------------------- | ----------------------------------------------------------------------- | +| `bar-stack` | `bar.ts` | 固定正负两组数据;有效堆叠柱图,截图覆盖零基线与正负累计位置 | +| `line-gap` | `data-zoom-brush-line.ts` | 固定空值并显式指定连接策略;断点/连接规则写入 purpose,由截图覆盖形状 | +| `scatter-symbol` | `scatter.ts` | 明确数据项与符号/大小映射,非空点集;截图覆盖位置、形状和大小 | +| `pie-label` | `pie-label.ts` | 固定大小扇区、外标签与引导线;数据/标签配置有效,截图覆盖防重叠和引导线 | +| `axis-label` | `axis-label-layout.ts` | 固定长英文分类和旋转配置;截图覆盖裁切、旋转及轴布局 | +| `waterfall` | `waterfall.ts` | 固定增减项与总计;验证数据和总计配置,截图覆盖累计高度和连接线 | +| `legend-filter` | `multiple-legend-layout.ts` | 点击一个图例项;选择状态和可见数据均改变,最终截图反映筛选 | +| `tooltip-hover` | `tooltip.ts` | 悬停已知数据点;目标 HTML tooltip 可见且包含精确期望值 | +| `datazoom-drag` | `datazoom.ts` | 拖动控件;公开缩放事件范围改变,可视数据减少,鼠标移出后截图 | +| `update-resize` | `event-update-spec.ts` | 调用 update/resize;更新值和画布尺寸均正确,等待最终绘制 | + +静态用例的语义检查不是另写一套像素布局算法;它防止空图、错误输入与错误图表类型,视觉比较负责呈现差异。新功能尚未被基线支持时是执行错误,不能自动跳过、换旧用例或算作通过。 + +初期不增加用例总数。先迁移并审核这 10 个精简 case,不改原始调试示例。新增用例需说明现有覆盖的缺口、确定性条件和故障注入办法,避免重新形成大量重复样例。 + +## 6. 一次运行的明确流程 + +```mermaid +flowchart TD + A[参数与本机预检] --> B[登记运行目录与锁] + B --> C[冻结本地用例和配置] + C --> D[解析官方 develop 或指定 SHA] + D --> E[构建工作区与基线产物] + E --> F[基线阶段:渲染、交互、验证、稳定截图] + F --> G{全部基线用例成功?} + G -->|是| H[本地阶段:相同输入与流程、比较截图] + G -->|否| I[记录错误和未执行用例] + H --> J[校验结果集合与必需附件] + I --> K[释放进程、浏览器、服务与 worktree] + J --> K + K --> L[最终 JSON、三图 HTML、Agent 摘要及退出码] +``` + +自比较跳过远端解析和基线构建,以同一次本地构建的副本作为两侧输入,其余生命周期完全一致。单 case 模式只缩小冻结清单中的选择集。 + +1. 先完成廉价参数检查;建立可写运行目录后立即记录运行标识、请求参数和未完成状态。缺少依赖、基线拉取/构建失败等均尽可能生成文件诊断。参数无法解析或输出目录不可写时允许仅 stderr,返回 2,明确没有生成报告。 +2. 获取带 `runId`、PID、启动时间及运行目录的仓库运行锁。保留同一工作区串行限制,因为本地构建会写公共产物目录。不静默夺取未知锁或终止已有进程;陈旧锁给出检查/清理路径。 +3. 复制用例、页面、配置与执行代码到运行目录。从副本发现并校验选择集。按排序后的相对路径、内容摘要生成递归清单,纳入所有实际测试资源;先前对源码目录的发现结果不能代替副本。 +4. 从固定的官方 GitHub URL 获取 develop 或完整 SHA,固定提交身份;不依赖 fork 的 origin,也不静默使用旧缓存。记录请求 ref 和解析 SHA。 +5. 本地沿用已安装依赖;基线 detached worktree 按自身锁文件安装。允许复用包管理器下载缓存,但两侧的安装目录与 workspace 包链接必须各自独立。记录锁文件、Node、Rush/包管理器版本和受控构建配置。 +6. 两侧使用同一最小构建配方。明确生成文件和输出目录,构建前只清理属于本次构建的生成产物;禁止递归清理源码或用户未跟踪文件。构建命令成功后仍检查目标产物确实新生成、非空,再复制并校验摘要。工作区在本地构建前后发生变化则终止,保留用户改动。 +7. 启动只监听 `127.0.0.1` 的随机端口服务,仅服务冻结用例和产物。浏览器执行公共绘制检查、交互、语义断言、字体就绪和稳定截图。稳定截图后再次检查页面错误,随后比较。 +8. 基线阶段生成本次临时快照;所有基线用例成功后才启动本地阶段。本地 `updateSnapshots: 'none'`,比较前确认基线存在。失败期间已经运行的 case 保留结果,其余 case 为 `not_run` 并给出原因。 +9. 将发现的选择集与两阶段结果按 ID 精确核对;重复、额外、缺失结果均为执行错误,不能仅比较数量。每个成功或差异 case 必须有两侧最终 PNG;差异结果还必须有对应差异图。 +10. 清理各类资源,最后汇总;清理异常单独记录,不覆盖原始失败。输出目录保留原始日志和阶段结果,报告生成异常仍返回 2,并在 stderr 给出可用证据位置。 + +环境固定:单 worker、无重试、全新 context、1000×800、图表 800×600、DPR 1、白底、en-US、UTC、Arial、30 秒单用例超时。禁用图表动画、tooltip 过渡与页面动画;固定页面截图包含 HTML tooltip。页面禁止远端请求,资源加载失败、页面异常及未处理 rejection 均失败。字体不跨机器复用截图,macOS/Linux 分别验收。 + +沿用 Playwright 的 PNG 比较与附件,颜色阈值 0.1、允许差异像素数 0;不实现第二套比较算法。比较抛错不能一律标为视觉差异:只有有效输入完成比较且产生视觉差异证据时才标为 `diff`,读取、解码、超时、附件缺失仍为执行错误。Playwright 提供原生截图比较与报告扩展能力:[视觉比较](https://playwright.dev/docs/test-snapshots)、[Reporters](https://playwright.dev/docs/test-reporters)。 + +## 7. 缓存、复现与运行隔离 + +只缓存最近一次成功的官方基线产物,每次重新截图。本地每次构建。缓存键包含 SHA、基线锁文件摘要、完整 Node 版本、平台/架构、实际构建配方及相关工具版本;拆分后计算 `build.mjs` 等实际构建依赖的摘要,避免改报告样式导致重新安装基线。 + +缓存 metadata 和 bundle 校验不一致、缺失或解析失败时记为 miss 并重建,而不是当作回归失败;权限或磁盘错误须明确报错。先写临时目录,成功后在锁保护下替换最近缓存,不发布半成品。基线 build 成功与截图成功分别记录,缓存只承诺产物构建成功。 + +复现命令固定基线 SHA,并标出本地 HEAD、dirty 和修改摘要。摘要不包含未提交修改的内容,不能凭摘要恢复工作区;报告保留冻结用例和两侧产物,便于检查实际输入,第一版不额外增加 replay 命令。`--self-compare` 只验证确定性,不能证明图表内容正确。 + +Ctrl+C/SIGTERM 终止本次子进程组,回收浏览器及 HTTP 服务,再清理本次 worktree 和锁。每项清理独立 try/finally,前一项失败不能阻止后一项;主错误和清理错误同时保留。SIGKILL/断电无法保证清理,遗留锁和阶段证据必须支持人工定位,工具不自动清除不属于本次运行的资源。 + +## 8. 结果协议与两类读者 + +继续使用 `summary.json` 作为唯一最终结果;保留 `schemaVersion: 1` 的已有字段与语义,兼容性扩展只增加可选字段。破坏性变更必须提升版本并说明迁移。用可运行契约测试验证生成结果,第一版不为此引入新的 schema 库。 + +建议补充 `runId`、`finalized`、请求参数、比较策略、冻结清单、阶段结果和诊断 `code`。运行中采用 `finalized: false` 和明确的未完成诊断;正常结束后原子写入最终 summary,HTML 与 Markdown 只渲染该对象。原始阶段结果按用例完成落盘,进程异常时不只依赖 `onEnd` 才有结果。 + +| 层级 | 状态规则 | +| ---------------------- | ------------------------------------------------------ | +| 用例 `passed` | 两侧执行、语义断言与截图成功,且图片无差异 | +| 用例 `diff` | 执行成功、图片比较完成,存在视觉差异并保留证据 | +| 用例 `error` | 执行、断言、截图、比较或必要证据失败 | +| 用例 `not_run` | 尚未完成两侧比较;记录阻断阶段和原因,不混为失败断言 | +| 运行 `error` / exit 2 | 任意执行/清理/报告错误,或空集、缺失结果、未完成选择集 | +| 运行 `diff` / exit 1 | 全部选择集有效完成,至少一个差异,没有执行错误 | +| 运行 `passed` / exit 0 | 全部选择集有效完成且无差异 | + +`complete` 仍表示所有所选用例是否完成有效比较,与 `finalized` 区分:一个正常结束的失败运行可以 finalized=true、complete=false;一个比较完整但清理失败的运行可以 complete=true、status=error。 + +诊断沿用 `phase`、`stage`、`category`、`message`;增加稳定 `code`,例如 `BASELINE_FETCH_FAILED`、`BUILD_FAILED`、`RESOURCE_FAILED`、`INTERACTION_ASSERTION_FAILED`、`TIMEOUT`、`MISSING_ARTIFACT`、`RESULT_SET_MISMATCH`、`RUN_INTERRUPTED`、`CLEANUP_FAILED`。错误来自明确失败位置,Agent 不需要解析自然语言来推断类别。保留原始错误与日志路径,不自动断言根因。 + +HTML 保留三图、四类计数、问题优先、用例/状态筛选、原图查看、源码位置、冻结副本和复现命令。成功 case 不伪造差异图;缺图显示明确诊断。所有图片链接为相对路径,可整体搬迁,不需要常驻服务或外网资源。 + +Agent 先读 `agent-summary.md` 的失败/未完成摘要,再读 JSON、源码或图片。JSON 使用相对证据路径,不包含图片 Base64;原始附件、trace 与日志按需读取。报告中没有模型调用、自动修复、自动放宽阈值或自动接受差异逻辑。 + +## 9. 交付步骤与验收门槛 + +| 阶段 | 实施内容 | 完成证据 | +| ------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | +| A:固定契约 | 拆分 10 个 case、显式清单、verify、来源记录、list/check、冻结输入 | 现有四个运行命令兼容;清单错误在构建前失败;迁移前后同产物无新增图片差异 | +| B:执行可靠性 | 拆分编排/构建/资源生命周期,精确选择集校验、缓存完整性、准备失败诊断、锁与清理 | 默认官方 develop 和指定 SHA 真正双侧构建通过;fork origin 不影响基线;失败路径返回 2 | +| C:报告协议 | 固定 summary 契约、阶段增量记录、原子终态、三图/Agent 一致性 | passed/diff/error/not_run 四类有实际或故障注入证据;缺附件不能变成 passed;离线页面可打开 | +| D:平台验收 | 外部准备环境验证、macOS/Linux 重复执行、耗时与清理检查、中文说明 | 两平台独立验收记录;全部必要检查完成才移除“原型”标识 | + +每阶段保持可执行,不一次性推倒重写。保留已验证原型作为行为参照,故障自检复用现有测试入口;故障注入只操作独立验证目录,不修改用户业务源码。 + +正式验收清单: + +- [ ] 两平台各 5 次完整十用例自比较:无差异、无执行错误。 +- [ ] 默认官方 develop、固定 SHA、单 case 均完整执行;fork origin 不改变默认基线。 +- [ ] 同一冻结 case 仅改变候选产物的颜色、位置、标签,均能检出差异并返回 1。 +- [ ] 工作区未提交源码进入本地构建;构建前后原有文件/索引保持,期间输入变化明确失败。 +- [ ] 基线失败、当前失败、浏览器启动失败、脚本缺失、渲染异常、超时、截图缺失、报告失败返回 2。 +- [ ] 四类交互分别抑制动作后必定失败;静态用例的错误输入、空绘制被检查捕获。 +- [ ] 空清单、重复 ID、错配结果、缺失结果、额外结果、缺失差异图不能产生通过结论。 +- [ ] 冷构建、缓存命中、缓存损坏重建均验证;记录 fetch、安装/构建、两侧截图、清理、报告阶段耗时,失败阶段也记耗时。 +- [ ] 截图阶段没有外网请求;干净公开环境可以完成依赖准备,不需要内网服务或凭证。 +- [ ] 正常退出、构建时和截图时 Ctrl+C/SIGTERM 无本次遗留进程和服务;重复运行无端口冲突。 +- [ ] 锁冲突不影响其他运行;清理失败保存原始错误;不可写输出路径给出明确终端诊断。 +- [ ] HTML 三图和 Agent JSON/Markdown 状态一致,目录搬迁后图片可用;人工故障演示不混入真实回归报告。 + +现有 macOS 原型证据用于说明方案可行,不替代上述正式版验收;Linux 当前仍未验收。内网 BugServer 继续承担发版前全量测试。 diff --git a/packages/vchart/__tests__/visual/README.md b/packages/vchart/__tests__/visual/README.md new file mode 100644 index 0000000000..47c740ef19 --- /dev/null +++ b/packages/vchart/__tests__/visual/README.md @@ -0,0 +1,185 @@ +# 本地视觉回归测试工具(Linux 待验收) + +工具的结构和设计取舍见 [设计稿](./DESIGN.md)。规范化实现已接入;macOS 验收结果见文末,Linux 仍需独立验收。 + +使用同一批精简用例,在本机分别运行当前工作区与官方 `VisActor/VChart` 的 develop 构建,生成截图和差异报告。无需内网 BugServer 或访问凭证。图片差异表示需要检查,不等同于缺陷;两侧共同存在的错误仍需其他测试发现。 + +## 准备 + +使用 Node.js 22。工具面向 macOS 14+ 和 Ubuntu 22.04/24.04;Linux 必须有 Chromium 所需系统库。先在仓库根目录完成依赖安装: + +```sh +node common/scripts/install-run-rush.js install --ignore-hooks +``` + +安装与锁文件对应的 Chromium: + +```sh +# macOS +node packages/vchart/node_modules/@playwright/test/cli.js install chromium --no-remove + +# Linux:系统依赖安装可能需要管理员权限 +node packages/vchart/node_modules/@playwright/test/cli.js install --with-deps chromium --no-remove +``` + +Rush 安装还需要项目现有的 node-canvas 编译依赖;按仓库贡献指南准备。截图命令本身不安装系统软件、不启动 Docker。 + +## 使用 + +在仓库根目录运行: + +```sh +node packages/vchart/scripts/visual-test.mjs +node packages/vchart/scripts/visual-test.mjs --baseline <完整的40位commit-sha> +node packages/vchart/scripts/visual-test.mjs --case pie-label +node packages/vchart/scripts/visual-test.mjs --self-compare +node packages/vchart/scripts/visual-test.mjs --list +node packages/vchart/scripts/visual-test.mjs --check +``` + +`--list` 独立列出用例元数据,不需要浏览器。`--check` 独立检查 Node、Git、当前依赖和 Chromium 实际启动,检查后关闭浏览器;不自动安装、不构建、不截图。预检日志位于 `.vchart-visual/preflight.log`。帮助不要求先安装 Playwright。 + +包目录内也可使用 `rushx test:visual`。`--help` 列出选项;不存在更新永久基线或接受差异的命令。 + +- 默认每次从官方仓库获取 develop,并固定本次 SHA;fork 的 `origin` 不影响基线选择。断网或拉取失败会报错,缓存不会冒充最新版本。 +- 指定 SHA 仍从官方仓库获取该提交;用于复现已知基线。首期不支持任意仓库或共同祖先自动选择。 +- 当前工作区包含未提交修改,每次重新构建;构建过程中源码发生变化会要求重跑。 +- 基线按自己的锁文件独立安装和构建。只缓存最近一次成功的基线构建,每次重新截图;缓存失效由 SHA、锁文件、Node、系统架构和构建配方决定;报告样式修改不使缓存失效,内容损坏会重建。 +- 自比较只构建本地一次,在两套隔离 context 中执行;它验证测试确定性,不能证明图表结果正确。 + +退出码:`0` 无差异;`1` 有视觉差异;`2` 参数、构建、执行、资源、超时、缺图或清理错误。多个问题同时出现时执行错误优先。 + +## 报告与清理 + +命令输出 `.vchart-visual/runs//index.html`,浏览器直接打开即可离线查看,无需启动报告服务: + +- 每个用例展示 **基线 / 本地 / 差异** 三列图片,点击图片打开原始分辨率;通过用例的差异列显示“无视觉差异”。 +- 汇总通过、视觉差异、执行错误、未完成数量;默认展示问题用例,可按状态和用例筛选。 +- 展示用例目的、实际用例源码行号、冻结用例链接、阶段诊断和单用例复现命令。指定基线 SHA 固定,但复现本地结果仍需要相同工作区修改。 +- 缺图明确显示“未生成或缺失”;已完成截图的结果丢失必要图片时,汇总升级为执行错误,CLI 返回 `2`。 +- 分享报告时复制整个运行目录,保留相对目录结构;单独复制 HTML 不包含图片。页面不请求外网。 + +同一次运行同时生成 `agent-summary.md` 和 `summary.json`,与 HTML 共用一份归一化结果: + +| 文件 / 字段 | 作用 | +| ------------------------------------------------ | ---------------------------------------------------------------------------------------- | +| `agent-summary.md` | Agent 首选入口:运行环境、失败/未完成用例、诊断、三图路径与复现命令;通过用例仅汇总 | +| `summary.json` / `schemaVersion: 1` | 版本化结构化结果,完整保存全部用例及证据路径,不嵌入图片 Base64 | +| `status` / `complete` / `counts` | 运行结论、是否完成所有用例的有效比较、四类用例计数;运行级错误仍可使已完成比较的运行失败 | +| `baseline` / `local` / `environment` / `timings` | 基线 SHA、本地 HEAD/dirty/修改摘要、运行时与浏览器版本、阶段耗时 | +| `cases[].source` | `path` 相对于仓库根目录;`line` 指向冻结模块入口;`frozenPath` 相对于报告目录 | +| `cases[].phases` / `errors` | 两侧状态及错误的 `phase`、`stage`、`category`、原始诊断文本 | +| `cases[].images` / `attachments` / `rerun` | 三图相对路径(缺失为 `null`)、截图/trace 等证据路径、仓库根目录复现命令 | +| `issues` / `logs` | 构建、中断、清理等运行级错误,以及实际存在的日志和原生报告路径 | + +用例状态为 `passed`、`diff`、`error`、`not_run`;运行状态为前三种。错误类别包括 `visual_difference`、`missing_artifact`、`timeout`、`resource`、`render`、`interaction`、`screenshot`、`comparison`、`setup`,以及运行级 `execution` / `report`。Agent 应先读取摘要,再按需读取 JSON、源码、图片或日志;报告只给出事实,不推断根因、不放宽阈值、不自动接受差异,也不调用任何模型 API。 + +基线和本地阶段的 Playwright HTML/JSON 报告仍保留用于详细调试。失败阶段可能没有完整原生 HTML,汇总页只链接实际存在的报告与日志。 + +需要交互查看 Playwright 报告时,显式运行: + +```sh +node packages/vchart/node_modules/@playwright/test/cli.js show-report .vchart-visual/runs//current-report +``` + +查看完成后 Ctrl+C 退出报告服务。测试命令不会自动启动此常驻服务。 + +正常结束、失败和 Ctrl+C 会回收测试子进程、浏览器、HTTP 服务及临时 worktree。报告保留供检查。SIGKILL 或断电无法执行清理:先确认无测试进程,再检查 `git worktree list`、删除该次临时 worktree 和 `.vchart-visual/running.lock` 后重跑。请勿删除其他任务的 worktree。 + +`.vchart-visual/` 已被 Git 忽略。确认没有测试运行后,可删除旧 `runs` 释放磁盘空间;不要提交截图,也不要把基线替换为待测图片。 + +## 用例约定 + +十个本地用例在 `cases/index.mjs` 显式注册,一用例一文件;元数据仅在清单中维护。每项包含 `id`、`purpose`、`file`、`sourceExample`。ID 必须唯一且符合 `^[a-z][a-z0-9-]*$`,file 是 cases 目录内的 `./.mjs`;缺文件、非法导出和空集合在构建前失败。 + +用例模块默认导出 `createSpec()`、可选 `exercise(page)`、必需 `verify(page)`。核心函数补中文说明。新增用例可复制以下结构: + +```js +export default { + createSpec() { + // 固定输入,每次创建新对象。 + return { type: 'bar', data: { values: [{ x: 'A', y: 10 }] }, xField: 'x', yField: 'y' }; + }, + async verify(page) { + // 检查公开 spec,图片比较负责布局呈现。 + await page.evaluate(() => { + if (window.__visualChart.getSpec().type !== 'bar') throw new Error('图表类型不正确'); + }); + } +}; +``` + +在清单中注册模块和原始本地示例路径,然后运行 `--list`、`--check`、`--self-compare --case ` 及默认基线单用例比较。新增 case 需说明现有覆盖缺口、确定性约束和故障验证方式;不修改原始调试示例。 + +`createSpec()` 不能导入本地 VChart 源码、Node API 或测试框架运行时代码,也不能加载网络数据。页面只加载指定产物;两侧共用冻结副本。`exercise()` 执行动作,`verify()` 验证实际目标状态;不能只等待固定时间或只检查图片存在。共享的场景树定位在 `helpers.mjs`,定位不到目标必须失败。 + +静态验证核对明确指定的类型、数据及配置子集,允许 VChart 合并主题默认值;公共检查还要求有效画布、图元及绘制像素。图例、tooltip、缩放和更新尺寸分别验证真实状态改变。 + +确定性配置集中在 `settings.mjs`:单 worker、无重试、新 context、Chromium headless、1000×800、图表 800×600、DPR 1、白底、Arial、en-US、UTC、单用例 30 秒。关闭动画,等待字体与连续稳定截图;像素颜色阈值 0.1、允许差异像素数 0。固定英文文本和数据,不使用远程图片或字体。 + +## 结果与错误协议 + +`summary.json` 是 HTML 和 Agent 摘要的共同数据源。已有 schemaVersion=1 字段保持兼容,新增 `runId`、`finalized`、`request`、`comparison`、`frozenFiles`、分阶段耗时和诊断 `code`。未完成用例还记录 `blockedBy`。 + +`finalized` 表示运行已形成终态,`complete` 表示所选用例全部完成有效比较;清理失败可以 finalized=true、complete=true、status=error。执行中保留 finalized=false 的 JSON;每完成一个用例原子落盘阶段结果,异常退出不丢失已完成诊断。 + +稳定错误码包括 `INVALID_ARGUMENT`、`PREFLIGHT_FAILED`、`CASE_MANIFEST_INVALID`、`BASELINE_FETCH_FAILED`、`BUILD_FAILED`、`WORKSPACE_CHANGED`、`RESOURCE_FAILED`、`RENDER_FAILED`、`CASE_ASSERTION_FAILED`、`INTERACTION_ASSERTION_FAILED`、`SCREENSHOT_FAILED`、`COMPARISON_FAILED`、`TIMEOUT`、`MISSING_ARTIFACT`、`RESULT_SET_MISMATCH`、`RUN_LOCKED`、`RUN_INTERRUPTED`、`RUN_INCOMPLETE`、`CLEANUP_FAILED`、`REPORT_FAILED` 和兜底 `EXECUTION_FAILED`。只有完整的原生比较证据才会标为 `VISUAL_DIFFERENCE`。 + +准备失败尽可能生成报告。参数不可解析或输出不可写时可能只有 stderr,退出码仍是 2;报告写入失败时保留阶段证据并尽可能写入错误 JSON,不保留误导性的成功 HTML。原始错误文字供排查,Agent 应优先使用稳定错误码。 + +锁的 `owner.json` 记录 runId、PID、启动时间和目录;工具不自动夺锁。SIGINT/SIGTERM 逐项清理本次资源,主错误与清理错误同时保留。SIGKILL/断电后的锁需人工确认,不允许据此终止其他进程。 + +## 验证工具本身 + +先完成一次自比较以生成本地 UMD,再运行故障自检: + +```sh +node --test packages/vchart/scripts/visual-test.test.mjs +``` + +自检在独立临时目录中验证:相同产物通过、仅候选构建改变颜色产生差异、缺图不自动生成基线、脚本缺失/异常/外部请求/超时及构建命令失败被归类为执行错误。自检结束自动清理目录和服务。 + +平台验收及实测耗时见本文件末尾的验证记录;没有实际验证的平台不能视为已通过。 + +## 原型历史记录(2026-09-21) + +环境:macOS 14.7.8 / arm64,Node.js 22.22.2,Playwright 1.63.0,Chromium 153.0.8010.12。官方基线为 `67400f3fb6501f62455392089b7a7d8367cf6b9a`。以下数据来自当前机器,不能作为其他机器的耗时保证。 + +| 检查 | 结果 | +| --------------------------------------- | ------------------------------------------------------------------------------------------------- | +| 十用例完整自比较,连续 5 轮 | 全部通过;每轮 33.8 ~ 36.9 秒 | +| 官方 develop 独立安装、构建和十用例对比 | 全部通过;最终版本约 173.5 秒 | +| 指定同一 SHA,命中基线构建缓存 | 全部通过;约 34.7 秒 | +| 本地构建 / 双侧十用例截图 | 自比较中位数分别约 21.7 / 11.9 秒 | +| 故障自检 | 11 项原型测试通过,包含四类无效交互、差异检出、缺图、加载错误、运行异常、外部请求、超时及命令失败 | +| 未提交源码进入构建 | 临时唯一导出标记出现在产物中;原文件按字节恢复 | +| 构建阶段及浏览器阶段 SIGINT | 返回 2,运行锁清理;浏览器阶段观察到的 18 个相关子进程均退出 | +| 临时 worktree / 用户原有改动 | 临时 worktree 已回收,原有 image-cloud 改动保留 | +| Linux | 未验收:当前环境没有可用的 Linux 执行环境 | + +首次基线准备会安装该版本的 monorepo 依赖,耗时包含网络下载、原生模块编译和 worktree 清理;命中缓存时仅复用构建产物,截图仍全部重新生成。首次准备当前工作区依赖和下载浏览器的时间不包含在上述测试耗时内。 + +原型报告增强验证:macOS 上 12 项自动检查通过,包括真实颜色差异的三图关联、`file://` 页面图片加载与筛选、无外网请求、差异图丢失升级为错误、准备失败保留未完成用例、HTML 转义和 Markdown 诊断。报告增强未单独完成 Linux 验收。 + +截图测试不替代内网 BugServer 的发版前全量测试。Linux 上仍需执行五轮完整自比较和故障自检后,才能标记该平台通过验收。 + +## 规范化版本验收 + +环境:macOS 14.7.8 / arm64,Node.js 22.22.2,Playwright 1.63.0,Chromium 153.0.8010.12。规范化代码的 macOS 验收完成,Linux 仍待验收。 + +| 检查 | 结果 | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------- | +| 原型与迁移用例使用同一构建产物 | 10 个 case 截图一致;`migration-check.json` | +| 十用例连续 5 轮完整自比较 | 每轮 10/10 通过;32.4 ~ 35.8 秒,无差异或执行错误 | +| 官方 develop 冷启动 | 10/10 通过,约 174.0 秒;固定 SHA `67400f3fb6501f62455392089b7a7d8367cf6b9a` | +| 指定同一 SHA,缓存命中 | 10/10 通过,约 35.7 秒;单 case 自比较约 25.0 秒 | +| 冷启动分阶段耗时 | 本地构建 19.8 秒;基线安装 79.6 秒、编译 22.9 秒;两侧截图合计 12.1 秒;清理 35.3 秒 | +| 工具自动检查 | 24 项全部通过;含三种视觉差异、空图、错误输入、四类无效交互、浏览器缺失、增量报告、缓存/锁/结果集合和写入失败 | +| 隔离 fork 与未提交源码 | origin 改为其他地址后仍获取官方 develop;唯一未提交导出进入 UMD;源码及 origin 保持原样 | +| 构建 SIGINT / 浏览器 SIGTERM | 均返回 2,记录 RUN_INTERRUPTED;运行锁移除,观察到的子进程无残留 | +| 离线与报告搬迁 | 三图、筛选和图片路径检查通过;HTML 不请求外网 | +| Linux | 待验收,不能用以上结果替代 | + +本机证据位于 `.vchart-visual/acceptance/`:`runs.json`、`node-tests.log`、`isolated-fork.json`、`signals.json`;迁移证据位于 `.vchart-visual/migration-check.json`。这些是本机生成产物,不提交 Git。其他机器应重新运行对应检查,不将此耗时作为承诺。 + +Linux 后续执行相同矩阵,并在公开环境验证依赖准备;双平台验收完成后再移除平台待验收标记。 diff --git a/packages/vchart/__tests__/visual/cases/axis-label.mjs b/packages/vchart/__tests__/visual/cases/axis-label.mjs new file mode 100644 index 0000000000..d6e23c2706 --- /dev/null +++ b/packages/vchart/__tests__/visual/cases/axis-label.mjs @@ -0,0 +1,27 @@ +import { verifySpec } from '../helpers.mjs'; + +/** 长文本、旋转和轴布局(axis-label-layout)。 */ +export default { + createSpec() { + // 显式旋转长标签,避免依赖自动阈值才能触发目标行为。 + return { + type: 'bar', + data: { + id: 'data', + values: ['North America', 'South America', 'Central Europe', 'South East Asia', 'Western Pacific'].map( + (x, i) => ({ x, y: 10 + i * 7 }) + ) + }, + xField: 'x', + yField: 'y', + axes: [ + { orient: 'bottom', label: { autoRotate: false, style: { angle: -35 } } }, + { orient: 'left', title: { visible: true, text: 'Revenue' } } + ] + }; + }, + async verify(page) { + // 核对目标输入,避免错误 spec 或空数据通过图片对比。 + await verifySpec(page, this.createSpec()); + } +}; diff --git a/packages/vchart/__tests__/visual/cases/bar-stack.mjs b/packages/vchart/__tests__/visual/cases/bar-stack.mjs new file mode 100644 index 0000000000..4adeaae2b7 --- /dev/null +++ b/packages/vchart/__tests__/visual/cases/bar-stack.mjs @@ -0,0 +1,10 @@ +import { barSpec, verifySpec } from '../helpers.mjs'; + +/** 正负值、堆叠与零基准线(bar)。 */ +export default { + createSpec: barSpec, + async verify(page) { + // 核对目标输入,避免错误 spec 或空数据通过图片对比。 + await verifySpec(page, this.createSpec()); + } +}; diff --git a/packages/vchart/__tests__/visual/cases/datazoom-drag.mjs b/packages/vchart/__tests__/visual/cases/datazoom-drag.mjs new file mode 100644 index 0000000000..23c4063dc0 --- /dev/null +++ b/packages/vchart/__tests__/visual/cases/datazoom-drag.mjs @@ -0,0 +1,39 @@ +import { graphicCenter } from '../helpers.mjs'; + +/** 拖动后的可视范围(datazoom)。 */ +export default { + createSpec() { + // 给缩放控件留出明确的初始范围。 + return { + type: 'bar', + data: { + id: 'data', + values: Array.from({ length: 12 }, (_, i) => ({ x: `M${i + 1}`, y: 10 + ((i * 13) % 40) })) + }, + xField: 'x', + yField: 'y', + dataZoom: [{ orient: 'bottom', start: 0, end: 1, filterMode: 'filter' }] + }; + }, + async exercise(page) { + // 监听公开事件,只有拖动真正改变范围才完成测试。 + await page.evaluate(() => { + window.__visualChart.on('dataZoomChange', event => { + window.__zoomResult = event.value; + }); + }); + const point = await graphicCenter(page, 'startHandler'); + await page.mouse.move(point.x, point.y); + await page.mouse.down(); + await page.mouse.move(point.x + 180, point.y, { steps: 12 }); + await page.mouse.up(); + await page.mouse.move(950, 750); + }, + async verify(page) { + // 缩放事件和可视数据必须均已改变。 + await page.waitForFunction(() => window.__zoomResult?.start > 0.1); + await page.waitForFunction( + () => window.__visualChart.getChart().getAllSeries()[0].getViewData().latestData.length < 12 + ); + } +}; diff --git a/packages/vchart/__tests__/visual/cases/index.mjs b/packages/vchart/__tests__/visual/cases/index.mjs new file mode 100644 index 0000000000..2c2ce2e9f0 --- /dev/null +++ b/packages/vchart/__tests__/visual/cases/index.mjs @@ -0,0 +1,76 @@ +/** + * @typedef {{id: string, purpose: string, file: string, sourceExample: string}} CaseMetadata + * @typedef {{createSpec: () => object, exercise?: (page: import('@playwright/test').Page) => Promise, verify: (page: import('@playwright/test').Page) => Promise}} VisualCase + */ +/** 本地用例元数据的唯一清单;模块必须保持浏览器和 Node 均可导入。 */ +export const cases = [ + { + id: 'bar-stack', + purpose: '正负值、堆叠与零基准线(bar)', + file: './bar-stack.mjs', + sourceExample: 'packages/vchart/__tests__/runtime/browser/test-page/bar.ts' + }, + { + id: 'line-gap', + purpose: '缺失值与折线连接(data-zoom-brush-line)', + file: './line-gap.mjs', + sourceExample: 'packages/vchart/__tests__/runtime/browser/test-page/data-zoom-brush-line.ts' + }, + { + id: 'scatter-symbol', + purpose: '散点位置、大小及符号(scatter)', + file: './scatter-symbol.mjs', + sourceExample: 'packages/vchart/__tests__/runtime/browser/test-page/scatter.ts' + }, + { + id: 'pie-label', + purpose: '外侧标签与引导线(pie-label)', + file: './pie-label.mjs', + sourceExample: 'packages/vchart/__tests__/runtime/browser/test-page/pie-label.ts' + }, + { + id: 'axis-label', + purpose: '长文本、旋转和轴布局(axis-label-layout)', + file: './axis-label.mjs', + sourceExample: 'packages/vchart/__tests__/runtime/browser/test-page/axis-label-layout.ts' + }, + { + id: 'waterfall', + purpose: '累计、总计与连接线(waterfall)', + file: './waterfall.mjs', + sourceExample: 'packages/vchart/__tests__/runtime/browser/test-page/waterfall.ts' + }, + { + id: 'legend-filter', + purpose: '图例点击筛选(multiple-legend-layout)', + file: './legend-filter.mjs', + sourceExample: 'packages/vchart/__tests__/runtime/browser/test-page/multiple-legend-layout.ts' + }, + { + id: 'tooltip-hover', + purpose: '鼠标悬停后的 HTML tooltip(tooltip)', + file: './tooltip-hover.mjs', + sourceExample: 'packages/vchart/__tests__/runtime/browser/test-page/tooltip.ts' + }, + { + id: 'datazoom-drag', + purpose: '拖动后的可视范围(datazoom)', + file: './datazoom-drag.mjs', + sourceExample: 'packages/vchart/__tests__/runtime/browser/test-page/datazoom.ts' + }, + { + id: 'update-resize', + purpose: '数据更新与尺寸调整(event-update-spec)', + file: './update-resize.mjs', + sourceExample: 'packages/vchart/__tests__/runtime/browser/test-page/event-update-spec.ts' + } +]; + +/** + * 从当前清单所在目录加载用例,冻结副本不回到工作区取代码。 + * @param {CaseMetadata} item + * @returns {Promise} + */ +export async function loadCase(item) { + return (await import(new URL(item.file, import.meta.url))).default; +} diff --git a/packages/vchart/__tests__/visual/cases/legend-filter.mjs b/packages/vchart/__tests__/visual/cases/legend-filter.mjs new file mode 100644 index 0000000000..3817d1808c --- /dev/null +++ b/packages/vchart/__tests__/visual/cases/legend-filter.mjs @@ -0,0 +1,26 @@ +import { barSpec, graphicCenter } from '../helpers.mjs'; + +/** 图例点击筛选(multiple-legend-layout)。 */ +export default { + createSpec() { + // 使用固定顺序的两个图例项。 + return { ...barSpec(), legends: { visible: true, orient: 'bottom', position: 'start' } }; + }, + async exercise(page) { + // 点击真实图例,并移出鼠标以稳定最终截图。 + await page.evaluate(() => { + window.__legendBefore = window.__visualChart.getLegendSelectedDataByIndex().length; + }); + const point = await graphicCenter(page, 'legendItem'); + await page.mouse.click(point.x, point.y); + await page.mouse.move(950, 750); + }, + async verify(page) { + // 筛选必须同时改变选中项和实际数据。 + await page.waitForFunction( + () => + window.__visualChart.getLegendSelectedDataByIndex().length === window.__legendBefore - 1 && + window.__visualChart.getChart().getAllSeries()[0].getViewData().latestData.length === 3 + ); + } +}; diff --git a/packages/vchart/__tests__/visual/cases/line-gap.mjs b/packages/vchart/__tests__/visual/cases/line-gap.mjs new file mode 100644 index 0000000000..add5f82313 --- /dev/null +++ b/packages/vchart/__tests__/visual/cases/line-gap.mjs @@ -0,0 +1,21 @@ +import { verifySpec } from '../helpers.mjs'; + +/** 缺失值与折线连接(data-zoom-brush-line)。 */ +export default { + createSpec() { + // 使用空值验证断点,保留标记点便于发现数据错位。 + return { + type: 'line', + data: { id: 'data', values: [12, 28, null, 18, 42].map((y, x) => ({ x: String(x), y })) }, + xField: 'x', + yField: 'y', + line: { style: { lineWidth: 3 } }, + point: { visible: true }, + invalidType: 'break' + }; + }, + async verify(page) { + // 核对目标输入,避免错误 spec 或空数据通过图片对比。 + await verifySpec(page, this.createSpec()); + } +}; diff --git a/packages/vchart/__tests__/visual/cases/pie-label.mjs b/packages/vchart/__tests__/visual/cases/pie-label.mjs new file mode 100644 index 0000000000..567bb88611 --- /dev/null +++ b/packages/vchart/__tests__/visual/cases/pie-label.mjs @@ -0,0 +1,21 @@ +import { verifySpec } from '../helpers.mjs'; + +/** 外侧标签与引导线(pie-label)。 */ +export default { + createSpec() { + // 小扇区与大扇区混合验证标签避让。 + return { + type: 'pie', + data: { id: 'data', values: [60, 20, 8, 5, 4, 3].map((value, i) => ({ name: `Category ${i + 1}`, value })) }, + categoryField: 'name', + valueField: 'value', + outerRadius: 0.7, + label: { visible: true, position: 'outside' }, + legends: { visible: false } + }; + }, + async verify(page) { + // 核对目标输入,避免错误 spec 或空数据通过图片对比。 + await verifySpec(page, this.createSpec()); + } +}; diff --git a/packages/vchart/__tests__/visual/cases/scatter-symbol.mjs b/packages/vchart/__tests__/visual/cases/scatter-symbol.mjs new file mode 100644 index 0000000000..1504773096 --- /dev/null +++ b/packages/vchart/__tests__/visual/cases/scatter-symbol.mjs @@ -0,0 +1,28 @@ +import { verifySpec } from '../helpers.mjs'; + +/** 散点位置、大小及符号(scatter)。 */ +export default { + createSpec() { + // 使用数值坐标和大小通道覆盖散点绘制。 + return { + type: 'scatter', + data: { + id: 'data', + values: [ + { x: 1, y: 4, size: 12 }, + { x: 3, y: 2, size: 24 }, + { x: 5, y: 7, size: 36 } + ] + }, + xField: 'x', + yField: 'y', + sizeField: 'size', + size: [12, 36], + point: { style: { symbolType: 'diamond' } } + }; + }, + async verify(page) { + // 核对目标输入,避免错误 spec 或空数据通过图片对比。 + await verifySpec(page, this.createSpec()); + } +}; diff --git a/packages/vchart/__tests__/visual/cases/tooltip-hover.mjs b/packages/vchart/__tests__/visual/cases/tooltip-hover.mjs new file mode 100644 index 0000000000..99120278f3 --- /dev/null +++ b/packages/vchart/__tests__/visual/cases/tooltip-hover.mjs @@ -0,0 +1,40 @@ +import {} from '../helpers.mjs'; + +/** 鼠标悬停后的 HTML tooltip(tooltip)。 */ +export default { + createSpec() { + // 单组正值柱图便于可靠定位柱体内部。 + return { + type: 'bar', + data: { + id: 'data', + values: [ + { x: 'A', y: 30 }, + { x: 'B', y: 50 }, + { x: 'C', y: 20 } + ] + }, + xField: 'x', + yField: 'y', + tooltip: { visible: true, renderMode: 'html', transitionDuration: 0 } + }; + }, + async exercise(page) { + // 使用公开坐标转换 API,验证 tooltip 内容而非只触发 mousemove。 + const point = await page.evaluate(() => window.__visualChart.convertDatumToPosition({ x: 'B', y: 25 }, {}, true)); + if (!point) throw new Error('无法定位 tooltip 目标'); + await page.mouse.move(point.x, point.y); + }, + async verify(page) { + // 目标 tooltip 必须可见且包含期望值。 + await page.waitForFunction(() => + [...document.querySelectorAll('[class*="tooltip"]')].some( + el => + el.textContent.includes('50') && + el.getBoundingClientRect().width > 0 && + getComputedStyle(el).visibility !== 'hidden' && + getComputedStyle(el).display !== 'none' + ) + ); + } +}; diff --git a/packages/vchart/__tests__/visual/cases/update-resize.mjs b/packages/vchart/__tests__/visual/cases/update-resize.mjs new file mode 100644 index 0000000000..4f894f2273 --- /dev/null +++ b/packages/vchart/__tests__/visual/cases/update-resize.mjs @@ -0,0 +1,33 @@ +import { barSpec } from '../helpers.mjs'; + +/** 数据更新与尺寸调整(event-update-spec)。 */ +export default { + createSpec: barSpec, + async exercise(page) { + // 等待公开异步 API 完成,并检查实际数据和画布尺寸。 + await page.evaluate(async () => { + await window.__visualChart.updateData('data', [ + { x: 'Updated', y: 55, group: 'Alpha' }, + { x: 'Second', y: 20, group: 'Alpha' } + ]); + const container = document.getElementById('chart'); + container.style.width = '640px'; + container.style.height = '480px'; + await window.__visualChart.resize(640, 480); + }); + }, + async verify(page) { + // 检查更新后的数据与真实画布尺寸。 + await page.evaluate(() => { + const canvas = window.__visualChart.getCanvas(); + const data = window.__visualChart.getChart().getAllSeries()[0].getViewData().latestData; + if ( + canvas.width !== 640 || + canvas.height !== 480 || + data.length !== 2 || + !data.some(item => item.x === 'Updated' && item.y === 55) + ) + throw new Error('更新或 resize 未生效'); + }); + } +}; diff --git a/packages/vchart/__tests__/visual/cases/waterfall.mjs b/packages/vchart/__tests__/visual/cases/waterfall.mjs new file mode 100644 index 0000000000..b1be521778 --- /dev/null +++ b/packages/vchart/__tests__/visual/cases/waterfall.mjs @@ -0,0 +1,29 @@ +import { verifySpec } from '../helpers.mjs'; + +/** 累计、总计与连接线(waterfall)。 */ +export default { + createSpec() { + // 总计项使用字段标记,不重复计算累计值。 + return { + type: 'waterfall', + data: { + id: 'data', + values: [ + { x: 'Start', y: 80 }, + { x: 'Growth', y: 25 }, + { x: 'Cost', y: -35 }, + { x: 'Other', y: 10 }, + { x: 'Total', total: true } + ] + }, + xField: 'x', + yField: 'y', + total: { type: 'field', tagField: 'total' }, + label: { visible: true } + }; + }, + async verify(page) { + // 核对目标输入,避免错误 spec 或空数据通过图片对比。 + await verifySpec(page, this.createSpec()); + } +}; diff --git a/packages/vchart/__tests__/visual/helpers.mjs b/packages/vchart/__tests__/visual/helpers.mjs new file mode 100644 index 0000000000..667d5f1367 --- /dev/null +++ b/packages/vchart/__tests__/visual/helpers.mjs @@ -0,0 +1,51 @@ +/** 创建固定的分组柱图数据,避免随机数据引入截图噪声。 */ +export function barSpec() { + return { + type: 'bar', + data: { + id: 'data', + values: [ + { x: 'A', y: 30, group: 'Alpha' }, + { x: 'A', y: 15, group: 'Beta' }, + { x: 'B', y: -12, group: 'Alpha' }, + { x: 'B', y: -22, group: 'Beta' }, + { x: 'C', y: 40, group: 'Alpha' }, + { x: 'C', y: 25, group: 'Beta' } + ] + }, + xField: 'x', + yField: 'y', + seriesField: 'group', + stack: true + }; +} + +/** 根据场景树图元的全局包围盒定位实际交互目标。 */ +export async function graphicCenter(page, name) { + return page.evaluate(name => { + // 读取渲染图元的实际位置,避免通过固定坐标掩盖布局变化。 + const graphic = window.__visualChart.getStage().find(node => node.name === name, true); + if (!graphic) throw new Error(`未找到交互图元 ${name}`); + const bounds = graphic.globalAABBBounds; + return { x: (bounds.x1 + bounds.x2) / 2, y: (bounds.y1 + bounds.y2) / 2 }; + }, name); +} + +/** 验证静态用例的关键输入与图表类型,布局差异交给截图判断。 */ +export async function verifySpec(page, expected) { + await page.evaluate(expected => { + const actual = window.__visualChart.getSpec(); + if (actual.type !== expected.type || actual.data?.values?.length !== expected.data.values.length) + throw new Error('图表类型或数据数量不正确'); + // VChart 会合并主题默认值,只核对用例明确指定的输入子集。 + function check(actual, expected, field) { + if (expected && typeof expected === 'object') { + if (!actual || typeof actual !== 'object') throw new Error('图表输入不正确:' + field); + if (Array.isArray(expected) && (!Array.isArray(actual) || actual.length !== expected.length)) + throw new Error('数据长度不正确:' + field); + for (const key of Object.keys(expected)) check(actual[key], expected[key], field + '.' + key); + } else if (actual !== expected) throw new Error('图表输入不正确:' + field); + } + check(actual, expected, 'spec'); + }, expected); +} diff --git a/packages/vchart/__tests__/visual/page.html b/packages/vchart/__tests__/visual/page.html new file mode 100644 index 0000000000..f8bb17e6fe --- /dev/null +++ b/packages/vchart/__tests__/visual/page.html @@ -0,0 +1,71 @@ + + + + + + + VChart visual regression + + + +
+ + + diff --git a/packages/vchart/__tests__/visual/playwright.config.mjs b/packages/vchart/__tests__/visual/playwright.config.mjs new file mode 100644 index 0000000000..17caa4b118 --- /dev/null +++ b/packages/vchart/__tests__/visual/playwright.config.mjs @@ -0,0 +1,37 @@ +import path from 'node:path'; +import { defineConfig } from '@playwright/test'; +import { settings } from './settings.mjs'; + +const runDir = process.env.VISUAL_RUN_DIR; +const phase = process.env.VISUAL_PHASE; +if (!runDir || !['baseline', 'current'].includes(phase)) throw new Error('请通过 visual-test.mjs 运行'); + +export default defineConfig({ + testDir: '.', + testMatch: 'visual.spec.mjs', + workers: 1, + retries: 0, + timeout: settings.timeout, + outputDir: path.join(runDir, `${phase}-results`), + snapshotPathTemplate: path.join(runDir, 'snapshots', '{arg}{ext}'), + updateSnapshots: phase === 'baseline' ? 'all' : 'none', + expect: { timeout: 5000, toMatchSnapshot: settings.comparison }, + reporter: [ + ['line'], + ['html', { outputFolder: path.join(runDir, `${phase}-report`), open: 'never' }], + ['json', { outputFile: path.join(runDir, `${phase}-playwright.json`) }], + ['./reporter.mjs'] + ], + use: { + browserName: 'chromium', + headless: true, + viewport: settings.viewport, + deviceScaleFactor: settings.deviceScaleFactor, + locale: settings.locale, + timezoneId: settings.timezoneId, + colorScheme: 'light', + reducedMotion: 'reduce', + serviceWorkers: 'block', + trace: 'retain-on-failure' + } +}); diff --git a/packages/vchart/__tests__/visual/report.mjs b/packages/vchart/__tests__/visual/report.mjs new file mode 100644 index 0000000000..db32f8509a --- /dev/null +++ b/packages/vchart/__tests__/visual/report.mjs @@ -0,0 +1,380 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/** 转义报告中的动态文本,同时用于 HTML 内容和属性。 */ +function escape(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +/** 将已存在的运行内文件转换为可搬迁的相对路径,拒绝越界引用。 */ +async function artifact(runDir, relative) { + if (!relative || path.isAbsolute(relative) || relative.split(/[\\/]/).includes('..')) return null; + try { + return (await fs.stat(path.join(runDir, relative))).isFile() ? relative : null; + } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } +} + +/** 汇合两阶段结果;缺少图片或未完成比较不能被解释为通过。 */ +export async function saveReport(runDir, summary) { + const phases = {}; + const reportStarted = performance.now(); + const issues = [...(summary.issues ?? [])]; + for (const phase of ['baseline', 'current']) { + phases[phase] = summary[`${phase}Result`]; + if (!phases[phase]) { + try { + phases[phase] = JSON.parse(await fs.readFile(path.join(runDir, `${phase}-result.json`), 'utf8')); + } catch (error) { + if (error.code !== 'ENOENT') + issues.push({ phase, category: 'report', code: 'REPORT_FAILED', message: String(error) }); + } + } + for (const message of phases[phase]?.errors ?? []) + issues.push({ phase, category: 'execution', code: 'EXECUTION_FAILED', message }); + } + if (summary.error) + issues.push({ phase: summary.stage ?? 'preparation', category: 'execution', message: summary.error }); + if (summary.cleanupError) issues.push({ phase: 'cleanup', category: 'execution', message: summary.cleanupError }); + for (const [phase, result] of Object.entries(phases)) { + if (!result) continue; + const expected = summary.cases.map(item => item.id); + const actual = result.tests.map(item => item.id); + if ( + actual.length !== expected.length || + new Set(actual).size !== actual.length || + actual.some(id => !expected.includes(id)) + ) + issues.push({ phase, category: 'execution', code: 'RESULT_SET_MISMATCH', message: '阶段结果与选择集不一致' }); + if (result.finalized === false) + issues.push({ phase, category: 'execution', code: 'RUN_INCOMPLETE', message: '阶段异常结束,保留已完成用例' }); + } + const baselineArg = + summary.baseline?.repository === 'working-tree' + ? '--self-compare' + : summary.baseline?.sha + ? `--baseline ${summary.baseline.sha}` + : ''; + const cases = []; + for (const item of summary.cases) { + const baseline = phases.baseline?.tests.find(test => test.id === item.id); + const current = phases.current?.tests.find(test => test.id === item.id); + const errors = []; + const images = { baseline: null, current: null, diff: null }; + for (const [phase, result] of [ + ['baseline', baseline], + ['current', current] + ]) { + for (const message of result?.errors ?? []) { + errors.push({ + phase, + stage: result.stage ?? 'execution', + category: result.status === 'diff' ? 'visual_difference' : result.category ?? 'execution', + code: result.code ?? (result.status === 'diff' ? 'VISUAL_DIFFERENCE' : 'EXECUTION_FAILED'), + message + }); + } + images[phase] = await artifact(runDir, result?.attachments?.find(file => file.name === 'rendered')?.path); + if (['passed', 'diff'].includes(result?.status) && !images[phase]) { + errors.push({ + phase, + stage: 'report', + category: 'missing_artifact', + code: 'MISSING_ARTIFACT', + message: '缺少最终截图' + }); + } + } + images.diff = await artifact(runDir, current?.attachments?.find(file => file.name.endsWith('-diff.png'))?.path); + if (current?.status === 'diff' && !images.diff) + errors.push({ + phase: 'current', + stage: 'report', + category: 'missing_artifact', + code: 'MISSING_ARTIFACT', + message: '缺少差异图' + }); + const executionError = + errors.some(error => error.category !== 'visual_difference') || + [baseline, current].some(result => result?.status === 'error'); + const status = executionError + ? 'error' + : baseline?.status !== 'passed' || !current || current.status === 'not_run' + ? 'not_run' + : current.status; + const attachments = []; + for (const [phase, result] of [ + ['baseline', baseline], + ['current', current] + ]) { + for (const file of result?.attachments ?? []) { + const relative = await artifact(runDir, file.path); + if (relative) attachments.push({ phase, ...file, path: relative }); + } + } + cases.push({ + ...item, + status, + ...(status === 'not_run' + ? { + blockedBy: { + phase: baseline?.status === 'passed' ? 'current' : 'baseline', + reason: '前序阶段失败或该阶段未完成' + } + } + : {}), + phases: { baseline: baseline?.status ?? 'not_run', current: current?.status ?? 'not_run' }, + durationMs: (baseline?.durationMs ?? 0) + (current?.durationMs ?? 0), + images, + attachments, + errors, + rerun: `node packages/vchart/scripts/visual-test.mjs ${baselineArg} --case '${item.id.replaceAll( + "'", + "'\\''" + )}'`.replace(' ', ' ') + }); + } + const counts = Object.fromEntries( + ['passed', 'diff', 'error', 'not_run'].map(status => [status, cases.filter(item => item.status === status).length]) + ); + const complete = + cases.length > 0 && + cases.every(item => ['passed', 'diff'].includes(item.status)) && + !issues.some(issue => ['RESULT_SET_MISMATCH', 'RUN_INCOMPLETE'].includes(issue.code)); + const status = summary.status === 'error' || issues.length || !complete ? 'error' : counts.diff ? 'diff' : 'passed'; + const logs = {}; + for (const name of [ + 'build.log', + 'baseline.log', + 'current.log', + 'baseline-report/index.html', + 'current-report/index.html' + ]) { + const relative = await artifact(runDir, name); + if (relative) logs[name] = relative; + } + const { baselineResult, currentResult, ...metadata } = summary; + const report = { + ...metadata, + schemaVersion: 1, + finalized: summary.finalized ?? true, + status, + complete, + counts, + cases, + issues, + logs, + environment: { + ...summary.environment, + browser: phases.current?.browser ?? phases.baseline?.browser ?? summary.environment.browser + } + }; + // 汇总只生成新对象;HTML 和 Markdown 不改变调用方的状态。 + report.timings = { ...summary.timings, reportMs: Math.round(performance.now() - reportStarted) }; + await writeJson(path.join(runDir, 'summary.json'), report); + await fs.writeFile(path.join(runDir, 'agent-summary.md'), agentSummary(report)); + await fs.writeFile(path.join(runDir, 'index.html'), renderHtml(report)); + report.timings.reportMs = Math.round(performance.now() - reportStarted); + if (report.timings.totalMs !== undefined) report.timings.totalMs += report.timings.reportMs; + await writeJson(path.join(runDir, 'summary.json'), report); + await fs.writeFile(path.join(runDir, 'agent-summary.md'), agentSummary(report)); + await fs.writeFile(path.join(runDir, 'index.html'), renderHtml(report)); + return report; +} + +/** 输出失败优先的纯文本入口,让 Agent 按路径读取证据而非消耗 Base64。 */ +function agentSummary(report) { + const lines = [ + '# VChart 视觉回归结果', + '', + `状态:${report.status};完整比较:${report.complete};${JSON.stringify(report.counts)}`, + '', + `基线:${report.baseline?.repository ?? '未解析'} @ ${report.baseline?.sha ?? '未解析'}`, + `本地:${report.local?.head ?? '未知'};dirty=${report.local?.dirty ?? '未知'};工作区摘要=${ + report.local?.digest ?? '未知' + }`, + `环境:${JSON.stringify(report.environment)}`, + '', + '完整结构化结果:[summary.json](summary.json)(schemaVersion=1);所有证据路径相对于此报告目录。', + '以下记录测试事实,不判断差异是否属于缺陷。复现命令在仓库根目录执行;基线 SHA 固定,本地工作区需要与摘要一致。', + '用例 source.path 指向仓库文件;source.frozenPath 指向本次运行冻结文件。', + '截图需由支持图片的 Agent 按需打开;本报告不调用模型,也不自动接受差异。', + '' + ]; + for (const issue of report.issues) + lines.push( + `## 运行错误:${issue.phase} / ${issue.code ?? issue.category}`, + '', + ...issue.message.split('\n').map(line => `> ${line}`), + '' + ); + for (const item of report.cases.filter(item => item.status !== 'passed')) { + lines.push( + `## ${item.id} — ${item.status}`, + '', + item.purpose, + '', + `源码:${item.source?.path ?? '未知'}:${item.source?.line ?? '?'};冻结副本:${ + item.source?.frozenPath ?? '未知' + }`, + `阶段:${JSON.stringify(item.phases)}`, + '', + `复现:\`${item.rerun}\``, + '' + ); + for (const [kind, file] of Object.entries(item.images)) + lines.push(`- ${kind}:${file ? `[${file}](${file})` : '未生成或缺失'}`); + for (const error of item.errors) + lines.push( + '', + `${error.phase} / ${error.stage} / ${error.code ?? error.category}`, + ...error.message.split('\n').map(line => `> ${line}`) + ); + lines.push(''); + } + if (report.complete && !report.counts.diff) lines.push('所选用例全部通过,详情见 summary.json。', ''); + lines.push('## 日志与详细报告', '', ...Object.values(report.logs).map(file => `- [${file}](${file})`), ''); + return lines.join('\n'); +} + +/** 生成无需服务和网络的三图报告;原图链接保留完整分辨率。 */ +function renderHtml(report) { + const labels = { passed: '通过', diff: '视觉差异', error: '执行错误', not_run: '未完成' }; + const link = (file, label) => `${escape(label)}`; + const baseline = report.baseline?.repository === 'working-tree' ? '工作区自比较' : '官方基线'; + const cards = report.cases + .map( + item => `
+

${escape(item.id)}

${ + labels[item.status] + }${(item.durationMs / 1000).toFixed(1)}s
+

${escape(item.purpose)}

${[ + ['baseline', `${baseline} · ${report.baseline?.sha?.slice(0, 8) ?? '?'}`], + ['current', `本地 · ${report.local?.head?.slice(0, 8) ?? '?'}${report.local?.dirty ? ' + 未提交修改' : ''}`], + ['diff', 'Diff · 差异图'] + ] + .map( + ([kind, title]) => + `
${escape(title)}
${ + item.images[kind] + ? `${escape(`${item.id} ${title}`)}` + : `
${ + kind === 'diff' && item.status === 'passed' ? '无视觉差异' : '未生成或缺失;请查看状态与诊断' + }
` + }
` + ) + .join('')}
+

用例:${escape(item.source?.path)}:${item.source?.line ?? '?'} · ${ + item.source?.frozenPath ? link(item.source.frozenPath, '查看冻结用例') : '' + } · baseline=${item.phases.baseline} / current=${item.phases.current}

+
${escape(item.rerun)}
+ ${ + item.errors.length + ? `
诊断(${ + item.errors.length + })${item.errors + .map( + error => + `

${escape(`${error.phase} / ${error.stage} / ${error.code ?? error.category}`)}

${escape(
+                  error.message
+                )}
` + ) + .join('')}
` + : '' + } + ${ + item.attachments.length + ? `
证据文件
    ${item.attachments + .map(file => `
  • ${escape(file.phase)} · ${link(file.path, file.name)}
  • `) + .join('')}
` + : '' + }
` + ) + .join(''); + return `VChart 视觉回归 · ${ + labels[report.status] + } +

VChart 视觉回归 · ${labels[report.status]}

${escape(report.startedAt)} · ${ + report.complete ? '所有用例完成比较' : '比较未完整完成' + } · ${escape(report.environment.platform)} / ${escape(report.environment.arch)} · Chromium ${escape( + report.environment.browser ?? '未启动' + )}

基线 ${escape(report.baseline?.sha ?? '未解析')} · 本地 ${escape(report.local?.head ?? '未解析')}${ + report.local?.dirty ? '(有未提交修改)' : '' + }

+
${Object.entries(report.counts) + .map(([status, count]) => `
${count}${labels[status]}
`) + .join('')}
+

同机、同一套用例对比。点击图片打开原图;视觉差异需要审查,不自动判为缺陷。

+ ${report.issues + .map( + issue => + `
${escape( + `${issue.phase} / ${issue.code ?? issue.category}` + )}
${escape(issue.message)}
` + ) + .join('')} +
${cards} +
+ `; +} + +/** 在报告目录内原子替换 JSON,避免读者看到半写入的终态。 */ +async function writeJson(file, value) { + const temporary = `${file}.tmp`; + await fs.writeFile(temporary, JSON.stringify(value, null, 2)); + await fs.rename(temporary, file); +} diff --git a/packages/vchart/__tests__/visual/reporter.mjs b/packages/vchart/__tests__/visual/reporter.mjs new file mode 100644 index 0000000000..153dfc10bd --- /dev/null +++ b/packages/vchart/__tests__/visual/reporter.mjs @@ -0,0 +1,90 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { stripVTControlCharacters } from 'node:util'; + +/** 每个完成用例即保存证据,不依赖进程正常结束才落盘。 */ +export default class VisualReporter { + tests = []; + errors = []; + onError(error) { + // 记录 worker 等运行级错误,并保留已完成用例。 + this.errors.push(stripVTControlCharacters(error.message ?? String(error))); + this.persist(false); + } + onTestEnd(test, result) { + // diff 标记仅由截图适配层在确认原生比较证据完整后添加。 + const difference = result.attachments.some(item => item.name === 'visual-difference'); + const stage = test.annotations.find(item => item.type === 'stage')?.description ?? 'setup'; + const category = + result.status === 'timedOut' + ? 'timeout' + : test.annotations.find(item => item.type === 'failure-category')?.description || stage; + const codes = { + resource: 'RESOURCE_FAILED', + render: 'RENDER_FAILED', + interaction: 'INTERACTION_ASSERTION_FAILED', + verify: 'CASE_ASSERTION_FAILED', + screenshot: 'SCREENSHOT_FAILED', + comparison: 'COMPARISON_FAILED', + timeout: 'TIMEOUT', + missing_artifact: 'MISSING_ARTIFACT', + setup: 'PREFLIGHT_FAILED' + }; + this.browser = test.annotations.find(item => item.type === 'browser')?.description ?? this.browser; + this.tests.push({ + id: test.title, + status: + result.status === 'passed' + ? 'passed' + : result.status === 'skipped' + ? 'not_run' + : result.status === 'failed' && difference + ? 'diff' + : 'error', + stage, + category, + code: + result.status === 'passed' + ? null + : result.status === 'skipped' + ? 'RUN_INCOMPLETE' + : difference + ? 'VISUAL_DIFFERENCE' + : codes[category] ?? 'EXECUTION_FAILED', + durationMs: result.duration, + errors: result.errors.map(error => stripVTControlCharacters(error.message ?? String(error))), + attachments: result.attachments + .filter(item => item.path) + .map(item => ({ + name: item.name, + contentType: item.contentType, + path: path.relative(process.env.VISUAL_RUN_DIR, item.path).split(path.sep).join('/') + })) + }); + this.persist(false); + } + persist(finalized, endedStatus) { + // 原子替换阶段结果;未终结的阶段始终不宣称完整成功。 + const status = + !finalized || + this.errors.length || + !this.tests.length || + this.tests.some(test => ['error', 'not_run'].includes(test.status)) || + ['interrupted', 'timedout'].includes(endedStatus) + ? 'error' + : this.tests.some(test => test.status === 'diff') + ? 'diff' + : 'passed'; + const file = path.join(process.env.VISUAL_RUN_DIR, `${process.env.VISUAL_PHASE}-result.json`); + const temporary = `${file}.tmp`; + fs.writeFileSync( + temporary, + JSON.stringify({ finalized, status, browser: this.browser, tests: this.tests, errors: this.errors }, null, 2) + ); + fs.renameSync(temporary, file); + } + onEnd(result) { + // 最终更新状态,保留每个用例独立写入的原始诊断。 + this.persist(true, result.status); + } +} diff --git a/packages/vchart/__tests__/visual/settings.mjs b/packages/vchart/__tests__/visual/settings.mjs new file mode 100644 index 0000000000..e10123d2d0 --- /dev/null +++ b/packages/vchart/__tests__/visual/settings.mjs @@ -0,0 +1,12 @@ +/** 两阶段共用的确定性环境与像素比较策略。 */ +export const settings = { + viewport: { width: 1000, height: 800 }, + chart: { width: 800, height: 600 }, + deviceScaleFactor: 1, + locale: 'en-US', + timezoneId: 'UTC', + fontFamily: 'Arial', + timeout: 30000, + stableTimeout: 5000, + comparison: { threshold: 0.1, maxDiffPixels: 0 } +}; diff --git a/packages/vchart/__tests__/visual/visual.spec.mjs b/packages/vchart/__tests__/visual/visual.spec.mjs new file mode 100644 index 0000000000..297c445f78 --- /dev/null +++ b/packages/vchart/__tests__/visual/visual.spec.mjs @@ -0,0 +1,134 @@ +import fs from 'node:fs/promises'; +import { test, expect } from '@playwright/test'; +import { cases, loadCase } from './cases/index.mjs'; +import { settings } from './settings.mjs'; + +/** 等待两张连续截图完全一致;持续变化视为执行错误而非像素差异。 */ +async function stableScreenshot(page) { + let previous; + const deadline = Date.now() + settings.stableTimeout; + while (Date.now() < deadline) { + const current = await page.screenshot({ animations: 'disabled', timeout: 5000 }); + if (previous?.equals(current)) return current; + previous = current; + await page.waitForTimeout(100); + } + throw new Error('截图持续变化,5 秒内未稳定'); +} + +/** 检查渲染错误和有效画布内容,拒绝两侧同时空白造成的假通过。 */ +async function checkDrawing(page, errors) { + if (errors.length) throw new Error(errors.join('\n')); + await page.evaluate(() => { + // 图表绘制必须产生非透明、非白色像素。 + if (window.__visualErrors.length) throw new Error(window.__visualErrors.join('\n')); + const canvas = window.__visualChart?.getCanvas(); + if (!canvas || canvas.width <= 0 || canvas.height <= 0) throw new Error('没有有效画布'); + const series = window.__visualChart.getChart().getAllSeries(); + if (!series.some(item => item.getSeriesMark()?.getGraphics()?.length > 0)) throw new Error('没有绘制数据图元'); + const context = canvas.getContext('2d'); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + let ink = 0; + for (let i = 0; i < pixels.length; i += 4) + if (pixels[i + 3] && (pixels[i] < 245 || pixels[i + 1] < 245 || pixels[i + 2] < 245)) ink++; + if (ink < 100) throw new Error('图表没有有效绘制'); + }); +} + +const selectedIds = JSON.parse(process.env.VISUAL_CASE_IDS); +for (const metadata of cases.filter(item => selectedIds.includes(item.id))) { + const item = { ...metadata, ...(await loadCase(metadata)) }; + test(item.id, async ({ page, browser }, info) => { + // 记录来源和浏览器版本,并收集整个用例生命周期内的异常。 + info.annotations.push( + { type: 'purpose', description: item.purpose }, + { type: 'browser', description: browser.version() } + ); + const stage = { type: 'stage', description: 'resource' }; + info.annotations.push(stage); + const failure = { type: 'failure-category', description: '' }; + info.annotations.push(failure); + const errors = []; + const base = process.env.VISUAL_BASE_URL; + page.on('pageerror', error => { + failure.description = 'render'; + errors.push(error.message); + }); + page.on('requestfailed', request => { + failure.description = 'resource'; + errors.push(`资源失败:${request.url()} ${request.failure()?.errorText}`); + }); + page.on('response', response => { + if (response.status() >= 400) { + failure.description = 'resource'; + errors.push(`HTTP ${response.status()}:${response.url()}`); + } + }); + await page.route('**/*', route => { + // 截图阶段禁止 CDN 和内网请求。 + if (new URL(route.request().url()).origin === base) return route.continue(); + failure.description = 'resource'; + errors.push(`禁止外部请求:${route.request().url()}`); + return route.abort(); + }); + try { + await page.goto(`${base}/suite/page.html?phase=${process.env.VISUAL_PHASE}&case=${item.id}`); + stage.description = 'render'; + await page.waitForFunction(() => window.__visualReady || window.__visualErrors?.length); + await checkDrawing(page, errors); + stage.description = 'interaction'; + await item.exercise?.(page); + stage.description = item.exercise ? 'interaction' : 'verify'; + await item.verify(page); + stage.description = 'screenshot'; + await page.evaluate(() => document.fonts.ready); + const screenshot = await stableScreenshot(page); + await checkDrawing(page, errors); + const renderedPath = info.outputPath('rendered.png'); + await fs.writeFile(renderedPath, screenshot); + await info.attach('rendered', { path: renderedPath, contentType: 'image/png' }); + stage.description = 'comparison'; + const name = `${item.id}.png`; + if (process.env.VISUAL_PHASE === 'current') { + // 缺失基线必须在图片断言之前归类为执行错误。 + try { + await fs.access(info.snapshotPath(name)); + } catch (error) { + failure.description = 'missing_artifact'; + throw error; + } + } + try { + expect(screenshot).toMatchSnapshot(name); + } catch (error) { + if (process.env.VISUAL_PHASE === 'current' && (await hasComparisonEvidence(info, name))) + await info.attach('visual-difference', { + body: Buffer.from('图像存在差异,请检查是否符合预期'), + contentType: 'text/plain' + }); + throw error; + } + } finally { + // 显式释放图表,浏览器 context 由 Playwright fixture 自动回收。 + await page.evaluate(() => window.__visualChart?.release()).catch(() => {}); + } + }); +} + +/** 只有原生比较生成完整 PNG 证据时才允许把断言失败归类为视觉差异。 */ +async function hasComparisonEvidence(info, name) { + const stem = name.slice(0, -4); + const dimensions = []; + for (const kind of ['expected', 'actual', 'diff']) { + const file = info.attachments.find(item => item.name === `${stem}-${kind}.png`)?.path; + if (!file) return false; + try { + const bytes = await fs.readFile(file); + if (bytes.length < 24 || bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a') return false; + dimensions.push(`${bytes.readUInt32BE(16)}x${bytes.readUInt32BE(20)}`); + } catch { + return false; + } + } + return new Set(dimensions).size === 1; +} diff --git a/packages/vchart/package.json b/packages/vchart/package.json index a730c82bfd..5b3cc9fe2c 100644 --- a/packages/vchart/package.json +++ b/packages/vchart/package.json @@ -49,7 +49,8 @@ "test-live": "npm run test-watch __tests__/unit", "test-watch": "cross-env DEBUG_MODE=1 jest --watch", "ci": "ts-node --transpileOnly --skipProject ./scripts/trigger-test.ts", - "size": "size-limit" + "size": "size-limit", + "test:visual": "node scripts/visual-test.mjs" }, "files": [ "esm", @@ -121,7 +122,8 @@ "size-limit": "9.0.0", "@size-limit/file": "9.0.0", "rimraf": "3.0.2", - "cross-env": "^7.0.3" + "cross-env": "^7.0.3", + "@playwright/test": "1.63.0" }, "dependencies": { "@visactor/vutils": "~1.0.24", diff --git a/packages/vchart/scripts/visual-test.mjs b/packages/vchart/scripts/visual-test.mjs new file mode 100644 index 0000000000..64a8069094 --- /dev/null +++ b/packages/vchart/scripts/visual-test.mjs @@ -0,0 +1,76 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { createRuntime, fault } from './visual/runtime.mjs'; +import { loadCases, preflight, runVisual } from './visual/runner.mjs'; + +/** 解析公共命令;帮助不加载 Playwright,非法选项仍明确报错。 */ +export function parseOptions(args) { + let values; + try { + ({ values } = parseArgs({ + args, + options: { + baseline: { type: 'string' }, + case: { type: 'string' }, + 'self-compare': { type: 'boolean' }, + list: { type: 'boolean' }, + check: { type: 'boolean' }, + help: { type: 'boolean' } + } + })); + } catch (error) { + throw fault('INVALID_ARGUMENT', error.message, error); + } + if (values.help) return values; + if ( + (values.list || values.check) && + ((values.list && values.check) || + values.baseline !== undefined || + values.case !== undefined || + values['self-compare']) + ) + throw fault('INVALID_ARGUMENT', '--list/--check 必须独立使用'); + if (values.baseline !== undefined && !/^[a-f0-9]{40}$/i.test(values.baseline)) + throw fault('INVALID_ARGUMENT', '--baseline 需要完整 40 位 SHA'); + if (values.baseline && values['self-compare']) throw fault('INVALID_ARGUMENT', '--baseline 与 --self-compare 互斥'); + if (values.case !== undefined && !/^[a-z][a-z0-9-]*$/.test(values.case)) + throw fault('INVALID_ARGUMENT', '--case 需要有效用例 ID'); + return values; +} +/** 入口只管理参数、预检模式和运行生命周期。 */ +async function main() { + const options = parseOptions(process.argv.slice(2)); + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); + if (options.help) { + console.log( + 'node packages/vchart/scripts/visual-test.mjs [--baseline <40位SHA>] [--case ] [--self-compare]\n--list 列举用例;--check 本机环境检查;--help 帮助\n默认:官方 VisActor/VChart develop。退出码:0 通过,1 视觉差异,2 执行错误。' + ); + return; + } + if (options.list || options.check) { + const cases = await loadCases(path.join(root, 'packages/vchart/__tests__/visual')); + if (options.list) { + console.log(cases.map(item => `${item.id}\t${item.purpose}\t${item.file}\t${item.sourceExample}`).join('\n')); + return; + } + await fs.mkdir(path.join(root, '.vchart-visual'), { recursive: true }); + const runtime = createRuntime(); + const detach = runtime.listenSignals(); + try { + console.log(JSON.stringify({ status: 'passed', ...(await preflight(root, runtime)), cases: cases.length })); + } finally { + const errors = await runtime.cleanup(); + detach(); + if (errors.length) throw fault('CLEANUP_FAILED', JSON.stringify(errors)); + } + return; + } + process.exitCode = await runVisual(root, options); +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) + main().catch(error => { + console.error(`${error.code ?? 'EXECUTION_FAILED'}: ${error.message}`); + process.exitCode = 2; + }); diff --git a/packages/vchart/scripts/visual-test.test.mjs b/packages/vchart/scripts/visual-test.test.mjs new file mode 100644 index 0000000000..830e1ff84f --- /dev/null +++ b/packages/vchart/scripts/visual-test.test.mjs @@ -0,0 +1,495 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { parseOptions } from './visual-test.mjs'; +import { executePhase as runPhase, loadCases, validateResults, acquireLock, runVisual } from './visual/runner.mjs'; +import { createRuntime, serve, fileManifest, cleanupAll } from './visual/runtime.mjs'; +import { readCache, publishCache, build, workingTree } from './visual/build.mjs'; +const runtime = createRuntime(); +/** 测试从冻结清单选择用例,使用与 CLI 相同的阶段入口。 */ +async function executePhase(dir, phase, url, id) { + const selected = await loadCases(path.join(dir, 'suite'), id); + return runPhase( + root, + dir, + phase, + url, + selected.map(item => item.id), + runtime + ); +} +/** 通过正式进程管理器验证命令失败。 */ +const run = (...args) => runtime.run(...args); +import { saveReport } from '../__tests__/visual/report.mjs'; +import { createRequire } from 'node:module'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const packageDir = path.join(root, 'packages/vchart'); + +test('CLI rejects unknown cases and conflicting baseline options', () => { + // 参数错误必须在构建之前返回约定的执行错误退出码。 + for (const args of [ + ['--case', 'missing'], + ['--case', ''], + ['--baseline', ''], + ['--baseline', 'a'.repeat(40), '--self-compare'], + ['--update-snapshots'], + ['--list', '--case', 'bar-stack'], + ['--check', '--self-compare'], + ['--check', '--list'] + ]) { + const result = spawnSync(process.execPath, [path.join(packageDir, 'scripts/visual-test.mjs'), ...args]); + assert.equal(result.status, 2, result.stderr.toString()); + } +}); + +test( + 'real screenshots distinguish differences, missing inputs and execution failures', + { timeout: 180000 }, + async t => { + // 使用已构建的真实 VChart,在独立目录中注入故障,不修改源码或正式报告。 + const bundle = await fs.readFile(path.join(packageDir, 'build/index.js')); + const output = path.join(root, '.vchart-visual'); + await fs.mkdir(output, { recursive: true }); + const dir = await fs.mkdtemp(path.join(output, 'verify-')); + await fs.cp(path.join(packageDir, '__tests__/visual'), path.join(dir, 'suite'), { recursive: true }); + await fs.symlink(path.join(packageDir, 'node_modules'), path.join(dir, 'node_modules'), 'dir'); + await fs.writeFile(path.join(dir, 'baseline.js'), bundle); + await fs.writeFile(path.join(dir, 'current.js'), bundle); + const { server, url } = await serve(dir); + // 真实阶段产物用于验证三图与结构化报告保持一致。 + const summary = () => ({ + status: 'passed', + environment: {}, + baseline: { repository: 'official', sha: 'a'.repeat(40) }, + local: { head: 'b'.repeat(40), dirty: true }, + cases: [ + { + id: 'bar-stack', + purpose: '颜色对比', + source: { path: 'packages/vchart/__tests__/visual/cases.mjs', line: 38, frozenPath: 'suite/cases.mjs' } + } + ] + }); + try { + await t.test('identical bundle passes', async () => { + // 同一构建分别生成基线和本地图像。 + assert.equal((await executePhase(dir, 'baseline', url, 'bar-stack')).status, 'passed'); + assert.equal((await executePhase(dir, 'current', url, 'bar-stack')).status, 'passed'); + }); + await t.test('candidate-only color change is a visual difference', async () => { + // 只修改候选构建的行为,两侧用例代码保持完全一致。 + for (const change of [ + 'color: ["#e00000", "#00a000"]', + 'padding: {left: 120, right: 20, top: 20, bottom: 20}', + 'label: {visible: true}' + ]) { + await fs.writeFile( + path.join(dir, 'current.js'), + Buffer.concat([ + bundle, + Buffer.from( + `\nconst Original = VChart.default; VChart.default = class extends Original { constructor(spec, options) { super({...spec, ${change}}, options); } };` + ) + ]) + ); + assert.equal((await executePhase(dir, 'current', url, 'bar-stack')).status, 'diff', change); + } + const input = summary(); + const report = await saveReport(dir, input); + assert.equal(input.status, 'passed', '汇总不能修改输入状态'); + assert.equal(report.status, 'diff'); + assert.equal(report.counts.diff, 1); + assert.equal(report.schemaVersion, 1); + for (const file of Object.values(report.cases[0].images)) { + assert.ok(file, JSON.stringify(report.cases[0])); + assert.ok(!path.isAbsolute(file)); + await fs.access(path.join(dir, file)); + } + assert.match(report.cases[0].rerun, /--baseline a{40} --case 'bar-stack'/); + assert.ok(!JSON.stringify(report).includes('base64')); + const markdown = await fs.readFile(path.join(dir, 'agent-summary.md'), 'utf8'); + assert.match(markdown, /bar-stack — diff/); + // file:// 打开整个报告,确认无需 HTTP 服务,筛选和图片均可用。 + const require = createRequire(path.join(packageDir, 'package.json')); + const { chromium } = require('@playwright/test'); + const browser = await chromium.launch(); + try { + const page = await browser.newPage(); + const unexpected = []; + page.on('request', request => { + if (!request.url().startsWith('file:')) unexpected.push(request.url()); + }); + page.on('pageerror', error => unexpected.push(error.message)); + await page.goto(new URL(`file://${dir}/index.html`).href); + await page.locator('article img').last().scrollIntoViewIfNeeded(); + await page.waitForFunction(() => + [...document.querySelectorAll('article img')].every(image => image.complete && image.naturalWidth > 0) + ); + assert.equal(await page.locator('article img').count(), 3); + await page.locator('#status').selectOption('passed'); + assert.equal(await page.locator('article:visible').count(), 0); + await page.locator('#status').selectOption('diff'); + assert.equal(await page.locator('article:visible').count(), 1); + assert.deepEqual(unexpected, []); + const moved = `${dir}-moved`; + try { + await fs.cp(dir, moved, { recursive: true, filter: source => !source.includes('node_modules') }); + await page.goto(new URL(`file://${moved}/index.html`).href); + await page.locator('article img').last().scrollIntoViewIfNeeded(); + await page.waitForFunction(() => [...document.images].every(image => image.complete && image.naturalWidth)); + } finally { + await fs.rm(moved, { recursive: true, force: true }); + } + } finally { + await browser.close(); + } + // 差异图丢失时,报告必须升级为执行错误,并保留诊断。 + const diff = path.join(dir, report.cases[0].images.diff); + await fs.rename(diff, `${diff}.saved`); + const missing = await saveReport(dir, summary()); + assert.equal(missing.status, 'error'); + assert.ok(missing.cases[0].errors.some(error => error.category === 'missing_artifact')); + await fs.rename(`${diff}.saved`, diff); + await fs.writeFile(path.join(dir, 'current.js'), bundle); + }); + await t.test('missing screenshot is an execution error', async () => { + // 缺图不得被自动补成基线。 + const snapshot = path.join(dir, 'snapshots/bar-stack.png'); + await fs.rename(snapshot, `${snapshot}.saved`); + await assert.rejects(executePhase(dir, 'current', url, 'bar-stack')); + await assert.rejects(fs.access(snapshot)); + const report = await saveReport(dir, summary()); + assert.equal(report.status, 'error'); + assert.equal(report.cases[0].status, 'error'); + assert.equal(report.cases[0].errors[0].stage, 'comparison'); + await fs.rename(`${snapshot}.saved`, snapshot); + }); + await t.test('missing bundle and runtime exception are execution errors', async () => { + // 同时覆盖资源加载和浏览器运行异常。 + await fs.rm(path.join(dir, 'current.js')); + await assert.rejects(executePhase(dir, 'current', url, 'bar-stack')); + await fs.writeFile( + path.join(dir, 'current.js'), + Buffer.concat([bundle, Buffer.from('\nthrow new Error("intentional runtime failure");')]) + ); + await assert.rejects(executePhase(dir, 'current', url, 'bar-stack')); + await fs.writeFile(path.join(dir, 'current.js'), bundle); + }); + await t.test('blocked external request is an execution error', async () => { + // 禁止页面请求外网,且失败不能退化成像素差异。 + await fs.writeFile( + path.join(dir, 'current.js'), + Buffer.concat([bundle, Buffer.from('\nfetch("https://example.com/forbidden");')]) + ); + await assert.rejects(executePhase(dir, 'current', url, 'bar-stack')); + await fs.writeFile(path.join(dir, 'current.js'), bundle); + }); + await t.test('empty rendering and incorrect static input cannot pass', async () => { + // 即使两份产物都能加载,空绘制和错误图表输入仍是执行错误。 + for (const change of ['data: {id: "data", values: []}', 'type: "line"']) { + await fs.writeFile( + path.join(dir, 'current.js'), + Buffer.concat([ + bundle, + Buffer.from( + `\nconst Original=VChart.default;VChart.default=class extends Original {constructor(spec,options){super({...spec,${change}},options);}};` + ) + ]) + ); + await assert.rejects(executePhase(dir, 'current', url, 'bar-stack')); + const result = JSON.parse(await fs.readFile(path.join(dir, 'current-result.json'))); + assert.equal(result.tests[0].status, 'error'); + } + await fs.writeFile(path.join(dir, 'current.js'), bundle); + }); + await t.test('render timeout is an execution error', async () => { + // 只缩短验证副本的超时,避免故障自检等待正式的三十秒。 + const configFile = path.join(dir, 'suite/settings.mjs'); + const config = await fs.readFile(configFile, 'utf8'); + await fs.writeFile(configFile, config.replace('timeout: 30000', 'timeout: 1000')); + const pageFile = path.join(dir, 'suite/page.html'); + const page = await fs.readFile(pageFile, 'utf8'); + await fs.writeFile(pageFile, page.replace('window.__visualReady = true', 'window.__visualReady = false')); + await assert.rejects(executePhase(dir, 'current', url, 'bar-stack')); + await fs.writeFile(configFile, config); + await fs.writeFile(pageFile, page); + }); + await t.test('failed build command rejects instead of using an old artifact', async () => { + // 命令退出非零时立即停止,不能继续读取已有构建文件。 + await assert.rejects(run(process.execPath, ['-e', 'process.exit(7)'], root, path.join(dir, 'failure.log'))); + }); + await t.test('ineffective interactions cannot pass', async () => { + // 分别抑制四类交互,验证状态断言而非只验证图片文件存在。 + const configFile = path.join(dir, 'suite/settings.mjs'); + const config = await fs.readFile(configFile, 'utf8'); + await fs.writeFile(configFile, config.replace('timeout: 30000', 'timeout: 1500')); + for (const id of ['legend-filter', 'tooltip-hover', 'datazoom-drag', 'update-resize']) { + const casesFile = path.join(dir, `suite/cases/${id}.mjs`); + const original = await fs.readFile(casesFile, 'utf8'); + // 替换模块的动作,保留原始 verify;不依赖某一行鼠标代码的格式。 + await fs.writeFile( + casesFile, + original.replace('export default {', 'const original = {') + + '\nexport default {...original, async exercise() {}};\n' + ); + await assert.rejects(executePhase(dir, 'baseline', url, id), id); + const result = JSON.parse(await fs.readFile(path.join(dir, 'baseline-result.json'), 'utf8')); + assert.equal(result.tests[0].id, id); + assert.equal(result.tests[0].status, 'error'); + await fs.writeFile(casesFile, original); + } + await fs.writeFile(configFile, config); + }); + await t.test('server cannot expose files outside its allowlist', async () => { + // 服务只能读取两份产物和冻结用例。 + assert.equal((await fetch(`${url}/summary.json`)).status, 404); + assert.equal((await fetch(`${url}/suite/%2e%2e%2fcurrent.js`)).status, 404); + }); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + await fs.rm(dir, { recursive: true, force: true }); + } + } +); + +test('preparation errors preserve not-run cases and escape HTML', async () => { + // 未进入 Playwright 的失败也生成可读报告,不伪造图片、通过状态或详细报告链接。 + const dir = await fs.mkdtemp(path.join(root, '.vchart-visual/report-check-')); + try { + const report = await saveReport(dir, { + status: 'error', + stage: 'localBuild', + error: '', + environment: {}, + cases: [{ id: 'bar-stack', purpose: 'minimal' }] + }); + assert.equal(report.complete, false); + assert.equal(report.counts.not_run, 1); + assert.deepEqual(report.logs, {}); + assert.deepEqual(report.cases[0].images, { baseline: null, current: null, diff: null }); + const html = await fs.readFile(path.join(dir, 'index.html'), 'utf8'); + assert.ok(!html.includes('