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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/index.d.ts +2 -0
  2. package/index.js +166 -34
  3. package/index.ts +254 -127
  4. package/package.json +2 -1
package/index.d.ts CHANGED
@@ -99,6 +99,7 @@ export interface RunMetadata {
99
99
  passedTests?: number;
100
100
  failedTests?: number;
101
101
  skippedTests?: number;
102
+ timedOutTests?: number;
102
103
  status?: string;
103
104
  testlensBuildName?: string;
104
105
  customMetadata?: Record<string, string | string[]>;
@@ -161,6 +162,7 @@ export declare class TestLensReporter implements Reporter {
161
162
  private runCreationFailed;
162
163
  private cliArgs;
163
164
  private pendingUploads;
165
+ private traceNetworkRows;
164
166
  private artifactStats;
165
167
  private artifactsSeen;
166
168
  /**
package/index.js CHANGED
@@ -121,6 +121,7 @@ class TestLensReporter {
121
121
  this.runCreationFailed = false; // Track if run creation failed due to limits
122
122
  this.cliArgs = {}; // Store CLI args separately
123
123
  this.pendingUploads = new Set(); // Track pending artifact uploads
124
+ this.traceNetworkRows = []; // Network requests/responses from trace zip for current test
124
125
  this.artifactStats = {
125
126
  uploaded: { screenshot: 0, video: 0, trace: 0, attachment: 0 },
126
127
  skipped: { screenshot: 0, video: 0, trace: 0, attachment: 0 },
@@ -406,7 +407,9 @@ class TestLensReporter {
406
407
  }
407
408
  }
408
409
  async onTestEnd(test, result) {
410
+ this.traceNetworkRows = []; // Reset at start of each test
409
411
  const testId = this.getTestId(test);
412
+ let testCaseId = '';
410
413
  let testData = this.testMap.get(testId);
411
414
  logger.debug(`[ARTIFACTS] attachments=${result.attachments?.length ?? 0}`);
412
415
  if (result.attachments && result.attachments.length > 0) {
@@ -577,10 +580,15 @@ class TestLensReporter {
577
580
  timestamp: new Date().toISOString(),
578
581
  test: testData
579
582
  });
583
+ testCaseId = testEndResponse?.testCaseId;
580
584
  // Handle artifacts (test case is now guaranteed to be in database)
581
585
  if (this.config.enableArtifacts) {
582
- // Pass test case DB ID if available for faster lookups
583
- await this.processArtifacts(testId, result, testEndResponse?.testCaseId);
586
+ // Pass test case DB ID if available for faster lookups; pass status/endTime so backend
587
+ // can fix up test case status if testEnd failed but artifact request succeeds
588
+ await this.processArtifacts(testId, result, testEndResponse?.testCaseId, {
589
+ status: testData.status,
590
+ endTime: testData.endTime
591
+ });
584
592
  }
585
593
  else if (result.attachments && result.attachments.length > 0) {
586
594
  for (const attachment of result.attachments) {
@@ -650,7 +658,7 @@ class TestLensReporter {
650
658
  spec: specData
651
659
  });
652
660
  // Send spec code blocks to API
653
- await this.sendSpecCodeBlocks(specPath);
661
+ await this.sendSpecCodeBlocks(specPath, test.title, testData?.errors || [], this.runId, testId, testCaseId);
654
662
  }
655
663
  }
656
664
  }
@@ -815,7 +823,7 @@ class TestLensReporter {
815
823
  // Don't throw error to avoid breaking test execution
816
824
  }
817
825
  }
818
- async processArtifacts(testId, result, testCaseDbId) {
826
+ async processArtifacts(testId, result, testCaseDbId, testEndPayload) {
819
827
  // Skip artifact processing if run creation failed
820
828
  if (this.runCreationFailed) {
821
829
  return;
@@ -839,6 +847,81 @@ class TestLensReporter {
839
847
  this.bumpArtifactStat('skipped', artifactType);
840
848
  continue;
841
849
  }
850
+ // Trace zip (default from Playwright): extract network-like data and print to console
851
+ const isTraceZip = artifactType === 'trace' && attachment.path && (attachment.path.endsWith('.zip') || attachment.contentType === 'application/zip');
852
+ if (isTraceZip) {
853
+ try {
854
+ const traceStart = Date.now();
855
+ const AdmZip = require('adm-zip');
856
+ const zip = new AdmZip(attachment.path);
857
+ const entries = zip.getEntries();
858
+ const networkRows = [];
859
+ for (const e of entries) {
860
+ if (e.isDirectory || !e.getData)
861
+ continue;
862
+ const name = (e.entryName || '').toLowerCase();
863
+ const isNetworkFile = name.endsWith('.network') || name.includes('.network') || name === 'network';
864
+ if (!isNetworkFile)
865
+ continue;
866
+ const data = e.getData();
867
+ if (!data)
868
+ continue;
869
+ const text = data.toString('utf8');
870
+ if (!text || text.length < 2)
871
+ continue;
872
+ const isApplicationJson = (o) => {
873
+ if (!o || typeof o !== 'object')
874
+ return false;
875
+ const res = o.snapshot?.response ?? o.response;
876
+ if (!res)
877
+ return false;
878
+ const mime = res.content?.mimeType;
879
+ if (typeof mime === 'string') {
880
+ const type = mime.split(';')[0].trim().toLowerCase();
881
+ return type === 'application/json';
882
+ }
883
+ const headers = res.headers;
884
+ if (Array.isArray(headers)) {
885
+ const ct = headers.find((h) => (h.name || '').toLowerCase() === 'content-type');
886
+ const val = ct?.value;
887
+ if (typeof val === 'string') {
888
+ const type = val.split(';')[0].trim().toLowerCase();
889
+ return type === 'application/json';
890
+ }
891
+ }
892
+ return false;
893
+ };
894
+ const lines = text.split(/\r?\n/).filter(Boolean);
895
+ for (const line of lines) {
896
+ try {
897
+ const obj = JSON.parse(line);
898
+ if (obj && typeof obj === 'object' && isApplicationJson(obj))
899
+ networkRows.push(obj);
900
+ }
901
+ catch (_) { }
902
+ }
903
+ if (networkRows.length === 0 && lines.length > 0) {
904
+ try {
905
+ const arr = JSON.parse(text);
906
+ if (Array.isArray(arr))
907
+ networkRows.push(...arr.filter((x) => x != null && typeof x === 'object' && isApplicationJson(x)));
908
+ }
909
+ catch (_) { }
910
+ }
911
+ }
912
+ const durationMs = Date.now() - traceStart;
913
+ if (networkRows.length > 0) {
914
+ this.traceNetworkRows = networkRows;
915
+ }
916
+ else {
917
+ this.traceNetworkRows = [];
918
+ }
919
+ }
920
+ catch (e) {
921
+ logger.warn('[TRACE] Could not read trace zip: ' + (e && e.message));
922
+ this.traceNetworkRows = [];
923
+ }
924
+ }
842
925
  try {
843
926
  // Determine proper filename with extension
844
927
  // Playwright attachment.name often doesn't have extension, so we need to derive it
@@ -894,7 +977,12 @@ class TestLensReporter {
894
977
  fileSize: this.getFileSize(attachment.path),
895
978
  storageType: 's3',
896
979
  s3Key: s3Data.key,
897
- s3Url: s3Data.url
980
+ s3Url: s3Data.url,
981
+ // So backend can fix test case status if testEnd failed but artifact succeeded
982
+ ...(testEndPayload && {
983
+ testStatus: testEndPayload.status,
984
+ testEndTime: testEndPayload.endTime
985
+ })
898
986
  };
899
987
  // Send artifact data to API
900
988
  await this.sendToApi({
@@ -928,12 +1016,12 @@ class TestLensReporter {
928
1016
  }
929
1017
  }
930
1018
  }
931
- async sendSpecCodeBlocks(specPath) {
1019
+ async sendSpecCodeBlocks(specPath, testName, errors, runId, test_id, testCaseId) {
932
1020
  try {
933
1021
  // Extract code blocks using built-in parser
934
1022
  const testBlocks = this.extractTestBlocks(specPath);
935
1023
  // Transform blocks to match backend API expectations
936
- const codeBlocks = testBlocks.map(block => ({
1024
+ const codeBlocks = testBlocks.filter(block => block.name === testName).map(block => ({
937
1025
  type: block.type, // 'test' or 'describe'
938
1026
  name: block.name, // test/describe name
939
1027
  content: block.content, // full code content
@@ -951,7 +1039,12 @@ class TestLensReporter {
951
1039
  await this.axiosInstance.post(specEndpoint, {
952
1040
  filePath: path.relative(process.cwd(), specPath),
953
1041
  codeBlocks,
954
- testSuiteName: path.basename(specPath).replace(/\.(spec|test)\.(js|ts)$/, '')
1042
+ errors,
1043
+ traceNetworkRows: this.traceNetworkRows,
1044
+ testSuiteName: path.basename(specPath).replace(/\.(spec|test)\.(js|ts)$/, ''),
1045
+ runId,
1046
+ test_id,
1047
+ testCaseId
955
1048
  });
956
1049
  logger.debug(`Sent ${codeBlocks.length} code blocks for: ${path.basename(specPath)}`);
957
1050
  }
@@ -970,53 +1063,94 @@ class TestLensReporter {
970
1063
  const content = fs.readFileSync(filePath, 'utf-8');
971
1064
  const blocks = [];
972
1065
  const lines = content.split('\n');
973
- let currentDescribe = null;
974
- let braceCount = 0;
1066
+ // Use a stack to track nested describe blocks with their brace depths
1067
+ const describeStack = [];
1068
+ let globalBraceCount = 0;
975
1069
  let inBlock = false;
976
1070
  let blockStart = -1;
1071
+ let blockBraceCount = 0;
977
1072
  let blockType = 'test';
978
1073
  let blockName = '';
1074
+ let blockDescribe = undefined;
979
1075
  for (let i = 0; i < lines.length; i++) {
980
1076
  const line = lines[i];
981
1077
  const trimmedLine = line.trim();
982
- // Check for describe blocks
983
- const describeMatch = trimmedLine.match(/describe\s*\(\s*['"`]([^'"`]+)['"`]/);
984
- if (describeMatch) {
985
- currentDescribe = describeMatch[1];
986
- }
987
- // Check for test blocks
988
- const testMatch = trimmedLine.match(/test\s*\(\s*['"`]([^'"`]+)['"`]/);
989
- if (testMatch && !inBlock) {
990
- blockType = 'test';
991
- blockName = testMatch[1];
992
- blockStart = i;
993
- braceCount = 0;
994
- inBlock = true;
1078
+ if (!inBlock) {
1079
+ // Check for describe blocks: describe(), test.describe(), test.describe.serial(), etc.
1080
+ const describeMatch = trimmedLine.match(/(?:test\.)?describe(?:\.(?:serial|parallel|only|skip|fixme))?\s*\(\s*['"`]([^'"`]+)['"`]/);
1081
+ if (describeMatch) {
1082
+ // Count braces on this line to find the opening brace
1083
+ let lineOpenBraces = 0;
1084
+ for (const char of line) {
1085
+ if (char === '{') {
1086
+ globalBraceCount++;
1087
+ lineOpenBraces++;
1088
+ }
1089
+ if (char === '}')
1090
+ globalBraceCount--;
1091
+ }
1092
+ // Push describe onto stack with the current brace depth
1093
+ describeStack.push({ name: describeMatch[1], braceDepth: globalBraceCount });
1094
+ continue;
1095
+ }
1096
+ // Check for test blocks: test(), test.only(), test.skip(), test.fixme(), it(), it.only(), etc.
1097
+ const testMatch = trimmedLine.match(/(?:test|it)(?:\.(?:only|skip|fixme|slow))?\s*\(\s*['"`]([^'"`]+)['"`]/);
1098
+ if (testMatch) {
1099
+ blockType = 'test';
1100
+ blockName = testMatch[1];
1101
+ blockStart = i;
1102
+ blockBraceCount = 0;
1103
+ // Capture the current innermost describe name
1104
+ blockDescribe = describeStack.length > 0 ? describeStack[describeStack.length - 1].name : undefined;
1105
+ inBlock = true;
1106
+ }
995
1107
  }
996
- // Count braces when in a block
1108
+ // Count braces
997
1109
  if (inBlock) {
998
1110
  for (const char of line) {
999
- if (char === '{')
1000
- braceCount++;
1001
- if (char === '}')
1002
- braceCount--;
1003
- if (braceCount === 0 && blockStart !== -1 && i > blockStart) {
1004
- // End of block found
1111
+ if (char === '{') {
1112
+ blockBraceCount++;
1113
+ globalBraceCount++;
1114
+ }
1115
+ if (char === '}') {
1116
+ blockBraceCount--;
1117
+ globalBraceCount--;
1118
+ }
1119
+ if (blockBraceCount === 0 && blockStart !== -1 && i > blockStart) {
1120
+ // End of test block found
1005
1121
  const blockContent = lines.slice(blockStart, i + 1).join('\n');
1006
1122
  blocks.push({
1007
1123
  type: blockType,
1008
1124
  name: blockName,
1009
1125
  content: blockContent,
1010
- describe: currentDescribe || undefined,
1126
+ describe: blockDescribe,
1011
1127
  startLine: blockStart + 1,
1012
1128
  endLine: i + 1
1013
1129
  });
1014
1130
  inBlock = false;
1015
1131
  blockStart = -1;
1132
+ // Pop any describe blocks that have closed
1133
+ while (describeStack.length > 0 && globalBraceCount < describeStack[describeStack.length - 1].braceDepth) {
1134
+ describeStack.pop();
1135
+ }
1016
1136
  break;
1017
1137
  }
1018
1138
  }
1019
1139
  }
1140
+ else {
1141
+ // Track braces outside of test blocks (for describe open/close)
1142
+ for (const char of line) {
1143
+ if (char === '{')
1144
+ globalBraceCount++;
1145
+ if (char === '}') {
1146
+ globalBraceCount--;
1147
+ // Pop any describe blocks that have closed
1148
+ while (describeStack.length > 0 && globalBraceCount < describeStack[describeStack.length - 1].braceDepth) {
1149
+ describeStack.pop();
1150
+ }
1151
+ }
1152
+ }
1153
+ }
1020
1154
  }
1021
1155
  return blocks;
1022
1156
  }
@@ -1127,7 +1261,6 @@ class TestLensReporter {
1127
1261
  // Step 1: Request pre-signed URL from server
1128
1262
  const presignedUrlEndpoint = `${baseUrl}/api/v1/artifacts/public/presigned-url`;
1129
1263
  const requestBody = {
1130
- apiKey: this.config.apiKey,
1131
1264
  testRunId: this.runId,
1132
1265
  testId: testId,
1133
1266
  fileName: fileName,
@@ -1169,7 +1302,6 @@ class TestLensReporter {
1169
1302
  // Step 3: Confirm upload with server to save metadata
1170
1303
  const confirmEndpoint = `${baseUrl}/api/v1/artifacts/public/confirm-upload`;
1171
1304
  const confirmBody = {
1172
- apiKey: this.config.apiKey,
1173
1305
  testRunId: this.runId,
1174
1306
  testId: testId,
1175
1307
  s3Key: s3Key,
@@ -1261,7 +1393,7 @@ class TestLensReporter {
1261
1393
  // Try different ways to access getType method
1262
1394
  const getType = mime.getType || mime.default?.getType;
1263
1395
  if (typeof getType === 'function') {
1264
- const mimeType = getType.call(mime, ext) || getType.call(mime.default, ext);
1396
+ const mimeType = getType.call(mime, ext) || (mime.default ? getType.call(mime.default, ext) : null);
1265
1397
  return mimeType || 'application/octet-stream';
1266
1398
  }
1267
1399
  }
package/index.ts CHANGED
@@ -61,8 +61,8 @@ const logger = pino({
61
61
  typeof args[0] === 'string'
62
62
  ? args[0]
63
63
  : typeof args[1] === 'string'
64
- ? args[1]
65
- : '';
64
+ ? args[1]
65
+ : '';
66
66
  if (shouldSuppressInfo(msg)) {
67
67
  return;
68
68
  }
@@ -187,6 +187,7 @@ export interface RunMetadata {
187
187
  passedTests?: number;
188
188
  failedTests?: number;
189
189
  skippedTests?: number;
190
+ timedOutTests?: number;
190
191
  status?: string;
191
192
  testlensBuildName?: string;
192
193
  customMetadata?: Record<string, string | string[]>;
@@ -250,6 +251,7 @@ export class TestLensReporter implements Reporter {
250
251
  private runCreationFailed: boolean = false; // Track if run creation failed due to limits
251
252
  private cliArgs: Record<string, any> = {}; // Store CLI args separately
252
253
  private pendingUploads: Set<Promise<any>> = new Set(); // Track pending artifact uploads
254
+ private traceNetworkRows: unknown[] = []; // Network requests/responses from trace zip for current test
253
255
  private artifactStats = {
254
256
  uploaded: { screenshot: 0, video: 0, trace: 0, attachment: 0 },
255
257
  skipped: { screenshot: 0, video: 0, trace: 0, attachment: 0 },
@@ -263,7 +265,7 @@ export class TestLensReporter implements Reporter {
263
265
  */
264
266
  private static parseCustomArgs(): Record<string, any> {
265
267
  const customArgs: Record<string, any> = {};
266
-
268
+
267
269
  // Common environment variable names for build metadata
268
270
  const envVarMappings: Record<string, string[]> = {
269
271
  // Support both TestLens-specific names (recommended) and common CI names
@@ -275,7 +277,7 @@ export class TestLensReporter implements Reporter {
275
277
  'project': ['PROJECT', 'PROJECT_NAME'],
276
278
  'customvalue': ['CUSTOMVALUE', 'CUSTOM_VALUE']
277
279
  };
278
-
280
+
279
281
  // Check for each metadata key
280
282
  Object.entries(envVarMappings).forEach(([key, envVars]) => {
281
283
  for (const envVar of envVars) {
@@ -293,7 +295,7 @@ export class TestLensReporter implements Reporter {
293
295
  }
294
296
  }
295
297
  });
296
-
298
+
297
299
  return customArgs;
298
300
  }
299
301
 
@@ -301,11 +303,11 @@ export class TestLensReporter implements Reporter {
301
303
  // Parse custom CLI arguments
302
304
  const customArgs = TestLensReporter.parseCustomArgs();
303
305
  this.cliArgs = customArgs; // Store CLI args separately for later use
304
-
306
+
305
307
  // Allow API key from environment variable if not provided in config
306
308
  // Check multiple environment variable names in priority order (uppercase and lowercase)
307
- const apiKey = options.apiKey
308
- || process.env.TESTLENS_API_KEY
309
+ const apiKey = options.apiKey
310
+ || process.env.TESTLENS_API_KEY
309
311
  || process.env.testlens_api_key
310
312
  || process.env.TESTLENS_KEY
311
313
  || process.env.testlens_key
@@ -314,7 +316,7 @@ export class TestLensReporter implements Reporter {
314
316
  || process.env.playwright_api_key
315
317
  || process.env.PW_API_KEY
316
318
  || process.env.pw_api_key;
317
-
319
+
318
320
  this.config = {
319
321
  apiEndpoint: options.apiEndpoint || 'https://testlens.qa-path.com/api/v1/webhook/playwright',
320
322
  apiKey: apiKey, // API key from config or environment variable
@@ -335,7 +337,7 @@ export class TestLensReporter implements Reporter {
335
337
  if (!this.config.apiKey) {
336
338
  throw new Error('API_KEY is required for TestLensReporter. Pass it as apiKey option in your playwright config or set one of these environment variables: TESTLENS_API_KEY, TESTLENS_KEY, PLAYWRIGHT_API_KEY, PW_API_KEY, API_KEY, or APIKEY.');
337
339
  }
338
-
340
+
339
341
  if (apiKey !== options.apiKey) {
340
342
  logger.debug('✓ Using API key from environment variable');
341
343
  }
@@ -389,11 +391,11 @@ export class TestLensReporter implements Reporter {
389
391
  (response) => response,
390
392
  async (error: any) => {
391
393
  const originalRequest = error.config;
392
-
394
+
393
395
  if (!originalRequest._retry && error.response?.status >= 500) {
394
396
  originalRequest._retry = true;
395
397
  originalRequest._retryCount = (originalRequest._retryCount || 0) + 1;
396
-
398
+
397
399
  if (originalRequest._retryCount <= this.config.retryAttempts) {
398
400
  // Exponential backoff
399
401
  const delay = Math.pow(2, originalRequest._retryCount) * 1000;
@@ -401,7 +403,7 @@ export class TestLensReporter implements Reporter {
401
403
  return this.axiosInstance(originalRequest);
402
404
  }
403
405
  }
404
-
406
+
405
407
  return Promise.reject(error);
406
408
  }
407
409
  );
@@ -411,7 +413,7 @@ export class TestLensReporter implements Reporter {
411
413
  this.specMap = new Map<string, SpecData>();
412
414
  this.testMap = new Map<string, TestData>();
413
415
  this.runCreationFailed = false;
414
-
416
+
415
417
  // Log custom metadata if any
416
418
  if (this.config.customMetadata && Object.keys(this.config.customMetadata).length > 0) {
417
419
  logger.debug('\n[METADATA] Custom Metadata Detected:');
@@ -433,11 +435,11 @@ export class TestLensReporter implements Reporter {
433
435
  nodeVersion: process.version,
434
436
  testlensVersion: this.getTestLensVersion()
435
437
  };
436
-
438
+
437
439
  // Add custom metadata if provided
438
440
  if (this.config.customMetadata && Object.keys(this.config.customMetadata).length > 0) {
439
441
  metadata.customMetadata = this.config.customMetadata;
440
-
442
+
441
443
  // Extract testlensBuildName as a dedicated field for dashboard display
442
444
  if (this.config.customMetadata.testlensBuildName) {
443
445
  const buildName = this.config.customMetadata.testlensBuildName;
@@ -445,7 +447,7 @@ export class TestLensReporter implements Reporter {
445
447
  metadata.testlensBuildName = Array.isArray(buildName) ? buildName[0] : buildName;
446
448
  }
447
449
  }
448
-
450
+
449
451
  return metadata;
450
452
  }
451
453
 
@@ -496,7 +498,7 @@ export class TestLensReporter implements Reporter {
496
498
  } else {
497
499
  logger.debug(`TestLens Reporter starting - Run ID: ${this.runId}`);
498
500
  }
499
-
501
+
500
502
  // Collect Git information if enabled
501
503
  if (this.config.enableGitInfo) {
502
504
  this.runMetadata.gitInfo = await this.collectGitInfo();
@@ -529,7 +531,7 @@ export class TestLensReporter implements Reporter {
529
531
  async onTestBegin(test: TestCase, result: TestResult): Promise<void> {
530
532
  // Log which test is starting
531
533
  logger.debug(`[TEST] Running test: ${test.title}`);
532
-
534
+
533
535
  const specPath = test.location.file;
534
536
  const specKey = `${specPath}-${test.parent.title}`;
535
537
 
@@ -557,7 +559,7 @@ export class TestLensReporter implements Reporter {
557
559
  }
558
560
 
559
561
  const testId = this.getTestId(test);
560
-
562
+
561
563
  // Only send testStart event on first attempt (retry 0)
562
564
  if (result.retry === 0) {
563
565
  // Create test data
@@ -606,7 +608,9 @@ export class TestLensReporter implements Reporter {
606
608
  }
607
609
 
608
610
  async onTestEnd(test: TestCase, result: TestResult): Promise<void> {
611
+ this.traceNetworkRows = []; // Reset at start of each test
609
612
  const testId = this.getTestId(test);
613
+ let testCaseId = '';
610
614
  let testData = this.testMap.get(testId);
611
615
 
612
616
  logger.debug(`[ARTIFACTS] attachments=${result.attachments?.length ?? 0}`);
@@ -631,7 +635,7 @@ export class TestLensReporter implements Reporter {
631
635
  // Create spec data if not exists (skipped tests might not have spec data either)
632
636
  const specPath = test.location.file;
633
637
  const specKey = `${specPath}-${test.parent.title}`;
634
-
638
+
635
639
  if (!this.specMap.has(specKey)) {
636
640
  const extractedTags = this.extractTags(test);
637
641
  const specData: SpecData = {
@@ -700,25 +704,25 @@ export class TestLensReporter implements Reporter {
700
704
  testData.endTime = new Date().toISOString();
701
705
  testData.errorMessages = result.errors.map((error: any) => error.message || error.toString());
702
706
  testData.currentRetry = result.retry;
703
-
707
+
704
708
  // Capture test location
705
709
  testData.location = {
706
710
  file: path.relative(process.cwd(), test.location.file),
707
711
  line: test.location.line,
708
712
  column: test.location.column
709
713
  };
710
-
714
+
711
715
  // Capture rich error details like Playwright's HTML report
712
716
  testData.errors = result.errors.map((error: any) => {
713
717
  const testError: TestError = {
714
718
  message: error.message || error.toString()
715
719
  };
716
-
720
+
717
721
  // Capture stack trace
718
722
  if (error.stack) {
719
723
  testError.stack = error.stack;
720
724
  }
721
-
725
+
722
726
  // Capture error location
723
727
  if (error.location) {
724
728
  testError.location = {
@@ -727,22 +731,22 @@ export class TestLensReporter implements Reporter {
727
731
  column: error.location.column
728
732
  };
729
733
  }
730
-
734
+
731
735
  // Capture code snippet around error - from Playwright error object
732
736
  if (error.snippet) {
733
737
  testError.snippet = error.snippet;
734
738
  }
735
-
739
+
736
740
  // Capture expected/actual values for assertion failures
737
741
  // Playwright stores these as specially formatted strings in the message
738
742
  const message = error.message || '';
739
-
743
+
740
744
  // Try to parse expected pattern from toHaveURL and similar assertions
741
745
  const expectedPatternMatch = message.match(/Expected pattern:\s*(.+?)(?:\n|$)/);
742
746
  if (expectedPatternMatch) {
743
747
  testError.expected = expectedPatternMatch[1].trim();
744
748
  }
745
-
749
+
746
750
  // Also try "Expected string:" format
747
751
  if (!testError.expected) {
748
752
  const expectedStringMatch = message.match(/Expected string:\s*["']?(.+?)["']?(?:\n|$)/);
@@ -750,13 +754,13 @@ export class TestLensReporter implements Reporter {
750
754
  testError.expected = expectedStringMatch[1].trim();
751
755
  }
752
756
  }
753
-
757
+
754
758
  // Try to parse received/actual value
755
759
  const receivedMatch = message.match(/Received (?:string|value):\s*["']?(.+?)["']?(?:\n|$)/);
756
760
  if (receivedMatch) {
757
761
  testError.actual = receivedMatch[1].trim();
758
762
  }
759
-
763
+
760
764
  // Parse call log entries for debugging info (timeouts, retries, etc.)
761
765
  const callLogMatch = message.match(/Call log:([\s\S]*?)(?=\n\n|\n\s*\d+\s*\||$)/);
762
766
  if (callLogMatch) {
@@ -766,19 +770,19 @@ export class TestLensReporter implements Reporter {
766
770
  testError.diff = callLog; // Reuse diff field for call log
767
771
  }
768
772
  }
769
-
773
+
770
774
  // Parse timeout information - multiple formats
771
775
  const timeoutMatch = message.match(/(?:with timeout|Timeout:?)\s*(\d+)ms/i);
772
776
  if (timeoutMatch) {
773
777
  testError.timeout = parseInt(timeoutMatch[1], 10);
774
778
  }
775
-
779
+
776
780
  // Parse matcher name (e.g., toHaveURL, toBeVisible)
777
781
  const matcherMatch = message.match(/expect\([^)]+\)\.(\w+)/);
778
782
  if (matcherMatch) {
779
783
  testError.matcherName = matcherMatch[1];
780
784
  }
781
-
785
+
782
786
  // Extract code snippet from message if not already captured
783
787
  // Look for lines like " 9 | await page.click..." or "> 11 | await expect..."
784
788
  if (!testError.snippet) {
@@ -787,7 +791,7 @@ export class TestLensReporter implements Reporter {
787
791
  testError.snippet = codeSnippetMatch[1].trim();
788
792
  }
789
793
  }
790
-
794
+
791
795
  return testError;
792
796
  });
793
797
 
@@ -801,11 +805,16 @@ export class TestLensReporter implements Reporter {
801
805
  timestamp: new Date().toISOString(),
802
806
  test: testData
803
807
  });
808
+ testCaseId = testEndResponse?.testCaseId;
804
809
 
805
810
  // Handle artifacts (test case is now guaranteed to be in database)
806
811
  if (this.config.enableArtifacts) {
807
- // Pass test case DB ID if available for faster lookups
808
- await this.processArtifacts(testId, result, testEndResponse?.testCaseId);
812
+ // Pass test case DB ID if available for faster lookups; pass status/endTime so backend
813
+ // can fix up test case status if testEnd failed but artifact request succeeds
814
+ await this.processArtifacts(testId, result, testEndResponse?.testCaseId, {
815
+ status: testData.status,
816
+ endTime: testData.endTime
817
+ });
809
818
  } else if (result.attachments && result.attachments.length > 0) {
810
819
  for (const attachment of result.attachments) {
811
820
  const artifactType = this.getArtifactType(attachment.name);
@@ -844,7 +853,7 @@ export class TestLensReporter implements Reporter {
844
853
  return this.testMap.get(tId);
845
854
  })
846
855
  .filter((tData: TestData | undefined): tData is TestData => !!tData);
847
-
856
+
848
857
  if (executedTests.length > 0) {
849
858
  const allTestStatuses = executedTests.map(tData => tData.status);
850
859
  if (allTestStatuses.every(status => status === 'passed')) {
@@ -866,9 +875,9 @@ export class TestLensReporter implements Reporter {
866
875
  if (aggregatedTags.length > 0) {
867
876
  specData.tags = aggregatedTags;
868
877
  }
869
-
878
+
870
879
  specData.endTime = new Date().toISOString();
871
-
880
+
872
881
  // Send spec end event to API
873
882
  await this.sendToApi({
874
883
  type: 'specEnd',
@@ -878,7 +887,7 @@ export class TestLensReporter implements Reporter {
878
887
  });
879
888
 
880
889
  // Send spec code blocks to API
881
- await this.sendSpecCodeBlocks(specPath);
890
+ await this.sendSpecCodeBlocks(specPath, test.title, testData?.errors || [], this.runId, testId, testCaseId);
882
891
  }
883
892
  }
884
893
  }
@@ -957,7 +966,7 @@ export class TestLensReporter implements Reporter {
957
966
  if (this.runCreationFailed && payload.type !== 'runStart') {
958
967
  return null;
959
968
  }
960
-
969
+
961
970
  try {
962
971
  const response = await this.axiosInstance.post('', payload, {
963
972
  headers: {
@@ -972,14 +981,14 @@ export class TestLensReporter implements Reporter {
972
981
  } catch (error: any) {
973
982
  const errorData = error?.response?.data;
974
983
  const status = error?.response?.status;
975
-
984
+
976
985
  // Check for limit exceeded (403)
977
986
  if (status === 403 && errorData?.error === 'limit_exceeded') {
978
987
  // Set flag to skip subsequent events
979
988
  if (payload.type === 'runStart' && errorData?.limit_type === 'test_runs') {
980
989
  this.runCreationFailed = true;
981
990
  }
982
-
991
+
983
992
  logger.error('\n' + '='.repeat(80));
984
993
  if (errorData?.limit_type === 'test_cases') {
985
994
  logger.error('[ERROR] TESTLENS ERROR: Test Cases Limit Reached');
@@ -1001,13 +1010,13 @@ export class TestLensReporter implements Reporter {
1001
1010
  logger.error('');
1002
1011
  return; // Don't log the full error object for limit errors
1003
1012
  }
1004
-
1013
+
1005
1014
  // Check for trial expiration, subscription errors, or limit errors (401)
1006
1015
  if (status === 401) {
1007
- if (errorData?.error === 'trial_expired' || errorData?.error === 'subscription_inactive' ||
1008
- errorData?.error === 'test_cases_limit_reached' || errorData?.error === 'test_runs_limit_reached') {
1016
+ if (errorData?.error === 'trial_expired' || errorData?.error === 'subscription_inactive' ||
1017
+ errorData?.error === 'test_cases_limit_reached' || errorData?.error === 'test_runs_limit_reached') {
1009
1018
  logger.error('\n' + '='.repeat(80));
1010
-
1019
+
1011
1020
  if (errorData?.error === 'test_cases_limit_reached') {
1012
1021
  logger.error('[ERROR] TESTLENS ERROR: Test Cases Limit Reached');
1013
1022
  } else if (errorData?.error === 'test_runs_limit_reached') {
@@ -1015,7 +1024,7 @@ export class TestLensReporter implements Reporter {
1015
1024
  } else {
1016
1025
  logger.error('[ERROR] TESTLENS ERROR: Your trial plan has ended');
1017
1026
  }
1018
-
1027
+
1019
1028
  logger.error('='.repeat(80));
1020
1029
  logger.error('');
1021
1030
  logger.error(errorData?.message || 'Your trial period has expired.');
@@ -1045,46 +1054,115 @@ export class TestLensReporter implements Reporter {
1045
1054
  method: error?.config?.method
1046
1055
  });
1047
1056
  }
1048
-
1057
+
1049
1058
  // Don't throw error to avoid breaking test execution
1050
1059
  }
1051
1060
  }
1052
1061
 
1053
- private async processArtifacts(testId: string, result: TestResult, testCaseDbId?: string): Promise<void> {
1062
+ private async processArtifacts(
1063
+ testId: string,
1064
+ result: TestResult,
1065
+ testCaseDbId?: string,
1066
+ testEndPayload?: { status: string; endTime: string }
1067
+ ): Promise<void> {
1054
1068
  // Skip artifact processing if run creation failed
1055
1069
  if (this.runCreationFailed) {
1056
1070
  return;
1057
1071
  }
1058
-
1072
+
1059
1073
  const attachments = result.attachments;
1060
-
1074
+
1061
1075
  for (const attachment of attachments) {
1062
1076
  const artifactType = this.getArtifactType(attachment.name);
1063
1077
  if (attachment.path) {
1064
1078
  // Check if attachment should be processed based on config
1065
1079
  const isVideo = artifactType === 'video' || attachment.contentType?.startsWith('video/');
1066
1080
  const isScreenshot = artifactType === 'screenshot' || attachment.contentType?.startsWith('image/');
1067
-
1081
+
1068
1082
  // Skip video if disabled in config
1069
1083
  if (isVideo && !this.config.enableVideo) {
1070
1084
  logger.debug(`[SKIP] Skipping video artifact ${attachment.name} - video capture disabled in config`);
1071
1085
  this.bumpArtifactStat('skipped', artifactType);
1072
1086
  continue;
1073
1087
  }
1074
-
1088
+
1075
1089
  // Skip screenshot if disabled in config
1076
1090
  if (isScreenshot && !this.config.enableScreenshot) {
1077
1091
  logger.debug(`[SKIP] Skipping screenshot artifact ${attachment.name} - screenshot capture disabled in config`);
1078
1092
  this.bumpArtifactStat('skipped', artifactType);
1079
1093
  continue;
1080
1094
  }
1081
-
1095
+
1096
+ // Trace zip (default from Playwright): extract network-like data and print to console
1097
+ const isTraceZip = artifactType === 'trace' && attachment.path && (attachment.path.endsWith('.zip') || attachment.contentType === 'application/zip');
1098
+ if (isTraceZip) {
1099
+ try {
1100
+ const traceStart = Date.now();
1101
+ const AdmZip = require('adm-zip');
1102
+ const zip = new AdmZip(attachment.path);
1103
+ const entries = zip.getEntries();
1104
+ const networkRows: unknown[] = [];
1105
+ for (const e of entries) {
1106
+ if (e.isDirectory || !e.getData) continue;
1107
+ const name = (e.entryName || '').toLowerCase();
1108
+ const isNetworkFile = name.endsWith('.network') || name.includes('.network') || name === 'network';
1109
+ if (!isNetworkFile) continue;
1110
+ const data = e.getData();
1111
+ if (!data) continue;
1112
+ const text = data.toString('utf8');
1113
+ if (!text || text.length < 2) continue;
1114
+ const isApplicationJson = (o: unknown): boolean => {
1115
+ if (!o || typeof o !== 'object') return false;
1116
+ const res = (o as any).snapshot?.response ?? (o as any).response;
1117
+ if (!res) return false;
1118
+ const mime = res.content?.mimeType;
1119
+ if (typeof mime === 'string') {
1120
+ const type = mime.split(';')[0].trim().toLowerCase();
1121
+ return type === 'application/json';
1122
+ }
1123
+ const headers = res.headers;
1124
+ if (Array.isArray(headers)) {
1125
+ const ct = headers.find((h: any) => (h.name || '').toLowerCase() === 'content-type');
1126
+ const val = ct?.value;
1127
+ if (typeof val === 'string') {
1128
+ const type = val.split(';')[0].trim().toLowerCase();
1129
+ return type === 'application/json';
1130
+ }
1131
+ }
1132
+ return false;
1133
+ };
1134
+ const lines = text.split(/\r?\n/).filter(Boolean);
1135
+ for (const line of lines) {
1136
+ try {
1137
+ const obj = JSON.parse(line);
1138
+ if (obj && typeof obj === 'object' && isApplicationJson(obj)) networkRows.push(obj);
1139
+ } catch (_) { }
1140
+ }
1141
+ if (networkRows.length === 0 && lines.length > 0) {
1142
+ try {
1143
+ const arr = JSON.parse(text);
1144
+ if (Array.isArray(arr)) networkRows.push(...arr.filter((x: unknown) => x != null && typeof x === 'object' && isApplicationJson(x)));
1145
+ } catch (_) { }
1146
+ }
1147
+ }
1148
+ const durationMs = Date.now() - traceStart;
1149
+ if (networkRows.length > 0) {
1150
+ this.traceNetworkRows = networkRows;
1151
+ } else {
1152
+ this.traceNetworkRows = [];
1153
+ }
1154
+ } catch (e) {
1155
+ logger.warn('[TRACE] Could not read trace zip: ' + (e && (e as Error).message));
1156
+ this.traceNetworkRows = [];
1157
+ }
1158
+ }
1159
+
1082
1160
  try {
1083
1161
  // Determine proper filename with extension
1084
1162
  // Playwright attachment.name often doesn't have extension, so we need to derive it
1085
1163
  let fileName = attachment.name;
1086
1164
  const existingExt = path.extname(fileName);
1087
-
1165
+
1088
1166
  if (!existingExt) {
1089
1167
  // Get extension from the actual file path
1090
1168
  const pathExt = path.extname(attachment.path);
@@ -1119,16 +1197,16 @@ export class TestLensReporter implements Reporter {
1119
1197
  this.bumpArtifactStat('skipped', artifactType);
1120
1198
  return;
1121
1199
  }
1122
-
1200
+
1123
1201
  const s3Data = await this.uploadArtifactToS3(attachment.path, testId, fileName, testCaseDbId);
1124
-
1202
+
1125
1203
  // Skip if upload failed or file was too large
1126
1204
  if (!s3Data) {
1127
1205
  logger.debug(`[SKIP] [Test: ${testId.substring(0, 8)}...] Skipping artifact ${attachment.name} - upload failed or file too large`);
1128
1206
  this.bumpArtifactStat('failed', artifactType);
1129
1207
  return;
1130
1208
  }
1131
-
1209
+
1132
1210
  const artifactData = {
1133
1211
  testId,
1134
1212
  type: this.getArtifactType(attachment.name),
@@ -1138,7 +1216,12 @@ export class TestLensReporter implements Reporter {
1138
1216
  fileSize: this.getFileSize(attachment.path),
1139
1217
  storageType: 's3',
1140
1218
  s3Key: s3Data.key,
1141
- s3Url: s3Data.url
1219
+ s3Url: s3Data.url,
1220
+ // So backend can fix test case status if testEnd failed but artifact succeeded
1221
+ ...(testEndPayload && {
1222
+ testStatus: testEndPayload.status,
1223
+ testEndTime: testEndPayload.endTime
1224
+ })
1142
1225
  };
1143
1226
 
1144
1227
  // Send artifact data to API
@@ -1148,20 +1231,20 @@ export class TestLensReporter implements Reporter {
1148
1231
  timestamp: new Date().toISOString(),
1149
1232
  artifact: artifactData
1150
1233
  });
1151
-
1234
+
1152
1235
  logger.debug(`[ARTIFACT] [Test: ${testId.substring(0, 8)}...] Processed artifact: ${fileName}`);
1153
1236
  } catch (error) {
1154
1237
  logger.error(`[ERROR] [Test: ${testId.substring(0, 8)}...] Failed to process artifact ${attachment.name}: ${(error as Error).message}`);
1155
1238
  this.bumpArtifactStat('failed', artifactType);
1156
1239
  }
1157
1240
  });
1158
-
1241
+
1159
1242
  // Track this upload and ensure cleanup on completion
1160
1243
  this.pendingUploads.add(uploadPromise);
1161
1244
  uploadPromise.finally(() => {
1162
1245
  this.pendingUploads.delete(uploadPromise);
1163
1246
  });
1164
-
1247
+
1165
1248
  // Don't await here - let uploads happen in parallel
1166
1249
  // They will be awaited in onEnd
1167
1250
  } catch (error) {
@@ -1174,20 +1257,20 @@ export class TestLensReporter implements Reporter {
1174
1257
  }
1175
1258
  }
1176
1259
 
1177
- private async sendSpecCodeBlocks(specPath: string): Promise<void> {
1260
+ private async sendSpecCodeBlocks(specPath: string, testName: string, errors: unknown[], runId: string, test_id: string, testCaseId: string): Promise<void> {
1178
1261
  try {
1179
1262
  // Extract code blocks using built-in parser
1180
1263
  const testBlocks = this.extractTestBlocks(specPath);
1181
-
1264
+
1182
1265
  // Transform blocks to match backend API expectations
1183
- const codeBlocks = testBlocks.map(block => ({
1266
+ const codeBlocks = testBlocks.filter(block => block.name === testName).map(block => ({
1184
1267
  type: block.type, // 'test' or 'describe'
1185
1268
  name: block.name, // test/describe name
1186
1269
  content: block.content, // full code content
1187
1270
  summary: null, // optional
1188
1271
  describe: block.describe // parent describe block name
1189
1272
  }));
1190
-
1273
+
1191
1274
  // Send to dedicated spec code blocks API endpoint
1192
1275
  // Extract base URL - handle both full and partial endpoint patterns
1193
1276
  let baseUrl = this.config.apiEndpoint.replace('/api/v1/webhook/playwright', '');
@@ -1196,23 +1279,28 @@ export class TestLensReporter implements Reporter {
1196
1279
  baseUrl = this.config.apiEndpoint.replace('/webhook/playwright', '');
1197
1280
  }
1198
1281
  const specEndpoint = `${baseUrl}/api/v1/webhook/playwright/spec-code-blocks`;
1199
-
1282
+
1200
1283
  await this.axiosInstance.post(specEndpoint, {
1201
1284
  filePath: path.relative(process.cwd(), specPath),
1202
1285
  codeBlocks,
1203
- testSuiteName: path.basename(specPath).replace(/\.(spec|test)\.(js|ts)$/, '')
1286
+ errors,
1287
+ traceNetworkRows: this.traceNetworkRows,
1288
+ testSuiteName: path.basename(specPath).replace(/\.(spec|test)\.(js|ts)$/, ''),
1289
+ runId,
1290
+ test_id,
1291
+ testCaseId
1204
1292
  });
1205
1293
 
1206
1294
  logger.debug(`Sent ${codeBlocks.length} code blocks for: ${path.basename(specPath)}`);
1207
1295
  } catch (error: any) {
1208
1296
  const errorData = error?.response?.data;
1209
-
1297
+
1210
1298
  // Handle duplicate spec code blocks gracefully (when re-running tests)
1211
1299
  if (errorData?.error && errorData.error.includes('duplicate key value violates unique constraint')) {
1212
1300
  logger.debug(`[INFO] Spec code blocks already exist for: ${path.basename(specPath)} (skipped)`);
1213
1301
  return;
1214
1302
  }
1215
-
1303
+
1216
1304
  logger.error(`Failed to send spec code blocks: ${errorData || error?.message || 'Unknown error'}`);
1217
1305
  }
1218
1306
  }
@@ -1222,61 +1310,102 @@ export class TestLensReporter implements Reporter {
1222
1310
  const content = fs.readFileSync(filePath, 'utf-8');
1223
1311
  const blocks: CodeBlock[] = [];
1224
1312
  const lines = content.split('\n');
1225
-
1226
- let currentDescribe: string | null = null;
1227
- let braceCount = 0;
1313
+
1314
+ // Use a stack to track nested describe blocks with their brace depths
1315
+ const describeStack: { name: string; braceDepth: number }[] = [];
1316
+ let globalBraceCount = 0;
1228
1317
  let inBlock = false;
1229
1318
  let blockStart = -1;
1319
+ let blockBraceCount = 0;
1230
1320
  let blockType: 'test' | 'describe' = 'test';
1231
1321
  let blockName = '';
1232
-
1322
+ let blockDescribe: string | undefined = undefined;
1323
+
1233
1324
  for (let i = 0; i < lines.length; i++) {
1234
1325
  const line = lines[i];
1235
1326
  const trimmedLine = line.trim();
1236
-
1237
- // Check for describe blocks
1238
- const describeMatch = trimmedLine.match(/describe\s*\(\s*['"`]([^'"`]+)['"`]/);
1239
- if (describeMatch) {
1240
- currentDescribe = describeMatch[1];
1241
- }
1242
-
1243
- // Check for test blocks
1244
- const testMatch = trimmedLine.match(/test\s*\(\s*['"`]([^'"`]+)['"`]/);
1245
- if (testMatch && !inBlock) {
1246
- blockType = 'test';
1247
- blockName = testMatch[1];
1248
- blockStart = i;
1249
- braceCount = 0;
1250
- inBlock = true;
1327
+
1328
+ if (!inBlock) {
1329
+ // Check for describe blocks: describe(), test.describe(), test.describe.serial(), etc.
1330
+ const describeMatch = trimmedLine.match(/(?:test\.)?describe(?:\.(?:serial|parallel|only|skip|fixme))?\s*\(\s*['"`]([^'"`]+)['"`]/);
1331
+ if (describeMatch) {
1332
+ // Count braces on this line to find the opening brace
1333
+ let lineOpenBraces = 0;
1334
+ for (const char of line) {
1335
+ if (char === '{') {
1336
+ globalBraceCount++;
1337
+ lineOpenBraces++;
1338
+ }
1339
+ if (char === '}') globalBraceCount--;
1340
+ }
1341
+ // Push describe onto stack with the current brace depth
1342
+ describeStack.push({ name: describeMatch[1], braceDepth: globalBraceCount });
1343
+ continue;
1344
+ }
1345
+
1346
+ // Check for test blocks: test(), test.only(), test.skip(), test.fixme(), it(), it.only(), etc.
1347
+ const testMatch = trimmedLine.match(/(?:test|it)(?:\.(?:only|skip|fixme|slow))?\s*\(\s*['"`]([^'"`]+)['"`]/);
1348
+ if (testMatch) {
1349
+ blockType = 'test';
1350
+ blockName = testMatch[1];
1351
+ blockStart = i;
1352
+ blockBraceCount = 0;
1353
+ // Capture the current innermost describe name
1354
+ blockDescribe = describeStack.length > 0 ? describeStack[describeStack.length - 1].name : undefined;
1355
+ inBlock = true;
1356
+ }
1251
1357
  }
1252
-
1253
- // Count braces when in a block
1358
+
1359
+ // Count braces
1254
1360
  if (inBlock) {
1255
1361
  for (const char of line) {
1256
- if (char === '{') braceCount++;
1257
- if (char === '}') braceCount--;
1258
-
1259
- if (braceCount === 0 && blockStart !== -1 && i > blockStart) {
1260
- // End of block found
1362
+ if (char === '{') {
1363
+ blockBraceCount++;
1364
+ globalBraceCount++;
1365
+ }
1366
+ if (char === '}') {
1367
+ blockBraceCount--;
1368
+ globalBraceCount--;
1369
+ }
1370
+
1371
+ if (blockBraceCount === 0 && blockStart !== -1 && i > blockStart) {
1372
+ // End of test block found
1261
1373
  const blockContent = lines.slice(blockStart, i + 1).join('\n');
1262
-
1374
+
1263
1375
  blocks.push({
1264
1376
  type: blockType,
1265
1377
  name: blockName,
1266
1378
  content: blockContent,
1267
- describe: currentDescribe || undefined,
1379
+ describe: blockDescribe,
1268
1380
  startLine: blockStart + 1,
1269
1381
  endLine: i + 1
1270
1382
  });
1271
-
1383
+
1272
1384
  inBlock = false;
1273
1385
  blockStart = -1;
1386
+
1387
+ // Pop any describe blocks that have closed
1388
+ while (describeStack.length > 0 && globalBraceCount < describeStack[describeStack.length - 1].braceDepth) {
1389
+ describeStack.pop();
1390
+ }
1274
1391
  break;
1275
1392
  }
1276
1393
  }
1394
+ } else {
1395
+ // Track braces outside of test blocks (for describe open/close)
1396
+ for (const char of line) {
1397
+ if (char === '{') globalBraceCount++;
1398
+ if (char === '}') {
1399
+ globalBraceCount--;
1400
+ // Pop any describe blocks that have closed
1401
+ while (describeStack.length > 0 && globalBraceCount < describeStack[describeStack.length - 1].braceDepth) {
1402
+ describeStack.pop();
1403
+ }
1404
+ }
1405
+ }
1277
1406
  }
1278
1407
  }
1279
-
1408
+
1280
1409
  return blocks;
1281
1410
  } catch (error: any) {
1282
1411
  logger.error(`Failed to extract test blocks from ${filePath}: ${error.message}`);
@@ -1292,7 +1421,7 @@ export class TestLensReporter implements Reporter {
1292
1421
  const author = execSync('git log -1 --pretty=format:"%an"', { encoding: 'utf-8' }).trim();
1293
1422
  const commitMessage = execSync('git log -1 --pretty=format:"%s"', { encoding: 'utf-8' }).trim();
1294
1423
  const commitTimestamp = execSync('git log -1 --pretty=format:"%ci"', { encoding: 'utf-8' }).trim();
1295
-
1424
+
1296
1425
  let remoteName = 'origin';
1297
1426
  let remoteUrl = '';
1298
1427
  try {
@@ -1305,9 +1434,9 @@ export class TestLensReporter implements Reporter {
1305
1434
  // Remote info is optional - handle gracefully
1306
1435
  logger.debug('[INFO] No git remote configured, skipping remote info');
1307
1436
  }
1308
-
1437
+
1309
1438
  const isDirty = execSync('git status --porcelain', { encoding: 'utf-8' }).trim().length > 0;
1310
-
1439
+
1311
1440
  return {
1312
1441
  branch,
1313
1442
  commit,
@@ -1341,7 +1470,7 @@ export class TestLensReporter implements Reporter {
1341
1470
 
1342
1471
  private extractTags(test: TestCase): string[] {
1343
1472
  const tags: string[] = [];
1344
-
1473
+
1345
1474
  // Playwright stores tags in the _tags property
1346
1475
  const testTags = (test as any)._tags;
1347
1476
  if (testTags && Array.isArray(testTags)) {
@@ -1372,7 +1501,7 @@ export class TestLensReporter implements Reporter {
1372
1501
  : [buildTagSource];
1373
1502
  buildTags.forEach(tag => tags.push(`@${tag}`));
1374
1503
  }
1375
-
1504
+
1376
1505
  // Remove duplicates and return
1377
1506
  return [...new Set(tags)];
1378
1507
  }
@@ -1390,15 +1519,14 @@ export class TestLensReporter implements Reporter {
1390
1519
  // Check file size first
1391
1520
  const fileSize = this.getFileSize(filePath);
1392
1521
  const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
1393
-
1522
+
1394
1523
  logger.debug(`📤 Uploading ${fileName} (${fileSizeMB}MB) directly to S3...`);
1395
1524
 
1396
1525
  const baseUrl = this.config.apiEndpoint.replace('/api/v1/webhook/playwright', '');
1397
-
1526
+
1398
1527
  // Step 1: Request pre-signed URL from server
1399
1528
  const presignedUrlEndpoint = `${baseUrl}/api/v1/artifacts/public/presigned-url`;
1400
1529
  const requestBody: any = {
1401
- apiKey: this.config.apiKey,
1402
1530
  testRunId: this.runId,
1403
1531
  testId: testId,
1404
1532
  fileName: fileName,
@@ -1406,12 +1534,12 @@ export class TestLensReporter implements Reporter {
1406
1534
  fileSize: fileSize,
1407
1535
  artifactType: this.getArtifactType(fileName)
1408
1536
  };
1409
-
1537
+
1410
1538
  // Include DB ID if available for faster lookup (avoids query)
1411
1539
  if (testCaseDbId) {
1412
1540
  requestBody.testCaseDbId = testCaseDbId;
1413
1541
  }
1414
-
1542
+
1415
1543
  const presignedResponse = await this.axiosInstance.post(presignedUrlEndpoint, requestBody, {
1416
1544
  timeout: 10000 // Quick timeout for metadata request
1417
1545
  });
@@ -1421,12 +1549,12 @@ export class TestLensReporter implements Reporter {
1421
1549
  }
1422
1550
 
1423
1551
  const { uploadUrl, s3Key, metadata } = presignedResponse.data;
1424
-
1552
+
1425
1553
  // Step 2: Upload directly to S3 using presigned URL
1426
1554
  logger.debug(`[UPLOAD] [Test: ${testId.substring(0, 8)}...] Uploading ${fileName} directly to S3...`);
1427
-
1555
+
1428
1556
  const fileBuffer = fs.readFileSync(filePath);
1429
-
1557
+
1430
1558
  // IMPORTANT: When using presigned URLs, we MUST include exactly the headers that were signed
1431
1559
  // The backend signs with ServerSideEncryption:'AES256', so we must send that header
1432
1560
  // AWS presigned URLs are very strict about header matching
@@ -1450,7 +1578,6 @@ export class TestLensReporter implements Reporter {
1450
1578
  // Step 3: Confirm upload with server to save metadata
1451
1579
  const confirmEndpoint = `${baseUrl}/api/v1/artifacts/public/confirm-upload`;
1452
1580
  const confirmBody: any = {
1453
- apiKey: this.config.apiKey,
1454
1581
  testRunId: this.runId,
1455
1582
  testId: testId,
1456
1583
  s3Key: s3Key,
@@ -1459,12 +1586,12 @@ export class TestLensReporter implements Reporter {
1459
1586
  fileSize: fileSize,
1460
1587
  artifactType: this.getArtifactType(fileName)
1461
1588
  };
1462
-
1589
+
1463
1590
  // Include DB ID if available for direct insert (avoids query and race condition)
1464
1591
  if (testCaseDbId) {
1465
1592
  confirmBody.testCaseDbId = testCaseDbId;
1466
1593
  }
1467
-
1594
+
1468
1595
  const confirmResponse = await this.axiosInstance.post(confirmEndpoint, confirmBody, {
1469
1596
  timeout: 10000
1470
1597
  });
@@ -1486,11 +1613,11 @@ export class TestLensReporter implements Reporter {
1486
1613
  // Check for trial expiration, subscription errors, or limit errors
1487
1614
  if (error?.response?.status === 401) {
1488
1615
  const errorData = error?.response?.data;
1489
-
1616
+
1490
1617
  if (errorData?.error === 'trial_expired' || errorData?.error === 'subscription_inactive' ||
1491
- errorData?.error === 'test_cases_limit_reached' || errorData?.error === 'test_runs_limit_reached') {
1618
+ errorData?.error === 'test_cases_limit_reached' || errorData?.error === 'test_runs_limit_reached') {
1492
1619
  logger.error('\n' + '='.repeat(80));
1493
-
1620
+
1494
1621
  if (errorData?.error === 'test_cases_limit_reached') {
1495
1622
  logger.error('[ERROR] TESTLENS ERROR: Test Cases Limit Reached');
1496
1623
  } else if (errorData?.error === 'test_runs_limit_reached') {
@@ -1498,7 +1625,7 @@ export class TestLensReporter implements Reporter {
1498
1625
  } else {
1499
1626
  logger.error('[ERROR] TESTLENS ERROR: Your trial plan has ended');
1500
1627
  }
1501
-
1628
+
1502
1629
  logger.error('='.repeat(80));
1503
1630
  logger.error('');
1504
1631
  logger.error(errorData?.message || 'Your trial period has expired.');
@@ -1515,10 +1642,10 @@ export class TestLensReporter implements Reporter {
1515
1642
  return null;
1516
1643
  }
1517
1644
  }
1518
-
1645
+
1519
1646
  // Better error messages for common issues
1520
1647
  let errorMsg = error.message;
1521
-
1648
+
1522
1649
  if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
1523
1650
  errorMsg = `Upload timeout - file may be too large or connection is slow`;
1524
1651
  } else if (error.response?.status === 413) {
@@ -1528,12 +1655,12 @@ export class TestLensReporter implements Reporter {
1528
1655
  } else if (error.response?.status === 403) {
1529
1656
  errorMsg = `Access denied (403) - presigned URL may have expired`;
1530
1657
  }
1531
-
1658
+
1532
1659
  logger.error(`[ERROR] Failed to upload ${fileName} to S3: ${errorMsg}`);
1533
1660
  if (error.response?.data) {
1534
1661
  logger.error({ errorDetails: error.response.data }, 'Error details');
1535
1662
  }
1536
-
1663
+
1537
1664
  // Don't throw, just return null to continue with other artifacts
1538
1665
  return null;
1539
1666
  }
@@ -1546,7 +1673,7 @@ export class TestLensReporter implements Reporter {
1546
1673
  // Try different ways to access getType method
1547
1674
  const getType = mime.getType || mime.default?.getType;
1548
1675
  if (typeof getType === 'function') {
1549
- const mimeType = getType.call(mime, ext) || getType.call(mime.default, ext);
1676
+ const mimeType = getType.call(mime, ext) || (mime.default ? getType.call(mime.default, ext) : null);
1550
1677
  return mimeType || 'application/octet-stream';
1551
1678
  }
1552
1679
  } catch (error: any) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alternative-path/testlens-playwright-reporter",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "description": "Universal Playwright reporter for TestLens - works with both TypeScript and JavaScript projects",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -51,6 +51,7 @@
51
51
  "dependencies": {
52
52
  "@aws-sdk/client-s3": "^3.624.0",
53
53
  "@aws-sdk/s3-request-presigner": "^3.624.0",
54
+ "adm-zip": "^0.5.16",
54
55
  "axios": "^1.11.0",
55
56
  "cross-env": "^7.0.3",
56
57
  "dotenv": "^16.4.5",