@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.
- package/README.md +1 -1
- package/dist/assert/asymmetric.d.ts +29 -0
- package/dist/assert/asymmetric.js +166 -0
- package/dist/assert/element-probes.d.ts +20 -0
- package/dist/assert/element-probes.js +45 -0
- package/dist/assert/expect.d.ts +20 -9
- package/dist/assert/expect.js +12 -4
- package/dist/assert/locator-assertion-base.d.ts +52 -0
- package/dist/assert/locator-assertion-base.js +78 -0
- package/dist/assert/locator-assertions.d.ts +30 -49
- package/dist/assert/locator-assertions.js +44 -79
- package/dist/assert/more-locator-assertions.d.ts +30 -0
- package/dist/assert/more-locator-assertions.js +86 -0
- package/dist/assert/retry-assertions.d.ts +28 -0
- package/dist/assert/retry-assertions.js +113 -0
- package/dist/assert/text-match.d.ts +4 -2
- package/dist/assert/text-match.js +10 -2
- package/dist/assert/value-assertions.d.ts +29 -1
- package/dist/assert/value-assertions.js +84 -5
- package/dist/assert/value-matchers.d.ts +19 -0
- package/dist/assert/value-matchers.js +53 -0
- 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/navigation-error.d.ts +5 -1
- package/dist/browser/navigation-error.js +10 -2
- package/dist/browser/page-content.d.ts +5 -0
- package/dist/browser/page-content.js +43 -0
- package/dist/browser/page-evaluate.d.ts +9 -0
- package/dist/browser/page-evaluate.js +20 -0
- package/dist/browser/page-locators.d.ts +18 -0
- package/dist/browser/page-locators.js +19 -0
- package/dist/browser/page-navigation.d.ts +18 -0
- package/dist/browser/page-navigation.js +52 -2
- 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 +76 -13
- package/dist/browser/page.js +103 -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 +26 -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 +46 -4
- package/dist/locator/element-reads.js +114 -2
- package/dist/locator/element-state.d.ts +5 -0
- package/dist/locator/element-state.js +2 -0
- package/dist/locator/evaluate.d.ts +12 -0
- package/dist/locator/evaluate.js +36 -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 +118 -30
- package/dist/locator/locator.js +218 -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/url-match.js +3 -1
- package/dist/script/call-function.d.ts +7 -0
- package/dist/script/call-function.js +21 -3
- package/dist/types/config.d.ts +7 -0
- package/dist/wait/wait-until.d.ts +3 -1
- package/dist/wait/wait-until.js +7 -2
- package/package.json +1 -1
package/dist/browser/page.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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() {
|
package/dist/config/variables.js
CHANGED
|
@@ -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) ||
|
|
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
|
+
};`;
|
|
@@ -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
|
}
|