frontend-perception-engine 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.
- frontend_perception_engine-0.1.0.dist-info/METADATA +235 -0
- frontend_perception_engine-0.1.0.dist-info/RECORD +50 -0
- frontend_perception_engine-0.1.0.dist-info/WHEEL +5 -0
- frontend_perception_engine-0.1.0.dist-info/entry_points.txt +2 -0
- frontend_perception_engine-0.1.0.dist-info/top_level.txt +1 -0
- navigation/__init__.py +1 -0
- navigation/browser_use/__init__.py +17 -0
- navigation/browser_use/agent_runner.py +165 -0
- navigation/browser_use/hints.py +155 -0
- navigation/browser_use/integration.py +46 -0
- navigation/browser_use/llm.py +70 -0
- navigation/codeGraph/__init__.py +4 -0
- navigation/codeGraph/crg_impl.py +170 -0
- navigation/codeGraph/factory.py +24 -0
- navigation/codeGraph/interface.py +78 -0
- navigation/codeGraph/null_impl.py +47 -0
- navigation/mcp/__init__.py +4 -0
- navigation/mcp/__main__.py +4 -0
- navigation/mcp/diff.py +28 -0
- navigation/mcp/envelope.py +50 -0
- navigation/mcp/handlers.py +791 -0
- navigation/mcp/instructions.py +36 -0
- navigation/mcp/resources.py +82 -0
- navigation/mcp/scan_registry.py +47 -0
- navigation/mcp/server.py +229 -0
- navigation/mcp/session_store.py +87 -0
- navigation/mcp/tools.py +332 -0
- navigation/perception/__init__.py +76 -0
- navigation/perception/artifacts.py +19 -0
- navigation/perception/auth_gate.py +57 -0
- navigation/perception/budget.py +55 -0
- navigation/perception/cdp_hub.py +122 -0
- navigation/perception/dev_insights.py +625 -0
- navigation/perception/exploration.py +99 -0
- navigation/perception/feature_flags.py +83 -0
- navigation/perception/file_upload.py +84 -0
- navigation/perception/flow_graph.py +121 -0
- navigation/perception/form_probe.py +148 -0
- navigation/perception/iframe_context.py +76 -0
- navigation/perception/observation.py +97 -0
- navigation/perception/preflight.py +91 -0
- navigation/perception/rich_editors.py +90 -0
- navigation/perception/route_guards.py +136 -0
- navigation/perception/runner.py +138 -0
- navigation/perception/scan.py +74 -0
- navigation/perception/scripted_actions.py +67 -0
- navigation/perception/state_manager.py +114 -0
- navigation/perception/verification.py +117 -0
- navigation/perception/virtual_scroll.py +84 -0
- navigation/perception/websocket_observer.py +79 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Phase 4: CRG-guided exploration with live verification."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from navigation.browser_use.hints import GraphHintResolver
|
|
9
|
+
from navigation.codeGraph.interface import ICodeGraph
|
|
10
|
+
|
|
11
|
+
from .verification import SuccessCriteria, read_current_url, verify
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class ExplorationStep:
|
|
16
|
+
action: str
|
|
17
|
+
detail: str
|
|
18
|
+
url: str = ""
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> dict:
|
|
21
|
+
return {"action": self.action, "detail": self.detail, "url": self.url}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class ExplorationResult:
|
|
26
|
+
goal: str
|
|
27
|
+
ok: bool
|
|
28
|
+
final_url: str
|
|
29
|
+
steps: list[ExplorationStep] = field(default_factory=list)
|
|
30
|
+
hint_summary: str = ""
|
|
31
|
+
error: str | None = None
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict:
|
|
34
|
+
return {
|
|
35
|
+
"goal": self.goal,
|
|
36
|
+
"ok": self.ok,
|
|
37
|
+
"final_url": self.final_url,
|
|
38
|
+
"hint_summary": self.hint_summary,
|
|
39
|
+
"steps": [s.to_dict() for s in self.steps],
|
|
40
|
+
"error": self.error,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def explore_with_hints(
|
|
45
|
+
session: Any,
|
|
46
|
+
code_graph: ICodeGraph | None,
|
|
47
|
+
goal: str,
|
|
48
|
+
*,
|
|
49
|
+
base_url: str,
|
|
50
|
+
success: SuccessCriteria,
|
|
51
|
+
candidate_paths: list[str] | None = None,
|
|
52
|
+
) -> ExplorationResult:
|
|
53
|
+
"""Use CRG hints to pick a path, navigate, verify — no blind agent loop."""
|
|
54
|
+
steps: list[ExplorationStep] = []
|
|
55
|
+
resolver = GraphHintResolver(code_graph)
|
|
56
|
+
hint = resolver.resolve(goal)
|
|
57
|
+
steps.append(ExplorationStep("crg.resolve", hint.summary, await read_current_url(session)))
|
|
58
|
+
|
|
59
|
+
paths = list(candidate_paths or [])
|
|
60
|
+
for kw in resolver.extract_keywords(goal):
|
|
61
|
+
if kw not in paths:
|
|
62
|
+
paths.append(f"/{kw}")
|
|
63
|
+
|
|
64
|
+
# CRG-related file paths often mention edge-lab
|
|
65
|
+
for fp in hint.related_files:
|
|
66
|
+
if "edge" in fp.lower():
|
|
67
|
+
paths.insert(0, "/edge-lab")
|
|
68
|
+
|
|
69
|
+
if "/edge-lab" not in paths:
|
|
70
|
+
paths.append("/edge-lab")
|
|
71
|
+
|
|
72
|
+
b = base_url.rstrip("/")
|
|
73
|
+
ok = False
|
|
74
|
+
final_url = ""
|
|
75
|
+
for path in paths:
|
|
76
|
+
target = path if path.startswith("http") else f"{b}/{path.lstrip('/')}"
|
|
77
|
+
try:
|
|
78
|
+
await session.navigate_to(target)
|
|
79
|
+
await asyncio.sleep(0.35)
|
|
80
|
+
final_url = await read_current_url(session)
|
|
81
|
+
steps.append(ExplorationStep("navigate", target, final_url))
|
|
82
|
+
result = await verify(session, success)
|
|
83
|
+
if result.ok:
|
|
84
|
+
ok = True
|
|
85
|
+
break
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
steps.append(ExplorationStep("navigate.error", str(exc), await read_current_url(session)))
|
|
88
|
+
|
|
89
|
+
if not ok:
|
|
90
|
+
final_url = await read_current_url(session)
|
|
91
|
+
|
|
92
|
+
return ExplorationResult(
|
|
93
|
+
goal=goal,
|
|
94
|
+
ok=ok,
|
|
95
|
+
final_url=final_url,
|
|
96
|
+
steps=steps,
|
|
97
|
+
hint_summary=hint.summary,
|
|
98
|
+
error=None if ok else "no candidate path verified",
|
|
99
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Phase 4: detect feature-gated UI vs hard failures."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .verification import read_page_text, read_current_url
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class FeatureFlagProbe:
|
|
13
|
+
route: str
|
|
14
|
+
flag_name: str
|
|
15
|
+
available_without_flag: bool
|
|
16
|
+
available_with_flag: bool
|
|
17
|
+
in_code_graph: bool = True
|
|
18
|
+
status: str = "unknown" # feature_gated | enabled | missing
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> dict:
|
|
21
|
+
return {
|
|
22
|
+
"route": self.route,
|
|
23
|
+
"flag_name": self.flag_name,
|
|
24
|
+
"available_without_flag": self.available_without_flag,
|
|
25
|
+
"available_with_flag": self.available_with_flag,
|
|
26
|
+
"in_code_graph": self.in_code_graph,
|
|
27
|
+
"status": self.status,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class FeatureFlagResult:
|
|
33
|
+
ok: bool
|
|
34
|
+
probes: list[FeatureFlagProbe] = field(default_factory=list)
|
|
35
|
+
error: str | None = None
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict:
|
|
38
|
+
return {"ok": self.ok, "probes": [p.to_dict() for p in self.probes], "error": self.error}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def probe_feature_flag(
|
|
42
|
+
session: Any,
|
|
43
|
+
base_url: str,
|
|
44
|
+
route: str,
|
|
45
|
+
*,
|
|
46
|
+
flag_query: str,
|
|
47
|
+
feature_text: str,
|
|
48
|
+
) -> FeatureFlagResult:
|
|
49
|
+
b = base_url.rstrip("/")
|
|
50
|
+
path = route if route.startswith("/") else f"/{route}"
|
|
51
|
+
probes: list[FeatureFlagProbe] = []
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
await session.navigate_to(f"{b}{path}")
|
|
55
|
+
await asyncio.sleep(0.3)
|
|
56
|
+
text_off = (await read_page_text(session, include_dom_text=True)).lower()
|
|
57
|
+
off_has_feature = feature_text.lower() in text_off
|
|
58
|
+
|
|
59
|
+
await session.navigate_to(f"{b}{path}?{flag_query}")
|
|
60
|
+
await asyncio.sleep(0.3)
|
|
61
|
+
text_on = (await read_page_text(session, include_dom_text=True)).lower()
|
|
62
|
+
on_has_feature = feature_text.lower() in text_on
|
|
63
|
+
|
|
64
|
+
status = "feature_gated"
|
|
65
|
+
if on_has_feature and not off_has_feature:
|
|
66
|
+
status = "feature_gated"
|
|
67
|
+
elif on_has_feature and off_has_feature:
|
|
68
|
+
status = "enabled"
|
|
69
|
+
else:
|
|
70
|
+
status = "missing"
|
|
71
|
+
|
|
72
|
+
probe = FeatureFlagProbe(
|
|
73
|
+
route=path,
|
|
74
|
+
flag_name=flag_query.split("=")[0],
|
|
75
|
+
available_without_flag=off_has_feature,
|
|
76
|
+
available_with_flag=on_has_feature,
|
|
77
|
+
status=status,
|
|
78
|
+
)
|
|
79
|
+
probes.append(probe)
|
|
80
|
+
ok = status == "feature_gated"
|
|
81
|
+
return FeatureFlagResult(ok=ok, probes=probes)
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
return FeatureFlagResult(ok=False, probes=probes, error=str(exc))
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Phase 4: programmatic file upload via CDP."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import tempfile
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .verification import evaluate_js, read_page_text
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class FileUploadResult:
|
|
15
|
+
ok: bool
|
|
16
|
+
filename: str
|
|
17
|
+
displayed: bool
|
|
18
|
+
error: str | None = None
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> dict:
|
|
21
|
+
return {
|
|
22
|
+
"ok": self.ok,
|
|
23
|
+
"filename": self.filename,
|
|
24
|
+
"displayed": self.displayed,
|
|
25
|
+
"error": self.error,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def upload_test_file(
|
|
30
|
+
session: Any,
|
|
31
|
+
*,
|
|
32
|
+
input_selector: str = '[data-testid="file-input"]',
|
|
33
|
+
content: str = "edge-lab upload test",
|
|
34
|
+
suffix: str = ".txt",
|
|
35
|
+
) -> FileUploadResult:
|
|
36
|
+
tmp_path: Path | None = None
|
|
37
|
+
try:
|
|
38
|
+
with tempfile.NamedTemporaryFile("w", suffix=suffix, delete=False, encoding="utf-8") as f:
|
|
39
|
+
f.write(content)
|
|
40
|
+
tmp_path = Path(f.name)
|
|
41
|
+
|
|
42
|
+
filename = tmp_path.name
|
|
43
|
+
cdp = await session.get_or_create_cdp_session(target_id=None, focus=True)
|
|
44
|
+
doc = await cdp.cdp_client.send.DOM.getDocument(session_id=cdp.session_id)
|
|
45
|
+
root_id = doc["root"]["nodeId"]
|
|
46
|
+
sel = await cdp.cdp_client.send.DOM.querySelector(
|
|
47
|
+
params={"nodeId": root_id, "selector": input_selector},
|
|
48
|
+
session_id=cdp.session_id,
|
|
49
|
+
)
|
|
50
|
+
node_id = sel.get("nodeId")
|
|
51
|
+
if not node_id:
|
|
52
|
+
return FileUploadResult(False, filename, False, "file input not found")
|
|
53
|
+
|
|
54
|
+
await cdp.cdp_client.send.DOM.setFileInputFiles(
|
|
55
|
+
params={"files": [str(tmp_path.resolve())], "nodeId": node_id},
|
|
56
|
+
session_id=cdp.session_id,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
await evaluate_js(
|
|
60
|
+
session,
|
|
61
|
+
f"""
|
|
62
|
+
(() => {{
|
|
63
|
+
const input = document.querySelector({input_selector!r});
|
|
64
|
+
if (!input) return false;
|
|
65
|
+
input.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
|
66
|
+
input.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
|
67
|
+
return true;
|
|
68
|
+
}})()
|
|
69
|
+
""",
|
|
70
|
+
)
|
|
71
|
+
await asyncio.sleep(0.3)
|
|
72
|
+
|
|
73
|
+
page = await read_page_text(session, include_dom_text=True)
|
|
74
|
+
base_name = Path(filename).name
|
|
75
|
+
displayed = base_name in page or "uploaded:" in page.lower()
|
|
76
|
+
return FileUploadResult(displayed, base_name, displayed)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
return FileUploadResult(False, tmp_path.name if tmp_path else "", False, str(exc))
|
|
79
|
+
finally:
|
|
80
|
+
if tmp_path and tmp_path.exists():
|
|
81
|
+
try:
|
|
82
|
+
tmp_path.unlink()
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Phase 3: flow graph — verified checkpoints, not just routes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .verification import SuccessCriteria
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class Checkpoint:
|
|
13
|
+
name: str
|
|
14
|
+
success: SuccessCriteria
|
|
15
|
+
instruction: str | None = None
|
|
16
|
+
navigate_url: str | None = None
|
|
17
|
+
ensure_url: str | None = None
|
|
18
|
+
prerequisite_state: str | None = None
|
|
19
|
+
max_steps: int = 8
|
|
20
|
+
max_attempts: int = 3
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def is_deterministic(self) -> bool:
|
|
24
|
+
return self.instruction is None and self.navigate_url is not None
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict[str, Any]:
|
|
27
|
+
return {
|
|
28
|
+
"name": self.name,
|
|
29
|
+
"mode": "deterministic" if self.is_deterministic else "agent",
|
|
30
|
+
"navigate_url": self.navigate_url,
|
|
31
|
+
"instruction": self.instruction,
|
|
32
|
+
"ensure_url": self.ensure_url,
|
|
33
|
+
"prerequisite_state": self.prerequisite_state,
|
|
34
|
+
"success": {
|
|
35
|
+
"url_contains": self.success.url_contains,
|
|
36
|
+
"text_contains": self.success.text_contains,
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class FlowGraph:
|
|
43
|
+
name: str
|
|
44
|
+
description: str
|
|
45
|
+
checkpoints: list[Checkpoint] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict[str, Any]:
|
|
48
|
+
return {
|
|
49
|
+
"name": self.name,
|
|
50
|
+
"description": self.description,
|
|
51
|
+
"checkpoints": [c.to_dict() for c in self.checkpoints],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validation_form_flow() -> FlowGraph:
|
|
56
|
+
return FlowGraph(
|
|
57
|
+
name="validation-form",
|
|
58
|
+
description="Probe validation rules then submit valid data",
|
|
59
|
+
checkpoints=[
|
|
60
|
+
Checkpoint(
|
|
61
|
+
name="open-form",
|
|
62
|
+
navigate_url="/forms/validation",
|
|
63
|
+
success=SuccessCriteria(
|
|
64
|
+
url_contains=["/forms/validation"],
|
|
65
|
+
text_contains=["Validated form"],
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
Checkpoint(
|
|
69
|
+
name="submit-empty",
|
|
70
|
+
instruction="Click Validate & submit without filling fields",
|
|
71
|
+
ensure_url="/forms/validation",
|
|
72
|
+
success=SuccessCriteria(
|
|
73
|
+
url_contains=["/forms/validation"],
|
|
74
|
+
text_contains=["Invalid email address"],
|
|
75
|
+
),
|
|
76
|
+
max_steps=4,
|
|
77
|
+
),
|
|
78
|
+
Checkpoint(
|
|
79
|
+
name="submit-valid",
|
|
80
|
+
instruction=(
|
|
81
|
+
"Fill Email test@example.com, Phone 1234567890, Age 25, "
|
|
82
|
+
"check terms, click Validate & submit"
|
|
83
|
+
),
|
|
84
|
+
ensure_url="/forms/validation",
|
|
85
|
+
success=SuccessCriteria(
|
|
86
|
+
url_contains=["/forms/validation"],
|
|
87
|
+
text_contains=["Form is valid"],
|
|
88
|
+
),
|
|
89
|
+
max_steps=8,
|
|
90
|
+
),
|
|
91
|
+
],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def shop_order_flow() -> FlowGraph:
|
|
96
|
+
return FlowGraph(
|
|
97
|
+
name="shop-order",
|
|
98
|
+
description="Buy Pulse Watch through checkout",
|
|
99
|
+
checkpoints=[
|
|
100
|
+
Checkpoint(
|
|
101
|
+
name="open-product",
|
|
102
|
+
navigate_url="/shop/product/p3",
|
|
103
|
+
success=SuccessCriteria(url_contains=["/shop/product/p3"]),
|
|
104
|
+
),
|
|
105
|
+
Checkpoint(
|
|
106
|
+
name="checkout-to-confirmation",
|
|
107
|
+
instruction=(
|
|
108
|
+
"Buy now, checkout, fill shipping and payment, place order"
|
|
109
|
+
),
|
|
110
|
+
prerequisite_state="cart_with_items",
|
|
111
|
+
success=SuccessCriteria(url_contains=["/shop/checkout/confirmation"]),
|
|
112
|
+
max_steps=35,
|
|
113
|
+
),
|
|
114
|
+
],
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
FLOWS: dict[str, Callable[[], FlowGraph]] = {
|
|
119
|
+
"validation-form": validation_form_flow,
|
|
120
|
+
"shop-order": shop_order_flow,
|
|
121
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Phase 1: probe forms — invalid submit first, extract rules, then valid submit."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .scripted_actions import click_button_text
|
|
10
|
+
from .verification import SuccessCriteria, evaluate_js, read_page_text, verify
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class ValidationRule:
|
|
15
|
+
field: str
|
|
16
|
+
message: str
|
|
17
|
+
source: str = "live_probe"
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict:
|
|
20
|
+
return {"field": self.field, "message": self.message, "source": self.source}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class FormProbeResult:
|
|
25
|
+
form_url: str
|
|
26
|
+
ok: bool
|
|
27
|
+
rules: list[ValidationRule] = field(default_factory=list)
|
|
28
|
+
invalid_verified: bool = False
|
|
29
|
+
valid_verified: bool = False
|
|
30
|
+
error: str | None = None
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict:
|
|
33
|
+
return {
|
|
34
|
+
"form_url": self.form_url,
|
|
35
|
+
"ok": self.ok,
|
|
36
|
+
"rules": [r.to_dict() for r in self.rules],
|
|
37
|
+
"invalid_verified": self.invalid_verified,
|
|
38
|
+
"valid_verified": self.valid_verified,
|
|
39
|
+
"error": self.error,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _extract_error_messages(session: Any) -> list[ValidationRule]:
|
|
44
|
+
raw = await evaluate_js(
|
|
45
|
+
session,
|
|
46
|
+
"""
|
|
47
|
+
Array.from(document.querySelectorAll('.error-text'))
|
|
48
|
+
.map(el => el.textContent.trim())
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
""",
|
|
51
|
+
)
|
|
52
|
+
rules: list[ValidationRule] = []
|
|
53
|
+
if not raw:
|
|
54
|
+
return rules
|
|
55
|
+
messages = raw if isinstance(raw, list) else [raw]
|
|
56
|
+
field_hints = {
|
|
57
|
+
"email": "email",
|
|
58
|
+
"phone": "phone",
|
|
59
|
+
"age": "age",
|
|
60
|
+
"18": "age",
|
|
61
|
+
"terms": "terms",
|
|
62
|
+
"digit": "phone",
|
|
63
|
+
}
|
|
64
|
+
for msg in messages:
|
|
65
|
+
msg_str = str(msg)
|
|
66
|
+
field = "unknown"
|
|
67
|
+
lower = msg_str.lower()
|
|
68
|
+
for hint, name in field_hints.items():
|
|
69
|
+
if hint in lower:
|
|
70
|
+
field = name
|
|
71
|
+
break
|
|
72
|
+
rules.append(ValidationRule(field=field, message=msg_str))
|
|
73
|
+
return rules
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _fill_validation_form(session: Any) -> bool:
|
|
77
|
+
return bool(
|
|
78
|
+
await evaluate_js(
|
|
79
|
+
session,
|
|
80
|
+
"""
|
|
81
|
+
(() => {
|
|
82
|
+
const form = document.querySelector('form.card');
|
|
83
|
+
if (!form) return false;
|
|
84
|
+
const inputs = Array.from(form.querySelectorAll('input'));
|
|
85
|
+
if (inputs.length < 4) return false;
|
|
86
|
+
const [email, phone, age, terms] = inputs;
|
|
87
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
|
88
|
+
if (!setter) return false;
|
|
89
|
+
for (const [el, val] of [[email, 'test@example.com'], [phone, '1234567890'], [age, '25']]) {
|
|
90
|
+
setter.call(el, val);
|
|
91
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
92
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
93
|
+
}
|
|
94
|
+
if (!terms.checked) terms.click();
|
|
95
|
+
return true;
|
|
96
|
+
})()
|
|
97
|
+
""",
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
EXPECTED_RULES = (
|
|
103
|
+
"Invalid email address",
|
|
104
|
+
"10 digits",
|
|
105
|
+
"18 or older",
|
|
106
|
+
"accept the terms",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def probe_validation_form(session: Any, base_url: str) -> FormProbeResult:
|
|
111
|
+
path = "/forms/validation"
|
|
112
|
+
url = f"{base_url.rstrip('/')}{path}"
|
|
113
|
+
try:
|
|
114
|
+
await session.navigate_to(url)
|
|
115
|
+
await asyncio.sleep(0.3)
|
|
116
|
+
|
|
117
|
+
await click_button_text(session, "Validate & submit")
|
|
118
|
+
await asyncio.sleep(0.3)
|
|
119
|
+
|
|
120
|
+
invalid = await verify(
|
|
121
|
+
session,
|
|
122
|
+
SuccessCriteria(url_contains=["/forms/validation"], text_contains=["Invalid email address"]),
|
|
123
|
+
)
|
|
124
|
+
rules = await _extract_error_messages(session)
|
|
125
|
+
|
|
126
|
+
await _fill_validation_form(session)
|
|
127
|
+
await click_button_text(session, "Validate & submit")
|
|
128
|
+
await asyncio.sleep(0.3)
|
|
129
|
+
|
|
130
|
+
valid = await verify(
|
|
131
|
+
session,
|
|
132
|
+
SuccessCriteria(url_contains=["/forms/validation"], text_contains=["Form is valid"]),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
haystack = (await read_page_text(session, include_dom_text=True)).lower()
|
|
136
|
+
expected_hits = sum(1 for e in EXPECTED_RULES if e.lower() in haystack or any(e.lower() in r.message.lower() for r in rules))
|
|
137
|
+
|
|
138
|
+
ok = invalid.ok and valid.ok and len(rules) >= 1
|
|
139
|
+
return FormProbeResult(
|
|
140
|
+
form_url=url,
|
|
141
|
+
ok=ok,
|
|
142
|
+
rules=rules,
|
|
143
|
+
invalid_verified=invalid.ok,
|
|
144
|
+
valid_verified=valid.ok,
|
|
145
|
+
error=None if ok else f"rules={len(rules)} expected_hits={expected_hits}",
|
|
146
|
+
)
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
return FormProbeResult(form_url=url, ok=False, error=str(exc))
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Phase 4: iframe-aware interaction (same-origin / srcdoc frames)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .verification import evaluate_js, read_page_text
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class IframeProbeResult:
|
|
13
|
+
ok: bool
|
|
14
|
+
frame_count: int
|
|
15
|
+
clicked_in_frame: bool
|
|
16
|
+
frame_text: str = ""
|
|
17
|
+
error: str | None = None
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict:
|
|
20
|
+
return {
|
|
21
|
+
"ok": self.ok,
|
|
22
|
+
"frame_count": self.frame_count,
|
|
23
|
+
"clicked_in_frame": self.clicked_in_frame,
|
|
24
|
+
"frame_text": self.frame_text[:500],
|
|
25
|
+
"error": self.error,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def probe_iframe_interaction(
|
|
30
|
+
session: Any,
|
|
31
|
+
*,
|
|
32
|
+
iframe_selector: str = '[data-testid="edge-iframe"]',
|
|
33
|
+
button_id: str = "frame-btn",
|
|
34
|
+
expected_text: str = "clicked",
|
|
35
|
+
) -> IframeProbeResult:
|
|
36
|
+
try:
|
|
37
|
+
frame_count = await evaluate_js(
|
|
38
|
+
session,
|
|
39
|
+
"document.querySelectorAll('iframe').length",
|
|
40
|
+
)
|
|
41
|
+
frame_count = int(frame_count or 0)
|
|
42
|
+
|
|
43
|
+
clicked = await evaluate_js(
|
|
44
|
+
session,
|
|
45
|
+
f"""
|
|
46
|
+
(() => {{
|
|
47
|
+
const frame = document.querySelector({iframe_selector!r});
|
|
48
|
+
if (!frame || !frame.contentDocument) return false;
|
|
49
|
+
const btn = frame.contentDocument.getElementById({button_id!r});
|
|
50
|
+
if (!btn) return false;
|
|
51
|
+
btn.click();
|
|
52
|
+
const out = frame.contentDocument.getElementById('out');
|
|
53
|
+
return out && out.textContent === {expected_text!r};
|
|
54
|
+
}})()
|
|
55
|
+
""",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
frame_text = await evaluate_js(
|
|
59
|
+
session,
|
|
60
|
+
f"""
|
|
61
|
+
(() => {{
|
|
62
|
+
const frame = document.querySelector({iframe_selector!r});
|
|
63
|
+
return frame?.contentDocument?.body?.innerText || '';
|
|
64
|
+
}})()
|
|
65
|
+
""",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
ok = bool(clicked) and frame_count >= 1
|
|
69
|
+
return IframeProbeResult(
|
|
70
|
+
ok=ok,
|
|
71
|
+
frame_count=frame_count,
|
|
72
|
+
clicked_in_frame=bool(clicked),
|
|
73
|
+
frame_text=str(frame_text or ""),
|
|
74
|
+
)
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
return IframeProbeResult(ok=False, frame_count=0, clicked_in_frame=False, error=str(exc))
|