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 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: failedTests + timedOutTests, // Include timeouts in failed count
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 + timedOutTests} failed (${timedOutTests} timeouts), ${skippedTests} skipped`);
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, attachment.name);
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: attachment.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: ${attachment.name} (uploaded to S3)`);
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: failedTests + timedOutTests, // Include timeouts in failed count
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 + timedOutTests} failed (${timedOutTests} timeouts), ${skippedTests} skipped`);
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, attachment.name);
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: attachment.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: ${attachment.name} (uploaded to S3)`);
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: failedTests + timedOutTests, // Include timeouts in failed count
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 + timedOutTests} failed (${timedOutTests} timeouts), ${skippedTests} skipped`);
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, attachment.name);
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: attachment.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: ${attachment.name} (uploaded to S3)`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testlens-playwright-reporter",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Universal Playwright reporter for TestLens - works with both TypeScript and JavaScript projects",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",