testlens-playwright-reporter 0.1.0

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,696 @@
1
+ const { randomUUID } = require('crypto');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const axios = require('axios');
6
+ const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
7
+ const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
8
+ const mime = require('mime');
9
+
10
+ // Load environment variables from .env file
11
+ // Embedded configuration from .env file (generated during build)
12
+ const EMBEDDED_CONFIG = {
13
+ "AWS_ACCESS_KEY_ID": "AKIA5QK76YZKWA2P5V4E",
14
+ "AWS_SECRET_ACCESS_KEY": "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",
15
+ "AWS_REGION": "eu-north-1",
16
+ "S3_BUCKET_NAME": "testlenss3",
17
+ "S3_ACL": "private",
18
+ "TEST_API_ENDPOINT": "https://testlens.qa-path.com/api/v1/webhook/playwright"
19
+ };
20
+
21
+ class TestLensReporter {
22
+ constructor(options = {}) {
23
+ this.config = {
24
+ apiEndpoint: EMBEDDED_CONFIG.TEST_API_ENDPOINT || options.apiEndpoint || '',
25
+ apiKey: options.apiKey, // API key must come from config file
26
+ enableRealTimeStream: options.enableRealTimeStream !== false,
27
+ enableGitInfo: options.enableGitInfo !== false,
28
+ enableArtifacts: options.enableArtifacts !== false,
29
+ enableS3Upload: options.enableS3Upload !== false,
30
+ batchSize: options.batchSize || 10,
31
+ flushInterval: options.flushInterval || 5000,
32
+ retryAttempts: options.retryAttempts || 3,
33
+ timeout: options.timeout || 30000,
34
+ ...options
35
+ };
36
+
37
+ if (!this.config.apiEndpoint) {
38
+ throw new Error('TEST_API_ENDPOINT is required for TestLensReporter. Set it in environment variable or pass as option.');
39
+ }
40
+
41
+ if (!this.config.apiKey) {
42
+ throw new Error('API_KEY is required for TestLensReporter. Pass it as apiKey option in your playwright config.');
43
+ }
44
+
45
+ // Initialize S3 client if S3 upload is enabled
46
+ this.s3Client = null;
47
+ this.s3Enabled = this.config.enableS3Upload && EMBEDDED_CONFIG.S3_BUCKET_NAME;
48
+
49
+ if (this.s3Enabled) {
50
+ try {
51
+ const s3Config = {
52
+ region: EMBEDDED_CONFIG.AWS_REGION || 'us-east-1',
53
+ maxAttempts: 3
54
+ };
55
+
56
+ if (EMBEDDED_CONFIG.AWS_ACCESS_KEY_ID && EMBEDDED_CONFIG.AWS_SECRET_ACCESS_KEY) {
57
+ s3Config.credentials = {
58
+ accessKeyId: EMBEDDED_CONFIG.AWS_ACCESS_KEY_ID,
59
+ secretAccessKey: EMBEDDED_CONFIG.AWS_SECRET_ACCESS_KEY
60
+ };
61
+ }
62
+
63
+ if (process.env.AWS_ENDPOINT) {
64
+ s3Config.endpoint = process.env.AWS_ENDPOINT;
65
+ s3Config.forcePathStyle = true;
66
+ }
67
+
68
+ this.s3Client = new S3Client(s3Config);
69
+ console.log('✅ S3 client initialized for artifact uploads');
70
+ } catch (error) {
71
+ console.error('❌ Failed to initialize S3 client:', error.message);
72
+ this.s3Enabled = false;
73
+ }
74
+ }
75
+
76
+ // Set up axios instance with retry logic
77
+ this.axiosInstance = axios.create({
78
+ baseURL: this.config.apiEndpoint,
79
+ timeout: this.config.timeout,
80
+ headers: {
81
+ 'Content-Type': 'application/json',
82
+ ...(this.config.apiKey && { 'X-API-Key': this.config.apiKey }),
83
+ },
84
+ });
85
+
86
+ // Add retry interceptor
87
+ this.axiosInstance.interceptors.response.use(
88
+ (response) => response,
89
+ async (error) => {
90
+ const originalRequest = error.config;
91
+
92
+ if (!originalRequest._retry && error.response?.status >= 500) {
93
+ originalRequest._retry = true;
94
+ originalRequest._retryCount = (originalRequest._retryCount || 0) + 1;
95
+
96
+ if (originalRequest._retryCount <= this.config.retryAttempts) {
97
+ // Exponential backoff
98
+ const delay = Math.pow(2, originalRequest._retryCount) * 1000;
99
+ await new Promise(resolve => setTimeout(resolve, delay));
100
+ return this.axiosInstance(originalRequest);
101
+ }
102
+ }
103
+
104
+ return Promise.reject(error);
105
+ }
106
+ );
107
+
108
+ this.runId = randomUUID();
109
+ this.runMetadata = this.initializeRunMetadata();
110
+ this.specMap = new Map();
111
+ this.testMap = new Map();
112
+ }
113
+
114
+ initializeRunMetadata() {
115
+ return {
116
+ id: this.runId,
117
+ startTime: new Date().toISOString(),
118
+ environment: process.env.NODE_ENV || 'development',
119
+ browser: 'multiple',
120
+ os: `${os.type()} ${os.release()}`,
121
+ playwrightVersion: this.getPlaywrightVersion(),
122
+ nodeVersion: process.version
123
+ };
124
+ }
125
+
126
+ getPlaywrightVersion() {
127
+ try {
128
+ const playwrightPackage = require('@playwright/test/package.json');
129
+ return playwrightPackage.version;
130
+ } catch (error) {
131
+ return 'unknown';
132
+ }
133
+ }
134
+
135
+ normalizeTestStatus(status) {
136
+ // Treat timeout as failed for consistency with analytics
137
+ if (status === 'timedOut') {
138
+ return 'failed';
139
+ }
140
+ return status;
141
+ }
142
+
143
+ normalizeRunStatus(status, hasTimeouts) {
144
+ // If run has timeouts, treat as failed
145
+ if (hasTimeouts && status === 'passed') {
146
+ return 'failed';
147
+ }
148
+ // Treat timeout status as failed
149
+ if (status === 'timedOut') {
150
+ return 'failed';
151
+ }
152
+ return status;
153
+ }
154
+
155
+ async onBegin(config, suite) {
156
+ console.log(`🚀 TestLens Reporter starting - Run ID: ${this.runId}`);
157
+
158
+ // Collect Git information if enabled
159
+ if (this.config.enableGitInfo) {
160
+ this.runMetadata.gitInfo = await this.collectGitInfo();
161
+ }
162
+
163
+ // Add shard information if available
164
+ if (config.shard) {
165
+ this.runMetadata.shardInfo = {
166
+ current: config.shard.current,
167
+ total: config.shard.total
168
+ };
169
+ }
170
+
171
+ // Send run start event to API
172
+ await this.sendToApi({
173
+ type: 'runStart',
174
+ runId: this.runId,
175
+ timestamp: new Date().toISOString(),
176
+ metadata: this.runMetadata
177
+ });
178
+ }
179
+
180
+ async onTestBegin(test, result) {
181
+ const specPath = test.location.file;
182
+ const specKey = `${specPath}-${test.parent.title}`;
183
+
184
+ // Create or update spec data
185
+ if (!this.specMap.has(specKey)) {
186
+ const specData = {
187
+ filePath: path.relative(process.cwd(), specPath),
188
+ testSuiteName: test.parent.title,
189
+ tags: this.extractTags(test),
190
+ startTime: new Date().toISOString(),
191
+ status: 'passed'
192
+ };
193
+ this.specMap.set(specKey, specData);
194
+
195
+ // Send spec start event to API
196
+ await this.sendToApi({
197
+ type: 'specStart',
198
+ runId: this.runId,
199
+ timestamp: new Date().toISOString(),
200
+ spec: specData
201
+ });
202
+ }
203
+
204
+ // Create test data
205
+ const testData = {
206
+ id: this.getTestId(test),
207
+ name: test.title,
208
+ status: 'passed',
209
+ originalStatus: 'passed',
210
+ duration: 0,
211
+ startTime: new Date().toISOString(),
212
+ endTime: '',
213
+ errorMessages: [],
214
+ retryAttempts: test.retries,
215
+ currentRetry: result.retry,
216
+ annotations: test.annotations.map(ann => ({
217
+ type: ann.type,
218
+ description: ann.description
219
+ })),
220
+ projectName: test.parent.project()?.name || 'default',
221
+ workerIndex: result.workerIndex,
222
+ parallelIndex: result.parallelIndex
223
+ };
224
+
225
+ this.testMap.set(testData.id, testData);
226
+
227
+ // Send test start event to API
228
+ await this.sendToApi({
229
+ type: 'testStart',
230
+ runId: this.runId,
231
+ timestamp: new Date().toISOString(),
232
+ test: testData
233
+ });
234
+ }
235
+
236
+ async onTestEnd(test, result) {
237
+ const testId = this.getTestId(test);
238
+ const testData = this.testMap.get(testId);
239
+
240
+ if (testData) {
241
+ // Preserve original status for detailed reporting, but normalize for consistency
242
+ testData.originalStatus = result.status;
243
+ testData.status = this.normalizeTestStatus(result.status);
244
+ testData.duration = result.duration;
245
+ testData.endTime = new Date().toISOString();
246
+ testData.errorMessages = result.errors.map(error => error.message || error.toString());
247
+
248
+ // Send test end event to API
249
+ await this.sendToApi({
250
+ type: 'testEnd',
251
+ runId: this.runId,
252
+ timestamp: new Date().toISOString(),
253
+ test: testData
254
+ });
255
+
256
+ // Handle artifacts
257
+ if (this.config.enableArtifacts) {
258
+ await this.processArtifacts(testId, result);
259
+ }
260
+ }
261
+
262
+ // Update spec status
263
+ const specPath = test.location.file;
264
+ const specKey = `${specPath}-${test.parent.title}`;
265
+ const specData = this.specMap.get(specKey);
266
+ if (specData) {
267
+ const normalizedStatus = this.normalizeTestStatus(result.status);
268
+ if (normalizedStatus === 'failed' && specData.status !== 'failed') {
269
+ specData.status = 'failed';
270
+ } else if (result.status === 'skipped' && specData.status === 'passed') {
271
+ specData.status = 'skipped';
272
+ }
273
+
274
+ // Check if all tests in spec are complete
275
+ const remainingTests = test.parent.tests.filter(t => {
276
+ const tId = this.getTestId(t);
277
+ const tData = this.testMap.get(tId);
278
+ return !tData || !tData.endTime;
279
+ });
280
+
281
+ if (remainingTests.length === 0) {
282
+ specData.endTime = new Date().toISOString();
283
+
284
+ // Send spec end event to API
285
+ await this.sendToApi({
286
+ type: 'specEnd',
287
+ runId: this.runId,
288
+ timestamp: new Date().toISOString(),
289
+ spec: specData
290
+ });
291
+
292
+ // Send spec code blocks to API
293
+ await this.sendSpecCodeBlocks(specPath);
294
+ }
295
+ }
296
+ }
297
+
298
+ async onEnd(result) {
299
+ this.runMetadata.endTime = new Date().toISOString();
300
+ this.runMetadata.duration = Date.now() - new Date(this.runMetadata.startTime).getTime();
301
+
302
+ // Calculate final stats
303
+ const totalTests = Array.from(this.testMap.values()).length;
304
+ const passedTests = Array.from(this.testMap.values()).filter(t => t.status === 'passed').length;
305
+ const failedTests = Array.from(this.testMap.values()).filter(t => t.status === 'failed').length;
306
+ const skippedTests = Array.from(this.testMap.values()).filter(t => t.status === 'skipped').length;
307
+ const timedOutTests = Array.from(this.testMap.values()).filter(t => t.originalStatus === 'timedOut').length;
308
+
309
+ // Normalize run status - if there are timeouts, treat run as failed
310
+ const hasTimeouts = timedOutTests > 0;
311
+ const normalizedRunStatus = this.normalizeRunStatus(result.status, hasTimeouts);
312
+
313
+ // Send run end event to API
314
+ await this.sendToApi({
315
+ type: 'runEnd',
316
+ runId: this.runId,
317
+ timestamp: new Date().toISOString(),
318
+ metadata: {
319
+ ...this.runMetadata,
320
+ totalTests,
321
+ passedTests,
322
+ failedTests: failedTests + timedOutTests, // Include timeouts in failed count
323
+ skippedTests,
324
+ timedOutTests,
325
+ status: normalizedRunStatus
326
+ }
327
+ });
328
+
329
+ // Wait for background artifact processing to complete (up to 10 seconds)
330
+ console.log('⏳ Waiting for background artifact processing to complete...');
331
+ await new Promise(resolve => setTimeout(resolve, 10000));
332
+
333
+ console.log(`📊 TestLens Report completed - Run ID: ${this.runId}`);
334
+ console.log(`🎯 Results: ${passedTests} passed, ${failedTests + timedOutTests} failed (${timedOutTests} timeouts), ${skippedTests} skipped`);
335
+ }
336
+
337
+ async sendToApi(payload) {
338
+ try {
339
+ const response = await this.axiosInstance.post('', payload);
340
+ if (this.config.enableRealTimeStream) {
341
+ console.log(`✅ Sent ${payload.type} event to TestLens`);
342
+ }
343
+ } catch (error) {
344
+ console.error(`❌ Failed to send ${payload.type} event to TestLens:`, {
345
+ message: error?.message || 'Unknown error',
346
+ status: error?.response?.status,
347
+ data: error?.response?.data
348
+ });
349
+
350
+ // Don't throw error to avoid breaking test execution
351
+ }
352
+ }
353
+
354
+ async processArtifacts(testId, result) {
355
+ const attachments = result.attachments;
356
+ console.log(`🔍 Processing artifacts for test ${testId}: ${attachments ? attachments.length : 0} attachments found`);
357
+
358
+ if (attachments && attachments.length > 0) {
359
+ console.log('📎 Attachment details:');
360
+ attachments.forEach((attachment, index) => {
361
+ console.log(` ${index + 1}. ${attachment.name} (${attachment.contentType}) - Path: ${attachment.path}`);
362
+ });
363
+ }
364
+
365
+ // Process artifacts with controlled async handling to ensure uploads complete
366
+ if (attachments && attachments.length > 0) {
367
+ // Process all artifacts asynchronously but track completion
368
+ console.log(`🔄 Processing ${attachments.length} artifacts asynchronously...`);
369
+
370
+ // Use process.nextTick to defer processing to next event loop iteration
371
+ process.nextTick(async () => {
372
+ for (let i = 0; i < attachments.length; i++) {
373
+ const attachment = attachments[i];
374
+ if (attachment.path) {
375
+ try {
376
+ console.log(`📤 Processing ${attachment.name} asynchronously...`);
377
+ let s3Data = null;
378
+
379
+ // Upload to S3 if enabled
380
+ if (this.s3Enabled && this.s3Client) {
381
+ s3Data = await this.uploadArtifactToS3(attachment.path, testId, attachment.name);
382
+ console.log(`✅ S3 upload completed for ${attachment.name}`);
383
+ }
384
+
385
+ const artifactData = {
386
+ testId,
387
+ type: this.getArtifactType(attachment.name),
388
+ path: attachment.path,
389
+ name: attachment.name,
390
+ contentType: attachment.contentType,
391
+ fileSize: s3Data ? s3Data.fileSize : this.getFileSize(attachment.path),
392
+ ...(s3Data && {
393
+ s3Key: s3Data.key,
394
+ s3Url: s3Data.url,
395
+ presignedUrl: s3Data.presignedUrl,
396
+ storageType: 's3'
397
+ })
398
+ };
399
+
400
+ // Send artifact data to API
401
+ await this.sendToApi({
402
+ type: 'artifact',
403
+ runId: this.runId,
404
+ timestamp: new Date().toISOString(),
405
+ artifact: artifactData
406
+ });
407
+
408
+ console.log(`📎 Processed artifact: ${attachment.name}${s3Data ? ' (uploaded to S3)' : ''}`);
409
+ } catch (error) {
410
+ console.error(`❌ Failed to process ${attachment.name}:`, error.message);
411
+ }
412
+ }
413
+ }
414
+ });
415
+ }
416
+ }
417
+
418
+ async sendSpecCodeBlocks(specPath) {
419
+ try {
420
+ // Extract code blocks using built-in parser
421
+ const testBlocks = this.extractTestBlocks(specPath);
422
+
423
+ // Transform blocks to match backend API expectations
424
+ const codeBlocks = testBlocks.map(block => ({
425
+ type: block.type, // 'test' or 'describe'
426
+ name: block.name, // test/describe name
427
+ content: block.content, // full code content
428
+ summary: null, // optional
429
+ describe: block.describe // parent describe block name
430
+ }));
431
+
432
+ // Send to dedicated spec code blocks API endpoint
433
+ const baseUrl = this.config.apiEndpoint.replace('/webhook/playwright', '');
434
+ const specEndpoint = `${baseUrl}/webhook/playwright/spec-code-blocks`;
435
+
436
+ await this.axiosInstance.post(specEndpoint, {
437
+ filePath: path.relative(process.cwd(), specPath),
438
+ codeBlocks,
439
+ testSuiteName: path.basename(specPath).replace(/\.(spec|test)\.(js|ts)$/, '')
440
+ });
441
+
442
+ console.log(`📝 Sent ${codeBlocks.length} code blocks for: ${path.basename(specPath)}`);
443
+ } catch (error) {
444
+ console.error('Failed to send spec code blocks:', error?.response?.data || error?.message || 'Unknown error');
445
+ }
446
+ }
447
+
448
+ extractTestBlocks(filePath) {
449
+ try {
450
+ const content = fs.readFileSync(filePath, 'utf-8');
451
+ const blocks = [];
452
+ const lines = content.split('\n');
453
+
454
+ let currentDescribe = null;
455
+ let braceCount = 0;
456
+ let inBlock = false;
457
+ let blockStart = -1;
458
+ let blockType = '';
459
+ let blockName = '';
460
+
461
+ for (let i = 0; i < lines.length; i++) {
462
+ const line = lines[i];
463
+ const trimmedLine = line.trim();
464
+
465
+ // Check for describe blocks
466
+ const describeMatch = trimmedLine.match(/describe\s*\(\s*['"`]([^'"`]+)['"`]/);
467
+ if (describeMatch) {
468
+ currentDescribe = describeMatch[1];
469
+ }
470
+
471
+ // Check for test blocks
472
+ const testMatch = trimmedLine.match(/test\s*\(\s*['"`]([^'"`]+)['"`]/);
473
+ if (testMatch && !inBlock) {
474
+ blockType = 'test';
475
+ blockName = testMatch[1];
476
+ blockStart = i;
477
+ braceCount = 0;
478
+ inBlock = true;
479
+ }
480
+
481
+ // Count braces when in a block
482
+ if (inBlock) {
483
+ for (const char of line) {
484
+ if (char === '{') braceCount++;
485
+ if (char === '}') braceCount--;
486
+
487
+ if (braceCount === 0 && blockStart !== -1 && i > blockStart) {
488
+ // End of block found
489
+ const blockContent = lines.slice(blockStart, i + 1).join('\n');
490
+
491
+ blocks.push({
492
+ type: blockType,
493
+ name: blockName,
494
+ content: blockContent,
495
+ describe: currentDescribe,
496
+ startLine: blockStart + 1,
497
+ endLine: i + 1
498
+ });
499
+
500
+ inBlock = false;
501
+ blockStart = -1;
502
+ break;
503
+ }
504
+ }
505
+ }
506
+ }
507
+
508
+ return blocks;
509
+ } catch (error) {
510
+ console.error(`Failed to extract test blocks from ${filePath}:`, error.message);
511
+ return [];
512
+ }
513
+ }
514
+
515
+ async collectGitInfo() {
516
+ try {
517
+ const { execSync } = require('child_process');
518
+
519
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
520
+ const commit = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
521
+ const shortCommit = commit.substring(0, 7);
522
+ const author = execSync('git log -1 --pretty=format:"%an"', { encoding: 'utf-8' }).trim();
523
+ const commitMessage = execSync('git log -1 --pretty=format:"%s"', { encoding: 'utf-8' }).trim();
524
+ const commitTimestamp = execSync('git log -1 --pretty=format:"%ci"', { encoding: 'utf-8' }).trim();
525
+
526
+ let remoteName = 'origin';
527
+ let remoteUrl = '';
528
+ try {
529
+ const remotes = execSync('git remote', { encoding: 'utf-8' }).trim();
530
+ if (remotes) {
531
+ remoteName = remotes.split('\n')[0] || 'origin';
532
+ remoteUrl = execSync(`git remote get-url ${remoteName}`, { encoding: 'utf-8' }).trim();
533
+ }
534
+ } catch (e) {
535
+ // Remote info is optional - handle gracefully
536
+ console.log('ℹ️ No git remote configured, skipping remote info');
537
+ }
538
+
539
+ const isDirty = execSync('git status --porcelain', { encoding: 'utf-8' }).trim().length > 0;
540
+
541
+ return {
542
+ branch,
543
+ commit,
544
+ shortCommit,
545
+ author,
546
+ commitMessage,
547
+ commitTimestamp,
548
+ isDirty,
549
+ remoteName,
550
+ remoteUrl
551
+ };
552
+ } catch (error) {
553
+ console.warn('Could not collect Git information:', error.message);
554
+ return null;
555
+ }
556
+ }
557
+
558
+ getArtifactType(name) {
559
+ if (name.includes('screenshot')) return 'screenshot';
560
+ if (name.includes('video')) return 'video';
561
+ if (name.includes('trace')) return 'trace';
562
+ return 'attachment';
563
+ }
564
+
565
+ extractTags(test) {
566
+ const tags = [];
567
+
568
+ test.annotations.forEach(annotation => {
569
+ if (annotation.type === 'tag' && annotation.description) {
570
+ tags.push(annotation.description);
571
+ }
572
+ });
573
+
574
+ const tagMatches = test.title.match(/@[\w-]+/g);
575
+ if (tagMatches) {
576
+ tags.push(...tagMatches);
577
+ }
578
+
579
+ return tags;
580
+ }
581
+
582
+ getTestId(test) {
583
+ const cleanTitle = test.title.replace(/@[\w-]+/g, '').trim();
584
+ return `${test.location.file}:${test.location.line}:${cleanTitle}`;
585
+ }
586
+
587
+ async uploadArtifactToS3(filePath, testId, fileName) {
588
+ console.log(`🔄 Starting S3 upload for ${fileName}...`);
589
+ try {
590
+ const fileContent = fs.readFileSync(filePath);
591
+ const fileSize = fileContent.length;
592
+ console.log(`📏 File size: ${fileSize} bytes`);
593
+
594
+ // Get content type based on file extension
595
+ const ext = path.extname(filePath).toLowerCase();
596
+ const contentType = this.getContentType(ext);
597
+ console.log(`📄 Content type: ${contentType}`);
598
+
599
+ // Generate S3 key
600
+ const s3Key = this.generateS3Key(this.runId, testId, fileName);
601
+ console.log(`🔑 S3 key: ${s3Key}`);
602
+
603
+ const uploadParams = {
604
+ Bucket: EMBEDDED_CONFIG.S3_BUCKET_NAME,
605
+ Key: s3Key,
606
+ Body: fileContent,
607
+ ContentType: contentType,
608
+ ACL: EMBEDDED_CONFIG.S3_ACL || 'private',
609
+ ServerSideEncryption: process.env.S3_SSE || 'AES256'
610
+ };
611
+
612
+ console.log(`☁️ Uploading to S3 bucket: ${EMBEDDED_CONFIG.S3_BUCKET_NAME}`);
613
+ await this.s3Client.send(new PutObjectCommand(uploadParams));
614
+ console.log(`✅ S3 upload completed for ${fileName}`);
615
+
616
+ // Generate presigned URL for frontend access (expires in 7 days)
617
+ console.log(`🔗 Generating presigned URL for ${fileName}...`);
618
+ const getCommand = new GetObjectCommand({
619
+ Bucket: EMBEDDED_CONFIG.S3_BUCKET_NAME,
620
+ Key: s3Key
621
+ });
622
+
623
+ const presignedUrl = await getSignedUrl(this.s3Client, getCommand, {
624
+ expiresIn: 604800 // 7 days
625
+ });
626
+ console.log(`✅ Presigned URL generated for ${fileName}`);
627
+
628
+ const s3Url = `https://${EMBEDDED_CONFIG.S3_BUCKET_NAME}.s3.${EMBEDDED_CONFIG.AWS_REGION || 'us-east-1'}.amazonaws.com/${s3Key}`;
629
+
630
+ return {
631
+ key: s3Key,
632
+ url: s3Url,
633
+ presignedUrl: presignedUrl,
634
+ fileSize,
635
+ contentType
636
+ };
637
+ } catch (error) {
638
+ console.error(`❌ Failed to upload ${fileName} to S3:`, error.message);
639
+ console.error(`❌ Error details:`, error);
640
+ throw error;
641
+ }
642
+ }
643
+
644
+ generateS3Key(runId, testId, fileName) {
645
+ const date = new Date().toISOString().slice(0, 10);
646
+ const safeTestId = this.sanitizeForS3(testId);
647
+ const safeFileName = this.sanitizeForS3(fileName);
648
+ const ext = path.extname(fileName);
649
+ const baseName = path.basename(fileName, ext);
650
+
651
+ return `test-artifacts/${date}/${runId}/${safeTestId}/${safeFileName}${ext}`;
652
+ }
653
+
654
+ sanitizeForS3(value) {
655
+ return value
656
+ .replace(/\\/g, '/') // Convert Windows backslashes to forward slashes first
657
+ .replace(/[\/:*?"<>|]/g, '-')
658
+ .replace(/[\x00-\x1f\x7f]/g, '-')
659
+ .replace(/[^ -~]/g, '-')
660
+ .replace(/\s+/g, '-')
661
+ .replace(/[_]/g, '-')
662
+ .replace(/-+/g, '-')
663
+ .replace(/^-|-$/g, '')
664
+ .replace(/\/{2,}/g, '/'); // Remove multiple consecutive slashes
665
+ }
666
+
667
+ getContentType(ext) {
668
+ const contentTypes = {
669
+ '.mp4': 'video/mp4',
670
+ '.webm': 'video/webm',
671
+ '.png': 'image/png',
672
+ '.jpg': 'image/jpeg',
673
+ '.jpeg': 'image/jpeg',
674
+ '.gif': 'image/gif',
675
+ '.json': 'application/json',
676
+ '.txt': 'text/plain',
677
+ '.html': 'text/html',
678
+ '.xml': 'application/xml',
679
+ '.zip': 'application/zip',
680
+ '.pdf': 'application/pdf'
681
+ };
682
+ return contentTypes[ext] || 'application/octet-stream';
683
+ }
684
+
685
+ getFileSize(filePath) {
686
+ try {
687
+ const stats = fs.statSync(filePath);
688
+ return stats.size;
689
+ } catch (error) {
690
+ console.warn(`Could not get file size for ${filePath}:`, error.message);
691
+ return 0;
692
+ }
693
+ }
694
+ }
695
+
696
+ module.exports = TestLensReporter;