@testspectra/matchers 1.0.70 → 1.1.0-rc.0

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 (51) hide show
  1. package/LICENSE.md +48 -0
  2. package/dist/__tests__/intercept.test.d.ts +1 -0
  3. package/dist/__tests__/intercept.test.js +183 -0
  4. package/dist/__tests__/matcher-contracts.test.d.ts +1 -0
  5. package/dist/__tests__/matcher-contracts.test.js +498 -0
  6. package/dist/__tests__/matchers.test.d.ts +1 -0
  7. package/dist/__tests__/matchers.test.js +161 -0
  8. package/dist/contract.d.ts +141 -0
  9. package/dist/contract.js +10 -0
  10. package/dist/index.d.ts +4 -21
  11. package/dist/index.js +4 -21
  12. package/dist/intercept/cdp-handler.d.ts +22 -0
  13. package/dist/intercept/cdp-handler.js +123 -0
  14. package/dist/intercept/index.d.ts +4 -0
  15. package/dist/intercept/index.js +4 -0
  16. package/dist/intercept/mock-handle.d.ts +49 -0
  17. package/dist/intercept/mock-handle.js +124 -0
  18. package/dist/intercept/mock-registry.d.ts +18 -0
  19. package/dist/intercept/mock-registry.js +97 -0
  20. package/dist/intercept/types.d.ts +43 -0
  21. package/dist/intercept/types.js +1 -0
  22. package/dist/matchers.d.ts +15 -4
  23. package/dist/matchers.js +609 -81
  24. package/dist/proto.d.ts +18 -283
  25. package/dist/proto.js +1 -114
  26. package/dist/reporter.d.ts +30 -0
  27. package/dist/reporter.js +97 -0
  28. package/dist/runner/collection.d.ts +33 -20
  29. package/dist/runner/collection.js +104 -26
  30. package/dist/runner/single.d.ts +125 -34
  31. package/dist/runner/single.js +290 -55
  32. package/dist/semantic.d.ts +11 -0
  33. package/dist/semantic.js +141 -0
  34. package/dist/spectra.d.ts +32 -8
  35. package/dist/spectra.js +157 -40
  36. package/dist/types.d.ts +1226 -28
  37. package/package.json +13 -8
  38. package/src/contract.ts +182 -0
  39. package/src/index.ts +4 -22
  40. package/src/proto.ts +22 -433
  41. package/src/runtime/assertions.ts +450 -0
  42. package/src/runtime/element_actions.ts +169 -0
  43. package/src/runtime/element_proxy.ts +159 -0
  44. package/src/runtime/element_state.ts +91 -0
  45. package/src/runtime/spectra.ts +109 -0
  46. package/src/types.ts +1424 -96
  47. package/tsconfig.json +2 -2
  48. package/src/matchers.ts +0 -146
  49. package/src/runner/collection.ts +0 -153
  50. package/src/runner/single.ts +0 -301
  51. package/src/spectra.ts +0 -376
package/dist/matchers.js CHANGED
@@ -5,125 +5,653 @@
5
5
  * @param target - Selector string or element instance.
6
6
  * @returns Resolved WebdriverIO.Element instance.
7
7
  */
