ego-webqa 0.2.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.
ego_webqa/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Public API for ego-webqa."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .config import Settings, Viewport
6
+ from .engine import AuditRuntimeError, audit
7
+ from .models import AuditReport, Issue
8
+
9
+ __all__ = [
10
+ "AuditReport",
11
+ "AuditRuntimeError",
12
+ "Issue",
13
+ "Settings",
14
+ "Viewport",
15
+ "audit",
16
+ ]
17
+
18
+ __version__ = "0.2.0"
ego_webqa/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
ego_webqa/cli.py ADDED
@@ -0,0 +1,178 @@
1
+ """Command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+
11
+ from . import __version__
12
+ from .config import Settings, Viewport, load_settings
13
+ from .engine import AuditRuntimeError, audit
14
+ from .reporting import write_reports
15
+ from .server import LocalServer
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(
20
+ prog="ego-webqa",
21
+ description="Deterministic delivery QA for AI-generated web pages.",
22
+ )
23
+ parser.add_argument("url", nargs="?", help="page URL")
24
+ parser.add_argument("--dir", help="serve this local directory during the audit")
25
+ parser.add_argument("--file", default="index.html", help="entry file under --dir")
26
+ parser.add_argument("--config", help="JSON configuration file")
27
+ parser.add_argument(
28
+ "--profile",
29
+ choices=("baseline", "accessibility"),
30
+ help="check profile (baseline or baseline plus accessibility checks)",
31
+ )
32
+ parser.add_argument(
33
+ "--viewports",
34
+ help="comma-separated WIDTHxHEIGHT[:NAME] list (default: 390x844,768x1024,1280x800)",
35
+ )
36
+ parser.add_argument("--out", default="webqa-report", help="artifact directory")
37
+ parser.add_argument("--timeout-ms", type=int, help="navigation/action timeout")
38
+ parser.add_argument("--ready-selector", help="wait for this selector before probing")
39
+ parser.add_argument(
40
+ "--wait-for-network-idle",
41
+ action="store_true",
42
+ help="wait up to 3 seconds for network idle before probing",
43
+ )
44
+ parser.add_argument(
45
+ "--ignore-selector",
46
+ action="append",
47
+ default=[],
48
+ help="selector to exclude",
49
+ )
50
+ parser.add_argument(
51
+ "--ignore-console-pattern",
52
+ action="append",
53
+ default=[],
54
+ help="regular expression for known console.error noise",
55
+ )
56
+ parser.add_argument(
57
+ "--ignore-request-pattern",
58
+ action="append",
59
+ default=[],
60
+ help="regular expression for known failed-request noise",
61
+ )
62
+ parser.add_argument("--smoke-selector", help="explicit primary CTA selector")
63
+ parser.add_argument(
64
+ "--smoke-success-selector",
65
+ help="selector that must transition to visible after the CTA click",
66
+ )
67
+ parser.add_argument(
68
+ "--smoke-success-url-pattern",
69
+ help="regular expression the URL must newly match after the CTA click",
70
+ )
71
+ parser.add_argument(
72
+ "--browser-channel",
73
+ help="Chrome channel, e.g. chrome, chrome-beta, msedge",
74
+ )
75
+ parser.add_argument("--no-smoke", action="store_true", help="skip the primary-CTA click")
76
+ parser.add_argument(
77
+ "--smoke-mode",
78
+ choices=("safe", "live"),
79
+ help="safe blocks smoke-test network requests; live permits real side effects",
80
+ )
81
+ parser.add_argument("--fail-on-warn", action="store_true", help="make warnings exit non-zero")
82
+ parser.add_argument("--json", action="store_true", help="print the complete JSON report")
83
+ parser.add_argument("--version", action="version", version=f"ego-webqa {__version__}")
84
+ return parser
85
+
86
+
87
+ def _settings(args: argparse.Namespace) -> Settings:
88
+ cfg = load_settings(args.config) if args.config else Settings()
89
+ updates: dict[str, object] = {}
90
+ if args.profile is not None:
91
+ updates["profile"] = args.profile
92
+ if args.viewports:
93
+ updates["viewports"] = [Viewport.parse(item) for item in args.viewports.split(",")]
94
+ if args.timeout_ms is not None:
95
+ updates["timeout_ms"] = args.timeout_ms
96
+ if args.ready_selector is not None:
97
+ updates["ready_selector"] = args.ready_selector
98
+ if args.wait_for_network_idle:
99
+ updates["wait_for_network_idle"] = True
100
+ if args.ignore_selector:
101
+ updates["ignore_selectors"] = [*cfg.ignore_selectors, *args.ignore_selector]
102
+ if args.ignore_console_pattern:
103
+ updates["ignore_console_patterns"] = [
104
+ *cfg.ignore_console_patterns,
105
+ *args.ignore_console_pattern,
106
+ ]
107
+ if args.ignore_request_pattern:
108
+ updates["ignore_request_patterns"] = [
109
+ *cfg.ignore_request_patterns,
110
+ *args.ignore_request_pattern,
111
+ ]
112
+ if args.smoke_selector is not None:
113
+ updates["smoke_selector"] = args.smoke_selector
114
+ if args.smoke_success_selector is not None:
115
+ updates["smoke_success_selector"] = args.smoke_success_selector
116
+ if args.smoke_success_url_pattern is not None:
117
+ updates["smoke_success_url_pattern"] = args.smoke_success_url_pattern
118
+ if args.browser_channel is not None:
119
+ updates["browser_channel"] = args.browser_channel
120
+ if args.smoke_mode is not None:
121
+ updates["smoke_mode"] = args.smoke_mode
122
+ if args.fail_on_warn:
123
+ updates["fail_on_warn"] = True
124
+ return Settings.from_mapping(updates, base=cfg)
125
+
126
+
127
+ def main(argv: Sequence[str] | None = None) -> int:
128
+ args = build_parser().parse_args(argv)
129
+ if not args.url and not args.dir:
130
+ print("ego-webqa: provide a URL or --dir", file=sys.stderr)
131
+ return 2
132
+ if args.url and args.dir:
133
+ print("ego-webqa: provide either a URL or --dir, not both", file=sys.stderr)
134
+ return 2
135
+ try:
136
+ cfg = _settings(args)
137
+ except (OSError, ValueError) as exc:
138
+ print(f"ego-webqa: invalid configuration: {exc}", file=sys.stderr)
139
+ return 2
140
+
141
+ server: LocalServer | None = None
142
+ target = args.url or ""
143
+ try:
144
+ if args.dir:
145
+ server = LocalServer(args.dir)
146
+ target = server.url(args.file)
147
+ report = audit(
148
+ target,
149
+ settings=cfg,
150
+ output_dir=Path(args.out),
151
+ smoke=not args.no_smoke,
152
+ )
153
+ artifacts = write_reports(report, args.out)
154
+ except (AuditRuntimeError, OSError, ValueError) as exc:
155
+ print(f"ego-webqa: {exc}", file=sys.stderr)
156
+ return 2
157
+ finally:
158
+ if server is not None:
159
+ server.close()
160
+
161
+ if args.json:
162
+ print(json.dumps(report.to_dict(), ensure_ascii=False, indent=2))
163
+ else:
164
+ icon = {"pass": "PASS", "warn": "WARN", "fail": "FAIL"}[report.status]
165
+ counts = report.to_dict()["counts"]
166
+ print(f"[{icon}] {target}")
167
+ print(
168
+ f"{len(report.viewports)} viewport(s), "
169
+ f"{counts['error']} error(s), {counts['warning']} warning(s), "
170
+ f"{report.duration_ms}ms"
171
+ )
172
+ for issue in report.issues:
173
+ prefix = f"[{issue.viewport}] " if issue.viewport else ""
174
+ print(f" {issue.severity.upper():7} {prefix}{issue.code}: {issue.message}")
175
+ if issue.hint:
176
+ print(f" hint: {issue.hint}")
177
+ print(f"artifacts: {artifacts['json']}")
178
+ return 0 if report.status in ("pass", "warn") else 1
ego_webqa/config.py ADDED
@@ -0,0 +1,332 @@
1
+ """Standalone configuration and thresholds.
2
+
3
+ This module is the source of truth for the open-source package. It deliberately
4
+ does not import another ego package.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass, field, replace
13
+ from pathlib import Path
14
+ from typing import Any, get_type_hints
15
+
16
+ DEFAULT_CTA_PATTERN = (
17
+ r"开始|立即|马上|提交|预约|预订|下单|购买|报名|领取|生成|创建|保存|发送|"
18
+ r"查看|试试|去支付|记一笔|记账|显示|释义|认识|再来|绘制|计算|"
19
+ r"start|get started|continue|submit|book|buy|sign up|create|save|send|view|try|checkout"
20
+ )
21
+ DEFAULT_NAV_PATTERN = (
22
+ r"首页|返回|上一|下一|更多|登录|注册|导航|home|back|previous|next|more|login|sign in|"
23
+ r"sign up|register|navigation|nav"
24
+ )
25
+ MOBILE_USER_AGENT = (
26
+ "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 "
27
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36"
28
+ )
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class Viewport:
33
+ """A browser viewport used by the deterministic check matrix."""
34
+
35
+ width: int
36
+ height: int
37
+ name: str = ""
38
+ device_scale_factor: float = 1.0
39
+ mobile: bool = False
40
+ user_agent: str = ""
41
+
42
+ def __post_init__(self) -> None:
43
+ if type(self.width) is not int or type(self.height) is not int:
44
+ raise ValueError("viewport dimensions must be integers")
45
+ if self.width < 200 or self.height < 200:
46
+ raise ValueError("viewport dimensions must both be at least 200")
47
+ if type(self.device_scale_factor) not in {int, float}:
48
+ raise ValueError("device_scale_factor must be a number")
49
+ if self.device_scale_factor <= 0:
50
+ raise ValueError("device_scale_factor must be positive")
51
+ if not isinstance(self.mobile, bool):
52
+ raise ValueError("viewport.mobile must be a boolean")
53
+ if not isinstance(self.user_agent, str):
54
+ raise ValueError("viewport.user_agent must be a string")
55
+ if not self.name:
56
+ object.__setattr__(self, "name", f"{self.width}x{self.height}")
57
+
58
+ @classmethod
59
+ def parse(cls, value: str) -> Viewport:
60
+ """Parse ``390x844`` or ``390x844:mobile``."""
61
+
62
+ text = value.strip()
63
+ name = ""
64
+ if ":" in text:
65
+ text, name = text.rsplit(":", 1)
66
+ match = re.fullmatch(r"(\d+)x(\d+)", text.lower())
67
+ if not match:
68
+ raise ValueError(f"invalid viewport {value!r}; expected WIDTHxHEIGHT[:NAME]")
69
+ width = int(match.group(1))
70
+ mobile = width <= 600 or name.lower() in {"mobile", "phone"}
71
+ return cls(width, int(match.group(2)), name, mobile=mobile)
72
+
73
+ def to_dict(self) -> dict[str, Any]:
74
+ return {
75
+ "name": self.name,
76
+ "width": self.width,
77
+ "height": self.height,
78
+ "device_scale_factor": self.device_scale_factor,
79
+ "mobile": self.mobile,
80
+ "user_agent": self.user_agent,
81
+ }
82
+
83
+
84
+ DEFAULT_VIEWPORTS = (
85
+ Viewport(390, 844, "mobile", mobile=True),
86
+ Viewport(768, 1024, "tablet"),
87
+ Viewport(1280, 800, "desktop"),
88
+ )
89
+
90
+
91
+ @dataclass(frozen=True, slots=True)
92
+ class Settings:
93
+ """All deterministic checks are configured here, with no hidden selectors."""
94
+
95
+ viewports: tuple[Viewport, ...] = DEFAULT_VIEWPORTS
96
+ profile: str = "baseline"
97
+ timeout_ms: int = 15_000
98
+ settle_ms: int = 350
99
+ ready_selector: str = ""
100
+ wait_for_network_idle: bool = False
101
+ min_tap_target_px: float = 44.0
102
+ hard_font_px: float = 10.0
103
+ min_font_px: float = 12.0
104
+ small_issue_report_min: int = 3
105
+ overlay_ratio: float = 0.4
106
+ overflow_tolerance_px: int = 2
107
+ aspect_tolerance: float = 0.05
108
+ blank_text_chars: int = 10
109
+ blank_element_count: int = 5
110
+ fcp_warn_ms: float = 3_000.0
111
+ cls_warn: float = 0.1
112
+ cls_error: float = 0.25
113
+ transfer_warn_bytes: int = 400_000
114
+ transfer_error_bytes: int = 1_500_000
115
+ request_warn_count: int = 30
116
+ request_error_count: int = 60
117
+ cta_pattern: str = DEFAULT_CTA_PATTERN
118
+ nav_pattern: str = DEFAULT_NAV_PATTERN
119
+ smoke_selector: str = ""
120
+ smoke_success_selector: str = ""
121
+ smoke_success_url_pattern: str = ""
122
+ smoke_mode: str = "safe"
123
+ ignore_selectors: tuple[str, ...] = ()
124
+ ignore_console_patterns: tuple[str, ...] = ()
125
+ ignore_request_patterns: tuple[str, ...] = ()
126
+ browser_channel: str = ""
127
+ fail_on_warn: bool = False
128
+ metadata: Mapping[str, Any] = field(default_factory=dict)
129
+
130
+ def __post_init__(self) -> None:
131
+ self._validate_types()
132
+ if self.profile not in {"baseline", "accessibility"}:
133
+ raise ValueError("profile must be 'baseline' or 'accessibility'")
134
+ if not self.viewports:
135
+ raise ValueError("at least one viewport is required")
136
+ if len(self.viewports) > 12:
137
+ raise ValueError("at most 12 viewports are supported")
138
+ if self.timeout_ms < 1 or self.settle_ms < 0:
139
+ raise ValueError("timeouts must be non-negative")
140
+ positive = {
141
+ "min_tap_target_px": self.min_tap_target_px,
142
+ "hard_font_px": self.hard_font_px,
143
+ "min_font_px": self.min_font_px,
144
+ }
145
+ for name, value in positive.items():
146
+ if value <= 0:
147
+ raise ValueError(f"{name} must be positive")
148
+ non_negative = {
149
+ "small_issue_report_min": self.small_issue_report_min,
150
+ "overflow_tolerance_px": self.overflow_tolerance_px,
151
+ "blank_text_chars": self.blank_text_chars,
152
+ "blank_element_count": self.blank_element_count,
153
+ "fcp_warn_ms": self.fcp_warn_ms,
154
+ "transfer_warn_bytes": self.transfer_warn_bytes,
155
+ "request_warn_count": self.request_warn_count,
156
+ }
157
+ for name, value in non_negative.items():
158
+ if value < 0:
159
+ raise ValueError(f"{name} must be non-negative")
160
+ if self.min_font_px < self.hard_font_px:
161
+ raise ValueError("min_font_px must be greater than or equal to hard_font_px")
162
+ ratios = {
163
+ "overlay_ratio": self.overlay_ratio,
164
+ "aspect_tolerance": self.aspect_tolerance,
165
+ "cls_warn": self.cls_warn,
166
+ "cls_error": self.cls_error,
167
+ }
168
+ for name, value in ratios.items():
169
+ if not 0 <= value <= 1:
170
+ raise ValueError(f"{name} must be between 0 and 1")
171
+ if self.cls_error < self.cls_warn:
172
+ raise ValueError("cls_error must be greater than or equal to cls_warn")
173
+ if self.transfer_error_bytes < self.transfer_warn_bytes:
174
+ raise ValueError("transfer_error_bytes must be >= transfer_warn_bytes")
175
+ if self.request_error_count < self.request_warn_count:
176
+ raise ValueError("request_error_count must be >= request_warn_count")
177
+ if self.smoke_mode not in {"safe", "live"}:
178
+ raise ValueError("smoke_mode must be 'safe' or 'live'")
179
+ try:
180
+ re.compile(self.cta_pattern)
181
+ re.compile(self.nav_pattern)
182
+ if self.smoke_success_url_pattern:
183
+ re.compile(self.smoke_success_url_pattern)
184
+ for pattern in self.ignore_console_patterns:
185
+ re.compile(pattern)
186
+ for pattern in self.ignore_request_patterns:
187
+ re.compile(pattern)
188
+ except re.error as exc:
189
+ raise ValueError(f"invalid configuration regular expression: {exc}") from exc
190
+
191
+ def _validate_types(self) -> None:
192
+ hints = get_type_hints(type(self))
193
+ for name in type(self).__dataclass_fields__:
194
+ value = getattr(self, name)
195
+ if name == "viewports":
196
+ if not isinstance(value, tuple) or not all(
197
+ isinstance(item, Viewport) for item in value
198
+ ):
199
+ raise ValueError("viewports must be a tuple of Viewport values")
200
+ elif name in {
201
+ "ignore_console_patterns",
202
+ "ignore_request_patterns",
203
+ "ignore_selectors",
204
+ }:
205
+ if not isinstance(value, tuple) or not all(isinstance(item, str) for item in value):
206
+ raise ValueError(f"{name} must be a tuple of strings")
207
+ else:
208
+ _validate_scalar_type(name, value, hints[name])
209
+
210
+ @classmethod
211
+ def from_mapping(cls, values: Mapping[str, Any], base: Settings | None = None) -> Settings:
212
+ """Build settings from a JSON-compatible mapping."""
213
+
214
+ current = base or cls()
215
+ allowed = set(cls.__dataclass_fields__)
216
+ unknown = sorted(set(values) - allowed)
217
+ if unknown:
218
+ raise ValueError(f"unknown config key(s): {', '.join(unknown)}")
219
+
220
+ changes: dict[str, Any] = {}
221
+ for key, value in values.items():
222
+ if key == "viewports":
223
+ if not isinstance(value, list) or not value:
224
+ raise ValueError("viewports must be a non-empty list")
225
+ changes[key] = tuple(
226
+ item if isinstance(item, Viewport) else Viewport.parse(str(item))
227
+ for item in value
228
+ )
229
+ elif key in {
230
+ "ignore_console_patterns",
231
+ "ignore_request_patterns",
232
+ "ignore_selectors",
233
+ }:
234
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
235
+ raise ValueError(f"{key} must be a list of strings")
236
+ changes[key] = tuple(value)
237
+ elif key == "metadata":
238
+ if not isinstance(value, dict):
239
+ raise ValueError("metadata must be an object")
240
+ changes[key] = dict(value)
241
+ else:
242
+ hints = get_type_hints(cls)
243
+ changes[key] = _coerce_mapping_scalar(key, value, hints[key])
244
+ return replace(current, **changes)
245
+
246
+ def to_dict(self) -> dict[str, Any]:
247
+ return {
248
+ "viewports": [viewport.to_dict() for viewport in self.viewports],
249
+ "profile": self.profile,
250
+ "timeout_ms": self.timeout_ms,
251
+ "settle_ms": self.settle_ms,
252
+ "ready_selector": self.ready_selector,
253
+ "wait_for_network_idle": self.wait_for_network_idle,
254
+ "min_tap_target_px": self.min_tap_target_px,
255
+ "hard_font_px": self.hard_font_px,
256
+ "min_font_px": self.min_font_px,
257
+ "small_issue_report_min": self.small_issue_report_min,
258
+ "overlay_ratio": self.overlay_ratio,
259
+ "overflow_tolerance_px": self.overflow_tolerance_px,
260
+ "aspect_tolerance": self.aspect_tolerance,
261
+ "blank_text_chars": self.blank_text_chars,
262
+ "blank_element_count": self.blank_element_count,
263
+ "fcp_warn_ms": self.fcp_warn_ms,
264
+ "cls_warn": self.cls_warn,
265
+ "cls_error": self.cls_error,
266
+ "transfer_warn_bytes": self.transfer_warn_bytes,
267
+ "transfer_error_bytes": self.transfer_error_bytes,
268
+ "request_warn_count": self.request_warn_count,
269
+ "request_error_count": self.request_error_count,
270
+ "cta_pattern": self.cta_pattern,
271
+ "nav_pattern": self.nav_pattern,
272
+ "smoke_selector": self.smoke_selector,
273
+ "smoke_success_selector": self.smoke_success_selector,
274
+ "smoke_success_url_pattern": self.smoke_success_url_pattern,
275
+ "smoke_mode": self.smoke_mode,
276
+ "ignore_selectors": list(self.ignore_selectors),
277
+ "ignore_console_patterns": list(self.ignore_console_patterns),
278
+ "ignore_request_patterns": list(self.ignore_request_patterns),
279
+ "browser_channel": self.browser_channel,
280
+ "fail_on_warn": self.fail_on_warn,
281
+ "metadata": dict(self.metadata),
282
+ }
283
+
284
+
285
+ def load_settings(path: str | Path) -> Settings:
286
+ """Load settings from a JSON file."""
287
+
288
+ raw = Path(path).read_text(encoding="utf-8")
289
+ try:
290
+ data = json.loads(raw)
291
+ except json.JSONDecodeError as exc:
292
+ raise ValueError(f"invalid JSON config {path}: {exc}") from exc
293
+ if not isinstance(data, dict):
294
+ raise ValueError("config root must be a JSON object")
295
+ return Settings.from_mapping(data)
296
+
297
+
298
+ def _coerce_mapping_scalar(name: str, value: Any, expected: Any) -> Any:
299
+ if expected is bool:
300
+ if type(value) is not bool:
301
+ raise ValueError(f"{name} must be a boolean")
302
+ return value
303
+ if expected is int:
304
+ if type(value) is not int:
305
+ raise ValueError(f"{name} must be an integer")
306
+ return value
307
+ if expected is float:
308
+ if type(value) not in {int, float}:
309
+ raise ValueError(f"{name} must be a number")
310
+ return float(value)
311
+ if expected is str:
312
+ if not isinstance(value, str):
313
+ raise ValueError(f"{name} must be a string")
314
+ return value
315
+ raise ValueError(f"{name} does not support JSON configuration")
316
+
317
+
318
+ def _validate_scalar_type(name: str, value: Any, expected: Any) -> None:
319
+ if expected is bool:
320
+ valid = type(value) is bool
321
+ elif expected is int:
322
+ valid = type(value) is int
323
+ elif expected is float:
324
+ valid = type(value) in {int, float}
325
+ elif expected is str:
326
+ valid = isinstance(value, str)
327
+ elif expected is Mapping or getattr(expected, "__origin__", None) is Mapping:
328
+ valid = isinstance(value, Mapping)
329
+ else:
330
+ valid = True
331
+ if not valid:
332
+ raise ValueError(f"{name} has an invalid type")