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
adapters/dom_scripts.py
ADDED
|
@@ -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
|