
Paddleocr Ui Test
- 5 installs
- 8 repo stars
- Updated April 10, 2026
- aotenjou/paddleocr-ui-test
paddleocr-ui-test is a Claude Code skill that validates rendered UIs by cross-referencing PaddleOCR screenshot text extraction with Playwright Accessibility Tree snapshots.
About
paddleocr-ui-test is a Claude skill for OCR-based UI validation. It extracts visible text from screenshots with PaddleOCR and cross-references it against Playwright accessibility tree snapshots to detect UI defects across six levels: text consistency, layout anomalies, DOM cross-validation, accessibility, i18n, and dynamic content. It supports page-type profiles, tunable rule files, baseline regression, and consuming upstream artifacts. A developer uses it to verify a rendered UI matches expected content and catch visual regressions.
- Validates rendered UI by extracting screenshot text with PaddleOCR
- Cross-references OCR text against Playwright Accessibility Tree snapshots
- 6 detection levels for text, layout, DOM cross-val, accessibility, i18n, and dynamic content
Paddleocr Ui Test by the numbers
- 5 all-time installs (skills.sh)
- Ranked #1,611 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
paddleocr-ui-test capabilities & compatibility
- Capabilities
- ui testing · ocr validation · visual regression · accessibility check
- Works with
- playwright · openai
- Use cases
- testing · ui design · web design
- Pricing
- Bring your own API key
What paddleocr-ui-test says it does
Provides dual-path UI validation using PaddleOCR screenshot text extraction cross-referenced with Playwright Accessibility Tree snapshots.
Env: `PADDLEOCR_API_KEY` 或 `SILICONFLOW_API_KEY`(必须)
npx skills add https://github.com/aotenjou/paddleocr-ui-test --skill paddleocr-ui-testAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5 |
|---|---|
| repo stars | ★ 8 |
| Last updated | April 10, 2026 |
| Repository | aotenjou/paddleocr-ui-test ↗ |
What it does
Validating a rendered UI by comparing PaddleOCR screenshot text against the Playwright accessibility tree.
Who is it for?
Developers validating rendered UI text, layout, and accessibility against expectations and baselines
When should I use this skill?
Test UI from screenshot, verify UI matches expected, run visual UI test, or baseline UI regression
What you get
- report.json
- report.md
- annotated screenshot
By the numbers
- 6 detection levels (L1-L6)
- 6 page-type profiles
- version 0.2.0
Files
PaddleOCR UI Testing
OCR 截图文字提取 + Playwright Accessibility Tree 交叉验证,6 级 UI 缺陷检测。
Prerequisites
- Env:
PADDLEOCR_API_KEY或SILICONFLOW_API_KEY(必须) - Python:
openai,playwright,Pillow - 浏览器:
playwright install chromium
Control Knobs
1. 测试范围 (--levels)
| Level | 检测什么 | 何时使用 |
|---|---|---|
| L1 | 文字一致性:可见文字是否符合预期 | 检查文案、按钮文字、标题是否正确 |
| L2 | 布局合理性:溢出、重叠、触控区域 | 检查排版异常、移动端触控可达性 |
| L3 | DOM 交叉验证:OCR 可见文字 vs A11y Tree | 核心功能:检测渲染异常、canvas 文字 |
| L4 | 无障碍:缺失 alt/label、emoji 作图标 | 表单页、图片密集页 |
| L5 | 多语言:检测错误的语言内容 | 国际化页面 |
| L6 | 动态内容:操作前后的文字变化 | 加载状态、分页、异步更新 |
默认: L1,L3。用户说"全面检查"时开 L1,L2,L3,L4,L5。
2. 页面类型 (--profile)
| Profile | 适用场景 | 自动设置 |
|---|---|---|
saas | 后台管理系统、数据表格页 | L1,L2,L3,L5 / 1920x1080 / 宽松数量差异 |
ecommerce | 电商网站、商品列表 | L1,L2,L3,L4,L5 / 1440x900 / 模糊匹配+触控检查 |
form | 登录/注册/表单页 | L1,L3,L4 / 1280x720 / 开启 label 检查 |
content | 博客/新闻/文章页 | L1,L2,L3 / 1440x900 / 宽松全页文字阈值 |
dashboard | 数据大屏/分析面板 | L2,L3,L6 / 1920x1080 / 重叠检测+内容持久化 |
mobile | 移动端 H5/响应式页 | L1,L2,L3,L4 / 375x812 / 触控区域+溢出检查 |
Profile 自动设置 levels、viewport、wait_ms 和规则覆盖。
3. 规则调优 (rules/*.json)
每个 rule 文件控制一个 level 的检测行为。agent 可根据需求修改:
L1: rules/text-consistency.json
default_strategy:exact(严格)/substring(默认)/fuzzy(容错)match_strategies.fuzzy.threshold: 模糊匹配阈值,默认 0.8ignore_patterns: 忽略的文字模式(如版本号、哈希值)
L2: rules/layout-anomaly.json
overflow.enabled: 是否检测溢出element_overlap.enabled+iou_threshold: 重叠检测开关和 IoU 阈值touch_target_size.enabled+min_width_px/min_height_px: 触控区域最小尺寸full_page_text.width_threshold/height_threshold: 全页文字判定阈值
L3: rules/dom-ocr-crossval.json
fuzzy_match.enabled+threshold: 模糊匹配开关和阈值(0.6 为匹配,0.7 为警告)count_mismatch.delta_threshold: 数量差异容忍比例,默认 0.3ignore_patterns: 忽略的文本模式
L4: rules/accessibility.json
missing_alt.enabled: 图片 alt 检查(默认开)missing_label.enabled: 交互元素 label 检查(默认关,form profile 开启)canvas_rendered_text.enabled: 检测 canvas 渲染文字(OCR 可见但 A11y 不可见)emoji_as_icon.enabled: 检测 emoji 用作图标
L5: rules/i18n.json
languages: 各语言的正则模式(zh/en/ja/ko)common_false_positives: 不误判的词(OK, API, URL 等)
L6: rules/dynamic-content.json
state_transitions: 预定义的状态转换模式(loading→content, content→error)max_tracked_changes: 最大追踪变化数,默认 5
4. 具体期望 (--config)
当用户有具体的文字预期时,生成 config JSON:
{
"expected_texts": {
"page_title": "Login Page",
"username_label": "Username",
"submit_button": "Login"
},
"expected_language": "en",
"ignore_texts": ["Powered by"]
}5. 回归对比 (--baseline / --baseline-file)
--baseline: 保存当前运行结果为基线(首次测试或用户说"保存基准")--baseline-file baseline.json: 与历史基线对比,检测文字移除/新增、布局偏移、数量变化- 基线文件自动保存在输出目录的
baseline.json
辅助参数
--annotate: 在截图上标注问题区域(失败时推荐开启)--actions: L6 动态测试的动作序列,如"click(#btn);wait(2000);screenshot"--output: 输出目录,默认./test-results
Output Artifacts
| 文件 | 说明 |
|---|---|
report.json | 结构化结果,含 issue 类型、严重级别、建议 |
report.md | 人类可读报告 |
screenshot.png | 捕获的截图 |
annotated.png | 标注了问题区域的截图(--annotate 时生成) |
baseline.json | 基线文件(--baseline 时生成) |
Integration
Input Contract
本 skill 接受的最小输入: --url (必须)。 上游 skill 不需要知道本 skill 的 config 格式,agent 负责转换。
新增轻量柔性适配输入模式(保持原能力不变):
--input-mode url(默认):原有模式,skill 自己采集 screenshot + a11y--input-mode artifacts --artifacts-dir <dir>:消费上游导出的页面产物--input-mode mcp --input-json <file>:消费 MCP payload(v1 仅路径型字段)
MCP payload v1 示例:
{
"source": "playwright-mcp",
"url": "https://example.com",
"viewport": "1280x720",
"screenshot_path": "./artifacts/screenshot.png",
"a11y_tree_path": "./artifacts/a11y_tree.json",
"dom_path": "./artifacts/dom.html"
}上游输出适配
| 上游输出 | 转换方式 | 示例 |
|---|---|---|
| dogfood 自由文本描述 | 提取关键文字 → expected_texts | "按钮显示 Submit" → {"submit_button": "Submit"} |
| dogfood 截图/问题列表 | 问题归类 → 对应 level 的检测规则 | "文字重叠" → 开 L2 |
| ui-ux-pro-max 设计系统 | 提取文案要求 → expected_texts | 设计稿按钮文字 → config |
| ui-ux-pro-max 无障碍建议 | 映射到 L4 规则开关 | "检查 label" → accessibility.missing_label.enabled=true |
| ui-ux-pro-max i18n 要求 | 映射到 L5 规则 | "需要中日韩支持" → 开 ja/ko 语言检测 |
| dev-browser 页面状态 | 复用截图 + HTML + A11y Tree | 不重新加载页面,直接消费产物 |
| 用户自然语言描述 | 直接生成 config | "确认标题是 Hello" → {"title": "Hello"} |
协作模式选择
根据用户意图自动选择协作模式:
| 用户说... | 模式 | 流程 |
|---|---|---|
| "检查这个页面" | standalone | 只跑本 skill (L1,L3) |
| "全面检查" | standalone+ | 本 skill 全 levels + --annotate |
| "先探索再检查" | dogfood → 本 skill | dogfood 发现 → 生成 config → 本 skill 验证 |
| "和之前比有没有变化" | baseline | 检测 baseline.json → --baseline-file |
| "设计实现得对不对" | ui-ux-pro-max → 本 skill | 设计意图 → 提取 expected → 验证 |
| "帮我操作然后检查" | dev-browser → 本 skill | dev-browser 导航+操作 → 本 skill 验证最终状态 |
dev-browser 会话复用
与 dev-browser 协作时,避免重复启动浏览器:
1. dev-browser 完成页面导航/操作后,导出:
- 截图:
page.screenshot() - HTML:
page.content() - A11y Tree:
page.evaluate(A11Y_TREE_SCRIPT)
2. 本 skill 直接消费这些产物,不重新加载页面 3. 好处: 保持 session/cookie 一致,节省 API 调用
Playwright MCP / UI test generation MCP 下游接入建议:
1. 上游 MCP 导出 screenshot + a11y tree + dom 到本地 artifacts 2. 本 skill 使用 --input-mode artifacts 或 --input-mode mcp 直接消费 3. 本 skill 输出 report.json / annotated.png 继续给下游 agent/MCP 使用
说明:L6 --actions 在当前轻量版本仅在 url 模式执行。
Output Contract
本 skill 的产出可被下游 skill 消费:
| 产物 | 格式 | 下游可消费 |
|---|---|---|
report.json | 结构化 issue 列表 | dogfood 可读取 results 补充新发现 |
annotated.png | 标注截图 | dev-browser 可定位 screenshot_region 坐标确认问题 |
baseline.json | 基线快照 | 后续运行可 --baseline-file 对比 |
screenshot.png | 原始截图 | 可喂给其他视觉分析 skill |
与其他 Skill 的协作流程
ui-ux-pro-max → 定义设计意图 (颜色/排版/文案/无障碍要求)
↓
dogfood → 探索实际页面 (发现问题/意外行为)
↓
paddleocr-ui-test → 验证并守卫 (把发现转为自动化回归检查)- dev-browser 是执行引擎: 所有 skill 都可用它做页面导航和交互
- ui-ux-pro-max 是理想态: 定义页面"应该"长什么样
- dogfood 是发现机制: 找出"实际"有什么问题
- 本 skill 是验证层: 把发现固化为可持续运行的自动化检查
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
*.egg
*.egg-info/
dist/
build/
*.whl
# Virtual environments
.venv/
venv/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
# Test artifacts
test-results/
test/benchmark_images/
test/benchmark_results.json
test/mock_data/
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
flowchart TB
subgraph INPUT["Input Layer"]
direction TB
CLI["CLI Entry<br/>scripts/ui_test.py"]
MODES["Input modes<br/>url | artifacts | mcp"]
CFG["Config sources<br/>rules/*.json<br/>profiles/*.json<br/>runtime --config"]
CLI --> MODES
CLI --> CFG
end
subgraph CONTRACTS["Unified Contracts"]
direction LR
EB["EvidenceBundle<br/>screenshot_path | a11y_tree | dom_html<br/>capabilities | provenance"]
DC["DetectionContext"]
end
subgraph PIPE["Runtime Pipeline"]
direction LR
ST1["collect_evidence"]
ST2["run_ocr_stage"]
ST3["build_detection_context"]
ST4["execute_levels"]
ST5["apply_baseline_stage<br/>enrich_findings"]
ST6["write_output_stage"]
ST1 --> ST2 --> ST3 --> ST4 --> ST5 --> ST6
end
subgraph DETECT["Detector Registry"]
direction TB
L1["L1 TextConsistencyDetector"]
L2["L2 LayoutReasonablenessDetector"]
L3["L3 DomConsistencyDetector<br/>requires: has_a11y"]
L4["L4 AccessibilityDetector<br/>requires: has_a11y"]
L5["L5 InternationalizationDetector"]
L6["L6 DynamicContentDetector<br/>requires: has_actions"]
CAP["Capability model<br/>has_dom | has_a11y | has_actions | has_source_map"]
SKIP["Missing capabilities are recorded as skipped<br/>with explicit reasons in detector_execution"]
CAP --> SKIP
end
subgraph SUPPORT["Supporting Modules"]
direction TB
OCR["providers/ocr.py<br/>default: paddleocr-vl"]
BASE["core/baseline.py<br/>baseline_diff.py"]
SMAP["source_map_lookup.py"]
QUAL["smoke_input_modes.py<br/>tests/test_architecture.py<br/>strict config validation"]
end
subgraph OUT["Outputs And Metadata"]
direction TB
RJSON["report.json<br/>source of truth"]
RDER["report.md<br/>report.junit.xml<br/>report.sarif.json"]
RFILES["annotated.png<br/>baseline.json<br/>screenshot.png"]
META["metadata.detector_execution<br/>metadata.source_map_execution<br/>snapshots.ocr_texts<br/>snapshots.a11y_elements"]
end
MODES --> ST1
CFG --> ST1
ST1 --> EB
EB --> ST2
ST2 --> OCR
ST3 --> DC
DC --> ST4
QUAL -.-> PIPE
ST4 --> L1
ST4 --> L2
ST4 --> L3
ST4 --> L4
ST4 --> L5
ST4 --> L6
L1 --> ST5
L2 --> ST5
L3 --> ST5
L4 --> ST5
L5 --> ST5
L6 --> ST5
BASE --> ST5
SMAP --> ST5
ST6 --> RJSON
ST6 --> RDER
ST6 --> RFILES
ST6 --> META
{
"name": "Login Page UI Test",
"url": "https://the-internet.herokuapp.com/login",
"levels": ["L1", "L2", "L3", "L4"],
"viewport": "1280x720",
"wait_ms": 2000,
"expected_texts": {
"page_title": "Login Page",
"description": "This is where you can log into the secure area",
"username_label": "Username",
"password_label": "Password",
"submit_button": "Login"
},
"expected_language": "en",
"ignore_texts": [
"Powered by Elemental Selenium"
]
}
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner.
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law or such agreed to in writing,
shall any Contributor be liable to You for damages, including any
direct, indirect, special, incidental, or consequential damages
of any character arising as a result of this License or out of the
use or inability to use the Work.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License.
END OF TERMS AND CONDITIONS
{
"name": "content",
"description": "Content / Blog / News pages",
"when_to_use": "Use when the user mentions blogs, news pages, article pages, documentation pages, or content display pages",
"default_levels": ["L1", "L2", "L3"],
"viewport": "1440x900",
"wait_ms": 2000,
"rule_overrides": {
"layout-anomaly.full_page_text.width_threshold": 0.9,
"layout-anomaly.full_page_text.height_threshold": 0.5
}
}
{
"name": "dashboard",
"description": "Data dashboard / Analytics pages",
"when_to_use": "Use when the user mentions data dashboards, analytics panels, chart pages, or real-time monitoring pages",
"default_levels": ["L2", "L3", "L6"],
"viewport": "1920x1080",
"wait_ms": 5000,
"rule_overrides": {
"dom-ocr-crossval.count_mismatch.delta_threshold": 0.1,
"layout-anomaly.element_overlap.enabled": true,
"dynamic-content.content_persistence.enabled": true
}
}
{
"name": "ecommerce",
"description": "E-commerce website",
"when_to_use": "Use when the user mentions e-commerce websites, product listings, shopping carts, checkout pages, product detail pages",
"default_levels": ["L1", "L2", "L3", "L4", "L5"],
"viewport": "1440x900",
"wait_ms": 4000,
"rule_overrides": {
"text-consistency.default_strategy": "fuzzy",
"layout-anomaly.touch_target_size.enabled": true,
"accessibility.missing_alt.enabled": true
},
"expected_elements": [
{"role": "button", "name_pattern": ".*(Buy|Add to Cart|Checkout|购买|结算)."}
]
}
{
"name": "form",
"description": "Form / Login / Registration pages",
"when_to_use": "Use when the user mentions login pages, registration pages, form pages, or any page with input fields and submit buttons",
"default_levels": ["L1", "L3", "L4"],
"viewport": "1280x720",
"wait_ms": 2000,
"rule_overrides": {
"accessibility.missing_label.enabled": true,
"accessibility.missing_label.roles": ["textbox", "button", "checkbox"]
}
}
{
"name": "mobile",
"description": "Mobile H5 / Responsive pages",
"when_to_use": "Use when the user mentions mobile pages, H5 pages, responsive design, or mobile browser pages",
"default_levels": ["L1", "L2", "L3", "L4"],
"viewport": "375x812",
"wait_ms": 3000,
"rule_overrides": {
"layout-anomaly.touch_target_size.enabled": true,
"layout-anomaly.touch_target_size.min_width_px": 44,
"layout-anomaly.touch_target_size.min_height_px": 44,
"layout-anomaly.overflow.enabled": true
}
}
{
"name": "saas",
"description": "SaaS backend management system",
"when_to_use": "Use when the user mentions backend management systems, data table pages, admin panels, CRM/ERP systems",
"default_levels": ["L1", "L2", "L3", "L5"],
"viewport": "1920x1080",
"wait_ms": 3000,
"rule_overrides": {
"dom-ocr-crossval.fuzzy_match.enabled": true,
"dom-ocr-crossval.count_mismatch.delta_threshold": 0.15
},
"expected_elements": [
{"role": "navigation", "min_count": 1},
{"role": "table", "min_count": 0}
],
"ignore_patterns": ["^v\\d+\\.\\d+", "^[a-f0-9]{7}$"]
}
PaddleOCR UI Test Skill
AI-driven UI testing skill combining PaddleOCR screenshot analysis with DOM/Accessibility Tree cross-validation.
中文文档
Overview
Traditional UI testing tools have blind spots:
| Approach | Tools | Core Defect |
|---|---|---|
| Pixel comparison | Playwright toHaveScreenshot, Percy | Only detects "different", can't understand "what's wrong, why" |
| DOM/A11y Tree | axe-core, Playwright a11y | Only analyzes structure, can't verify "what the user actually sees" |
PaddleOCR bridges the gap between "pixels" and "semantics" — extracting structured UI information from screenshots (text content, position, layout relationships), enabling AI Agents to "understand" screenshots like humans do.
Architecture
Source: docs/architecture.mmd
flowchart TB
subgraph INPUT["Input Layer"]
direction TB
CLI["CLI Entry<br/>scripts/ui_test.py"]
MODES["Input modes<br/>url | artifacts | mcp"]
CFG["Config sources<br/>rules/*.json<br/>profiles/*.json<br/>runtime --config"]
CLI --> MODES
CLI --> CFG
end
subgraph CONTRACTS["Unified Contracts"]
direction LR
EB["EvidenceBundle<br/>screenshot_path | a11y_tree | dom_html<br/>capabilities | provenance"]
DC["DetectionContext"]
end
subgraph PIPE["Runtime Pipeline"]
direction LR
ST1["collect_evidence"]
ST2["run_ocr_stage"]
ST3["build_detection_context"]
ST4["execute_levels"]
ST5["apply_baseline_stage<br/>enrich_findings"]
ST6["write_output_stage"]
ST1 --> ST2 --> ST3 --> ST4 --> ST5 --> ST6
end
subgraph DETECT["Detector Registry"]
direction TB
L1["L1 TextConsistencyDetector"]
L2["L2 LayoutReasonablenessDetector"]
L3["L3 DomConsistencyDetector<br/>requires: has_a11y"]
L4["L4 AccessibilityDetector<br/>requires: has_a11y"]
L5["L5 InternationalizationDetector"]
L6["L6 DynamicContentDetector<br/>requires: has_actions"]
CAP["Capability model<br/>has_dom | has_a11y | has_actions | has_source_map"]
SKIP["Missing capabilities are recorded as skipped<br/>with explicit reasons in detector_execution"]
CAP --> SKIP
end
subgraph SUPPORT["Supporting Modules"]
direction TB
OCR["providers/ocr.py<br/>default: paddleocr-vl"]
BASE["core/baseline.py<br/>baseline_diff.py"]
SMAP["source_map_lookup.py"]
QUAL["smoke_input_modes.py<br/>tests/test_architecture.py<br/>strict config validation"]
end
subgraph OUT["Outputs And Metadata"]
direction TB
RJSON["report.json<br/>source of truth"]
RDER["report.md<br/>report.junit.xml<br/>report.sarif.json"]
RFILES["annotated.png<br/>baseline.json<br/>screenshot.png"]
META["metadata.detector_execution<br/>metadata.source_map_execution<br/>snapshots.ocr_texts<br/>snapshots.a11y_elements"]
end
MODES --> ST1
CFG --> ST1
ST1 --> EB
EB --> ST2
ST2 --> OCR
ST3 --> DC
DC --> ST4
QUAL -.-> PIPE
ST4 --> L1
ST4 --> L2
ST4 --> L3
ST4 --> L4
ST4 --> L5
ST4 --> L6
L1 --> ST5
L2 --> ST5
L3 --> ST5
L4 --> ST5
L5 --> ST5
L6 --> ST5
BASE --> ST5
SMAP --> ST5
ST6 --> RJSON
ST6 --> RDER
ST6 --> RFILES
ST6 --> METARuntime Pipeline
The runtime now follows a strict staged pipeline in scripts/core/pipeline.py:
1. collect_evidence: normalize url, artifacts, or mcp inputs into one EvidenceBundle 2. run_ocr_stage: call the selected OCR provider and optionally capture before/after OCR for L6 3. build_detection_context: construct the shared DetectionContext 4. execute_levels: run built-in detectors with capability checks 5. apply_baseline_stage and enrich_findings: append regression findings and optional source-map locations 6. write_output_stage: emit report.json and derived formats
Capability Model
Each evidence bundle carries capabilities such as:
has_domhas_a11yhas_actionshas_source_map
Detectors declare required capabilities in scripts/engines/. Missing requirements do not fail silently; they are recorded in report.json -> metadata.detector_execution as skipped detectors with explicit reasons.
Installation
Recommended (npx skills)
Install from GitHub (global for OpenCode):
npx skills add aotenjou/paddleOCR-UI-test --skill paddleocr-ui-test -g -a opencode -yInstall from GitHub (global for Claude Code):
npx skills add aotenjou/paddleOCR-UI-test --skill paddleocr-ui-test -g -a claude-code -yList available skills in the repository:
npx skills add aotenjou/paddleOCR-UI-test --listManual
Copy or symlink the repository root to one of:
~/.agents/skills/paddleocr-ui-test/~/.claude/skills/paddleocr-ui-test/.claude/skills/paddleocr-ui-test/(project-local)
Prerequisites
pip install openai playwright Pillow
playwright install chromium
export SILICONFLOW_API_KEY="your-api-key"Usage
Trigger by mentioning: "test UI", "check screenshot", "verify UI", "visual regression", "OCR test", etc.
Or run directly:
# Minimal: just URL
python3 scripts/ui_test.py --url https://example.com
# With profile (auto-sets levels, viewport, rules)
python3 scripts/ui_test.py --url https://example.com --profile form
# With specific expected texts
python3 scripts/ui_test.py --url https://example.com --config test-config.json --annotate
# Full suite with annotated screenshot
python3 scripts/ui_test.py --url https://example.com --levels L1,L2,L3,L4,L5 --annotate
# Consume pre-captured artifacts (dev-browser / Playwright MCP output)
python3 scripts/ui_test.py --input-mode artifacts --artifacts-dir ./artifacts --source playwright-mcp
# Consume MCP payload JSON (path-based payload v1)
python3 scripts/ui_test.py --input-mode mcp --input-json ./mcp-payload.json --source ui-test-generation-mcpMCP Payload (v1 path-based)
{
"source": "playwright-mcp",
"url": "https://example.com",
"viewport": "1280x720",
"screenshot_path": "./artifacts/screenshot.png",
"a11y_tree_path": "./artifacts/a11y_tree.json",
"dom_path": "./artifacts/dom.html"
}5 Control Knobs
| Knob | Parameter | Description |
|---|---|---|
| Test Scope | --levels | L1-L6, default L1,L3 |
| Page Type | --profile | saas/ecommerce/form/content/dashboard/mobile |
| Rule Tuning | rules/*.json | Adjust thresholds, strategies, toggles |
| Specific Expectations | --config | Expected texts, language, etc. |
| Regression Compare | --baseline | Save baseline or diff against history |
Config Contract
rules/*.json,profiles/*.json, and runtime--configare validated against explicit allowed keys.- Unknown runtime config keys now fail fast instead of being silently ignored.
- Profile and runtime config merge into a single execution config before detector execution.
Test Levels
| Level | Scenario | Method |
|---|---|---|
| L1 | Text consistency | OCR vs expected text (exact/substring/fuzzy) |
| L2 | Layout reasonableness | Overflow, overlap, touch target size |
| L3 | DOM consistency | OCR vs A11y Tree cross-validation |
| L4 | Accessibility | Missing alt/labels, canvas text, emoji icons |
| L5 | Internationalization | Language detection (zh/en/ja/ko) |
| L6 | Dynamic content | Action sequences + screenshot comparison |
Project Structure
paddleOCR-UItest/
├── SKILL.md # Agent instructions (5 control knobs)
├── skill.json # Skill metadata
├── README.md # English docs
├── README.zh-CN.md # Chinese docs
├── LICENSE # Apache-2.0
├── rules/ # Data-driven rules (6 files, with agent_hints)
│ ├── text-consistency.json # L1: match strategies, thresholds, ignore patterns
│ ├── layout-anomaly.json # L2: overflow, overlap, touch targets
│ ├── dom-ocr-crossval.json # L3: fuzzy matching, count mismatch
│ ├── accessibility.json # L4: alt, labels, canvas, emoji
│ ├── i18n.json # L5: language patterns, false positives
│ └── dynamic-content.json # L6: state transitions, tracking
├── profiles/ # Industry presets (6 files, with when_to_use)
│ ├── saas.json # Backend management systems
│ ├── ecommerce.json # E-commerce websites
│ ├── form.json # Login/registration forms
│ ├── content.json # Blogs/articles
│ ├── dashboard.json # Data dashboards
│ └── mobile.json # Mobile H5 pages
├── scripts/
│ ├── ui_test.py # Thin CLI orchestrator
│ ├── smoke_input_modes.py # Lightweight adapter smoke checks
│ ├── compare_ocr_dom.py # Standalone OCR vs DOM validator (--ci)
│ ├── baseline_diff.py # Baseline regression engine
│ ├── annotate_screenshot.py # Annotated screenshot generator
│ ├── source_map_lookup.py # Source code location resolver
│ ├── core/ # Shared contracts and runtime pipeline
│ │ ├── models.py # EvidenceBundle, Issue, DetectionContext, execution records
│ │ ├── config.py # Strict rules/profile/runtime-config loading and merging
│ │ ├── pipeline.py # collect_evidence/run_ocr/... staged runtime
│ │ ├── reporting.py # Unified report payload + json/markdown/junit/sarif writers
│ │ ├── baseline.py # Baseline creation and diff logic
│ │ ├── a11y.py # Shared a11y tree flattening
│ │ └── text_utils.py # Shared OCR/DOM text matching utilities
│ ├── engines/ # Built-in L1-L6 detectors and capability-aware registry
│ │ ├── base.py # Detector base class and descriptors
│ │ └── registry.py # execute_levels + detector metadata
│ ├── providers/ # OCR provider abstraction (default: paddleocr-vl)
│ │ └── ocr.py # Provider registry and provider descriptors
│ └── adapters/ # Input adapters
│ ├── base.py # Input adapter base contract
│ ├── registry.py # Input-mode detection + adapter descriptors
│ ├── standalone_url.py # Playwright-based live capture
│ ├── artifact_dir.py # Filesystem artifact bundle loader
│ └── mcp_payload.py # Path-based MCP payload loader
├── references/
│ ├── ocr-api.md # PaddleOCR API configuration
│ ├── a11y-tree.md # Accessibility tree format
│ └── test-patterns.md # Common test patterns + CI/CD example
├── tests/
│ └── test_architecture.py # Architecture, capability, and report-contract regression tests
└── examples/
└── test-config.json # Test config exampleIntegration
This skill is designed to work alongside other UI testing skills:
- dogfood: Exploratory testing → discoveries become
expected_textsconfig → this skill guards against regressions - dev-browser: Browser automation → navigates pages → this skill validates the final state
- ui-ux-pro-max: Design system → defines expected UI → this skill verifies implementation matches design
Current Execution Model
- Built around one internal evidence contract rather than separate pipelines for each input mode.
- Backward compatible: existing
--urlworkflow still works. - Adds downstream-friendly modes for external captures:
--input-mode artifacts --artifacts-dir <dir>--input-mode mcp --input-json <file>L6 --actionsis supported inurlmode only.report.jsonis the canonical output payload; markdown, JUnit, and SARIF are derived from it.
Output and Provider Extensions
- New report formats:
--format junit,--format sarif, or--format all - OCR backend is now provider-based:
--ocr-provider paddleocr-vl report.jsonnow includes:snapshots.ocr_textssnapshots.a11y_elementsmetadata.detector_executionmetadata.source_map_executionbaseline_diff.pycan consume normalizedreport.jsonsnapshots directly for regression comparison
See SKILL.md for the full integration protocol including input/output contracts and collaboration modes.
CI/CD Integration
# .github/workflows/ui-test.yml
name: UI Test
on: [push]
jobs:
ui-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install openai playwright Pillow
playwright install chromium
- name: Run UI tests
env:
SILICONFLOW_API_KEY: ${{ secrets.SILICONFLOW_API_KEY }}
run: |
python3 scripts/ui_test.py \
--url https://staging.example.com \
--levels L1,L3 \
--config tests/ui-config.json \
--output test-results \
--format json
- name: Upload results
uses: actions/upload-artifact@v4
with:
name: ui-test-results
path: test-results/License
Apache-2.0. See LICENSE.
PaddleOCR UI 测试
基于 PaddleOCR 截图分析与 DOM/无障碍树交叉验证的 AI 驱动 UI 测试工具。
English README
概述
传统 UI 测试工具存在盲区:
| 方案 | 代表工具 | 核心缺陷 |
|---|---|---|
| 像素对比 | Playwright toHaveScreenshot, Percy | 只能检测"是否不同",无法理解"哪里错了、为什么错" |
| DOM/A11y Tree | axe-core, Playwright a11y | 只能分析结构,无法验证"用户实际看到了什么" |
PaddleOCR 填补了"像素"和"语义"之间的鸿沟——从截图中提取结构化的 UI 信息(文字内容、位置、布局关系),让 AI Agent 能像人一样"看懂"截图。
架构
源文件:docs/architecture.mmd
flowchart TB
subgraph INPUT["输入层"]
direction TB
CLI["CLI 入口<br/>scripts/ui_test.py"]
MODES["输入模式<br/>url | artifacts | mcp"]
CFG["配置来源<br/>rules/*.json<br/>profiles/*.json<br/>runtime --config"]
CLI --> MODES
CLI --> CFG
end
subgraph CONTRACTS["统一契约"]
direction LR
EB["EvidenceBundle<br/>screenshot_path | a11y_tree | dom_html<br/>capabilities | provenance"]
DC["DetectionContext"]
end
subgraph PIPE["运行时 Pipeline"]
direction LR
ST1["collect_evidence"]
ST2["run_ocr_stage"]
ST3["build_detection_context"]
ST4["execute_levels"]
ST5["apply_baseline_stage<br/>enrich_findings"]
ST6["write_output_stage"]
ST1 --> ST2 --> ST3 --> ST4 --> ST5 --> ST6
end
subgraph DETECT["Detector Registry"]
direction TB
L1["L1 TextConsistencyDetector"]
L2["L2 LayoutReasonablenessDetector"]
L3["L3 DomConsistencyDetector<br/>依赖:has_a11y"]
L4["L4 AccessibilityDetector<br/>依赖:has_a11y"]
L5["L5 InternationalizationDetector"]
L6["L6 DynamicContentDetector<br/>依赖:has_actions"]
CAP["能力模型<br/>has_dom | has_a11y | has_actions | has_source_map"]
SKIP["能力缺失时会记录为 skipped<br/>并写入 detector_execution"]
CAP --> SKIP
end
subgraph SUPPORT["辅助模块"]
direction TB
OCR["providers/ocr.py<br/>默认:paddleocr-vl"]
BASE["core/baseline.py<br/>baseline_diff.py"]
SMAP["source_map_lookup.py"]
QUAL["smoke_input_modes.py<br/>tests/test_architecture.py<br/>严格配置校验"]
end
subgraph OUT["输出与元数据"]
direction TB
RJSON["report.json<br/>事实源"]
RDER["report.md<br/>report.junit.xml<br/>report.sarif.json"]
RFILES["annotated.png<br/>baseline.json<br/>screenshot.png"]
META["metadata.detector_execution<br/>metadata.source_map_execution<br/>snapshots.ocr_texts<br/>snapshots.a11y_elements"]
end
MODES --> ST1
CFG --> ST1
ST1 --> EB
EB --> ST2
ST2 --> OCR
ST3 --> DC
DC --> ST4
QUAL -.-> PIPE
ST4 --> L1
ST4 --> L2
ST4 --> L3
ST4 --> L4
ST4 --> L5
ST4 --> L6
L1 --> ST5
L2 --> ST5
L3 --> ST5
L4 --> ST5
L5 --> ST5
L6 --> ST5
BASE --> ST5
SMAP --> ST5
ST6 --> RJSON
ST6 --> RDER
ST6 --> RFILES
ST6 --> META运行时 Pipeline
运行时流程现在固定为 scripts/core/pipeline.py 中的分阶段管线:
1. collect_evidence:把 url、artifacts、mcp 三种输入统一归一成一个 EvidenceBundle 2. run_ocr_stage:调用选定 OCR provider,必要时为 L6 生成前后 OCR 快照 3. build_detection_context:构建共享的 DetectionContext 4. execute_levels:执行内建 detector,并统一做能力检查 5. apply_baseline_stage 与 enrich_findings:追加 baseline 回归结果和可选 source-map 定位 6. write_output_stage:写出 report.json 及其派生格式
能力模型
每个 EvidenceBundle 都携带 capabilities,包括:
has_domhas_a11yhas_actionshas_source_map
各 detector 在 scripts/engines/ 中声明所需能力。能力缺失时不会静默跳过,而会在 report.json -> metadata.detector_execution 中记录为 skipped,并带上原因。
安装
推荐方式(npx skills)
从 GitHub 安装(OpenCode 全局):
npx skills add aotenjou/paddleOCR-UI-test --skill paddleocr-ui-test -g -a opencode -y从 GitHub 安装(Claude Code 全局):
npx skills add aotenjou/paddleOCR-UI-test --skill paddleocr-ui-test -g -a claude-code -y列出仓库中可用的 skills:
npx skills add aotenjou/paddleOCR-UI-test --list手动安装
将仓库根目录复制或符号链接到以下位置之一:
~/.agents/skills/paddleocr-ui-test/~/.claude/skills/paddleocr-ui-test/.claude/skills/paddleocr-ui-test/(项目本地)
前置要求
pip install openai playwright Pillow
playwright install chromium
export SILICONFLOW_API_KEY="your-api-key"使用方法
通过关键词触发:"test UI"、"check screenshot"、"verify UI"、"visual regression"、"OCR test" 等。
或直接运行:
# 最小:只需 URL
python3 scripts/ui_test.py --url https://example.com
# 使用 profile(自动设置 levels、viewport、规则)
python3 scripts/ui_test.py --url https://example.com --profile form
# 指定预期文字
python3 scripts/ui_test.py --url https://example.com --config test-config.json --annotate
# 全面检查 + 标注截图
python3 scripts/ui_test.py --url https://example.com --levels L1,L2,L3,L4,L5 --annotate
# 消费预采集产物(dev-browser / Playwright MCP 输出)
python3 scripts/ui_test.py --input-mode artifacts --artifacts-dir ./artifacts --source playwright-mcp
# 消费 MCP payload JSON(v1 路径字段)
python3 scripts/ui_test.py --input-mode mcp --input-json ./mcp-payload.json --source ui-test-generation-mcpMCP Payload(v1 路径型)
{
"source": "playwright-mcp",
"url": "https://example.com",
"viewport": "1280x720",
"screenshot_path": "./artifacts/screenshot.png",
"a11y_tree_path": "./artifacts/a11y_tree.json",
"dom_path": "./artifacts/dom.html"
}5 个控制旋钮
| 旋钮 | 参数 | 说明 |
|---|---|---|
| 测试范围 | --levels | L1-L6,默认 L1,L3 |
| 页面类型 | --profile | saas/ecommerce/form/content/dashboard/mobile |
| 规则调优 | rules/*.json | 修改阈值、策略、开关 |
| 具体期望 | --config | 预期文字、语言等 |
| 回归对比 | --baseline | 保存基线或与历史对比 |
配置契约
rules/*.json、profiles/*.json和运行时--config都会按显式允许字段做校验。- 运行时配置里的未知字段现在会直接报错,而不是静默忽略。
- profile 与 runtime config 会先合并成一份统一执行配置,再进入 detector 阶段。
测试级别
| 级别 | 场景 | 检测方法 |
|---|---|---|
| L1 | 文字一致性 | OCR 文字 vs 预期文字(精确/子串/模糊) |
| L2 | 布局合理性 | 溢出、重叠、触控区域大小 |
| L3 | DOM 一致性 | OCR vs A11y Tree 交叉验证 |
| L4 | 无障碍 | 缺失 alt/label、canvas 文字、emoji 图标 |
| L5 | 国际化 | 语言检测(zh/en/ja/ko) |
| L6 | 动态内容 | 动作序列 + 截图前后对比 |
项目结构
paddleOCR-UItest/
├── SKILL.md # Agent 指令(5 个控制旋钮)
├── skill.json # Skill 元数据
├── README.md # 英文文档
├── README.zh-CN.md # 中文文档
├── LICENSE # Apache-2.0
├── rules/ # 数据驱动规则(6 个文件,含 agent_hints)
│ ├── text-consistency.json # L1: 匹配策略、阈值、忽略模式
│ ├── layout-anomaly.json # L2: 溢出、重叠、触控区域
│ ├── dom-ocr-crossval.json # L3: 模糊匹配、数量差异
│ ├── accessibility.json # L4: alt、label、canvas、emoji
│ ├── i18n.json # L5: 语言正则、误判词
│ └── dynamic-content.json # L6: 状态转换、追踪数
├── profiles/ # 行业预设(6 个文件,含 when_to_use)
│ ├── saas.json # 后台管理系统
│ ├── ecommerce.json # 电商网站
│ ├── form.json # 表单/登录页
│ ├── content.json # 博客/文章页
│ ├── dashboard.json # 数据大屏
│ └── mobile.json # 移动端 H5
├── scripts/
│ ├── ui_test.py # 轻量 CLI 编排入口
│ ├── smoke_input_modes.py # 输入模式轻量 smoke 检查
│ ├── compare_ocr_dom.py # OCR vs DOM 交叉验证引擎(支持 --ci)
│ ├── baseline_diff.py # 基线回归对比引擎
│ ├── annotate_screenshot.py # 标注截图生成
│ ├── source_map_lookup.py # 源码位置映射
│ ├── core/ # 共享契约与运行时 pipeline
│ │ ├── models.py # EvidenceBundle、Issue、DetectionContext、执行记录
│ │ ├── config.py # 严格规则/配置/profile 加载与合并
│ │ ├── pipeline.py # collect_evidence/run_ocr/... 分阶段运行
│ │ ├── reporting.py # 统一报告载荷 + json/markdown/junit/sarif 输出
│ │ ├── baseline.py # baseline 生成与 diff 逻辑
│ │ ├── a11y.py # 共享 a11y tree flatten 逻辑
│ │ └── text_utils.py # 共享 OCR/DOM 文本匹配工具
│ ├── engines/ # 内建 L1-L6 检测器与能力感知 registry
│ │ ├── base.py # Detector 基类与描述信息
│ │ └── registry.py # execute_levels + detector 元数据
│ ├── providers/ # OCR provider 抽象(默认 paddleocr-vl)
│ │ └── ocr.py # Provider registry 与 provider 描述
│ └── adapters/ # 输入适配层
│ ├── base.py # Input adapter 基础契约
│ ├── registry.py # 输入模式识别 + adapter 描述信息
│ ├── standalone_url.py # 基于 Playwright 的实时采集
│ ├── artifact_dir.py # 文件系统 artifact bundle 加载
│ └── mcp_payload.py # 路径型 MCP payload 加载
├── references/
│ ├── ocr-api.md # PaddleOCR API 配置
│ ├── a11y-tree.md # 无障碍树格式
│ └── test-patterns.md # 常见测试模式 + CI/CD 示例
├── tests/
│ └── test_architecture.py # 架构、能力模型与报告契约回归测试
└── examples/
└── test-config.json # 测试配置示例与其他 Skill 协作
本 skill 设计为与其他 UI 测试 skill 协同工作:
- dogfood:探索性测试 → 发现的问题转为
expected_texts→ 本 skill 做回归守卫 - dev-browser:浏览器自动化 → 导航页面 → 本 skill 验证最终状态
- ui-ux-pro-max:设计系统 → 定义预期 UI → 本 skill 验证实现匹配设计
当前执行模型
- 内部围绕统一 evidence 契约运行,而不是按不同输入模式维护多套执行流程。
- 向后兼容:原有
--url工作流保持不变。 - 增加对外部采集结果友好的输入模式:
--input-mode artifacts --artifacts-dir <dir>--input-mode mcp --input-json <file>L6 --actions仅在url模式支持。report.json是主事实源,markdown、JUnit、SARIF 都从它派生。
输出与 Provider 扩展
- 新增报告格式:
--format junit、--format sarif、--format all - OCR 后端改为 provider 化:
--ocr-provider paddleocr-vl report.json现在包含:snapshots.ocr_textssnapshots.a11y_elementsmetadata.detector_executionmetadata.source_map_executionbaseline_diff.py可以直接消费归一化后的report.jsonsnapshots 做回归对比
完整协作协议与输入/输出契约见 SKILL.md。
CI/CD 集成
# .github/workflows/ui-test.yml
name: UI Test
on: [push]
jobs:
ui-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install openai playwright Pillow
playwright install chromium
- name: Run UI tests
env:
SILICONFLOW_API_KEY: ${{ secrets.SILICONFLOW_API_KEY }}
run: |
python3 scripts/ui_test.py \
--url https://staging.example.com \
--levels L1,L3 \
--config tests/ui-config.json \
--output test-results \
--format json
- name: Upload results
uses: actions/upload-artifact@v4
with:
name: ui-test-results
path: test-results/许可证
Apache-2.0。详见 LICENSE。
Accessibility Tree Format
Overview
The accessibility tree (A11y Tree) is a structured representation of all UI elements on a page, similar to what screen readers use. It provides semantic information about each element beyond what is visible in a screenshot.
Tree Structure
Each node in the tree contains:
{
"role": "button",
"name": "Submit",
"bounds": {
"x": 480,
"y": 280,
"width": 80,
"height": 40
},
"tagName": "BUTTON",
"visible": true,
"children": [...]
}Fields
| Field | Type | Description |
|---|---|---|
role | string | Semantic role (button, textbox, heading, etc.) |
name | string | Accessible name (aria-label, alt, or text content) |
bounds | object | Position and size in pixels |
bounds.x | number | Left edge in pixels |
bounds.y | number | Top edge in pixels |
bounds.width | number | Element width in pixels |
bounds.height | number | Element height in pixels |
tagName | string | HTML tag name |
visible | boolean | Whether element has non-zero dimensions |
children | array | Nested child elements |
Common Roles
| Role | HTML Element | Description |
|---|---|---|
button | <button> | Clickable button |
textbox | <input> | Text input field |
link | <a> | Hyperlink |
heading | <h1>-<h6> | Section heading |
img | <img> | Image |
navigation | <nav> | Navigation region |
main | <main> | Main content area |
banner | <header> | Page header |
contentinfo | <footer> | Page footer |
list | <ul>, <ol> | List container |
listitem | <li> | List item |
generic | <div>, <span> | Generic container |
How This Plugin Builds the Tree
Unlike Playwright's built-in accessibility.snapshot(), this plugin uses a custom page.evaluate() script that:
1. Walks the DOM tree starting from document.body 2. Extracts role from role attribute or HTML tag name 3. Extracts name from aria-label, alt, or textContent 4. Gets bounding box via getBoundingClientRect() 5. Recurses into children up to depth 20
This approach works in any browser without Playwright-specific APIs and provides pixel-accurate bounds for coordinate matching with OCR results.
Cross-Validation with OCR
The key insight: A11y Tree tells you what SHOULD be visible, OCR tells you what IS visible.
| Scenario | A11y Tree | OCR | Conclusion |
|---|---|---|---|
| Normal | "Submit" | "Submit" | OK |
| Missing render | "Submit" | not found | Element hidden/off-screen |
| Text corruption | "Submit" | "Subm1t" | Font/rendering issue |
| Extra content | not found | "Debug: true" | Unexpected visible content |
| Count mismatch | 10 items | 7 items | Some items not rendered |
Coordinate Matching
To correlate OCR text regions with DOM elements:
1. Take OCR box center point: (x, y) 2. Find DOM element whose bounds contains (x, y) 3. If multiple elements overlap, pick the smallest (most specific) 4. Use the matched element's tagName and data-file attribute for source mapping
PaddleOCR API Configuration
API Endpoint
Default endpoint (SiliconFlow):
https://api.siliconflow.cn/v1/chat/completionsEnvironment Variables
| Variable | Description | Default |
|---|---|---|
PADDLEOCR_API_KEY | API key for authentication | (required) |
SILICONFLOW_API_KEY | Fallback API key | (required) |
PADDLEOCR_MODEL | Model identifier | PaddlePaddle/PaddleOCR-VL-1.5 |
PADDLEOCR_API_URL | API endpoint URL | https://api.siliconflow.cn/v1/chat/completions |
Supported Models
| Model | Description | Best For |
|---|---|---|
PaddlePaddle/PaddleOCR-VL-1.5 | Latest PaddleOCR vision-language model | General OCR, Chinese + English |
PaddlePaddle/PaddleOCR-VL-1.0 | Previous version | Legacy compatibility |
Request Format
from openai import AsyncOpenAI
client = AsyncOpenAI(
api_key="your-api-key",
base_url="https://api.siliconflow.cn/v1/chat/completions"
)
response = await client.chat.completions.create(
model="PaddlePaddle/PaddleOCR-VL-1.5",
messages=[
{"role": "system", "content": "Extract all text with coordinates."},
{"role": "user", "content": [
{"type": "image_url", "image_url": {
"url": "data:image/png;base64,..."
}}
]}
],
max_tokens=4000,
temperature=0,
)Response Format
{
"texts": [
{
"text": "识别的文字",
"box": [[x1, y1], [x2, y2], [x3, y3], [x4, y4]]
}
]
}Rate Limits
| Tier | Requests/min | Notes |
|---|---|---|
| Free | 10 | Suitable for development |
| Paid | 100+ | Contact SiliconFlow for higher limits |
Getting API Key
1. Visit https://siliconflow.cn 2. Register an account 3. Generate API key from dashboard 4. Set as environment variable: export SILICONFLOW_API_KEY="your-key"
Common UI Test Patterns
This document describes common test patterns and configurations for paddleocr-ui-test.
Pattern 1: Critical Text Verification (L1)
Verify that specific text elements are present and correct on a page.
{
"expected_texts": {
"page_title": "Welcome to the-internet",
"submit_button": "Submit",
"login_button": "Login",
"error_message": "Invalid credentials"
},
"expected_language": "en"
}Use case: Regression testing after i18n changes, verifying copy updates.
Pattern 2: Form Validation (L1 + L3)
Test that form fields render correctly and match DOM expectations.
{
"expected_texts": {
"username_label": "Username",
"password_label": "Password",
"submit_button": "Login"
},
"expected_elements": [
{"role": "textbox", "name": "Username"},
{"role": "textbox", "name": "Password"},
{"role": "button", "name": "Login"}
]
}Use case: Ensuring form accessibility and visual rendering match.
Pattern 3: List/Table Content (L3)
Verify that all items declared in DOM are actually rendered.
{
"expected_counts": {
"list_items": 10,
"table_rows": 5
}
}Use case: Pagination bugs, virtual scrolling issues, lazy loading failures.
Pattern 4: Multi-Language Pages (L5)
Test that the correct language is displayed.
{
"expected_language": "zh",
"expected_texts": {
"nav_home": "首页",
"nav_about": "关于",
"nav_contact": "联系"
}
}Use case: i18n regression testing, detecting untranslated strings.
Pattern 5: Loading State Transitions (L6)
Test that loading indicators disappear and content appears.
{
"dynamic_test": {
"initial_state": {
"should_contain": ["Loading...", "spinner"],
"should_not_contain": ["data content"]
},
"action": "wait(3000)",
"final_state": {
"should_contain": ["data content"],
"should_not_contain": ["Loading...", "spinner"]
}
}
}Use case: Async content loading, skeleton screens, error states.
Pattern 6: Responsive Layout (L2)
Test layout at different viewport sizes.
# Mobile viewport
python3 scripts/ui_test.py --url https://example.com \
--viewport 375x812 --levels L2
# Tablet viewport
python3 scripts/ui_test.py --url https://example.com \
--viewport 768x1024 --levels L2
# Desktop viewport
python3 scripts/ui_test.py --url https://example.com \
--viewport 1920x1080 --levels L2Use case: Responsive design regression, overflow detection.
Pattern 7: Accessibility Audit (L4)
Joint OCR + A11y analysis for accessibility issues.
python3 scripts/ui_test.py --url https://example.com \
--levels L4 --annotateDetects:
- Images without alt text
- Buttons without accessible names
- Text that is visible but not in accessibility tree (canvas-rendered)
- Low-confidence OCR regions (possible contrast issues)
Pattern 8: Full Suite (L1-L6)
Run all test levels for comprehensive analysis.
python3 scripts/ui_test.py --url https://example.com \
--levels L1,L2,L3,L4,L5,L6 \
--viewport 1920x1080 \
--annotate \
--source-map ./dist \
--format bothCI/CD Integration
# .github/workflows/ui-test.yml
name: UI Test
on: [push]
jobs:
ui-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install openai playwright Pillow
playwright install chromium
- name: Run UI tests
env:
PADDLEOCR_API_KEY: ${{ secrets.PADDLEOCR_API_KEY }}
run: |
python3 scripts/ui_test.py \
--url https://staging.example.com \
--levels L1,L3 \
--config tests/ui-config.json \
--output test-results \
--format json
- name: Upload results
uses: actions/upload-artifact@v4
with:
name: ui-test-results
path: test-results/Tips
1. Start narrow: Begin with L1 (text) tests on critical pages before expanding 2. Use configs: Keep expected texts in config files, not command lines 3. Annotate: Always use --annotate for visual debugging of failures 4. Source maps: Provide --source-map in CI to get file:line in reports 5. Stable waits: Adjust --wait based on page load characteristics 6. Viewport matters: Test at multiple viewports for responsive issues
{
"version": "1.0",
"description": "L4: Accessibility analysis rules",
"agent_hints": {
"when_to_change": "Enable missing_label for form pages; ensure missing_alt is enabled for image-heavy pages; enable canvas_rendered_text to detect canvas-drawn text",
"safe_defaults": "missing_alt enabled by default, others disabled",
"tunable_params": ["missing_alt.enabled", "missing_alt.roles", "missing_label.enabled", "missing_label.roles", "canvas_rendered_text.enabled", "emoji_as_icon.enabled"]
},
"missing_alt": {
"enabled": true,
"roles": ["image", "graphic", "img"],
"ignore_values": ["", "image", "icon"],
"severity": "error"
},
"missing_label": {
"enabled": false,
"roles": ["button", "textbox", "checkbox", "radio", "combobox"],
"severity": "error"
},
"canvas_rendered_text": {
"enabled": false,
"description": "Text visible in OCR but not in accessibility tree",
"severity": "warning"
},
"emoji_as_icon": {
"enabled": false,
"emoji_pattern": "[\\U0001F600-\\U0001F64F\\U0001F300-\\U0001F5FF\\U0001F680-\\U0001F6FF\\U0001F1E0-\\U0001F1FF\\u2600-\\u26FF\\u2700-\\u27BF]",
"severity": "warning"
}
}
{
"version": "1.0",
"description": "L3: DOM vs OCR cross-validation rules",
"agent_hints": {
"when_to_change": "Lower fuzzy_match.threshold for pages with minor text rendering differences; lower count_mismatch.delta_threshold for data table pages",
"safe_defaults": "fuzzy_match enabled with threshold 0.6; count_mismatch delta 0.3",
"tunable_params": ["fuzzy_match.enabled", "fuzzy_match.threshold", "fuzzy_match.warning_threshold", "count_mismatch.delta_threshold", "count_mismatch.min_elements", "ignore_patterns"]
},
"fuzzy_match": {
"enabled": true,
"algorithm": "lcs",
"threshold": 0.6,
"warning_threshold": 0.7
},
"count_mismatch": {
"enabled": true,
"delta_threshold": 0.3,
"min_elements": 5,
"severity": "warning"
},
"dom_not_rendered": {
"max_results": 10,
"severity": "error"
},
"rendered_not_in_dom": {
"max_results": 10,
"severity": "warning"
},
"ignore_patterns": [
"^[\\s\\u200b\\u200c\\u200d]+$"
]
}
{
"version": "1.0",
"description": "L6: Dynamic content state transition rules",
"agent_hints": {
"when_to_change": "Add custom loading patterns to state_transitions; enable content_persistence to track critical text elements",
"safe_defaults": "loading->content and content->error built-in; max_tracked_changes 5",
"tunable_params": ["state_transitions", "content_persistence.enabled", "content_persistence.critical_texts", "max_tracked_changes"]
},
"state_transitions": {
"loading_to_content": {
"should_disappear": ["Loading...", "loading", "spinner", "请稍候"],
"should_appear": [],
"timeout_ms": 5000
},
"content_to_error": {
"should_disappear": [],
"should_appear": ["Error", "error", "失败", "异常"],
"timeout_ms": 10000
}
},
"content_persistence": {
"enabled": false,
"critical_texts": []
},
"max_tracked_changes": 5
}
{
"version": "1.0",
"description": "L5: Internationalization rules",
"agent_hints": {
"when_to_change": "Add new languages in languages with script_pattern; add false positive terms to common_false_positives",
"safe_defaults": "zh/en built-in, ja/ko optional; common_false_positives includes common abbreviations and technical terms",
"tunable_params": ["languages", "common_false_positives", "mixed_language.enabled"]
},
"languages": {
"zh": {
"script_pattern": "[\\u4e00-\\u9fff]",
"min_word_length": 1,
"description": "Chinese"
},
"en": {
"script_pattern": "[a-zA-Z]{4,}",
"min_word_length": 4,
"description": "English"
},
"ja": {
"script_pattern": "[\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9fff]",
"min_word_length": 1,
"description": "Japanese"
},
"ko": {
"script_pattern": "[\\uac00-\\ud7af\\u1100-\\u11ff]",
"min_word_length": 1,
"description": "Korean"
}
},
"mixed_language": {
"enabled": false,
"severity": "info"
},
"common_false_positives": [
"OK", "Login", "Sign Up", "API", "URL", "ID", "CSS", "HTML", "JS"
]
}
{
"version": "1.0",
"description": "L2: Layout anomaly detection rules",
"agent_hints": {
"when_to_change": "Enable touch_target_size and overflow for mobile pages; enable element_overlap for data dashboards",
"safe_defaults": "overflow and full_page_text enabled by default, others disabled",
"tunable_params": ["overflow.enabled", "element_overlap.enabled", "element_overlap.iou_threshold", "touch_target_size.enabled", "touch_target_size.min_width_px", "full_page_text.width_threshold", "full_page_text.height_threshold"]
},
"overflow": {
"enabled": true,
"severity": "warning"
},
"full_page_text": {
"enabled": true,
"width_threshold": 0.95,
"height_threshold": 0.8,
"severity": "warning"
},
"element_overlap": {
"enabled": false,
"iou_threshold": 0.3,
"severity": "warning"
},
"text_truncation": {
"enabled": false,
"min_expected_chars": 20,
"severity": "warning"
},
"touch_target_size": {
"enabled": false,
"min_width_px": 44,
"min_height_px": 44,
"severity": "warning"
}
}
{
"version": "1.0",
"description": "L1: Text consistency rules",
"agent_hints": {
"when_to_change": "Set default_strategy to 'exact' when strict matching is required, or 'fuzzy' to tolerate minor typos",
"safe_defaults": "substring works for most scenarios",
"tunable_params": ["default_strategy", "match_strategies.fuzzy.threshold", "ignore_patterns", "max_results_per_check"]
},
"match_strategies": {
"exact": {
"type": "exact",
"description": "Exact string match"
},
"substring": {
"type": "substring",
"description": "Substring match (default)"
},
"fuzzy": {
"type": "fuzzy",
"threshold": 0.8,
"description": "LCS-based fuzzy matching"
}
},
"default_strategy": "substring",
"severity_overrides": {
"text_missing": "error",
"text_mismatch": "error",
"extra_text": "info"
},
"max_results_per_check": 10,
"ignore_patterns": []
}
"""Input adapters for PaddleOCR UI test."""
from .registry import build_adapter, detect_input_mode, list_adapters
__all__ = ["build_adapter", "detect_input_mode", "list_adapters"]
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict
from .base import EvidenceBundle, InputAdapter
class ArtifactDirAdapter(InputAdapter):
name = "artifacts"
description = "Load screenshot and optional DOM/A11y artifacts from a directory."
produced_capabilities = ("has_dom", "has_a11y")
async def load_bundle(self) -> EvidenceBundle:
artifacts_dir = Path(self.args.artifacts_dir).resolve()
if not artifacts_dir.exists() or not artifacts_dir.is_dir():
raise ValueError(f"Invalid artifacts directory: {artifacts_dir}")
screenshot_path = self._find_screenshot(artifacts_dir)
a11y_tree = self._load_json_optional(
artifacts_dir / "a11y_tree.json", default={}
)
dom_html = self._load_text_optional(artifacts_dir / "dom.html")
meta = self._load_json_optional(artifacts_dir / "metadata.json", default={})
return EvidenceBundle(
screenshot_path=str(screenshot_path),
a11y_tree=a11y_tree,
dom_html=dom_html,
url=meta.get("url", self.args.url or ""),
viewport=meta.get("viewport", self.args.viewport or ""),
source=self.args.source or meta.get("source", "artifacts"),
extras={"artifacts_dir": str(artifacts_dir)},
capabilities={
"has_dom": bool(dom_html),
"has_a11y": bool(a11y_tree),
"has_actions": False,
},
provenance={
"artifacts_dir": str(artifacts_dir),
"metadata_file": str(artifacts_dir / "metadata.json"),
},
)
def _find_screenshot(self, artifacts_dir: Path) -> Path:
candidates = [
artifacts_dir / "screenshot.png",
artifacts_dir / "screenshot.jpg",
artifacts_dir / "screenshot.jpeg",
artifacts_dir / "screen.png",
]
for candidate in candidates:
if candidate.exists():
return candidate
raise ValueError(
f"No screenshot file found in {artifacts_dir}. "
"Expected one of: screenshot.png/jpg/jpeg or screen.png"
)
@staticmethod
def _load_json_optional(path: Path, default: Dict[str, Any]) -> Dict[str, Any]:
if not path.exists():
return default
return json.loads(path.read_text(encoding="utf-8"))
@staticmethod
def _load_text_optional(path: Path) -> str:
if not path.exists():
return ""
return path.read_text(encoding="utf-8")
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, Optional
from core.models import EvidenceBundle
class InputAdapter:
name = "base"
description = ""
produced_capabilities: tuple[str, ...] = ()
def __init__(self, args: Any):
self.args = args
async def load_bundle(self) -> EvidenceBundle:
raise NotImplementedError
@classmethod
def descriptor(cls) -> Dict[str, Any]:
return {
"name": cls.name,
"description": cls.description,
"produced_capabilities": list(cls.produced_capabilities),
}
@staticmethod
def _resolve_path(path_value: str, base_dir: Optional[Path] = None) -> Path:
path = Path(path_value)
if path.is_absolute():
return path
if base_dir:
return (base_dir / path).resolve()
return path.resolve()
from __future__ import annotations
import json
from pathlib import Path
from .base import EvidenceBundle, InputAdapter
class MCPPayloadAdapter(InputAdapter):
name = "mcp"
description = "Load evidence from a path-based MCP payload."
produced_capabilities = ("has_dom", "has_a11y")
async def load_bundle(self) -> EvidenceBundle:
input_json_path = Path(self.args.input_json).resolve()
if not input_json_path.exists():
raise ValueError(f"Input JSON not found: {input_json_path}")
payload = json.loads(input_json_path.read_text(encoding="utf-8"))
base_dir = input_json_path.parent
screenshot_file = payload.get("screenshot_path")
if not screenshot_file:
raise ValueError("MCP payload missing required field: screenshot_path")
screenshot_path = self._resolve_path(screenshot_file, base_dir=base_dir)
if not screenshot_path.exists():
raise ValueError(f"MCP screenshot file not found: {screenshot_path}")
a11y_tree = {}
if "a11y_tree" in payload and isinstance(payload["a11y_tree"], dict):
a11y_tree = payload["a11y_tree"]
elif payload.get("a11y_tree_path"):
a11y_tree_path = self._resolve_path(
payload["a11y_tree_path"], base_dir=base_dir
)
if a11y_tree_path.exists():
a11y_tree = json.loads(a11y_tree_path.read_text(encoding="utf-8"))
dom_html = ""
if payload.get("dom_html"):
dom_html = payload["dom_html"]
elif payload.get("dom_path"):
dom_path = self._resolve_path(payload["dom_path"], base_dir=base_dir)
if dom_path.exists():
dom_html = dom_path.read_text(encoding="utf-8")
return EvidenceBundle(
screenshot_path=str(screenshot_path),
a11y_tree=a11y_tree,
dom_html=dom_html,
url=payload.get("url", self.args.url or ""),
viewport=payload.get("viewport", self.args.viewport or ""),
source=self.args.source or payload.get("source", "mcp"),
extras={"input_json": str(input_json_path)},
capabilities={
"has_dom": bool(dom_html),
"has_a11y": bool(a11y_tree),
"has_actions": False,
},
provenance={
"input_json": str(input_json_path),
},
)
from __future__ import annotations
from .artifact_dir import ArtifactDirAdapter
from .mcp_payload import MCPPayloadAdapter
BUILTIN_INPUTS = {
"url": {
"name": "url",
"description": "Capture a page live with Playwright and build an evidence bundle.",
"produced_capabilities": ["has_dom", "has_a11y", "has_actions"],
},
"artifacts": ArtifactDirAdapter.descriptor(),
"mcp": MCPPayloadAdapter.descriptor(),
}
def detect_input_mode(args) -> str:
if args.input_mode and args.input_mode != "auto":
return args.input_mode
if args.input_json:
return "mcp"
if args.artifacts_dir:
return "artifacts"
return "url"
def list_adapters(detailed: bool = False):
if detailed:
return [BUILTIN_INPUTS[name] for name in ("url", "artifacts", "mcp")]
return ["url", "artifacts", "mcp"]
def build_adapter(args):
mode = detect_input_mode(args)
if mode == "mcp":
if not args.input_json:
raise ValueError("--input-mode mcp requires --input-json")
return MCPPayloadAdapter(args), mode
if mode == "artifacts":
if not args.artifacts_dir:
raise ValueError("--input-mode artifacts requires --artifacts-dir")
return ArtifactDirAdapter(args), mode
return None, "url"
from __future__ import annotations
from pathlib import Path
from typing import Optional, Tuple
from playwright.async_api import async_playwright
from .base import EvidenceBundle
A11Y_TREE_SCRIPT = """() => {
function buildA11yTree(node, depth = 0) {
if (depth > 20) return null;
const role = node.getAttribute('role') ||
(node.tagName === 'BUTTON' ? 'button' : '') ||
(node.tagName === 'INPUT' ? 'textbox' : '') ||
(node.tagName === 'IMG' ? 'img' : '') ||
(node.tagName === 'A' ? 'link' : '') ||
(node.tagName === 'H1' ? 'heading' : '') ||
(node.tagName === 'H2' ? 'heading' : '') ||
(node.tagName === 'H3' ? 'heading' : '') ||
(node.tagName === 'H4' ? 'heading' : '') ||
(node.tagName === 'H5' ? 'heading' : '') ||
(node.tagName === 'H6' ? 'heading' : '') ||
(node.tagName === 'NAV' ? 'navigation' : '') ||
(node.tagName === 'MAIN' ? 'main' : '') ||
(node.tagName === 'HEADER' ? 'banner' : '') ||
(node.tagName === 'FOOTER' ? 'contentinfo' : '') ||
node.tagName?.toLowerCase() || '';
const name = node.getAttribute('aria-label') ||
node.getAttribute('alt') ||
node.textContent?.trim().substring(0, 200) || '';
const rect = node.getBoundingClientRect();
const result = {
role: role || 'generic',
name: name,
bounds: {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height)
},
tagName: node.tagName,
visible: rect.width > 0 && rect.height > 0
};
const children = [];
for (const child of node.children) {
const childTree = buildA11yTree(child, depth + 1);
if (childTree) children.push(childTree);
}
if (children.length) result.children = children;
return result;
}
return buildA11yTree(document.body);
}"""
async def capture_from_url(
*,
url: str,
viewport: str,
wait_ms: int,
screenshot_path: Path,
source: str = "standalone",
actions: Optional[str] = None,
after_screenshot_path: Optional[Path] = None,
) -> Tuple[EvidenceBundle, Optional[str]]:
viewport_tuple = tuple(map(int, viewport.split("x")))
after_path = None
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page(
viewport={"width": viewport_tuple[0], "height": viewport_tuple[1]}
)
print(f"Navigating to {url} ...")
await page.goto(url, wait_until="networkidle")
await page.wait_for_timeout(wait_ms)
print("Capturing screenshot ...")
await page.screenshot(path=str(screenshot_path), full_page=True)
print("Extracting accessibility tree ...")
a11y_tree = await page.evaluate(A11Y_TREE_SCRIPT)
dom_html = await page.content()
if actions:
await execute_actions(page, actions)
if after_screenshot_path:
await page.screenshot(path=str(after_screenshot_path), full_page=True)
after_path = str(after_screenshot_path)
await browser.close()
return (
EvidenceBundle(
screenshot_path=str(screenshot_path),
a11y_tree=a11y_tree or {},
dom_html=dom_html,
url=url,
viewport=viewport,
source=source,
capabilities={
"has_dom": bool(dom_html),
"has_a11y": bool(a11y_tree),
"has_actions": bool(actions),
},
provenance={
"browser": "playwright-chromium",
"capture_mode": "standalone_url",
},
),
after_path,
)
async def execute_actions(page, actions_str: str) -> None:
actions = actions_str.split(";")
for action in actions:
action = action.strip()
if not action:
continue
if action.startswith("click("):
selector = action[6:-1]
print(f" Clicking: {selector}")
await page.click(selector)
elif action.startswith("wait("):
ms = int(action[5:-1])
print(f" Waiting: {ms}ms")
await page.wait_for_timeout(ms)
elif action.startswith("type("):
inner = action[5:-1]
comma_idx = inner.find(",")
if comma_idx != -1:
selector = inner[:comma_idx].strip()
text = inner[comma_idx + 1 :].strip().strip('"').strip("'")
print(f" Typing '{text}' into: {selector}")
await page.fill(selector, text)
elif action == "screenshot":
pass
else:
print(f" Unknown action: {action}")
#!/usr/bin/env python3
"""
Annotate screenshot with issue markers from UI test results.
Draws colored bounding boxes and labels on the screenshot for each detected issue.
"""
import argparse
import json
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from PIL import Image, ImageDraw, ImageFont
COLOR_MAP = {
"error": (255, 0, 0, 200),
"warning": (255, 165, 0, 200),
"info": (0, 128, 255, 200),
}
LABEL_COLORS = {
"error": (255, 255, 255),
"warning": (255, 255, 255),
"info": (255, 255, 255),
}
def get_font(size: int = 16) -> ImageFont.FreeTypeFont:
"""Get a font for drawing labels, with fallback."""
font_paths = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/System/Library/Fonts/Helvetica.ttc",
"C:\\Windows\\Fonts\\arial.ttf",
]
for fp in font_paths:
try:
return ImageFont.truetype(fp, size)
except (IOError, OSError):
continue
return ImageFont.load_default()
def annotate_screenshot(
screenshot_path: str,
results: List[Dict[str, Any]],
output_path: str,
label_size: int = 16,
border_width: int = 3,
) -> str:
"""Draw issue markers on screenshot.
Args:
screenshot_path: Path to original screenshot
results: List of test result dicts with screenshot_region
output_path: Path to save annotated image
label_size: Font size for labels
border_width: Width of bounding box borders
Returns:
Path to the annotated image
"""
img = Image.open(screenshot_path).convert("RGBA")
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
font = get_font(label_size)
for r in results:
region = r.get("screenshot_region")
if not region or len(region) < 4:
continue
severity = r.get("severity", "info")
color = COLOR_MAP.get(severity, (128, 128, 128, 180))
label_color = LABEL_COLORS.get(severity, (255, 255, 255))
box = [tuple(p) for p in region[:4]]
draw.polygon(box, outline=color, width=border_width)
label = f"[{r.get('level', '?')}] {r.get('type', '?')}"
top_left = box[0]
bbox = draw.textbbox(top_left, label, font=font)
label_w = bbox[2] - bbox[0]
label_h = bbox[3] - bbox[1]
label_bg = (
top_left[0],
top_left[1] - label_h - 4,
top_left[0] + label_w + 8,
top_left[1],
)
if label_bg[1] < 0:
label_bg = (
top_left[0],
top_left[1] + 4,
top_left[0] + label_w + 8,
top_left[1] + label_h + 8,
)
draw.rectangle(label_bg, fill=color)
draw.text(
(top_left[0] + 4, label_bg[1] + 2),
label,
fill=label_color,
font=font,
)
annotated = Image.alpha_composite(img, overlay)
annotated = annotated.convert("RGB")
annotated.save(output_path)
return output_path
def main():
parser = argparse.ArgumentParser(description="Annotate screenshot with issue markers")
parser.add_argument("--screenshot", required=True, help="Path to screenshot")
parser.add_argument("--results", required=True, help="Path to report.json")
parser.add_argument("--output", default=None, help="Output path (default: annotated.png)")
parser.add_argument("--label-size", type=int, default=16, help="Label font size")
args = parser.parse_args()
if args.output is None:
args.output = str(Path(args.screenshot).parent / "annotated.png")
report = json.loads(Path(args.results).read_text())
results = report.get("results", [])
output = annotate_screenshot(args.screenshot, results, args.output, args.label_size)
print(f"Annotated screenshot saved: {output}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Baseline diff engine for UI regression testing."""
from __future__ import annotations
import argparse
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Tuple
from core.baseline import BaselineDiff
def _load_json(path: str) -> Dict[str, Any]:
return json.loads(Path(path).read_text(encoding="utf-8"))
def _extract_current_snapshots(current_report: Dict[str, Any]) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
snapshots = current_report.get("snapshots", {})
ocr_texts = snapshots.get("ocr_texts")
a11y_elements = snapshots.get("a11y_elements")
if ocr_texts is None:
ocr_texts = []
for result in current_report.get("results", []):
if result.get("screenshot_region"):
ocr_texts.append(
{
"text": result.get("element", ""),
"box": result["screenshot_region"],
}
)
if a11y_elements is None:
a11y_elements = []
return {"texts": ocr_texts}, a11y_elements
def _normalize_baseline_data(baseline_payload: Dict[str, Any]) -> Dict[str, Any]:
if "ocr_texts" in baseline_payload or "a11y_elements" in baseline_payload:
return baseline_payload
extracted_ocr, extracted_a11y = _extract_current_snapshots(baseline_payload)
normalized = dict(baseline_payload)
normalized["ocr_texts"] = extracted_ocr.get("texts", [])
normalized["a11y_elements"] = extracted_a11y
normalized.setdefault("image_size", baseline_payload.get("image_size", [1280, 720]))
return normalized
def main() -> None:
parser = argparse.ArgumentParser(description="Baseline diff for UI regression testing")
parser.add_argument("--baseline", required=True, help="Path to baseline JSON file")
parser.add_argument("--current-report", required=True, help="Path to current report.json")
parser.add_argument(
"--threshold",
type=float,
default=0.1,
help="Layout shift threshold (ratio of max dimension)",
)
parser.add_argument("--output", default=None, help="Output diff report path")
args = parser.parse_args()
baseline_data = _normalize_baseline_data(_load_json(args.baseline))
current_report = _load_json(args.current_report)
current_ocr, current_a11y = _extract_current_snapshots(current_report)
diff = BaselineDiff(
current_ocr=current_ocr,
current_a11y=current_a11y,
baseline_data=baseline_data,
threshold=args.threshold,
)
results = diff.run()
output_data = {
"schema_version": "1.1",
"diff_timestamp": datetime.now(timezone.utc).isoformat(),
"baseline_file": args.baseline,
"current_report": args.current_report,
"threshold": args.threshold,
"summary": {
"total_issues": len(results),
"errors": sum(1 for result in results if result.get("severity") == "error"),
"warnings": sum(1 for result in results if result.get("severity") == "warning"),
},
"issues": results,
}
output_path = Path(args.output) if args.output else Path(args.baseline).parent / "diff.json"
output_path.write_text(json.dumps(output_data, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"Diff report saved: {output_path}")
print(
f" {output_data['summary']['total_issues']} issues: "
f"{output_data['summary']['errors']} errors, "
f"{output_data['summary']['warnings']} warnings"
)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Compare OCR results against DOM/Accessibility Tree."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Dict, List
from core.a11y import flatten_a11y_tree
from core.config import load_rules
from core.text_utils import build_cross_validation_findings
def load_json(path: str) -> Any:
return json.loads(Path(path).read_text(encoding="utf-8"))
def compare(
ocr_texts: List[Dict[str, Any]],
a11y_elements: List[Dict[str, Any]],
rules: Dict[str, Any],
) -> Dict[str, Any]:
return build_cross_validation_findings(ocr_texts, a11y_elements, rules)
def main() -> None:
parser = argparse.ArgumentParser(description="OCR vs DOM cross-validation")
parser.add_argument("--ocr", required=True, help="OCR result JSON file")
parser.add_argument("--a11y", required=True, help="A11y tree JSON file")
parser.add_argument(
"--output", default="cross_validation_report.json", help="Output file"
)
parser.add_argument("--rules", help="Rules directory for config overrides")
parser.add_argument(
"--ci",
action="store_true",
help="CI mode: exit code 1 if issues found at or above --fail-on severity",
)
parser.add_argument(
"--fail-on",
choices=["error", "warning"],
default="error",
help="Minimum severity to fail in CI mode (default: error)",
)
args = parser.parse_args()
rules = load_rules(args.rules)
ocr_data = load_json(args.ocr)
a11y_data = load_json(args.a11y)
ocr_texts = ocr_data.get("texts", []) if isinstance(ocr_data, dict) else ocr_data
if isinstance(a11y_data, dict):
a11y_elements = flatten_a11y_tree(a11y_data, leaf_only=True)
else:
a11y_elements = a11y_data
report = compare(ocr_texts, a11y_elements, rules=rules)
Path(args.output).write_text(
json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8"
)
print(f"Cross-validation report: {args.output}")
print(f" A11y texts: {report['summary']['a11y_text_count']}")
print(f" OCR texts: {report['summary']['ocr_text_count']}")
print(f" Matching: {report['summary']['matching']}")
print(f" Issues: {report['summary']['issues']}")
if args.ci:
severities = [issue.get("severity", "info") for issue in report.get("issues", [])]
if args.fail_on == "error" and "error" in severities:
sys.exit(1)
if args.fail_on == "warning" and any(
severity in {"error", "warning"} for severity in severities
):
sys.exit(1)
if __name__ == "__main__":
main()
"""Core contracts and helpers for PaddleOCR UI test."""
from .a11y import flatten_a11y_tree
from .baseline import BaselineDiff, create_baseline
from .config import (
SUPPORTED_LEVELS,
DEFAULT_PROFILES_DIR,
DEFAULT_RULES_DIR,
list_available_profiles,
load_profile,
load_rules,
load_runtime_config,
resolve_run_settings,
)
from .models import DetectionContext, EvidenceBundle, Issue, coerce_issues
from .reporting import ReportWriter, summarize_issues
from .text_utils import build_cross_validation_findings, find_best_match, text_similarity
__all__ = [
"BaselineDiff",
"DetectionContext",
"EvidenceBundle",
"Issue",
"ReportWriter",
"SUPPORTED_LEVELS",
"DEFAULT_PROFILES_DIR",
"DEFAULT_RULES_DIR",
"build_cross_validation_findings",
"coerce_issues",
"create_baseline",
"find_best_match",
"flatten_a11y_tree",
"list_available_profiles",
"load_profile",
"load_rules",
"load_runtime_config",
"resolve_run_settings",
"summarize_issues",
"text_similarity",
]
from __future__ import annotations
from typing import Any, Dict, List
def flatten_a11y_tree(
tree: Dict[str, Any],
*,
leaf_only: bool = True,
include_empty: bool = False,
) -> List[Dict[str, Any]]:
"""Flatten an accessibility tree to a list of nodes.
By default only leaf nodes are returned to avoid duplicate text inherited
from ancestor textContent values.
"""
elements: List[Dict[str, Any]] = []
def walk(node: Dict[str, Any], path: str = "") -> None:
role = node.get("role", "")
name = node.get("name", "")
value = node.get("value", "")
bounds = node.get("bounds", {})
text = (name or value or "").strip()
children = node.get("children", []) or []
is_leaf = len(children) == 0
if (include_empty or text) and (not leaf_only or is_leaf):
elements.append(
{
"role": role,
"text": text,
"bounds": bounds,
"path": path,
"tagName": node.get("tagName", ""),
}
)
for index, child in enumerate(children):
child_path = f"{path}/{role}[{index}]" if path else f"{role}[{index}]"
walk(child, child_path)
if tree:
walk(tree)
return elements
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from .models import Issue, coerce_issues
class BaselineDiff:
"""Compare current test results against a saved baseline."""
def __init__(
self,
current_ocr: Dict[str, Any],
current_a11y: List[Dict[str, Any]],
baseline_data: Dict[str, Any],
threshold: float = 0.1,
image_size: Optional[Tuple[int, int]] = None,
):
self.current_ocr_texts = {
t["text"] for t in current_ocr.get("texts", []) if t.get("text")
}
self.current_a11y_texts = {e["text"] for e in current_a11y if e.get("text")}
self.baseline_ocr_texts = {
t["text"]
for t in baseline_data.get("ocr_texts", [])
if isinstance(t, dict) and t.get("text")
}
self.baseline_a11y_texts = {
e["text"]
for e in baseline_data.get("a11y_elements", [])
if isinstance(e, dict) and e.get("text")
}
self.baseline_ocr_map = {
t["text"]: t
for t in baseline_data.get("ocr_texts", [])
if isinstance(t, dict) and t.get("text")
}
self.current_ocr_map = {
t["text"]: t for t in current_ocr.get("texts", []) if t.get("text")
}
self.threshold = threshold
self.image_size = image_size or tuple(
baseline_data.get("image_size", [1280, 720])
)
self.results: List[Issue] = []
def run(self) -> List[Dict[str, Any]]:
self._check_removed_texts()
self._check_added_texts()
self._check_layout_shifts()
self._check_element_count_changes()
self._check_a11y_regressions()
return coerce_issues(self.results)
def _check_removed_texts(self) -> None:
removed = self.baseline_ocr_texts - self.current_ocr_texts
for text in list(removed)[:10]:
self.results.append(
Issue(
type="baseline_regression",
level="L1",
severity="error",
element=text[:60],
expected=f"Text exists in baseline: '{text}'",
actual="Not found in current run",
suggestion=(
"Regression: text that was previously visible is now missing"
),
meta={"subtype": "text_removed"},
)
)
def _check_added_texts(self) -> None:
added = self.current_ocr_texts - self.baseline_ocr_texts
for text in list(added)[:10]:
self.results.append(
Issue(
type="baseline_regression",
level="L1",
severity="warning",
element=text[:60],
expected="No unexpected text",
actual=f"New text: '{text}'",
suggestion="Unexpected content appeared - verify if intentional",
meta={"subtype": "text_added"},
)
)
def _check_layout_shifts(self) -> None:
for text in self.current_ocr_texts & self.baseline_ocr_texts:
old_item = self.baseline_ocr_map.get(text)
new_item = self.current_ocr_map.get(text)
if not old_item or not new_item:
continue
old_box = old_item.get("box", [])
new_box = new_item.get("box", [])
if len(old_box) != 4 or len(new_box) != 4:
continue
shift = self._calculate_shift(old_box, new_box)
max_dim = max(self.image_size)
if shift > self.threshold * max_dim:
self.results.append(
Issue(
type="baseline_regression",
level="L2",
severity="warning",
element=text[:60],
expected=f"Position: {old_box}",
actual=f"Position: {new_box} (shift: {shift:.0f}px)",
screenshot_region=new_box,
suggestion="Layout shift detected - element moved significantly",
meta={"subtype": "layout_shift"},
)
)
def _check_element_count_changes(self) -> None:
old_count = len(self.baseline_ocr_texts)
new_count = len(self.current_ocr_texts)
if old_count > 0:
delta = abs(new_count - old_count) / old_count
if delta > self.threshold:
self.results.append(
Issue(
type="baseline_regression",
level="L3",
severity="warning",
element="page",
expected=f"~{old_count} text elements",
actual=f"{new_count} text elements (change: {delta:.0%})",
suggestion="Significant change in text element count",
meta={"subtype": "count_change"},
)
)
def _check_a11y_regressions(self) -> None:
removed_a11y = self.baseline_a11y_texts - self.current_a11y_texts
for text in list(removed_a11y)[:5]:
self.results.append(
Issue(
type="baseline_regression",
level="L4",
severity="error",
element=text[:60],
expected=f"A11y element exists in baseline: '{text}'",
actual="Not found in current accessibility tree",
suggestion=(
"Accessibility regression: element lost its accessible name"
),
meta={"subtype": "a11y_element_removed"},
)
)
@staticmethod
def _calculate_shift(box1: List[List[int]], box2: List[List[int]]) -> float:
cx1 = sum(point[0] for point in box1) / 4
cy1 = sum(point[1] for point in box1) / 4
cx2 = sum(point[0] for point in box2) / 4
cy2 = sum(point[1] for point in box2) / 4
return ((cx2 - cx1) ** 2 + (cy2 - cy1) ** 2) ** 0.5
def create_baseline(
ocr_result: Dict[str, Any],
a11y_elements: List[Dict[str, Any]],
url: str,
viewport: str,
image_size: Tuple[int, int],
summary: Dict[str, Any],
) -> Dict[str, Any]:
return {
"baseline_version": "1.1",
"created_at": datetime.now(timezone.utc).isoformat(),
"url": url,
"viewport": viewport,
"image_size": list(image_size),
"ocr_texts": ocr_result.get("texts", []),
"a11y_elements": a11y_elements,
"summary": summary,
}
from __future__ import annotations
import copy
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_RULES_DIR = REPO_ROOT / "rules"
DEFAULT_PROFILES_DIR = REPO_ROOT / "profiles"
SUPPORTED_LEVELS = ("L1", "L2", "L3", "L4", "L5", "L6")
RULE_FILE_MAP = {
"text-consistency": "text-consistency.json",
"layout-anomaly": "layout-anomaly.json",
"dom-ocr-crossval": "dom-ocr-crossval.json",
"accessibility": "accessibility.json",
"i18n": "i18n.json",
"dynamic-content": "dynamic-content.json",
}
DEFAULT_RULES: Dict[str, Dict[str, Any]] = {
"text-consistency": {
"version": "1.0",
"match_strategies": {
"exact": {"type": "exact"},
"substring": {"type": "substring"},
"fuzzy": {"type": "fuzzy", "threshold": 0.8},
},
"default_strategy": "substring",
"severity_overrides": {
"text_missing": "error",
"text_mismatch": "error",
"extra_text": "info",
},
"max_results_per_check": 10,
"ignore_patterns": [],
},
"layout-anomaly": {
"version": "1.0",
"overflow": {"enabled": True, "severity": "warning"},
"full_page_text": {
"enabled": True,
"width_threshold": 0.95,
"height_threshold": 0.8,
"severity": "warning",
},
"element_overlap": {
"enabled": False,
"iou_threshold": 0.3,
"severity": "warning",
},
"text_truncation": {
"enabled": False,
"min_expected_chars": 20,
"severity": "warning",
},
"touch_target_size": {
"enabled": False,
"min_width_px": 44,
"min_height_px": 44,
"severity": "warning",
},
},
"dom-ocr-crossval": {
"version": "1.0",
"fuzzy_match": {
"enabled": True,
"threshold": 0.6,
"warning_threshold": 0.7,
},
"count_mismatch": {
"enabled": True,
"delta_threshold": 0.3,
"min_elements": 5,
"severity": "warning",
},
"dom_not_rendered": {"max_results": 10, "severity": "error"},
"rendered_not_in_dom": {"max_results": 10, "severity": "warning"},
"ignore_patterns": [r"^[\s\u200b\u200c\u200d]+$"],
},
"accessibility": {
"version": "1.0",
"missing_alt": {
"enabled": True,
"roles": ["image", "graphic", "img"],
"ignore_values": ["", "image", "icon"],
"severity": "error",
},
"missing_label": {
"enabled": False,
"roles": ["button", "textbox", "checkbox", "radio", "combobox"],
"severity": "error",
},
"canvas_rendered_text": {"enabled": False, "severity": "warning"},
"emoji_as_icon": {
"enabled": False,
"emoji_pattern": r"[\U0001F600-\U0001F64F\U0001F300-\U0001F5FF\U0001F680-\U0001F6FF\U0001F1E0-\U0001F1FF\u2600-\u26FF\u2700-\u27BF]",
"severity": "warning",
},
},
"i18n": {
"version": "1.0",
"languages": {
"zh": {"script_pattern": r"[\u4e00-\u9fff]", "min_word_length": 1},
"en": {"script_pattern": r"[a-zA-Z]{4,}", "min_word_length": 4},
"ja": {
"script_pattern": r"[\u3040-\u309f\u30a0-\u30ff\u4e00-\u9fff]",
"min_word_length": 1,
},
"ko": {
"script_pattern": r"[\uac00-\ud7af\u1100-\u11ff]",
"min_word_length": 1,
},
},
"mixed_language": {"enabled": False, "severity": "info"},
"common_false_positives": [
"OK",
"Login",
"Sign Up",
"API",
"URL",
"ID",
"CSS",
"HTML",
"JS",
],
},
"dynamic-content": {
"version": "1.0",
"state_transitions": {
"loading_to_content": {
"should_disappear": ["Loading...", "loading", "spinner", "请稍候"],
"should_appear": [],
"timeout_ms": 5000,
},
"content_to_error": {
"should_disappear": [],
"should_appear": ["Error", "error", "失败", "异常"],
"timeout_ms": 10000,
},
},
"content_persistence": {"enabled": False, "critical_texts": []},
"max_tracked_changes": 5,
},
}
ALLOWED_RULE_KEYS = {
"text-consistency": {
"version",
"description",
"agent_hints",
"match_strategies",
"default_strategy",
"severity_overrides",
"max_results_per_check",
"ignore_patterns",
},
"layout-anomaly": {
"version",
"description",
"agent_hints",
"overflow",
"full_page_text",
"element_overlap",
"text_truncation",
"touch_target_size",
},
"dom-ocr-crossval": {
"version",
"description",
"agent_hints",
"fuzzy_match",
"count_mismatch",
"dom_not_rendered",
"rendered_not_in_dom",
"ignore_patterns",
},
"accessibility": {
"version",
"description",
"agent_hints",
"missing_alt",
"missing_label",
"canvas_rendered_text",
"emoji_as_icon",
},
"i18n": {
"version",
"description",
"agent_hints",
"languages",
"mixed_language",
"common_false_positives",
},
"dynamic-content": {
"version",
"description",
"agent_hints",
"state_transitions",
"content_persistence",
"max_tracked_changes",
},
}
PROFILE_ALLOWED_KEYS = {
"name",
"description",
"when_to_use",
"default_levels",
"viewport",
"wait_ms",
"rule_overrides",
"expected_elements",
"ignore_patterns",
}
EXPECTED_ELEMENT_ALLOWED_KEYS = {"role", "name_pattern", "min_count", "max_count"}
RUNTIME_CONFIG_ALLOWED_KEYS = {
"expected_texts",
"expected_language",
"ignore_texts",
"levels",
"wait_ms",
"expected_elements",
"ignore_patterns",
}
@dataclass
class ResolvedRunSettings:
config: Dict[str, Any]
rules: Dict[str, Any]
levels: List[str]
viewport: str
wait_ms: int
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
merged = copy.deepcopy(base)
for key, value in override.items():
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
merged[key] = _deep_merge(merged[key], value)
else:
merged[key] = copy.deepcopy(value)
return merged
def _dedupe_strings(values: Sequence[str]) -> List[str]:
seen = set()
results = []
for value in values:
if value not in seen:
seen.add(value)
results.append(value)
return results
def _has_nested_path(data: Dict[str, Any], parts: Sequence[str]) -> bool:
target: Any = data
for part in parts:
if not isinstance(target, dict) or part not in target:
return False
target = target[part]
return True
def _set_nested_path(data: Dict[str, Any], parts: Sequence[str], value: Any) -> None:
target = data
for part in parts[:-1]:
target = target[part]
target[parts[-1]] = value
def _validate_viewport(viewport: str) -> None:
if not re.fullmatch(r"\d+x\d+", viewport or ""):
raise ValueError(f"Invalid viewport format: {viewport}")
def _validate_levels(levels: Sequence[str]) -> None:
invalid = [level for level in levels if level not in SUPPORTED_LEVELS]
if invalid:
raise ValueError(
f"Unsupported test levels: {', '.join(invalid)}. Expected subset of {', '.join(SUPPORTED_LEVELS)}"
)
def _validate_expected_elements(expected_elements: Sequence[Dict[str, Any]]) -> None:
for idx, spec in enumerate(expected_elements):
unknown = set(spec) - EXPECTED_ELEMENT_ALLOWED_KEYS
if unknown:
raise ValueError(
f"Unknown expected_elements keys at index {idx}: {', '.join(sorted(unknown))}"
)
if not spec.get("role"):
raise ValueError(f"expected_elements[{idx}] must define 'role'")
def list_available_profiles(profiles_dir: Optional[str] = None) -> List[str]:
target = Path(profiles_dir) if profiles_dir else DEFAULT_PROFILES_DIR
if not target.exists():
return []
return sorted(path.stem for path in target.glob("*.json"))
def load_rules(rules_dir: Optional[str] = None) -> Dict[str, Dict[str, Any]]:
rules_path = Path(rules_dir) if rules_dir else DEFAULT_RULES_DIR
rules: Dict[str, Dict[str, Any]] = {}
for section, filename in RULE_FILE_MAP.items():
loaded: Dict[str, Any] = {}
filepath = rules_path / filename
if filepath.exists():
loaded = json.loads(filepath.read_text(encoding="utf-8"))
unknown = set(loaded) - ALLOWED_RULE_KEYS[section]
if unknown:
raise ValueError(
f"Unknown keys in rule file {filename}: {', '.join(sorted(unknown))}"
)
rules[section] = _deep_merge(DEFAULT_RULES[section], loaded)
return rules
def apply_rule_overrides(rules: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]:
updated = copy.deepcopy(rules)
for dotted_key, value in (overrides or {}).items():
parts = dotted_key.split(".")
if not _has_nested_path(updated, parts):
raise ValueError(f"Unknown rule override path: {dotted_key}")
_set_nested_path(updated, parts, value)
return updated
def load_profile(
profile_name: str,
*,
profiles_dir: Optional[str] = None,
rules: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
profiles_path = Path(profiles_dir) if profiles_dir else DEFAULT_PROFILES_DIR
profile_path = profiles_path / f"{profile_name}.json"
if not profile_path.exists():
available = list_available_profiles(str(profiles_path))
raise ValueError(
f"Unknown profile: {profile_name}. Available: {', '.join(available) if available else 'none'}"
)
profile = json.loads(profile_path.read_text(encoding="utf-8"))
unknown = set(profile) - PROFILE_ALLOWED_KEYS
if unknown:
raise ValueError(
f"Unknown keys in profile {profile_name}: {', '.join(sorted(unknown))}"
)
if profile.get("default_levels"):
_validate_levels(profile["default_levels"])
if profile.get("viewport"):
_validate_viewport(profile["viewport"])
if profile.get("expected_elements"):
_validate_expected_elements(profile["expected_elements"])
candidate_rules = rules or load_rules()
if profile.get("rule_overrides"):
apply_rule_overrides(candidate_rules, profile["rule_overrides"])
return profile
def load_runtime_config(config_path: Optional[str]) -> Dict[str, Any]:
if not config_path:
return {}
config = json.loads(Path(config_path).read_text(encoding="utf-8"))
unknown = set(config) - RUNTIME_CONFIG_ALLOWED_KEYS
if unknown:
raise ValueError(
f"Unknown keys in runtime config: {', '.join(sorted(unknown))}"
)
if "levels" in config:
_validate_levels(config["levels"])
if "expected_elements" in config:
_validate_expected_elements(config["expected_elements"])
return config
def resolve_run_settings(
*,
args: Any,
raw_config: Optional[Dict[str, Any]],
rules: Dict[str, Any],
profile: Optional[Dict[str, Any]] = None,
) -> ResolvedRunSettings:
config = copy.deepcopy(raw_config or {})
resolved_rules = copy.deepcopy(rules)
profile = copy.deepcopy(profile or {})
if profile.get("rule_overrides"):
resolved_rules = apply_rule_overrides(resolved_rules, profile["rule_overrides"])
merged_expected_elements = list(profile.get("expected_elements", [])) + list(
config.get("expected_elements", [])
)
if merged_expected_elements:
_validate_expected_elements(merged_expected_elements)
config["expected_elements"] = merged_expected_elements
merged_ignore_patterns = _dedupe_strings(
list(profile.get("ignore_patterns", [])) + list(config.get("ignore_patterns", []))
)
if merged_ignore_patterns:
config["ignore_patterns"] = merged_ignore_patterns
for section in ("text-consistency", "dom-ocr-crossval"):
resolved_rules[section]["ignore_patterns"] = _dedupe_strings(
list(resolved_rules[section].get("ignore_patterns", []))
+ merged_ignore_patterns
)
levels = config.get("levels") or profile.get("default_levels") or ["L1", "L3"]
if getattr(args, "levels", None):
levels = [level.strip() for level in args.levels.split(",") if level.strip()]
_validate_levels(levels)
viewport = getattr(args, "viewport", None) or profile.get("viewport", "1280x720")
_validate_viewport(viewport)
wait_ms = int(config.get("wait_ms", profile.get("wait_ms", getattr(args, "wait", 2000))))
return ResolvedRunSettings(
config=config,
rules=resolved_rules,
levels=levels,
viewport=viewport,
wait_ms=wait_ms,
)
from __future__ import annotations
from dataclasses import asdict, dataclass, field, is_dataclass
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
@dataclass
class EvidenceBundle:
screenshot_path: str
a11y_tree: Dict[str, Any] = field(default_factory=dict)
dom_html: Optional[str] = None
url: str = ""
viewport: str = ""
source: str = "standalone"
state: str = "current"
extras: Dict[str, Any] = field(default_factory=dict)
schema_version: str = "1.1"
capabilities: Dict[str, bool] = field(default_factory=dict)
provenance: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
defaults = {
"has_dom": bool(self.dom_html),
"has_a11y": bool(self.a11y_tree),
"has_actions": False,
"has_source_map": bool(self.extras.get("source_map")),
}
defaults.update(self.capabilities)
self.capabilities = defaults
provenance = {
"source": self.source,
"state": self.state,
}
provenance.update(self.provenance)
self.provenance = provenance
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@dataclass
class Issue:
type: str
level: str
severity: str
element: str
expected: str
actual: str
screenshot_region: Optional[List[List[int]]] = None
suggestion: str = ""
evidence: Dict[str, Any] = field(default_factory=dict)
source_location: Dict[str, Any] = field(default_factory=dict)
meta: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
data = asdict(self)
if not self.screenshot_region:
data.pop("screenshot_region", None)
if not self.source_location:
data.pop("source_location", None)
if not self.evidence:
data.pop("evidence", None)
if not self.meta:
data.pop("meta", None)
if not self.suggestion:
data.pop("suggestion", None)
return data
@dataclass
class DetectionContext:
ocr_result: Dict[str, Any]
a11y_elements: List[Dict[str, Any]]
config: Dict[str, Any]
image_size: Tuple[int, int]
rules: Dict[str, Any] = field(default_factory=dict)
evidence: Optional[EvidenceBundle] = None
before_ocr: Optional[Dict[str, Any]] = None
after_ocr: Optional[Dict[str, Any]] = None
def capabilities(self) -> Dict[str, bool]:
base = dict(self.evidence.capabilities) if self.evidence else {}
inferred = {
"has_dom": bool(self.evidence and self.evidence.dom_html),
"has_a11y": bool(self.a11y_elements),
"has_actions": bool(base.get("has_actions"))
and bool(self.before_ocr)
and bool(self.after_ocr),
"has_source_map": bool(base.get("has_source_map")),
}
inferred.update(base)
if self.before_ocr and self.after_ocr:
inferred["has_actions"] = True
return inferred
@dataclass
class ExecutionRecord:
level: str
detector: str
status: str
reason: str = ""
required_capabilities: List[str] = field(default_factory=list)
missing_capabilities: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
data = asdict(self)
if not self.reason:
data.pop("reason", None)
if not self.required_capabilities:
data.pop("required_capabilities", None)
if not self.missing_capabilities:
data.pop("missing_capabilities", None)
return data
@dataclass
class DetectionOutcome:
issues: List[Dict[str, Any]] = field(default_factory=list)
execution: List[Dict[str, Any]] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"issues": list(self.issues),
"execution": coerce_execution_records(self.execution),
}
IssueLike = Union[Issue, Dict[str, Any]]
def coerce_issue(issue: IssueLike) -> Dict[str, Any]:
if isinstance(issue, Issue):
return issue.to_dict()
if is_dataclass(issue):
return asdict(issue)
return dict(issue)
def coerce_issues(issues: Iterable[IssueLike]) -> List[Dict[str, Any]]:
return [coerce_issue(issue) for issue in issues]
ExecutionRecordLike = Union[ExecutionRecord, Dict[str, Any]]
def coerce_execution_record(record: ExecutionRecordLike) -> Dict[str, Any]:
if isinstance(record, ExecutionRecord):
return record.to_dict()
if is_dataclass(record):
return asdict(record)
return dict(record)
def coerce_execution_records(
records: Iterable[ExecutionRecordLike],
) -> List[Dict[str, Any]]:
return [coerce_execution_record(record) for record in records]
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from PIL import Image
from adapters.registry import build_adapter
from core.a11y import flatten_a11y_tree
from core.baseline import BaselineDiff, create_baseline
from core.models import DetectionContext, EvidenceBundle
from core.reporting import ReportWriter
from providers.ocr import create_ocr_provider
@dataclass
class CollectedEvidence:
bundle: EvidenceBundle
input_mode: str
screenshot_path: Path
after_capture_path: Optional[str]
a11y_elements: List[Dict[str, Any]]
image_size: Tuple[int, int]
@dataclass
class OCRArtifacts:
ocr_result: Dict[str, Any]
before_ocr: Optional[Dict[str, Any]] = None
after_ocr: Optional[Dict[str, Any]] = None
def _copy_screenshot_to_output(source_path: Path, output_dir: Path) -> Path:
output_path = output_dir / "screenshot.png"
if source_path.resolve() != output_path.resolve():
output_path.write_bytes(source_path.read_bytes())
return output_path
async def collect_evidence(
args: Any,
*,
viewport: str,
wait_ms: int,
output_dir: Path,
enable_actions: bool = False,
) -> CollectedEvidence:
adapter, input_mode = build_adapter(args)
if input_mode == "url":
if not args.url:
raise ValueError("--url is required when input mode is 'url'")
from adapters.standalone_url import capture_from_url
requested_screenshot = output_dir / "screenshot.png"
after_screenshot_path = output_dir / "screenshot_after.png"
bundle, after_capture_path = await capture_from_url(
url=args.url,
viewport=viewport,
wait_ms=wait_ms,
screenshot_path=requested_screenshot,
source=args.source or "standalone",
actions=args.actions if enable_actions and args.actions else None,
after_screenshot_path=after_screenshot_path,
)
else:
bundle = await adapter.load_bundle()
after_capture_path = None
screenshot_path = _copy_screenshot_to_output(
Path(bundle.screenshot_path),
output_dir,
)
bundle.screenshot_path = str(screenshot_path)
if getattr(args, "source_map", None):
bundle.capabilities["has_source_map"] = True
bundle.extras["source_map"] = args.source_map
bundle.provenance["source_map"] = args.source_map
a11y_elements = flatten_a11y_tree(bundle.a11y_tree or {}, leaf_only=True)
with Image.open(screenshot_path) as image:
image_size = image.size
return CollectedEvidence(
bundle=bundle,
input_mode=input_mode,
screenshot_path=screenshot_path,
after_capture_path=after_capture_path,
a11y_elements=a11y_elements,
image_size=image_size,
)
async def run_ocr_stage(
args: Any,
*,
api_key: str,
collected: CollectedEvidence,
run_action_ocr: bool = False,
) -> OCRArtifacts:
ocr_provider = create_ocr_provider(args.ocr_provider, api_key=api_key)
print(
f"Sending screenshot to {args.ocr_provider} "
f"({collected.image_size[0]}x{collected.image_size[1]}) ..."
)
ocr_result = await ocr_provider.recognize(str(collected.screenshot_path))
before_ocr = None
after_ocr = None
if (
run_action_ocr
and collected.bundle.capabilities.get("has_actions")
and collected.after_capture_path
):
before_ocr = ocr_result
print("Running after-action OCR ...")
after_ocr = await ocr_provider.recognize(collected.after_capture_path)
return OCRArtifacts(
ocr_result=ocr_result,
before_ocr=before_ocr,
after_ocr=after_ocr,
)
def build_detection_context(
*,
settings: Any,
collected: CollectedEvidence,
ocr_artifacts: OCRArtifacts,
) -> DetectionContext:
return DetectionContext(
ocr_result=ocr_artifacts.ocr_result,
a11y_elements=collected.a11y_elements,
config=settings.config,
image_size=collected.image_size,
rules=settings.rules,
evidence=collected.bundle,
before_ocr=ocr_artifacts.before_ocr,
after_ocr=ocr_artifacts.after_ocr,
)
def apply_baseline_stage(
args: Any,
*,
output_dir: Path,
collected: CollectedEvidence,
ocr_artifacts: OCRArtifacts,
viewport: str,
summary: Dict[str, Any],
results: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
if args.baseline:
baseline_data = create_baseline(
ocr_result=ocr_artifacts.ocr_result,
a11y_elements=collected.a11y_elements,
url=collected.bundle.url or args.url or "",
viewport=collected.bundle.viewport or viewport,
image_size=collected.image_size,
summary=summary,
)
baseline_path = output_dir / "baseline.json"
baseline_path.write_text(
json.dumps(baseline_data, indent=2, ensure_ascii=False),
encoding="utf-8",
)
print(f" Baseline saved: {baseline_path}")
if not args.baseline_file:
return results
baseline_data = json.loads(Path(args.baseline_file).read_text(encoding="utf-8"))
diff_engine = BaselineDiff(
current_ocr=ocr_artifacts.ocr_result,
current_a11y=collected.a11y_elements,
baseline_data=baseline_data,
threshold=args.diff_threshold,
image_size=collected.image_size,
)
diff_results = diff_engine.run()
print(f" Baseline diff: {len(diff_results)} issues found")
return [*results, *diff_results]
def enrich_findings(
results: List[Dict[str, Any]],
*,
a11y_elements: List[Dict[str, Any]],
source_map_dir: Optional[str],
) -> tuple[List[Dict[str, Any]], Dict[str, Any]]:
if not source_map_dir:
return results, {"status": "skipped", "reason": "no source map directory"}
if not a11y_elements:
return results, {
"status": "skipped",
"reason": "missing required capabilities",
"missing_capabilities": ["has_a11y"],
}
from source_map_lookup import resolve_issues
enriched = resolve_issues([dict(issue) for issue in results], a11y_elements, source_map_dir)
return enriched, {"status": "executed", "source_map_dir": source_map_dir}
def write_output_stage(
args: Any,
*,
collected: CollectedEvidence,
ocr_artifacts: OCRArtifacts,
results: List[Dict[str, Any]],
duration: float,
profile_name: str,
levels: List[str],
detector_execution: List[Dict[str, Any]],
source_map_execution: Dict[str, Any],
) -> tuple[ReportWriter, Dict[str, str]]:
reporter = ReportWriter(
url=collected.bundle.url or args.url or "(external artifact)",
results=results,
output_dir=args.output,
image_size=collected.image_size,
duration=duration,
metadata={
"input_mode": collected.input_mode,
"source": collected.bundle.source,
"profile": profile_name,
"levels": levels,
"ocr_provider": args.ocr_provider,
"capabilities": collected.bundle.capabilities,
"provenance": collected.bundle.provenance,
"detector_execution": detector_execution,
"source_map_execution": source_map_execution,
},
snapshots={
"ocr_texts": ocr_artifacts.ocr_result.get("texts", []),
"a11y_elements": collected.a11y_elements,
},
)
return reporter, reporter.write_selected(args.format)
from __future__ import annotations
import re
from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple
def normalize_text(text: str) -> str:
return re.sub(r"\s+", " ", (text or "")).strip()
def compile_patterns(patterns: Iterable[str]) -> List[Pattern[str]]:
return [re.compile(pattern) for pattern in patterns or []]
def matches_any_pattern(text: str, patterns: Sequence[Pattern[str]]) -> bool:
return any(pattern.search(text) for pattern in patterns)
def text_similarity(a: str, b: str) -> float:
a_norm = normalize_text(a).lower()
b_norm = normalize_text(b).lower()
if not a_norm or not b_norm:
return 0.0
if a_norm == b_norm:
return 1.0
if a_norm in b_norm or b_norm in a_norm:
return 0.8
m, n = len(a_norm), len(b_norm)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if a_norm[i - 1] == b_norm[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
return dp[m][n] / max(m, n)
def find_best_match(
query: str, candidates: Sequence[str], threshold: float = 0.6
) -> Tuple[Optional[str], float]:
best_match: Optional[str] = None
best_score = 0.0
for candidate in candidates:
score = text_similarity(query, candidate)
if score > best_score:
best_match = candidate
best_score = score
if best_score >= threshold:
return best_match, best_score
return None, best_score
def distinct_texts(items: Iterable[Dict[str, Any]], key: str = "text") -> List[str]:
seen = set()
results = []
for item in items:
text = normalize_text(str(item.get(key, "")))
if text and text not in seen:
seen.add(text)
results.append(text)
return results
def build_cross_validation_findings(
ocr_texts: Sequence[Dict[str, Any]],
a11y_elements: Sequence[Dict[str, Any]],
rules: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
dom_rules = (rules or {}).get("dom-ocr-crossval", {})
fuzzy_cfg = dom_rules.get("fuzzy_match", {})
count_cfg = dom_rules.get("count_mismatch", {})
fuzzy_enabled = fuzzy_cfg.get("enabled", True)
fuzzy_threshold = fuzzy_cfg.get("threshold", 0.6)
warning_threshold = fuzzy_cfg.get("warning_threshold", 0.7)
ignore_patterns = compile_patterns(dom_rules.get("ignore_patterns", []))
ocr_text_list = distinct_texts(ocr_texts)
a11y_text_list = distinct_texts(a11y_elements)
ocr_set = set(ocr_text_list)
a11y_set = set(a11y_text_list)
issues: List[Dict[str, Any]] = []
matched_a11y = set()
for a11y_text in a11y_text_list:
if matches_any_pattern(a11y_text, ignore_patterns):
matched_a11y.add(a11y_text)
continue
if a11y_text in ocr_set:
matched_a11y.add(a11y_text)
continue
if fuzzy_enabled:
best_match, similarity = find_best_match(
a11y_text, ocr_text_list, threshold=fuzzy_threshold
)
if best_match is not None:
matched_a11y.add(a11y_text)
issues.append(
{
"type": "text_mismatch",
"severity": "warning"
if similarity >= warning_threshold
else "error",
"a11y_text": a11y_text,
"closest_ocr": best_match,
"similarity": round(similarity, 3),
"description": (
f"DOM says '{a11y_text}', OCR sees '{best_match}' "
f"(similarity: {similarity:.1%})"
),
}
)
continue
issues.append(
{
"type": "dom_not_rendered",
"severity": dom_rules.get("dom_not_rendered", {}).get(
"severity", "error"
),
"a11y_text": a11y_text,
"description": (
f"Text '{a11y_text}' exists in DOM but not visible in screenshot"
),
}
)
rendered_not_in_dom_max = dom_rules.get("rendered_not_in_dom", {}).get(
"max_results", 10
)
for text in ocr_text_list:
if text in a11y_set or matches_any_pattern(text, ignore_patterns):
continue
issues.append(
{
"type": "rendered_not_in_dom",
"severity": dom_rules.get("rendered_not_in_dom", {}).get(
"severity", "warning"
),
"ocr_text": text,
"description": (
f"Text '{text}' visible in screenshot but not in accessibility tree"
),
}
)
if sum(1 for issue in issues if issue["type"] == "rendered_not_in_dom") >= rendered_not_in_dom_max:
break
delta_threshold = count_cfg.get("delta_threshold", 0.3)
min_elements = count_cfg.get("min_elements", 5)
count_delta = abs(len(a11y_set) - len(ocr_set))
max_count = max(len(a11y_set), len(ocr_set), 1)
if (
count_cfg.get("enabled", True)
and count_delta > max_count * delta_threshold
and len(a11y_set) > min_elements
):
issues.append(
{
"type": "count_mismatch",
"severity": count_cfg.get("severity", "warning"),
"a11y_count": len(a11y_set),
"ocr_count": len(ocr_set),
"description": (
f"Text count mismatch: {len(a11y_set)} in DOM vs {len(ocr_set)} in OCR "
f"({count_delta / max_count:.0%} delta)"
),
}
)
dom_not_rendered_max = dom_rules.get("dom_not_rendered", {}).get("max_results", 10)
dom_not_rendered = [
issue for issue in issues if issue["type"] == "dom_not_rendered"
][:dom_not_rendered_max]
others = [issue for issue in issues if issue["type"] != "dom_not_rendered"]
final_issues = dom_not_rendered + others
return {
"summary": {
"a11y_text_count": len(a11y_set),
"ocr_text_count": len(ocr_set),
"matching": len(ocr_set & a11y_set),
"issues": len(final_issues),
},
"issues": final_issues,
}
"""Level detectors for PaddleOCR UI test."""
from .registry import DETECTOR_REGISTRY, execute_levels, list_detectors, run_levels
__all__ = ["DETECTOR_REGISTRY", "execute_levels", "list_detectors", "run_levels"]
"""Provider abstractions for PaddleOCR UI test."""
from .ocr import (
OCR_PROVIDER_REGISTRY,
PaddleOCRProvider,
create_ocr_provider,
describe_ocr_provider,
list_ocr_providers,
)
__all__ = [
"OCR_PROVIDER_REGISTRY",
"PaddleOCRProvider",
"create_ocr_provider",
"describe_ocr_provider",
"list_ocr_providers",
]
{
"name": "paddleocr-ui-test",
"description": "AI-driven UI testing skill combining PaddleOCR screenshot analysis with DOM/Accessibility Tree cross-validation",
"version": "0.2.0"
}
Related skills
FAQ
How does paddleocr-ui-test detect defects?
It cross-references PaddleOCR-extracted screenshot text with the Playwright accessibility tree across six detection levels.
What does it require to run?
A PADDLEOCR_API_KEY or SILICONFLOW_API_KEY, plus Python (openai, playwright, Pillow) and a Chromium install.