@itmetsam/samurai-framework 0.21.2 → 0.21.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +1 -1
  2. package/dist/assert/asymmetric.d.ts +29 -0
  3. package/dist/assert/asymmetric.js +166 -0
  4. package/dist/assert/element-probes.d.ts +20 -0
  5. package/dist/assert/element-probes.js +45 -0
  6. package/dist/assert/expect.d.ts +20 -9
  7. package/dist/assert/expect.js +12 -4
  8. package/dist/assert/locator-assertion-base.d.ts +52 -0
  9. package/dist/assert/locator-assertion-base.js +78 -0
  10. package/dist/assert/locator-assertions.d.ts +30 -49
  11. package/dist/assert/locator-assertions.js +44 -79
  12. package/dist/assert/more-locator-assertions.d.ts +30 -0
  13. package/dist/assert/more-locator-assertions.js +86 -0
  14. package/dist/assert/retry-assertions.d.ts +28 -0
  15. package/dist/assert/retry-assertions.js +113 -0
  16. package/dist/assert/text-match.d.ts +4 -2
  17. package/dist/assert/text-match.js +10 -2
  18. package/dist/assert/value-assertions.d.ts +29 -1
  19. package/dist/assert/value-assertions.js +84 -5
  20. package/dist/assert/value-matchers.d.ts +19 -0
  21. package/dist/assert/value-matchers.js +53 -0
  22. package/dist/browser/dialog.js +4 -1
  23. package/dist/browser/init-script.d.ts +11 -0
  24. package/dist/browser/init-script.js +29 -0
  25. package/dist/browser/navigation-error.d.ts +5 -1
  26. package/dist/browser/navigation-error.js +10 -2
  27. package/dist/browser/page-content.d.ts +5 -0
  28. package/dist/browser/page-content.js +43 -0
  29. package/dist/browser/page-evaluate.d.ts +9 -0
  30. package/dist/browser/page-evaluate.js +20 -0
  31. package/dist/browser/page-locators.d.ts +18 -0
  32. package/dist/browser/page-locators.js +19 -0
  33. package/dist/browser/page-navigation.d.ts +18 -0
  34. package/dist/browser/page-navigation.js +52 -2
  35. package/dist/browser/page-permissions.d.ts +6 -0
  36. package/dist/browser/page-permissions.js +15 -0
  37. package/dist/browser/page-settings.d.ts +3 -0
  38. package/dist/browser/page-settings.js +4 -0
  39. package/dist/browser/page.d.ts +76 -13
  40. package/dist/browser/page.js +103 -45
  41. package/dist/config/variables.js +2 -1
  42. package/dist/locator/accessible-name.d.ts +22 -0
  43. package/dist/locator/accessible-name.js +128 -0
  44. package/dist/locator/action-timeout-error.js +5 -1
  45. package/dist/locator/actionability.d.ts +3 -2
  46. package/dist/locator/actionability.js +4 -4
  47. package/dist/locator/attribute-locator.d.ts +13 -0
  48. package/dist/locator/attribute-locator.js +27 -0
  49. package/dist/locator/chain-resolver.d.ts +12 -3
  50. package/dist/locator/chain-resolver.js +85 -15
  51. package/dist/locator/check-actions.d.ts +15 -0
  52. package/dist/locator/check-actions.js +44 -0
  53. package/dist/locator/combine-step.d.ts +5 -0
  54. package/dist/locator/combine-step.js +15 -0
  55. package/dist/locator/control-probe.d.ts +10 -0
  56. package/dist/locator/control-probe.js +50 -0
  57. package/dist/locator/element-access.d.ts +26 -0
  58. package/dist/locator/element-access.js +53 -0
  59. package/dist/locator/element-actions.d.ts +17 -0
  60. package/dist/locator/element-actions.js +51 -0
  61. package/dist/locator/element-reads.d.ts +46 -4
  62. package/dist/locator/element-reads.js +114 -2
  63. package/dist/locator/element-state.d.ts +5 -0
  64. package/dist/locator/element-state.js +2 -0
  65. package/dist/locator/evaluate.d.ts +12 -0
  66. package/dist/locator/evaluate.js +36 -0
  67. package/dist/locator/file-actions.d.ts +2 -2
  68. package/dist/locator/filter-step.d.ts +24 -0
  69. package/dist/locator/filter-step.js +72 -0
  70. package/dist/locator/label-locator.d.ts +1 -1
  71. package/dist/locator/locator.d.ts +118 -30
  72. package/dist/locator/locator.js +218 -151
  73. package/dist/locator/page-scripts.d.ts +2 -0
  74. package/dist/locator/page-scripts.js +6 -0
  75. package/dist/locator/page-search.d.ts +11 -2
  76. package/dist/locator/page-search.js +15 -3
  77. package/dist/locator/page-step.d.ts +16 -0
  78. package/dist/locator/page-step.js +35 -0
  79. package/dist/locator/role-locator.d.ts +65 -0
  80. package/dist/locator/role-locator.js +125 -0
  81. package/dist/locator/role-table.d.ts +18 -0
  82. package/dist/locator/role-table.js +190 -0
  83. package/dist/locator/select-actions.d.ts +13 -0
  84. package/dist/locator/select-actions.js +73 -0
  85. package/dist/locator/selector-string.d.ts +7 -0
  86. package/dist/locator/selector-string.js +27 -0
  87. package/dist/locator/selector.d.ts +47 -15
  88. package/dist/locator/selector.js +83 -12
  89. package/dist/locator/strict-mode.d.ts +6 -0
  90. package/dist/locator/strict-mode.js +6 -0
  91. package/dist/locator/text-locator.d.ts +1 -1
  92. package/dist/network/url-match.js +3 -1
  93. package/dist/script/call-function.d.ts +7 -0
  94. package/dist/script/call-function.js +21 -3
  95. package/dist/types/config.d.ts +7 -0
  96. package/dist/wait/wait-until.d.ts +3 -1
  97. package/dist/wait/wait-until.js +7 -2
  98. package/package.json +1 -1
