
Paddleocr Ui Test
- 1 installs
- 8 repo stars
- Updated April 10, 2026
- aotenjou/paddleocr-uitest
PaddleOCR UI Testing is a skill that cross-validates PaddleOCR screenshot text against Playwright accessibility-tree snapshots to run OCR-based visual UI regression tests.
About
PaddleOCR UI Testing runs OCR over page screenshots and cross-references the extracted text and box coordinates against Playwright accessibility-tree snapshots to catch visual regressions. A developer runs it to detect text mismatches, layout anomalies, DOM-vs-render discrepancies, and internationalization issues on a live URL. It outputs JSON and Markdown reports and can map each issue to a source code location when a source map is supplied.
- Combines PaddleOCR screenshot text extraction with Playwright accessibility-tree snapshots
- Six test levels: text, layout, DOM consistency, accessibility, i18n, dynamic content
- Maps detected UI issues back to source file:line via a source map
Paddleocr Ui Test by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,750 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
Requires a PaddleOCR API key (PADDLEOCR_API_KEY or SILICONFLOW_API_KEY)
- Capabilities
- visual regression · accessibility audit · ui test
- Works with
- playwright
- Use cases
- testing · ui design
- Runs
- Runs locally
- Pricing
- Bring your own API key
What paddleocr-ui-test says it does
AI-driven UI testing that combines PaddleOCR screenshot analysis with DOM/Accessibility Tree cross-validation for intelligent visual regression testing.
Cross-reference what OCR sees vs what the DOM claims exists.
PaddleOCR API key via `PADDLEOCR_API_KEY` environment variable (or `SILICONFLOW_API_KEY`)
npx skills add https://github.com/aotenjou/paddleocr-uitest --skill paddleocr-ui-testAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 8 |
| Last updated | April 10, 2026 |
| Repository | aotenjou/paddleocr-uitest ↗ |
What it does
Validate that a rendered web UI matches the DOM and expected text by cross-checking OCR screenshot output against the Playwright accessibility tree.
Who is it for?
OCR-based visual and DOM-consistency regression testing of rendered web pages
When should I use this skill?
when the user asks to test UI from a screenshot, verify UI matches expected, or run an OCR-based UI test
What you get
A JSON + Markdown report of UI text, layout, and DOM-consistency failures mapped to source code locations
- JSON test report
- Markdown test report
- optional annotated screenshot
By the numbers
- six-level UI testing (L1-L6)
- default runs levels L1,L3
Files
PaddleOCR UI Testing
AI-driven UI testing that combines PaddleOCR screenshot analysis with DOM/Accessibility Tree cross-validation for intelligent visual regression testing.
Overview
This skill provides six-level UI testing capabilities:
| Level | Scenario | Detection Method |
|---|---|---|
| L1 | Text consistency | OCR text vs expected text |
| L2 | Layout合理性 | OCR box coordinate analysis |
| L3 | DOM consistency | OCR vs A11y Tree cross-validation |
| L4 | Accessibility | OCR + A11y joint analysis |
| L5 | Internationalization | OCR language detection |
| L6 | Dynamic content | Screenshot sequence comparison |
Quick Start
Prerequisites
- Python 3.8+ with
openai,playwright,Pillowinstalled - PaddleOCR API key via
PADDLEOCR_API_KEYenvironment variable (orSILICONFLOW_API_KEY) - Playwright browsers installed (
playwright install)
Basic Usage
Run the /ui-test command or execute the test script directly:
python3 scripts/ui_test.py --url https://example.com --config examples/test-config.jsonArguments
| Argument | Description |
|---|---|
--url | Target URL to test (required) |
--config | Test configuration JSON file |
--levels | Test levels to run: L1,L2,L3,L4,L5,L6 (default: L1,L3) |
--viewport | Browser viewport size, e.g. "1920x1080" (default: 1280x720) |
--wait | Milliseconds to wait after page load (default: 2000) |
--output | Output directory for results (default: ./test-results) |
--format | Output format: json, markdown, both (default: both) |
--source-map | Path to source map directory for code location lookup |
--annotate | Generate annotated screenshot with issue markers |
Test Execution Flow
1. Navigate to URL with Playwright
2. Wait for page to stabilize
3. Capture screenshot
4. Extract accessibility tree snapshot
5. Send screenshot to PaddleOCR for text + coordinate extraction
6. Cross-validate OCR results against A11y Tree
7. Map issues to source code locations (if --source-map provided)
8. Generate report (JSON + Markdown)
9. Optionally generate annotated screenshotTest Levels Detail
L1: Text Consistency
Compare text visible in screenshot against expected text values.
python3 scripts/ui_test.py --url https://example.com/login \
--levels L1 \
--config examples/test-config.jsonDetects: typos, missing text, extra text, character encoding issues, text truncation.
L2: Layout Reasonableness
Analyze OCR box coordinates to detect layout anomalies.
python3 scripts/ui_test.py --url https://example.com --levels L2Detects: overlapping elements, text overflow, misaligned components, hidden content visible.
L3: DOM Consistency (Core Feature)
Cross-reference what OCR sees vs what the DOM claims exists.
python3 scripts/ui_test.py --url https://example.com --levels L3Detects: elements in DOM but not rendered, elements rendered but not in DOM, text content mismatches, count discrepancies.
L4: Accessibility
Joint OCR + A11y analysis for visual accessibility issues.
python3 scripts/ui_test.py --url https://example.com --levels L4Detects: low contrast text (inferred from OCR confidence), missing labels, unreadable text.
L5: Internationalization
Detect language mismatches in multi-language UIs.
python3 scripts/ui_test.py --url https://example.com/zh --levels L5Detects: untranslated strings, wrong language content, encoding issues.
L6: Dynamic Content
Compare screenshot sequences to verify state transitions.
python3 scripts/ui_test.py --url https://example.com --levels L6 \
--actions "click(#load-more);wait(2000);screenshot"Detects: loading states not clearing, animations stuck, content not updating.
Output Format
JSON Report
{
"test_id": "ui-test-20260402-001",
"url": "https://example.com/login",
"timestamp": "2026-04-02T10:30:00Z",
"summary": {
"total_checks": 12,
"passed": 10,
"failed": 2,
"warnings": 1
},
"results": [
{
"type": "text_mismatch",
"severity": "error",
"level": "L1",
"element": "submit_button",
"expected": "提交",
"actual": "提 交",
"source_location": "src/components/LoginForm.tsx:42",
"screenshot_region": [[480, 280], [560, 320]],
"suggestion": "检查 CSS letter-spacing 或 font-kerning 设置"
}
]
}Markdown Report
Human-readable report with test summary, failed items, warnings, and annotated screenshot reference.
Source Code Location Mapping
When --source-map is provided, issues are mapped to source code locations:
1. OCR identifies text at pixel coordinates (x, y) 2. Playwright provides DOM element at same coordinates 3. Source map resolves DOM element to original source file:line 4. Report includes exact file path and line number for fixes
Integration with Other Skills
With dogfood (Exploratory Testing)
1. Run dogfood first for exploratory page analysis 2. Extract issues found by dogfood as test cases 3. Run paddleocr-ui-test for automated regression verification
With dev-browser (Browser Automation)
1. Use dev-browser to navigate to target pages 2. Capture screenshots via dev-browser 3. Feed screenshots to paddleocr-ui-test for analysis
Additional Resources
Reference Files
- `references/ocr-api.md` - PaddleOCR API configuration and model selection
- `references/a11y-tree.md` - Accessibility Tree format and parsing guide
- `references/test-patterns.md` - Common UI test patterns and configurations
Example Files
- `examples/test-config.json` - Complete test configuration example
Scripts
- `scripts/ui_test.py` - Main test execution script
- `scripts/compare_ocr_dom.py` - OCR vs DOM cross-validation engine
- `scripts/source_map_lookup.py` - Source code location resolver
# 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
{
"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
PaddleOCR UI Test Skill
AI-driven UI testing skill combining PaddleOCR screenshot analysis with DOM/Accessibility Tree cross-validation.
Installation
Recommended (npx skills)
Install from GitHub (global for OpenCode):
npx skills add <owner>/paddleocr-ui-test --skill paddleocr-ui-test -g -a opencode -yInstall from GitHub (global for Claude Code):
npx skills add <owner>/paddleocr-ui-test --skill paddleocr-ui-test -g -a claude-code -yList discoverable skills in the repository:
npx skills add <owner>/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:
python3 scripts/ui_test.py --url https://example.com --levels L1,L2,L3 --output resultsTest Levels
| Level | Scenario | Method |
|---|---|---|
| L1 | Text consistency | OCR vs expected text |
| L2 | Layout reasonableness | OCR box coordinates |
| L3 | DOM consistency | OCR vs A11y Tree |
| L4 | Accessibility | Joint OCR + A11y |
| L5 | Internationalization | Language detection |
| L6 | Dynamic content | Screenshot sequences |
License
Apache-2.0
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
#!/usr/bin/env python3
"""
Compare OCR results against DOM/Accessibility Tree.
Standalone cross-validation engine that takes OCR output and A11y tree data,
produces a structured diff report.
Usage:
python3 compare_ocr_dom.py --ocr ocr_result.json --a11y a11y_tree.json [--output report.json]
"""
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Dict, List, Set, Tuple
def load_json(path: str) -> Dict[str, Any]:
"""Load JSON from file."""
return json.loads(Path(path).read_text(encoding="utf-8"))
def flatten_a11y_tree(tree: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Flatten accessibility tree to element list."""
elements = []
def walk(node: Dict[str, Any], path: str = ""):
role = node.get("role", "")
name = node.get("name", "")
value = node.get("value", "")
bounds = node.get("bounds", {})
text = (name or value or "").strip()
if text:
elements.append(
{
"role": role,
"text": text,
"bounds": bounds,
"path": path,
}
)
for i, child in enumerate(node.get("children", [])):
child_path = f"{path}/{role}[{i}]" if path else f"{role}[{i}]"
walk(child, child_path)
if tree:
walk(tree)
return elements
def text_similarity(a: str, b: str) -> float:
"""Sequence-aware text similarity ratio (0.0 - 1.0).
Uses longest common subsequence to detect typos that character-set
based metrics miss (e.g. "Password" vs "Pasword" share the same
character set but are different strings).
"""
if not a or not b:
return 0.0
a_lower = a.lower().strip()
b_lower = b.lower().strip()
if a_lower == b_lower:
return 1.0
if a_lower in b_lower or b_lower in a_lower:
return 0.8
# Longest common subsequence length
m, n = len(a_lower), len(b_lower)
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_lower[i - 1] == b_lower[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
lcs_len = dp[m][n]
# Normalise by the longer string length so short typos are penalised
return lcs_len / max(m, n)
def find_best_match(
query: str, candidates: List[str], threshold: float = 0.6
) -> Tuple[str, float]:
"""Find the best matching candidate text."""
best = ("", 0.0)
for cand in candidates:
sim = text_similarity(query, cand)
if sim > best[1]:
best = (cand, sim)
return best if best[1] >= threshold else ("", best[1])
def compare(ocr_texts: List[Dict], a11y_elements: List[Dict]) -> Dict[str, Any]:
"""Cross-validate OCR results against A11y tree."""
ocr_text_list = [t["text"].strip() for t in ocr_texts if t.get("text", "").strip()]
a11y_text_list = [
e["text"].strip() for e in a11y_elements if e.get("text", "").strip()
]
ocr_set = set(ocr_text_list)
a11y_set = set(a11y_text_list)
issues = []
for text in sorted(a11y_set - ocr_set):
best_match, sim = find_best_match(text, ocr_text_list)
if best_match and sim < 1.0:
issues.append(
{
"type": "text_mismatch",
"severity": "error" if sim < 0.7 else "warning",
"a11y_text": text,
"closest_ocr": best_match,
"similarity": round(sim, 3),
"description": f"DOM says '{text}', OCR sees '{best_match}' (similarity: {sim:.1%})",
}
)
else:
issues.append(
{
"type": "dom_not_rendered",
"severity": "error",
"a11y_text": text,
"description": f"Text '{text}' exists in DOM but not visible in screenshot",
}
)
for text in sorted(ocr_set - a11y_set):
issues.append(
{
"type": "rendered_not_in_dom",
"severity": "warning",
"ocr_text": text,
"description": f"Text '{text}' visible in screenshot but not in accessibility tree",
}
)
return {
"summary": {
"a11y_text_count": len(a11y_set),
"ocr_text_count": len(ocr_set),
"matching": len(ocr_set & a11y_set),
"issues": len(issues),
},
"issues": issues,
}
def main():
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"
)
args = parser.parse_args()
ocr_data = load_json(args.ocr)
a11y_data = load_json(args.a11y)
ocr_texts = ocr_data.get("texts", [])
a11y_elements = flatten_a11y_tree(a11y_data)
report = compare(ocr_texts, a11y_elements)
Path(args.output).write_text(json.dumps(report, indent=2, ensure_ascii=False))
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 __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Source Map Lookup - Map UI test issues to source code locations.
Uses DOM element coordinates and source maps to resolve visual issues
back to the original source file and line number.
Usage:
python3 source_map_lookup.py --screenshot screenshot.png --dom dom_elements.json \
--source-map ./dist --issue issue.json --output resolved.json
"""
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
def load_json(path: str) -> Any:
"""Load JSON from file."""
return json.loads(Path(path).read_text(encoding="utf-8"))
def find_element_at_position(
dom_elements: List[Dict[str, Any]], x: int, y: int
) -> Optional[Dict[str, Any]]:
"""Find the DOM element that contains the given pixel position."""
candidates = []
for elem in dom_elements:
bounds = elem.get("bounds", {})
bx = bounds.get("x", 0)
by = bounds.get("y", 0)
bw = bounds.get("width", 0)
bh = bounds.get("height", 0)
if bx <= x <= bx + bw and by <= y <= by + bh:
candidates.append((bw * bh, elem))
if not candidates:
return None
candidates.sort(key=lambda c: c[0])
return candidates[0][1]
def lookup_source_location(
source_map_dir: str, file_path: str, line: int
) -> Dict[str, Any]:
"""
Resolve a compiled file path and line to original source location.
In production this would use the `sourcemap` library to parse .map files.
For now, this is a placeholder that returns the input if no source map
files are found.
"""
sm_dir = Path(source_map_dir)
if not sm_dir.exists():
return {
"file": file_path,
"line": line,
"resolved": False,
"reason": "source-map dir not found",
}
map_files = list(sm_dir.rglob("*.map"))
if not map_files:
return {
"file": file_path,
"line": line,
"resolved": False,
"reason": "no .map files found",
}
for mf in map_files:
try:
sm = json.loads(mf.read_text())
sources = sm.get("sources", [])
for src in sources:
if file_path.endswith(src) or src.endswith(file_path.split("/")[-1]):
return {
"file": src,
"line": line,
"resolved": True,
"source_map": str(mf),
}
except (json.JSONDecodeError, KeyError):
continue
return {
"file": file_path,
"line": line,
"resolved": False,
"reason": "no matching source map entry",
}
def resolve_issues(
issues: List[Dict[str, Any]],
dom_elements: List[Dict[str, Any]],
source_map_dir: str,
) -> List[Dict[str, Any]]:
"""Add source_location to each issue."""
for issue in issues:
region = issue.get("screenshot_region")
if not region or len(region) < 2:
issue["source_location"] = {"resolved": False, "reason": "no region data"}
continue
x = region[0][0]
y = region[0][1]
elem = find_element_at_position(dom_elements, x, y)
if not elem:
issue["source_location"] = {
"resolved": False,
"reason": f"no DOM element at ({x}, {y})",
"pixel": [x, y],
}
continue
tag = elem.get("tagName", "").lower()
data_file = elem.get("data-file", "")
data_line = elem.get("data-line", 0)
if data_file:
loc = lookup_source_location(source_map_dir, data_file, int(data_line))
issue["source_location"] = loc
else:
issue["source_location"] = {
"resolved": False,
"reason": f"element <{tag}> has no data-file attribute",
"element": {"tag": tag, "name": elem.get("name", "")},
"hint": "Enable data-component attributes in dev mode for precise mapping",
}
return issues
def main():
parser = argparse.ArgumentParser(
description="Map UI issues to source code locations"
)
parser.add_argument(
"--issues", required=True, help="Issues JSON file (from ui_test.py)"
)
parser.add_argument("--dom", required=True, help="DOM elements JSON file")
parser.add_argument("--source-map", required=True, help="Source map directory")
parser.add_argument("--output", default="resolved_issues.json", help="Output file")
args = parser.parse_args()
issues_data = load_json(args.issues)
issues = (
issues_data.get("results", issues_data)
if isinstance(issues_data, dict)
else issues_data
)
dom_elements = load_json(args.dom)
if isinstance(dom_elements, dict):
from compare_ocr_dom import flatten_a11y_tree
dom_elements = flatten_a11y_tree(dom_elements)
resolved = resolve_issues(issues, dom_elements, args.source_map)
Path(args.output).write_text(json.dumps(resolved, indent=2, ensure_ascii=False))
resolved_count = sum(
1 for r in resolved if r.get("source_location", {}).get("resolved")
)
print(f"Resolved issues: {args.output}")
print(f" Total: {len(resolved)}")
print(f" Resolved to source: {resolved_count}")
print(f" Unresolved: {len(resolved) - resolved_count}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
PaddleOCR UI Test - Main execution script.
Combines PaddleOCR screenshot analysis with Playwright Accessibility Tree
for intelligent UI testing across 6 levels (L1-L6).
Usage:
python3 ui_test.py --url https://example.com [options]
"""
import argparse
import asyncio
import base64
import json
import os
import re
import sys
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from PIL import Image
from playwright.async_api import async_playwright
try:
from openai import AsyncOpenAI
except ImportError:
print("Error: openai library required. Run: pip install openai")
sys.exit(1)
# ─── Configuration ───────────────────────────────────────────────────────────
PADDLEOCR_API_KEY = os.environ.get("PADDLEOCR_API_KEY") or os.environ.get(
"SILICONFLOW_API_KEY"
)
PADDLEOCR_MODEL = os.environ.get("PADDLEOCR_MODEL", "PaddlePaddle/PaddleOCR-VL-1.5")
PADDLEOCR_API_URL = os.environ.get("PADDLEOCR_API_URL", "https://api.siliconflow.cn/v1")
# ─── OCR Client ──────────────────────────────────────────────────────────────
class PaddleOCRClient:
"""Client for PaddleOCR-VL via SiliconFlow API.
PaddleOCR-VL outputs text interleaved with <|LOC_xxx|> tags.
This client parses that format and converts LOC values to pixel coordinates.
"""
def __init__(self, api_key: str, model: str, api_url: str):
self.api_key = api_key
self.model = model
self.api_url = api_url
@staticmethod
def parse_loc_response(
content: str, image_size: Tuple[int, int]
) -> List[Dict[str, Any]]:
"""Parse PaddleOCR-VL native output format.
The model outputs text followed by <|LOC_xxx|> tags for coordinates.
LOC values are normalized and must be scaled to pixel coordinates.
"""
img_width, img_height = image_size
text_coord_pairs = re.findall(r"([^\|<]+?)((?:<\|LOC_\d+\|\>)+)", content)
if not text_coord_pairs:
lines = [line.strip() for line in content.split("\n") if line.strip()]
return [{"text": line, "box": []} for line in lines]
all_loc_values = []
for _, loc_tags in text_coord_pairs:
coords = [int(c) for c in re.findall(r"LOC_(\d+)", loc_tags)]
all_loc_values.extend(coords)
max_loc = max(all_loc_values) if all_loc_values else 972
x_scale = img_width / max_loc if max_loc > 0 else 1
y_scale = img_height / max_loc if max_loc > 0 else 1
texts = []
for text_chunk, loc_tags in text_coord_pairs:
text = text_chunk.strip()
if not text:
continue
coord_matches = re.findall(r"LOC_(\d+)", loc_tags)
coords = [int(c) for c in coord_matches]
if len(coords) >= 8:
box = []
for i in range(0, 8, 2):
x = int(coords[i] * x_scale)
y = int(coords[i + 1] * y_scale)
box.append([x, y])
elif len(coords) >= 4:
x1 = int(coords[0] * x_scale)
y1 = int(coords[1] * y_scale)
x2 = int(coords[2] * x_scale)
y2 = int(coords[3] * y_scale)
box = [[x1, y1], [x2, y1], [x2, y2], [x1, y2]]
else:
box = []
texts.append({"text": text, "box": box})
return texts
async def recognize(
self, image_path: str, prompt: Optional[str] = None
) -> Dict[str, Any]:
"""Send image to PaddleOCR and get text + coordinates."""
client = AsyncOpenAI(api_key=self.api_key, base_url=self.api_url)
with open(image_path, "rb") as f:
image_b64 = base64.b64encode(f.read()).decode("utf-8")
ext = Path(image_path).suffix.lower()
mime_map = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".bmp": "image/bmp",
}
mime = mime_map.get(ext, "image/png")
user_prompt = prompt or "OCR"
response = await client.chat.completions.create(
model=self.model,
messages=[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{image_b64}"},
},
{"type": "text", "text": user_prompt},
],
},
],
max_tokens=4000,
temperature=0,
)
content = response.choices[0].message.content or ""
with Image.open(image_path) as img:
image_size = img.size
# Check if model returned placeholder characters instead of real text
placeholder_chars = set("☐□📧📝")
real_text = [
c for c in content if c not in placeholder_chars and not c.isspace()
]
if content and not real_text:
# Model returned only placeholders - fallback: use content as-is line by line
lines = [line.strip() for line in content.split("\n") if line.strip()]
texts = [{"text": line, "box": []} for line in lines]
else:
texts = self.parse_loc_response(content, image_size)
return {
"texts": texts,
"full_text": "\n".join(t["text"] for t in texts),
}
# ─── Accessibility Tree Parser ───────────────────────────────────────────────
def parse_a11y_tree(tree: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Flatten accessibility tree into a list of elements with text and position.
Only extracts text from leaf nodes to avoid duplicate/concatenated text
from parent elements that include all their children's textContent.
"""
elements = []
def walk(node: Dict[str, Any], path: str = ""):
role = node.get("role", "")
name = node.get("name", "")
value = node.get("value", "")
bounds = node.get("bounds", {})
children = node.get("children", [])
text = name or value or ""
text = text.strip()
# Only add text from leaf nodes (no children or only generic children)
# to avoid duplicate text from parent elements
is_leaf = len(children) == 0
if is_leaf and text:
elements.append(
{
"role": role,
"text": text,
"bounds": bounds,
"path": path,
}
)
for i, child in enumerate(children):
child_path = f"{path}/{role}[{i}]" if path else f"{role}[{i}]"
walk(child, child_path)
if tree:
walk(tree)
return elements
# ─── Test Levels ─────────────────────────────────────────────────────────────
class UITestEngine:
"""Execute UI test levels L1-L6."""
def __init__(
self,
ocr_result: Dict,
a11y_elements: List[Dict],
config: Dict[str, Any],
image_size: Tuple[int, int],
):
self.ocr_texts = ocr_result.get("texts", [])
self.a11y_elements = a11y_elements
self.config = config
self.image_size = image_size
self.results: List[Dict[str, Any]] = []
def _add_result(
self,
level: str,
issue_type: str,
severity: str,
element: str,
expected: str,
actual: str,
region: Optional[List] = None,
suggestion: str = "",
):
self.results.append(
{
"type": issue_type,
"level": level,
"severity": severity,
"element": element,
"expected": expected,
"actual": actual,
"screenshot_region": region,
"suggestion": suggestion,
}
)
def run_l1_text_consistency(self):
"""L1: Verify visible text matches expected values from config."""
expected_texts = self.config.get("expected_texts", {})
for element_name, expected_text in expected_texts.items():
matched = False
for ocr_item in self.ocr_texts:
if expected_text in ocr_item.get("text", ""):
matched = True
break
if not matched:
actual_texts = [t["text"] for t in self.ocr_texts[:5]]
self._add_result(
level="L1",
issue_type="text_missing",
severity="error",
element=element_name,
expected=expected_text,
actual=f"Not found. Nearby texts: {', '.join(actual_texts)}",
suggestion="Check if the element is rendered or if text content changed",
)
def run_l2_layout_reasonableness(self):
"""L2: Detect layout anomalies from OCR box coordinates."""
img_w, img_h = self.image_size
for i, item in enumerate(self.ocr_texts):
box = item.get("box", [])
if len(box) != 4:
continue
x_coords = [p[0] for p in box]
y_coords = [p[1] for p in box]
x_min, x_max = min(x_coords), max(x_coords)
y_min, y_max = min(y_coords), max(y_coords)
width = x_max - x_min
height = y_max - y_min
if x_max > img_w or y_max > img_h:
self._add_result(
level="L2",
issue_type="overflow",
severity="warning",
element=f"text_region_{i}",
expected=f"within {img_w}x{img_h}",
actual=f"extends to ({x_max}, {y_max})",
region=box,
suggestion="Check container overflow settings or viewport size",
)
if width > img_w * 0.95 and height > img_h * 0.8:
self._add_result(
level="L2",
issue_type="possible_full_page_text",
severity="warning",
element=f"text_region_{i}",
expected="normal text block",
actual=f"spans {width}x{height} ({width / img_w * 100:.0f}% width)",
region=box,
suggestion="Verify this is not a rendering artifact",
)
def run_l3_dom_consistency(self):
"""L3: Cross-reference OCR vs DOM/A11y Tree content."""
ocr_text_set = {
t["text"].strip() for t in self.ocr_texts if t.get("text", "").strip()
}
a11y_text_set = {
e["text"].strip() for e in self.a11y_elements if e.get("text", "").strip()
}
in_a11y_not_ocr = a11y_text_set - ocr_text_set
in_ocr_not_a11y = ocr_text_set - a11y_text_set
for text in list(in_a11y_not_ocr)[:10]:
self._add_result(
level="L3",
issue_type="dom_not_rendered",
severity="error",
element="unknown",
expected=f"Text '{text}' should be visible",
actual="Not detected in screenshot by OCR",
suggestion="Element may be hidden, off-screen, or have visibility:hidden",
)
for text in list(in_ocr_not_a11y)[:10]:
self._add_result(
level="L3",
issue_type="rendered_not_in_dom",
severity="warning",
element="unknown",
expected="All visible text should be in DOM",
actual=f"Text '{text}' visible but not in accessibility tree",
suggestion="May be canvas-rendered text or missing ARIA label",
)
a11y_count = len(a11y_text_set)
ocr_count = len(ocr_text_set)
if (
abs(a11y_count - ocr_count) > max(a11y_count, ocr_count) * 0.3
and a11y_count > 5
):
self._add_result(
level="L3",
issue_type="count_mismatch",
severity="warning",
element="page",
expected=f"~{a11y_count} text elements in DOM",
actual=f"{ocr_count} text regions in screenshot",
suggestion="Large discrepancy may indicate rendering issues or hidden content",
)
def run_l4_accessibility(self):
"""L4: Joint OCR + A11y accessibility analysis."""
for elem in self.a11y_elements:
if elem["role"] in ("image", "graphic", "img"):
if not elem.get("text") or elem["text"] in ("", "image", "icon"):
self._add_result(
level="L4",
issue_type="missing_alt",
severity="error",
element=f"{elem['role']} at {elem.get('path', 'unknown')}",
expected="Descriptive alt text",
actual=elem.get("text", "(empty)"),
suggestion="Add meaningful alt attribute to image",
)
def run_l5_internationalization(self):
"""L5: Detect language mismatches."""
expected_lang = self.config.get("expected_language", "")
if not expected_lang:
return
cn_pattern = re.compile(r"[\u4e00-\u9fff]")
en_pattern = re.compile(r"[a-zA-Z]{4,}")
for item in self.ocr_texts:
text = item.get("text", "")
has_cn = bool(cn_pattern.search(text))
has_en = bool(en_pattern.search(text))
if expected_lang == "zh" and has_en and not has_cn:
self._add_result(
level="L5",
issue_type="untranslated_text",
severity="warning",
element="text_region",
expected="Chinese text",
actual=f"English text found: '{text[:50]}'",
region=item.get("box"),
suggestion="Check i18n translation files for missing keys",
)
elif expected_lang == "en" and has_cn:
self._add_result(
level="L5",
issue_type="unexpected_language",
severity="warning",
element="text_region",
expected="English text",
actual=f"Chinese text found: '{text[:50]}'",
region=item.get("box"),
suggestion="Check locale configuration or fallback language",
)
def run_l6_dynamic_content(self, before_ocr: Dict, after_ocr: Dict):
"""L6: Compare screenshot sequences for state transitions."""
before_texts = {t["text"] for t in before_ocr.get("texts", [])}
after_texts = {t["text"] for t in after_ocr.get("texts", [])}
removed = before_texts - after_texts
added = after_texts - before_texts
for text in list(removed)[:5]:
self._add_result(
level="L6",
issue_type="content_removed",
severity="info",
element="dynamic",
expected="Text persists",
actual=f"Text '{text[:50]}' no longer visible",
suggestion="Expected for loading states; verify if intended",
)
for text in list(added)[:5]:
self._add_result(
level="L6",
issue_type="content_added",
severity="info",
element="dynamic",
expected="No new content",
actual=f"New text: '{text[:50]}'",
suggestion="Verify new content is expected after interaction",
)
def run(self, levels: List[str]) -> List[Dict[str, Any]]:
"""Execute specified test levels."""
level_map = {
"L1": self.run_l1_text_consistency,
"L2": self.run_l2_layout_reasonableness,
"L3": self.run_l3_dom_consistency,
"L4": self.run_l4_accessibility,
"L5": self.run_l5_internationalization,
}
for level in levels:
if level in level_map:
level_map[level]()
return self.results
# ─── Report Generator ────────────────────────────────────────────────────────
class ReportGenerator:
"""Generate JSON and Markdown reports."""
def __init__(
self,
url: str,
results: List[Dict],
output_dir: str,
image_size: Tuple[int, int],
duration: float,
):
self.url = url
self.results = results
self.output_dir = Path(output_dir)
self.image_size = image_size
self.duration = duration
self.output_dir.mkdir(parents=True, exist_ok=True)
def _summary(self) -> Dict[str, int]:
summary = {"total": len(self.results), "error": 0, "warning": 0, "info": 0}
for r in self.results:
sev = r.get("severity", "info")
if sev in summary:
summary[sev] += 1
return summary
def generate_json(self) -> str:
"""Generate structured JSON report."""
summary = self._summary()
report = {
"test_id": f"ui-test-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}",
"url": self.url,
"timestamp": datetime.now(timezone.utc).isoformat(),
"image_size": list(self.image_size),
"duration_seconds": round(self.duration, 2),
"summary": {
"total_checks": summary["total"],
"passed": summary["total"] - summary["error"] - summary["warning"],
"failed": summary["error"],
"warnings": summary["warning"],
"info": summary["info"],
},
"results": self.results,
}
path = self.output_dir / "report.json"
path.write_text(json.dumps(report, indent=2, ensure_ascii=False))
return str(path)
def generate_markdown(self) -> str:
"""Generate human-readable Markdown report."""
summary = self._summary()
errors = [r for r in self.results if r["severity"] == "error"]
warnings = [r for r in self.results if r["severity"] == "warning"]
infos = [r for r in self.results if r["severity"] == "info"]
lines = [
f"# UI Test Report — {self.url}",
"",
f"| Item | Value |",
f"|------|-------|",
f"| URL | {self.url} |",
f"| Time | {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')} |",
f"| Image Size | {self.image_size[0]}x{self.image_size[1]} |",
f"| Duration | {self.duration:.1f}s |",
f"| Checks | {summary['total']} total "
f"(✅ {summary['total'] - summary['error'] - summary['warning']} "
f"❌ {summary['error']} ⚠️ {summary['warning']}) |",
"",
]
if errors:
lines.append("## ❌ Errors\n")
for i, r in enumerate(errors, 1):
lines.append(f"**{i}. [{r['level']}] {r['type']}** — {r['element']}")
lines.append(f"- Expected: `{r['expected']}`")
lines.append(f"- Actual: `{r['actual']}`")
if r.get("suggestion"):
lines.append(f"- Suggestion: {r['suggestion']}")
lines.append("")
if warnings:
lines.append("## ⚠️ Warnings\n")
for i, r in enumerate(warnings, 1):
lines.append(f"**{i}. [{r['level']}] {r['type']}** — {r['element']}")
lines.append(f"- Expected: `{r['expected']}`")
lines.append(f"- Actual: `{r['actual']}`")
if r.get("suggestion"):
lines.append(f"- Suggestion: {r['suggestion']}")
lines.append("")
if infos:
lines.append("## ℹ️ Info\n")
for i, r in enumerate(infos, 1):
lines.append(f"**{i}. [{r['level']}] {r['type']}**: {r['actual']}")
lines.append("")
if not self.results:
lines.append("## ✅ All checks passed\n")
path = self.output_dir / "report.md"
path.write_text("\n".join(lines))
return str(path)
# ─── Main ────────────────────────────────────────────────────────────────────
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 run_test(args) -> None:
"""Execute the full UI test pipeline."""
start_time = time.time()
if not PADDLEOCR_API_KEY:
print("Error: PADDLEOCR_API_KEY or SILICONFLOW_API_KEY not set")
sys.exit(1)
ocr_client = PaddleOCRClient(
api_key=PADDLEOCR_API_KEY,
model=PADDLEOCR_MODEL,
api_url=PADDLEOCR_API_URL,
)
config = {}
if args.config:
config = json.loads(Path(args.config).read_text())
levels = args.levels.split(",") if args.levels else ["L1", "L3"]
viewport = (
tuple(map(int, args.viewport.split("x"))) if args.viewport else (1280, 720)
)
screenshot_path = Path(args.output) / "screenshot.png"
Path(args.output).mkdir(parents=True, exist_ok=True)
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page(
viewport={"width": viewport[0], "height": viewport[1]}
)
print(f"Navigating to {args.url} ...")
await page.goto(args.url, wait_until="networkidle")
await page.wait_for_timeout(args.wait)
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)
a11y_elements = parse_a11y_tree(a11y_tree or {})
img = Image.open(screenshot_path)
image_size = img.size
print(f"Sending screenshot to PaddleOCR ({image_size[0]}x{image_size[1]}) ...")
ocr_result = await ocr_client.recognize(str(screenshot_path))
print(f"Running test levels: {', '.join(levels)} ...")
engine = UITestEngine(
ocr_result=ocr_result,
a11y_elements=a11y_elements,
config=config,
image_size=image_size,
)
results = engine.run(levels)
duration = time.time() - start_time
print(f"Generating reports to {args.output} ...")
reporter = ReportGenerator(
url=args.url,
results=results,
output_dir=args.output,
image_size=image_size,
duration=duration,
)
if args.format in ("json", "both"):
json_path = reporter.generate_json()
print(f" JSON report: {json_path}")
if args.format in ("markdown", "both"):
md_path = reporter.generate_markdown()
print(f" Markdown report: {md_path}")
summary = reporter._summary()
print(
f"\nDone in {duration:.1f}s — "
f"{summary['total']} checks: "
f"❌ {summary['error']} errors, "
f"⚠️ {summary['warning']} warnings, "
f"ℹ️ {summary['info']} info"
)
await browser.close()
def main():
parser = argparse.ArgumentParser(description="PaddleOCR UI Test")
parser.add_argument("--url", required=True, help="Target URL")
parser.add_argument("--config", help="Test config JSON file")
parser.add_argument(
"--levels", default="L1,L3", help="Test levels (L1-L6, comma-separated)"
)
parser.add_argument("--viewport", default="1280x720", help="Viewport size WxH")
parser.add_argument("--wait", type=int, default=2000, help="Wait ms after load")
parser.add_argument("--output", default="./test-results", help="Output directory")
parser.add_argument(
"--format", choices=["json", "markdown", "both"], default="both"
)
parser.add_argument("--source-map", help="Source map directory")
parser.add_argument(
"--annotate", action="store_true", help="Generate annotated screenshot"
)
args = parser.parse_args()
asyncio.run(run_test(args))
if __name__ == "__main__":
main()
{
"name": "paddleocr-ui-test",
"description": "AI-driven UI testing skill combining PaddleOCR screenshot analysis with DOM/Accessibility Tree cross-validation",
"version": "0.1.0"
}
Related skills
FAQ
What does paddleocr-ui-test check?
It runs six levels of checks: text consistency, layout reasonableness, DOM consistency, accessibility, internationalization, and dynamic content.
What does it need to run?
Python 3.8+ with openai, playwright, and Pillow, a PADDLEOCR_API_KEY (or SILICONFLOW_API_KEY), and installed Playwright browsers.