@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
@@ -0,0 +1,35 @@
1
+ import { ATTRIBUTE_LOCATE } from "./attribute-locator.js";
2
+ import { LABEL_LOCATE } from "./label-locator.js";
3
+ import { isNativeRole, ROLE_LOCATE, roleSearchArgs } from "./role-locator.js";
4
+ import { TEXT_LOCATE } from "./text-locator.js";
5
+ /** The arguments of the text-matching searches: `(value, match, ignoreCase)`, and the attribute for `attribute` steps */
6
+ function textArgs(selector) {
7
+ const args = [selector.value, selector.match, selector.ignoreCase];
8
+ return selector.kind === "attribute" ? [...args, selector.attribute] : args;
9
+ }
10
+ /** The text search of a `text` step, which the browser's own `innerText` locator cannot do (or does not exist) */
11
+ export function textSearch(selector) {
12
+ return { fn: TEXT_LOCATE, args: textArgs(selector) };
13
+ }
14
+ /**
15
+ * The page-side search for a step the browser cannot locate by itself: labels, attributes, RegExp texts and roles with a RegExp name or options.
16
+ * `undefined` when the browser's locator does the job
17
+ */
18
+ export function pageSearch(selector) {
19
+ switch (selector.kind) {
20
+ case "label":
21
+ return { fn: LABEL_LOCATE, args: textArgs(selector) };
22
+ case "attribute":
23
+ return { fn: ATTRIBUTE_LOCATE, args: textArgs(selector) };
24
+ case "text":
25
+ return typeof selector.value === "string"
26
+ ? undefined
27
+ : textSearch(selector);
28
+ case "role":
29
+ return isNativeRole(selector)
30
+ ? undefined
31
+ : { fn: ROLE_LOCATE, args: [...roleSearchArgs(selector)] };
32
+ default:
33
+ return undefined;
34
+ }
35
+ }
@@ -0,0 +1,65 @@
1
+ /** What `getByRole` takes beside the role */
2
+ export interface RoleOptions {
3
+ /**
4
+ * The accessible name. A string is matched in full and case-sensitively (whitespace normalised) unless `exact` is
5
+ * false; a RegExp is tested on the name as written (flags kept, `g` and `y` dropped) and ignores `exact`
6
+ */
7
+ name?: string | RegExp;
8
+ /**
9
+ * `true` (the default) keeps the full-string, case-sensitive name match; `false` switches this call to Playwright's
10
+ * matching: a case-insensitive substring. This is the one intentional difference from Playwright, whose default is
11
+ * the case-insensitive substring. Does not apply to a RegExp
12
+ * @default true
13
+ */
14
+ exact?: boolean;
15
+ /** The heading level: `h1` to `h6`, else `aria-level` */
16
+ level?: number;
17
+ /** `checked` of a checkbox, radio or switch, from the element's state or `aria-checked`; `"mixed"` for indeterminate */
18
+ checked?: boolean | "mixed";
19
+ /** `aria-pressed` of a toggle button */
20
+ pressed?: boolean | "mixed";
21
+ /** `aria-expanded` */
22
+ expanded?: boolean;
23
+ /** `selected` of an option, or `aria-selected` */
24
+ selected?: boolean;
25
+ /** Disabled by `disabled` (also through a disabled fieldset) or by `aria-disabled` on it or an ancestor */
26
+ disabled?: boolean;
27
+ /** Also match elements that are not rendered or are hidden from assistive technology (`aria-hidden`). @default false */
28
+ includeHidden?: boolean;
29
+ }
30
+ /** A `role` step: the options that are not their default, so the plain steps stay `{ kind, role, name }` */
31
+ export interface RoleSelector extends Omit<RoleOptions, "exact" | "includeHidden"> {
32
+ kind: "role";
33
+ role: string;
34
+ /** Present (as `false`) only when the name is matched loosely */
35
+ exact?: false;
36
+ /** Present (as `true`) only when hidden elements count */
37
+ includeHidden?: true;
38
+ }
39
+ /** The `role` step for `getByRole(role, options)`; `nonEmpty` rejects a blank role */
40
+ export declare function buildRoleSelector(role: string, options: RoleOptions): RoleSelector;
41
+ /** True when the browser's accessibility locator does the whole job: no RegExp, no loose match, no state, nothing hidden */
42
+ export declare function isNativeRole(selector: RoleSelector): boolean;
43
+ /** The options of a step as the page-side search takes them */
44
+ export declare function roleSearchArgs(selector: RoleSelector): readonly [string, {
45
+ /** Present (as `false`) only when the name is matched loosely */
46
+ exact?: false;
47
+ /** Present (as `true`) only when hidden elements count */
48
+ includeHidden?: true;
49
+ name?: (string | RegExp) | undefined;
50
+ level?: number | undefined;
51
+ checked?: boolean | "mixed" | undefined;
52
+ pressed?: boolean | "mixed" | undefined;
53
+ expanded?: boolean | undefined;
54
+ selected?: boolean | undefined;
55
+ disabled?: boolean | undefined;
56
+ }];
57
+ /**
58
+ * Function declaration run in the page as `(starts, role, options)`; returns the elements with the role that pass
59
+ * every option, unique and in document order. This is the search for what the browser's accessibility locator cannot
60
+ * do (see `isNativeRole`); it is the same in Firefox and Chrome, which differ on ignored nodes (Chrome's locator
61
+ * also returns `aria-hidden` elements). The role is the `role` attribute's first token, else the implicit role (`ROLE_OF_JS`).
62
+ *
63
+ * A string, so the transpiler cannot inject helpers that do not exist in the page.
64
+ */
65
+ export declare const ROLE_LOCATE: string;
@@ -0,0 +1,125 @@
1
+ import { ACCESSIBLE_NAME_JS } from "./accessible-name.js";
2
+ import { DOCUMENT_ORDER_JS, MATCHING_JS, ROOTS_JS } from "./page-search.js";
3
+ const STATE_OPTIONS = [
4
+ "level",
5
+ "checked",
6
+ "pressed",
7
+ "expanded",
8
+ "selected",
9
+ "disabled",
10
+ ];
11
+ function checkOption(name, ok, expected) {
12
+ if (!ok)
13
+ throw new TypeError(`getByRole(): ${name} must be ${expected}`);
14
+ }
15
+ /** The `role` step for `getByRole(role, options)`; `nonEmpty` rejects a blank role */
16
+ export function buildRoleSelector(role, options) {
17
+ if (typeof options !== "object" || options === null) {
18
+ throw new TypeError("getByRole(): options must be an object");
19
+ }
20
+ const { name, exact = true, includeHidden = false } = options;
21
+ const step = { kind: "role", role };
22
+ if (name !== undefined) {
23
+ checkOption("name", typeof name === "string" || name instanceof RegExp, "a string or a RegExp");
24
+ step.name =
25
+ name instanceof RegExp
26
+ ? new RegExp(name.source, name.flags.replace(/[gy]/g, ""))
27
+ : name;
28
+ }
29
+ if (exact === false)
30
+ step.exact = false;
31
+ if (includeHidden === true)
32
+ step.includeHidden = true;
33
+ const { level, checked, pressed, expanded, selected, disabled } = options;
34
+ checkOption("level", level === undefined || (Number.isInteger(level) && level >= 1), "a positive integer");
35
+ for (const [key, value] of [
36
+ ["checked", checked],
37
+ ["pressed", pressed],
38
+ ]) {
39
+ checkOption(key, value === undefined || typeof value === "boolean" || value === "mixed", 'a boolean or "mixed"');
40
+ }
41
+ for (const [key, value] of [
42
+ ["expanded", expanded],
43
+ ["selected", selected],
44
+ ["disabled", disabled],
45
+ ]) {
46
+ checkOption(key, value === undefined || typeof value === "boolean", "a boolean");
47
+ }
48
+ for (const key of STATE_OPTIONS) {
49
+ if (options[key] !== undefined)
50
+ Object.assign(step, { [key]: options[key] });
51
+ }
52
+ return step;
53
+ }
54
+ /** True when the browser's accessibility locator does the whole job: no RegExp, no loose match, no state, nothing hidden */
55
+ export function isNativeRole(selector) {
56
+ return ((selector.name === undefined || typeof selector.name === "string") &&
57
+ selector.exact === undefined &&
58
+ selector.includeHidden === undefined &&
59
+ STATE_OPTIONS.every((key) => selector[key] === undefined));
60
+ }
61
+ /** The options of a step as the page-side search takes them */
62
+ export function roleSearchArgs(selector) {
63
+ const { kind: _kind, role: _role, ...options } = selector;
64
+ return [selector.role, options];
65
+ }
66
+ /** Page-side source: whether `el` has the state `want` in `property` (`checked`, `pressed`, ...) */
67
+ const STATES_JS = `
68
+ const tokenState = (value) => (value === "true" ? true : value === "mixed" ? "mixed" : false);
69
+ const ancestorsOrSelf = function* (el) { for (let n = el; n; n = n.parentElement) yield n; };
70
+ const stateOf = {
71
+ checked: (el) => {
72
+ const type = (el.getAttribute("type") || "").toLowerCase();
73
+ if (el.localName === "input" && (type === "checkbox" || type === "radio")) return el.indeterminate ? "mixed" : el.checked;
74
+ return tokenState(el.getAttribute("aria-checked"));
75
+ },
76
+ pressed: (el) => tokenState(el.getAttribute("aria-pressed")),
77
+ expanded: (el) => el.getAttribute("aria-expanded") === "true" || (el.localName === "details" && el.open === true),
78
+ selected: (el) => (el.localName === "option" ? el.selected : el.getAttribute("aria-selected") === "true"),
79
+ disabled: (el) => {
80
+ if (typeof el.matches === "function" && el.matches(":disabled")) return true;
81
+ for (const n of ancestorsOrSelf(el)) if (n.getAttribute("aria-disabled") === "true") return true;
82
+ return false;
83
+ },
84
+ level: (el) => {
85
+ const heading = /^h([1-6])$/.exec(el.localName);
86
+ const own = Number(el.getAttribute("aria-level"));
87
+ return own > 0 ? own : heading ? Number(heading[1]) : 0;
88
+ },
89
+ };`;
90
+ /**
91
+ * Function declaration run in the page as `(starts, role, options)`; returns the elements with the role that pass
92
+ * every option, unique and in document order. This is the search for what the browser's accessibility locator cannot
93
+ * do (see `isNativeRole`); it is the same in Firefox and Chrome, which differ on ignored nodes (Chrome's locator
94
+ * also returns `aria-hidden` elements). The role is the `role` attribute's first token, else the implicit role (`ROLE_OF_JS`).
95
+ *
96
+ * A string, so the transpiler cannot inject helpers that do not exist in the page.
97
+ */
98
+ export const ROLE_LOCATE = `(starts, wantedRole, options) => {
99
+ ${ACCESSIBLE_NAME_JS}
100
+ ${STATES_JS}
101
+ const hasName = options.name !== undefined;
102
+ const value = hasName ? options.name : "";
103
+ const match = options.exact === false ? "partial" : "full";
104
+ const ignoreCase = options.exact === false;
105
+ ${MATCHING_JS}
106
+ ${ROOTS_JS}
107
+ const role = wantedRole.toLowerCase();
108
+ const seen = new Set();
109
+ const matches = [];
110
+ for (const root of roots) {
111
+ for (const el of root.querySelectorAll("*")) {
112
+ if (seen.has(el)) continue;
113
+ seen.add(el);
114
+ if (roleOf(el) !== role) continue;
115
+ if (!options.includeHidden && isHidden(el)) continue;
116
+ let ok = true;
117
+ for (const key of ["checked", "pressed", "expanded", "selected", "disabled", "level"]) {
118
+ if (options[key] !== undefined && stateOf[key](el) !== options[key]) { ok = false; break; }
119
+ }
120
+ if (ok && hasName && !textMatches(accessibleName(el))) ok = false;
121
+ if (ok) matches.push(el);
122
+ }
123
+ }
124
+ return matches.sort(${DOCUMENT_ORDER_JS});
125
+ }`;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The implicit ARIA role of an element, for the in-page role search (`getByRole` with a RegExp name, `exact: false`,
3
+ * or a state option). A table of tags, a few attribute rules and a few context rules, in the spirit of HTML-AAM;
4
+ * the browser's own accessibility tree is the reference for the plain `getByRole(role, { name })`, which does not use this.
5
+ */
6
+ /** Tags with one role whatever their attributes */
7
+ export declare const TAG_ROLES: Readonly<Record<string, string>>;
8
+ /**
9
+ * Roles whose accessible name may come from their content (ARIA 1.2 "name from: contents"), which
10
+ * `accessibleName` reads for the element itself, not only for what it embeds.
11
+ */
12
+ export declare const NAME_FROM_CONTENT_ROLES: readonly string[];
13
+ /**
14
+ * Page-side source: `roleOf(el)` is the role of an element: the first token of `role=""`, else the implicit role,
15
+ * else `""` (no role). Reads only `localName`, `getAttribute`, `hasAttribute` and `parentElement`, so a unit test can
16
+ * run it on a fake element. `div` and `span` are `generic`.
17
+ */
18
+ export declare const ROLE_OF_JS: string;
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The implicit ARIA role of an element, for the in-page role search (`getByRole` with a RegExp name, `exact: false`,
3
+ * or a state option). A table of tags, a few attribute rules and a few context rules, in the spirit of HTML-AAM;
4
+ * the browser's own accessibility tree is the reference for the plain `getByRole(role, { name })`, which does not use this.
5
+ */
6
+ /** Tags with one role whatever their attributes */
7
+ export const TAG_ROLES = {
8
+ address: "group",
9
+ article: "article",
10
+ aside: "complementary",
11
+ blockquote: "blockquote",
12
+ button: "button",
13
+ caption: "caption",
14
+ code: "code",
15
+ dd: "definition",
16
+ del: "deletion",
17
+ details: "group",
18
+ dfn: "term",
19
+ dialog: "dialog",
20
+ dt: "term",
21
+ em: "emphasis",
22
+ fieldset: "group",
23
+ figure: "figure",
24
+ h1: "heading",
25
+ h2: "heading",
26
+ h3: "heading",
27
+ h4: "heading",
28
+ h5: "heading",
29
+ h6: "heading",
30
+ hgroup: "group",
31
+ hr: "separator",
32
+ ins: "insertion",
33
+ li: "listitem",
34
+ main: "main",
35
+ mark: "mark",
36
+ menu: "list",
37
+ meter: "meter",
38
+ nav: "navigation",
39
+ ol: "list",
40
+ optgroup: "group",
41
+ option: "option",
42
+ output: "status",
43
+ p: "paragraph",
44
+ progress: "progressbar",
45
+ search: "search",
46
+ strong: "strong",
47
+ sub: "subscript",
48
+ sup: "superscript",
49
+ table: "table",
50
+ tbody: "rowgroup",
51
+ tfoot: "rowgroup",
52
+ thead: "rowgroup",
53
+ textarea: "textbox",
54
+ time: "time",
55
+ tr: "row",
56
+ ul: "list",
57
+ };
58
+ /** `<input type>` to role; an unknown type is a textbox, `hidden` and `file` have none */
59
+ const INPUT_ROLES = {
60
+ button: "button",
61
+ checkbox: "checkbox",
62
+ image: "button",
63
+ number: "spinbutton",
64
+ radio: "radio",
65
+ range: "slider",
66
+ reset: "button",
67
+ search: "searchbox",
68
+ submit: "button",
69
+ };
70
+ /** Input types that have no role at all */
71
+ const INPUT_TYPES_WITHOUT_ROLE = [
72
+ "hidden",
73
+ "file",
74
+ "color",
75
+ "date",
76
+ "datetime-local",
77
+ "month",
78
+ "time",
79
+ "week",
80
+ ];
81
+ /** Landmarks that stop a `header` / `footer` from being the page's banner / content info */
82
+ const SECTIONING_TAGS = [
83
+ "article",
84
+ "aside",
85
+ "main",
86
+ "nav",
87
+ "section",
88
+ ];
89
+ const SECTIONING_ROLES = [
90
+ "article",
91
+ "complementary",
92
+ "main",
93
+ "navigation",
94
+ "region",
95
+ ];
96
+ /**
97
+ * Roles whose accessible name may come from their content (ARIA 1.2 "name from: contents"), which
98
+ * `accessibleName` reads for the element itself, not only for what it embeds.
99
+ */
100
+ export const NAME_FROM_CONTENT_ROLES = [
101
+ "button",
102
+ "cell",
103
+ "checkbox",
104
+ "columnheader",
105
+ "gridcell",
106
+ "heading",
107
+ "link",
108
+ "menuitem",
109
+ "menuitemcheckbox",
110
+ "menuitemradio",
111
+ "option",
112
+ "radio",
113
+ "row",
114
+ "rowheader",
115
+ "switch",
116
+ "tab",
117
+ "tooltip",
118
+ "treeitem",
119
+ ];
120
+ /**
121
+ * Page-side source: `roleOf(el)` is the role of an element: the first token of `role=""`, else the implicit role,
122
+ * else `""` (no role). Reads only `localName`, `getAttribute`, `hasAttribute` and `parentElement`, so a unit test can
123
+ * run it on a fake element. `div` and `span` are `generic`.
124
+ */
125
+ export const ROLE_OF_JS = `
126
+ const TAG_ROLES = ${JSON.stringify(TAG_ROLES)};
127
+ const INPUT_ROLES = ${JSON.stringify(INPUT_ROLES)};
128
+ const NO_ROLE_INPUTS = ${JSON.stringify(INPUT_TYPES_WITHOUT_ROLE)};
129
+ const SECTIONING_TAGS = ${JSON.stringify(SECTIONING_TAGS)};
130
+ const SECTIONING_ROLES = ${JSON.stringify(SECTIONING_ROLES)};
131
+ const hasText = (el, name) => (el.getAttribute(name) || "").trim() !== "";
132
+ const isNamed = (el) => hasText(el, "aria-label") || hasText(el, "aria-labelledby");
133
+ const insideSectioning = (el) => {
134
+ for (let n = el.parentElement; n; n = n.parentElement) {
135
+ const explicit = (n.getAttribute("role") || "").trim().split(/\\s+/)[0];
136
+ if (SECTIONING_TAGS.includes(n.localName) || SECTIONING_ROLES.includes(explicit)) return true;
137
+ }
138
+ return false;
139
+ };
140
+ const inGrid = (el) => {
141
+ for (let n = el.parentElement; n; n = n.parentElement) {
142
+ const explicit = (n.getAttribute("role") || "").trim().split(/\\s+/)[0];
143
+ if (explicit === "grid" || explicit === "treegrid") return true;
144
+ }
145
+ return false;
146
+ };
147
+ const implicitRole = (el) => {
148
+ const tag = el.localName;
149
+ switch (tag) {
150
+ case "a":
151
+ case "area":
152
+ return el.hasAttribute("href") ? "link" : tag === "a" ? "generic" : "";
153
+ case "input": {
154
+ const type = (el.getAttribute("type") || "text").toLowerCase();
155
+ if (NO_ROLE_INPUTS.includes(type)) return "";
156
+ const mapped = INPUT_ROLES[type];
157
+ if (mapped) return type === "search" && el.hasAttribute("list") ? "combobox" : mapped;
158
+ return el.hasAttribute("list") ? "combobox" : "textbox";
159
+ }
160
+ case "select":
161
+ return el.hasAttribute("multiple") || Number(el.getAttribute("size")) > 1 ? "listbox" : "combobox";
162
+ case "datalist":
163
+ return "listbox";
164
+ case "img":
165
+ return el.getAttribute("alt") === "" && !el.hasAttribute("title") ? "presentation" : "img";
166
+ case "section":
167
+ return isNamed(el) ? "region" : "";
168
+ case "form":
169
+ return isNamed(el) ? "form" : "";
170
+ case "header":
171
+ return insideSectioning(el) ? "" : "banner";
172
+ case "footer":
173
+ return insideSectioning(el) ? "" : "contentinfo";
174
+ case "td":
175
+ return inGrid(el) ? "gridcell" : "cell";
176
+ case "th": {
177
+ const scope = (el.getAttribute("scope") || "").toLowerCase();
178
+ return scope === "row" || scope === "rowgroup" ? "rowheader" : "columnheader";
179
+ }
180
+ case "div":
181
+ case "span":
182
+ return "generic";
183
+ default:
184
+ return TAG_ROLES[tag] ?? "";
185
+ }
186
+ };
187
+ const roleOf = (el) => {
188
+ const explicit = (el.getAttribute("role") || "").trim().split(/\\s+/)[0];
189
+ return explicit ? explicit.toLowerCase() : implicitRole(el);
190
+ };`;
@@ -0,0 +1,13 @@
1
+ import type { ActionOptions } from "./actionability.js";
2
+ import { type ElementAccess } from "./element-access.js";
3
+ /** One option to select: a string matches the option's value or its label; an object must match every field it names */
4
+ export type SelectOptionValue = string | {
5
+ value?: string;
6
+ label?: string;
7
+ index?: number;
8
+ };
9
+ /**
10
+ * Selects the options of a `<select>` (or `<select multiple>`, whose selection it replaces) and returns the values
11
+ * now selected. Waits until the select is visible and enabled, and until every wanted option exists.
12
+ */
13
+ export declare function selectOptions(access: ElementAccess, values: SelectOptionValue | SelectOptionValue[], options?: ActionOptions): Promise<string[]>;
@@ -0,0 +1,73 @@
1
+ import { resolveTimeout } from "../config/timeouts.js";
2
+ import { waitUntil, WaitTimeoutError } from "../wait/wait-until.js";
3
+ import { callOnExistingElement } from "./element-access.js";
4
+ import { SELECT_CHECKS } from "./page-scripts.js";
5
+ /**
6
+ * Body run in the page as `(el, wanted) => { ... }`: selects the matching options of a `<select>`, then fires
7
+ * `input` and `change` once each. Selects nothing and reports what is missing when a wanted option is not there.
8
+ */
9
+ const SELECT_BODY = `
10
+ if (el.tagName !== "SELECT") return { notSelect: true };
11
+ const options = Array.from(el.options);
12
+ const matches = (option, want, index) =>
13
+ (want.any === undefined || option.value === want.any || option.label === want.any) &&
14
+ (want.value === undefined || option.value === want.value) &&
15
+ (want.label === undefined || option.label === want.label) &&
16
+ (want.index === undefined || index === want.index);
17
+ const missing = wanted.filter((want) => !options.some((option, i) => matches(option, want, i)));
18
+ if (missing.length) return { missing };
19
+ let chosen = options.filter((option, i) => wanted.some((want) => matches(option, want, i)));
20
+ if (!el.multiple) chosen = chosen.slice(0, 1);
21
+ el.focus();
22
+ for (const option of options) option.selected = chosen.includes(option);
23
+ el.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
24
+ el.dispatchEvent(new Event("change", { bubbles: true }));
25
+ return { values: Array.from(el.selectedOptions, (option) => option.value) };`;
26
+ function normalise(values) {
27
+ const list = Array.isArray(values) ? values : [values];
28
+ return list.map((item) => {
29
+ if (typeof item === "string")
30
+ return { any: item };
31
+ if (typeof item !== "object" || item === null) {
32
+ throw new TypeError(`selectOption(): expected a string, { value, label, index } or an array of those, got ${item === null ? "null" : typeof item}`);
33
+ }
34
+ const { value, label, index } = item;
35
+ return {
36
+ ...(value !== undefined && { value }),
37
+ ...(label !== undefined && { label }),
38
+ ...(index !== undefined && { index }),
39
+ };
40
+ });
41
+ }
42
+ function describeWanted(want) {
43
+ return want.any !== undefined
44
+ ? JSON.stringify(want.any)
45
+ : JSON.stringify(want);
46
+ }
47
+ /**
48
+ * Selects the options of a `<select>` (or `<select multiple>`, whose selection it replaces) and returns the values
49
+ * now selected. Waits until the select is visible and enabled, and until every wanted option exists.
50
+ */
51
+ export async function selectOptions(access, values, options) {
52
+ const wanted = normalise(values);
53
+ await access.waitForActionable("selectOption", SELECT_CHECKS, { scroll: true, hitTest: false }, options);
54
+ const timeout = await resolveTimeout(options?.timeout);
55
+ const attempt = () => callOnExistingElement(access, `return ((el, wanted) => { ${SELECT_BODY} })(el, ${JSON.stringify(wanted)});`, "selectOption");
56
+ let outcome;
57
+ try {
58
+ outcome = await waitUntil(attempt, (current) => !("missing" in current), {
59
+ timeout,
60
+ });
61
+ }
62
+ catch (err) {
63
+ if (!(err instanceof WaitTimeoutError))
64
+ throw err;
65
+ const last = err.last;
66
+ const missing = last && "missing" in last ? last.missing : [];
67
+ throw new Error(`selectOption(): no option matches ${missing.map(describeWanted).join(", ")} in ${access.selector} within ${timeout}ms`);
68
+ }
69
+ if ("notSelect" in outcome) {
70
+ throw new Error(`selectOption(): ${access.selector} is not a <select>`);
71
+ }
72
+ return outcome.values;
73
+ }
@@ -0,0 +1,7 @@
1
+ import { type Selector } from "./selector.js";
2
+ /**
3
+ * The selector step for the string `locator(…)` takes. `xpath=…` and `css=…` name the kind. A string starting
4
+ * with `//`, `/`, `(`, `..` or `./` is an xpath, as is a relative path that cannot be css (`a[@id='x']`,
5
+ * `button[text()='Go']`). Anything else is css (`input[name='x']`, `a, button`, `main`).
6
+ */
7
+ export declare function selectorFromString(value: string): Selector;
@@ -0,0 +1,27 @@
1
+ import { cssSelector, xpathSelector } from "./selector.js";
2
+ /** Prefixes that make a string an xpath, as in Playwright: `//a`, `/html`, `(//a)[2]`, `../x`, `./x` */
3
+ const XPATH_START = /^(?:\/|\(|\.\.|\.\/)/;
4
+ /**
5
+ * What only an xpath written without a leading `//` (the older form of `locator("a[@id='x']")`) has and a
6
+ * css selector cannot: `@`, `/`, `::`, a function call (a `(` that does not follow a `:pseudo-class`) or a
7
+ * positional predicate (`li[2]`, `li[last()]`). Quoted text is ignored, so `a[href^='/x']` stays css.
8
+ */
9
+ const RELATIVE_XPATH = /@|\/|::|(?<!:[\w-]+)\(|\[\s*(?:\d|last\b)/;
10
+ const QUOTED = /(["'])(?:\\.|(?!\1).)*\1/g;
11
+ /**
12
+ * The selector step for the string `locator(…)` takes. `xpath=…` and `css=…` name the kind. A string starting
13
+ * with `//`, `/`, `(`, `..` or `./` is an xpath, as is a relative path that cannot be css (`a[@id='x']`,
14
+ * `button[text()='Go']`). Anything else is css (`input[name='x']`, `a, button`, `main`).
15
+ */
16
+ export function selectorFromString(value) {
17
+ const text = value.trim();
18
+ if (text.startsWith("xpath="))
19
+ return xpathSelector(text.slice(6));
20
+ if (text.startsWith("css="))
21
+ return cssSelector(text.slice(4));
22
+ if (text === "" || XPATH_START.test(text))
23
+ return xpathSelector(text);
24
+ return RELATIVE_XPATH.test(text.replace(QUOTED, "''"))
25
+ ? xpathSelector(text)
26
+ : cssSelector(text);
27
+ }
@@ -1,4 +1,5 @@
1
1
  import type { NodeLocator } from "../types/bidi-modules/browsing-context.js";
2
+ import { type RoleOptions, type RoleSelector } from "./role-locator.js";
2
3
  /** One step of a locator. A locator is a chain of them, each searching inside the previous step's matches */
3
4
  export type Selector = {
4
5
  kind: "xpath";
@@ -8,45 +9,76 @@ export type Selector = {
8
9
  value: string;
9
10
  } | ({
10
11
  kind: "text";
11
- } & TextMatch) | {
12
- kind: "role";
13
- role: string;
14
- name?: string;
15
- }
12
+ } & TextMatch) | RoleSelector
16
13
  /** Form controls named by a `<label>`, `aria-labelledby` or `aria-label` whose text matches */
17
14
  | ({
18
15
  kind: "label";
19
16
  } & TextMatch)
17
+ /** Elements whose `placeholder`, `alt` or `title` attribute matches */
18
+ | ({
19
+ kind: "attribute";
20
+ attribute: TextAttribute;
21
+ } & TextMatch)
20
22
  /** Elements whose `data-testid` attribute equals `value` */
21
23
  | {
22
24
  kind: "testid";
23
25
  value: string;
24
26
  }
25
- /** Picks the n-th (0-based) of everything the chain so far matches; `all()` builds these */
27
+ /** Picks the n-th (0-based) of everything the chain so far matches; a negative index counts from the end. `nth()` and `all()` build these */
26
28
  | {
27
29
  kind: "nth";
28
30
  index: number;
31
+ }
32
+ /** Keeps the elements the chain so far matched that pass every given test (see `filterSelector`) */
33
+ | ({
34
+ kind: "filter";
35
+ } & FilterTests)
36
+ /** The elements two whole chains both match (`and`), or either matches (`or`, in document order); built by `and()` and `or()` */
37
+ | {
38
+ kind: "and" | "or";
39
+ left: readonly Selector[];
40
+ right: readonly Selector[];
29
41
  };
30
- /** What a text or label step looks for */
42
+ /** A text test of a filter: a string is a case-insensitive substring, a RegExp is tested on the element's text */
43
+ export type TextCriterion = string | RegExp;
44
+ /** The tests of a `filter` step; `has` and `hasNot` are chains searched inside each candidate */
45
+ export interface FilterTests {
46
+ hasText?: TextCriterion;
47
+ hasNotText?: TextCriterion;
48
+ has?: readonly Selector[];
49
+ hasNot?: readonly Selector[];
50
+ }
51
+ /** The attributes `getByPlaceholder`, `getByAltText` and `getByTitle` look at */
52
+ export type TextAttribute = "placeholder" | "alt" | "title";
53
+ /** What a text, label or attribute step looks for; a RegExp is tested as written (`match` and `ignoreCase` do not apply) */
31
54
  interface TextMatch {
32
- value: string;
55
+ value: string | RegExp;
33
56
  match: "full" | "partial";
34
57
  ignoreCase: boolean;
35
58
  }
36
59
  export interface TextOptions {
37
- /** @default "full" */
60
+ /**
61
+ * `true` (the default) keeps the full-string, case-sensitive match; `false` switches this call to Playwright's
62
+ * matching: a case-insensitive substring, whitespace normalised. Does not apply to a RegExp. This is the one
63
+ * intentional difference from Playwright, whose default is the case-insensitive substring.
64
+ * @default true
65
+ */
66
+ exact?: boolean;
67
+ /** @default "full", or "partial" with `exact: false` */
38
68
  match?: "full" | "partial";
39
- /** @default false */
69
+ /** @default false, or true with `exact: false` */
40
70
  ignoreCase?: boolean;
41
71
  }
72
+ /** What `getByPlaceholder`, `getByAltText` and `getByTitle` take */
73
+ export type ExactOption = Pick<TextOptions, "exact">;
42
74
  export declare function xpathSelector(value: string): Selector;
43
75
  export declare function cssSelector(value: string): Selector;
44
- export declare function textSelector(value: string, options?: TextOptions): Selector;
45
- export declare function labelSelector(value: string, options?: TextOptions): Selector;
76
+ export declare function nthSelector(index: number): Selector;
77
+ export declare function textSelector(value: string | RegExp, options?: TextOptions): Selector;
78
+ export declare function labelSelector(value: string | RegExp, options?: TextOptions): Selector;
79
+ export declare function attributeSelector(attribute: TextAttribute, value: string | RegExp, options?: ExactOption): Selector;
46
80
  export declare function testIdSelector(value: string): Selector;
47
- export declare function roleSelector(role: string, { name }?: {
48
- name?: string;
49
- }): Selector;
81
+ export declare function roleSelector(role: string, options?: RoleOptions): Selector;
50
82
  /**
51
83
  * Normalises an xpath. Relative xpaths get a leading `//` (`.//` when `scoped`, i.e. searching inside
52
84
  * a parent match). Absolute (`/…`), grouped (`(…)`) and context (`./…`) paths are kept; in a scoped