8
+ /**
9
+ * Resolves an ElementTarget (selector string, WebdriverIO.Element, Playwright Locator, or ChainablePromiseElement)
10
+ * to an actionable element instance.
11
+ *
12
+ * @param target - Selector string or element instance.
13
+ * @returns Resolved element or locator instance.
14
+ */
8
15
  export async function resolveElement(target) {
9
- if (typeof target === "string") {
10
- const el = await $(target);
11
- return el;
16
+ if (typeof target === 'string') {
17
+ if (typeof globalThis.$ === 'function') {
18
+ const el = await globalThis.$(target);
19
+ return el;
20
+ }
21
+ const b = globalThis.browser;
22
+ if (b && typeof b.$ === 'function') {
23
+ const el = await b.$(target);
24
+ return el;
25
+ }
26
+ throw new Error(`Cannot resolve selector "${target}": No global '$' or browser context available.`);
12
27
  }
13
28
  const el = await target;
14
29
  return el;
15
30
  }
16
31
  /**
17
- * Executes a single element matcher against the WebdriverIO browser / element context.
32
+ * Executes a single element matcher against the active runner context (Playwright or WebdriverIO).
18
33
  *
19
34
  * @param target - Optional element target (not required for browser-level assertions).
20
35
  * @param matcher - Single element matcher string.
21
36
  * @param args - Arguments passed to the matcher.
22
37
  */
23
38
  export async function executeSingleMatcher(target, matcher, args) {
24
- const isBrowserLevel = matcher === "have.url" ||
25
- matcher === "contain.url" ||
26
- matcher === "have.title" ||
27
- matcher === "contain.title";
39
+ const isBrowserLevel = matcher === 'have.url' || matcher === 'contain.url' || matcher === 'have.title' || matcher === 'contain.title';
40
+ const b = typeof browser !== 'undefined' ? browser : globalThis.browser;
28
41
  if (isBrowserLevel) {
29
42
  const expected = args[0];
43
+ const exp = typeof expect !== 'undefined' ? expect : globalThis.expect;
44
+ if (exp && typeof exp(b)?.toHaveUrl === 'function') {
45
+ switch (matcher) {
46
+ case 'have.url':
47
+ await exp(b).toHaveUrl(expected);
48
+ break;
49
+ case 'contain.url':
50
+ await exp(b).toHaveUrl(exp.stringContaining
51
+ ? exp.stringContaining(expected)
52
+ : new RegExp(String(expected).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
53
+ break;
54
+ case 'have.title':
55
+ await exp(b).toHaveTitle(expected);
56
+ break;
57
+ case 'contain.title':
58
+ await exp(b).toHaveTitle(exp.stringContaining
59
+ ? exp.stringContaining(expected)
60
+ : new RegExp(String(expected).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
61
+ break;
62
+ }
63
+ }
64
+ else {
65
+ // Direct polling fallback (works with Playwright Page or generic browser)
66
+ const currentUrl = b?.getUrl
67
+ ? await b.getUrl()
68
+ : b?.url
69
+ ? typeof b.url === 'function'
70
+ ? await b.url()
71
+ : b.url
72
+ : '';
73
+ const currentTitle = b?.getTitle
74
+ ? await b.getTitle()
75
+ : b?.title
76
+ ? typeof b.title === 'function'
77
+ ? await b.title()
78
+ : b.title
79
+ : '';
80
+ if (matcher === 'have.url' && currentUrl !== expected) {
81
+ throw new Error(`Expected URL to be "${expected}", but got "${currentUrl}"`);
82
+ }
83
+ if (matcher === 'contain.url' && !currentUrl.includes(expected)) {
84
+ throw new Error(`Expected URL to contain "${expected}", but got "${currentUrl}"`);
85
+ }
86
+ if (matcher === 'have.title' && currentTitle !== expected) {
87
+ throw new Error(`Expected title to be "${expected}", but got "${currentTitle}"`);
88
+ }
89
+ if (matcher === 'contain.title' && !currentTitle.includes(expected)) {
90
+ throw new Error(`Expected title to contain "${expected}", but got "${currentTitle}"`);
91
+ }
92
+ }
93
+ return;
94
+ }
95
+ if (!target) {
96
+ throw new Error(`Assertion matcher '${matcher}' requires an element target.`);
97
+ }
98
+ const el = await resolveElement(target);
99
+ const exp = typeof expect !== 'undefined' ? expect : globalThis.expect;
100
+ // Branch 1: WebdriverIO expect-webdriverio
101
+ if (exp && typeof exp(el)?.toBeDisplayed === 'function') {
30
102
  switch (matcher) {
31
- case "have.url":
32
- await expect(browser).toHaveUrl(expected);
103
+ case 'be.visible':
104
+ await exp(el).toBeDisplayed();
105
+ break;
106
+ case 'not.be.visible':
107
+ await exp(el).not.toBeDisplayed();
108
+ break;
109
+ case 'exist':
110
+ await exp(el).toExist();
111
+ break;
112
+ case 'not.exist':
113
+ await exp(el).not.toExist();
114
+ break;
115
+ case 'be.clickable':
116
+ await exp(el).toBeClickable();
117
+ break;
118
+ case 'not.be.clickable':
119
+ await exp(el).not.toBeClickable();
120
+ break;
121
+ case 'be.enabled':
122
+ await exp(el).toBeEnabled();
123
+ break;
124
+ case 'be.disabled':
125
+ await exp(el).toBeDisabled();
126
+ break;
127
+ case 'be.checked':
128
+ case 'be.selected':
129
+ await exp(el).toBeSelected();
130
+ break;
131
+ case 'not.be.checked':
132
+ case 'not.be.selected':
133
+ await exp(el).not.toBeSelected();
134
+ break;
135
+ case 'be.focused':
136
+ await exp(el).toBeFocused();
137
+ break;
138
+ case 'not.be.focused':
139
+ await exp(el).not.toBeFocused();
140
+ break;
141
+ case 'have.value':
142
+ await exp(el).toHaveValue(args[0]);
143
+ break;
144
+ case 'not.have.value':
145
+ await exp(el).not.toHaveValue(args[0]);
146
+ break;
147
+ case 'contain.value':
148
+ await exp(el).toHaveValue(exp.stringContaining
149
+ ? exp.stringContaining(args[0])
150
+ : new RegExp(String(args[0]).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
151
+ break;
152
+ case 'not.contain.value':
153
+ await exp(el).not.toHaveValue(exp.stringContaining
154
+ ? exp.stringContaining(args[0])
155
+ : new RegExp(String(args[0]).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
156
+ break;
157
+ case 'have.text':
158
+ await exp(el).toHaveText(args[0]);
159
+ break;
160
+ case 'not.have.text':
161
+ await exp(el).not.toHaveText(args[0]);
162
+ break;
163
+ case 'contain.text':
164
+ await exp(el).toHaveText(exp.stringContaining
165
+ ? exp.stringContaining(args[0])
166
+ : new RegExp(String(args[0]).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
167
+ break;
168
+ case 'not.contain.text':
169
+ await exp(el).not.toHaveText(exp.stringContaining
170
+ ? exp.stringContaining(args[0])
171
+ : new RegExp(String(args[0]).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
33
172
  break;
34
- case "contain.url":
35
- await expect(browser).toHaveUrl(expect.stringContaining(expected));
173
+ case 'have.class':
174
+ await exp(el).toHaveElementClass(args[0]);
36
175
  break;
37
- case "have.title":
38
- await expect(browser).toHaveTitle(expected);
176
+ case 'not.have.class':
177
+ await exp(el).not.toHaveElementClass(args[0]);
39
178
  break;
40
- case "contain.title":
41
- await expect(browser).toHaveTitle(expect.stringContaining(expected));
179
+ case 'have.attr':
180
+ if (args.length >= 2)
181
+ await exp(el).toHaveAttribute(args[0], args[1]);
182
+ else
183
+ await exp(el).toHaveAttribute(args[0]);
42
184
  break;
185
+ case 'not.have.attr':
186
+ await exp(el).not.toHaveAttribute(args[0]);
187
+ break;
188
+ case 'have.css':
189
+ await exp(el).toHaveStyle({ [args[0]]: args[1] });
190
+ break;
191
+ case 'not.have.css':
192
+ await exp(el).not.toHaveStyle({ [args[0]]: args[1] });
193
+ break;
194
+ default:
195
+ throw new Error(`Unsupported matcher: ${matcher}`);
43
196
  }
44
197
  return;
45
198
  }
46
- if (!target) {
47
- throw new Error(`Assertion matcher '${matcher}' requires an element target.`);
199
+ // Branch 2: Playwright expect(locator)
200
+ if (exp && typeof exp(el)?.toBeVisible === 'function') {
201
+ switch (matcher) {
202
+ case 'be.visible':
203
+ await exp(el).toBeVisible();
204
+ break;
205
+ case 'not.be.visible':
206
+ await exp(el).not.toBeVisible();
207
+ break;
208
+ case 'exist':
209
+ await exp(el).toBeAttached();
210
+ break;
211
+ case 'not.exist':
212
+ await exp(el).not.toBeAttached();
213
+ break;
214
+ case 'be.clickable':
215
+ case 'be.enabled':
216
+ await exp(el).toBeEnabled();
217
+ break;
218
+ case 'not.be.clickable':
219
+ case 'be.disabled':
220
+ await exp(el).toBeDisabled();
221
+ break;
222
+ case 'be.checked':
223
+ case 'be.selected':
224
+ await exp(el).toBeChecked();
225
+ break;
226
+ case 'not.be.checked':
227
+ case 'not.be.selected':
228
+ await exp(el).not.toBeChecked();
229
+ break;
230
+ case 'be.focused':
231
+ await exp(el).toBeFocused();
232
+ break;
233
+ case 'not.be.focused':
234
+ await exp(el).not.toBeFocused();
235
+ break;
236
+ case 'have.value':
237
+ await exp(el).toHaveValue(args[0]);
238
+ break;
239
+ case 'not.have.value':
240
+ await exp(el).not.toHaveValue(args[0]);
241
+ break;
242
+ case 'contain.value':
243
+ await exp(el).toHaveValue(new RegExp(String(args[0]).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
244
+ break;
245
+ case 'not.contain.value':
246
+ await exp(el).not.toHaveValue(new RegExp(String(args[0]).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
247
+ break;
248
+ case 'have.text':
249
+ await exp(el).toHaveText(args[0]);
250
+ break;
251
+ case 'not.have.text':
252
+ await exp(el).not.toHaveText(args[0]);
253
+ break;
254
+ case 'contain.text':
255
+ await exp(el).toContainText(args[0]);
256
+ break;
257
+ case 'not.contain.text':
258
+ await exp(el).not.toContainText(args[0]);
259
+ break;
260
+ case 'have.class':
261
+ await exp(el).toHaveClass(new RegExp(`(^|\\s)${String(args[0]).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`));
262
+ break;
263
+ case 'not.have.class':
264
+ await exp(el).not.toHaveClass(new RegExp(`(^|\\s)${String(args[0]).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`));
265
+ break;
266
+ case 'have.attr':
267
+ if (args.length >= 2)
268
+ await exp(el).toHaveAttribute(args[0], args[1]);
269
+ else
270
+ await exp(el).toHaveAttribute(args[0], /.*/);
271
+ break;
272
+ case 'not.have.attr':
273
+ await exp(el).not.toHaveAttribute(args[0], /.*/);
274
+ break;
275
+ case 'have.css':
276
+ await exp(el).toHaveCSS(args[0], args[1]);
277
+ break;
278
+ case 'not.have.css':
279
+ await exp(el).not.toHaveCSS(args[0], args[1]);
280
+ break;
281
+ default:
282
+ throw new Error(`Unsupported matcher: ${matcher}`);
283
+ }
284
+ return;
48
285
  }
49
- const el = await resolveElement(target);
50
- switch (matcher) {
51
- case "be.visible":
52
- await expect(el).toBeDisplayed();
53
- break;
54
- case "not.be.visible":
55
- await expect(el).not.toBeDisplayed();
56
- break;
57
- case "exist":
58
- await expect(el).toExist();
59
- break;
60
- case "not.exist":
61
- await expect(el).not.toExist();
62
- break;
63
- case "be.enabled":
64
- await expect(el).toBeEnabled();
65
- break;
66
- case "be.disabled":
67
- await expect(el).toBeDisabled();
68
- break;
69
- case "be.checked":
70
- case "be.selected":
71
- await expect(el).toBeSelected();
72
- break;
73
- case "not.be.checked":
74
- case "not.be.selected":
75
- await expect(el).not.toBeSelected();
76
- break;
77
- case "have.value":
78
- await expect(el).toHaveValue(args[0]);
79
- break;
80
- case "contain.value":
81
- await expect(el).toHaveValue(expect.stringContaining(args[0]));
82
- break;
83
- case "have.text":
84
- await expect(el).toHaveText(args[0]);
85
- break;
86
- case "contain.text":
87
- await expect(el).toHaveText(expect.stringContaining(args[0]));
88
- break;
89
- case "have.class":
90
- await expect(el).toHaveElementClass(args[0]);
91
- break;
92
- case "have.attr":
93
- if (args.length >= 2) {
94
- await expect(el).toHaveAttribute(args[0], args[1]);
286
+ // Branch 3: Universal fallback evaluation with auto-waiting polling
287
+ const passed = await pollUntil(async () => {
288
+ const currentEl = typeof target === 'string' ? await resolveElement(target) : el;
289
+ switch (matcher) {
290
+ case 'be.visible': {
291
+ return typeof currentEl.isDisplayed === 'function'
292
+ ? await currentEl.isDisplayed()
293
+ : typeof currentEl.isVisible === 'function'
294
+ ? await currentEl.isVisible()
295
+ : true;
95
296
  }
96
- else {
97
- await expect(el).toHaveAttribute(args[0]);
297
+ case 'not.be.visible': {
298
+ const v = typeof currentEl.isDisplayed === 'function'
299
+ ? await currentEl.isDisplayed()
300
+ : typeof currentEl.isVisible === 'function'
301
+ ? await currentEl.isVisible()
302
+ : false;
303
+ return !v;
98
304
  }
99
- break;
100
- default:
101
- throw new Error(`Unsupported matcher: ${matcher}`);
305
+ case 'exist': {
306
+ if (typeof currentEl.isExisting === 'function')
307
+ return await currentEl.isExisting();
308
+ if (typeof currentEl.count === 'function')
309
+ return (await currentEl.count()) > 0;
310
+ if (currentEl.locator?.count)
311
+ return (await currentEl.locator.count()) > 0;
312
+ return true;
313
+ }
314
+ case 'not.exist': {
315
+ if (typeof currentEl.isExisting === 'function')
316
+ return !(await currentEl.isExisting());
317
+ if (typeof currentEl.count === 'function')
318
+ return (await currentEl.count()) === 0;
319
+ if (currentEl.locator?.count)
320
+ return (await currentEl.locator.count()) === 0;
321
+ return false;
322
+ }
323
+ case 'be.clickable':
324
+ case 'be.enabled': {
325
+ return typeof currentEl.isEnabled === 'function' ? await currentEl.isEnabled() : true;
326
+ }
327
+ case 'not.be.clickable':
328
+ case 'be.disabled': {
329
+ return typeof currentEl.isEnabled === 'function' ? !(await currentEl.isEnabled()) : false;
330
+ }
331
+ case 'be.checked':
332
+ case 'be.selected': {
333
+ return typeof currentEl.isSelected === 'function'
334
+ ? await currentEl.isSelected()
335
+ : typeof currentEl.isChecked === 'function'
336
+ ? await currentEl.isChecked()
337
+ : false;
338
+ }
339
+ case 'not.be.checked':
340
+ case 'not.be.selected': {
341
+ const sel = typeof currentEl.isSelected === 'function'
342
+ ? await currentEl.isSelected()
343
+ : typeof currentEl.isChecked === 'function'
344
+ ? await currentEl.isChecked()
345
+ : true;
346
+ return !sel;
347
+ }
348
+ case 'be.focused': {
349
+ return typeof currentEl.isFocused === 'function' ? await currentEl.isFocused() : false;
350
+ }
351
+ case 'not.be.focused': {
352
+ return typeof currentEl.isFocused === 'function' ? !(await currentEl.isFocused()) : true;
353
+ }
354
+ case 'have.value': {
355
+ const val = typeof currentEl.getValue === 'function'
356
+ ? await currentEl.getValue()
357
+ : typeof currentEl.inputValue === 'function'
358
+ ? await currentEl.inputValue()
359
+ : '';
360
+ return val === args[0];
361
+ }
362
+ case 'not.have.value': {
363
+ const val = typeof currentEl.getValue === 'function'
364
+ ? await currentEl.getValue()
365
+ : typeof currentEl.inputValue === 'function'
366
+ ? await currentEl.inputValue()
367
+ : '';
368
+ return val !== args[0];
369
+ }
370
+ case 'contain.value': {
371
+ const val = typeof currentEl.getValue === 'function'
372
+ ? await currentEl.getValue()
373
+ : typeof currentEl.inputValue === 'function'
374
+ ? await currentEl.inputValue()
375
+ : '';
376
+ return String(val).includes(args[0]);
377
+ }
378
+ case 'not.contain.value': {
379
+ const val = typeof currentEl.getValue === 'function'
380
+ ? await currentEl.getValue()
381
+ : typeof currentEl.inputValue === 'function'
382
+ ? await currentEl.inputValue()
383
+ : '';
384
+ return !String(val).includes(args[0]);
385
+ }
386
+ case 'have.text': {
387
+ const text = typeof currentEl.getText === 'function'
388
+ ? await currentEl.getText()
389
+ : typeof currentEl.textContent === 'function'
390
+ ? await currentEl.textContent()
391
+ : '';
392
+ return text.trim() === String(args[0]).trim();
393
+ }
394
+ case 'not.have.text': {
395
+ const text = typeof currentEl.getText === 'function'
396
+ ? await currentEl.getText()
397
+ : typeof currentEl.textContent === 'function'
398
+ ? await currentEl.textContent()
399
+ : '';
400
+ return text.trim() !== String(args[0]).trim();
401
+ }
402
+ case 'contain.text': {
403
+ const text = typeof currentEl.getText === 'function'
404
+ ? await currentEl.getText()
405
+ : typeof currentEl.textContent === 'function'
406
+ ? await currentEl.textContent()
407
+ : '';
408
+ return text.includes(String(args[0]));
409
+ }
410
+ case 'not.contain.text': {
411
+ const text = typeof currentEl.getText === 'function'
412
+ ? await currentEl.getText()
413
+ : typeof currentEl.textContent === 'function'
414
+ ? await currentEl.textContent()
415
+ : '';
416
+ return !text.includes(String(args[0]));
417
+ }
418
+ case 'have.class': {
419
+ const cls = typeof currentEl.getAttribute === 'function' ? (await currentEl.getAttribute('class')) || '' : '';
420
+ return cls.split(/\s+/).includes(args[0]);
421
+ }
422
+ case 'not.have.class': {
423
+ const cls = typeof currentEl.getAttribute === 'function' ? (await currentEl.getAttribute('class')) || '' : '';
424
+ return !cls.split(/\s+/).includes(args[0]);
425
+ }
426
+ case 'have.attr': {
427
+ const attrVal = typeof currentEl.getAttribute === 'function' ? await currentEl.getAttribute(args[0]) : null;
428
+ if (args.length >= 2)
429
+ return attrVal === args[1];
430
+ return attrVal !== null;
431
+ }
432
+ case 'not.have.attr': {
433
+ const attrVal = typeof currentEl.getAttribute === 'function' ? await currentEl.getAttribute(args[0]) : null;
434
+ if (args.length >= 2)
435
+ return attrVal !== args[1];
436
+ return attrVal === null;
437
+ }
438
+ case 'have.css': {
439
+ const cssVal = typeof currentEl.getCSSProperty === 'function'
440
+ ? (await currentEl.getCSSProperty(args[0]))?.value
441
+ : typeof currentEl.evaluate === 'function'
442
+ ? await currentEl.evaluate((node, prop) => window.getComputedStyle(node).getPropertyValue(prop), args[0])
443
+ : null;
444
+ if (!cssVal)
445
+ return false;
446
+ const norm = (v) => String(v ?? '')
447
+ .replace(/\s+/g, '')
448
+ .replace(/rgba\((.+?),1\)/, 'rgb($1)')
449
+ .toLowerCase();
450
+ return norm(cssVal) === norm(args[1]);
451
+ }
452
+ case 'not.have.css': {
453
+ const cssVal = typeof currentEl.getCSSProperty === 'function'
454
+ ? (await currentEl.getCSSProperty(args[0]))?.value
455
+ : typeof currentEl.evaluate === 'function'
456
+ ? await currentEl.evaluate((node, prop) => window.getComputedStyle(node).getPropertyValue(prop), args[0])
457
+ : null;
458
+ if (!cssVal)
459
+ return true;
460
+ const norm = (v) => String(v ?? '')
461
+ .replace(/\s+/g, '')
462
+ .replace(/rgba\((.+?),1\)/, 'rgb($1)')
463
+ .toLowerCase();
464
+ return norm(cssVal) !== norm(args[1]);
465
+ }
466
+ default:
467
+ throw new Error(`Unsupported matcher: ${matcher}`);
468
+ }
469
+ }, 5000);
470
+ if (!passed) {
471
+ throw new Error(`Assertion matcher '${matcher}' failed for target within 5000ms timeout.`);
472
+ }
473
+ }
474
+ async function pollUntil(condition, timeout = 5000, interval = 50) {
475
+ const start = Date.now();
476
+ while (Date.now() - start < timeout) {
477
+ try {
478
+ if (await condition())
479
+ return true;
480
+ }
481
+ catch {
482
+ // ignore and retry
483
+ }
484
+ await new Promise((r) => setTimeout(r, interval));
485
+ }
486
+ try {
487
+ return await condition();
488
+ }
489
+ catch {
490
+ return false;
102
491
  }
103
492
  }
104
493
  /**
105
- * Executes a multi-element collection matcher against WebdriverIO $$.
494
+ * Executes a multi-element collection matcher against $$.
106
495
  *
107
496
  * @param selector - CSS/XPath selector for the collection.
108
497
  * @param matcher - Multi-element matcher string.
109
498
  * @param args - Expected values or count limits.
110
499
  */
111
500
  export async function executeMultiMatcher(selector, matcher, args) {
112
- const elements = await $$(selector);
113
- switch (matcher) {
114
- case "have.length":
115
- await expect(elements).toBeElementsArrayOfSize(args[0]);
501
+ const elements = typeof globalThis.$$ === 'function' ? await globalThis.$$(selector) : [];
502
+ const exp = typeof expect !== 'undefined' ? expect : globalThis.expect;
503
+ if (exp && typeof exp(elements)?.toBeElementsArrayOfSize === 'function') {
504
+ switch (matcher) {
505
+ case 'have.length':
506
+ await exp(elements).toBeElementsArrayOfSize(args[0]);
507
+ break;
508
+ case 'not.have.length':
509
+ await exp(elements).not.toBeElementsArrayOfSize(args[0]);
510
+ break;
511
+ case 'have.length.greaterThan':
512
+ await exp(elements).toBeElementsArrayOfSize({ gte: args[0] + 1 });
513
+ break;
514
+ case 'have.length.lessThan':
515
+ await exp(elements).toBeElementsArrayOfSize({ lte: Math.max(0, args[0] - 1) });
516
+ break;
517
+ case 'be.empty':
518
+ await exp(elements).toBeElementsArrayOfSize(0);
519
+ break;
520
+ case 'not.be.empty':
521
+ await exp(elements).toBeElementsArrayOfSize({ gte: 1 });
522
+ break;
523
+ case 'exist':
524
+ await exp(elements).toBeElementsArrayOfSize({ gte: 1 });
525
+ break;
526
+ default:
527
+ throw new Error(`Unsupported multi-element matcher: ${matcher}`);
528
+ }
529
+ return;
530
+ }
531
+ // Playwright Locator / Array length fallback with polling
532
+ const passed = await pollUntil(async () => {
533
+ const currentElements = typeof globalThis.$$ === 'function' ? await globalThis.$$(selector) : [];
534
+ const count = Array.isArray(currentElements)
535
+ ? currentElements.length
536
+ : typeof currentElements.count === 'function'
537
+ ? await currentElements.count()
538
+ : 0;
539
+ switch (matcher) {
540
+ case 'have.length':
541
+ return count === args[0];
542
+ case 'not.have.length':
543
+ return count !== args[0];
544
+ case 'have.length.greaterThan':
545
+ return count > args[0];
546
+ case 'have.length.lessThan':
547
+ return count < args[0];
548
+ case 'be.empty':
549
+ return count === 0;
550
+ case 'not.be.empty':
551
+ case 'exist':
552
+ return count > 0;
553
+ default:
554
+ return false;
555
+ }
556
+ }, 5000);
557
+ if (!passed) {
558
+ const currentElements = typeof globalThis.$$ === 'function' ? await globalThis.$$(selector) : [];
559
+ const count = Array.isArray(currentElements)
560
+ ? currentElements.length
561
+ : typeof currentElements.count === 'function'
562
+ ? await currentElements.count()
563
+ : 0;
564
+ throw new Error(`Assertion '${matcher}' failed for collection '${selector}' within 5000ms. Current count: ${count}`);
565
+ }
566
+ }
567
+ /**
568
+ * Executes browser-level context assertions.
569
+ */
570
+ export async function executeBrowserAssertion(assertion, args = []) {
571
+ const b = typeof browser !== 'undefined' ? browser : globalThis.browser;
572
+ if (!b)
573
+ throw new Error('Browser instance is not available.');
574
+ const exp = typeof expect !== 'undefined' ? expect : globalThis.expect;
575
+ switch (assertion) {
576
+ case 'shouldHaveUrl':
577
+ if (exp && typeof exp(b)?.toHaveUrl === 'function') {
578
+ await exp(b).toHaveUrl(args[0]);
579
+ }
580
+ else {
581
+ const url = b.getUrl ? await b.getUrl() : b.url ? (typeof b.url === 'function' ? await b.url() : b.url) : '';
582
+ if (url !== args[0])
583
+ throw new Error(`Expected URL to be "${args[0]}", but got "${url}"`);
584
+ }
116
585
  break;
117
- case "have.length.greaterThan":
118
- await expect(elements).toBeElementsArrayOfSize({ gte: args[0] + 1 });
586
+ case 'shouldContainUrl':
587
+ if (exp && typeof exp(b)?.toHaveUrl === 'function') {
588
+ await exp(b).toHaveUrl(new RegExp(String(args[0]).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
589
+ }
590
+ else {
591
+ const url = b.getUrl ? await b.getUrl() : b.url ? (typeof b.url === 'function' ? await b.url() : b.url) : '';
592
+ if (!url.includes(args[0]))
593
+ throw new Error(`Expected URL to contain "${args[0]}", but got "${url}"`);
594
+ }
119
595
  break;
120
- case "be.empty":
121
- await expect(elements).toBeElementsArrayOfSize(0);
596
+ case 'shouldHaveTitle':
597
+ if (exp && typeof exp(b)?.toHaveTitle === 'function') {
598
+ await exp(b).toHaveTitle(args[0]);
599
+ }
600
+ else {
601
+ const title = b.getTitle
602
+ ? await b.getTitle()
603
+ : b.title
604
+ ? typeof b.title === 'function'
605
+ ? await b.title()
606
+ : b.title
607
+ : '';
608
+ if (title !== args[0])
609
+ throw new Error(`Expected title to be "${args[0]}", but got "${title}"`);
610
+ }
122
611
  break;
123
- case "exist":
124
- await expect(elements).toBeElementsArrayOfSize({ gte: 1 });
612
+ case 'shouldContainTitle':
613
+ if (exp && typeof exp(b)?.toHaveTitle === 'function') {
614
+ await exp(b).toHaveTitle(new RegExp(String(args[0]).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
615
+ }
616
+ else {
617
+ const title = b.getTitle
618
+ ? await b.getTitle()
619
+ : b.title
620
+ ? typeof b.title === 'function'
621
+ ? await b.title()
622
+ : b.title
623
+ : '';
624
+ if (!title.includes(args[0]))
625
+ throw new Error(`Expected title to contain "${args[0]}", but got "${title}"`);
626
+ }
627
+ break;
628
+ case 'shouldBePageLoaded':
629
+ if (b.waitUntil) {
630
+ await b.waitUntil(async () => {
631
+ const state = await b.execute(() => document.readyState);
632
+ return state === 'complete';
633
+ }, { timeout: 10000, timeoutMsg: 'Expected page to be fully loaded (document.readyState === "complete")' });
634
+ }
635
+ else if (b.waitForLoadState) {
636
+ await b.waitForLoadState('domcontentloaded');
637
+ }
638
+ break;
639
+ case 'shouldHaveNoConsoleErrors':
640
+ try {
641
+ const logs = await b.getLogs?.('browser');
642
+ if (Array.isArray(logs)) {
643
+ const severeLogs = logs.filter((log) => log.level === 'SEVERE');
644
+ if (severeLogs.length > 0) {
645
+ throw new Error(`Browser console has errors: ${severeLogs.map((l) => l.message).join('; ')}`);
646
+ }
647
+ }
648
+ }
649
+ catch (err) {
650
+ if (err.message && err.message.includes('Browser console has errors'))
651
+ throw err;
652
+ }
125
653
  break;
126
654
  default:
127
- throw new Error(`Unsupported multi-element matcher: ${matcher}`);
655
+ throw new Error(`Unsupported browser assertion: ${assertion}`);
128
656
  }
129
657
  }