testlens-playwright-reporter 0.2.6 → 0.2.8
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/index.d.ts +48 -0
- package/index.js +160 -7
- package/index.ts +184 -7
- package/lib/index.js +136 -7
- package/package.json +1 -1
package/index.d.ts
CHANGED
|
@@ -11,6 +11,10 @@ export interface TestLensReporterConfig {
|
|
|
11
11
|
enableGitInfo?: boolean;
|
|
12
12
|
/** Enable artifact processing */
|
|
13
13
|
enableArtifacts?: boolean;
|
|
14
|
+
/** Enable video capture - defaults to true */
|
|
15
|
+
enableVideo?: boolean;
|
|
16
|
+
/** Enable screenshot capture - defaults to true */
|
|
17
|
+
enableScreenshot?: boolean;
|
|
14
18
|
/** Batch size for API requests */
|
|
15
19
|
batchSize?: number;
|
|
16
20
|
/** Flush interval in milliseconds */
|
|
@@ -36,6 +40,10 @@ export interface TestLensReporterOptions {
|
|
|
36
40
|
enableGitInfo?: boolean;
|
|
37
41
|
/** Enable artifact processing */
|
|
38
42
|
enableArtifacts?: boolean;
|
|
43
|
+
/** Enable video capture - defaults to true */
|
|
44
|
+
enableVideo?: boolean;
|
|
45
|
+
/** Enable screenshot capture - defaults to true */
|
|
46
|
+
enableScreenshot?: boolean;
|
|
39
47
|
/** Batch size for API requests */
|
|
40
48
|
batchSize?: number;
|
|
41
49
|
/** Flush interval in milliseconds */
|
|
@@ -62,6 +70,46 @@ export interface GitInfo {
|
|
|
62
70
|
remoteUrl: string;
|
|
63
71
|
}
|
|
64
72
|
|
|
73
|
+
/** Rich error details from Playwright */
|
|
74
|
+
export interface TestError {
|
|
75
|
+
message: string;
|
|
76
|
+
stack?: string;
|
|
77
|
+
location?: {
|
|
78
|
+
file: string;
|
|
79
|
+
line: number;
|
|
80
|
+
column: number;
|
|
81
|
+
};
|
|
82
|
+
snippet?: string;
|
|
83
|
+
expected?: string;
|
|
84
|
+
actual?: string;
|
|
85
|
+
diff?: string;
|
|
86
|
+
matcherName?: string;
|
|
87
|
+
timeout?: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface TestData {
|
|
91
|
+
id: string;
|
|
92
|
+
name: string;
|
|
93
|
+
status: string;
|
|
94
|
+
originalStatus?: string;
|
|
95
|
+
duration: number;
|
|
96
|
+
startTime: string;
|
|
97
|
+
endTime: string;
|
|
98
|
+
errorMessages: string[];
|
|
99
|
+
errors?: TestError[];
|
|
100
|
+
retryAttempts: number;
|
|
101
|
+
currentRetry: number;
|
|
102
|
+
annotations: Array<{ type: string; description?: string }>;
|
|
103
|
+
projectName: string;
|
|
104
|
+
workerIndex?: number;
|
|
105
|
+
parallelIndex?: number;
|
|
106
|
+
location?: {
|
|
107
|
+
file: string;
|
|
108
|
+
line: number;
|
|
109
|
+
column: number;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
65
113
|
export default class TestLensReporter implements Reporter {
|
|
66
114
|
constructor(options: TestLensReporterOptions);
|
|
67
115
|
|
package/index.js
CHANGED
|
@@ -27,6 +27,8 @@ class TestLensReporter {
|
|
|
27
27
|
enableRealTimeStream: options.enableRealTimeStream !== undefined ? options.enableRealTimeStream : true,
|
|
28
28
|
enableGitInfo: options.enableGitInfo !== undefined ? options.enableGitInfo : true,
|
|
29
29
|
enableArtifacts: options.enableArtifacts !== undefined ? options.enableArtifacts : true,
|
|
30
|
+
enableVideo: options.enableVideo !== undefined ? options.enableVideo : true, // Default to true, override from config
|
|
31
|
+
enableScreenshot: options.enableScreenshot !== undefined ? options.enableScreenshot : true, // Default to true, override from config
|
|
30
32
|
batchSize: options.batchSize || 10,
|
|
31
33
|
flushInterval: options.flushInterval || 5000,
|
|
32
34
|
retryAttempts: options.retryAttempts !== undefined ? options.retryAttempts : 0,
|
|
@@ -211,6 +213,7 @@ class TestLensReporter {
|
|
|
211
213
|
startTime: new Date().toISOString(),
|
|
212
214
|
endTime: '',
|
|
213
215
|
errorMessages: [],
|
|
216
|
+
errors: [],
|
|
214
217
|
retryAttempts: test.retries,
|
|
215
218
|
currentRetry: result.retry,
|
|
216
219
|
annotations: test.annotations.map(ann => ({
|
|
@@ -219,7 +222,12 @@ class TestLensReporter {
|
|
|
219
222
|
})),
|
|
220
223
|
projectName: test.parent.project()?.name || 'default',
|
|
221
224
|
workerIndex: result.workerIndex,
|
|
222
|
-
parallelIndex: result.parallelIndex
|
|
225
|
+
parallelIndex: result.parallelIndex,
|
|
226
|
+
location: {
|
|
227
|
+
file: path.relative(process.cwd(), test.location.file),
|
|
228
|
+
line: test.location.line,
|
|
229
|
+
column: test.location.column
|
|
230
|
+
}
|
|
223
231
|
};
|
|
224
232
|
|
|
225
233
|
this.testMap.set(testData.id, testData);
|
|
@@ -252,6 +260,96 @@ class TestLensReporter {
|
|
|
252
260
|
testData.endTime = new Date().toISOString();
|
|
253
261
|
testData.errorMessages = result.errors.map(error => error.message || error.toString());
|
|
254
262
|
testData.currentRetry = result.retry;
|
|
263
|
+
|
|
264
|
+
// Capture test location
|
|
265
|
+
testData.location = {
|
|
266
|
+
file: path.relative(process.cwd(), test.location.file),
|
|
267
|
+
line: test.location.line,
|
|
268
|
+
column: test.location.column
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Capture rich error details like Playwright's HTML report
|
|
272
|
+
testData.errors = result.errors.map(error => {
|
|
273
|
+
const testError = {
|
|
274
|
+
message: error.message || error.toString()
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// Capture stack trace
|
|
278
|
+
if (error.stack) {
|
|
279
|
+
testError.stack = error.stack;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Capture error location
|
|
283
|
+
if (error.location) {
|
|
284
|
+
testError.location = {
|
|
285
|
+
file: path.relative(process.cwd(), error.location.file),
|
|
286
|
+
line: error.location.line,
|
|
287
|
+
column: error.location.column
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Capture code snippet around error - from Playwright error object
|
|
292
|
+
if (error.snippet) {
|
|
293
|
+
testError.snippet = error.snippet;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Capture expected/actual values for assertion failures
|
|
297
|
+
// Playwright stores these as specially formatted strings in the message
|
|
298
|
+
const message = error.message || '';
|
|
299
|
+
|
|
300
|
+
// Try to parse expected pattern from toHaveURL and similar assertions
|
|
301
|
+
const expectedPatternMatch = message.match(/Expected pattern:\s*(.+?)(?:\n|$)/);
|
|
302
|
+
if (expectedPatternMatch) {
|
|
303
|
+
testError.expected = expectedPatternMatch[1].trim();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Also try "Expected string:" format
|
|
307
|
+
if (!testError.expected) {
|
|
308
|
+
const expectedStringMatch = message.match(/Expected string:\s*["']?(.+?)["']?(?:\n|$)/);
|
|
309
|
+
if (expectedStringMatch) {
|
|
310
|
+
testError.expected = expectedStringMatch[1].trim();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Try to parse received/actual value
|
|
315
|
+
const receivedMatch = message.match(/Received (?:string|value):\s*["']?(.+?)["']?(?:\n|$)/);
|
|
316
|
+
if (receivedMatch) {
|
|
317
|
+
testError.actual = receivedMatch[1].trim();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Parse call log entries for debugging info (timeouts, retries, etc.)
|
|
321
|
+
const callLogMatch = message.match(/Call log:([\s\S]*?)(?=\n\n|\n\s*\d+\s*\||$)/);
|
|
322
|
+
if (callLogMatch) {
|
|
323
|
+
// Store call log separately for display
|
|
324
|
+
const callLog = callLogMatch[1].trim();
|
|
325
|
+
if (callLog) {
|
|
326
|
+
testError.diff = callLog; // Reuse diff field for call log
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Parse timeout information - multiple formats
|
|
331
|
+
const timeoutMatch = message.match(/(?:with timeout|Timeout:?)\s*(\d+)ms/i);
|
|
332
|
+
if (timeoutMatch) {
|
|
333
|
+
testError.timeout = parseInt(timeoutMatch[1], 10);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Parse matcher name (e.g., toHaveURL, toBeVisible)
|
|
337
|
+
const matcherMatch = message.match(/expect\([^)]+\)\.(\w+)/);
|
|
338
|
+
if (matcherMatch) {
|
|
339
|
+
testError.matcherName = matcherMatch[1];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Extract code snippet from message if not already captured
|
|
343
|
+
// Look for lines like " 9 | await page.click..." or "> 11 | await expect..."
|
|
344
|
+
if (!testError.snippet) {
|
|
345
|
+
const codeSnippetMatch = message.match(/((?:\s*>?\s*\d+\s*\|.*\n?)+)/);
|
|
346
|
+
if (codeSnippetMatch) {
|
|
347
|
+
testError.snippet = codeSnippetMatch[1].trim();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return testError;
|
|
352
|
+
});
|
|
255
353
|
|
|
256
354
|
// Only send testEnd event after final retry attempt
|
|
257
355
|
// If test passed or this is the last retry, send the event
|
|
@@ -316,8 +414,10 @@ class TestLensReporter {
|
|
|
316
414
|
// Calculate final stats
|
|
317
415
|
const totalTests = Array.from(this.testMap.values()).length;
|
|
318
416
|
const passedTests = Array.from(this.testMap.values()).filter(t => t.status === 'passed').length;
|
|
417
|
+
// failedTests already includes timedOut tests since normalizeTestStatus converts 'timedOut' to 'failed'
|
|
319
418
|
const failedTests = Array.from(this.testMap.values()).filter(t => t.status === 'failed').length;
|
|
320
419
|
const skippedTests = Array.from(this.testMap.values()).filter(t => t.status === 'skipped').length;
|
|
420
|
+
// Track timedOut separately for reporting purposes only (not for count)
|
|
321
421
|
const timedOutTests = Array.from(this.testMap.values()).filter(t => t.originalStatus === 'timedOut').length;
|
|
322
422
|
|
|
323
423
|
// Normalize run status - if there are timeouts, treat run as failed
|
|
@@ -333,9 +433,9 @@ class TestLensReporter {
|
|
|
333
433
|
...this.runMetadata,
|
|
334
434
|
totalTests,
|
|
335
435
|
passedTests,
|
|
336
|
-
failedTests
|
|
436
|
+
failedTests, // Already includes timedOut tests (normalized to 'failed')
|
|
337
437
|
skippedTests,
|
|
338
|
-
timedOutTests,
|
|
438
|
+
timedOutTests, // For informational purposes
|
|
339
439
|
status: normalizedRunStatus
|
|
340
440
|
}
|
|
341
441
|
});
|
|
@@ -345,7 +445,7 @@ class TestLensReporter {
|
|
|
345
445
|
await new Promise(resolve => setTimeout(resolve, 10000));
|
|
346
446
|
|
|
347
447
|
console.log(`📊 TestLens Report completed - Run ID: ${this.runId}`);
|
|
348
|
-
console.log(`🎯 Results: ${passedTests} passed, ${failedTests
|
|
448
|
+
console.log(`🎯 Results: ${passedTests} passed, ${failedTests} failed (${timedOutTests} timeouts), ${skippedTests} skipped`);
|
|
349
449
|
}
|
|
350
450
|
|
|
351
451
|
async sendToApi(payload) {
|
|
@@ -386,17 +486,70 @@ class TestLensReporter {
|
|
|
386
486
|
for (let i = 0; i < attachments.length; i++) {
|
|
387
487
|
const attachment = attachments[i];
|
|
388
488
|
if (attachment.path) {
|
|
489
|
+
// Check if attachment should be processed based on config
|
|
490
|
+
const artifactType = this.getArtifactType(attachment.name);
|
|
491
|
+
const isVideo = artifactType === 'video' || (attachment.contentType && attachment.contentType.startsWith('video/'));
|
|
492
|
+
const isScreenshot = artifactType === 'screenshot' || (attachment.contentType && attachment.contentType.startsWith('image/'));
|
|
493
|
+
|
|
494
|
+
// Skip video if disabled in config
|
|
495
|
+
if (isVideo && !this.config.enableVideo) {
|
|
496
|
+
console.log(`⏭️ Skipping video artifact ${attachment.name} - video capture disabled in config`);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Skip screenshot if disabled in config
|
|
501
|
+
if (isScreenshot && !this.config.enableScreenshot) {
|
|
502
|
+
console.log(`⏭️ Skipping screenshot artifact ${attachment.name} - screenshot capture disabled in config`);
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
|
|
389
506
|
try {
|
|
390
507
|
console.log(`📤 Processing ${attachment.name} asynchronously...`);
|
|
391
508
|
|
|
509
|
+
// Determine proper filename with extension
|
|
510
|
+
// Playwright attachment.name often doesn't have extension, so we need to derive it
|
|
511
|
+
let fileName = attachment.name;
|
|
512
|
+
const existingExt = path.extname(fileName);
|
|
513
|
+
|
|
514
|
+
if (!existingExt) {
|
|
515
|
+
// Get extension from the actual file path
|
|
516
|
+
const pathExt = path.extname(attachment.path);
|
|
517
|
+
if (pathExt) {
|
|
518
|
+
fileName = `${fileName}${pathExt}`;
|
|
519
|
+
} else if (attachment.contentType) {
|
|
520
|
+
// Fallback: derive extension from contentType
|
|
521
|
+
const mimeToExt = {
|
|
522
|
+
'image/png': '.png',
|
|
523
|
+
'image/jpeg': '.jpg',
|
|
524
|
+
'image/gif': '.gif',
|
|
525
|
+
'image/webp': '.webp',
|
|
526
|
+
'video/webm': '.webm',
|
|
527
|
+
'video/mp4': '.mp4',
|
|
528
|
+
'application/zip': '.zip',
|
|
529
|
+
'application/json': '.json',
|
|
530
|
+
'text/plain': '.txt'
|
|
531
|
+
};
|
|
532
|
+
const ext = mimeToExt[attachment.contentType];
|
|
533
|
+
if (ext) {
|
|
534
|
+
fileName = `${fileName}${ext}`;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
392
539
|
// Upload to S3 first
|
|
393
|
-
const s3Data = await this.uploadArtifactToS3(attachment.path, testId,
|
|
540
|
+
const s3Data = await this.uploadArtifactToS3(attachment.path, testId, fileName);
|
|
541
|
+
|
|
542
|
+
// Skip if upload failed or file was too large
|
|
543
|
+
if (!s3Data) {
|
|
544
|
+
console.log(`⏭️ Skipping artifact ${attachment.name} - upload failed or file too large`);
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
394
547
|
|
|
395
548
|
const artifactData = {
|
|
396
549
|
testId,
|
|
397
550
|
type: this.getArtifactType(attachment.name),
|
|
398
551
|
path: attachment.path,
|
|
399
|
-
name:
|
|
552
|
+
name: fileName,
|
|
400
553
|
contentType: attachment.contentType,
|
|
401
554
|
fileSize: this.getFileSize(attachment.path),
|
|
402
555
|
storageType: 's3',
|
|
@@ -412,7 +565,7 @@ class TestLensReporter {
|
|
|
412
565
|
artifact: artifactData
|
|
413
566
|
});
|
|
414
567
|
|
|
415
|
-
console.log(`📎 Processed artifact: ${
|
|
568
|
+
console.log(`📎 Processed artifact: ${fileName} (uploaded to S3)`);
|
|
416
569
|
} catch (error) {
|
|
417
570
|
console.error(`❌ Failed to process ${attachment.name}:`, error.message);
|
|
418
571
|
}
|
package/index.ts
CHANGED
|
@@ -32,6 +32,10 @@ export interface TestLensReporterConfig {
|
|
|
32
32
|
enableGitInfo?: boolean;
|
|
33
33
|
/** Enable artifact processing */
|
|
34
34
|
enableArtifacts?: boolean;
|
|
35
|
+
/** Enable video capture - defaults to true */
|
|
36
|
+
enableVideo?: boolean;
|
|
37
|
+
/** Enable screenshot capture - defaults to true */
|
|
38
|
+
enableScreenshot?: boolean;
|
|
35
39
|
/** Batch size for API requests */
|
|
36
40
|
batchSize?: number;
|
|
37
41
|
/** Flush interval in milliseconds */
|
|
@@ -57,6 +61,10 @@ export interface TestLensReporterOptions {
|
|
|
57
61
|
enableGitInfo?: boolean;
|
|
58
62
|
/** Enable artifact processing */
|
|
59
63
|
enableArtifacts?: boolean;
|
|
64
|
+
/** Enable video capture - defaults to true */
|
|
65
|
+
enableVideo?: boolean;
|
|
66
|
+
/** Enable screenshot capture - defaults to true */
|
|
67
|
+
enableScreenshot?: boolean;
|
|
60
68
|
/** Batch size for API requests */
|
|
61
69
|
batchSize?: number;
|
|
62
70
|
/** Flush interval in milliseconds */
|
|
@@ -115,6 +123,22 @@ export interface RunMetadata {
|
|
|
115
123
|
status?: string;
|
|
116
124
|
}
|
|
117
125
|
|
|
126
|
+
export interface TestError {
|
|
127
|
+
message: string;
|
|
128
|
+
stack?: string;
|
|
129
|
+
location?: {
|
|
130
|
+
file: string;
|
|
131
|
+
line: number;
|
|
132
|
+
column: number;
|
|
133
|
+
};
|
|
134
|
+
snippet?: string;
|
|
135
|
+
expected?: string;
|
|
136
|
+
actual?: string;
|
|
137
|
+
diff?: string;
|
|
138
|
+
matcherName?: string;
|
|
139
|
+
timeout?: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
118
142
|
export interface TestData {
|
|
119
143
|
id: string;
|
|
120
144
|
name: string;
|
|
@@ -124,12 +148,18 @@ export interface TestData {
|
|
|
124
148
|
startTime: string;
|
|
125
149
|
endTime: string;
|
|
126
150
|
errorMessages: string[];
|
|
151
|
+
errors?: TestError[]; // Rich error details
|
|
127
152
|
retryAttempts: number;
|
|
128
153
|
currentRetry: number;
|
|
129
154
|
annotations: Array<{ type: string; description?: string }>;
|
|
130
155
|
projectName: string;
|
|
131
156
|
workerIndex?: number;
|
|
132
157
|
parallelIndex?: number;
|
|
158
|
+
location?: {
|
|
159
|
+
file: string;
|
|
160
|
+
line: number;
|
|
161
|
+
column: number;
|
|
162
|
+
};
|
|
133
163
|
}
|
|
134
164
|
|
|
135
165
|
export interface SpecData {
|
|
@@ -156,6 +186,8 @@ export class TestLensReporter implements Reporter {
|
|
|
156
186
|
enableRealTimeStream: options.enableRealTimeStream !== undefined ? options.enableRealTimeStream : true,
|
|
157
187
|
enableGitInfo: options.enableGitInfo !== undefined ? options.enableGitInfo : true,
|
|
158
188
|
enableArtifacts: options.enableArtifacts !== undefined ? options.enableArtifacts : true,
|
|
189
|
+
enableVideo: options.enableVideo !== undefined ? options.enableVideo : true, // Default to true, override from config
|
|
190
|
+
enableScreenshot: options.enableScreenshot !== undefined ? options.enableScreenshot : true, // Default to true, override from config
|
|
159
191
|
batchSize: options.batchSize || 10,
|
|
160
192
|
flushInterval: options.flushInterval || 5000,
|
|
161
193
|
retryAttempts: options.retryAttempts !== undefined ? options.retryAttempts : 0,
|
|
@@ -340,6 +372,7 @@ export class TestLensReporter implements Reporter {
|
|
|
340
372
|
startTime: new Date().toISOString(),
|
|
341
373
|
endTime: '',
|
|
342
374
|
errorMessages: [],
|
|
375
|
+
errors: [],
|
|
343
376
|
retryAttempts: test.retries,
|
|
344
377
|
currentRetry: result.retry,
|
|
345
378
|
annotations: test.annotations.map((ann: any) => ({
|
|
@@ -348,7 +381,12 @@ export class TestLensReporter implements Reporter {
|
|
|
348
381
|
})),
|
|
349
382
|
projectName: test.parent.project()?.name || 'default',
|
|
350
383
|
workerIndex: result.workerIndex,
|
|
351
|
-
parallelIndex: result.parallelIndex
|
|
384
|
+
parallelIndex: result.parallelIndex,
|
|
385
|
+
location: {
|
|
386
|
+
file: path.relative(process.cwd(), test.location.file),
|
|
387
|
+
line: test.location.line,
|
|
388
|
+
column: test.location.column
|
|
389
|
+
}
|
|
352
390
|
};
|
|
353
391
|
|
|
354
392
|
this.testMap.set(testData.id, testData);
|
|
@@ -381,6 +419,96 @@ export class TestLensReporter implements Reporter {
|
|
|
381
419
|
testData.endTime = new Date().toISOString();
|
|
382
420
|
testData.errorMessages = result.errors.map((error: any) => error.message || error.toString());
|
|
383
421
|
testData.currentRetry = result.retry;
|
|
422
|
+
|
|
423
|
+
// Capture test location
|
|
424
|
+
testData.location = {
|
|
425
|
+
file: path.relative(process.cwd(), test.location.file),
|
|
426
|
+
line: test.location.line,
|
|
427
|
+
column: test.location.column
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// Capture rich error details like Playwright's HTML report
|
|
431
|
+
testData.errors = result.errors.map((error: any) => {
|
|
432
|
+
const testError: TestError = {
|
|
433
|
+
message: error.message || error.toString()
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
// Capture stack trace
|
|
437
|
+
if (error.stack) {
|
|
438
|
+
testError.stack = error.stack;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Capture error location
|
|
442
|
+
if (error.location) {
|
|
443
|
+
testError.location = {
|
|
444
|
+
file: path.relative(process.cwd(), error.location.file),
|
|
445
|
+
line: error.location.line,
|
|
446
|
+
column: error.location.column
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Capture code snippet around error - from Playwright error object
|
|
451
|
+
if (error.snippet) {
|
|
452
|
+
testError.snippet = error.snippet;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Capture expected/actual values for assertion failures
|
|
456
|
+
// Playwright stores these as specially formatted strings in the message
|
|
457
|
+
const message = error.message || '';
|
|
458
|
+
|
|
459
|
+
// Try to parse expected pattern from toHaveURL and similar assertions
|
|
460
|
+
const expectedPatternMatch = message.match(/Expected pattern:\s*(.+?)(?:\n|$)/);
|
|
461
|
+
if (expectedPatternMatch) {
|
|
462
|
+
testError.expected = expectedPatternMatch[1].trim();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Also try "Expected string:" format
|
|
466
|
+
if (!testError.expected) {
|
|
467
|
+
const expectedStringMatch = message.match(/Expected string:\s*["']?(.+?)["']?(?:\n|$)/);
|
|
468
|
+
if (expectedStringMatch) {
|
|
469
|
+
testError.expected = expectedStringMatch[1].trim();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Try to parse received/actual value
|
|
474
|
+
const receivedMatch = message.match(/Received (?:string|value):\s*["']?(.+?)["']?(?:\n|$)/);
|
|
475
|
+
if (receivedMatch) {
|
|
476
|
+
testError.actual = receivedMatch[1].trim();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Parse call log entries for debugging info (timeouts, retries, etc.)
|
|
480
|
+
const callLogMatch = message.match(/Call log:([\s\S]*?)(?=\n\n|\n\s*\d+\s*\||$)/);
|
|
481
|
+
if (callLogMatch) {
|
|
482
|
+
// Store call log separately for display
|
|
483
|
+
const callLog = callLogMatch[1].trim();
|
|
484
|
+
if (callLog) {
|
|
485
|
+
testError.diff = callLog; // Reuse diff field for call log
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Parse timeout information - multiple formats
|
|
490
|
+
const timeoutMatch = message.match(/(?:with timeout|Timeout:?)\s*(\d+)ms/i);
|
|
491
|
+
if (timeoutMatch) {
|
|
492
|
+
testError.timeout = parseInt(timeoutMatch[1], 10);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Parse matcher name (e.g., toHaveURL, toBeVisible)
|
|
496
|
+
const matcherMatch = message.match(/expect\([^)]+\)\.(\w+)/);
|
|
497
|
+
if (matcherMatch) {
|
|
498
|
+
testError.matcherName = matcherMatch[1];
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Extract code snippet from message if not already captured
|
|
502
|
+
// Look for lines like " 9 | await page.click..." or "> 11 | await expect..."
|
|
503
|
+
if (!testError.snippet) {
|
|
504
|
+
const codeSnippetMatch = message.match(/((?:\s*>?\s*\d+\s*\|.*\n?)+)/);
|
|
505
|
+
if (codeSnippetMatch) {
|
|
506
|
+
testError.snippet = codeSnippetMatch[1].trim();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return testError;
|
|
511
|
+
});
|
|
384
512
|
|
|
385
513
|
// Only send testEnd event after final retry attempt
|
|
386
514
|
// If test passed or this is the last retry, send the event
|
|
@@ -445,8 +573,10 @@ export class TestLensReporter implements Reporter {
|
|
|
445
573
|
// Calculate final stats
|
|
446
574
|
const totalTests = Array.from(this.testMap.values()).length;
|
|
447
575
|
const passedTests = Array.from(this.testMap.values()).filter(t => t.status === 'passed').length;
|
|
576
|
+
// failedTests already includes timedOut tests since normalizeTestStatus converts 'timedOut' to 'failed'
|
|
448
577
|
const failedTests = Array.from(this.testMap.values()).filter(t => t.status === 'failed').length;
|
|
449
578
|
const skippedTests = Array.from(this.testMap.values()).filter(t => t.status === 'skipped').length;
|
|
579
|
+
// Track timedOut separately for reporting purposes only (not for count)
|
|
450
580
|
const timedOutTests = Array.from(this.testMap.values()).filter(t => t.originalStatus === 'timedOut').length;
|
|
451
581
|
|
|
452
582
|
// Normalize run status - if there are timeouts, treat run as failed
|
|
@@ -462,15 +592,15 @@ export class TestLensReporter implements Reporter {
|
|
|
462
592
|
...this.runMetadata,
|
|
463
593
|
totalTests,
|
|
464
594
|
passedTests,
|
|
465
|
-
failedTests
|
|
595
|
+
failedTests, // Already includes timedOut tests (normalized to 'failed')
|
|
466
596
|
skippedTests,
|
|
467
|
-
timedOutTests,
|
|
597
|
+
timedOutTests, // For informational purposes
|
|
468
598
|
status: normalizedRunStatus
|
|
469
599
|
}
|
|
470
600
|
});
|
|
471
601
|
|
|
472
602
|
console.log(`📊 TestLens Report completed - Run ID: ${this.runId}`);
|
|
473
|
-
console.log(`🎯 Results: ${passedTests} passed, ${failedTests
|
|
603
|
+
console.log(`🎯 Results: ${passedTests} passed, ${failedTests} failed (${timedOutTests} timeouts), ${skippedTests} skipped`);
|
|
474
604
|
}
|
|
475
605
|
|
|
476
606
|
private async sendToApi(payload: any): Promise<void> {
|
|
@@ -500,9 +630,56 @@ export class TestLensReporter implements Reporter {
|
|
|
500
630
|
|
|
501
631
|
for (const attachment of attachments) {
|
|
502
632
|
if (attachment.path) {
|
|
633
|
+
// Check if attachment should be processed based on config
|
|
634
|
+
const artifactType = this.getArtifactType(attachment.name);
|
|
635
|
+
const isVideo = artifactType === 'video' || attachment.contentType?.startsWith('video/');
|
|
636
|
+
const isScreenshot = artifactType === 'screenshot' || attachment.contentType?.startsWith('image/');
|
|
637
|
+
|
|
638
|
+
// Skip video if disabled in config
|
|
639
|
+
if (isVideo && !this.config.enableVideo) {
|
|
640
|
+
console.log(`⏭️ Skipping video artifact ${attachment.name} - video capture disabled in config`);
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Skip screenshot if disabled in config
|
|
645
|
+
if (isScreenshot && !this.config.enableScreenshot) {
|
|
646
|
+
console.log(`⏭️ Skipping screenshot artifact ${attachment.name} - screenshot capture disabled in config`);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
|
|
503
650
|
try {
|
|
651
|
+
// Determine proper filename with extension
|
|
652
|
+
// Playwright attachment.name often doesn't have extension, so we need to derive it
|
|
653
|
+
let fileName = attachment.name;
|
|
654
|
+
const existingExt = path.extname(fileName);
|
|
655
|
+
|
|
656
|
+
if (!existingExt) {
|
|
657
|
+
// Get extension from the actual file path
|
|
658
|
+
const pathExt = path.extname(attachment.path);
|
|
659
|
+
if (pathExt) {
|
|
660
|
+
fileName = `${fileName}${pathExt}`;
|
|
661
|
+
} else if (attachment.contentType) {
|
|
662
|
+
// Fallback: derive extension from contentType
|
|
663
|
+
const mimeToExt: Record<string, string> = {
|
|
664
|
+
'image/png': '.png',
|
|
665
|
+
'image/jpeg': '.jpg',
|
|
666
|
+
'image/gif': '.gif',
|
|
667
|
+
'image/webp': '.webp',
|
|
668
|
+
'video/webm': '.webm',
|
|
669
|
+
'video/mp4': '.mp4',
|
|
670
|
+
'application/zip': '.zip',
|
|
671
|
+
'application/json': '.json',
|
|
672
|
+
'text/plain': '.txt'
|
|
673
|
+
};
|
|
674
|
+
const ext = mimeToExt[attachment.contentType];
|
|
675
|
+
if (ext) {
|
|
676
|
+
fileName = `${fileName}${ext}`;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
504
681
|
// Upload to S3 first
|
|
505
|
-
const s3Data = await this.uploadArtifactToS3(attachment.path, testId,
|
|
682
|
+
const s3Data = await this.uploadArtifactToS3(attachment.path, testId, fileName);
|
|
506
683
|
|
|
507
684
|
// Skip if upload failed or file was too large
|
|
508
685
|
if (!s3Data) {
|
|
@@ -514,7 +691,7 @@ export class TestLensReporter implements Reporter {
|
|
|
514
691
|
testId,
|
|
515
692
|
type: this.getArtifactType(attachment.name),
|
|
516
693
|
path: attachment.path,
|
|
517
|
-
name:
|
|
694
|
+
name: fileName,
|
|
518
695
|
contentType: attachment.contentType,
|
|
519
696
|
fileSize: this.getFileSize(attachment.path),
|
|
520
697
|
storageType: 's3',
|
|
@@ -530,7 +707,7 @@ export class TestLensReporter implements Reporter {
|
|
|
530
707
|
artifact: artifactData
|
|
531
708
|
});
|
|
532
709
|
|
|
533
|
-
console.log(`📎 Processed artifact: ${
|
|
710
|
+
console.log(`📎 Processed artifact: ${fileName} (uploaded to S3)`);
|
|
534
711
|
} catch (error) {
|
|
535
712
|
console.error(`❌ Failed to process artifact ${attachment.name}:`, (error as Error).message);
|
|
536
713
|
}
|
package/lib/index.js
CHANGED
|
@@ -63,6 +63,8 @@ class TestLensReporter {
|
|
|
63
63
|
enableRealTimeStream: options.enableRealTimeStream !== undefined ? options.enableRealTimeStream : true,
|
|
64
64
|
enableGitInfo: options.enableGitInfo !== undefined ? options.enableGitInfo : true,
|
|
65
65
|
enableArtifacts: options.enableArtifacts !== undefined ? options.enableArtifacts : true,
|
|
66
|
+
enableVideo: options.enableVideo !== undefined ? options.enableVideo : true, // Default to true, override from config
|
|
67
|
+
enableScreenshot: options.enableScreenshot !== undefined ? options.enableScreenshot : true, // Default to true, override from config
|
|
66
68
|
batchSize: options.batchSize || 10,
|
|
67
69
|
flushInterval: options.flushInterval || 5000,
|
|
68
70
|
retryAttempts: options.retryAttempts !== undefined ? options.retryAttempts : 0,
|
|
@@ -227,6 +229,7 @@ class TestLensReporter {
|
|
|
227
229
|
startTime: new Date().toISOString(),
|
|
228
230
|
endTime: '',
|
|
229
231
|
errorMessages: [],
|
|
232
|
+
errors: [],
|
|
230
233
|
retryAttempts: test.retries,
|
|
231
234
|
currentRetry: result.retry,
|
|
232
235
|
annotations: test.annotations.map((ann) => ({
|
|
@@ -235,7 +238,12 @@ class TestLensReporter {
|
|
|
235
238
|
})),
|
|
236
239
|
projectName: test.parent.project()?.name || 'default',
|
|
237
240
|
workerIndex: result.workerIndex,
|
|
238
|
-
parallelIndex: result.parallelIndex
|
|
241
|
+
parallelIndex: result.parallelIndex,
|
|
242
|
+
location: {
|
|
243
|
+
file: path.relative(process.cwd(), test.location.file),
|
|
244
|
+
line: test.location.line,
|
|
245
|
+
column: test.location.column
|
|
246
|
+
}
|
|
239
247
|
};
|
|
240
248
|
this.testMap.set(testData.id, testData);
|
|
241
249
|
// Send test start event to API
|
|
@@ -265,6 +273,82 @@ class TestLensReporter {
|
|
|
265
273
|
testData.endTime = new Date().toISOString();
|
|
266
274
|
testData.errorMessages = result.errors.map((error) => error.message || error.toString());
|
|
267
275
|
testData.currentRetry = result.retry;
|
|
276
|
+
// Capture test location
|
|
277
|
+
testData.location = {
|
|
278
|
+
file: path.relative(process.cwd(), test.location.file),
|
|
279
|
+
line: test.location.line,
|
|
280
|
+
column: test.location.column
|
|
281
|
+
};
|
|
282
|
+
// Capture rich error details like Playwright's HTML report
|
|
283
|
+
testData.errors = result.errors.map((error) => {
|
|
284
|
+
const testError = {
|
|
285
|
+
message: error.message || error.toString()
|
|
286
|
+
};
|
|
287
|
+
// Capture stack trace
|
|
288
|
+
if (error.stack) {
|
|
289
|
+
testError.stack = error.stack;
|
|
290
|
+
}
|
|
291
|
+
// Capture error location
|
|
292
|
+
if (error.location) {
|
|
293
|
+
testError.location = {
|
|
294
|
+
file: path.relative(process.cwd(), error.location.file),
|
|
295
|
+
line: error.location.line,
|
|
296
|
+
column: error.location.column
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
// Capture code snippet around error - from Playwright error object
|
|
300
|
+
if (error.snippet) {
|
|
301
|
+
testError.snippet = error.snippet;
|
|
302
|
+
}
|
|
303
|
+
// Capture expected/actual values for assertion failures
|
|
304
|
+
// Playwright stores these as specially formatted strings in the message
|
|
305
|
+
const message = error.message || '';
|
|
306
|
+
// Try to parse expected pattern from toHaveURL and similar assertions
|
|
307
|
+
const expectedPatternMatch = message.match(/Expected pattern:\s*(.+?)(?:\n|$)/);
|
|
308
|
+
if (expectedPatternMatch) {
|
|
309
|
+
testError.expected = expectedPatternMatch[1].trim();
|
|
310
|
+
}
|
|
311
|
+
// Also try "Expected string:" format
|
|
312
|
+
if (!testError.expected) {
|
|
313
|
+
const expectedStringMatch = message.match(/Expected string:\s*["']?(.+?)["']?(?:\n|$)/);
|
|
314
|
+
if (expectedStringMatch) {
|
|
315
|
+
testError.expected = expectedStringMatch[1].trim();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// Try to parse received/actual value
|
|
319
|
+
const receivedMatch = message.match(/Received (?:string|value):\s*["']?(.+?)["']?(?:\n|$)/);
|
|
320
|
+
if (receivedMatch) {
|
|
321
|
+
testError.actual = receivedMatch[1].trim();
|
|
322
|
+
}
|
|
323
|
+
// Parse call log entries for debugging info (timeouts, retries, etc.)
|
|
324
|
+
const callLogMatch = message.match(/Call log:([\s\S]*?)(?=\n\n|\n\s*\d+\s*\||$)/);
|
|
325
|
+
if (callLogMatch) {
|
|
326
|
+
// Store call log separately for display
|
|
327
|
+
const callLog = callLogMatch[1].trim();
|
|
328
|
+
if (callLog) {
|
|
329
|
+
testError.diff = callLog; // Reuse diff field for call log
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// Parse timeout information - multiple formats
|
|
333
|
+
const timeoutMatch = message.match(/(?:with timeout|Timeout:?)\s*(\d+)ms/i);
|
|
334
|
+
if (timeoutMatch) {
|
|
335
|
+
testError.timeout = parseInt(timeoutMatch[1], 10);
|
|
336
|
+
}
|
|
337
|
+
// Parse matcher name (e.g., toHaveURL, toBeVisible)
|
|
338
|
+
const matcherMatch = message.match(/expect\([^)]+\)\.(\w+)/);
|
|
339
|
+
if (matcherMatch) {
|
|
340
|
+
testError.matcherName = matcherMatch[1];
|
|
341
|
+
}
|
|
342
|
+
// Extract code snippet from message if not already captured
|
|
343
|
+
// Look for lines like " 9 | await page.click..." or "> 11 | await expect..."
|
|
344
|
+
if (!testError.snippet) {
|
|
345
|
+
const codeSnippetMatch = message.match(/((?:\s*>?\s*\d+\s*\|.*\n?)+)/);
|
|
346
|
+
if (codeSnippetMatch) {
|
|
347
|
+
testError.snippet = codeSnippetMatch[1].trim();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return testError;
|
|
351
|
+
});
|
|
268
352
|
// Only send testEnd event after final retry attempt
|
|
269
353
|
// If test passed or this is the last retry, send the event
|
|
270
354
|
const isFinalAttempt = result.status === 'passed' || result.status === 'skipped' || result.retry >= test.retries;
|
|
@@ -320,8 +404,10 @@ class TestLensReporter {
|
|
|
320
404
|
// Calculate final stats
|
|
321
405
|
const totalTests = Array.from(this.testMap.values()).length;
|
|
322
406
|
const passedTests = Array.from(this.testMap.values()).filter(t => t.status === 'passed').length;
|
|
407
|
+
// failedTests already includes timedOut tests since normalizeTestStatus converts 'timedOut' to 'failed'
|
|
323
408
|
const failedTests = Array.from(this.testMap.values()).filter(t => t.status === 'failed').length;
|
|
324
409
|
const skippedTests = Array.from(this.testMap.values()).filter(t => t.status === 'skipped').length;
|
|
410
|
+
// Track timedOut separately for reporting purposes only (not for count)
|
|
325
411
|
const timedOutTests = Array.from(this.testMap.values()).filter(t => t.originalStatus === 'timedOut').length;
|
|
326
412
|
// Normalize run status - if there are timeouts, treat run as failed
|
|
327
413
|
const hasTimeouts = timedOutTests > 0;
|
|
@@ -335,14 +421,14 @@ class TestLensReporter {
|
|
|
335
421
|
...this.runMetadata,
|
|
336
422
|
totalTests,
|
|
337
423
|
passedTests,
|
|
338
|
-
failedTests
|
|
424
|
+
failedTests, // Already includes timedOut tests (normalized to 'failed')
|
|
339
425
|
skippedTests,
|
|
340
|
-
timedOutTests,
|
|
426
|
+
timedOutTests, // For informational purposes
|
|
341
427
|
status: normalizedRunStatus
|
|
342
428
|
}
|
|
343
429
|
});
|
|
344
430
|
console.log(`📊 TestLens Report completed - Run ID: ${this.runId}`);
|
|
345
|
-
console.log(`🎯 Results: ${passedTests} passed, ${failedTests
|
|
431
|
+
console.log(`🎯 Results: ${passedTests} passed, ${failedTests} failed (${timedOutTests} timeouts), ${skippedTests} skipped`);
|
|
346
432
|
}
|
|
347
433
|
async sendToApi(payload) {
|
|
348
434
|
try {
|
|
@@ -369,9 +455,52 @@ class TestLensReporter {
|
|
|
369
455
|
const attachments = result.attachments;
|
|
370
456
|
for (const attachment of attachments) {
|
|
371
457
|
if (attachment.path) {
|
|
458
|
+
// Check if attachment should be processed based on config
|
|
459
|
+
const artifactType = this.getArtifactType(attachment.name);
|
|
460
|
+
const isVideo = artifactType === 'video' || attachment.contentType?.startsWith('video/');
|
|
461
|
+
const isScreenshot = artifactType === 'screenshot' || attachment.contentType?.startsWith('image/');
|
|
462
|
+
// Skip video if disabled in config
|
|
463
|
+
if (isVideo && !this.config.enableVideo) {
|
|
464
|
+
console.log(`⏭️ Skipping video artifact ${attachment.name} - video capture disabled in config`);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
// Skip screenshot if disabled in config
|
|
468
|
+
if (isScreenshot && !this.config.enableScreenshot) {
|
|
469
|
+
console.log(`⏭️ Skipping screenshot artifact ${attachment.name} - screenshot capture disabled in config`);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
372
472
|
try {
|
|
473
|
+
// Determine proper filename with extension
|
|
474
|
+
// Playwright attachment.name often doesn't have extension, so we need to derive it
|
|
475
|
+
let fileName = attachment.name;
|
|
476
|
+
const existingExt = path.extname(fileName);
|
|
477
|
+
if (!existingExt) {
|
|
478
|
+
// Get extension from the actual file path
|
|
479
|
+
const pathExt = path.extname(attachment.path);
|
|
480
|
+
if (pathExt) {
|
|
481
|
+
fileName = `${fileName}${pathExt}`;
|
|
482
|
+
}
|
|
483
|
+
else if (attachment.contentType) {
|
|
484
|
+
// Fallback: derive extension from contentType
|
|
485
|
+
const mimeToExt = {
|
|
486
|
+
'image/png': '.png',
|
|
487
|
+
'image/jpeg': '.jpg',
|
|
488
|
+
'image/gif': '.gif',
|
|
489
|
+
'image/webp': '.webp',
|
|
490
|
+
'video/webm': '.webm',
|
|
491
|
+
'video/mp4': '.mp4',
|
|
492
|
+
'application/zip': '.zip',
|
|
493
|
+
'application/json': '.json',
|
|
494
|
+
'text/plain': '.txt'
|
|
495
|
+
};
|
|
496
|
+
const ext = mimeToExt[attachment.contentType];
|
|
497
|
+
if (ext) {
|
|
498
|
+
fileName = `${fileName}${ext}`;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
373
502
|
// Upload to S3 first
|
|
374
|
-
const s3Data = await this.uploadArtifactToS3(attachment.path, testId,
|
|
503
|
+
const s3Data = await this.uploadArtifactToS3(attachment.path, testId, fileName);
|
|
375
504
|
// Skip if upload failed or file was too large
|
|
376
505
|
if (!s3Data) {
|
|
377
506
|
console.log(`⏭️ Skipping artifact ${attachment.name} - upload failed or file too large`);
|
|
@@ -381,7 +510,7 @@ class TestLensReporter {
|
|
|
381
510
|
testId,
|
|
382
511
|
type: this.getArtifactType(attachment.name),
|
|
383
512
|
path: attachment.path,
|
|
384
|
-
name:
|
|
513
|
+
name: fileName,
|
|
385
514
|
contentType: attachment.contentType,
|
|
386
515
|
fileSize: this.getFileSize(attachment.path),
|
|
387
516
|
storageType: 's3',
|
|
@@ -395,7 +524,7 @@ class TestLensReporter {
|
|
|
395
524
|
timestamp: new Date().toISOString(),
|
|
396
525
|
artifact: artifactData
|
|
397
526
|
});
|
|
398
|
-
console.log(`📎 Processed artifact: ${
|
|
527
|
+
console.log(`📎 Processed artifact: ${fileName} (uploaded to S3)`);
|
|
399
528
|
}
|
|
400
529
|
catch (error) {
|
|
401
530
|
console.error(`❌ Failed to process artifact ${attachment.name}:`, error.message);
|
package/package.json
CHANGED