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

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.
@@ -3,8 +3,6 @@ import { services } from './services/index.js';
3
3
  /**
4
4
  * Stores path to file as artifact and uploads it to the S3 storage
5
5
  * @param {string | {path: string, type: string, name: string}} data - path to file or object with path, type and name
6
- * @param {any} [context=null] - optional context parameter
7
- * @returns {void}
8
6
  */
9
7
  function saveArtifact(data, context = null) {
10
8
  if (process.env.IS_PLAYWRIGHT)
@@ -16,8 +14,7 @@ function saveArtifact(data, context = null) {
16
14
 
17
15
  /**
18
16
  * Attach log message(s) to the test report
19
- * @param {...any} args - log messages to attach
20
- * @returns {void}
17
+ * @param string
21
18
  */
22
19
  function logMessage(...args) {
23
20
  if (process.env.IS_PLAYWRIGHT) throw new Error('This function is not available in Playwright framework');
@@ -26,8 +23,7 @@ function logMessage(...args) {
26
23
 
27
24
  /**
28
25
  * Similar to "log" function but marks message in report as a step
29
- * @param {string} message - step message
30
- * @returns {void}
26
+ * @param {string} message
31
27
  */
32
28
  function addStep(message) {
33
29
  if (process.env.IS_PLAYWRIGHT)
@@ -38,9 +34,8 @@ function addStep(message) {
38
34
 
39
35
  /**
40
36
  * Add key-value pair(s) to the test report
41
- * @param {{[key: string]: string} | string} keyValue - object { key: value } (multiple props allowed) or key (string)
42
- * @param {string|null} [value=null] - optional value when keyValue is a string
43
- * @returns {void}
37
+ * @param {{[key: string]: string} | string} keyValue object { key: value } (multiple props allowed) or key (string)
38
+ * @param {string?} value
44
39
  */
45
40
  function setKeyValue(keyValue, value = null) {
46
41
  if (process.env.IS_PLAYWRIGHT)
@@ -53,32 +48,48 @@ function setKeyValue(keyValue, value = null) {
53
48
  }
54
49
 
55
50
  /**
56
- * Add label(s) to the test report
51
+ * Add a single label to the test report
57
52
  * @param {string} key - label key (e.g. 'severity', 'feature', or just 'smoke' for labels without values)
58
- * @param {string|string[]|null} [value=null] - optional label value(s) (e.g. 'high', 'login') or array of values
59
- * @returns {void}
53
+ * @param {string} [value] - optional label value (e.g. 'high', 'login')
60
54
  */
61
55
  function setLabel(key, value = null) {
62
56
  if (Array.isArray(value)) {
63
- value.forEach(val => setLabel(key, val));
57
+ value.forEach(v => setLabel(key, v));
64
58
  return;
65
59
  }
66
60
 
67
- const labelObject = value !== null && value !== undefined && value !== ''
68
- ? { label: `${key}:${value}` }
69
- : { label: key };
70
- services.links.put([labelObject]);
71
- }
61
+ if (!key || typeof key !== 'string') {
62
+ console.warn('Label key must be a non-empty string');
63
+ return;
64
+ }
72
65
 
66
+ // Limit key length to 255 characters
67
+ if (key.length > 255) {
68
+ console.warn('Label key is too long, trimmed to 255 characters:', key);
69
+ key = key.substring(0, 255);
70
+ }
73
71
 
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);
72
+ let labelString = key;
73
+ if (value !== null && value !== undefined && value !== '') {
74
+ if (typeof value !== 'string') {
75
+ console.warn('Label value must be a string, converting:', value);
76
+ value = String(value);
77
+ }
78
+ // Limit value length to 255 characters
79
+ if (value.length > 255) {
80
+ console.warn('Label value is too long, trimmed to 255 characters:', value);
81
+ value = value.substring(0, 255);
82
+ }
83
+ labelString = `${key}:${value}`;
84
+ }
85
+
86
+ // Limit total label length to 255 characters
87
+ if (labelString.length > 255) {
88
+ console.warn('Label is too long, trimmed to 255 characters:', labelString);
89
+ labelString = labelString.substring(0, 255);
90
+ }
91
+
92
+ services.labels.put([labelString]);
82
93
  }
83
94
 
84
95
  export default {
@@ -87,5 +98,4 @@ export default {
87
98
  step: addStep,
88
99
  keyValue: setKeyValue,
89
100
  label: setLabel,
90
- linkTest,
91
101
  };
package/src/reporter.js CHANGED
@@ -9,15 +9,14 @@ 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;
13
12
 
14
13
  /**
15
- * @typedef {typeof import('./reporter-functions.js').default.artifact} ArtifactFunction
16
- * @typedef {typeof import('./reporter-functions.js').default.log} LogFunction
17
- * @typedef {typeof import('./services/index.js').services.logger} LoggerService
18
- * @typedef {typeof import('./reporter-functions.js').default.keyValue} MetaFunction
19
- * @typedef {typeof import('./reporter-functions.js').default.step} StepFunction
20
- * @typedef {typeof import('./reporter-functions.js').default.label} LabelFunction
14
+ * @typedef {import('./reporter-functions.js')} artifact
15
+ * @typedef {import('./reporter-functions.js')} log
16
+ * @typedef {import('./services/index.js')} logger
17
+ * @typedef {import('./reporter-functions.js')} meta
18
+ * @typedef {import('./reporter-functions.js')} step
19
+ * @typedef {import('./reporter-functions.js')} label
21
20
  */
22
21
  export default {
23
22
  /**
@@ -31,7 +30,6 @@ export default {
31
30
  meta: reporterFunctions.keyValue,
32
31
  step: reporterFunctions.step,
33
32
  label: reporterFunctions.label,
34
- linkTest: reporterFunctions.linkTest,
35
33
 
36
34
  // TestomatClient,
37
35
  // 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 { linkStorage } from './links.js';
4
+ import { labelStorage } from './labels.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
- links: linkStorage,
11
+ labels: labelStorage,
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('links', labels, context);
26
+ dataStorage.putData('labels', 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('links', context);
35
+ const labelsList = dataStorage.getData('labels', context);
36
36
  if (!labelsList || !labelsList?.length) return [];
37
37
 
38
38
  const allLabels = [];
@@ -53,7 +53,7 @@ const parseSuite = suiteTitle => {
53
53
  */
54
54
  const validateSuiteId = suiteId => {
55
55
  if (!suiteId) return null;
56
-
56
+
57
57
  const match = suiteId.match(SUITE_ID_REGEX);
58
58
  return match ? match[0] : null;
59
59
  };
@@ -273,7 +273,7 @@ const fileSystem = {
273
273
  const foundedTestLog = (app, tests) => {
274
274
  const n = tests.length;
275
275
 
276
- return console.log(app, `✅ We found ${n === 1 ? 'one test' : `${n} tests`} in Testomat.io!`);
276
+ return n === 1 ? console.log(app, `✅ We found one test!`) : console.log(app, `✅ We found ${n} tests!`);
277
277
  };
278
278
 
279
279
  const humanize = text => {
@@ -354,14 +354,12 @@ function storeRunId(runId) {
354
354
  }
355
355
 
356
356
  /**
357
- *
357
+ *
358
358
  * @returns {String|null} latest run ID
359
359
  */
360
360
  function readLatestRunId() {
361
361
  try {
362
362
  const filePath = path.join(os.tmpdir(), `testomatio.latest.run`);
363
- if (!fs.existsSync(filePath)) return null;
364
-
365
363
  const stats = fs.statSync(filePath);
366
364
  const diff = +new Date() - +stats.mtime;
367
365
  const diffHours = diff / 1000 / 60 / 60;
package/src/xmlReader.js CHANGED
@@ -161,11 +161,8 @@ 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);
167
164
 
168
- this.tests = this.tests.concat(deduplicatedTests);
165
+ this.tests = this.tests.concat(resultTests);
169
166
 
170
167
  return {
171
168
  status: result?.toLowerCase(),
@@ -174,7 +171,7 @@ class XmlReader {
174
171
  passed_count: parseInt(passed, 10),
175
172
  failed_count: parseInt(failed, 10),
176
173
  skipped_count: parseInt(inconclusive + skipped, 10),
177
- tests: deduplicatedTests,
174
+ tests: resultTests,
178
175
  };
179
176
  }
180
177
 
@@ -322,145 +319,6 @@ class XmlReader {
322
319
  };
323
320
  }
324
321
 
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
-
464
322
  calculateStats() {
465
323
  this.stats = {
466
324
  ...this.stats,
@@ -627,8 +485,7 @@ function reduceTestCases(prev, item) {
627
485
  testCases
628
486
  .filter(t => !!t)
629
487
  .forEach(testCaseItem => {
630
- // Use consistent Test Explorer structure: prioritize fullname for file path
631
- const file = extractSourceFilePath(testCaseItem, item);
488
+ const file = testCaseItem.file || item.filepath || item.fullname || item.package || '';
632
489
 
633
490
  let stack = '';
634
491
  let message = '';
@@ -643,13 +500,12 @@ function reduceTestCases(prev, item) {
643
500
  if (!message) message = stack.trim().split('\n')[0];
644
501
 
645
502
  const isParametrized = item.type === 'ParameterizedMethod';
503
+ const preferClassname = reduceOptions.preferClassname || isParametrized;
646
504
 
647
505
  // SpecFlow config
648
506
  let { title, tags, testId } = fetchProperties(isParametrized ? item : testCaseItem);
649
507
  let example = null;
650
-
651
- // Use consistent Test Explorer structure for suite title
652
- const suiteTitle = extractTestExplorerSuiteTitle(testCaseItem, item);
508
+ const suiteTitle = preferClassname ? testCaseItem.classname : item.name || testCaseItem.classname;
653
509
 
654
510
  title ||= testCaseItem.name || testCaseItem.methodname || testCaseItem.classname;
655
511
  tags ||= [];
@@ -721,57 +577,6 @@ function reduceTestCases(prev, item) {
721
577
  return prev;
722
578
  }
723
579
 
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
-
775
580
  function processTestSuite(testsuite) {
776
581
  if (!testsuite) return [];
777
582
  if (testsuite.testsuite) return processTestSuite(testsuite.testsuite);
@@ -782,17 +587,9 @@ function processTestSuite(testsuite) {
782
587
  suites = [testsuite];
783
588
  }
784
589
 
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, []);
590
+ const subSuites = suites.filter(s => s['test-suite'] && !testsuite['test-case']);
794
591
 
795
- return [...childResults, ...leafResults];
592
+ return [...subSuites.map(s => processTestSuite(s['test-suite'])), ...suites.reduce(reduceTestCases, [])].flat();
796
593
  }
797
594
 
798
595
  function fetchProperties(item) {
@@ -1,69 +0,0 @@
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();