@itmetsam/samurai-framework 0.21.1 → 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 (70) hide show
  1. package/README.md +8 -3
  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/network-tracker.d.ts +17 -1
  67. package/dist/network/network-tracker.js +63 -18
  68. package/dist/network/url-match.js +3 -1
  69. package/dist/types/config.d.ts +7 -0
  70. 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}";
@@ -11,6 +11,12 @@ export interface TrackerOptions {
11
11
  * Follows the network activity of one page (its frames included): which requests are in flight,
12
12
  * and `request` / `response` / `requestfailed` events. In-flight is keyed by `request id:redirect count`,
13
13
  * so a redirect chain is one live request at a time.
14
+ *
15
+ * Chrome (chromium-bidi) reports a redirect response only once the browser has started the next hop, and it can
16
+ * leave out that hop's `beforeRequestSent`: when the hop's `requestWillBeSentExtraInfo` reaches it before the
17
+ * redirect's `requestWillBeSent`, it files the extra info under the previous hop and never announces the new one.
18
+ * So on Chrome a redirect keeps the chain in flight until the next hop shows itself, and a hop that first shows up
19
+ * with its response or error is announced then.
14
20
  */
15
21
  export declare class NetworkTracker {
16
22
  private tree;
@@ -21,13 +27,16 @@ export declare class NetworkTracker {
21
27
  private subscription;
22
28
  /**
23
29
  * Every hop that is in flight or may still be the source of a redirect, by hop key. A redirect response ends a
24
- * hop's flight (`live` false) but keeps it, for the next hop's `redirectedFrom`.
30
+ * hop's flight (`live` false; on Chrome `answered`, live until the next hop shows) but keeps it, for the next
31
+ * hop's `redirectedFrom`.
25
32
  */
26
33
  private hops;
27
34
  /** Rejects a pending `waitForIdle`; each wait adds its own */
28
35
  private waiters;
29
36
  private disposed;
30
37
  private constructor();
38
+ /** Whether a redirect response means the next hop has already started (Chrome), see the class comment */
39
+ private get redirectsStartNextHop();
31
40
  static start(connector: BiDiConnector, tree: ContextTree, contextId: string, options?: TrackerOptions): Promise<NetworkTracker>;
32
41
  /** Requests currently in flight */
33
42
  get inflight(): number;
@@ -52,6 +61,13 @@ export declare class NetworkTracker {
52
61
  private owns;
53
62
  private static key;
54
63
  private onBeforeRequestSent;
64
+ /** Tracks a new hop and emits its `request`; the hop it was redirected from is done with */
65
+ private startHop;
66
+ /**
67
+ * The hop a response or error is for. A hop Chrome never announced (see the class comment) is started here,
68
+ * when the hop it was redirected from is known.
69
+ */
70
+ private hopOf;
55
71
  private onResponseCompleted;
56
72
  private onFetchError;
57
73
  /**
@@ -12,6 +12,12 @@ const URLS_IN_MESSAGE = 5;
12
12
  * Follows the network activity of one page (its frames included): which requests are in flight,
13
13
  * and `request` / `response` / `requestfailed` events. In-flight is keyed by `request id:redirect count`,
14
14
  * so a redirect chain is one live request at a time.
15
+ *
16
+ * Chrome (chromium-bidi) reports a redirect response only once the browser has started the next hop, and it can
17
+ * leave out that hop's `beforeRequestSent`: when the hop's `requestWillBeSentExtraInfo` reaches it before the
18
+ * redirect's `requestWillBeSent`, it files the extra info under the previous hop and never announces the new one.
19
+ * So on Chrome a redirect keeps the chain in flight until the next hop shows itself, and a hop that first shows up
20
+ * with its response or error is announced then.
15
21
  */
16
22
  export class NetworkTracker {
17
23
  constructor(tree, contextId, ignore, connector) {
@@ -22,7 +28,8 @@ export class NetworkTracker {
22
28
  this.events = new EventEmitter();
23
29
  /**
24
30
  * Every hop that is in flight or may still be the source of a redirect, by hop key. A redirect response ends a
25
- * hop's flight (`live` false) but keeps it, for the next hop's `redirectedFrom`.
31
+ * hop's flight (`live` false; on Chrome `answered`, live until the next hop shows) but keeps it, for the next
32
+ * hop's `redirectedFrom`.
26
33
  */
27
34
  this.hops = new Map();
28
35
  /** Rejects a pending `waitForIdle`; each wait adds its own */
@@ -31,30 +38,22 @@ export class NetworkTracker {
31
38
  this.onBeforeRequestSent = (params) => {
32
39
  if (!this.owns(params.context))
33
40
  return;
34
- const key = NetworkTracker.key(params.request, params.redirectCount);
35
- const { redirectCount } = params;
36
- const previousKey = `${params.request.request}:${redirectCount - 1}`;
37
- const redirectedFrom = redirectCount > 0 ? (this.hops.get(previousKey)?.request ?? null) : null;
38
- this.hops.delete(previousKey);
39
- const request = toRequest(params.request, params.navigation, params.isBlocked, redirectedFrom);
40
- if (this.ignore(request))
41
- return;
42
- this.hops.set(key, { request, context: params.context, live: true });
43
- this.emit("request", request);
44
- this.events.emit("change");
41
+ this.startHop(params);
45
42
  };
46
43
  this.onResponseCompleted = (params) => {
47
44
  if (!this.owns(params.context))
48
45
  return;
49
46
  const key = NetworkTracker.key(params.request, params.redirectCount);
50
- const hop = this.hops.get(key);
51
- if (!hop?.live)
47
+ const hop = this.hopOf(params);
48
+ if (!hop?.live || hop.answered)
52
49
  return;
53
50
  const { request } = hop;
54
51
  const { response } = params;
55
52
  // A redirect response keeps its request around: the next hop links back to it
56
53
  if (response.status < 300 || response.status >= 400)
57
54
  this.hops.delete(key);
55
+ else if (this.redirectsStartNextHop && hasLocation(response))
56
+ hop.answered = true;
58
57
  else
59
58
  hop.live = false;
60
59
  this.emit("response", {
@@ -71,11 +70,13 @@ export class NetworkTracker {
71
70
  if (!this.owns(params.context))
72
71
  return;
73
72
  const key = NetworkTracker.key(params.request, params.redirectCount);
74
- const hop = this.hops.get(key);
73
+ const hop = this.hopOf(params);
75
74
  if (!hop?.live)
76
75
  return;
77
76
  this.hops.delete(key);
78
- this.emit("requestfailed", Object.assign(hop.request, { errorText: params.errorText }));
77
+ // A hop that has its response already did not fail
78
+ if (!hop.answered)
79
+ this.emit("requestfailed", Object.assign(hop.request, { errorText: params.errorText }));
79
80
  this.events.emit("change");
80
81
  };
81
82
  /**
@@ -86,19 +87,24 @@ export class NetworkTracker {
86
87
  if (!this.owns(params.context))
87
88
  return;
88
89
  let changed = false;
89
- for (const [key, { request, context, live }] of [...this.hops]) {
90
+ for (const [key, { request, context, live, answered }] of [...this.hops]) {
90
91
  if (!live || request.navigation === params.navigation)
91
92
  continue;
92
93
  if (!this.tree.isWithin(context, params.context))
93
94
  continue;
94
95
  this.hops.delete(key);
95
- this.emit("requestfailed", Object.assign(request, { errorText: ABORTED_BY_NAVIGATION }));
96
+ if (!answered)
97
+ this.emit("requestfailed", Object.assign(request, { errorText: ABORTED_BY_NAVIGATION }));
96
98
  changed = true;
97
99
  }
98
100
  if (changed)
99
101
  this.events.emit("change");
100
102
  };
101
103
  }
104
+ /** Whether a redirect response means the next hop has already started (Chrome), see the class comment */
105
+ get redirectsStartNextHop() {
106
+ return this.connector.browserName === "chrome";
107
+ }
102
108
  static async start(connector, tree, contextId, options = {}) {
103
109
  const tracker = new NetworkTracker(tree, contextId, options.ignore ?? (() => false), connector);
104
110
  // Listen first, then subscribe: nothing can arrive in between
@@ -189,6 +195,41 @@ export class NetworkTracker {
189
195
  static key(data, redirectCount) {
190
196
  return `${data.request}:${redirectCount}`;
191
197
  }
198
+ /** Tracks a new hop and emits its `request`; the hop it was redirected from is done with */
199
+ startHop(params) {
200
+ const key = NetworkTracker.key(params.request, params.redirectCount);
201
+ const { redirectCount } = params;
202
+ const previousKey = `${params.request.request}:${redirectCount - 1}`;
203
+ const previous = redirectCount > 0 ? this.hops.get(previousKey) : undefined;
204
+ this.hops.delete(previousKey);
205
+ const request = toRequest(params.request, params.navigation, params.isBlocked, previous?.request ?? null);
206
+ if (this.ignore(request)) {
207
+ if (previous?.live)
208
+ this.events.emit("change");
209
+ return undefined;
210
+ }
211
+ const hop = {
212
+ request,
213
+ context: params.context,
214
+ live: true,
215
+ answered: false,
216
+ };
217
+ this.hops.set(key, hop);
218
+ this.emit("request", request);
219
+ this.events.emit("change");
220
+ return hop;
221
+ }
222
+ /**
223
+ * The hop a response or error is for. A hop Chrome never announced (see the class comment) is started here,
224
+ * when the hop it was redirected from is known.
225
+ */
226
+ hopOf(params) {
227
+ const hop = this.hops.get(NetworkTracker.key(params.request, params.redirectCount));
228
+ if (hop || params.redirectCount === 0)
229
+ return hop;
230
+ const previousKey = `${params.request.request}:${params.redirectCount - 1}`;
231
+ return this.hops.has(previousKey) ? this.startHop(params) : undefined;
232
+ }
192
233
  /** A listener that throws must not stop the tracker or the other listeners */
193
234
  emit(event, ...args) {
194
235
  for (const listener of this.events.listeners(event)) {
@@ -201,3 +242,7 @@ export class NetworkTracker {
201
242
  }
202
243
  }
203
244
  }
245
+ /** Whether a 3xx response says where to go next: only then does the browser follow it */
246
+ function hasLocation(response) {
247
+ return response.headers.some((header) => header.name.toLowerCase() === "location");
248
+ }
@@ -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.1",
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": {