@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.
@@ -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(/&lt;/g, '<')
68
+ .replace(/&gt;/g, '>')
69
+ .replace(/&quot;/g, '"')
70
+ .replace(/&apos;/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(/&amp;/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
+ }