@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
@@ -1,5 +1,5 @@
1
1
  import Locator from "../locator/locator.js";
2
- import { cssSelector, labelSelector, roleSelector, testIdSelector, textSelector, } from "../locator/selector.js";
2
+ import { attributeSelector, cssSelector, labelSelector, roleSelector, testIdSelector, textSelector, } from "../locator/selector.js";
3
3
  import EventEmitter from "node:events";
4
4
  import path from "node:path";
5
5
  import { projectDir } from "../config/config.js";
@@ -19,27 +19,32 @@ import { DocumentWatcher } from "./document-watcher.js";
19
19
  import { PageNavigation } from "./page-navigation.js";
20
20
  import { PageNetwork } from "./page-network.js";
21
21
  import { PageTransfers } from "./page-transfers.js";
22
- import { initScriptSource } from "./init-script.js";
22
+ import { InitScripts } from "./init-script.js";
23
+ import { grantPermission } from "./page-permissions.js";
24
+ import { PageLocators } from "./page-locators.js";
23
25
  import { sourcesFor, waitForPageEvent, } from "./page-events.js";
24
26
  import { PageConfig, } from "./page-settings.js";
25
27
  import { applyViewport } from "./viewport.js";
26
28
  import { HelperRealm } from "../locator/helper-realm.js";
27
- import { addPreload } from "../script/preload.js";
28
29
  import { BiDiError } from "../transport/bidi-error.js";
29
30
  import { callFunction } from "../script/call-function.js";
