@alternative-path/testlens-playwright-reporter 0.4.10 → 0.4.11

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