@@ -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 { LABEL_LOCATE } from "./label-locator.js";
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
- /** In document order; `max` limits the final result. Nothing is cached: the first match can change between polls */
25
- async resolveChain(chain, max) {
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, index > 0, nodes, index === chain.length - 1 ? max : undefined);
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
- if (selector.kind === "label")
50
- return this.locateInPage(LABEL_LOCATE, selector, startNodes, max);
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(TEXT_LOCATE, selector, startNodes, max);
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(TEXT_LOCATE, selector, startNodes, max);
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, value, match, ignoreCase) => elements` */
93
- async locateInPage(fn, selector, startNodes, max) {
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
- selector.value,
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,26 @@
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
+ /** Every element the locator matches now, in document order; never strict, never waits */
12
+ resolveAll(): Promise<ElementHandle[]>;
13
+ }
14
+ /** Calls `fn` in the page; a throwing function becomes a "Failed to locate element" error */
15
+ export declare function callInPage<T>(access: Pick<ElementAccess, "connector" | "contextId" | "selector">, fn: string, args: Arg[], sandbox?: string): Promise<T>;
16
+ /**
17
+ * Calls a function on the first matching element: `body` sees it as `el`, followed by `params`.
18
+ * Answers `whenMissing` when nothing matches, or the element disappeared meanwhile.
19
+ */
20
+ export declare function callOnElement<T>(access: ElementAccess, whenMissing: T, body: string, params?: string[], args?: Arg[], method?: string): Promise<T>;
21
+ /** Like `callOnElement`, for operations that cannot go on without the element */
22
+ export declare function callOnExistingElement<T>(access: ElementAccess, body: string, method?: string): Promise<T>;
23
+ /** The error for an operation that needs an element when none matches */
24
+ export declare function noElementError(selector: string): Error;
25
+ /** The element the locator matches now; throws when none does */
26
+ 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
+ }
@@ -0,0 +1,17 @@
1
+ import { type ElementScreenshotOptions } from "../browser/screenshot.js";
2
+ import type { ActionOptions } from "./actionability.js";
3
+ import { type ElementAccess } from "./element-access.js";
4
+ /** Waits until the element is attached, visible, stable, enabled and not covered, then clicks its centre with real pointer input */
5
+ export declare function clickElement(access: ElementAccess, options?: ActionOptions): Promise<void>;
6
+ /** Takes a screenshot of just the element, after scrolling it into view and waiting until it is attached and visible */
7
+ export declare function screenshotElement(access: ElementAccess, options?: ElementScreenshotOptions & {
8
+ timeout?: number;
9
+ }): Promise<Buffer>;
10
+ /** Waits until the element is attached, then focuses it */
11
+ export declare function focusElement(access: ElementAccess, options?: ActionOptions): Promise<void>;
12
+ /** Waits until the element is attached, visible and enabled, focuses it and presses `key` with real keyboard input */
13
+ export declare function pressOnElement(access: ElementAccess, key: string, options?: ActionOptions): Promise<void>;
14
+ /** Waits until the field is editable and not covered, then replaces its value by typing `value`; an empty string clears it */
15
+ export declare function fillElement(access: ElementAccess, value: string, options?: ActionOptions & {
16
+ output?: string;
17
+ }): Promise<void>;
@@ -0,0 +1,51 @@
1
+ import { bindOutput } from "../wait/output-sink.js";
2
+ import { takeScreenshot, } from "../browser/screenshot.js";
3
+ import { callOnElement, callOnExistingElement, requireElement, } from "./element-access.js";
4
+ import { clickRect, DEFAULT_PAUSE_MS, NAMED_KEYS, pause, pressKey, typeText, } from "./input-actions.js";
5
+ import { ATTACHED_ONLY, CLICK_CHECKS, FILL_CHECKS, PRESS_CHECKS, SCREENSHOT_CHECKS, } from "./page-scripts.js";
6
+ /** Waits until the element is attached, visible, stable, enabled and not covered, then clicks its centre with real pointer input */
7
+ export async function clickElement(access, options) {
8
+ const state = await access.waitForActionable("click", CLICK_CHECKS, { scroll: true, hitTest: true }, options);
9
+ // "attached" is always required, so the box is present
10
+ await clickRect(access.connector, access.contextId, state.box);
11
+ }
12
+ /** Takes a screenshot of just the element, after scrolling it into view and waiting until it is attached and visible */
13
+ export async function screenshotElement(access, options = {}) {
14
+ const { timeout, ...capture } = options;
15
+ await access.waitForActionable("screenshot", SCREENSHOT_CHECKS, { scroll: true, hitTest: false }, timeout === undefined ? undefined : { timeout });
16
+ const el = await requireElement(access);
17
+ return takeScreenshot(access.connector, access.contextId, capture, {
18
+ sharedId: el.sharedId,
19
+ });
20
+ }
21
+ /** Waits until the element is attached, then focuses it */
22
+ export async function focusElement(access, options) {
23
+ await access.waitForActionable("focus", ATTACHED_ONLY, { scroll: false, hitTest: false }, options);
24
+ await callOnExistingElement(access, `el.focus();`);
25
+ }
26
+ /** Waits until the element is attached, visible and enabled, focuses it and presses `key` with real keyboard input */
27
+ export async function pressOnElement(access, key, options) {
28
+ const code = NAMED_KEYS[key] ?? (Array.from(key).length === 1 ? key : "");
29
+ if (code === "") {
30
+ throw new Error(`press(): unknown key "${key}". Use one of ${Object.keys(NAMED_KEYS).join(", ")} or a single character`);
31
+ }
32
+ await access.waitForActionable("press", PRESS_CHECKS, { scroll: true, hitTest: false }, options);
33
+ await callOnElement(access, undefined, `el.focus();`);
34
+ await pressKey(access.connector, access.contextId, code);
35
+ }
36
+ /** Waits until the field is editable and not covered, then replaces its value by typing `value`; an empty string clears it */
37
+ export async function fillElement(access, value, options) {
38
+ // Taken when the call starts, so a fill that outlives its test saves into that test's closed sink
39
+ const save = options?.output === undefined
40
+ ? undefined
41
+ : bindOutput("fill()", options.output);
42
+ const state = await access.waitForActionable("fill", FILL_CHECKS, { scroll: true, hitTest: true }, options);
43
+ // Typing over the selected existing value replaces it; Backspace clears it for an empty value
44
+ const keys = value === "" ? NAMED_KEYS.Backspace : value;
45
+ await clickRect(access.connector, access.contextId, state.box);
46
+ // Lets the focus the click caused (and the page's focus handlers) settle before the text is selected and typed over
47
+ await pause(access.connector, access.contextId, DEFAULT_PAUSE_MS);
48
+ await callOnElement(access, undefined, `if (typeof el.select === "function") el.select();`);
49
+ await typeText(access.connector, access.contextId, keys);
50
+ save?.(value);
51
+ }
@@ -1,4 +1,46 @@
1
- /** Body run in the page as `(el) => { ... }`: a checked checkbox or radio input, or `aria-checked="true"` */
2
- export declare const IS_CHECKED_BODY = "\n const type = (el.getAttribute(\"type\") || \"\").toLowerCase();\n const input = el.tagName === \"INPUT\" && (type === \"checkbox\" || type === \"radio\");\n return (input && el.checked) || el.getAttribute(\"aria-checked\") === \"true\";";
3
- /** The CSS property name for `getPropertyValue`: camelCase becomes kebab-case (`backgroundColor` to `background-color`), `--custom` and kebab-case names are kept */
4
- export declare function cssPropertyName(name: string): string;
1
+ import type { ElementRectangle } from "../types/bidi-modules/browsing-context.js";
2
+ import { type ActionabilityContext } from "./actionability.js";
3
+ import { type ElementAccess } from "./element-access.js";
4
+ /** Trimmed text content, or null when the element is missing */
5
+ export declare function readTextContent(access: ElementAccess): Promise<string | null>;
6
+ /** Rendered text (what a person would copy: line breaks, no hidden text), untrimmed, or null when the element is missing */
7
+ export declare function readInnerText(access: ElementAccess): Promise<string | null>;
8
+ /** The markup inside the element, or null when the element is missing */
9
+ export declare function readInnerHTML(access: ElementAccess): Promise<string | null>;
10
+ /** `textContent` of every match, as the page has it (untrimmed); an empty array when none match */
11
+ export declare function readAllTextContents(access: ElementAccess): Promise<string[]>;
12
+ /** `innerText` of every match; an empty array when none match */
13
+ export declare function readAllInnerTexts(access: ElementAccess): Promise<string[]>;
14
+ /** The element's box in CSS pixels from the viewport's top left */
15
+ export interface BoundingBox {
16
+ x: number;
17
+ y: number;
18
+ width: number;
19
+ height: number;
20
+ }
21
+ /** The element's box, or null when it is missing or not rendered (`display: none`, detached): it has no boxes then */
22
+ export declare function readBoundingBox(access: ElementAccess): Promise<BoundingBox | null>;
23
+ /** The accessible name (see `ACCESSIBLE_NAME_JS`), "" when it has none, or null when the element is missing */
24
+ export declare function readAccessibleName(access: ElementAccess): Promise<string | null>;
25
+ /** The accessible description, "" when it has none, or null when the element is missing */
26
+ export declare function readAccessibleDescription(access: ElementAccess): Promise<string | null>;
27
+ /** Value of an input/textarea/select, or null when missing */
28
+ export declare function readInputValue(access: ElementAccess): Promise<string | null>;
29
+ /** Attribute value, or null when the element or attribute is missing */
30
+ export declare function readAttribute(access: ElementAccess, name: string): Promise<string | null>;
31
+ /** Whether a checkbox or radio input is checked or the element has `aria-checked="true"`; null when the element is missing */
32
+ export declare function readIsChecked(access: ElementAccess): Promise<boolean | null>;
33
+ /** The class attribute as written ("" when the element has none), or null when the element is missing */
34
+ export declare function readClassAttribute(access: ElementAccess): Promise<string | null>;
35
+ /** Computed value of a CSS property (camelCase names become kebab-case, `--custom` is kept), or null when the element is missing */
36
+ export declare function readCssValue(access: ElementAccess, name: string): Promise<string | null>;
37
+ /** The browser's getBoundingClientRect result for the element */
38
+ export declare function readBoundingClientRect(access: ElementAccess): Promise<ElementRectangle>;
39
+ /** Whether the element exists, has a non-empty box and is not hidden by CSS */
40
+ export declare function readIsVisible(ctx: ActionabilityContext): Promise<boolean>;
41
+ /** Whether the element exists and is not disabled; does not wait */
42
+ export declare function readIsEnabled(ctx: ActionabilityContext): Promise<boolean>;
43
+ /** Like `readIsEnabled`, but null when the element is missing, so callers can tell "disabled" from "not there"; does not wait */
44
+ export declare function readEnabledState(ctx: ActionabilityContext): Promise<boolean | null>;
45
+ /** Whether the element exists and accepts typing (enabled, not readonly, a text field); does not wait */
46
+ export declare function readIsEditable(ctx: ActionabilityContext): Promise<boolean>;
@@ -1,11 +1,123 @@
1
+ import { probeState } from "./actionability.js";
2
+ import { callOnElement, callOnExistingElement, } from "./element-access.js";
3
+ import { describeType } from "./element-state.js";
4
+ import { ACCESSIBLE_NAME_JS } from "./accessible-name.js";
5
+ import { evaluateOnAll } from "./evaluate.js";
1
6
  /** Body run in the page as `(el) => { ... }`: a checked checkbox or radio input, or `aria-checked="true"` */
2
- export const IS_CHECKED_BODY = `
7
+ const IS_CHECKED_BODY = `
3
8
  const type = (el.getAttribute("type") || "").toLowerCase();
4
9
  const input = el.tagName === "INPUT" && (type === "checkbox" || type === "radio");
5
10
  return (input && el.checked) || el.getAttribute("aria-checked") === "true";`;
