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 +18 -0
- ego_webqa/__main__.py +5 -0
- ego_webqa/cli.py +178 -0
- ego_webqa/config.py +332 -0
- ego_webqa/engine.py +832 -0
- ego_webqa/models.py +74 -0
- ego_webqa/probes.py +447 -0
- ego_webqa/py.typed +1 -0
- ego_webqa/reporting.py +120 -0
- ego_webqa/server.py +46 -0
- ego_webqa-0.2.0.dist-info/METADATA +244 -0
- ego_webqa-0.2.0.dist-info/RECORD +16 -0
- ego_webqa-0.2.0.dist-info/WHEEL +5 -0
- ego_webqa-0.2.0.dist-info/entry_points.txt +2 -0
- ego_webqa-0.2.0.dist-info/licenses/LICENSE +22 -0
- ego_webqa-0.2.0.dist-info/top_level.txt +1 -0
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
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")
|