@itmetsam/samurai-framework 0.21.1 → 0.21.3
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.
- package/README.md +8 -3
- package/dist/assert/locator-assertions.d.ts +4 -0
- package/dist/assert/locator-assertions.js +10 -2
- package/dist/assert/text-match.d.ts +2 -2
- package/dist/assert/text-match.js +2 -2
- package/dist/browser/dialog.js +4 -1
- package/dist/browser/init-script.d.ts +11 -0
- package/dist/browser/init-script.js +29 -0
- package/dist/browser/page-locators.d.ts +18 -0
- package/dist/browser/page-locators.js +19 -0
- package/dist/browser/page-permissions.d.ts +6 -0
- package/dist/browser/page-permissions.js +15 -0
- package/dist/browser/page-settings.d.ts +3 -0
- package/dist/browser/page-settings.js +4 -0
- package/dist/browser/page.d.ts +29 -13
- package/dist/browser/page.js +46 -45
- package/dist/config/variables.js +2 -1
- package/dist/locator/accessible-name.d.ts +22 -0
- package/dist/locator/accessible-name.js +128 -0
- package/dist/locator/action-timeout-error.js +5 -1
- package/dist/locator/actionability.d.ts +3 -2
- package/dist/locator/actionability.js +4 -4
- package/dist/locator/attribute-locator.d.ts +13 -0
- package/dist/locator/attribute-locator.js +27 -0
- package/dist/locator/chain-resolver.d.ts +12 -3
- package/dist/locator/chain-resolver.js +85 -15
- package/dist/locator/check-actions.d.ts +15 -0
- package/dist/locator/check-actions.js +44 -0
- package/dist/locator/combine-step.d.ts +5 -0
- package/dist/locator/combine-step.js +15 -0
- package/dist/locator/control-probe.d.ts +10 -0
- package/dist/locator/control-probe.js +50 -0
- package/dist/locator/element-access.d.ts +24 -0
- package/dist/locator/element-access.js +53 -0
- package/dist/locator/element-actions.d.ts +17 -0
- package/dist/locator/element-actions.js +51 -0
- package/dist/locator/element-reads.d.ts +29 -4
- package/dist/locator/element-reads.js +83 -2
- package/dist/locator/element-state.d.ts +5 -0
- package/dist/locator/element-state.js +2 -0
- package/dist/locator/file-actions.d.ts +2 -2
- package/dist/locator/filter-step.d.ts +24 -0
- package/dist/locator/filter-step.js +72 -0
- package/dist/locator/label-locator.d.ts +1 -1
- package/dist/locator/locator.d.ts +94 -29
- package/dist/locator/locator.js +183 -151
- package/dist/locator/page-scripts.d.ts +2 -0
- package/dist/locator/page-scripts.js +6 -0
- package/dist/locator/page-search.d.ts +11 -2
- package/dist/locator/page-search.js +15 -3
- package/dist/locator/page-step.d.ts +16 -0
- package/dist/locator/page-step.js +35 -0
- package/dist/locator/role-locator.d.ts +65 -0
- package/dist/locator/role-locator.js +125 -0
- package/dist/locator/role-table.d.ts +18 -0
- package/dist/locator/role-table.js +190 -0
- package/dist/locator/select-actions.d.ts +13 -0
- package/dist/locator/select-actions.js +73 -0
- package/dist/locator/selector-string.d.ts +7 -0
- package/dist/locator/selector-string.js +27 -0
- package/dist/locator/selector.d.ts +47 -15
- package/dist/locator/selector.js +83 -12
- package/dist/locator/strict-mode.d.ts +6 -0
- package/dist/locator/strict-mode.js +6 -0
- package/dist/locator/text-locator.d.ts +1 -1
- package/dist/network/network-tracker.d.ts +17 -1
- package/dist/network/network-tracker.js +63 -18
- package/dist/network/url-match.js +3 -1
- package/dist/types/config.d.ts +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { NAME_FROM_CONTENT_ROLES, ROLE_OF_JS } from "./role-table.js";
|
|
2
|
+
/**
|
|
3
|
+
* Page-side source that defines, in the scope it is pasted into:
|
|
4
|
+
*
|
|
5
|
+
* - `isHidden(el)`: not rendered or hidden from assistive technology: `display: none`, `visibility: hidden` or
|
|
6
|
+
* `collapse`, or `aria-hidden="true"` on the element or an ancestor.
|
|
7
|
+
* - `accessibleName(el)` and `accessibleDescription(el)`: strings, whitespace collapsed and trimmed.
|
|
8
|
+
* - `roleOf(el)` (from `ROLE_OF_JS`).
|
|
9
|
+
*
|
|
10
|
+
* The name follows the accname algorithm in a simplified form, the order being: `aria-labelledby` (hidden content is
|
|
11
|
+
* read when referenced directly), `aria-label`, the native text alternative (`alt` of img / area / input[type=image],
|
|
12
|
+
* `value` of button-like inputs, the `<label>`s of a form control, `<legend>`, `<caption>`, `<figcaption>`, svg
|
|
13
|
+
* `<title>`), the content for roles that take their name from it (and always for what an embedding walks through),
|
|
14
|
+
* the `title` attribute, and for a text field its `placeholder`. Embedded form controls count by their value, and
|
|
15
|
+
* `::before` / `::after` text by its CSS content. Hidden descendants are skipped. A reference loop stops at the
|
|
16
|
+
* element already visited. The description is `aria-describedby`, else `aria-description`, else the `title` when it was not
|
|
17
|
+
* used as the name.
|
|
18
|
+
*
|
|
19
|
+
* It deliberately differs from the browser's own accessibility tree: no CSS `display` quirks for table parts, no
|
|
20
|
+
* `<summary>` or `<details>` rules, no shadow DOM or slot flattening, no `aria-owns`, and `placeholder` counts as a name
|
|
21
|
+
* for inputs in both browsers' way (Firefox and Chrome disagree there).
|
|
22
|
+
*/
|
|
23
|
+
export const ACCESSIBLE_NAME_JS = `
|
|
24
|
+
${ROLE_OF_JS}
|
|
25
|
+
const NAME_FROM_CONTENT = ${JSON.stringify(NAME_FROM_CONTENT_ROLES)};
|
|
26
|
+
const squash = (text) => String(text).replace(/\\s+/g, " ").trim();
|
|
27
|
+
const hiddenSelf = (n) => {
|
|
28
|
+
if (n.getAttribute("aria-hidden") === "true") return true;
|
|
29
|
+
const style = getComputedStyle(n);
|
|
30
|
+
return style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse";
|
|
31
|
+
};
|
|
32
|
+
const isHidden = (el) => {
|
|
33
|
+
if (hiddenSelf(el)) return true;
|
|
34
|
+
for (let n = el.parentElement; n; n = n.parentElement) {
|
|
35
|
+
if (n.getAttribute("aria-hidden") === "true" || getComputedStyle(n).display === "none") return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
};
|
|
39
|
+
const byIds = (el, attribute) => (el.getAttribute(attribute) || "").split(/\\s+/)
|
|
40
|
+
.map((id) => (id ? el.ownerDocument.getElementById(id) : null)).filter(Boolean);
|
|
41
|
+
const pseudoText = (el, which) => {
|
|
42
|
+
const content = getComputedStyle(el, which).content;
|
|
43
|
+
return content && content !== "none" && content !== "normal" && /^["']/.test(content) ? content.slice(1, -1) : "";
|
|
44
|
+
};
|
|
45
|
+
const embeddedValue = (el, role) => {
|
|
46
|
+
if (role === "textbox" || role === "searchbox" || role === "spinbutton" || role === "slider") return el.value ?? el.getAttribute("aria-valuenow") ?? "";
|
|
47
|
+
if (role === "combobox" || role === "listbox") {
|
|
48
|
+
const chosen = el.localName === "select" ? Array.from(el.selectedOptions) : [];
|
|
49
|
+
return chosen.map((option) => option.label || option.textContent).join(" ");
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
/** The text of a node and what it embeds. \`seen\` guards reference loops; \`top\` is the node whose name is asked for */
|
|
54
|
+
const walk = (node, seen, top) => {
|
|
55
|
+
if (node.nodeType === 3) return node.nodeValue;
|
|
56
|
+
if (node.nodeType !== 1 || seen.has(node)) return "";
|
|
57
|
+
if (node !== top && hiddenSelf(node)) return "";
|
|
58
|
+
if (["script", "style", "noscript", "template"].includes(node.localName)) return "";
|
|
59
|
+
seen.add(node);
|
|
60
|
+
const label = node !== top && (node.getAttribute("aria-label") || "").trim();
|
|
61
|
+
if (label) return label;
|
|
62
|
+
const role = roleOf(node);
|
|
63
|
+
if (node !== top) {
|
|
64
|
+
const value = embeddedValue(node, role);
|
|
65
|
+
if (value !== null) return value;
|
|
66
|
+
}
|
|
67
|
+
let text = pseudoText(node, "::before");
|
|
68
|
+
for (const child of node.childNodes) {
|
|
69
|
+
const part = walk(child, seen, top);
|
|
70
|
+
const block = child.nodeType === 1 && getComputedStyle(child).display !== "inline";
|
|
71
|
+
text += block ? " " + part + " " : part;
|
|
72
|
+
}
|
|
73
|
+
return text + pseudoText(node, "::after");
|
|
74
|
+
};
|
|
75
|
+
const fromReferences = (el, attribute, seen) => squash(byIds(el, attribute).map((ref) => {
|
|
76
|
+
const own = (ref.getAttribute("aria-label") || "").trim();
|
|
77
|
+
return own || walk(ref, new Set(seen), ref);
|
|
78
|
+
}).join(" "));
|
|
79
|
+
const labelText = (el, seen) => squash(Array.from(el.labels || []).map((label) => walk(label, new Set(seen), label)).join(" "));
|
|
80
|
+
const nativeName = (el, seen) => {
|
|
81
|
+
const tag = el.localName;
|
|
82
|
+
const type = (el.getAttribute("type") || "").toLowerCase();
|
|
83
|
+
const first = (selector) => el.querySelector(":scope > " + selector);
|
|
84
|
+
if (tag === "img" || tag === "area" || (tag === "input" && type === "image")) {
|
|
85
|
+
const alt = el.getAttribute("alt");
|
|
86
|
+
if (alt !== null && squash(alt)) return squash(alt);
|
|
87
|
+
if (tag === "input") return squash(el.getAttribute("value") || "") || labelText(el, seen);
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
if (tag === "input" && (type === "button" || type === "submit" || type === "reset")) {
|
|
91
|
+
const value = squash(el.getAttribute("value") || "");
|
|
92
|
+
return value || labelText(el, seen) || (type === "submit" ? "Submit" : type === "reset" ? "Reset" : "");
|
|
93
|
+
}
|
|
94
|
+
if (tag === "input" || tag === "textarea" || tag === "select" || tag === "meter" || tag === "progress" || tag === "output") return labelText(el, seen);
|
|
95
|
+
if (tag === "fieldset") { const legend = first("legend"); return legend ? squash(walk(legend, new Set(seen), legend)) : ""; }
|
|
96
|
+
if (tag === "table") { const caption = first("caption"); return caption ? squash(walk(caption, new Set(seen), caption)) : ""; }
|
|
97
|
+
if (tag === "figure") { const caption = first("figcaption"); return caption ? squash(walk(caption, new Set(seen), caption)) : ""; }
|
|
98
|
+
if (tag === "svg") { const title = first("title"); return title ? squash(title.textContent) : ""; }
|
|
99
|
+
return "";
|
|
100
|
+
};
|
|
101
|
+
/** The name and where it came from (\`title\` or \`placeholder\` matter to the description) */
|
|
102
|
+
const nameSource = (el) => {
|
|
103
|
+
const seen = new Set([el]);
|
|
104
|
+
const referenced = fromReferences(el, "aria-labelledby", seen);
|
|
105
|
+
if (referenced) return { name: referenced, source: "labelledby" };
|
|
106
|
+
const own = squash(el.getAttribute("aria-label") || "");
|
|
107
|
+
if (own) return { name: own, source: "label" };
|
|
108
|
+
const native = nativeName(el, seen);
|
|
109
|
+
if (native) return { name: native, source: "native" };
|
|
110
|
+
if (NAME_FROM_CONTENT.includes(roleOf(el)) || el.localName === "label" || el.localName === "legend" || el.localName === "caption") {
|
|
111
|
+
const content = squash(walk(el, new Set(), el));
|
|
112
|
+
if (content) return { name: content, source: "content" };
|
|
113
|
+
}
|
|
114
|
+
const title = squash(el.getAttribute("title") || "");
|
|
115
|
+
if (title) return { name: title, source: "title" };
|
|
116
|
+
const placeholder = squash(el.getAttribute("placeholder") || "");
|
|
117
|
+
if ((el.localName === "input" || el.localName === "textarea") && placeholder) return { name: placeholder, source: "placeholder" };
|
|
118
|
+
return { name: "", source: "" };
|
|
119
|
+
};
|
|
120
|
+
const accessibleName = (el) => nameSource(el).name;
|
|
121
|
+
const accessibleDescription = (el) => {
|
|
122
|
+
const referenced = fromReferences(el, "aria-describedby", new Set([el]));
|
|
123
|
+
if (referenced) return referenced;
|
|
124
|
+
const attribute = squash(el.getAttribute("aria-description") || "");
|
|
125
|
+
if (attribute) return attribute;
|
|
126
|
+
const title = squash(el.getAttribute("title") || "");
|
|
127
|
+
return title && nameSource(el).source !== "title" ? title : "";
|
|
128
|
+
};`;
|
|
@@ -12,7 +12,8 @@ export interface ActionOptions {
|
|
|
12
12
|
}
|
|
13
13
|
/** What the actionability waits need of a locator: finding its element, calling into the page, naming itself */
|
|
14
14
|
export interface ActionabilityContext {
|
|
15
|
-
|
|
15
|
+
/** `method` names the public method that asks; with it, a strict locator throws when several elements match */
|
|
16
|
+
resolveOne(method?: string): Promise<ElementHandle | null>;
|
|
16
17
|
call<T>(fn: string, args: unknown[], sandbox?: string): Promise<T>;
|
|
17
18
|
/** The locator's description for error messages */
|
|
18
19
|
readonly selector: string;
|
|
@@ -20,7 +21,7 @@ export interface ActionabilityContext {
|
|
|
20
21
|
readonly helpers: HelperInstaller | undefined;
|
|
21
22
|
}
|
|
22
23
|
/** Reads the element's actionability state: one search, one probe */
|
|
23
|
-
export declare function probeState(ctx: ActionabilityContext, options: ProbeOptions): Promise<ElementState>;
|
|
24
|
+
export declare function probeState(ctx: ActionabilityContext, options: ProbeOptions, method: string): Promise<ElementState>;
|
|
24
25
|
/**
|
|
25
26
|
* Probes until every required check passes and returns that state.
|
|
26
27
|
* Throws ActionTimeoutError describing the last probe on timeout.
|
|
@@ -8,8 +8,8 @@ import { allPass, DETACHED_STATE, evaluateChecks, parseElementState, PROBE_ELEME
|
|
|
8
8
|
import { CALL_PROBE, HELPER_SANDBOX, HELPERS_MISSING, } from "./helper-realm.js";
|
|
9
9
|
import { ATTACHED_ONLY } from "./page-scripts.js";
|
|
10
10
|
/** Reads the element's actionability state: one search, one probe */
|
|
11
|
-
export async function probeState(ctx, options) {
|
|
12
|
-
const el = await ctx.resolveOne();
|
|
11
|
+
export async function probeState(ctx, options, method) {
|
|
12
|
+
const el = await ctx.resolveOne(method);
|
|
13
13
|
if (!el)
|
|
14
14
|
return DETACHED_STATE;
|
|
15
15
|
try {
|
|
@@ -53,7 +53,7 @@ export async function waitForActionable(ctx, action, checks, probe, options) {
|
|
|
53
53
|
: checks;
|
|
54
54
|
let lastChecks;
|
|
55
55
|
try {
|
|
56
|
-
return await waitUntil(() => probeState(ctx, probe), (current, previous) => {
|
|
56
|
+
return await waitUntil(() => probeState(ctx, probe, action), (current, previous) => {
|
|
57
57
|
lastChecks = evaluateChecks(required, current, previous);
|
|
58
58
|
return allPass(lastChecks);
|
|
59
59
|
}, { timeout });
|
|
@@ -88,7 +88,7 @@ export async function waitForState(ctx, { state = "visible", timeout }) {
|
|
|
88
88
|
hidden: (current) => !current.attached || !current.visible,
|
|
89
89
|
};
|
|
90
90
|
try {
|
|
91
|
-
await waitUntil(() => probeState(ctx, { scroll: false, hitTest: false }), reached[state], { timeout: resolved });
|
|
91
|
+
await waitUntil(() => probeState(ctx, { scroll: false, hitTest: false }, "waitFor"), reached[state], { timeout: resolved });
|
|
92
92
|
}
|
|
93
93
|
catch (err) {
|
|
94
94
|
if (!(err instanceof WaitTimeoutError))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Function declaration run in the page as `(starts, value, match, ignoreCase, attribute)`; returns the
|
|
3
|
+
* elements whose `placeholder`, `alt` or `title` attribute matches. BiDi has no such locator, so the
|
|
4
|
+
* framework does it.
|
|
5
|
+
*
|
|
6
|
+
* - Searches the descendants of every start node (the whole document when there are none).
|
|
7
|
+
* - The attribute text is whitespace collapsed and trimmed; `full` needs equality, `partial` a substring,
|
|
8
|
+
* a RegExp is tested as written (see `MATCHING_JS`).
|
|
9
|
+
* - Results are unique and in document order.
|
|
10
|
+
*
|
|
11
|
+
* A string, so the transpiler cannot inject helpers that do not exist in the page.
|
|
12
|
+
*/
|
|
13
|
+
export declare const ATTRIBUTE_LOCATE = "(starts, value, match, ignoreCase, attribute) => {\n \n const collapse = (text) => String(text).replace(/\\s+/g, \" \").trim();\n const normalise = (text) => {\n const collapsed = collapse(text);\n return ignoreCase ? collapsed.toLowerCase() : collapsed;\n };\n const pattern = typeof value === \"string\" ? null : new RegExp(value.source, value.flags.replace(/[gy]/g, \"\"));\n const wanted = pattern ? \"\" : normalise(value);\n const textMatches = (text) => {\n if (pattern) return pattern.test(collapse(text));\n const normal = normalise(text);\n return match === \"full\" ? normal === wanted : normal.includes(wanted);\n };\n const roots = starts.length > 0 ? starts : [document];\n const seen = new Set();\n const matches = [];\n for (const root of roots) {\n for (const el of root.querySelectorAll(\"[\" + attribute + \"]\")) {\n if (seen.has(el)) continue;\n seen.add(el);\n if (textMatches(el.getAttribute(attribute))) matches.push(el);\n }\n }\n return matches.sort((a, b) => (a === b ? 0 : a.compareDocumentPosition(b) & 4 ? -1 : 1));\n}";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { DOCUMENT_ORDER_JS, MATCHING_JS, ROOTS_JS } from "./page-search.js";
|
|
2
|
+
/**
|
|
3
|
+
* Function declaration run in the page as `(starts, value, match, ignoreCase, attribute)`; returns the
|
|
4
|
+
* elements whose `placeholder`, `alt` or `title` attribute matches. BiDi has no such locator, so the
|
|
5
|
+
* framework does it.
|
|
6
|
+
*
|
|
7
|
+
* - Searches the descendants of every start node (the whole document when there are none).
|
|
8
|
+
* - The attribute text is whitespace collapsed and trimmed; `full` needs equality, `partial` a substring,
|
|
9
|
+
* a RegExp is tested as written (see `MATCHING_JS`).
|
|
10
|
+
* - Results are unique and in document order.
|
|
11
|
+
*
|
|
12
|
+
* A string, so the transpiler cannot inject helpers that do not exist in the page.
|
|
13
|
+
*/
|
|
14
|
+
export const ATTRIBUTE_LOCATE = `(starts, value, match, ignoreCase, attribute) => {
|
|
15
|
+
${MATCHING_JS}
|
|
16
|
+
${ROOTS_JS}
|
|
17
|
+
const seen = new Set();
|
|
18
|
+
const matches = [];
|
|
19
|
+
for (const root of roots) {
|
|
20
|
+
for (const el of root.querySelectorAll("[" + attribute + "]")) {
|
|
21
|
+
if (seen.has(el)) continue;
|
|
22
|
+
seen.add(el);
|
|
23
|
+
if (textMatches(el.getAttribute(attribute))) matches.push(el);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return matches.sort(${DOCUMENT_ORDER_JS});
|
|
27
|
+
}`;
|
|
@@ -11,10 +11,19 @@ export declare class ChainResolver {
|
|
|
11
11
|
private readonly contextId;
|
|
12
12
|
private readonly call;
|
|
13
13
|
constructor(connector: BiDiConnector, contextId: BrowsingContext, call: CallInPage);
|
|
14
|
-
/**
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* In document order; `max` limits the final result. Nothing is cached: the first match can change between polls.
|
|
16
|
+
* `start` makes the chain relative to those elements: its first step searches inside them, like a later step.
|
|
17
|
+
*/
|
|
18
|
+
resolveChain(chain: readonly Selector[], max?: number, start?: readonly ElementHandle[]): Promise<ElementHandle[]>;
|
|
19
|
+
/**
|
|
20
|
+
* `and` keeps what both chains match; `or` what either matches, in document order. Both chains search from
|
|
21
|
+
* scratch (or inside `from`, when the whole chain is relative). An `or` needs no more than `max` of each
|
|
22
|
+
*/
|
|
23
|
+
private combine;
|
|
24
|
+
private inDocumentOrder;
|
|
16
25
|
private locateStep;
|
|
17
26
|
private locateNatively;
|
|
18
|
-
/** Runs a page-side search `(starts,
|
|
27
|
+
/** Runs a page-side search `(starts, ...args) => elements` */
|
|
19
28
|
private locateInPage;
|
|
20
29
|
}
|
|
@@ -2,10 +2,12 @@ import { BiDiError } from "../transport/bidi-error.js";
|
|
|
2
2
|
import { UnsupportedOperationError } from "../transport/unsupported.js";
|
|
3
3
|
import { ElementHandle } from "../script/element-handle.js";
|
|
4
4
|
import { isDocumentSwap } from "./document-swap.js";
|
|
5
|
-
import {
|
|
5
|
+
import { intersect, union } from "./combine-step.js";
|
|
6
|
+
import { applyFilter } from "./filter-step.js";
|
|
7
|
+
import { SORT_IN_DOCUMENT_ORDER } from "./page-search.js";
|
|
6
8
|
import { InvalidSelectorError } from "./selector-errors.js";
|
|
9
|
+
import { pageSearch, textSearch } from "./page-step.js";
|
|
7
10
|
import { describeSelector, toBiDiLocator } from "./selector.js";
|
|
8
|
-
import { TEXT_LOCATE } from "./text-locator.js";
|
|
9
11
|
/** Locator types a connection's browser rejected, so they are not tried again on every poll */
|
|
10
12
|
const unsupportedByConnector = new WeakMap();
|
|
11
13
|
function unsupportedTypes(connector) {
|
|
@@ -14,6 +16,19 @@ function unsupportedTypes(connector) {
|
|
|
14
16
|
unsupportedByConnector.set(connector, (types = new Set()));
|
|
15
17
|
return types;
|
|
16
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* How many matches step `index` has to find: `max` for the last step; for a step an `nth` follows, only
|
|
21
|
+
* as many as that index needs, when one start node (or none) makes the search order the document's
|
|
22
|
+
*/
|
|
23
|
+
function stepLimit(chain, index, from, max) {
|
|
24
|
+
if (index === chain.length - 1)
|
|
25
|
+
return max;
|
|
26
|
+
const next = chain[index + 1];
|
|
27
|
+
const single = (from?.length ?? 0) <= 1;
|
|
28
|
+
return next.kind === "nth" && next.index >= 0 && single
|
|
29
|
+
? next.index + 1
|
|
30
|
+
: undefined;
|
|
31
|
+
}
|
|
17
32
|
/** Finds the elements a chain of selector steps matches: every step searches inside the previous step's matches */
|
|
18
33
|
export class ChainResolver {
|
|
19
34
|
constructor(connector, contextId, call) {
|
|
@@ -21,17 +36,50 @@ export class ChainResolver {
|
|
|
21
36
|
this.contextId = contextId;
|
|
22
37
|
this.call = call;
|
|
23
38
|
}
|
|
24
|
-
/**
|
|
25
|
-
|
|
39
|
+
/**
|
|
40
|
+
* In document order; `max` limits the final result. Nothing is cached: the first match can change between polls.
|
|
41
|
+
* `start` makes the chain relative to those elements: its first step searches inside them, like a later step.
|
|
42
|
+
*/
|
|
43
|
+
async resolveChain(chain, max, start) {
|
|
26
44
|
let nodes;
|
|
27
45
|
for (const [index, selector] of chain.entries()) {
|
|
46
|
+
const from = nodes ?? start;
|
|
28
47
|
if (selector.kind === "nth") {
|
|
29
|
-
const picked = nodes?.[selector.index];
|
|
48
|
+
const picked = nodes?.[selector.index < 0 ? nodes.length + selector.index : selector.index];
|
|
30
49
|
nodes = picked ? [picked] : [];
|
|
31
50
|
}
|
|
51
|
+
else if (selector.kind === "and" || selector.kind === "or") {
|
|
52
|
+
try {
|
|
53
|
+
nodes = await this.combine(selector, from, stepLimit(chain, index, from, max));
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
if (isDocumentSwap(err))
|
|
57
|
+
return [];
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else if (selector.kind === "filter") {
|
|
62
|
+
try {
|
|
63
|
+
nodes = from
|
|
64
|
+
? await applyFilter(from, selector, {
|
|
65
|
+
call: this.call,
|
|
66
|
+
findInside: (inner, element) => this.resolveChain(inner, 1, [element]),
|
|
67
|
+
})
|
|
68
|
+
: [];
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
if (isDocumentSwap(err))
|
|
72
|
+
return [];
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
32
76
|
else {
|
|
33
77
|
try {
|
|
34
|
-
nodes = await this.locateStep(selector,
|
|
78
|
+
nodes = await this.locateStep(selector, from !== undefined, from, stepLimit(chain, index, from, max));
|
|
79
|
+
// Results per start node are not in document order, which an index into them needs
|
|
80
|
+
if (chain[index + 1]?.kind === "nth" && (from?.length ?? 0) > 1) {
|
|
81
|
+
nodes = await this.inDocumentOrder(nodes);
|
|
82
|
+
}
|
|
35
83
|
}
|
|
36
84
|
catch (err) {
|
|
37
85
|
// The page is swapping documents: nothing matches right now, and the waits that poll try again
|
|
@@ -45,12 +93,36 @@ export class ChainResolver {
|
|
|
45
93
|
}
|
|
46
94
|
return nodes ?? [];
|
|
47
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* `and` keeps what both chains match; `or` what either matches, in document order. Both chains search from
|
|
98
|
+
* scratch (or inside `from`, when the whole chain is relative). An `or` needs no more than `max` of each
|
|
99
|
+
*/
|
|
100
|
+
async combine({ kind, left, right }, from, max) {
|
|
101
|
+
const limit = kind === "or" ? max : undefined;
|
|
102
|
+
const first = await this.resolveChain(left, limit, from);
|
|
103
|
+
const second = await this.resolveChain(right, limit, from);
|
|
104
|
+
if (kind === "and") {
|
|
105
|
+
const both = intersect(first, second);
|
|
106
|
+
return max === undefined ? both : both.slice(0, max);
|
|
107
|
+
}
|
|
108
|
+
if (first.length === 0 || second.length === 0) {
|
|
109
|
+
return (first.length === 0 ? second : first).slice(0, max);
|
|
110
|
+
}
|
|
111
|
+
const ordered = await this.inDocumentOrder(union(first, second));
|
|
112
|
+
return max === undefined ? ordered : ordered.slice(0, max);
|
|
113
|
+
}
|
|
114
|
+
async inDocumentOrder(nodes) {
|
|
115
|
+
if (nodes.length < 2)
|
|
116
|
+
return nodes;
|
|
117
|
+
return this.call(SORT_IN_DOCUMENT_ORDER, [nodes]);
|
|
118
|
+
}
|
|
48
119
|
async locateStep(selector, scoped, startNodes, max) {
|
|
49
|
-
|
|
50
|
-
|
|
120
|
+
const inPage = pageSearch(selector);
|
|
121
|
+
if (inPage)
|
|
122
|
+
return this.locateInPage(inPage, startNodes, max);
|
|
51
123
|
if (selector.kind === "text" &&
|
|
52
124
|
unsupportedTypes(this.connector).has("innerText")) {
|
|
53
|
-
return this.locateInPage(
|
|
125
|
+
return this.locateInPage(textSearch(selector), startNodes, max);
|
|
54
126
|
}
|
|
55
127
|
try {
|
|
56
128
|
return await this.locateNatively(selector, scoped, startNodes, max);
|
|
@@ -63,7 +135,7 @@ export class ChainResolver {
|
|
|
63
135
|
if (selector.kind === "text") {
|
|
64
136
|
// The browser has no innerText locator (Firefox): remember that and search in the page instead
|
|
65
137
|
unsupportedTypes(this.connector).add("innerText");
|
|
66
|
-
return this.locateInPage(
|
|
138
|
+
return this.locateInPage(textSearch(selector), startNodes, max);
|
|
67
139
|
}
|
|
68
140
|
throw new UnsupportedOperationError(`${describeSelector(selector, scoped)}: this kind of locator is not supported by the browser (${err.message})`);
|
|
69
141
|
}
|
|
@@ -89,13 +161,11 @@ export class ChainResolver {
|
|
|
89
161
|
}
|
|
90
162
|
return [...found.values()];
|
|
91
163
|
}
|
|
92
|
-
/** Runs a page-side search `(starts,
|
|
93
|
-
async locateInPage(fn,
|
|
164
|
+
/** Runs a page-side search `(starts, ...args) => elements` */
|
|
165
|
+
async locateInPage({ fn, args }, startNodes, max) {
|
|
94
166
|
const found = await this.call(fn, [
|
|
95
167
|
startNodes ?? [],
|
|
96
|
-
|
|
97
|
-
selector.match,
|
|
98
|
-
selector.ignoreCase,
|
|
168
|
+
...args,
|
|
99
169
|
]);
|
|
100
170
|
return max === undefined ? found : found.slice(0, max);
|
|
101
171
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ActionOptions } from "./actionability.js";
|
|
2
|
+
import { type ElementAccess } from "./element-access.js";
|
|
3
|
+
export interface CheckOptions extends ActionOptions {
|
|
4
|
+
/** Wait for the element to be actionable, but do not click (and so do not change anything) */
|
|
5
|
+
trial?: boolean;
|
|
6
|
+
}
|
|
7
|
+
/** Which of the three public methods asked, for the messages */
|
|
8
|
+
type CheckMethod = "check" | "uncheck" | "setChecked";
|
|
9
|
+
/**
|
|
10
|
+
* Brings a checkbox, radio or `role=checkbox|radio|switch` element to `checked`. Already there: nothing is clicked.
|
|
11
|
+
* Otherwise waits until the control (or one of its labels, for a hidden input) can be clicked, clicks it with real
|
|
12
|
+
* pointer input and verifies the state changed.
|
|
13
|
+
*/
|
|
14
|
+
export declare function setChecked(access: ElementAccess, method: CheckMethod, checked: boolean, options?: CheckOptions): Promise<void>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { callOnExistingElement } from "./element-access.js";
|
|
2
|
+
import { clickRect } from "./input-actions.js";
|
|
3
|
+
import { ATTACHED_ONLY, CLICK_CHECKS } from "./page-scripts.js";
|
|
4
|
+
/** Body run in the page as `(el) => { ... }`: what kind of control the element is, and whether it is checked */
|
|
5
|
+
const CONTROL_INFO_BODY = `
|
|
6
|
+
const type = (el.getAttribute("type") || "").toLowerCase();
|
|
7
|
+
const role = (el.getAttribute("role") || "").trim().split(/\\s+/)[0].toLowerCase();
|
|
8
|
+
let kind = null;
|
|
9
|
+
if (el.tagName === "INPUT" && (type === "checkbox" || type === "radio")) kind = type;
|
|
10
|
+
else if (role === "checkbox" || role === "switch" || role === "menuitemcheckbox") kind = "checkbox";
|
|
11
|
+
else if (role === "radio" || role === "menuitemradio") kind = "radio";
|
|
12
|
+
const native = el.tagName === "INPUT" && (type === "checkbox" || type === "radio");
|
|
13
|
+
return { kind, checked: native ? el.checked : el.getAttribute("aria-checked") === "true" };`;
|
|
14
|
+
function readControl(access, method) {
|
|
15
|
+
return callOnExistingElement(access, CONTROL_INFO_BODY, method);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Brings a checkbox, radio or `role=checkbox|radio|switch` element to `checked`. Already there: nothing is clicked.
|
|
19
|
+
* Otherwise waits until the control (or one of its labels, for a hidden input) can be clicked, clicks it with real
|
|
20
|
+
* pointer input and verifies the state changed.
|
|
21
|
+
*/
|
|
22
|
+
export async function setChecked(access, method, checked, options) {
|
|
23
|
+
const { trial, ...action } = options ?? {};
|
|
24
|
+
await access.waitForActionable(method, ATTACHED_ONLY, { scroll: false, hitTest: false }, action);
|
|
25
|
+
const before = await readControl(access, method);
|
|
26
|
+
if (before.kind === null) {
|
|
27
|
+
throw new Error(`${method}(): element is not a checkbox, radio, or role=checkbox/radio/switch`);
|
|
28
|
+
}
|
|
29
|
+
if (before.checked === checked)
|
|
30
|
+
return;
|
|
31
|
+
if (before.kind === "radio" && !checked) {
|
|
32
|
+
throw new Error(`${method}(): a radio button cannot be unchecked`);
|
|
33
|
+
}
|
|
34
|
+
const state = await access.waitForActionable(method,
|
|
35
|
+
// The same checks as a click; the probe's `control` mode decides what counts as the target
|
|
36
|
+
CLICK_CHECKS, { scroll: true, hitTest: true, control: true }, action);
|
|
37
|
+
if (trial)
|
|
38
|
+
return;
|
|
39
|
+
// "attached" is always required, so the box is present
|
|
40
|
+
await clickRect(access.connector, access.contextId, state.box);
|
|
41
|
+
if ((await readControl(access, method)).checked !== checked) {
|
|
42
|
+
throw new Error(`${method}(): clicking the ${before.kind} did not change its state`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ElementHandle } from "../script/element-handle.js";
|
|
2
|
+
/** The elements in both lists, in the order of the first */
|
|
3
|
+
export declare function intersect(left: readonly ElementHandle[], right: readonly ElementHandle[]): ElementHandle[];
|
|
4
|
+
/** Both lists joined, each element once (the same node has the same `sharedId` in every search) */
|
|
5
|
+
export declare function union(left: readonly ElementHandle[], right: readonly ElementHandle[]): ElementHandle[];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The elements in both lists, in the order of the first */
|
|
2
|
+
export function intersect(left, right) {
|
|
3
|
+
const wanted = new Set(right.map(({ sharedId }) => sharedId));
|
|
4
|
+
return left.filter(({ sharedId }) => wanted.has(sharedId));
|
|
5
|
+
}
|
|
6
|
+
/** Both lists joined, each element once (the same node has the same `sharedId` in every search) */
|
|
7
|
+
export function union(left, right) {
|
|
8
|
+
const seen = new Set();
|
|
9
|
+
return [...left, ...right].filter(({ sharedId }) => {
|
|
10
|
+
if (seen.has(sharedId))
|
|
11
|
+
return false;
|
|
12
|
+
seen.add(sharedId);
|
|
13
|
+
return true;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-side branch of the actionability probe for checkboxes, radios and switches (`ProbeOptions.control`).
|
|
3
|
+
* These are often hidden on purpose: a native input at `opacity: 0` (or 1px, or `display: none`) with a styled
|
|
4
|
+
* `<label>` on top. A user clicks the label, so the probe accepts the input itself or any of its labels as the
|
|
5
|
+
* click target: the first one that is shown and reached by a click at its centre. The reported `box` is that
|
|
6
|
+
* target's, so the click lands where a user's would. Other actions keep the stricter probe.
|
|
7
|
+
*
|
|
8
|
+
* Runs inside `PROBE_ELEMENT` with `el` and `options` in scope; a string for the same reason it is (no helpers).
|
|
9
|
+
*/
|
|
10
|
+
export declare const CONTROL_PROBE_BRANCH = "\n if (options.control) {\n const labels = Array.from(el.labels || []);\n const shown = (node) => {\n const r = node.getBoundingClientRect();\n const s = getComputedStyle(node);\n return r.width > 0 && r.height > 0 && s.display !== \"none\" && s.visibility !== \"hidden\";\n };\n const candidates = [el, ...labels].filter(shown);\n const reaches = (node, top) => node === top || node.contains(top) ||\n (node === el && labels.some((label) => label === top || label.contains(top)));\n const bringIntoView = (node) => {\n const r = node.getBoundingClientRect();\n const inside = r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth;\n if (options.scroll && !inside) node.scrollIntoView({ block: \"center\", inline: \"center\" });\n };\n let chosen = candidates[0] || el;\n let hitTarget = null;\n if (options.hitTest) {\n for (const node of candidates) {\n bringIntoView(node);\n const r = node.getBoundingClientRect();\n const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);\n const hit = !top ? \"nothing\" : reaches(node, top) ? \"self\"\n : top.tagName.toLowerCase() + (top.id ? \"#\" + top.id : \"\") +\n Array.from(top.classList, (name) => \".\" + name).join(\"\");\n if (node === candidates[0]) hitTarget = hit;\n if (hit === \"self\") { chosen = node; hitTarget = \"self\"; break; }\n }\n } else if (candidates.length) {\n bringIntoView(chosen);\n }\n const box = chosen.getBoundingClientRect();\n return {\n attached: true, visible: candidates.length > 0,\n enabled: !el.matches(\":disabled\") && el.getAttribute(\"aria-disabled\") !== \"true\",\n editable: false,\n box: { x: box.left, y: box.top, width: box.width, height: box.height },\n hitTarget,\n };\n }";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-side branch of the actionability probe for checkboxes, radios and switches (`ProbeOptions.control`).
|
|
3
|
+
* These are often hidden on purpose: a native input at `opacity: 0` (or 1px, or `display: none`) with a styled
|
|
4
|
+
* `<label>` on top. A user clicks the label, so the probe accepts the input itself or any of its labels as the
|
|
5
|
+
* click target: the first one that is shown and reached by a click at its centre. The reported `box` is that
|
|
6
|
+
* target's, so the click lands where a user's would. Other actions keep the stricter probe.
|
|
7
|
+
*
|
|
8
|
+
* Runs inside `PROBE_ELEMENT` with `el` and `options` in scope; a string for the same reason it is (no helpers).
|
|
9
|
+
*/
|
|
10
|
+
export const CONTROL_PROBE_BRANCH = `
|
|
11
|
+
if (options.control) {
|
|
12
|
+
const labels = Array.from(el.labels || []);
|
|
13
|
+
const shown = (node) => {
|
|
14
|
+
const r = node.getBoundingClientRect();
|
|
15
|
+
const s = getComputedStyle(node);
|
|
16
|
+
return r.width > 0 && r.height > 0 && s.display !== "none" && s.visibility !== "hidden";
|
|
17
|
+
};
|
|
18
|
+
const candidates = [el, ...labels].filter(shown);
|
|
19
|
+
const reaches = (node, top) => node === top || node.contains(top) ||
|
|
20
|
+
(node === el && labels.some((label) => label === top || label.contains(top)));
|
|
21
|
+
const bringIntoView = (node) => {
|
|
22
|
+
const r = node.getBoundingClientRect();
|
|
23
|
+
const inside = r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth;
|
|
24
|
+
if (options.scroll && !inside) node.scrollIntoView({ block: "center", inline: "center" });
|
|
25
|
+
};
|
|
26
|
+
let chosen = candidates[0] || el;
|
|
27
|
+
let hitTarget = null;
|
|
28
|
+
if (options.hitTest) {
|
|
29
|
+
for (const node of candidates) {
|
|
30
|
+
bringIntoView(node);
|
|
31
|
+
const r = node.getBoundingClientRect();
|
|
32
|
+
const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
|
33
|
+
const hit = !top ? "nothing" : reaches(node, top) ? "self"
|
|
34
|
+
: top.tagName.toLowerCase() + (top.id ? "#" + top.id : "") +
|
|
35
|
+
Array.from(top.classList, (name) => "." + name).join("");
|
|
36
|
+
if (node === candidates[0]) hitTarget = hit;
|
|
37
|
+
if (hit === "self") { chosen = node; hitTarget = "self"; break; }
|
|
38
|
+
}
|
|
39
|
+
} else if (candidates.length) {
|
|
40
|
+
bringIntoView(chosen);
|
|
41
|
+
}
|
|
42
|
+
const box = chosen.getBoundingClientRect();
|
|
43
|
+
return {
|
|
44
|
+
attached: true, visible: candidates.length > 0,
|
|
45
|
+
enabled: !el.matches(":disabled") && el.getAttribute("aria-disabled") !== "true",
|
|
46
|
+
editable: false,
|
|
47
|
+
box: { x: box.left, y: box.top, width: box.width, height: box.height },
|
|
48
|
+
hitTarget,
|
|
49
|
+
};
|
|
50
|
+
}`;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ElementHandle } from "../script/element-handle.js";
|
|
2
|
+
import type { Arg } from "../script/serialize.js";
|
|
3
|
+
import type { ElementContext } from "./file-actions.js";
|
|
4
|
+
/** What reads and actions need of a locator: its page, its description, and finding its element */
|
|
5
|
+
export interface ElementAccess extends ElementContext {
|
|
6
|
+
/**
|
|
7
|
+
* The element the locator matches first. With a `method` name and strict selectors on, several matches
|
|
8
|
+
* throw instead (`method` is the public method that asked, for the message)
|
|
9
|
+
*/
|
|
10
|
+
resolveOne(method?: string): Promise<ElementHandle | null>;
|
|
11
|
+
}
|
|
12
|
+
/** Calls `fn` in the page; a throwing function becomes a "Failed to locate element" error */
|
|
13
|
+
export declare function callInPage<T>(access: Pick<ElementAccess, "connector" | "contextId" | "selector">, fn: string, args: Arg[], sandbox?: string): Promise<T>;
|
|
14
|
+
/**
|
|
15
|
+
* Calls a function on the first matching element: `body` sees it as `el`, followed by `params`.
|
|
16
|
+
* Answers `whenMissing` when nothing matches, or the element disappeared meanwhile.
|
|
17
|
+
*/
|
|
18
|
+
export declare function callOnElement<T>(access: ElementAccess, whenMissing: T, body: string, params?: string[], args?: Arg[], method?: string): Promise<T>;
|
|
19
|
+
/** Like `callOnElement`, for operations that cannot go on without the element */
|
|
20
|
+
export declare function callOnExistingElement<T>(access: ElementAccess, body: string, method?: string): Promise<T>;
|
|
21
|
+
/** The error for an operation that needs an element when none matches */
|
|
22
|
+
export declare function noElementError(selector: string): Error;
|
|
23
|
+
/** The element the locator matches now; throws when none does */
|
|
24
|
+
export declare function requireElement(access: ElementAccess): Promise<ElementHandle>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { BiDiError } from "../transport/bidi-error.js";
|
|
2
|
+
import { callFunction, ScriptError } from "../script/call-function.js";
|
|
3
|
+
/** Calls `fn` in the page; a throwing function becomes a "Failed to locate element" error */
|
|
4
|
+
export async function callInPage(access, fn, args, sandbox) {
|
|
5
|
+
try {
|
|
6
|
+
return await callFunction(access.connector, access.contextId, fn, args, {
|
|
7
|
+
awaitPromise: false,
|
|
8
|
+
...(sandbox !== undefined && { sandbox }),
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
if (err instanceof ScriptError) {
|
|
13
|
+
throw new Error(`Failed to locate element ${access.selector}. Details: ${err.text}`);
|
|
14
|
+
}
|
|
15
|
+
throw err;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Calls a function on the first matching element: `body` sees it as `el`, followed by `params`.
|
|
20
|
+
* Answers `whenMissing` when nothing matches, or the element disappeared meanwhile.
|
|
21
|
+
*/
|
|
22
|
+
export async function callOnElement(access, whenMissing, body, params = [], args = [], method) {
|
|
23
|
+
const el = await access.resolveOne(method);
|
|
24
|
+
if (!el)
|
|
25
|
+
return whenMissing;
|
|
26
|
+
try {
|
|
27
|
+
return await access.call(`(el${params.map((param) => `, ${param}`).join("")}) => { ${body} }`, [el, ...args]);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
if (err instanceof BiDiError && err.code === "no such node")
|
|
31
|
+
return whenMissing;
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Like `callOnElement`, for operations that cannot go on without the element */
|
|
36
|
+
export async function callOnExistingElement(access, body, method) {
|
|
37
|
+
const missing = Symbol("missing");
|
|
38
|
+
const result = await callOnElement(access, missing, body, [], [], method);
|
|
39
|
+
if (result === missing)
|
|
40
|
+
throw noElementError(access.selector);
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
/** The error for an operation that needs an element when none matches */
|
|
44
|
+
export function noElementError(selector) {
|
|
45
|
+
return new Error(`Failed to locate element ${selector}. Details: no element matches`);
|
|
46
|
+
}
|
|
47
|
+
/** The element the locator matches now; throws when none does */
|
|
48
|
+
export async function requireElement(access) {
|
|
49
|
+
const el = await access.resolveOne();
|
|
50
|
+
if (!el)
|
|
51
|
+
throw noElementError(access.selector);
|
|
52
|
+
return el;
|
|
53
|
+
}
|