@alternative-path/testlens-playwright-reporter 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/index.d.ts +193 -192
- package/index.js +221 -156
- package/index.ts +227 -162
- package/package.json +74 -82
- package/postinstall.js +8 -3
package/index.js
CHANGED
|
@@ -9,6 +9,11 @@ const fs = tslib_1.__importStar(require("fs"));
|
|
|
9
9
|
const https = tslib_1.__importStar(require("https"));
|
|
10
10
|
const axios_1 = tslib_1.__importDefault(require("axios"));
|
|
11
11
|
const child_process_1 = require("child_process");
|
|
12
|
+
const pino_1 = tslib_1.__importDefault(require("pino"));
|
|
13
|
+
// Create pino logger instance
|
|
14
|
+
const logger = (0, pino_1.default)({
|
|
15
|
+
level: process.env.LOG_LEVEL || 'info'
|
|
16
|
+
});
|
|
12
17
|
// Lazy-load mime module to support ESM
|
|
13
18
|
let mimeModule = null;
|
|
14
19
|
async function getMime() {
|
|
@@ -45,11 +50,11 @@ class TestLensReporter {
|
|
|
45
50
|
// For testlensBuildTag, support comma-separated values
|
|
46
51
|
if (key === 'testlensBuildTag' && value.includes(',')) {
|
|
47
52
|
customArgs[key] = value.split(',').map(tag => tag.trim()).filter(tag => tag);
|
|
48
|
-
|
|
53
|
+
logger.info(`✓ Found ${envVar}=${value} (mapped to '${key}' as array of ${customArgs[key].length} tags)`);
|
|
49
54
|
}
|
|
50
55
|
else {
|
|
51
56
|
customArgs[key] = value;
|
|
52
|
-
|
|
57
|
+
logger.info(`✓ Found ${envVar}=${value} (mapped to '${key}')`);
|
|
53
58
|
}
|
|
54
59
|
break; // Use first match
|
|
55
60
|
}
|
|
@@ -60,6 +65,7 @@ class TestLensReporter {
|
|
|
60
65
|
constructor(options) {
|
|
61
66
|
this.runCreationFailed = false; // Track if run creation failed due to limits
|
|
62
67
|
this.cliArgs = {}; // Store CLI args separately
|
|
68
|
+
this.pendingUploads = new Set(); // Track pending artifact uploads
|
|
63
69
|
// Parse custom CLI arguments
|
|
64
70
|
const customArgs = TestLensReporter.parseCustomArgs();
|
|
65
71
|
this.cliArgs = customArgs; // Store CLI args separately for later use
|
|
@@ -87,32 +93,43 @@ class TestLensReporter {
|
|
|
87
93
|
flushInterval: options.flushInterval || 5000,
|
|
88
94
|
retryAttempts: options.retryAttempts !== undefined ? options.retryAttempts : 0,
|
|
89
95
|
timeout: options.timeout || 60000,
|
|
96
|
+
rejectUnauthorized: options.rejectUnauthorized,
|
|
97
|
+
ignoreSslErrors: options.ignoreSslErrors,
|
|
90
98
|
customMetadata: { ...options.customMetadata, ...customArgs } // Config metadata first, then CLI args override
|
|
91
99
|
};
|
|
92
100
|
if (!this.config.apiKey) {
|
|
93
101
|
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.');
|
|
94
102
|
}
|
|
95
103
|
if (apiKey !== options.apiKey) {
|
|
96
|
-
|
|
104
|
+
logger.info('✓ Using API key from environment variable');
|
|
105
|
+
}
|
|
106
|
+
// Default environment to allow self-signed certs unless explicitly set
|
|
107
|
+
if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === undefined) {
|
|
108
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
97
109
|
}
|
|
98
110
|
// Determine SSL validation behavior
|
|
99
|
-
let rejectUnauthorized =
|
|
100
|
-
// Check various ways SSL validation can be disabled (in order of precedence)
|
|
101
|
-
if (this.config.ignoreSslErrors) {
|
|
102
|
-
// Explicit configuration option
|
|
111
|
+
let rejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0'; // Default to secure unless explicitly disabled
|
|
112
|
+
// Check various ways SSL validation can be disabled or enforced (in order of precedence)
|
|
113
|
+
if (this.config.ignoreSslErrors === true) {
|
|
103
114
|
rejectUnauthorized = false;
|
|
104
|
-
|
|
115
|
+
logger.warn('[WARN] SSL certificate validation disabled via ignoreSslErrors option');
|
|
105
116
|
}
|
|
106
117
|
else if (this.config.rejectUnauthorized === false) {
|
|
107
|
-
// Explicit configuration option
|
|
108
118
|
rejectUnauthorized = false;
|
|
109
|
-
|
|
119
|
+
logger.warn('[WARN] SSL certificate validation disabled via rejectUnauthorized option');
|
|
120
|
+
}
|
|
121
|
+
else if (this.config.rejectUnauthorized === true) {
|
|
122
|
+
rejectUnauthorized = true;
|
|
110
123
|
}
|
|
111
124
|
else if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') {
|
|
112
|
-
// Environment variable override
|
|
113
125
|
rejectUnauthorized = false;
|
|
114
|
-
|
|
126
|
+
logger.warn('[WARN] SSL certificate validation disabled via NODE_TLS_REJECT_UNAUTHORIZED environment variable');
|
|
127
|
+
}
|
|
128
|
+
else if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '1') {
|
|
129
|
+
rejectUnauthorized = true;
|
|
115
130
|
}
|
|
131
|
+
// Mirror the resolved value so all HTTPS requests in this process follow it
|
|
132
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = rejectUnauthorized ? '1' : '0';
|
|
116
133
|
// Set up axios instance with retry logic and enhanced SSL handling
|
|
117
134
|
this.axiosInstance = axios_1.default.create({
|
|
118
135
|
baseURL: this.config.apiEndpoint,
|
|
@@ -151,11 +168,11 @@ class TestLensReporter {
|
|
|
151
168
|
this.runCreationFailed = false;
|
|
152
169
|
// Log custom metadata if any
|
|
153
170
|
if (this.config.customMetadata && Object.keys(this.config.customMetadata).length > 0) {
|
|
154
|
-
|
|
171
|
+
logger.info('\n[METADATA] Custom Metadata Detected:');
|
|
155
172
|
Object.entries(this.config.customMetadata).forEach(([key, value]) => {
|
|
156
|
-
|
|
173
|
+
logger.info(` ${key}: ${value}`);
|
|
157
174
|
});
|
|
158
|
-
|
|
175
|
+
logger.info('');
|
|
159
176
|
}
|
|
160
177
|
}
|
|
161
178
|
initializeRunMetadata() {
|
|
@@ -166,7 +183,8 @@ class TestLensReporter {
|
|
|
166
183
|
browser: 'multiple',
|
|
167
184
|
os: `${os.type()} ${os.release()}`,
|
|
168
185
|
playwrightVersion: this.getPlaywrightVersion(),
|
|
169
|
-
nodeVersion: process.version
|
|
186
|
+
nodeVersion: process.version,
|
|
187
|
+
testlensVersion: this.getTestLensVersion()
|
|
170
188
|
};
|
|
171
189
|
// Add custom metadata if provided
|
|
172
190
|
if (this.config.customMetadata && Object.keys(this.config.customMetadata).length > 0) {
|
|
@@ -189,6 +207,15 @@ class TestLensReporter {
|
|
|
189
207
|
return 'unknown';
|
|
190
208
|
}
|
|
191
209
|
}
|
|
210
|
+
getTestLensVersion() {
|
|
211
|
+
try {
|
|
212
|
+
const testlensPackage = require('./package.json');
|
|
213
|
+
return testlensPackage.version;
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
return 'unknown';
|
|
217
|
+
}
|
|
218
|
+
}
|
|
192
219
|
normalizeTestStatus(status) {
|
|
193
220
|
// Treat timeout as failed for consistency with analytics
|
|
194
221
|
if (status === 'timedOut') {
|
|
@@ -210,24 +237,24 @@ class TestLensReporter {
|
|
|
210
237
|
async onBegin(config, suite) {
|
|
211
238
|
// Show Build Name if provided, otherwise show Run ID
|
|
212
239
|
if (this.runMetadata.testlensBuildName) {
|
|
213
|
-
|
|
214
|
-
|
|
240
|
+
logger.info(`🚀 TestLens Reporter starting - Build: ${this.runMetadata.testlensBuildName}`);
|
|
241
|
+
logger.info(` Run ID: ${this.runId}`);
|
|
215
242
|
}
|
|
216
243
|
else {
|
|
217
|
-
|
|
244
|
+
logger.info(`🚀 TestLens Reporter starting - Run ID: ${this.runId}`);
|
|
218
245
|
}
|
|
219
246
|
// Collect Git information if enabled
|
|
220
247
|
if (this.config.enableGitInfo) {
|
|
221
248
|
this.runMetadata.gitInfo = await this.collectGitInfo();
|
|
222
249
|
if (this.runMetadata.gitInfo) {
|
|
223
|
-
|
|
250
|
+
logger.info(`📦 Git info collected: branch=${this.runMetadata.gitInfo.branch}, commit=${this.runMetadata.gitInfo.shortCommit}, author=${this.runMetadata.gitInfo.author}`);
|
|
224
251
|
}
|
|
225
252
|
else {
|
|
226
|
-
|
|
253
|
+
logger.warn(`[WARN] Git info collection returned null - not in a git repository or git not available`);
|
|
227
254
|
}
|
|
228
255
|
}
|
|
229
256
|
else {
|
|
230
|
-
|
|
257
|
+
logger.info(`[INFO] Git info collection disabled (enableGitInfo: false)`);
|
|
231
258
|
}
|
|
232
259
|
// Add shard information if available
|
|
233
260
|
if (config.shard) {
|
|
@@ -246,7 +273,7 @@ class TestLensReporter {
|
|
|
246
273
|
}
|
|
247
274
|
async onTestBegin(test, result) {
|
|
248
275
|
// Log which test is starting
|
|
249
|
-
|
|
276
|
+
logger.info(`\n[TEST] Running test: ${test.title}`);
|
|
250
277
|
const specPath = test.location.file;
|
|
251
278
|
const specKey = `${specPath}-${test.parent.title}`;
|
|
252
279
|
// Create or update spec data
|
|
@@ -319,10 +346,10 @@ class TestLensReporter {
|
|
|
319
346
|
async onTestEnd(test, result) {
|
|
320
347
|
const testId = this.getTestId(test);
|
|
321
348
|
let testData = this.testMap.get(testId);
|
|
322
|
-
|
|
349
|
+
logger.info(`[TestLens] onTestEnd called for test: ${test.title}, status: ${result.status}, testData exists: ${!!testData}`);
|
|
323
350
|
// For skipped tests, onTestBegin might not be called, so we need to create the test data here
|
|
324
351
|
if (!testData) {
|
|
325
|
-
|
|
352
|
+
logger.info(`[TestLens] Creating test data for skipped/uncreated test: ${test.title}`);
|
|
326
353
|
// Create spec data if not exists (skipped tests might not have spec data either)
|
|
327
354
|
const specPath = test.location.file;
|
|
328
355
|
const specKey = `${specPath}-${test.parent.title}`;
|
|
@@ -465,23 +492,20 @@ class TestLensReporter {
|
|
|
465
492
|
}
|
|
466
493
|
return testError;
|
|
467
494
|
});
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
-
// Pass test case DB ID if available for faster lookups
|
|
483
|
-
await this.processArtifacts(testId, result, testEndResponse?.testCaseId);
|
|
484
|
-
}
|
|
495
|
+
// Send testEnd event for all tests, regardless of status
|
|
496
|
+
// This ensures tests that are interrupted or have unexpected statuses are properly recorded
|
|
497
|
+
logger.info(`[TestLens] Sending testEnd - testId: ${testData.id}, status: ${testData.status}, originalStatus: ${testData.originalStatus}`);
|
|
498
|
+
// Send test end event to API and get response
|
|
499
|
+
const testEndResponse = await this.sendToApi({
|
|
500
|
+
type: 'testEnd',
|
|
501
|
+
runId: this.runId,
|
|
502
|
+
timestamp: new Date().toISOString(),
|
|
503
|
+
test: testData
|
|
504
|
+
});
|
|
505
|
+
// Handle artifacts (test case is now guaranteed to be in database)
|
|
506
|
+
if (this.config.enableArtifacts) {
|
|
507
|
+
// Pass test case DB ID if available for faster lookups
|
|
508
|
+
await this.processArtifacts(testId, result, testEndResponse?.testCaseId);
|
|
485
509
|
}
|
|
486
510
|
}
|
|
487
511
|
// Update spec status
|
|
@@ -497,12 +521,33 @@ class TestLensReporter {
|
|
|
497
521
|
specData.status = 'skipped';
|
|
498
522
|
}
|
|
499
523
|
// Check if all tests in spec are complete
|
|
524
|
+
// Only consider tests that were actually executed (have testData)
|
|
500
525
|
const remainingTests = test.parent.tests.filter((t) => {
|
|
501
526
|
const tId = this.getTestId(t);
|
|
502
527
|
const tData = this.testMap.get(tId);
|
|
503
|
-
|
|
528
|
+
// If testData exists but no endTime, it's still running
|
|
529
|
+
return tData && !tData.endTime;
|
|
504
530
|
});
|
|
505
531
|
if (remainingTests.length === 0) {
|
|
532
|
+
// Determine final spec status based on all executed tests
|
|
533
|
+
const executedTests = test.parent.tests
|
|
534
|
+
.map((t) => {
|
|
535
|
+
const tId = this.getTestId(t);
|
|
536
|
+
return this.testMap.get(tId);
|
|
537
|
+
})
|
|
538
|
+
.filter((tData) => !!tData);
|
|
539
|
+
if (executedTests.length > 0) {
|
|
540
|
+
const allTestStatuses = executedTests.map(tData => tData.status);
|
|
541
|
+
if (allTestStatuses.every(status => status === 'passed')) {
|
|
542
|
+
specData.status = 'passed';
|
|
543
|
+
}
|
|
544
|
+
else if (allTestStatuses.some(status => status === 'failed')) {
|
|
545
|
+
specData.status = 'failed';
|
|
546
|
+
}
|
|
547
|
+
else if (allTestStatuses.every(status => status === 'skipped')) {
|
|
548
|
+
specData.status = 'skipped';
|
|
549
|
+
}
|
|
550
|
+
}
|
|
506
551
|
// Aggregate tags from all tests in this spec
|
|
507
552
|
const allTags = new Set();
|
|
508
553
|
test.parent.tests.forEach((t) => {
|
|
@@ -530,6 +575,17 @@ class TestLensReporter {
|
|
|
530
575
|
async onEnd(result) {
|
|
531
576
|
this.runMetadata.endTime = new Date().toISOString();
|
|
532
577
|
this.runMetadata.duration = Date.now() - new Date(this.runMetadata.startTime).getTime();
|
|
578
|
+
// Wait for all pending artifact uploads to complete before sending runEnd
|
|
579
|
+
if (this.pendingUploads.size > 0) {
|
|
580
|
+
logger.info(`⏳ Waiting for ${this.pendingUploads.size} artifact upload(s) to complete...`);
|
|
581
|
+
try {
|
|
582
|
+
await Promise.all(Array.from(this.pendingUploads));
|
|
583
|
+
logger.info(`[OK] All artifact uploads completed`);
|
|
584
|
+
}
|
|
585
|
+
catch (error) {
|
|
586
|
+
logger.error(`[WARN] Some artifact uploads failed, continuing with runEnd`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
533
589
|
// Calculate final stats
|
|
534
590
|
const totalTests = Array.from(this.testMap.values()).length;
|
|
535
591
|
const passedTests = Array.from(this.testMap.values()).filter(t => t.status === 'passed').length;
|
|
@@ -558,13 +614,13 @@ class TestLensReporter {
|
|
|
558
614
|
});
|
|
559
615
|
// Show Build Name if provided, otherwise show Run ID
|
|
560
616
|
if (this.runMetadata.testlensBuildName) {
|
|
561
|
-
|
|
562
|
-
|
|
617
|
+
logger.info(`[COMPLETE] TestLens Report completed - Build: ${this.runMetadata.testlensBuildName}`);
|
|
618
|
+
logger.info(` Run ID: ${this.runId}`);
|
|
563
619
|
}
|
|
564
620
|
else {
|
|
565
|
-
|
|
621
|
+
logger.info(`[COMPLETE] TestLens Report completed - Run ID: ${this.runId}`);
|
|
566
622
|
}
|
|
567
|
-
|
|
623
|
+
logger.info(`[RESULTS] ${passedTests} passed, ${failedTests} failed (${timedOutTests} timeouts), ${skippedTests} skipped`);
|
|
568
624
|
}
|
|
569
625
|
async sendToApi(payload) {
|
|
570
626
|
// Skip sending if run creation already failed
|
|
@@ -578,7 +634,7 @@ class TestLensReporter {
|
|
|
578
634
|
}
|
|
579
635
|
});
|
|
580
636
|
if (this.config.enableRealTimeStream) {
|
|
581
|
-
|
|
637
|
+
logger.info(`[OK] Sent ${payload.type} event to TestLens (HTTP ${response.status})`);
|
|
582
638
|
}
|
|
583
639
|
// Return response data for caller to use
|
|
584
640
|
return response.data;
|
|
@@ -592,65 +648,66 @@ class TestLensReporter {
|
|
|
592
648
|
if (payload.type === 'runStart' && errorData?.limit_type === 'test_runs') {
|
|
593
649
|
this.runCreationFailed = true;
|
|
594
650
|
}
|
|
595
|
-
|
|
651
|
+
logger.error('\n' + '='.repeat(80));
|
|
596
652
|
if (errorData?.limit_type === 'test_cases') {
|
|
597
|
-
|
|
653
|
+
logger.error('[ERROR] TESTLENS ERROR: Test Cases Limit Reached');
|
|
598
654
|
}
|
|
599
655
|
else if (errorData?.limit_type === 'test_runs') {
|
|
600
|
-
|
|
656
|
+
logger.error('[ERROR] TESTLENS ERROR: Test Runs Limit Reached');
|
|
601
657
|
}
|
|
602
658
|
else {
|
|
603
|
-
|
|
659
|
+
logger.error('[ERROR] TESTLENS ERROR: Plan Limit Reached');
|
|
604
660
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
661
|
+
logger.error('='.repeat(80));
|
|
662
|
+
logger.error('');
|
|
663
|
+
logger.error(errorData?.message || 'You have reached your plan limit.');
|
|
664
|
+
logger.error('');
|
|
665
|
+
logger.error(`Current usage: ${errorData?.current_usage || 'N/A'} / ${errorData?.limit || 'N/A'}`);
|
|
666
|
+
logger.error('');
|
|
667
|
+
logger.error('To continue, please upgrade your plan.');
|
|
668
|
+
logger.error('Contact: support@alternative-path.com');
|
|
669
|
+
logger.error('');
|
|
670
|
+
logger.error('='.repeat(80));
|
|
671
|
+
logger.error('');
|
|
616
672
|
return; // Don't log the full error object for limit errors
|
|
617
673
|
}
|
|
618
674
|
// Check for trial expiration, subscription errors, or limit errors (401)
|
|
619
675
|
if (status === 401) {
|
|
620
676
|
if (errorData?.error === 'trial_expired' || errorData?.error === 'subscription_inactive' ||
|
|
621
677
|
errorData?.error === 'test_cases_limit_reached' || errorData?.error === 'test_runs_limit_reached') {
|
|
622
|
-
|
|
678
|
+
logger.error('\n' + '='.repeat(80));
|
|
623
679
|
if (errorData?.error === 'test_cases_limit_reached') {
|
|
624
|
-
|
|
680
|
+
logger.error('[ERROR] TESTLENS ERROR: Test Cases Limit Reached');
|
|
625
681
|
}
|
|
626
682
|
else if (errorData?.error === 'test_runs_limit_reached') {
|
|
627
|
-
|
|
683
|
+
logger.error('[ERROR] TESTLENS ERROR: Test Runs Limit Reached');
|
|
628
684
|
}
|
|
629
685
|
else {
|
|
630
|
-
|
|
686
|
+
logger.error('[ERROR] TESTLENS ERROR: Your trial plan has ended');
|
|
631
687
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
688
|
+
logger.error('='.repeat(80));
|
|
689
|
+
logger.error('');
|
|
690
|
+
logger.error(errorData?.message || 'Your trial period has expired.');
|
|
691
|
+
logger.error('');
|
|
692
|
+
logger.error('To continue using TestLens, please upgrade to Enterprise plan.');
|
|
693
|
+
logger.error('Contact: ' + (errorData?.contactEmail || 'support@alternative-path.com'));
|
|
694
|
+
logger.error('');
|
|
639
695
|
if (errorData?.trial_end_date) {
|
|
640
|
-
|
|
641
|
-
|
|
696
|
+
logger.error(`Trial ended: ${new Date(errorData.trial_end_date).toLocaleDateString()}`);
|
|
697
|
+
logger.error('');
|
|
642
698
|
}
|
|
643
|
-
|
|
644
|
-
|
|
699
|
+
logger.error('='.repeat(80));
|
|
700
|
+
logger.error('');
|
|
645
701
|
}
|
|
646
702
|
else {
|
|
647
|
-
|
|
703
|
+
logger.error(`[ERROR] Authentication failed: ${errorData?.error || 'Invalid API key'}`);
|
|
648
704
|
}
|
|
649
705
|
}
|
|
650
706
|
else if (status !== 403) {
|
|
651
707
|
// Log other errors (but not 403 which we handled above)
|
|
652
|
-
|
|
653
|
-
message:
|
|
708
|
+
logger.error({
|
|
709
|
+
message: `[ERROR] Failed to send ${payload.type} event to TestLens`,
|
|
710
|
+
error: error?.message || 'Unknown error',
|
|
654
711
|
status: status,
|
|
655
712
|
statusText: error?.response?.statusText,
|
|
656
713
|
data: errorData,
|
|
@@ -676,12 +733,12 @@ class TestLensReporter {
|
|
|
676
733
|
const isScreenshot = artifactType === 'screenshot' || attachment.contentType?.startsWith('image/');
|
|
677
734
|
// Skip video if disabled in config
|
|
678
735
|
if (isVideo && !this.config.enableVideo) {
|
|
679
|
-
|
|
736
|
+
logger.info(`[SKIP] Skipping video artifact ${attachment.name} - video capture disabled in config`);
|
|
680
737
|
continue;
|
|
681
738
|
}
|
|
682
739
|
// Skip screenshot if disabled in config
|
|
683
740
|
if (isScreenshot && !this.config.enableScreenshot) {
|
|
684
|
-
|
|
741
|
+
logger.info(`[SKIP] Skipping screenshot artifact ${attachment.name} - screenshot capture disabled in config`);
|
|
685
742
|
continue;
|
|
686
743
|
}
|
|
687
744
|
try {
|
|
@@ -715,34 +772,53 @@ class TestLensReporter {
|
|
|
715
772
|
}
|
|
716
773
|
}
|
|
717
774
|
// Upload to S3 first (pass DB ID if available for faster lookup)
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
775
|
+
// Create upload promise that we can track
|
|
776
|
+
const uploadPromise = Promise.resolve().then(async () => {
|
|
777
|
+
try {
|
|
778
|
+
if (!attachment.path) {
|
|
779
|
+
logger.info(`[SKIP] [Test: ${testId.substring(0, 8)}...] Skipping artifact ${attachment.name} - no file path`);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
const s3Data = await this.uploadArtifactToS3(attachment.path, testId, fileName, testCaseDbId);
|
|
783
|
+
// Skip if upload failed or file was too large
|
|
784
|
+
if (!s3Data) {
|
|
785
|
+
logger.info(`[SKIP] [Test: ${testId.substring(0, 8)}...] Skipping artifact ${attachment.name} - upload failed or file too large`);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const artifactData = {
|
|
789
|
+
testId,
|
|
790
|
+
type: this.getArtifactType(attachment.name),
|
|
791
|
+
path: attachment.path,
|
|
792
|
+
name: fileName,
|
|
793
|
+
contentType: attachment.contentType,
|
|
794
|
+
fileSize: this.getFileSize(attachment.path),
|
|
795
|
+
storageType: 's3',
|
|
796
|
+
s3Key: s3Data.key,
|
|
797
|
+
s3Url: s3Data.url
|
|
798
|
+
};
|
|
799
|
+
// Send artifact data to API
|
|
800
|
+
await this.sendToApi({
|
|
801
|
+
type: 'artifact',
|
|
802
|
+
runId: this.runId,
|
|
803
|
+
timestamp: new Date().toISOString(),
|
|
804
|
+
artifact: artifactData
|
|
805
|
+
});
|
|
806
|
+
logger.info(`[ARTIFACT] [Test: ${testId.substring(0, 8)}...] Processed artifact: ${fileName}`);
|
|
807
|
+
}
|
|
808
|
+
catch (error) {
|
|
809
|
+
logger.error(`[ERROR] [Test: ${testId.substring(0, 8)}...] Failed to process artifact ${attachment.name}: ${error.message}`);
|
|
810
|
+
}
|
|
741
811
|
});
|
|
742
|
-
|
|
812
|
+
// Track this upload and ensure cleanup on completion
|
|
813
|
+
this.pendingUploads.add(uploadPromise);
|
|
814
|
+
uploadPromise.finally(() => {
|
|
815
|
+
this.pendingUploads.delete(uploadPromise);
|
|
816
|
+
});
|
|
817
|
+
// Don't await here - let uploads happen in parallel
|
|
818
|
+
// They will be awaited in onEnd
|
|
743
819
|
}
|
|
744
820
|
catch (error) {
|
|
745
|
-
|
|
821
|
+
logger.error(`[ERROR] [Test: ${testId.substring(0, 8)}...] Failed to setup artifact upload ${attachment.name}: ${error.message}`);
|
|
746
822
|
}
|
|
747
823
|
}
|
|
748
824
|
}
|
|
@@ -760,23 +836,28 @@ class TestLensReporter {
|
|
|
760
836
|
describe: block.describe // parent describe block name
|
|
761
837
|
}));
|
|
762
838
|
// Send to dedicated spec code blocks API endpoint
|
|
763
|
-
|
|
764
|
-
|
|
839
|
+
// Extract base URL - handle both full and partial endpoint patterns
|
|
840
|
+
let baseUrl = this.config.apiEndpoint.replace('/api/v1/webhook/playwright', '');
|
|
841
|
+
if (baseUrl === this.config.apiEndpoint) {
|
|
842
|
+
// Fallback: try alternative pattern if main pattern didn't match
|
|
843
|
+
baseUrl = this.config.apiEndpoint.replace('/webhook/playwright', '');
|
|
844
|
+
}
|
|
845
|
+
const specEndpoint = `${baseUrl}/api/v1/webhook/playwright/spec-code-blocks`;
|
|
765
846
|
await this.axiosInstance.post(specEndpoint, {
|
|
766
847
|
filePath: path.relative(process.cwd(), specPath),
|
|
767
848
|
codeBlocks,
|
|
768
849
|
testSuiteName: path.basename(specPath).replace(/\.(spec|test)\.(js|ts)$/, '')
|
|
769
850
|
});
|
|
770
|
-
|
|
851
|
+
logger.info(`📝 Sent ${codeBlocks.length} code blocks for: ${path.basename(specPath)}`);
|
|
771
852
|
}
|
|
772
853
|
catch (error) {
|
|
773
854
|
const errorData = error?.response?.data;
|
|
774
855
|
// Handle duplicate spec code blocks gracefully (when re-running tests)
|
|
775
856
|
if (errorData?.error && errorData.error.includes('duplicate key value violates unique constraint')) {
|
|
776
|
-
|
|
857
|
+
logger.info(`[INFO] Spec code blocks already exist for: ${path.basename(specPath)} (skipped)`);
|
|
777
858
|
return;
|
|
778
859
|
}
|
|
779
|
-
|
|
860
|
+
logger.error(`Failed to send spec code blocks: ${errorData || error?.message || 'Unknown error'}`);
|
|
780
861
|
}
|
|
781
862
|
}
|
|
782
863
|
extractTestBlocks(filePath) {
|
|
@@ -835,7 +916,7 @@ class TestLensReporter {
|
|
|
835
916
|
return blocks;
|
|
836
917
|
}
|
|
837
918
|
catch (error) {
|
|
838
|
-
|
|
919
|
+
logger.error(`Failed to extract test blocks from ${filePath}: ${error.message}`);
|
|
839
920
|
return [];
|
|
840
921
|
}
|
|
841
922
|
}
|
|
@@ -858,7 +939,7 @@ class TestLensReporter {
|
|
|
858
939
|
}
|
|
859
940
|
catch (e) {
|
|
860
941
|
// Remote info is optional - handle gracefully
|
|
861
|
-
|
|
942
|
+
logger.info('[INFO] No git remote configured, skipping remote info');
|
|
862
943
|
}
|
|
863
944
|
const isDirty = (0, child_process_1.execSync)('git status --porcelain', { encoding: 'utf-8' }).trim().length > 0;
|
|
864
945
|
return {
|
|
@@ -930,7 +1011,7 @@ class TestLensReporter {
|
|
|
930
1011
|
// Check file size first
|
|
931
1012
|
const fileSize = this.getFileSize(filePath);
|
|
932
1013
|
const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
|
|
933
|
-
|
|
1014
|
+
logger.info(`📤 Uploading ${fileName} (${fileSizeMB}MB) directly to S3...`);
|
|
934
1015
|
const baseUrl = this.config.apiEndpoint.replace('/api/v1/webhook/playwright', '');
|
|
935
1016
|
// Step 1: Request pre-signed URL from server
|
|
936
1017
|
const presignedUrlEndpoint = `${baseUrl}/api/v1/artifacts/public/presigned-url`;
|
|
@@ -955,7 +1036,7 @@ class TestLensReporter {
|
|
|
955
1036
|
}
|
|
956
1037
|
const { uploadUrl, s3Key, metadata } = presignedResponse.data;
|
|
957
1038
|
// Step 2: Upload directly to S3 using presigned URL
|
|
958
|
-
|
|
1039
|
+
logger.info(`[UPLOAD] [Test: ${testId.substring(0, 8)}...] Uploading ${fileName} directly to S3...`);
|
|
959
1040
|
const fileBuffer = fs.readFileSync(filePath);
|
|
960
1041
|
// IMPORTANT: When using presigned URLs, we MUST include exactly the headers that were signed
|
|
961
1042
|
// The backend signs with ServerSideEncryption:'AES256', so we must send that header
|
|
@@ -973,7 +1054,7 @@ class TestLensReporter {
|
|
|
973
1054
|
if (uploadResponse.status !== 200) {
|
|
974
1055
|
throw new Error(`S3 upload failed with status ${uploadResponse.status}`);
|
|
975
1056
|
}
|
|
976
|
-
|
|
1057
|
+
logger.info(`[OK] [Test: ${testId.substring(0, 8)}...] S3 upload completed for ${fileName}`);
|
|
977
1058
|
// Step 3: Confirm upload with server to save metadata
|
|
978
1059
|
const confirmEndpoint = `${baseUrl}/api/v1/artifacts/public/confirm-upload`;
|
|
979
1060
|
const confirmBody = {
|
|
@@ -995,7 +1076,7 @@ class TestLensReporter {
|
|
|
995
1076
|
});
|
|
996
1077
|
if (confirmResponse.status === 201 && confirmResponse.data.success) {
|
|
997
1078
|
const artifact = confirmResponse.data.artifact;
|
|
998
|
-
|
|
1079
|
+
logger.info(`[OK] [Test: ${testId.substring(0, 8)}...] Upload confirmed${testCaseDbId ? ' (fast path)' : ' (fallback)'}`);
|
|
999
1080
|
return {
|
|
1000
1081
|
key: s3Key,
|
|
1001
1082
|
url: artifact.s3Url,
|
|
@@ -1014,29 +1095,29 @@ class TestLensReporter {
|
|
|
1014
1095
|
const errorData = error?.response?.data;
|
|
1015
1096
|
if (errorData?.error === 'trial_expired' || errorData?.error === 'subscription_inactive' ||
|
|
1016
1097
|
errorData?.error === 'test_cases_limit_reached' || errorData?.error === 'test_runs_limit_reached') {
|
|
1017
|
-
|
|
1098
|
+
logger.error('\n' + '='.repeat(80));
|
|
1018
1099
|
if (errorData?.error === 'test_cases_limit_reached') {
|
|
1019
|
-
|
|
1100
|
+
logger.error('[ERROR] TESTLENS ERROR: Test Cases Limit Reached');
|
|
1020
1101
|
}
|
|
1021
1102
|
else if (errorData?.error === 'test_runs_limit_reached') {
|
|
1022
|
-
|
|
1103
|
+
logger.error('[ERROR] TESTLENS ERROR: Test Runs Limit Reached');
|
|
1023
1104
|
}
|
|
1024
1105
|
else {
|
|
1025
|
-
|
|
1106
|
+
logger.error('[ERROR] TESTLENS ERROR: Your trial plan has ended');
|
|
1026
1107
|
}
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1108
|
+
logger.error('='.repeat(80));
|
|
1109
|
+
logger.error('');
|
|
1110
|
+
logger.error(errorData?.message || 'Your trial period has expired.');
|
|
1111
|
+
logger.error('');
|
|
1112
|
+
logger.error('To continue using TestLens, please upgrade to Enterprise plan.');
|
|
1113
|
+
logger.error('Contact: ' + (errorData?.contactEmail || 'support@alternative-path.com'));
|
|
1114
|
+
logger.error('');
|
|
1034
1115
|
if (errorData?.trial_end_date) {
|
|
1035
|
-
|
|
1036
|
-
|
|
1116
|
+
logger.error(`Trial ended: ${new Date(errorData.trial_end_date).toLocaleDateString()}`);
|
|
1117
|
+
logger.error('');
|
|
1037
1118
|
}
|
|
1038
|
-
|
|
1039
|
-
|
|
1119
|
+
logger.error('='.repeat(80));
|
|
1120
|
+
logger.error('');
|
|
1040
1121
|
return null;
|
|
1041
1122
|
}
|
|
1042
1123
|
}
|
|
@@ -1054,9 +1135,9 @@ class TestLensReporter {
|
|
|
1054
1135
|
else if (error.response?.status === 403) {
|
|
1055
1136
|
errorMsg = `Access denied (403) - presigned URL may have expired`;
|
|
1056
1137
|
}
|
|
1057
|
-
|
|
1138
|
+
logger.error(`[ERROR] Failed to upload ${fileName} to S3: ${errorMsg}`);
|
|
1058
1139
|
if (error.response?.data) {
|
|
1059
|
-
|
|
1140
|
+
logger.error({ errorDetails: error.response.data }, 'Error details');
|
|
1060
1141
|
}
|
|
1061
1142
|
// Don't throw, just return null to continue with other artifacts
|
|
1062
1143
|
return null;
|
|
@@ -1074,7 +1155,7 @@ class TestLensReporter {
|
|
|
1074
1155
|
}
|
|
1075
1156
|
}
|
|
1076
1157
|
catch (error) {
|
|
1077
|
-
|
|
1158
|
+
logger.warn(`Failed to get MIME type for ${fileName}: ${error.message}`);
|
|
1078
1159
|
}
|
|
1079
1160
|
// Fallback to basic content type mapping
|
|
1080
1161
|
const contentTypes = {
|
|
@@ -1093,31 +1174,15 @@ class TestLensReporter {
|
|
|
1093
1174
|
};
|
|
1094
1175
|
return contentTypes[ext] || 'application/octet-stream';
|
|
1095
1176
|
}
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
const safeTestId = this.sanitizeForS3(testId);
|
|
1099
|
-
const safeFileName = this.sanitizeForS3(fileName);
|
|
1100
|
-
const ext = path.extname(fileName);
|
|
1101
|
-
const baseName = path.basename(fileName, ext);
|
|
1102
|
-
return `test-artifacts/${date}/${runId}/${safeTestId}/${safeFileName}${ext}`;
|
|
1103
|
-
}
|
|
1104
|
-
sanitizeForS3(value) {
|
|
1105
|
-
return value
|
|
1106
|
-
.replace(/[\/:*?"<>|]/g, '-')
|
|
1107
|
-
.replace(/[-\u001f\u007f]/g, '-')
|
|
1108
|
-
.replace(/[^-~]/g, '-')
|
|
1109
|
-
.replace(/\s+/g, '-')
|
|
1110
|
-
.replace(/[_]/g, '-')
|
|
1111
|
-
.replace(/-+/g, '-')
|
|
1112
|
-
.replace(/^-|-$/g, '');
|
|
1113
|
-
}
|
|
1177
|
+
// Note: S3 key generation and sanitization are handled server-side
|
|
1178
|
+
// generateS3Key() and sanitizeForS3() methods removed as they were not used
|
|
1114
1179
|
getFileSize(filePath) {
|
|
1115
1180
|
try {
|
|
1116
1181
|
const stats = fs.statSync(filePath);
|
|
1117
1182
|
return stats.size;
|
|
1118
1183
|
}
|
|
1119
1184
|
catch (error) {
|
|
1120
|
-
|
|
1185
|
+
logger.warn(`Could not get file size for ${filePath}: ${error.message}`);
|
|
1121
1186
|
return 0;
|
|
1122
1187
|
}
|
|
1123
1188
|
}
|