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