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.
Files changed (50) hide show
  1. frontend_perception_engine-0.1.0.dist-info/METADATA +235 -0
  2. frontend_perception_engine-0.1.0.dist-info/RECORD +50 -0
  3. frontend_perception_engine-0.1.0.dist-info/WHEEL +5 -0
  4. frontend_perception_engine-0.1.0.dist-info/entry_points.txt +2 -0
  5. frontend_perception_engine-0.1.0.dist-info/top_level.txt +1 -0
  6. navigation/__init__.py +1 -0
  7. navigation/browser_use/__init__.py +17 -0
  8. navigation/browser_use/agent_runner.py +165 -0
  9. navigation/browser_use/hints.py +155 -0
  10. navigation/browser_use/integration.py +46 -0
  11. navigation/browser_use/llm.py +70 -0
  12. navigation/codeGraph/__init__.py +4 -0
  13. navigation/codeGraph/crg_impl.py +170 -0
  14. navigation/codeGraph/factory.py +24 -0
  15. navigation/codeGraph/interface.py +78 -0
  16. navigation/codeGraph/null_impl.py +47 -0
  17. navigation/mcp/__init__.py +4 -0
  18. navigation/mcp/__main__.py +4 -0
  19. navigation/mcp/diff.py +28 -0
  20. navigation/mcp/envelope.py +50 -0
  21. navigation/mcp/handlers.py +791 -0
  22. navigation/mcp/instructions.py +36 -0
  23. navigation/mcp/resources.py +82 -0
  24. navigation/mcp/scan_registry.py +47 -0
  25. navigation/mcp/server.py +229 -0
  26. navigation/mcp/session_store.py +87 -0
  27. navigation/mcp/tools.py +332 -0
  28. navigation/perception/__init__.py +76 -0
  29. navigation/perception/artifacts.py +19 -0
  30. navigation/perception/auth_gate.py +57 -0
  31. navigation/perception/budget.py +55 -0
  32. navigation/perception/cdp_hub.py +122 -0
  33. navigation/perception/dev_insights.py +625 -0
  34. navigation/perception/exploration.py +99 -0
  35. navigation/perception/feature_flags.py +83 -0
  36. navigation/perception/file_upload.py +84 -0
  37. navigation/perception/flow_graph.py +121 -0
  38. navigation/perception/form_probe.py +148 -0
  39. navigation/perception/iframe_context.py +76 -0
  40. navigation/perception/observation.py +97 -0
  41. navigation/perception/preflight.py +91 -0
  42. navigation/perception/rich_editors.py +90 -0
  43. navigation/perception/route_guards.py +136 -0
  44. navigation/perception/runner.py +138 -0
  45. navigation/perception/scan.py +74 -0
  46. navigation/perception/scripted_actions.py +67 -0
  47. navigation/perception/state_manager.py +114 -0
  48. navigation/perception/verification.py +117 -0
  49. navigation/perception/virtual_scroll.py +84 -0
  50. 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))