@testomatio/reporter 2.3.0-beta.1-links → 2.3.0-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/adapter/codecept.js +0 -2
- package/lib/adapter/cucumber/current.js +0 -2
- package/lib/adapter/jest.js +0 -2
- package/lib/adapter/playwright.js +0 -2
- package/lib/adapter/webdriver.js +11 -8
- package/lib/client.js +32 -1
- package/lib/data-storage.js +5 -5
- package/lib/reporter-functions.d.ts +3 -10
- package/lib/reporter-functions.js +4 -13
- package/lib/reporter.d.ts +14 -14
- package/lib/services/artifacts.d.ts +1 -1
- package/lib/services/key-values.d.ts +1 -1
- package/lib/services/labels.d.ts +22 -0
- package/lib/services/labels.js +62 -0
- package/lib/services/links.d.ts +1 -1
- package/lib/services/logger.d.ts +1 -1
- package/lib/xmlReader.d.ts +7 -0
- package/lib/xmlReader.js +250 -11
- package/package.json +1 -1
- package/src/adapter/codecept.js +0 -3
- package/src/adapter/cucumber/current.js +0 -2
- package/src/adapter/jest.js +0 -2
- package/src/adapter/playwright.js +0 -2
- package/src/adapter/webdriver.js +11 -8
- package/src/client.js +31 -1
- package/src/data-storage.js +6 -5
- package/src/reporter-functions.js +8 -16
- package/src/services/labels.js +58 -0
- package/src/xmlReader.js +287 -11
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
|
-
|
|
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:
|
|
144
|
+
tests: deduplicatedTests,
|
|
143
145
|
};
|
|
144
146
|
}
|
|
145
147
|
processTRX(jsonSuite) {
|
|
@@ -275,6 +277,171 @@ 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 && Array.isArray(test.example) && test.example.length > 0) {
|
|
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 if it has parameters
|
|
292
|
+
if (existingTest.example && Array.isArray(existingTest.example) && existingTest.example.length > 0) {
|
|
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
|
+
// Clear the main test's example since it's now in examples array
|
|
301
|
+
delete existingTest.example;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// Add this test's execution as an example
|
|
305
|
+
existingTest.examples.push({
|
|
306
|
+
parameters: test.example,
|
|
307
|
+
status: test.status,
|
|
308
|
+
run_time: test.run_time,
|
|
309
|
+
message: test.message,
|
|
310
|
+
stack: test.stack
|
|
311
|
+
});
|
|
312
|
+
// Update the main test status to reflect the worst status
|
|
313
|
+
if (test.status === 'failed' || existingTest.status === 'failed') {
|
|
314
|
+
existingTest.status = 'failed';
|
|
315
|
+
}
|
|
316
|
+
else if (test.status === 'skipped' && existingTest.status !== 'failed') {
|
|
317
|
+
existingTest.status = 'skipped';
|
|
318
|
+
}
|
|
319
|
+
// Update total run time
|
|
320
|
+
existingTest.run_time = (existingTest.run_time || 0) + (test.run_time || 0);
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
// Merge test properties for non-parameterized tests, prioritizing Test Explorer structure
|
|
324
|
+
if (test.test_id && !existingTest.test_id) {
|
|
325
|
+
existingTest.test_id = test.test_id;
|
|
326
|
+
}
|
|
327
|
+
// Keep the most complete test data
|
|
328
|
+
if (test.stack && !existingTest.stack) {
|
|
329
|
+
existingTest.stack = test.stack;
|
|
330
|
+
}
|
|
331
|
+
if (test.message && !existingTest.message) {
|
|
332
|
+
existingTest.message = test.message;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// Prefer Test Explorer structure (longer, more complete suite_title)
|
|
336
|
+
if (test.suite_title && test.suite_title.length > existingTest.suite_title.length) {
|
|
337
|
+
existingTest.suite_title = test.suite_title;
|
|
338
|
+
}
|
|
339
|
+
// Always use the source file path if available
|
|
340
|
+
if (test.file && test.file.endsWith('.cs')) {
|
|
341
|
+
existingTest.file = test.file;
|
|
342
|
+
}
|
|
343
|
+
else if (!existingTest.file || !existingTest.file.endsWith('.cs')) {
|
|
344
|
+
existingTest.file = this.extractCsFileFromPath(test);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
// Fix file path to use proper .cs file names from source paths
|
|
349
|
+
if (!test.file || !test.file.endsWith('.cs')) {
|
|
350
|
+
test.file = this.extractCsFileFromPath(test);
|
|
351
|
+
}
|
|
352
|
+
fqnMap.set(fqn, test);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
return Array.from(fqnMap.values());
|
|
356
|
+
}
|
|
357
|
+
generateFQN(test) {
|
|
358
|
+
// Generate Fully Qualified Name: Namespace + Class + Method (standard .NET FQN)
|
|
359
|
+
// Don't include assembly as it can vary between different test structures
|
|
360
|
+
const namespace = this.extractNamespace(test);
|
|
361
|
+
const className = this.extractClassName(test);
|
|
362
|
+
const methodName = test.title;
|
|
363
|
+
// Use the most complete namespace.class structure available
|
|
364
|
+
if (test.suite_title && test.suite_title.includes('.')) {
|
|
365
|
+
return `${test.suite_title}.${methodName}`;
|
|
366
|
+
}
|
|
367
|
+
return `${namespace}.${className}.${methodName}`;
|
|
368
|
+
}
|
|
369
|
+
generateNormalizedFQN(test) {
|
|
370
|
+
// Generate normalized FQN for deduplication by extracting the core namespace.class.method
|
|
371
|
+
// For parameterized tests, we want the SAME FQN so they merge into one test with multiple Examples
|
|
372
|
+
const fullClassName = test.suite_title || '';
|
|
373
|
+
const methodName = test.title;
|
|
374
|
+
// Extract the most specific namespace.class pattern
|
|
375
|
+
if (fullClassName.includes('.')) {
|
|
376
|
+
const parts = fullClassName.split('.');
|
|
377
|
+
if (parts.length >= 2) {
|
|
378
|
+
const className = parts[parts.length - 1];
|
|
379
|
+
// Look for common .NET namespace patterns and normalize them:
|
|
380
|
+
// TestProject.Tests.MyClass -> Tests.MyClass
|
|
381
|
+
// Tests.MyClass -> Tests.MyClass
|
|
382
|
+
// MyProject.SubNamespace.Tests.MyClass -> Tests.MyClass
|
|
383
|
+
let normalizedNamespace = '';
|
|
384
|
+
for (let i = parts.length - 2; i >= 0; i--) {
|
|
385
|
+
const part = parts[i];
|
|
386
|
+
// Build namespace from right to left, excluding project names
|
|
387
|
+
if (part === 'Tests' || part.endsWith('Tests') || part.includes('Test')) {
|
|
388
|
+
// Found a test namespace, use it as the normalized namespace
|
|
389
|
+
normalizedNamespace = part;
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
else if (i === parts.length - 2) {
|
|
393
|
+
// If no test namespace found, use the immediate parent as namespace
|
|
394
|
+
normalizedNamespace = part;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return `${normalizedNamespace}.${className}.${methodName}`;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// Fallback for simple class names
|
|
401
|
+
return `${fullClassName}.${methodName}`;
|
|
402
|
+
}
|
|
403
|
+
extractAssemblyName(test) {
|
|
404
|
+
// Extract assembly name from file path or use default
|
|
405
|
+
if (test.file) {
|
|
406
|
+
const parts = test.file.split(/[/\\]/);
|
|
407
|
+
return parts[0] || 'DefaultAssembly';
|
|
408
|
+
}
|
|
409
|
+
return 'DefaultAssembly';
|
|
410
|
+
}
|
|
411
|
+
extractNamespace(test) {
|
|
412
|
+
// Extract namespace from suite_title or classname
|
|
413
|
+
if (test.suite_title && test.suite_title.includes('.')) {
|
|
414
|
+
const parts = test.suite_title.split('.');
|
|
415
|
+
return parts.slice(0, -1).join('.');
|
|
416
|
+
}
|
|
417
|
+
return test.suite_title || 'DefaultNamespace';
|
|
418
|
+
}
|
|
419
|
+
extractClassName(test) {
|
|
420
|
+
// Extract class name from suite_title
|
|
421
|
+
if (test.suite_title && test.suite_title.includes('.')) {
|
|
422
|
+
const parts = test.suite_title.split('.');
|
|
423
|
+
return parts[parts.length - 1];
|
|
424
|
+
}
|
|
425
|
+
return test.suite_title || 'DefaultClass';
|
|
426
|
+
}
|
|
427
|
+
extractCsFileFromPath(test) {
|
|
428
|
+
// Extract .cs file name from source file path, not namespace
|
|
429
|
+
if (test.file) {
|
|
430
|
+
// Look for actual .cs file path patterns
|
|
431
|
+
const csFileMatch = test.file.match(/([^/\\]+\.cs)$/);
|
|
432
|
+
if (csFileMatch) {
|
|
433
|
+
return test.file;
|
|
434
|
+
}
|
|
435
|
+
// If no .cs extension, assume it's a namespace path and convert to likely file name
|
|
436
|
+
const className = this.extractClassName(test);
|
|
437
|
+
const pathParts = test.file.split(/[/\\]/);
|
|
438
|
+
pathParts[pathParts.length - 1] = `${className}.cs`;
|
|
439
|
+
return pathParts.join('/');
|
|
440
|
+
}
|
|
441
|
+
// Fallback to class name
|
|
442
|
+
const className = this.extractClassName(test);
|
|
443
|
+
return `${className}.cs`;
|
|
444
|
+
}
|
|
278
445
|
calculateStats() {
|
|
279
446
|
this.stats = {
|
|
280
447
|
...this.stats,
|
|
@@ -430,7 +597,8 @@ function reduceTestCases(prev, item) {
|
|
|
430
597
|
testCases
|
|
431
598
|
.filter(t => !!t)
|
|
432
599
|
.forEach(testCaseItem => {
|
|
433
|
-
|
|
600
|
+
// Use consistent Test Explorer structure: prioritize fullname for file path
|
|
601
|
+
const file = extractSourceFilePath(testCaseItem, item);
|
|
434
602
|
let stack = '';
|
|
435
603
|
let message = '';
|
|
436
604
|
if (testCaseItem.error)
|
|
@@ -450,17 +618,33 @@ function reduceTestCases(prev, item) {
|
|
|
450
618
|
if (!message)
|
|
451
619
|
message = stack.trim().split('\n')[0];
|
|
452
620
|
const isParametrized = item.type === 'ParameterizedMethod';
|
|
453
|
-
const preferClassname = reduceOptions.preferClassname || isParametrized;
|
|
454
621
|
// SpecFlow config
|
|
455
622
|
let { title, tags, testId } = fetchProperties(isParametrized ? item : testCaseItem);
|
|
456
623
|
let example = null;
|
|
457
|
-
|
|
624
|
+
// Use consistent Test Explorer structure for suite title
|
|
625
|
+
const suiteTitle = extractTestExplorerSuiteTitle(testCaseItem, item);
|
|
458
626
|
title ||= testCaseItem.name || testCaseItem.methodname || testCaseItem.classname;
|
|
459
627
|
tags ||= [];
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
628
|
+
// Store original test name for parameter extraction
|
|
629
|
+
const originalTestName = testCaseItem.name || testCaseItem.methodname;
|
|
630
|
+
// Handle NUnit-style arguments from <arguments> element
|
|
631
|
+
if (testCaseItem.arguments && testCaseItem.arguments.arg) {
|
|
632
|
+
const args = Array.isArray(testCaseItem.arguments.arg)
|
|
633
|
+
? testCaseItem.arguments.arg
|
|
634
|
+
: [testCaseItem.arguments.arg];
|
|
635
|
+
example = args; // Store as array instead of object
|
|
636
|
+
// Remove parameters from title for NUnit tests
|
|
637
|
+
title = (testCaseItem.methodname || title).replace(/\(.*?\)/, '').trim();
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
// Fallback to parsing parameters from test name (SpecFlow, etc.)
|
|
641
|
+
const exampleMatches = originalTestName?.match(/\((.*?)\)$/);
|
|
642
|
+
if (exampleMatches) {
|
|
643
|
+
// Extract and store parameters as Examples
|
|
644
|
+
const parameterValues = exampleMatches[1].split(',').map(v => v.trim().replace(/['"]/g, ''));
|
|
645
|
+
example = parameterValues;
|
|
646
|
+
title = title.replace(/\(.*?\)/, '').trim();
|
|
647
|
+
}
|
|
464
648
|
}
|
|
465
649
|
stack = `${testCaseItem['system-out'] || testCaseItem.output || testCaseItem.log || ''}\n\n${stack}\n\n${suiteOutput}\n\n${suiteErr}`.trim();
|
|
466
650
|
if (!testId)
|
|
@@ -508,6 +692,7 @@ function reduceTestCases(prev, item) {
|
|
|
508
692
|
run_time: parseFloat(testCaseItem.time || testCaseItem.duration) * 1000,
|
|
509
693
|
status,
|
|
510
694
|
title,
|
|
695
|
+
originalTestName, // Store original name for parameter-aware FQN generation
|
|
511
696
|
root_suite_id: TESTOMATIO_SUITE,
|
|
512
697
|
suite_title: suiteTitle,
|
|
513
698
|
files,
|
|
@@ -516,6 +701,54 @@ function reduceTestCases(prev, item) {
|
|
|
516
701
|
});
|
|
517
702
|
return prev;
|
|
518
703
|
}
|
|
704
|
+
function extractSourceFilePath(testCaseItem, item) {
|
|
705
|
+
// Priority order for file path extraction to match Test Explorer structure:
|
|
706
|
+
// 1. filepath attribute (direct .cs file path from NUnit)
|
|
707
|
+
// 2. fullname (contains full project path)
|
|
708
|
+
// 3. file attribute from test case
|
|
709
|
+
// 4. package (fallback)
|
|
710
|
+
// NUnit provides filepath attribute with actual .cs file path - use this first
|
|
711
|
+
if (item.filepath) {
|
|
712
|
+
// Clean up Windows/Unix path separators and ensure proper format
|
|
713
|
+
return item.filepath.replace(/\\/g, '/');
|
|
714
|
+
}
|
|
715
|
+
if (testCaseItem.file)
|
|
716
|
+
return testCaseItem.file.replace(/\\/g, '/');
|
|
717
|
+
if (item.fullname) {
|
|
718
|
+
// Extract actual file path from fullname if it contains path separators
|
|
719
|
+
const fullnameParts = item.fullname.split('.');
|
|
720
|
+
if (fullnameParts.length > 2) {
|
|
721
|
+
// Reconstruct path from project.namespace.class structure
|
|
722
|
+
const projectName = fullnameParts[0];
|
|
723
|
+
const namespaceParts = fullnameParts.slice(1, -1);
|
|
724
|
+
const className = fullnameParts[fullnameParts.length - 1];
|
|
725
|
+
return `${projectName}/${namespaceParts.join('/')}/${className}.cs`;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
if (item.package)
|
|
729
|
+
return item.package.replace(/\\/g, '/');
|
|
730
|
+
// Fallback: construct from classname
|
|
731
|
+
if (testCaseItem.classname) {
|
|
732
|
+
const parts = testCaseItem.classname.split('.');
|
|
733
|
+
const className = parts[parts.length - 1];
|
|
734
|
+
const namespacePath = parts.slice(0, -1).join('/');
|
|
735
|
+
return `${namespacePath}/${className}.cs`;
|
|
736
|
+
}
|
|
737
|
+
return '';
|
|
738
|
+
}
|
|
739
|
+
function extractTestExplorerSuiteTitle(testCaseItem, item) {
|
|
740
|
+
// Extract suite title to match Test Explorer structure (Project/Namespace hierarchy)
|
|
741
|
+
// Priority: fullname > classname > name
|
|
742
|
+
if (item.fullname) {
|
|
743
|
+
// Use fullname to maintain Test Explorer structure
|
|
744
|
+
return item.fullname;
|
|
745
|
+
}
|
|
746
|
+
if (testCaseItem.classname) {
|
|
747
|
+
return testCaseItem.classname;
|
|
748
|
+
}
|
|
749
|
+
// Fallback to item name but prefer classname structure
|
|
750
|
+
return item.name || testCaseItem.classname || 'UnknownClass';
|
|
751
|
+
}
|
|
519
752
|
function processTestSuite(testsuite) {
|
|
520
753
|
if (!testsuite)
|
|
521
754
|
return [];
|
|
@@ -527,8 +760,14 @@ function processTestSuite(testsuite) {
|
|
|
527
760
|
if (!Array.isArray(testsuite)) {
|
|
528
761
|
suites = [testsuite];
|
|
529
762
|
}
|
|
530
|
-
|
|
531
|
-
|
|
763
|
+
// Only process suites that have test cases OR child suites, but avoid double processing
|
|
764
|
+
const subSuites = suites.filter(s => s['test-suite'] && !s['test-case']);
|
|
765
|
+
const leafSuites = suites.filter(s => s['test-case'] || s.testcase);
|
|
766
|
+
// Process child suites recursively
|
|
767
|
+
const childResults = subSuites.map(s => processTestSuite(s['test-suite'])).flat();
|
|
768
|
+
// Process leaf suites with actual test cases
|
|
769
|
+
const leafResults = leafSuites.reduce(reduceTestCases, []);
|
|
770
|
+
return [...childResults, ...leafResults];
|
|
532
771
|
}
|
|
533
772
|
function fetchProperties(item) {
|
|
534
773
|
const tags = [];
|
package/package.json
CHANGED
package/src/adapter/codecept.js
CHANGED
|
@@ -113,7 +113,6 @@ class CucumberReporter extends Formatter {
|
|
|
113
113
|
const logs = services.logger.getLogs(testTitle).join('\n');
|
|
114
114
|
const artifacts = services.artifacts.get(testTitle);
|
|
115
115
|
const keyValues = services.keyValues.get(testTitle);
|
|
116
|
-
const links = services.links.get(testTitle);
|
|
117
116
|
|
|
118
117
|
this.client.addTestRun(status, {
|
|
119
118
|
// error: testCaseAttempt.worstTestStepResult.message,
|
|
@@ -124,7 +123,6 @@ class CucumberReporter extends Formatter {
|
|
|
124
123
|
.trim(),
|
|
125
124
|
example: { ...example },
|
|
126
125
|
logs,
|
|
127
|
-
links,
|
|
128
126
|
manuallyAttachedArtifacts: artifacts,
|
|
129
127
|
meta: keyValues,
|
|
130
128
|
title: scenario,
|
package/src/adapter/jest.js
CHANGED
|
@@ -59,7 +59,6 @@ export class JestReporter {
|
|
|
59
59
|
const logs = getTestLogs(result);
|
|
60
60
|
const artifacts = services.artifacts.get(result.fullName);
|
|
61
61
|
const keyValues = services.keyValues.get(result.fullName);
|
|
62
|
-
const links = services.links.get(result.fullName);
|
|
63
62
|
|
|
64
63
|
const deducedStatus = status === 'pending' ? 'skipped' : status;
|
|
65
64
|
// In jest if test is not matched with test name pattern it is considered as skipped.
|
|
@@ -73,7 +72,6 @@ export class JestReporter {
|
|
|
73
72
|
title,
|
|
74
73
|
time: duration,
|
|
75
74
|
logs,
|
|
76
|
-
links,
|
|
77
75
|
manuallyAttachedArtifacts: artifacts,
|
|
78
76
|
meta: keyValues,
|
|
79
77
|
});
|
|
@@ -58,7 +58,6 @@ class PlaywrightReporter {
|
|
|
58
58
|
}
|
|
59
59
|
const manuallyAttachedArtifacts = services.artifacts.get(fullTestTitle);
|
|
60
60
|
const testMeta = services.keyValues.get(fullTestTitle);
|
|
61
|
-
const links = services.links.get(fullTestTitle);
|
|
62
61
|
const rid = test.id || test.testId || uuidv4();
|
|
63
62
|
|
|
64
63
|
/**
|
|
@@ -96,7 +95,6 @@ class PlaywrightReporter {
|
|
|
96
95
|
steps: steps.length ? steps : undefined,
|
|
97
96
|
time: duration,
|
|
98
97
|
logs,
|
|
99
|
-
links,
|
|
100
98
|
manuallyAttachedArtifacts,
|
|
101
99
|
meta: {
|
|
102
100
|
browser: project.browser,
|
package/src/adapter/webdriver.js
CHANGED
|
@@ -52,10 +52,14 @@ class WebdriverReporter extends WDIOReporter {
|
|
|
52
52
|
onTestEnd(test) {
|
|
53
53
|
test.suite = test.parent;
|
|
54
54
|
const logs = getTestLogs(test.fullTitle);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
// TODO: FIX: artifacts for some reason leads to empty report on Testomat.io
|
|
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;
|
|
61
|
+
test.artifacts = artifacts;
|
|
62
|
+
test.meta = keyValues;
|
|
59
63
|
|
|
60
64
|
this._addTestPromises.push(this.addTest(test));
|
|
61
65
|
}
|
|
@@ -70,7 +74,7 @@ class WebdriverReporter extends WDIOReporter {
|
|
|
70
74
|
async addTest(test) {
|
|
71
75
|
if (!this.client) return;
|
|
72
76
|
|
|
73
|
-
const { title, _duration: duration, state, error, output
|
|
77
|
+
const { title, _duration: duration, state, error, output } = test;
|
|
74
78
|
|
|
75
79
|
const testId = getTestomatIdFromTestTitle(title);
|
|
76
80
|
|
|
@@ -81,11 +85,10 @@ class WebdriverReporter extends WDIOReporter {
|
|
|
81
85
|
|
|
82
86
|
await this.client.addTestRun(state, {
|
|
83
87
|
rid: test.uid || '',
|
|
84
|
-
manuallyAttachedArtifacts: artifacts,
|
|
88
|
+
manuallyAttachedArtifacts: test.artifacts,
|
|
85
89
|
error,
|
|
86
|
-
logs,
|
|
87
|
-
meta,
|
|
88
|
-
links,
|
|
90
|
+
logs: test.logs,
|
|
91
|
+
meta: test.meta,
|
|
89
92
|
title,
|
|
90
93
|
test_id: testId,
|
|
91
94
|
time: duration,
|
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');
|
|
@@ -181,7 +182,6 @@ class Client {
|
|
|
181
182
|
suite_id,
|
|
182
183
|
test_id,
|
|
183
184
|
timestamp,
|
|
184
|
-
links,
|
|
185
185
|
manuallyAttachedArtifacts,
|
|
186
186
|
overwrite,
|
|
187
187
|
} = testData;
|
|
@@ -190,6 +190,35 @@ class Client {
|
|
|
190
190
|
// stringify meta values and limit keys and values length to 255
|
|
191
191
|
meta = Object.entries(meta)
|
|
192
192
|
.filter(([, value]) => value !== null && value !== undefined)
|
|
193
|
+
.map(([key, value]) => {
|
|
194
|
+
try {
|
|
195
|
+
if (typeof value === 'object') {
|
|
196
|
+
value = JSON.stringify(value);
|
|
197
|
+
} else if (typeof value !== 'string') {
|
|
198
|
+
try {
|
|
199
|
+
value = value.toString();
|
|
200
|
+
} catch (err) {
|
|
201
|
+
console.warn(APP_PREFIX, `Can't convert meta value to string`, err);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (value?.length > 255) {
|
|
206
|
+
value = value.substring(0, 255);
|
|
207
|
+
debug(APP_PREFIX, `Meta info value "${value}" is too long, trimmed to 255 characters`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (key?.length > 255) {
|
|
211
|
+
const newKey = key.substring(0, 255);
|
|
212
|
+
debug(APP_PREFIX, `Meta info key "${key}" is too long, trimmed to 255 characters`);
|
|
213
|
+
return [newKey, value];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return [key, value];
|
|
217
|
+
} catch (err) {
|
|
218
|
+
debug(APP_PREFIX, `Error while processing meta info key ${key}`, err);
|
|
219
|
+
return [null, null];
|
|
220
|
+
}
|
|
221
|
+
})
|
|
193
222
|
.reduce((acc, [key, value]) => {
|
|
194
223
|
if (key) acc[key] = value;
|
|
195
224
|
return acc;
|
|
@@ -197,6 +226,7 @@ class Client {
|
|
|
197
226
|
|
|
198
227
|
// Get links from storage using the test context
|
|
199
228
|
const testContext = suite_title ? `${suite_title} ${title}` : title;
|
|
229
|
+
const links = linkStorage.get(testContext) || [];
|
|
200
230
|
|
|
201
231
|
let errorFormatted = '';
|
|
202
232
|
if (error) {
|
package/src/data-storage.js
CHANGED
|
@@ -116,7 +116,7 @@ class DataStorage {
|
|
|
116
116
|
try {
|
|
117
117
|
if (global?.testomatioDataStore[dataType]) {
|
|
118
118
|
const testData = global.testomatioDataStore[dataType][context];
|
|
119
|
-
if (testData) debug(
|
|
119
|
+
if (testData) debug(`"${dataType}" data for constext "${context}":`, testData.join(', '));
|
|
120
120
|
return testData || [];
|
|
121
121
|
}
|
|
122
122
|
// debug(`No ${this.dataType} data for context ${context} in <global> storage`);
|
|
@@ -137,7 +137,7 @@ class DataStorage {
|
|
|
137
137
|
const filepath = join(dataDirPath, `${dataType}_${context}`);
|
|
138
138
|
if (fs.existsSync(filepath)) {
|
|
139
139
|
const testDataAsText = fs.readFileSync(filepath, 'utf-8');
|
|
140
|
-
if (testDataAsText) debug(
|
|
140
|
+
if (testDataAsText) debug(`"${dataType}" data for context "${context}":`, testDataAsText);
|
|
141
141
|
const testDataArr = testDataAsText?.split(os.EOL) || [];
|
|
142
142
|
return testDataArr;
|
|
143
143
|
}
|
|
@@ -156,7 +156,7 @@ class DataStorage {
|
|
|
156
156
|
* @param {*} context
|
|
157
157
|
*/
|
|
158
158
|
#putDataToGlobalVar(dataType, data, context) {
|
|
159
|
-
debug('
|
|
159
|
+
debug('Saving data to global variable for ', context, ':', data);
|
|
160
160
|
if (!global.testomatioDataStore) global.testomatioDataStore = {};
|
|
161
161
|
if (!global.testomatioDataStore?.[dataType]) global.testomatioDataStore[dataType] = {};
|
|
162
162
|
|
|
@@ -177,7 +177,7 @@ class DataStorage {
|
|
|
177
177
|
const filename = `${dataType}_${context}`;
|
|
178
178
|
const filepath = join(dataDirPath, filename);
|
|
179
179
|
if (!fs.existsSync(dataDirPath)) fileSystem.createDir(dataDirPath);
|
|
180
|
-
debug(
|
|
180
|
+
debug(`Saving data to file for context "${context}" to ${filepath}. Data: ${JSON.stringify(data)}`);
|
|
181
181
|
|
|
182
182
|
// append new line if file already exists (in this case its definitely includes some data)
|
|
183
183
|
if (fs.existsSync(filepath)) {
|
|
@@ -192,7 +192,8 @@ function stringToMD5Hash(str) {
|
|
|
192
192
|
const md5 = crypto.createHash('md5');
|
|
193
193
|
md5.update(str);
|
|
194
194
|
const hash = md5.digest('hex');
|
|
195
|
-
|
|
195
|
+
|
|
196
|
+
return hash;
|
|
196
197
|
}
|
|
197
198
|
|
|
198
199
|
export const dataStorage = DataStorage.getInstance();
|
|
@@ -53,21 +53,24 @@ function setKeyValue(keyValue, value = null) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
* Add
|
|
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
62
|
if (Array.isArray(value)) {
|
|
63
|
-
|
|
63
|
+
value.forEach(val => setLabel(key, val));
|
|
64
|
+
return;
|
|
64
65
|
}
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
|
|
67
|
+
const labelObject = value !== null && value !== undefined && value !== ''
|
|
68
|
+
? { label: `${key}:${value}` }
|
|
67
69
|
: { label: key };
|
|
68
70
|
services.links.put([labelObject]);
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
|
|
71
74
|
/**
|
|
72
75
|
* Add link(s) to the test report
|
|
73
76
|
* @param {...string} testIds - test IDs to link
|
|
@@ -78,16 +81,6 @@ function linkTest(...testIds) {
|
|
|
78
81
|
services.links.put(links);
|
|
79
82
|
}
|
|
80
83
|
|
|
81
|
-
/**
|
|
82
|
-
* Add JIRA issue link(s) to the test report
|
|
83
|
-
* @param {...string} jiraIds - JIRA issue IDs to link
|
|
84
|
-
* @returns {void}
|
|
85
|
-
*/
|
|
86
|
-
function linkJira(...jiraIds) {
|
|
87
|
-
const links = jiraIds.map(jiraId => ({ jira: jiraId }));
|
|
88
|
-
services.links.put(links);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
84
|
export default {
|
|
92
85
|
artifact: saveArtifact,
|
|
93
86
|
log: logMessage,
|
|
@@ -95,5 +88,4 @@ export default {
|
|
|
95
88
|
keyValue: setKeyValue,
|
|
96
89
|
label: setLabel,
|
|
97
90
|
linkTest,
|
|
98
|
-
linkJira,
|
|
99
91
|
};
|
package/src/services/labels.js
CHANGED
|
@@ -1 +1,59 @@
|
|
|
1
|
+
import createDebugMessages from 'debug';
|
|
2
|
+
import { dataStorage } from '../data-storage.js';
|
|
1
3
|
|
|
4
|
+
const debug = createDebugMessages('@testomatio/reporter:services-labels');
|
|
5
|
+
class LabelStorage {
|
|
6
|
+
static #instance;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
*
|
|
10
|
+
* @returns {LabelStorage}
|
|
11
|
+
*/
|
|
12
|
+
static getInstance() {
|
|
13
|
+
if (!this.#instance) {
|
|
14
|
+
this.#instance = new LabelStorage();
|
|
15
|
+
}
|
|
16
|
+
return this.#instance;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Stores labels array and passes it to reporter
|
|
21
|
+
* @param {string[]} labels - array of label strings
|
|
22
|
+
* @param {*} context - full test title
|
|
23
|
+
*/
|
|
24
|
+
put(labels, context = null) {
|
|
25
|
+
if (!labels || !Array.isArray(labels)) return;
|
|
26
|
+
dataStorage.putData('links', labels, context);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns labels array for the test
|
|
31
|
+
* @param {*} context testId or test context from test runner
|
|
32
|
+
* @returns {string[]} labels array, e.g. ['smoke', 'severity:high', 'feature:user_account']
|
|
33
|
+
*/
|
|
34
|
+
get(context = null) {
|
|
35
|
+
const labelsList = dataStorage.getData('links', context);
|
|
36
|
+
if (!labelsList || !labelsList?.length) return [];
|
|
37
|
+
|
|
38
|
+
const allLabels = [];
|
|
39
|
+
for (const labels of labelsList) {
|
|
40
|
+
if (Array.isArray(labels)) {
|
|
41
|
+
allLabels.push(...labels);
|
|
42
|
+
} else if (typeof labels === 'string') {
|
|
43
|
+
try {
|
|
44
|
+
const parsedLabels = JSON.parse(labels);
|
|
45
|
+
if (Array.isArray(parsedLabels)) {
|
|
46
|
+
allLabels.push(...parsedLabels);
|
|
47
|
+
}
|
|
48
|
+
} catch (e) {
|
|
49
|
+
debug(`Error parsing labels for test ${context}`, labels);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Remove duplicates
|
|
55
|
+
return [...new Set(allLabels)];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const labelStorage = LabelStorage.getInstance();
|