31
+ import { readContent } from "./page-content.js";
32
+ import { evaluateInPage } from "./page-evaluate.js";
30
33
  export default class Page {
31
34
  /** `settings` override what `samurai.config.ts` says; without them the config is read when needed */
32
35
  constructor(biDiConnector, contextId, settings) {
33
36
  this.lazyTree = new Lazy();
34
- this.initScripts = new Set();
37
+ this.initScripts = new InitScripts();
35
38
  this.pageEvents = new EventEmitter();
36
39
  this.lazyLogs = new Lazy();
37
40
  this.errorsAllowed = false;
38
41
  this.isClosed = false;
42
+ this.knownViewport = null;
39
43
  this.biDiConnector = biDiConnector;
40
44
  this.id = contextId;
41
45
  this.pageConfig = new PageConfig(settings);
42
46
  this.helperRealm = new HelperRealm(biDiConnector, contextId);
47
+ this.locators = new PageLocators(biDiConnector, contextId, this.helperRealm, this.pageConfig, () => this.assertOpen());
43
48
  this.documents = new DocumentWatcher(biDiConnector, contextId);
44
49
  this.net = new PageNetwork(biDiConnector, contextId, {
45
50
  config: this.pageConfig,
@@ -70,33 +75,57 @@ export default class Page {
70
75
  tree() {
71
76
  return this.lazyTree.get(() => ContextTree.create(this.biDiConnector));
72
77
  }
73
- /** Locates an element on the page and returns an interactable object */
74
- locator(xpath) {
75
- return this.locate(xpath);
78
+ /**
79
+ * Locates elements on the page. An xpath (`//x`, `/html/body`, `(//x)[2]`, `./x`, `xpath=…`, or an older
80
+ * relative one such as `a[@id='x']`) or a css selector (`input[name='x']`, `a, button`, `css=…`).
81
+ * `options` filter the matches, as `locator.filter()` does
82
+ */
83
+ locator(selector, options) {
84
+ const found = this.locators.locate(selector);
85
+ return options === undefined ? found : found.filter(options);
76
86
  }
77
- locate(selector) {
78
- this.assertOpen();
79
- return new Locator(selector, this.biDiConnector, this.id, this.helperRealm);
87
+ /**
88
+ * Makes this page's locators strict (or not): an action or read on a locator matching several elements then
89
+ * throws `name(): strict mode violation, N elements match ...` instead of using the first. `undefined` goes back
90
+ * to config `use.strictSelectors` (off unless set). Applies to locators made afterwards and to earlier ones that have not
91
+ * run an action or read yet: a locator keeps the answer it got at its first one
92
+ */
93
+ setStrictSelectors(enabled) {
94
+ this.locators.setStrictSelectors(enabled);
80
95
  }
81
96
  /** Locates elements with a CSS selector */
82
97
  getByCss(css) {
83
- return this.locate([cssSelector(css)]);
98
+ return this.locators.locate([cssSelector(css)]);
84
99
  }
85
- /** Locates elements by their text: an exact match unless `match: "partial"` */
100
+ /** Locates elements by their text: an exact match unless `match: "partial"` or `exact: false`; a RegExp is tested as written (see `TextOptions`) */
86
101
  getByText(text, options) {
87
- return this.locate([textSelector(text, options)]);
102
+ return this.locators.locate([textSelector(text, options)]);
88
103
  }
89
- /** Locates form controls by their label text: an exact match unless `match: "partial"` */
104
+ /** Locates form controls by their label text: an exact match unless `match: "partial"` or `exact: false`; a RegExp is tested as written */
90
105
  getByLabel(text, options) {
91
- return this.locate([labelSelector(text, options)]);
106
+ return this.locators.locate([labelSelector(text, options)]);
107
+ }
108
+ /** Locates elements by their `placeholder` (see `TextOptions` for `exact` and RegExp) */
109
+ getByPlaceholder(text, options) {
110
+ return this.locators.locate([
111
+ attributeSelector("placeholder", text, options),
112
+ ]);
113
+ }
114
+ /** Locates elements by their `alt` text (images, areas, image inputs) */
115
+ getByAltText(text, options) {
116
+ return this.locators.locate([attributeSelector("alt", text, options)]);
117
+ }
118
+ /** Locates elements by their `title` attribute */
119
+ getByTitle(text, options) {
120
+ return this.locators.locate([attributeSelector("title", text, options)]);
92
121
  }
93
122
  /** Locates elements by their `data-testid` attribute */
94
123
  getByTestId(testId) {
95
- return this.locate([testIdSelector(testId)]);
124
+ return this.locators.locate([testIdSelector(testId)]);
96
125
  }
97
- /** Locates elements by ARIA role and, optionally, accessible name */
126
+ /** Locates elements by ARIA role and, optionally, accessible name and state (see `RoleOptions`) */
98
127
  getByRole(role, options) {
99
- return this.locate([roleSelector(role, options)]);
128
+ return this.locators.locate([roleSelector(role, options)]);
100
129
  }
101
130
  /**
102
131
  * Runs `script` before any page script in every document this page loads from now on, and once in
@@ -109,21 +138,7 @@ export default class Page {
109
138
  */
110
139
  async addInitScript(script, arg) {
111
140
  this.assertOpen();
112
- const source = initScriptSource(script, arg);
113
- const handle = await addPreload(this.biDiConnector, {
114
- source,
115
- contexts: [this.id],
116
- onRunError: "log",
117
- });
118
- const tracked = {
119
- id: handle.id,
120
- dispose: async () => {
121
- this.initScripts.delete(tracked);
122
- await handle.dispose();
123
- },
124
- };
125
- this.initScripts.add(tracked);
126
- return tracked;
141
+ return this.initScripts.add(this.biDiConnector, this.id, script, arg);
127
142
  }
128
143
  /**
129
144
  * Starts recording what a person does in the page as steps (see `Recorder`). The page must be driven by
@@ -137,7 +152,7 @@ export default class Page {
137
152
  }
138
153
  /** Removes everything the page registered in the browser: init scripts, framework helpers and event subscriptions */
139
154
  async dispose() {
140
- const scripts = [...this.initScripts];
155
+ const scripts = this.initScripts.all();
141
156
  const results = await Promise.allSettled([
142
157
  ...scripts.map((handle) => handle.dispose()),
143
158
  this.helperRealm.dispose(),
@@ -209,11 +224,49 @@ export default class Page {
209
224
  this.assertOpen();
210
225
  return callFunction(this.biDiConnector, this.id, "() => location.href", [], { awaitPromise: false });
211
226
  }
227
+ /**
228
+ * Waits until the URL matches `url` (a string equals it, or with `*` / `**` is a glob; a RegExp is tested on it;
229
+ * a function gets it as a `URL`), then until the page reached `waitUntil` (default "load"). Also sees `pushState`.
230
+ * @example
231
+ * await page.getByRole("button", { name: "Sign in" }).click();
232
+ * await page.waitForURL("**\/dashboard");
233
+ */
234
+ waitForURL(url, options) {
235
+ return this.nav.waitForURL(url, options);
236
+ }
237
+ /** The markup of the document now, doctype included */
238
+ async content() {
239
+ this.assertOpen();
240
+ return readContent(this.biDiConnector, this.id);
241
+ }
242
+ /**
243
+ * Replaces the document with `html` and waits for `waitUntil` (default "load"). Scripts and styles in it run and apply;
244
+ * the page keeps its URL and origin (it is not a `data:` page).
245
+ * @example
246
+ * await page.setContent("<button>Go</button><script>document.title = 'x'</script>");
247
+ */
248
+ async setContent(html, options) {
249
+ return this.nav.setContent(html, options);
250
+ }
212
251
  /** The title of the document now */
213
252
  async title() {
214
253
  this.assertOpen();
215
254
  return callFunction(this.biDiConnector, this.id, "() => document.title", [], { awaitPromise: false });
216
255
  }
256
+ /**
257
+ * Runs `fn` in the page, with `arg` as its only argument, and returns the result (a promise is awaited). `fn` is a function,
258
+ * the source of one (`"async () => ..."`), or any other string, which is an expression (`"document.title"`; it takes no argument).
259
+ * Values come back as plain JavaScript: Date, Map, Set, RegExp, BigInt, NaN, undefined, nested objects and arrays;
260
+ * an element as an `ElementHandle`; what cannot be copied (a function, window, error, promise) as a `RemoteObject` with its `type`.
261
+ * The page's own error message is kept (`ScriptError`). A function written under tsx must not contain named inner functions
262
+ * (the transpiler adds a `__name` helper the page lacks); a string always works.
263
+ * @example
264
+ * const count = await page.evaluate((min) => document.querySelectorAll("li").length > min, 3);
265
+ */
266
+ async evaluate(fn, arg) {
267
+ this.assertOpen();
268
+ return evaluateInPage(this.biDiConnector, this.id, fn, arg);
269
+ }
217
270
  /**
218
271
  * Sets the size of the viewport (and, optionally, the device pixel ratio); `null` puts the browser's own size back.
219
272
  * @example
@@ -222,6 +275,21 @@ export default class Page {
222
275
  async setViewport(viewport) {
223
276
  this.assertOpen();
224
277
  await applyViewport(this.biDiConnector, this.id, viewport);
278
+ this.knownViewport = viewport && {
279
+ width: viewport.width,
280
+ height: viewport.height,
281
+ };
282
+ }
283
+ /** Playwright's name for `setViewport` */
284
+ setViewportSize(size) {
285
+ return this.setViewport(size);
286
+ }
287
+ /**
288
+ * The size this page was last given (`setViewport`, `setViewportSize`, or the default from config `use.viewport` when the
289
+ * browser opened it), read at once; null before one is known, after `setViewport(null)` and when config turns the viewport off.
290
+ */
291
+ viewportSize() {
292
+ return this.knownViewport && { ...this.knownViewport };
225
293
  }
226
294
  /** Gives the page the size from config `use.viewport` (1280x720 by default); the browser does this when it opens a page */
227
295
  async applyDefaultViewport() {
@@ -302,17 +370,7 @@ export default class Page {
302
370
  */
303
371
  async grantPermission(name, options = {}) {
304
372
  this.assertOpen();
305
- const origin = options.origin ?? new URL(await this.url()).origin;
306
- // about:blank and data: pages have no origin to grant to
307
- if (origin === "null")
308
- throw new Error("grantPermission(): needs a page on a web origin, or an explicit origin option");
309
- const context = this.browserContext;
310
- await this.biDiConnector.send("permissions.setPermission", {
311
- descriptor: { name },
312
- state: "granted",
313
- origin,
314
- ...(context && !context.isDefault && { userContext: context.id }),
315
- });
373
+ await grantPermission(this.biDiConnector, () => this.url(), () => this.browserContext, name, options);
316
374
  }
317
375
  /** True once `close()` has run */
318
376
  get closed() {
@@ -18,7 +18,8 @@ export function isPassthroughKey(target, key) {
18
18
  export function createEnvFixture(environment, variables) {
19
19
  return new Proxy(Object.freeze({ ...variables }), {
20
20
  get(target, key, receiver) {
21
- if (isPassthroughKey(target, key) || Object.prototype.hasOwnProperty.call(target, key))
21
+ if (isPassthroughKey(target, key) ||
22
+ Object.prototype.hasOwnProperty.call(target, key))
22
23
  return Reflect.get(target, key, receiver);
23
24
  throw new UnknownVariableError(String(key), environment);
24
25
  },
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Page-side source that defines, in the scope it is pasted into:
3
+ *
4
+ * - `isHidden(el)`: not rendered or hidden from assistive technology: `display: none`, `visibility: hidden` or
5
+ * `collapse`, or `aria-hidden="true"` on the element or an ancestor.
6
+ * - `accessibleName(el)` and `accessibleDescription(el)`: strings, whitespace collapsed and trimmed.
7
+ * - `roleOf(el)` (from `ROLE_OF_JS`).
8
+ *
9
+ * The name follows the accname algorithm in a simplified form, the order being: `aria-labelledby` (hidden content is
10
+ * read when referenced directly), `aria-label`, the native text alternative (`alt` of img / area / input[type=image],
11
+ * `value` of button-like inputs, the `<label>`s of a form control, `<legend>`, `<caption>`, `<figcaption>`, svg
12
+ * `<title>`), the content for roles that take their name from it (and always for what an embedding walks through),
13
+ * the `title` attribute, and for a text field its `placeholder`. Embedded form controls count by their value, and
14
+ * `::before` / `::after` text by its CSS content. Hidden descendants are skipped. A reference loop stops at the
15
+ * element already visited. The description is `aria-describedby`, else `aria-description`, else the `title` when it was not
16
+ * used as the name.
17
+ *
18
+ * It deliberately differs from the browser's own accessibility tree: no CSS `display` quirks for table parts, no
19
+ * `<summary>` or `<details>` rules, no shadow DOM or slot flattening, no `aria-owns`, and `placeholder` counts as a name
20
+ * for inputs in both browsers' way (Firefox and Chrome disagree there).
21
+ */
22
+ export declare const ACCESSIBLE_NAME_JS: string;
@@ -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
+ };`;
@@ -1,4 +1,8 @@
1
- const SYMBOLS = { pass: "✓", fail: "✗", pending: "—" };
1
+ const SYMBOLS = {
2
+ pass: "✓",
3
+ fail: "✗",
4
+ pending: "—",
5
+ };
2
6
  function formatMessage({ action, selector, timeout, reason, checks, coveredBy, state, }) {
3
7
  switch (reason) {
4
8
  case "unreadable":
@@ -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
- resolveOne(): Promise<ElementHandle | null>;
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
- /** In document order; `max` limits the final result. Nothing is cached: the first match can change between polls */
15
- resolveChain(chain: readonly Selector[], max?: number): Promise<ElementHandle[]>;
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, value, match, ignoreCase) => elements` */
27
+ /** Runs a page-side search `(starts, ...args) => elements` */
19
28
  private locateInPage;
20
29
  }