testlens-playwright-reporter 0.3.2 → 0.3.4

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.js CHANGED
@@ -1,919 +1,1097 @@
1
- const { randomUUID } = require('crypto');
2
- const os = require('os');
3
- const path = require('path');
4
- const fs = require('fs');
5
- const https = require('https');
6
- const axios = require('axios');
7
- const FormData = require('form-data');
8
-
9
- // Lazy-load mime module to support ESM
10
- let mimeModule = null;
11
- async function getMime() {
12
- if (!mimeModule) {
13
- const imported = await import('mime');
14
- // Handle both default export and named exports
15
- mimeModule = imported.default || imported;
16
- }
17
- return mimeModule;
18
- }
19
-
20
- class TestLensReporter {
21
- constructor(options = {}) {
22
- this.config = {
23
- apiEndpoint: options.apiEndpoint || 'https://testlens.qa-path.com/api/v1/webhook/playwright',
24
- apiKey: options.apiKey, // API key must come from config file
25
- enableRealTimeStream: options.enableRealTimeStream !== undefined ? options.enableRealTimeStream : true,
26
- enableGitInfo: options.enableGitInfo !== undefined ? options.enableGitInfo : true,
27
- enableArtifacts: options.enableArtifacts !== undefined ? options.enableArtifacts : true,
28
- enableVideo: options.enableVideo !== undefined ? options.enableVideo : true, // Default to true, override from config
29
- enableScreenshot: options.enableScreenshot !== undefined ? options.enableScreenshot : true, // Default to true, override from config
30
- batchSize: options.batchSize || 10,
31
- flushInterval: options.flushInterval || 5000,
32
- retryAttempts: options.retryAttempts !== undefined ? options.retryAttempts : 0,
33
- timeout: options.timeout || 60000
34
- };
35
-
36
- if (!this.config.apiKey) {
37
- throw new Error('API_KEY is required for TestLensReporter. Pass it as apiKey option in your playwright config.');
38
- }
39
-
40
- // Determine SSL validation behavior
41
- let rejectUnauthorized = true; // Default to secure
42
-
43
- // Check various ways SSL validation can be disabled (in order of precedence)
44
- if (this.config.ignoreSslErrors) {
45
- // Explicit configuration option
46
- rejectUnauthorized = false;
47
- } else if (this.config.rejectUnauthorized === false) {
48
- // Explicit configuration option
49
- rejectUnauthorized = false;
50
- } else if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') {
51
- // Environment variable override
52
- rejectUnauthorized = false;
53
- }
54
-
55
- // Set up axios instance with retry logic and enhanced SSL handling
56
- this.axiosInstance = axios.create({
57
- baseURL: this.config.apiEndpoint,
58
- timeout: this.config.timeout,
59
- headers: {
60
- 'Content-Type': 'application/json',
61
- ...(this.config.apiKey && { 'X-API-Key': this.config.apiKey }),
62
- },
63
- // Enhanced SSL handling with flexible TLS configuration
64
- httpsAgent: new https.Agent({
65
- rejectUnauthorized: rejectUnauthorized,
66
- // Allow any TLS version for better compatibility
67
- minVersion: 'TLSv1.2',
68
- maxVersion: 'TLSv1.3'
69
- })
70
- });
71
-
72
- // Add retry interceptor
73
- this.axiosInstance.interceptors.response.use(
74
- (response) => response,
75
- async (error) => {
76
- const originalRequest = error.config;
77
-
78
- if (!originalRequest._retry && error.response?.status >= 500) {
79
- originalRequest._retry = true;
80
- originalRequest._retryCount = (originalRequest._retryCount || 0) + 1;
81
-
82
- if (originalRequest._retryCount <= this.config.retryAttempts) {
83
- // Exponential backoff
84
- const delay = Math.pow(2, originalRequest._retryCount) * 1000;
85
- await new Promise(resolve => setTimeout(resolve, delay));
86
- return this.axiosInstance(originalRequest);
87
- }
88
- }
89
-
90
- return Promise.reject(error);
91
- }
92
- );
93
-
94
- this.runId = randomUUID();
95
- this.runMetadata = this.initializeRunMetadata();
96
- this.specMap = new Map();
97
- this.testMap = new Map();
98
- }
99
-
100
- initializeRunMetadata() {
101
- return {
102
- id: this.runId,
103
- startTime: new Date().toISOString(),
104
- environment: process.env.NODE_ENV || 'development',
105
- browser: 'multiple',
106
- os: `${os.type()} ${os.release()}`,
107
- playwrightVersion: this.getPlaywrightVersion(),
108
- nodeVersion: process.version
109
- };
110
- }
111
-
112
- getPlaywrightVersion() {
113
- try {
114
- const playwrightPackage = require('@playwright/test/package.json');
115
- return playwrightPackage.version;
116
- } catch (error) {
117
- return 'unknown';
118
- }
119
- }
120
-
121
- normalizeTestStatus(status) {
122
- // Treat timeout as failed for consistency with analytics
123
- if (status === 'timedOut') {
124
- return 'failed';
125
- }
126
- return status;
127
- }
128
-
129
- normalizeRunStatus(status, hasTimeouts) {
130
- // If run has timeouts, treat as failed
131
- if (hasTimeouts && status === 'passed') {
132
- return 'failed';
133
- }
134
- // Treat timeout status as failed
135
- if (status === 'timedOut') {
136
- return 'failed';
137
- }
138
- return status;
139
- }
140
-
141
- async onBegin(config, suite) {
142
- // Collect Git information if enabled
143
- if (this.config.enableGitInfo) {
144
- this.runMetadata.gitInfo = await this.collectGitInfo();
145
- }
146
-
147
- // Add shard information if available
148
- if (config.shard) {
149
- this.runMetadata.shardInfo = {
150
- current: config.shard.current,
151
- total: config.shard.total
152
- };
153
- }
154
-
155
- // Send run start event to API
156
- await this.sendToApi({
157
- type: 'runStart',
158
- runId: this.runId,
159
- timestamp: new Date().toISOString(),
160
- metadata: this.runMetadata
161
- });
162
- }
163
-
164
- async onTestBegin(test, result) {
165
- const specPath = test.location.file;
166
- const specKey = `${specPath}-${test.parent.title}`;
167
-
168
- // Create or update spec data
169
- if (!this.specMap.has(specKey)) {
170
- const specData = {
171
- filePath: path.relative(process.cwd(), specPath),
172
- testSuiteName: test.parent.title,
173
- tags: this.extractTags(test),
174
- startTime: new Date().toISOString(),
175
- status: 'running'
176
- };
177
- this.specMap.set(specKey, specData);
178
-
179
- // Send spec start event to API
180
- await this.sendToApi({
181
- type: 'specStart',
182
- runId: this.runId,
183
- timestamp: new Date().toISOString(),
184
- spec: specData
185
- });
186
- }
187
-
188
- const testId = this.getTestId(test);
189
-
190
- // Only send testStart event on first attempt (retry 0)
191
- if (result.retry === 0) {
192
- // Create test data
193
- const testData = {
194
- id: testId,
195
- name: test.title,
196
- status: 'running',
197
- originalStatus: 'running',
198
- duration: 0,
199
- startTime: new Date().toISOString(),
200
- endTime: '',
201
- errorMessages: [],
202
- errors: [],
203
- retryAttempts: test.retries,
204
- currentRetry: result.retry,
205
- annotations: test.annotations.map(ann => ({
206
- type: ann.type,
207
- description: ann.description
208
- })),
209
- projectName: test.parent.project()?.name || 'default',
210
- workerIndex: result.workerIndex,
211
- parallelIndex: result.parallelIndex,
212
- location: {
213
- file: path.relative(process.cwd(), test.location.file),
214
- line: test.location.line,
215
- column: test.location.column
216
- }
217
- };
218
-
219
- this.testMap.set(testData.id, testData);
220
-
221
- // Send test start event to API
222
- await this.sendToApi({
223
- type: 'testStart',
224
- runId: this.runId,
225
- timestamp: new Date().toISOString(),
226
- test: testData
227
- });
228
- } else {
229
- // For retries, just update the existing test data
230
- const existingTestData = this.testMap.get(testId);
231
- if (existingTestData) {
232
- existingTestData.currentRetry = result.retry;
233
- }
234
- }
235
- }
236
-
237
- async onTestEnd(test, result) {
238
- const testId = this.getTestId(test);
239
- let testData = this.testMap.get(testId);
240
-
241
- // For skipped tests, onTestBegin might not be called, so we need to create the test data here
242
- if (!testData) {
243
- // Create spec data if not exists (skipped tests might not have spec data either)
244
- const specPath = test.location.file;
245
- const specKey = `${specPath}-${test.parent.title}`;
246
-
247
- if (!this.specMap.has(specKey)) {
248
- const specData = {
249
- filePath: path.relative(process.cwd(), specPath),
250
- testSuiteName: test.parent.title,
251
- tags: this.extractTags(test),
252
- startTime: new Date().toISOString(),
253
- status: 'skipped'
254
- };
255
- this.specMap.set(specKey, specData);
256
-
257
- // Send spec start event to API
258
- await this.sendToApi({
259
- type: 'specStart',
260
- runId: this.runId,
261
- timestamp: new Date().toISOString(),
262
- spec: specData
263
- });
264
- }
265
-
266
- // Create test data for skipped test
267
- testData = {
268
- id: testId,
269
- name: test.title,
270
- status: 'skipped',
271
- originalStatus: 'skipped',
272
- duration: 0,
273
- startTime: new Date().toISOString(),
274
- endTime: new Date().toISOString(),
275
- errorMessages: [],
276
- errors: [],
277
- retryAttempts: test.retries,
278
- currentRetry: 0,
279
- annotations: test.annotations.map(ann => ({
280
- type: ann.type,
281
- description: ann.description
282
- })),
283
- projectName: test.parent.project()?.name || 'default',
284
- workerIndex: result.workerIndex,
285
- parallelIndex: result.parallelIndex,
286
- location: {
287
- file: path.relative(process.cwd(), test.location.file),
288
- line: test.location.line,
289
- column: test.location.column
290
- }
291
- };
292
-
293
- this.testMap.set(testId, testData);
294
-
295
- // Send test start event first (so the test gets created in DB)
296
- await this.sendToApi({
297
- type: 'testStart',
298
- runId: this.runId,
299
- timestamp: new Date().toISOString(),
300
- test: testData
301
- });
302
- }
303
-
304
- if (testData) {
305
- // Update test data with latest result
306
- testData.originalStatus = result.status;
307
- testData.status = this.normalizeTestStatus(result.status);
308
- testData.duration = result.duration;
309
- testData.endTime = new Date().toISOString();
310
- testData.errorMessages = result.errors.map(error => error.message || error.toString());
311
- testData.currentRetry = result.retry;
312
-
313
- // Capture test location
314
- testData.location = {
315
- file: path.relative(process.cwd(), test.location.file),
316
- line: test.location.line,
317
- column: test.location.column
318
- };
319
-
320
- // Capture rich error details like Playwright's HTML report
321
- testData.errors = result.errors.map(error => {
322
- const testError = {
323
- message: error.message || error.toString()
324
- };
325
-
326
- // Capture stack trace
327
- if (error.stack) {
328
- testError.stack = error.stack;
329
- }
330
-
331
- // Capture error location
332
- if (error.location) {
333
- testError.location = {
334
- file: path.relative(process.cwd(), error.location.file),
335
- line: error.location.line,
336
- column: error.location.column
337
- };
338
- }
339
-
340
- // Capture code snippet around error - from Playwright error object
341
- if (error.snippet) {
342
- testError.snippet = error.snippet;
343
- }
344
-
345
- // Capture expected/actual values for assertion failures
346
- // Playwright stores these as specially formatted strings in the message
347
- const message = error.message || '';
348
-
349
- // Try to parse expected pattern from toHaveURL and similar assertions
350
- const expectedPatternMatch = message.match(/Expected pattern:\s*(.+?)(?:\n|$)/);
351
- if (expectedPatternMatch) {
352
- testError.expected = expectedPatternMatch[1].trim();
353
- }
354
-
355
- // Also try "Expected string:" format
356
- if (!testError.expected) {
357
- const expectedStringMatch = message.match(/Expected string:\s*["']?(.+?)["']?(?:\n|$)/);
358
- if (expectedStringMatch) {
359
- testError.expected = expectedStringMatch[1].trim();
360
- }
361
- }
362
-
363
- // Try to parse received/actual value
364
- const receivedMatch = message.match(/Received (?:string|value):\s*["']?(.+?)["']?(?:\n|$)/);
365
- if (receivedMatch) {
366
- testError.actual = receivedMatch[1].trim();
367
- }
368
-
369
- // Parse call log entries for debugging info (timeouts, retries, etc.)
370
- const callLogMatch = message.match(/Call log:([\s\S]*?)(?=\n\n|\n\s*\d+\s*\||$)/);
371
- if (callLogMatch) {
372
- // Store call log separately for display
373
- const callLog = callLogMatch[1].trim();
374
- if (callLog) {
375
- testError.diff = callLog; // Reuse diff field for call log
376
- }
377
- }
378
-
379
- // Parse timeout information - multiple formats
380
- const timeoutMatch = message.match(/(?:with timeout|Timeout:?)\s*(\d+)ms/i);
381
- if (timeoutMatch) {
382
- testError.timeout = parseInt(timeoutMatch[1], 10);
383
- }
384
-
385
- // Parse matcher name (e.g., toHaveURL, toBeVisible)
386
- const matcherMatch = message.match(/expect\([^)]+\)\.(\w+)/);
387
- if (matcherMatch) {
388
- testError.matcherName = matcherMatch[1];
389
- }
390
-
391
- // Extract code snippet from message if not already captured
392
- // Look for lines like " 9 | await page.click..." or "> 11 | await expect..."
393
- if (!testError.snippet) {
394
- const codeSnippetMatch = message.match(/((?:\s*>?\s*\d+\s*\|.*\n?)+)/);
395
- if (codeSnippetMatch) {
396
- testError.snippet = codeSnippetMatch[1].trim();
397
- }
398
- }
399
-
400
- return testError;
401
- });
402
-
403
- // Only send testEnd event after final retry attempt
404
- // If test passed or this is the last retry, send the event
405
- const isFinalAttempt = result.status === 'passed' || result.status === 'skipped' || result.retry >= test.retries;
406
-
407
- if (isFinalAttempt) {
408
- // Send test end event to API
409
- await this.sendToApi({
410
- type: 'testEnd',
411
- runId: this.runId,
412
- timestamp: new Date().toISOString(),
413
- test: testData
414
- });
415
-
416
- // Handle artifacts
417
- if (this.config.enableArtifacts) {
418
- await this.processArtifacts(testId, result);
419
- }
420
- }
421
- }
422
-
423
- // Update spec status
424
- const specPath = test.location.file;
425
- const specKey = `${specPath}-${test.parent.title}`;
426
- const specData = this.specMap.get(specKey);
427
- if (specData) {
428
- const normalizedStatus = this.normalizeTestStatus(result.status);
429
- if (normalizedStatus === 'failed' && specData.status !== 'failed') {
430
- specData.status = 'failed';
431
- } else if (result.status === 'skipped' && specData.status === 'passed') {
432
- specData.status = 'skipped';
433
- }
434
-
435
- // Check if all tests in spec are complete
436
- const remainingTests = test.parent.tests.filter(t => {
437
- const tId = this.getTestId(t);
438
- const tData = this.testMap.get(tId);
439
- return !tData || !tData.endTime;
440
- });
441
-
442
- if (remainingTests.length === 0) {
443
- specData.endTime = new Date().toISOString();
444
-
445
- // Send spec end event to API
446
- await this.sendToApi({
447
- type: 'specEnd',
448
- runId: this.runId,
449
- timestamp: new Date().toISOString(),
450
- spec: specData
451
- });
452
-
453
- // Send spec code blocks to API
454
- await this.sendSpecCodeBlocks(specPath);
455
- }
456
- }
457
- }
458
-
459
- async onEnd(result) {
460
- this.runMetadata.endTime = new Date().toISOString();
461
- this.runMetadata.duration = Date.now() - new Date(this.runMetadata.startTime).getTime();
462
-
463
- // Calculate final stats
464
- const totalTests = Array.from(this.testMap.values()).length;
465
- const passedTests = Array.from(this.testMap.values()).filter(t => t.status === 'passed').length;
466
- // failedTests already includes timedOut tests since normalizeTestStatus converts 'timedOut' to 'failed'
467
- const failedTests = Array.from(this.testMap.values()).filter(t => t.status === 'failed').length;
468
- const skippedTests = Array.from(this.testMap.values()).filter(t => t.status === 'skipped').length;
469
- // Track timedOut separately for reporting purposes only (not for count)
470
- const timedOutTests = Array.from(this.testMap.values()).filter(t => t.originalStatus === 'timedOut').length;
471
-
472
- // Normalize run status - if there are timeouts, treat run as failed
473
- const hasTimeouts = timedOutTests > 0;
474
- const normalizedRunStatus = this.normalizeRunStatus(result.status, hasTimeouts);
475
-
476
- // Send run end event to API
477
- await this.sendToApi({
478
- type: 'runEnd',
479
- runId: this.runId,
480
- timestamp: new Date().toISOString(),
481
- metadata: {
482
- ...this.runMetadata,
483
- totalTests,
484
- passedTests,
485
- failedTests, // Already includes timedOut tests (normalized to 'failed')
486
- skippedTests,
487
- timedOutTests, // For informational purposes
488
- status: normalizedRunStatus
489
- }
490
- });
491
-
492
- // Wait for background artifact processing to complete (up to 10 seconds)
493
- await new Promise(resolve => setTimeout(resolve, 10000));
494
- }
495
-
496
- async sendToApi(payload) {
497
- try {
498
- const response = await this.axiosInstance.post('', payload, {
499
- headers: {
500
- 'X-API-Key': this.config.apiKey
501
- }
502
- });
503
- } catch (error) {
504
- console.error(`❌ Failed to send ${payload.type} event to TestLens:`, {
505
- message: error?.message || 'Unknown error',
506
- status: error?.response?.status,
507
- data: error?.response?.data
508
- });
509
-
510
- // Don't throw error to avoid breaking test execution
511
- }
512
- }
513
-
514
- async processArtifacts(testId, result) {
515
- const attachments = result.attachments;
516
-
517
- // Process artifacts with controlled async handling to ensure uploads complete
518
- if (attachments && attachments.length > 0) {
519
- // Process all artifacts asynchronously but track completion
520
-
521
- // Use process.nextTick to defer processing to next event loop iteration
522
- process.nextTick(async () => {
523
- for (let i = 0; i < attachments.length; i++) {
524
- const attachment = attachments[i];
525
- if (attachment.path) {
526
- // Check if attachment should be processed based on config
527
- const artifactType = this.getArtifactType(attachment.name);
528
- const isVideo = artifactType === 'video' || (attachment.contentType && attachment.contentType.startsWith('video/'));
529
- const isScreenshot = artifactType === 'screenshot' || (attachment.contentType && attachment.contentType.startsWith('image/'));
530
-
531
- // Skip video if disabled in config
532
- if (isVideo && !this.config.enableVideo) {
533
- continue;
534
- }
535
-
536
- // Skip screenshot if disabled in config
537
- if (isScreenshot && !this.config.enableScreenshot) {
538
- continue;
539
- }
540
-
541
- try {
542
-
543
- // Determine proper filename with extension
544
- // Playwright attachment.name often doesn't have extension, so we need to derive it
545
- let fileName = attachment.name;
546
- const existingExt = path.extname(fileName);
547
-
548
- if (!existingExt) {
549
- // Get extension from the actual file path
550
- const pathExt = path.extname(attachment.path);
551
- if (pathExt) {
552
- fileName = `${fileName}${pathExt}`;
553
- } else if (attachment.contentType) {
554
- // Fallback: derive extension from contentType
555
- const mimeToExt = {
556
- 'image/png': '.png',
557
- 'image/jpeg': '.jpg',
558
- 'image/gif': '.gif',
559
- 'image/webp': '.webp',
560
- 'video/webm': '.webm',
561
- 'video/mp4': '.mp4',
562
- 'application/zip': '.zip',
563
- 'application/json': '.json',
564
- 'text/plain': '.txt'
565
- };
566
- const ext = mimeToExt[attachment.contentType];
567
- if (ext) {
568
- fileName = `${fileName}${ext}`;
569
- }
570
- }
571
- }
572
-
573
- // Upload to S3 first
574
- const s3Data = await this.uploadArtifactToS3(attachment.path, testId, fileName);
575
-
576
- // Skip if upload failed or file was too large
577
- if (!s3Data) {
578
- continue;
579
- }
580
-
581
- const artifactData = {
582
- testId,
583
- type: this.getArtifactType(attachment.name),
584
- path: attachment.path,
585
- name: fileName,
586
- contentType: attachment.contentType,
587
- fileSize: this.getFileSize(attachment.path),
588
- storageType: 's3',
589
- s3Key: s3Data.key,
590
- s3Url: s3Data.url
591
- };
592
-
593
- // Send artifact data to API
594
- await this.sendToApi({
595
- type: 'artifact',
596
- runId: this.runId,
597
- timestamp: new Date().toISOString(),
598
- artifact: artifactData
599
- });
600
- } catch (error) {
601
- console.error(`❌ Failed to process ${attachment.name}:`, error.message);
602
- }
603
- }
604
- }
605
- });
606
- }
607
- }
608
-
609
- async sendSpecCodeBlocks(specPath) {
610
- try {
611
- // Extract code blocks using built-in parser
612
- const testBlocks = this.extractTestBlocks(specPath);
613
-
614
- // Transform blocks to match backend API expectations
615
- const codeBlocks = testBlocks.map(block => ({
616
- type: block.type, // 'test' or 'describe'
617
- name: block.name, // test/describe name
618
- content: block.content, // full code content
619
- summary: null, // optional
620
- describe: block.describe // parent describe block name
621
- }));
622
-
623
- // Send to dedicated spec code blocks API endpoint
624
- const baseUrl = this.config.apiEndpoint.replace('/webhook/playwright', '');
625
- const specEndpoint = `${baseUrl}/webhook/playwright/spec-code-blocks`;
626
-
627
- await this.axiosInstance.post(specEndpoint, {
628
- filePath: path.relative(process.cwd(), specPath),
629
- codeBlocks,
630
- testSuiteName: path.basename(specPath).replace(/\.(spec|test)\.(js|ts)$/, '')
631
- });
632
- } catch (error) {
633
- console.error('Failed to send spec code blocks:', error?.response?.data || error?.message || 'Unknown error');
634
- }
635
- }
636
-
637
- extractTestBlocks(filePath) {
638
- try {
639
- const content = fs.readFileSync(filePath, 'utf-8');
640
- const blocks = [];
641
- const lines = content.split('\n');
642
-
643
- let currentDescribe = null;
644
- let braceCount = 0;
645
- let inBlock = false;
646
- let blockStart = -1;
647
- let blockType = '';
648
- let blockName = '';
649
-
650
- for (let i = 0; i < lines.length; i++) {
651
- const line = lines[i];
652
- const trimmedLine = line.trim();
653
-
654
- // Check for describe blocks
655
- const describeMatch = trimmedLine.match(/describe\s*\(\s*['"`]([^'"`]+)['"`]/);
656
- if (describeMatch) {
657
- currentDescribe = describeMatch[1];
658
- }
659
-
660
- // Check for test blocks
661
- const testMatch = trimmedLine.match(/test\s*\(\s*['"`]([^'"`]+)['"`]/);
662
- if (testMatch && !inBlock) {
663
- blockType = 'test';
664
- blockName = testMatch[1];
665
- blockStart = i;
666
- braceCount = 0;
667
- inBlock = true;
668
- }
669
-
670
- // Count braces when in a block
671
- if (inBlock) {
672
- for (const char of line) {
673
- if (char === '{') braceCount++;
674
- if (char === '}') braceCount--;
675
-
676
- if (braceCount === 0 && blockStart !== -1 && i > blockStart) {
677
- // End of block found
678
- const blockContent = lines.slice(blockStart, i + 1).join('\n');
679
-
680
- blocks.push({
681
- type: blockType,
682
- name: blockName,
683
- content: blockContent,
684
- describe: currentDescribe,
685
- startLine: blockStart + 1,
686
- endLine: i + 1
687
- });
688
-
689
- inBlock = false;
690
- blockStart = -1;
691
- break;
692
- }
693
- }
694
- }
695
- }
696
-
697
- return blocks;
698
- } catch (error) {
699
- console.error(`Failed to extract test blocks from ${filePath}:`, error.message);
700
- return [];
701
- }
702
- }
703
-
704
- async collectGitInfo() {
705
- try {
706
- const { execSync } = require('child_process');
707
-
708
- const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
709
- const commit = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
710
- const shortCommit = commit.substring(0, 7);
711
- const author = execSync('git log -1 --pretty=format:"%an"', { encoding: 'utf-8' }).trim();
712
- const commitMessage = execSync('git log -1 --pretty=format:"%s"', { encoding: 'utf-8' }).trim();
713
- const commitTimestamp = execSync('git log -1 --pretty=format:"%ci"', { encoding: 'utf-8' }).trim();
714
-
715
- let remoteName = 'origin';
716
- let remoteUrl = '';
717
- try {
718
- const remotes = execSync('git remote', { encoding: 'utf-8' }).trim();
719
- if (remotes) {
720
- remoteName = remotes.split('\n')[0] || 'origin';
721
- remoteUrl = execSync(`git remote get-url ${remoteName}`, { encoding: 'utf-8' }).trim();
722
- }
723
- } catch (e) {
724
- // Remote info is optional - handle gracefully
725
- }
726
-
727
- const isDirty = execSync('git status --porcelain', { encoding: 'utf-8' }).trim().length > 0;
728
-
729
- return {
730
- branch,
731
- commit,
732
- shortCommit,
733
- author,
734
- message: commitMessage,
735
- timestamp: commitTimestamp,
736
- isDirty,
737
- remoteName,
738
- remoteUrl
739
- };
740
- } catch (error) {
741
- console.warn('Could not collect Git information:', error.message);
742
- return null;
743
- }
744
- }
745
-
746
- getArtifactType(name) {
747
- if (name.includes('screenshot')) return 'screenshot';
748
- if (name.includes('video')) return 'video';
749
- if (name.includes('trace')) return 'trace';
750
- return 'attachment';
751
- }
752
-
753
- extractTags(test) {
754
- const tags = [];
755
-
756
- test.annotations.forEach(annotation => {
757
- if (annotation.type === 'tag' && annotation.description) {
758
- tags.push(annotation.description);
759
- }
760
- });
761
-
762
- const tagMatches = test.title.match(/@[\w-]+/g);
763
- if (tagMatches) {
764
- tags.push(...tagMatches);
765
- }
766
-
767
- return tags;
768
- }
769
-
770
- getTestId(test) {
771
- const cleanTitle = test.title.replace(/@[\w-]+/g, '').trim();
772
- // Normalize path separators to forward slashes for cross-platform consistency
773
- const normalizedFile = test.location.file.replace(/\\/g, '/');
774
- return `${normalizedFile}:${test.location.line}:${cleanTitle}`;
775
- }
776
-
777
- getFileSize(filePath) {
778
- try {
779
- const stats = fs.statSync(filePath);
780
- return stats.size;
781
- } catch (error) {
782
- console.warn(`Could not get file size for ${filePath}:`, error.message);
783
- return 0;
784
- }
785
- }
786
-
787
- async uploadArtifactToS3(filePath, testId, fileName) {
788
- try {
789
- // Check file size first
790
- const fileSize = this.getFileSize(filePath);
791
- const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
792
-
793
- const baseUrl = this.config.apiEndpoint.replace('/api/v1/webhook/playwright', '');
794
-
795
- // Step 1: Request pre-signed URL from server
796
- const presignedUrlEndpoint = `${baseUrl}/api/v1/artifacts/public/presigned-url`;
797
- const presignedResponse = await this.axiosInstance.post(presignedUrlEndpoint, {
798
- apiKey: this.config.apiKey,
799
- testRunId: this.runId,
800
- testId: testId,
801
- fileName: fileName,
802
- fileType: await this.getContentType(fileName),
803
- fileSize: fileSize,
804
- artifactType: this.getArtifactType(fileName)
805
- }, {
806
- timeout: 10000 // Quick timeout for metadata request
807
- });
808
-
809
- if (!presignedResponse.data.success) {
810
- throw new Error(`Failed to get presigned URL: ${presignedResponse.data.error || 'Unknown error'}`);
811
- }
812
-
813
- const { uploadUrl, s3Key, metadata } = presignedResponse.data;
814
-
815
- // Step 2: Upload directly to S3 using presigned URL
816
- const fileBuffer = fs.readFileSync(filePath);
817
-
818
- // IMPORTANT: When using presigned URLs, we MUST include exactly the headers that were signed
819
- // The backend signs with ServerSideEncryption:'AES256', so we must send that header
820
- // AWS presigned URLs are very strict about header matching
821
- const uploadResponse = await axios.put(uploadUrl, fileBuffer, {
822
- headers: {
823
- 'x-amz-server-side-encryption': 'AES256'
824
- },
825
- maxContentLength: Infinity,
826
- maxBodyLength: Infinity,
827
- timeout: Math.max(600000, Math.ceil(fileSize / (1024 * 1024)) * 10000), // 10s per MB, min 10 minutes - increased for large trace files
828
- // Don't use custom HTTPS agent for S3 uploads
829
- httpsAgent: undefined
830
- });
831
-
832
- if (uploadResponse.status !== 200) {
833
- throw new Error(`S3 upload failed with status ${uploadResponse.status}`);
834
- }
835
-
836
- // Step 3: Confirm upload with server to save metadata
837
- const confirmEndpoint = `${baseUrl}/api/v1/artifacts/public/confirm-upload`;
838
- const confirmResponse = await this.axiosInstance.post(confirmEndpoint, {
839
- apiKey: this.config.apiKey,
840
- testRunId: this.runId,
841
- testId: testId,
842
- s3Key: s3Key,
843
- fileName: fileName,
844
- fileType: await this.getContentType(fileName),
845
- fileSize: fileSize,
846
- artifactType: this.getArtifactType(fileName)
847
- }, {
848
- timeout: 10000
849
- });
850
-
851
- if (confirmResponse.status === 201 && confirmResponse.data.success) {
852
- const artifact = confirmResponse.data.artifact;
853
- return {
854
- key: s3Key,
855
- url: artifact.s3Url,
856
- presignedUrl: artifact.presignedUrl,
857
- fileSize: artifact.fileSize,
858
- contentType: artifact.contentType
859
- };
860
- } else {
861
- throw new Error(`Upload confirmation failed: ${confirmResponse.data.error || 'Unknown error'}`);
862
- }
863
- } catch (error) {
864
- // Better error messages for common issues
865
- let errorMsg = error.message;
866
-
867
- if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
868
- errorMsg = `Upload timeout - file may be too large or connection is slow`;
869
- } else if (error.response?.status === 413) {
870
- errorMsg = `File too large (413) - server rejected the upload`;
871
- } else if (error.response?.status === 400) {
872
- errorMsg = `Bad request (400) - ${error.response.data?.error || 'check file format'}`;
873
- } else if (error.response?.status === 403) {
874
- errorMsg = `Access denied (403) - presigned URL may have expired`;
875
- }
876
-
877
- console.error(`❌ Failed to upload ${fileName} to S3:`, errorMsg);
878
- if (error.response?.data) {
879
- console.error('Error details:', error.response.data);
880
- }
881
-
882
- // Don't throw, just return null to continue with other artifacts
883
- return null;
884
- }
885
- }
886
-
887
- async getContentType(fileName) {
888
- const ext = path.extname(fileName).toLowerCase();
889
- try {
890
- const mime = await getMime();
891
- // Try different ways to access getType method
892
- const getType = mime.getType || mime.default?.getType;
893
- if (typeof getType === 'function') {
894
- const mimeType = getType.call(mime, ext) || getType.call(mime.default, ext);
895
- return mimeType || 'application/octet-stream';
896
- }
897
- } catch (error) {
898
- console.warn(`Failed to get MIME type for ${fileName}:`, error.message);
899
- }
900
- // Fallback to basic content type mapping
901
- const contentTypes = {
902
- '.mp4': 'video/mp4',
903
- '.webm': 'video/webm',
904
- '.png': 'image/png',
905
- '.jpg': 'image/jpeg',
906
- '.jpeg': 'image/jpeg',
907
- '.gif': 'image/gif',
908
- '.json': 'application/json',
909
- '.txt': 'text/plain',
910
- '.html': 'text/html',
911
- '.xml': 'application/xml',
912
- '.zip': 'application/zip',
913
- '.pdf': 'application/pdf'
914
- };
915
- return contentTypes[ext] || 'application/octet-stream';
916
- }
917
- }
918
-
919
- module.exports = TestLensReporter;
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TestLensReporter = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const crypto_1 = require("crypto");
6
+ const os = tslib_1.__importStar(require("os"));
7
+ const path = tslib_1.__importStar(require("path"));
8
+ const fs = tslib_1.__importStar(require("fs"));
9
+ const https = tslib_1.__importStar(require("https"));
10
+ const axios_1 = tslib_1.__importDefault(require("axios"));
11
+ const child_process_1 = require("child_process");
12
+ // Lazy-load mime module to support ESM
13
+ let mimeModule = null;
14
+ async function getMime() {
15
+ if (!mimeModule) {
16
+ const imported = await Promise.resolve().then(() => tslib_1.__importStar(require('mime')));
17
+ // Handle both default export and named exports
18
+ mimeModule = imported.default || imported;
19
+ }
20
+ return mimeModule;
21
+ }
22
+ class TestLensReporter {
23
+ /**
24
+ * Parse custom metadata from environment variables
25
+ * Checks for common metadata environment variables
26
+ */
27
+ static parseCustomArgs() {
28
+ const customArgs = {};
29
+ // Common environment variable names for build metadata
30
+ const envVarMappings = {
31
+ 'testlensBuildTag': ['BUILDTAG', 'BUILD_TAG', 'TestlensBuildTag'],
32
+ 'testlensBuildName': ['BUILDNAME', 'BUILD_NAME', 'TestlensBuildName'],
33
+ 'environment': ['ENVIRONMENT', 'ENV', 'NODE_ENV', 'DEPLOYMENT_ENV'],
34
+ 'branch': ['BRANCH', 'GIT_BRANCH', 'CI_COMMIT_BRANCH', 'GITHUB_REF_NAME'],
35
+ 'team': ['TEAM', 'TEAM_NAME'],
36
+ 'project': ['PROJECT', 'PROJECT_NAME'],
37
+ 'customvalue': ['CUSTOMVALUE', 'CUSTOM_VALUE']
38
+ };
39
+ // Check for each metadata key
40
+ Object.entries(envVarMappings).forEach(([key, envVars]) => {
41
+ for (const envVar of envVars) {
42
+ const value = process.env[envVar];
43
+ if (value) {
44
+ customArgs[key] = value;
45
+ console.log(`✓ Found ${envVar}=${value} (mapped to '${key}')`);
46
+ break; // Use first match
47
+ }
48
+ }
49
+ });
50
+ return customArgs;
51
+ }
52
+ constructor(options) {
53
+ this.runCreationFailed = false; // Track if run creation failed due to limits
54
+ // Parse custom CLI arguments
55
+ const customArgs = TestLensReporter.parseCustomArgs();
56
+ // Allow API key from environment variable if not provided in config
57
+ // Check multiple environment variable names in priority order (uppercase and lowercase)
58
+ const apiKey = options.apiKey
59
+ || process.env.TESTLENS_API_KEY
60
+ || process.env.testlens_api_key
61
+ || process.env.TESTLENS_KEY
62
+ || process.env.testlens_key
63
+ || process.env.testlensApiKey
64
+ || process.env.PLAYWRIGHT_API_KEY
65
+ || process.env.playwright_api_key
66
+ || process.env.PW_API_KEY
67
+ || process.env.pw_api_key;
68
+ this.config = {
69
+ apiEndpoint: options.apiEndpoint || 'https://testlens.qa-path.com/api/v1/webhook/playwright',
70
+ apiKey: apiKey, // API key from config or environment variable
71
+ enableRealTimeStream: options.enableRealTimeStream !== undefined ? options.enableRealTimeStream : true,
72
+ enableGitInfo: options.enableGitInfo !== undefined ? options.enableGitInfo : true,
73
+ enableArtifacts: options.enableArtifacts !== undefined ? options.enableArtifacts : true,
74
+ enableVideo: options.enableVideo !== undefined ? options.enableVideo : true, // Default to true, override from config
75
+ enableScreenshot: options.enableScreenshot !== undefined ? options.enableScreenshot : true, // Default to true, override from config
76
+ batchSize: options.batchSize || 10,
77
+ flushInterval: options.flushInterval || 5000,
78
+ retryAttempts: options.retryAttempts !== undefined ? options.retryAttempts : 0,
79
+ timeout: options.timeout || 60000,
80
+ customMetadata: { ...customArgs, ...options.customMetadata } // CLI args + config metadata
81
+ };
82
+ if (!this.config.apiKey) {
83
+ throw new Error('API_KEY is required for TestLensReporter. Pass it as apiKey option in your playwright config or set one of these environment variables: TESTLENS_API_KEY, TESTLENS_KEY, PLAYWRIGHT_API_KEY, PW_API_KEY, API_KEY, or APIKEY.');
84
+ }
85
+ if (apiKey !== options.apiKey) {
86
+ console.log('✓ Using API key from environment variable');
87
+ }
88
+ // Determine SSL validation behavior
89
+ let rejectUnauthorized = true; // Default to secure
90
+ // Check various ways SSL validation can be disabled (in order of precedence)
91
+ if (this.config.ignoreSslErrors) {
92
+ // Explicit configuration option
93
+ rejectUnauthorized = false;
94
+ console.log('⚠️ SSL certificate validation disabled via ignoreSslErrors option');
95
+ }
96
+ else if (this.config.rejectUnauthorized === false) {
97
+ // Explicit configuration option
98
+ rejectUnauthorized = false;
99
+ console.log('⚠️ SSL certificate validation disabled via rejectUnauthorized option');
100
+ }
101
+ else if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') {
102
+ // Environment variable override
103
+ rejectUnauthorized = false;
104
+ console.log('⚠️ SSL certificate validation disabled via NODE_TLS_REJECT_UNAUTHORIZED environment variable');
105
+ }
106
+ // Set up axios instance with retry logic and enhanced SSL handling
107
+ this.axiosInstance = axios_1.default.create({
108
+ baseURL: this.config.apiEndpoint,
109
+ timeout: this.config.timeout,
110
+ headers: {
111
+ 'Content-Type': 'application/json',
112
+ ...(this.config.apiKey && { 'X-API-Key': this.config.apiKey }),
113
+ },
114
+ // Enhanced SSL handling with flexible TLS configuration
115
+ httpsAgent: new https.Agent({
116
+ rejectUnauthorized: rejectUnauthorized,
117
+ // Allow any TLS version for better compatibility
118
+ minVersion: 'TLSv1.2',
119
+ maxVersion: 'TLSv1.3'
120
+ })
121
+ });
122
+ // Add retry interceptor
123
+ this.axiosInstance.interceptors.response.use((response) => response, async (error) => {
124
+ const originalRequest = error.config;
125
+ if (!originalRequest._retry && error.response?.status >= 500) {
126
+ originalRequest._retry = true;
127
+ originalRequest._retryCount = (originalRequest._retryCount || 0) + 1;
128
+ if (originalRequest._retryCount <= this.config.retryAttempts) {
129
+ // Exponential backoff
130
+ const delay = Math.pow(2, originalRequest._retryCount) * 1000;
131
+ await new Promise(resolve => setTimeout(resolve, delay));
132
+ return this.axiosInstance(originalRequest);
133
+ }
134
+ }
135
+ return Promise.reject(error);
136
+ });
137
+ this.runId = (0, crypto_1.randomUUID)();
138
+ this.runMetadata = this.initializeRunMetadata();
139
+ this.specMap = new Map();
140
+ this.testMap = new Map();
141
+ this.runCreationFailed = false;
142
+ // Log custom metadata if any
143
+ if (this.config.customMetadata && Object.keys(this.config.customMetadata).length > 0) {
144
+ console.log('\n📋 Custom Metadata Detected:');
145
+ Object.entries(this.config.customMetadata).forEach(([key, value]) => {
146
+ console.log(` ${key}: ${value}`);
147
+ });
148
+ console.log('');
149
+ }
150
+ }
151
+ initializeRunMetadata() {
152
+ const metadata = {
153
+ id: this.runId,
154
+ startTime: new Date().toISOString(),
155
+ environment: 'production',
156
+ browser: 'multiple',
157
+ os: `${os.type()} ${os.release()}`,
158
+ playwrightVersion: this.getPlaywrightVersion(),
159
+ nodeVersion: process.version
160
+ };
161
+ // Add custom metadata if provided
162
+ if (this.config.customMetadata && Object.keys(this.config.customMetadata).length > 0) {
163
+ metadata.customMetadata = this.config.customMetadata;
164
+ // Extract testlensBuildName as a dedicated field for dashboard display
165
+ if (this.config.customMetadata.testlensBuildName) {
166
+ metadata.testlensBuildName = this.config.customMetadata.testlensBuildName;
167
+ }
168
+ }
169
+ return metadata;
170
+ }
171
+ getPlaywrightVersion() {
172
+ try {
173
+ const playwrightPackage = require('@playwright/test/package.json');
174
+ return playwrightPackage.version;
175
+ }
176
+ catch (error) {
177
+ return 'unknown';
178
+ }
179
+ }
180
+ normalizeTestStatus(status) {
181
+ // Treat timeout as failed for consistency with analytics
182
+ if (status === 'timedOut') {
183
+ return 'failed';
184
+ }
185
+ return status;
186
+ }
187
+ normalizeRunStatus(status, hasTimeouts) {
188
+ // If run has timeouts, treat as failed
189
+ if (hasTimeouts && status === 'passed') {
190
+ return 'failed';
191
+ }
192
+ // Treat timeout status as failed
193
+ if (status === 'timedOut') {
194
+ return 'failed';
195
+ }
196
+ return status;
197
+ }
198
+ async onBegin(config, suite) {
199
+ // Show Build Name if provided, otherwise show Run ID
200
+ if (this.runMetadata.testlensBuildName) {
201
+ console.log(`🚀 TestLens Reporter starting - Build: ${this.runMetadata.testlensBuildName}`);
202
+ console.log(` Run ID: ${this.runId}`);
203
+ }
204
+ else {
205
+ console.log(`🚀 TestLens Reporter starting - Run ID: ${this.runId}`);
206
+ }
207
+ // Collect Git information if enabled
208
+ if (this.config.enableGitInfo) {
209
+ this.runMetadata.gitInfo = await this.collectGitInfo();
210
+ if (this.runMetadata.gitInfo) {
211
+ console.log(`📦 Git info collected: branch=${this.runMetadata.gitInfo.branch}, commit=${this.runMetadata.gitInfo.shortCommit}, author=${this.runMetadata.gitInfo.author}`);
212
+ }
213
+ else {
214
+ console.log(`⚠️ Git info collection returned null - not in a git repository or git not available`);
215
+ }
216
+ }
217
+ else {
218
+ console.log(`ℹ️ Git info collection disabled (enableGitInfo: false)`);
219
+ }
220
+ // Add shard information if available
221
+ if (config.shard) {
222
+ this.runMetadata.shardInfo = {
223
+ current: config.shard.current,
224
+ total: config.shard.total
225
+ };
226
+ }
227
+ // Send run start event to API
228
+ await this.sendToApi({
229
+ type: 'runStart',
230
+ runId: this.runId,
231
+ timestamp: new Date().toISOString(),
232
+ metadata: this.runMetadata
233
+ });
234
+ }
235
+ async onTestBegin(test, result) {
236
+ // Log which test is starting
237
+ console.log(`\n▶️ Running test: ${test.title}`);
238
+ const specPath = test.location.file;
239
+ const specKey = `${specPath}-${test.parent.title}`;
240
+ // Create or update spec data
241
+ if (!this.specMap.has(specKey)) {
242
+ const extractedTags = this.extractTags(test);
243
+ const specData = {
244
+ filePath: path.relative(process.cwd(), specPath),
245
+ testSuiteName: test.parent.title,
246
+ startTime: new Date().toISOString(),
247
+ status: 'running'
248
+ };
249
+ if (extractedTags.length > 0) {
250
+ specData.tags = extractedTags;
251
+ }
252
+ this.specMap.set(specKey, specData);
253
+ // Send spec start event to API
254
+ await this.sendToApi({
255
+ type: 'specStart',
256
+ runId: this.runId,
257
+ timestamp: new Date().toISOString(),
258
+ spec: specData
259
+ });
260
+ }
261
+ const testId = this.getTestId(test);
262
+ // Only send testStart event on first attempt (retry 0)
263
+ if (result.retry === 0) {
264
+ // Create test data
265
+ const testData = {
266
+ id: testId,
267
+ name: test.title,
268
+ status: 'running',
269
+ originalStatus: 'running',
270
+ duration: 0,
271
+ startTime: new Date().toISOString(),
272
+ endTime: '',
273
+ errorMessages: [],
274
+ errors: [],
275
+ retryAttempts: test.retries,
276
+ currentRetry: result.retry,
277
+ annotations: test.annotations.map((ann) => ({
278
+ type: ann.type,
279
+ description: ann.description
280
+ })),
281
+ projectName: test.parent.project()?.name || 'default',
282
+ workerIndex: result.workerIndex,
283
+ parallelIndex: result.parallelIndex,
284
+ location: {
285
+ file: path.relative(process.cwd(), test.location.file),
286
+ line: test.location.line,
287
+ column: test.location.column
288
+ }
289
+ };
290
+ this.testMap.set(testData.id, testData);
291
+ // Send test start event to API
292
+ await this.sendToApi({
293
+ type: 'testStart',
294
+ runId: this.runId,
295
+ timestamp: new Date().toISOString(),
296
+ test: testData
297
+ });
298
+ }
299
+ else {
300
+ // For retries, just update the existing test data
301
+ const existingTestData = this.testMap.get(testId);
302
+ if (existingTestData) {
303
+ existingTestData.currentRetry = result.retry;
304
+ }
305
+ }
306
+ }
307
+ async onTestEnd(test, result) {
308
+ const testId = this.getTestId(test);
309
+ let testData = this.testMap.get(testId);
310
+ console.log(`[TestLens] onTestEnd called for test: ${test.title}, status: ${result.status}, testData exists: ${!!testData}`);
311
+ // For skipped tests, onTestBegin might not be called, so we need to create the test data here
312
+ if (!testData) {
313
+ console.log(`[TestLens] Creating test data for skipped/uncreated test: ${test.title}`);
314
+ // Create spec data if not exists (skipped tests might not have spec data either)
315
+ const specPath = test.location.file;
316
+ const specKey = `${specPath}-${test.parent.title}`;
317
+ if (!this.specMap.has(specKey)) {
318
+ const extractedTags = this.extractTags(test);
319
+ const specData = {
320
+ filePath: path.relative(process.cwd(), specPath),
321
+ testSuiteName: test.parent.title,
322
+ startTime: new Date().toISOString(),
323
+ status: 'skipped'
324
+ };
325
+ if (extractedTags.length > 0) {
326
+ specData.tags = extractedTags;
327
+ }
328
+ this.specMap.set(specKey, specData);
329
+ // Send spec start event to API
330
+ await this.sendToApi({
331
+ type: 'specStart',
332
+ runId: this.runId,
333
+ timestamp: new Date().toISOString(),
334
+ spec: specData
335
+ });
336
+ }
337
+ // Create test data for skipped test
338
+ testData = {
339
+ id: testId,
340
+ name: test.title,
341
+ status: 'skipped',
342
+ originalStatus: 'skipped',
343
+ duration: 0,
344
+ startTime: new Date().toISOString(),
345
+ endTime: new Date().toISOString(),
346
+ errorMessages: [],
347
+ errors: [],
348
+ retryAttempts: test.retries,
349
+ currentRetry: 0,
350
+ annotations: test.annotations.map((ann) => ({
351
+ type: ann.type,
352
+ description: ann.description
353
+ })),
354
+ projectName: test.parent.project()?.name || 'default',
355
+ workerIndex: result.workerIndex,
356
+ parallelIndex: result.parallelIndex,
357
+ location: {
358
+ file: path.relative(process.cwd(), test.location.file),
359
+ line: test.location.line,
360
+ column: test.location.column
361
+ }
362
+ };
363
+ this.testMap.set(testId, testData);
364
+ // Send test start event first (so the test gets created in DB)
365
+ await this.sendToApi({
366
+ type: 'testStart',
367
+ runId: this.runId,
368
+ timestamp: new Date().toISOString(),
369
+ test: testData
370
+ });
371
+ }
372
+ if (testData) {
373
+ // Update test data with latest result
374
+ testData.originalStatus = result.status;
375
+ testData.status = this.normalizeTestStatus(result.status);
376
+ testData.duration = result.duration;
377
+ testData.endTime = new Date().toISOString();
378
+ testData.errorMessages = result.errors.map((error) => error.message || error.toString());
379
+ testData.currentRetry = result.retry;
380
+ // Capture test location
381
+ testData.location = {
382
+ file: path.relative(process.cwd(), test.location.file),
383
+ line: test.location.line,
384
+ column: test.location.column
385
+ };
386
+ // Capture rich error details like Playwright's HTML report
387
+ testData.errors = result.errors.map((error) => {
388
+ const testError = {
389
+ message: error.message || error.toString()
390
+ };
391
+ // Capture stack trace
392
+ if (error.stack) {
393
+ testError.stack = error.stack;
394
+ }
395
+ // Capture error location
396
+ if (error.location) {
397
+ testError.location = {
398
+ file: path.relative(process.cwd(), error.location.file),
399
+ line: error.location.line,
400
+ column: error.location.column
401
+ };
402
+ }
403
+ // Capture code snippet around error - from Playwright error object
404
+ if (error.snippet) {
405
+ testError.snippet = error.snippet;
406
+ }
407
+ // Capture expected/actual values for assertion failures
408
+ // Playwright stores these as specially formatted strings in the message
409
+ const message = error.message || '';
410
+ // Try to parse expected pattern from toHaveURL and similar assertions
411
+ const expectedPatternMatch = message.match(/Expected pattern:\s*(.+?)(?:\n|$)/);
412
+ if (expectedPatternMatch) {
413
+ testError.expected = expectedPatternMatch[1].trim();
414
+ }
415
+ // Also try "Expected string:" format
416
+ if (!testError.expected) {
417
+ const expectedStringMatch = message.match(/Expected string:\s*["']?(.+?)["']?(?:\n|$)/);
418
+ if (expectedStringMatch) {
419
+ testError.expected = expectedStringMatch[1].trim();
420
+ }
421
+ }
422
+ // Try to parse received/actual value
423
+ const receivedMatch = message.match(/Received (?:string|value):\s*["']?(.+?)["']?(?:\n|$)/);
424
+ if (receivedMatch) {
425
+ testError.actual = receivedMatch[1].trim();
426
+ }
427
+ // Parse call log entries for debugging info (timeouts, retries, etc.)
428
+ const callLogMatch = message.match(/Call log:([\s\S]*?)(?=\n\n|\n\s*\d+\s*\||$)/);
429
+ if (callLogMatch) {
430
+ // Store call log separately for display
431
+ const callLog = callLogMatch[1].trim();
432
+ if (callLog) {
433
+ testError.diff = callLog; // Reuse diff field for call log
434
+ }
435
+ }
436
+ // Parse timeout information - multiple formats
437
+ const timeoutMatch = message.match(/(?:with timeout|Timeout:?)\s*(\d+)ms/i);
438
+ if (timeoutMatch) {
439
+ testError.timeout = parseInt(timeoutMatch[1], 10);
440
+ }
441
+ // Parse matcher name (e.g., toHaveURL, toBeVisible)
442
+ const matcherMatch = message.match(/expect\([^)]+\)\.(\w+)/);
443
+ if (matcherMatch) {
444
+ testError.matcherName = matcherMatch[1];
445
+ }
446
+ // Extract code snippet from message if not already captured
447
+ // Look for lines like " 9 | await page.click..." or "> 11 | await expect..."
448
+ if (!testError.snippet) {
449
+ const codeSnippetMatch = message.match(/((?:\s*>?\s*\d+\s*\|.*\n?)+)/);
450
+ if (codeSnippetMatch) {
451
+ testError.snippet = codeSnippetMatch[1].trim();
452
+ }
453
+ }
454
+ return testError;
455
+ });
456
+ // Only send testEnd event after final retry attempt
457
+ // If test passed or this is the last retry, send the event
458
+ const isFinalAttempt = result.status === 'passed' || result.status === 'skipped' || result.retry >= test.retries;
459
+ if (isFinalAttempt) {
460
+ console.log(`[TestLens] Sending testEnd - testId: ${testData.id}, status: ${testData.status}, originalStatus: ${testData.originalStatus}`);
461
+ // Send test end event to API
462
+ await this.sendToApi({
463
+ type: 'testEnd',
464
+ runId: this.runId,
465
+ timestamp: new Date().toISOString(),
466
+ test: testData
467
+ });
468
+ // Handle artifacts
469
+ if (this.config.enableArtifacts) {
470
+ await this.processArtifacts(testId, result);
471
+ }
472
+ }
473
+ }
474
+ // Update spec status
475
+ const specPath = test.location.file;
476
+ const specKey = `${specPath}-${test.parent.title}`;
477
+ const specData = this.specMap.get(specKey);
478
+ if (specData) {
479
+ const normalizedStatus = this.normalizeTestStatus(result.status);
480
+ if (normalizedStatus === 'failed' && specData.status !== 'failed') {
481
+ specData.status = 'failed';
482
+ }
483
+ else if (result.status === 'skipped' && specData.status === 'passed') {
484
+ specData.status = 'skipped';
485
+ }
486
+ // Check if all tests in spec are complete
487
+ const remainingTests = test.parent.tests.filter((t) => {
488
+ const tId = this.getTestId(t);
489
+ const tData = this.testMap.get(tId);
490
+ return !tData || !tData.endTime;
491
+ });
492
+ if (remainingTests.length === 0) {
493
+ // Aggregate tags from all tests in this spec
494
+ const allTags = new Set();
495
+ test.parent.tests.forEach((t) => {
496
+ const tags = this.extractTags(t);
497
+ tags.forEach(tag => allTags.add(tag));
498
+ });
499
+ const aggregatedTags = Array.from(allTags);
500
+ // Only update tags if we have any
501
+ if (aggregatedTags.length > 0) {
502
+ specData.tags = aggregatedTags;
503
+ }
504
+ specData.endTime = new Date().toISOString();
505
+ // Send spec end event to API
506
+ await this.sendToApi({
507
+ type: 'specEnd',
508
+ runId: this.runId,
509
+ timestamp: new Date().toISOString(),
510
+ spec: specData
511
+ });
512
+ // Send spec code blocks to API
513
+ await this.sendSpecCodeBlocks(specPath);
514
+ }
515
+ }
516
+ }
517
+ async onEnd(result) {
518
+ this.runMetadata.endTime = new Date().toISOString();
519
+ this.runMetadata.duration = Date.now() - new Date(this.runMetadata.startTime).getTime();
520
+ // Calculate final stats
521
+ const totalTests = Array.from(this.testMap.values()).length;
522
+ const passedTests = Array.from(this.testMap.values()).filter(t => t.status === 'passed').length;
523
+ // failedTests already includes timedOut tests since normalizeTestStatus converts 'timedOut' to 'failed'
524
+ const failedTests = Array.from(this.testMap.values()).filter(t => t.status === 'failed').length;
525
+ const skippedTests = Array.from(this.testMap.values()).filter(t => t.status === 'skipped').length;
526
+ // Track timedOut separately for reporting purposes only (not for count)
527
+ const timedOutTests = Array.from(this.testMap.values()).filter(t => t.originalStatus === 'timedOut').length;
528
+ // Normalize run status - if there are timeouts, treat run as failed
529
+ const hasTimeouts = timedOutTests > 0;
530
+ const normalizedRunStatus = this.normalizeRunStatus(result.status, hasTimeouts);
531
+ // Send run end event to API
532
+ await this.sendToApi({
533
+ type: 'runEnd',
534
+ runId: this.runId,
535
+ timestamp: new Date().toISOString(),
536
+ metadata: {
537
+ ...this.runMetadata,
538
+ totalTests,
539
+ passedTests,
540
+ failedTests, // Already includes timedOut tests (normalized to 'failed')
541
+ skippedTests,
542
+ timedOutTests, // For informational purposes
543
+ status: normalizedRunStatus
544
+ }
545
+ });
546
+ // Show Build Name if provided, otherwise show Run ID
547
+ if (this.runMetadata.testlensBuildName) {
548
+ console.log(`📊 TestLens Report completed - Build: ${this.runMetadata.testlensBuildName}`);
549
+ console.log(` Run ID: ${this.runId}`);
550
+ }
551
+ else {
552
+ console.log(`📊 TestLens Report completed - Run ID: ${this.runId}`);
553
+ }
554
+ console.log(`🎯 Results: ${passedTests} passed, ${failedTests} failed (${timedOutTests} timeouts), ${skippedTests} skipped`);
555
+ }
556
+ async sendToApi(payload) {
557
+ // Skip sending if run creation already failed
558
+ if (this.runCreationFailed && payload.type !== 'runStart') {
559
+ return;
560
+ }
561
+ try {
562
+ const response = await this.axiosInstance.post('', payload, {
563
+ headers: {
564
+ 'X-API-Key': this.config.apiKey
565
+ }
566
+ });
567
+ if (this.config.enableRealTimeStream) {
568
+ console.log(`✅ Sent ${payload.type} event to TestLens (HTTP ${response.status})`);
569
+ }
570
+ }
571
+ catch (error) {
572
+ const errorData = error?.response?.data;
573
+ const status = error?.response?.status;
574
+ // Check for limit exceeded (403)
575
+ if (status === 403 && errorData?.error === 'limit_exceeded') {
576
+ // Set flag to skip subsequent events
577
+ if (payload.type === 'runStart' && errorData?.limit_type === 'test_runs') {
578
+ this.runCreationFailed = true;
579
+ }
580
+ console.error('\n' + '='.repeat(80));
581
+ if (errorData?.limit_type === 'test_cases') {
582
+ console.error('❌ TESTLENS ERROR: Test Cases Limit Reached');
583
+ }
584
+ else if (errorData?.limit_type === 'test_runs') {
585
+ console.error('❌ TESTLENS ERROR: Test Runs Limit Reached');
586
+ }
587
+ else {
588
+ console.error('❌ TESTLENS ERROR: Plan Limit Reached');
589
+ }
590
+ console.error('='.repeat(80));
591
+ console.error('');
592
+ console.error(errorData?.message || 'You have reached your plan limit.');
593
+ console.error('');
594
+ console.error(`Current usage: ${errorData?.current_usage || 'N/A'} / ${errorData?.limit || 'N/A'}`);
595
+ console.error('');
596
+ console.error('To continue, please upgrade your plan.');
597
+ console.error('Contact: support@alternative-path.com');
598
+ console.error('');
599
+ console.error('='.repeat(80));
600
+ console.error('');
601
+ return; // Don't log the full error object for limit errors
602
+ }
603
+ // Check for trial expiration, subscription errors, or limit errors (401)
604
+ if (status === 401) {
605
+ if (errorData?.error === 'trial_expired' || errorData?.error === 'subscription_inactive' ||
606
+ errorData?.error === 'test_cases_limit_reached' || errorData?.error === 'test_runs_limit_reached') {
607
+ console.error('\n' + '='.repeat(80));
608
+ if (errorData?.error === 'test_cases_limit_reached') {
609
+ console.error('❌ TESTLENS ERROR: Test Cases Limit Reached');
610
+ }
611
+ else if (errorData?.error === 'test_runs_limit_reached') {
612
+ console.error('❌ TESTLENS ERROR: Test Runs Limit Reached');
613
+ }
614
+ else {
615
+ console.error('❌ TESTLENS ERROR: Your trial plan has ended');
616
+ }
617
+ console.error('='.repeat(80));
618
+ console.error('');
619
+ console.error(errorData?.message || 'Your trial period has expired.');
620
+ console.error('');
621
+ console.error('To continue using TestLens, please upgrade to Enterprise plan.');
622
+ console.error('Contact: ' + (errorData?.contactEmail || 'support@alternative-path.com'));
623
+ console.error('');
624
+ if (errorData?.trial_end_date) {
625
+ console.error(`Trial ended: ${new Date(errorData.trial_end_date).toLocaleDateString()}`);
626
+ console.error('');
627
+ }
628
+ console.error('='.repeat(80));
629
+ console.error('');
630
+ }
631
+ else {
632
+ console.error(`❌ Authentication failed: ${errorData?.error || 'Invalid API key'}`);
633
+ }
634
+ }
635
+ else if (status !== 403) {
636
+ // Log other errors (but not 403 which we handled above)
637
+ console.error(`❌ Failed to send ${payload.type} event to TestLens:`, {
638
+ message: error?.message || 'Unknown error',
639
+ status: status,
640
+ statusText: error?.response?.statusText,
641
+ data: errorData,
642
+ code: error?.code,
643
+ url: error?.config?.url,
644
+ method: error?.config?.method
645
+ });
646
+ }
647
+ // Don't throw error to avoid breaking test execution
648
+ }
649
+ }
650
+ async processArtifacts(testId, result) {
651
+ // Skip artifact processing if run creation failed
652
+ if (this.runCreationFailed) {
653
+ return;
654
+ }
655
+ const attachments = result.attachments;
656
+ for (const attachment of attachments) {
657
+ if (attachment.path) {
658
+ // Check if attachment should be processed based on config
659
+ const artifactType = this.getArtifactType(attachment.name);
660
+ const isVideo = artifactType === 'video' || attachment.contentType?.startsWith('video/');
661
+ const isScreenshot = artifactType === 'screenshot' || attachment.contentType?.startsWith('image/');
662
+ // Skip video if disabled in config
663
+ if (isVideo && !this.config.enableVideo) {
664
+ console.log(`⏭️ Skipping video artifact ${attachment.name} - video capture disabled in config`);
665
+ continue;
666
+ }
667
+ // Skip screenshot if disabled in config
668
+ if (isScreenshot && !this.config.enableScreenshot) {
669
+ console.log(`⏭️ Skipping screenshot artifact ${attachment.name} - screenshot capture disabled in config`);
670
+ continue;
671
+ }
672
+ try {
673
+ // Determine proper filename with extension
674
+ // Playwright attachment.name often doesn't have extension, so we need to derive it
675
+ let fileName = attachment.name;
676
+ const existingExt = path.extname(fileName);
677
+ if (!existingExt) {
678
+ // Get extension from the actual file path
679
+ const pathExt = path.extname(attachment.path);
680
+ if (pathExt) {
681
+ fileName = `${fileName}${pathExt}`;
682
+ }
683
+ else if (attachment.contentType) {
684
+ // Fallback: derive extension from contentType
685
+ const mimeToExt = {
686
+ 'image/png': '.png',
687
+ 'image/jpeg': '.jpg',
688
+ 'image/gif': '.gif',
689
+ 'image/webp': '.webp',
690
+ 'video/webm': '.webm',
691
+ 'video/mp4': '.mp4',
692
+ 'application/zip': '.zip',
693
+ 'application/json': '.json',
694
+ 'text/plain': '.txt'
695
+ };
696
+ const ext = mimeToExt[attachment.contentType];
697
+ if (ext) {
698
+ fileName = `${fileName}${ext}`;
699
+ }
700
+ }
701
+ }
702
+ // Upload to S3 first
703
+ const s3Data = await this.uploadArtifactToS3(attachment.path, testId, fileName);
704
+ // Skip if upload failed or file was too large
705
+ if (!s3Data) {
706
+ console.log(`⏭️ Skipping artifact ${attachment.name} - upload failed or file too large`);
707
+ continue;
708
+ }
709
+ const artifactData = {
710
+ testId,
711
+ type: this.getArtifactType(attachment.name),
712
+ path: attachment.path,
713
+ name: fileName,
714
+ contentType: attachment.contentType,
715
+ fileSize: this.getFileSize(attachment.path),
716
+ storageType: 's3',
717
+ s3Key: s3Data.key,
718
+ s3Url: s3Data.url
719
+ };
720
+ // Send artifact data to API
721
+ await this.sendToApi({
722
+ type: 'artifact',
723
+ runId: this.runId,
724
+ timestamp: new Date().toISOString(),
725
+ artifact: artifactData
726
+ });
727
+ console.log(`📎 Processed artifact: ${fileName} (uploaded to S3)`);
728
+ }
729
+ catch (error) {
730
+ console.error(`❌ Failed to process artifact ${attachment.name}:`, error.message);
731
+ }
732
+ }
733
+ }
734
+ }
735
+ async sendSpecCodeBlocks(specPath) {
736
+ try {
737
+ // Extract code blocks using built-in parser
738
+ const testBlocks = this.extractTestBlocks(specPath);
739
+ // Transform blocks to match backend API expectations
740
+ const codeBlocks = testBlocks.map(block => ({
741
+ type: block.type, // 'test' or 'describe'
742
+ name: block.name, // test/describe name
743
+ content: block.content, // full code content
744
+ summary: null, // optional
745
+ describe: block.describe // parent describe block name
746
+ }));
747
+ // Send to dedicated spec code blocks API endpoint
748
+ const baseUrl = this.config.apiEndpoint.replace('/webhook/playwright', '');
749
+ const specEndpoint = `${baseUrl}/webhook/playwright/spec-code-blocks`;
750
+ await this.axiosInstance.post(specEndpoint, {
751
+ filePath: path.relative(process.cwd(), specPath),
752
+ codeBlocks,
753
+ testSuiteName: path.basename(specPath).replace(/\.(spec|test)\.(js|ts)$/, '')
754
+ });
755
+ console.log(`📝 Sent ${codeBlocks.length} code blocks for: ${path.basename(specPath)}`);
756
+ }
757
+ catch (error) {
758
+ const errorData = error?.response?.data;
759
+ // Handle duplicate spec code blocks gracefully (when re-running tests)
760
+ if (errorData?.error && errorData.error.includes('duplicate key value violates unique constraint')) {
761
+ console.log(`ℹ️ Spec code blocks already exist for: ${path.basename(specPath)} (skipped)`);
762
+ return;
763
+ }
764
+ console.error('Failed to send spec code blocks:', errorData || error?.message || 'Unknown error');
765
+ }
766
+ }
767
+ extractTestBlocks(filePath) {
768
+ try {
769
+ const content = fs.readFileSync(filePath, 'utf-8');
770
+ const blocks = [];
771
+ const lines = content.split('\n');
772
+ let currentDescribe = null;
773
+ let braceCount = 0;
774
+ let inBlock = false;
775
+ let blockStart = -1;
776
+ let blockType = 'test';
777
+ let blockName = '';
778
+ for (let i = 0; i < lines.length; i++) {
779
+ const line = lines[i];
780
+ const trimmedLine = line.trim();
781
+ // Check for describe blocks
782
+ const describeMatch = trimmedLine.match(/describe\s*\(\s*['"`]([^'"`]+)['"`]/);
783
+ if (describeMatch) {
784
+ currentDescribe = describeMatch[1];
785
+ }
786
+ // Check for test blocks
787
+ const testMatch = trimmedLine.match(/test\s*\(\s*['"`]([^'"`]+)['"`]/);
788
+ if (testMatch && !inBlock) {
789
+ blockType = 'test';
790
+ blockName = testMatch[1];
791
+ blockStart = i;
792
+ braceCount = 0;
793
+ inBlock = true;
794
+ }
795
+ // Count braces when in a block
796
+ if (inBlock) {
797
+ for (const char of line) {
798
+ if (char === '{')
799
+ braceCount++;
800
+ if (char === '}')
801
+ braceCount--;
802
+ if (braceCount === 0 && blockStart !== -1 && i > blockStart) {
803
+ // End of block found
804
+ const blockContent = lines.slice(blockStart, i + 1).join('\n');
805
+ blocks.push({
806
+ type: blockType,
807
+ name: blockName,
808
+ content: blockContent,
809
+ describe: currentDescribe || undefined,
810
+ startLine: blockStart + 1,
811
+ endLine: i + 1
812
+ });
813
+ inBlock = false;
814
+ blockStart = -1;
815
+ break;
816
+ }
817
+ }
818
+ }
819
+ }
820
+ return blocks;
821
+ }
822
+ catch (error) {
823
+ console.error(`Failed to extract test blocks from ${filePath}:`, error.message);
824
+ return [];
825
+ }
826
+ }
827
+ async collectGitInfo() {
828
+ try {
829
+ const branch = (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
830
+ const commit = (0, child_process_1.execSync)('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
831
+ const shortCommit = commit.substring(0, 7);
832
+ const author = (0, child_process_1.execSync)('git log -1 --pretty=format:"%an"', { encoding: 'utf-8' }).trim();
833
+ const commitMessage = (0, child_process_1.execSync)('git log -1 --pretty=format:"%s"', { encoding: 'utf-8' }).trim();
834
+ const commitTimestamp = (0, child_process_1.execSync)('git log -1 --pretty=format:"%ci"', { encoding: 'utf-8' }).trim();
835
+ let remoteName = 'origin';
836
+ let remoteUrl = '';
837
+ try {
838
+ const remotes = (0, child_process_1.execSync)('git remote', { encoding: 'utf-8' }).trim();
839
+ if (remotes) {
840
+ remoteName = remotes.split('\n')[0] || 'origin';
841
+ remoteUrl = (0, child_process_1.execSync)(`git remote get-url ${remoteName}`, { encoding: 'utf-8' }).trim();
842
+ }
843
+ }
844
+ catch (e) {
845
+ // Remote info is optional - handle gracefully
846
+ console.log('ℹ️ No git remote configured, skipping remote info');
847
+ }
848
+ const isDirty = (0, child_process_1.execSync)('git status --porcelain', { encoding: 'utf-8' }).trim().length > 0;
849
+ return {
850
+ branch,
851
+ commit,
852
+ shortCommit,
853
+ author,
854
+ message: commitMessage,
855
+ timestamp: commitTimestamp,
856
+ isDirty,
857
+ remoteName,
858
+ remoteUrl
859
+ };
860
+ }
861
+ catch (error) {
862
+ // Silently skip git information if not in a git repository
863
+ return null;
864
+ }
865
+ }
866
+ getArtifactType(name) {
867
+ if (name.includes('screenshot'))
868
+ return 'screenshot';
869
+ if (name.includes('video'))
870
+ return 'video';
871
+ if (name.includes('trace'))
872
+ return 'trace';
873
+ return 'attachment';
874
+ }
875
+ extractTags(test) {
876
+ const tags = [];
877
+ // Playwright stores tags in the _tags property
878
+ const testTags = test._tags;
879
+ if (testTags && Array.isArray(testTags)) {
880
+ tags.push(...testTags);
881
+ }
882
+ // Also get tags from parent suites by walking up the tree
883
+ let currentSuite = test.parent;
884
+ while (currentSuite) {
885
+ const suiteTags = currentSuite._tags;
886
+ if (suiteTags && Array.isArray(suiteTags)) {
887
+ tags.push(...suiteTags);
888
+ }
889
+ currentSuite = currentSuite.parent;
890
+ }
891
+ // Also extract @tags from test title for backward compatibility
892
+ const tagMatches = test.title.match(/@[\w-]+/g);
893
+ if (tagMatches) {
894
+ tags.push(...tagMatches);
895
+ }
896
+ // Add testlensBuildTag from custom metadata if present
897
+ if (this.config.customMetadata?.testlensBuildTag) {
898
+ tags.push(`@${this.config.customMetadata.testlensBuildTag}`);
899
+ }
900
+ // Remove duplicates and return
901
+ return [...new Set(tags)];
902
+ }
903
+ getTestId(test) {
904
+ const cleanTitle = test.title.replace(/@[\w-]+/g, '').trim();
905
+ // Normalize path separators to forward slashes for cross-platform consistency
906
+ const normalizedFile = test.location.file.replace(/\\/g, '/');
907
+ return `${normalizedFile}:${test.location.line}:${cleanTitle}`;
908
+ }
909
+ async uploadArtifactToS3(filePath, testId, fileName) {
910
+ try {
911
+ // Check file size first
912
+ const fileSize = this.getFileSize(filePath);
913
+ const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
914
+ console.log(`📤 Uploading ${fileName} (${fileSizeMB}MB) directly to S3...`);
915
+ const baseUrl = this.config.apiEndpoint.replace('/api/v1/webhook/playwright', '');
916
+ // Step 1: Request pre-signed URL from server
917
+ const presignedUrlEndpoint = `${baseUrl}/api/v1/artifacts/public/presigned-url`;
918
+ const presignedResponse = await this.axiosInstance.post(presignedUrlEndpoint, {
919
+ apiKey: this.config.apiKey,
920
+ testRunId: this.runId,
921
+ testId: testId,
922
+ fileName: fileName,
923
+ fileType: await this.getContentType(fileName),
924
+ fileSize: fileSize,
925
+ artifactType: this.getArtifactType(fileName)
926
+ }, {
927
+ timeout: 10000 // Quick timeout for metadata request
928
+ });
929
+ if (!presignedResponse.data.success) {
930
+ throw new Error(`Failed to get presigned URL: ${presignedResponse.data.error || 'Unknown error'}`);
931
+ }
932
+ const { uploadUrl, s3Key, metadata } = presignedResponse.data;
933
+ // Step 2: Upload directly to S3 using presigned URL
934
+ console.log(`⬆️ Uploading ${fileName} directly to S3 (bypass server)...`);
935
+ const fileBuffer = fs.readFileSync(filePath);
936
+ // IMPORTANT: When using presigned URLs, we MUST include exactly the headers that were signed
937
+ // The backend signs with ServerSideEncryption:'AES256', so we must send that header
938
+ // AWS presigned URLs are very strict about header matching
939
+ const uploadResponse = await axios_1.default.put(uploadUrl, fileBuffer, {
940
+ headers: {
941
+ 'x-amz-server-side-encryption': 'AES256'
942
+ },
943
+ maxContentLength: Infinity,
944
+ maxBodyLength: Infinity,
945
+ timeout: Math.max(600000, Math.ceil(fileSize / (1024 * 1024)) * 10000), // 10s per MB, min 10 minutes - increased for large trace files
946
+ // Don't use custom HTTPS agent for S3 uploads
947
+ httpsAgent: undefined
948
+ });
949
+ if (uploadResponse.status !== 200) {
950
+ throw new Error(`S3 upload failed with status ${uploadResponse.status}`);
951
+ }
952
+ console.log(`✅ S3 direct upload completed for ${fileName}`);
953
+ // Step 3: Confirm upload with server to save metadata
954
+ const confirmEndpoint = `${baseUrl}/api/v1/artifacts/public/confirm-upload`;
955
+ const confirmResponse = await this.axiosInstance.post(confirmEndpoint, {
956
+ apiKey: this.config.apiKey,
957
+ testRunId: this.runId,
958
+ testId: testId,
959
+ s3Key: s3Key,
960
+ fileName: fileName,
961
+ fileType: await this.getContentType(fileName),
962
+ fileSize: fileSize,
963
+ artifactType: this.getArtifactType(fileName)
964
+ }, {
965
+ timeout: 10000
966
+ });
967
+ if (confirmResponse.status === 201 && confirmResponse.data.success) {
968
+ const artifact = confirmResponse.data.artifact;
969
+ console.log(`✅ Upload confirmed and saved to database`);
970
+ return {
971
+ key: s3Key,
972
+ url: artifact.s3Url,
973
+ presignedUrl: artifact.presignedUrl,
974
+ fileSize: artifact.fileSize,
975
+ contentType: artifact.contentType
976
+ };
977
+ }
978
+ else {
979
+ throw new Error(`Upload confirmation failed: ${confirmResponse.data.error || 'Unknown error'}`);
980
+ }
981
+ }
982
+ catch (error) {
983
+ // Check for trial expiration, subscription errors, or limit errors
984
+ if (error?.response?.status === 401) {
985
+ const errorData = error?.response?.data;
986
+ if (errorData?.error === 'trial_expired' || errorData?.error === 'subscription_inactive' ||
987
+ errorData?.error === 'test_cases_limit_reached' || errorData?.error === 'test_runs_limit_reached') {
988
+ console.error('\\n' + '='.repeat(80));
989
+ if (errorData?.error === 'test_cases_limit_reached') {
990
+ console.error('❌ TESTLENS ERROR: Test Cases Limit Reached');
991
+ }
992
+ else if (errorData?.error === 'test_runs_limit_reached') {
993
+ console.error('❌ TESTLENS ERROR: Test Runs Limit Reached');
994
+ }
995
+ else {
996
+ console.error('❌ TESTLENS ERROR: Your trial plan has ended');
997
+ }
998
+ console.error('='.repeat(80));
999
+ console.error('');
1000
+ console.error(errorData?.message || 'Your trial period has expired.');
1001
+ console.error('');
1002
+ console.error('To continue using TestLens, please upgrade to Enterprise plan.');
1003
+ console.error('Contact: ' + (errorData?.contactEmail || 'support@alternative-path.com'));
1004
+ console.error('');
1005
+ if (errorData?.trial_end_date) {
1006
+ console.error(`Trial ended: ${new Date(errorData.trial_end_date).toLocaleDateString()}`);
1007
+ console.error('');
1008
+ }
1009
+ console.error('='.repeat(80));
1010
+ console.error('');
1011
+ return null;
1012
+ }
1013
+ }
1014
+ // Better error messages for common issues
1015
+ let errorMsg = error.message;
1016
+ if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
1017
+ errorMsg = `Upload timeout - file may be too large or connection is slow`;
1018
+ }
1019
+ else if (error.response?.status === 413) {
1020
+ errorMsg = `File too large (413) - server rejected the upload`;
1021
+ }
1022
+ else if (error.response?.status === 400) {
1023
+ errorMsg = `Bad request (400) - ${error.response.data?.error || 'check file format'}`;
1024
+ }
1025
+ else if (error.response?.status === 403) {
1026
+ errorMsg = `Access denied (403) - presigned URL may have expired`;
1027
+ }
1028
+ console.error(`❌ Failed to upload ${fileName} to S3:`, errorMsg);
1029
+ if (error.response?.data) {
1030
+ console.error('Error details:', error.response.data);
1031
+ }
1032
+ // Don't throw, just return null to continue with other artifacts
1033
+ return null;
1034
+ }
1035
+ }
1036
+ async getContentType(fileName) {
1037
+ const ext = path.extname(fileName).toLowerCase();
1038
+ try {
1039
+ const mime = await getMime();
1040
+ // Try different ways to access getType method
1041
+ const getType = mime.getType || mime.default?.getType;
1042
+ if (typeof getType === 'function') {
1043
+ const mimeType = getType.call(mime, ext) || getType.call(mime.default, ext);
1044
+ return mimeType || 'application/octet-stream';
1045
+ }
1046
+ }
1047
+ catch (error) {
1048
+ console.warn(`Failed to get MIME type for ${fileName}:`, error.message);
1049
+ }
1050
+ // Fallback to basic content type mapping
1051
+ const contentTypes = {
1052
+ '.mp4': 'video/mp4',
1053
+ '.webm': 'video/webm',
1054
+ '.png': 'image/png',
1055
+ '.jpg': 'image/jpeg',
1056
+ '.jpeg': 'image/jpeg',
1057
+ '.gif': 'image/gif',
1058
+ '.json': 'application/json',
1059
+ '.txt': 'text/plain',
1060
+ '.html': 'text/html',
1061
+ '.xml': 'application/xml',
1062
+ '.zip': 'application/zip',
1063
+ '.pdf': 'application/pdf'
1064
+ };
1065
+ return contentTypes[ext] || 'application/octet-stream';
1066
+ }
1067
+ generateS3Key(runId, testId, fileName) {
1068
+ const date = new Date().toISOString().slice(0, 10);
1069
+ const safeTestId = this.sanitizeForS3(testId);
1070
+ const safeFileName = this.sanitizeForS3(fileName);
1071
+ const ext = path.extname(fileName);
1072
+ const baseName = path.basename(fileName, ext);
1073
+ return `test-artifacts/${date}/${runId}/${safeTestId}/${safeFileName}${ext}`;
1074
+ }
1075
+ sanitizeForS3(value) {
1076
+ return value
1077
+ .replace(/[\/:*?"<>|]/g, '-')
1078
+ .replace(/[-\u001f\u007f]/g, '-')
1079
+ .replace(/[^-~]/g, '-')
1080
+ .replace(/\s+/g, '-')
1081
+ .replace(/[_]/g, '-')
1082
+ .replace(/-+/g, '-')
1083
+ .replace(/^-|-$/g, '');
1084
+ }
1085
+ getFileSize(filePath) {
1086
+ try {
1087
+ const stats = fs.statSync(filePath);
1088
+ return stats.size;
1089
+ }
1090
+ catch (error) {
1091
+ console.warn(`Could not get file size for ${filePath}:`, error.message);
1092
+ return 0;
1093
+ }
1094
+ }
1095
+ }
1096
+ exports.TestLensReporter = TestLensReporter;
1097
+ exports.default = TestLensReporter;