@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.ts
CHANGED
|
@@ -6,7 +6,12 @@ import * as https from 'https';
|
|
|
6
6
|
import axios, { AxiosInstance } from 'axios';
|
|
7
7
|
import type { Reporter, TestCase, TestResult, FullConfig, Suite } from '@playwright/test/reporter';
|
|
8
8
|
import { execSync } from 'child_process';
|
|
9
|
-
import
|
|
9
|
+
import pino from 'pino';
|
|
10
|
+
|
|
11
|
+
// Create pino logger instance
|
|
12
|
+
const logger = pino({
|
|
13
|
+
level: process.env.LOG_LEVEL || 'info'
|
|
14
|
+
});
|
|
10
15
|
|
|
11
16
|
// Lazy-load mime module to support ESM
|
|
12
17
|
let mimeModule: any = null;
|
|
@@ -113,6 +118,7 @@ export interface RunMetadata {
|
|
|
113
118
|
os: string;
|
|
114
119
|
playwrightVersion: string;
|
|
115
120
|
nodeVersion: string;
|
|
121
|
+
testlensVersion?: string;
|
|
116
122
|
gitInfo?: GitInfo | null;
|
|
117
123
|
shardInfo?: {
|
|
118
124
|
current: number;
|
|
@@ -184,6 +190,7 @@ export class TestLensReporter implements Reporter {
|
|
|
184
190
|
private testMap: Map<string, TestData>;
|
|
185
191
|
private runCreationFailed: boolean = false; // Track if run creation failed due to limits
|
|
186
192
|
private cliArgs: Record<string, any> = {}; // Store CLI args separately
|
|
193
|
+
private pendingUploads: Set<Promise<any>> = new Set(); // Track pending artifact uploads
|
|
187
194
|
|
|
188
195
|
/**
|
|
189
196
|
* Parse custom metadata from environment variables
|
|
@@ -212,10 +219,10 @@ export class TestLensReporter implements Reporter {
|
|
|
212
219
|
// For testlensBuildTag, support comma-separated values
|
|
213
220
|
if (key === 'testlensBuildTag' && value.includes(',')) {
|
|
214
221
|
customArgs[key] = value.split(',').map(tag => tag.trim()).filter(tag => tag);
|
|
215
|
-
|
|
222
|
+
logger.info(`✓ Found ${envVar}=${value} (mapped to '${key}' as array of ${customArgs[key].length} tags)`);
|
|
216
223
|
} else {
|
|
217
224
|
customArgs[key] = value;
|
|
218
|
-
|
|
225
|
+
logger.info(`✓ Found ${envVar}=${value} (mapped to '${key}')`);
|
|
219
226
|
}
|
|
220
227
|
break; // Use first match
|
|
221
228
|
}
|
|
@@ -255,6 +262,8 @@ export class TestLensReporter implements Reporter {
|
|
|
255
262
|
flushInterval: options.flushInterval || 5000,
|
|
256
263
|
retryAttempts: options.retryAttempts !== undefined ? options.retryAttempts : 0,
|
|
257
264
|
timeout: options.timeout || 60000,
|
|
265
|
+
rejectUnauthorized: options.rejectUnauthorized,
|
|
266
|
+
ignoreSslErrors: options.ignoreSslErrors,
|
|
258
267
|
customMetadata: { ...options.customMetadata, ...customArgs } // Config metadata first, then CLI args override
|
|
259
268
|
} as Required<TestLensReporterConfig>;
|
|
260
269
|
|
|
@@ -263,27 +272,36 @@ export class TestLensReporter implements Reporter {
|
|
|
263
272
|
}
|
|
264
273
|
|
|
265
274
|
if (apiKey !== options.apiKey) {
|
|
266
|
-
|
|
275
|
+
logger.info('✓ Using API key from environment variable');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Default environment to allow self-signed certs unless explicitly set
|
|
279
|
+
if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === undefined) {
|
|
280
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
267
281
|
}
|
|
268
282
|
|
|
269
283
|
// Determine SSL validation behavior
|
|
270
|
-
let rejectUnauthorized =
|
|
284
|
+
let rejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0'; // Default to secure unless explicitly disabled
|
|
271
285
|
|
|
272
|
-
// Check various ways SSL validation can be disabled (in order of precedence)
|
|
273
|
-
if (this.config.ignoreSslErrors) {
|
|
274
|
-
// Explicit configuration option
|
|
286
|
+
// Check various ways SSL validation can be disabled or enforced (in order of precedence)
|
|
287
|
+
if (this.config.ignoreSslErrors === true) {
|
|
275
288
|
rejectUnauthorized = false;
|
|
276
|
-
|
|
289
|
+
logger.warn('[WARN] SSL certificate validation disabled via ignoreSslErrors option');
|
|
277
290
|
} else if (this.config.rejectUnauthorized === false) {
|
|
278
|
-
// Explicit configuration option
|
|
279
291
|
rejectUnauthorized = false;
|
|
280
|
-
|
|
292
|
+
logger.warn('[WARN] SSL certificate validation disabled via rejectUnauthorized option');
|
|
293
|
+
} else if (this.config.rejectUnauthorized === true) {
|
|
294
|
+
rejectUnauthorized = true;
|
|
281
295
|
} else if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') {
|
|
282
|
-
// Environment variable override
|
|
283
296
|
rejectUnauthorized = false;
|
|
284
|
-
|
|
297
|
+
logger.warn('[WARN] SSL certificate validation disabled via NODE_TLS_REJECT_UNAUTHORIZED environment variable');
|
|
298
|
+
} else if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '1') {
|
|
299
|
+
rejectUnauthorized = true;
|
|
285
300
|
}
|
|
286
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
|
+
|
|
287
305
|
// Set up axios instance with retry logic and enhanced SSL handling
|
|
288
306
|
this.axiosInstance = axios.create({
|
|
289
307
|
baseURL: this.config.apiEndpoint,
|
|
@@ -331,11 +349,11 @@ export class TestLensReporter implements Reporter {
|
|
|
331
349
|
|
|
332
350
|
// Log custom metadata if any
|
|
333
351
|
if (this.config.customMetadata && Object.keys(this.config.customMetadata).length > 0) {
|
|
334
|
-
|
|
352
|
+
logger.info('\n[METADATA] Custom Metadata Detected:');
|
|
335
353
|
Object.entries(this.config.customMetadata).forEach(([key, value]) => {
|
|
336
|
-
|
|
354
|
+
logger.info(` ${key}: ${value}`);
|
|
337
355
|
});
|
|
338
|
-
|
|
356
|
+
logger.info('');
|
|
339
357
|
}
|
|
340
358
|
}
|
|
341
359
|
|
|
@@ -347,7 +365,8 @@ export class TestLensReporter implements Reporter {
|
|
|
347
365
|
browser: 'multiple',
|
|
348
366
|
os: `${os.type()} ${os.release()}`,
|
|
349
367
|
playwrightVersion: this.getPlaywrightVersion(),
|
|
350
|
-
nodeVersion: process.version
|
|
368
|
+
nodeVersion: process.version,
|
|
369
|
+
testlensVersion: this.getTestLensVersion()
|
|
351
370
|
};
|
|
352
371
|
|
|
353
372
|
// Add custom metadata if provided
|
|
@@ -374,6 +393,15 @@ export class TestLensReporter implements Reporter {
|
|
|
374
393
|
}
|
|
375
394
|
}
|
|
376
395
|
|
|
396
|
+
private getTestLensVersion(): string {
|
|
397
|
+
try {
|
|
398
|
+
const testlensPackage = require('./package.json');
|
|
399
|
+
return testlensPackage.version;
|
|
400
|
+
} catch (error) {
|
|
401
|
+
return 'unknown';
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
377
405
|
private normalizeTestStatus(status: string): string {
|
|
378
406
|
// Treat timeout as failed for consistency with analytics
|
|
379
407
|
if (status === 'timedOut') {
|
|
@@ -397,22 +425,22 @@ export class TestLensReporter implements Reporter {
|
|
|
397
425
|
async onBegin(config: FullConfig, suite: Suite): Promise<void> {
|
|
398
426
|
// Show Build Name if provided, otherwise show Run ID
|
|
399
427
|
if (this.runMetadata.testlensBuildName) {
|
|
400
|
-
|
|
401
|
-
|
|
428
|
+
logger.info(`🚀 TestLens Reporter starting - Build: ${this.runMetadata.testlensBuildName}`);
|
|
429
|
+
logger.info(` Run ID: ${this.runId}`);
|
|
402
430
|
} else {
|
|
403
|
-
|
|
431
|
+
logger.info(`🚀 TestLens Reporter starting - Run ID: ${this.runId}`);
|
|
404
432
|
}
|
|
405
433
|
|
|
406
434
|
// Collect Git information if enabled
|
|
407
435
|
if (this.config.enableGitInfo) {
|
|
408
436
|
this.runMetadata.gitInfo = await this.collectGitInfo();
|
|
409
437
|
if (this.runMetadata.gitInfo) {
|
|
410
|
-
|
|
438
|
+
logger.info(`📦 Git info collected: branch=${this.runMetadata.gitInfo.branch}, commit=${this.runMetadata.gitInfo.shortCommit}, author=${this.runMetadata.gitInfo.author}`);
|
|
411
439
|
} else {
|
|
412
|
-
|
|
440
|
+
logger.warn(`[WARN] Git info collection returned null - not in a git repository or git not available`);
|
|
413
441
|
}
|
|
414
442
|
} else {
|
|
415
|
-
|
|
443
|
+
logger.info(`[INFO] Git info collection disabled (enableGitInfo: false)`);
|
|
416
444
|
}
|
|
417
445
|
|
|
418
446
|
// Add shard information if available
|
|
@@ -434,7 +462,7 @@ export class TestLensReporter implements Reporter {
|
|
|
434
462
|
|
|
435
463
|
async onTestBegin(test: TestCase, result: TestResult): Promise<void> {
|
|
436
464
|
// Log which test is starting
|
|
437
|
-
|
|
465
|
+
logger.info(`\n[TEST] Running test: ${test.title}`);
|
|
438
466
|
|
|
439
467
|
const specPath = test.location.file;
|
|
440
468
|
const specKey = `${specPath}-${test.parent.title}`;
|
|
@@ -515,11 +543,11 @@ export class TestLensReporter implements Reporter {
|
|
|
515
543
|
const testId = this.getTestId(test);
|
|
516
544
|
let testData = this.testMap.get(testId);
|
|
517
545
|
|
|
518
|
-
|
|
546
|
+
logger.info(`[TestLens] onTestEnd called for test: ${test.title}, status: ${result.status}, testData exists: ${!!testData}`);
|
|
519
547
|
|
|
520
548
|
// For skipped tests, onTestBegin might not be called, so we need to create the test data here
|
|
521
549
|
if (!testData) {
|
|
522
|
-
|
|
550
|
+
logger.info(`[TestLens] Creating test data for skipped/uncreated test: ${test.title}`);
|
|
523
551
|
// Create spec data if not exists (skipped tests might not have spec data either)
|
|
524
552
|
const specPath = test.location.file;
|
|
525
553
|
const specKey = `${specPath}-${test.parent.title}`;
|
|
@@ -683,25 +711,21 @@ export class TestLensReporter implements Reporter {
|
|
|
683
711
|
return testError;
|
|
684
712
|
});
|
|
685
713
|
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
timestamp: new Date().toISOString(),
|
|
697
|
-
test: testData
|
|
698
|
-
});
|
|
714
|
+
// Send testEnd event for all tests, regardless of status
|
|
715
|
+
// This ensures tests that are interrupted or have unexpected statuses are properly recorded
|
|
716
|
+
logger.info(`[TestLens] Sending testEnd - testId: ${testData.id}, status: ${testData.status}, originalStatus: ${testData.originalStatus}`);
|
|
717
|
+
// Send test end event to API and get response
|
|
718
|
+
const testEndResponse = await this.sendToApi({
|
|
719
|
+
type: 'testEnd',
|
|
720
|
+
runId: this.runId,
|
|
721
|
+
timestamp: new Date().toISOString(),
|
|
722
|
+
test: testData
|
|
723
|
+
});
|
|
699
724
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
}
|
|
725
|
+
// Handle artifacts (test case is now guaranteed to be in database)
|
|
726
|
+
if (this.config.enableArtifacts) {
|
|
727
|
+
// Pass test case DB ID if available for faster lookups
|
|
728
|
+
await this.processArtifacts(testId, result, testEndResponse?.testCaseId);
|
|
705
729
|
}
|
|
706
730
|
}
|
|
707
731
|
|
|
@@ -718,13 +742,33 @@ export class TestLensReporter implements Reporter {
|
|
|
718
742
|
}
|
|
719
743
|
|
|
720
744
|
// Check if all tests in spec are complete
|
|
745
|
+
// Only consider tests that were actually executed (have testData)
|
|
721
746
|
const remainingTests = test.parent.tests.filter((t: any) => {
|
|
722
747
|
const tId = this.getTestId(t);
|
|
723
748
|
const tData = this.testMap.get(tId);
|
|
724
|
-
|
|
749
|
+
// If testData exists but no endTime, it's still running
|
|
750
|
+
return tData && !tData.endTime;
|
|
725
751
|
});
|
|
726
752
|
|
|
727
753
|
if (remainingTests.length === 0) {
|
|
754
|
+
// Determine final spec status based on all executed tests
|
|
755
|
+
const executedTests = test.parent.tests
|
|
756
|
+
.map((t: any) => {
|
|
757
|
+
const tId = this.getTestId(t);
|
|
758
|
+
return this.testMap.get(tId);
|
|
759
|
+
})
|
|
760
|
+
.filter((tData: TestData | undefined): tData is TestData => !!tData);
|
|
761
|
+
|
|
762
|
+
if (executedTests.length > 0) {
|
|
763
|
+
const allTestStatuses = executedTests.map(tData => tData.status);
|
|
764
|
+
if (allTestStatuses.every(status => status === 'passed')) {
|
|
765
|
+
specData.status = 'passed';
|
|
766
|
+
} else if (allTestStatuses.some(status => status === 'failed')) {
|
|
767
|
+
specData.status = 'failed';
|
|
768
|
+
} else if (allTestStatuses.every(status => status === 'skipped')) {
|
|
769
|
+
specData.status = 'skipped';
|
|
770
|
+
}
|
|
771
|
+
}
|
|
728
772
|
// Aggregate tags from all tests in this spec
|
|
729
773
|
const allTags = new Set<string>();
|
|
730
774
|
test.parent.tests.forEach((t: any) => {
|
|
@@ -757,6 +801,17 @@ export class TestLensReporter implements Reporter {
|
|
|
757
801
|
this.runMetadata.endTime = new Date().toISOString();
|
|
758
802
|
this.runMetadata.duration = Date.now() - new Date(this.runMetadata.startTime).getTime();
|
|
759
803
|
|
|
804
|
+
// Wait for all pending artifact uploads to complete before sending runEnd
|
|
805
|
+
if (this.pendingUploads.size > 0) {
|
|
806
|
+
logger.info(`⏳ Waiting for ${this.pendingUploads.size} artifact upload(s) to complete...`);
|
|
807
|
+
try {
|
|
808
|
+
await Promise.all(Array.from(this.pendingUploads));
|
|
809
|
+
logger.info(`[OK] All artifact uploads completed`);
|
|
810
|
+
} catch (error) {
|
|
811
|
+
logger.error(`[WARN] Some artifact uploads failed, continuing with runEnd`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
760
815
|
// Calculate final stats
|
|
761
816
|
const totalTests = Array.from(this.testMap.values()).length;
|
|
762
817
|
const passedTests = Array.from(this.testMap.values()).filter(t => t.status === 'passed').length;
|
|
@@ -788,12 +843,12 @@ export class TestLensReporter implements Reporter {
|
|
|
788
843
|
|
|
789
844
|
// Show Build Name if provided, otherwise show Run ID
|
|
790
845
|
if (this.runMetadata.testlensBuildName) {
|
|
791
|
-
|
|
792
|
-
|
|
846
|
+
logger.info(`[COMPLETE] TestLens Report completed - Build: ${this.runMetadata.testlensBuildName}`);
|
|
847
|
+
logger.info(` Run ID: ${this.runId}`);
|
|
793
848
|
} else {
|
|
794
|
-
|
|
849
|
+
logger.info(`[COMPLETE] TestLens Report completed - Run ID: ${this.runId}`);
|
|
795
850
|
}
|
|
796
|
-
|
|
851
|
+
logger.info(`[RESULTS] ${passedTests} passed, ${failedTests} failed (${timedOutTests} timeouts), ${skippedTests} skipped`);
|
|
797
852
|
}
|
|
798
853
|
|
|
799
854
|
private async sendToApi(payload: any): Promise<any> {
|
|
@@ -809,7 +864,7 @@ export class TestLensReporter implements Reporter {
|
|
|
809
864
|
}
|
|
810
865
|
});
|
|
811
866
|
if (this.config.enableRealTimeStream) {
|
|
812
|
-
|
|
867
|
+
logger.info(`[OK] Sent ${payload.type} event to TestLens (HTTP ${response.status})`);
|
|
813
868
|
}
|
|
814
869
|
// Return response data for caller to use
|
|
815
870
|
return response.data;
|
|
@@ -824,25 +879,25 @@ export class TestLensReporter implements Reporter {
|
|
|
824
879
|
this.runCreationFailed = true;
|
|
825
880
|
}
|
|
826
881
|
|
|
827
|
-
|
|
882
|
+
logger.error('\n' + '='.repeat(80));
|
|
828
883
|
if (errorData?.limit_type === 'test_cases') {
|
|
829
|
-
|
|
884
|
+
logger.error('[ERROR] TESTLENS ERROR: Test Cases Limit Reached');
|
|
830
885
|
} else if (errorData?.limit_type === 'test_runs') {
|
|
831
|
-
|
|
886
|
+
logger.error('[ERROR] TESTLENS ERROR: Test Runs Limit Reached');
|
|
832
887
|
} else {
|
|
833
|
-
|
|
888
|
+
logger.error('[ERROR] TESTLENS ERROR: Plan Limit Reached');
|
|
834
889
|
}
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
890
|
+
logger.error('='.repeat(80));
|
|
891
|
+
logger.error('');
|
|
892
|
+
logger.error(errorData?.message || 'You have reached your plan limit.');
|
|
893
|
+
logger.error('');
|
|
894
|
+
logger.error(`Current usage: ${errorData?.current_usage || 'N/A'} / ${errorData?.limit || 'N/A'}`);
|
|
895
|
+
logger.error('');
|
|
896
|
+
logger.error('To continue, please upgrade your plan.');
|
|
897
|
+
logger.error('Contact: support@alternative-path.com');
|
|
898
|
+
logger.error('');
|
|
899
|
+
logger.error('='.repeat(80));
|
|
900
|
+
logger.error('');
|
|
846
901
|
return; // Don't log the full error object for limit errors
|
|
847
902
|
}
|
|
848
903
|
|
|
@@ -850,36 +905,37 @@ export class TestLensReporter implements Reporter {
|
|
|
850
905
|
if (status === 401) {
|
|
851
906
|
if (errorData?.error === 'trial_expired' || errorData?.error === 'subscription_inactive' ||
|
|
852
907
|
errorData?.error === 'test_cases_limit_reached' || errorData?.error === 'test_runs_limit_reached') {
|
|
853
|
-
|
|
908
|
+
logger.error('\n' + '='.repeat(80));
|
|
854
909
|
|
|
855
910
|
if (errorData?.error === 'test_cases_limit_reached') {
|
|
856
|
-
|
|
911
|
+
logger.error('[ERROR] TESTLENS ERROR: Test Cases Limit Reached');
|
|
857
912
|
} else if (errorData?.error === 'test_runs_limit_reached') {
|
|
858
|
-
|
|
913
|
+
logger.error('[ERROR] TESTLENS ERROR: Test Runs Limit Reached');
|
|
859
914
|
} else {
|
|
860
|
-
|
|
915
|
+
logger.error('[ERROR] TESTLENS ERROR: Your trial plan has ended');
|
|
861
916
|
}
|
|
862
917
|
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
918
|
+
logger.error('='.repeat(80));
|
|
919
|
+
logger.error('');
|
|
920
|
+
logger.error(errorData?.message || 'Your trial period has expired.');
|
|
921
|
+
logger.error('');
|
|
922
|
+
logger.error('To continue using TestLens, please upgrade to Enterprise plan.');
|
|
923
|
+
logger.error('Contact: ' + (errorData?.contactEmail || 'support@alternative-path.com'));
|
|
924
|
+
logger.error('');
|
|
870
925
|
if (errorData?.trial_end_date) {
|
|
871
|
-
|
|
872
|
-
|
|
926
|
+
logger.error(`Trial ended: ${new Date(errorData.trial_end_date).toLocaleDateString()}`);
|
|
927
|
+
logger.error('');
|
|
873
928
|
}
|
|
874
|
-
|
|
875
|
-
|
|
929
|
+
logger.error('='.repeat(80));
|
|
930
|
+
logger.error('');
|
|
876
931
|
} else {
|
|
877
|
-
|
|
932
|
+
logger.error(`[ERROR] Authentication failed: ${errorData?.error || 'Invalid API key'}`);
|
|
878
933
|
}
|
|
879
934
|
} else if (status !== 403) {
|
|
880
935
|
// Log other errors (but not 403 which we handled above)
|
|
881
|
-
|
|
882
|
-
message:
|
|
936
|
+
logger.error({
|
|
937
|
+
message: `[ERROR] Failed to send ${payload.type} event to TestLens`,
|
|
938
|
+
error: error?.message || 'Unknown error',
|
|
883
939
|
status: status,
|
|
884
940
|
statusText: error?.response?.statusText,
|
|
885
941
|
data: errorData,
|
|
@@ -910,13 +966,13 @@ export class TestLensReporter implements Reporter {
|
|
|
910
966
|
|
|
911
967
|
// Skip video if disabled in config
|
|
912
968
|
if (isVideo && !this.config.enableVideo) {
|
|
913
|
-
|
|
969
|
+
logger.info(`[SKIP] Skipping video artifact ${attachment.name} - video capture disabled in config`);
|
|
914
970
|
continue;
|
|
915
971
|
}
|
|
916
972
|
|
|
917
973
|
// Skip screenshot if disabled in config
|
|
918
974
|
if (isScreenshot && !this.config.enableScreenshot) {
|
|
919
|
-
|
|
975
|
+
logger.info(`[SKIP] Skipping screenshot artifact ${attachment.name} - screenshot capture disabled in config`);
|
|
920
976
|
continue;
|
|
921
977
|
}
|
|
922
978
|
|
|
@@ -952,37 +1008,58 @@ export class TestLensReporter implements Reporter {
|
|
|
952
1008
|
}
|
|
953
1009
|
|
|
954
1010
|
// Upload to S3 first (pass DB ID if available for faster lookup)
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1011
|
+
// Create upload promise that we can track
|
|
1012
|
+
const uploadPromise = Promise.resolve().then(async () => {
|
|
1013
|
+
try {
|
|
1014
|
+
if (!attachment.path) {
|
|
1015
|
+
logger.info(`[SKIP] [Test: ${testId.substring(0, 8)}...] Skipping artifact ${attachment.name} - no file path`);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const s3Data = await this.uploadArtifactToS3(attachment.path, testId, fileName, testCaseDbId);
|
|
1020
|
+
|
|
1021
|
+
// Skip if upload failed or file was too large
|
|
1022
|
+
if (!s3Data) {
|
|
1023
|
+
logger.info(`[SKIP] [Test: ${testId.substring(0, 8)}...] Skipping artifact ${attachment.name} - upload failed or file too large`);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const artifactData = {
|
|
1028
|
+
testId,
|
|
1029
|
+
type: this.getArtifactType(attachment.name),
|
|
1030
|
+
path: attachment.path,
|
|
1031
|
+
name: fileName,
|
|
1032
|
+
contentType: attachment.contentType,
|
|
1033
|
+
fileSize: this.getFileSize(attachment.path),
|
|
1034
|
+
storageType: 's3',
|
|
1035
|
+
s3Key: s3Data.key,
|
|
1036
|
+
s3Url: s3Data.url
|
|
1037
|
+
};
|
|
974
1038
|
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1039
|
+
// Send artifact data to API
|
|
1040
|
+
await this.sendToApi({
|
|
1041
|
+
type: 'artifact',
|
|
1042
|
+
runId: this.runId,
|
|
1043
|
+
timestamp: new Date().toISOString(),
|
|
1044
|
+
artifact: artifactData
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
logger.info(`[ARTIFACT] [Test: ${testId.substring(0, 8)}...] Processed artifact: ${fileName}`);
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
logger.error(`[ERROR] [Test: ${testId.substring(0, 8)}...] Failed to process artifact ${attachment.name}: ${(error as Error).message}`);
|
|
1050
|
+
}
|
|
981
1051
|
});
|
|
982
1052
|
|
|
983
|
-
|
|
1053
|
+
// Track this upload and ensure cleanup on completion
|
|
1054
|
+
this.pendingUploads.add(uploadPromise);
|
|
1055
|
+
uploadPromise.finally(() => {
|
|
1056
|
+
this.pendingUploads.delete(uploadPromise);
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
// Don't await here - let uploads happen in parallel
|
|
1060
|
+
// They will be awaited in onEnd
|
|
984
1061
|
} catch (error) {
|
|
985
|
-
|
|
1062
|
+
logger.error(`[ERROR] [Test: ${testId.substring(0, 8)}...] Failed to setup artifact upload ${attachment.name}: ${(error as Error).message}`);
|
|
986
1063
|
}
|
|
987
1064
|
}
|
|
988
1065
|
}
|
|
@@ -1003,8 +1080,13 @@ export class TestLensReporter implements Reporter {
|
|
|
1003
1080
|
}));
|
|
1004
1081
|
|
|
1005
1082
|
// Send to dedicated spec code blocks API endpoint
|
|
1006
|
-
|
|
1007
|
-
|
|
1083
|
+
// Extract base URL - handle both full and partial endpoint patterns
|
|
1084
|
+
let baseUrl = this.config.apiEndpoint.replace('/api/v1/webhook/playwright', '');
|
|
1085
|
+
if (baseUrl === this.config.apiEndpoint) {
|
|
1086
|
+
// Fallback: try alternative pattern if main pattern didn't match
|
|
1087
|
+
baseUrl = this.config.apiEndpoint.replace('/webhook/playwright', '');
|
|
1088
|
+
}
|
|
1089
|
+
const specEndpoint = `${baseUrl}/api/v1/webhook/playwright/spec-code-blocks`;
|
|
1008
1090
|
|
|
1009
1091
|
await this.axiosInstance.post(specEndpoint, {
|
|
1010
1092
|
filePath: path.relative(process.cwd(), specPath),
|
|
@@ -1012,17 +1094,17 @@ export class TestLensReporter implements Reporter {
|
|
|
1012
1094
|
testSuiteName: path.basename(specPath).replace(/\.(spec|test)\.(js|ts)$/, '')
|
|
1013
1095
|
});
|
|
1014
1096
|
|
|
1015
|
-
|
|
1097
|
+
logger.info(`📝 Sent ${codeBlocks.length} code blocks for: ${path.basename(specPath)}`);
|
|
1016
1098
|
} catch (error: any) {
|
|
1017
1099
|
const errorData = error?.response?.data;
|
|
1018
1100
|
|
|
1019
1101
|
// Handle duplicate spec code blocks gracefully (when re-running tests)
|
|
1020
1102
|
if (errorData?.error && errorData.error.includes('duplicate key value violates unique constraint')) {
|
|
1021
|
-
|
|
1103
|
+
logger.info(`[INFO] Spec code blocks already exist for: ${path.basename(specPath)} (skipped)`);
|
|
1022
1104
|
return;
|
|
1023
1105
|
}
|
|
1024
1106
|
|
|
1025
|
-
|
|
1107
|
+
logger.error(`Failed to send spec code blocks: ${errorData || error?.message || 'Unknown error'}`);
|
|
1026
1108
|
}
|
|
1027
1109
|
}
|
|
1028
1110
|
|
|
@@ -1088,7 +1170,7 @@ export class TestLensReporter implements Reporter {
|
|
|
1088
1170
|
|
|
1089
1171
|
return blocks;
|
|
1090
1172
|
} catch (error: any) {
|
|
1091
|
-
|
|
1173
|
+
logger.error(`Failed to extract test blocks from ${filePath}: ${error.message}`);
|
|
1092
1174
|
return [];
|
|
1093
1175
|
}
|
|
1094
1176
|
}
|
|
@@ -1112,7 +1194,7 @@ export class TestLensReporter implements Reporter {
|
|
|
1112
1194
|
}
|
|
1113
1195
|
} catch (e) {
|
|
1114
1196
|
// Remote info is optional - handle gracefully
|
|
1115
|
-
|
|
1197
|
+
logger.info('[INFO] No git remote configured, skipping remote info');
|
|
1116
1198
|
}
|
|
1117
1199
|
|
|
1118
1200
|
const isDirty = execSync('git status --porcelain', { encoding: 'utf-8' }).trim().length > 0;
|
|
@@ -1193,7 +1275,7 @@ export class TestLensReporter implements Reporter {
|
|
|
1193
1275
|
const fileSize = this.getFileSize(filePath);
|
|
1194
1276
|
const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
|
|
1195
1277
|
|
|
1196
|
-
|
|
1278
|
+
logger.info(`📤 Uploading ${fileName} (${fileSizeMB}MB) directly to S3...`);
|
|
1197
1279
|
|
|
1198
1280
|
const baseUrl = this.config.apiEndpoint.replace('/api/v1/webhook/playwright', '');
|
|
1199
1281
|
|
|
@@ -1225,7 +1307,7 @@ export class TestLensReporter implements Reporter {
|
|
|
1225
1307
|
const { uploadUrl, s3Key, metadata } = presignedResponse.data;
|
|
1226
1308
|
|
|
1227
1309
|
// Step 2: Upload directly to S3 using presigned URL
|
|
1228
|
-
|
|
1310
|
+
logger.info(`[UPLOAD] [Test: ${testId.substring(0, 8)}...] Uploading ${fileName} directly to S3...`);
|
|
1229
1311
|
|
|
1230
1312
|
const fileBuffer = fs.readFileSync(filePath);
|
|
1231
1313
|
|
|
@@ -1247,7 +1329,7 @@ export class TestLensReporter implements Reporter {
|
|
|
1247
1329
|
throw new Error(`S3 upload failed with status ${uploadResponse.status}`);
|
|
1248
1330
|
}
|
|
1249
1331
|
|
|
1250
|
-
|
|
1332
|
+
logger.info(`[OK] [Test: ${testId.substring(0, 8)}...] S3 upload completed for ${fileName}`);
|
|
1251
1333
|
|
|
1252
1334
|
// Step 3: Confirm upload with server to save metadata
|
|
1253
1335
|
const confirmEndpoint = `${baseUrl}/api/v1/artifacts/public/confirm-upload`;
|
|
@@ -1273,7 +1355,7 @@ export class TestLensReporter implements Reporter {
|
|
|
1273
1355
|
|
|
1274
1356
|
if (confirmResponse.status === 201 && confirmResponse.data.success) {
|
|
1275
1357
|
const artifact = confirmResponse.data.artifact;
|
|
1276
|
-
|
|
1358
|
+
logger.info(`[OK] [Test: ${testId.substring(0, 8)}...] Upload confirmed${testCaseDbId ? ' (fast path)' : ' (fallback)'}`);
|
|
1277
1359
|
return {
|
|
1278
1360
|
key: s3Key,
|
|
1279
1361
|
url: artifact.s3Url,
|
|
@@ -1291,29 +1373,29 @@ export class TestLensReporter implements Reporter {
|
|
|
1291
1373
|
|
|
1292
1374
|
if (errorData?.error === 'trial_expired' || errorData?.error === 'subscription_inactive' ||
|
|
1293
1375
|
errorData?.error === 'test_cases_limit_reached' || errorData?.error === 'test_runs_limit_reached') {
|
|
1294
|
-
|
|
1376
|
+
logger.error('\n' + '='.repeat(80));
|
|
1295
1377
|
|
|
1296
1378
|
if (errorData?.error === 'test_cases_limit_reached') {
|
|
1297
|
-
|
|
1379
|
+
logger.error('[ERROR] TESTLENS ERROR: Test Cases Limit Reached');
|
|
1298
1380
|
} else if (errorData?.error === 'test_runs_limit_reached') {
|
|
1299
|
-
|
|
1381
|
+
logger.error('[ERROR] TESTLENS ERROR: Test Runs Limit Reached');
|
|
1300
1382
|
} else {
|
|
1301
|
-
|
|
1383
|
+
logger.error('[ERROR] TESTLENS ERROR: Your trial plan has ended');
|
|
1302
1384
|
}
|
|
1303
1385
|
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1386
|
+
logger.error('='.repeat(80));
|
|
1387
|
+
logger.error('');
|
|
1388
|
+
logger.error(errorData?.message || 'Your trial period has expired.');
|
|
1389
|
+
logger.error('');
|
|
1390
|
+
logger.error('To continue using TestLens, please upgrade to Enterprise plan.');
|
|
1391
|
+
logger.error('Contact: ' + (errorData?.contactEmail || 'support@alternative-path.com'));
|
|
1392
|
+
logger.error('');
|
|
1311
1393
|
if (errorData?.trial_end_date) {
|
|
1312
|
-
|
|
1313
|
-
|
|
1394
|
+
logger.error(`Trial ended: ${new Date(errorData.trial_end_date).toLocaleDateString()}`);
|
|
1395
|
+
logger.error('');
|
|
1314
1396
|
}
|
|
1315
|
-
|
|
1316
|
-
|
|
1397
|
+
logger.error('='.repeat(80));
|
|
1398
|
+
logger.error('');
|
|
1317
1399
|
return null;
|
|
1318
1400
|
}
|
|
1319
1401
|
}
|
|
@@ -1331,9 +1413,9 @@ export class TestLensReporter implements Reporter {
|
|
|
1331
1413
|
errorMsg = `Access denied (403) - presigned URL may have expired`;
|
|
1332
1414
|
}
|
|
1333
1415
|
|
|
1334
|
-
|
|
1416
|
+
logger.error(`[ERROR] Failed to upload ${fileName} to S3: ${errorMsg}`);
|
|
1335
1417
|
if (error.response?.data) {
|
|
1336
|
-
|
|
1418
|
+
logger.error({ errorDetails: error.response.data }, 'Error details');
|
|
1337
1419
|
}
|
|
1338
1420
|
|
|
1339
1421
|
// Don't throw, just return null to continue with other artifacts
|
|
@@ -1352,7 +1434,7 @@ export class TestLensReporter implements Reporter {
|
|
|
1352
1434
|
return mimeType || 'application/octet-stream';
|
|
1353
1435
|
}
|
|
1354
1436
|
} catch (error: any) {
|
|
1355
|
-
|
|
1437
|
+
logger.warn(`Failed to get MIME type for ${fileName}: ${error.message}`);
|
|
1356
1438
|
}
|
|
1357
1439
|
// Fallback to basic content type mapping
|
|
1358
1440
|
const contentTypes: Record<string, string> = {
|
|
@@ -1372,36 +1454,19 @@ export class TestLensReporter implements Reporter {
|
|
|
1372
1454
|
return contentTypes[ext] || 'application/octet-stream';
|
|
1373
1455
|
}
|
|
1374
1456
|
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
const safeTestId = this.sanitizeForS3(testId);
|
|
1378
|
-
const safeFileName = this.sanitizeForS3(fileName);
|
|
1379
|
-
const ext = path.extname(fileName);
|
|
1380
|
-
const baseName = path.basename(fileName, ext);
|
|
1381
|
-
|
|
1382
|
-
return `test-artifacts/${date}/${runId}/${safeTestId}/${safeFileName}${ext}`;
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
private sanitizeForS3(value: string): string {
|
|
1386
|
-
return value
|
|
1387
|
-
.replace(/[\/:*?"<>|]/g, '-')
|
|
1388
|
-
.replace(/[-\u001f\u007f]/g, '-')
|
|
1389
|
-
.replace(/[^-~]/g, '-')
|
|
1390
|
-
.replace(/\s+/g, '-')
|
|
1391
|
-
.replace(/[_]/g, '-')
|
|
1392
|
-
.replace(/-+/g, '-')
|
|
1393
|
-
.replace(/^-|-$/g, '');
|
|
1394
|
-
}
|
|
1457
|
+
// Note: S3 key generation and sanitization are handled server-side
|
|
1458
|
+
// generateS3Key() and sanitizeForS3() methods removed as they were not used
|
|
1395
1459
|
|
|
1396
1460
|
private getFileSize(filePath: string): number {
|
|
1397
1461
|
try {
|
|
1398
1462
|
const stats = fs.statSync(filePath);
|
|
1399
1463
|
return stats.size;
|
|
1400
1464
|
} catch (error) {
|
|
1401
|
-
|
|
1465
|
+
logger.warn(`Could not get file size for ${filePath}: ${(error as Error).message}`);
|
|
1402
1466
|
return 0;
|
|
1403
1467
|
}
|
|
1404
1468
|
}
|
|
1405
1469
|
}
|
|
1406
1470
|
|
|
1407
1471
|
export default TestLensReporter;
|
|
1472
|
+
|