@testomatio/reporter 2.9.0 → 2.9.1-beta.2-allure-tms-link
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/README.md +4 -2
- package/lib/allureReader.d.ts +93 -0
- package/lib/allureReader.js +547 -0
- package/lib/bin/cli.js +28 -0
- package/lib/junit-adapter/index.js +4 -0
- package/lib/junit-adapter/kotlin.d.ts +5 -0
- package/lib/junit-adapter/kotlin.js +46 -0
- package/lib/utils/pipe_utils.d.ts +17 -0
- package/lib/utils/pipe_utils.js +45 -0
- package/lib/utils/utils.js +9 -0
- package/lib/xmlReader.d.ts +0 -1
- package/lib/xmlReader.js +2 -38
- package/package.json +1 -1
- package/src/allureReader.js +635 -0
- package/src/bin/cli.js +35 -0
- package/src/junit-adapter/index.js +4 -0
- package/src/junit-adapter/kotlin.js +48 -0
- package/src/utils/pipe_utils.js +49 -0
- package/src/utils/utils.js +5 -0
- package/src/xmlReader.js +2 -47
package/README.md
CHANGED
|
@@ -76,6 +76,7 @@ npx testomatio-reporter <command> [options]
|
|
|
76
76
|
| [TestCafe](./docs/frameworks.md#testcafe) | [Detox](./docs/frameworks.md#detox) | [Codeception](https://github.com/testomatio/php-reporter) |
|
|
77
77
|
| [Newman (Postman)](./docs/frameworks.md#newman) | [JUnit](./docs/junit.md#junit) | [NUnit](./docs/junit.md#nunit) |
|
|
78
78
|
| [PyTest](./docs/junit.md#pytest) | [PHPUnit](./docs/junit.md#phpunit) | [Protractor](./docs/frameworks.md#protractor) |
|
|
79
|
+
| [Allure](./docs/allure.md) | | |
|
|
79
80
|
|
|
80
81
|
or **any [other via JUnit](./docs/junit.md)** report....
|
|
81
82
|
|
|
@@ -137,9 +138,10 @@ Bring this reporter on CI and never lose test results again!
|
|
|
137
138
|
- [HTML report](./docs/pipes/html.md)
|
|
138
139
|
- [Markdown report](./docs/pipes/markdown.md)
|
|
139
140
|
- [Bitbucket](./docs/pipes/bitbucket.md)
|
|
140
|
-
-
|
|
141
|
-
- 📓 [JUnit](./docs/junit.md)
|
|
141
|
+
- 📓 [JUnit Reports](./docs/junit.md)
|
|
142
142
|
- 🗄️ [Artifacts](./docs/artifacts.md)
|
|
143
|
+
- 🔬 [Allure Reports](./docs/allure.md)
|
|
144
|
+
- 🔗 [Linking Tests](./docs/linking-tests.md)
|
|
143
145
|
- 🔂 [Workflows](./docs/workflows.md)
|
|
144
146
|
- 🖊️ [Logger](./docs/logger.md)
|
|
145
147
|
- 🪲 [Debug File Format](./docs/debug-file-format.md)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export default AllureReader;
|
|
2
|
+
declare class AllureReader {
|
|
3
|
+
constructor(opts?: {});
|
|
4
|
+
requestParams: {
|
|
5
|
+
apiKey: any;
|
|
6
|
+
url: any;
|
|
7
|
+
title: string;
|
|
8
|
+
env: string;
|
|
9
|
+
group_title: string;
|
|
10
|
+
batchMode: "manual";
|
|
11
|
+
};
|
|
12
|
+
runId: any;
|
|
13
|
+
opts: {};
|
|
14
|
+
withPackage: any;
|
|
15
|
+
store: {};
|
|
16
|
+
pipesPromise: Promise<any[]>;
|
|
17
|
+
_tests: any[];
|
|
18
|
+
stats: {};
|
|
19
|
+
suites: {};
|
|
20
|
+
uploader: S3Uploader;
|
|
21
|
+
version: any;
|
|
22
|
+
set tests(value: any[]);
|
|
23
|
+
get tests(): any[];
|
|
24
|
+
createRun(): Promise<any[]>;
|
|
25
|
+
pipes: any;
|
|
26
|
+
parse(resultsPattern: any): {};
|
|
27
|
+
parseContainerFiles(containerFiles: any): void;
|
|
28
|
+
processAllureResult(result: any, resultsDir: any): {
|
|
29
|
+
rid: any;
|
|
30
|
+
title: any;
|
|
31
|
+
status: any;
|
|
32
|
+
suite_title: any;
|
|
33
|
+
file: string;
|
|
34
|
+
run_time: number;
|
|
35
|
+
steps: any;
|
|
36
|
+
message: any;
|
|
37
|
+
stack: any;
|
|
38
|
+
meta: {};
|
|
39
|
+
links: {
|
|
40
|
+
label: string;
|
|
41
|
+
}[];
|
|
42
|
+
artifacts: any[];
|
|
43
|
+
create: boolean;
|
|
44
|
+
overwrite: boolean;
|
|
45
|
+
};
|
|
46
|
+
mapStatus(status: any): any;
|
|
47
|
+
extractSuiteTitle(result: any): any;
|
|
48
|
+
stripNamespace(suiteName: any): any;
|
|
49
|
+
extractFile(result: any): string;
|
|
50
|
+
getFileExtension(result: any): any;
|
|
51
|
+
extractMeta(result: any): {};
|
|
52
|
+
extractLinks(result: any): {
|
|
53
|
+
label: string;
|
|
54
|
+
}[];
|
|
55
|
+
/**
|
|
56
|
+
* Extract a Testomat.io test id from Allure links so reported tests match
|
|
57
|
+
* existing cases instead of creating duplicates.
|
|
58
|
+
*
|
|
59
|
+
* Allure's `@TmsLink("T1a2b3c4d")` produces a link with `type: "tms"`. Some exporters
|
|
60
|
+
* omit the type but still point the link URL at a Testomat.io test page; both are
|
|
61
|
+
* accepted. The link `name` is used as the id (falling back to the last URL segment).
|
|
62
|
+
*
|
|
63
|
+
* @param {object} result - Parsed Allure result JSON
|
|
64
|
+
* @returns {string|null} Normalized test id, or null when no usable link exists
|
|
65
|
+
*/
|
|
66
|
+
extractTestId(result: object): string | null;
|
|
67
|
+
/**
|
|
68
|
+
* Normalize a value into a Testomat.io test id.
|
|
69
|
+
*
|
|
70
|
+
* Testomat.io test ids are exactly **8 word characters**. The value may arrive bare
|
|
71
|
+
* (`1a2b3c4d`), or carrying the `T` / `@T` markers Testomat uses in code and titles
|
|
72
|
+
* (`T1a2b3c4d`, `@T1a2b3c4d`). The markers are removed only when doing so still leaves
|
|
73
|
+
* a valid 8-char id, so a real id that happens to start with `T` is preserved.
|
|
74
|
+
*
|
|
75
|
+
* Anything that does not resolve to a valid 8-char id — a numeric Allure TestOps id
|
|
76
|
+
* like `12345`, a JIRA key, a 6-digit TMS number — is rejected (returns null) so we
|
|
77
|
+
* never send an unmatchable id that would create duplicates.
|
|
78
|
+
*
|
|
79
|
+
* @param {string|number|null|undefined} value
|
|
80
|
+
* @returns {string|null} The bare 8-char id, or null when the value is not a valid id
|
|
81
|
+
*/
|
|
82
|
+
normalizeTestId(value: string | number | null | undefined): string | null;
|
|
83
|
+
convertSteps(steps: any, depth?: number): any;
|
|
84
|
+
calculateRunTime(item: any): number;
|
|
85
|
+
convertParameters(parameters: any): {};
|
|
86
|
+
combineRetryAttempts(attempts: any): any;
|
|
87
|
+
calculateStats(): {};
|
|
88
|
+
fetchSourceCode(): void;
|
|
89
|
+
getLanguage(): any;
|
|
90
|
+
uploadArtifacts(): Promise<void>;
|
|
91
|
+
uploadData(): Promise<any[]>;
|
|
92
|
+
}
|
|
93
|
+
import { S3Uploader } from './uploader.js';
|
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const debug_1 = __importDefault(require("debug"));
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const glob_1 = require("glob");
|
|
11
|
+
const constants_js_1 = require("./constants.js");
|
|
12
|
+
const crypto_1 = require("crypto");
|
|
13
|
+
const url_1 = require("url");
|
|
14
|
+
const config_js_1 = require("./config.js");
|
|
15
|
+
const uploader_js_1 = require("./uploader.js");
|
|
16
|
+
const index_js_1 = require("./pipe/index.js");
|
|
17
|
+
const pipe_utils_js_1 = require("./utils/pipe_utils.js");
|
|
18
|
+
const utils_js_1 = require("./utils/utils.js");
|
|
19
|
+
const index_js_2 = __importDefault(require("./junit-adapter/index.js"));
|
|
20
|
+
// @ts-ignore
|
|
21
|
+
const debug = (0, debug_1.default)('@testomatio/reporter:allure');
|
|
22
|
+
const TESTOMATIO_URL = process.env.TESTOMATIO_URL || 'https://app.testomat.io';
|
|
23
|
+
const { TESTOMATIO_RUNGROUP_TITLE, TESTOMATIO_SUITE, TESTOMATIO_TITLE, TESTOMATIO_ENV, TESTOMATIO_RUN } = process.env;
|
|
24
|
+
class AllureReader {
|
|
25
|
+
constructor(opts = {}) {
|
|
26
|
+
this.requestParams = {
|
|
27
|
+
apiKey: opts.apiKey || config_js_1.config.TESTOMATIO,
|
|
28
|
+
url: opts.url || TESTOMATIO_URL,
|
|
29
|
+
title: TESTOMATIO_TITLE,
|
|
30
|
+
env: TESTOMATIO_ENV,
|
|
31
|
+
group_title: TESTOMATIO_RUNGROUP_TITLE,
|
|
32
|
+
// Buffer tests and flush them manually in size-limited chunks, exactly like XmlReader.
|
|
33
|
+
// No setInterval auto-upload means each test is sent exactly once (no double-send).
|
|
34
|
+
batchMode: constants_js_1.BATCH_MODE.MANUAL,
|
|
35
|
+
};
|
|
36
|
+
this.runId = opts.runId || TESTOMATIO_RUN;
|
|
37
|
+
this.opts = opts || {};
|
|
38
|
+
this.withPackage = opts.withPackage || false;
|
|
39
|
+
this.store = {};
|
|
40
|
+
this.pipesPromise = (0, index_js_1.pipesFactory)(opts, this.store);
|
|
41
|
+
this._tests = [];
|
|
42
|
+
this.stats = {};
|
|
43
|
+
this.suites = {};
|
|
44
|
+
this.uploader = new uploader_js_1.S3Uploader();
|
|
45
|
+
// Allure results already contain steps and stack traces for all tests,
|
|
46
|
+
// so enable passing them for passed tests by default
|
|
47
|
+
if (!process.env.TESTOMATIO_STACK_PASSED) {
|
|
48
|
+
process.env.TESTOMATIO_STACK_PASSED = '1';
|
|
49
|
+
}
|
|
50
|
+
if (!process.env.TESTOMATIO_STEPS_PASSED) {
|
|
51
|
+
process.env.TESTOMATIO_STEPS_PASSED = '1';
|
|
52
|
+
}
|
|
53
|
+
const packageJsonPath = path_1.default.resolve(__dirname, '..', 'package.json');
|
|
54
|
+
this.version = JSON.parse(fs_1.default.readFileSync(packageJsonPath).toString()).version;
|
|
55
|
+
console.log(constants_js_1.APP_PREFIX, `Testomatio Reporter v${this.version}`);
|
|
56
|
+
}
|
|
57
|
+
get tests() {
|
|
58
|
+
return this._tests;
|
|
59
|
+
}
|
|
60
|
+
set tests(value) {
|
|
61
|
+
this._tests = value;
|
|
62
|
+
}
|
|
63
|
+
async createRun() {
|
|
64
|
+
const runParams = {
|
|
65
|
+
api_key: this.requestParams.apiKey,
|
|
66
|
+
title: this.requestParams.title,
|
|
67
|
+
env: this.requestParams.env,
|
|
68
|
+
group_title: this.requestParams.group_title,
|
|
69
|
+
batchMode: this.requestParams.batchMode,
|
|
70
|
+
};
|
|
71
|
+
debug('Run', runParams);
|
|
72
|
+
this.pipes = this.pipes || (await this.pipesPromise);
|
|
73
|
+
const run = await Promise.all(this.pipes.map(p => p.createRun(runParams)));
|
|
74
|
+
this.uploader.checkEnabled();
|
|
75
|
+
return run;
|
|
76
|
+
}
|
|
77
|
+
parse(resultsPattern) {
|
|
78
|
+
this._tests = [];
|
|
79
|
+
let pattern = resultsPattern;
|
|
80
|
+
// Auto-append wildcard if pattern refers to a directory (like XML command does)
|
|
81
|
+
if (!pattern.endsWith('.json') && !pattern.includes('*')) {
|
|
82
|
+
if (pattern.endsWith('/') || (fs_1.default.existsSync(pattern) && fs_1.default.statSync(pattern).isDirectory())) {
|
|
83
|
+
pattern = pattern.replace(/\/+$/, '') + '/*-result.json';
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
pattern += '*-result.json';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const resultsDir = path_1.default.dirname(pattern);
|
|
90
|
+
console.log(constants_js_1.APP_PREFIX, `Scanning for Allure results in: ${resultsDir}`);
|
|
91
|
+
console.log(constants_js_1.APP_PREFIX, `Using pattern: ${pattern}`);
|
|
92
|
+
const resultFiles = glob_1.glob.sync(pattern);
|
|
93
|
+
const containerFiles = glob_1.glob.sync(pattern.replace('*-result.json', '*-container.json'));
|
|
94
|
+
if (resultFiles.length === 0 && containerFiles.length === 0) {
|
|
95
|
+
throw new Error(`No Allure result files found matching pattern: ${pattern}`);
|
|
96
|
+
}
|
|
97
|
+
console.log(constants_js_1.APP_PREFIX, `Found ${resultFiles.length} result files and ${containerFiles.length} container files`);
|
|
98
|
+
this.parseContainerFiles(containerFiles);
|
|
99
|
+
// Store all tests temporarily for deduplication by historyId
|
|
100
|
+
const allTests = [];
|
|
101
|
+
for (const file of resultFiles) {
|
|
102
|
+
const fullPath = file;
|
|
103
|
+
const fileDir = path_1.default.dirname(file);
|
|
104
|
+
try {
|
|
105
|
+
const resultData = JSON.parse(fs_1.default.readFileSync(fullPath, 'utf8'));
|
|
106
|
+
const test = this.processAllureResult(resultData, fileDir);
|
|
107
|
+
if (test) {
|
|
108
|
+
test._historyId = resultData.historyId;
|
|
109
|
+
test._stop = resultData.stop || 0;
|
|
110
|
+
allTests.push(test);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
console.warn(constants_js_1.APP_PREFIX, `Failed to parse ${file}:`, err.message);
|
|
115
|
+
debug('Parse error:', err);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const attemptsMap = new Map();
|
|
119
|
+
for (const test of allTests) {
|
|
120
|
+
const historyId = test._historyId || test.rid;
|
|
121
|
+
if (!attemptsMap.has(historyId)) {
|
|
122
|
+
attemptsMap.set(historyId, []);
|
|
123
|
+
}
|
|
124
|
+
attemptsMap.get(historyId).push(test);
|
|
125
|
+
}
|
|
126
|
+
const uniqueTestsMap = new Map();
|
|
127
|
+
for (const [historyId, attempts] of attemptsMap) {
|
|
128
|
+
uniqueTestsMap.set(historyId, this.combineRetryAttempts(attempts));
|
|
129
|
+
}
|
|
130
|
+
// Convert map to array and clean up internal fields
|
|
131
|
+
this._tests = Array.from(uniqueTestsMap.values()).map(t => {
|
|
132
|
+
delete t._historyId;
|
|
133
|
+
delete t._stop;
|
|
134
|
+
return t;
|
|
135
|
+
});
|
|
136
|
+
console.log(constants_js_1.APP_PREFIX, `Processed ${this._tests.length} unique tests (from ${allTests.length} result files)`);
|
|
137
|
+
return this.calculateStats();
|
|
138
|
+
}
|
|
139
|
+
parseContainerFiles(containerFiles) {
|
|
140
|
+
for (const file of containerFiles) {
|
|
141
|
+
try {
|
|
142
|
+
const data = JSON.parse(fs_1.default.readFileSync(file, 'utf8'));
|
|
143
|
+
if (data.name && data.children) {
|
|
144
|
+
data.children.forEach(uuid => {
|
|
145
|
+
this.suites[uuid] = data.name;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
debug('Failed to parse container file:', file, err.message);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
debug('Parsed suites:', this.suites);
|
|
154
|
+
}
|
|
155
|
+
processAllureResult(result, resultsDir) {
|
|
156
|
+
const test = {
|
|
157
|
+
rid: result.uuid || (0, crypto_1.randomUUID)(),
|
|
158
|
+
title: result.name || 'Unknown test',
|
|
159
|
+
status: this.mapStatus(result.status),
|
|
160
|
+
suite_title: this.extractSuiteTitle(result),
|
|
161
|
+
file: this.extractFile(result),
|
|
162
|
+
run_time: this.calculateRunTime(result),
|
|
163
|
+
steps: this.convertSteps(result.steps || []),
|
|
164
|
+
message: result.statusDetails?.message || '',
|
|
165
|
+
stack: result.statusDetails?.trace || '',
|
|
166
|
+
meta: this.extractMeta(result),
|
|
167
|
+
links: this.extractLinks(result),
|
|
168
|
+
artifacts: [],
|
|
169
|
+
create: true,
|
|
170
|
+
overwrite: true,
|
|
171
|
+
};
|
|
172
|
+
// Use the @TmsLink / Testomat.io link as the test id so reported tests MATCH
|
|
173
|
+
// existing cases instead of creating duplicates on every run.
|
|
174
|
+
const testId = this.extractTestId(result);
|
|
175
|
+
if (testId) {
|
|
176
|
+
test.test_id = testId;
|
|
177
|
+
}
|
|
178
|
+
// Add description if present
|
|
179
|
+
if (result.description) {
|
|
180
|
+
test.description = result.description;
|
|
181
|
+
}
|
|
182
|
+
if (result.parameters && result.parameters.length > 0) {
|
|
183
|
+
test.example = this.convertParameters(result.parameters);
|
|
184
|
+
}
|
|
185
|
+
if (result.attachments && result.attachments.length > 0) {
|
|
186
|
+
const attachments = result.attachments
|
|
187
|
+
.map(att => {
|
|
188
|
+
const fullPath = path_1.default.join(resultsDir, att.source);
|
|
189
|
+
if (fs_1.default.existsSync(fullPath)) {
|
|
190
|
+
return fullPath;
|
|
191
|
+
}
|
|
192
|
+
debug('Attachment file not found:', fullPath);
|
|
193
|
+
return null;
|
|
194
|
+
})
|
|
195
|
+
.filter(Boolean);
|
|
196
|
+
if (attachments.length > 0) {
|
|
197
|
+
test.files = attachments;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return test;
|
|
201
|
+
}
|
|
202
|
+
mapStatus(status) {
|
|
203
|
+
const statusMap = {
|
|
204
|
+
passed: 'passed',
|
|
205
|
+
failed: 'failed',
|
|
206
|
+
broken: 'failed',
|
|
207
|
+
skipped: 'skipped',
|
|
208
|
+
pending: 'skipped',
|
|
209
|
+
};
|
|
210
|
+
return statusMap[status] || 'failed';
|
|
211
|
+
}
|
|
212
|
+
extractSuiteTitle(result) {
|
|
213
|
+
const labels = result.labels || [];
|
|
214
|
+
// Only use suite label for suite_title
|
|
215
|
+
// Epic and Feature are sent as separate labels in meta
|
|
216
|
+
const suiteLabel = labels.find(l => l.name === 'suite')?.value;
|
|
217
|
+
if (suiteLabel) {
|
|
218
|
+
return this.stripNamespace(suiteLabel);
|
|
219
|
+
}
|
|
220
|
+
// Fallback to parentSuite or subSuite if no suite label
|
|
221
|
+
const parentSuite = labels.find(l => l.name === 'parentSuite')?.value;
|
|
222
|
+
const subSuite = labels.find(l => l.name === 'subSuite')?.value;
|
|
223
|
+
if (parentSuite && subSuite) {
|
|
224
|
+
return `${parentSuite} / ${subSuite}`;
|
|
225
|
+
}
|
|
226
|
+
if (parentSuite)
|
|
227
|
+
return parentSuite;
|
|
228
|
+
if (subSuite)
|
|
229
|
+
return subSuite;
|
|
230
|
+
return 'Default Suite';
|
|
231
|
+
}
|
|
232
|
+
stripNamespace(suiteName) {
|
|
233
|
+
if (suiteName && suiteName.includes('.')) {
|
|
234
|
+
return suiteName.split('.').pop();
|
|
235
|
+
}
|
|
236
|
+
return suiteName;
|
|
237
|
+
}
|
|
238
|
+
extractFile(result) {
|
|
239
|
+
const labels = result.labels || [];
|
|
240
|
+
const packageLabel = labels.find(l => l.name === 'package')?.value;
|
|
241
|
+
const testClassLabel = labels.find(l => l.name === 'testClass')?.value;
|
|
242
|
+
if (!packageLabel) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
const ext = this.getFileExtension(result);
|
|
246
|
+
let className;
|
|
247
|
+
if (testClassLabel) {
|
|
248
|
+
className = testClassLabel.split('.').pop();
|
|
249
|
+
}
|
|
250
|
+
else if (result.fullName) {
|
|
251
|
+
const fullNameParts = result.fullName.split('.');
|
|
252
|
+
className = fullNameParts[fullNameParts.length - 2] || fullNameParts[fullNameParts.length - 1];
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
if (this.withPackage) {
|
|
258
|
+
const parts = packageLabel.split('.');
|
|
259
|
+
return `${parts.join('/')}/${className}.${ext}`;
|
|
260
|
+
}
|
|
261
|
+
return `${className}.${ext}`;
|
|
262
|
+
}
|
|
263
|
+
getFileExtension(result) {
|
|
264
|
+
const labels = result.labels || [];
|
|
265
|
+
const languageLabel = labels.find(l => l.name === 'language')?.value;
|
|
266
|
+
const extMap = {
|
|
267
|
+
java: 'java',
|
|
268
|
+
kotlin: 'kt',
|
|
269
|
+
javascript: 'js',
|
|
270
|
+
typescript: 'ts',
|
|
271
|
+
python: 'py',
|
|
272
|
+
ruby: 'rb',
|
|
273
|
+
'c#': 'cs',
|
|
274
|
+
php: 'php',
|
|
275
|
+
};
|
|
276
|
+
return extMap[languageLabel?.toLowerCase()] || 'java';
|
|
277
|
+
}
|
|
278
|
+
extractMeta(result) {
|
|
279
|
+
const labels = result.labels || [];
|
|
280
|
+
const excludedLabels = [
|
|
281
|
+
'suite', 'package', 'parentSuite', 'subSuite',
|
|
282
|
+
'testClass', 'testMethod', 'epic', 'feature',
|
|
283
|
+
];
|
|
284
|
+
const meta = {};
|
|
285
|
+
labels.forEach(label => {
|
|
286
|
+
if (!excludedLabels.includes(label.name)) {
|
|
287
|
+
meta[label.name] = label.value;
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
return meta;
|
|
291
|
+
}
|
|
292
|
+
extractLinks(result) {
|
|
293
|
+
const labels = result.labels || [];
|
|
294
|
+
const links = [];
|
|
295
|
+
const epicLabel = labels.find(l => l.name === 'epic');
|
|
296
|
+
const featureLabel = labels.find(l => l.name === 'feature');
|
|
297
|
+
if (epicLabel?.value) {
|
|
298
|
+
links.push({ label: `epic:${epicLabel.value}` });
|
|
299
|
+
}
|
|
300
|
+
if (featureLabel?.value) {
|
|
301
|
+
links.push({ label: `feature:${featureLabel.value}` });
|
|
302
|
+
}
|
|
303
|
+
return links.length > 0 ? links : undefined;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Extract a Testomat.io test id from Allure links so reported tests match
|
|
307
|
+
* existing cases instead of creating duplicates.
|
|
308
|
+
*
|
|
309
|
+
* Allure's `@TmsLink("T1a2b3c4d")` produces a link with `type: "tms"`. Some exporters
|
|
310
|
+
* omit the type but still point the link URL at a Testomat.io test page; both are
|
|
311
|
+
* accepted. The link `name` is used as the id (falling back to the last URL segment).
|
|
312
|
+
*
|
|
313
|
+
* @param {object} result - Parsed Allure result JSON
|
|
314
|
+
* @returns {string|null} Normalized test id, or null when no usable link exists
|
|
315
|
+
*/
|
|
316
|
+
extractTestId(result) {
|
|
317
|
+
const links = result.links || [];
|
|
318
|
+
if (!links.length)
|
|
319
|
+
return null;
|
|
320
|
+
const isTmsLink = l => typeof l?.type === 'string' && l.type.toLowerCase() === 'tms';
|
|
321
|
+
const isTestomatioLink = l => typeof l?.url === 'string' && /testomat\.io\/[^\s]*\/test\//i.test(l.url);
|
|
322
|
+
const link = links.find(isTmsLink) || links.find(isTestomatioLink);
|
|
323
|
+
if (!link)
|
|
324
|
+
return null;
|
|
325
|
+
// Prefer the explicit link name; fall back to the id segment of a Testomat.io URL.
|
|
326
|
+
let id = this.normalizeTestId(link.name);
|
|
327
|
+
if (!id && typeof link.url === 'string') {
|
|
328
|
+
const fromUrl = link.url.match(/\/test\/([\w\d]{8})(?=$|[/?#])/i);
|
|
329
|
+
if (fromUrl)
|
|
330
|
+
id = fromUrl[1];
|
|
331
|
+
}
|
|
332
|
+
return id;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Normalize a value into a Testomat.io test id.
|
|
336
|
+
*
|
|
337
|
+
* Testomat.io test ids are exactly **8 word characters**. The value may arrive bare
|
|
338
|
+
* (`1a2b3c4d`), or carrying the `T` / `@T` markers Testomat uses in code and titles
|
|
339
|
+
* (`T1a2b3c4d`, `@T1a2b3c4d`). The markers are removed only when doing so still leaves
|
|
340
|
+
* a valid 8-char id, so a real id that happens to start with `T` is preserved.
|
|
341
|
+
*
|
|
342
|
+
* Anything that does not resolve to a valid 8-char id — a numeric Allure TestOps id
|
|
343
|
+
* like `12345`, a JIRA key, a 6-digit TMS number — is rejected (returns null) so we
|
|
344
|
+
* never send an unmatchable id that would create duplicates.
|
|
345
|
+
*
|
|
346
|
+
* @param {string|number|null|undefined} value
|
|
347
|
+
* @returns {string|null} The bare 8-char id, or null when the value is not a valid id
|
|
348
|
+
*/
|
|
349
|
+
normalizeTestId(value) {
|
|
350
|
+
if (value === null || value === undefined)
|
|
351
|
+
return null;
|
|
352
|
+
const match = value.toString().trim().match(/^@?T?([\w\d]{8})$/);
|
|
353
|
+
return match ? match[1] : null;
|
|
354
|
+
}
|
|
355
|
+
convertSteps(steps, depth = 0) {
|
|
356
|
+
if (depth >= 10)
|
|
357
|
+
return null;
|
|
358
|
+
return steps
|
|
359
|
+
.map(step => {
|
|
360
|
+
const convertedStep = {
|
|
361
|
+
category: 'user',
|
|
362
|
+
title: step.name || step.title || 'Unknown step',
|
|
363
|
+
duration: this.calculateRunTime(step),
|
|
364
|
+
steps: this.convertSteps(step.steps || [], depth + 1),
|
|
365
|
+
};
|
|
366
|
+
if (convertedStep.steps && convertedStep.steps.length === 0) {
|
|
367
|
+
delete convertedStep.steps;
|
|
368
|
+
}
|
|
369
|
+
if (convertedStep.duration === 0) {
|
|
370
|
+
delete convertedStep.duration;
|
|
371
|
+
}
|
|
372
|
+
return convertedStep;
|
|
373
|
+
})
|
|
374
|
+
.filter(Boolean);
|
|
375
|
+
}
|
|
376
|
+
calculateRunTime(item) {
|
|
377
|
+
if (item.start && item.stop) {
|
|
378
|
+
const durationMs = item.stop - item.start;
|
|
379
|
+
return durationMs / 1000;
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
convertParameters(parameters) {
|
|
384
|
+
const example = {};
|
|
385
|
+
parameters.forEach(param => {
|
|
386
|
+
if (param.name) {
|
|
387
|
+
example[param.name] = param.value;
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
return example;
|
|
391
|
+
}
|
|
392
|
+
combineRetryAttempts(attempts) {
|
|
393
|
+
attempts.sort((a, b) => (a._stop || 0) - (b._stop || 0));
|
|
394
|
+
const finalTest = attempts[attempts.length - 1];
|
|
395
|
+
const retryCount = attempts.length - 1;
|
|
396
|
+
if (retryCount > 0) {
|
|
397
|
+
finalTest.retries = retryCount;
|
|
398
|
+
}
|
|
399
|
+
const failedAttempts = attempts.filter(t => t.status === 'failed');
|
|
400
|
+
if (failedAttempts.length === 0) {
|
|
401
|
+
return finalTest;
|
|
402
|
+
}
|
|
403
|
+
const failureMessages = [];
|
|
404
|
+
const failureStacks = [];
|
|
405
|
+
for (const failed of failedAttempts) {
|
|
406
|
+
const attemptNum = attempts.indexOf(failed) + 1;
|
|
407
|
+
if (failed.message) {
|
|
408
|
+
failureMessages.push(`[Attempt ${attemptNum}] ${failed.message}`);
|
|
409
|
+
}
|
|
410
|
+
if (failed.stack) {
|
|
411
|
+
failureStacks.push(`\n--- Attempt ${attemptNum} ---\n${failed.stack}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (failureMessages.length > 0) {
|
|
415
|
+
finalTest.message = failureMessages.join('\n');
|
|
416
|
+
}
|
|
417
|
+
if (failureStacks.length > 0) {
|
|
418
|
+
finalTest.stack = failureStacks.join('\n');
|
|
419
|
+
}
|
|
420
|
+
if (finalTest.status === 'passed') {
|
|
421
|
+
finalTest.status = 'failed';
|
|
422
|
+
const retryMsg = `Test passed after ${failedAttempts.length} retries. Previous failures:\n`;
|
|
423
|
+
finalTest.message = retryMsg + finalTest.message;
|
|
424
|
+
}
|
|
425
|
+
return finalTest;
|
|
426
|
+
}
|
|
427
|
+
calculateStats() {
|
|
428
|
+
this.stats = {
|
|
429
|
+
create_tests: true,
|
|
430
|
+
tests_count: this._tests.length,
|
|
431
|
+
passed_count: 0,
|
|
432
|
+
failed_count: 0,
|
|
433
|
+
skipped_count: 0,
|
|
434
|
+
duration: 0,
|
|
435
|
+
status: 'passed',
|
|
436
|
+
tests: this._tests,
|
|
437
|
+
};
|
|
438
|
+
this._tests.forEach(t => {
|
|
439
|
+
if (t.status === 'passed')
|
|
440
|
+
this.stats.passed_count++;
|
|
441
|
+
if (t.status === 'failed')
|
|
442
|
+
this.stats.failed_count++;
|
|
443
|
+
if (t.status === 'skipped')
|
|
444
|
+
this.stats.skipped_count++;
|
|
445
|
+
});
|
|
446
|
+
this.stats.duration = this._tests.reduce((acc, t) => acc + (t.run_time || 0), 0);
|
|
447
|
+
if (this.stats.failed_count)
|
|
448
|
+
this.stats.status = 'failed';
|
|
449
|
+
debug('Stats:', this.stats);
|
|
450
|
+
return this.stats;
|
|
451
|
+
}
|
|
452
|
+
fetchSourceCode() {
|
|
453
|
+
const adapter = this.adapter || (0, index_js_2.default)(this.getLanguage(), this.opts);
|
|
454
|
+
this._tests.forEach(t => {
|
|
455
|
+
try {
|
|
456
|
+
let filePath = t.file;
|
|
457
|
+
if (adapter && adapter.getFilePath) {
|
|
458
|
+
filePath = adapter.getFilePath(t);
|
|
459
|
+
}
|
|
460
|
+
if (!filePath)
|
|
461
|
+
return;
|
|
462
|
+
if (!fs_1.default.existsSync(filePath)) {
|
|
463
|
+
debug('Source file not found:', filePath);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const contents = fs_1.default.readFileSync(filePath).toString();
|
|
467
|
+
const code = (0, utils_js_1.fetchSourceCode)(contents, { ...t, lang: this.getLanguage() });
|
|
468
|
+
if (code) {
|
|
469
|
+
t.code = code;
|
|
470
|
+
debug('Fetched code for test %s', t.title);
|
|
471
|
+
}
|
|
472
|
+
// Don't override an id already taken from a @TmsLink — the link is the
|
|
473
|
+
// explicit, source-independent match key the client maintains.
|
|
474
|
+
const testId = (0, utils_js_1.fetchIdFromCode)(contents, { lang: this.getLanguage() });
|
|
475
|
+
if (testId && !t.test_id) {
|
|
476
|
+
t.test_id = testId;
|
|
477
|
+
debug('Fetched test id %s for test %s', testId, t.title);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
debug('Failed to fetch source code:', err.message);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
getLanguage() {
|
|
486
|
+
if (this._tests.length === 0)
|
|
487
|
+
return null;
|
|
488
|
+
return this._tests[0].meta?.language || this.opts.lang;
|
|
489
|
+
}
|
|
490
|
+
async uploadArtifacts() {
|
|
491
|
+
for (const test of this._tests.filter(t => t.files && t.files.length > 0)) {
|
|
492
|
+
const runId = this.runId || this.store.runId || Date.now().toString();
|
|
493
|
+
const artifacts = await Promise.all(test.files.map(f => this.uploader.uploadFileByPath(f, [runId, test.rid, path_1.default.basename(f)])));
|
|
494
|
+
test.artifacts = artifacts.filter(a => a && a.link).map(a => a.link);
|
|
495
|
+
delete test.files;
|
|
496
|
+
if (test.artifacts.length > 0) {
|
|
497
|
+
console.log(constants_js_1.APP_PREFIX, `🗄️ Uploaded ${picocolors_1.default.bold(`${test.artifacts.length} artifacts`)} for test ${test.title}`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
async uploadData() {
|
|
502
|
+
await this.uploadArtifacts();
|
|
503
|
+
this.calculateStats();
|
|
504
|
+
this.fetchSourceCode();
|
|
505
|
+
this.pipes = this.pipes || (await this.pipesPromise);
|
|
506
|
+
const finishData = {
|
|
507
|
+
api_key: this.requestParams.apiKey,
|
|
508
|
+
status: 'finished',
|
|
509
|
+
duration: this.stats.duration,
|
|
510
|
+
};
|
|
511
|
+
if (!this._tests || !Array.isArray(this._tests) || this._tests.length === 0) {
|
|
512
|
+
debug('No tests to upload, finishing run');
|
|
513
|
+
return Promise.all(this.pipes.map(p => p.finishRun(finishData)));
|
|
514
|
+
}
|
|
515
|
+
// Upload tests in size-limited chunks (max 1MB each), exactly like XmlReader.
|
|
516
|
+
// The testomatio pipe runs in MANUAL batch mode, so addTest only buffers tests and
|
|
517
|
+
// sync() flushes one batch request per chunk. There is no setInterval auto-upload,
|
|
518
|
+
// so each test is sent exactly once (no double-send) and requests stay under the limit.
|
|
519
|
+
const testChunks = (0, pipe_utils_js_1.splitTestsIntoChunks)(this._tests);
|
|
520
|
+
const totalChunks = testChunks.length;
|
|
521
|
+
const totalTests = this._tests.length;
|
|
522
|
+
debug(`Split ${totalTests} tests into ${totalChunks} chunks (max 1MB per chunk)`);
|
|
523
|
+
let uploadedTests = 0;
|
|
524
|
+
for (let i = 0; i < testChunks.length; i++) {
|
|
525
|
+
const chunk = testChunks[i];
|
|
526
|
+
if (totalChunks > 1) {
|
|
527
|
+
debug(`Uploading chunk ${i + 1}/${totalChunks} (${chunk.length} tests)`);
|
|
528
|
+
}
|
|
529
|
+
// Buffer each test in the chunk, then flush the whole chunk as a single batch
|
|
530
|
+
for (const test of chunk) {
|
|
531
|
+
await Promise.all(this.pipes.map(p => p.addTest(test)));
|
|
532
|
+
}
|
|
533
|
+
await Promise.all(this.pipes.map(p => p.sync()));
|
|
534
|
+
uploadedTests += chunk.length;
|
|
535
|
+
debug(`Uploaded ${uploadedTests}/${totalTests} tests`);
|
|
536
|
+
}
|
|
537
|
+
if (totalChunks > 1) {
|
|
538
|
+
console.log(constants_js_1.APP_PREFIX, `✅ Successfully uploaded ${uploadedTests} tests in ${totalChunks} chunks`);
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
console.log(constants_js_1.APP_PREFIX, `✅ Successfully uploaded ${uploadedTests} tests`);
|
|
542
|
+
}
|
|
543
|
+
debug('Uploaded %d tests, finishing run', this._tests.length);
|
|
544
|
+
return Promise.all(this.pipes.map(p => p.finishRun(finishData)));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
module.exports = AllureReader;
|