@testomatio/reporter 2.1.1 → 2.1.3-beta.1-xml-import

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.
@@ -41,7 +41,7 @@ class DataStorage {
41
41
  /**
42
42
  * Puts any data to storage (file or global variable).
43
43
  * If file: stores data as text, if global variable – stores as array of data.
44
- * @param {'log' | 'artifact' | 'keyvalue' | 'labels'} dataType
44
+ * @param {'log' | 'artifact' | 'keyvalue' | 'links'} dataType
45
45
  * @param {*} data anything you want to store (string, object, array, etc)
46
46
  * @param {*} context could be testId or any context (test name, suite name, including their IDs etc)
47
47
  * suite name + test name is used by default
@@ -70,7 +70,7 @@ class DataStorage {
70
70
  * Returns data, stored for specific test/context (or data which was stored without test id specified).
71
71
  * This method will get data from global variable and/or from from file (previosly saved with put method).
72
72
  *
73
- * @param {'log' | 'artifact' | 'keyvalue' | 'labels'} dataType
73
+ * @param {'log' | 'artifact' | 'keyvalue' | 'links'} dataType
74
74
  * @param {string} context
75
75
  * @returns {any []} array of data (any type), null (if no data found for context) or string (if data type is log)
76
76
  */
@@ -108,7 +108,7 @@ class DataStorage {
108
108
  }
109
109
 
110
110
  /**
111
- * @param {'log' | 'artifact' | 'keyvalue' | 'labels'} dataType
111
+ * @param {'log' | 'artifact' | 'keyvalue' | 'links'} dataType
112
112
  * @param {string} context
113
113
  * @returns aray of data (any type)
114
114
  */
@@ -127,7 +127,7 @@ class DataStorage {
127
127
  }
128
128
 
129
129
  /**
130
- * @param {'log' | 'artifact' | 'keyvalue' | 'labels'} dataType
130
+ * @param {'log' | 'artifact' | 'keyvalue' | 'links'} dataType
131
131
  * @param {*} context
132
132
  * @returns array of data (any type)
133
133
  */
@@ -151,7 +151,7 @@ class DataStorage {
151
151
 
152
152
  /**
153
153
  * Puts data to global variable. Unlike the file storage, stores data in array (file storage just append as string).
154
- * @param {'log' | 'artifact' | 'keyvalue' | 'labels'} dataType
154
+ * @param {'log' | 'artifact' | 'keyvalue' | 'links'} dataType
155
155
  * @param {*} data
156
156
  * @param {*} context
157
157
  */
@@ -166,7 +166,7 @@ class DataStorage {
166
166
 
167
167
  /**
168
168
  * Puts data to file. Unlike the global variable storage, stores data as string
169
- * @param {'log' | 'artifact' | 'keyvalue' | 'labels'} dataType
169
+ * @param {'log' | 'artifact' | 'keyvalue' | 'links'} dataType
170
170
  * @param {*} data
171
171
  * @param {string} context
172
172
  * @returns
@@ -53,44 +53,32 @@ function setKeyValue(keyValue, value = null) {
53
53
  }
54
54
 
55
55
  /**
56
- * Add a single label to the test report
56
+ * Add label(s) to the test report
57
57
  * @param {string} key - label key (e.g. 'severity', 'feature', or just 'smoke' for labels without values)
58
- * @param {string|null} [value=null] - optional label value (e.g. 'high', 'login')
58
+ * @param {string|string[]|null} [value=null] - optional label value(s) (e.g. 'high', 'login') or array of values
59
59
  * @returns {void}
60
60
  */
61
61
  function setLabel(key, value = null) {
62
- if (!key || typeof key !== 'string') {
63
- console.warn('Label key must be a non-empty string');
62
+ if (Array.isArray(value)) {
63
+ value.forEach(val => setLabel(key, val));
64
64
  return;
65
65
  }
66
66
 
67
- // Limit key length to 255 characters
68
- if (key.length > 255) {
69
- console.warn('Label key is too long, trimmed to 255 characters:', key);
70
- key = key.substring(0, 255);
71
- }
72
-
73
- let labelString = key;
74
- if (value !== null && value !== undefined && value !== '') {
75
- if (typeof value !== 'string') {
76
- console.warn('Label value must be a string, converting:', value);
77
- value = String(value);
78
- }
79
- // Limit value length to 255 characters
80
- if (value.length > 255) {
81
- console.warn('Label value is too long, trimmed to 255 characters:', value);
82
- value = value.substring(0, 255);
83
- }
84
- labelString = `${key}:${value}`;
85
- }
67
+ const labelObject = value !== null && value !== undefined && value !== ''
68
+ ? { label: `${key}:${value}` }
69
+ : { label: key };
70
+ services.links.put([labelObject]);
71
+ }
86
72
 
87
- // Limit total label length to 255 characters
88
- if (labelString.length > 255) {
89
- console.warn('Label is too long, trimmed to 255 characters:', labelString);
90
- labelString = labelString.substring(0, 255);
91
- }
92
73
 
93
- services.labels.put([labelString]);
74
+ /**
75
+ * Add link(s) to the test report
76
+ * @param {...string} testIds - test IDs to link
77
+ * @returns {void}
78
+ */
79
+ function linkTest(...testIds) {
80
+ const links = testIds.map(testId => ({ test: testId }));
81
+ services.links.put(links);
94
82
  }
95
83
 
96
84
  export default {
@@ -99,4 +87,5 @@ export default {
99
87
  step: addStep,
100
88
  keyValue: setKeyValue,
101
89
  label: setLabel,
90
+ linkTest,
102
91
  };
package/src/reporter.js CHANGED
@@ -9,6 +9,7 @@ export const logger = services.logger;
9
9
  export const meta = reporterFunctions.keyValue;
10
10
  export const step = reporterFunctions.step;
11
11
  export const label = reporterFunctions.label;
12
+ export const linkTest = reporterFunctions.linkTest;
12
13
 
13
14
  /**
14
15
  * @typedef {typeof import('./reporter-functions.js').default.artifact} ArtifactFunction
@@ -30,6 +31,7 @@ export default {
30
31
  meta: reporterFunctions.keyValue,
31
32
  step: reporterFunctions.step,
32
33
  label: reporterFunctions.label,
34
+ linkTest: reporterFunctions.linkTest,
33
35
 
34
36
  // TestomatClient,
35
37
  // TRConstants,
@@ -1,14 +1,14 @@
1
1
  import { logger } from './logger.js';
2
2
  import { artifactStorage } from './artifacts.js';
3
3
  import { keyValueStorage } from './key-values.js';
4
- import { labelStorage } from './labels.js';
4
+ import { linkStorage } from './links.js';
5
5
  import { dataStorage } from '../data-storage.js';
6
6
 
7
7
  export const services = {
8
8
  logger,
9
9
  artifacts: artifactStorage,
10
10
  keyValues: keyValueStorage,
11
- labels: labelStorage,
11
+ links: linkStorage,
12
12
  setContext: context => {
13
13
  dataStorage.setContext(context);
14
14
  },
@@ -23,7 +23,7 @@ class LabelStorage {
23
23
  */
24
24
  put(labels, context = null) {
25
25
  if (!labels || !Array.isArray(labels)) return;
26
- dataStorage.putData('labels', labels, context);
26
+ dataStorage.putData('links', labels, context);
27
27
  }
28
28
 
29
29
  /**
@@ -32,7 +32,7 @@ class LabelStorage {
32
32
  * @returns {string[]} labels array, e.g. ['smoke', 'severity:high', 'feature:user_account']
33
33
  */
34
34
  get(context = null) {
35
- const labelsList = dataStorage.getData('labels', context);
35
+ const labelsList = dataStorage.getData('links', context);
36
36
  if (!labelsList || !labelsList?.length) return [];
37
37
 
38
38
  const allLabels = [];
@@ -0,0 +1,69 @@
1
+ import createDebugMessages from 'debug';
2
+ import { dataStorage } from '../data-storage.js';
3
+
4
+ const debug = createDebugMessages('@testomatio/reporter:services-links');
5
+
6
+ class LinkStorage {
7
+ static #instance;
8
+
9
+ /**
10
+ *
11
+ * @returns {LinkStorage}
12
+ */
13
+ static getInstance() {
14
+ if (!this.#instance) {
15
+ this.#instance = new LinkStorage();
16
+ }
17
+ return this.#instance;
18
+ }
19
+
20
+ /**
21
+ * Stores links array and passes it to reporter
22
+ * @param {object[]} links - array of link objects
23
+ * @param {*} context - full test title
24
+ */
25
+ put(links, context = null) {
26
+ if (!links || !Array.isArray(links)) return;
27
+ dataStorage.putData('links', links, context);
28
+ }
29
+
30
+ /**
31
+ * Returns links array for the test
32
+ * @param {*} context testId or test context from test runner
33
+ * @returns {object[]} links array, e.g. [{test: 'TEST-123'}, {jira: 'JIRA-456'}]
34
+ */
35
+ get(context = null) {
36
+ const linksList = dataStorage.getData('links', context);
37
+ if (!linksList || !linksList?.length) return [];
38
+
39
+ const allLinks = [];
40
+ for (const links of linksList) {
41
+ if (Array.isArray(links)) {
42
+ allLinks.push(...links);
43
+ } else if (typeof links === 'string') {
44
+ try {
45
+ const parsedLinks = JSON.parse(links);
46
+ if (Array.isArray(parsedLinks)) {
47
+ allLinks.push(...parsedLinks);
48
+ }
49
+ } catch (e) {
50
+ debug(`Error parsing links for test ${context}`, links);
51
+ }
52
+ }
53
+ }
54
+
55
+ // Remove duplicates based on JSON string comparison
56
+ const uniqueLinks = [];
57
+ const seen = new Set();
58
+ for (const link of allLinks) {
59
+ const key = JSON.stringify(link);
60
+ if (!seen.has(key)) {
61
+ seen.add(key);
62
+ uniqueLinks.push(link);
63
+ }
64
+ }
65
+ return uniqueLinks;
66
+ }
67
+ }
68
+
69
+ export const linkStorage = LinkStorage.getInstance();
package/src/xmlReader.js CHANGED
@@ -161,8 +161,11 @@ class XmlReader {
161
161
 
162
162
  reduceOptions.preferClassname = this.stats.language === 'python';
163
163
  const resultTests = processTestSuite(jsonSuite['test-suite']);
164
+
165
+ // Deduplicate tests based on FQN (Assembly + Namespace + Class + Method)
166
+ const deduplicatedTests = this.deduplicateTestsByFQN(resultTests);
164
167
 
165
- this.tests = this.tests.concat(resultTests);
168
+ this.tests = this.tests.concat(deduplicatedTests);
166
169
 
167
170
  return {
168
171
  status: result?.toLowerCase(),
@@ -171,7 +174,7 @@ class XmlReader {
171
174
  passed_count: parseInt(passed, 10),
172
175
  failed_count: parseInt(failed, 10),
173
176
  skipped_count: parseInt(inconclusive + skipped, 10),
174
- tests: resultTests,
177
+ tests: deduplicatedTests,
175
178
  };
176
179
  }
177
180
 
@@ -319,6 +322,145 @@ class XmlReader {
319
322
  };
320
323
  }
321
324
 
325
+ deduplicateTestsByFQN(tests) {
326
+ const fqnMap = new Map();
327
+
328
+ tests.forEach(test => {
329
+ const fqn = this.generateNormalizedFQN(test);
330
+
331
+ if (fqnMap.has(fqn)) {
332
+ const existingTest = fqnMap.get(fqn);
333
+ // Merge test properties, prioritizing Test Explorer structure but updating with IDs
334
+ if (test.test_id && !existingTest.test_id) {
335
+ existingTest.test_id = test.test_id;
336
+ }
337
+ // Keep the most complete test data
338
+ if (test.stack && !existingTest.stack) {
339
+ existingTest.stack = test.stack;
340
+ }
341
+ if (test.message && !existingTest.message) {
342
+ existingTest.message = test.message;
343
+ }
344
+ // Prefer Test Explorer structure (longer, more complete suite_title)
345
+ if (test.suite_title && test.suite_title.length > existingTest.suite_title.length) {
346
+ existingTest.suite_title = test.suite_title;
347
+ existingTest.file = this.extractCsFileFromPath(test);
348
+ }
349
+ } else {
350
+ // Fix file path to use proper .cs file names from source paths
351
+ test.file = this.extractCsFileFromPath(test);
352
+ fqnMap.set(fqn, test);
353
+ }
354
+ });
355
+
356
+ return Array.from(fqnMap.values());
357
+ }
358
+
359
+ generateFQN(test) {
360
+ // Generate Fully Qualified Name: Namespace + Class + Method (standard .NET FQN)
361
+ // Don't include assembly as it can vary between different test structures
362
+ const namespace = this.extractNamespace(test);
363
+ const className = this.extractClassName(test);
364
+ const methodName = test.title;
365
+
366
+ // Use the most complete namespace.class structure available
367
+ if (test.suite_title && test.suite_title.includes('.')) {
368
+ return `${test.suite_title}.${methodName}`;
369
+ }
370
+
371
+ return `${namespace}.${className}.${methodName}`;
372
+ }
373
+
374
+ generateNormalizedFQN(test) {
375
+ // Generate normalized FQN for deduplication by extracting the core namespace.class.method
376
+ // This normalizes different representations of the same test
377
+
378
+ const fullClassName = test.suite_title || '';
379
+ const methodName = test.title;
380
+
381
+ // Extract the most specific namespace.class pattern
382
+ if (fullClassName.includes('.')) {
383
+ const parts = fullClassName.split('.');
384
+
385
+ if (parts.length >= 2) {
386
+ const className = parts[parts.length - 1];
387
+
388
+ // Look for common .NET namespace patterns and normalize them:
389
+ // TestProject.Tests.MyClass -> Tests.MyClass
390
+ // Tests.MyClass -> Tests.MyClass
391
+ // MyProject.SubNamespace.Tests.MyClass -> Tests.MyClass
392
+
393
+ let normalizedNamespace = '';
394
+ for (let i = parts.length - 2; i >= 0; i--) {
395
+ const part = parts[i];
396
+
397
+ // Build namespace from right to left, excluding project names
398
+ if (part === 'Tests' || part.endsWith('Tests') || part.includes('Test')) {
399
+ // Found a test namespace, use it as the normalized namespace
400
+ normalizedNamespace = part;
401
+ break;
402
+ } else if (i === parts.length - 2) {
403
+ // If no test namespace found, use the immediate parent as namespace
404
+ normalizedNamespace = part;
405
+ }
406
+ }
407
+
408
+ return `${normalizedNamespace}.${className}.${methodName}`;
409
+ }
410
+ }
411
+
412
+ // Fallback for simple class names
413
+ return `${fullClassName}.${methodName}`;
414
+ }
415
+
416
+ extractAssemblyName(test) {
417
+ // Extract assembly name from file path or use default
418
+ if (test.file) {
419
+ const parts = test.file.split(/[/\\]/);
420
+ return parts[0] || 'DefaultAssembly';
421
+ }
422
+ return 'DefaultAssembly';
423
+ }
424
+
425
+ extractNamespace(test) {
426
+ // Extract namespace from suite_title or classname
427
+ if (test.suite_title && test.suite_title.includes('.')) {
428
+ const parts = test.suite_title.split('.');
429
+ return parts.slice(0, -1).join('.');
430
+ }
431
+ return test.suite_title || 'DefaultNamespace';
432
+ }
433
+
434
+ extractClassName(test) {
435
+ // Extract class name from suite_title
436
+ if (test.suite_title && test.suite_title.includes('.')) {
437
+ const parts = test.suite_title.split('.');
438
+ return parts[parts.length - 1];
439
+ }
440
+ return test.suite_title || 'DefaultClass';
441
+ }
442
+
443
+ extractCsFileFromPath(test) {
444
+ // Extract .cs file name from source file path, not namespace
445
+ if (test.file) {
446
+ // Look for actual .cs file path patterns
447
+ const csFileMatch = test.file.match(/([^/\\]+\.cs)$/);
448
+ if (csFileMatch) {
449
+ return test.file;
450
+ }
451
+
452
+ // If no .cs extension, assume it's a namespace path and convert to likely file name
453
+ const className = this.extractClassName(test);
454
+ const pathParts = test.file.split(/[/\\]/);
455
+ pathParts[pathParts.length - 1] = `${className}.cs`;
456
+ return pathParts.join('/');
457
+ }
458
+
459
+ // Fallback to class name
460
+ const className = this.extractClassName(test);
461
+ return `${className}.cs`;
462
+ }
463
+
322
464
  calculateStats() {
323
465
  this.stats = {
324
466
  ...this.stats,
@@ -485,7 +627,8 @@ function reduceTestCases(prev, item) {
485
627
  testCases
486
628
  .filter(t => !!t)
487
629
  .forEach(testCaseItem => {
488
- const file = testCaseItem.file || item.filepath || item.fullname || item.package || '';
630
+ // Use consistent Test Explorer structure: prioritize fullname for file path
631
+ const file = extractSourceFilePath(testCaseItem, item);
489
632
 
490
633
  let stack = '';
491
634
  let message = '';
@@ -500,12 +643,13 @@ function reduceTestCases(prev, item) {
500
643
  if (!message) message = stack.trim().split('\n')[0];
501
644
 
502
645
  const isParametrized = item.type === 'ParameterizedMethod';
503
- const preferClassname = reduceOptions.preferClassname || isParametrized;
504
646
 
505
647
  // SpecFlow config
506
648
  let { title, tags, testId } = fetchProperties(isParametrized ? item : testCaseItem);
507
649
  let example = null;
508
- const suiteTitle = preferClassname ? testCaseItem.classname : item.name || testCaseItem.classname;
650
+
651
+ // Use consistent Test Explorer structure for suite title
652
+ const suiteTitle = extractTestExplorerSuiteTitle(testCaseItem, item);
509
653
 
510
654
  title ||= testCaseItem.name || testCaseItem.methodname || testCaseItem.classname;
511
655
  tags ||= [];
@@ -577,6 +721,57 @@ function reduceTestCases(prev, item) {
577
721
  return prev;
578
722
  }
579
723
 
724
+ function extractSourceFilePath(testCaseItem, item) {
725
+ // Priority order for file path extraction to match Test Explorer structure:
726
+ // 1. fullname (contains full project path)
727
+ // 2. filepath (direct file path)
728
+ // 3. file attribute from test case
729
+ // 4. package (fallback)
730
+
731
+ if (item.fullname) {
732
+ // Extract actual file path from fullname if it contains path separators
733
+ const fullnameParts = item.fullname.split('.');
734
+ if (fullnameParts.length > 2) {
735
+ // Reconstruct path from project.namespace.class structure
736
+ const projectName = fullnameParts[0];
737
+ const namespaceParts = fullnameParts.slice(1, -1);
738
+ const className = fullnameParts[fullnameParts.length - 1];
739
+ return `${projectName}/${namespaceParts.join('/')}/${className}.cs`;
740
+ }
741
+ }
742
+
743
+ if (item.filepath) return item.filepath;
744
+ if (testCaseItem.file) return testCaseItem.file;
745
+ if (item.package) return item.package;
746
+
747
+ // Fallback: construct from classname
748
+ if (testCaseItem.classname) {
749
+ const parts = testCaseItem.classname.split('.');
750
+ const className = parts[parts.length - 1];
751
+ const namespacePath = parts.slice(0, -1).join('/');
752
+ return `${namespacePath}/${className}.cs`;
753
+ }
754
+
755
+ return '';
756
+ }
757
+
758
+ function extractTestExplorerSuiteTitle(testCaseItem, item) {
759
+ // Extract suite title to match Test Explorer structure (Project/Namespace hierarchy)
760
+ // Priority: fullname > classname > name
761
+
762
+ if (item.fullname) {
763
+ // Use fullname to maintain Test Explorer structure
764
+ return item.fullname;
765
+ }
766
+
767
+ if (testCaseItem.classname) {
768
+ return testCaseItem.classname;
769
+ }
770
+
771
+ // Fallback to item name but prefer classname structure
772
+ return item.name || testCaseItem.classname || 'UnknownClass';
773
+ }
774
+
580
775
  function processTestSuite(testsuite) {
581
776
  if (!testsuite) return [];
582
777
  if (testsuite.testsuite) return processTestSuite(testsuite.testsuite);
@@ -587,9 +782,17 @@ function processTestSuite(testsuite) {
587
782
  suites = [testsuite];
588
783
  }
589
784
 
590
- const subSuites = suites.filter(s => s['test-suite'] && !testsuite['test-case']);
785
+ // Only process suites that have test cases OR child suites, but avoid double processing
786
+ const subSuites = suites.filter(s => s['test-suite'] && !s['test-case']);
787
+ const leafSuites = suites.filter(s => s['test-case'] || s.testcase);
788
+
789
+ // Process child suites recursively
790
+ const childResults = subSuites.map(s => processTestSuite(s['test-suite'])).flat();
791
+
792
+ // Process leaf suites with actual test cases
793
+ const leafResults = leafSuites.reduce(reduceTestCases, []);
591
794
 
592
- return [...subSuites.map(s => processTestSuite(s['test-suite'])), ...suites.reduce(reduceTestCases, [])].flat();
795
+ return [...childResults, ...leafResults];
593
796
  }
594
797
 
595
798
  function fetchProperties(item) {