@testomatio/reporter 2.1.2-beta.1-alias → 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.
package/lib/xmlReader.js CHANGED
@@ -131,7 +131,9 @@ class XmlReader {
131
131
  const { result, total, passed, failed, inconclusive, skipped } = jsonSuite;
132
132
  reduceOptions.preferClassname = this.stats.language === 'python';
133
133
  const resultTests = processTestSuite(jsonSuite['test-suite']);
134
- this.tests = this.tests.concat(resultTests);
134
+ // Deduplicate tests based on FQN (Assembly + Namespace + Class + Method)
135
+ const deduplicatedTests = this.deduplicateTestsByFQN(resultTests);
136
+ this.tests = this.tests.concat(deduplicatedTests);
135
137
  return {
136
138
  status: result?.toLowerCase(),
137
139
  create_tests: true,
@@ -139,7 +141,7 @@ class XmlReader {
139
141
  passed_count: parseInt(passed, 10),
140
142
  failed_count: parseInt(failed, 10),
141
143
  skipped_count: parseInt(inconclusive + skipped, 10),
142
- tests: resultTests,
144
+ tests: deduplicatedTests,
143
145
  };
144
146
  }
145
147
  processTRX(jsonSuite) {
@@ -275,6 +277,125 @@ class XmlReader {
275
277
  tests,
276
278
  };
277
279
  }
280
+ deduplicateTestsByFQN(tests) {
281
+ const fqnMap = new Map();
282
+ tests.forEach(test => {
283
+ const fqn = this.generateNormalizedFQN(test);
284
+ if (fqnMap.has(fqn)) {
285
+ const existingTest = fqnMap.get(fqn);
286
+ // Merge test properties, prioritizing Test Explorer structure but updating with IDs
287
+ if (test.test_id && !existingTest.test_id) {
288
+ existingTest.test_id = test.test_id;
289
+ }
290
+ // Keep the most complete test data
291
+ if (test.stack && !existingTest.stack) {
292
+ existingTest.stack = test.stack;
293
+ }
294
+ if (test.message && !existingTest.message) {
295
+ existingTest.message = test.message;
296
+ }
297
+ // Prefer Test Explorer structure (longer, more complete suite_title)
298
+ if (test.suite_title && test.suite_title.length > existingTest.suite_title.length) {
299
+ existingTest.suite_title = test.suite_title;
300
+ existingTest.file = this.extractCsFileFromPath(test);
301
+ }
302
+ }
303
+ else {
304
+ // Fix file path to use proper .cs file names from source paths
305
+ test.file = this.extractCsFileFromPath(test);
306
+ fqnMap.set(fqn, test);
307
+ }
308
+ });
309
+ return Array.from(fqnMap.values());
310
+ }
311
+ generateFQN(test) {
312
+ // Generate Fully Qualified Name: Namespace + Class + Method (standard .NET FQN)
313
+ // Don't include assembly as it can vary between different test structures
314
+ const namespace = this.extractNamespace(test);
315
+ const className = this.extractClassName(test);
316
+ const methodName = test.title;
317
+ // Use the most complete namespace.class structure available
318
+ if (test.suite_title && test.suite_title.includes('.')) {
319
+ return `${test.suite_title}.${methodName}`;
320
+ }
321
+ return `${namespace}.${className}.${methodName}`;
322
+ }
323
+ generateNormalizedFQN(test) {
324
+ // Generate normalized FQN for deduplication by extracting the core namespace.class.method
325
+ // This normalizes different representations of the same test
326
+ const fullClassName = test.suite_title || '';
327
+ const methodName = test.title;
328
+ // Extract the most specific namespace.class pattern
329
+ if (fullClassName.includes('.')) {
330
+ const parts = fullClassName.split('.');
331
+ if (parts.length >= 2) {
332
+ const className = parts[parts.length - 1];
333
+ // Look for common .NET namespace patterns and normalize them:
334
+ // TestProject.Tests.MyClass -> Tests.MyClass
335
+ // Tests.MyClass -> Tests.MyClass
336
+ // MyProject.SubNamespace.Tests.MyClass -> Tests.MyClass
337
+ let normalizedNamespace = '';
338
+ for (let i = parts.length - 2; i >= 0; i--) {
339
+ const part = parts[i];
340
+ // Build namespace from right to left, excluding project names
341
+ if (part === 'Tests' || part.endsWith('Tests') || part.includes('Test')) {
342
+ // Found a test namespace, use it as the normalized namespace
343
+ normalizedNamespace = part;
344
+ break;
345
+ }
346
+ else if (i === parts.length - 2) {
347
+ // If no test namespace found, use the immediate parent as namespace
348
+ normalizedNamespace = part;
349
+ }
350
+ }
351
+ return `${normalizedNamespace}.${className}.${methodName}`;
352
+ }
353
+ }
354
+ // Fallback for simple class names
355
+ return `${fullClassName}.${methodName}`;
356
+ }
357
+ extractAssemblyName(test) {
358
+ // Extract assembly name from file path or use default
359
+ if (test.file) {
360
+ const parts = test.file.split(/[/\\]/);
361
+ return parts[0] || 'DefaultAssembly';
362
+ }
363
+ return 'DefaultAssembly';
364
+ }
365
+ extractNamespace(test) {
366
+ // Extract namespace from suite_title or classname
367
+ if (test.suite_title && test.suite_title.includes('.')) {
368
+ const parts = test.suite_title.split('.');
369
+ return parts.slice(0, -1).join('.');
370
+ }
371
+ return test.suite_title || 'DefaultNamespace';
372
+ }
373
+ extractClassName(test) {
374
+ // Extract class name from suite_title
375
+ if (test.suite_title && test.suite_title.includes('.')) {
376
+ const parts = test.suite_title.split('.');
377
+ return parts[parts.length - 1];
378
+ }
379
+ return test.suite_title || 'DefaultClass';
380
+ }
381
+ extractCsFileFromPath(test) {
382
+ // Extract .cs file name from source file path, not namespace
383
+ if (test.file) {
384
+ // Look for actual .cs file path patterns
385
+ const csFileMatch = test.file.match(/([^/\\]+\.cs)$/);
386
+ if (csFileMatch) {
387
+ return test.file;
388
+ }
389
+ // If no .cs extension, assume it's a namespace path and convert to likely file name
390
+ const className = this.extractClassName(test);
391
+ const pathParts = test.file.split(/[/\\]/);
392
+ pathParts[pathParts.length - 1] = `${className}.cs`;
393
+ return pathParts.join('/');
394
+ }
395
+ // Fallback to class name
396
+ const className = this.extractClassName(test);
397
+ return `${className}.cs`;
398
+ }
278
399
  calculateStats() {
279
400
  this.stats = {
280
401
  ...this.stats,
@@ -430,7 +551,8 @@ function reduceTestCases(prev, item) {
430
551
  testCases
431
552
  .filter(t => !!t)
432
553
  .forEach(testCaseItem => {
433
- const file = testCaseItem.file || item.filepath || item.fullname || item.package || '';
554
+ // Use consistent Test Explorer structure: prioritize fullname for file path
555
+ const file = extractSourceFilePath(testCaseItem, item);
434
556
  let stack = '';
435
557
  let message = '';
436
558
  if (testCaseItem.error)
@@ -450,11 +572,11 @@ function reduceTestCases(prev, item) {
450
572
  if (!message)
451
573
  message = stack.trim().split('\n')[0];
452
574
  const isParametrized = item.type === 'ParameterizedMethod';
453
- const preferClassname = reduceOptions.preferClassname || isParametrized;
454
575
  // SpecFlow config
455
576
  let { title, tags, testId } = fetchProperties(isParametrized ? item : testCaseItem);
456
577
  let example = null;
457
- const suiteTitle = preferClassname ? testCaseItem.classname : item.name || testCaseItem.classname;
578
+ // Use consistent Test Explorer structure for suite title
579
+ const suiteTitle = extractTestExplorerSuiteTitle(testCaseItem, item);
458
580
  title ||= testCaseItem.name || testCaseItem.methodname || testCaseItem.classname;
459
581
  tags ||= [];
460
582
  const exampleMatches = testCaseItem.name?.match(/\S\((.*?)\)/);
@@ -516,6 +638,51 @@ function reduceTestCases(prev, item) {
516
638
  });
517
639
  return prev;
518
640
  }
641
+ function extractSourceFilePath(testCaseItem, item) {
642
+ // Priority order for file path extraction to match Test Explorer structure:
643
+ // 1. fullname (contains full project path)
644
+ // 2. filepath (direct file path)
645
+ // 3. file attribute from test case
646
+ // 4. package (fallback)
647
+ if (item.fullname) {
648
+ // Extract actual file path from fullname if it contains path separators
649
+ const fullnameParts = item.fullname.split('.');
650
+ if (fullnameParts.length > 2) {
651
+ // Reconstruct path from project.namespace.class structure
652
+ const projectName = fullnameParts[0];
653
+ const namespaceParts = fullnameParts.slice(1, -1);
654
+ const className = fullnameParts[fullnameParts.length - 1];
655
+ return `${projectName}/${namespaceParts.join('/')}/${className}.cs`;
656
+ }
657
+ }
658
+ if (item.filepath)
659
+ return item.filepath;
660
+ if (testCaseItem.file)
661
+ return testCaseItem.file;
662
+ if (item.package)
663
+ return item.package;
664
+ // Fallback: construct from classname
665
+ if (testCaseItem.classname) {
666
+ const parts = testCaseItem.classname.split('.');
667
+ const className = parts[parts.length - 1];
668
+ const namespacePath = parts.slice(0, -1).join('/');
669
+ return `${namespacePath}/${className}.cs`;
670
+ }
671
+ return '';
672
+ }
673
+ function extractTestExplorerSuiteTitle(testCaseItem, item) {
674
+ // Extract suite title to match Test Explorer structure (Project/Namespace hierarchy)
675
+ // Priority: fullname > classname > name
676
+ if (item.fullname) {
677
+ // Use fullname to maintain Test Explorer structure
678
+ return item.fullname;
679
+ }
680
+ if (testCaseItem.classname) {
681
+ return testCaseItem.classname;
682
+ }
683
+ // Fallback to item name but prefer classname structure
684
+ return item.name || testCaseItem.classname || 'UnknownClass';
685
+ }
519
686
  function processTestSuite(testsuite) {
520
687
  if (!testsuite)
521
688
  return [];
@@ -527,8 +694,14 @@ function processTestSuite(testsuite) {
527
694
  if (!Array.isArray(testsuite)) {
528
695
  suites = [testsuite];
529
696
  }
530
- const subSuites = suites.filter(s => s['test-suite'] && !testsuite['test-case']);
531
- return [...subSuites.map(s => processTestSuite(s['test-suite'])), ...suites.reduce(reduceTestCases, [])].flat();
697
+ // Only process suites that have test cases OR child suites, but avoid double processing
698
+ const subSuites = suites.filter(s => s['test-suite'] && !s['test-case']);
699
+ const leafSuites = suites.filter(s => s['test-case'] || s.testcase);
700
+ // Process child suites recursively
701
+ const childResults = subSuites.map(s => processTestSuite(s['test-suite'])).flat();
702
+ // Process leaf suites with actual test cases
703
+ const leafResults = leafSuites.reduce(reduceTestCases, []);
704
+ return [...childResults, ...leafResults];
532
705
  }
533
706
  function fetchProperties(item) {
534
707
  const tags = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.1.2-beta.1-alias",
3
+ "version": "2.1.3-beta.1-xml-import",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -163,7 +163,7 @@ function CodeceptReporter(config) {
163
163
  const manuallyAttachedArtifacts = services.artifacts.get(test.fullTitle());
164
164
  const keyValues = services.keyValues.get(test.fullTitle());
165
165
  const stepHierarchy = buildUnifiedStepHierarchy(test.steps, hookSteps);
166
- const labels = services.labels.get(test.fullTitle());
166
+ const links = services.links.get(test.fullTitle());
167
167
 
168
168
  services.setContext(null);
169
169
 
@@ -178,7 +178,7 @@ function CodeceptReporter(config) {
178
178
  files,
179
179
  steps: stepHierarchy, // Array of step objects per API schema
180
180
  logs,
181
- labels,
181
+ links,
182
182
  manuallyAttachedArtifacts,
183
183
  meta: { ...keyValues, ...test.meta },
184
184
  });
@@ -61,6 +61,7 @@ function MochaReporter(runner, opts) {
61
61
  const logs = getTestLogs(test);
62
62
  const artifacts = services.artifacts.get(test.fullTitle());
63
63
  const keyValues = services.keyValues.get(test.fullTitle());
64
+ const links = services.links.get(test.fullTitle());
64
65
 
65
66
  client.addTestRun(STATUS.PASSED, {
66
67
  test_id: testId,
@@ -72,6 +73,7 @@ function MochaReporter(runner, opts) {
72
73
  logs,
73
74
  manuallyAttachedArtifacts: artifacts,
74
75
  meta: keyValues,
76
+ links,
75
77
  });
76
78
  });
77
79
 
@@ -79,6 +81,10 @@ function MochaReporter(runner, opts) {
79
81
  skipped += 1;
80
82
  console.log('skip: %s', test.fullTitle());
81
83
  const testId = getTestomatIdFromTestTitle(test.title);
84
+ const artifacts = services.artifacts.get(test.fullTitle());
85
+ const keyValues = services.keyValues.get(test.fullTitle());
86
+ const links = services.links.get(test.fullTitle());
87
+
82
88
  client.addTestRun(STATUS.SKIPPED, {
83
89
  title: getTestName(test),
84
90
  suite_title: getSuiteTitle(test),
@@ -86,6 +92,9 @@ function MochaReporter(runner, opts) {
86
92
  file: getFile(test),
87
93
  test_id: testId,
88
94
  time: test.duration,
95
+ manuallyAttachedArtifacts: artifacts,
96
+ meta: keyValues,
97
+ links,
89
98
  });
90
99
  });
91
100
 
@@ -95,6 +104,9 @@ function MochaReporter(runner, opts) {
95
104
  const testId = getTestomatIdFromTestTitle(test.title);
96
105
 
97
106
  const logs = getTestLogs(test);
107
+ const artifacts = services.artifacts.get(test.fullTitle());
108
+ const keyValues = services.keyValues.get(test.fullTitle());
109
+ const links = services.links.get(test.fullTitle());
98
110
 
99
111
  client.addTestRun(STATUS.FAILED, {
100
112
  error: err,
@@ -105,6 +117,9 @@ function MochaReporter(runner, opts) {
105
117
  code: process.env.TESTOMATIO_UPDATE_CODE ? test.body.toString() : '',
106
118
  time: test.duration,
107
119
  logs,
120
+ manuallyAttachedArtifacts: artifacts,
121
+ meta: keyValues,
122
+ links,
108
123
  });
109
124
  });
110
125
 
@@ -53,11 +53,13 @@ class WebdriverReporter extends WDIOReporter {
53
53
  test.suite = test.parent;
54
54
  const logs = getTestLogs(test.fullTitle);
55
55
  // TODO: FIX: artifacts for some reason leads to empty report on Testomat.io
56
- // const artifacts = services.artifacts.get(test.fullTitle);
57
- // const keyValues = services.keyValues.get(test.fullTitle);
56
+ // ^ not reproduced anymore (Jul 2025)
57
+ // but still be under investigation
58
+ const artifacts = services.artifacts.get(test.fullTitle);
59
+ const keyValues = services.keyValues.get(test.fullTitle);
58
60
  test.logs = logs;
59
- // test.artifacts = artifacts;
60
- // test.meta = keyValues;
61
+ test.artifacts = artifacts;
62
+ test.meta = keyValues;
61
63
 
62
64
  this._addTestPromises.push(this.addTest(test));
63
65
  }
package/src/client.js CHANGED
@@ -11,6 +11,7 @@ import path, { sep } from 'path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { S3Uploader } from './uploader.js';
13
13
  import { formatStep, readLatestRunId, storeRunId, validateSuiteId } from './utils/utils.js';
14
+ import { linkStorage } from './services/links.js';
14
15
  import { filesize as prettyBytes } from 'filesize';
15
16
 
16
17
  const debug = createDebugMessages('@testomatio/reporter:client');
@@ -182,7 +183,6 @@ class Client {
182
183
  test_id,
183
184
  timestamp,
184
185
  manuallyAttachedArtifacts,
185
- labels,
186
186
  overwrite,
187
187
  } = testData;
188
188
  let { message = '', meta = {} } = testData;
@@ -224,7 +224,9 @@ class Client {
224
224
  return acc;
225
225
  }, {});
226
226
 
227
- // Labels are simple array of strings, no processing needed
227
+ // Get links from storage using the test context
228
+ const testContext = suite_title ? `${suite_title} ${title}` : title;
229
+ const links = linkStorage.get(testContext) || [];
228
230
 
229
231
  let errorFormatted = '';
230
232
  if (error) {
@@ -280,7 +282,7 @@ class Client {
280
282
  timestamp,
281
283
  artifacts,
282
284
  meta,
283
- labels,
285
+ links,
284
286
  overwrite,
285
287
  ...(rootSuiteId && { root_suite_id: rootSuiteId }),
286
288
  };
@@ -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();
@@ -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 n === 1 ? console.log(app, `✅ We found one test!`) : console.log(app, `✅ We found ${n} tests!`);
276
+ return console.log(app, `✅ We found ${n === 1 ? 'one test' : `${n} tests`} in Testomat.io!`);
277
277
  };
278
278
 
279
279
  const humanize = text => {
@@ -354,12 +354,14 @@ 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
+
363
365
  const stats = fs.statSync(filePath);
364
366
  const diff = +new Date() - +stats.mtime;
365
367
  const diffHours = diff / 1000 / 60 / 60;