testuiux 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bughunter/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ from bughunter.schema import BoundingBox, VisualIssue, ViewportResult, AuditReport
2
+ from bughunter.crawler import capture_viewports
3
+ from bughunter.detector import detect_visual_issues
4
+ from bughunter.annotator import annotate_image
5
+ from bughunter.reporter import generate_html_report
6
+ from bughunter.json_reporter import export_json_report
7
+ from bughunter.summarizer import generate_agent_briefing
8
+
9
+ __all__ = [
10
+ "BoundingBox",
11
+ "VisualIssue",
12
+ "ViewportResult",
13
+ "AuditReport",
14
+ "capture_viewports",
15
+ "detect_visual_issues",
16
+ "annotate_image",
17
+ "generate_html_report",
18
+ "export_json_report",
19
+ "generate_agent_briefing",
20
+ ]
bughunter/annotator.py ADDED
@@ -0,0 +1,62 @@
1
+ from pathlib import Path
2
+ from PIL import Image, ImageDraw, ImageFont
3
+ from bughunter.schema import SeverityLevel, VisualIssue
4
+
5
+ SEVERITY_COLORS = {
6
+ SeverityLevel.CRITICAL: {"border": (220, 38, 38, 255), "fill": (220, 38, 38, 40)},
7
+ SeverityLevel.WARNING: {"border": (217, 119, 6, 255), "fill": (217, 119, 6, 40)},
8
+ SeverityLevel.INFO: {"border": (37, 99, 235, 255), "fill": (37, 99, 235, 30)},
9
+ }
10
+
11
+
12
+ def get_default_font(size: int = 14):
13
+ try:
14
+ return ImageFont.truetype("arial.ttf", size)
15
+ except IOError:
16
+ return ImageFont.load_default()
17
+
18
+
19
+ def annotate_image(
20
+ screenshot_path: Path,
21
+ issues: list[VisualIssue],
22
+ output_path: Path,
23
+ ) -> Path:
24
+ base_img = Image.open(screenshot_path).convert("RGBA")
25
+ overlay = Image.new("RGBA", base_img.size, (255, 255, 255, 0))
26
+ draw = ImageDraw.Draw(overlay)
27
+
28
+ img_w, img_h = base_img.size
29
+ font = get_default_font(14)
30
+ badge_font = get_default_font(12)
31
+
32
+ for idx, issue in enumerate(issues, start=1):
33
+ x1 = int((issue.box.xmin / 1000.0) * img_w)
34
+ y1 = int((issue.box.ymin / 1000.0) * img_h)
35
+ x2 = int((issue.box.xmax / 1000.0) * img_w)
36
+ y2 = int((issue.box.ymax / 1000.0) * img_h)
37
+
38
+ x1 = max(0, min(img_w - 1, x1))
39
+ y1 = max(0, min(img_h - 1, y1))
40
+ x2 = max(x1 + 10, min(img_w, x2))
41
+ y2 = max(y1 + 10, min(img_h, y2))
42
+
43
+ scheme = SEVERITY_COLORS.get(issue.severity, SEVERITY_COLORS[SeverityLevel.INFO])
44
+
45
+ draw.rectangle([x1, y1, x2, y2], fill=scheme["fill"], outline=scheme["border"], width=3)
46
+
47
+ badge_text = f"#{idx} [{issue.severity.value}] {issue.type.value}"
48
+ text_bbox = draw.textbbox((x1, y1), badge_text, font=badge_font)
49
+ text_w = text_bbox[2] - text_bbox[0]
50
+ text_h = text_bbox[3] - text_bbox[1]
51
+
52
+ badge_y1 = max(0, y1 - text_h - 8)
53
+ badge_y2 = badge_y1 + text_h + 8
54
+ badge_x2 = min(img_w, x1 + text_w + 12)
55
+
56
+ draw.rectangle([x1, badge_y1, badge_x2, badge_y2], fill=scheme["border"])
57
+ draw.text((x1 + 6, badge_y1 + 4), badge_text, fill=(255, 255, 255, 255), font=badge_font)
58
+
59
+ combined = Image.alpha_composite(base_img, overlay).convert("RGB")
60
+ output_path.parent.mkdir(parents=True, exist_ok=True)
61
+ combined.save(output_path, "PNG")
62
+ return output_path
bughunter/cli.py ADDED
@@ -0,0 +1,206 @@
1
+ import argparse
2
+ import asyncio
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+ from rich.panel import Panel
8
+ from bughunter.schema import AuditReport
9
+ from bughunter.crawler import capture_viewports
10
+ from bughunter.detector import detect_visual_issues
11
+ from bughunter.annotator import annotate_image
12
+ from bughunter.reporter import generate_html_report
13
+ from bughunter.json_reporter import export_json_report
14
+ from bughunter.summarizer import generate_agent_briefing
15
+
16
+ console = Console()
17
+
18
+
19
+ async def run_audit(
20
+ target: str,
21
+ viewports: list[str],
22
+ mode: str,
23
+ api_base: str,
24
+ model: str,
25
+ api_key: str,
26
+ output_dir: Path,
27
+ small_model: str,
28
+ notify: bool,
29
+ format_type: str,
30
+ ) -> tuple[AuditReport, str]:
31
+ console.print(
32
+ Panel(
33
+ f"[bold cyan]Target:[/bold cyan] {target}\n"
34
+ f"[bold cyan]Mode:[/bold cyan] {mode}\n"
35
+ f"[bold cyan]Vision Model:[/bold cyan] {model if mode == 'api' else 'mock/heuristic'}\n"
36
+ f"[bold cyan]Triage Model:[/bold cyan] {small_model if notify else 'disabled'}\n"
37
+ f"[bold cyan]Format:[/bold cyan] {format_type}",
38
+ title="Visual Bug Hunter",
39
+ )
40
+ )
41
+
42
+ output_dir.mkdir(parents=True, exist_ok=True)
43
+
44
+ with console.status("[bold green]Capturing responsive viewports with Playwright..."):
45
+ viewport_results = await capture_viewports(
46
+ target_url=target,
47
+ output_dir=output_dir,
48
+ selected_viewports=viewports,
49
+ full_page=True,
50
+ )
51
+
52
+ for vp in viewport_results:
53
+ with console.status(f"[bold yellow]Analyzing visual issues for {vp.viewport_name} viewport..."):
54
+ issues = await detect_visual_issues(
55
+ image_path=Path(vp.screenshot_path),
56
+ viewport_name=vp.viewport_name,
57
+ mode=mode,
58
+ api_base=api_base,
59
+ model=model,
60
+ api_key=api_key,
61
+ )
62
+ vp.issues = issues
63
+
64
+ annotated_filename = f"annotated_{vp.viewport_name}.png"
65
+ annotated_path = output_dir / annotated_filename
66
+ annotate_image(
67
+ screenshot_path=Path(vp.screenshot_path),
68
+ issues=issues,
69
+ output_path=annotated_path,
70
+ )
71
+ vp.annotated_path = str(annotated_path)
72
+
73
+ report = AuditReport(
74
+ target_url=target,
75
+ timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
76
+ viewports=viewport_results,
77
+ )
78
+
79
+ json_path = output_dir / "report.json"
80
+ export_json_report(report, json_path)
81
+
82
+ if format_type in ("html", "all"):
83
+ report_html_path = output_dir / "report.html"
84
+ generate_html_report(report, report_html_path)
85
+
86
+ briefing = ""
87
+ if notify:
88
+ with console.status(f"[bold magenta]Generating agent briefing with small model ({small_model})..."):
89
+ briefing = await generate_agent_briefing(
90
+ report=report,
91
+ api_base=api_base,
92
+ model=small_model,
93
+ api_key=api_key,
94
+ use_small_model=(mode == "api"),
95
+ )
96
+ briefing_file = output_dir / "agent_briefing.txt"
97
+ with open(briefing_file, "w", encoding="utf-8") as f:
98
+ f.write(briefing)
99
+
100
+ return report, briefing
101
+
102
+
103
+ def main():
104
+ parser = argparse.ArgumentParser(description="Autonomous Visual Bug Hunter with Qwen2.5-VL")
105
+ parser.add_argument("target", help="Target URL (e.g. http://localhost:3000) or local HTML file")
106
+ parser.add_argument(
107
+ "--viewports",
108
+ default="mobile,desktop",
109
+ help="Comma-separated viewports: mobile,tablet,desktop (default: mobile,desktop)",
110
+ )
111
+ parser.add_argument(
112
+ "--mode",
113
+ choices=["mock", "api"],
114
+ default="mock",
115
+ help="Detection mode: mock (heuristic demo) or api (Qwen2.5-VL via Ollama/OpenAI)",
116
+ )
117
+ parser.add_argument(
118
+ "--api-base",
119
+ default="http://localhost:11434/v1",
120
+ help="Base URL for model endpoints (default: http://localhost:11434/v1)",
121
+ )
122
+ parser.add_argument(
123
+ "--model",
124
+ default="qwen2.5-vl:3b",
125
+ help="Vision model name (default: qwen2.5-vl:3b)",
126
+ )
127
+ parser.add_argument(
128
+ "--small-model",
129
+ default="qwen2.5:1.5b",
130
+ help="Small triage model for agent notification briefing (default: qwen2.5:1.5b)",
131
+ )
132
+ parser.add_argument(
133
+ "--api-key",
134
+ default="ollama",
135
+ help="API Key for model endpoints",
136
+ )
137
+ parser.add_argument(
138
+ "--output-dir",
139
+ default="./audit_results",
140
+ help="Directory to save artifacts (default: ./audit_results)",
141
+ )
142
+ parser.add_argument(
143
+ "--format",
144
+ choices=["json", "html", "all"],
145
+ default="json",
146
+ help="Report output format: json, html, or all (default: json)",
147
+ )
148
+ parser.add_argument(
149
+ "--notify",
150
+ action="store_true",
151
+ default=True,
152
+ help="Automatically trigger small model triage briefing for main AI agent",
153
+ )
154
+ parser.add_argument(
155
+ "--no-notify",
156
+ dest="notify",
157
+ action="store_false",
158
+ help="Disable triage briefing",
159
+ )
160
+
161
+ args = parser.parse_args()
162
+ selected_viewports = [v.strip() for v in args.viewports.split(",") if v.strip()]
163
+ out_dir = Path(args.output_dir).resolve()
164
+
165
+ report, briefing = asyncio.run(
166
+ run_audit(
167
+ target=args.target,
168
+ viewports=selected_viewports,
169
+ mode=args.mode,
170
+ api_base=args.api_base,
171
+ model=args.model,
172
+ api_key=args.api_key,
173
+ output_dir=out_dir,
174
+ small_model=args.small_model,
175
+ notify=args.notify,
176
+ format_type=args.format,
177
+ )
178
+ )
179
+
180
+ table = Table(title="Visual QA Audit Summary")
181
+ table.add_column("Viewport", style="cyan")
182
+ table.add_column("Dimensions", style="dim")
183
+ table.add_column("Defects Found", justify="right")
184
+ table.add_column("Critical", justify="right", style="red")
185
+ table.add_column("Warning", justify="right", style="yellow")
186
+
187
+ for vp in report.viewports:
188
+ crits = sum(1 for i in vp.issues if i.severity.value == "CRITICAL")
189
+ warns = sum(1 for i in vp.issues if i.severity.value == "WARNING")
190
+ table.add_row(
191
+ vp.viewport_name,
192
+ f"{vp.width}x{vp.height}",
193
+ str(len(vp.issues)),
194
+ str(crits),
195
+ str(warns),
196
+ )
197
+
198
+ console.print(table)
199
+ console.print(f"[bold green]JSON Report saved:[/bold green] {out_dir / 'report.json'}")
200
+
201
+ if briefing:
202
+ console.print(Panel(briefing, title="[bold magenta]AI Agent Executive Briefing (Small Model)[/bold magenta]", border_style="magenta"))
203
+
204
+
205
+ if __name__ == "__main__":
206
+ main()
bughunter/crawler.py ADDED
@@ -0,0 +1,111 @@
1
+ import asyncio
2
+ from pathlib import Path
3
+ from playwright.async_api import async_playwright
4
+ from bughunter.schema import ViewportResult
5
+
6
+ VIEWPORT_CONFIGS = {
7
+ "mobile": {
8
+ "width": 375,
9
+ "height": 812,
10
+ "is_mobile": True,
11
+ "device_scale_factor": 2,
12
+ },
13
+ "tablet": {
14
+ "width": 768,
15
+ "height": 1024,
16
+ "is_mobile": True,
17
+ "device_scale_factor": 2,
18
+ },
19
+ "desktop": {
20
+ "width": 1440,
21
+ "height": 900,
22
+ "is_mobile": False,
23
+ "device_scale_factor": 1,
24
+ },
25
+ }
26
+
27
+
28
+ def normalize_target_url(target: str) -> str:
29
+ if target.startswith(("http://", "https://", "file://")):
30
+ return target
31
+ local_path = Path(target).resolve()
32
+ if local_path.exists():
33
+ return local_path.as_uri()
34
+ return f"https://{target}"
35
+
36
+
37
+ async def capture_single_viewport(
38
+ browser,
39
+ target_url: str,
40
+ viewport_name: str,
41
+ config: dict,
42
+ output_dir: Path,
43
+ full_page: bool = True,
44
+ ) -> ViewportResult:
45
+ context = await browser.new_context(
46
+ viewport={"width": config["width"], "height": config["height"]},
47
+ is_mobile=config.get("is_mobile", False),
48
+ device_scale_factor=config.get("device_scale_factor", 1),
49
+ )
50
+ page = await context.new_page()
51
+
52
+ try:
53
+ await page.goto(target_url, wait_until="domcontentloaded", timeout=30000)
54
+ try:
55
+ await page.wait_for_load_state("networkidle", timeout=5000)
56
+ except Exception:
57
+ pass
58
+ await asyncio.sleep(0.5)
59
+
60
+ screenshot_filename = f"screenshot_{viewport_name}.png"
61
+ screenshot_path = output_dir / screenshot_filename
62
+
63
+ await page.screenshot(
64
+ path=str(screenshot_path),
65
+ full_page=full_page,
66
+ )
67
+
68
+ return ViewportResult(
69
+ viewport_name=viewport_name,
70
+ width=config["width"],
71
+ height=config["height"],
72
+ screenshot_path=str(screenshot_path),
73
+ )
74
+ finally:
75
+ await context.close()
76
+
77
+
78
+ async def capture_viewports(
79
+ target_url: str,
80
+ output_dir: Path,
81
+ selected_viewports: list[str] | None = None,
82
+ full_page: bool = True,
83
+ ) -> list[ViewportResult]:
84
+ normalized_url = normalize_target_url(target_url)
85
+ output_dir.mkdir(parents=True, exist_ok=True)
86
+
87
+ if not selected_viewports:
88
+ selected_viewports = list(VIEWPORT_CONFIGS.keys())
89
+
90
+ results: list[ViewportResult] = []
91
+
92
+ async with async_playwright() as p:
93
+ browser = await p.chromium.launch(headless=True)
94
+ try:
95
+ for vp_name in selected_viewports:
96
+ if vp_name not in VIEWPORT_CONFIGS:
97
+ continue
98
+ cfg = VIEWPORT_CONFIGS[vp_name]
99
+ res = await capture_single_viewport(
100
+ browser=browser,
101
+ target_url=normalized_url,
102
+ viewport_name=vp_name,
103
+ config=cfg,
104
+ output_dir=output_dir,
105
+ full_page=full_page,
106
+ )
107
+ results.append(res)
108
+ finally:
109
+ await browser.close()
110
+
111
+ return results
bughunter/detector.py ADDED
@@ -0,0 +1,168 @@
1
+ import base64
2
+ import json
3
+ import re
4
+ from pathlib import Path
5
+ import httpx
6
+ from bughunter.schema import BoundingBox, IssueType, SeverityLevel, VisualIssue
7
+
8
+ SYSTEM_PROMPT = """You are an expert Frontend QA Engineer. Analyze this webpage screenshot and detect any visual bugs.
9
+ Look specifically for:
10
+ 1. TEXT_OVERLAP: Text colliding or rendering directly over other text, images, or controls.
11
+ 2. ELEMENT_COLLISION: Modals, fixed footers, or floating action buttons occluding underlying buttons or content.
12
+ 3. HORIZONTAL_OVERFLOW: Elements overflowing beyond the viewport edge.
13
+ 4. TEXT_TRUNCATION: Text awkwardly cut off or overflowing containers without ellipsis.
14
+ 5. BROKEN_ASSET: Broken image placeholders, missing icons, stuck loaders.
15
+ 6. CONTRAST_LOW: Text unreadable due to insufficient contrast against the background.
16
+
17
+ For each issue, return a JSON object with:
18
+ - type: TEXT_OVERLAP | ELEMENT_COLLISION | HORIZONTAL_OVERFLOW | TEXT_TRUNCATION | BROKEN_ASSET | CONTRAST_LOW
19
+ - severity: CRITICAL | WARNING | INFO
20
+ - description: Concise explanation of the visual defect
21
+ - suggested_fix: CSS or HTML rule to resolve it
22
+ - box_2d: [ymin, xmin, ymax, xmax] normalized to 0-1000
23
+
24
+ Output MUST be a valid JSON array of objects only. If no bugs exist, return []."""
25
+
26
+
27
+ def encode_image_base64(image_path: Path) -> str:
28
+ with open(image_path, "rb") as f:
29
+ return base64.b64encode(f.read()).decode("utf-8")
30
+
31
+
32
+ def parse_llm_json(content: str) -> list[dict]:
33
+ clean_str = content.strip()
34
+ match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", clean_str)
35
+ if match:
36
+ clean_str = match.group(1).strip()
37
+ try:
38
+ parsed = json.loads(clean_str)
39
+ if isinstance(parsed, list):
40
+ return parsed
41
+ if isinstance(parsed, dict) and "issues" in parsed and isinstance(parsed["issues"], list):
42
+ return parsed["issues"]
43
+ return []
44
+ except json.JSONDecodeError:
45
+ return []
46
+
47
+
48
+ def to_visual_issues(items: list[dict]) -> list[VisualIssue]:
49
+ results: list[VisualIssue] = []
50
+ for item in items:
51
+ try:
52
+ itype = IssueType(item.get("type", "TEXT_OVERLAP").upper())
53
+ except ValueError:
54
+ itype = IssueType.TEXT_OVERLAP
55
+
56
+ try:
57
+ sev = SeverityLevel(item.get("severity", "WARNING").upper())
58
+ except ValueError:
59
+ sev = SeverityLevel.WARNING
60
+
61
+ box_raw = item.get("box_2d", [0, 0, 100, 100])
62
+ if not (isinstance(box_raw, list) and len(box_raw) == 4):
63
+ continue
64
+
65
+ ymin, xmin, ymax, xmax = [max(0, min(1000, int(v))) for v in box_raw]
66
+ if ymax <= ymin or xmax <= xmin:
67
+ continue
68
+
69
+ results.append(
70
+ VisualIssue(
71
+ type=itype,
72
+ severity=sev,
73
+ description=item.get("description", "Visual defect detected"),
74
+ suggested_fix=item.get("suggested_fix", "Check CSS layout rules"),
75
+ box=BoundingBox(ymin=ymin, xmin=xmin, ymax=ymax, xmax=xmax),
76
+ )
77
+ )
78
+ return results
79
+
80
+
81
+ async def detect_via_api(
82
+ image_path: Path,
83
+ api_base: str = "http://localhost:11434/v1",
84
+ model: str = "qwen2.5-vl:3b",
85
+ api_key: str = "ollama",
86
+ ) -> list[VisualIssue]:
87
+ b64 = encode_image_base64(image_path)
88
+ payload = {
89
+ "model": model,
90
+ "messages": [
91
+ {
92
+ "role": "user",
93
+ "content": [
94
+ {"type": "text", "text": SYSTEM_PROMPT},
95
+ {
96
+ "type": "image_url",
97
+ "image_url": {"url": f"data:image/png;base64,{b64}"},
98
+ },
99
+ ],
100
+ }
101
+ ],
102
+ "temperature": 0.1,
103
+ }
104
+
105
+ headers = {"Authorization": f"Bearer {api_key}"}
106
+ async with httpx.AsyncClient(timeout=120.0) as client:
107
+ resp = await client.post(f"{api_base}/chat/completions", json=payload, headers=headers)
108
+ resp.raise_for_status()
109
+ data = resp.json()
110
+ raw_text = data["choices"][0]["message"]["content"]
111
+ raw_issues = parse_llm_json(raw_text)
112
+ return to_visual_issues(raw_issues)
113
+
114
+
115
+ def detect_mock(image_path: Path, viewport_name: str) -> list[VisualIssue]:
116
+ issues: list[VisualIssue] = []
117
+ if viewport_name == "mobile":
118
+ issues.append(
119
+ VisualIssue(
120
+ type=IssueType.ELEMENT_COLLISION,
121
+ severity=SeverityLevel.CRITICAL,
122
+ description="Fixed bottom banner occludes the primary checkout CTA button.",
123
+ suggested_fix="Increase main container padding-bottom: 80px or reduce banner height on mobile viewports.",
124
+ box=BoundingBox(ymin=740, xmin=45, ymax=850, xmax=955),
125
+ )
126
+ )
127
+ issues.append(
128
+ VisualIssue(
129
+ type=IssueType.TEXT_TRUNCATION,
130
+ severity=SeverityLevel.WARNING,
131
+ description="Product title overflows card boundaries and gets clipped abruptly.",
132
+ suggested_fix="Apply text-overflow: ellipsis; overflow: hidden; white-space: nowrap; to title container.",
133
+ box=BoundingBox(ymin=240, xmin=50, ymax=310, xmax=950),
134
+ )
135
+ )
136
+ issues.append(
137
+ VisualIssue(
138
+ type=IssueType.HORIZONTAL_OVERFLOW,
139
+ severity=SeverityLevel.CRITICAL,
140
+ description="Pricing table width exceeds screen viewport creating unwanted horizontal scrolling.",
141
+ suggested_fix="Set max-width: 100%; overflow-x: auto; or switch to stacked flex layout.",
142
+ box=BoundingBox(ymin=420, xmin=30, ymax=650, xmax=1000),
143
+ )
144
+ )
145
+ elif viewport_name == "desktop":
146
+ issues.append(
147
+ VisualIssue(
148
+ type=IssueType.CONTRAST_LOW,
149
+ severity=SeverityLevel.WARNING,
150
+ description="Subheading text color #b0b0b0 has insufficient contrast ratio against #ffffff background.",
151
+ suggested_fix="Change text color to #4a5568 or darker to meet WCAG AA 4.5:1 ratio requirement.",
152
+ box=BoundingBox(ymin=150, xmin=120, ymax=195, xmax=880),
153
+ )
154
+ )
155
+ return issues
156
+
157
+
158
+ async def detect_visual_issues(
159
+ image_path: Path,
160
+ viewport_name: str,
161
+ mode: str = "mock",
162
+ api_base: str = "http://localhost:11434/v1",
163
+ model: str = "qwen2.5-vl:3b",
164
+ api_key: str = "ollama",
165
+ ) -> list[VisualIssue]:
166
+ if mode == "mock":
167
+ return detect_mock(image_path, viewport_name)
168
+ return await detect_via_api(image_path, api_base=api_base, model=model, api_key=api_key)
@@ -0,0 +1,11 @@
1
+ import json
2
+ from pathlib import Path
3
+ from bughunter.schema import AuditReport
4
+
5
+
6
+ def export_json_report(report: AuditReport, output_file: Path) -> Path:
7
+ output_file.parent.mkdir(parents=True, exist_ok=True)
8
+ report_dict = report.model_dump()
9
+ with open(output_file, "w", encoding="utf-8") as f:
10
+ json.dump(report_dict, f, indent=2)
11
+ return output_file