6
11
  /** The CSS property name for `getPropertyValue`: camelCase becomes kebab-case (`backgroundColor` to `background-color`), `--custom` and kebab-case names are kept */
7
- export function cssPropertyName(name) {
12
+ function cssPropertyName(name) {
8
13
  if (name.startsWith("--"))
9
14
  return name;
10
15
  return name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
11
16
  }
17
+ function booleanOrNull(value) {
18
+ if (typeof value === "boolean")
19
+ return value;
20
+ if (value === null)
21
+ return null;
22
+ throw new Error(`Expected a boolean or null but received "${describeType(value)}"`);
23
+ }
24
+ function stringOrNull(value) {
25
+ if (typeof value === "string")
26
+ return value;
27
+ if (value === null)
28
+ return null;
29
+ throw new Error(`Expected a string or null but received "${describeType(value)}"`);
30
+ }
31
+ /** Trimmed text content, or null when the element is missing */
32
+ export async function readTextContent(access) {
33
+ return stringOrNull(await callOnElement(access, null, `return el.textContent.trim();`, [], [], "textContent"));
34
+ }
35
+ /** Rendered text (what a person would copy: line breaks, no hidden text), untrimmed, or null when the element is missing */
36
+ export async function readInnerText(access) {
37
+ return stringOrNull(await callOnElement(access, null, `return el.innerText;`, [], [], "innerText"));
38
+ }
39
+ /** The markup inside the element, or null when the element is missing */
40
+ export async function readInnerHTML(access) {
41
+ return stringOrNull(await callOnElement(access, null, `return el.innerHTML;`, [], [], "innerHTML"));
42
+ }
43
+ /** `textContent` of every match, as the page has it (untrimmed); an empty array when none match */
44
+ export function readAllTextContents(access) {
45
+ return evaluateOnAll(access, `(els) => els.map((el) => el.textContent ?? "")`, undefined);
46
+ }
47
+ /** `innerText` of every match; an empty array when none match */
48
+ export function readAllInnerTexts(access) {
49
+ return evaluateOnAll(access, `(els) => els.map((el) => el.innerText ?? "")`, undefined);
50
+ }
51
+ /** The element's box, or null when it is missing or not rendered (`display: none`, detached): it has no boxes then */
52
+ export async function readBoundingBox(access) {
53
+ const box = await callOnElement(access, null, `
54
+ if (el.getClientRects().length === 0) return null;
55
+ const { x, y, width, height } = el.getBoundingClientRect();
56
+ return { x, y, width, height };
57
+ `, [], [], "boundingBox");
58
+ if (box === null)
59
+ return null;
60
+ if (typeof box !== "object") {
61
+ throw new Error(`Expected to receive an object but instead received "${describeType(box)}"`);
62
+ }
63
+ return box;
64
+ }
65
+ /** The accessible name (see `ACCESSIBLE_NAME_JS`), "" when it has none, or null when the element is missing */
66
+ export async function readAccessibleName(access) {
67
+ return stringOrNull(await callOnElement(access, null, `${ACCESSIBLE_NAME_JS} return accessibleName(el);`, [], [], "accessibleName"));
68
+ }
69
+ /** The accessible description, "" when it has none, or null when the element is missing */
70
+ export async function readAccessibleDescription(access) {
71
+ return stringOrNull(await callOnElement(access, null, `${ACCESSIBLE_NAME_JS} return accessibleDescription(el);`, [], [], "accessibleDescription"));
72
+ }
73
+ /** Value of an input/textarea/select, or null when missing */
74
+ export async function readInputValue(access) {
75
+ return stringOrNull(await callOnElement(access, null, `return typeof el.value === "string" ? el.value : null;`, [], [], "inputValue"));
76
+ }
77
+ /** Attribute value, or null when the element or attribute is missing */
78
+ export async function readAttribute(access, name) {
79
+ return stringOrNull(await callOnElement(access, null, `return el.getAttribute(name);`, ["name"], [name], "getAttribute"));
80
+ }
81
+ /** Whether a checkbox or radio input is checked or the element has `aria-checked="true"`; null when the element is missing */
82
+ export async function readIsChecked(access) {
83
+ return booleanOrNull(await callOnElement(access, null, IS_CHECKED_BODY, [], [], "isChecked"));
84
+ }
85
+ /** The class attribute as written ("" when the element has none), or null when the element is missing */
86
+ export async function readClassAttribute(access) {
87
+ return stringOrNull(await callOnElement(access, null, `return el.getAttribute("class") ?? "";`, [], [], "classAttribute"));
88
+ }
89
+ /** Computed value of a CSS property (camelCase names become kebab-case, `--custom` is kept), or null when the element is missing */
90
+ export async function readCssValue(access, name) {
91
+ return stringOrNull(await callOnElement(access, null, `return getComputedStyle(el).getPropertyValue(name);`, ["name"], [cssPropertyName(name)], "cssValue"));
92
+ }
93
+ /** The browser's getBoundingClientRect result for the element */
94
+ export async function readBoundingClientRect(access) {
95
+ const rect = await callOnExistingElement(access, `
96
+ const { x, y, width, height, top, right, bottom, left } = el.getBoundingClientRect();
97
+ return { x, y, width, height, top, right, bottom, left };
98
+ `, "getBoundingClientRect");
99
+ if (typeof rect !== "object" || rect === null) {
100
+ throw new Error(`Expected to receive an object but instead received "${describeType(rect)}"`);
101
+ }
102
+ return rect;
103
+ }
104
+ /** Whether the element exists, has a non-empty box and is not hidden by CSS */
105
+ export async function readIsVisible(ctx) {
106
+ const state = await probeState(ctx, { scroll: false, hitTest: false }, "isVisible");
107
+ return state.attached && state.visible;
108
+ }
109
+ /** Whether the element exists and is not disabled; does not wait */
110
+ export async function readIsEnabled(ctx) {
111
+ const state = await probeState(ctx, { scroll: false, hitTest: false }, "isEnabled");
112
+ return state.attached && state.enabled;
113
+ }
114
+ /** Like `readIsEnabled`, but null when the element is missing, so callers can tell "disabled" from "not there"; does not wait */
115
+ export async function readEnabledState(ctx) {
116
+ const state = await probeState(ctx, { scroll: false, hitTest: false }, "enabledState");
117
+ return state.attached ? state.enabled : null;
118
+ }
119
+ /** Whether the element exists and accepts typing (enabled, not readonly, a text field); does not wait */
120
+ export async function readIsEditable(ctx) {
121
+ const state = await probeState(ctx, { scroll: false, hitTest: false }, "isEditable");
122
+ return state.attached && state.editable;
123
+ }