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,518 @@
1
+ """Isolated and parameterized DOM scripts evaluated in the browser context."""
2
+
3
+ from typing import Final
4
+
5
+ DETECT_LOGIN_PAGE_SCRIPT: Final[str] = """() => {
6
+ const hasUser = document.querySelector('#username, input[type="email"], input[name="username"]');
7
+ const hasPass = document.querySelector('#password, input[type="password"]');
8
+ const title = document.title.toLowerCase();
9
+ return !!(hasPass && (hasUser || title.includes('sign in') || title.includes('login')));
10
+ }"""
11
+
12
+ INJECT_LOGIN_CREDENTIALS_SCRIPT: Final[str] = """([u, p]) => {
13
+ const userEl = document.querySelector('#username, input[type="email"], input[name="username"]');
14
+ const passEl = document.querySelector('#password, input[type="password"]');
15
+ if (userEl) {
16
+ userEl.value = u;
17
+ userEl.dispatchEvent(new Event('input', { bubbles: true }));
18
+ userEl.dispatchEvent(new Event('change', { bubbles: true }));
19
+ }
20
+ if (passEl) {
21
+ passEl.value = p;
22
+ passEl.dispatchEvent(new Event('input', { bubbles: true }));
23
+ passEl.dispatchEvent(new Event('change', { bubbles: true }));
24
+ }
25
+ }"""
26
+
27
+ PORTAL_DROPDOWN_CLICK_SCRIPT: Final[str] = """() => {
28
+ const toggle = document.querySelector('button.dropdown-toggle, .dropdown-toggle, [data-toggle="dropdown"]');
29
+ if (toggle && toggle.offsetParent !== null) {
30
+ toggle.click();
31
+ return true;
32
+ }
33
+ return false;
34
+ }"""
35
+
36
+ PORTAL_APPLY_CLICK_SCRIPT: Final[str] = """() => {
37
+ // 1. Prioritize inner manual apply option inside opened dropdown
38
+ const manualOpt = document.querySelector('#applyOption-top-manual, .social-apply-option a, a.applyOption');
39
+ if (manualOpt && manualOpt.offsetParent !== null) {
40
+ manualOpt.click();
41
+ return true;
42
+ }
43
+
44
+ // 2. Search for explicit apply buttons/links that are not dropdown toggles
45
+ const links = Array.from(document.querySelectorAll('a, button, input[type="button"]'));
46
+ const applyBtn = links.find(el => {
47
+ if (el.classList.contains('dropdown-toggle')) return false;
48
+ const t = (el.innerText || el.value || '').trim().toLowerCase();
49
+ return t === 'apply now' || t === 'apply' || t === 'apply now »' || el.classList.contains('dialogApplyBtn');
50
+ });
51
+ if (applyBtn && applyBtn.offsetParent !== null) {
52
+ applyBtn.click();
53
+ return true;
54
+ }
55
+ return false;
56
+ }"""
57
+
58
+ CHECK_FORM_LOADING_SCRIPT: Final[str] = (
59
+ "() => document.body.innerText.trim() === 'Loading...' || document.body.innerText.trim() === ''"
60
+ )
61
+
62
+ EXPAND_ALL_SECTIONS_SCRIPT: Final[str] = """() => {
63
+ let count = 0;
64
+ const sections = Array.from(document.querySelectorAll('.rcmFormSection, [class*="rcmFormSection"]'));
65
+ for (let s of sections) {
66
+ const btn = s.querySelector('button.rcmFormSectionTopBar, .rcmFormSectionTopBar');
67
+ const content = s.querySelector('.sectionContent, [class*="sectionContent"]');
68
+ const isClosed = (btn && btn.getAttribute('aria-expanded') === 'false') ||
69
+ (content && content.classList.contains('sectionContentClosed'));
70
+ if (isClosed && btn) {
71
+ btn.click();
72
+ count++;
73
+ }
74
+ }
75
+ return count;
76
+ }"""
77
+
78
+ INSPECT_PAGE_DOM_SCRIPT: Final[str] = """() => {
79
+ const result = {
80
+ title: document.title,
81
+ url: window.location.href,
82
+ buttons: [],
83
+ dropdowns: [],
84
+ inputs: [],
85
+ choices: [],
86
+ file_uploads: [],
87
+ sections: [],
88
+ already_uploaded_files: []
89
+ };
90
+
91
+ function clean(t) {
92
+ if (!t) return '';
93
+ return t.replace(/[\\u00a0\\r\\n\\t]+/g, ' ').replace(/^[\\s\\*]+/, '').trim();
94
+ }
95
+
96
+ function getLabel(el) {
97
+ if (el.getAttribute('aria-label')) return clean(el.getAttribute('aria-label'));
98
+ if (el.getAttribute('title')) return clean(el.getAttribute('title'));
99
+ if (el.id) {
100
+ const lbl = document.querySelector('label[for="' + el.id + '"]');
101
+ if (lbl && lbl.innerText) return clean(lbl.innerText);
102
+ }
103
+ const parentLbl = el.closest('label');
104
+ if (parentLbl && parentLbl.innerText) return clean(parentLbl.innerText);
105
+
106
+ const container = el.closest('tr, .rcmFormField, .form-group, .field, .row, div');
107
+ if (container) {
108
+ const lblEl = container.querySelector('label, .control-label, .field-label, th, dt, span.label, strong');
109
+ if (lblEl && lblEl !== el && lblEl.innerText) {
110
+ return clean(lblEl.innerText);
111
+ }
112
+ const text = container.innerText.trim().split('\\n')[0];
113
+ if (text && text.length < 80) return clean(text);
114
+ }
115
+ return clean(el.placeholder || el.name || '');
116
+ }
117
+
118
+ function isRequired(el, labelText) {
119
+ if (el.required || el.getAttribute('aria-required') === 'true') return true;
120
+ const lower = (labelText || '').toLowerCase();
121
+ if (lower.includes('*') || lower.includes('required')) return true;
122
+ const container = el.closest('tr, .rcmFormField, .form-group, .field, div');
123
+ if (container) {
124
+ const reqMarker = container.querySelector('.required, .mandatory, [aria-hidden="false"]');
125
+ if (reqMarker && (reqMarker.innerText || '').includes('*')) return true;
126
+ if ((container.innerText || '').includes('*')) return true;
127
+ }
128
+ return false;
129
+ }
130
+
131
+ // 1. Sections
132
+ const sectionTitles = Array.from(document.querySelectorAll('.rcmFormSectionTopBar, legend, h2, h3'))
133
+ .map(el => clean(el.innerText))
134
+ .filter(t => t.length > 2 && t.length < 80);
135
+ result.sections = Array.from(new Set(sectionTitles));
136
+
137
+ // 2. File Uploads
138
+ const seenUploadLabels = new Set();
139
+ const uploadNodes = Array.from(document.querySelectorAll('input[type="file"], .sfFileUpload, .fileUploadWrapper, [role="button"][id*="_attach"]'));
140
+ uploadNodes.forEach(fi => {
141
+ if (['LINK', 'SCRIPT', 'STYLE'].includes(fi.tagName)) return;
142
+ const id = fi.id || '';
143
+ if (id.includes('Deleting') || id.includes('Loading') || id.includes('Success') ||
144
+ id.includes('aria') || id.includes('Date') || id.includes('Icon') ||
145
+ id.includes('Form') || id.includes('Wrapper')) return;
146
+
147
+ const rawLbl = getLabel(fi) || 'Document Upload';
148
+ const lbl = clean(rawLbl.split('\\n')[0]);
149
+ if (!lbl || seenUploadLabels.has(lbl.toLowerCase())) return;
150
+ seenUploadLabels.add(lbl.toLowerCase());
151
+
152
+ result.file_uploads.push({
153
+ id: id,
154
+ label: lbl,
155
+ required: isRequired(fi, rawLbl)
156
+ });
157
+ });
158
+
159
+ const docLinks = Array.from(document.querySelectorAll('.documentName, a[href*="download"], a[title*="Download"], span[title*=".pdf"]'))
160
+ .map(a => clean(a.innerText || a.title))
161
+ .filter(t => t.includes('.pdf') || t.includes('.doc') || t.includes('.docx'));
162
+ result.already_uploaded_files = Array.from(new Set(docLinks));
163
+
164
+ // 3. Dropdowns & Picklists
165
+ document.querySelectorAll('select').forEach(sel => {
166
+ if (sel.offsetParent === null) return;
167
+ const lbl = getLabel(sel);
168
+ result.dropdowns.push({
169
+ id: sel.id || sel.name,
170
+ label: lbl,
171
+ type: 'select',
172
+ required: isRequired(sel, lbl),
173
+ value: sel.value || (sel.selectedOptions[0] ? clean(sel.selectedOptions[0].text) : '')
174
+ });
175
+ });
176
+
177
+ const picklistInputs = Array.from(document.querySelectorAll('input')).filter(inp =>
178
+ inp.id.endsWith(':_input') || inp.getAttribute('role') === 'combobox'
179
+ );
180
+ picklistInputs.forEach(pi => {
181
+ const lbl = getLabel(pi);
182
+ const baseId = pi.id.replace(':_input', '');
183
+ const btn = document.getElementById(baseId + ':_selectButton');
184
+ result.dropdowns.push({
185
+ id: pi.id,
186
+ trigger_id: btn ? btn.id : '',
187
+ label: lbl,
188
+ type: 'picklist',
189
+ required: isRequired(pi, lbl),
190
+ value: clean(pi.value)
191
+ });
192
+ });
193
+
194
+ // 4. Choices (Radio / Checkbox Groups)
195
+ const choiceGroups = {};
196
+ const radios = Array.from(document.querySelectorAll('input[type="radio"], input[type="checkbox"], [role="radio"]'));
197
+ radios.forEach(r => {
198
+ const rg = r.closest('[role="radiogroup"], .rcmFormField, .form-group, tr, fieldset');
199
+ let q = '';
200
+ if (rg) {
201
+ if (rg.getAttribute('aria-labelledby')) {
202
+ const qNode = document.getElementById(rg.getAttribute('aria-labelledby'));
203
+ if (qNode) q = clean(qNode.innerText);
204
+ }
205
+ if (!q) {
206
+ const qEl = rg.querySelector('label, .rcmFormQuestionLabel, legend, strong');
207
+ if (qEl) q = clean(qEl.innerText);
208
+ }
209
+ }
210
+ if (!q && r.name) q = r.name;
211
+ if (!q) return;
212
+
213
+ let optLabel = clean(r.getAttribute('aria-label') || r.value || (r.parentElement ? r.parentElement.innerText : ''));
214
+ if (!optLabel || optLabel.toLowerCase() === 'options') return;
215
+ if (optLabel.length > 40) optLabel = optLabel.slice(0, 40);
216
+
217
+ if (!choiceGroups[q]) {
218
+ choiceGroups[q] = {
219
+ question: q,
220
+ type: (r.getAttribute('role') === 'radio' || r.type === 'radio') ? 'radio' : 'checkbox',
221
+ required: isRequired(r, q),
222
+ options: [],
223
+ selected: null
224
+ };
225
+ }
226
+
227
+ const isChecked = r.checked || r.getAttribute('aria-checked') === 'true' || r.classList.contains('radioOn');
228
+ if (!choiceGroups[q].options.includes(optLabel)) {
229
+ choiceGroups[q].options.push(optLabel);
230
+ }
231
+ if (isChecked) {
232
+ choiceGroups[q].selected = optLabel;
233
+ }
234
+ });
235
+ result.choices = Object.values(choiceGroups);
236
+
237
+ // 5. Inputs
238
+ const standardInputs = Array.from(document.querySelectorAll('input, textarea')).filter(el => {
239
+ const t = (el.type || 'text').toLowerCase();
240
+ if (['radio', 'checkbox', 'file', 'hidden', 'button', 'submit', 'reset'].includes(t)) return false;
241
+ if (el.id.endsWith(':_input')) return false;
242
+ if (el.getAttribute('role') === 'combobox') return false;
243
+ if (el.offsetParent === null) return false;
244
+ return true;
245
+ });
246
+
247
+ standardInputs.forEach(inp => {
248
+ const lbl = getLabel(inp);
249
+ if (!lbl) return;
250
+ result.inputs.push({
251
+ id: inp.id || inp.name,
252
+ label: lbl,
253
+ type: inp.type || inp.tagName.toLowerCase(),
254
+ required: isRequired(inp, lbl),
255
+ value: inp.value || ''
256
+ });
257
+ });
258
+
259
+ // 6. Action Buttons
260
+ const buttonEls = Array.from(document.querySelectorAll('button, input[type="submit"], input[type="button"], a.btn, [role="button"], span[id$="_submitBtn"], span[id$="_saveBtn"]'));
261
+ const seenButtons = new Set();
262
+ buttonEls.forEach(btn => {
263
+ if (btn.offsetParent === null) return;
264
+ const id = btn.id || '';
265
+ if (id.endsWith(':_selectButton')) return;
266
+ const text = clean(btn.innerText || btn.value || btn.title);
267
+ if (!text || text.length < 2 || text === '' || text === 'Options' || text === 'Menu') return;
268
+
269
+ const lower = text.toLowerCase();
270
+ let action = 'action';
271
+ if (lower.includes('submit') || lower.includes('apply')) action = 'submit';
272
+ else if (lower.includes('sign in') || lower.includes('log in') || lower.includes('login')) action = 'login';
273
+ else if (lower.includes('start') || lower.includes('begin')) action = 'start';
274
+ else if (lower.includes('save') || lower.includes('draft')) action = 'save';
275
+ else if (lower.includes('next') || lower.includes('continue')) action = 'next';
276
+ else if (lower.includes('back') || lower.includes('prev')) action = 'back';
277
+ else if (lower.includes('expand')) action = 'expand_all';
278
+ else if (lower.includes('collapse')) action = 'collapse_all';
279
+ else if (lower.includes('add') || lower.includes('attach')) action = 'add_entry';
280
+ else if (lower.includes('cancel') || lower.includes('close')) action = 'cancel';
281
+ else action = 'button';
282
+
283
+ const key = text + '_' + action;
284
+ if (seenButtons.has(key)) return;
285
+ seenButtons.add(key);
286
+
287
+ result.buttons.push({
288
+ id: id,
289
+ text: text,
290
+ action: action
291
+ });
292
+ });
293
+
294
+ return result;
295
+ }"""
296
+
297
+ PROBE_PICKLIST_OPTIONS_SCRIPT: Final[str] = """(ownsId) => {
298
+ let container = ownsId ? document.getElementById(ownsId) : null;
299
+ if (!container) container = document.querySelector('[role="listbox"]:not([style*="display: none"]), .rcmpaginatedselectlist');
300
+ const scope = container || document;
301
+ const items = Array.from(scope.querySelectorAll('.globalMenuItem, li, [role="option"]'));
302
+ return items.map(i => (i.innerText || '').trim()).filter(t => t.length > 0 && !t.includes('Loading') && !t.includes('Press Up'));
303
+ }"""
304
+
305
+ SET_FIELD_DOM_SCRIPT: Final[str] = """([target, val]) => {
306
+ const lowerTarget = target.toLowerCase().trim();
307
+
308
+ function matchElement(el) {
309
+ if (el.id && el.id.toLowerCase() === lowerTarget) return true;
310
+ if (el.name && el.name.toLowerCase() === lowerTarget) return true;
311
+ if (el.placeholder && el.placeholder.toLowerCase().includes(lowerTarget)) return true;
312
+
313
+ if (el.id) {
314
+ const l = document.querySelector('label[for="' + el.id + '"]');
315
+ if (l && l.innerText.toLowerCase().includes(lowerTarget)) return true;
316
+ }
317
+ const container = el.closest('tr, .rcmFormField, .form-group, .field, div');
318
+ if (container && container.innerText.toLowerCase().includes(lowerTarget)) return true;
319
+ return false;
320
+ }
321
+
322
+ let exactEl = document.getElementById(target);
323
+ if (!exactEl && target.includes(':')) {
324
+ exactEl = document.querySelector('[id="' + target + '"]');
325
+ }
326
+
327
+ // 1. SELECT
328
+ const selects = Array.from(document.querySelectorAll('select'));
329
+ const targetSelect = exactEl && exactEl.tagName === 'SELECT' ? exactEl : selects.find(matchElement);
330
+ if (targetSelect) {
331
+ const strVal = String(val).toLowerCase();
332
+ let matched = false;
333
+ for (let opt of targetSelect.options) {
334
+ if (opt.value.toLowerCase() === strVal || opt.text.toLowerCase().includes(strVal)) {
335
+ targetSelect.value = opt.value;
336
+ targetSelect.dispatchEvent(new Event('change', { bubbles: true }));
337
+ matched = true;
338
+ break;
339
+ }
340
+ }
341
+ return { success: matched, type: 'select', id: targetSelect.id };
342
+ }
343
+
344
+ // 2. Picklist
345
+ const picklistInputs = Array.from(document.querySelectorAll('input')).filter(i =>
346
+ i.id.endsWith(':_input') || i.getAttribute('role') === 'combobox'
347
+ );
348
+ const targetPicklist = (exactEl && picklistInputs.includes(exactEl)) ? exactEl : picklistInputs.find(matchElement);
349
+ if (targetPicklist) {
350
+ const baseId = targetPicklist.id.replace(':_input', '');
351
+ const btn = document.getElementById(baseId + ':_selectButton');
352
+ return { success: false, type: 'picklist', id: targetPicklist.id, trigger_button: btn ? btn.id : null };
353
+ }
354
+
355
+ // 3. Radio / Checkboxes
356
+ const choices = Array.from(document.querySelectorAll('input[type="radio"], input[type="checkbox"]'));
357
+ const matchingChoices = choices.filter(c => matchElement(c) || (c.name && c.name.toLowerCase().includes(lowerTarget)));
358
+ if (matchingChoices.length > 0) {
359
+ const strVal = String(val).toLowerCase();
360
+ let chosen = null;
361
+ for (let c of matchingChoices) {
362
+ const cLabel = (c.value + ' ' + (c.nextSibling ? c.nextSibling.textContent : '') + ' ' + (c.parentElement ? c.parentElement.innerText : '')).toLowerCase();
363
+ if (cLabel.includes(strVal) || c.value.toLowerCase() === strVal) {
364
+ chosen = c;
365
+ break;
366
+ }
367
+ }
368
+ if (!chosen && matchingChoices.length === 1 && (val === true || strVal === 'yes' || strVal === 'true')) {
369
+ chosen = matchingChoices[0];
370
+ }
371
+ if (chosen) {
372
+ chosen.checked = true;
373
+ chosen.dispatchEvent(new Event('change', { bubbles: true }));
374
+ chosen.dispatchEvent(new Event('click', { bubbles: true }));
375
+ return { success: true, type: chosen.type, id: chosen.id };
376
+ }
377
+ }
378
+
379
+ // Custom Radio Groups
380
+ const customGroups = Array.from(document.querySelectorAll('[role="radiogroup"], .radioGroup, .rcmFormQuestionElement, .RCMFormField'));
381
+ const matchingGroup = customGroups.find(g => (g.innerText || '').toLowerCase().includes(lowerTarget));
382
+ if (matchingGroup) {
383
+ const strVal = String(val).toLowerCase();
384
+ const radioOptions = Array.from(matchingGroup.querySelectorAll('.globalRadio, [role="radio"], .radioCheck, .sfRadioInputField'));
385
+ for (let opt of radioOptions) {
386
+ const optText = (opt.innerText || '').trim().toLowerCase();
387
+ if (optText === strVal || optText.startsWith(strVal)) {
388
+ const clickable = opt.querySelector('.radioCheck, [role="radio"], .radioContainer') || opt;
389
+ clickable.click();
390
+ return { success: true, type: 'custom_radio', id: opt.id || '', value: strVal };
391
+ }
392
+ }
393
+ }
394
+
395
+ // 4. Text / Textarea
396
+ const textInputs = Array.from(document.querySelectorAll('input, textarea')).filter(i => {
397
+ const t = (i.type || '').toLowerCase();
398
+ return !['radio', 'checkbox', 'file', 'hidden', 'submit', 'button'].includes(t);
399
+ });
400
+ const targetText = exactEl || textInputs.find(matchElement);
401
+ if (targetText) {
402
+ let toSet = String(val);
403
+ if (targetText.maxLength > 0 && toSet.length > targetText.maxLength) {
404
+ toSet = toSet.substring(0, targetText.maxLength);
405
+ }
406
+ targetText.value = toSet;
407
+ targetText.dispatchEvent(new Event('input', { bubbles: true }));
408
+ targetText.dispatchEvent(new Event('change', { bubbles: true }));
409
+ targetText.dispatchEvent(new Event('blur', { bubbles: true }));
410
+ return { success: true, type: targetText.tagName.toLowerCase(), id: targetText.id, value: toSet };
411
+ }
412
+
413
+ return { success: false, reason: 'No matching element found' };
414
+ }"""
415
+
416
+ PICKLIST_AUTO_SCROLL_SCRIPT: Final[str] = """async ([inpId, target]) => {
417
+ const input = document.getElementById(inpId);
418
+ const owns = input ? input.getAttribute('aria-owns') : null;
419
+ const list = owns ? document.getElementById(owns) : null;
420
+ if (!list) return { success: false, reason: 'no list container' };
421
+
422
+ const targetLower = String(target).trim().toLowerCase();
423
+ const scrollParent = list.closest('.scroll, .scrolling, div[style*="overflow"]') || list.parentElement;
424
+
425
+ for (let scrollStep = 0; scrollStep < 5; scrollStep++) {
426
+ const items = Array.from(list.querySelectorAll('a, li'));
427
+ const match = items.find(el => {
428
+ const title = (el.getAttribute('title') || '').trim().toLowerCase();
429
+ const text = (el.innerText || '').trim().toLowerCase();
430
+ return title === targetLower || text === targetLower ||
431
+ (targetLower.length > 3 && (title.includes(targetLower) || text.includes(targetLower)));
432
+ });
433
+
434
+ if (match) {
435
+ match.click();
436
+ return { success: true, text: match.getAttribute('title') || match.innerText };
437
+ }
438
+
439
+ if (scrollParent) {
440
+ scrollParent.scrollTop = scrollParent.scrollHeight;
441
+ scrollParent.dispatchEvent(new Event('scroll', { bubbles: true }));
442
+ await new Promise(r => setTimeout(r, 450));
443
+ } else {
444
+ break;
445
+ }
446
+ }
447
+
448
+ return { success: false, reason: 'not found' };
449
+ }"""
450
+
451
+ VERIFY_FIELD_DOM_SCRIPT: Final[str] = """([target, expected]) => {
452
+ const lower = target.toLowerCase().trim();
453
+ function matchEl(el) {
454
+ if (el.id && el.id.toLowerCase() === lower) return true;
455
+ if (el.name && el.name.toLowerCase() === lower) return true;
456
+ if (el.placeholder && el.placeholder.toLowerCase().includes(lower)) return true;
457
+ if (el.id) {
458
+ const lbl = document.querySelector('label[for="' + el.id + '"]');
459
+ if (lbl && lbl.innerText.toLowerCase().includes(lower)) return true;
460
+ }
461
+ const c = el.closest('tr, .rcmFormField, .form-group, div');
462
+ if (c && c.innerText.toLowerCase().includes(lower)) return true;
463
+ return false;
464
+ }
465
+
466
+ const junk = new Set(['login/ view profile', 'no selection', '']);
467
+ const pick = Array.from(document.querySelectorAll('input[role="combobox"], input[id$=":_input"]')).find(matchEl);
468
+ if (pick) {
469
+ const v = (pick.value || '').trim().toLowerCase();
470
+ if (v && !junk.has(v)) return v;
471
+ return null;
472
+ }
473
+
474
+ const inp = Array.from(document.querySelectorAll('input, textarea'))
475
+ .filter(i => !['radio','checkbox','file','hidden','submit','button'].includes((i.type||'').toLowerCase()))
476
+ .find(matchEl);
477
+ if (inp) return (inp.value || '').trim() || null;
478
+
479
+ const groups = Array.from(document.querySelectorAll('[role="radiogroup"], .radioGroup'));
480
+ const grp = groups.find(g => (g.innerText||'').toLowerCase().includes(lower));
481
+ if (grp) {
482
+ const checked = grp.querySelector('[aria-checked="true"], input[type="radio"]:checked');
483
+ if (checked) {
484
+ return checked.getAttribute('aria-label') || checked.value || 'checked';
485
+ }
486
+ const selectedRadio = Array.from(grp.querySelectorAll('.globalRadio')).find(r => {
487
+ const span = r.querySelector('.radioCheck');
488
+ return span && (span.classList.contains('checked') || span.classList.contains('selected') ||
489
+ r.getAttribute('aria-checked') === 'true');
490
+ });
491
+ if (selectedRadio) return (selectedRadio.innerText||'').trim().toLowerCase() || 'checked';
492
+ }
493
+
494
+ return null;
495
+ }"""
496
+
497
+ CLICK_BUTTON_DOM_SCRIPT: Final[str] = """(t) => {
498
+ const lower = t.toLowerCase().trim();
499
+ const direct = document.getElementById(t);
500
+ if (direct) { direct.click(); return true; }
501
+
502
+ const btns = Array.from(document.querySelectorAll('button, input[type="button"], input[type="submit"], a, [role="button"], span[id$="_submitBtn"], span[id$="_saveBtn"], .modal-footer *, .modal-close, [class*="close"], [aria-label*="close" i], p, span'));
503
+ let chosen = btns.find(b => {
504
+ const text = (b.innerText || b.value || b.title || '').trim().toLowerCase();
505
+ return text === lower || (b.id && b.id.toLowerCase() === lower);
506
+ });
507
+ if (!chosen) {
508
+ chosen = btns.find(b => {
509
+ const text = (b.innerText || b.value || b.title || b.id || '').trim().toLowerCase();
510
+ return text.includes(lower);
511
+ });
512
+ }
513
+ if (chosen) {
514
+ chosen.click();
515
+ return true;
516
+ }
517
+ return false;
518
+ }"""
@@ -0,0 +1,108 @@
1
+ """Browser context options envelope builder for Playwright."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Optional
6
+ from config.settings import Settings, get_settings, load_device_profile
7
+ from core.exceptions import ConfigurationError
8
+ from core.models import DeviceProfile
9
+
10
+
11
+ class BrowserContextEnvelopeBuilder:
12
+ """Constructs the browser context options envelope for Playwright.
13
+
14
+ Reads strictly from a DeviceProfile and optional Settings model.
15
+ Never injects hardcoded string literals for device details, user agent,
16
+ or viewport dimensions. Missing required attributes trigger a fallback to
17
+ the verified configuration file, or raise a descriptive ConfigurationError.
18
+ """
19
+
20
+ REQUIRED_ATTRIBUTES = ("user_agent", "viewport_width", "viewport_height")
21
+
22
+ def __init__(
23
+ self,
24
+ profile: Optional[DeviceProfile] = None,
25
+ settings: Optional[Settings] = None,
26
+ profile_path: Optional[str] = None,
27
+ ):
28
+ self.profile = profile
29
+ self.settings = settings
30
+ self.profile_path = profile_path
31
+
32
+ def _resolve_profile(self) -> DeviceProfile:
33
+ """Resolve profile strictly from passed model or dedicated config file."""
34
+ if self.profile is not None:
35
+ missing = [
36
+ attr
37
+ for attr in self.REQUIRED_ATTRIBUTES
38
+ if not getattr(self.profile, attr, None)
39
+ ]
40
+ if missing:
41
+ try:
42
+ verified_profile = load_device_profile(self.profile_path, reload=True)
43
+ for attr in missing:
44
+ verified_val = getattr(verified_profile, attr, None)
45
+ if verified_val:
46
+ setattr(self.profile, attr, verified_val)
47
+ except Exception as exc:
48
+ raise ConfigurationError(
49
+ f"DeviceProfile is missing required attribute(s) {missing!r} and verified "
50
+ f"fallback configuration could not be loaded: {exc}"
51
+ ) from exc
52
+
53
+ still_missing = [
54
+ attr
55
+ for attr in self.REQUIRED_ATTRIBUTES
56
+ if not getattr(self.profile, attr, None)
57
+ ]
58
+ if still_missing:
59
+ raise ConfigurationError(
60
+ f"Required attribute(s) {still_missing!r} missing from DeviceProfile "
61
+ f"and could not be found in verified configuration."
62
+ )
63
+ return self.profile
64
+
65
+ return load_device_profile(self.profile_path)
66
+
67
+ def _resolve_settings(self) -> Settings:
68
+ """Resolve settings from passed model or singleton."""
69
+ if self.settings is not None:
70
+ return self.settings
71
+ return get_settings()
72
+
73
+ def build_context_envelope(self) -> Dict[str, Any]:
74
+ """Builds and returns the Playwright browser.new_context() envelope dictionary."""
75
+ profile = self._resolve_profile()
76
+ settings = self._resolve_settings()
77
+
78
+ envelope: Dict[str, Any] = {
79
+ "viewport": profile.viewport_dict(),
80
+ "user_agent": profile.user_agent,
81
+ "device_scale_factor": profile.device_scale_factor,
82
+ "is_mobile": profile.is_mobile,
83
+ "has_touch": profile.has_touch,
84
+ "locale": profile.locale,
85
+ "timezone_id": profile.timezone_id,
86
+ }
87
+
88
+ if profile.permissions:
89
+ envelope["permissions"] = list(profile.permissions)
90
+
91
+ geo = profile.geolocation_dict()
92
+ if geo:
93
+ envelope["geolocation"] = geo
94
+ if "geolocation" not in envelope.get("permissions", []):
95
+ envelope.setdefault("permissions", []).append("geolocation")
96
+ elif settings.geolocation_latitude is not None and settings.geolocation_longitude is not None:
97
+ envelope["geolocation"] = {
98
+ "latitude": settings.geolocation_latitude,
99
+ "longitude": settings.geolocation_longitude,
100
+ "accuracy": settings.geolocation_accuracy or 10.0,
101
+ }
102
+ if "geolocation" not in envelope.get("permissions", []):
103
+ envelope.setdefault("permissions", []).append("geolocation")
104
+
105
+ if settings.proxy:
106
+ envelope["proxy"] = settings.proxy.to_playwright_dict()
107
+
108
+ return envelope