@testspectra/reporter 1.1.13
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 +5 -0
- package/dist/formatter.d.ts +38 -0
- package/dist/formatter.js +379 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/parser.d.ts +61 -0
- package/dist/parser.js +238 -0
- package/dist/reporter.d.ts +32 -0
- package/dist/reporter.js +270 -0
- package/dist/types.d.ts +138 -0
- package/dist/types.js +1 -0
- package/package.json +31 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ActionKey, AssertionKey } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Extracts a string representation from an element/target identifier or object.
|
|
4
|
+
*/
|
|
5
|
+
export declare function extractTargetString(target?: any): string;
|
|
6
|
+
/**
|
|
7
|
+
* Renders a `ScopedSelector`'s `.parent` chain (produced by `element.get()`/`.getAll()` chaining)
|
|
8
|
+
* as `"parent" within "grandparent"`, nearest ancestor first — or null when the target isn't scoped.
|
|
9
|
+
*/
|
|
10
|
+
export declare function extractWithinChain(target?: any): string | null;
|
|
11
|
+
/**
|
|
12
|
+
* Formats an element target into a descriptive label.
|
|
13
|
+
*/
|
|
14
|
+
export declare function formatElementTarget(target?: any): string;
|
|
15
|
+
/**
|
|
16
|
+
* Formats a collection target into a descriptive label.
|
|
17
|
+
*/
|
|
18
|
+
export declare function formatCollectionTarget(target?: any): string;
|
|
19
|
+
/**
|
|
20
|
+
* Maps positional action arguments array to a named argument dictionary.
|
|
21
|
+
*/
|
|
22
|
+
export declare function mapActionArgs(key: string, arr: any): Record<string, any>;
|
|
23
|
+
/**
|
|
24
|
+
* Maps positional assertion arguments array to a named argument dictionary.
|
|
25
|
+
*/
|
|
26
|
+
export declare function mapAssertionArgs(key: string, arr: any): Record<string, any>;
|
|
27
|
+
/**
|
|
28
|
+
* Transforms raw action commands into grammatically correct English sentences.
|
|
29
|
+
*/
|
|
30
|
+
export declare function formatAction(actionKey: ActionKey | string, target?: any, args?: Record<string, any>): string;
|
|
31
|
+
/**
|
|
32
|
+
* Transforms raw matcher assertions into grammatically correct English sentences.
|
|
33
|
+
*/
|
|
34
|
+
export declare function formatAssertion(assertionKey: AssertionKey | string, target?: any, args?: Record<string, any>): string;
|
|
35
|
+
/**
|
|
36
|
+
* Parses raw JSON action/assertion payloads or native orchestrator payloads into English sentences.
|
|
37
|
+
*/
|
|
38
|
+
export declare function parseAndFormat(cmdText: string): string;
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracts a string representation from an element/target identifier or object.
|
|
3
|
+
*/
|
|
4
|
+
export function extractTargetString(target) {
|
|
5
|
+
if (!target)
|
|
6
|
+
return '';
|
|
7
|
+
if (typeof target === 'string')
|
|
8
|
+
return target;
|
|
9
|
+
if (typeof target === 'object') {
|
|
10
|
+
if (typeof target.selector === 'string')
|
|
11
|
+
return target.selector;
|
|
12
|
+
if (typeof target.name === 'string')
|
|
13
|
+
return target.name;
|
|
14
|
+
if (typeof target.target === 'string')
|
|
15
|
+
return target.target;
|
|
16
|
+
}
|
|
17
|
+
return String(target);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Renders a `ScopedSelector`'s `.parent` chain (produced by `element.get()`/`.getAll()` chaining)
|
|
21
|
+
* as `"parent" within "grandparent"`, nearest ancestor first — or null when the target isn't scoped.
|
|
22
|
+
*/
|
|
23
|
+
export function extractWithinChain(target) {
|
|
24
|
+
if (!target || typeof target !== 'object' || !target.parent)
|
|
25
|
+
return null;
|
|
26
|
+
const chain = [];
|
|
27
|
+
for (let p = target.parent; p; p = p.parent) {
|
|
28
|
+
if (p.selector) {
|
|
29
|
+
chain.push(p.selector);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return chain.length > 0 ? chain.join('" within "') : null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Formats an element target into a descriptive label.
|
|
36
|
+
*/
|
|
37
|
+
export function formatElementTarget(target) {
|
|
38
|
+
const sel = extractTargetString(target);
|
|
39
|
+
if (!sel)
|
|
40
|
+
return 'element {target}';
|
|
41
|
+
if (sel.startsWith('element "') || sel.startsWith('collection "')) {
|
|
42
|
+
return sel;
|
|
43
|
+
}
|
|
44
|
+
const within = extractWithinChain(target);
|
|
45
|
+
return within ? `element "${sel}" within "${within}"` : `element "${sel}"`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Formats a collection target into a descriptive label.
|
|
49
|
+
*/
|
|
50
|
+
export function formatCollectionTarget(target) {
|
|
51
|
+
const sel = extractTargetString(target);
|
|
52
|
+
if (!sel)
|
|
53
|
+
return 'collection "{selector}"';
|
|
54
|
+
if (sel.startsWith('collection "')) {
|
|
55
|
+
return sel;
|
|
56
|
+
}
|
|
57
|
+
const within = extractWithinChain(target);
|
|
58
|
+
return within ? `collection "${sel}" within "${within}"` : `collection "${sel}"`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Maps positional action arguments array to a named argument dictionary.
|
|
62
|
+
*/
|
|
63
|
+
export function mapActionArgs(key, arr) {
|
|
64
|
+
if (!Array.isArray(arr)) {
|
|
65
|
+
return typeof arr === 'object' && arr !== null ? arr : {};
|
|
66
|
+
}
|
|
67
|
+
switch (key) {
|
|
68
|
+
case 'type':
|
|
69
|
+
return { text: arr[0] };
|
|
70
|
+
case 'select':
|
|
71
|
+
case 'selectOption':
|
|
72
|
+
return { option: arr[0] };
|
|
73
|
+
case 'dragDrop':
|
|
74
|
+
return { dest: arr[0] };
|
|
75
|
+
case 'longPress':
|
|
76
|
+
return { durationMs: arr[0] };
|
|
77
|
+
case 'navigate':
|
|
78
|
+
return { url: arr[0] };
|
|
79
|
+
case 'setCookies':
|
|
80
|
+
return { url: arr[0] };
|
|
81
|
+
case 'setViewport':
|
|
82
|
+
return { width: arr[0], height: arr[1] };
|
|
83
|
+
case 'wait':
|
|
84
|
+
return { ms: arr[0] };
|
|
85
|
+
case 'pressKey':
|
|
86
|
+
return { key: arr[0] };
|
|
87
|
+
case 'scroll':
|
|
88
|
+
return { direction: arr[0], pixels: arr[1] };
|
|
89
|
+
case 'swipe':
|
|
90
|
+
return { direction: arr[0], distance: arr[1] };
|
|
91
|
+
case 'intercept':
|
|
92
|
+
return { method: arr[0], url: arr[1] };
|
|
93
|
+
default:
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Maps positional assertion arguments array to a named argument dictionary.
|
|
99
|
+
*/
|
|
100
|
+
export function mapAssertionArgs(key, arr) {
|
|
101
|
+
if (!Array.isArray(arr)) {
|
|
102
|
+
return typeof arr === 'object' && arr !== null ? arr : { expected: arr };
|
|
103
|
+
}
|
|
104
|
+
if (key === 'have.attr' || key === 'not.have.attr') {
|
|
105
|
+
return arr.length >= 2 ? { name: arr[0], value: arr[1], expected: arr[0] } : { name: arr[0], expected: arr[0] };
|
|
106
|
+
}
|
|
107
|
+
if (key === 'have.css' || key === 'not.have.css') {
|
|
108
|
+
return { name: arr[0], property: arr[0], value: arr[1], expected: arr[0] };
|
|
109
|
+
}
|
|
110
|
+
if (arr.length >= 2) {
|
|
111
|
+
return { name: arr[0], value: arr[1], expected: arr[0] };
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
expected: arr[0],
|
|
115
|
+
name: arr[0],
|
|
116
|
+
text: arr[0],
|
|
117
|
+
value: arr[0],
|
|
118
|
+
title: arr[0],
|
|
119
|
+
url: arr[0],
|
|
120
|
+
length: arr[0],
|
|
121
|
+
min: arr[0],
|
|
122
|
+
max: arr[0],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Transforms raw action commands into grammatically correct English sentences.
|
|
127
|
+
*/
|
|
128
|
+
export function formatAction(actionKey, target, args = {}) {
|
|
129
|
+
const el = formatElementTarget(target);
|
|
130
|
+
switch (actionKey) {
|
|
131
|
+
case 'mount':
|
|
132
|
+
return `Mount component "${extractTargetString(target) || '{component}'}"`;
|
|
133
|
+
case 'click':
|
|
134
|
+
return `Click on ${el}`;
|
|
135
|
+
case 'doubleClick':
|
|
136
|
+
return `Double-click on ${el}`;
|
|
137
|
+
case 'rightClick':
|
|
138
|
+
return `Right-click on ${el}`;
|
|
139
|
+
case 'type':
|
|
140
|
+
return `Type "${args.text ?? args.value ?? '{text}'}" into ${el}`;
|
|
141
|
+
case 'clear':
|
|
142
|
+
return `Clear text in ${el}`;
|
|
143
|
+
case 'select':
|
|
144
|
+
case 'selectOption':
|
|
145
|
+
return `Select option "${args.option ?? args.value ?? args.text ?? '{option}'}" in ${el}`;
|
|
146
|
+
case 'hover':
|
|
147
|
+
return `Hover over ${el}`;
|
|
148
|
+
case 'dragDrop': {
|
|
149
|
+
const dest = args.dest || args.destination ? formatElementTarget(args.dest || args.destination) : 'element {dest}';
|
|
150
|
+
return `Drag ${el} and drop onto ${dest}`;
|
|
151
|
+
}
|
|
152
|
+
case 'scrollIntoView':
|
|
153
|
+
return `Scroll ${el} into view`;
|
|
154
|
+
case 'longPress':
|
|
155
|
+
return `Long-press on ${el} for ${args.durationMs ?? args.ms ?? args.duration ?? '{durationMs}'}ms`;
|
|
156
|
+
case 'waitForElement':
|
|
157
|
+
return `Wait for ${el} to be displayed`;
|
|
158
|
+
case 'navigate':
|
|
159
|
+
return `Navigate to "${args.url ?? '{url}'}"`;
|
|
160
|
+
case 'back':
|
|
161
|
+
return `Navigate back in browser`;
|
|
162
|
+
case 'forward':
|
|
163
|
+
return `Navigate forward in browser`;
|
|
164
|
+
case 'refresh':
|
|
165
|
+
return `Refresh current page`;
|
|
166
|
+
case 'setViewport':
|
|
167
|
+
return `Set viewport to ${args.width ?? '{width}'}x${args.height ?? '{height}'}`;
|
|
168
|
+
case 'wait':
|
|
169
|
+
return `Wait for ${args.ms ?? args.durationMs ?? '{ms}'}ms`;
|
|
170
|
+
case 'pressKey':
|
|
171
|
+
return `Press keyboard key "${args.key ?? args.value ?? '{key}'}"`;
|
|
172
|
+
case 'scroll':
|
|
173
|
+
return `Scroll ${args.direction ?? '{direction}'} by ${args.pixels ?? '{pixels}'}px`;
|
|
174
|
+
case 'swipe':
|
|
175
|
+
return `Swipe ${args.direction ?? '{direction}'} by ${args.distance ?? '{distance}'}px`;
|
|
176
|
+
case 'upload':
|
|
177
|
+
return `Upload file "${args.files ?? args.value ?? args.file ?? '{file}'}" to ${el}`;
|
|
178
|
+
case 'intercept':
|
|
179
|
+
return `Intercept ${args.method ?? '{method}'} "${args.url ?? '{url}'}" mock rule registered`;
|
|
180
|
+
case 'clearCookies':
|
|
181
|
+
return `Clear all browser cookies`;
|
|
182
|
+
case 'setCookies':
|
|
183
|
+
return `Set cookies for "${args.url ?? '{url}'}"`;
|
|
184
|
+
case 'clearLocalStorage':
|
|
185
|
+
return `Clear browser localStorage`;
|
|
186
|
+
default:
|
|
187
|
+
return `Perform ${actionKey} on ${el}`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Transforms raw matcher assertions into grammatically correct English sentences.
|
|
192
|
+
*/
|
|
193
|
+
export function formatAssertion(assertionKey, target, args = {}) {
|
|
194
|
+
const el = formatElementTarget(target);
|
|
195
|
+
const col = formatCollectionTarget(target);
|
|
196
|
+
switch (assertionKey) {
|
|
197
|
+
case 'be.visible':
|
|
198
|
+
return `Expect ${el} to be visible`;
|
|
199
|
+
case 'not.be.visible':
|
|
200
|
+
return `Expect ${el} not to be visible`;
|
|
201
|
+
case 'exist':
|
|
202
|
+
return `Expect ${el} to exist in DOM`;
|
|
203
|
+
case 'not.exist':
|
|
204
|
+
return `Expect ${el} not to exist in DOM`;
|
|
205
|
+
case 'be.clickable':
|
|
206
|
+
return `Expect ${el} to be clickable`;
|
|
207
|
+
case 'not.be.clickable':
|
|
208
|
+
return `Expect ${el} not to be clickable`;
|
|
209
|
+
case 'be.enabled':
|
|
210
|
+
return `Expect ${el} to be enabled`;
|
|
211
|
+
case 'be.disabled':
|
|
212
|
+
return `Expect ${el} to be disabled`;
|
|
213
|
+
case 'be.checked':
|
|
214
|
+
return `Expect ${el} to be checked`;
|
|
215
|
+
case 'not.be.checked':
|
|
216
|
+
return `Expect ${el} not to be checked`;
|
|
217
|
+
case 'be.selected':
|
|
218
|
+
return `Expect ${el} to be selected`;
|
|
219
|
+
case 'not.be.selected':
|
|
220
|
+
return `Expect ${el} not to be selected`;
|
|
221
|
+
case 'be.focused':
|
|
222
|
+
return `Expect ${el} to be focused`;
|
|
223
|
+
case 'not.be.focused':
|
|
224
|
+
return `Expect ${el} not to be focused`;
|
|
225
|
+
case 'have.text':
|
|
226
|
+
return `Expect ${el} to have text "${args.expected ?? args.text ?? '{str}'}"`;
|
|
227
|
+
case 'not.have.text':
|
|
228
|
+
return `Expect ${el} not to have text "${args.expected ?? args.text ?? '{str}'}"`;
|
|
229
|
+
case 'contain.text':
|
|
230
|
+
return `Expect ${el} to contain text "${args.expected ?? args.text ?? '{sub}'}"`;
|
|
231
|
+
case 'not.contain.text':
|
|
232
|
+
return `Expect ${el} not to contain text "${args.expected ?? args.text ?? '{s}'}"`;
|
|
233
|
+
case 'have.value':
|
|
234
|
+
return `Expect ${el} to have value "${args.expected ?? args.value ?? '{val}'}"`;
|
|
235
|
+
case 'not.have.value':
|
|
236
|
+
return `Expect ${el} not to have value "${args.expected ?? args.value ?? '{val}'}"`;
|
|
237
|
+
case 'contain.value':
|
|
238
|
+
return `Expect ${el} to contain value "${args.expected ?? args.value ?? '{s}'}"`;
|
|
239
|
+
case 'not.contain.value':
|
|
240
|
+
return `Expect ${el} not to contain value "${args.expected ?? args.value ?? '{s}'}"`;
|
|
241
|
+
case 'have.attr':
|
|
242
|
+
return args.value !== undefined
|
|
243
|
+
? `Expect ${el} to have attribute "${args.name ?? args.expected ?? '{k}'}" = "${args.value}"`
|
|
244
|
+
: `Expect ${el} to have attribute "${args.name ?? args.expected ?? '{k}'}"`;
|
|
245
|
+
case 'not.have.attr':
|
|
246
|
+
return `Expect ${el} not to have attribute "${args.name ?? args.expected ?? '{k}'}"`;
|
|
247
|
+
case 'have.class':
|
|
248
|
+
return `Expect ${el} to have class "${args.expected ?? args.className ?? '{cls}'}"`;
|
|
249
|
+
case 'not.have.class':
|
|
250
|
+
return `Expect ${el} not to have class "${args.expected ?? args.className ?? '{cls}'}"`;
|
|
251
|
+
case 'have.css':
|
|
252
|
+
return `Expect ${el} to have CSS "${args.name ?? args.property ?? '{prop}'}" = "${args.value ?? '{val}'}"`;
|
|
253
|
+
case 'not.have.css':
|
|
254
|
+
return `Expect ${el} not to have CSS "${args.name ?? args.property ?? '{p}'}" = "${args.value ?? '{v}'}"`;
|
|
255
|
+
// Collections
|
|
256
|
+
case 'have.length':
|
|
257
|
+
return `Expect ${col} count to equal ${args.expected ?? args.length ?? '{n}'}`;
|
|
258
|
+
case 'not.have.length':
|
|
259
|
+
return `Expect ${col} count not to equal ${args.expected ?? args.length ?? '{n}'}`;
|
|
260
|
+
case 'have.length.greaterThan':
|
|
261
|
+
return `Expect ${col} count to be greater than ${args.expected ?? args.min ?? '{n}'}`;
|
|
262
|
+
case 'have.length.lessThan':
|
|
263
|
+
return `Expect ${col} count to be less than ${args.expected ?? args.max ?? '{n}'}`;
|
|
264
|
+
case 'be.empty':
|
|
265
|
+
return `Expect ${col} to be empty`;
|
|
266
|
+
case 'not.be.empty':
|
|
267
|
+
return `Expect ${col} not to be empty`;
|
|
268
|
+
// Browser Context
|
|
269
|
+
case 'have.url':
|
|
270
|
+
case 'shouldHaveUrl':
|
|
271
|
+
return `Expect browser URL to be "${args.expected ?? args.url ?? '{url}'}"`;
|
|
272
|
+
case 'contain.url':
|
|
273
|
+
case 'shouldContainUrl':
|
|
274
|
+
return `Expect browser URL to contain "${args.expected ?? args.url ?? '{url}'}"`;
|
|
275
|
+
case 'have.title':
|
|
276
|
+
case 'shouldHaveTitle':
|
|
277
|
+
return `Expect browser title to be "${args.expected ?? args.title ?? '{title}'}"`;
|
|
278
|
+
case 'contain.title':
|
|
279
|
+
case 'shouldContainTitle':
|
|
280
|
+
return `Expect browser title to contain "${args.expected ?? args.title ?? '{title}'}"`;
|
|
281
|
+
case 'be.loaded':
|
|
282
|
+
case 'shouldBeLoaded':
|
|
283
|
+
case 'be.pageLoaded':
|
|
284
|
+
case 'shouldBePageLoaded':
|
|
285
|
+
return `Expect page to be fully loaded`;
|
|
286
|
+
case 'have.noConsoleErrors':
|
|
287
|
+
case 'shouldHaveNoConsoleErrors':
|
|
288
|
+
return `Expect browser console to have no errors`;
|
|
289
|
+
default:
|
|
290
|
+
return `Expect ${el} to match ${assertionKey}`;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Parses raw JSON action/assertion payloads or native orchestrator payloads into English sentences.
|
|
295
|
+
*/
|
|
296
|
+
export function parseAndFormat(cmdText) {
|
|
297
|
+
try {
|
|
298
|
+
const payload = JSON.parse(cmdText);
|
|
299
|
+
const target = payload.target;
|
|
300
|
+
if (payload.type === 'action') {
|
|
301
|
+
return formatAction(payload.key, target, mapActionArgs(payload.key, payload.args || []));
|
|
302
|
+
}
|
|
303
|
+
else if (payload.type === 'assertion' || payload.type === 'browser') {
|
|
304
|
+
return formatAssertion(payload.key, target, mapAssertionArgs(payload.key, payload.args || []));
|
|
305
|
+
}
|
|
306
|
+
// Native Orchestrator payload format ({ test, action, selector, target, ... })
|
|
307
|
+
if (payload.action) {
|
|
308
|
+
const action = payload.action;
|
|
309
|
+
const t = payload.selector || payload.target;
|
|
310
|
+
switch (action) {
|
|
311
|
+
case 'navigate':
|
|
312
|
+
return formatAction('navigate', undefined, {
|
|
313
|
+
url: t || payload.url || payload.value || 'http://localhost:5173',
|
|
314
|
+
});
|
|
315
|
+
case 'click':
|
|
316
|
+
return formatAction('click', t);
|
|
317
|
+
case 'double_click':
|
|
318
|
+
case 'doubleClick':
|
|
319
|
+
return formatAction('doubleClick', t);
|
|
320
|
+
case 'right_click':
|
|
321
|
+
case 'rightClick':
|
|
322
|
+
return formatAction('rightClick', t);
|
|
323
|
+
case 'type':
|
|
324
|
+
case 'type_text':
|
|
325
|
+
case 'set_value':
|
|
326
|
+
return formatAction('type', t, { text: payload.text || payload.value });
|
|
327
|
+
case 'clear':
|
|
328
|
+
return formatAction('clear', t);
|
|
329
|
+
case 'hover':
|
|
330
|
+
return formatAction('hover', t);
|
|
331
|
+
case 'scroll_into_view':
|
|
332
|
+
case 'scrollIntoView':
|
|
333
|
+
return formatAction('scrollIntoView', t);
|
|
334
|
+
case 'should_be_visible':
|
|
335
|
+
return formatAssertion('be.visible', t);
|
|
336
|
+
case 'should_not_be_visible':
|
|
337
|
+
return formatAssertion('not.be.visible', t);
|
|
338
|
+
case 'should_exist':
|
|
339
|
+
return formatAssertion('exist', t);
|
|
340
|
+
case 'should_not_exist':
|
|
341
|
+
return formatAssertion('not.exist', t);
|
|
342
|
+
case 'should_be_enabled':
|
|
343
|
+
return formatAssertion('be.enabled', t);
|
|
344
|
+
case 'should_be_disabled':
|
|
345
|
+
return formatAssertion('be.disabled', t);
|
|
346
|
+
case 'should_be_clickable':
|
|
347
|
+
return formatAssertion('be.clickable', t);
|
|
348
|
+
case 'should_have_text':
|
|
349
|
+
return formatAssertion('have.text', t, { expected: payload.expected || payload.text || payload.value });
|
|
350
|
+
case 'should_contain_text':
|
|
351
|
+
return formatAssertion('contain.text', t, {
|
|
352
|
+
expected: payload.expected || payload.text || payload.value,
|
|
353
|
+
});
|
|
354
|
+
case 'should_have_value':
|
|
355
|
+
return formatAssertion('have.value', t, { expected: payload.expected || payload.value });
|
|
356
|
+
case 'should_contain_value':
|
|
357
|
+
return formatAssertion('contain.value', t, { expected: payload.expected || payload.value });
|
|
358
|
+
case 'should_have_length':
|
|
359
|
+
return formatAssertion('have.length', t, {
|
|
360
|
+
expected: payload.expected || payload.length || payload.value,
|
|
361
|
+
});
|
|
362
|
+
case 'should_have_title':
|
|
363
|
+
return formatAssertion('have.title', undefined, {
|
|
364
|
+
expected: payload.expected || payload.value || t || 'TestSpectra',
|
|
365
|
+
});
|
|
366
|
+
case 'should_be_empty':
|
|
367
|
+
return formatAssertion('be.empty', t);
|
|
368
|
+
case 'should_not_be_empty':
|
|
369
|
+
return formatAssertion('not.be.empty', t);
|
|
370
|
+
default:
|
|
371
|
+
return `Execute step "${action}" on ${t || 'target'}`;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return cmdText;
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return cmdText;
|
|
378
|
+
}
|
|
379
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
* # @testspectra/reporter
|
|
4
|
+
*
|
|
5
|
+
* Canonical semantic reporter, stream parser, and execution telemetry for TestSpectra.
|
|
6
|
+
*/
|
|
7
|
+
export * from './types.js';
|
|
8
|
+
export * from './formatter.js';
|
|
9
|
+
export * from './parser.js';
|
|
10
|
+
export * from './reporter.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
* # @testspectra/reporter
|
|
4
|
+
*
|
|
5
|
+
* Canonical semantic reporter, stream parser, and execution telemetry for TestSpectra.
|
|
6
|
+
*/
|
|
7
|
+
export * from './types.js';
|
|
8
|
+
export * from './formatter.js';
|
|
9
|
+
export * from './parser.js';
|
|
10
|
+
export * from './reporter.js';
|
package/dist/parser.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ParsedLineResult, ParsedTestHeader, ParsedToken, TestLog } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Strips ANSI color and control escape sequences from a string.
|
|
4
|
+
*/
|
|
5
|
+
export declare function stripAnsi(str: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Parses test headers in formats like:
|
|
8
|
+
* - "✓ ApiInterception / TC-0008-fetch-mocking (412ms)"
|
|
9
|
+
* - "✗ ApiInterception / TC-0009-post-mocking (65ms)"
|
|
10
|
+
* - "● Playground / TC-0001-form-controls"
|
|
11
|
+
* - "✓ TC-0008-fetch-mocking (412ms)"
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseTestHeader(rawText: string): ParsedTestHeader;
|
|
14
|
+
/**
|
|
15
|
+
* Detects whether a terminal stdout line belongs to an execution summary card or phase timing breakdown.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isSummaryLine(raw: string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Checks whether a raw or cleaned line is an internal machine protocol token
|
|
20
|
+
* (e.g. `[TESTSPECTRA_STEP_START]`, `[TESTSPECTRA_STEP_PASS]`, `[TESTSPECTRA_STEP_FAIL]`,
|
|
21
|
+
* `[TESTSPECTRA_TEST_START]`, `[TESTSPECTRA_TEST_PASS]`, `[TESTSPECTRA_TEST_FAIL]`,
|
|
22
|
+
* `[TESTSPECTRA_TEST_ERROR]`, `[TESTSPECTRA_COMPLETE]`, `[TESTSPECTRA_NETWORK]`, `[TESTSPECTRA_DURATION]`).
|
|
23
|
+
*/
|
|
24
|
+
export declare function isInternalProtocolLine(raw: string): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Real-Time Terminal Stdout Tokenizer & Log Factory.
|
|
27
|
+
*/
|
|
28
|
+
export declare class TestOutputParser {
|
|
29
|
+
private _currentError;
|
|
30
|
+
/** Returns the most recently captured error message string. */
|
|
31
|
+
get currentError(): string;
|
|
32
|
+
/** Clears the current error state. */
|
|
33
|
+
clearError(): void;
|
|
34
|
+
/**
|
|
35
|
+
* Extracts optional worker tags such as `[0-0]`, `[0-1]`, `[worker-1]`, `[worker:0]`.
|
|
36
|
+
* Ensures internal tokens like `[TESTSPECTRA_...]` are never falsely classified as worker tags.
|
|
37
|
+
*/
|
|
38
|
+
extractWorkerTag(line: string): {
|
|
39
|
+
workerId?: string;
|
|
40
|
+
remaining: string;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Parses a raw line of terminal output, stripping worker tags and extracting lifecycle tokens.
|
|
44
|
+
*/
|
|
45
|
+
parseLineWithWorker(line: string): ParsedLineResult;
|
|
46
|
+
/**
|
|
47
|
+
* Evaluates a clean terminal line and extracts lifecycle tokens.
|
|
48
|
+
*
|
|
49
|
+
* @param line - Clean line text without worker prefixes.
|
|
50
|
+
* @returns The extracted `ParsedToken`.
|
|
51
|
+
*/
|
|
52
|
+
parseLine(line: string): ParsedToken;
|
|
53
|
+
/**
|
|
54
|
+
* Factory creating a structured `TestLog` from a terminal output line.
|
|
55
|
+
*
|
|
56
|
+
* @param line - Raw line text.
|
|
57
|
+
* @param isError - Whether the line originated from stderr.
|
|
58
|
+
* @param executionId - Associated execution ID.
|
|
59
|
+
*/
|
|
60
|
+
createTestLog(line: string, isError: boolean, executionId?: string): TestLog;
|
|
61
|
+
}
|
package/dist/parser.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { parseAndFormat } from './formatter.js';
|
|
2
|
+
/**
|
|
3
|
+
* Strips ANSI color and control escape sequences from a string.
|
|
4
|
+
*/
|
|
5
|
+
export function stripAnsi(str) {
|
|
6
|
+
return str.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Parses test headers in formats like:
|
|
10
|
+
* - "✓ ApiInterception / TC-0008-fetch-mocking (412ms)"
|
|
11
|
+
* - "✗ ApiInterception / TC-0009-post-mocking (65ms)"
|
|
12
|
+
* - "● Playground / TC-0001-form-controls"
|
|
13
|
+
* - "✓ TC-0008-fetch-mocking (412ms)"
|
|
14
|
+
*/
|
|
15
|
+
export function parseTestHeader(rawText) {
|
|
16
|
+
const clean = stripAnsi(rawText)
|
|
17
|
+
.replace(/^\[TESTSPECTRA_TEST_(START|PASS|FAIL)\]/, '')
|
|
18
|
+
.replace(/^[✓✗●✔✖]\s*/, '')
|
|
19
|
+
.trim();
|
|
20
|
+
const matchDuration = clean.match(/\(([^)]+)ms\)$/);
|
|
21
|
+
const durationMs = matchDuration ? parseInt(matchDuration[1], 10) : undefined;
|
|
22
|
+
const titlePart = clean.replace(/\s*\([^)]+\)$/, '').trim();
|
|
23
|
+
if (titlePart.includes(' / ')) {
|
|
24
|
+
const parts = titlePart.split(' / ');
|
|
25
|
+
const suiteName = parts[0].trim();
|
|
26
|
+
const testCaseId = parts.slice(1).join(' / ').trim();
|
|
27
|
+
return {
|
|
28
|
+
suiteName: suiteName || undefined,
|
|
29
|
+
testCaseId: testCaseId || titlePart,
|
|
30
|
+
durationMs,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
testCaseId: titlePart,
|
|
35
|
+
durationMs,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Detects whether a terminal stdout line belongs to an execution summary card or phase timing breakdown.
|
|
40
|
+
*/
|
|
41
|
+
export function isSummaryLine(raw) {
|
|
42
|
+
const clean = stripAnsi(raw).trim();
|
|
43
|
+
return (clean.includes('TESTSPECTRA EXECUTION SUMMARY') ||
|
|
44
|
+
clean.includes('TestSpectra Execution Summary') ||
|
|
45
|
+
clean.includes('EXECUTION TIMING BREAKDOWN') ||
|
|
46
|
+
clean.startsWith('╭') ||
|
|
47
|
+
clean.startsWith('├') ||
|
|
48
|
+
clean.startsWith('╰') ||
|
|
49
|
+
clean.startsWith('┌') ||
|
|
50
|
+
clean.startsWith('└') ||
|
|
51
|
+
clean.startsWith('│') ||
|
|
52
|
+
/^\d+\.\s+(Master Chromium|Worker Spawning|Incognito Context|Total Test Execution|Context Teardown)/.test(clean));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Checks whether a raw or cleaned line is an internal machine protocol token
|
|
56
|
+
* (e.g. `[TESTSPECTRA_STEP_START]`, `[TESTSPECTRA_STEP_PASS]`, `[TESTSPECTRA_STEP_FAIL]`,
|
|
57
|
+
* `[TESTSPECTRA_TEST_START]`, `[TESTSPECTRA_TEST_PASS]`, `[TESTSPECTRA_TEST_FAIL]`,
|
|
58
|
+
* `[TESTSPECTRA_TEST_ERROR]`, `[TESTSPECTRA_COMPLETE]`, `[TESTSPECTRA_NETWORK]`, `[TESTSPECTRA_DURATION]`).
|
|
59
|
+
*/
|
|
60
|
+
export function isInternalProtocolLine(raw) {
|
|
61
|
+
const clean = stripAnsi(raw).trim();
|
|
62
|
+
return /^\[TESTSPECTRA_[A-Z0-9_]+\]/i.test(clean);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Real-Time Terminal Stdout Tokenizer & Log Factory.
|
|
66
|
+
*/
|
|
67
|
+
export class TestOutputParser {
|
|
68
|
+
_currentError = '';
|
|
69
|
+
/** Returns the most recently captured error message string. */
|
|
70
|
+
get currentError() {
|
|
71
|
+
return this._currentError;
|
|
72
|
+
}
|
|
73
|
+
/** Clears the current error state. */
|
|
74
|
+
clearError() {
|
|
75
|
+
this._currentError = '';
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Extracts optional worker tags such as `[0-0]`, `[0-1]`, `[worker-1]`, `[worker:0]`.
|
|
79
|
+
* Ensures internal tokens like `[TESTSPECTRA_...]` are never falsely classified as worker tags.
|
|
80
|
+
*/
|
|
81
|
+
extractWorkerTag(line) {
|
|
82
|
+
const raw = stripAnsi(line).trim();
|
|
83
|
+
if (/^\[(TESTSPECTRA|Network|TestSpectra)/i.test(raw)) {
|
|
84
|
+
return { workerId: undefined, remaining: raw };
|
|
85
|
+
}
|
|
86
|
+
const match = raw.match(/^\[(\d+-\d+|worker[:_-]?\w+|\d+)\]\s*(.*)$/i);
|
|
87
|
+
if (match) {
|
|
88
|
+
return { workerId: match[1], remaining: match[2] };
|
|
89
|
+
}
|
|
90
|
+
return { workerId: undefined, remaining: raw };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Parses a raw line of terminal output, stripping worker tags and extracting lifecycle tokens.
|
|
94
|
+
*/
|
|
95
|
+
parseLineWithWorker(line) {
|
|
96
|
+
const { workerId, remaining } = this.extractWorkerTag(line);
|
|
97
|
+
const token = this.parseLine(remaining);
|
|
98
|
+
return { workerId, token, cleanLine: remaining };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Evaluates a clean terminal line and extracts lifecycle tokens.
|
|
102
|
+
*
|
|
103
|
+
* @param line - Clean line text without worker prefixes.
|
|
104
|
+
* @returns The extracted `ParsedToken`.
|
|
105
|
+
*/
|
|
106
|
+
parseLine(line) {
|
|
107
|
+
const raw = stripAnsi(line).trim();
|
|
108
|
+
if (!raw)
|
|
109
|
+
return { type: 'none' };
|
|
110
|
+
// 1. Check for summary cards or timing tables
|
|
111
|
+
if (isSummaryLine(raw)) {
|
|
112
|
+
return { type: 'summary_banner', text: raw };
|
|
113
|
+
}
|
|
114
|
+
// 2. Spec file announcement: "RUNNING in <path>"
|
|
115
|
+
const runningMatch = raw.match(/RUNNING in (.+)/);
|
|
116
|
+
if (runningMatch) {
|
|
117
|
+
let specPath = runningMatch[1].trim();
|
|
118
|
+
specPath = specPath.replace(/\s+-\s+[a-zA-Z0-9_-]+$/, '').trim();
|
|
119
|
+
specPath = specPath.replace(/^[a-zA-Z0-9_-]+\s+-\s+/, '').trim();
|
|
120
|
+
return { type: 'spec_file', specPath };
|
|
121
|
+
}
|
|
122
|
+
// 3. Step passed: "✔ <stepText> (<duration>ms)" or "[TESTSPECTRA_STEP_PASS]"
|
|
123
|
+
if (raw.startsWith('✔ ') || raw.startsWith('[TESTSPECTRA_STEP_PASS]')) {
|
|
124
|
+
const fullText = raw
|
|
125
|
+
.replace('[TESTSPECTRA_STEP_PASS]', '')
|
|
126
|
+
.replace(/^✔\s*/, '')
|
|
127
|
+
.trim();
|
|
128
|
+
const match = fullText.match(/\(([^)]+)ms\)$/);
|
|
129
|
+
const durationMs = match ? parseInt(match[1], 10) : undefined;
|
|
130
|
+
const cmdText = fullText.replace(/\s*\([^)]+\)$/, '').trim();
|
|
131
|
+
const formatted = parseAndFormat(cmdText);
|
|
132
|
+
return { type: 'step_pass', stepText: formatted, durationMs };
|
|
133
|
+
}
|
|
134
|
+
// 4. Step failed: "✖ <stepText> (<duration>ms)" or "[TESTSPECTRA_STEP_FAIL]"
|
|
135
|
+
if (raw.startsWith('✖ ') || raw.startsWith('[TESTSPECTRA_STEP_FAIL]')) {
|
|
136
|
+
const fullText = raw
|
|
137
|
+
.replace('[TESTSPECTRA_STEP_FAIL]', '')
|
|
138
|
+
.replace(/^✖\s*/, '')
|
|
139
|
+
.trim();
|
|
140
|
+
const match = fullText.match(/\(([^)]+)ms\)$/);
|
|
141
|
+
const durationMs = match ? parseInt(match[1], 10) : undefined;
|
|
142
|
+
const cmdText = fullText.replace(/\s*\([^)]+\)$/, '').trim();
|
|
143
|
+
const formatted = parseAndFormat(cmdText);
|
|
144
|
+
return { type: 'step_fail', stepText: formatted, durationMs };
|
|
145
|
+
}
|
|
146
|
+
// 5. Test error: "[TESTSPECTRA_TEST_ERROR]" or "↳ Error: <msg>"
|
|
147
|
+
if (raw.startsWith('[TESTSPECTRA_TEST_ERROR]') || raw.includes('↳ Error:')) {
|
|
148
|
+
const message = raw
|
|
149
|
+
.replace('[TESTSPECTRA_TEST_ERROR]', '')
|
|
150
|
+
.replace(/.*↳\s*Error:\s*/, '')
|
|
151
|
+
.trim();
|
|
152
|
+
this._currentError = message;
|
|
153
|
+
return { type: 'test_error', message };
|
|
154
|
+
}
|
|
155
|
+
// 6. Test started: "[TESTSPECTRA_TEST_START]" or "● <title>"
|
|
156
|
+
if (raw.startsWith('[TESTSPECTRA_TEST_START]') || raw.startsWith('● ')) {
|
|
157
|
+
const content = raw
|
|
158
|
+
.replace('[TESTSPECTRA_TEST_START]', '')
|
|
159
|
+
.replace(/^●\s*/, '')
|
|
160
|
+
.trim();
|
|
161
|
+
try {
|
|
162
|
+
const parsed = JSON.parse(content);
|
|
163
|
+
if (parsed && typeof parsed === 'object') {
|
|
164
|
+
const title = parsed.title || parsed.id || '';
|
|
165
|
+
const id = typeof parsed.id === 'string' ? parsed.id : undefined;
|
|
166
|
+
const testFilePath = typeof parsed.testFilePath === 'string' ? parsed.testFilePath : undefined;
|
|
167
|
+
const workerId = typeof parsed.workerId === 'number' ? parsed.workerId : undefined;
|
|
168
|
+
const header = parseTestHeader(title);
|
|
169
|
+
return {
|
|
170
|
+
type: 'test_start',
|
|
171
|
+
title,
|
|
172
|
+
suiteName: header.suiteName,
|
|
173
|
+
testCaseId: header.testCaseId,
|
|
174
|
+
id,
|
|
175
|
+
testFilePath,
|
|
176
|
+
workerId,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch { }
|
|
181
|
+
const header = parseTestHeader(content);
|
|
182
|
+
return {
|
|
183
|
+
type: 'test_start',
|
|
184
|
+
title: content,
|
|
185
|
+
suiteName: header.suiteName,
|
|
186
|
+
testCaseId: header.testCaseId,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
// 7. Test passed: "[TESTSPECTRA_TEST_PASS]" or "✓ <title> (<duration>ms)"
|
|
190
|
+
if (raw.startsWith('[TESTSPECTRA_TEST_PASS]') || raw.startsWith('✓ ')) {
|
|
191
|
+
const header = parseTestHeader(raw);
|
|
192
|
+
this._currentError = '';
|
|
193
|
+
return {
|
|
194
|
+
type: 'test_pass',
|
|
195
|
+
title: header.testCaseId,
|
|
196
|
+
suiteName: header.suiteName,
|
|
197
|
+
testCaseId: header.testCaseId,
|
|
198
|
+
durationMs: header.durationMs,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
// 8. Test failed: "[TESTSPECTRA_TEST_FAIL]" or "✗ <title> (<duration>ms)"
|
|
202
|
+
if (raw.startsWith('[TESTSPECTRA_TEST_FAIL]') || raw.startsWith('✗ ')) {
|
|
203
|
+
const header = parseTestHeader(raw);
|
|
204
|
+
return {
|
|
205
|
+
type: 'test_fail',
|
|
206
|
+
title: header.testCaseId,
|
|
207
|
+
suiteName: header.suiteName,
|
|
208
|
+
testCaseId: header.testCaseId,
|
|
209
|
+
durationMs: header.durationMs,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return { type: 'none' };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Factory creating a structured `TestLog` from a terminal output line.
|
|
216
|
+
*
|
|
217
|
+
* @param line - Raw line text.
|
|
218
|
+
* @param isError - Whether the line originated from stderr.
|
|
219
|
+
* @param executionId - Associated execution ID.
|
|
220
|
+
*/
|
|
221
|
+
createTestLog(line, isError, executionId) {
|
|
222
|
+
const now = new Date();
|
|
223
|
+
const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
|
|
224
|
+
let level = isError ? 'ERROR' : 'INFO';
|
|
225
|
+
if (line.includes('✓') || line.includes('✔') || line.includes('PASSED'))
|
|
226
|
+
level = 'SUCCESS';
|
|
227
|
+
else if (line.includes('✗') || line.includes('✖') || line.includes('FAILED'))
|
|
228
|
+
level = 'ERROR';
|
|
229
|
+
else if (line.includes('WARN') || line.includes('Warning:'))
|
|
230
|
+
level = 'WARNING';
|
|
231
|
+
return {
|
|
232
|
+
timestamp: timeStr,
|
|
233
|
+
level,
|
|
234
|
+
message: line,
|
|
235
|
+
runId: executionId,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ExecutionSummary, NetworkResource, PendingTestCase, ReporterOptions, TestLog, TestRunResult } from './types.js';
|
|
2
|
+
export declare class SemanticReporter {
|
|
3
|
+
protected logs: TestLog[];
|
|
4
|
+
protected networkEvents: NetworkResource[];
|
|
5
|
+
protected pendingTests: Map<string, PendingTestCase>;
|
|
6
|
+
protected workerToTest: Map<number, string>;
|
|
7
|
+
protected pendingErrorMsg?: string;
|
|
8
|
+
protected passedCount: number;
|
|
9
|
+
protected failedCount: number;
|
|
10
|
+
protected options: ReporterOptions;
|
|
11
|
+
constructor(options?: ReporterOptions);
|
|
12
|
+
/**
|
|
13
|
+
* Resolves an active pending test case by test title or worker ID.
|
|
14
|
+
*/
|
|
15
|
+
resolveTestCase(testTitle?: string, workerId?: number): PendingTestCase | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Ingests a raw string line or structured TestLog entry from stdout/stderr.
|
|
18
|
+
*/
|
|
19
|
+
addLog(input: string | TestLog): void;
|
|
20
|
+
/**
|
|
21
|
+
* Returns a snapshot of currently running pending test cases.
|
|
22
|
+
*/
|
|
23
|
+
getPendingTests(): PendingTestCase[];
|
|
24
|
+
/**
|
|
25
|
+
* Returns aggregated test run telemetry results.
|
|
26
|
+
*/
|
|
27
|
+
getResult(): TestRunResult;
|
|
28
|
+
/**
|
|
29
|
+
* Computes an execution summary.
|
|
30
|
+
*/
|
|
31
|
+
getSummary(reportLocation?: string): ExecutionSummary;
|
|
32
|
+
}
|
package/dist/reporter.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { parseAndFormat } from './formatter.js';
|
|
2
|
+
import { stripAnsi } from './parser.js';
|
|
3
|
+
export class SemanticReporter {
|
|
4
|
+
logs = [];
|
|
5
|
+
networkEvents = [];
|
|
6
|
+
pendingTests = new Map();
|
|
7
|
+
workerToTest = new Map();
|
|
8
|
+
pendingErrorMsg;
|
|
9
|
+
passedCount = 0;
|
|
10
|
+
failedCount = 0;
|
|
11
|
+
options;
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
this.options = options;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolves an active pending test case by test title or worker ID.
|
|
17
|
+
*/
|
|
18
|
+
resolveTestCase(testTitle, workerId) {
|
|
19
|
+
if (testTitle && this.pendingTests.has(testTitle)) {
|
|
20
|
+
return this.pendingTests.get(testTitle);
|
|
21
|
+
}
|
|
22
|
+
if (workerId !== undefined && this.workerToTest.has(workerId)) {
|
|
23
|
+
const title = this.workerToTest.get(workerId);
|
|
24
|
+
if (title && this.pendingTests.has(title)) {
|
|
25
|
+
return this.pendingTests.get(title);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (this.pendingTests.size === 1) {
|
|
29
|
+
return this.pendingTests.values().next().value;
|
|
30
|
+
}
|
|
31
|
+
return Array.from(this.pendingTests.values()).pop();
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Ingests a raw string line or structured TestLog entry from stdout/stderr.
|
|
35
|
+
*/
|
|
36
|
+
addLog(input) {
|
|
37
|
+
const log = typeof input === 'string'
|
|
38
|
+
? {
|
|
39
|
+
timestamp: new Date().toISOString(),
|
|
40
|
+
level: 'INFO',
|
|
41
|
+
message: input,
|
|
42
|
+
}
|
|
43
|
+
: input;
|
|
44
|
+
this.logs.push(log);
|
|
45
|
+
this.options.onLog?.(log);
|
|
46
|
+
const raw = stripAnsi(log.message || '').trim();
|
|
47
|
+
if (!raw)
|
|
48
|
+
return;
|
|
49
|
+
// Filter out WDIO framework internal noise
|
|
50
|
+
if (raw.includes('RUNNING in chrome') ||
|
|
51
|
+
raw.includes('PASSED in chrome') ||
|
|
52
|
+
raw.includes('FAILED in chrome') ||
|
|
53
|
+
raw.includes('[NetworkRecorder]') ||
|
|
54
|
+
raw.includes('"spec" Reporter:') ||
|
|
55
|
+
raw.includes('"DotReporter" Reporter:') ||
|
|
56
|
+
raw.includes('SilentReporter') ||
|
|
57
|
+
raw.startsWith('---') ||
|
|
58
|
+
raw === '.' ||
|
|
59
|
+
raw.includes('Session ID:') ||
|
|
60
|
+
raw.includes('Running: chrome') ||
|
|
61
|
+
raw.includes('[chrome') ||
|
|
62
|
+
raw.includes('[firefox') ||
|
|
63
|
+
raw.includes('[safari') ||
|
|
64
|
+
raw.includes('[edge') ||
|
|
65
|
+
raw.includes('(root)') ||
|
|
66
|
+
raw.includes('passing (') ||
|
|
67
|
+
raw.includes('failing (') ||
|
|
68
|
+
raw.includes('Spec Files:') ||
|
|
69
|
+
(raw.includes('Execution of') && raw.includes('workers started')) ||
|
|
70
|
+
raw.includes('Spawning WDIO execution') ||
|
|
71
|
+
raw.includes('WARN webdriver:') ||
|
|
72
|
+
raw.includes('WebDriverError: unknown error: net::ERR_CONNECTION_REFUSED') ||
|
|
73
|
+
raw.includes('when running "url" with method "POST"') ||
|
|
74
|
+
raw.includes('Session info: chrome=')) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// 1. Test started -> initialize test buffer & worker slot
|
|
78
|
+
if (raw.startsWith('[TESTSPECTRA_TEST_START]')) {
|
|
79
|
+
const content = raw.replace('[TESTSPECTRA_TEST_START]', '').trim();
|
|
80
|
+
let title = content;
|
|
81
|
+
let workerId;
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(content);
|
|
84
|
+
if (parsed.title) {
|
|
85
|
+
title = parsed.title;
|
|
86
|
+
workerId = parsed.workerId;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch { }
|
|
90
|
+
const pending = {
|
|
91
|
+
title,
|
|
92
|
+
workerId,
|
|
93
|
+
steps: [],
|
|
94
|
+
currentStep: 'Running suite...',
|
|
95
|
+
startTime: Date.now(),
|
|
96
|
+
};
|
|
97
|
+
this.pendingTests.set(title, pending);
|
|
98
|
+
if (workerId !== undefined) {
|
|
99
|
+
this.workerToTest.set(workerId, title);
|
|
100
|
+
}
|
|
101
|
+
this.options.onTestStart?.(pending);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// 2. Step in-progress / start
|
|
105
|
+
if (raw.startsWith('[TESTSPECTRA_STEP_START]')) {
|
|
106
|
+
const fullText = raw.replace('[TESTSPECTRA_STEP_START]', '').trim();
|
|
107
|
+
let testTitle = '';
|
|
108
|
+
let workerId;
|
|
109
|
+
try {
|
|
110
|
+
const payload = JSON.parse(fullText);
|
|
111
|
+
testTitle = payload.test || payload.title || '';
|
|
112
|
+
workerId = payload.workerId;
|
|
113
|
+
}
|
|
114
|
+
catch { }
|
|
115
|
+
const formatted = parseAndFormat(fullText);
|
|
116
|
+
const testCase = this.resolveTestCase(testTitle, workerId);
|
|
117
|
+
if (testCase) {
|
|
118
|
+
testCase.currentStep = formatted;
|
|
119
|
+
}
|
|
120
|
+
this.options.onStepStart?.(testTitle || testCase?.title || '', formatted, workerId ?? testCase?.workerId);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// 3. Step passed -> record step to matching test case
|
|
124
|
+
if (raw.startsWith('[TESTSPECTRA_STEP_PASS]')) {
|
|
125
|
+
const fullText = raw.replace('[TESTSPECTRA_STEP_PASS]', '').trim();
|
|
126
|
+
const match = fullText.match(/\(([^)]+)ms\)$/);
|
|
127
|
+
const duration = match ? parseInt(match[1], 10) : 0;
|
|
128
|
+
const cmdText = fullText.replace(/\s*\([^)]+\)$/, '').trim();
|
|
129
|
+
const formatted = parseAndFormat(cmdText);
|
|
130
|
+
let testTitle = '';
|
|
131
|
+
let workerId;
|
|
132
|
+
try {
|
|
133
|
+
const payload = JSON.parse(cmdText);
|
|
134
|
+
testTitle = payload.test || payload.title || '';
|
|
135
|
+
workerId = payload.workerId;
|
|
136
|
+
}
|
|
137
|
+
catch { }
|
|
138
|
+
const testCase = this.resolveTestCase(testTitle, workerId);
|
|
139
|
+
const step = { text: formatted, duration, passed: true };
|
|
140
|
+
if (testCase) {
|
|
141
|
+
testCase.steps.push(step);
|
|
142
|
+
testCase.currentStep = formatted;
|
|
143
|
+
}
|
|
144
|
+
this.options.onStepPass?.(testTitle || testCase?.title || '', step, workerId ?? testCase?.workerId);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// 4. Step failed -> record failed step to matching test case
|
|
148
|
+
if (raw.startsWith('[TESTSPECTRA_STEP_FAIL]')) {
|
|
149
|
+
const fullText = raw.replace('[TESTSPECTRA_STEP_FAIL]', '').trim();
|
|
150
|
+
const match = fullText.match(/\(([^)]+)ms\)$/);
|
|
151
|
+
const duration = match ? parseInt(match[1], 10) : 0;
|
|
152
|
+
const cmdText = fullText.replace(/\s*\([^)]+\)$/, '').trim();
|
|
153
|
+
const formatted = parseAndFormat(cmdText);
|
|
154
|
+
let testTitle = '';
|
|
155
|
+
let workerId;
|
|
156
|
+
try {
|
|
157
|
+
const payload = JSON.parse(cmdText);
|
|
158
|
+
testTitle = payload.test || payload.title || '';
|
|
159
|
+
workerId = payload.workerId;
|
|
160
|
+
}
|
|
161
|
+
catch { }
|
|
162
|
+
const testCase = this.resolveTestCase(testTitle, workerId);
|
|
163
|
+
const step = { text: formatted, duration, passed: false };
|
|
164
|
+
if (testCase) {
|
|
165
|
+
testCase.steps.push(step);
|
|
166
|
+
testCase.currentStep = formatted;
|
|
167
|
+
}
|
|
168
|
+
this.options.onStepFail?.(testTitle || testCase?.title || '', step, workerId ?? testCase?.workerId);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
// 5. Test error detail
|
|
172
|
+
if (raw.startsWith('[TESTSPECTRA_TEST_ERROR]')) {
|
|
173
|
+
const err = raw.replace('[TESTSPECTRA_TEST_ERROR]', '').trim();
|
|
174
|
+
const latest = Array.from(this.pendingTests.values()).pop();
|
|
175
|
+
if (latest) {
|
|
176
|
+
latest.errorMsg = latest.errorMsg ? `${latest.errorMsg}\n${err}` : err;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
this.pendingErrorMsg = this.pendingErrorMsg ? `${this.pendingErrorMsg}\n${err}` : err;
|
|
180
|
+
}
|
|
181
|
+
this.options.onTestError?.(err, latest?.title, latest?.workerId);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// 6. Test passed completely -> flush atomic test block
|
|
185
|
+
if (raw.startsWith('[TESTSPECTRA_TEST_PASS]')) {
|
|
186
|
+
const fullText = raw.replace('[TESTSPECTRA_TEST_PASS]', '').trim();
|
|
187
|
+
const match = fullText.match(/\(([^)]+)ms\)$/);
|
|
188
|
+
const duration = match ? parseInt(match[1], 10) : 0;
|
|
189
|
+
const title = fullText.replace(/\s*\([^)]+\)$/, '').trim();
|
|
190
|
+
this.passedCount++;
|
|
191
|
+
const testCase = this.pendingTests.get(title) || this.resolveTestCase(title);
|
|
192
|
+
const steps = testCase?.steps || [];
|
|
193
|
+
const workerId = testCase?.workerId;
|
|
194
|
+
this.pendingTests.delete(title);
|
|
195
|
+
if (workerId !== undefined) {
|
|
196
|
+
this.workerToTest.delete(workerId);
|
|
197
|
+
}
|
|
198
|
+
this.options.onTestPass?.({ title, duration, steps, workerId });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// 7. Test failed completely -> flush atomic failed test block
|
|
202
|
+
if (raw.startsWith('[TESTSPECTRA_TEST_FAIL]')) {
|
|
203
|
+
const fullText = raw.replace('[TESTSPECTRA_TEST_FAIL]', '').trim();
|
|
204
|
+
const match = fullText.match(/\(([^)]+)ms\)$/);
|
|
205
|
+
const duration = match ? parseInt(match[1], 10) : 0;
|
|
206
|
+
const title = fullText.replace(/\s*\([^)]+\)$/, '').trim();
|
|
207
|
+
this.failedCount++;
|
|
208
|
+
const testCase = this.pendingTests.get(title) || this.resolveTestCase(title);
|
|
209
|
+
const steps = testCase?.steps || [];
|
|
210
|
+
const errorMsg = testCase?.errorMsg || this.pendingErrorMsg;
|
|
211
|
+
const workerId = testCase?.workerId;
|
|
212
|
+
this.pendingTests.delete(title);
|
|
213
|
+
if (workerId !== undefined) {
|
|
214
|
+
this.workerToTest.delete(workerId);
|
|
215
|
+
}
|
|
216
|
+
this.pendingErrorMsg = undefined;
|
|
217
|
+
this.options.onTestFail?.({ title, duration, steps, errorMsg, workerId });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// 8. Test duration token
|
|
221
|
+
if (raw.startsWith('[TESTSPECTRA_DURATION]')) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// 9. Network resource telemetry
|
|
225
|
+
if (raw.startsWith('[TESTSPECTRA_NETWORK]')) {
|
|
226
|
+
try {
|
|
227
|
+
const jsonStr = raw.replace('[TESTSPECTRA_NETWORK]', '').trim();
|
|
228
|
+
const payload = JSON.parse(jsonStr);
|
|
229
|
+
this.networkEvents.push(payload);
|
|
230
|
+
this.options.onNetwork?.(payload);
|
|
231
|
+
}
|
|
232
|
+
catch { }
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Returns a snapshot of currently running pending test cases.
|
|
238
|
+
*/
|
|
239
|
+
getPendingTests() {
|
|
240
|
+
return Array.from(this.pendingTests.values());
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Returns aggregated test run telemetry results.
|
|
244
|
+
*/
|
|
245
|
+
getResult() {
|
|
246
|
+
const hasFailures = this.failedCount > 0;
|
|
247
|
+
return {
|
|
248
|
+
status: hasFailures ? 'failed' : 'passed',
|
|
249
|
+
duration: '0s',
|
|
250
|
+
passedCount: this.passedCount,
|
|
251
|
+
failedCount: this.failedCount,
|
|
252
|
+
logs: this.logs,
|
|
253
|
+
networkResources: this.networkEvents.length > 0 ? this.networkEvents : undefined,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Computes an execution summary.
|
|
258
|
+
*/
|
|
259
|
+
getSummary(reportLocation = '.testspectra/reports/result.json') {
|
|
260
|
+
const totalTests = this.passedCount + this.failedCount;
|
|
261
|
+
return {
|
|
262
|
+
totalSuites: 1,
|
|
263
|
+
totalTests,
|
|
264
|
+
passedCount: this.passedCount,
|
|
265
|
+
failedCount: this.failedCount,
|
|
266
|
+
durationMs: 0,
|
|
267
|
+
reportLocation,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
export type ActionKey = 'click' | 'doubleClick' | 'rightClick' | 'type' | 'clear' | 'select' | 'selectOption' | 'hover' | 'dragDrop' | 'scrollIntoView' | 'longPress' | 'waitForElement' | 'navigate' | 'back' | 'forward' | 'refresh' | 'setViewport' | 'wait' | 'pressKey' | 'scroll' | 'swipe' | 'upload' | 'intercept' | 'clearCookies' | 'setCookies' | 'clearLocalStorage' | 'mount';
|
|
2
|
+
export type AssertionKey = 'be.visible' | 'not.be.visible' | 'exist' | 'not.exist' | 'be.clickable' | 'not.be.clickable' | 'be.enabled' | 'be.disabled' | 'be.checked' | 'not.be.checked' | 'be.selected' | 'not.be.selected' | 'be.focused' | 'not.be.focused' | 'have.text' | 'not.have.text' | 'contain.text' | 'not.contain.text' | 'have.value' | 'not.have.value' | 'contain.value' | 'not.contain.value' | 'have.attr' | 'not.have.attr' | 'have.class' | 'not.have.class' | 'have.css' | 'not.have.css' | 'have.length' | 'not.have.length' | 'have.length.greaterThan' | 'have.length.lessThan' | 'be.empty' | 'not.be.empty' | 'shouldHaveUrl' | 'shouldContainUrl' | 'shouldHaveTitle' | 'shouldContainTitle' | 'shouldBeLoaded' | 'shouldBePageLoaded' | 'shouldHaveNoConsoleErrors' | 'have.url' | 'contain.url' | 'have.title' | 'contain.title' | 'be.loaded' | 'be.pageLoaded' | 'have.noConsoleErrors';
|
|
3
|
+
export type TestLogLevel = 'INFO' | 'SUCCESS' | 'WARNING' | 'ERROR' | 'DEBUG';
|
|
4
|
+
export interface TestLog {
|
|
5
|
+
timestamp: string;
|
|
6
|
+
level: TestLogLevel;
|
|
7
|
+
message: string;
|
|
8
|
+
runId?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface NetworkResource {
|
|
11
|
+
requestId: string;
|
|
12
|
+
url: string;
|
|
13
|
+
method: string;
|
|
14
|
+
status: number;
|
|
15
|
+
type?: string;
|
|
16
|
+
statusText?: string;
|
|
17
|
+
domain?: string;
|
|
18
|
+
protocol?: string;
|
|
19
|
+
timing?: {
|
|
20
|
+
waiting: number;
|
|
21
|
+
download: number;
|
|
22
|
+
};
|
|
23
|
+
size?: number;
|
|
24
|
+
transferSize?: number;
|
|
25
|
+
priority?: string;
|
|
26
|
+
cached?: boolean;
|
|
27
|
+
initiator?: string;
|
|
28
|
+
remoteIP?: string;
|
|
29
|
+
connectionId?: string;
|
|
30
|
+
startTime?: number;
|
|
31
|
+
runId?: string;
|
|
32
|
+
testTitle?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface PendingStep {
|
|
35
|
+
text: string;
|
|
36
|
+
duration: number;
|
|
37
|
+
passed: boolean;
|
|
38
|
+
}
|
|
39
|
+
export interface PendingTestCase {
|
|
40
|
+
title: string;
|
|
41
|
+
workerId?: number;
|
|
42
|
+
steps: PendingStep[];
|
|
43
|
+
currentStep?: string;
|
|
44
|
+
errorMsg?: string;
|
|
45
|
+
videoPath?: string;
|
|
46
|
+
startTime: number;
|
|
47
|
+
}
|
|
48
|
+
export interface TestRunResult {
|
|
49
|
+
status: 'passed' | 'failed' | 'error' | string;
|
|
50
|
+
duration: string;
|
|
51
|
+
passedCount: number;
|
|
52
|
+
failedCount: number;
|
|
53
|
+
logs: TestLog[];
|
|
54
|
+
networkResources?: NetworkResource[];
|
|
55
|
+
}
|
|
56
|
+
export interface ExecutionSummary {
|
|
57
|
+
totalSuites: number;
|
|
58
|
+
totalTests: number;
|
|
59
|
+
passedCount: number;
|
|
60
|
+
failedCount: number;
|
|
61
|
+
durationMs: number;
|
|
62
|
+
reportLocation: string;
|
|
63
|
+
}
|
|
64
|
+
export interface ParsedTestHeader {
|
|
65
|
+
suiteName?: string;
|
|
66
|
+
testCaseId: string;
|
|
67
|
+
durationMs?: number;
|
|
68
|
+
}
|
|
69
|
+
export type ParsedToken = {
|
|
70
|
+
type: 'spec_file';
|
|
71
|
+
specPath: string;
|
|
72
|
+
} | {
|
|
73
|
+
type: 'test_start';
|
|
74
|
+
title: string;
|
|
75
|
+
suiteName?: string;
|
|
76
|
+
testCaseId?: string;
|
|
77
|
+
id?: string;
|
|
78
|
+
testFilePath?: string;
|
|
79
|
+
workerId?: number;
|
|
80
|
+
} | {
|
|
81
|
+
type: 'test_error';
|
|
82
|
+
message: string;
|
|
83
|
+
} | {
|
|
84
|
+
type: 'test_pass';
|
|
85
|
+
title: string;
|
|
86
|
+
suiteName?: string;
|
|
87
|
+
testCaseId?: string;
|
|
88
|
+
durationMs?: number;
|
|
89
|
+
} | {
|
|
90
|
+
type: 'test_fail';
|
|
91
|
+
title: string;
|
|
92
|
+
suiteName?: string;
|
|
93
|
+
testCaseId?: string;
|
|
94
|
+
durationMs?: number;
|
|
95
|
+
} | {
|
|
96
|
+
type: 'step_pass';
|
|
97
|
+
stepText: string;
|
|
98
|
+
durationMs?: number;
|
|
99
|
+
} | {
|
|
100
|
+
type: 'step_fail';
|
|
101
|
+
stepText: string;
|
|
102
|
+
durationMs?: number;
|
|
103
|
+
} | {
|
|
104
|
+
type: 'summary_banner';
|
|
105
|
+
text: string;
|
|
106
|
+
} | {
|
|
107
|
+
type: 'none';
|
|
108
|
+
};
|
|
109
|
+
export interface ParsedLineResult {
|
|
110
|
+
workerId?: string;
|
|
111
|
+
token: ParsedToken;
|
|
112
|
+
cleanLine: string;
|
|
113
|
+
}
|
|
114
|
+
export interface ReporterCallbacks {
|
|
115
|
+
onTestStart?: (test: PendingTestCase) => void;
|
|
116
|
+
onStepStart?: (testTitle: string, stepText: string, workerId?: number) => void;
|
|
117
|
+
onStepPass?: (testTitle: string, step: PendingStep, workerId?: number) => void;
|
|
118
|
+
onStepFail?: (testTitle: string, step: PendingStep, workerId?: number) => void;
|
|
119
|
+
onTestPass?: (test: {
|
|
120
|
+
title: string;
|
|
121
|
+
duration: number;
|
|
122
|
+
steps: PendingStep[];
|
|
123
|
+
workerId?: number;
|
|
124
|
+
}) => void;
|
|
125
|
+
onTestFail?: (test: {
|
|
126
|
+
title: string;
|
|
127
|
+
duration: number;
|
|
128
|
+
steps: PendingStep[];
|
|
129
|
+
errorMsg?: string;
|
|
130
|
+
workerId?: number;
|
|
131
|
+
}) => void;
|
|
132
|
+
onTestError?: (errorMessage: string, testTitle?: string, workerId?: number) => void;
|
|
133
|
+
onNetwork?: (resource: NetworkResource) => void;
|
|
134
|
+
onLog?: (log: TestLog) => void;
|
|
135
|
+
}
|
|
136
|
+
export interface ReporterOptions extends ReporterCallbacks {
|
|
137
|
+
isInteractive?: boolean;
|
|
138
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@testspectra/reporter",
|
|
3
|
+
"version": "1.1.13",
|
|
4
|
+
"description": "Canonical semantic reporter, output stream parser, and execution telemetry for TestSpectra",
|
|
5
|
+
"files": [
|
|
6
|
+
"dist",
|
|
7
|
+
"LICENSE.md"
|
|
8
|
+
],
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^20.14.0",
|
|
20
|
+
"typescript": "^5.6.3",
|
|
21
|
+
"vitest": "^2.1.8"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc",
|
|
27
|
+
"watch": "tsc -w",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"test": "vitest run"
|
|
30
|
+
}
|
|
31
|
+
}
|