@itmetsam/samurai-framework 0.21.2 → 0.21.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +1 -1
  2. package/dist/assert/locator-assertions.d.ts +4 -0
  3. package/dist/assert/locator-assertions.js +10 -2
  4. package/dist/assert/text-match.d.ts +2 -2
  5. package/dist/assert/text-match.js +2 -2
  6. package/dist/browser/dialog.js +4 -1
  7. package/dist/browser/init-script.d.ts +11 -0
  8. package/dist/browser/init-script.js +29 -0
  9. package/dist/browser/page-locators.d.ts +18 -0
  10. package/dist/browser/page-locators.js +19 -0
  11. package/dist/browser/page-permissions.d.ts +6 -0
  12. package/dist/browser/page-permissions.js +15 -0
  13. package/dist/browser/page-settings.d.ts +3 -0
  14. package/dist/browser/page-settings.js +4 -0
  15. package/dist/browser/page.d.ts +29 -13
  16. package/dist/browser/page.js +46 -45
  17. package/dist/config/variables.js +2 -1
  18. package/dist/locator/accessible-name.d.ts +22 -0
  19. package/dist/locator/accessible-name.js +128 -0
  20. package/dist/locator/action-timeout-error.js +5 -1
  21. package/dist/locator/actionability.d.ts +3 -2
  22. package/dist/locator/actionability.js +4 -4
  23. package/dist/locator/attribute-locator.d.ts +13 -0
  24. package/dist/locator/attribute-locator.js +27 -0
  25. package/dist/locator/chain-resolver.d.ts +12 -3
  26. package/dist/locator/chain-resolver.js +85 -15
  27. package/dist/locator/check-actions.d.ts +15 -0
  28. package/dist/locator/check-actions.js +44 -0
  29. package/dist/locator/combine-step.d.ts +5 -0
  30. package/dist/locator/combine-step.js +15 -0
  31. package/dist/locator/control-probe.d.ts +10 -0
  32. package/dist/locator/control-probe.js +50 -0
  33. package/dist/locator/element-access.d.ts +24 -0
  34. package/dist/locator/element-access.js +53 -0
  35. package/dist/locator/element-actions.d.ts +17 -0
  36. package/dist/locator/element-actions.js +51 -0
  37. package/dist/locator/element-reads.d.ts +29 -4
  38. package/dist/locator/element-reads.js +83 -2
  39. package/dist/locator/element-state.d.ts +5 -0
  40. package/dist/locator/element-state.js +2 -0
  41. package/dist/locator/file-actions.d.ts +2 -2
  42. package/dist/locator/filter-step.d.ts +24 -0
  43. package/dist/locator/filter-step.js +72 -0
  44. package/dist/locator/label-locator.d.ts +1 -1
  45. package/dist/locator/locator.d.ts +94 -29
  46. package/dist/locator/locator.js +183 -151
  47. package/dist/locator/page-scripts.d.ts +2 -0
  48. package/dist/locator/page-scripts.js +6 -0
  49. package/dist/locator/page-search.d.ts +11 -2
  50. package/dist/locator/page-search.js +15 -3
  51. package/dist/locator/page-step.d.ts +16 -0
  52. package/dist/locator/page-step.js +35 -0
  53. package/dist/locator/role-locator.d.ts +65 -0
  54. package/dist/locator/role-locator.js +125 -0
  55. package/dist/locator/role-table.d.ts +18 -0
  56. package/dist/locator/role-table.js +190 -0
  57. package/dist/locator/select-actions.d.ts +13 -0
  58. package/dist/locator/select-actions.js +73 -0
  59. package/dist/locator/selector-string.d.ts +7 -0
  60. package/dist/locator/selector-string.js +27 -0
  61. package/dist/locator/selector.d.ts +47 -15
  62. package/dist/locator/selector.js +83 -12
  63. package/dist/locator/strict-mode.d.ts +6 -0
  64. package/dist/locator/strict-mode.js +6 -0
  65. package/dist/locator/text-locator.d.ts +1 -1
  66. package/dist/network/url-match.js +3 -1
  67. package/dist/types/config.d.ts +7 -0
  68. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { buildRoleSelector, isNativeRole, } from "./role-locator.js";
