cdpbrowser 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- CdpBrowser.py +19 -0
- cdpbrowser/__init__.py +68 -0
- cdpbrowser/bridge.js +909 -0
- cdpbrowser/cdp/__init__.py +32 -0
- cdpbrowser/cdp/chrome.py +495 -0
- cdpbrowser/cdp/errors.py +47 -0
- cdpbrowser/cdp/transport.py +384 -0
- cdpbrowser/keys.py +128 -0
- cdpbrowser/library.py +1440 -0
- cdpbrowser/listener.py +112 -0
- cdpbrowser/locators.py +183 -0
- cdpbrowser/page.py +1880 -0
- cdpbrowser/polling.py +133 -0
- cdpbrowser/promises.py +345 -0
- cdpbrowser/recorder.js +258 -0
- cdpbrowser/recorder.py +208 -0
- cdpbrowser/visual.py +286 -0
- cdpbrowser-0.1.0.dist-info/METADATA +436 -0
- cdpbrowser-0.1.0.dist-info/RECORD +21 -0
- cdpbrowser-0.1.0.dist-info/WHEEL +5 -0
- cdpbrowser-0.1.0.dist-info/top_level.txt +2 -0
cdpbrowser/bridge.js
ADDED
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* cdpbrowser page bridge.
|
|
3
|
+
*
|
|
4
|
+
* Injected automatically into every new document via
|
|
5
|
+
* Page.addScriptToEvaluateOnNewDocument; global pollution is limited to the
|
|
6
|
+
* single window.__cdpb property. Every function runs synchronously and
|
|
7
|
+
* returns only JSON-serializable values, so Runtime.evaluate
|
|
8
|
+
* (returnByValue=true) results come back into Python as-is.
|
|
9
|
+
*
|
|
10
|
+
* The locator argument matches the shape of locators.py's to_bridge_arg()
|
|
11
|
+
* output, {"s": "css"|"id"|"t"|"x", "v": string}:
|
|
12
|
+
* css — document.querySelector
|
|
13
|
+
* id — exact match on the data-testid attribute (compared by iteration
|
|
14
|
+
* to avoid value-escaping issues)
|
|
15
|
+
* t — text matching: on whitespace-normalized textContent, exact match
|
|
16
|
+
* first, then a case-insensitive substring fallback. With nesting
|
|
17
|
+
* (button>span), prefers the element with the shortest text (the
|
|
18
|
+
* leaf-most one).
|
|
19
|
+
* x — XPath via document.evaluate FIRST_ORDERED_NODE
|
|
20
|
+
* r — role matching: explicit role attribute first, then the implicit
|
|
21
|
+
* role map, case-insensitive exact match, first in document order.
|
|
22
|
+
* The value splits into role/name at the first space, as in
|
|
23
|
+
* "button" or "button Save" (name is a case-insensitive exact match
|
|
24
|
+
* against the accessible name).
|
|
25
|
+
* l — label matching: case-insensitive exact match against the
|
|
26
|
+
* accessible name (simplified accname), first in document order.
|
|
27
|
+
*
|
|
28
|
+
* The accessible name is a **simplified accname** — aria-label →
|
|
29
|
+
* aria-labelledby → label[for] → wrapping label → name-from-content
|
|
30
|
+
* (button/link only) → placeholder → img alt → title, in that order.
|
|
31
|
+
* Boundaries: aria-describedby, recursion exclusion for purely decorative
|
|
32
|
+
* elements, and name-from-content extensions (headings etc.) are not
|
|
33
|
+
* implemented (the full two-pass accname algorithm is out of scope).
|
|
34
|
+
*
|
|
35
|
+
* Atomic primitives (click/setValue) run the find→visible→enabled check in
|
|
36
|
+
* a single step and return {"ok": true} or
|
|
37
|
+
* {"ok": false, "reason": ...[, "describe"]}. reason is "not-found" |
|
|
38
|
+
* "not-visible" | "not-enabled"; describe is failure diagnostics attached
|
|
39
|
+
* only when the element exists.
|
|
40
|
+
*/
|
|
41
|
+
(function () {
|
|
42
|
+
"use strict";
|
|
43
|
+
|
|
44
|
+
var TESTID_ATTR = "data-testid";
|
|
45
|
+
|
|
46
|
+
// Non-display elements excluded from text matching.
|
|
47
|
+
var SKIP_TAGS = {
|
|
48
|
+
SCRIPT: true,
|
|
49
|
+
STYLE: true,
|
|
50
|
+
HEAD: true,
|
|
51
|
+
TITLE: true,
|
|
52
|
+
META: true,
|
|
53
|
+
LINK: true,
|
|
54
|
+
NOSCRIPT: true,
|
|
55
|
+
TEMPLATE: true,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function normText(value) {
|
|
59
|
+
return String(value === null || value === undefined ? "" : value)
|
|
60
|
+
.replace(/\s+/g, " ")
|
|
61
|
+
.trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Direct text: collect only the element's own text nodes and normalize (for failure diagnostics).
|
|
65
|
+
function directText(el) {
|
|
66
|
+
var text = "";
|
|
67
|
+
for (var node = el.firstChild; node; node = node.nextSibling) {
|
|
68
|
+
if (node.nodeType === 3) {
|
|
69
|
+
text += node.nodeValue;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return normText(text);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function allElements() {
|
|
76
|
+
var root = document.body || document.documentElement;
|
|
77
|
+
if (!root || !root.querySelectorAll) {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
return root.querySelectorAll("*");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// deep: modifier — pierce open shadow DOM (opt-in).
|
|
84
|
+
// Scans the document root plus every open shadowRoot found recursively.
|
|
85
|
+
// closed shadowRoots are unreachable in the first place (boundary).
|
|
86
|
+
function deepRoots() {
|
|
87
|
+
var roots = [document];
|
|
88
|
+
var queue = [document];
|
|
89
|
+
while (queue.length) {
|
|
90
|
+
var current = queue.shift();
|
|
91
|
+
var elements;
|
|
92
|
+
try {
|
|
93
|
+
elements = current.querySelectorAll("*");
|
|
94
|
+
} catch (err) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
for (var i = 0; i < elements.length; i++) {
|
|
98
|
+
var sr = elements[i].shadowRoot;
|
|
99
|
+
if (sr) {
|
|
100
|
+
roots.push(sr);
|
|
101
|
+
queue.push(sr);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return roots;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function deepElements() {
|
|
109
|
+
var roots = deepRoots();
|
|
110
|
+
var pool = [];
|
|
111
|
+
for (var i = 0; i < roots.length; i++) {
|
|
112
|
+
var body = roots[i].body || roots[i];
|
|
113
|
+
var list;
|
|
114
|
+
try {
|
|
115
|
+
list = body.querySelectorAll("*");
|
|
116
|
+
} catch (err) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
for (var j = 0; j < list.length; j++) {
|
|
120
|
+
pool.push(list[j]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return pool;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function findByTestId(value) {
|
|
127
|
+
var candidates = document.querySelectorAll("[" + TESTID_ATTR + "]");
|
|
128
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
129
|
+
if (candidates[i].getAttribute(TESTID_ATTR) === value) {
|
|
130
|
+
return candidates[i];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function findByXPath(expression) {
|
|
137
|
+
var result = document.evaluate(
|
|
138
|
+
expression,
|
|
139
|
+
document,
|
|
140
|
+
null,
|
|
141
|
+
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
|
142
|
+
null
|
|
143
|
+
);
|
|
144
|
+
var node = result.singleNodeValue;
|
|
145
|
+
return node && node.nodeType === 1 ? node : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function findByText(value, pool) {
|
|
149
|
+
var wanted = normText(value);
|
|
150
|
+
if (!wanted) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
var lower = wanted.toLowerCase();
|
|
154
|
+
var elements = pool || allElements();
|
|
155
|
+
var bestExact = null;
|
|
156
|
+
var bestExactLen = 0;
|
|
157
|
+
var bestPartial = null;
|
|
158
|
+
var bestPartialLen = 0;
|
|
159
|
+
for (var i = 0; i < elements.length; i++) {
|
|
160
|
+
var el = elements[i];
|
|
161
|
+
if (SKIP_TAGS[el.tagName]) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
var text = normText(el.textContent);
|
|
165
|
+
if (!text) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (text === wanted) {
|
|
169
|
+
// Exact match: prefer the element with the shortest text (closest to
|
|
170
|
+
// the leaf), ties broken by document order first. Stable when parent
|
|
171
|
+
// and child share the same text (button>span).
|
|
172
|
+
if (bestExact === null || text.length < bestExactLen) {
|
|
173
|
+
bestExact = el;
|
|
174
|
+
bestExactLen = text.length;
|
|
175
|
+
}
|
|
176
|
+
} else if (text.toLowerCase().indexOf(lower) !== -1) {
|
|
177
|
+
if (bestPartial === null || text.length < bestPartialLen) {
|
|
178
|
+
bestPartial = el;
|
|
179
|
+
bestPartialLen = text.length;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return bestExact !== null ? bestExact : bestPartial;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ----------------------------------------------------------------------
|
|
187
|
+
// ARIA — implicit role + simplified accessible name (see boundary note above)
|
|
188
|
+
// ----------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
function implicitRole(el) {
|
|
191
|
+
switch (el.tagName) {
|
|
192
|
+
case "A":
|
|
193
|
+
return el.hasAttribute("href") ? "link" : null;
|
|
194
|
+
case "BUTTON":
|
|
195
|
+
return "button";
|
|
196
|
+
case "INPUT": {
|
|
197
|
+
var type = (el.getAttribute("type") || "text").toLowerCase();
|
|
198
|
+
if (type === "checkbox") return "checkbox";
|
|
199
|
+
if (type === "radio") return "radio";
|
|
200
|
+
if (type === "submit" || type === "button") return "button";
|
|
201
|
+
if (type === "hidden") return null;
|
|
202
|
+
return "textbox";
|
|
203
|
+
}
|
|
204
|
+
case "SELECT":
|
|
205
|
+
return "combobox";
|
|
206
|
+
case "TEXTAREA":
|
|
207
|
+
return "textbox";
|
|
208
|
+
case "H1":
|
|
209
|
+
case "H2":
|
|
210
|
+
case "H3":
|
|
211
|
+
case "H4":
|
|
212
|
+
case "H5":
|
|
213
|
+
case "H6":
|
|
214
|
+
return "heading";
|
|
215
|
+
case "UL":
|
|
216
|
+
return "list";
|
|
217
|
+
case "LI":
|
|
218
|
+
return "listitem";
|
|
219
|
+
case "NAV":
|
|
220
|
+
return "navigation";
|
|
221
|
+
case "MAIN":
|
|
222
|
+
return "main";
|
|
223
|
+
case "TABLE":
|
|
224
|
+
return "table";
|
|
225
|
+
case "IMG":
|
|
226
|
+
return "img";
|
|
227
|
+
case "FORM":
|
|
228
|
+
return "form";
|
|
229
|
+
default:
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Effective role list: explicit role attribute tokens first, then the implicit role.
|
|
235
|
+
function effectiveRoles(el) {
|
|
236
|
+
var roles = [];
|
|
237
|
+
var explicit = el.getAttribute("role");
|
|
238
|
+
if (explicit) {
|
|
239
|
+
var tokens = normText(explicit).toLowerCase().split(" ");
|
|
240
|
+
for (var i = 0; i < tokens.length; i++) {
|
|
241
|
+
if (tokens[i] && roles.indexOf(tokens[i]) === -1) {
|
|
242
|
+
roles.push(tokens[i]);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
var implicit = implicitRole(el);
|
|
247
|
+
if (implicit && roles.indexOf(implicit) === -1) {
|
|
248
|
+
roles.push(implicit);
|
|
249
|
+
}
|
|
250
|
+
return roles;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Tags where name-from-content applies (simplified: button/link only).
|
|
254
|
+
function hasNameFromContent(el) {
|
|
255
|
+
var tag = el.tagName;
|
|
256
|
+
return tag === "BUTTON" || (tag === "A" && el.hasAttribute("href"));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function attributeValueSelector(attr, value) {
|
|
260
|
+
// Escape the attribute value string — CSS.escape is for identifiers, unfit for quoted values.
|
|
261
|
+
var escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
262
|
+
return "[" + attr + '="' + escaped + '"]';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function accessibleName(el) {
|
|
266
|
+
var label = normText(el.getAttribute("aria-label"));
|
|
267
|
+
if (label) {
|
|
268
|
+
return label;
|
|
269
|
+
}
|
|
270
|
+
var labelledby = normText(el.getAttribute("aria-labelledby"));
|
|
271
|
+
if (labelledby) {
|
|
272
|
+
var parts = [];
|
|
273
|
+
var ids = labelledby.split(" ");
|
|
274
|
+
for (var i = 0; i < ids.length; i++) {
|
|
275
|
+
var target = document.getElementById(ids[i]);
|
|
276
|
+
if (target) {
|
|
277
|
+
parts.push(target.textContent || "");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
var joined = normText(parts.join(" "));
|
|
281
|
+
if (joined) {
|
|
282
|
+
return joined;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (el.id) {
|
|
286
|
+
var forLabel = document.querySelector(
|
|
287
|
+
"label" + attributeValueSelector("for", el.id)
|
|
288
|
+
);
|
|
289
|
+
var forText = forLabel ? normText(forLabel.textContent) : "";
|
|
290
|
+
if (forText) {
|
|
291
|
+
return forText;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// Wrapping label. Exclude the case where the element itself is a label —
|
|
295
|
+
// closest("label") returns the element itself, so without the exclusion
|
|
296
|
+
// a <label> element would shadow its associated input and findByLabel
|
|
297
|
+
// would return the label instead of the form control.
|
|
298
|
+
var wrapping =
|
|
299
|
+
el.closest && el.tagName !== "LABEL" ? el.closest("label") : null;
|
|
300
|
+
var wrappingText = wrapping ? normText(wrapping.textContent) : "";
|
|
301
|
+
if (wrappingText) {
|
|
302
|
+
return wrappingText;
|
|
303
|
+
}
|
|
304
|
+
if (hasNameFromContent(el)) {
|
|
305
|
+
var content = normText(el.textContent);
|
|
306
|
+
if (content) {
|
|
307
|
+
return content;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
var placeholder = normText(el.getAttribute("placeholder"));
|
|
311
|
+
if (placeholder) {
|
|
312
|
+
return placeholder;
|
|
313
|
+
}
|
|
314
|
+
if (el.tagName === "IMG") {
|
|
315
|
+
var alt = normText(el.getAttribute("alt"));
|
|
316
|
+
if (alt) {
|
|
317
|
+
return alt;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return normText(el.getAttribute("title"));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function matchesName(el, wantedName) {
|
|
324
|
+
return accessibleName(el).toLowerCase() === wantedName.toLowerCase();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function findByRole(value, pool) {
|
|
328
|
+
var elements = pool || allElements();
|
|
329
|
+
var spec = normText(value);
|
|
330
|
+
if (!spec) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
var sep = spec.indexOf(" ");
|
|
334
|
+
var wantedRole = (sep === -1 ? spec : spec.slice(0, sep)).toLowerCase();
|
|
335
|
+
var wantedName = sep === -1 ? null : spec.slice(sep + 1);
|
|
336
|
+
for (var i = 0; i < elements.length; i++) {
|
|
337
|
+
var el = elements[i];
|
|
338
|
+
if (SKIP_TAGS[el.tagName]) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (effectiveRoles(el).indexOf(wantedRole) === -1) {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (wantedName !== null && !matchesName(el, wantedName)) {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
return el;
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function findByLabel(value, pool) {
|
|
353
|
+
var wanted = normText(value);
|
|
354
|
+
if (!wanted) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
var elements = pool || allElements();
|
|
358
|
+
for (var i = 0; i < elements.length; i++) {
|
|
359
|
+
var el = elements[i];
|
|
360
|
+
if (SKIP_TAGS[el.tagName]) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
var name = accessibleName(el);
|
|
364
|
+
if (name && name.toLowerCase() === wanted.toLowerCase()) {
|
|
365
|
+
return el;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Locator resolution. Unknown strategies/empty values are treated as "not
|
|
372
|
+
// found" (null). Invalid CSS/XPath syntax is intentionally rethrown as-is
|
|
373
|
+
// — the browser's SyntaxError must surface as exceptionDetails so it stays
|
|
374
|
+
// diagnosable from Python.
|
|
375
|
+
// ext: strategy — looks up framework components from the registry via Ext
|
|
376
|
+
// JS ComponentQuery and returns the rendered DOM (el.dom). Bypasses DOM
|
|
377
|
+
// selectors, anchoring on the app code's stable component structure
|
|
378
|
+
// (itemId/reference/text configs). Throws an explicit error when Ext is
|
|
379
|
+
// absent rather than failing silently with not-found.
|
|
380
|
+
function extComponents(selector) {
|
|
381
|
+
if (
|
|
382
|
+
typeof window.Ext !== "object" ||
|
|
383
|
+
window.Ext === null ||
|
|
384
|
+
!window.Ext.ComponentQuery ||
|
|
385
|
+
typeof window.Ext.ComponentQuery.query !== "function"
|
|
386
|
+
) {
|
|
387
|
+
throw new Error(
|
|
388
|
+
"ext: selector requires Ext JS — window.Ext.ComponentQuery not found"
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
var matches = window.Ext.ComponentQuery.query(String(selector)) || [];
|
|
392
|
+
var rendered = [];
|
|
393
|
+
for (var i = 0; i < matches.length; i++) {
|
|
394
|
+
var comp = matches[i];
|
|
395
|
+
var dom =
|
|
396
|
+
(comp.el && comp.el.dom) ||
|
|
397
|
+
(comp.element && comp.element.dom) ||
|
|
398
|
+
null; // classic: el.dom / modern: element.dom
|
|
399
|
+
if (dom && dom.nodeType === 1) {
|
|
400
|
+
rendered.push(dom);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return rendered;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function findByExt(value) {
|
|
407
|
+
var all = extComponents(value);
|
|
408
|
+
return all.length ? all[0] : null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function resolve(locator) {
|
|
412
|
+
if (!locator || typeof locator !== "object") {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
if (locator.p) {
|
|
416
|
+
return resolveDeep(locator);
|
|
417
|
+
}
|
|
418
|
+
var value = locator.v;
|
|
419
|
+
if (typeof value !== "string") {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
switch (locator.s) {
|
|
423
|
+
case "css":
|
|
424
|
+
return document.querySelector(value);
|
|
425
|
+
case "id":
|
|
426
|
+
return findByTestId(value);
|
|
427
|
+
case "t":
|
|
428
|
+
return findByText(value);
|
|
429
|
+
case "x":
|
|
430
|
+
return findByXPath(value);
|
|
431
|
+
case "r":
|
|
432
|
+
return findByRole(value);
|
|
433
|
+
case "l":
|
|
434
|
+
return findByLabel(value);
|
|
435
|
+
case "e":
|
|
436
|
+
return findByExt(value);
|
|
437
|
+
default:
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function isVisible(el) {
|
|
443
|
+
if (!el || typeof el.getBoundingClientRect !== "function") {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
var style = window.getComputedStyle(el);
|
|
447
|
+
if (
|
|
448
|
+
style.display === "none" ||
|
|
449
|
+
style.visibility === "hidden" ||
|
|
450
|
+
style.visibility === "collapse"
|
|
451
|
+
) {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
var box = el.getBoundingClientRect();
|
|
455
|
+
return box.width > 0 && box.height > 0;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// :disabled covers both form controls and controls under a disabled
|
|
459
|
+
// fieldset. Every other element never matches :disabled, hence enabled=true.
|
|
460
|
+
function isEnabled(el) {
|
|
461
|
+
return !el.matches(":disabled");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function state(el) {
|
|
465
|
+
return { visible: isVisible(el), enabled: isEnabled(el) };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function status(locator) {
|
|
469
|
+
var el = resolve(locator);
|
|
470
|
+
if (!el) {
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
var s = state(el);
|
|
474
|
+
return { exists: true, visible: s.visible, enabled: s.enabled };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function describe(el) {
|
|
478
|
+
return {
|
|
479
|
+
tag: el.tagName.toLowerCase(),
|
|
480
|
+
text: directText(el),
|
|
481
|
+
testid: el.getAttribute(TESTID_ATTR),
|
|
482
|
+
id: el.id || null,
|
|
483
|
+
classes: Array.prototype.slice.call(el.classList || []),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function failure(el, reason) {
|
|
488
|
+
var result = { ok: false, reason: reason };
|
|
489
|
+
if (el) {
|
|
490
|
+
result.describe = describe(el);
|
|
491
|
+
}
|
|
492
|
+
return result;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Atomic precondition check: find→visible→enabled. On failure, returns a result carrying the reason.
|
|
496
|
+
function checkActionable(locator) {
|
|
497
|
+
var el = resolve(locator);
|
|
498
|
+
if (!el) {
|
|
499
|
+
return { el: null, error: failure(null, "not-found") };
|
|
500
|
+
}
|
|
501
|
+
var s = state(el);
|
|
502
|
+
if (!s.visible) {
|
|
503
|
+
return { el: el, error: failure(el, "not-visible") };
|
|
504
|
+
}
|
|
505
|
+
if (!s.enabled) {
|
|
506
|
+
return { el: el, error: failure(el, "not-enabled") };
|
|
507
|
+
}
|
|
508
|
+
return { el: el, error: null };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function click(locator) {
|
|
512
|
+
var check = checkActionable(locator);
|
|
513
|
+
if (check.error) {
|
|
514
|
+
return check.error;
|
|
515
|
+
}
|
|
516
|
+
check.el.click();
|
|
517
|
+
return { ok: true };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// React-compatible value setting: the prototype native setter must be used
|
|
521
|
+
// so React's value tracking hook (trigger) fires. input/change events are
|
|
522
|
+
// bubbled afterwards.
|
|
523
|
+
function setElementValue(el, value) {
|
|
524
|
+
var stringValue = value === null || value === undefined ? "" : String(value);
|
|
525
|
+
var proto = null;
|
|
526
|
+
if (typeof HTMLInputElement !== "undefined" && el instanceof HTMLInputElement) {
|
|
527
|
+
proto = HTMLInputElement.prototype;
|
|
528
|
+
} else if (
|
|
529
|
+
typeof HTMLTextAreaElement !== "undefined" &&
|
|
530
|
+
el instanceof HTMLTextAreaElement
|
|
531
|
+
) {
|
|
532
|
+
proto = HTMLTextAreaElement.prototype;
|
|
533
|
+
}
|
|
534
|
+
var applied = false;
|
|
535
|
+
if (proto) {
|
|
536
|
+
var descriptor = Object.getOwnPropertyDescriptor(proto, "value");
|
|
537
|
+
if (descriptor && typeof descriptor.set === "function") {
|
|
538
|
+
descriptor.set.call(el, stringValue);
|
|
539
|
+
applied = true;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (!applied) {
|
|
543
|
+
try {
|
|
544
|
+
el.value = stringValue;
|
|
545
|
+
} catch (err) {
|
|
546
|
+
// Element without a value property — still proceed through event dispatch.
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
550
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function setValue(locator, value) {
|
|
554
|
+
var check = checkActionable(locator);
|
|
555
|
+
if (check.error) {
|
|
556
|
+
return check.error;
|
|
557
|
+
}
|
|
558
|
+
setElementValue(check.el, value);
|
|
559
|
+
return { ok: true };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function getText(locator) {
|
|
563
|
+
var el = resolve(locator);
|
|
564
|
+
if (!el) {
|
|
565
|
+
return { ok: false, reason: "not-found" };
|
|
566
|
+
}
|
|
567
|
+
var text = el.textContent;
|
|
568
|
+
return { ok: true, text: text === null || text === undefined ? "" : String(text) };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function getAttr(locator, name) {
|
|
572
|
+
var el = resolve(locator);
|
|
573
|
+
if (!el) {
|
|
574
|
+
return { ok: false, reason: "not-found" };
|
|
575
|
+
}
|
|
576
|
+
var value = el.getAttribute(String(name));
|
|
577
|
+
return { ok: true, value: value === null ? null : String(value) };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Multi-match across all strategies. For count snapshots only — unlike
|
|
581
|
+
// findOne's tiebreak (shortest text first, etc.), returns the full
|
|
582
|
+
// document-order list as-is.
|
|
583
|
+
// A ShadowRoot is ineligible as a context node for document.evaluate
|
|
584
|
+
// (#document-fragment). Inside a shadow root, the first child element is
|
|
585
|
+
// used as the context and "//x" expressions are rewritten to the relative
|
|
586
|
+
// ".//x". Absolute paths starting with "/" are only meaningful from the
|
|
587
|
+
// document root, so they are skipped inside shadows.
|
|
588
|
+
function evaluateXPathInRoot(expression, root, resultType) {
|
|
589
|
+
var context = root;
|
|
590
|
+
var expr = expression;
|
|
591
|
+
if (root !== document) {
|
|
592
|
+
context = root.firstElementChild;
|
|
593
|
+
if (!context) {
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
if (expression.indexOf("//") === 0) {
|
|
597
|
+
expr = "." + expression;
|
|
598
|
+
} else if (expression.charAt(0) === "/") {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
try {
|
|
603
|
+
return document.evaluate(expr, context, null, resultType, null);
|
|
604
|
+
} catch (err) {
|
|
605
|
+
throw err; // syntax errors propagate as-is (same contract as shallow)
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// deep: modifier lookup — sweeps the document plus every open shadowRoot
|
|
610
|
+
// in document order. closed roots are unreachable (boundary). CSS/XPath
|
|
611
|
+
// syntax errors are rethrown as-is, same as shallow, to stay diagnosable.
|
|
612
|
+
function resolveDeep(locator) {
|
|
613
|
+
var value = locator.v;
|
|
614
|
+
if (typeof value !== "string") {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
switch (locator.s) {
|
|
618
|
+
case "css":
|
|
619
|
+
var roots = deepRoots();
|
|
620
|
+
for (var i = 0; i < roots.length; i++) {
|
|
621
|
+
var hit = roots[i].querySelector(value);
|
|
622
|
+
if (hit) {
|
|
623
|
+
return hit;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return null;
|
|
627
|
+
case "id":
|
|
628
|
+
var poolId = deepElements();
|
|
629
|
+
for (var a = 0; a < poolId.length; a++) {
|
|
630
|
+
if (poolId[a].getAttribute(TESTID_ATTR) === value) {
|
|
631
|
+
return poolId[a];
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return null;
|
|
635
|
+
case "t":
|
|
636
|
+
return findByText(value, deepElements());
|
|
637
|
+
case "x":
|
|
638
|
+
var rootsX = deepRoots();
|
|
639
|
+
for (var b = 0; b < rootsX.length; b++) {
|
|
640
|
+
var result = evaluateXPathInRoot(
|
|
641
|
+
value,
|
|
642
|
+
rootsX[b],
|
|
643
|
+
XPathResult.FIRST_ORDERED_NODE_TYPE
|
|
644
|
+
);
|
|
645
|
+
var node = result && result.singleNodeValue;
|
|
646
|
+
if (node && node.nodeType === 1) {
|
|
647
|
+
return node;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return null;
|
|
651
|
+
case "r":
|
|
652
|
+
return findByRole(value, deepElements());
|
|
653
|
+
case "l":
|
|
654
|
+
return findByLabel(value, deepElements());
|
|
655
|
+
case "e":
|
|
656
|
+
return findByExt(value);
|
|
657
|
+
default:
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function resolveAllDeep(locator) {
|
|
663
|
+
var value = locator.v;
|
|
664
|
+
var out = [];
|
|
665
|
+
var roots = deepRoots();
|
|
666
|
+
switch (locator.s) {
|
|
667
|
+
case "css":
|
|
668
|
+
for (var i = 0; i < roots.length; i++) {
|
|
669
|
+
out = out.concat(
|
|
670
|
+
Array.prototype.slice.call(roots[i].querySelectorAll(value))
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
return out;
|
|
674
|
+
case "x":
|
|
675
|
+
for (var j = 0; j < roots.length; j++) {
|
|
676
|
+
var snap = evaluateXPathInRoot(
|
|
677
|
+
value,
|
|
678
|
+
roots[j],
|
|
679
|
+
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE
|
|
680
|
+
);
|
|
681
|
+
if (!snap) {
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
for (var k = 0; k < snap.snapshotLength; k++) {
|
|
685
|
+
var node = snap.snapshotItem(k);
|
|
686
|
+
if (node && node.nodeType === 1) {
|
|
687
|
+
out.push(node);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return out;
|
|
692
|
+
default:
|
|
693
|
+
// id/t/r/l — flatten the deep pool in document order, apply the same predicate.
|
|
694
|
+
return resolveAll({
|
|
695
|
+
s: locator.s,
|
|
696
|
+
v: value,
|
|
697
|
+
p: 0,
|
|
698
|
+
__deepPool: deepElements(),
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function resolveAll(locator) {
|
|
704
|
+
if (!locator || typeof locator !== "object") {
|
|
705
|
+
return [];
|
|
706
|
+
}
|
|
707
|
+
if (locator.p) {
|
|
708
|
+
return resolveAllDeep(locator);
|
|
709
|
+
}
|
|
710
|
+
var value = locator.v;
|
|
711
|
+
if (typeof value !== "string") {
|
|
712
|
+
return [];
|
|
713
|
+
}
|
|
714
|
+
var out = [];
|
|
715
|
+
switch (locator.s) {
|
|
716
|
+
case "css":
|
|
717
|
+
return Array.prototype.slice.call(document.querySelectorAll(value));
|
|
718
|
+
case "id":
|
|
719
|
+
var candidates = locator.__deepPool || document.querySelectorAll(
|
|
720
|
+
"[" + TESTID_ATTR + "]"
|
|
721
|
+
);
|
|
722
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
723
|
+
if (candidates[i].getAttribute(TESTID_ATTR) === value) {
|
|
724
|
+
out.push(candidates[i]);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return out;
|
|
728
|
+
case "x":
|
|
729
|
+
var snap = document.evaluate(
|
|
730
|
+
value,
|
|
731
|
+
document,
|
|
732
|
+
null,
|
|
733
|
+
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
|
|
734
|
+
null
|
|
735
|
+
);
|
|
736
|
+
for (var j = 0; j < snap.snapshotLength; j++) {
|
|
737
|
+
var node = snap.snapshotItem(j);
|
|
738
|
+
if (node && node.nodeType === 1) {
|
|
739
|
+
out.push(node);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return out;
|
|
743
|
+
case "t":
|
|
744
|
+
var wanted = normText(value);
|
|
745
|
+
var lowerT = wanted.toLowerCase();
|
|
746
|
+
var elementsT = locator.__deepPool || allElements();
|
|
747
|
+
for (var k = 0; k < elementsT.length; k++) {
|
|
748
|
+
var elT = elementsT[k];
|
|
749
|
+
if (SKIP_TAGS[elT.tagName]) {
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
var textT = normText(elT.textContent);
|
|
753
|
+
if (!textT) {
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
if (textT === wanted || textT.toLowerCase().indexOf(lowerT) !== -1) {
|
|
757
|
+
out.push(elT);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return out;
|
|
761
|
+
case "r":
|
|
762
|
+
var spec = normText(value);
|
|
763
|
+
var sepR = spec.indexOf(" ");
|
|
764
|
+
var wantedRole = (sepR === -1 ? spec : spec.slice(0, sepR)).toLowerCase();
|
|
765
|
+
var wantedNameR = sepR === -1 ? null : spec.slice(sepR + 1);
|
|
766
|
+
var elementsR = locator.__deepPool || allElements();
|
|
767
|
+
for (var m = 0; m < elementsR.length; m++) {
|
|
768
|
+
var elR = elementsR[m];
|
|
769
|
+
if (SKIP_TAGS[elR.tagName]) {
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (effectiveRoles(elR).indexOf(wantedRole) === -1) {
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
if (wantedNameR !== null && !matchesName(elR, wantedNameR)) {
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
out.push(elR);
|
|
779
|
+
}
|
|
780
|
+
return out;
|
|
781
|
+
case "l":
|
|
782
|
+
var wantedL = normText(value).toLowerCase();
|
|
783
|
+
var elementsL = locator.__deepPool || allElements();
|
|
784
|
+
for (var n = 0; n < elementsL.length; n++) {
|
|
785
|
+
var elL = elementsL[n];
|
|
786
|
+
if (SKIP_TAGS[elL.tagName]) {
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
var nameL = accessibleName(elL);
|
|
790
|
+
if (nameL && nameL.toLowerCase() === wantedL) {
|
|
791
|
+
out.push(elL);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return out;
|
|
795
|
+
case "e":
|
|
796
|
+
return extComponents(value);
|
|
797
|
+
default:
|
|
798
|
+
return [];
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function count(locator) {
|
|
803
|
+
var n = resolveAll(locator).length;
|
|
804
|
+
return { count: n };
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function focus(locator) {
|
|
808
|
+
var el = resolve(locator);
|
|
809
|
+
if (!el) {
|
|
810
|
+
return { ok: false, reason: "not-found" };
|
|
811
|
+
}
|
|
812
|
+
if (typeof el.focus !== "function") {
|
|
813
|
+
return { ok: false, reason: "not-focusable" };
|
|
814
|
+
}
|
|
815
|
+
el.focus();
|
|
816
|
+
return { ok: true };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function rect(locator) {
|
|
820
|
+
var el = resolve(locator);
|
|
821
|
+
if (!el) {
|
|
822
|
+
return { ok: false, reason: "not-found" };
|
|
823
|
+
}
|
|
824
|
+
var r = el.getBoundingClientRect();
|
|
825
|
+
return {
|
|
826
|
+
ok: true,
|
|
827
|
+
x: r.left,
|
|
828
|
+
y: r.top,
|
|
829
|
+
width: r.width,
|
|
830
|
+
height: r.height,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function scrollIntoView(locator) {
|
|
835
|
+
var el = resolve(locator);
|
|
836
|
+
if (!el) {
|
|
837
|
+
return { ok: false, reason: "not-found" };
|
|
838
|
+
}
|
|
839
|
+
if (typeof el.scrollIntoViewIfNeeded === "function") {
|
|
840
|
+
el.scrollIntoViewIfNeeded(true);
|
|
841
|
+
} else if (typeof el.scrollIntoView === "function") {
|
|
842
|
+
el.scrollIntoView({ block: "center", inline: "center" });
|
|
843
|
+
}
|
|
844
|
+
return { ok: true };
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Visual regression masking — lays a fixed black overlay over each matched
|
|
848
|
+
// element. Using the same mask on both baseline and actual excludes
|
|
849
|
+
// volatile content (timestamps etc.) from the comparison. position:fixed
|
|
850
|
+
// does not trigger reflow.
|
|
851
|
+
var MASK_CLASS = "__cdpb_mask__";
|
|
852
|
+
|
|
853
|
+
function mask(locators) {
|
|
854
|
+
if (!Array.isArray(locators)) {
|
|
855
|
+
locators = [locators];
|
|
856
|
+
}
|
|
857
|
+
var count = 0;
|
|
858
|
+
for (var i = 0; i < locators.length; i++) {
|
|
859
|
+
var matches = resolveAll(locators[i]);
|
|
860
|
+
for (var j = 0; j < matches.length; j++) {
|
|
861
|
+
var el = matches[j];
|
|
862
|
+
var r = el.getBoundingClientRect();
|
|
863
|
+
if (r.width <= 0 || r.height <= 0) {
|
|
864
|
+
continue; // hidden elements have no surface to mask.
|
|
865
|
+
}
|
|
866
|
+
var box = document.createElement("div");
|
|
867
|
+
box.className = MASK_CLASS;
|
|
868
|
+
box.setAttribute("data-testid", "__cdpb_mask__");
|
|
869
|
+
box.style.position = "fixed";
|
|
870
|
+
box.style.left = r.left + "px";
|
|
871
|
+
box.style.top = r.top + "px";
|
|
872
|
+
box.style.width = r.width + "px";
|
|
873
|
+
box.style.height = r.height + "px";
|
|
874
|
+
box.style.background = "#000";
|
|
875
|
+
box.style.zIndex = "2147483647";
|
|
876
|
+
document.documentElement.appendChild(box);
|
|
877
|
+
count++;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return { ok: true, masked: count };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function unmask() {
|
|
884
|
+
var all = document.querySelectorAll("." + MASK_CLASS);
|
|
885
|
+
for (var i = 0; i < all.length; i++) {
|
|
886
|
+
all[i].remove();
|
|
887
|
+
}
|
|
888
|
+
return { ok: true, removed: all.length };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
window.__cdpb = {
|
|
892
|
+
findOne: resolve,
|
|
893
|
+
_state: state,
|
|
894
|
+
_describe: describe,
|
|
895
|
+
exists: status,
|
|
896
|
+
visible: status,
|
|
897
|
+
enabled: status,
|
|
898
|
+
click: click,
|
|
899
|
+
setValue: setValue,
|
|
900
|
+
getText: getText,
|
|
901
|
+
getAttr: getAttr,
|
|
902
|
+
focus: focus,
|
|
903
|
+
rect: rect,
|
|
904
|
+
scrollIntoView: scrollIntoView,
|
|
905
|
+
mask: mask,
|
|
906
|
+
unmask: unmask,
|
|
907
|
+
count: count,
|
|
908
|
+
};
|
|
909
|
+
})();
|