@alternative-path/testlens-playwright-reporter 0.4.6 → 0.4.8

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 CHANGED
@@ -1,1587 +1,1714 @@
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 type { Reporter, TestCase, TestResult, FullConfig, Suite } from '@playwright/test/reporter';
8
- import { execSync } from 'child_process';
9
- import pino from 'pino';
10
-
11
- const isVerboseLogs =
12
- process.env.TESTLENS_LOG_VERBOSE === '1' ||
13
- process.env.TESTLENS_LOG_VERBOSE === 'true';
14
- const noisyInfoPrefixes = [
15
- '[OK]',
16
- '[UPLOAD]',
17
- '[ARTIFACT]',
18
- '[SKIP]',
19
- '[TestLens] onTestEnd called',
20
- '[TestLens] Sending testEnd'
21
- ];
22
- const noisyInfoSubstrings = [
23
- 'Sent runStart event to TestLens',
24
- 'Sent specStart event to TestLens',
25
- 'Sent testStart event to TestLens',
26
- 'Sent testEnd event to TestLens',
27
- 'Sent runEnd event to TestLens',
28
- 'Sent artifact event to TestLens',
29
- 'S3 upload completed',
30
- 'Upload confirmed',
31
- 'Uploading ',
32
- 'Processed artifact',
33
- 'Spec code blocks',
34
- '\n[METADATA]',
35
- 'Custom Metadata Detected',
36
- 'Using API key from environment variable'
37
- ];
38
-
39
- const shouldSuppressInfo = (msg: string): boolean => {
40
- if (isVerboseLogs) return false;
41
- if (!msg) return false;
42
- return (
43
- noisyInfoPrefixes.some(prefix => msg.startsWith(prefix)) ||
44
- noisyInfoSubstrings.some(sub => msg.includes(sub))
45
- );
46
- };
47
-
48
- // Create pino logger instance
49
- const logger = pino({
50
- level: process.env.TESTLENS_LOG_LEVEL || process.env.LOG_LEVEL || 'info',
51
- base: null, // remove pid/hostname
52
- timestamp: false, // remove time
53
- formatters: {
54
- level: () => ({}) // remove level
55
- },
56
- hooks: {
57
- logMethod(args, method, level) {
58
- // pino level is numeric (info = 30)
59
- if (level === 30) {
60
- const msg =
61
- typeof args[0] === 'string'
62
- ? args[0]
63
- : typeof args[1] === 'string'
64
- ? args[1]
65
- : '';
66
- if (shouldSuppressInfo(msg)) {
67
- return;
68
- }
69
- }
70
- return method.apply(this, args as any);
71
- }
72
- }
73
- });
74
-
75
- // Lazy-load mime module to support ESM
76
- let mimeModule: any = null;
77
- async function getMime() {
78
- if (!mimeModule) {
79
- const imported = await import('mime');
80
- // Handle both default export and named exports
81
- mimeModule = imported.default || imported;
82
- }
83
- return mimeModule;
84
- }
85
-
86
- export interface TestLensReporterConfig {
87
- /** TestLens API endpoint URL */
88
- apiEndpoint?: string;
89
- /** API key for authentication - can be provided in config or via TESTLENS_API_KEY environment variable */
90
- apiKey?: string;
91
- /** Enable real-time streaming of test events */
92
- enableRealTimeStream?: boolean;
93
- /** Enable Git information collection */
94
- enableGitInfo?: boolean;
95
- /** Enable artifact processing */
96
- enableArtifacts?: boolean;
97
- /** Enable video capture - defaults to true */
98
- enableVideo?: boolean;
99
- /** Enable screenshot capture - defaults to true */
100
- enableScreenshot?: boolean;
101
- /** Batch size for API requests */
102
- batchSize?: number;
103
- /** Flush interval in milliseconds */
104
- flushInterval?: number;
105
- /** Number of retry attempts for failed API calls */
106
- retryAttempts?: number;
107
- /** Request timeout in milliseconds */
108
- timeout?: number;
109
- /** SSL certificate validation - set to false to disable SSL verification */
110
- rejectUnauthorized?: boolean;
111
- /** Alternative SSL option - set to true to ignore SSL certificate errors */
112
- ignoreSslErrors?: boolean;
113
- /** Custom metadata from CLI arguments (automatically parsed from --key=value arguments) */
114
- customMetadata?: Record<string, string | string[]>;
115
- }
116
-
117
- export interface TestLensReporterOptions {
118
- /** TestLens API endpoint URL */
119
- apiEndpoint?: string;
120
- /** API key for authentication - can be provided in config or via TESTLENS_API_KEY environment variable */
121
- apiKey?: string;
122
- /** Enable real-time streaming of test events */
123
- enableRealTimeStream?: boolean;
124
- /** Enable Git information collection */
125
- enableGitInfo?: boolean;
126
- /** Enable artifact processing */
127
- enableArtifacts?: boolean;
128
- /** Enable video capture - defaults to true */
129
- enableVideo?: boolean;
130
- /** Enable screenshot capture - defaults to true */
131
- enableScreenshot?: boolean;
132
- /** Batch size for API requests */
133
- batchSize?: number;
134
- /** Flush interval in milliseconds */
135
- flushInterval?: number;
136
- /** Number of retry attempts for failed API calls */
137
- retryAttempts?: number;
138
- /** Request timeout in milliseconds */
139
- timeout?: number;
140
- /** SSL certificate validation - set to false to disable SSL verification */
141
- rejectUnauthorized?: boolean;
142
- /** Alternative SSL option - set to true to ignore SSL certificate errors */
143
- ignoreSslErrors?: boolean;
144
- /** Custom metadata from CLI arguments (automatically parsed from --key=value arguments) */
145
- customMetadata?: Record<string, string | string[]>;
146
- }
147
-
148
- export interface GitInfo {
149
- branch: string;
150
- commit: string;
151
- shortCommit: string;
152
- author: string;
153
- message: string;
154
- timestamp: string;
155
- isDirty: boolean;
156
- remoteName: string;
157
- remoteUrl: string;
158
- }
159
-
160
- export interface CodeBlock {
161
- type: 'test' | 'describe';
162
- name: string;
163
- content: string;
164
- summary?: string;
165
- describe?: string;
166
- startLine?: number;
167
- endLine?: number;
168
- }
169
-
170
- export interface RunMetadata {
171
- id: string;
172
- startTime: string;
173
- endTime?: string;
174
- duration?: number;
175
- environment: string;
176
- browser: string;
177
- os: string;
178
- playwrightVersion: string;
179
- nodeVersion: string;
180
- testlensVersion?: string;
181
- gitInfo?: GitInfo | null;
182
- shardInfo?: {
183
- current: number;
184
- total: number;
185
- };
186
- totalTests?: number;
187
- passedTests?: number;
188
- failedTests?: number;
189
- skippedTests?: number;
190
- status?: string;
191
- testlensBuildName?: string;
192
- customMetadata?: Record<string, string | string[]>;
193
- }
194
-
195
- export interface TestError {
196
- message: string;
197
- stack?: string;
198
- location?: {
199
- file: string;
200
- line: number;
201
- column: number;
202
- };
203
- snippet?: string;
204
- expected?: string;
205
- actual?: string;
206
- diff?: string;
207
- matcherName?: string;
208
- timeout?: number;
209
- }
210
-
211
- export interface TestData {
212
- id: string;
213
- name: string;
214
- status: string;
215
- originalStatus?: string; // Preserve original Playwright status
216
- duration: number;
217
- startTime: string;
218
- endTime: string;
219
- errorMessages: string[];
220
- errors?: TestError[]; // Rich error details
221
- retryAttempts: number;
222
- currentRetry: number;
223
- annotations: Array<{ type: string; description?: string }>;
224
- projectName: string;
225
- workerIndex?: number;
226
- parallelIndex?: number;
227
- location?: {
228
- file: string;
229
- line: number;
230
- column: number;
231
- };
232
- }
233
-
234
- export interface SpecData {
235
- filePath: string;
236
- testSuiteName: string;
237
- tags?: string[];
238
- startTime: string;
239
- endTime?: string;
240
- status: string;
241
- }
242
-
243
- export class TestLensReporter implements Reporter {
244
- private config: Required<TestLensReporterConfig>;
245
- private axiosInstance: AxiosInstance;
246
- private runId: string;
247
- private runMetadata: RunMetadata;
248
- private specMap: Map<string, SpecData>;
249
- private testMap: Map<string, TestData>;
250
- private runCreationFailed: boolean = false; // Track if run creation failed due to limits
251
- private cliArgs: Record<string, any> = {}; // Store CLI args separately
252
- private pendingUploads: Set<Promise<any>> = new Set(); // Track pending artifact uploads
253
- private artifactStats = {
254
- uploaded: { screenshot: 0, video: 0, trace: 0, attachment: 0 },
255
- skipped: { screenshot: 0, video: 0, trace: 0, attachment: 0 },
256
- failed: { screenshot: 0, video: 0, trace: 0, attachment: 0 }
257
- };
258
- private artifactsSeen: number = 0;
259
-
260
- /**
261
- * Parse custom metadata from environment variables
262
- * Checks for common metadata environment variables
263
- */
264
- private static parseCustomArgs(): Record<string, any> {
265
- const customArgs: Record<string, any> = {};
266
-
267
- // Common environment variable names for build metadata
268
- const envVarMappings: Record<string, string[]> = {
269
- // Support both TestLens-specific names (recommended) and common CI names
270
- 'testlensBuildTag': ['testlensBuildTag', 'TESTLENS_BUILD_TAG', 'TESTLENS_BUILDTAG', 'BUILDTAG', 'BUILD_TAG', 'TestlensBuildTag', 'TestLensBuildTag'],
271
- 'testlensBuildName': ['testlensBuildName', 'TESTLENS_BUILD_NAME', 'TESTLENS_BUILDNAME', 'BUILDNAME', 'BUILD_NAME', 'TestlensBuildName', 'TestLensBuildName'],
272
- 'environment': ['ENVIRONMENT', 'ENV', 'NODE_ENV', 'DEPLOYMENT_ENV'],
273
- 'branch': ['BRANCH', 'GIT_BRANCH', 'CI_COMMIT_BRANCH', 'GITHUB_REF_NAME'],
274
- 'team': ['TEAM', 'TEAM_NAME'],
275
- 'project': ['PROJECT', 'PROJECT_NAME'],
276
- 'customvalue': ['CUSTOMVALUE', 'CUSTOM_VALUE']
277
- };
278
-
279
- // Check for each metadata key
280
- Object.entries(envVarMappings).forEach(([key, envVars]) => {
281
- for (const envVar of envVars) {
282
- const value = process.env[envVar];
283
- if (value) {
284
- // For testlensBuildTag, support comma-separated values
285
- if (key === 'testlensBuildTag' && value.includes(',')) {
286
- customArgs[key] = value.split(',').map(tag => tag.trim()).filter(tag => tag);
287
- logger.info(`✓ Found ${envVar}=${value} (mapped to '${key}' as array of ${customArgs[key].length} tags)`);
288
- } else {
289
- customArgs[key] = value;
290
- logger.info(`✓ Found ${envVar}=${value} (mapped to '${key}')`);
291
- }
292
- break; // Use first match
293
- }
294
- }
295
- });
296
-
297
- return customArgs;
298
- }
299
-
300
- constructor(options: TestLensReporterOptions) {
301
- // Parse custom CLI arguments
302
- const customArgs = TestLensReporter.parseCustomArgs();
303
- this.cliArgs = customArgs; // Store CLI args separately for later use
304
-
305
- // Allow API key from environment variable if not provided in config
306
- // Check multiple environment variable names in priority order (uppercase and lowercase)
307
- const apiKey = options.apiKey
308
- || process.env.TESTLENS_API_KEY
309
- || process.env.testlens_api_key
310
- || process.env.TESTLENS_KEY
311
- || process.env.testlens_key
312
- || process.env.testlensApiKey
313
- || process.env.PLAYWRIGHT_API_KEY
314
- || process.env.playwright_api_key
315
- || process.env.PW_API_KEY
316
- || process.env.pw_api_key;
317
-
318
- this.config = {
319
- apiEndpoint: options.apiEndpoint || 'https://testlens.qa-path.com/api/v1/webhook/playwright',
320
- apiKey: apiKey, // API key from config or environment variable
321
- enableRealTimeStream: options.enableRealTimeStream !== undefined ? options.enableRealTimeStream : true,
322
- enableGitInfo: options.enableGitInfo !== undefined ? options.enableGitInfo : true,
323
- enableArtifacts: options.enableArtifacts !== undefined ? options.enableArtifacts : true,
324
- enableVideo: options.enableVideo !== undefined ? options.enableVideo : true, // Default to true, override from config
325
- enableScreenshot: options.enableScreenshot !== undefined ? options.enableScreenshot : true, // Default to true, override from config
326
- batchSize: options.batchSize || 10,
327
- flushInterval: options.flushInterval || 5000,
328
- retryAttempts: options.retryAttempts !== undefined ? options.retryAttempts : 0,
329
- timeout: options.timeout || 60000,
330
- rejectUnauthorized: options.rejectUnauthorized,
331
- ignoreSslErrors: options.ignoreSslErrors,
332
- customMetadata: { ...options.customMetadata, ...customArgs } // Config metadata first, then CLI args override
333
- } as Required<TestLensReporterConfig>;
334
-
335
- if (!this.config.apiKey) {
336
- 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.');
337
- }
338
-
339
- if (apiKey !== options.apiKey) {
340
- logger.info('✓ Using API key from environment variable');
341
- }
342
-
343
- // Default environment to allow self-signed certs unless explicitly set
344
- if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === undefined) {
345
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
346
- }
347
-
348
- // Determine SSL validation behavior
349
- let rejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0'; // Default to secure unless explicitly disabled
350
-
351
- // Check various ways SSL validation can be disabled or enforced (in order of precedence)
352
- if (this.config.ignoreSslErrors === true) {
353
- rejectUnauthorized = false;
354
- logger.warn('[WARN] SSL certificate validation disabled via ignoreSslErrors option');
355
- } else if (this.config.rejectUnauthorized === false) {
356
- rejectUnauthorized = false;
357
- logger.warn('[WARN] SSL certificate validation disabled via rejectUnauthorized option');
358
- } else if (this.config.rejectUnauthorized === true) {
359
- rejectUnauthorized = true;
360
- } else if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') {
361
- rejectUnauthorized = false;
362
- logger.warn('[WARN] SSL certificate validation disabled via NODE_TLS_REJECT_UNAUTHORIZED environment variable');
363
- } else if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '1') {
364
- rejectUnauthorized = true;
365
- }
366
-
367
- // Mirror the resolved value so all HTTPS requests in this process follow it
368
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = rejectUnauthorized ? '1' : '0';
369
-
370
- // Set up axios instance with retry logic and enhanced SSL handling
371
- this.axiosInstance = axios.create({
372
- baseURL: this.config.apiEndpoint,
373
- timeout: this.config.timeout,
374
- headers: {
375
- 'Content-Type': 'application/json',
376
- ...(this.config.apiKey && { 'X-API-Key': this.config.apiKey }),
377
- },
378
- // Enhanced SSL handling with flexible TLS configuration
379
- httpsAgent: new https.Agent({
380
- rejectUnauthorized: rejectUnauthorized,
381
- // Allow any TLS version for better compatibility
382
- minVersion: 'TLSv1.2',
383
- maxVersion: 'TLSv1.3'
384
- })
385
- });
386
-
387
- // Add retry interceptor
388
- this.axiosInstance.interceptors.response.use(
389
- (response) => response,
390
- async (error: any) => {
391
- const originalRequest = error.config;
392
-
393
- if (!originalRequest._retry && error.response?.status >= 500) {
394
- originalRequest._retry = true;
395
- originalRequest._retryCount = (originalRequest._retryCount || 0) + 1;
396
-
397
- if (originalRequest._retryCount <= this.config.retryAttempts) {
398
- // Exponential backoff
399
- const delay = Math.pow(2, originalRequest._retryCount) * 1000;
400
- await new Promise(resolve => setTimeout(resolve, delay));
401
- return this.axiosInstance(originalRequest);
402
- }
403
- }
404
-
405
- return Promise.reject(error);
406
- }
407
- );
408
-
409
- this.runId = randomUUID();
410
- this.runMetadata = this.initializeRunMetadata();
411
- this.specMap = new Map<string, SpecData>();
412
- this.testMap = new Map<string, TestData>();
413
- this.runCreationFailed = false;
414
-
415
- // Log custom metadata if any
416
- if (this.config.customMetadata && Object.keys(this.config.customMetadata).length > 0) {
417
- logger.info('\n[METADATA] Custom Metadata Detected:');
418
- Object.entries(this.config.customMetadata).forEach(([key, value]) => {
419
- logger.info(` ${key}: ${value}`);
420
- });
421
- logger.info('');
422
- }
423
- }
424
-
425
- private initializeRunMetadata(): RunMetadata {
426
- const metadata: RunMetadata = {
427
- id: this.runId,
428
- startTime: new Date().toISOString(),
429
- environment: 'production',
430
- browser: 'multiple',
431
- os: `${os.type()} ${os.release()}`,
432
- playwrightVersion: this.getPlaywrightVersion(),
433
- nodeVersion: process.version,
434
- testlensVersion: this.getTestLensVersion()
435
- };
436
-
437
- // Add custom metadata if provided
438
- if (this.config.customMetadata && Object.keys(this.config.customMetadata).length > 0) {
439
- metadata.customMetadata = this.config.customMetadata;
440
-
441
- // Extract testlensBuildName as a dedicated field for dashboard display
442
- if (this.config.customMetadata.testlensBuildName) {
443
- const buildName = this.config.customMetadata.testlensBuildName;
444
- // Handle both string and array (take first element if array)
445
- metadata.testlensBuildName = Array.isArray(buildName) ? buildName[0] : buildName;
446
- }
447
- }
448
-
449
- return metadata;
450
- }
451
-
452
- private getPlaywrightVersion(): string {
453
- try {
454
- const playwrightPackage = require('@playwright/test/package.json');
455
- return playwrightPackage.version;
456
- } catch (error) {
457
- return 'unknown';
458
- }
459
- }
460
-
461
- private getTestLensVersion(): string {
462
- try {
463
- const testlensPackage = require('./package.json');
464
- return testlensPackage.version;
465
- } catch (error) {
466
- return 'unknown';
467
- }
468
- }
469
-
470
- private normalizeTestStatus(status: string): string {
471
- // Treat timeout as failed for consistency with analytics
472
- if (status === 'timedOut') {
473
- return 'failed';
474
- }
475
- return status;
476
- }
477
-
478
- private normalizeRunStatus(status: string, hasTimeouts: boolean): string {
479
- // If run has timeouts, treat as failed
480
- if (hasTimeouts && status === 'passed') {
481
- return 'failed';
482
- }
483
- // Treat timeout status as failed
484
- if (status === 'timedOut') {
485
- return 'failed';
486
- }
487
- return status;
488
- }
489
-
490
- async onBegin(config: FullConfig, suite: Suite): Promise<void> {
491
- logger.info(`[REPORTER] source=${__filename} enableArtifacts=${this.config.enableArtifacts}`);
492
- // Show Build Name if provided, otherwise show Run ID
493
- if (this.runMetadata.testlensBuildName) {
494
- logger.info(`TestLens Reporter starting - Build: ${this.runMetadata.testlensBuildName}`);
495
- logger.info(` Run ID: ${this.runId}`);
496
- } else {
497
- logger.info(`TestLens Reporter starting - Run ID: ${this.runId}`);
498
- }
499
-
500
- // Collect Git information if enabled
501
- if (this.config.enableGitInfo) {
502
- this.runMetadata.gitInfo = await this.collectGitInfo();
503
- if (this.runMetadata.gitInfo) {
504
- logger.info(`Git info collected: branch=${this.runMetadata.gitInfo.branch}, commit=${this.runMetadata.gitInfo.shortCommit}, author=${this.runMetadata.gitInfo.author}`);
505
- } else {
506
- logger.warn(`[WARN] Git info collection returned null - not in a git repository or git not available`);
507
- }
508
- } else {
509
- logger.info(`[INFO] Git info collection disabled (enableGitInfo: false)`);
510
- }
511
-
512
- // Add shard information if available
513
- if (config.shard) {
514
- this.runMetadata.shardInfo = {
515
- current: config.shard.current,
516
- total: config.shard.total
517
- };
518
- }
519
-
520
- // Send run start event to API
521
- await this.sendToApi({
522
- type: 'runStart',
523
- runId: this.runId,
524
- timestamp: new Date().toISOString(),
525
- metadata: this.runMetadata
526
- });
527
- }
528
-
529
- async onTestBegin(test: TestCase, result: TestResult): Promise<void> {
530
- // Log which test is starting
531
- logger.info(`[TEST] Running test: ${test.title}`);
532
-
533
- const specPath = test.location.file;
534
- const specKey = `${specPath}-${test.parent.title}`;
535
-
536
- // Create or update spec data
537
- if (!this.specMap.has(specKey)) {
538
- const extractedTags = this.extractTags(test);
539
- const specData: SpecData = {
540
- filePath: path.relative(process.cwd(), specPath),
541
- testSuiteName: test.parent.title,
542
- startTime: new Date().toISOString(),
543
- status: 'running'
544
- };
545
- if (extractedTags.length > 0) {
546
- specData.tags = extractedTags;
547
- }
548
- this.specMap.set(specKey, specData);
549
-
550
- // Send spec start event to API
551
- await this.sendToApi({
552
- type: 'specStart',
553
- runId: this.runId,
554
- timestamp: new Date().toISOString(),
555
- spec: specData
556
- });
557
- }
558
-
559
- const testId = this.getTestId(test);
560
-
561
- // Only send testStart event on first attempt (retry 0)
562
- if (result.retry === 0) {
563
- // Create test data
564
- const testData: TestData = {
565
- id: testId,
566
- name: test.title,
567
- status: 'running',
568
- originalStatus: 'running',
569
- duration: 0,
570
- startTime: new Date().toISOString(),
571
- endTime: '',
572
- errorMessages: [],
573
- errors: [],
574
- retryAttempts: test.retries,
575
- currentRetry: result.retry,
576
- annotations: test.annotations.map((ann: any) => ({
577
- type: ann.type,
578
- description: ann.description
579
- })),
580
- projectName: test.parent.project()?.name || 'default',
581
- workerIndex: result.workerIndex,
582
- parallelIndex: result.parallelIndex,
583
- location: {
584
- file: path.relative(process.cwd(), test.location.file),
585
- line: test.location.line,
586
- column: test.location.column
587
- }
588
- };
589
-
590
- this.testMap.set(testData.id, testData);
591
-
592
- // Send test start event to API
593
- await this.sendToApi({
594
- type: 'testStart',
595
- runId: this.runId,
596
- timestamp: new Date().toISOString(),
597
- test: testData
598
- });
599
- } else {
600
- // For retries, just update the existing test data
601
- const existingTestData = this.testMap.get(testId);
602
- if (existingTestData) {
603
- existingTestData.currentRetry = result.retry;
604
- }
605
- }
606
- }
607
-
608
- async onTestEnd(test: TestCase, result: TestResult): Promise<void> {
609
- const testId = this.getTestId(test);
610
- let testData = this.testMap.get(testId);
611
-
612
- logger.info(`[ARTIFACTS] attachments=${result.attachments?.length ?? 0}`);
613
-
614
- if (result.attachments && result.attachments.length > 0) {
615
- for (const attachment of result.attachments) {
616
- const artifactType = this.getArtifactType(attachment.name);
617
- this.artifactsSeen += 1;
618
- if (attachment.path) {
619
- this.bumpArtifactStat('uploaded', artifactType);
620
- } else {
621
- this.bumpArtifactStat('skipped', artifactType);
622
- }
623
- }
624
- }
625
-
626
- logger.info(`[TestLens] onTestEnd called for test: ${test.title}, status: ${result.status}, testData exists: ${!!testData}`);
627
-
628
- // For skipped tests, onTestBegin might not be called, so we need to create the test data here
629
- if (!testData) {
630
- logger.info(`[TestLens] Creating test data for skipped/uncreated test: ${test.title}`);
631
- // Create spec data if not exists (skipped tests might not have spec data either)
632
- const specPath = test.location.file;
633
- const specKey = `${specPath}-${test.parent.title}`;
634
-
635
- if (!this.specMap.has(specKey)) {
636
- const extractedTags = this.extractTags(test);
637
- const specData: SpecData = {
638
- filePath: path.relative(process.cwd(), specPath),
639
- testSuiteName: test.parent.title,
640
- startTime: new Date().toISOString(),
641
- status: 'skipped'
642
- };
643
- if (extractedTags.length > 0) {
644
- specData.tags = extractedTags;
645
- }
646
- this.specMap.set(specKey, specData);
647
-
648
- // Send spec start event to API
649
- await this.sendToApi({
650
- type: 'specStart',
651
- runId: this.runId,
652
- timestamp: new Date().toISOString(),
653
- spec: specData
654
- });
655
- }
656
-
657
- // Create test data for skipped test
658
- testData = {
659
- id: testId,
660
- name: test.title,
661
- status: 'skipped',
662
- originalStatus: 'skipped',
663
- duration: 0,
664
- startTime: new Date().toISOString(),
665
- endTime: new Date().toISOString(),
666
- errorMessages: [],
667
- errors: [],
668
- retryAttempts: test.retries,
669
- currentRetry: 0,
670
- annotations: test.annotations.map((ann: any) => ({
671
- type: ann.type,
672
- description: ann.description
673
- })),
674
- projectName: test.parent.project()?.name || 'default',
675
- workerIndex: result.workerIndex,
676
- parallelIndex: result.parallelIndex,
677
- location: {
678
- file: path.relative(process.cwd(), test.location.file),
679
- line: test.location.line,
680
- column: test.location.column
681
- }
682
- };
683
-
684
- this.testMap.set(testId, testData);
685
-
686
- // Send test start event first (so the test gets created in DB)
687
- await this.sendToApi({
688
- type: 'testStart',
689
- runId: this.runId,
690
- timestamp: new Date().toISOString(),
691
- test: testData
692
- });
693
- }
694
-
695
- if (testData) {
696
- // Update test data with latest result
697
- testData.originalStatus = result.status;
698
- testData.status = this.normalizeTestStatus(result.status);
699
- testData.duration = result.duration;
700
- testData.endTime = new Date().toISOString();
701
- testData.errorMessages = result.errors.map((error: any) => error.message || error.toString());
702
- testData.currentRetry = result.retry;
703
-
704
- // Capture test location
705
- testData.location = {
706
- file: path.relative(process.cwd(), test.location.file),
707
- line: test.location.line,
708
- column: test.location.column
709
- };
710
-
711
- // Capture rich error details like Playwright's HTML report
712
- testData.errors = result.errors.map((error: any) => {
713
- const testError: TestError = {
714
- message: error.message || error.toString()
715
- };
716
-
717
- // Capture stack trace
718
- if (error.stack) {
719
- testError.stack = error.stack;
720
- }
721
-
722
- // Capture error location
723
- if (error.location) {
724
- testError.location = {
725
- file: path.relative(process.cwd(), error.location.file),
726
- line: error.location.line,
727
- column: error.location.column
728
- };
729
- }
730
-
731
- // Capture code snippet around error - from Playwright error object
732
- if (error.snippet) {
733
- testError.snippet = error.snippet;
734
- }
735
-
736
- // Capture expected/actual values for assertion failures
737
- // Playwright stores these as specially formatted strings in the message
738
- const message = error.message || '';
739
-
740
- // Try to parse expected pattern from toHaveURL and similar assertions
741
- const expectedPatternMatch = message.match(/Expected pattern:\s*(.+?)(?:\n|$)/);
742
- if (expectedPatternMatch) {
743
- testError.expected = expectedPatternMatch[1].trim();
744
- }
745
-
746
- // Also try "Expected string:" format
747
- if (!testError.expected) {
748
- const expectedStringMatch = message.match(/Expected string:\s*["']?(.+?)["']?(?:\n|$)/);
749
- if (expectedStringMatch) {
750
- testError.expected = expectedStringMatch[1].trim();
751
- }
752
- }
753
-
754
- // Try to parse received/actual value
755
- const receivedMatch = message.match(/Received (?:string|value):\s*["']?(.+?)["']?(?:\n|$)/);
756
- if (receivedMatch) {
757
- testError.actual = receivedMatch[1].trim();
758
- }
759
-
760
- // Parse call log entries for debugging info (timeouts, retries, etc.)
761
- const callLogMatch = message.match(/Call log:([\s\S]*?)(?=\n\n|\n\s*\d+\s*\||$)/);
762
- if (callLogMatch) {
763
- // Store call log separately for display
764
- const callLog = callLogMatch[1].trim();
765
- if (callLog) {
766
- testError.diff = callLog; // Reuse diff field for call log
767
- }
768
- }
769
-
770
- // Parse timeout information - multiple formats
771
- const timeoutMatch = message.match(/(?:with timeout|Timeout:?)\s*(\d+)ms/i);
772
- if (timeoutMatch) {
773
- testError.timeout = parseInt(timeoutMatch[1], 10);
774
- }
775
-
776
- // Parse matcher name (e.g., toHaveURL, toBeVisible)
777
- const matcherMatch = message.match(/expect\([^)]+\)\.(\w+)/);
778
- if (matcherMatch) {
779
- testError.matcherName = matcherMatch[1];
780
- }
781
-
782
- // Extract code snippet from message if not already captured
783
- // Look for lines like " 9 | await page.click..." or "> 11 | await expect..."
784
- if (!testError.snippet) {
785
- const codeSnippetMatch = message.match(/((?:\s*>?\s*\d+\s*\|.*\n?)+)/);
786
- if (codeSnippetMatch) {
787
- testError.snippet = codeSnippetMatch[1].trim();
788
- }
789
- }
790
-
791
- return testError;
792
- });
793
-
794
- // Send testEnd event for all tests, regardless of status
795
- // This ensures tests that are interrupted or have unexpected statuses are properly recorded
796
- logger.info(`[TestLens] Sending testEnd - testId: ${testData.id}, status: ${testData.status}, originalStatus: ${testData.originalStatus}`);
797
- // Send test end event to API and get response
798
- const testEndResponse = await this.sendToApi({
799
- type: 'testEnd',
800
- runId: this.runId,
801
- timestamp: new Date().toISOString(),
802
- test: testData
803
- });
804
-
805
- // Handle artifacts (test case is now guaranteed to be in database)
806
- if (this.config.enableArtifacts) {
807
- // Pass test case DB ID if available for faster lookups
808
- await this.processArtifacts(testId, result, testEndResponse?.testCaseId);
809
- } else if (result.attachments && result.attachments.length > 0) {
810
- for (const attachment of result.attachments) {
811
- const artifactType = this.getArtifactType(attachment.name);
812
- this.artifactsSeen += 1;
813
- this.bumpArtifactStat('skipped', artifactType);
814
- }
815
- }
816
- }
817
-
818
- // Update spec status
819
- const specPath = test.location.file;
820
- const specKey = `${specPath}-${test.parent.title}`;
821
- const specData = this.specMap.get(specKey);
822
- if (specData) {
823
- const normalizedStatus = this.normalizeTestStatus(result.status);
824
- if (normalizedStatus === 'failed' && specData.status !== 'failed') {
825
- specData.status = 'failed';
826
- } else if (result.status === 'skipped' && specData.status === 'passed') {
827
- specData.status = 'skipped';
828
- }
829
-
830
- // Check if all tests in spec are complete
831
- // Only consider tests that were actually executed (have testData)
832
- const remainingTests = test.parent.tests.filter((t: any) => {
833
- const tId = this.getTestId(t);
834
- const tData = this.testMap.get(tId);
835
- // If testData exists but no endTime, it's still running
836
- return tData && !tData.endTime;
837
- });
838
-
839
- if (remainingTests.length === 0) {
840
- // Determine final spec status based on all executed tests
841
- const executedTests = test.parent.tests
842
- .map((t: any) => {
843
- const tId = this.getTestId(t);
844
- return this.testMap.get(tId);
845
- })
846
- .filter((tData: TestData | undefined): tData is TestData => !!tData);
847
-
848
- if (executedTests.length > 0) {
849
- const allTestStatuses = executedTests.map(tData => tData.status);
850
- if (allTestStatuses.every(status => status === 'passed')) {
851
- specData.status = 'passed';
852
- } else if (allTestStatuses.some(status => status === 'failed')) {
853
- specData.status = 'failed';
854
- } else if (allTestStatuses.every(status => status === 'skipped')) {
855
- specData.status = 'skipped';
856
- }
857
- }
858
- // Aggregate tags from all tests in this spec
859
- const allTags = new Set<string>();
860
- test.parent.tests.forEach((t: any) => {
861
- const tags = this.extractTags(t);
862
- tags.forEach(tag => allTags.add(tag));
863
- });
864
- const aggregatedTags = Array.from(allTags);
865
- // Only update tags if we have any
866
- if (aggregatedTags.length > 0) {
867
- specData.tags = aggregatedTags;
868
- }
869
-
870
- specData.endTime = new Date().toISOString();
871
-
872
- // Send spec end event to API
873
- await this.sendToApi({
874
- type: 'specEnd',
875
- runId: this.runId,
876
- timestamp: new Date().toISOString(),
877
- spec: specData
878
- });
879
-
880
- // Send spec code blocks to API
881
- await this.sendSpecCodeBlocks(specPath);
882
- }
883
- }
884
- }
885
-
886
- async onEnd(result: { status: string }): Promise<void> {
887
- this.runMetadata.endTime = new Date().toISOString();
888
- this.runMetadata.duration = Date.now() - new Date(this.runMetadata.startTime).getTime();
889
-
890
- // Wait for all pending artifact uploads to complete before sending runEnd
891
- if (this.pendingUploads.size > 0) {
892
- logger.debug(`Waiting for ${this.pendingUploads.size} artifact upload(s) to complete...`);
893
- try {
894
- await Promise.all(Array.from(this.pendingUploads));
895
- logger.debug(`[OK] All artifact uploads completed`);
896
- } catch (error) {
897
- logger.error(`[WARN] Some artifact uploads failed, continuing with runEnd`);
898
- }
899
- }
900
-
901
- const uploaded = this.artifactStats.uploaded;
902
- const skipped = this.artifactStats.skipped;
903
- const failed = this.artifactStats.failed;
904
- // Always show summary so users can confirm artifact settings
905
- const summary =
906
- `[ARTIFACTS] seen=${this.artifactsSeen} | ` +
907
- `uploaded screenshot=${uploaded.screenshot}, video=${uploaded.video}, trace=${uploaded.trace}, attachment=${uploaded.attachment} | ` +
908
- `skipped screenshot=${skipped.screenshot}, video=${skipped.video}, trace=${skipped.trace}, attachment=${skipped.attachment} | ` +
909
- `failed screenshot=${failed.screenshot}, video=${failed.video}, trace=${failed.trace}, attachment=${failed.attachment}`;
910
- if (logger.levelVal > 30) {
911
- console.log(summary);
912
- } else {
913
- logger.info(summary);
914
- }
915
-
916
- // Calculate final stats
917
- const totalTests = Array.from(this.testMap.values()).length;
918
- const passedTests = Array.from(this.testMap.values()).filter(t => t.status === 'passed').length;
919
- // failedTests already includes timedOut tests since normalizeTestStatus converts 'timedOut' to 'failed'
920
- const failedTests = Array.from(this.testMap.values()).filter(t => t.status === 'failed').length;
921
- const skippedTests = Array.from(this.testMap.values()).filter(t => t.status === 'skipped').length;
922
- // Track timedOut separately for reporting purposes only (not for count)
923
- const timedOutTests = Array.from(this.testMap.values()).filter(t => t.originalStatus === 'timedOut').length;
924
-
925
- // Normalize run status - if there are timeouts, treat run as failed
926
- const hasTimeouts = timedOutTests > 0;
927
- const normalizedRunStatus = this.normalizeRunStatus(result.status, hasTimeouts);
928
-
929
- // Send run end event to API
930
- await this.sendToApi({
931
- type: 'runEnd',
932
- runId: this.runId,
933
- timestamp: new Date().toISOString(),
934
- metadata: {
935
- ...this.runMetadata,
936
- totalTests,
937
- passedTests,
938
- failedTests, // Already includes timedOut tests (normalized to 'failed')
939
- skippedTests,
940
- timedOutTests, // For informational purposes
941
- status: normalizedRunStatus
942
- }
943
- });
944
-
945
- // Show Build Name if provided, otherwise show Run ID
946
- if (this.runMetadata.testlensBuildName) {
947
- logger.info(`[COMPLETE] TestLens Report completed - Build: ${this.runMetadata.testlensBuildName}`);
948
- logger.info(` Run ID: ${this.runId}`);
949
- } else {
950
- logger.info(`[COMPLETE] TestLens Report completed - Run ID: ${this.runId}`);
951
- }
952
- logger.info(`[RESULTS] ${passedTests} passed, ${failedTests} failed (${timedOutTests} timeouts), ${skippedTests} skipped`);
953
- }
954
-
955
- private async sendToApi(payload: any): Promise<any> {
956
- // Skip sending if run creation already failed
957
- if (this.runCreationFailed && payload.type !== 'runStart') {
958
- return null;
959
- }
960
-
961
- try {
962
- const response = await this.axiosInstance.post('', payload, {
963
- headers: {
964
- 'X-API-Key': this.config.apiKey
965
- }
966
- });
967
- if (this.config.enableRealTimeStream) {
968
- logger.debug(`[OK] Sent ${payload.type} event to TestLens (HTTP ${response.status})`);
969
- }
970
- // Return response data for caller to use
971
- return response.data;
972
- } catch (error: any) {
973
- const errorData = error?.response?.data;
974
- const status = error?.response?.status;
975
-
976
- // Check for limit exceeded (403)
977
- if (status === 403 && errorData?.error === 'limit_exceeded') {
978
- // Set flag to skip subsequent events
979
- if (payload.type === 'runStart' && errorData?.limit_type === 'test_runs') {
980
- this.runCreationFailed = true;
981
- }
982
-
983
- logger.error('\n' + '='.repeat(80));
984
- if (errorData?.limit_type === 'test_cases') {
985
- logger.error('[ERROR] TESTLENS ERROR: Test Cases Limit Reached');
986
- } else if (errorData?.limit_type === 'test_runs') {
987
- logger.error('[ERROR] TESTLENS ERROR: Test Runs Limit Reached');
988
- } else {
989
- logger.error('[ERROR] TESTLENS ERROR: Plan Limit Reached');
990
- }
991
- logger.error('='.repeat(80));
992
- logger.error('');
993
- logger.error(errorData?.message || 'You have reached your plan limit.');
994
- logger.error('');
995
- logger.error(`Current usage: ${errorData?.current_usage || 'N/A'} / ${errorData?.limit || 'N/A'}`);
996
- logger.error('');
997
- logger.error('To continue, please upgrade your plan.');
998
- logger.error('Contact: support@alternative-path.com');
999
- logger.error('');
1000
- logger.error('='.repeat(80));
1001
- logger.error('');
1002
- return; // Don't log the full error object for limit errors
1003
- }
1004
-
1005
- // Check for trial expiration, subscription errors, or limit errors (401)
1006
- if (status === 401) {
1007
- if (errorData?.error === 'trial_expired' || errorData?.error === 'subscription_inactive' ||
1008
- errorData?.error === 'test_cases_limit_reached' || errorData?.error === 'test_runs_limit_reached') {
1009
- logger.error('\n' + '='.repeat(80));
1010
-
1011
- if (errorData?.error === 'test_cases_limit_reached') {
1012
- logger.error('[ERROR] TESTLENS ERROR: Test Cases Limit Reached');
1013
- } else if (errorData?.error === 'test_runs_limit_reached') {
1014
- logger.error('[ERROR] TESTLENS ERROR: Test Runs Limit Reached');
1015
- } else {
1016
- logger.error('[ERROR] TESTLENS ERROR: Your trial plan has ended');
1017
- }
1018
-
1019
- logger.error('='.repeat(80));
1020
- logger.error('');
1021
- logger.error(errorData?.message || 'Your trial period has expired.');
1022
- logger.error('');
1023
- logger.error('To continue using TestLens, please upgrade to Enterprise plan.');
1024
- logger.error('Contact: ' + (errorData?.contactEmail || 'support@alternative-path.com'));
1025
- logger.error('');
1026
- if (errorData?.trial_end_date) {
1027
- logger.error(`Trial ended: ${new Date(errorData.trial_end_date).toLocaleDateString()}`);
1028
- logger.error('');
1029
- }
1030
- logger.error('='.repeat(80));
1031
- logger.error('');
1032
- } else {
1033
- logger.error(`[ERROR] Authentication failed: ${errorData?.error || 'Invalid API key'}`);
1034
- }
1035
- } else if (status !== 403) {
1036
- // Log other errors (but not 403 which we handled above)
1037
- logger.error({
1038
- message: `[ERROR] Failed to send ${payload.type} event to TestLens`,
1039
- error: error?.message || 'Unknown error',
1040
- status: status,
1041
- statusText: error?.response?.statusText,
1042
- data: errorData,
1043
- code: error?.code,
1044
- url: error?.config?.url,
1045
- method: error?.config?.method
1046
- });
1047
- }
1048
-
1049
- // Don't throw error to avoid breaking test execution
1050
- }
1051
- }
1052
-
1053
- private async processArtifacts(testId: string, result: TestResult, testCaseDbId?: string): Promise<void> {
1054
- // Skip artifact processing if run creation failed
1055
- if (this.runCreationFailed) {
1056
- return;
1057
- }
1058
-
1059
- const attachments = result.attachments;
1060
-
1061
- for (const attachment of attachments) {
1062
- const artifactType = this.getArtifactType(attachment.name);
1063
- if (attachment.path) {
1064
- // Check if attachment should be processed based on config
1065
- const isVideo = artifactType === 'video' || attachment.contentType?.startsWith('video/');
1066
- const isScreenshot = artifactType === 'screenshot' || attachment.contentType?.startsWith('image/');
1067
-
1068
- // Skip video if disabled in config
1069
- if (isVideo && !this.config.enableVideo) {
1070
- logger.debug(`[SKIP] Skipping video artifact ${attachment.name} - video capture disabled in config`);
1071
- this.bumpArtifactStat('skipped', artifactType);
1072
- continue;
1073
- }
1074
-
1075
- // Skip screenshot if disabled in config
1076
- if (isScreenshot && !this.config.enableScreenshot) {
1077
- logger.debug(`[SKIP] Skipping screenshot artifact ${attachment.name} - screenshot capture disabled in config`);
1078
- this.bumpArtifactStat('skipped', artifactType);
1079
- continue;
1080
- }
1081
-
1082
- try {
1083
- // Determine proper filename with extension
1084
- // Playwright attachment.name often doesn't have extension, so we need to derive it
1085
- let fileName = attachment.name;
1086
- const existingExt = path.extname(fileName);
1087
-
1088
- if (!existingExt) {
1089
- // Get extension from the actual file path
1090
- const pathExt = path.extname(attachment.path);
1091
- if (pathExt) {
1092
- fileName = `${fileName}${pathExt}`;
1093
- } else if (attachment.contentType) {
1094
- // Fallback: derive extension from contentType
1095
- const mimeToExt: Record<string, string> = {
1096
- 'image/png': '.png',
1097
- 'image/jpeg': '.jpg',
1098
- 'image/gif': '.gif',
1099
- 'image/webp': '.webp',
1100
- 'video/webm': '.webm',
1101
- 'video/mp4': '.mp4',
1102
- 'application/zip': '.zip',
1103
- 'application/json': '.json',
1104
- 'text/plain': '.txt'
1105
- };
1106
- const ext = mimeToExt[attachment.contentType];
1107
- if (ext) {
1108
- fileName = `${fileName}${ext}`;
1109
- }
1110
- }
1111
- }
1112
-
1113
- // Upload to S3 first (pass DB ID if available for faster lookup)
1114
- // Create upload promise that we can track
1115
- const uploadPromise = Promise.resolve().then(async () => {
1116
- try {
1117
- if (!attachment.path) {
1118
- logger.debug(`[SKIP] [Test: ${testId.substring(0, 8)}...] Skipping artifact ${attachment.name} - no file path`);
1119
- this.bumpArtifactStat('skipped', artifactType);
1120
- return;
1121
- }
1122
-
1123
- const s3Data = await this.uploadArtifactToS3(attachment.path, testId, fileName, testCaseDbId);
1124
-
1125
- // Skip if upload failed or file was too large
1126
- if (!s3Data) {
1127
- logger.debug(`[SKIP] [Test: ${testId.substring(0, 8)}...] Skipping artifact ${attachment.name} - upload failed or file too large`);
1128
- this.bumpArtifactStat('failed', artifactType);
1129
- return;
1130
- }
1131
-
1132
- const artifactData = {
1133
- testId,
1134
- type: this.getArtifactType(attachment.name),
1135
- path: attachment.path,
1136
- name: fileName,
1137
- contentType: attachment.contentType,
1138
- fileSize: this.getFileSize(attachment.path),
1139
- storageType: 's3',
1140
- s3Key: s3Data.key,
1141
- s3Url: s3Data.url
1142
- };
1143
-
1144
- // Send artifact data to API
1145
- await this.sendToApi({
1146
- type: 'artifact',
1147
- runId: this.runId,
1148
- timestamp: new Date().toISOString(),
1149
- artifact: artifactData
1150
- });
1151
-
1152
- logger.debug(`[ARTIFACT] [Test: ${testId.substring(0, 8)}...] Processed artifact: ${fileName}`);
1153
- } catch (error) {
1154
- logger.error(`[ERROR] [Test: ${testId.substring(0, 8)}...] Failed to process artifact ${attachment.name}: ${(error as Error).message}`);
1155
- this.bumpArtifactStat('failed', artifactType);
1156
- }
1157
- });
1158
-
1159
- // Track this upload and ensure cleanup on completion
1160
- this.pendingUploads.add(uploadPromise);
1161
- uploadPromise.finally(() => {
1162
- this.pendingUploads.delete(uploadPromise);
1163
- });
1164
-
1165
- // Don't await here - let uploads happen in parallel
1166
- // They will be awaited in onEnd
1167
- } catch (error) {
1168
- logger.error(`[ERROR] [Test: ${testId.substring(0, 8)}...] Failed to setup artifact upload ${attachment.name}: ${(error as Error).message}`);
1169
- this.bumpArtifactStat('failed', artifactType);
1170
- }
1171
- } else {
1172
- this.bumpArtifactStat('skipped', artifactType);
1173
- }
1174
- }
1175
- }
1176
-
1177
- private async sendSpecCodeBlocks(specPath: string): Promise<void> {
1178
- try {
1179
- // Extract code blocks using built-in parser
1180
- const testBlocks = this.extractTestBlocks(specPath);
1181
-
1182
- // Transform blocks to match backend API expectations
1183
- const codeBlocks = testBlocks.map(block => ({
1184
- type: block.type, // 'test' or 'describe'
1185
- name: block.name, // test/describe name
1186
- content: block.content, // full code content
1187
- summary: null, // optional
1188
- describe: block.describe // parent describe block name
1189
- }));
1190
-
1191
- // Send to dedicated spec code blocks API endpoint
1192
- // Extract base URL - handle both full and partial endpoint patterns
1193
- let baseUrl = this.config.apiEndpoint.replace('/api/v1/webhook/playwright', '');
1194
- if (baseUrl === this.config.apiEndpoint) {
1195
- // Fallback: try alternative pattern if main pattern didn't match
1196
- baseUrl = this.config.apiEndpoint.replace('/webhook/playwright', '');
1197
- }
1198
- const specEndpoint = `${baseUrl}/api/v1/webhook/playwright/spec-code-blocks`;
1199
-
1200
- await this.axiosInstance.post(specEndpoint, {
1201
- filePath: path.relative(process.cwd(), specPath),
1202
- codeBlocks,
1203
- testSuiteName: path.basename(specPath).replace(/\.(spec|test)\.(js|ts)$/, '')
1204
- });
1205
-
1206
- logger.info(`Sent ${codeBlocks.length} code blocks for: ${path.basename(specPath)}`);
1207
- } catch (error: any) {
1208
- const errorData = error?.response?.data;
1209
-
1210
- // Handle duplicate spec code blocks gracefully (when re-running tests)
1211
- if (errorData?.error && errorData.error.includes('duplicate key value violates unique constraint')) {
1212
- logger.info(`[INFO] Spec code blocks already exist for: ${path.basename(specPath)} (skipped)`);
1213
- return;
1214
- }
1215
-
1216
- logger.error(`Failed to send spec code blocks: ${errorData || error?.message || 'Unknown error'}`);
1217
- }
1218
- }
1219
-
1220
- private extractTestBlocks(filePath: string): CodeBlock[] {
1221
- try {
1222
- const content = fs.readFileSync(filePath, 'utf-8');
1223
- const blocks: CodeBlock[] = [];
1224
- const lines = content.split('\n');
1225
-
1226
- let currentDescribe: string | null = null;
1227
- let braceCount = 0;
1228
- let inBlock = false;
1229
- let blockStart = -1;
1230
- let blockType: 'test' | 'describe' = 'test';
1231
- let blockName = '';
1232
-
1233
- for (let i = 0; i < lines.length; i++) {
1234
- const line = lines[i];
1235
- const trimmedLine = line.trim();
1236
-
1237
- // Check for describe blocks
1238
- const describeMatch = trimmedLine.match(/describe\s*\(\s*['"`]([^'"`]+)['"`]/);
1239
- if (describeMatch) {
1240
- currentDescribe = describeMatch[1];
1241
- }
1242
-
1243
- // Check for test blocks
1244
- const testMatch = trimmedLine.match(/test\s*\(\s*['"`]([^'"`]+)['"`]/);
1245
- if (testMatch && !inBlock) {
1246
- blockType = 'test';
1247
- blockName = testMatch[1];
1248
- blockStart = i;
1249
- braceCount = 0;
1250
- inBlock = true;
1251
- }
1252
-
1253
- // Count braces when in a block
1254
- if (inBlock) {
1255
- for (const char of line) {
1256
- if (char === '{') braceCount++;
1257
- if (char === '}') braceCount--;
1258
-
1259
- if (braceCount === 0 && blockStart !== -1 && i > blockStart) {
1260
- // End of block found
1261
- const blockContent = lines.slice(blockStart, i + 1).join('\n');
1262
-
1263
- blocks.push({
1264
- type: blockType,
1265
- name: blockName,
1266
- content: blockContent,
1267
- describe: currentDescribe || undefined,
1268
- startLine: blockStart + 1,
1269
- endLine: i + 1
1270
- });
1271
-
1272
- inBlock = false;
1273
- blockStart = -1;
1274
- break;
1275
- }
1276
- }
1277
- }
1278
- }
1279
-
1280
- return blocks;
1281
- } catch (error: any) {
1282
- logger.error(`Failed to extract test blocks from ${filePath}: ${error.message}`);
1283
- return [];
1284
- }
1285
- }
1286
-
1287
- private async collectGitInfo(): Promise<GitInfo | null> {
1288
- try {
1289
- const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
1290
- const commit = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
1291
- const shortCommit = commit.substring(0, 7);
1292
- const author = execSync('git log -1 --pretty=format:"%an"', { encoding: 'utf-8' }).trim();
1293
- const commitMessage = execSync('git log -1 --pretty=format:"%s"', { encoding: 'utf-8' }).trim();
1294
- const commitTimestamp = execSync('git log -1 --pretty=format:"%ci"', { encoding: 'utf-8' }).trim();
1295
-
1296
- let remoteName = 'origin';
1297
- let remoteUrl = '';
1298
- try {
1299
- const remotes = execSync('git remote', { encoding: 'utf-8' }).trim();
1300
- if (remotes) {
1301
- remoteName = remotes.split('\n')[0] || 'origin';
1302
- remoteUrl = execSync(`git remote get-url ${remoteName}`, { encoding: 'utf-8' }).trim();
1303
- }
1304
- } catch (e) {
1305
- // Remote info is optional - handle gracefully
1306
- logger.info('[INFO] No git remote configured, skipping remote info');
1307
- }
1308
-
1309
- const isDirty = execSync('git status --porcelain', { encoding: 'utf-8' }).trim().length > 0;
1310
-
1311
- return {
1312
- branch,
1313
- commit,
1314
- shortCommit,
1315
- author,
1316
- message: commitMessage,
1317
- timestamp: commitTimestamp,
1318
- isDirty,
1319
- remoteName,
1320
- remoteUrl
1321
- };
1322
- } catch (error: any) {
1323
- // Silently skip git information if not in a git repository
1324
- return null;
1325
- }
1326
- }
1327
-
1328
- private getArtifactType(name: string): string {
1329
- if (name.includes('screenshot')) return 'screenshot';
1330
- if (name.includes('video')) return 'video';
1331
- if (name.includes('trace')) return 'trace';
1332
- return 'attachment';
1333
- }
1334
-
1335
- private bumpArtifactStat(stat: 'uploaded' | 'skipped' | 'failed', type: string): void {
1336
- const bucket = this.artifactStats[stat] as Record<string, number>;
1337
- if (bucket[type] !== undefined) {
1338
- bucket[type] += 1;
1339
- }
1340
- }
1341
-
1342
- private extractTags(test: TestCase): string[] {
1343
- const tags: string[] = [];
1344
-
1345
- // Playwright stores tags in the _tags property
1346
- const testTags = (test as any)._tags;
1347
- if (testTags && Array.isArray(testTags)) {
1348
- tags.push(...testTags);
1349
- }
1350
-
1351
- // Also get tags from parent suites by walking up the tree
1352
- let currentSuite: Suite | undefined = test.parent;
1353
- while (currentSuite) {
1354
- const suiteTags = (currentSuite as any)._tags;
1355
- if (suiteTags && Array.isArray(suiteTags)) {
1356
- tags.push(...suiteTags);
1357
- }
1358
- currentSuite = currentSuite.parent;
1359
- }
1360
-
1361
- // Also extract @tags from test title for backward compatibility
1362
- const tagMatches = test.title.match(/@[\w-]+/g);
1363
- if (tagMatches) {
1364
- tags.push(...tagMatches);
1365
- }
1366
-
1367
- // Add testlensBuildTag: CLI args take precedence over config
1368
- const buildTagSource = this.cliArgs.testlensBuildTag || this.config.customMetadata?.testlensBuildTag;
1369
- if (buildTagSource) {
1370
- const buildTags = Array.isArray(buildTagSource)
1371
- ? buildTagSource
1372
- : [buildTagSource];
1373
- buildTags.forEach(tag => tags.push(`@${tag}`));
1374
- }
1375
-
1376
- // Remove duplicates and return
1377
- return [...new Set(tags)];
1378
- }
1379
-
1380
- private getTestId(test: TestCase): string {
1381
- const cleanTitle = test.title.replace(/@[\w-]+/g, '').trim();
1382
- // Normalize path separators to forward slashes for cross-platform consistency
1383
- const normalizedFile = test.location.file.replace(/\\/g, '/');
1384
- return `${normalizedFile}:${test.location.line}:${cleanTitle}`;
1385
- }
1386
-
1387
-
1388
- private async uploadArtifactToS3(filePath: string, testId: string, fileName: string, testCaseDbId?: string): Promise<{ key: string; url: string; presignedUrl: string; fileSize: number; contentType: string } | null> {
1389
- try {
1390
- // Check file size first
1391
- const fileSize = this.getFileSize(filePath);
1392
- const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
1393
-
1394
- logger.debug(`📤 Uploading ${fileName} (${fileSizeMB}MB) directly to S3...`);
1395
-
1396
- const baseUrl = this.config.apiEndpoint.replace('/api/v1/webhook/playwright', '');
1397
-
1398
- // Step 1: Request pre-signed URL from server
1399
- const presignedUrlEndpoint = `${baseUrl}/api/v1/artifacts/public/presigned-url`;
1400
- const requestBody: any = {
1401
- apiKey: this.config.apiKey,
1402
- testRunId: this.runId,
1403
- testId: testId,
1404
- fileName: fileName,
1405
- fileType: await this.getContentType(fileName),
1406
- fileSize: fileSize,
1407
- artifactType: this.getArtifactType(fileName)
1408
- };
1409
-
1410
- // Include DB ID if available for faster lookup (avoids query)
1411
- if (testCaseDbId) {
1412
- requestBody.testCaseDbId = testCaseDbId;
1413
- }
1414
-
1415
- const presignedResponse = await this.axiosInstance.post(presignedUrlEndpoint, requestBody, {
1416
- timeout: 10000 // Quick timeout for metadata request
1417
- });
1418
-
1419
- if (!presignedResponse.data.success) {
1420
- throw new Error(`Failed to get presigned URL: ${presignedResponse.data.error || 'Unknown error'}`);
1421
- }
1422
-
1423
- const { uploadUrl, s3Key, metadata } = presignedResponse.data;
1424
-
1425
- // Step 2: Upload directly to S3 using presigned URL
1426
- logger.debug(`[UPLOAD] [Test: ${testId.substring(0, 8)}...] Uploading ${fileName} directly to S3...`);
1427
-
1428
- const fileBuffer = fs.readFileSync(filePath);
1429
-
1430
- // IMPORTANT: When using presigned URLs, we MUST include exactly the headers that were signed
1431
- // The backend signs with ServerSideEncryption:'AES256', so we must send that header
1432
- // AWS presigned URLs are very strict about header matching
1433
- const uploadResponse = await axios.put(uploadUrl, fileBuffer, {
1434
- headers: {
1435
- 'x-amz-server-side-encryption': 'AES256'
1436
- },
1437
- maxContentLength: Infinity,
1438
- maxBodyLength: Infinity,
1439
- timeout: Math.max(600000, Math.ceil(fileSize / (1024 * 1024)) * 10000), // 10s per MB, min 10 minutes - increased for large trace files
1440
- // Don't use custom HTTPS agent for S3 uploads
1441
- httpsAgent: undefined
1442
- });
1443
-
1444
- if (uploadResponse.status !== 200) {
1445
- throw new Error(`S3 upload failed with status ${uploadResponse.status}`);
1446
- }
1447
-
1448
- logger.debug(`[OK] [Test: ${testId.substring(0, 8)}...] S3 upload completed for ${fileName}`);
1449
-
1450
- // Step 3: Confirm upload with server to save metadata
1451
- const confirmEndpoint = `${baseUrl}/api/v1/artifacts/public/confirm-upload`;
1452
- const confirmBody: any = {
1453
- apiKey: this.config.apiKey,
1454
- testRunId: this.runId,
1455
- testId: testId,
1456
- s3Key: s3Key,
1457
- fileName: fileName,
1458
- fileType: await this.getContentType(fileName),
1459
- fileSize: fileSize,
1460
- artifactType: this.getArtifactType(fileName)
1461
- };
1462
-
1463
- // Include DB ID if available for direct insert (avoids query and race condition)
1464
- if (testCaseDbId) {
1465
- confirmBody.testCaseDbId = testCaseDbId;
1466
- }
1467
-
1468
- const confirmResponse = await this.axiosInstance.post(confirmEndpoint, confirmBody, {
1469
- timeout: 10000
1470
- });
1471
-
1472
- if (confirmResponse.status === 201 && confirmResponse.data.success) {
1473
- const artifact = confirmResponse.data.artifact;
1474
- logger.debug(`[OK] [Test: ${testId.substring(0, 8)}...] Upload confirmed${testCaseDbId ? ' (fast path)' : ' (fallback)'}`);
1475
- return {
1476
- key: s3Key,
1477
- url: artifact.s3Url,
1478
- presignedUrl: artifact.presignedUrl,
1479
- fileSize: artifact.fileSize,
1480
- contentType: artifact.contentType
1481
- };
1482
- } else {
1483
- throw new Error(`Upload confirmation failed: ${confirmResponse.data.error || 'Unknown error'}`);
1484
- }
1485
- } catch (error: any) {
1486
- // Check for trial expiration, subscription errors, or limit errors
1487
- if (error?.response?.status === 401) {
1488
- const errorData = error?.response?.data;
1489
-
1490
- if (errorData?.error === 'trial_expired' || errorData?.error === 'subscription_inactive' ||
1491
- errorData?.error === 'test_cases_limit_reached' || errorData?.error === 'test_runs_limit_reached') {
1492
- logger.error('\n' + '='.repeat(80));
1493
-
1494
- if (errorData?.error === 'test_cases_limit_reached') {
1495
- logger.error('[ERROR] TESTLENS ERROR: Test Cases Limit Reached');
1496
- } else if (errorData?.error === 'test_runs_limit_reached') {
1497
- logger.error('[ERROR] TESTLENS ERROR: Test Runs Limit Reached');
1498
- } else {
1499
- logger.error('[ERROR] TESTLENS ERROR: Your trial plan has ended');
1500
- }
1501
-
1502
- logger.error('='.repeat(80));
1503
- logger.error('');
1504
- logger.error(errorData?.message || 'Your trial period has expired.');
1505
- logger.error('');
1506
- logger.error('To continue using TestLens, please upgrade to Enterprise plan.');
1507
- logger.error('Contact: ' + (errorData?.contactEmail || 'support@alternative-path.com'));
1508
- logger.error('');
1509
- if (errorData?.trial_end_date) {
1510
- logger.error(`Trial ended: ${new Date(errorData.trial_end_date).toLocaleDateString()}`);
1511
- logger.error('');
1512
- }
1513
- logger.error('='.repeat(80));
1514
- logger.error('');
1515
- return null;
1516
- }
1517
- }
1518
-
1519
- // Better error messages for common issues
1520
- let errorMsg = error.message;
1521
-
1522
- if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
1523
- errorMsg = `Upload timeout - file may be too large or connection is slow`;
1524
- } else if (error.response?.status === 413) {
1525
- errorMsg = `File too large (413) - server rejected the upload`;
1526
- } else if (error.response?.status === 400) {
1527
- errorMsg = `Bad request (400) - ${error.response.data?.error || 'check file format'}`;
1528
- } else if (error.response?.status === 403) {
1529
- errorMsg = `Access denied (403) - presigned URL may have expired`;
1530
- }
1531
-
1532
- logger.error(`[ERROR] Failed to upload ${fileName} to S3: ${errorMsg}`);
1533
- if (error.response?.data) {
1534
- logger.error({ errorDetails: error.response.data }, 'Error details');
1535
- }
1536
-
1537
- // Don't throw, just return null to continue with other artifacts
1538
- return null;
1539
- }
1540
- }
1541
-
1542
- private async getContentType(fileName: string): Promise<string> {
1543
- const ext = path.extname(fileName).toLowerCase();
1544
- try {
1545
- const mime = await getMime();
1546
- // Try different ways to access getType method
1547
- const getType = mime.getType || mime.default?.getType;
1548
- if (typeof getType === 'function') {
1549
- const mimeType = getType.call(mime, ext) || getType.call(mime.default, ext);
1550
- return mimeType || 'application/octet-stream';
1551
- }
1552
- } catch (error: any) {
1553
- logger.warn(`Failed to get MIME type for ${fileName}: ${error.message}`);
1554
- }
1555
- // Fallback to basic content type mapping
1556
- const contentTypes: Record<string, string> = {
1557
- '.mp4': 'video/mp4',
1558
- '.webm': 'video/webm',
1559
- '.png': 'image/png',
1560
- '.jpg': 'image/jpeg',
1561
- '.jpeg': 'image/jpeg',
1562
- '.gif': 'image/gif',
1563
- '.json': 'application/json',
1564
- '.txt': 'text/plain',
1565
- '.html': 'text/html',
1566
- '.xml': 'application/xml',
1567
- '.zip': 'application/zip',
1568
- '.pdf': 'application/pdf'
1569
- };
1570
- return contentTypes[ext] || 'application/octet-stream';
1571
- }
1572
-
1573
- // Note: S3 key generation and sanitization are handled server-side
1574
- // generateS3Key() and sanitizeForS3() methods removed as they were not used
1575
-
1576
- private getFileSize(filePath: string): number {
1577
- try {
1578
- const stats = fs.statSync(filePath);
1579
- return stats.size;
1580
- } catch (error) {
1581
- logger.warn(`Could not get file size for ${filePath}: ${(error as Error).message}`);
1582
- return 0;
1583
- }
1584
- }
1585
- }
1586
-
1587
- export default TestLensReporter;
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 type { Reporter, TestCase, TestResult, FullConfig, Suite } from '@playwright/test/reporter';
8
+ import { execSync } from 'child_process';
9
+ import pino from 'pino';
10
+
11
+ const isVerboseLogs =
12
+ process.env.TESTLENS_LOG_VERBOSE === '1' ||
13
+ process.env.TESTLENS_LOG_VERBOSE === 'true';
14
+ const noisyInfoPrefixes = [
15
+ '[OK]',
16
+ '[UPLOAD]',
17
+ '[ARTIFACT]',
18
+ '[SKIP]',
19
+ '[TestLens] onTestEnd called',
20
+ '[TestLens] Sending testEnd'
21
+ ];
22
+ const noisyInfoSubstrings = [
23
+ 'Sent runStart event to TestLens',
24
+ 'Sent specStart event to TestLens',
25
+ 'Sent testStart event to TestLens',
26
+ 'Sent testEnd event to TestLens',
27
+ 'Sent runEnd event to TestLens',
28
+ 'Sent artifact event to TestLens',
29
+ 'S3 upload completed',
30
+ 'Upload confirmed',
31
+ 'Uploading ',
32
+ 'Processed artifact',
33
+ 'Spec code blocks',
34
+ '\n[METADATA]',
35
+ 'Custom Metadata Detected',
36
+ 'Using API key from environment variable'
37
+ ];
38
+
39
+ const shouldSuppressInfo = (msg: string): boolean => {
40
+ if (isVerboseLogs) return false;
41
+ if (!msg) return false;
42
+ return (
43
+ noisyInfoPrefixes.some(prefix => msg.startsWith(prefix)) ||
44
+ noisyInfoSubstrings.some(sub => msg.includes(sub))
45
+ );
46
+ };
47
+
48
+ // Create pino logger instance
49
+ const logger = pino({
50
+ level: process.env.TESTLENS_LOG_LEVEL || process.env.LOG_LEVEL || 'info',
51
+ base: null, // remove pid/hostname
52
+ timestamp: false, // remove time
53
+ formatters: {
54
+ level: () => ({}) // remove level
55
+ },
56
+ hooks: {
57
+ logMethod(args, method, level) {
58
+ // pino level is numeric (info = 30)
59
+ if (level === 30) {
60
+ const msg =
61
+ typeof args[0] === 'string'
62
+ ? args[0]
63
+ : typeof args[1] === 'string'
64
+ ? args[1]
65
+ : '';
66
+ if (shouldSuppressInfo(msg)) {
67
+ return;
68
+ }
69
+ }
70
+ return method.apply(this, args as any);
71
+ }
72
+ }
73
+ });
74
+
75
+ // Lazy-load mime module to support ESM
76
+ let mimeModule: any = null;
77
+ async function getMime() {
78
+ if (!mimeModule) {
79
+ const imported = await import('mime');
80
+ // Handle both default export and named exports
81
+ mimeModule = imported.default || imported;
82
+ }
83
+ return mimeModule;
84
+ }
85
+
86
+ export interface TestLensReporterConfig {
87
+ /** TestLens API endpoint URL */
88
+ apiEndpoint?: string;
89
+ /** API key for authentication - can be provided in config or via TESTLENS_API_KEY environment variable */
90
+ apiKey?: string;
91
+ /** Enable real-time streaming of test events */
92
+ enableRealTimeStream?: boolean;
93
+ /** Enable Git information collection */
94
+ enableGitInfo?: boolean;
95
+ /** Enable artifact processing */
96
+ enableArtifacts?: boolean;
97
+ /** Enable video capture - defaults to true */
98
+ enableVideo?: boolean;
99
+ /** Enable screenshot capture - defaults to true */
100
+ enableScreenshot?: boolean;
101
+ /** Batch size for API requests */
102
+ batchSize?: number;
103
+ /** Flush interval in milliseconds */
104
+ flushInterval?: number;
105
+ /** Number of retry attempts for failed API calls */
106
+ retryAttempts?: number;
107
+ /** Request timeout in milliseconds */
108
+ timeout?: number;
109
+ /** SSL certificate validation - set to false to disable SSL verification */
110
+ rejectUnauthorized?: boolean;
111
+ /** Alternative SSL option - set to true to ignore SSL certificate errors */
112
+ ignoreSslErrors?: boolean;
113
+ /** Custom metadata from CLI arguments (automatically parsed from --key=value arguments) */
114
+ customMetadata?: Record<string, string | string[]>;
115
+ }
116
+
117
+ export interface TestLensReporterOptions {
118
+ /** TestLens API endpoint URL */
119
+ apiEndpoint?: string;
120
+ /** API key for authentication - can be provided in config or via TESTLENS_API_KEY environment variable */
121
+ apiKey?: string;
122
+ /** Enable real-time streaming of test events */
123
+ enableRealTimeStream?: boolean;
124
+ /** Enable Git information collection */
125
+ enableGitInfo?: boolean;
126
+ /** Enable artifact processing */
127
+ enableArtifacts?: boolean;
128
+ /** Enable video capture - defaults to true */
129
+ enableVideo?: boolean;
130
+ /** Enable screenshot capture - defaults to true */
131
+ enableScreenshot?: boolean;
132
+ /** Batch size for API requests */
133
+ batchSize?: number;
134
+ /** Flush interval in milliseconds */
135
+ flushInterval?: number;
136
+ /** Number of retry attempts for failed API calls */
137
+ retryAttempts?: number;
138
+ /** Request timeout in milliseconds */
139
+ timeout?: number;
140
+ /** SSL certificate validation - set to false to disable SSL verification */
141
+ rejectUnauthorized?: boolean;
142
+ /** Alternative SSL option - set to true to ignore SSL certificate errors */
143
+ ignoreSslErrors?: boolean;
144
+ /** Custom metadata from CLI arguments (automatically parsed from --key=value arguments) */
145
+ customMetadata?: Record<string, string | string[]>;
146
+ }
147
+
148
+ export interface GitInfo {
149
+ branch: string;
150
+ commit: string;
151
+ shortCommit: string;
152
+ author: string;
153
+ message: string;
154
+ timestamp: string;
155
+ isDirty: boolean;
156
+ remoteName: string;
157
+ remoteUrl: string;
158
+ }
159
+
160
+ export interface CodeBlock {
161
+ type: 'test' | 'describe';
162
+ name: string;
163
+ content: string;
164
+ summary?: string;
165
+ describe?: string;
166
+ startLine?: number;
167
+ endLine?: number;
168
+ }
169
+
170
+ export interface RunMetadata {
171
+ id: string;
172
+ startTime: string;
173
+ endTime?: string;
174
+ duration?: number;
175
+ environment: string;
176
+ browser: string;
177
+ os: string;
178
+ playwrightVersion: string;
179
+ nodeVersion: string;
180
+ testlensVersion?: string;
181
+ gitInfo?: GitInfo | null;
182
+ shardInfo?: {
183
+ current: number;
184
+ total: number;
185
+ };
186
+ totalTests?: number;
187
+ passedTests?: number;
188
+ failedTests?: number;
189
+ skippedTests?: number;
190
+ timedOutTests?: number;
191
+ status?: string;
192
+ testlensBuildName?: string;
193
+ customMetadata?: Record<string, string | string[]>;
194
+ }
195
+
196
+ export interface TestError {
197
+ message: string;
198
+ stack?: string;
199
+ location?: {
200
+ file: string;
201
+ line: number;
202
+ column: number;
203
+ };
204
+ snippet?: string;
205
+ expected?: string;
206
+ actual?: string;
207
+ diff?: string;
208
+ matcherName?: string;
209
+ timeout?: number;
210
+ }
211
+
212
+ export interface TestData {
213
+ id: string;
214
+ name: string;
215
+ status: string;
216
+ originalStatus?: string; // Preserve original Playwright status
217
+ duration: number;
218
+ startTime: string;
219
+ endTime: string;
220
+ errorMessages: string[];
221
+ errors?: TestError[]; // Rich error details
222
+ retryAttempts: number;
223
+ currentRetry: number;
224
+ annotations: Array<{ type: string; description?: string }>;
225
+ projectName: string;
226
+ workerIndex?: number;
227
+ parallelIndex?: number;
228
+ location?: {
229
+ file: string;
230
+ line: number;
231
+ column: number;
232
+ };
233
+ }
234
+
235
+ export interface SpecData {
236
+ filePath: string;
237
+ testSuiteName: string;
238
+ tags?: string[];
239
+ startTime: string;
240
+ endTime?: string;
241
+ status: string;
242
+ }
243
+
244
+ export class TestLensReporter implements Reporter {
245
+ private config: Required<TestLensReporterConfig>;
246
+ private axiosInstance: AxiosInstance;
247
+ private runId: string;
248
+ private runMetadata: RunMetadata;
249
+ private specMap: Map<string, SpecData>;
250
+ private testMap: Map<string, TestData>;
251
+ private runCreationFailed: boolean = false; // Track if run creation failed due to limits
252
+ private cliArgs: Record<string, any> = {}; // Store CLI args separately
253
+ private pendingUploads: Set<Promise<any>> = new Set(); // Track pending artifact uploads
254
+ private traceNetworkRows: unknown[] = []; // Network requests/responses from trace zip for current test
255
+ private artifactStats = {
256
+ uploaded: { screenshot: 0, video: 0, trace: 0, attachment: 0 },
257
+ skipped: { screenshot: 0, video: 0, trace: 0, attachment: 0 },
258
+ failed: { screenshot: 0, video: 0, trace: 0, attachment: 0 }
259
+ };
260
+ private artifactsSeen: number = 0;
261
+
262
+ /**
263
+ * Parse custom metadata from environment variables
264
+ * Checks for common metadata environment variables
265
+ */
266
+ private static parseCustomArgs(): Record<string, any> {
267
+ const customArgs: Record<string, any> = {};
268
+
269
+ // Common environment variable names for build metadata
270
+ const envVarMappings: Record<string, string[]> = {
271
+ // Support both TestLens-specific names (recommended) and common CI names
272
+ 'testlensBuildTag': ['testlensBuildTag', 'TESTLENS_BUILD_TAG', 'TESTLENS_BUILDTAG', 'BUILDTAG', 'BUILD_TAG', 'TestlensBuildTag', 'TestLensBuildTag'],
273
+ 'testlensBuildName': ['testlensBuildName', 'TESTLENS_BUILD_NAME', 'TESTLENS_BUILDNAME', 'BUILDNAME', 'BUILD_NAME', 'TestlensBuildName', 'TestLensBuildName'],
274
+ 'environment': ['ENVIRONMENT', 'ENV', 'NODE_ENV', 'DEPLOYMENT_ENV'],
275
+ 'branch': ['BRANCH', 'GIT_BRANCH', 'CI_COMMIT_BRANCH', 'GITHUB_REF_NAME'],
276
+ 'team': ['TEAM', 'TEAM_NAME'],
277
+ 'project': ['PROJECT', 'PROJECT_NAME'],
278
+ 'customvalue': ['CUSTOMVALUE', 'CUSTOM_VALUE']
279
+ };
280
+
281
+ // Check for each metadata key
282
+ Object.entries(envVarMappings).forEach(([key, envVars]) => {
283
+ for (const envVar of envVars) {
284
+ const value = process.env[envVar];
285
+ if (value) {
286
+ // For testlensBuildTag, support comma-separated values
287
+ if (key === 'testlensBuildTag' && value.includes(',')) {
288
+ customArgs[key] = value.split(',').map(tag => tag.trim()).filter(tag => tag);
289
+ logger.debug(`✓ Found ${envVar}=${value} (mapped to '${key}' as array of ${customArgs[key].length} tags)`);
290
+ } else {
291
+ customArgs[key] = value;
292
+ logger.debug(`✓ Found ${envVar}=${value} (mapped to '${key}')`);
293
+ }
294
+ break; // Use first match
295
+ }
296
+ }
297
+ });
298
+
299
+ return customArgs;
300
+ }
301
+
302
+ constructor(options: TestLensReporterOptions) {
303
+ // Parse custom CLI arguments
304
+ const customArgs = TestLensReporter.parseCustomArgs();
305
+ this.cliArgs = customArgs; // Store CLI args separately for later use
306
+
307
+ // Allow API key from environment variable if not provided in config
308
+ // Check multiple environment variable names in priority order (uppercase and lowercase)
309
+ const apiKey = options.apiKey
310
+ || process.env.TESTLENS_API_KEY
311
+ || process.env.testlens_api_key
312
+ || process.env.TESTLENS_KEY
313
+ || process.env.testlens_key
314
+ || process.env.testlensApiKey
315
+ || process.env.PLAYWRIGHT_API_KEY
316
+ || process.env.playwright_api_key
317
+ || process.env.PW_API_KEY
318
+ || process.env.pw_api_key;
319
+
320
+ this.config = {
321
+ apiEndpoint: options.apiEndpoint || 'https://testlens.qa-path.com/api/v1/webhook/playwright',
322
+ apiKey: apiKey, // API key from config or environment variable
323
+ enableRealTimeStream: options.enableRealTimeStream !== undefined ? options.enableRealTimeStream : true,
324
+ enableGitInfo: options.enableGitInfo !== undefined ? options.enableGitInfo : true,
325
+ enableArtifacts: options.enableArtifacts !== undefined ? options.enableArtifacts : true,
326
+ enableVideo: options.enableVideo !== undefined ? options.enableVideo : true, // Default to true, override from config
327
+ enableScreenshot: options.enableScreenshot !== undefined ? options.enableScreenshot : true, // Default to true, override from config
328
+ batchSize: options.batchSize || 10,
329
+ flushInterval: options.flushInterval || 5000,
330
+ retryAttempts: options.retryAttempts !== undefined ? options.retryAttempts : 0,
331
+ timeout: options.timeout || 60000,
332
+ rejectUnauthorized: options.rejectUnauthorized,
333
+ ignoreSslErrors: options.ignoreSslErrors,
334
+ customMetadata: { ...options.customMetadata, ...customArgs } // Config metadata first, then CLI args override
335
+ } as Required<TestLensReporterConfig>;
336
+
337
+ if (!this.config.apiKey) {
338
+ 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.');
339
+ }
340
+
341
+ if (apiKey !== options.apiKey) {
342
+ logger.debug('✓ Using API key from environment variable');
343
+ }
344
+
345
+ // Default environment to allow self-signed certs unless explicitly set
346
+ if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === undefined) {
347
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
348
+ }
349
+
350
+ // Determine SSL validation behavior
351
+ let rejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0'; // Default to secure unless explicitly disabled
352
+
353
+ // Check various ways SSL validation can be disabled or enforced (in order of precedence)
354
+ if (this.config.ignoreSslErrors === true) {
355
+ rejectUnauthorized = false;
356
+ logger.debug('[DEBUG] SSL certificate validation disabled via ignoreSslErrors option');
357
+ } else if (this.config.rejectUnauthorized === false) {
358
+ rejectUnauthorized = false;
359
+ logger.debug('[DEBUG] SSL certificate validation disabled via rejectUnauthorized option');
360
+ } else if (this.config.rejectUnauthorized === true) {
361
+ rejectUnauthorized = true;
362
+ } else if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') {
363
+ rejectUnauthorized = false;
364
+ logger.debug('[DEBUG] SSL certificate validation disabled via NODE_TLS_REJECT_UNAUTHORIZED environment variable');
365
+ } else if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '1') {
366
+ rejectUnauthorized = true;
367
+ }
368
+
369
+ // Mirror the resolved value so all HTTPS requests in this process follow it
370
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = rejectUnauthorized ? '1' : '0';
371
+
372
+ // Set up axios instance with retry logic and enhanced SSL handling
373
+ this.axiosInstance = axios.create({
374
+ baseURL: this.config.apiEndpoint,
375
+ timeout: this.config.timeout,
376
+ headers: {
377
+ 'Content-Type': 'application/json',
378
+ ...(this.config.apiKey && { 'X-API-Key': this.config.apiKey }),
379
+ },
380
+ // Enhanced SSL handling with flexible TLS configuration
381
+ httpsAgent: new https.Agent({
382
+ rejectUnauthorized: rejectUnauthorized,
383
+ // Allow any TLS version for better compatibility
384
+ minVersion: 'TLSv1.2',
385
+ maxVersion: 'TLSv1.3'
386
+ })
387
+ });
388
+
389
+ // Add retry interceptor
390
+ this.axiosInstance.interceptors.response.use(
391
+ (response) => response,
392
+ async (error: any) => {
393
+ const originalRequest = error.config;
394
+
395
+ if (!originalRequest._retry && error.response?.status >= 500) {
396
+ originalRequest._retry = true;
397
+ originalRequest._retryCount = (originalRequest._retryCount || 0) + 1;
398
+
399
+ if (originalRequest._retryCount <= this.config.retryAttempts) {
400
+ // Exponential backoff
401
+ const delay = Math.pow(2, originalRequest._retryCount) * 1000;
402
+ await new Promise(resolve => setTimeout(resolve, delay));
403
+ return this.axiosInstance(originalRequest);
404
+ }
405
+ }
406
+
407
+ return Promise.reject(error);
408
+ }
409
+ );
410
+
411
+ this.runId = randomUUID();
412
+ this.runMetadata = this.initializeRunMetadata();
413
+ this.specMap = new Map<string, SpecData>();
414
+ this.testMap = new Map<string, TestData>();
415
+ this.runCreationFailed = false;
416
+
417
+ // Log custom metadata if any
418
+ if (this.config.customMetadata && Object.keys(this.config.customMetadata).length > 0) {
419
+ logger.debug('\n[METADATA] Custom Metadata Detected:');
420
+ Object.entries(this.config.customMetadata).forEach(([key, value]) => {
421
+ logger.debug(` ${key}: ${value}`);
422
+ });
423
+ logger.debug('');
424
+ }
425
+ }
426
+
427
+ private initializeRunMetadata(): RunMetadata {
428
+ const metadata: RunMetadata = {
429
+ id: this.runId,
430
+ startTime: new Date().toISOString(),
431
+ environment: 'production',
432
+ browser: 'multiple',
433
+ os: `${os.type()} ${os.release()}`,
434
+ playwrightVersion: this.getPlaywrightVersion(),
435
+ nodeVersion: process.version,
436
+ testlensVersion: this.getTestLensVersion()
437
+ };
438
+
439
+ // Add custom metadata if provided
440
+ if (this.config.customMetadata && Object.keys(this.config.customMetadata).length > 0) {
441
+ metadata.customMetadata = this.config.customMetadata;
442
+
443
+ // Extract testlensBuildName as a dedicated field for dashboard display
444
+ if (this.config.customMetadata.testlensBuildName) {
445
+ const buildName = this.config.customMetadata.testlensBuildName;
446
+ // Handle both string and array (take first element if array)
447
+ metadata.testlensBuildName = Array.isArray(buildName) ? buildName[0] : buildName;
448
+ }
449
+ }
450
+
451
+ return metadata;
452
+ }
453
+
454
+ private getPlaywrightVersion(): string {
455
+ try {
456
+ const playwrightPackage = require('@playwright/test/package.json');
457
+ return playwrightPackage.version;
458
+ } catch (error) {
459
+ return 'unknown';
460
+ }
461
+ }
462
+
463
+ private getTestLensVersion(): string {
464
+ try {
465
+ const testlensPackage = require('./package.json');
466
+ return testlensPackage.version;
467
+ } catch (error) {
468
+ return 'unknown';
469
+ }
470
+ }
471
+
472
+ private normalizeTestStatus(status: string): string {
473
+ // Treat timeout as failed for consistency with analytics
474
+ if (status === 'timedOut') {
475
+ return 'failed';
476
+ }
477
+ return status;
478
+ }
479
+
480
+ private normalizeRunStatus(status: string, hasTimeouts: boolean): string {
481
+ // If run has timeouts, treat as failed
482
+ if (hasTimeouts && status === 'passed') {
483
+ return 'failed';
484
+ }
485
+ // Treat timeout status as failed
486
+ if (status === 'timedOut') {
487
+ return 'failed';
488
+ }
489
+ return status;
490
+ }
491
+
492
+ async onBegin(config: FullConfig, suite: Suite): Promise<void> {
493
+ logger.debug(`[REPORTER] source=${__filename} enableArtifacts=${this.config.enableArtifacts}`);
494
+ // Show Build Name if provided, otherwise show Run ID
495
+ if (this.runMetadata.testlensBuildName) {
496
+ logger.debug(`TestLens Reporter starting - Build: ${this.runMetadata.testlensBuildName}`);
497
+ logger.debug(` Run ID: ${this.runId}`);
498
+ } else {
499
+ logger.debug(`TestLens Reporter starting - Run ID: ${this.runId}`);
500
+ }
501
+
502
+ // Collect Git information if enabled
503
+ if (this.config.enableGitInfo) {
504
+ this.runMetadata.gitInfo = await this.collectGitInfo();
505
+ if (this.runMetadata.gitInfo) {
506
+ logger.debug(`Git info collected: branch=${this.runMetadata.gitInfo.branch}, commit=${this.runMetadata.gitInfo.shortCommit}, author=${this.runMetadata.gitInfo.author}`);
507
+ } else {
508
+ logger.warn(`[WARN] Git info collection returned null - not in a git repository or git not available`);
509
+ }
510
+ } else {
511
+ logger.debug(`[INFO] Git info collection disabled (enableGitInfo: false)`);
512
+ }
513
+
514
+ // Add shard information if available
515
+ if (config.shard) {
516
+ this.runMetadata.shardInfo = {
517
+ current: config.shard.current,
518
+ total: config.shard.total
519
+ };
520
+ }
521
+
522
+ // Send run start event to API
523
+ await this.sendToApi({
524
+ type: 'runStart',
525
+ runId: this.runId,
526
+ timestamp: new Date().toISOString(),
527
+ metadata: this.runMetadata
528
+ });
529
+ }
530
+
531
+ async onTestBegin(test: TestCase, result: TestResult): Promise<void> {
532
+ // Log which test is starting
533
+ logger.debug(`[TEST] Running test: ${test.title}`);
534
+
535
+ const specPath = test.location.file;
536
+ const specKey = `${specPath}-${test.parent.title}`;
537
+
538
+ // Create or update spec data
539
+ if (!this.specMap.has(specKey)) {
540
+ const extractedTags = this.extractTags(test);
541
+ const specData: SpecData = {
542
+ filePath: path.relative(process.cwd(), specPath),
543
+ testSuiteName: test.parent.title,
544
+ startTime: new Date().toISOString(),
545
+ status: 'running'
546
+ };
547
+ if (extractedTags.length > 0) {
548
+ specData.tags = extractedTags;
549
+ }
550
+ this.specMap.set(specKey, specData);
551
+
552
+ // Send spec start event to API
553
+ await this.sendToApi({
554
+ type: 'specStart',
555
+ runId: this.runId,
556
+ timestamp: new Date().toISOString(),
557
+ spec: specData
558
+ });
559
+ }
560
+
561
+ const testId = this.getTestId(test);
562
+
563
+ // Only send testStart event on first attempt (retry 0)
564
+ if (result.retry === 0) {
565
+ // Create test data
566
+ const testData: TestData = {
567
+ id: testId,
568
+ name: test.title,
569
+ status: 'running',
570
+ originalStatus: 'running',
571
+ duration: 0,
572
+ startTime: new Date().toISOString(),
573
+ endTime: '',
574
+ errorMessages: [],
575
+ errors: [],
576
+ retryAttempts: test.retries,
577
+ currentRetry: result.retry,
578
+ annotations: test.annotations.map((ann: any) => ({
579
+ type: ann.type,
580
+ description: ann.description
581
+ })),
582
+ projectName: test.parent.project()?.name || 'default',
583
+ workerIndex: result.workerIndex,
584
+ parallelIndex: result.parallelIndex,
585
+ location: {
586
+ file: path.relative(process.cwd(), test.location.file),
587
+ line: test.location.line,
588
+ column: test.location.column
589
+ }
590
+ };
591
+
592
+ this.testMap.set(testData.id, testData);
593
+
594
+ // Send test start event to API
595
+ await this.sendToApi({
596
+ type: 'testStart',
597
+ runId: this.runId,
598
+ timestamp: new Date().toISOString(),
599
+ test: testData
600
+ });
601
+ } else {
602
+ // For retries, just update the existing test data
603
+ const existingTestData = this.testMap.get(testId);
604
+ if (existingTestData) {
605
+ existingTestData.currentRetry = result.retry;
606
+ }
607
+ }
608
+ }
609
+
610
+ async onTestEnd(test: TestCase, result: TestResult): Promise<void> {
611
+ this.traceNetworkRows = []; // Reset at start of each test
612
+ const testId = this.getTestId(test);
613
+ let testCaseId = '';
614
+ let testData = this.testMap.get(testId);
615
+
616
+ logger.debug(`[ARTIFACTS] attachments=${result.attachments?.length ?? 0}`);
617
+
618
+ if (result.attachments && result.attachments.length > 0) {
619
+ for (const attachment of result.attachments) {
620
+ const artifactType = this.getArtifactType(attachment.name);
621
+ this.artifactsSeen += 1;
622
+ if (attachment.path) {
623
+ this.bumpArtifactStat('uploaded', artifactType);
624
+ } else {
625
+ this.bumpArtifactStat('skipped', artifactType);
626
+ }
627
+ }
628
+ }
629
+
630
+ logger.debug(`[TestLens] onTestEnd called for test: ${test.title}, status: ${result.status}, testData exists: ${!!testData}`);
631
+
632
+ // For skipped tests, onTestBegin might not be called, so we need to create the test data here
633
+ if (!testData) {
634
+ logger.debug(`[TestLens] Creating test data for skipped/uncreated test: ${test.title}`);
635
+ // Create spec data if not exists (skipped tests might not have spec data either)
636
+ const specPath = test.location.file;
637
+ const specKey = `${specPath}-${test.parent.title}`;
638
+
639
+ if (!this.specMap.has(specKey)) {
640
+ const extractedTags = this.extractTags(test);
641
+ const specData: SpecData = {
642
+ filePath: path.relative(process.cwd(), specPath),
643
+ testSuiteName: test.parent.title,
644
+ startTime: new Date().toISOString(),
645
+ status: 'skipped'
646
+ };
647
+ if (extractedTags.length > 0) {
648
+ specData.tags = extractedTags;
649
+ }
650
+ this.specMap.set(specKey, specData);
651
+
652
+ // Send spec start event to API
653
+ await this.sendToApi({
654
+ type: 'specStart',
655
+ runId: this.runId,
656
+ timestamp: new Date().toISOString(),
657
+ spec: specData
658
+ });
659
+ }
660
+
661
+ // Create test data for skipped test
662
+ testData = {
663
+ id: testId,
664
+ name: test.title,
665
+ status: 'skipped',
666
+ originalStatus: 'skipped',
667
+ duration: 0,
668
+ startTime: new Date().toISOString(),
669
+ endTime: new Date().toISOString(),
670
+ errorMessages: [],
671
+ errors: [],
672
+ retryAttempts: test.retries,
673
+ currentRetry: 0,
674
+ annotations: test.annotations.map((ann: any) => ({
675
+ type: ann.type,
676
+ description: ann.description
677
+ })),
678
+ projectName: test.parent.project()?.name || 'default',
679
+ workerIndex: result.workerIndex,
680
+ parallelIndex: result.parallelIndex,
681
+ location: {
682
+ file: path.relative(process.cwd(), test.location.file),
683
+ line: test.location.line,
684
+ column: test.location.column
685
+ }
686
+ };
687
+
688
+ this.testMap.set(testId, testData);
689
+
690
+ // Send test start event first (so the test gets created in DB)
691
+ await this.sendToApi({
692
+ type: 'testStart',
693
+ runId: this.runId,
694
+ timestamp: new Date().toISOString(),
695
+ test: testData
696
+ });
697
+ }
698
+
699
+ if (testData) {
700
+ // Update test data with latest result
701
+ testData.originalStatus = result.status;
702
+ testData.status = this.normalizeTestStatus(result.status);
703
+ testData.duration = result.duration;
704
+ testData.endTime = new Date().toISOString();
705
+ testData.errorMessages = result.errors.map((error: any) => error.message || error.toString());
706
+ testData.currentRetry = result.retry;
707
+
708
+ // Capture test location
709
+ testData.location = {
710
+ file: path.relative(process.cwd(), test.location.file),
711
+ line: test.location.line,
712
+ column: test.location.column
713
+ };
714
+
715
+ // Capture rich error details like Playwright's HTML report
716
+ testData.errors = result.errors.map((error: any) => {
717
+ const testError: TestError = {
718
+ message: error.message || error.toString()
719
+ };
720
+
721
+ // Capture stack trace
722
+ if (error.stack) {
723
+ testError.stack = error.stack;
724
+ }
725
+
726
+ // Capture error location
727
+ if (error.location) {
728
+ testError.location = {
729
+ file: path.relative(process.cwd(), error.location.file),
730
+ line: error.location.line,
731
+ column: error.location.column
732
+ };
733
+ }
734
+
735
+ // Capture code snippet around error - from Playwright error object
736
+ if (error.snippet) {
737
+ testError.snippet = error.snippet;
738
+ }
739
+
740
+ // Capture expected/actual values for assertion failures
741
+ // Playwright stores these as specially formatted strings in the message
742
+ const message = error.message || '';
743
+
744
+ // Try to parse expected pattern from toHaveURL and similar assertions
745
+ const expectedPatternMatch = message.match(/Expected pattern:\s*(.+?)(?:\n|$)/);
746
+ if (expectedPatternMatch) {
747
+ testError.expected = expectedPatternMatch[1].trim();
748
+ }
749
+
750
+ // Also try "Expected string:" format
751
+ if (!testError.expected) {
752
+ const expectedStringMatch = message.match(/Expected string:\s*["']?(.+?)["']?(?:\n|$)/);
753
+ if (expectedStringMatch) {
754
+ testError.expected = expectedStringMatch[1].trim();
755
+ }
756
+ }
757
+
758
+ // Try to parse received/actual value
759
+ const receivedMatch = message.match(/Received (?:string|value):\s*["']?(.+?)["']?(?:\n|$)/);
760
+ if (receivedMatch) {
761
+ testError.actual = receivedMatch[1].trim();
762
+ }
763
+
764
+ // Parse call log entries for debugging info (timeouts, retries, etc.)
765
+ const callLogMatch = message.match(/Call log:([\s\S]*?)(?=\n\n|\n\s*\d+\s*\||$)/);
766
+ if (callLogMatch) {
767
+ // Store call log separately for display
768
+ const callLog = callLogMatch[1].trim();
769
+ if (callLog) {
770
+ testError.diff = callLog; // Reuse diff field for call log
771
+ }
772
+ }
773
+
774
+ // Parse timeout information - multiple formats
775
+ const timeoutMatch = message.match(/(?:with timeout|Timeout:?)\s*(\d+)ms/i);
776
+ if (timeoutMatch) {
777
+ testError.timeout = parseInt(timeoutMatch[1], 10);
778
+ }
779
+
780
+ // Parse matcher name (e.g., toHaveURL, toBeVisible)
781
+ const matcherMatch = message.match(/expect\([^)]+\)\.(\w+)/);
782
+ if (matcherMatch) {
783
+ testError.matcherName = matcherMatch[1];
784
+ }
785
+
786
+ // Extract code snippet from message if not already captured
787
+ // Look for lines like " 9 | await page.click..." or "> 11 | await expect..."
788
+ if (!testError.snippet) {
789
+ const codeSnippetMatch = message.match(/((?:\s*>?\s*\d+\s*\|.*\n?)+)/);
790
+ if (codeSnippetMatch) {
791
+ testError.snippet = codeSnippetMatch[1].trim();
792
+ }
793
+ }
794
+
795
+ return testError;
796
+ });
797
+
798
+ // Send testEnd event for all tests, regardless of status
799
+ // This ensures tests that are interrupted or have unexpected statuses are properly recorded
800
+ logger.debug(`[TestLens] Sending testEnd - testId: ${testData.id}, status: ${testData.status}, originalStatus: ${testData.originalStatus}`);
801
+ // Send test end event to API and get response
802
+ const testEndResponse = await this.sendToApi({
803
+ type: 'testEnd',
804
+ runId: this.runId,
805
+ timestamp: new Date().toISOString(),
806
+ test: testData
807
+ });
808
+ testCaseId = testEndResponse?.testCaseId;
809
+
810
+ // Handle artifacts (test case is now guaranteed to be in database)
811
+ if (this.config.enableArtifacts) {
812
+ // Pass test case DB ID if available for faster lookups; pass status/endTime so backend
813
+ // can fix up test case status if testEnd failed but artifact request succeeds
814
+ await this.processArtifacts(testId, result, testEndResponse?.testCaseId, {
815
+ status: testData.status,
816
+ endTime: testData.endTime
817
+ });
818
+ } else if (result.attachments && result.attachments.length > 0) {
819
+ for (const attachment of result.attachments) {
820
+ const artifactType = this.getArtifactType(attachment.name);
821
+ this.artifactsSeen += 1;
822
+ this.bumpArtifactStat('skipped', artifactType);
823
+ }
824
+ }
825
+ }
826
+
827
+ // Update spec status
828
+ const specPath = test.location.file;
829
+ const specKey = `${specPath}-${test.parent.title}`;
830
+ const specData = this.specMap.get(specKey);
831
+ if (specData) {
832
+ const normalizedStatus = this.normalizeTestStatus(result.status);
833
+ if (normalizedStatus === 'failed' && specData.status !== 'failed') {
834
+ specData.status = 'failed';
835
+ } else if (result.status === 'skipped' && specData.status === 'passed') {
836
+ specData.status = 'skipped';
837
+ }
838
+
839
+ // Check if all tests in spec are complete
840
+ // Only consider tests that were actually executed (have testData)
841
+ const remainingTests = test.parent.tests.filter((t: any) => {
842
+ const tId = this.getTestId(t);
843
+ const tData = this.testMap.get(tId);
844
+ // If testData exists but no endTime, it's still running
845
+ return tData && !tData.endTime;
846
+ });
847
+
848
+ if (remainingTests.length === 0) {
849
+ // Determine final spec status based on all executed tests
850
+ const executedTests = test.parent.tests
851
+ .map((t: any) => {
852
+ const tId = this.getTestId(t);
853
+ return this.testMap.get(tId);
854
+ })
855
+ .filter((tData: TestData | undefined): tData is TestData => !!tData);
856
+
857
+ if (executedTests.length > 0) {
858
+ const allTestStatuses = executedTests.map(tData => tData.status);
859
+ if (allTestStatuses.every(status => status === 'passed')) {
860
+ specData.status = 'passed';
861
+ } else if (allTestStatuses.some(status => status === 'failed')) {
862
+ specData.status = 'failed';
863
+ } else if (allTestStatuses.every(status => status === 'skipped')) {
864
+ specData.status = 'skipped';
865
+ }
866
+ }
867
+ // Aggregate tags from all tests in this spec
868
+ const allTags = new Set<string>();
869
+ test.parent.tests.forEach((t: any) => {
870
+ const tags = this.extractTags(t);
871
+ tags.forEach(tag => allTags.add(tag));
872
+ });
873
+ const aggregatedTags = Array.from(allTags);
874
+ // Only update tags if we have any
875
+ if (aggregatedTags.length > 0) {
876
+ specData.tags = aggregatedTags;
877
+ }
878
+
879
+ specData.endTime = new Date().toISOString();
880
+
881
+ // Send spec end event to API
882
+ await this.sendToApi({
883
+ type: 'specEnd',
884
+ runId: this.runId,
885
+ timestamp: new Date().toISOString(),
886
+ spec: specData
887
+ });
888
+
889
+ // Send spec code blocks to API
890
+ await this.sendSpecCodeBlocks(specPath, test.title, testData?.errors || [], this.runId, testId, testCaseId);
891
+ }
892
+ }
893
+ }
894
+
895
+ async onEnd(result: { status: string }): Promise<void> {
896
+ this.runMetadata.endTime = new Date().toISOString();
897
+ this.runMetadata.duration = Date.now() - new Date(this.runMetadata.startTime).getTime();
898
+
899
+ // Wait for all pending artifact uploads to complete before sending runEnd
900
+ if (this.pendingUploads.size > 0) {
901
+ logger.debug(`Waiting for ${this.pendingUploads.size} artifact upload(s) to complete...`);
902
+ try {
903
+ await Promise.all(Array.from(this.pendingUploads));
904
+ logger.debug(`[OK] All artifact uploads completed`);
905
+ } catch (error) {
906
+ logger.error(`[WARN] Some artifact uploads failed, continuing with runEnd`);
907
+ }
908
+ }
909
+
910
+ const uploaded = this.artifactStats.uploaded;
911
+ const skipped = this.artifactStats.skipped;
912
+ const failed = this.artifactStats.failed;
913
+ // Always show summary so users can confirm artifact settings
914
+ const summary =
915
+ `[ARTIFACTS] seen=${this.artifactsSeen} | ` +
916
+ `uploaded screenshot=${uploaded.screenshot}, video=${uploaded.video}, trace=${uploaded.trace}, attachment=${uploaded.attachment} | ` +
917
+ `skipped screenshot=${skipped.screenshot}, video=${skipped.video}, trace=${skipped.trace}, attachment=${skipped.attachment} | ` +
918
+ `failed screenshot=${failed.screenshot}, video=${failed.video}, trace=${failed.trace}, attachment=${failed.attachment}`;
919
+ if (logger.levelVal > 30) {
920
+ console.log(summary);
921
+ } else {
922
+ logger.debug(summary);
923
+ }
924
+
925
+ // Calculate final stats
926
+ const totalTests = Array.from(this.testMap.values()).length;
927
+ const passedTests = Array.from(this.testMap.values()).filter(t => t.status === 'passed').length;
928
+ // failedTests already includes timedOut tests since normalizeTestStatus converts 'timedOut' to 'failed'
929
+ const failedTests = Array.from(this.testMap.values()).filter(t => t.status === 'failed').length;
930
+ const skippedTests = Array.from(this.testMap.values()).filter(t => t.status === 'skipped').length;
931
+ // Track timedOut separately for reporting purposes only (not for count)
932
+ const timedOutTests = Array.from(this.testMap.values()).filter(t => t.originalStatus === 'timedOut').length;
933
+
934
+ // Normalize run status - if there are timeouts, treat run as failed
935
+ const hasTimeouts = timedOutTests > 0;
936
+ const normalizedRunStatus = this.normalizeRunStatus(result.status, hasTimeouts);
937
+
938
+ // Send run end event to API
939
+ await this.sendToApi({
940
+ type: 'runEnd',
941
+ runId: this.runId,
942
+ timestamp: new Date().toISOString(),
943
+ metadata: {
944
+ ...this.runMetadata,
945
+ totalTests,
946
+ passedTests,
947
+ failedTests, // Already includes timedOut tests (normalized to 'failed')
948
+ skippedTests,
949
+ timedOutTests, // For informational purposes
950
+ status: normalizedRunStatus
951
+ }
952
+ });
953
+
954
+ // Show Build Name if provided, otherwise show Run ID
955
+ if (this.runMetadata.testlensBuildName) {
956
+ logger.debug(`[COMPLETE] TestLens Report completed - Build: ${this.runMetadata.testlensBuildName}`);
957
+ logger.debug(` Run ID: ${this.runId}`);
958
+ } else {
959
+ logger.debug(`[COMPLETE] TestLens Report completed - Run ID: ${this.runId}`);
960
+ }
961
+ logger.debug(`[RESULTS] ${passedTests} passed, ${failedTests} failed (${timedOutTests} timeouts), ${skippedTests} skipped`);
962
+ }
963
+
964
+ private async sendToApi(payload: any): Promise<any> {
965
+ // Skip sending if run creation already failed
966
+ if (this.runCreationFailed && payload.type !== 'runStart') {
967
+ return null;
968
+ }
969
+
970
+ try {
971
+ const response = await this.axiosInstance.post('', payload, {
972
+ headers: {
973
+ 'X-API-Key': this.config.apiKey
974
+ }
975
+ });
976
+ if (this.config.enableRealTimeStream) {
977
+ logger.debug(`[OK] Sent ${payload.type} event to TestLens (HTTP ${response.status})`);
978
+ }
979
+ // Return response data for caller to use
980
+ return response.data;
981
+ } catch (error: any) {
982
+ const errorData = error?.response?.data;
983
+ const status = error?.response?.status;
984
+
985
+ // Check for limit exceeded (403)
986
+ if (status === 403 && errorData?.error === 'limit_exceeded') {
987
+ // Set flag to skip subsequent events
988
+ if (payload.type === 'runStart' && errorData?.limit_type === 'test_runs') {
989
+ this.runCreationFailed = true;
990
+ }
991
+
992
+ logger.error('\n' + '='.repeat(80));
993
+ if (errorData?.limit_type === 'test_cases') {
994
+ logger.error('[ERROR] TESTLENS ERROR: Test Cases Limit Reached');
995
+ } else if (errorData?.limit_type === 'test_runs') {
996
+ logger.error('[ERROR] TESTLENS ERROR: Test Runs Limit Reached');
997
+ } else {
998
+ logger.error('[ERROR] TESTLENS ERROR: Plan Limit Reached');
999
+ }
1000
+ logger.error('='.repeat(80));
1001
+ logger.error('');
1002
+ logger.error(errorData?.message || 'You have reached your plan limit.');
1003
+ logger.error('');
1004
+ logger.error(`Current usage: ${errorData?.current_usage || 'N/A'} / ${errorData?.limit || 'N/A'}`);
1005
+ logger.error('');
1006
+ logger.error('To continue, please upgrade your plan.');
1007
+ logger.error('Contact: support@alternative-path.com');
1008
+ logger.error('');
1009
+ logger.error('='.repeat(80));
1010
+ logger.error('');
1011
+ return; // Don't log the full error object for limit errors
1012
+ }
1013
+
1014
+ // Check for trial expiration, subscription errors, or limit errors (401)
1015
+ if (status === 401) {
1016
+ if (errorData?.error === 'trial_expired' || errorData?.error === 'subscription_inactive' ||
1017
+ errorData?.error === 'test_cases_limit_reached' || errorData?.error === 'test_runs_limit_reached') {
1018
+ logger.error('\n' + '='.repeat(80));
1019
+
1020
+ if (errorData?.error === 'test_cases_limit_reached') {
1021
+ logger.error('[ERROR] TESTLENS ERROR: Test Cases Limit Reached');
1022
+ } else if (errorData?.error === 'test_runs_limit_reached') {
1023
+ logger.error('[ERROR] TESTLENS ERROR: Test Runs Limit Reached');
1024
+ } else {
1025
+ logger.error('[ERROR] TESTLENS ERROR: Your trial plan has ended');
1026
+ }
1027
+
1028
+ logger.error('='.repeat(80));
1029
+ logger.error('');
1030
+ logger.error(errorData?.message || 'Your trial period has expired.');
1031
+ logger.error('');
1032
+ logger.error('To continue using TestLens, please upgrade to Enterprise plan.');
1033
+ logger.error('Contact: ' + (errorData?.contactEmail || 'support@alternative-path.com'));
1034
+ logger.error('');
1035
+ if (errorData?.trial_end_date) {
1036
+ logger.error(`Trial ended: ${new Date(errorData.trial_end_date).toLocaleDateString()}`);
1037
+ logger.error('');
1038
+ }
1039
+ logger.error('='.repeat(80));
1040
+ logger.error('');
1041
+ } else {
1042
+ logger.error(`[ERROR] Authentication failed: ${errorData?.error || 'Invalid API key'}`);
1043
+ }
1044
+ } else if (status !== 403) {
1045
+ // Log other errors (but not 403 which we handled above)
1046
+ logger.error({
1047
+ message: `[ERROR] Failed to send ${payload.type} event to TestLens`,
1048
+ error: error?.message || 'Unknown error',
1049
+ status: status,
1050
+ statusText: error?.response?.statusText,
1051
+ data: errorData,
1052
+ code: error?.code,
1053
+ url: error?.config?.url,
1054
+ method: error?.config?.method
1055
+ });
1056
+ }
1057
+
1058
+ // Don't throw error to avoid breaking test execution
1059
+ }
1060
+ }
1061
+
1062
+ private async processArtifacts(
1063
+ testId: string,
1064
+ result: TestResult,
1065
+ testCaseDbId?: string,
1066
+ testEndPayload?: { status: string; endTime: string }
1067
+ ): Promise<void> {
1068
+ // Skip artifact processing if run creation failed
1069
+ if (this.runCreationFailed) {
1070
+ return;
1071
+ }
1072
+
1073
+ const attachments = result.attachments;
1074
+
1075
+ for (const attachment of attachments) {
1076
+ const artifactType = this.getArtifactType(attachment.name);
1077
+ if (attachment.path) {
1078
+ // Check if attachment should be processed based on config
1079
+ const isVideo = artifactType === 'video' || attachment.contentType?.startsWith('video/');
1080
+ const isScreenshot = artifactType === 'screenshot' || attachment.contentType?.startsWith('image/');
1081
+
1082
+ // Skip video if disabled in config
1083
+ if (isVideo && !this.config.enableVideo) {
1084
+ logger.debug(`[SKIP] Skipping video artifact ${attachment.name} - video capture disabled in config`);
1085
+ this.bumpArtifactStat('skipped', artifactType);
1086
+ continue;
1087
+ }
1088
+
1089
+ // Skip screenshot if disabled in config
1090
+ if (isScreenshot && !this.config.enableScreenshot) {
1091
+ logger.debug(`[SKIP] Skipping screenshot artifact ${attachment.name} - screenshot capture disabled in config`);
1092
+ this.bumpArtifactStat('skipped', artifactType);
1093
+ continue;
1094
+ }
1095
+
1096
+ // Trace zip (default from Playwright): extract network-like data and print to console
1097
+ const isTraceZip = artifactType === 'trace' && attachment.path && (attachment.path.endsWith('.zip') || attachment.contentType === 'application/zip');
1098
+ if (isTraceZip) {
1099
+ try {
1100
+ const traceStart = Date.now();
1101
+ const AdmZip = require('adm-zip');
1102
+ const zip = new AdmZip(attachment.path);
1103
+ const entries = zip.getEntries();
1104
+ const networkRows: unknown[] = [];
1105
+ for (const e of entries) {
1106
+ if (e.isDirectory || !e.getData) continue;
1107
+ const name = (e.entryName || '').toLowerCase();
1108
+ const isNetworkFile = name.endsWith('.network') || name.includes('.network') || name === 'network';
1109
+ if (!isNetworkFile) continue;
1110
+ const data = e.getData();
1111
+ if (!data) continue;
1112
+ const text = data.toString('utf8');
1113
+ if (!text || text.length < 2) continue;
1114
+ const isApplicationJson = (o: unknown): boolean => {
1115
+ if (!o || typeof o !== 'object') return false;
1116
+ const res = (o as any).snapshot?.response ?? (o as any).response;
1117
+ if (!res) return false;
1118
+ const mime = res.content?.mimeType;
1119
+ if (typeof mime === 'string') {
1120
+ const type = mime.split(';')[0].trim().toLowerCase();
1121
+ return type === 'application/json';
1122
+ }
1123
+ const headers = res.headers;
1124
+ if (Array.isArray(headers)) {
1125
+ const ct = headers.find((h: any) => (h.name || '').toLowerCase() === 'content-type');
1126
+ const val = ct?.value;
1127
+ if (typeof val === 'string') {
1128
+ const type = val.split(';')[0].trim().toLowerCase();
1129
+ return type === 'application/json';
1130
+ }
1131
+ }
1132
+ return false;
1133
+ };
1134
+ const lines = text.split(/\r?\n/).filter(Boolean);
1135
+ for (const line of lines) {
1136
+ try {
1137
+ const obj = JSON.parse(line);
1138
+ if (obj && typeof obj === 'object' && isApplicationJson(obj)) networkRows.push(obj);
1139
+ } catch (_) { }
1140
+ }
1141
+ if (networkRows.length === 0 && lines.length > 0) {
1142
+ try {
1143
+ const arr = JSON.parse(text);
1144
+ if (Array.isArray(arr)) networkRows.push(...arr.filter((x: unknown) => x != null && typeof x === 'object' && isApplicationJson(x)));
1145
+ } catch (_) { }
1146
+ }
1147
+ }
1148
+ const durationMs = Date.now() - traceStart;
1149
+ if (networkRows.length > 0) {
1150
+ this.traceNetworkRows = networkRows;
1151
+ } else {
1152
+ this.traceNetworkRows = [];
1153
+ }
1154
+ } catch (e) {
1155
+ logger.warn('[TRACE] Could not read trace zip: ' + (e && (e as Error).message));
1156
+ this.traceNetworkRows = [];
1157
+ }
1158
+ }
1159
+
1160
+ try {
1161
+ // Determine proper filename with extension
1162
+ // Playwright attachment.name often doesn't have extension, so we need to derive it
1163
+ let fileName = attachment.name;
1164
+ const existingExt = path.extname(fileName);
1165
+
1166
+ if (!existingExt) {
1167
+ // Get extension from the actual file path
1168
+ const pathExt = path.extname(attachment.path);
1169
+ if (pathExt) {
1170
+ fileName = `${fileName}${pathExt}`;
1171
+ } else if (attachment.contentType) {
1172
+ // Fallback: derive extension from contentType
1173
+ const mimeToExt: Record<string, string> = {
1174
+ 'image/png': '.png',
1175
+ 'image/jpeg': '.jpg',
1176
+ 'image/gif': '.gif',
1177
+ 'image/webp': '.webp',
1178
+ 'video/webm': '.webm',
1179
+ 'video/mp4': '.mp4',
1180
+ 'application/zip': '.zip',
1181
+ 'application/json': '.json',
1182
+ 'text/plain': '.txt'
1183
+ };
1184
+ const ext = mimeToExt[attachment.contentType];
1185
+ if (ext) {
1186
+ fileName = `${fileName}${ext}`;
1187
+ }
1188
+ }
1189
+ }
1190
+
1191
+ // Upload to S3 first (pass DB ID if available for faster lookup)
1192
+ // Create upload promise that we can track
1193
+ const uploadPromise = Promise.resolve().then(async () => {
1194
+ try {
1195
+ if (!attachment.path) {
1196
+ logger.debug(`[SKIP] [Test: ${testId.substring(0, 8)}...] Skipping artifact ${attachment.name} - no file path`);
1197
+ this.bumpArtifactStat('skipped', artifactType);
1198
+ return;
1199
+ }
1200
+
1201
+ const s3Data = await this.uploadArtifactToS3(attachment.path, testId, fileName, testCaseDbId);
1202
+
1203
+ // Skip if upload failed or file was too large
1204
+ if (!s3Data) {
1205
+ logger.debug(`[SKIP] [Test: ${testId.substring(0, 8)}...] Skipping artifact ${attachment.name} - upload failed or file too large`);
1206
+ this.bumpArtifactStat('failed', artifactType);
1207
+ return;
1208
+ }
1209
+
1210
+ const artifactData = {
1211
+ testId,
1212
+ type: this.getArtifactType(attachment.name),
1213
+ path: attachment.path,
1214
+ name: fileName,
1215
+ contentType: attachment.contentType,
1216
+ fileSize: this.getFileSize(attachment.path),
1217
+ storageType: 's3',
1218
+ s3Key: s3Data.key,
1219
+ s3Url: s3Data.url,
1220
+ // So backend can fix test case status if testEnd failed but artifact succeeded
1221
+ ...(testEndPayload && {
1222
+ testStatus: testEndPayload.status,
1223
+ testEndTime: testEndPayload.endTime
1224
+ })
1225
+ };
1226
+
1227
+ // Send artifact data to API
1228
+ await this.sendToApi({
1229
+ type: 'artifact',
1230
+ runId: this.runId,
1231
+ timestamp: new Date().toISOString(),
1232
+ artifact: artifactData
1233
+ });
1234
+
1235
+ logger.debug(`[ARTIFACT] [Test: ${testId.substring(0, 8)}...] Processed artifact: ${fileName}`);
1236
+ } catch (error) {
1237
+ logger.error(`[ERROR] [Test: ${testId.substring(0, 8)}...] Failed to process artifact ${attachment.name}: ${(error as Error).message}`);
1238
+ this.bumpArtifactStat('failed', artifactType);
1239
+ }
1240
+ });
1241
+
1242
+ // Track this upload and ensure cleanup on completion
1243
+ this.pendingUploads.add(uploadPromise);
1244
+ uploadPromise.finally(() => {
1245
+ this.pendingUploads.delete(uploadPromise);
1246
+ });
1247
+
1248
+ // Don't await here - let uploads happen in parallel
1249
+ // They will be awaited in onEnd
1250
+ } catch (error) {
1251
+ logger.error(`[ERROR] [Test: ${testId.substring(0, 8)}...] Failed to setup artifact upload ${attachment.name}: ${(error as Error).message}`);
1252
+ this.bumpArtifactStat('failed', artifactType);
1253
+ }
1254
+ } else {
1255
+ this.bumpArtifactStat('skipped', artifactType);
1256
+ }
1257
+ }
1258
+ }
1259
+
1260
+ private async sendSpecCodeBlocks(specPath: string, testName: string, errors: unknown[], runId: string, test_id: string, testCaseId: string): Promise<void> {
1261
+ try {
1262
+ // Extract code blocks using built-in parser
1263
+ const testBlocks = this.extractTestBlocks(specPath);
1264
+
1265
+ // Transform blocks to match backend API expectations
1266
+ const codeBlocks = testBlocks.filter(block => block.name === testName).map(block => ({
1267
+ type: block.type, // 'test' or 'describe'
1268
+ name: block.name, // test/describe name
1269
+ content: block.content, // full code content
1270
+ summary: null, // optional
1271
+ describe: block.describe // parent describe block name
1272
+ }));
1273
+
1274
+ // Send to dedicated spec code blocks API endpoint
1275
+ // Extract base URL - handle both full and partial endpoint patterns
1276
+ let baseUrl = this.config.apiEndpoint.replace('/api/v1/webhook/playwright', '');
1277
+ if (baseUrl === this.config.apiEndpoint) {
1278
+ // Fallback: try alternative pattern if main pattern didn't match
1279
+ baseUrl = this.config.apiEndpoint.replace('/webhook/playwright', '');
1280
+ }
1281
+ const specEndpoint = `${baseUrl}/api/v1/webhook/playwright/spec-code-blocks`;
1282
+
1283
+ await this.axiosInstance.post(specEndpoint, {
1284
+ filePath: path.relative(process.cwd(), specPath),
1285
+ codeBlocks,
1286
+ errors,
1287
+ traceNetworkRows: this.traceNetworkRows,
1288
+ testSuiteName: path.basename(specPath).replace(/\.(spec|test)\.(js|ts)$/, ''),
1289
+ runId,
1290
+ test_id,
1291
+ testCaseId
1292
+ });
1293
+
1294
+ logger.debug(`Sent ${codeBlocks.length} code blocks for: ${path.basename(specPath)}`);
1295
+ } catch (error: any) {
1296
+ const errorData = error?.response?.data;
1297
+
1298
+ // Handle duplicate spec code blocks gracefully (when re-running tests)
1299
+ if (errorData?.error && errorData.error.includes('duplicate key value violates unique constraint')) {
1300
+ logger.debug(`[INFO] Spec code blocks already exist for: ${path.basename(specPath)} (skipped)`);
1301
+ return;
1302
+ }
1303
+
1304
+ logger.error(`Failed to send spec code blocks: ${errorData || error?.message || 'Unknown error'}`);
1305
+ }
1306
+ }
1307
+
1308
+ private extractTestBlocks(filePath: string): CodeBlock[] {
1309
+ try {
1310
+ const content = fs.readFileSync(filePath, 'utf-8');
1311
+ const blocks: CodeBlock[] = [];
1312
+ const lines = content.split('\n');
1313
+
1314
+ // Use a stack to track nested describe blocks with their brace depths
1315
+ const describeStack: { name: string; braceDepth: number }[] = [];
1316
+ let globalBraceCount = 0;
1317
+ let inBlock = false;
1318
+ let blockStart = -1;
1319
+ let blockBraceCount = 0;
1320
+ let blockType: 'test' | 'describe' = 'test';
1321
+ let blockName = '';
1322
+ let blockDescribe: string | undefined = undefined;
1323
+
1324
+ for (let i = 0; i < lines.length; i++) {
1325
+ const line = lines[i];
1326
+ const trimmedLine = line.trim();
1327
+
1328
+ if (!inBlock) {
1329
+ // Check for describe blocks: describe(), test.describe(), test.describe.serial(), etc.
1330
+ const describeMatch = trimmedLine.match(/(?:test\.)?describe(?:\.(?:serial|parallel|only|skip|fixme))?\s*\(\s*['"`]([^'"`]+)['"`]/);
1331
+ if (describeMatch) {
1332
+ // Count braces on this line to find the opening brace
1333
+ let lineOpenBraces = 0;
1334
+ for (const char of line) {
1335
+ if (char === '{') {
1336
+ globalBraceCount++;
1337
+ lineOpenBraces++;
1338
+ }
1339
+ if (char === '}') globalBraceCount--;
1340
+ }
1341
+ // Push describe onto stack with the current brace depth
1342
+ describeStack.push({ name: describeMatch[1], braceDepth: globalBraceCount });
1343
+ continue;
1344
+ }
1345
+
1346
+ // Check for test blocks: test(), test.only(), test.skip(), test.fixme(), it(), it.only(), etc.
1347
+ const testMatch = trimmedLine.match(/(?:test|it)(?:\.(?:only|skip|fixme|slow))?\s*\(\s*['"`]([^'"`]+)['"`]/);
1348
+ if (testMatch) {
1349
+ blockType = 'test';
1350
+ blockName = testMatch[1];
1351
+ blockStart = i;
1352
+ blockBraceCount = 0;
1353
+ // Capture the current innermost describe name
1354
+ blockDescribe = describeStack.length > 0 ? describeStack[describeStack.length - 1].name : undefined;
1355
+ inBlock = true;
1356
+ }
1357
+ }
1358
+
1359
+ // Count braces
1360
+ if (inBlock) {
1361
+ for (const char of line) {
1362
+ if (char === '{') {
1363
+ blockBraceCount++;
1364
+ globalBraceCount++;
1365
+ }
1366
+ if (char === '}') {
1367
+ blockBraceCount--;
1368
+ globalBraceCount--;
1369
+ }
1370
+
1371
+ if (blockBraceCount === 0 && blockStart !== -1 && i > blockStart) {
1372
+ // End of test block found
1373
+ const blockContent = lines.slice(blockStart, i + 1).join('\n');
1374
+
1375
+ blocks.push({
1376
+ type: blockType,
1377
+ name: blockName,
1378
+ content: blockContent,
1379
+ describe: blockDescribe,
1380
+ startLine: blockStart + 1,
1381
+ endLine: i + 1
1382
+ });
1383
+
1384
+ inBlock = false;
1385
+ blockStart = -1;
1386
+
1387
+ // Pop any describe blocks that have closed
1388
+ while (describeStack.length > 0 && globalBraceCount < describeStack[describeStack.length - 1].braceDepth) {
1389
+ describeStack.pop();
1390
+ }
1391
+ break;
1392
+ }
1393
+ }
1394
+ } else {
1395
+ // Track braces outside of test blocks (for describe open/close)
1396
+ for (const char of line) {
1397
+ if (char === '{') globalBraceCount++;
1398
+ if (char === '}') {
1399
+ globalBraceCount--;
1400
+ // Pop any describe blocks that have closed
1401
+ while (describeStack.length > 0 && globalBraceCount < describeStack[describeStack.length - 1].braceDepth) {
1402
+ describeStack.pop();
1403
+ }
1404
+ }
1405
+ }
1406
+ }
1407
+ }
1408
+
1409
+ return blocks;
1410
+ } catch (error: any) {
1411
+ logger.error(`Failed to extract test blocks from ${filePath}: ${error.message}`);
1412
+ return [];
1413
+ }
1414
+ }
1415
+
1416
+ private async collectGitInfo(): Promise<GitInfo | null> {
1417
+ try {
1418
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
1419
+ const commit = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
1420
+ const shortCommit = commit.substring(0, 7);
1421
+ const author = execSync('git log -1 --pretty=format:"%an"', { encoding: 'utf-8' }).trim();
1422
+ const commitMessage = execSync('git log -1 --pretty=format:"%s"', { encoding: 'utf-8' }).trim();
1423
+ const commitTimestamp = execSync('git log -1 --pretty=format:"%ci"', { encoding: 'utf-8' }).trim();
1424
+
1425
+ let remoteName = 'origin';
1426
+ let remoteUrl = '';
1427
+ try {
1428
+ const remotes = execSync('git remote', { encoding: 'utf-8' }).trim();
1429
+ if (remotes) {
1430
+ remoteName = remotes.split('\n')[0] || 'origin';
1431
+ remoteUrl = execSync(`git remote get-url ${remoteName}`, { encoding: 'utf-8' }).trim();
1432
+ }
1433
+ } catch (e) {
1434
+ // Remote info is optional - handle gracefully
1435
+ logger.debug('[INFO] No git remote configured, skipping remote info');
1436
+ }
1437
+
1438
+ const isDirty = execSync('git status --porcelain', { encoding: 'utf-8' }).trim().length > 0;
1439
+
1440
+ return {
1441
+ branch,
1442
+ commit,
1443
+ shortCommit,
1444
+ author,
1445
+ message: commitMessage,
1446
+ timestamp: commitTimestamp,
1447
+ isDirty,
1448
+ remoteName,
1449
+ remoteUrl
1450
+ };
1451
+ } catch (error: any) {
1452
+ // Silently skip git information if not in a git repository
1453
+ return null;
1454
+ }
1455
+ }
1456
+
1457
+ private getArtifactType(name: string): string {
1458
+ if (name.includes('screenshot')) return 'screenshot';
1459
+ if (name.includes('video')) return 'video';
1460
+ if (name.includes('trace')) return 'trace';
1461
+ return 'attachment';
1462
+ }
1463
+
1464
+ private bumpArtifactStat(stat: 'uploaded' | 'skipped' | 'failed', type: string): void {
1465
+ const bucket = this.artifactStats[stat] as Record<string, number>;
1466
+ if (bucket[type] !== undefined) {
1467
+ bucket[type] += 1;
1468
+ }
1469
+ }
1470
+
1471
+ private extractTags(test: TestCase): string[] {
1472
+ const tags: string[] = [];
1473
+
1474
+ // Playwright stores tags in the _tags property
1475
+ const testTags = (test as any)._tags;
1476
+ if (testTags && Array.isArray(testTags)) {
1477
+ tags.push(...testTags);
1478
+ }
1479
+
1480
+ // Also get tags from parent suites by walking up the tree
1481
+ let currentSuite: Suite | undefined = test.parent;
1482
+ while (currentSuite) {
1483
+ const suiteTags = (currentSuite as any)._tags;
1484
+ if (suiteTags && Array.isArray(suiteTags)) {
1485
+ tags.push(...suiteTags);
1486
+ }
1487
+ currentSuite = currentSuite.parent;
1488
+ }
1489
+
1490
+ // Also extract @tags from test title for backward compatibility
1491
+ const tagMatches = test.title.match(/@[\w-]+/g);
1492
+ if (tagMatches) {
1493
+ tags.push(...tagMatches);
1494
+ }
1495
+
1496
+ // Add testlensBuildTag: CLI args take precedence over config
1497
+ const buildTagSource = this.cliArgs.testlensBuildTag || this.config.customMetadata?.testlensBuildTag;
1498
+ if (buildTagSource) {
1499
+ const buildTags = Array.isArray(buildTagSource)
1500
+ ? buildTagSource
1501
+ : [buildTagSource];
1502
+ buildTags.forEach(tag => tags.push(`@${tag}`));
1503
+ }
1504
+
1505
+ // Remove duplicates and return
1506
+ return [...new Set(tags)];
1507
+ }
1508
+
1509
+ private getTestId(test: TestCase): string {
1510
+ const cleanTitle = test.title.replace(/@[\w-]+/g, '').trim();
1511
+ // Normalize path separators to forward slashes for cross-platform consistency
1512
+ const normalizedFile = test.location.file.replace(/\\/g, '/');
1513
+ return `${normalizedFile}:${test.location.line}:${cleanTitle}`;
1514
+ }
1515
+
1516
+
1517
+ private async uploadArtifactToS3(filePath: string, testId: string, fileName: string, testCaseDbId?: string): Promise<{ key: string; url: string; presignedUrl: string; fileSize: number; contentType: string } | null> {
1518
+ try {
1519
+ // Check file size first
1520
+ const fileSize = this.getFileSize(filePath);
1521
+ const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
1522
+
1523
+ logger.debug(`📤 Uploading ${fileName} (${fileSizeMB}MB) directly to S3...`);
1524
+
1525
+ const baseUrl = this.config.apiEndpoint.replace('/api/v1/webhook/playwright', '');
1526
+
1527
+ // Step 1: Request pre-signed URL from server
1528
+ const presignedUrlEndpoint = `${baseUrl}/api/v1/artifacts/public/presigned-url`;
1529
+ const requestBody: any = {
1530
+ testRunId: this.runId,
1531
+ testId: testId,
1532
+ fileName: fileName,
1533
+ fileType: await this.getContentType(fileName),
1534
+ fileSize: fileSize,
1535
+ artifactType: this.getArtifactType(fileName)
1536
+ };
1537
+
1538
+ // Include DB ID if available for faster lookup (avoids query)
1539
+ if (testCaseDbId) {
1540
+ requestBody.testCaseDbId = testCaseDbId;
1541
+ }
1542
+
1543
+ const presignedResponse = await this.axiosInstance.post(presignedUrlEndpoint, requestBody, {
1544
+ timeout: 10000 // Quick timeout for metadata request
1545
+ });
1546
+
1547
+ if (!presignedResponse.data.success) {
1548
+ throw new Error(`Failed to get presigned URL: ${presignedResponse.data.error || 'Unknown error'}`);
1549
+ }
1550
+
1551
+ const { uploadUrl, s3Key, metadata } = presignedResponse.data;
1552
+
1553
+ // Step 2: Upload directly to S3 using presigned URL
1554
+ logger.debug(`[UPLOAD] [Test: ${testId.substring(0, 8)}...] Uploading ${fileName} directly to S3...`);
1555
+
1556
+ const fileBuffer = fs.readFileSync(filePath);
1557
+
1558
+ // IMPORTANT: When using presigned URLs, we MUST include exactly the headers that were signed
1559
+ // The backend signs with ServerSideEncryption:'AES256', so we must send that header
1560
+ // AWS presigned URLs are very strict about header matching
1561
+ const uploadResponse = await axios.put(uploadUrl, fileBuffer, {
1562
+ headers: {
1563
+ 'x-amz-server-side-encryption': 'AES256'
1564
+ },
1565
+ maxContentLength: Infinity,
1566
+ maxBodyLength: Infinity,
1567
+ timeout: Math.max(600000, Math.ceil(fileSize / (1024 * 1024)) * 10000), // 10s per MB, min 10 minutes - increased for large trace files
1568
+ // Don't use custom HTTPS agent for S3 uploads
1569
+ httpsAgent: undefined
1570
+ });
1571
+
1572
+ if (uploadResponse.status !== 200) {
1573
+ throw new Error(`S3 upload failed with status ${uploadResponse.status}`);
1574
+ }
1575
+
1576
+ logger.debug(`[OK] [Test: ${testId.substring(0, 8)}...] S3 upload completed for ${fileName}`);
1577
+
1578
+ // Step 3: Confirm upload with server to save metadata
1579
+ const confirmEndpoint = `${baseUrl}/api/v1/artifacts/public/confirm-upload`;
1580
+ const confirmBody: any = {
1581
+ testRunId: this.runId,
1582
+ testId: testId,
1583
+ s3Key: s3Key,
1584
+ fileName: fileName,
1585
+ fileType: await this.getContentType(fileName),
1586
+ fileSize: fileSize,
1587
+ artifactType: this.getArtifactType(fileName)
1588
+ };
1589
+
1590
+ // Include DB ID if available for direct insert (avoids query and race condition)
1591
+ if (testCaseDbId) {
1592
+ confirmBody.testCaseDbId = testCaseDbId;
1593
+ }
1594
+
1595
+ const confirmResponse = await this.axiosInstance.post(confirmEndpoint, confirmBody, {
1596
+ timeout: 10000
1597
+ });
1598
+
1599
+ if (confirmResponse.status === 201 && confirmResponse.data.success) {
1600
+ const artifact = confirmResponse.data.artifact;
1601
+ logger.debug(`[OK] [Test: ${testId.substring(0, 8)}...] Upload confirmed${testCaseDbId ? ' (fast path)' : ' (fallback)'}`);
1602
+ return {
1603
+ key: s3Key,
1604
+ url: artifact.s3Url,
1605
+ presignedUrl: artifact.presignedUrl,
1606
+ fileSize: artifact.fileSize,
1607
+ contentType: artifact.contentType
1608
+ };
1609
+ } else {
1610
+ throw new Error(`Upload confirmation failed: ${confirmResponse.data.error || 'Unknown error'}`);
1611
+ }
1612
+ } catch (error: any) {
1613
+ // Check for trial expiration, subscription errors, or limit errors
1614
+ if (error?.response?.status === 401) {
1615
+ const errorData = error?.response?.data;
1616
+
1617
+ if (errorData?.error === 'trial_expired' || errorData?.error === 'subscription_inactive' ||
1618
+ errorData?.error === 'test_cases_limit_reached' || errorData?.error === 'test_runs_limit_reached') {
1619
+ logger.error('\n' + '='.repeat(80));
1620
+
1621
+ if (errorData?.error === 'test_cases_limit_reached') {
1622
+ logger.error('[ERROR] TESTLENS ERROR: Test Cases Limit Reached');
1623
+ } else if (errorData?.error === 'test_runs_limit_reached') {
1624
+ logger.error('[ERROR] TESTLENS ERROR: Test Runs Limit Reached');
1625
+ } else {
1626
+ logger.error('[ERROR] TESTLENS ERROR: Your trial plan has ended');
1627
+ }
1628
+
1629
+ logger.error('='.repeat(80));
1630
+ logger.error('');
1631
+ logger.error(errorData?.message || 'Your trial period has expired.');
1632
+ logger.error('');
1633
+ logger.error('To continue using TestLens, please upgrade to Enterprise plan.');
1634
+ logger.error('Contact: ' + (errorData?.contactEmail || 'support@alternative-path.com'));
1635
+ logger.error('');
1636
+ if (errorData?.trial_end_date) {
1637
+ logger.error(`Trial ended: ${new Date(errorData.trial_end_date).toLocaleDateString()}`);
1638
+ logger.error('');
1639
+ }
1640
+ logger.error('='.repeat(80));
1641
+ logger.error('');
1642
+ return null;
1643
+ }
1644
+ }
1645
+
1646
+ // Better error messages for common issues
1647
+ let errorMsg = error.message;
1648
+
1649
+ if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
1650
+ errorMsg = `Upload timeout - file may be too large or connection is slow`;
1651
+ } else if (error.response?.status === 413) {
1652
+ errorMsg = `File too large (413) - server rejected the upload`;
1653
+ } else if (error.response?.status === 400) {
1654
+ errorMsg = `Bad request (400) - ${error.response.data?.error || 'check file format'}`;
1655
+ } else if (error.response?.status === 403) {
1656
+ errorMsg = `Access denied (403) - presigned URL may have expired`;
1657
+ }
1658
+
1659
+ logger.error(`[ERROR] Failed to upload ${fileName} to S3: ${errorMsg}`);
1660
+ if (error.response?.data) {
1661
+ logger.error({ errorDetails: error.response.data }, 'Error details');
1662
+ }
1663
+
1664
+ // Don't throw, just return null to continue with other artifacts
1665
+ return null;
1666
+ }
1667
+ }
1668
+
1669
+ private async getContentType(fileName: string): Promise<string> {
1670
+ const ext = path.extname(fileName).toLowerCase();
1671
+ try {
1672
+ const mime = await getMime();
1673
+ // Try different ways to access getType method
1674
+ const getType = mime.getType || mime.default?.getType;
1675
+ if (typeof getType === 'function') {
1676
+ const mimeType = getType.call(mime, ext) || (mime.default ? getType.call(mime.default, ext) : null);
1677
+ return mimeType || 'application/octet-stream';
1678
+ }
1679
+ } catch (error: any) {
1680
+ logger.warn(`Failed to get MIME type for ${fileName}: ${error.message}`);
1681
+ }
1682
+ // Fallback to basic content type mapping
1683
+ const contentTypes: Record<string, string> = {
1684
+ '.mp4': 'video/mp4',
1685
+ '.webm': 'video/webm',
1686
+ '.png': 'image/png',
1687
+ '.jpg': 'image/jpeg',
1688
+ '.jpeg': 'image/jpeg',
1689
+ '.gif': 'image/gif',
1690
+ '.json': 'application/json',
1691
+ '.txt': 'text/plain',
1692
+ '.html': 'text/html',
1693
+ '.xml': 'application/xml',
1694
+ '.zip': 'application/zip',
1695
+ '.pdf': 'application/pdf'
1696
+ };
1697
+ return contentTypes[ext] || 'application/octet-stream';
1698
+ }
1699
+
1700
+ // Note: S3 key generation and sanitization are handled server-side
1701
+ // generateS3Key() and sanitizeForS3() methods removed as they were not used
1702
+
1703
+ private getFileSize(filePath: string): number {
1704
+ try {
1705
+ const stats = fs.statSync(filePath);
1706
+ return stats.size;
1707
+ } catch (error) {
1708
+ logger.warn(`Could not get file size for ${filePath}: ${(error as Error).message}`);
1709
+ return 0;
1710
+ }
1711
+ }
1712
+ }
1713
+
1714
+ export default TestLensReporter;