testomatio-reporter-cli 2.9.5 → 2.10.1
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/package.json +1 -1
- package/src/adapter/codecept.js +24 -3
- package/src/allureReader.js +954 -0
- package/src/bin/cli.js +49 -3
- 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/package.json
CHANGED
package/src/adapter/codecept.js
CHANGED
|
@@ -152,12 +152,33 @@ function CodeceptReporter(config) {
|
|
|
152
152
|
services.setContext(null);
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
-
//
|
|
155
|
+
// Report setup hook failures against the current test while preserving skipped tests in the suite.
|
|
156
156
|
event.dispatcher.on(event.hook.failed, hook => {
|
|
157
157
|
const error = hook?.ctx?.currentTest?.err || hook?.err;
|
|
158
158
|
|
|
159
|
-
//
|
|
160
|
-
|
|
159
|
+
// CodeceptJS does not emit test.after for the test associated with a failed BeforeSuite,
|
|
160
|
+
// so report that test here. The remaining tests are emitted as skipped.
|
|
161
|
+
if (hook.name === 'BeforeSuiteHook') {
|
|
162
|
+
const suite = hook.runnable?.parent || hook.suite;
|
|
163
|
+
if (!suite) return;
|
|
164
|
+
|
|
165
|
+
const test = hook?.ctx?.currentTest || hook?.test || suite.tests?.[0];
|
|
166
|
+
if (!test) return;
|
|
167
|
+
|
|
168
|
+
reportedTestUids.add(test.uid);
|
|
169
|
+
const reportTestPromise = client.addTestRun(STATUS.FAILED, {
|
|
170
|
+
...stripExampleFromTitle(test.title),
|
|
171
|
+
rid: test.uid,
|
|
172
|
+
test_id: getTestomatIdFromTestTitle(test.title),
|
|
173
|
+
suite_title: stripTagsFromTitle(suite.title),
|
|
174
|
+
error,
|
|
175
|
+
time: hook?.runnable?.duration,
|
|
176
|
+
});
|
|
177
|
+
reportTestPromises.push(reportTestPromise);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Preserve the existing Before hook behavior: mark all tests in the suite as failed.
|
|
181
|
+
if (hook.name === 'BeforeHook') {
|
|
161
182
|
const suite = hook.runnable.parent;
|
|
162
183
|
if (!suite) return;
|
|
163
184
|
|
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
import createDebugMessages from 'debug';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import { glob } from 'glob';
|
|
6
|
+
import { APP_PREFIX, STATUS, BATCH_MODE } from './constants.js';
|
|
7
|
+
import { randomUUID } from 'crypto';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { config } from './config.js';
|
|
10
|
+
import { S3Uploader } from './uploader.js';
|
|
11
|
+
import { pipesFactory } from './pipe/index.js';
|
|
12
|
+
import { splitTestsIntoChunks } from './utils/pipe_utils.js';
|
|
13
|
+
import {
|
|
14
|
+
fetchSourceCode,
|
|
15
|
+
fetchIdFromCode,
|
|
16
|
+
} from './utils/utils.js';
|
|
17
|
+
import adapterFactory from './junit-adapter/index.js';
|
|
18
|
+
|
|
19
|
+
// @ts-ignore
|
|
20
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
|
|
22
|
+
const debug = createDebugMessages('@testomatio/reporter:allure');
|
|
23
|
+
|
|
24
|
+
const TESTOMATIO_URL = process.env.TESTOMATIO_URL || 'https://app.testomat.io';
|
|
25
|
+
const { TESTOMATIO_RUNGROUP_TITLE, TESTOMATIO_SUITE, TESTOMATIO_TITLE, TESTOMATIO_ENV, TESTOMATIO_RUN } = process.env;
|
|
26
|
+
|
|
27
|
+
class AllureReader {
|
|
28
|
+
constructor(opts = {}) {
|
|
29
|
+
this.requestParams = {
|
|
30
|
+
apiKey: opts.apiKey || config.TESTOMATIO,
|
|
31
|
+
url: opts.url || TESTOMATIO_URL,
|
|
32
|
+
title: TESTOMATIO_TITLE,
|
|
33
|
+
env: TESTOMATIO_ENV,
|
|
34
|
+
group_title: TESTOMATIO_RUNGROUP_TITLE,
|
|
35
|
+
// Buffer tests and flush them manually in size-limited chunks, exactly like XmlReader.
|
|
36
|
+
// No setInterval auto-upload means each test is sent exactly once (no double-send).
|
|
37
|
+
batchMode: BATCH_MODE.MANUAL,
|
|
38
|
+
};
|
|
39
|
+
this.runId = opts.runId || TESTOMATIO_RUN;
|
|
40
|
+
this.opts = opts || {};
|
|
41
|
+
this.withPackage = opts.withPackage || false;
|
|
42
|
+
this.store = {};
|
|
43
|
+
this.pipesPromise = pipesFactory(opts, this.store);
|
|
44
|
+
this._tests = [];
|
|
45
|
+
this.stats = {};
|
|
46
|
+
this.suites = {};
|
|
47
|
+
this.uploader = new S3Uploader();
|
|
48
|
+
|
|
49
|
+
// Allure results already contain steps and stack traces for all tests,
|
|
50
|
+
// so enable passing them for passed tests by default
|
|
51
|
+
if (!process.env.TESTOMATIO_STACK_PASSED) {
|
|
52
|
+
process.env.TESTOMATIO_STACK_PASSED = '1';
|
|
53
|
+
}
|
|
54
|
+
if (!process.env.TESTOMATIO_STEPS_PASSED) {
|
|
55
|
+
process.env.TESTOMATIO_STEPS_PASSED = '1';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const packageJsonPath = path.resolve(__dirname, '..', 'package.json');
|
|
59
|
+
this.version = JSON.parse(fs.readFileSync(packageJsonPath).toString()).version;
|
|
60
|
+
console.log(APP_PREFIX, `Testomatio Reporter v${this.version}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get tests() {
|
|
64
|
+
return this._tests;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
set tests(value) {
|
|
68
|
+
this._tests = value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async createRun() {
|
|
72
|
+
const runParams = {
|
|
73
|
+
api_key: this.requestParams.apiKey,
|
|
74
|
+
title: this.requestParams.title,
|
|
75
|
+
env: this.requestParams.env,
|
|
76
|
+
group_title: this.requestParams.group_title,
|
|
77
|
+
batchMode: this.requestParams.batchMode,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
debug('Run', runParams);
|
|
81
|
+
this.pipes = this.pipes || (await this.pipesPromise);
|
|
82
|
+
|
|
83
|
+
const run = await Promise.all(this.pipes.map(p => p.createRun(runParams)));
|
|
84
|
+
this.uploader.checkEnabled();
|
|
85
|
+
return run;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
parse(resultsPattern) {
|
|
89
|
+
this._tests = [];
|
|
90
|
+
let pattern = resultsPattern;
|
|
91
|
+
|
|
92
|
+
// Auto-append wildcard if pattern refers to a directory (like XML command does)
|
|
93
|
+
if (!pattern.endsWith('.json') && !pattern.includes('*')) {
|
|
94
|
+
if (pattern.endsWith('/') || (fs.existsSync(pattern) && fs.statSync(pattern).isDirectory())) {
|
|
95
|
+
pattern = pattern.replace(/\/+$/, '') + '/*-result.json';
|
|
96
|
+
} else {
|
|
97
|
+
pattern += '*-result.json';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const resultsDir = path.dirname(pattern);
|
|
102
|
+
|
|
103
|
+
console.log(APP_PREFIX, `Scanning for Allure results in: ${resultsDir}`);
|
|
104
|
+
console.log(APP_PREFIX, `Using pattern: ${pattern}`);
|
|
105
|
+
|
|
106
|
+
const resultFiles = glob.sync(pattern);
|
|
107
|
+
const containerFiles = glob.sync(pattern.replace('*-result.json', '*-container.json'));
|
|
108
|
+
|
|
109
|
+
if (resultFiles.length === 0 && containerFiles.length === 0) {
|
|
110
|
+
throw new Error(`No Allure result files found matching pattern: ${pattern}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log(APP_PREFIX, `Found ${resultFiles.length} result files and ${containerFiles.length} container files`);
|
|
114
|
+
|
|
115
|
+
this.parseContainerFiles(containerFiles);
|
|
116
|
+
|
|
117
|
+
// Store all tests temporarily for deduplication by historyId
|
|
118
|
+
const allTests = [];
|
|
119
|
+
for (const file of resultFiles) {
|
|
120
|
+
const fullPath = file;
|
|
121
|
+
const fileDir = path.dirname(file);
|
|
122
|
+
try {
|
|
123
|
+
const resultData = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
|
124
|
+
const test = this.processAllureResult(resultData, fileDir);
|
|
125
|
+
if (test) {
|
|
126
|
+
test._historyId = resultData.historyId;
|
|
127
|
+
test._stop = resultData.stop || 0;
|
|
128
|
+
allTests.push(test);
|
|
129
|
+
}
|
|
130
|
+
} catch (err) {
|
|
131
|
+
console.warn(APP_PREFIX, `Failed to parse ${file}:`, err.message);
|
|
132
|
+
debug('Parse error:', err);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const attemptsMap = new Map();
|
|
137
|
+
for (const test of allTests) {
|
|
138
|
+
const historyId = test._historyId || test.rid;
|
|
139
|
+
if (!attemptsMap.has(historyId)) {
|
|
140
|
+
attemptsMap.set(historyId, []);
|
|
141
|
+
}
|
|
142
|
+
attemptsMap.get(historyId).push(test);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const uniqueTestsMap = new Map();
|
|
146
|
+
for (const [historyId, attempts] of attemptsMap) {
|
|
147
|
+
uniqueTestsMap.set(historyId, this.combineRetryAttempts(attempts));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Convert map to array and clean up internal fields
|
|
151
|
+
this._tests = Array.from(uniqueTestsMap.values()).map(t => {
|
|
152
|
+
delete t._historyId;
|
|
153
|
+
delete t._stop;
|
|
154
|
+
return t;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
console.log(APP_PREFIX, `Processed ${this._tests.length} unique tests (from ${allTests.length} result files)`);
|
|
158
|
+
|
|
159
|
+
return this.calculateStats();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
parseContainerFiles(containerFiles) {
|
|
163
|
+
for (const file of containerFiles) {
|
|
164
|
+
try {
|
|
165
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
166
|
+
if (data.name && data.children) {
|
|
167
|
+
data.children.forEach(uuid => {
|
|
168
|
+
this.suites[uuid] = data.name;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
} catch (err) {
|
|
172
|
+
debug('Failed to parse container file:', file, err.message);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
debug('Parsed suites:', this.suites);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
processAllureResult(result, resultsDir) {
|
|
179
|
+
const test = {
|
|
180
|
+
rid: result.uuid || randomUUID(),
|
|
181
|
+
title: result.name || 'Unknown test',
|
|
182
|
+
status: this.mapStatus(result.status),
|
|
183
|
+
suite_title: this.extractSuiteTitle(result),
|
|
184
|
+
file: this.extractFile(result),
|
|
185
|
+
run_time: this.calculateRunTime(result),
|
|
186
|
+
steps: this.convertSteps(result.steps || []),
|
|
187
|
+
message: result.statusDetails?.message || '',
|
|
188
|
+
stack: result.statusDetails?.trace || '',
|
|
189
|
+
meta: this.extractMeta(result),
|
|
190
|
+
links: this.extractLinks(result),
|
|
191
|
+
artifacts: [],
|
|
192
|
+
create: true,
|
|
193
|
+
overwrite: true,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// @TmsLink references link the result to existing Testomat.io cases. Add EVERY id as
|
|
197
|
+
// a linked case ({ test: id }) so all of them are updated from this one result, with
|
|
198
|
+
// consistent behaviour whether the test has one or many @TmsLink. The test_id is kept
|
|
199
|
+
// a SEPARATE concern — it is never derived from @TmsLink; it is only set from a native
|
|
200
|
+
// Testomat.io id found in source (see fetchSourceCode / fetchIdFromCode).
|
|
201
|
+
this.addLinkedTestIds(test, this.extractTmsIds(result));
|
|
202
|
+
|
|
203
|
+
// Add description if present
|
|
204
|
+
if (result.description) {
|
|
205
|
+
test.description = result.description;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (result.parameters && result.parameters.length > 0) {
|
|
209
|
+
test.example = this.convertParameters(result.parameters);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (result.attachments && result.attachments.length > 0) {
|
|
213
|
+
const attachments = result.attachments
|
|
214
|
+
.map(att => {
|
|
215
|
+
const fullPath = path.join(resultsDir, att.source);
|
|
216
|
+
if (fs.existsSync(fullPath)) {
|
|
217
|
+
return fullPath;
|
|
218
|
+
}
|
|
219
|
+
debug('Attachment file not found:', fullPath);
|
|
220
|
+
return null;
|
|
221
|
+
})
|
|
222
|
+
.filter(Boolean);
|
|
223
|
+
|
|
224
|
+
if (attachments.length > 0) {
|
|
225
|
+
test.files = attachments;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return test;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
mapStatus(status) {
|
|
233
|
+
const statusMap = {
|
|
234
|
+
passed: 'passed',
|
|
235
|
+
failed: 'failed',
|
|
236
|
+
broken: 'failed',
|
|
237
|
+
skipped: 'skipped',
|
|
238
|
+
pending: 'skipped',
|
|
239
|
+
};
|
|
240
|
+
return statusMap[status] || 'failed';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Map an Allure step status to the Testomat.io Step status enum
|
|
245
|
+
* (`passed | failed | none | custom`, see testomat-api-definition.yml).
|
|
246
|
+
*
|
|
247
|
+
* Allure marks a step `broken` when it threw an unexpected error — that is a
|
|
248
|
+
* failure for reporting purposes, matching how `mapStatus` treats tests.
|
|
249
|
+
* `skipped` and anything unknown/absent become `none` (the neutral value),
|
|
250
|
+
* since the step enum has no `skipped`.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} status - Allure step status
|
|
253
|
+
* @returns {'passed'|'failed'|'none'} Testomat.io step status
|
|
254
|
+
*/
|
|
255
|
+
mapStepStatus(status) {
|
|
256
|
+
const statusMap = {
|
|
257
|
+
passed: 'passed',
|
|
258
|
+
failed: 'failed',
|
|
259
|
+
broken: 'failed',
|
|
260
|
+
skipped: 'none',
|
|
261
|
+
pending: 'none',
|
|
262
|
+
};
|
|
263
|
+
return statusMap[status] || 'none';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
extractSuiteTitle(result) {
|
|
267
|
+
const labels = result.labels || [];
|
|
268
|
+
|
|
269
|
+
// Only use suite label for suite_title
|
|
270
|
+
// Epic and Feature are sent as separate labels in meta
|
|
271
|
+
const suiteLabel = labels.find(l => l.name === 'suite')?.value;
|
|
272
|
+
|
|
273
|
+
if (suiteLabel) {
|
|
274
|
+
return this.stripNamespace(suiteLabel);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Fallback to parentSuite or subSuite if no suite label
|
|
278
|
+
const parentSuite = labels.find(l => l.name === 'parentSuite')?.value;
|
|
279
|
+
const subSuite = labels.find(l => l.name === 'subSuite')?.value;
|
|
280
|
+
|
|
281
|
+
if (parentSuite && subSuite) {
|
|
282
|
+
return `${parentSuite} / ${subSuite}`;
|
|
283
|
+
}
|
|
284
|
+
if (parentSuite) return parentSuite;
|
|
285
|
+
if (subSuite) return subSuite;
|
|
286
|
+
|
|
287
|
+
return 'Default Suite';
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
stripNamespace(suiteName) {
|
|
291
|
+
if (suiteName && suiteName.includes('.')) {
|
|
292
|
+
return suiteName.split('.').pop();
|
|
293
|
+
}
|
|
294
|
+
return suiteName;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
extractFile(result) {
|
|
298
|
+
const labels = result.labels || [];
|
|
299
|
+
const packageLabel = labels.find(l => l.name === 'package')?.value;
|
|
300
|
+
const testClassLabel = labels.find(l => l.name === 'testClass')?.value;
|
|
301
|
+
|
|
302
|
+
if (!packageLabel) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const ext = this.getFileExtension(result);
|
|
307
|
+
let className;
|
|
308
|
+
|
|
309
|
+
if (testClassLabel) {
|
|
310
|
+
className = testClassLabel.split('.').pop();
|
|
311
|
+
} else if (result.fullName) {
|
|
312
|
+
const fullNameParts = result.fullName.split('.');
|
|
313
|
+
className = fullNameParts[fullNameParts.length - 2] || fullNameParts[fullNameParts.length - 1];
|
|
314
|
+
} else {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (this.withPackage) {
|
|
319
|
+
const parts = packageLabel.split('.');
|
|
320
|
+
return `${parts.join('/')}/${className}.${ext}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return `${className}.${ext}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
getFileExtension(result) {
|
|
327
|
+
const labels = result.labels || [];
|
|
328
|
+
const languageLabel = labels.find(l => l.name === 'language')?.value;
|
|
329
|
+
|
|
330
|
+
const extMap = {
|
|
331
|
+
java: 'java',
|
|
332
|
+
kotlin: 'kt',
|
|
333
|
+
javascript: 'js',
|
|
334
|
+
typescript: 'ts',
|
|
335
|
+
python: 'py',
|
|
336
|
+
ruby: 'rb',
|
|
337
|
+
'c#': 'cs',
|
|
338
|
+
php: 'php',
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
return extMap[languageLabel?.toLowerCase()] || 'java';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
extractMeta(result) {
|
|
345
|
+
const labels = result.labels || [];
|
|
346
|
+
const excludedLabels = [
|
|
347
|
+
'suite', 'package', 'parentSuite', 'subSuite',
|
|
348
|
+
'testClass', 'testMethod', 'epic', 'feature',
|
|
349
|
+
];
|
|
350
|
+
|
|
351
|
+
const meta = {};
|
|
352
|
+
labels.forEach(label => {
|
|
353
|
+
if (!excludedLabels.includes(label.name)) {
|
|
354
|
+
meta[label.name] = label.value;
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
return meta;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
extractLinks(result) {
|
|
362
|
+
const labels = result.labels || [];
|
|
363
|
+
const links = [];
|
|
364
|
+
|
|
365
|
+
const epicLabel = labels.find(l => l.name === 'epic');
|
|
366
|
+
const featureLabel = labels.find(l => l.name === 'feature');
|
|
367
|
+
|
|
368
|
+
if (epicLabel?.value) {
|
|
369
|
+
links.push({ label: `epic:${epicLabel.value}` });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (featureLabel?.value) {
|
|
373
|
+
links.push({ label: `feature:${featureLabel.value}` });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return links.length > 0 ? links : undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Extract all Testomat.io test ids from Allure links so reported results match
|
|
381
|
+
* existing cases instead of creating duplicates.
|
|
382
|
+
*
|
|
383
|
+
* A test may carry several `@TmsLink`s (e.g. `@TmsLinks(@TmsLink("…"), @TmsLink("…"))`),
|
|
384
|
+
* each producing a link with `type: "tms"`. Some exporters omit the type but still point
|
|
385
|
+
* the link URL at a Testomat.io test page; both are accepted. The link `name` is used as
|
|
386
|
+
* the id (falling back to the id segment of a Testomat.io URL). Ids are normalized and
|
|
387
|
+
* de-duplicated, preserving order so the first one stays the primary `test_id`.
|
|
388
|
+
*
|
|
389
|
+
* @param {object} result - Parsed Allure result JSON
|
|
390
|
+
* @returns {string[]} Normalized test ids (possibly empty)
|
|
391
|
+
*/
|
|
392
|
+
extractTmsIds(result) {
|
|
393
|
+
const links = result.links || [];
|
|
394
|
+
if (!links.length) return [];
|
|
395
|
+
|
|
396
|
+
const isTmsLink = l => typeof l?.type === 'string' && l.type.toLowerCase() === 'tms';
|
|
397
|
+
const isTestomatioLink = l => typeof l?.url === 'string' && /testomat\.io\/[^\s]*\/test\//i.test(l.url);
|
|
398
|
+
|
|
399
|
+
const ids = [];
|
|
400
|
+
for (const link of links) {
|
|
401
|
+
if (!isTmsLink(link) && !isTestomatioLink(link)) continue;
|
|
402
|
+
|
|
403
|
+
// Prefer the explicit link name; fall back to the id segment of a Testomat.io URL.
|
|
404
|
+
let id = this.normalizeTestId(link.name);
|
|
405
|
+
if (!id && typeof link.url === 'string') {
|
|
406
|
+
const fromUrl = link.url.match(/\/test\/([\w\d]{8})(?=$|[/?#])/i);
|
|
407
|
+
if (fromUrl) id = fromUrl[1];
|
|
408
|
+
}
|
|
409
|
+
if (id && !ids.includes(id)) ids.push(id);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return ids;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The primary Testomat.io test id (the first `@TmsLink`), or null when there is none.
|
|
417
|
+
*
|
|
418
|
+
* @param {object} result - Parsed Allure result JSON
|
|
419
|
+
* @returns {string|null}
|
|
420
|
+
*/
|
|
421
|
+
extractTestId(result) {
|
|
422
|
+
return this.extractTmsIds(result)[0] || null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Attach additional linked test cases to a reported test as `{ test: id }` link entries
|
|
427
|
+
* (the same shape `linkTest()` uses), so a single result updates every case it is linked
|
|
428
|
+
* to — not just the primary `test_id`. Existing links are preserved; duplicates skipped.
|
|
429
|
+
*
|
|
430
|
+
* @param {object} test - converted test
|
|
431
|
+
* @param {string[]} ids - additional test ids to link
|
|
432
|
+
*/
|
|
433
|
+
addLinkedTestIds(test, ids) {
|
|
434
|
+
if (!ids || !ids.length) return;
|
|
435
|
+
if (!Array.isArray(test.links)) test.links = [];
|
|
436
|
+
for (const id of ids) {
|
|
437
|
+
if (!test.links.some(l => l && l.test === id)) {
|
|
438
|
+
test.links.push({ test: id });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Normalize a value into a Testomat.io test id.
|
|
445
|
+
*
|
|
446
|
+
* Testomat.io test ids are exactly **8 word characters**. The value may arrive bare
|
|
447
|
+
* (`1a2b3c4d`), or carrying the `T` / `@T` markers Testomat uses in code and titles
|
|
448
|
+
* (`T1a2b3c4d`, `@T1a2b3c4d`). The markers are removed only when doing so still leaves
|
|
449
|
+
* a valid 8-char id, so a real id that happens to start with `T` is preserved.
|
|
450
|
+
*
|
|
451
|
+
* Anything that does not resolve to a valid 8-char id — a numeric Allure TestOps id
|
|
452
|
+
* like `12345`, a JIRA key, a 6-digit TMS number — is rejected (returns null) so we
|
|
453
|
+
* never send an unmatchable id that would create duplicates.
|
|
454
|
+
*
|
|
455
|
+
* @param {string|number|null|undefined} value
|
|
456
|
+
* @returns {string|null} The bare 8-char id, or null when the value is not a valid id
|
|
457
|
+
*/
|
|
458
|
+
normalizeTestId(value) {
|
|
459
|
+
if (value === null || value === undefined) return null;
|
|
460
|
+
const match = value.toString().trim().match(/^@?T?([\w\d]{8})$/);
|
|
461
|
+
return match ? match[1] : null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Recover a Testomat.io test id from the `@TmsLink("…")` annotation in test source.
|
|
466
|
+
*
|
|
467
|
+
* Allure does not emit link annotations for skipped (`@Ignore` / `@Disabled`) tests,
|
|
468
|
+
* so their results carry no `tms` link and `extractTestId` returns null — which makes
|
|
469
|
+
* the server create a duplicate case. The id still lives in the source, on the test
|
|
470
|
+
* method, so we read it from there as a fallback.
|
|
471
|
+
*
|
|
472
|
+
* The lookup is method-scoped: we locate the test method declaration by name, collect
|
|
473
|
+
* its annotation/comment block (the lines directly above it, up to the previous code
|
|
474
|
+
* construct) and read every `@TmsLink` from it — so an unrelated method's annotation can
|
|
475
|
+
* never be picked up. Both `@TmsLink("…")` and the container forms
|
|
476
|
+
* `@TmsLinks(@TmsLink("…"), @TmsLink("…"))` (single- or multi-line) are supported.
|
|
477
|
+
*
|
|
478
|
+
* @param {string} contents - full source file
|
|
479
|
+
* @param {object} test - converted test (uses `title`)
|
|
480
|
+
* @returns {string[]} normalized 8-char ids in source order (possibly empty)
|
|
481
|
+
*/
|
|
482
|
+
extractTmsIdsFromSource(contents, test) {
|
|
483
|
+
if (!contents || !test || !test.title) return [];
|
|
484
|
+
|
|
485
|
+
const lines = contents.split('\n');
|
|
486
|
+
const title = test.title.replace(/[([].*$/, '').trim();
|
|
487
|
+
if (!title) return [];
|
|
488
|
+
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
489
|
+
|
|
490
|
+
// Locate the test method declaration (Kotlin `fun name(`, then a typed declaration,
|
|
491
|
+
// then a generic `name(` fallback for other JVM languages).
|
|
492
|
+
let idx = lines.findIndex(l => new RegExp(`\\bfun\\s+${escaped}\\s*\\(`).test(l));
|
|
493
|
+
if (idx === -1) idx = lines.findIndex(l => new RegExp(`\\b[\\w.<>\\[\\]]+\\s+${escaped}\\s*\\(`).test(l));
|
|
494
|
+
if (idx === -1) idx = lines.findIndex(l => new RegExp(`\\b${escaped}\\s*\\(`).test(l));
|
|
495
|
+
if (idx === -1) return [];
|
|
496
|
+
|
|
497
|
+
// Collect the annotation/comment block above the declaration. Stop at the previous
|
|
498
|
+
// code construct (a line ending in `{`/`}` or another method declaration) so we never
|
|
499
|
+
// read into a sibling method. Multi-line `@TmsLinks(...)` continuation lines (e.g. a
|
|
500
|
+
// lone `@TmsLink("…"),` or a closing `)`) are kept because they aren't boundaries.
|
|
501
|
+
const block = [];
|
|
502
|
+
for (let i = idx - 1, scanned = 0; i >= 0 && scanned < 60; i--, scanned++) {
|
|
503
|
+
const trimmed = lines[i].trim();
|
|
504
|
+
if (trimmed.endsWith('{') || trimmed.endsWith('}')) break;
|
|
505
|
+
if (/\bfun\s+\w+\s*\(/.test(trimmed)) break;
|
|
506
|
+
block.push(lines[i]);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const ids = [];
|
|
510
|
+
const tmsRe = /\bTmsLink\s*\(\s*["']([^"']+)["']/g;
|
|
511
|
+
let match;
|
|
512
|
+
// block was collected bottom-up; reverse so ids come out in source order
|
|
513
|
+
const text = block.reverse().join('\n');
|
|
514
|
+
while ((match = tmsRe.exec(text)) !== null) {
|
|
515
|
+
const id = this.normalizeTestId(match[1]);
|
|
516
|
+
if (id && !ids.includes(id)) ids.push(id);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return ids;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The primary `@TmsLink` id for a test method in source, or null. Convenience wrapper
|
|
524
|
+
* around {@link extractTmsIdsFromSource}.
|
|
525
|
+
*
|
|
526
|
+
* @param {string} contents - full source file
|
|
527
|
+
* @param {object} test - converted test (uses `title`)
|
|
528
|
+
* @returns {string|null}
|
|
529
|
+
*/
|
|
530
|
+
extractTmsIdFromSource(contents, test) {
|
|
531
|
+
return this.extractTmsIdsFromSource(contents, test)[0] || null;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
convertSteps(steps, depth = 0) {
|
|
535
|
+
if (depth >= 10) return null;
|
|
536
|
+
|
|
537
|
+
return steps
|
|
538
|
+
.map(step => {
|
|
539
|
+
const convertedStep = {
|
|
540
|
+
category: 'user',
|
|
541
|
+
title: step.name || step.title || 'Unknown step',
|
|
542
|
+
status: this.mapStepStatus(step.status),
|
|
543
|
+
duration: this.calculateRunTime(step),
|
|
544
|
+
steps: this.convertSteps(step.steps || [], depth + 1),
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
// Attach the failure description (error message + trace with the failing
|
|
548
|
+
// code line) straight onto the failed step. Testomat.io renders a step's
|
|
549
|
+
// `error` inline in the step tree, so the failure shows up on the exact
|
|
550
|
+
// step that broke instead of only at the test level.
|
|
551
|
+
//
|
|
552
|
+
// Allure propagates a failure's statusDetails up the whole step chain, so
|
|
553
|
+
// every ancestor of the real failure point is also `failed` and carries
|
|
554
|
+
// the same message. To avoid repeating it at every level, only surface the
|
|
555
|
+
// error on the deepest failed step — skip it whenever a descendant step
|
|
556
|
+
// already shows the error.
|
|
557
|
+
const error = this.extractStepError(step);
|
|
558
|
+
if (error && !this.subtreeHasError(convertedStep.steps)) {
|
|
559
|
+
convertedStep.error = error;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (convertedStep.steps && convertedStep.steps.length === 0) {
|
|
563
|
+
delete convertedStep.steps;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (convertedStep.duration === 0) {
|
|
567
|
+
delete convertedStep.duration;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return convertedStep;
|
|
571
|
+
})
|
|
572
|
+
.filter(Boolean);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Check whether any step in the given (already converted) subtree already
|
|
577
|
+
* carries an `error`. Used to keep the failure message on the deepest failed
|
|
578
|
+
* step only, instead of repeating it on every ancestor in the failure chain.
|
|
579
|
+
*
|
|
580
|
+
* @param {Array<object>|undefined} steps - converted child steps
|
|
581
|
+
* @returns {boolean}
|
|
582
|
+
*/
|
|
583
|
+
subtreeHasError(steps) {
|
|
584
|
+
if (!steps || !steps.length) return false;
|
|
585
|
+
return steps.some(step => step.error || this.subtreeHasError(step.steps));
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Build the `error` payload for a failed step from its Allure `statusDetails`.
|
|
590
|
+
*
|
|
591
|
+
* Allure stores the failure message and stack trace (which includes the failing
|
|
592
|
+
* source line) in `statusDetails` on the step itself. We only surface it for
|
|
593
|
+
* failed/broken steps — passing or skipped steps carry no error. Returns null
|
|
594
|
+
* when there is no usable failure information so the field is omitted entirely.
|
|
595
|
+
*
|
|
596
|
+
* @param {object} step - Allure step
|
|
597
|
+
* @returns {{message: string, stack: string}|null}
|
|
598
|
+
*/
|
|
599
|
+
extractStepError(step) {
|
|
600
|
+
if (this.mapStepStatus(step.status) !== 'failed') return null;
|
|
601
|
+
|
|
602
|
+
const details = step.statusDetails || {};
|
|
603
|
+
const message = details.message || '';
|
|
604
|
+
const stack = details.trace || '';
|
|
605
|
+
|
|
606
|
+
if (!message && !stack) return null;
|
|
607
|
+
|
|
608
|
+
return { message, stack };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
calculateRunTime(item) {
|
|
612
|
+
if (item.start && item.stop) {
|
|
613
|
+
const durationMs = item.stop - item.start;
|
|
614
|
+
return durationMs / 1000;
|
|
615
|
+
}
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
convertParameters(parameters) {
|
|
620
|
+
const example = {};
|
|
621
|
+
parameters.forEach(param => {
|
|
622
|
+
if (param.name) {
|
|
623
|
+
example[param.name] = param.value;
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
return example;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
combineRetryAttempts(attempts) {
|
|
630
|
+
attempts.sort((a, b) => (a._stop || 0) - (b._stop || 0));
|
|
631
|
+
|
|
632
|
+
const finalTest = attempts[attempts.length - 1];
|
|
633
|
+
const retryCount = attempts.length - 1;
|
|
634
|
+
|
|
635
|
+
if (retryCount > 0) {
|
|
636
|
+
finalTest.retries = retryCount;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const failedAttempts = attempts.filter(t => t.status === 'failed');
|
|
640
|
+
|
|
641
|
+
if (failedAttempts.length === 0) {
|
|
642
|
+
return finalTest;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const failureMessages = [];
|
|
646
|
+
const failureStacks = [];
|
|
647
|
+
|
|
648
|
+
for (const failed of failedAttempts) {
|
|
649
|
+
const attemptNum = attempts.indexOf(failed) + 1;
|
|
650
|
+
if (failed.message) {
|
|
651
|
+
failureMessages.push(`[Attempt ${attemptNum}] ${failed.message}`);
|
|
652
|
+
}
|
|
653
|
+
if (failed.stack) {
|
|
654
|
+
failureStacks.push(`\n--- Attempt ${attemptNum} ---\n${failed.stack}`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (failureMessages.length > 0) {
|
|
659
|
+
finalTest.message = failureMessages.join('\n');
|
|
660
|
+
}
|
|
661
|
+
if (failureStacks.length > 0) {
|
|
662
|
+
finalTest.stack = failureStacks.join('\n');
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// When the final attempt passed (after earlier failures) keep the test as passed —
|
|
666
|
+
// this mirrors the server's overwrite-by-latest retry model. The aggregated failure
|
|
667
|
+
// message/stack built above is retained so the flakiness history stays visible.
|
|
668
|
+
if (finalTest.status === 'passed') {
|
|
669
|
+
const retryMsg = `Test passed after ${failedAttempts.length} retries. Previous failures:\n`;
|
|
670
|
+
finalTest.message = retryMsg + finalTest.message;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return finalTest;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
calculateStats() {
|
|
677
|
+
this.stats = {
|
|
678
|
+
create_tests: true,
|
|
679
|
+
tests_count: this._tests.length,
|
|
680
|
+
passed_count: 0,
|
|
681
|
+
failed_count: 0,
|
|
682
|
+
skipped_count: 0,
|
|
683
|
+
duration: 0,
|
|
684
|
+
status: 'passed',
|
|
685
|
+
tests: this._tests,
|
|
686
|
+
};
|
|
687
|
+
this._tests.forEach(t => {
|
|
688
|
+
if (t.status === 'passed') this.stats.passed_count++;
|
|
689
|
+
if (t.status === 'failed') this.stats.failed_count++;
|
|
690
|
+
if (t.status === 'skipped') this.stats.skipped_count++;
|
|
691
|
+
});
|
|
692
|
+
this.stats.duration = this._tests.reduce((acc, t) => acc + (t.run_time || 0), 0);
|
|
693
|
+
if (this.stats.failed_count) this.stats.status = 'failed';
|
|
694
|
+
|
|
695
|
+
debug('Stats:', this.stats);
|
|
696
|
+
return this.stats;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
fetchSourceCode() {
|
|
700
|
+
const adapter = this.adapter || adapterFactory(this.getLanguage(), this.opts);
|
|
701
|
+
|
|
702
|
+
this._tests.forEach(t => {
|
|
703
|
+
try {
|
|
704
|
+
let filePath = t.file;
|
|
705
|
+
|
|
706
|
+
if (adapter && adapter.getFilePath) {
|
|
707
|
+
filePath = adapter.getFilePath(t);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (!filePath) return;
|
|
711
|
+
|
|
712
|
+
if (!fs.existsSync(filePath)) {
|
|
713
|
+
debug('Source file not found:', filePath);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const contents = fs.readFileSync(filePath).toString();
|
|
718
|
+
const code = fetchSourceCode(contents, { ...t, lang: this.getLanguage() });
|
|
719
|
+
if (code) {
|
|
720
|
+
t.code = code;
|
|
721
|
+
debug('Fetched code for test %s', t.title);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Don't override an id already taken from a @TmsLink — the link is the
|
|
725
|
+
// explicit, source-independent match key the client maintains.
|
|
726
|
+
const testId = fetchIdFromCode(contents, { lang: this.getLanguage() });
|
|
727
|
+
if (testId && !t.test_id) {
|
|
728
|
+
t.test_id = testId;
|
|
729
|
+
debug('Fetched test id %s for test %s', testId, t.title);
|
|
730
|
+
}
|
|
731
|
+
} catch (err) {
|
|
732
|
+
debug('Failed to fetch source code:', err.message);
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
getLanguage() {
|
|
738
|
+
if (this._tests.length === 0) return null;
|
|
739
|
+
return this._tests[0].meta?.language || this.opts.lang;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Link tests to their Testomat.io cases by reading `@TmsLink` from source for tests that
|
|
744
|
+
* carry no `{ test: … }` link yet. This rescues skipped (`@Ignore` / `@Disabled`) tests,
|
|
745
|
+
* whose Allure results drop the `@TmsLink` link and would otherwise create duplicate
|
|
746
|
+
* cases. Every `@TmsLink` on the method is linked (consistent with executed tests); the
|
|
747
|
+
* `test_id` is left untouched — links and test_id are separate concerns.
|
|
748
|
+
*
|
|
749
|
+
* Opt-in: only runs when `--java-tests` (a source root) is provided. The source files are
|
|
750
|
+
* indexed once by basename; for each not-yet-linked test we read the candidate file(s)
|
|
751
|
+
* matching its `testFile` / class name and parse the method's `@TmsLink`(s).
|
|
752
|
+
*/
|
|
753
|
+
recoverTmsLinksFromSource() {
|
|
754
|
+
const root = this.opts.javaTests;
|
|
755
|
+
if (!root) return;
|
|
756
|
+
|
|
757
|
+
const hasTestLink = t => Array.isArray(t.links) && t.links.some(l => l && l.test);
|
|
758
|
+
const unlinked = this._tests.filter(t => !hasTestLink(t));
|
|
759
|
+
if (!unlinked.length) return;
|
|
760
|
+
|
|
761
|
+
if (!fs.existsSync(root)) {
|
|
762
|
+
debug('java-tests source root not found: %s', root);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const index = this.indexSourceFiles(root);
|
|
767
|
+
let recovered = 0;
|
|
768
|
+
|
|
769
|
+
for (const t of unlinked) {
|
|
770
|
+
for (const filePath of this.sourceCandidatesForTest(t, index)) {
|
|
771
|
+
let contents;
|
|
772
|
+
try {
|
|
773
|
+
contents = fs.readFileSync(filePath).toString();
|
|
774
|
+
} catch (err) {
|
|
775
|
+
debug('Failed to read source %s: %s', filePath, err.message);
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
const ids = this.extractTmsIdsFromSource(contents, t);
|
|
779
|
+
if (ids.length) {
|
|
780
|
+
this.addLinkedTestIds(t, ids);
|
|
781
|
+
recovered++;
|
|
782
|
+
debug('Linked %s to case(s) %s from @TmsLink in %s', t.title, ids.join(', '), filePath);
|
|
783
|
+
break;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (recovered) {
|
|
789
|
+
console.log(APP_PREFIX, `🔗 Linked ${pc.bold(recovered)} test(s) to cases from @TmsLink in source`);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Fallback: when a test has no `test_id`, adopt the first linked case (`@TmsLink`) as its
|
|
795
|
+
* `test_id` so it matches an existing case instead of creating a method-named duplicate.
|
|
796
|
+
*
|
|
797
|
+
* Runs after `fetchSourceCode`, so a native Testomat.io id parsed from source always wins.
|
|
798
|
+
* The id stays in `links` as well — every linked case is still updated; this only gives the
|
|
799
|
+
* test a primary identity to match on.
|
|
800
|
+
*/
|
|
801
|
+
applyPrimaryTestIdFromLinks() {
|
|
802
|
+
for (const t of this._tests) {
|
|
803
|
+
if (t.test_id) continue;
|
|
804
|
+
const firstTestLink = Array.isArray(t.links) ? t.links.find(l => l && l.test) : null;
|
|
805
|
+
if (firstTestLink) {
|
|
806
|
+
t.test_id = firstTestLink.test;
|
|
807
|
+
debug('Using first linked case %s as test_id for %s', firstTestLink.test, t.title);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Build (and cache) a basename -> [absolute paths] index of source files under `root`.
|
|
814
|
+
* Walks synchronously, skipping common build/dependency directories.
|
|
815
|
+
*
|
|
816
|
+
* @param {string} root
|
|
817
|
+
* @returns {Map<string, string[]>}
|
|
818
|
+
*/
|
|
819
|
+
indexSourceFiles(root) {
|
|
820
|
+
if (this._sourceIndex) return this._sourceIndex;
|
|
821
|
+
|
|
822
|
+
const exts = new Set(['.kt', '.java', '.py', '.rb', '.cs']);
|
|
823
|
+
const skipDirs = new Set(['node_modules', '.git', 'build', 'out', 'target', '.gradle', 'dist', 'bin']);
|
|
824
|
+
const index = new Map();
|
|
825
|
+
const stack = [root];
|
|
826
|
+
|
|
827
|
+
while (stack.length) {
|
|
828
|
+
const dir = stack.pop();
|
|
829
|
+
let entries;
|
|
830
|
+
try {
|
|
831
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
832
|
+
} catch (err) {
|
|
833
|
+
debug('Failed to read dir %s: %s', dir, err.message);
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
for (const entry of entries) {
|
|
837
|
+
const full = path.join(dir, entry.name);
|
|
838
|
+
if (entry.isDirectory()) {
|
|
839
|
+
if (!skipDirs.has(entry.name)) stack.push(full);
|
|
840
|
+
} else if (exts.has(path.extname(entry.name))) {
|
|
841
|
+
const list = index.get(entry.name) || [];
|
|
842
|
+
list.push(full);
|
|
843
|
+
index.set(entry.name, list);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
this._sourceIndex = index;
|
|
849
|
+
return index;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Candidate source file paths for a test, looked up in the basename index.
|
|
854
|
+
* Uses the `testFile` meta label (e.g. `Foo.kt`) and the class name from `file`,
|
|
855
|
+
* trying both `.kt` and `.java` extensions. Ambiguity (same basename in several
|
|
856
|
+
* dirs) is harmless: only the file that actually declares the method will match.
|
|
857
|
+
*
|
|
858
|
+
* @param {object} t
|
|
859
|
+
* @param {Map<string, string[]>} index
|
|
860
|
+
* @returns {string[]}
|
|
861
|
+
*/
|
|
862
|
+
sourceCandidatesForTest(t, index) {
|
|
863
|
+
const names = new Set();
|
|
864
|
+
if (t.meta?.testFile) names.add(t.meta.testFile);
|
|
865
|
+
if (t.file) {
|
|
866
|
+
const base = path.basename(t.file);
|
|
867
|
+
names.add(base);
|
|
868
|
+
const stem = base.replace(/\.[^.]+$/, '');
|
|
869
|
+
['kt', 'java'].forEach(ext => names.add(`${stem}.${ext}`));
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const paths = [];
|
|
873
|
+
for (const name of names) {
|
|
874
|
+
(index.get(name) || []).forEach(p => paths.push(p));
|
|
875
|
+
}
|
|
876
|
+
return paths;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
async uploadArtifacts() {
|
|
880
|
+
for (const test of this._tests.filter(t => t.files && t.files.length > 0)) {
|
|
881
|
+
const runId = this.runId || this.store.runId || Date.now().toString();
|
|
882
|
+
const artifacts = await Promise.all(
|
|
883
|
+
test.files.map(f => this.uploader.uploadFileByPath(f, [runId, test.rid, path.basename(f)])),
|
|
884
|
+
);
|
|
885
|
+
test.artifacts = artifacts.filter(a => a && a.link).map(a => a.link);
|
|
886
|
+
delete test.files;
|
|
887
|
+
if (test.artifacts.length > 0) {
|
|
888
|
+
console.log(APP_PREFIX, `🗄️ Uploaded ${pc.bold(`${test.artifacts.length} artifacts`)} for test ${test.title}`);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
async uploadData() {
|
|
894
|
+
await this.uploadArtifacts();
|
|
895
|
+
this.calculateStats();
|
|
896
|
+
this.fetchSourceCode();
|
|
897
|
+
this.recoverTmsLinksFromSource();
|
|
898
|
+
this.applyPrimaryTestIdFromLinks();
|
|
899
|
+
|
|
900
|
+
this.pipes = this.pipes || (await this.pipesPromise);
|
|
901
|
+
|
|
902
|
+
const finishData = {
|
|
903
|
+
api_key: this.requestParams.apiKey,
|
|
904
|
+
status: 'finished',
|
|
905
|
+
duration: this.stats.duration,
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
if (!this._tests || !Array.isArray(this._tests) || this._tests.length === 0) {
|
|
909
|
+
debug('No tests to upload, finishing run');
|
|
910
|
+
return Promise.all(this.pipes.map(p => p.finishRun(finishData)));
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Upload tests in size-limited chunks (max 1MB each), exactly like XmlReader.
|
|
914
|
+
// The testomatio pipe runs in MANUAL batch mode, so addTest only buffers tests and
|
|
915
|
+
// sync() flushes one batch request per chunk. There is no setInterval auto-upload,
|
|
916
|
+
// so each test is sent exactly once (no double-send) and requests stay under the limit.
|
|
917
|
+
const testChunks = splitTestsIntoChunks(this._tests);
|
|
918
|
+
|
|
919
|
+
const totalChunks = testChunks.length;
|
|
920
|
+
const totalTests = this._tests.length;
|
|
921
|
+
|
|
922
|
+
debug(`Split ${totalTests} tests into ${totalChunks} chunks (max 1MB per chunk)`);
|
|
923
|
+
|
|
924
|
+
let uploadedTests = 0;
|
|
925
|
+
for (let i = 0; i < testChunks.length; i++) {
|
|
926
|
+
const chunk = testChunks[i];
|
|
927
|
+
|
|
928
|
+
if (totalChunks > 1) {
|
|
929
|
+
debug(`Uploading chunk ${i + 1}/${totalChunks} (${chunk.length} tests)`);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Buffer each test in the chunk, then flush the whole chunk as a single batch
|
|
933
|
+
for (const test of chunk) {
|
|
934
|
+
await Promise.all(this.pipes.map(p => p.addTest(test)));
|
|
935
|
+
}
|
|
936
|
+
await Promise.all(this.pipes.map(p => p.sync()));
|
|
937
|
+
|
|
938
|
+
uploadedTests += chunk.length;
|
|
939
|
+
debug(`Uploaded ${uploadedTests}/${totalTests} tests`);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (totalChunks > 1) {
|
|
943
|
+
console.log(APP_PREFIX, `✅ Successfully uploaded ${uploadedTests} tests in ${totalChunks} chunks`);
|
|
944
|
+
} else {
|
|
945
|
+
console.log(APP_PREFIX, `✅ Successfully uploaded ${uploadedTests} tests`);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
debug('Uploaded %d tests, finishing run', this._tests.length);
|
|
949
|
+
|
|
950
|
+
return Promise.all(this.pipes.map(p => p.finishRun(finishData)));
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
export default AllureReader;
|
package/src/bin/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { glob } from 'glob';
|
|
|
6
6
|
import createDebugMessages from 'debug';
|
|
7
7
|
import TestomatClient from '../client.js';
|
|
8
8
|
import XmlReader from '../xmlReader.js';
|
|
9
|
+
import AllureReader from '../allureReader.js';
|
|
9
10
|
import { APP_PREFIX, STATUS, DEBUG_FILE, BATCH_MODE } from '../constants.js';
|
|
10
11
|
import { cleanLatestRunId, getPackageVersion, applyFilter } from '../utils/utils.js';
|
|
11
12
|
import { config } from '../config.js';
|
|
@@ -53,6 +54,7 @@ program
|
|
|
53
54
|
.option('--kind <type>', 'Specify run type: automated, manual, or mixed')
|
|
54
55
|
.option('--filter <filter>', 'Scope the prepared run to tests matching the filter (no execution)')
|
|
55
56
|
.option('--format <format>', 'Machine-readable output: print only the run id to stdout (e.g. --format id)')
|
|
57
|
+
.option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only)')
|
|
56
58
|
.action(async opts => {
|
|
57
59
|
cleanLatestRunId();
|
|
58
60
|
|
|
@@ -68,7 +70,8 @@ program
|
|
|
68
70
|
const tests = await client.prepareRun({ pipe, pipeOptions: optsArray.join(':') });
|
|
69
71
|
if (!tests || tests.length === 0) {
|
|
70
72
|
log.warn(pc.yellow('No tests found for the filter. Run not created.'));
|
|
71
|
-
|
|
73
|
+
// --warn treats an empty match as a normal outcome (e.g. a PR touching no mapped files)
|
|
74
|
+
process.exit(opts.warn ? 0 : 1);
|
|
72
75
|
}
|
|
73
76
|
createRunParams.configuration = {
|
|
74
77
|
tests: tests.filter(id => id.startsWith('T')).map(id => id.slice(1)),
|
|
@@ -126,6 +129,7 @@ program
|
|
|
126
129
|
(value, prev) => prev.concat([value]),
|
|
127
130
|
[],
|
|
128
131
|
)
|
|
132
|
+
.option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only)')
|
|
129
133
|
.action(async (command, opts) => {
|
|
130
134
|
if (opts.remote) {
|
|
131
135
|
if (opts.filterList) {
|
|
@@ -161,8 +165,8 @@ program
|
|
|
161
165
|
if (!tests || tests.length === 0) {
|
|
162
166
|
log.warn( pc.yellow('No tests found.'));
|
|
163
167
|
// Exit non-zero on --filter-list so scripts can detect "nothing to run"
|
|
164
|
-
// via $? and skip launching the runner.
|
|
165
|
-
if (opts.filterList) process.exit(1);
|
|
168
|
+
// via $? and skip launching the runner. --warn downgrades it to 0.
|
|
169
|
+
if (opts.filterList && !opts.warn) process.exit(1);
|
|
166
170
|
return;
|
|
167
171
|
}
|
|
168
172
|
|
|
@@ -353,6 +357,48 @@ program
|
|
|
353
357
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
354
358
|
});
|
|
355
359
|
|
|
360
|
+
program
|
|
361
|
+
.command('allure')
|
|
362
|
+
.description('Parse Allure result files and upload to Testomat.io')
|
|
363
|
+
.argument('<pattern>', 'Allure result directory pattern')
|
|
364
|
+
.option('-d, --dir <dir>', 'Project directory')
|
|
365
|
+
.option('--timelimit <time>', 'default time limit in seconds to kill a stuck process')
|
|
366
|
+
.option('--with-package', 'Keep full package path in file names (default: strip package prefix)')
|
|
367
|
+
.option(
|
|
368
|
+
'--java-tests [path]',
|
|
369
|
+
'Path to test sources; links cases from @TmsLink in source for skipped tests (default: src/test)',
|
|
370
|
+
)
|
|
371
|
+
.option('--lang <lang>', 'Language used (java, kotlin, ...)')
|
|
372
|
+
.action(async (pattern, opts) => {
|
|
373
|
+
let javaTests = opts.javaTests;
|
|
374
|
+
if (javaTests === true) javaTests = 'src/test';
|
|
375
|
+
|
|
376
|
+
const runReader = new AllureReader({ withPackage: opts.withPackage, javaTests, lang: opts.lang });
|
|
377
|
+
|
|
378
|
+
let timeoutTimer;
|
|
379
|
+
if (opts.timelimit) {
|
|
380
|
+
timeoutTimer = setTimeout(
|
|
381
|
+
() => {
|
|
382
|
+
console.log(
|
|
383
|
+
`⚠️ Reached timeout of ${opts.timelimit}s. Exiting... (Exit code is 0 to not fail the pipeline)`,
|
|
384
|
+
);
|
|
385
|
+
process.exit(0);
|
|
386
|
+
},
|
|
387
|
+
parseInt(opts.timelimit, 10) * 1000,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
await runReader.parse(pattern);
|
|
393
|
+
await runReader.createRun();
|
|
394
|
+
await runReader.uploadData();
|
|
395
|
+
} catch (err) {
|
|
396
|
+
console.log(APP_PREFIX, 'Error uploading Allure results:', err);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
400
|
+
});
|
|
401
|
+
|
|
356
402
|
program
|
|
357
403
|
.command('upload-artifacts')
|
|
358
404
|
.description('Upload artifacts to Testomat.io')
|
|
@@ -4,6 +4,7 @@ import JavaAdapter from './java.js';
|
|
|
4
4
|
import PythonAdapter from './python.js';
|
|
5
5
|
import RubyAdapter from './ruby.js';
|
|
6
6
|
import CSharpAdapter from './csharp.js';
|
|
7
|
+
import KotlinAdapter from './kotlin.js';
|
|
7
8
|
|
|
8
9
|
function AdapterFactory(lang, opts) {
|
|
9
10
|
if (lang === 'java') {
|
|
@@ -21,6 +22,9 @@ function AdapterFactory(lang, opts) {
|
|
|
21
22
|
if (lang === 'c#' || lang === 'csharp') {
|
|
22
23
|
return new CSharpAdapter(opts);
|
|
23
24
|
}
|
|
25
|
+
if (lang === 'kotlin') {
|
|
26
|
+
return new KotlinAdapter(opts);
|
|
27
|
+
}
|
|
24
28
|
|
|
25
29
|
return new Adapter(opts);
|
|
26
30
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import Adapter from './adapter.js';
|
|
3
|
+
|
|
4
|
+
class KotlinAdapter extends Adapter {
|
|
5
|
+
getFilePath(t) {
|
|
6
|
+
const fileName = namespaceToFileName(t.suite_title);
|
|
7
|
+
return this.opts.javaTests + path.sep + fileName;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
formatTest(t) {
|
|
11
|
+
const fileParts = t.suite_title.split('.');
|
|
12
|
+
|
|
13
|
+
t.file = namespaceToFileName(t.suite_title);
|
|
14
|
+
t.title = t.title.split('(')[0];
|
|
15
|
+
|
|
16
|
+
// detect params
|
|
17
|
+
const paramMatches = t.title.match(/\[(.*?)\]/g);
|
|
18
|
+
|
|
19
|
+
if (paramMatches) {
|
|
20
|
+
const params = paramMatches.map((_match, index) => `param${index + 1}`);
|
|
21
|
+
if (params.length === 1) params[0] = 'param';
|
|
22
|
+
let paramIndex = 0;
|
|
23
|
+
|
|
24
|
+
t.title = t.title.replace(/: \[(.*?)\]/g, () => {
|
|
25
|
+
if (params.length < 2) return `\${param}`;
|
|
26
|
+
const paramName = params[paramIndex] || `param${paramIndex + 1}`;
|
|
27
|
+
paramIndex++;
|
|
28
|
+
return `\${${paramName}}`;
|
|
29
|
+
});
|
|
30
|
+
const example = {};
|
|
31
|
+
paramMatches.forEach((match, index) => {
|
|
32
|
+
example[params[index]] = match.replace(/[[\]]/g, '');
|
|
33
|
+
});
|
|
34
|
+
t.example = example;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
t.suite_title = fileParts[fileParts.length - 1].replace(/\$/g, ' | ');
|
|
38
|
+
return t;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function namespaceToFileName(fileName) {
|
|
43
|
+
const fileParts = fileName.split('.');
|
|
44
|
+
fileParts[fileParts.length - 1] = fileParts[fileParts.length - 1]?.replace(/\$.*/, '');
|
|
45
|
+
return `${fileParts.join(path.sep)}.kt`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default KotlinAdapter;
|
package/src/utils/pipe_utils.js
CHANGED
|
@@ -161,6 +161,53 @@ function parsePipeOptions(optionsStr) {
|
|
|
161
161
|
return options;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Calculate the approximate size of data in bytes (JSON stringified, UTF-8 encoded length).
|
|
166
|
+
* @param {Object} data - Data to measure
|
|
167
|
+
* @returns {number} Size in bytes
|
|
168
|
+
*/
|
|
169
|
+
function getObjectSize(data) {
|
|
170
|
+
const body = JSON.stringify(data);
|
|
171
|
+
return new TextEncoder().encode(body).length;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Split a tests array into chunks bounded by serialized size.
|
|
176
|
+
* Used by the XML and Allure readers so both upload tests with identical batching:
|
|
177
|
+
* each chunk becomes a single batch request (manual batch mode), which keeps requests
|
|
178
|
+
* under the size limit and guarantees every test is sent exactly once.
|
|
179
|
+
*
|
|
180
|
+
* @param {Array} tests - Array of tests to split
|
|
181
|
+
* @param {number} [maxSizeBytes=1048576] - Maximum serialized size per chunk (default 1MB)
|
|
182
|
+
* @returns {Array<Array>} Array of test chunks
|
|
183
|
+
*/
|
|
184
|
+
function splitTestsIntoChunks(tests, maxSizeBytes = 1 * 1024 * 1024) {
|
|
185
|
+
const chunks = [];
|
|
186
|
+
let currentChunk = [];
|
|
187
|
+
let currentChunkSize = 0;
|
|
188
|
+
|
|
189
|
+
for (const test of tests) {
|
|
190
|
+
const testSize = getObjectSize(test);
|
|
191
|
+
|
|
192
|
+
const wouldExceedSize = currentChunkSize + testSize > maxSizeBytes;
|
|
193
|
+
|
|
194
|
+
if (wouldExceedSize && currentChunk.length > 0) {
|
|
195
|
+
chunks.push(currentChunk);
|
|
196
|
+
currentChunk = [];
|
|
197
|
+
currentChunkSize = 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
currentChunk.push(test);
|
|
201
|
+
currentChunkSize += testSize;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (currentChunk.length > 0) {
|
|
205
|
+
chunks.push(currentChunk);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return chunks;
|
|
209
|
+
}
|
|
210
|
+
|
|
164
211
|
/**
|
|
165
212
|
* Format a list of test IDs for `--filter-list` machine-readable output.
|
|
166
213
|
* Used when the CLI `--format` option is passed,
|
|
@@ -190,4 +237,6 @@ export {
|
|
|
190
237
|
fullName,
|
|
191
238
|
parsePipeOptions,
|
|
192
239
|
formatFilterListIds,
|
|
240
|
+
getObjectSize,
|
|
241
|
+
splitTestsIntoChunks,
|
|
193
242
|
};
|
package/src/utils/utils.js
CHANGED
|
@@ -289,6 +289,11 @@ const fetchSourceCode = (contents, opts = {}) => {
|
|
|
289
289
|
if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`@DisplayName("${title}`));
|
|
290
290
|
if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`public void ${title}`));
|
|
291
291
|
if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`${title}(`));
|
|
292
|
+
} else if (opts.lang === 'kotlin') {
|
|
293
|
+
lineIndex = lines.findIndex(l => l.includes(`fun test${title}`));
|
|
294
|
+
if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`@DisplayName("${title}`));
|
|
295
|
+
if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`fun ${title}`));
|
|
296
|
+
if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`${title}(`));
|
|
292
297
|
} else if (opts.lang === 'csharp') {
|
|
293
298
|
// Find the method declaration line
|
|
294
299
|
let methodLineIndex = lines.findIndex(l => l.includes(`public void ${title}(`));
|
package/src/xmlReader.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
transformEnvVarToBoolean,
|
|
19
19
|
} from './utils/utils.js';
|
|
20
20
|
import { pipesFactory } from './pipe/index.js';
|
|
21
|
+
import { splitTestsIntoChunks } from './utils/pipe_utils.js';
|
|
21
22
|
import adapterFactory from './junit-adapter/index.js';
|
|
22
23
|
import { config } from './config.js';
|
|
23
24
|
import { S3Uploader } from './uploader.js';
|
|
@@ -553,52 +554,6 @@ class XmlReader {
|
|
|
553
554
|
return run;
|
|
554
555
|
}
|
|
555
556
|
|
|
556
|
-
/**
|
|
557
|
-
* Calculate the approximate size of data in bytes (JSON stringified length)
|
|
558
|
-
* @param {Object} data - Data to measure
|
|
559
|
-
* @returns {number} Size in bytes
|
|
560
|
-
*/
|
|
561
|
-
#getObjectSize(data) {
|
|
562
|
-
const body = JSON.stringify(data);
|
|
563
|
-
return new TextEncoder().encode(body).length;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
/**
|
|
567
|
-
* Split tests array into chunks based on data size
|
|
568
|
-
* @param {Array} tests - Array of tests to split
|
|
569
|
-
* @returns {Array<Array>} Array of test chunks
|
|
570
|
-
*/
|
|
571
|
-
#splitTestsIntoChunks(tests) {
|
|
572
|
-
const maxSizeBytes = 1 * 1024 * 1024;
|
|
573
|
-
|
|
574
|
-
const chunks = [];
|
|
575
|
-
let currentChunk = [];
|
|
576
|
-
let currentChunkSize = 0;
|
|
577
|
-
|
|
578
|
-
for (const test of tests) {
|
|
579
|
-
const testSize = this.#getObjectSize(test);
|
|
580
|
-
|
|
581
|
-
const wouldExceedSize = currentChunkSize + testSize > maxSizeBytes;
|
|
582
|
-
|
|
583
|
-
if (wouldExceedSize) {
|
|
584
|
-
if (currentChunk.length > 0) {
|
|
585
|
-
chunks.push(currentChunk);
|
|
586
|
-
}
|
|
587
|
-
currentChunk = [];
|
|
588
|
-
currentChunkSize = 0;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
currentChunk.push(test);
|
|
592
|
-
currentChunkSize += testSize;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
if (currentChunk.length > 0) {
|
|
596
|
-
chunks.push(currentChunk);
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
return chunks;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
557
|
async uploadData() {
|
|
603
558
|
await this.uploadArtifacts();
|
|
604
559
|
this.calculateStats();
|
|
@@ -623,7 +578,7 @@ class XmlReader {
|
|
|
623
578
|
return Promise.all(this.pipes.map(p => p.finishRun(finishData)));
|
|
624
579
|
}
|
|
625
580
|
|
|
626
|
-
const testChunks =
|
|
581
|
+
const testChunks = splitTestsIntoChunks(this.tests);
|
|
627
582
|
|
|
628
583
|
const totalChunks = testChunks.length;
|
|
629
584
|
const totalTests = this.tests.length;
|