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