@testspectra/matchers 1.0.69 → 1.1.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.
@@ -0,0 +1,124 @@
1
+ export class MockHandle {
2
+ rule;
3
+ onceQueue = [];
4
+ defaultResponse = null;
5
+ abortReason = null;
6
+ interceptedCalls = [];
7
+ waitingCallers = [];
8
+ constructor(rule) {
9
+ this.rule = rule;
10
+ if (rule.response) {
11
+ this.defaultResponse = rule.response;
12
+ }
13
+ }
14
+ /**
15
+ * Updates the default mock response for all subsequent matching requests.
16
+ */
17
+ respondWith(response) {
18
+ this.defaultResponse = response;
19
+ this.abortReason = null;
20
+ return this;
21
+ }
22
+ /**
23
+ * Queues a one-time mock response for the next matching request (FIFO queue for polling/retries).
24
+ */
25
+ respondOnce(response) {
26
+ this.onceQueue.push(response);
27
+ return this;
28
+ }
29
+ /**
30
+ * Simulates a network failure or connection abort.
31
+ */
32
+ abort(errorCode = 'Aborted') {
33
+ this.abortReason = errorCode;
34
+ return this;
35
+ }
36
+ /**
37
+ * Total matching requests intercepted so far.
38
+ */
39
+ get callCount() {
40
+ return this.interceptedCalls.length;
41
+ }
42
+ /**
43
+ * Historical array of intercepted requests.
44
+ */
45
+ get calls() {
46
+ return [...this.interceptedCalls];
47
+ }
48
+ /**
49
+ * Awaits until a matching request arrives or times out.
50
+ */
51
+ async waitForCall(options = {}) {
52
+ const timeout = options.timeout ?? 5000;
53
+ const targetCount = options.count ?? 1;
54
+ if (this.interceptedCalls.length >= targetCount) {
55
+ return this.interceptedCalls[targetCount - 1];
56
+ }
57
+ return new Promise((resolve, reject) => {
58
+ const timer = setTimeout(() => {
59
+ const idx = this.waitingCallers.findIndex((w) => w.timer === timer);
60
+ if (idx !== -1)
61
+ this.waitingCallers.splice(idx, 1);
62
+ reject(new Error(`waitForCall timed out after ${timeout}ms. Expected ${targetCount} call(s), but received ${this.interceptedCalls.length}.`));
63
+ }, timeout);
64
+ this.waitingCallers.push({
65
+ targetCount,
66
+ resolve,
67
+ reject,
68
+ timer,
69
+ });
70
+ });
71
+ }
72
+ /**
73
+ * Internal fulfillment method called when a request matches this rule.
74
+ */
75
+ async matchAndFulfill(req) {
76
+ this.interceptedCalls.push(req);
77
+ // Notify any waiting callers whose count requirement is met
78
+ for (let i = this.waitingCallers.length - 1; i >= 0; i--) {
79
+ const waiter = this.waitingCallers[i];
80
+ if (this.interceptedCalls.length >= waiter.targetCount) {
81
+ clearTimeout(waiter.timer);
82
+ waiter.resolve(req);
83
+ this.waitingCallers.splice(i, 1);
84
+ }
85
+ }
86
+ if (this.abortReason) {
87
+ return { aborted: this.abortReason };
88
+ }
89
+ // 1. Shift from FIFO onceQueue if available
90
+ if (this.onceQueue.length > 0) {
91
+ const nextOnce = this.onceQueue.shift();
92
+ return { response: nextOnce };
93
+ }
94
+ // 2. Fall back to default response
95
+ if (typeof this.defaultResponse === 'function') {
96
+ const res = await this.defaultResponse(req);
97
+ return { response: res };
98
+ }
99
+ if (this.defaultResponse) {
100
+ return { response: this.defaultResponse };
101
+ }
102
+ // Default 200 OK
103
+ return {
104
+ response: {
105
+ status: 200,
106
+ headers: { 'content-type': 'application/json' },
107
+ body: {},
108
+ },
109
+ };
110
+ }
111
+ /**
112
+ * Resets local state and queued responses.
113
+ */
114
+ clear() {
115
+ this.onceQueue = [];
116
+ this.interceptedCalls = [];
117
+ this.abortReason = null;
118
+ for (const waiter of this.waitingCallers) {
119
+ clearTimeout(waiter.timer);
120
+ waiter.reject(new Error('Mock rule cleared while waiting for call'));
121
+ }
122
+ this.waitingCallers = [];
123
+ }
124
+ }
@@ -0,0 +1,18 @@
1
+ import { MockHandle } from './mock-handle.js';
2
+ import { InterceptRule, RecordedNetworkResource } from './types.js';
3
+ export declare function globToRegExp(glob: string): RegExp;
4
+ export declare function matchesUrl(pattern: string | RegExp, url: string): boolean;
5
+ export declare function matchesMethod(ruleMethod?: string, reqMethod?: string): boolean;
6
+ export declare class MockRegistry {
7
+ private static instance;
8
+ private handles;
9
+ private recordedResources;
10
+ static getInstance(): MockRegistry;
11
+ register(rule: InterceptRule): MockHandle;
12
+ findMatchingHandle(method: string, url: string): MockHandle | undefined;
13
+ clear(): void;
14
+ recordResource(resource: RecordedNetworkResource): void;
15
+ getRecordedResources(): RecordedNetworkResource[];
16
+ clearRecordedResources(): void;
17
+ exportNetworkResources(filePath: string): void;
18
+ }
@@ -0,0 +1,97 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { MockHandle } from './mock-handle.js';
4
+ export function globToRegExp(glob) {
5
+ let regexStr = '';
6
+ let i = 0;
7
+ while (i < glob.length) {
8
+ const c = glob[i];
9
+ if (c === '*' && glob[i + 1] === '*') {
10
+ regexStr += '.*';
11
+ i += 2;
12
+ }
13
+ else if (c === '*') {
14
+ regexStr += '[^/]*';
15
+ i += 1;
16
+ }
17
+ else if (['.', '?', '+', '^', '$', '[', ']', '(', ')', '{', '}', '|', '\\'].includes(c)) {
18
+ regexStr += `\\${c}`;
19
+ i += 1;
20
+ }
21
+ else {
22
+ regexStr += c;
23
+ i += 1;
24
+ }
25
+ }
26
+ // If glob does not start with protocol and does not start with wildcard, allow leading domain
27
+ if (!glob.startsWith('http://') && !glob.startsWith('https://') && !glob.startsWith('*')) {
28
+ return new RegExp(`(?:https?://[^/]+)?${regexStr}(?:\\?.*)?$`);
29
+ }
30
+ return new RegExp(`^${regexStr}(?:\\?.*)?$`);
31
+ }
32
+ export function matchesUrl(pattern, url) {
33
+ if (pattern instanceof RegExp) {
34
+ return pattern.test(url);
35
+ }
36
+ if (pattern === url) {
37
+ return true;
38
+ }
39
+ try {
40
+ const parsed = new URL(url, 'http://localhost');
41
+ if (pattern === parsed.pathname || pattern === `${parsed.pathname}${parsed.search}`) {
42
+ return true;
43
+ }
44
+ }
45
+ catch { }
46
+ const reg = globToRegExp(pattern);
47
+ return reg.test(url);
48
+ }
49
+ export function matchesMethod(ruleMethod, reqMethod = 'GET') {
50
+ if (!ruleMethod || ruleMethod.toUpperCase() === 'ALL') {
51
+ return true;
52
+ }
53
+ return ruleMethod.toUpperCase() === reqMethod.toUpperCase();
54
+ }
55
+ export class MockRegistry {
56
+ static instance;
57
+ handles = [];
58
+ recordedResources = [];
59
+ static getInstance() {
60
+ if (!MockRegistry.instance) {
61
+ MockRegistry.instance = new MockRegistry();
62
+ }
63
+ return MockRegistry.instance;
64
+ }
65
+ register(rule) {
66
+ const handle = new MockHandle(rule);
67
+ this.handles.unshift(handle);
68
+ return handle;
69
+ }
70
+ findMatchingHandle(method, url) {
71
+ return this.handles.find((h) => {
72
+ return matchesMethod(h.rule.method, method) && matchesUrl(h.rule.url, url);
73
+ });
74
+ }
75
+ clear() {
76
+ for (const h of this.handles) {
77
+ h.clear();
78
+ }
79
+ this.handles = [];
80
+ }
81
+ recordResource(resource) {
82
+ this.recordedResources.push(resource);
83
+ }
84
+ getRecordedResources() {
85
+ return [...this.recordedResources];
86
+ }
87
+ clearRecordedResources() {
88
+ this.recordedResources = [];
89
+ }
90
+ exportNetworkResources(filePath) {
91
+ const dir = path.dirname(filePath);
92
+ if (!fs.existsSync(dir)) {
93
+ fs.mkdirSync(dir, { recursive: true });
94
+ }
95
+ fs.writeFileSync(filePath, JSON.stringify(this.recordedResources, null, 2), 'utf-8');
96
+ }
97
+ }
@@ -0,0 +1,43 @@
1
+ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'ALL';
2
+ export type NetworkAbortReason = 'Failed' | 'Aborted' | 'TimedOut' | 'ConnectionReset' | 'AccessDenied';
3
+ export interface MockResponse {
4
+ status?: number;
5
+ headers?: Record<string, string>;
6
+ body?: any;
7
+ delayMs?: number;
8
+ }
9
+ export interface InterceptedRequest {
10
+ id: string;
11
+ url: string;
12
+ method: string;
13
+ headers?: Record<string, string>;
14
+ body?: any;
15
+ timestamp: number;
16
+ }
17
+ export interface InterceptRule {
18
+ url: string | RegExp;
19
+ method?: HttpMethod;
20
+ response?: MockResponse | ((request: InterceptedRequest) => MockResponse | Promise<MockResponse>);
21
+ }
22
+ export interface NetworkResourceTiming {
23
+ dns: number;
24
+ tcp: number;
25
+ ssl: number;
26
+ ttfb: number;
27
+ download: number;
28
+ total: number;
29
+ }
30
+ export interface RecordedNetworkResource {
31
+ id: string;
32
+ url: string;
33
+ method: string;
34
+ status: number;
35
+ statusText: string;
36
+ resourceType: string;
37
+ requestHeaders?: Record<string, string>;
38
+ responseHeaders?: Record<string, string>;
39
+ requestBody?: any;
40
+ responseBody?: any;
41
+ timing: NetworkResourceTiming;
42
+ transferSize: number;
43
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,4 +1,4 @@
1
- import { SingleElementMatcher, MultiElementMatcher, ElementTarget } from "./types.js";
1
+ import { SingleElementMatcher, MultiElementMatcher, ElementTarget } from './types.js';
2
2
  /**
3
3
  * Resolves an ElementTarget (selector string, WebdriverIO.Element, or ChainablePromiseElement)
4
4
  * to an actionable WebdriverIO.Element.
@@ -23,3 +23,7 @@ export declare function executeSingleMatcher(target: ElementTarget | undefined,
23
23
  * @param args - Expected values or count limits.
24
24
  */
25
25
  export declare function executeMultiMatcher(selector: string, matcher: MultiElementMatcher, args: any[]): Promise<void>;
26
+ /**
27
+ * Executes browser-level context assertions.
28
+ */
29
+ export declare function executeBrowserAssertion(assertion: 'shouldHaveUrl' | 'shouldContainUrl' | 'shouldHaveTitle' | 'shouldContainTitle' | 'shouldBePageLoaded' | 'shouldHaveNoConsoleErrors', args?: any[]): Promise<void>;
package/dist/matchers.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * @returns Resolved WebdriverIO.Element instance.
7
7
  */
8
8
  export async function resolveElement(target) {
9
- if (typeof target === "string") {
9
+ if (typeof target === 'string') {
10
10
  const el = await $(target);
11
11
  return el;
12
12
  }
@@ -21,23 +21,20 @@ export async function resolveElement(target) {
21
21
  * @param args - Arguments passed to the matcher.
22
22
  */
23
23
  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";
24
+ const isBrowserLevel = matcher === 'have.url' || matcher === 'contain.url' || matcher === 'have.title' || matcher === 'contain.title';
28
25
  if (isBrowserLevel) {
29
26
  const expected = args[0];
30
27
  switch (matcher) {
31
- case "have.url":
28
+ case 'have.url':
32
29
  await expect(browser).toHaveUrl(expected);
33
30
  break;
34
- case "contain.url":
31
+ case 'contain.url':
35
32
  await expect(browser).toHaveUrl(expect.stringContaining(expected));
36
33
  break;
37
- case "have.title":
34
+ case 'have.title':
38
35
  await expect(browser).toHaveTitle(expected);
39
36
  break;
40
- case "contain.title":
37
+ case 'contain.title':
41
38
  await expect(browser).toHaveTitle(expect.stringContaining(expected));
42
39
  break;
43
40
  }
@@ -48,48 +45,75 @@ export async function executeSingleMatcher(target, matcher, args) {
48
45
  }
49
46
  const el = await resolveElement(target);
50
47
  switch (matcher) {
51
- case "be.visible":
48
+ case 'be.visible':
52
49
  await expect(el).toBeDisplayed();
53
50
  break;
54
- case "not.be.visible":
51
+ case 'not.be.visible':
55
52
  await expect(el).not.toBeDisplayed();
56
53
  break;
57
- case "exist":
54
+ case 'exist':
58
55
  await expect(el).toExist();
59
56
  break;
60
- case "not.exist":
57
+ case 'not.exist':
61
58
  await expect(el).not.toExist();
62
59
  break;
63
- case "be.enabled":
60
+ case 'be.clickable':
61
+ await expect(el).toBeClickable();
62
+ break;
63
+ case 'not.be.clickable':
64
+ await expect(el).not.toBeClickable();
65
+ break;
66
+ case 'be.enabled':
64
67
  await expect(el).toBeEnabled();
65
68
  break;
66
- case "be.disabled":
69
+ case 'be.disabled':
67
70
  await expect(el).toBeDisabled();
68
71
  break;
69
- case "be.checked":
70
- case "be.selected":
72
+ case 'be.checked':
73
+ case 'be.selected':
71
74
  await expect(el).toBeSelected();
72
75
  break;
73
- case "not.be.checked":
74
- case "not.be.selected":
76
+ case 'not.be.checked':
77
+ case 'not.be.selected':
75
78
  await expect(el).not.toBeSelected();
76
79
  break;
77
- case "have.value":
80
+ case 'be.focused':
81
+ await expect(el).toBeFocused();
82
+ break;
83
+ case 'not.be.focused':
84
+ await expect(el).not.toBeFocused();
85
+ break;
86
+ case 'have.value':
78
87
  await expect(el).toHaveValue(args[0]);
79
88
  break;
80
- case "contain.value":
89
+ case 'not.have.value':
90
+ await expect(el).not.toHaveValue(args[0]);
91
+ break;
92
+ case 'contain.value':
81
93
  await expect(el).toHaveValue(expect.stringContaining(args[0]));
82
94
  break;
83
- case "have.text":
95
+ case 'not.contain.value':
96
+ await expect(el).not.toHaveValue(expect.stringContaining(args[0]));
97
+ break;
98
+ case 'have.text':
84
99
  await expect(el).toHaveText(args[0]);
85
100
  break;
86
- case "contain.text":
101
+ case 'not.have.text':
102
+ await expect(el).not.toHaveText(args[0]);
103
+ break;
104
+ case 'contain.text':
87
105
  await expect(el).toHaveText(expect.stringContaining(args[0]));
88
106
  break;
89
- case "have.class":
107
+ case 'not.contain.text':
108
+ await expect(el).not.toHaveText(expect.stringContaining(args[0]));
109
+ break;
110
+ case 'have.class':
90
111
  await expect(el).toHaveElementClass(args[0]);
91
112
  break;
92
- case "have.attr":
113
+ case 'not.have.class':
114
+ await expect(el).not.toHaveElementClass(args[0]);
115
+ break;
116
+ case 'have.attr':
93
117
  if (args.length >= 2) {
94
118
  await expect(el).toHaveAttribute(args[0], args[1]);
95
119
  }
@@ -97,6 +121,15 @@ export async function executeSingleMatcher(target, matcher, args) {
97
121
  await expect(el).toHaveAttribute(args[0]);
98
122
  }
99
123
  break;
124
+ case 'not.have.attr':
125
+ await expect(el).not.toHaveAttribute(args[0]);
126
+ break;
127
+ case 'have.css':
128
+ await expect(el).toHaveStyle({ [args[0]]: args[1] });
129
+ break;
130
+ case 'not.have.css':
131
+ await expect(el).not.toHaveStyle({ [args[0]]: args[1] });
132
+ break;
100
133
  default:
101
134
  throw new Error(`Unsupported matcher: ${matcher}`);
102
135
  }
@@ -111,19 +144,73 @@ export async function executeSingleMatcher(target, matcher, args) {
111
144
  export async function executeMultiMatcher(selector, matcher, args) {
112
145
  const elements = await $$(selector);
113
146
  switch (matcher) {
114
- case "have.length":
147
+ case 'have.length':
115
148
  await expect(elements).toBeElementsArrayOfSize(args[0]);
116
149
  break;
117
- case "have.length.greaterThan":
150
+ case 'not.have.length':
151
+ await expect(elements).not.toBeElementsArrayOfSize(args[0]);
152
+ break;
153
+ case 'have.length.greaterThan':
118
154
  await expect(elements).toBeElementsArrayOfSize({ gte: args[0] + 1 });
119
155
  break;
120
- case "be.empty":
156
+ case 'have.length.lessThan':
157
+ await expect(elements).toBeElementsArrayOfSize({ lte: Math.max(0, args[0] - 1) });
158
+ break;
159
+ case 'be.empty':
121
160
  await expect(elements).toBeElementsArrayOfSize(0);
122
161
  break;
123
- case "exist":
162
+ case 'not.be.empty':
163
+ await expect(elements).toBeElementsArrayOfSize({ gte: 1 });
164
+ break;
165
+ case 'exist':
124
166
  await expect(elements).toBeElementsArrayOfSize({ gte: 1 });
125
167
  break;
126
168
  default:
127
169
  throw new Error(`Unsupported multi-element matcher: ${matcher}`);
128
170
  }
129
171
  }
172
+ /**
173
+ * Executes browser-level context assertions.
174
+ */
175
+ export async function executeBrowserAssertion(assertion, args = []) {
176
+ const b = typeof browser !== 'undefined' ? browser : globalThis.browser;
177
+ if (!b)
178
+ throw new Error('WebdriverIO browser instance is not available.');
179
+ switch (assertion) {
180
+ case 'shouldHaveUrl':
181
+ await expect(b).toHaveUrl(args[0]);
182
+ break;
183
+ case 'shouldContainUrl':
184
+ await expect(b).toHaveUrl(new RegExp(String(args[0]).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
185
+ break;
186
+ case 'shouldHaveTitle':
187
+ await expect(b).toHaveTitle(args[0]);
188
+ break;
189
+ case 'shouldContainTitle':
190
+ await expect(b).toHaveTitle(new RegExp(String(args[0]).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
191
+ break;
192
+ case 'shouldBePageLoaded':
193
+ await b.waitUntil(async () => {
194
+ const state = await b.execute(() => document.readyState);
195
+ return state === 'complete';
196
+ }, { timeout: 10000, timeoutMsg: 'Expected page to be fully loaded (document.readyState === "complete")' });
197
+ break;
198
+ case 'shouldHaveNoConsoleErrors':
199
+ try {
200
+ const logs = await b.getLogs?.('browser');
201
+ if (Array.isArray(logs)) {
202
+ const severeLogs = logs.filter((log) => log.level === 'SEVERE');
203
+ if (severeLogs.length > 0) {
204
+ throw new Error(`Browser console has errors: ${severeLogs.map((l) => l.message).join('; ')}`);
205
+ }
206
+ }
207
+ }
208
+ catch (err) {
209
+ if (err.message && err.message.includes('Browser console has errors'))
210
+ throw err;
211
+ }
212
+ break;
213
+ default:
214
+ throw new Error(`Unsupported browser assertion: ${assertion}`);
215
+ }
216
+ }