@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.
- package/index.d.ts +2 -0
- package/index.js +166 -34
- package/index.ts +254 -127
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
974
|
-
|
|
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
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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
|
|
1108
|
+
// Count braces
|
|
997
1109
|
if (inBlock) {
|
|
998
1110
|
for (const char of line) {
|
|
999
|
-
if (char === '{')
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
if (
|
|
1004
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1227
|
-
|
|
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
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
|
1358
|
+
|
|
1359
|
+
// Count braces
|
|
1254
1360
|
if (inBlock) {
|
|
1255
1361
|
for (const char of line) {
|
|
1256
|
-
if (char === '{')
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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",
|