webpilot-engine 2.0.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.
- adapters/__init__.py +14 -0
- adapters/base.py +34 -0
- adapters/browser_adapter.py +300 -0
- adapters/dom_scripts.py +518 -0
- adapters/envelope_builder.py +108 -0
- adapters/live_session_manager.py +326 -0
- adapters/upload_adapter.py +77 -0
- builder.py +83 -0
- cli.py +341 -0
- config/__init__.py +23 -0
- config/settings.py +339 -0
- constants/__init__.py +165 -0
- constants/contracts.py +95 -0
- constants/selectors.py +27 -0
- constants/timeouts.py +25 -0
- core/__init__.py +45 -0
- core/exceptions.py +33 -0
- core/models.py +273 -0
- engine.py +199 -0
- exceptions.py +23 -0
- mcp_server.py +347 -0
- models.py +29 -0
- orchestration/__init__.py +19 -0
- orchestration/context.py +71 -0
- orchestration/flow_orchestrator.py +358 -0
- orchestration/session_coordinator.py +88 -0
- services/__init__.py +17 -0
- services/auth_navigation_service.py +129 -0
- services/field_interaction_service.py +174 -0
- services/form_filler_service.py +106 -0
- services/inspection_formatter_service.py +68 -0
- services/inspection_service.py +40 -0
- services/reactive_interaction_service.py +174 -0
- supervisor/__init__.py +13 -0
- supervisor/client.py +171 -0
- supervisor/contracts.py +62 -0
- supervisor/master_daemon.py +303 -0
- supervisor/shell.py +194 -0
- supervisor/worker_process.py +400 -0
- validators/__init__.py +9 -0
- validators/form_validator.py +28 -0
- validators/payload_validator.py +25 -0
- webpilot_engine-2.0.0.dist-info/METADATA +299 -0
- webpilot_engine-2.0.0.dist-info/RECORD +47 -0
- webpilot_engine-2.0.0.dist-info/WHEEL +5 -0
- webpilot_engine-2.0.0.dist-info/entry_points.txt +6 -0
- webpilot_engine-2.0.0.dist-info/top_level.txt +14 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Field value setting and DOM verification service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from playwright.sync_api import Page
|
|
7
|
+
|
|
8
|
+
from adapters.dom_scripts import (
|
|
9
|
+
SET_FIELD_DOM_SCRIPT,
|
|
10
|
+
PICKLIST_AUTO_SCROLL_SCRIPT,
|
|
11
|
+
VERIFY_FIELD_DOM_SCRIPT,
|
|
12
|
+
)
|
|
13
|
+
from constants.timeouts import (
|
|
14
|
+
PAUSE_INPUT_SET_MS,
|
|
15
|
+
PAUSE_RETRY_FALLBACK_MS,
|
|
16
|
+
PAUSE_PICKLIST_EXPAND_MS,
|
|
17
|
+
PAUSE_PICKLIST_CLOSE_MS,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FieldInteractionService:
|
|
22
|
+
"""Sets form field values across standard and complex enterprise widgets and verifies DOM state."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, page: Page):
|
|
25
|
+
self.page = page
|
|
26
|
+
|
|
27
|
+
def set_field(self, target_identifier: str, value: Any) -> bool:
|
|
28
|
+
"""Sets field value according to its widget type using JS manipulation or Playwright fallback."""
|
|
29
|
+
res = self.page.evaluate(SET_FIELD_DOM_SCRIPT, [target_identifier, value])
|
|
30
|
+
|
|
31
|
+
# Picklist: trigger button click + scoped auto-scrolling
|
|
32
|
+
if res.get("type") == "picklist" and res.get("trigger_button"):
|
|
33
|
+
try:
|
|
34
|
+
btn_loc = self.page.locator(f'[id="{res["trigger_button"]}"]').first
|
|
35
|
+
if btn_loc.is_visible(timeout=1500):
|
|
36
|
+
btn_loc.scroll_into_view_if_needed()
|
|
37
|
+
btn_loc.click(force=True)
|
|
38
|
+
self.page.wait_for_timeout(PAUSE_PICKLIST_EXPAND_MS)
|
|
39
|
+
|
|
40
|
+
pick_res = self.page.evaluate(PICKLIST_AUTO_SCROLL_SCRIPT, [res["id"], value])
|
|
41
|
+
|
|
42
|
+
if pick_res.get("success"):
|
|
43
|
+
self.page.wait_for_timeout(300)
|
|
44
|
+
return True
|
|
45
|
+
else:
|
|
46
|
+
self.page.keyboard.press("Escape")
|
|
47
|
+
self.page.wait_for_timeout(PAUSE_PICKLIST_CLOSE_MS)
|
|
48
|
+
except Exception as exc:
|
|
49
|
+
print(f" [!] Picklist click error: {exc}")
|
|
50
|
+
|
|
51
|
+
# Non-picklist JS success
|
|
52
|
+
if res.get("success") and res.get("type") not in ("picklist",):
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
# Fallback to Playwright native interaction
|
|
56
|
+
return self._playwright_click_fallback(target_identifier, value)
|
|
57
|
+
|
|
58
|
+
def _playwright_click_fallback(self, target: str, value: Any) -> bool:
|
|
59
|
+
"""Fallback setter using Playwright native locators (click / fill)."""
|
|
60
|
+
page = self.page
|
|
61
|
+
strval = str(value)
|
|
62
|
+
lower = target.lower().strip()
|
|
63
|
+
|
|
64
|
+
# 1. Picklist combobox by aria-label or title
|
|
65
|
+
for loc in [
|
|
66
|
+
page.locator(f'input[aria-label*="{target}"][role="combobox"]'),
|
|
67
|
+
page.locator(f'input[title*="{target}"][role="combobox"]'),
|
|
68
|
+
]:
|
|
69
|
+
try:
|
|
70
|
+
if loc.count() > 0 and loc.first.is_visible(timeout=300):
|
|
71
|
+
loc.first.click()
|
|
72
|
+
page.wait_for_timeout(300)
|
|
73
|
+
for role_name in ["menuitem", "option", "listitem"]:
|
|
74
|
+
opt = page.get_by_role(role_name, name=strval, exact=False) # type: ignore
|
|
75
|
+
if opt.count() > 0:
|
|
76
|
+
opt.first.click()
|
|
77
|
+
page.wait_for_timeout(PAUSE_RETRY_FALLBACK_MS)
|
|
78
|
+
return True
|
|
79
|
+
page.keyboard.press("Escape")
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
# 2. Native radio by ARIA role
|
|
84
|
+
try:
|
|
85
|
+
radio = page.get_by_role("radio", name=strval, exact=False)
|
|
86
|
+
if radio.count() > 0:
|
|
87
|
+
radio.first.click()
|
|
88
|
+
return True
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
# 3. Custom ARIA radioLabel spans (SuccessFactors)
|
|
93
|
+
for selector in [
|
|
94
|
+
f'.radioLabel:text-is("{strval}")',
|
|
95
|
+
f'.radioLabel:has-text("{strval}")',
|
|
96
|
+
]:
|
|
97
|
+
try:
|
|
98
|
+
spans = page.locator(selector)
|
|
99
|
+
if spans.count() > 0:
|
|
100
|
+
spans.first.click()
|
|
101
|
+
return True
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
# 4. Text / Textarea via label
|
|
106
|
+
try:
|
|
107
|
+
field = page.get_by_label(lower, exact=False)
|
|
108
|
+
if field.count() > 0 and field.first.is_visible(timeout=300):
|
|
109
|
+
field.first.fill(strval)
|
|
110
|
+
field.first.dispatch_event("change")
|
|
111
|
+
return True
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
# 5. Text via placeholder
|
|
116
|
+
try:
|
|
117
|
+
field = page.get_by_placeholder(lower, exact=False)
|
|
118
|
+
if field.count() > 0 and field.first.is_visible(timeout=300):
|
|
119
|
+
field.first.fill(strval)
|
|
120
|
+
field.first.dispatch_event("change")
|
|
121
|
+
return True
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
# 6. Username / Email generic heuristic
|
|
126
|
+
if lower in ("username", "user", "email", "login", "user name", "email address"):
|
|
127
|
+
for sel in [
|
|
128
|
+
'input[type="email"]',
|
|
129
|
+
'input[name*="user" i]',
|
|
130
|
+
'input[id*="user" i]',
|
|
131
|
+
'input[name*="email" i]',
|
|
132
|
+
'input[id*="email" i]',
|
|
133
|
+
'input[autocomplete="username"]',
|
|
134
|
+
'input[autocomplete="email"]',
|
|
135
|
+
]:
|
|
136
|
+
try:
|
|
137
|
+
f = page.locator(sel)
|
|
138
|
+
if f.count() > 0 and f.first.is_visible(timeout=300):
|
|
139
|
+
f.first.fill(strval)
|
|
140
|
+
f.first.dispatch_event("input")
|
|
141
|
+
f.first.dispatch_event("change")
|
|
142
|
+
return True
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
# 7. Password generic heuristic
|
|
147
|
+
if lower in ("password", "pass", "pwd", "user password"):
|
|
148
|
+
try:
|
|
149
|
+
f = page.locator('input[type="password"]')
|
|
150
|
+
if f.count() > 0 and f.first.is_visible(timeout=300):
|
|
151
|
+
f.first.fill(strval)
|
|
152
|
+
f.first.dispatch_event("input")
|
|
153
|
+
f.first.dispatch_event("change")
|
|
154
|
+
return True
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
def verify_field_value(self, target: str, expected: Any) -> bool:
|
|
161
|
+
"""Reads back current DOM value and checks if it matches expected."""
|
|
162
|
+
expected_str = str(expected).strip().lower()
|
|
163
|
+
junk = {"login/ view profile", "no selection", ""}
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
actual = self.page.evaluate(VERIFY_FIELD_DOM_SCRIPT, [target, expected_str])
|
|
167
|
+
if actual is None:
|
|
168
|
+
return False
|
|
169
|
+
actual_lower = str(actual).strip().lower()
|
|
170
|
+
if actual_lower in junk:
|
|
171
|
+
return False
|
|
172
|
+
return expected_str in actual_lower
|
|
173
|
+
except Exception:
|
|
174
|
+
return False
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Sequential form filling orchestration service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Tuple
|
|
6
|
+
from playwright.sync_api import Page
|
|
7
|
+
|
|
8
|
+
from constants.selectors import SELECTOR_SECTION_BUTTON
|
|
9
|
+
from constants.timeouts import PAUSE_INPUT_SET_MS, PAUSE_RETRY_FALLBACK_MS
|
|
10
|
+
from services.field_interaction_service import FieldInteractionService
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FormFillerService:
|
|
14
|
+
"""Orchestrates section-by-section and global sequential form filling."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, page: Page, field_service: FieldInteractionService):
|
|
17
|
+
self.page = page
|
|
18
|
+
self.field_service = field_service
|
|
19
|
+
|
|
20
|
+
def fill_form_sequential(self, field_mappings: List[Tuple[str, Any]]) -> Dict[str, List[Tuple[str, Any]]]:
|
|
21
|
+
"""Universal sequential form filler. Opens one section, fills matching fields, then advances."""
|
|
22
|
+
results: Dict[str, List[Tuple[str, Any]]] = {
|
|
23
|
+
"confirmed": [],
|
|
24
|
+
"unconfirmed": [],
|
|
25
|
+
"failed": [],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
pending = list(field_mappings)
|
|
29
|
+
section_count = self.page.locator(SELECTOR_SECTION_BUTTON).count()
|
|
30
|
+
print(f"[*] Sequential fill: {len(pending)} fields across {section_count} sections.")
|
|
31
|
+
|
|
32
|
+
def try_fill(key: str, val: Any) -> str:
|
|
33
|
+
"""Attempt fill + DOM verify. Returns 'confirmed', 'unconfirmed', or 'failed'."""
|
|
34
|
+
success = self.field_service.set_field(key, val)
|
|
35
|
+
if not success:
|
|
36
|
+
return "failed"
|
|
37
|
+
|
|
38
|
+
self.page.wait_for_timeout(PAUSE_INPUT_SET_MS)
|
|
39
|
+
if self.field_service.verify_field_value(key, val):
|
|
40
|
+
return "confirmed"
|
|
41
|
+
|
|
42
|
+
# One retry via pure Playwright click
|
|
43
|
+
self.page.wait_for_timeout(PAUSE_RETRY_FALLBACK_MS)
|
|
44
|
+
self.field_service._playwright_click_fallback(key, val)
|
|
45
|
+
self.page.wait_for_timeout(PAUSE_RETRY_FALLBACK_MS)
|
|
46
|
+
if self.field_service.verify_field_value(key, val):
|
|
47
|
+
return "confirmed"
|
|
48
|
+
return "unconfirmed"
|
|
49
|
+
|
|
50
|
+
# Pass 1: Section by section
|
|
51
|
+
for sec_idx in range(section_count):
|
|
52
|
+
if not pending:
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
sec_btn = self.page.locator(SELECTOR_SECTION_BUTTON).nth(sec_idx)
|
|
56
|
+
try:
|
|
57
|
+
sec_label = sec_btn.inner_text(timeout=800).strip()[:50]
|
|
58
|
+
except Exception:
|
|
59
|
+
sec_label = f"Section {sec_idx + 1}"
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
is_collapsed = sec_btn.get_attribute("aria-expanded") == "false"
|
|
63
|
+
if is_collapsed:
|
|
64
|
+
sec_btn.scroll_into_view_if_needed()
|
|
65
|
+
sec_btn.click(force=True)
|
|
66
|
+
self.page.wait_for_timeout(400)
|
|
67
|
+
print(f" [▶] Opened: {sec_label}")
|
|
68
|
+
else:
|
|
69
|
+
print(f" [=] Already open: {sec_label}")
|
|
70
|
+
except Exception:
|
|
71
|
+
print(f" [?] Could not check/open: {sec_label}")
|
|
72
|
+
|
|
73
|
+
still_pending = []
|
|
74
|
+
for key, val in pending:
|
|
75
|
+
status = try_fill(key, val)
|
|
76
|
+
if status == "confirmed":
|
|
77
|
+
results["confirmed"].append((key, val))
|
|
78
|
+
print(f" [✓] {key!r} -> {val!r}")
|
|
79
|
+
elif status == "unconfirmed":
|
|
80
|
+
still_pending.append((key, val))
|
|
81
|
+
else:
|
|
82
|
+
still_pending.append((key, val))
|
|
83
|
+
|
|
84
|
+
pending = still_pending
|
|
85
|
+
|
|
86
|
+
# Pass 2: Global retry for unmatched fields
|
|
87
|
+
if pending:
|
|
88
|
+
print(f"\n[*] Pass 2: {len(pending)} field(s) not matched in any section — trying globally...")
|
|
89
|
+
for key, val in pending:
|
|
90
|
+
status = try_fill(key, val)
|
|
91
|
+
if status == "confirmed":
|
|
92
|
+
results["confirmed"].append((key, val))
|
|
93
|
+
print(f" [✓] {key!r} -> {val!r}")
|
|
94
|
+
elif status == "unconfirmed":
|
|
95
|
+
results["unconfirmed"].append((key, val))
|
|
96
|
+
print(f" [?] Not confirmed: {key!r} -> {val!r}")
|
|
97
|
+
else:
|
|
98
|
+
results["failed"].append((key, val))
|
|
99
|
+
print(f" [✗] Failed: {key!r} -> {val!r}")
|
|
100
|
+
|
|
101
|
+
print(
|
|
102
|
+
f"\n[*] Fill summary: ✓{len(results['confirmed'])} confirmed | "
|
|
103
|
+
f"?{len(results['unconfirmed'])} unconfirmed | "
|
|
104
|
+
f"✗{len(results['failed'])} failed"
|
|
105
|
+
)
|
|
106
|
+
return results
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Inspection summary formatting service."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
class InspectionFormatterService:
|
|
6
|
+
"""Formats raw inspection schema into a concise, token-optimized summary."""
|
|
7
|
+
|
|
8
|
+
@staticmethod
|
|
9
|
+
def format_summary(data: Dict[str, Any]) -> str:
|
|
10
|
+
"""Returns a concise, human-readable summary of the inspected form."""
|
|
11
|
+
lines = []
|
|
12
|
+
lines.append(f"=== FORM INSPECTION SUMMARY: '{data.get('title')}' ===")
|
|
13
|
+
lines.append(f"URL: {data.get('url')}\n")
|
|
14
|
+
|
|
15
|
+
# Sections
|
|
16
|
+
sections = data.get("sections", [])
|
|
17
|
+
if sections:
|
|
18
|
+
lines.append(f"📁 Sections ({len(sections)}): " + ", ".join(sections))
|
|
19
|
+
lines.append("")
|
|
20
|
+
|
|
21
|
+
# Inputs
|
|
22
|
+
inputs = data.get("inputs", [])
|
|
23
|
+
lines.append(f"📝 Text & Numeric Input Fields ({len(inputs)}):")
|
|
24
|
+
for inp in inputs:
|
|
25
|
+
req_flag = " [REQUIRED]" if inp.get("required") else ""
|
|
26
|
+
cur = f" = '{inp['value']}'" if inp.get("value") else ""
|
|
27
|
+
lines.append(f" • Label: '{inp['label']}' (ID: {inp['id']}){req_flag}{cur}")
|
|
28
|
+
lines.append("")
|
|
29
|
+
|
|
30
|
+
# Dropdowns
|
|
31
|
+
dropdowns = data.get("dropdowns", [])
|
|
32
|
+
lines.append(f"🔽 Dropdowns & Picklists ({len(dropdowns)}):")
|
|
33
|
+
for dd in dropdowns:
|
|
34
|
+
req_flag = " [REQUIRED]" if dd.get("required") else ""
|
|
35
|
+
cur = f" = '{dd['value']}'" if dd.get("value") else ""
|
|
36
|
+
opts = dd.get("sample_options") or []
|
|
37
|
+
sample_str = f" [Sample: {', '.join(opts[:4])}]" if opts else ""
|
|
38
|
+
lines.append(f" • Label: '{dd['label']}' (ID: {dd['id']}){req_flag}{cur}{sample_str}")
|
|
39
|
+
lines.append("")
|
|
40
|
+
|
|
41
|
+
# Choices
|
|
42
|
+
choices = data.get("choices", [])
|
|
43
|
+
lines.append(f"🔘 Choice Groups (Radio / Checkbox) ({len(choices)}):")
|
|
44
|
+
for ch in choices:
|
|
45
|
+
req_flag = " [REQUIRED]" if ch.get("required") else ""
|
|
46
|
+
opts_str = ", ".join(ch.get("options", []))
|
|
47
|
+
sel = f" [Selected: {ch['selected']}]" if ch.get("selected") else ""
|
|
48
|
+
lines.append(f" • Question: '{ch['question']}'{req_flag} -> [{opts_str}]{sel}")
|
|
49
|
+
lines.append("")
|
|
50
|
+
|
|
51
|
+
# File Uploads
|
|
52
|
+
uploads = data.get("file_uploads", [])
|
|
53
|
+
lines.append(f"📎 File Upload Areas ({len(uploads)}):")
|
|
54
|
+
for up in uploads:
|
|
55
|
+
req_flag = " [REQUIRED]" if up.get("required") else ""
|
|
56
|
+
lines.append(f" • Label: '{up['label']}' (ID: {up['id']}){req_flag}")
|
|
57
|
+
if data.get("already_uploaded_files"):
|
|
58
|
+
lines.append(f" Already attached: {data['already_uploaded_files']}")
|
|
59
|
+
lines.append("")
|
|
60
|
+
|
|
61
|
+
# Action Buttons
|
|
62
|
+
buttons = data.get("buttons", [])
|
|
63
|
+
lines.append(f"🔘 Key Action Buttons ({len(buttons)}):")
|
|
64
|
+
for btn in buttons:
|
|
65
|
+
lines.append(f" • [{btn.get('action', 'action').upper()}] '{btn['text']}' (ID: {btn['id']})")
|
|
66
|
+
lines.append("")
|
|
67
|
+
|
|
68
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Form schema inspection service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
from playwright.sync_api import Page
|
|
7
|
+
from adapters.dom_scripts import INSPECT_PAGE_DOM_SCRIPT, PROBE_PICKLIST_OPTIONS_SCRIPT
|
|
8
|
+
|
|
9
|
+
class InspectionService:
|
|
10
|
+
"""Inspects webpage DOM and produces structured form schema."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, page: Page):
|
|
13
|
+
self.page = page
|
|
14
|
+
|
|
15
|
+
def inspect(self, unpack_options: bool = True) -> Dict[str, Any]:
|
|
16
|
+
"""Deep inspection of the active page. Extracts form components, inputs, buttons, and uploads."""
|
|
17
|
+
raw = self.page.evaluate(INSPECT_PAGE_DOM_SCRIPT)
|
|
18
|
+
|
|
19
|
+
if unpack_options:
|
|
20
|
+
print("[*] Probing picklist samples (unpack_options=True)...")
|
|
21
|
+
for dd in raw.get("dropdowns", []):
|
|
22
|
+
btn_id = dd.get("trigger_id")
|
|
23
|
+
if btn_id and self.page.locator(f'[id="{btn_id}"]').count() > 0:
|
|
24
|
+
try:
|
|
25
|
+
btn_loc = self.page.locator(f'[id="{btn_id}"]').first
|
|
26
|
+
if btn_loc.is_visible():
|
|
27
|
+
btn_loc.click(force=True)
|
|
28
|
+
self.page.wait_for_timeout(300)
|
|
29
|
+
base_id = dd["id"].replace(":_input", "")
|
|
30
|
+
owns_id = f"{base_id}:_listSelect"
|
|
31
|
+
opts = self.page.evaluate(PROBE_PICKLIST_OPTIONS_SCRIPT, owns_id)
|
|
32
|
+
if opts:
|
|
33
|
+
dd["options_count"] = len(opts)
|
|
34
|
+
dd["sample_options"] = opts[:5]
|
|
35
|
+
self.page.keyboard.press("Escape")
|
|
36
|
+
self.page.wait_for_timeout(100)
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
return raw
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Reactive button clicking and state observation service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Dict, Optional
|
|
6
|
+
from playwright.sync_api import BrowserContext, Page
|
|
7
|
+
|
|
8
|
+
from adapters.dom_scripts import CLICK_BUTTON_DOM_SCRIPT
|
|
9
|
+
from constants.selectors import SELECTOR_MODAL_DIALOGS
|
|
10
|
+
from constants.timeouts import PAUSE_REACTIVE_STABILIZE_MS
|
|
11
|
+
from services.inspection_service import InspectionService
|
|
12
|
+
from validators.form_validator import FormValidator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ReactiveInteractionService:
|
|
16
|
+
"""Clicks buttons and actively tracks environmental mutations (tabs, routes, modals, fields)."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
page: Page,
|
|
21
|
+
context: BrowserContext,
|
|
22
|
+
inspection_service: InspectionService,
|
|
23
|
+
form_validator: FormValidator,
|
|
24
|
+
on_active_page_change: Optional[Callable[[Page], None]] = None,
|
|
25
|
+
):
|
|
26
|
+
self.page = page
|
|
27
|
+
self.context = context
|
|
28
|
+
self.inspection_service = inspection_service
|
|
29
|
+
self.form_validator = form_validator
|
|
30
|
+
self.on_active_page_change = on_active_page_change
|
|
31
|
+
|
|
32
|
+
def click_button(self, button_text_or_purpose: str) -> Dict[str, Any]:
|
|
33
|
+
"""Intelligently clicks a button/link and monitors reactive consequences."""
|
|
34
|
+
target = button_text_or_purpose.strip()
|
|
35
|
+
|
|
36
|
+
# 1. Baseline state
|
|
37
|
+
pre_url = self.page.url
|
|
38
|
+
pre_tabs_count = len(self.context.pages)
|
|
39
|
+
pre_inputs_count = self.page.locator("input, select, textarea").count()
|
|
40
|
+
pre_modals_count = self.page.locator(SELECTOR_MODAL_DIALOGS).count()
|
|
41
|
+
|
|
42
|
+
clicked = False
|
|
43
|
+
|
|
44
|
+
# Strategy A: Native ID
|
|
45
|
+
clean_id = target.lstrip("#")
|
|
46
|
+
try:
|
|
47
|
+
loc = self.page.locator(f"#{clean_id}")
|
|
48
|
+
if loc.count() > 0 and loc.first.is_visible():
|
|
49
|
+
loc.first.click()
|
|
50
|
+
clicked = True
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
# Strategy B: Native Text / Role
|
|
55
|
+
if not clicked:
|
|
56
|
+
try:
|
|
57
|
+
for selector in [
|
|
58
|
+
f"button:has-text('{target}')",
|
|
59
|
+
f"a:has-text('{target}')",
|
|
60
|
+
f"[role='button']:has-text('{target}')",
|
|
61
|
+
f"input[type='button'][value*='{target}']",
|
|
62
|
+
f"input[type='submit'][value*='{target}']",
|
|
63
|
+
f".modal-footer p:has-text('{target}')",
|
|
64
|
+
f"p:has-text('{target}')",
|
|
65
|
+
f"span:has-text('{target}')",
|
|
66
|
+
]:
|
|
67
|
+
cand = self.page.locator(selector)
|
|
68
|
+
if cand.count() > 0 and cand.first.is_visible():
|
|
69
|
+
cand.first.click()
|
|
70
|
+
clicked = True
|
|
71
|
+
break
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
# Strategy C: JavaScript fallback
|
|
76
|
+
if not clicked:
|
|
77
|
+
clicked = self.page.evaluate(CLICK_BUTTON_DOM_SCRIPT, target)
|
|
78
|
+
|
|
79
|
+
# Strategy D: Modal Dismiss / Escape fallback
|
|
80
|
+
if not clicked and target.lower().strip() in ("close", "dismiss", "cancel", "escape"):
|
|
81
|
+
try:
|
|
82
|
+
self.page.keyboard.press("Escape")
|
|
83
|
+
clicked = True
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
if not clicked:
|
|
88
|
+
return {"success": False, "target": target}
|
|
89
|
+
|
|
90
|
+
# 2. Wait for reactive stabilization and loading overlay dissolution
|
|
91
|
+
self.page.wait_for_timeout(PAUSE_REACTIVE_STABILIZE_MS)
|
|
92
|
+
try:
|
|
93
|
+
self.page.wait_for_load_state("domcontentloaded", timeout=4000)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
self._wait_for_loading_dissolution()
|
|
98
|
+
|
|
99
|
+
# 3. Assess reactive changes
|
|
100
|
+
outcome: Dict[str, Any] = {
|
|
101
|
+
"success": True,
|
|
102
|
+
"target": target,
|
|
103
|
+
"redirected": False,
|
|
104
|
+
"new_window": False,
|
|
105
|
+
"new_url": None,
|
|
106
|
+
"modal_opened": False,
|
|
107
|
+
"modal_text": "",
|
|
108
|
+
"new_inputs_count": 0,
|
|
109
|
+
"schema": None,
|
|
110
|
+
"alerts": self.form_validator.get_validation_errors(),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Check for new window / tab
|
|
114
|
+
if len(self.context.pages) > pre_tabs_count:
|
|
115
|
+
self.page = self.context.pages[-1]
|
|
116
|
+
if self.on_active_page_change:
|
|
117
|
+
self.on_active_page_change(self.page)
|
|
118
|
+
outcome["new_window"] = True
|
|
119
|
+
outcome["new_url"] = self.page.url
|
|
120
|
+
outcome["schema"] = self.inspection_service.inspect(unpack_options=False)
|
|
121
|
+
return outcome
|
|
122
|
+
|
|
123
|
+
# Check for URL redirect / client-side route change
|
|
124
|
+
post_url = self.page.url
|
|
125
|
+
if post_url != pre_url:
|
|
126
|
+
outcome["redirected"] = True
|
|
127
|
+
outcome["new_url"] = post_url
|
|
128
|
+
outcome["schema"] = self.inspection_service.inspect(unpack_options=False)
|
|
129
|
+
return outcome
|
|
130
|
+
|
|
131
|
+
# Check for modal / dialog appearance
|
|
132
|
+
post_modals = self.page.locator(SELECTOR_MODAL_DIALOGS)
|
|
133
|
+
if post_modals.count() > pre_modals_count:
|
|
134
|
+
outcome["modal_opened"] = True
|
|
135
|
+
try:
|
|
136
|
+
outcome["modal_text"] = post_modals.first.inner_text().strip()[:400]
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
outcome["schema"] = self.inspection_service.inspect(unpack_options=False)
|
|
140
|
+
return outcome
|
|
141
|
+
|
|
142
|
+
# Check for dynamic DOM form inputs growth
|
|
143
|
+
post_inputs_count = self.page.locator("input, select, textarea").count()
|
|
144
|
+
if post_inputs_count > pre_inputs_count:
|
|
145
|
+
outcome["new_inputs_count"] = post_inputs_count - pre_inputs_count
|
|
146
|
+
outcome["schema"] = self.inspection_service.inspect(unpack_options=False)
|
|
147
|
+
return outcome
|
|
148
|
+
|
|
149
|
+
return outcome
|
|
150
|
+
|
|
151
|
+
def _wait_for_loading_dissolution(self, timeout_ms: int = 10000) -> None:
|
|
152
|
+
"""Detects if a blocking loader, spinner, or progress bar appeared and waits for its dissolution."""
|
|
153
|
+
try:
|
|
154
|
+
loader_selectors = [
|
|
155
|
+
"#loading:visible",
|
|
156
|
+
".loading:visible",
|
|
157
|
+
".spinner:visible",
|
|
158
|
+
".busyIndicator:visible",
|
|
159
|
+
".sapUiBusy:visible",
|
|
160
|
+
"[role='progressbar']:visible",
|
|
161
|
+
"[aria-busy='true']",
|
|
162
|
+
]
|
|
163
|
+
for sel in loader_selectors:
|
|
164
|
+
loc = self.page.locator(sel)
|
|
165
|
+
if loc.count() > 0 and loc.first.is_visible():
|
|
166
|
+
print(f"[*] Blocking loader detected ('{sel}'). Waiting for completion...")
|
|
167
|
+
try:
|
|
168
|
+
loc.first.wait_for(state="hidden", timeout=timeout_ms)
|
|
169
|
+
print("[✓] Loader completed and disappeared.")
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
break
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
supervisor/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Supervisor package for Multi-Tier Master Supervisor & Operational Worker."""
|
|
2
|
+
|
|
3
|
+
from supervisor.client import SupervisorClient
|
|
4
|
+
from supervisor.contracts import SupervisorActionRequest, SupervisorActionResponse
|
|
5
|
+
from supervisor.master_daemon import MasterSupervisor, run_master_daemon
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"SupervisorClient",
|
|
9
|
+
"SupervisorActionRequest",
|
|
10
|
+
"SupervisorActionResponse",
|
|
11
|
+
"MasterSupervisor",
|
|
12
|
+
"run_master_daemon",
|
|
13
|
+
]
|