@testcollab/cli 1.3.0
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/.github/workflows/release.yml +53 -0
- package/DEVELOPMENT.md +225 -0
- package/LICENSE +21 -0
- package/README.md +378 -0
- package/docs/frameworks.md +485 -0
- package/docs/specgen.md +77 -0
- package/package.json +54 -0
- package/samples/reports/junit.xml +12 -0
- package/samples/reports/mochawesome.json +110 -0
- package/scripts/bump-version.js +145 -0
- package/src/ai/discovery.js +123 -0
- package/src/commands/createTestPlan.js +259 -0
- package/src/commands/featuresync.js +753 -0
- package/src/commands/report.js +1109 -0
- package/src/commands/specgen.js +430 -0
- package/src/index.js +74 -0
|
@@ -0,0 +1,1109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* report.js
|
|
3
|
+
*
|
|
4
|
+
* Upload test run results to TestCollab and attach them to a Test Plan.
|
|
5
|
+
* Supports:
|
|
6
|
+
* - Mochawesome JSON
|
|
7
|
+
* - JUnit XML
|
|
8
|
+
*
|
|
9
|
+
* This command follows the same direct execution-update flow used by the
|
|
10
|
+
* cypress reporter plugin: it validates context, fetches assigned executed
|
|
11
|
+
* cases, and updates each executed test case directly.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
|
|
17
|
+
const RUN_RESULT_MAP = {
|
|
18
|
+
pass: 1,
|
|
19
|
+
fail: 2,
|
|
20
|
+
skip: 3,
|
|
21
|
+
block: 4,
|
|
22
|
+
unexecuted: 0
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const SYSTEM_STATUS = {
|
|
26
|
+
PASSED: 'passed',
|
|
27
|
+
FAILED: 'failed',
|
|
28
|
+
SKIPPED: 'skipped'
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const TC_ID_PATTERNS = [
|
|
32
|
+
/\[\s*TC-(\d+)\s*\]/i,
|
|
33
|
+
/\bTC-(\d+)\b/i,
|
|
34
|
+
/\bid-(\d+)\b/i,
|
|
35
|
+
/\btestcase-(\d+)\b/i
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const CONFIG_ID_PATTERNS = [
|
|
39
|
+
/\bconfig-id-(\d+)\b/i,
|
|
40
|
+
/\bconfig-(\d+)\b/i,
|
|
41
|
+
/\[\s*config-id-(\d+)\s*\]/i
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function toAbsolutePath(inputPath) {
|
|
45
|
+
return path.isAbsolute(inputPath) ? inputPath : path.join(process.cwd(), inputPath);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getBaseApiUrl(apiUrl) {
|
|
49
|
+
if (apiUrl && String(apiUrl).trim()) {
|
|
50
|
+
return String(apiUrl).trim().replace(/\/+$/, '');
|
|
51
|
+
}
|
|
52
|
+
if (process.env.NODE_ENV === 'production') {
|
|
53
|
+
return 'https://api.testcollab.io';
|
|
54
|
+
}
|
|
55
|
+
if (process.env.NODE_ENV === 'staging') {
|
|
56
|
+
return 'https://api.testcollab-dev.io';
|
|
57
|
+
}
|
|
58
|
+
return 'http://localhost:1337';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function decodeXmlEntities(value) {
|
|
62
|
+
if (value === undefined || value === null) {
|
|
63
|
+
return '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return String(value)
|
|
67
|
+
.replace(/</g, '<')
|
|
68
|
+
.replace(/>/g, '>')
|
|
69
|
+
.replace(/"/g, '"')
|
|
70
|
+
.replace(/'/g, "'")
|
|
71
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
|
|
72
|
+
try {
|
|
73
|
+
return String.fromCodePoint(Number.parseInt(hex, 16));
|
|
74
|
+
} catch {
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
.replace(/&#([0-9]+);/g, (_, decimal) => {
|
|
79
|
+
try {
|
|
80
|
+
return String.fromCodePoint(Number.parseInt(decimal, 10));
|
|
81
|
+
} catch {
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
.replace(/&/g, '&');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseXmlAttributes(input) {
|
|
89
|
+
const attrs = {};
|
|
90
|
+
const attrRegex = /([\w:-]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
|
91
|
+
let match;
|
|
92
|
+
|
|
93
|
+
while ((match = attrRegex.exec(input)) !== null) {
|
|
94
|
+
const key = match[1];
|
|
95
|
+
const value = match[3] !== undefined ? match[3] : match[4];
|
|
96
|
+
attrs[key] = decodeXmlEntities(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return attrs;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getFailureDetails(body) {
|
|
103
|
+
const expandedFailure = body.match(/<(failure|error)\b([^>]*)>([\s\S]*?)<\/\1>/i);
|
|
104
|
+
if (expandedFailure) {
|
|
105
|
+
const attrs = parseXmlAttributes(expandedFailure[2] || '');
|
|
106
|
+
return {
|
|
107
|
+
message: attrs.message || attrs.type || '',
|
|
108
|
+
stack: decodeXmlEntities((expandedFailure[3] || '').trim())
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const shortFailure = body.match(/<(failure|error)\b([^>]*)\/>/i);
|
|
113
|
+
if (shortFailure) {
|
|
114
|
+
const attrs = parseXmlAttributes(shortFailure[2] || '');
|
|
115
|
+
return {
|
|
116
|
+
message: attrs.message || attrs.type || '',
|
|
117
|
+
stack: ''
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
message: '',
|
|
123
|
+
stack: ''
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function collectAllTestsFromSuite(suite) {
|
|
128
|
+
try {
|
|
129
|
+
const tests = suite && Array.isArray(suite.tests) ? [...suite.tests] : [];
|
|
130
|
+
const childSuites = suite && Array.isArray(suite.suites) ? suite.suites : [];
|
|
131
|
+
childSuites.forEach((childSuite) => {
|
|
132
|
+
const nestedTests = collectAllTestsFromSuite(childSuite);
|
|
133
|
+
if (nestedTests && nestedTests.length) {
|
|
134
|
+
tests.push(...nestedTests);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
return tests;
|
|
138
|
+
} catch {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function unique(items) {
|
|
144
|
+
return [...new Set(items.filter(Boolean))];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function durationMsToSeconds(durationMs) {
|
|
148
|
+
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
return Math.ceil(durationMs / 1000);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function durationSecondsToSeconds(durationSeconds) {
|
|
155
|
+
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
return Math.ceil(durationSeconds);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getTestState(testData) {
|
|
162
|
+
const state = String(testData?.state || '').toLowerCase();
|
|
163
|
+
if (testData?.pass === true || state === SYSTEM_STATUS.PASSED) {
|
|
164
|
+
return SYSTEM_STATUS.PASSED;
|
|
165
|
+
}
|
|
166
|
+
if (testData?.fail === true || state === SYSTEM_STATUS.FAILED) {
|
|
167
|
+
return SYSTEM_STATUS.FAILED;
|
|
168
|
+
}
|
|
169
|
+
return SYSTEM_STATUS.SKIPPED;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function toRunStatus(status) {
|
|
173
|
+
if (status === SYSTEM_STATUS.PASSED) {
|
|
174
|
+
return RUN_RESULT_MAP.pass;
|
|
175
|
+
}
|
|
176
|
+
if (status === SYSTEM_STATUS.FAILED) {
|
|
177
|
+
return RUN_RESULT_MAP.fail;
|
|
178
|
+
}
|
|
179
|
+
return RUN_RESULT_MAP.skip;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function getConfigIdFromSuiteTitle(title) {
|
|
183
|
+
const value = String(title || '');
|
|
184
|
+
const match = /^config-id-(\d+)$/i.exec(value.trim());
|
|
185
|
+
return match && match[1] ? match[1] : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function extractConfigIdFromText(text) {
|
|
189
|
+
const normalizedText = String(text || '');
|
|
190
|
+
for (const pattern of CONFIG_ID_PATTERNS) {
|
|
191
|
+
const match = normalizedText.match(pattern);
|
|
192
|
+
if (match && match[1]) {
|
|
193
|
+
return match[1];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function extractTestCaseIdFromTitle(title) {
|
|
200
|
+
const normalizedTitle = String(title || '');
|
|
201
|
+
|
|
202
|
+
const suffix = normalizedTitle.split('-').pop();
|
|
203
|
+
if (suffix && /^\d+$/.test(suffix)) {
|
|
204
|
+
return suffix;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const pattern of TC_ID_PATTERNS) {
|
|
208
|
+
const match = normalizedTitle.match(pattern);
|
|
209
|
+
if (match && match[1]) {
|
|
210
|
+
return match[1];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function extractTestCaseIdFromMochawesomeTest(testData) {
|
|
217
|
+
const testTitle = String(testData?.title || '').trim();
|
|
218
|
+
const fullTitle = String(testData?.fullTitle || '').trim();
|
|
219
|
+
return extractTestCaseIdFromTitle(testTitle) || extractTestCaseIdFromTitle(fullTitle);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function prepareMochawesomeRunRecord(testData) {
|
|
223
|
+
if (!testData || typeof testData !== 'object') {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const tcId = extractTestCaseIdFromMochawesomeTest(testData);
|
|
228
|
+
if (!tcId) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const testState = getTestState(testData);
|
|
233
|
+
const status = toRunStatus(testState);
|
|
234
|
+
const errMessage = String(testData?.err?.message || '').trim();
|
|
235
|
+
const errStack = String(testData?.err?.estack || testData?.err?.stack || '').trim();
|
|
236
|
+
const errDetails = errStack || errMessage || null;
|
|
237
|
+
|
|
238
|
+
const durationRaw = Number.parseInt(testData?.duration, 10);
|
|
239
|
+
const duration = durationMsToSeconds(durationRaw);
|
|
240
|
+
|
|
241
|
+
const title = String(testData?.fullTitle || testData?.title || '').trim() || '(Unnamed test case)';
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
tcId,
|
|
245
|
+
status,
|
|
246
|
+
errDetails,
|
|
247
|
+
title,
|
|
248
|
+
duration
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function readMochawesomePayload(absResultPath) {
|
|
253
|
+
const rawContent = fs.readFileSync(absResultPath, 'utf8');
|
|
254
|
+
let payload;
|
|
255
|
+
try {
|
|
256
|
+
payload = JSON.parse(rawContent);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
throw new Error(`Invalid Mochawesome JSON: ${error?.message || String(error)}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!payload || typeof payload !== 'object') {
|
|
262
|
+
throw new Error('Mochawesome result content is empty or invalid');
|
|
263
|
+
}
|
|
264
|
+
if (!Array.isArray(payload.results) || payload.results.length === 0) {
|
|
265
|
+
throw new Error('Mochawesome payload has no results');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return payload;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function parseMochawesomeReport(payload) {
|
|
272
|
+
const reportData = payload;
|
|
273
|
+
if (!reportData || typeof reportData !== 'object') {
|
|
274
|
+
throw new Error('Mochawesome result content is empty or invalid');
|
|
275
|
+
}
|
|
276
|
+
if (!Array.isArray(reportData.results) || !reportData.results.length) {
|
|
277
|
+
throw new Error('Mochawesome payload has no results');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const resultsToUpload = {};
|
|
281
|
+
const unresolvedIds = [];
|
|
282
|
+
let hasConfig = false;
|
|
283
|
+
let tests = 0;
|
|
284
|
+
let passes = 0;
|
|
285
|
+
let failures = 0;
|
|
286
|
+
let skipped = 0;
|
|
287
|
+
|
|
288
|
+
reportData.results.forEach((fileResult) => {
|
|
289
|
+
let topSuites = fileResult && Array.isArray(fileResult.suites) ? fileResult.suites : [];
|
|
290
|
+
if (!topSuites.length && fileResult && Array.isArray(fileResult.tests) && fileResult.tests.length) {
|
|
291
|
+
// Some reporters emit tests directly on the top result object.
|
|
292
|
+
topSuites = [fileResult];
|
|
293
|
+
}
|
|
294
|
+
if (!topSuites.length) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const configSuites = topSuites
|
|
299
|
+
.map((suite) => ({ suite, id: getConfigIdFromSuiteTitle(suite?.title) }))
|
|
300
|
+
.filter((entry) => Boolean(entry.id));
|
|
301
|
+
|
|
302
|
+
const allTopSuitesAreConfigs = configSuites.length > 0 && configSuites.length === topSuites.length;
|
|
303
|
+
|
|
304
|
+
if (allTopSuitesAreConfigs) {
|
|
305
|
+
hasConfig = true;
|
|
306
|
+
configSuites.forEach(({ suite, id }) => {
|
|
307
|
+
const testsInSuite = collectAllTestsFromSuite(suite);
|
|
308
|
+
testsInSuite.forEach((testData) => {
|
|
309
|
+
const state = getTestState(testData);
|
|
310
|
+
tests += 1;
|
|
311
|
+
if (state === SYSTEM_STATUS.PASSED) {
|
|
312
|
+
passes += 1;
|
|
313
|
+
} else if (state === SYSTEM_STATUS.FAILED) {
|
|
314
|
+
failures += 1;
|
|
315
|
+
} else {
|
|
316
|
+
skipped += 1;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const runRecord = prepareMochawesomeRunRecord(testData);
|
|
320
|
+
if (!runRecord) {
|
|
321
|
+
unresolvedIds.push(String(testData?.fullTitle || testData?.title || '').trim() || '(Unnamed test case)');
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!resultsToUpload[id]) {
|
|
326
|
+
resultsToUpload[id] = [];
|
|
327
|
+
}
|
|
328
|
+
resultsToUpload[id].push(runRecord);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
topSuites.forEach((suite) => {
|
|
335
|
+
const testsInSuite = collectAllTestsFromSuite(suite);
|
|
336
|
+
testsInSuite.forEach((testData) => {
|
|
337
|
+
const state = getTestState(testData);
|
|
338
|
+
tests += 1;
|
|
339
|
+
if (state === SYSTEM_STATUS.PASSED) {
|
|
340
|
+
passes += 1;
|
|
341
|
+
} else if (state === SYSTEM_STATUS.FAILED) {
|
|
342
|
+
failures += 1;
|
|
343
|
+
} else {
|
|
344
|
+
skipped += 1;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const runRecord = prepareMochawesomeRunRecord(testData);
|
|
348
|
+
if (!runRecord) {
|
|
349
|
+
unresolvedIds.push(String(testData?.fullTitle || testData?.title || '').trim() || '(Unnamed test case)');
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (!resultsToUpload['0']) {
|
|
354
|
+
resultsToUpload['0'] = [];
|
|
355
|
+
}
|
|
356
|
+
resultsToUpload['0'].push(runRecord);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
if (!Object.keys(resultsToUpload).length) {
|
|
362
|
+
throw new Error('Could not parse results.');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (!tests && reportData.stats && Number.isFinite(reportData.stats.tests)) {
|
|
366
|
+
tests = reportData.stats.tests;
|
|
367
|
+
passes = Number.isFinite(reportData.stats.passes) ? reportData.stats.passes : passes;
|
|
368
|
+
failures = Number.isFinite(reportData.stats.failures) ? reportData.stats.failures : failures;
|
|
369
|
+
const pending = Number.isFinite(reportData.stats.pending) ? reportData.stats.pending : 0;
|
|
370
|
+
const explicitSkipped = Number.isFinite(reportData.stats.skipped) ? reportData.stats.skipped : 0;
|
|
371
|
+
skipped = explicitSkipped || pending;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
format: 'mochawesome',
|
|
376
|
+
hasConfig,
|
|
377
|
+
resultsToUpload,
|
|
378
|
+
stats: {
|
|
379
|
+
tests,
|
|
380
|
+
passes,
|
|
381
|
+
failures,
|
|
382
|
+
skipped
|
|
383
|
+
},
|
|
384
|
+
unresolvedIds: unique(unresolvedIds)
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function parseJUnitXml(junitXmlContent) {
|
|
389
|
+
if (!junitXmlContent || typeof junitXmlContent !== 'string') {
|
|
390
|
+
throw new Error('JUnit XML content is empty or invalid');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const testCases = [];
|
|
394
|
+
const testcaseRegex = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/gi;
|
|
395
|
+
let match;
|
|
396
|
+
|
|
397
|
+
while ((match = testcaseRegex.exec(junitXmlContent)) !== null) {
|
|
398
|
+
const attrs = parseXmlAttributes(match[1] || '');
|
|
399
|
+
const body = match[2] || '';
|
|
400
|
+
|
|
401
|
+
const rawName = (attrs.name || '').trim();
|
|
402
|
+
const rawClassName = (attrs.classname || '').trim();
|
|
403
|
+
const timeInSeconds = Number.parseFloat(attrs.time);
|
|
404
|
+
const duration = durationSecondsToSeconds(timeInSeconds);
|
|
405
|
+
|
|
406
|
+
let state = SYSTEM_STATUS.PASSED;
|
|
407
|
+
const hasSkipped = /<skipped\b/i.test(body) || String(attrs.status || '').toLowerCase() === SYSTEM_STATUS.SKIPPED;
|
|
408
|
+
const hasFailed = /<failure\b/i.test(body) || /<error\b/i.test(body);
|
|
409
|
+
|
|
410
|
+
if (hasSkipped) {
|
|
411
|
+
state = SYSTEM_STATUS.SKIPPED;
|
|
412
|
+
} else if (hasFailed) {
|
|
413
|
+
state = SYSTEM_STATUS.FAILED;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const failureDetails = getFailureDetails(body);
|
|
417
|
+
const testCaseId = extractTestCaseIdFromTitle(rawName) || extractTestCaseIdFromTitle(rawClassName);
|
|
418
|
+
const configId = extractConfigIdFromText(rawName) || extractConfigIdFromText(rawClassName);
|
|
419
|
+
|
|
420
|
+
testCases.push({
|
|
421
|
+
title: rawName || '(Unnamed test case)',
|
|
422
|
+
suite: rawClassName || 'JUnit Tests',
|
|
423
|
+
testCaseId,
|
|
424
|
+
configId,
|
|
425
|
+
duration,
|
|
426
|
+
state,
|
|
427
|
+
failureMessage: failureDetails.message,
|
|
428
|
+
failureStack: failureDetails.stack
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (!testCases.length) {
|
|
433
|
+
throw new Error('No <testcase> elements were found in the provided JUnit XML');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return testCases;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function parseJUnitReport(junitXmlContent) {
|
|
440
|
+
const testCases = parseJUnitXml(junitXmlContent);
|
|
441
|
+
|
|
442
|
+
const resultsToUpload = {};
|
|
443
|
+
const unresolvedIds = [];
|
|
444
|
+
let hasConfig = false;
|
|
445
|
+
|
|
446
|
+
let passes = 0;
|
|
447
|
+
let failures = 0;
|
|
448
|
+
let skipped = 0;
|
|
449
|
+
|
|
450
|
+
testCases.forEach((testCase) => {
|
|
451
|
+
if (testCase.state === SYSTEM_STATUS.PASSED) {
|
|
452
|
+
passes += 1;
|
|
453
|
+
} else if (testCase.state === SYSTEM_STATUS.FAILED) {
|
|
454
|
+
failures += 1;
|
|
455
|
+
} else {
|
|
456
|
+
skipped += 1;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (!testCase.testCaseId) {
|
|
460
|
+
unresolvedIds.push(testCase.title);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const key = testCase.configId ? String(testCase.configId) : '0';
|
|
465
|
+
if (testCase.configId) {
|
|
466
|
+
hasConfig = true;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (!resultsToUpload[key]) {
|
|
470
|
+
resultsToUpload[key] = [];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
resultsToUpload[key].push({
|
|
474
|
+
tcId: String(testCase.testCaseId),
|
|
475
|
+
status: toRunStatus(testCase.state),
|
|
476
|
+
errDetails: String(testCase.failureStack || testCase.failureMessage || '').trim() || null,
|
|
477
|
+
title: `${testCase.suite} ${testCase.title}`.trim(),
|
|
478
|
+
duration: testCase.duration
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
if (!Object.keys(resultsToUpload).length) {
|
|
483
|
+
throw new Error('Could not parse results.');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
format: 'junit',
|
|
488
|
+
hasConfig,
|
|
489
|
+
resultsToUpload,
|
|
490
|
+
stats: {
|
|
491
|
+
tests: testCases.length,
|
|
492
|
+
passes,
|
|
493
|
+
failures,
|
|
494
|
+
skipped
|
|
495
|
+
},
|
|
496
|
+
unresolvedIds: unique(unresolvedIds)
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function encodeComment(value) {
|
|
501
|
+
const text = String(value || '').trim();
|
|
502
|
+
if (!text) {
|
|
503
|
+
return '';
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
return escape(text);
|
|
508
|
+
} catch {
|
|
509
|
+
return encodeURIComponent(text);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
class TcApiClient {
|
|
514
|
+
constructor({ accessToken, projectId, testPlanId, baseApiUrl }) {
|
|
515
|
+
this.accessToken = String(accessToken);
|
|
516
|
+
this.projectId = Number(projectId);
|
|
517
|
+
this.testPlanId = Number(testPlanId);
|
|
518
|
+
this.baseApiUrl = getBaseApiUrl(baseApiUrl);
|
|
519
|
+
|
|
520
|
+
this.project = null;
|
|
521
|
+
this.user = null;
|
|
522
|
+
this.testPlan = null;
|
|
523
|
+
this.testPlanRun = null;
|
|
524
|
+
this.testPlanConfigs = null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
buildUrl(endpoint) {
|
|
528
|
+
const normalized = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
|
529
|
+
const separator = normalized.includes('?') ? '&' : '?';
|
|
530
|
+
return `${this.baseApiUrl}${normalized}${separator}token=${encodeURIComponent(this.accessToken)}`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async request(endpoint, options = {}) {
|
|
534
|
+
const {
|
|
535
|
+
method = 'GET',
|
|
536
|
+
body
|
|
537
|
+
} = options;
|
|
538
|
+
|
|
539
|
+
const headers = {
|
|
540
|
+
Accept: 'application/json'
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const requestOptions = {
|
|
544
|
+
method,
|
|
545
|
+
headers
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
if (body !== undefined) {
|
|
549
|
+
headers['Content-Type'] = 'application/json';
|
|
550
|
+
requestOptions.body = JSON.stringify(body);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
let response;
|
|
554
|
+
try {
|
|
555
|
+
response = await fetch(this.buildUrl(endpoint), requestOptions);
|
|
556
|
+
} catch (error) {
|
|
557
|
+
throw new Error(`Failed to call ${endpoint}: ${error?.message || String(error)}`);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const rawBody = await response.text();
|
|
561
|
+
let data = null;
|
|
562
|
+
if (rawBody) {
|
|
563
|
+
try {
|
|
564
|
+
data = JSON.parse(rawBody);
|
|
565
|
+
} catch {
|
|
566
|
+
data = rawBody;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (!response.ok) {
|
|
571
|
+
const message =
|
|
572
|
+
(data && typeof data === 'object' && (data.message || data.error || data.status)) ||
|
|
573
|
+
(typeof data === 'string' ? data : '') ||
|
|
574
|
+
response.statusText ||
|
|
575
|
+
`Request failed with status ${response.status}`;
|
|
576
|
+
|
|
577
|
+
const error = new Error(String(message));
|
|
578
|
+
error.status = response.status;
|
|
579
|
+
error.data = data;
|
|
580
|
+
throw error;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return data;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async hasAccessTokenExpired() {
|
|
587
|
+
try {
|
|
588
|
+
const responseData = await this.request('/system');
|
|
589
|
+
if (responseData && (responseData.code === 401 || responseData.statusCode === 401)) {
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
return false;
|
|
593
|
+
} catch (error) {
|
|
594
|
+
if (error?.status === 401) {
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async getUserInfo() {
|
|
602
|
+
if (this.user && this.user.id) {
|
|
603
|
+
return this.user;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
try {
|
|
607
|
+
const resources = await this.request('/users/me');
|
|
608
|
+
if (resources && resources.id) {
|
|
609
|
+
this.user = resources;
|
|
610
|
+
return resources;
|
|
611
|
+
}
|
|
612
|
+
} catch {
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async getProjectInfo() {
|
|
620
|
+
if (this.project && this.project.id) {
|
|
621
|
+
return this.project;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
try {
|
|
625
|
+
const resources = await this.request(`/projects/${this.projectId}`);
|
|
626
|
+
if (resources && resources.id) {
|
|
627
|
+
this.project = resources;
|
|
628
|
+
return resources;
|
|
629
|
+
}
|
|
630
|
+
} catch {
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async getTestplanInfo() {
|
|
638
|
+
if (this.testPlan && this.testPlan.id) {
|
|
639
|
+
return this.testPlan;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
try {
|
|
643
|
+
const resources = await this.request(`/testplans/${this.testPlanId}`);
|
|
644
|
+
if (resources && resources.id) {
|
|
645
|
+
this.testPlan = resources;
|
|
646
|
+
return resources;
|
|
647
|
+
}
|
|
648
|
+
} catch {
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async getTestplanRunInfo() {
|
|
656
|
+
if (this.testPlanRun && this.testPlanRun.id) {
|
|
657
|
+
return this.testPlanRun;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const params = new URLSearchParams({
|
|
661
|
+
project: String(this.projectId),
|
|
662
|
+
testplan: String(this.testPlanId),
|
|
663
|
+
_limit: '1',
|
|
664
|
+
_sort: 'id:desc'
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
try {
|
|
668
|
+
const resources = await this.request(`/testplanregressions?${params.toString()}`);
|
|
669
|
+
if (Array.isArray(resources) && resources.length && resources[0] && resources[0].id) {
|
|
670
|
+
this.testPlanRun = resources[0];
|
|
671
|
+
return this.testPlanRun;
|
|
672
|
+
}
|
|
673
|
+
} catch {
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async getTestplanConfigs() {
|
|
681
|
+
if (Array.isArray(this.testPlanConfigs) && this.testPlanConfigs.length) {
|
|
682
|
+
return this.testPlanConfigs;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const params = new URLSearchParams({
|
|
686
|
+
project: String(this.projectId),
|
|
687
|
+
testplan: String(this.testPlanId),
|
|
688
|
+
_limit: '-1'
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
try {
|
|
692
|
+
const resources = await this.request(`/testplanconfigurations?${params.toString()}`);
|
|
693
|
+
if (Array.isArray(resources)) {
|
|
694
|
+
this.testPlanConfigs = resources;
|
|
695
|
+
return resources;
|
|
696
|
+
}
|
|
697
|
+
} catch {
|
|
698
|
+
return [];
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return [];
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async getAssignedCases(testPlanConfigId = null) {
|
|
705
|
+
if (!this.testPlanRun || !this.testPlanRun.id || !this.user || !this.user.id) {
|
|
706
|
+
return [];
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const params = new URLSearchParams({
|
|
710
|
+
project: String(this.projectId),
|
|
711
|
+
test_plan: String(this.testPlanId),
|
|
712
|
+
regression: String(this.testPlanRun.id),
|
|
713
|
+
assigned_to: String(this.user.id),
|
|
714
|
+
_limit: '-1'
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
if (testPlanConfigId && String(testPlanConfigId) !== '0') {
|
|
718
|
+
params.set('test_plan_config', String(testPlanConfigId));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
try {
|
|
722
|
+
const resources = await this.request(`/executedtestcases?${params.toString()}`);
|
|
723
|
+
if (Array.isArray(resources)) {
|
|
724
|
+
return resources;
|
|
725
|
+
}
|
|
726
|
+
return [];
|
|
727
|
+
} catch {
|
|
728
|
+
return [];
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async updateCaseRunResult(id, data) {
|
|
733
|
+
try {
|
|
734
|
+
const updateResult = await this.request(`/executedtestcases/${id}`, {
|
|
735
|
+
method: 'PUT',
|
|
736
|
+
body: data
|
|
737
|
+
});
|
|
738
|
+
if (updateResult && updateResult.id) {
|
|
739
|
+
return updateResult;
|
|
740
|
+
}
|
|
741
|
+
} catch {
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async uploadCaseComments(data) {
|
|
749
|
+
try {
|
|
750
|
+
const createResult = await this.request('/executioncomments', {
|
|
751
|
+
method: 'POST',
|
|
752
|
+
body: data
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
if (createResult && createResult.id) {
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
} catch {
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
return false;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async updateCaseTimeTaken(id, data) {
|
|
766
|
+
try {
|
|
767
|
+
await this.request(`/executedtestcases/${id}/updateTimeTaken`, {
|
|
768
|
+
method: 'PUT',
|
|
769
|
+
body: data
|
|
770
|
+
});
|
|
771
|
+
return true;
|
|
772
|
+
} catch {
|
|
773
|
+
return false;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function findMatchingExecutedCase(casesAssigned, runRecord, hasConfig, configId) {
|
|
779
|
+
const targetCaseId = String(runRecord.tcId);
|
|
780
|
+
if (hasConfig && configId && String(configId) !== '0') {
|
|
781
|
+
const targetConfigId = String(configId);
|
|
782
|
+
return casesAssigned.find((assignedCase) => {
|
|
783
|
+
const assignedTestCaseId = assignedCase?.test_plan_test_case?.test_case;
|
|
784
|
+
const assignedConfigId = assignedCase?.test_plan_config?.id;
|
|
785
|
+
return (
|
|
786
|
+
assignedTestCaseId !== undefined &&
|
|
787
|
+
String(assignedTestCaseId) === targetCaseId &&
|
|
788
|
+
assignedConfigId !== undefined &&
|
|
789
|
+
String(assignedConfigId) === targetConfigId
|
|
790
|
+
);
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return casesAssigned.find((assignedCase) => {
|
|
795
|
+
const assignedTestCaseId = assignedCase?.test_plan_test_case?.test_case;
|
|
796
|
+
return assignedTestCaseId !== undefined && String(assignedTestCaseId) === targetCaseId;
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function buildUpdatePayload({ execCase, projectId, testPlanId, runRecord, configId, hasConfig }) {
|
|
801
|
+
const payload = {
|
|
802
|
+
id: execCase.id,
|
|
803
|
+
test_plan_test_case: execCase.test_plan_test_case.id,
|
|
804
|
+
project: projectId,
|
|
805
|
+
status: runRecord.status,
|
|
806
|
+
test_plan: testPlanId
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
if (hasConfig && configId && String(configId) !== '0') {
|
|
810
|
+
payload.test_plan_config = Number(configId);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (runRecord.duration && runRecord.duration > 0) {
|
|
814
|
+
const existingTime = Number(execCase.time_taken) > 0 ? Number(execCase.time_taken) : 0;
|
|
815
|
+
payload.time_taken = (runRecord.duration * 1000) + existingTime;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (Array.isArray(execCase?.test_case_revision?.steps) && execCase.test_case_revision.steps.length) {
|
|
819
|
+
payload.step_wise_result = execCase.test_case_revision.steps.map((step) => ({
|
|
820
|
+
...step,
|
|
821
|
+
status: runRecord.status
|
|
822
|
+
}));
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return payload;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function uploadUsingReporterFlow({
|
|
829
|
+
apiKey,
|
|
830
|
+
projectId,
|
|
831
|
+
testPlanId,
|
|
832
|
+
apiUrl,
|
|
833
|
+
hasConfig,
|
|
834
|
+
resultsToUpload,
|
|
835
|
+
unresolvedIds
|
|
836
|
+
}) {
|
|
837
|
+
const tcApiInstance = new TcApiClient({
|
|
838
|
+
accessToken: apiKey,
|
|
839
|
+
projectId,
|
|
840
|
+
testPlanId,
|
|
841
|
+
baseApiUrl: apiUrl
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
const hasTokenExpired = await tcApiInstance.hasAccessTokenExpired();
|
|
845
|
+
if (hasTokenExpired === true) {
|
|
846
|
+
throw new Error('Access token validation failed.');
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const projectData = await tcApiInstance.getProjectInfo();
|
|
850
|
+
if (!projectData || !projectData.id) {
|
|
851
|
+
throw new Error('Project could not be fetched. Ensure the project ID is correct and you have access.');
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const testPlanData = await tcApiInstance.getTestplanInfo();
|
|
855
|
+
if (!testPlanData || !testPlanData.id) {
|
|
856
|
+
throw new Error('Testplan could not be fetched.');
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (
|
|
860
|
+
testPlanData.project &&
|
|
861
|
+
testPlanData.project.id &&
|
|
862
|
+
String(testPlanData.project.id) !== String(projectData.id)
|
|
863
|
+
) {
|
|
864
|
+
throw new Error('Testplan does not belong to project.');
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const testPlanRun = await tcApiInstance.getTestplanRunInfo();
|
|
868
|
+
if (!testPlanRun || !testPlanRun.id) {
|
|
869
|
+
throw new Error('Run information not found.');
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
await tcApiInstance.getTestplanConfigs();
|
|
873
|
+
|
|
874
|
+
const casesAssigned = await tcApiInstance.getAssignedCases();
|
|
875
|
+
console.log({ 'Total assigned cases found': Array.isArray(casesAssigned) ? casesAssigned.length : 0 });
|
|
876
|
+
|
|
877
|
+
const unmatchedCaseIds = new Set();
|
|
878
|
+
const unmatchedConfigIds = new Set();
|
|
879
|
+
let matched = 0;
|
|
880
|
+
let updated = 0;
|
|
881
|
+
let errors = 0;
|
|
882
|
+
|
|
883
|
+
const configIds = Object.keys(resultsToUpload);
|
|
884
|
+
|
|
885
|
+
for (const configId of configIds) {
|
|
886
|
+
const records = Array.isArray(resultsToUpload[configId]) ? resultsToUpload[configId] : [];
|
|
887
|
+
|
|
888
|
+
if (hasConfig) {
|
|
889
|
+
console.log('--------------------------------------------------------------------------');
|
|
890
|
+
console.log({ processing_for_config_id: configId });
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
for (const runRecord of records) {
|
|
894
|
+
try {
|
|
895
|
+
console.log({ Processing: runRecord });
|
|
896
|
+
|
|
897
|
+
if (!runRecord || !runRecord.tcId) {
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const execCase = findMatchingExecutedCase(casesAssigned, runRecord, hasConfig, configId);
|
|
902
|
+
if (!execCase || !execCase.id) {
|
|
903
|
+
if (hasConfig && String(configId) !== '0') {
|
|
904
|
+
unmatchedConfigIds.add(`${runRecord.tcId}:${configId}`);
|
|
905
|
+
} else {
|
|
906
|
+
unmatchedCaseIds.add(String(runRecord.tcId));
|
|
907
|
+
}
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
matched += 1;
|
|
912
|
+
|
|
913
|
+
const updatePayload = buildUpdatePayload({
|
|
914
|
+
execCase,
|
|
915
|
+
projectId,
|
|
916
|
+
testPlanId,
|
|
917
|
+
runRecord,
|
|
918
|
+
configId,
|
|
919
|
+
hasConfig
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
const updateResult = await tcApiInstance.updateCaseRunResult(execCase.id, updatePayload);
|
|
923
|
+
if (!updateResult || !updateResult.id) {
|
|
924
|
+
errors += 1;
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
updated += 1;
|
|
929
|
+
|
|
930
|
+
if (runRecord.status === RUN_RESULT_MAP.fail && runRecord.errDetails) {
|
|
931
|
+
await tcApiInstance.uploadCaseComments({
|
|
932
|
+
project: projectId,
|
|
933
|
+
executed_test_case: execCase.id,
|
|
934
|
+
mentions: [],
|
|
935
|
+
comment: encodeComment(runRecord.errDetails)
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
if (updatePayload.time_taken) {
|
|
940
|
+
await tcApiInstance.updateCaseTimeTaken(execCase.id, {
|
|
941
|
+
time_taken: updatePayload.time_taken,
|
|
942
|
+
project: projectId
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
} catch {
|
|
946
|
+
errors += 1;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
return {
|
|
952
|
+
matched,
|
|
953
|
+
updated,
|
|
954
|
+
errors,
|
|
955
|
+
unresolvedIds: unique(unresolvedIds || []),
|
|
956
|
+
unmatchedCaseIds: unique([...unmatchedCaseIds]),
|
|
957
|
+
unmatchedConfigIds: unique([...unmatchedConfigIds])
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function validateRequiredOptions({ apiKey, project, testPlanId }) {
|
|
962
|
+
if (!apiKey) {
|
|
963
|
+
console.error('❌ Error: No API key provided');
|
|
964
|
+
console.error(' Pass --api-key <key> or set the TESTCOLLAB_TOKEN environment variable.');
|
|
965
|
+
process.exit(1);
|
|
966
|
+
}
|
|
967
|
+
if (!project) {
|
|
968
|
+
console.error('❌ Error: --project is required');
|
|
969
|
+
process.exit(1);
|
|
970
|
+
}
|
|
971
|
+
if (!testPlanId) {
|
|
972
|
+
console.error('❌ Error: --test-plan-id is required');
|
|
973
|
+
process.exit(1);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const parsedProjectId = Number(project);
|
|
977
|
+
const parsedTestPlanId = Number(testPlanId);
|
|
978
|
+
|
|
979
|
+
if (Number.isNaN(parsedProjectId)) {
|
|
980
|
+
console.error('❌ Error: --project must be a number');
|
|
981
|
+
process.exit(1);
|
|
982
|
+
}
|
|
983
|
+
if (Number.isNaN(parsedTestPlanId)) {
|
|
984
|
+
console.error('❌ Error: --test-plan-id must be a number');
|
|
985
|
+
process.exit(1);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
return {
|
|
989
|
+
parsedProjectId,
|
|
990
|
+
parsedTestPlanId
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function logUploadSummary(formatLabel, summary) {
|
|
995
|
+
console.log(`✅ ${formatLabel} report processed (${summary.matched || 0} matched, ${summary.updated || 0} updated)`);
|
|
996
|
+
|
|
997
|
+
if (summary.unresolvedIds?.length) {
|
|
998
|
+
console.warn(`⚠️ ${summary.unresolvedIds.length} testcase(s) missing TestCollab ID`);
|
|
999
|
+
}
|
|
1000
|
+
if (summary.unmatchedCaseIds?.length) {
|
|
1001
|
+
console.warn(`⚠️ ${summary.unmatchedCaseIds.length} testcase ID(s) not found in assigned executed cases`);
|
|
1002
|
+
}
|
|
1003
|
+
if (summary.unmatchedConfigIds?.length) {
|
|
1004
|
+
console.warn(`⚠️ ${summary.unmatchedConfigIds.length} testcase/config pair(s) could not be matched`);
|
|
1005
|
+
}
|
|
1006
|
+
if (summary.errors) {
|
|
1007
|
+
console.warn(`⚠️ ${summary.errors} testcase update(s) failed while processing report`);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function normalizeReportFormat(value) {
|
|
1012
|
+
const format = String(value || '').trim().toLowerCase();
|
|
1013
|
+
if (format === 'mochawesome' || format === 'junit') {
|
|
1014
|
+
return format;
|
|
1015
|
+
}
|
|
1016
|
+
return '';
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
export async function report(options) {
|
|
1020
|
+
const {
|
|
1021
|
+
project,
|
|
1022
|
+
testPlanId,
|
|
1023
|
+
format,
|
|
1024
|
+
resultFile,
|
|
1025
|
+
apiUrl
|
|
1026
|
+
} = options;
|
|
1027
|
+
|
|
1028
|
+
// Resolve API key: --api-key flag takes precedence, then TESTCOLLAB_TOKEN env var
|
|
1029
|
+
const apiKey = options.apiKey || process.env.TESTCOLLAB_TOKEN;
|
|
1030
|
+
|
|
1031
|
+
const {
|
|
1032
|
+
parsedProjectId,
|
|
1033
|
+
parsedTestPlanId
|
|
1034
|
+
} = validateRequiredOptions({ apiKey, project, testPlanId });
|
|
1035
|
+
|
|
1036
|
+
const normalizedFormat = normalizeReportFormat(format);
|
|
1037
|
+
if (!normalizedFormat) {
|
|
1038
|
+
console.error('❌ Error: --format must be either "mochawesome" or "junit"');
|
|
1039
|
+
process.exit(1);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (!resultFile || !String(resultFile).trim()) {
|
|
1043
|
+
console.error('❌ Error: --result-file is required');
|
|
1044
|
+
process.exit(1);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const absResultPath = toAbsolutePath(String(resultFile).trim());
|
|
1048
|
+
if (!fs.existsSync(absResultPath)) {
|
|
1049
|
+
console.error(`❌ Error: Result file not found at: ${absResultPath}`);
|
|
1050
|
+
console.error(' Ensure the result file exists and you passed a valid path via --result-file <path>');
|
|
1051
|
+
process.exit(1);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (normalizedFormat === 'junit') {
|
|
1055
|
+
try {
|
|
1056
|
+
const junitXmlContent = fs.readFileSync(absResultPath, 'utf8');
|
|
1057
|
+
const parsedReport = parseJUnitReport(junitXmlContent);
|
|
1058
|
+
const stats = parsedReport.stats;
|
|
1059
|
+
|
|
1060
|
+
console.log(
|
|
1061
|
+
`ℹ️ Parsed JUnit XML (${stats.tests} tests: ${stats.passes} passed, ${stats.failures} failed, ${stats.skipped} skipped)`
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
console.log('🚀 Uploading JUnit test run result to TestCollab...');
|
|
1065
|
+
const summary = await uploadUsingReporterFlow({
|
|
1066
|
+
apiKey: String(apiKey),
|
|
1067
|
+
projectId: parsedProjectId,
|
|
1068
|
+
testPlanId: parsedTestPlanId,
|
|
1069
|
+
apiUrl,
|
|
1070
|
+
hasConfig: parsedReport.hasConfig,
|
|
1071
|
+
resultsToUpload: parsedReport.resultsToUpload,
|
|
1072
|
+
unresolvedIds: parsedReport.unresolvedIds
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
logUploadSummary('JUnit', summary);
|
|
1076
|
+
} catch (err) {
|
|
1077
|
+
console.error(`❌ Error: ${err?.message || String(err)}`);
|
|
1078
|
+
process.exit(1);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
try {
|
|
1085
|
+
const mochawesomePayload = readMochawesomePayload(absResultPath);
|
|
1086
|
+
const parsedReport = parseMochawesomeReport(mochawesomePayload);
|
|
1087
|
+
const stats = parsedReport.stats;
|
|
1088
|
+
|
|
1089
|
+
console.log(
|
|
1090
|
+
`ℹ️ Parsed Mochawesome JSON (${stats.tests} tests: ${stats.passes} passed, ${stats.failures} failed, ${stats.skipped} skipped)`
|
|
1091
|
+
);
|
|
1092
|
+
|
|
1093
|
+
console.log('🚀 Uploading Mochawesome test run result to TestCollab...');
|
|
1094
|
+
const summary = await uploadUsingReporterFlow({
|
|
1095
|
+
apiKey: String(apiKey),
|
|
1096
|
+
projectId: parsedProjectId,
|
|
1097
|
+
testPlanId: parsedTestPlanId,
|
|
1098
|
+
apiUrl,
|
|
1099
|
+
hasConfig: parsedReport.hasConfig,
|
|
1100
|
+
resultsToUpload: parsedReport.resultsToUpload,
|
|
1101
|
+
unresolvedIds: parsedReport.unresolvedIds
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
logUploadSummary('Mochawesome', summary);
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
console.error(`❌ Error: ${err?.message || String(err)}`);
|
|
1107
|
+
process.exit(1);
|
|
1108
|
+
}
|
|
1109
|
+
}
|