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 +20 -0
- bughunter/annotator.py +62 -0
- bughunter/cli.py +206 -0
- bughunter/crawler.py +111 -0
- bughunter/detector.py +168 -0
- bughunter/json_reporter.py +11 -0
- bughunter/reporter.py +324 -0
- bughunter/schema.py +70 -0
- bughunter/summarizer.py +90 -0
- testuiux/__init__.py +20 -0
- testuiux-0.1.0.dist-info/METADATA +108 -0
- testuiux-0.1.0.dist-info/RECORD +16 -0
- testuiux-0.1.0.dist-info/WHEEL +5 -0
- testuiux-0.1.0.dist-info/entry_points.txt +3 -0
- testuiux-0.1.0.dist-info/licenses/LICENSE +190 -0
- testuiux-0.1.0.dist-info/top_level.txt +2 -0
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
|