@alternative-path/testlens-playwright-reporter 0.3.7

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