1
2
  function nonEmpty(value, what) {
2
3
  if (value.trim() === "")
3
4
  throw new TypeError(`${what} must not be empty`);
@@ -9,8 +10,26 @@ export function xpathSelector(value) {
9
10
  export function cssSelector(value) {
10
11
  return { kind: "css", value: nonEmpty(value, "a css selector") };
11
12
  }
12
- function textMatch(value, what, { match = "full", ignoreCase = false }) {
13
- return { value: nonEmpty(value, what), match, ignoreCase };
13
+ export function nthSelector(index) {
14
+ if (!Number.isInteger(index)) {
15
+ throw new TypeError(`nth(): index must be an integer, got ${index}`);
16
+ }
17
+ return { kind: "nth", index };
18
+ }
19
+ function textMatch(value, what, { exact = true, match, ignoreCase }) {
20
+ if (value instanceof RegExp) {
21
+ // The page tests it as written; g and y would make a repeated test depend on lastIndex
22
+ const plain = new RegExp(value.source, value.flags.replace(/[gy]/g, ""));
23
+ return { value: plain, match: "full", ignoreCase: false };
24
+ }
25
+ if (typeof value !== "string") {
26
+ throw new TypeError(`${what} must be a string or a RegExp`);
27
+ }
28
+ return {
29
+ value: nonEmpty(value, what),
30
+ match: match ?? (exact ? "full" : "partial"),
31
+ ignoreCase: ignoreCase ?? !exact,
32
+ };
14
33
  }
15
34
  export function textSelector(value, options = {}) {
16
35
  return { kind: "text", ...textMatch(value, "the text", options) };
@@ -18,14 +37,24 @@ export function textSelector(value, options = {}) {
18
37
  export function labelSelector(value, options = {}) {
19
38
  return { kind: "label", ...textMatch(value, "the label", options) };
20
39
  }
40
+ const ATTRIBUTE_WHAT = {
41
+ placeholder: "the placeholder",
42
+ alt: "the alt text",
43
+ title: "the title",
44
+ };
45
+ export function attributeSelector(attribute, value, options = {}) {
46
+ return {
47
+ kind: "attribute",
48
+ attribute,
49
+ ...textMatch(value, ATTRIBUTE_WHAT[attribute], options),
50
+ };
51
+ }
21
52
  export function testIdSelector(value) {
22
53
  return { kind: "testid", value: nonEmpty(value, "the test id") };
23
54
  }
24
- export function roleSelector(role, { name } = {}) {
55
+ export function roleSelector(role, options = {}) {
25
56
  nonEmpty(role, "the role");
26
- return name === undefined
27
- ? { kind: "role", role }
28
- : { kind: "role", role, name };
57
+ return buildRoleSelector(role, options);
29
58
  }
30
59
  /**
31
60
  * Normalises an xpath. Relative xpaths get a leading `//` (`.//` when `scoped`, i.e. searching inside
@@ -48,6 +77,9 @@ export function toBiDiLocator(selector, scoped) {
48
77
  case "css":
49
78
  return { type: "css", value: selector.value };
50
79
  case "text":
80
+ if (typeof selector.value !== "string") {
81
+ throw new Error("a RegExp text is resolved by the framework, not by the browser");
82
+ }
51
83
  return {
52
84
  type: "innerText",
53
85
  value: selector.value,
@@ -55,6 +87,9 @@ export function toBiDiLocator(selector, scoped) {
55
87
  ignoreCase: selector.ignoreCase,
56
88
  };
57
89
  case "role":
90
+ if (!isNativeRole(selector) || typeof selector.name === "object") {
91
+ throw new Error("this role search is resolved by the framework, not by the browser");
92
+ }
58
93
  return {
59
94
  type: "accessibility",
60
95
  value: {
@@ -69,9 +104,14 @@ export function toBiDiLocator(selector, scoped) {
69
104
  };
70
105
  // The locator resolves these two itself, so a chain never hands them to the browser
71
106
  case "label":
72
- throw new Error("label is resolved by the framework, not by the browser");
107
+ case "attribute":
108
+ throw new Error(`${selector.kind} is resolved by the framework, not by the browser`);
73
109
  case "nth":
74
110
  throw new Error("nth is resolved by the framework, not by the browser");
111
+ case "filter":
112
+ case "and":
113
+ case "or":
114
+ throw new Error(`${selector.kind} is resolved by the framework, not by the browser`);
75
115
  }
76
116
  }
77
117
  /** A short name for error messages */
@@ -87,19 +127,50 @@ export function describeSelector(selector, scoped) {
87
127
  selector.match === "partial" && "partial",
88
128
  selector.ignoreCase && "ignoring case",
89
129
  ].filter(Boolean);
90
- return `${selector.kind}=${JSON.stringify(selector.value)}${flags.length ? ` (${flags.join(", ")})` : ""}`;
130
+ return `${selector.kind}=${describeValue(selector.value)}${flags.length ? ` (${flags.join(", ")})` : ""}`;
131
+ }
132
+ case "attribute": {
133
+ const flags = [
134
+ selector.match === "partial" && "partial",
135
+ selector.ignoreCase && "ignoring case",
136
+ ].filter(Boolean);
137
+ return `${selector.attribute}=${describeValue(selector.value)}${flags.length ? ` (${flags.join(", ")})` : ""}`;
91
138
  }
92
139
  case "testid":
93
140
  return `testid=${JSON.stringify(selector.value)}`;
94
141
  case "role":
95
- return (`role=${selector.role}` +
96
- (selector.name === undefined
97
- ? ""
98
- : `[name=${JSON.stringify(selector.name)}]`));
142
+ return `role=${selector.role}${describeRoleOptions(selector)}`;
99
143
  case "nth":
100
144
  return `nth=${selector.index}`;
145
+ case "filter":
146
+ return `filter(${describeFilter(selector)})`;
147
+ case "and":
148
+ case "or":
149
+ return `(${describeChain(selector.left)}) ${selector.kind} (${describeChain(selector.right)})`;
101
150
  }
102
151
  }
152
+ function describeValue(value) {
153
+ return typeof value === "string" ? JSON.stringify(value) : String(value);
154
+ }
155
+ function describeRoleOptions({ kind: _kind, role: _role, name, exact, ...rest }) {
156
+ const parts = [
157
+ name !== undefined && `name=${describeValue(name)}`,
158
+ exact === false && "exact=false",
159
+ ...Object.entries(rest).map(([key, value]) => `${key}=${String(value)}`),
160
+ ].filter(Boolean);
161
+ return parts.length ? `[${parts.join(", ")}]` : "";
162
+ }
163
+ function describeFilter(tests) {
164
+ return [
165
+ tests.hasText !== undefined && `hasText=${describeValue(tests.hasText)}`,
166
+ tests.hasNotText !== undefined &&
167
+ `hasNotText=${describeValue(tests.hasNotText)}`,
168
+ tests.has && `has=${describeChain(tests.has)}`,
169
+ tests.hasNot && `hasNot=${describeChain(tests.hasNot)}`,
170
+ ]
171
+ .filter(Boolean)
172
+ .join(", ");
173
+ }
103
174
  export function describeChain(chain) {
104
175
  return chain
105
176
  .map((selector, index) => describeSelector(selector, index > 0))
@@ -0,0 +1,6 @@
1
+ /** Whether a locator throws when it matches several elements; asked when an action or read needs its element */
2
+ export type StrictSource = () => Promise<boolean>;
3
+ /** The default: the first match is used */
4
+ export declare const NOT_STRICT: StrictSource;
5
+ /** The error a strict locator throws for an action or read that found several elements */
6
+ export declare function strictViolation(method: string, count: number, selector: string): Error;
@@ -0,0 +1,6 @@
1
+ /** The default: the first match is used */
2
+ export const NOT_STRICT = () => Promise.resolve(false);
3
+ /** The error a strict locator throws for an action or read that found several elements */
4
+ export function strictViolation(method, count, selector) {
5
+ return new Error(`${method}(): strict mode violation, ${count} elements match ${selector}`);
6
+ }
@@ -10,4 +10,4 @@
10
10
  *
11
11
  * A string, so the transpiler cannot inject helpers that do not exist in the page.
12
12
  */
13
- export declare const TEXT_LOCATE = "(starts, value, match, ignoreCase) => {\n const skipped = new Set([\"SCRIPT\", \"STYLE\", \"NOSCRIPT\", \"TEMPLATE\", \"HEAD\", \"TITLE\", \"META\", \"LINK\"]);\n \n const normalise = (text) => {\n const collapsed = String(text).replace(/\\s+/g, \" \").trim();\n return ignoreCase ? collapsed.toLowerCase() : collapsed;\n };\n const wanted = normalise(value);\n const textMatches = (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(\"*\")) {\n if (seen.has(el) || skipped.has(el.tagName.toUpperCase())) continue;\n seen.add(el);\n if (textMatches(el.innerText ?? el.textContent ?? \"\")) matches.push(el);\n }\n }\n const hasMatchingDescendant = new Set();\n for (const el of matches) {\n for (let parent = el.parentElement; parent; parent = parent.parentElement) hasMatchingDescendant.add(parent);\n }\n return matches\n .filter((el) => !hasMatchingDescendant.has(el))\n .sort((a, b) => (a === b ? 0 : a.compareDocumentPosition(b) & 4 ? -1 : 1));\n}";
13
+ export declare const TEXT_LOCATE = "(starts, value, match, ignoreCase) => {\n const skipped = new Set([\"SCRIPT\", \"STYLE\", \"NOSCRIPT\", \"TEMPLATE\", \"HEAD\", \"TITLE\", \"META\", \"LINK\"]);\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(\"*\")) {\n if (seen.has(el) || skipped.has(el.tagName.toUpperCase())) continue;\n seen.add(el);\n if (textMatches(el.innerText ?? el.textContent ?? \"\")) matches.push(el);\n }\n }\n const hasMatchingDescendant = new Set();\n for (const el of matches) {\n for (let parent = el.parentElement; parent; parent = parent.parentElement) hasMatchingDescendant.add(parent);\n }\n return matches\n .filter((el) => !hasMatchingDescendant.has(el))\n .sort((a, b) => (a === b ? 0 : a.compareDocumentPosition(b) & 4 ? -1 : 1));\n}";
@@ -24,7 +24,9 @@ function globToRegExp(glob) {
24
24
  /** Whether `url` matches `pattern` */
25
25
  export function matchUrl(pattern, url) {
26
26
  if (typeof pattern === "string") {
27
- return isExactString(pattern) ? pattern === url : globToRegExp(pattern).test(url);
27
+ return isExactString(pattern)
28
+ ? pattern === url
29
+ : globToRegExp(pattern).test(url);
28
30
  }
29
31
  // A global or sticky RegExp remembers where it stopped; every match must start fresh
30
32
  pattern.lastIndex = 0;
@@ -142,6 +142,13 @@ export interface SamuraiTestConfig {
142
142
  * Left out, a run is headless when the `CI` environment variable is set and shows a window otherwise
143
143
  */
144
144
  headless?: boolean;
145
+ /**
146
+ * Make an action or read on a locator that matches several elements throw
147
+ * `name(): strict mode violation, N elements match ...` instead of using the first one. Narrow the locator
148
+ * (`first()`, `nth()`, `filter()`) or turn it off for one locator with `locator.strict(false)`
149
+ * @default false
150
+ */
151
+ strictSelectors?: boolean;
145
152
  };
146
153
  /** What to do with the browser's console output and uncaught page exceptions */
147
154
  logs?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itmetsam/samurai-framework",
3
- "version": "0.21.2",
3
+ "version": "0.21.3",
4
4
  "description": "Browser test framework over WebDriver BiDi: auto-waiting locators, environments and secrets, a recorder, and a command line.",
5
5
  "main": "dist/api.js",
6
6
  "exports": {