@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.
- package/LICENSE.md +48 -0
- package/dist/__tests__/intercept.test.d.ts +1 -0
- package/dist/__tests__/intercept.test.js +183 -0
- package/dist/__tests__/matchers.test.d.ts +1 -0
- package/dist/__tests__/matchers.test.js +161 -0
- package/dist/index.d.ts +3 -21
- package/dist/index.js +3 -21
- package/dist/intercept/cdp-handler.d.ts +22 -0
- package/dist/intercept/cdp-handler.js +123 -0
- package/dist/intercept/index.d.ts +4 -0
- package/dist/intercept/index.js +4 -0
- package/dist/intercept/mock-handle.d.ts +49 -0
- package/dist/intercept/mock-handle.js +124 -0
- package/dist/intercept/mock-registry.d.ts +18 -0
- package/dist/intercept/mock-registry.js +97 -0
- package/dist/intercept/types.d.ts +43 -0
- package/dist/intercept/types.js +1 -0
- package/dist/matchers.d.ts +5 -1
- package/dist/matchers.js +116 -29
- package/dist/proto.d.ts +18 -283
- package/dist/proto.js +1 -114
- package/dist/reporter.d.ts +30 -0
- package/dist/reporter.js +85 -0
- package/dist/runner/collection.d.ts +33 -20
- package/dist/runner/collection.js +78 -26
- package/dist/runner/single.d.ts +123 -32
- package/dist/runner/single.js +266 -48
- package/dist/spectra.d.ts +32 -8
- package/dist/spectra.js +157 -40
- package/dist/types.d.ts +1195 -28
- package/package.json +16 -12
- package/src/index.ts +3 -22
- package/src/proto.ts +22 -433
- package/src/types.ts +1403 -96
- package/tsconfig.json +2 -2
- package/src/matchers.ts +0 -146
- package/src/runner/collection.ts +0 -153
- package/src/runner/single.ts +0 -301
- package/src/spectra.ts +0 -376
|
@@ -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 {};
|
package/dist/matchers.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SingleElementMatcher, MultiElementMatcher, ElementTarget } from
|
|
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 ===
|
|
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 ===
|
|
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
|
|
28
|
+
case 'have.url':
|
|
32
29
|
await expect(browser).toHaveUrl(expected);
|
|
33
30
|
break;
|
|
34
|
-
case
|
|
31
|
+
case 'contain.url':
|
|
35
32
|
await expect(browser).toHaveUrl(expect.stringContaining(expected));
|
|
36
33
|
break;
|
|
37
|
-
case
|
|
34
|
+
case 'have.title':
|
|
38
35
|
await expect(browser).toHaveTitle(expected);
|
|
39
36
|
break;
|
|
40
|
-
case
|
|
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
|
|
48
|
+
case 'be.visible':
|
|
52
49
|
await expect(el).toBeDisplayed();
|
|
53
50
|
break;
|
|
54
|
-
case
|
|
51
|
+
case 'not.be.visible':
|
|
55
52
|
await expect(el).not.toBeDisplayed();
|
|
56
53
|
break;
|
|
57
|
-
case
|
|
54
|
+
case 'exist':
|
|
58
55
|
await expect(el).toExist();
|
|
59
56
|
break;
|
|
60
|
-
case
|
|
57
|
+
case 'not.exist':
|
|
61
58
|
await expect(el).not.toExist();
|
|
62
59
|
break;
|
|
63
|
-
case
|
|
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
|
|
69
|
+
case 'be.disabled':
|
|
67
70
|
await expect(el).toBeDisabled();
|
|
68
71
|
break;
|
|
69
|
-
case
|
|
70
|
-
case
|
|
72
|
+
case 'be.checked':
|
|
73
|
+
case 'be.selected':
|
|
71
74
|
await expect(el).toBeSelected();
|
|
72
75
|
break;
|
|
73
|
-
case
|
|
74
|
-
case
|
|
76
|
+
case 'not.be.checked':
|
|
77
|
+
case 'not.be.selected':
|
|
75
78
|
await expect(el).not.toBeSelected();
|
|
76
79
|
break;
|
|
77
|
-
case
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
147
|
+
case 'have.length':
|
|
115
148
|
await expect(elements).toBeElementsArrayOfSize(args[0]);
|
|
116
149
|
break;
|
|
117
|
-
case
|
|
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
|
|
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
|
|
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
|
+
}
|