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.
Files changed (47) hide show
  1. adapters/__init__.py +14 -0
  2. adapters/base.py +34 -0
  3. adapters/browser_adapter.py +300 -0
  4. adapters/dom_scripts.py +518 -0
  5. adapters/envelope_builder.py +108 -0
  6. adapters/live_session_manager.py +326 -0
  7. adapters/upload_adapter.py +77 -0
  8. builder.py +83 -0
  9. cli.py +341 -0
  10. config/__init__.py +23 -0
  11. config/settings.py +339 -0
  12. constants/__init__.py +165 -0
  13. constants/contracts.py +95 -0
  14. constants/selectors.py +27 -0
  15. constants/timeouts.py +25 -0
  16. core/__init__.py +45 -0
  17. core/exceptions.py +33 -0
  18. core/models.py +273 -0
  19. engine.py +199 -0
  20. exceptions.py +23 -0
  21. mcp_server.py +347 -0
  22. models.py +29 -0
  23. orchestration/__init__.py +19 -0
  24. orchestration/context.py +71 -0
  25. orchestration/flow_orchestrator.py +358 -0
  26. orchestration/session_coordinator.py +88 -0
  27. services/__init__.py +17 -0
  28. services/auth_navigation_service.py +129 -0
  29. services/field_interaction_service.py +174 -0
  30. services/form_filler_service.py +106 -0
  31. services/inspection_formatter_service.py +68 -0
  32. services/inspection_service.py +40 -0
  33. services/reactive_interaction_service.py +174 -0
  34. supervisor/__init__.py +13 -0
  35. supervisor/client.py +171 -0
  36. supervisor/contracts.py +62 -0
  37. supervisor/master_daemon.py +303 -0
  38. supervisor/shell.py +194 -0
  39. supervisor/worker_process.py +400 -0
  40. validators/__init__.py +9 -0
  41. validators/form_validator.py +28 -0
  42. validators/payload_validator.py +25 -0
  43. webpilot_engine-2.0.0.dist-info/METADATA +299 -0
  44. webpilot_engine-2.0.0.dist-info/RECORD +47 -0
  45. webpilot_engine-2.0.0.dist-info/WHEEL +5 -0
  46. webpilot_engine-2.0.0.dist-info/entry_points.txt +6 -0
  47. 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
+ ]