@testomatio/reporter 2.1.3-beta.1-multi-links → 2.1.3-beta.2-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/reporter.d.ts CHANGED
@@ -1,14 +1,11 @@
1
- export type artifact = typeof import("./reporter-functions.js");
2
1
  export const artifact: (data: string | {
3
2
  path: string;
4
3
  type: string;
5
4
  name: string;
6
5
  }, context?: any) => void;
7
- export type log = typeof import("./reporter-functions.js");
8
6
  export const log: (...args: any[]) => void;
9
- export type logger = typeof import("./services/index.js");
10
7
  export const logger: {
11
- "__#13@#originalUserLogger": {
8
+ "__#14@#originalUserLogger": {
12
9
  assert(condition?: boolean, ...data: any[]): void;
13
10
  assert(value: any, message?: string, ...optionalParams: any[]): void;
14
11
  clear(): void;
@@ -53,13 +50,13 @@ export const logger: {
53
50
  profile(label?: string): void;
54
51
  profileEnd(label?: string): void;
55
52
  };
56
- "__#13@#userLoggerWithOverridenMethods": any;
53
+ "__#14@#userLoggerWithOverridenMethods": any;
57
54
  logLevel: string;
58
55
  step(strings: any, ...values: any[]): void;
59
56
  getLogs(context: string): string[];
60
- "__#13@#stringifyLogs"(...args: any[]): string;
57
+ "__#14@#stringifyLogs"(...args: any[]): string;
61
58
  _templateLiteralLog(strings: any, ...args: any[]): void;
62
- "__#13@#logWrapper"(argsArray: any, level: any): void;
59
+ "__#14@#logWrapper"(argsArray: any, level: any): void;
63
60
  assert(...args: any[]): void;
64
61
  debug(...args: any[]): void;
65
62
  error(...args: any[]): void;
@@ -75,17 +72,15 @@ export const logger: {
75
72
  }): void;
76
73
  prettyObjects: boolean;
77
74
  };
78
- export type meta = typeof import("./reporter-functions.js");
79
75
  export const meta: (keyValue: {
80
76
  [key: string]: string;
81
77
  } | string, value?: string | null) => void;
82
- export type step = typeof import("./reporter-functions.js");
83
78
  export const step: (message: string) => void;
84
- export type label = typeof import("./reporter-functions.js");
85
- export const label: (key: string, value?: string) => void;
79
+ export const label: (key: string, value?: string | string[] | null) => void;
80
+ export const linkTest: (...testIds: string[]) => void;
86
81
  declare namespace _default {
87
82
  let testomatioLogger: {
88
- "__#13@#originalUserLogger": {
83
+ "__#14@#originalUserLogger": {
89
84
  assert(condition?: boolean, ...data: any[]): void;
90
85
  assert(value: any, message?: string, ...optionalParams: any[]): void;
91
86
  clear(): void;
@@ -130,13 +125,13 @@ declare namespace _default {
130
125
  profile(label?: string): void;
131
126
  profileEnd(label?: string): void;
132
127
  };
133
- "__#13@#userLoggerWithOverridenMethods": any;
128
+ "__#14@#userLoggerWithOverridenMethods": any;
134
129
  logLevel: string;
135
130
  step(strings: any, ...values: any[]): void;
136
131
  getLogs(context: string): string[];
137
- "__#13@#stringifyLogs"(...args: any[]): string;
132
+ "__#14@#stringifyLogs"(...args: any[]): string;
138
133
  _templateLiteralLog(strings: any, ...args: any[]): void;
139
- "__#13@#logWrapper"(argsArray: any, level: any): void;
134
+ "__#14@#logWrapper"(argsArray: any, level: any): void;
140
135
  assert(...args: any[]): void;
141
136
  debug(...args: any[]): void;
142
137
  error(...args: any[]): void;
@@ -159,7 +154,7 @@ declare namespace _default {
159
154
  }, context?: any) => void;
160
155
  let log: (...args: any[]) => void;
161
156
  let logger: {
162
- "__#13@#originalUserLogger": {
157
+ "__#14@#originalUserLogger": {
163
158
  assert(condition?: boolean, ...data: any[]): void;
164
159
  assert(value: any, message?: string, ...optionalParams: any[]): void;
165
160
  clear(): void;
@@ -204,13 +199,13 @@ declare namespace _default {
204
199
  profile(label?: string): void;
205
200
  profileEnd(label?: string): void;
206
201
  };
207
- "__#13@#userLoggerWithOverridenMethods": any;
202
+ "__#14@#userLoggerWithOverridenMethods": any;
208
203
  logLevel: string;
209
204
  step(strings: any, ...values: any[]): void;
210
205
  getLogs(context: string): string[];
211
- "__#13@#stringifyLogs"(...args: any[]): string;
206
+ "__#14@#stringifyLogs"(...args: any[]): string;
212
207
  _templateLiteralLog(strings: any, ...args: any[]): void;
213
- "__#13@#logWrapper"(argsArray: any, level: any): void;
208
+ "__#14@#logWrapper"(argsArray: any, level: any): void;
214
209
  assert(...args: any[]): void;
215
210
  debug(...args: any[]): void;
216
211
  error(...args: any[]): void;
@@ -230,6 +225,13 @@ declare namespace _default {
230
225
  [key: string]: string;
231
226
  } | string, value?: string | null) => void;
232
227
  let step: (message: string) => void;
233
- let label: (key: string, value?: string) => void;
228
+ let label: (key: string, value?: string | string[] | null) => void;
229
+ let linkTest: (...testIds: string[]) => void;
234
230
  }
235
231
  export default _default;
232
+ export type ArtifactFunction = typeof import("./reporter-functions.js").default.artifact;
233
+ export type LogFunction = typeof import("./reporter-functions.js").default.log;
234
+ export type LoggerService = typeof import("./services/index.js").services.logger;
235
+ export type MetaFunction = typeof import("./reporter-functions.js").default.keyValue;
236
+ export type StepFunction = typeof import("./reporter-functions.js").default.step;
237
+ export type LabelFunction = typeof import("./reporter-functions.js").default.label;
package/lib/reporter.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.label = exports.step = exports.meta = exports.logger = exports.log = exports.artifact = void 0;
6
+ exports.linkTest = exports.label = exports.step = exports.meta = exports.logger = exports.log = exports.artifact = void 0;
7
7
  // import TestomatClient from './client.js';
8
8
  // import * as TRConstants from './constants.js';
9
9
  const index_js_1 = require("./services/index.js");
@@ -14,13 +14,14 @@ exports.logger = index_js_1.services.logger;
14
14
  exports.meta = reporter_functions_js_1.default.keyValue;
15
15
  exports.step = reporter_functions_js_1.default.step;
16
16
  exports.label = reporter_functions_js_1.default.label;
17
+ exports.linkTest = reporter_functions_js_1.default.linkTest;
17
18
  /**
18
- * @typedef {import('./reporter-functions.js')} artifact
19
- * @typedef {import('./reporter-functions.js')} log
20
- * @typedef {import('./services/index.js')} logger
21
- * @typedef {import('./reporter-functions.js')} meta
22
- * @typedef {import('./reporter-functions.js')} step
23
- * @typedef {import('./reporter-functions.js')} label
19
+ * @typedef {typeof import('./reporter-functions.js').default.artifact} ArtifactFunction
20
+ * @typedef {typeof import('./reporter-functions.js').default.log} LogFunction
21
+ * @typedef {typeof import('./services/index.js').services.logger} LoggerService
22
+ * @typedef {typeof import('./reporter-functions.js').default.keyValue} MetaFunction
23
+ * @typedef {typeof import('./reporter-functions.js').default.step} StepFunction
24
+ * @typedef {typeof import('./reporter-functions.js').default.label} LabelFunction
24
25
  */
25
26
  module.exports = {
26
27
  /**
@@ -33,6 +34,7 @@ module.exports = {
33
34
  meta: reporter_functions_js_1.default.keyValue,
34
35
  step: reporter_functions_js_1.default.step,
35
36
  label: reporter_functions_js_1.default.label,
37
+ linkTest: reporter_functions_js_1.default.linkTest,
36
38
  // TestomatClient,
37
39
  // TRConstants,
38
40
  };
@@ -3,7 +3,7 @@ export const artifactStorage: ArtifactStorage;
3
3
  * Artifact storage is supposed to store file paths
4
4
  */
5
5
  declare class ArtifactStorage {
6
- static "__#14@#instance": any;
6
+ static "__#15@#instance": any;
7
7
  /**
8
8
  * Singleton
9
9
  * @returns {ArtifactStorage}
@@ -2,10 +2,10 @@ export namespace services {
2
2
  export { logger };
3
3
  export { artifactStorage as artifacts };
4
4
  export { keyValueStorage as keyValues };
5
- export { labelStorage as labels };
5
+ export { linkStorage as links };
6
6
  export function setContext(context: any): void;
7
7
  }
8
8
  import { logger } from './logger.js';
9
9
  import { artifactStorage } from './artifacts.js';
10
10
  import { keyValueStorage } from './key-values.js';
11
- import { labelStorage } from './labels.js';
11
+ import { linkStorage } from './links.js';
@@ -4,13 +4,13 @@ exports.services = void 0;
4
4
  const logger_js_1 = require("./logger.js");
5
5
  const artifacts_js_1 = require("./artifacts.js");
6
6
  const key_values_js_1 = require("./key-values.js");
7
- const labels_js_1 = require("./labels.js");
7
+ const links_js_1 = require("./links.js");
8
8
  const data_storage_js_1 = require("../data-storage.js");
9
9
  exports.services = {
10
10
  logger: logger_js_1.logger,
11
11
  artifacts: artifacts_js_1.artifactStorage,
12
12
  keyValues: key_values_js_1.keyValueStorage,
13
- labels: labels_js_1.labelStorage,
13
+ links: links_js_1.linkStorage,
14
14
  setContext: context => {
15
15
  data_storage_js_1.dataStorage.setContext(context);
16
16
  },
@@ -1,6 +1,6 @@
1
1
  export const keyValueStorage: KeyValueStorage;
2
2
  declare class KeyValueStorage {
3
- static "__#15@#instance": any;
3
+ static "__#16@#instance": any;
4
4
  /**
5
5
  *
6
6
  * @returns {KeyValueStorage}
@@ -1,6 +1,6 @@
1
1
  export const labelStorage: LabelStorage;
2
2
  declare class LabelStorage {
3
- static "__#16@#instance": any;
3
+ static "__#19@#instance": any;
4
4
  /**
5
5
  *
6
6
  * @returns {LabelStorage}
@@ -27,7 +27,7 @@ class LabelStorage {
27
27
  put(labels, context = null) {
28
28
  if (!labels || !Array.isArray(labels))
29
29
  return;
30
- data_storage_js_1.dataStorage.putData('labels', labels, context);
30
+ data_storage_js_1.dataStorage.putData('links', labels, context);
31
31
  }
32
32
  /**
33
33
  * Returns labels array for the test
@@ -35,7 +35,7 @@ class LabelStorage {
35
35
  * @returns {string[]} labels array, e.g. ['smoke', 'severity:high', 'feature:user_account']
36
36
  */
37
37
  get(context = null) {
38
- const labelsList = data_storage_js_1.dataStorage.getData('labels', context);
38
+ const labelsList = data_storage_js_1.dataStorage.getData('links', context);
39
39
  if (!labelsList || !labelsList?.length)
40
40
  return [];
41
41
  const allLabels = [];
@@ -5,7 +5,7 @@ export const logger: Logger;
5
5
  * Supports different syntaxes to satisfy any user preferences.
6
6
  */
7
7
  declare class Logger {
8
- static "__#13@#instance": any;
8
+ static "__#14@#instance": any;
9
9
  /**
10
10
  *
11
11
  * @returns {Logger}
@@ -321,7 +321,7 @@ const fileSystem = {
321
321
  exports.fileSystem = fileSystem;
322
322
  const foundedTestLog = (app, tests) => {
323
323
  const n = tests.length;
324
- return n === 1 ? console.log(app, `✅ We found one test!`) : console.log(app, `✅ We found ${n} tests!`);
324
+ return console.log(app, `✅ We found ${n === 1 ? 'one test' : `${n} tests`} in Testomat.io!`);
325
325
  };
326
326
  exports.foundedTestLog = foundedTestLog;
327
327
  const humanize = text => {
@@ -399,6 +399,8 @@ function storeRunId(runId) {
399
399
  function readLatestRunId() {
400
400
  try {
401
401
  const filePath = path_1.default.join(os_1.default.tmpdir(), `testomatio.latest.run`);
402
+ if (!fs_1.default.existsSync(filePath))
403
+ return null;
402
404
  const stats = fs_1.default.statSync(filePath);
403
405
  const diff = +new Date() - +stats.mtime;
404
406
  const diffHours = diff / 1000 / 60 / 60;
@@ -77,6 +77,13 @@ declare class XmlReader {
77
77
  skipped_count: number;
78
78
  tests: any[];
79
79
  };
80
+ deduplicateTestsByFQN(tests: any): any[];
81
+ generateFQN(test: any): string;
82
+ generateNormalizedFQN(test: any): string;
83
+ extractAssemblyName(test: any): any;
84
+ extractNamespace(test: any): any;
85
+ extractClassName(test: any): any;
86
+ extractCsFileFromPath(test: any): any;
80
87
  calculateStats(): {};
81
88
  fetchSourceCode(): void;
82
89
  formatTests(): void;
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,169 @@ 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
+ // For parameterized tests, merge as Examples
287
+ if (test.example) {
288
+ // Initialize examples array if it doesn't exist
289
+ if (!existingTest.examples) {
290
+ existingTest.examples = [];
291
+ // Add the existing test's example as the first item
292
+ if (existingTest.example) {
293
+ existingTest.examples.push({
294
+ parameters: existingTest.example,
295
+ status: existingTest.status,
296
+ run_time: existingTest.run_time,
297
+ message: existingTest.message,
298
+ stack: existingTest.stack
299
+ });
300
+ }
301
+ }
302
+ // Add this test's execution as an example
303
+ existingTest.examples.push({
304
+ parameters: test.example,
305
+ status: test.status,
306
+ run_time: test.run_time,
307
+ message: test.message,
308
+ stack: test.stack
309
+ });
310
+ // Update the main test status to reflect the worst status
311
+ if (test.status === 'failed' || existingTest.status === 'failed') {
312
+ existingTest.status = 'failed';
313
+ }
314
+ else if (test.status === 'skipped' && existingTest.status !== 'failed') {
315
+ existingTest.status = 'skipped';
316
+ }
317
+ // Update total run time
318
+ existingTest.run_time = (existingTest.run_time || 0) + (test.run_time || 0);
319
+ // Merge stack traces if they're different
320
+ if (test.stack && test.stack !== existingTest.stack) {
321
+ existingTest.stack = existingTest.stack + '\n\n---\n\n' + test.stack;
322
+ }
323
+ // Merge messages if they're different
324
+ if (test.message && test.message !== existingTest.message) {
325
+ existingTest.message = existingTest.message + '; ' + test.message;
326
+ }
327
+ }
328
+ else {
329
+ // Merge test properties for non-parameterized tests, prioritizing Test Explorer structure
330
+ if (test.test_id && !existingTest.test_id) {
331
+ existingTest.test_id = test.test_id;
332
+ }
333
+ // Keep the most complete test data
334
+ if (test.stack && !existingTest.stack) {
335
+ existingTest.stack = test.stack;
336
+ }
337
+ if (test.message && !existingTest.message) {
338
+ existingTest.message = test.message;
339
+ }
340
+ }
341
+ // Prefer Test Explorer structure (longer, more complete suite_title)
342
+ if (test.suite_title && test.suite_title.length > existingTest.suite_title.length) {
343
+ existingTest.suite_title = test.suite_title;
344
+ existingTest.file = this.extractCsFileFromPath(test);
345
+ }
346
+ }
347
+ else {
348
+ // Fix file path to use proper .cs file names from source paths
349
+ test.file = this.extractCsFileFromPath(test);
350
+ fqnMap.set(fqn, test);
351
+ }
352
+ });
353
+ return Array.from(fqnMap.values());
354
+ }
355
+ generateFQN(test) {
356
+ // Generate Fully Qualified Name: Namespace + Class + Method (standard .NET FQN)
357
+ // Don't include assembly as it can vary between different test structures
358
+ const namespace = this.extractNamespace(test);
359
+ const className = this.extractClassName(test);
360
+ const methodName = test.title;
361
+ // Use the most complete namespace.class structure available
362
+ if (test.suite_title && test.suite_title.includes('.')) {
363
+ return `${test.suite_title}.${methodName}`;
364
+ }
365
+ return `${namespace}.${className}.${methodName}`;
366
+ }
367
+ generateNormalizedFQN(test) {
368
+ // Generate normalized FQN for deduplication by extracting the core namespace.class.method
369
+ // For parameterized tests, we want the SAME FQN so they merge into one test with multiple Examples
370
+ const fullClassName = test.suite_title || '';
371
+ const methodName = test.title;
372
+ // Extract the most specific namespace.class pattern
373
+ if (fullClassName.includes('.')) {
374
+ const parts = fullClassName.split('.');
375
+ if (parts.length >= 2) {
376
+ const className = parts[parts.length - 1];
377
+ // Look for common .NET namespace patterns and normalize them:
378
+ // TestProject.Tests.MyClass -> Tests.MyClass
379
+ // Tests.MyClass -> Tests.MyClass
380
+ // MyProject.SubNamespace.Tests.MyClass -> Tests.MyClass
381
+ let normalizedNamespace = '';
382
+ for (let i = parts.length - 2; i >= 0; i--) {
383
+ const part = parts[i];
384
+ // Build namespace from right to left, excluding project names
385
+ if (part === 'Tests' || part.endsWith('Tests') || part.includes('Test')) {
386
+ // Found a test namespace, use it as the normalized namespace
387
+ normalizedNamespace = part;
388
+ break;
389
+ }
390
+ else if (i === parts.length - 2) {
391
+ // If no test namespace found, use the immediate parent as namespace
392
+ normalizedNamespace = part;
393
+ }
394
+ }
395
+ return `${normalizedNamespace}.${className}.${methodName}`;
396
+ }
397
+ }
398
+ // Fallback for simple class names
399
+ return `${fullClassName}.${methodName}`;
400
+ }
401
+ extractAssemblyName(test) {
402
+ // Extract assembly name from file path or use default
403
+ if (test.file) {
404
+ const parts = test.file.split(/[/\\]/);
405
+ return parts[0] || 'DefaultAssembly';
406
+ }
407
+ return 'DefaultAssembly';
408
+ }
409
+ extractNamespace(test) {
410
+ // Extract namespace from suite_title or classname
411
+ if (test.suite_title && test.suite_title.includes('.')) {
412
+ const parts = test.suite_title.split('.');
413
+ return parts.slice(0, -1).join('.');
414
+ }
415
+ return test.suite_title || 'DefaultNamespace';
416
+ }
417
+ extractClassName(test) {
418
+ // Extract class name from suite_title
419
+ if (test.suite_title && test.suite_title.includes('.')) {
420
+ const parts = test.suite_title.split('.');
421
+ return parts[parts.length - 1];
422
+ }
423
+ return test.suite_title || 'DefaultClass';
424
+ }
425
+ extractCsFileFromPath(test) {
426
+ // Extract .cs file name from source file path, not namespace
427
+ if (test.file) {
428
+ // Look for actual .cs file path patterns
429
+ const csFileMatch = test.file.match(/([^/\\]+\.cs)$/);
430
+ if (csFileMatch) {
431
+ return test.file;
432
+ }
433
+ // If no .cs extension, assume it's a namespace path and convert to likely file name
434
+ const className = this.extractClassName(test);
435
+ const pathParts = test.file.split(/[/\\]/);
436
+ pathParts[pathParts.length - 1] = `${className}.cs`;
437
+ return pathParts.join('/');
438
+ }
439
+ // Fallback to class name
440
+ const className = this.extractClassName(test);
441
+ return `${className}.cs`;
442
+ }
278
443
  calculateStats() {
279
444
  this.stats = {
280
445
  ...this.stats,
@@ -430,7 +595,8 @@ function reduceTestCases(prev, item) {
430
595
  testCases
431
596
  .filter(t => !!t)
432
597
  .forEach(testCaseItem => {
433
- const file = testCaseItem.file || item.filepath || item.fullname || item.package || '';
598
+ // Use consistent Test Explorer structure: prioritize fullname for file path
599
+ const file = extractSourceFilePath(testCaseItem, item);
434
600
  let stack = '';
435
601
  let message = '';
436
602
  if (testCaseItem.error)
@@ -450,16 +616,20 @@ function reduceTestCases(prev, item) {
450
616
  if (!message)
451
617
  message = stack.trim().split('\n')[0];
452
618
  const isParametrized = item.type === 'ParameterizedMethod';
453
- const preferClassname = reduceOptions.preferClassname || isParametrized;
454
619
  // SpecFlow config
455
620
  let { title, tags, testId } = fetchProperties(isParametrized ? item : testCaseItem);
456
621
  let example = null;
457
- const suiteTitle = preferClassname ? testCaseItem.classname : item.name || testCaseItem.classname;
622
+ // Use consistent Test Explorer structure for suite title
623
+ const suiteTitle = extractTestExplorerSuiteTitle(testCaseItem, item);
458
624
  title ||= testCaseItem.name || testCaseItem.methodname || testCaseItem.classname;
459
625
  tags ||= [];
460
- const exampleMatches = testCaseItem.name?.match(/\S\((.*?)\)/);
626
+ // Store original test name for parameter extraction
627
+ const originalTestName = testCaseItem.name || testCaseItem.methodname;
628
+ const exampleMatches = originalTestName?.match(/\((.*?)\)$/);
461
629
  if (exampleMatches) {
462
- example = { ...exampleMatches[1].split(',').map(v => v.trim().replace(/[^\w\s-]/g, '')) };
630
+ // Extract and store parameters as Examples
631
+ const parameterValues = exampleMatches[1].split(',').map(v => v.trim().replace(/['"]/g, ''));
632
+ example = { ...parameterValues };
463
633
  title = title.replace(/\(.*?\)/, '').trim();
464
634
  }
465
635
  stack = `${testCaseItem['system-out'] || testCaseItem.output || testCaseItem.log || ''}\n\n${stack}\n\n${suiteOutput}\n\n${suiteErr}`.trim();
@@ -508,6 +678,7 @@ function reduceTestCases(prev, item) {
508
678
  run_time: parseFloat(testCaseItem.time || testCaseItem.duration) * 1000,
509
679
  status,
510
680
  title,
681
+ originalTestName, // Store original name for parameter-aware FQN generation
511
682
  root_suite_id: TESTOMATIO_SUITE,
512
683
  suite_title: suiteTitle,
513
684
  files,
@@ -516,6 +687,51 @@ function reduceTestCases(prev, item) {
516
687
  });
517
688
  return prev;
518
689
  }
690
+ function extractSourceFilePath(testCaseItem, item) {
691
+ // Priority order for file path extraction to match Test Explorer structure:
692
+ // 1. fullname (contains full project path)
693
+ // 2. filepath (direct file path)
694
+ // 3. file attribute from test case
695
+ // 4. package (fallback)
696
+ if (item.fullname) {
697
+ // Extract actual file path from fullname if it contains path separators
698
+ const fullnameParts = item.fullname.split('.');
699
+ if (fullnameParts.length > 2) {
700
+ // Reconstruct path from project.namespace.class structure
701
+ const projectName = fullnameParts[0];
702
+ const namespaceParts = fullnameParts.slice(1, -1);
703
+ const className = fullnameParts[fullnameParts.length - 1];
704
+ return `${projectName}/${namespaceParts.join('/')}/${className}.cs`;
705
+ }
706
+ }
707
+ if (item.filepath)
708
+ return item.filepath;
709
+ if (testCaseItem.file)
710
+ return testCaseItem.file;
711
+ if (item.package)
712
+ return item.package;
713
+ // Fallback: construct from classname
714
+ if (testCaseItem.classname) {
715
+ const parts = testCaseItem.classname.split('.');
716
+ const className = parts[parts.length - 1];
717
+ const namespacePath = parts.slice(0, -1).join('/');
718
+ return `${namespacePath}/${className}.cs`;
719
+ }
720
+ return '';
721
+ }
722
+ function extractTestExplorerSuiteTitle(testCaseItem, item) {
723
+ // Extract suite title to match Test Explorer structure (Project/Namespace hierarchy)
724
+ // Priority: fullname > classname > name
725
+ if (item.fullname) {
726
+ // Use fullname to maintain Test Explorer structure
727
+ return item.fullname;
728
+ }
729
+ if (testCaseItem.classname) {
730
+ return testCaseItem.classname;
731
+ }
732
+ // Fallback to item name but prefer classname structure
733
+ return item.name || testCaseItem.classname || 'UnknownClass';
734
+ }
519
735
  function processTestSuite(testsuite) {
520
736
  if (!testsuite)
521
737
  return [];
@@ -527,8 +743,14 @@ function processTestSuite(testsuite) {
527
743
  if (!Array.isArray(testsuite)) {
528
744
  suites = [testsuite];
529
745
  }
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();
746
+ // Only process suites that have test cases OR child suites, but avoid double processing
747
+ const subSuites = suites.filter(s => s['test-suite'] && !s['test-case']);
748
+ const leafSuites = suites.filter(s => s['test-case'] || s.testcase);
749
+ // Process child suites recursively
750
+ const childResults = subSuites.map(s => processTestSuite(s['test-suite'])).flat();
751
+ // Process leaf suites with actual test cases
752
+ const leafResults = leafSuites.reduce(reduceTestCases, []);
753
+ return [...childResults, ...leafResults];
532
754
  }
533
755
  function fetchProperties(item) {
534
756
  const tags = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.1.3-beta.1-multi-links",
3
+ "version": "2.1.3-beta.2-xml-import",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -56,19 +56,20 @@ function CodeceptReporter(config) {
56
56
 
57
57
  output.debug = function(msg) {
58
58
  originalOutput.debug(msg);
59
- dataStorage.putData('log', repeat(this.stepShift) + pc.cyan(msg.toString()));
59
+ dataStorage.putData('log', repeat(this?.stepShift || 0) + pc.cyan(msg.toString()));
60
60
  };
61
61
 
62
62
  output.say = function(message, color = 'cyan') {
63
63
  originalOutput.say(message, color);
64
- const sayMsg = repeat(this.stepShift) + ` ${pc.bold(pc[color](message))}`;
64
+ const sayMsg = repeat(this?.stepShift || 0) + ` ${pc.bold(pc[color](message))}`;
65
65
  dataStorage.putData('log', sayMsg);
66
66
  };
67
67
 
68
68
  output.log = function(msg) {
69
69
  originalOutput.log(msg);
70
- dataStorage.putData('log', repeat(this.stepShift) + pc.gray(msg));
70
+ dataStorage.putData('log', repeat(this?.stepShift || 0) + pc.gray(msg));
71
71
  };
72
+ output.stepShift = 0;
72
73
 
73
74
  recorder.startUnlessRunning();
74
75
 
@@ -162,7 +163,7 @@ function CodeceptReporter(config) {
162
163
  const manuallyAttachedArtifacts = services.artifacts.get(test.fullTitle());
163
164
  const keyValues = services.keyValues.get(test.fullTitle());
164
165
  const stepHierarchy = buildUnifiedStepHierarchy(test.steps, hookSteps);
165
- const labels = services.labels.get(test.fullTitle());
166
+ const links = services.links.get(test.fullTitle());
166
167
 
167
168
  services.setContext(null);
168
169
 
@@ -177,7 +178,7 @@ function CodeceptReporter(config) {
177
178
  files,
178
179
  steps: stepHierarchy, // Array of step objects per API schema
179
180
  logs,
180
- labels,
181
+ links,
181
182
  manuallyAttachedArtifacts,
182
183
  meta: { ...keyValues, ...test.meta },
183
184
  });