testomatio-reporter-cli 2.9.1-beta.4-allure-step-status → 2.9.2
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 +2 -0
- package/src/adapter/playwright.js +22 -9
- package/src/bin/cli.js +96 -36
- package/src/junit-adapter/index.js +0 -4
- package/src/pipe/debug.js +11 -0
- package/src/replay.js +21 -0
- package/src/template/testomatio.hbs +77 -11
- package/src/utils/pipe_utils.js +0 -49
- package/src/utils/utils.js +0 -5
- package/src/xmlReader.js +47 -2
- package/src/allureReader.js +0 -691
- package/src/junit-adapter/kotlin.js +0 -48
package/package.json
CHANGED
package/src/adapter/codecept.js
CHANGED
|
@@ -278,6 +278,8 @@ async function uploadAttachments(client, attachments, messagePrefix, attachmentT
|
|
|
278
278
|
log.info(`Attachments: ${messagePrefix} ${attachments.length} ${attachmentType} ...`);
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
await Promise.all(client.pipes?.map(pipe => pipe.addArtifacts?.(attachments)) || []);
|
|
282
|
+
|
|
281
283
|
const promises = attachments.map(async attachment => {
|
|
282
284
|
const { rid, title, path, type } = attachment;
|
|
283
285
|
const file = { path, type, title };
|
|
@@ -48,14 +48,8 @@ class PlaywrightReporter {
|
|
|
48
48
|
const { title } = test;
|
|
49
49
|
const { error, duration } = result;
|
|
50
50
|
const pwAttachments = (result.attachments || []).filter(a => a.body || a.path);
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
.map(att => ({
|
|
54
|
-
path: this.#getArtifactPath(att),
|
|
55
|
-
title: att.name || title,
|
|
56
|
-
type: att.contentType,
|
|
57
|
-
}))
|
|
58
|
-
.filter(f => f.path);
|
|
51
|
+
const files = buildArtifactFiles(pwAttachments, attachment => this.#getArtifactPath(attachment), title);
|
|
52
|
+
const artifactsForUpload = processArtifactsForUpload(pwAttachments);
|
|
59
53
|
|
|
60
54
|
const suite_title = test.parent ? test.parent?.title : path.basename(test?.location?.file);
|
|
61
55
|
|
|
@@ -150,7 +144,7 @@ class PlaywrightReporter {
|
|
|
150
144
|
this.uploads.push({
|
|
151
145
|
rid: `${rid}-${project.name}`,
|
|
152
146
|
title: test.title,
|
|
153
|
-
files:
|
|
147
|
+
files: artifactsForUpload,
|
|
154
148
|
file: test.location?.file,
|
|
155
149
|
});
|
|
156
150
|
// remove empty uploads
|
|
@@ -229,6 +223,25 @@ function checkStatus(status) {
|
|
|
229
223
|
);
|
|
230
224
|
}
|
|
231
225
|
|
|
226
|
+
function buildArtifactFiles(attachments, getArtifactPath, title) {
|
|
227
|
+
return attachments
|
|
228
|
+
.filter(isScreenshotArtifact)
|
|
229
|
+
.map(attachment => ({
|
|
230
|
+
path: getArtifactPath(attachment),
|
|
231
|
+
title: attachment.name || title,
|
|
232
|
+
type: attachment.contentType,
|
|
233
|
+
}))
|
|
234
|
+
.filter(file => file.path);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function processArtifactsForUpload(attachments) {
|
|
238
|
+
return attachments.filter(attachment => !isScreenshotArtifact(attachment));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function isScreenshotArtifact(attachment) {
|
|
242
|
+
return attachment?.contentType === 'image/png' && attachment?.name === 'screenshot';
|
|
243
|
+
}
|
|
244
|
+
|
|
232
245
|
function appendStep(step, shift = 0) {
|
|
233
246
|
// nesting too deep, ignore those steps
|
|
234
247
|
if (shift >= 10) return;
|
package/src/bin/cli.js
CHANGED
|
@@ -6,7 +6,6 @@ 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';
|
|
10
9
|
import { APP_PREFIX, STATUS, DEBUG_FILE, BATCH_MODE } from '../constants.js';
|
|
11
10
|
import { cleanLatestRunId, getPackageVersion, applyFilter } from '../utils/utils.js';
|
|
12
11
|
import { config } from '../config.js';
|
|
@@ -17,6 +16,10 @@ import dotenv from 'dotenv';
|
|
|
17
16
|
import Replay from '../replay.js';
|
|
18
17
|
import { log } from '../utils/log.js';
|
|
19
18
|
import { formatFilterListIds } from '../utils/pipe_utils.js';
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import { Gaxios } from 'gaxios';
|
|
22
|
+
import { generateShortFilename } from '../adapter/utils/step-formatter.js';
|
|
20
23
|
|
|
21
24
|
const debug = createDebugMessages('@testomatio/reporter:cli');
|
|
22
25
|
const version = getPackageVersion();
|
|
@@ -352,47 +355,104 @@ program
|
|
|
352
355
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
353
356
|
});
|
|
354
357
|
|
|
355
|
-
program
|
|
356
|
-
.command('allure')
|
|
357
|
-
.description('Parse Allure result files and upload to Testomat.io')
|
|
358
|
-
.argument('<pattern>', 'Allure result directory pattern')
|
|
359
|
-
.option('-d, --dir <dir>', 'Project directory')
|
|
360
|
-
.option('--timelimit <time>', 'default time limit in seconds to kill a stuck process')
|
|
361
|
-
.option('--with-package', 'Keep full package path in file names (default: strip package prefix)')
|
|
362
|
-
.action(async (pattern, opts) => {
|
|
363
|
-
const runReader = new AllureReader({ withPackage: opts.withPackage });
|
|
364
|
-
|
|
365
|
-
let timeoutTimer;
|
|
366
|
-
if (opts.timelimit) {
|
|
367
|
-
timeoutTimer = setTimeout(
|
|
368
|
-
() => {
|
|
369
|
-
console.log(
|
|
370
|
-
`⚠️ Reached timeout of ${opts.timelimit}s. Exiting... (Exit code is 0 to not fail the pipeline)`,
|
|
371
|
-
);
|
|
372
|
-
process.exit(0);
|
|
373
|
-
},
|
|
374
|
-
parseInt(opts.timelimit, 10) * 1000,
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
try {
|
|
379
|
-
await runReader.parse(pattern);
|
|
380
|
-
await runReader.createRun();
|
|
381
|
-
await runReader.uploadData();
|
|
382
|
-
} catch (err) {
|
|
383
|
-
console.log(APP_PREFIX, 'Error uploading Allure results:', err);
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
387
|
-
});
|
|
388
|
-
|
|
389
358
|
program
|
|
390
359
|
.command('upload-artifacts')
|
|
391
360
|
.description('Upload artifacts to Testomat.io')
|
|
361
|
+
.argument('[jsonl-file]', 'Path to JSONL debug file (optional)')
|
|
392
362
|
.option('--force', 'Re-upload artifacts even if they were uploaded before')
|
|
393
|
-
.action(async opts => {
|
|
363
|
+
.action(async (jsonlFile, opts) => {
|
|
394
364
|
const apiKey = config.TESTOMATIO;
|
|
395
365
|
|
|
366
|
+
// JSONL file mode: upload artifacts from debug file
|
|
367
|
+
if (jsonlFile) {
|
|
368
|
+
if (!fs.existsSync(jsonlFile)) {
|
|
369
|
+
log.error(`JSONL file not found: ${jsonlFile}`);
|
|
370
|
+
return process.exit(1);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const replay = new Replay({ apiKey });
|
|
374
|
+
const { tests, runId } = replay.parseDebugFile(jsonlFile);
|
|
375
|
+
|
|
376
|
+
if (!runId) {
|
|
377
|
+
log.error('runId not found in JSONL file');
|
|
378
|
+
return process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
log.info(`Processing ${tests.length} tests from ${jsonlFile}`);
|
|
382
|
+
|
|
383
|
+
const client = new TestomatClient({
|
|
384
|
+
apiKey,
|
|
385
|
+
runId,
|
|
386
|
+
batchMode: BATCH_MODE.DISABLED,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
await client.createRun();
|
|
390
|
+
client.uploader.checkEnabled();
|
|
391
|
+
client.uploader.disableLogStorage();
|
|
392
|
+
|
|
393
|
+
const apiUrl = process.env.TESTOMATIO_URL || 'https://app.testomat.io';
|
|
394
|
+
const http = new Gaxios();
|
|
395
|
+
let uploadedCount = 0;
|
|
396
|
+
let failedCount = 0;
|
|
397
|
+
|
|
398
|
+
for (const test of tests) {
|
|
399
|
+
const testId = test.test_id;
|
|
400
|
+
if (!testId) continue;
|
|
401
|
+
|
|
402
|
+
const artifacts = [];
|
|
403
|
+
|
|
404
|
+
const collect = (items) => {
|
|
405
|
+
for (const item of items || []) {
|
|
406
|
+
const p = typeof item === 'object' ? item?.path : item;
|
|
407
|
+
if (p && fs.existsSync(p)) artifacts.push(p);
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
collect(test.files);
|
|
411
|
+
|
|
412
|
+
const walkSteps = (steps) => {
|
|
413
|
+
for (const step of steps || []) {
|
|
414
|
+
collect(step.artifacts);
|
|
415
|
+
walkSteps(step.steps);
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
walkSteps(test.steps);
|
|
419
|
+
|
|
420
|
+
if (artifacts.length === 0) continue;
|
|
421
|
+
|
|
422
|
+
const urls = [];
|
|
423
|
+
for (const artifact of artifacts) {
|
|
424
|
+
try {
|
|
425
|
+
const s3Id = test.rid || testId.replace('@', '');
|
|
426
|
+
const filename = generateShortFilename(artifact);
|
|
427
|
+
const result = await client.uploader.uploadFileByPath(artifact, [runId, s3Id, filename]);
|
|
428
|
+
if (result) urls.push(typeof result === 'string' ? result : result.link);
|
|
429
|
+
} catch (e) {
|
|
430
|
+
debug(`Failed to upload ${artifact}:`, e.message);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (urls.length === 0) continue;
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
await http.request({
|
|
438
|
+
method: 'POST',
|
|
439
|
+
url: `${apiUrl}/api/reporter/${runId}/testrun?api_key=${apiKey}`,
|
|
440
|
+
data: { test_id: testId, artifacts: urls },
|
|
441
|
+
});
|
|
442
|
+
uploadedCount++;
|
|
443
|
+
} catch (e) {
|
|
444
|
+
log.error(`Failed ${testId}: ${e.message}`);
|
|
445
|
+
failedCount++;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
log.info(`🗄️ ${uploadedCount} tests with artifacts uploaded`);
|
|
450
|
+
if (failedCount > 0) {
|
|
451
|
+
log.warn(`⚠️ ${failedCount} tests failed to upload artifacts`);
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
396
456
|
process.env.TESTOMATIO_DISABLE_ARTIFACTS = '';
|
|
397
457
|
const runId = process.env.TESTOMATIO_RUN || process.env.runId || readLatestRunId();
|
|
398
458
|
|
|
@@ -4,7 +4,6 @@ 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';
|
|
8
7
|
|
|
9
8
|
function AdapterFactory(lang, opts) {
|
|
10
9
|
if (lang === 'java') {
|
|
@@ -22,9 +21,6 @@ function AdapterFactory(lang, opts) {
|
|
|
22
21
|
if (lang === 'c#' || lang === 'csharp') {
|
|
23
22
|
return new CSharpAdapter(opts);
|
|
24
23
|
}
|
|
25
|
-
if (lang === 'kotlin') {
|
|
26
|
-
return new KotlinAdapter(opts);
|
|
27
|
-
}
|
|
28
24
|
|
|
29
25
|
return new Adapter(opts);
|
|
30
26
|
}
|
package/src/pipe/debug.js
CHANGED
|
@@ -111,6 +111,17 @@ export class DebugPipe {
|
|
|
111
111
|
log.info(`History: ${this.historyDir}`);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Logs artifacts data to the debug file.
|
|
116
|
+
* Used for trace.zip, video files and other artifacts uploaded after tests finish.
|
|
117
|
+
* @param {Array} artifacts - Array of artifacts with { rid, title, path, type }
|
|
118
|
+
*/
|
|
119
|
+
addArtifacts(artifacts) {
|
|
120
|
+
if (!this.isEnabled || !artifacts?.length) return;
|
|
121
|
+
const logData = { action: 'addArtifacts', artifacts, runId: this.store.runId };
|
|
122
|
+
this.logToFile(logData);
|
|
123
|
+
}
|
|
124
|
+
|
|
114
125
|
async sync() {
|
|
115
126
|
this.flushBufferedTests();
|
|
116
127
|
}
|
package/src/replay.js
CHANGED
|
@@ -49,6 +49,7 @@ export class Replay {
|
|
|
49
49
|
const testsWithoutRid = []; // For tests without rid (backward compatibility)
|
|
50
50
|
const envVars = {};
|
|
51
51
|
let runId = null;
|
|
52
|
+
const artifactsToAdd = []; // Store artifacts to add after all tests are loaded
|
|
52
53
|
|
|
53
54
|
// Parse debug file line by line
|
|
54
55
|
for (const [lineIndex, line] of lines.entries()) {
|
|
@@ -119,6 +120,11 @@ export class Replay {
|
|
|
119
120
|
// Handle tests without rid (no deduplication)
|
|
120
121
|
testsWithoutRid.push({ ...test });
|
|
121
122
|
}
|
|
123
|
+
} else if (logEntry.action === 'addArtifacts' && logEntry.artifacts) {
|
|
124
|
+
if (logEntry.runId && !runId) {
|
|
125
|
+
runId = logEntry.runId;
|
|
126
|
+
}
|
|
127
|
+
artifactsToAdd.push(...logEntry.artifacts);
|
|
122
128
|
} else if (logEntry.action === 'finishRun') {
|
|
123
129
|
finishParams = logEntry.params || {};
|
|
124
130
|
if (logEntry.runId && !runId) {
|
|
@@ -138,6 +144,21 @@ export class Replay {
|
|
|
138
144
|
this.onError(`${parseErrors - 3} more parse errors occurred`);
|
|
139
145
|
}
|
|
140
146
|
|
|
147
|
+
for (const artifact of artifactsToAdd) {
|
|
148
|
+
if (artifact.rid) {
|
|
149
|
+
const ridToFind = artifact.rid;
|
|
150
|
+
const fullRid = runId ? `${runId}-${ridToFind}` : ridToFind;
|
|
151
|
+
|
|
152
|
+
const test = testsMap.get(fullRid) || testsMap.get(ridToFind);
|
|
153
|
+
if (test && artifact.path) {
|
|
154
|
+
if (!test.files) test.files = [];
|
|
155
|
+
if (!test.files.includes(artifact.path)) {
|
|
156
|
+
test.files.push(artifact.path);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
141
162
|
// Combine tests with rid and tests without rid
|
|
142
163
|
const allTests = [...Array.from(testsMap.values()), ...testsWithoutRid];
|
|
143
164
|
|
|
@@ -681,8 +681,9 @@
|
|
|
681
681
|
|
|
682
682
|
.test-group-header {
|
|
683
683
|
padding: 20px;
|
|
684
|
-
background:
|
|
685
|
-
color:
|
|
684
|
+
background: var(--gray-50);
|
|
685
|
+
color: var(--gray-800);
|
|
686
|
+
border-left: 4px solid var(--primary-color);
|
|
686
687
|
cursor: pointer;
|
|
687
688
|
display: flex;
|
|
688
689
|
justify-content: space-between;
|
|
@@ -691,7 +692,37 @@
|
|
|
691
692
|
}
|
|
692
693
|
|
|
693
694
|
.test-group-header:hover {
|
|
694
|
-
|
|
695
|
+
background: var(--gray-100);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
.test-group-header.has-failures {
|
|
699
|
+
background: #fef2f2;
|
|
700
|
+
color: var(--gray-900);
|
|
701
|
+
border-left-color: var(--danger-color);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
.test-group-header.has-failures:hover {
|
|
705
|
+
background: #fee2e2;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
.test-group-header.has-skips {
|
|
709
|
+
background: #fffbeb;
|
|
710
|
+
color: var(--gray-900);
|
|
711
|
+
border-left-color: var(--warning-color);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
.test-group-header.has-skips:hover {
|
|
715
|
+
background: #fef3c7;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
.test-group-header.all-passed {
|
|
719
|
+
background: #ecfdf5;
|
|
720
|
+
color: var(--gray-900);
|
|
721
|
+
border-left-color: var(--success-color);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.test-group-header.all-passed:hover {
|
|
725
|
+
background: #d1fae5;
|
|
695
726
|
}
|
|
696
727
|
|
|
697
728
|
.test-group-title {
|
|
@@ -707,28 +738,56 @@
|
|
|
707
738
|
}
|
|
708
739
|
|
|
709
740
|
.test-group-count {
|
|
710
|
-
background:
|
|
741
|
+
background: white;
|
|
742
|
+
color: var(--gray-600);
|
|
711
743
|
padding: 6px 16px;
|
|
712
744
|
border-radius: 20px;
|
|
713
745
|
font-size: 14px;
|
|
714
746
|
font-weight: 600;
|
|
747
|
+
border: 1px solid var(--gray-200);
|
|
715
748
|
}
|
|
716
749
|
|
|
717
750
|
.test-group-stats {
|
|
718
751
|
display: flex;
|
|
719
|
-
gap:
|
|
752
|
+
gap: 8px;
|
|
720
753
|
font-size: 14px;
|
|
721
754
|
margin-right: 10px;
|
|
755
|
+
flex-wrap: wrap;
|
|
756
|
+
justify-content: flex-end;
|
|
722
757
|
}
|
|
723
758
|
|
|
724
759
|
.test-group-stat {
|
|
725
760
|
display: flex;
|
|
726
761
|
align-items: center;
|
|
727
762
|
gap: 6px;
|
|
763
|
+
padding: 6px 10px;
|
|
764
|
+
border-radius: 999px;
|
|
765
|
+
font-weight: 700;
|
|
766
|
+
background: white;
|
|
767
|
+
border: 1px solid currentColor;
|
|
728
768
|
}
|
|
729
769
|
|
|
730
|
-
.test-group-
|
|
770
|
+
.test-group-stat.passed {
|
|
771
|
+
color: var(--success-color);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
.test-group-stat.failed {
|
|
731
775
|
color: white;
|
|
776
|
+
background: var(--danger-color);
|
|
777
|
+
border-color: var(--danger-color);
|
|
778
|
+
box-shadow: 0 1px 3px rgba(239, 68, 68, 0.35);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
.test-group-stat.skipped {
|
|
782
|
+
color: var(--warning-color);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
.test-group-stat.todo {
|
|
786
|
+
color: #8b5cf6;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
.test-group-expand {
|
|
790
|
+
color: var(--gray-500);
|
|
732
791
|
transition: var(--transition);
|
|
733
792
|
font-size: 18px;
|
|
734
793
|
}
|
|
@@ -3366,11 +3425,18 @@
|
|
|
3366
3425
|
const failedCount = group.tests.filter(t => t.status.toLowerCase() === 'failed').length;
|
|
3367
3426
|
const skippedCount = group.tests.filter(t => t.status.toLowerCase() === 'skipped').length;
|
|
3368
3427
|
const todoCount = group.tests.filter(t => t.status.toLowerCase() === 'todo').length;
|
|
3428
|
+
const groupStateClass = failedCount > 0
|
|
3429
|
+
? 'has-failures'
|
|
3430
|
+
: skippedCount > 0 || todoCount > 0
|
|
3431
|
+
? 'has-skips'
|
|
3432
|
+
: passedCount > 0
|
|
3433
|
+
? 'all-passed'
|
|
3434
|
+
: '';
|
|
3369
3435
|
|
|
3370
3436
|
const groupIcon = group.type === 'suite' ? 'layer-group' : 'file-code';
|
|
3371
3437
|
|
|
3372
3438
|
const header = document.createElement('div');
|
|
3373
|
-
header.className = 'test-group-header';
|
|
3439
|
+
header.className = ['test-group-header', groupStateClass].filter(Boolean).join(' ');
|
|
3374
3440
|
header.innerHTML = `
|
|
3375
3441
|
<div class='test-group-title'>
|
|
3376
3442
|
<i class='fas fa-${groupIcon}'></i>
|
|
@@ -3378,10 +3444,10 @@
|
|
|
3378
3444
|
<div class='test-group-count'>${group.tests.length} tests</div>
|
|
3379
3445
|
</div>
|
|
3380
3446
|
<div class='test-group-stats'>
|
|
3381
|
-
${passedCount > 0 ? `<div class='test-group-stat
|
|
3382
|
-
${failedCount > 0 ? `<div class='test-group-stat
|
|
3383
|
-
${skippedCount > 0 ? `<div class='test-group-stat
|
|
3384
|
-
${todoCount > 0 ? `<div class='test-group-stat
|
|
3447
|
+
${passedCount > 0 ? `<div class='test-group-stat passed'><i class='fas fa-check-circle'></i> ${passedCount}</div>` : ''}
|
|
3448
|
+
${failedCount > 0 ? `<div class='test-group-stat failed'><i class='fas fa-times-circle'></i> ${failedCount}</div>` : ''}
|
|
3449
|
+
${skippedCount > 0 ? `<div class='test-group-stat skipped'><i class='fas fa-forward'></i> ${skippedCount}</div>` : ''}
|
|
3450
|
+
${todoCount > 0 ? `<div class='test-group-stat todo'><i class='fas fa-clock'></i> ${todoCount}</div>` : ''}
|
|
3385
3451
|
</div>
|
|
3386
3452
|
<i class='fas fa-chevron-down test-group-expand'></i>
|
|
3387
3453
|
`;
|
package/src/utils/pipe_utils.js
CHANGED
|
@@ -161,53 +161,6 @@ 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
|
-
|
|
211
164
|
/**
|
|
212
165
|
* Format a list of test IDs for `--filter-list` machine-readable output.
|
|
213
166
|
* Used when the CLI `--format` option is passed,
|
|
@@ -237,6 +190,4 @@ export {
|
|
|
237
190
|
fullName,
|
|
238
191
|
parsePipeOptions,
|
|
239
192
|
formatFilterListIds,
|
|
240
|
-
getObjectSize,
|
|
241
|
-
splitTestsIntoChunks,
|
|
242
193
|
};
|
package/src/utils/utils.js
CHANGED
|
@@ -289,11 +289,6 @@ 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}(`));
|
|
297
292
|
} else if (opts.lang === 'csharp') {
|
|
298
293
|
// Find the method declaration line
|
|
299
294
|
let methodLineIndex = lines.findIndex(l => l.includes(`public void ${title}(`));
|
package/src/xmlReader.js
CHANGED
|
@@ -18,7 +18,6 @@ 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';
|
|
22
21
|
import adapterFactory from './junit-adapter/index.js';
|
|
23
22
|
import { config } from './config.js';
|
|
24
23
|
import { S3Uploader } from './uploader.js';
|
|
@@ -554,6 +553,52 @@ class XmlReader {
|
|
|
554
553
|
return run;
|
|
555
554
|
}
|
|
556
555
|
|
|
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
|
+
|
|
557
602
|
async uploadData() {
|
|
558
603
|
await this.uploadArtifacts();
|
|
559
604
|
this.calculateStats();
|
|
@@ -578,7 +623,7 @@ class XmlReader {
|
|
|
578
623
|
return Promise.all(this.pipes.map(p => p.finishRun(finishData)));
|
|
579
624
|
}
|
|
580
625
|
|
|
581
|
-
const testChunks = splitTestsIntoChunks(this.tests);
|
|
626
|
+
const testChunks = this.#splitTestsIntoChunks(this.tests);
|
|
582
627
|
|
|
583
628
|
const totalChunks = testChunks.length;
|
|
584
629
|
const totalTests = this.tests.length;
|
package/src/allureReader.js
DELETED
|
@@ -1,691 +0,0 @@
|
|
|
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
|
-
// Use the @TmsLink / Testomat.io link as the test id so reported tests MATCH
|
|
197
|
-
// existing cases instead of creating duplicates on every run.
|
|
198
|
-
const testId = this.extractTestId(result);
|
|
199
|
-
if (testId) {
|
|
200
|
-
test.test_id = testId;
|
|
201
|
-
}
|
|
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 a Testomat.io test id from Allure links so reported tests match
|
|
381
|
-
* existing cases instead of creating duplicates.
|
|
382
|
-
*
|
|
383
|
-
* Allure's `@TmsLink("T1a2b3c4d")` produces a link with `type: "tms"`. Some exporters
|
|
384
|
-
* omit the type but still point the link URL at a Testomat.io test page; both are
|
|
385
|
-
* accepted. The link `name` is used as the id (falling back to the last URL segment).
|
|
386
|
-
*
|
|
387
|
-
* @param {object} result - Parsed Allure result JSON
|
|
388
|
-
* @returns {string|null} Normalized test id, or null when no usable link exists
|
|
389
|
-
*/
|
|
390
|
-
extractTestId(result) {
|
|
391
|
-
const links = result.links || [];
|
|
392
|
-
if (!links.length) return null;
|
|
393
|
-
|
|
394
|
-
const isTmsLink = l => typeof l?.type === 'string' && l.type.toLowerCase() === 'tms';
|
|
395
|
-
const isTestomatioLink = l => typeof l?.url === 'string' && /testomat\.io\/[^\s]*\/test\//i.test(l.url);
|
|
396
|
-
|
|
397
|
-
const link = links.find(isTmsLink) || links.find(isTestomatioLink);
|
|
398
|
-
if (!link) return null;
|
|
399
|
-
|
|
400
|
-
// Prefer the explicit link name; fall back to the id segment of a Testomat.io URL.
|
|
401
|
-
let id = this.normalizeTestId(link.name);
|
|
402
|
-
if (!id && typeof link.url === 'string') {
|
|
403
|
-
const fromUrl = link.url.match(/\/test\/([\w\d]{8})(?=$|[/?#])/i);
|
|
404
|
-
if (fromUrl) id = fromUrl[1];
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
return id;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/**
|
|
411
|
-
* Normalize a value into a Testomat.io test id.
|
|
412
|
-
*
|
|
413
|
-
* Testomat.io test ids are exactly **8 word characters**. The value may arrive bare
|
|
414
|
-
* (`1a2b3c4d`), or carrying the `T` / `@T` markers Testomat uses in code and titles
|
|
415
|
-
* (`T1a2b3c4d`, `@T1a2b3c4d`). The markers are removed only when doing so still leaves
|
|
416
|
-
* a valid 8-char id, so a real id that happens to start with `T` is preserved.
|
|
417
|
-
*
|
|
418
|
-
* Anything that does not resolve to a valid 8-char id — a numeric Allure TestOps id
|
|
419
|
-
* like `12345`, a JIRA key, a 6-digit TMS number — is rejected (returns null) so we
|
|
420
|
-
* never send an unmatchable id that would create duplicates.
|
|
421
|
-
*
|
|
422
|
-
* @param {string|number|null|undefined} value
|
|
423
|
-
* @returns {string|null} The bare 8-char id, or null when the value is not a valid id
|
|
424
|
-
*/
|
|
425
|
-
normalizeTestId(value) {
|
|
426
|
-
if (value === null || value === undefined) return null;
|
|
427
|
-
const match = value.toString().trim().match(/^@?T?([\w\d]{8})$/);
|
|
428
|
-
return match ? match[1] : null;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
convertSteps(steps, depth = 0) {
|
|
432
|
-
if (depth >= 10) return null;
|
|
433
|
-
|
|
434
|
-
return steps
|
|
435
|
-
.map(step => {
|
|
436
|
-
const convertedStep = {
|
|
437
|
-
category: 'user',
|
|
438
|
-
title: step.name || step.title || 'Unknown step',
|
|
439
|
-
status: this.mapStepStatus(step.status),
|
|
440
|
-
duration: this.calculateRunTime(step),
|
|
441
|
-
steps: this.convertSteps(step.steps || [], depth + 1),
|
|
442
|
-
};
|
|
443
|
-
|
|
444
|
-
// Attach the failure description (error message + trace with the failing
|
|
445
|
-
// code line) straight onto the failed step. Testomat.io renders a step's
|
|
446
|
-
// `error` inline in the step tree, so the failure shows up on the exact
|
|
447
|
-
// step that broke instead of only at the test level.
|
|
448
|
-
const error = this.extractStepError(step);
|
|
449
|
-
if (error) {
|
|
450
|
-
convertedStep.error = error;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
if (convertedStep.steps && convertedStep.steps.length === 0) {
|
|
454
|
-
delete convertedStep.steps;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
if (convertedStep.duration === 0) {
|
|
458
|
-
delete convertedStep.duration;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
return convertedStep;
|
|
462
|
-
})
|
|
463
|
-
.filter(Boolean);
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/**
|
|
467
|
-
* Build the `error` payload for a failed step from its Allure `statusDetails`.
|
|
468
|
-
*
|
|
469
|
-
* Allure stores the failure message and stack trace (which includes the failing
|
|
470
|
-
* source line) in `statusDetails` on the step itself. We only surface it for
|
|
471
|
-
* failed/broken steps — passing or skipped steps carry no error. Returns null
|
|
472
|
-
* when there is no usable failure information so the field is omitted entirely.
|
|
473
|
-
*
|
|
474
|
-
* @param {object} step - Allure step
|
|
475
|
-
* @returns {{message: string, stack: string}|null}
|
|
476
|
-
*/
|
|
477
|
-
extractStepError(step) {
|
|
478
|
-
if (this.mapStepStatus(step.status) !== 'failed') return null;
|
|
479
|
-
|
|
480
|
-
const details = step.statusDetails || {};
|
|
481
|
-
const message = details.message || '';
|
|
482
|
-
const stack = details.trace || '';
|
|
483
|
-
|
|
484
|
-
if (!message && !stack) return null;
|
|
485
|
-
|
|
486
|
-
return { message, stack };
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
calculateRunTime(item) {
|
|
490
|
-
if (item.start && item.stop) {
|
|
491
|
-
const durationMs = item.stop - item.start;
|
|
492
|
-
return durationMs / 1000;
|
|
493
|
-
}
|
|
494
|
-
return null;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
convertParameters(parameters) {
|
|
498
|
-
const example = {};
|
|
499
|
-
parameters.forEach(param => {
|
|
500
|
-
if (param.name) {
|
|
501
|
-
example[param.name] = param.value;
|
|
502
|
-
}
|
|
503
|
-
});
|
|
504
|
-
return example;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
combineRetryAttempts(attempts) {
|
|
508
|
-
attempts.sort((a, b) => (a._stop || 0) - (b._stop || 0));
|
|
509
|
-
|
|
510
|
-
const finalTest = attempts[attempts.length - 1];
|
|
511
|
-
const retryCount = attempts.length - 1;
|
|
512
|
-
|
|
513
|
-
if (retryCount > 0) {
|
|
514
|
-
finalTest.retries = retryCount;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
const failedAttempts = attempts.filter(t => t.status === 'failed');
|
|
518
|
-
|
|
519
|
-
if (failedAttempts.length === 0) {
|
|
520
|
-
return finalTest;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
const failureMessages = [];
|
|
524
|
-
const failureStacks = [];
|
|
525
|
-
|
|
526
|
-
for (const failed of failedAttempts) {
|
|
527
|
-
const attemptNum = attempts.indexOf(failed) + 1;
|
|
528
|
-
if (failed.message) {
|
|
529
|
-
failureMessages.push(`[Attempt ${attemptNum}] ${failed.message}`);
|
|
530
|
-
}
|
|
531
|
-
if (failed.stack) {
|
|
532
|
-
failureStacks.push(`\n--- Attempt ${attemptNum} ---\n${failed.stack}`);
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
if (failureMessages.length > 0) {
|
|
537
|
-
finalTest.message = failureMessages.join('\n');
|
|
538
|
-
}
|
|
539
|
-
if (failureStacks.length > 0) {
|
|
540
|
-
finalTest.stack = failureStacks.join('\n');
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
if (finalTest.status === 'passed') {
|
|
544
|
-
finalTest.status = 'failed';
|
|
545
|
-
const retryMsg = `Test passed after ${failedAttempts.length} retries. Previous failures:\n`;
|
|
546
|
-
finalTest.message = retryMsg + finalTest.message;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
return finalTest;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
calculateStats() {
|
|
553
|
-
this.stats = {
|
|
554
|
-
create_tests: true,
|
|
555
|
-
tests_count: this._tests.length,
|
|
556
|
-
passed_count: 0,
|
|
557
|
-
failed_count: 0,
|
|
558
|
-
skipped_count: 0,
|
|
559
|
-
duration: 0,
|
|
560
|
-
status: 'passed',
|
|
561
|
-
tests: this._tests,
|
|
562
|
-
};
|
|
563
|
-
this._tests.forEach(t => {
|
|
564
|
-
if (t.status === 'passed') this.stats.passed_count++;
|
|
565
|
-
if (t.status === 'failed') this.stats.failed_count++;
|
|
566
|
-
if (t.status === 'skipped') this.stats.skipped_count++;
|
|
567
|
-
});
|
|
568
|
-
this.stats.duration = this._tests.reduce((acc, t) => acc + (t.run_time || 0), 0);
|
|
569
|
-
if (this.stats.failed_count) this.stats.status = 'failed';
|
|
570
|
-
|
|
571
|
-
debug('Stats:', this.stats);
|
|
572
|
-
return this.stats;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
fetchSourceCode() {
|
|
576
|
-
const adapter = this.adapter || adapterFactory(this.getLanguage(), this.opts);
|
|
577
|
-
|
|
578
|
-
this._tests.forEach(t => {
|
|
579
|
-
try {
|
|
580
|
-
let filePath = t.file;
|
|
581
|
-
|
|
582
|
-
if (adapter && adapter.getFilePath) {
|
|
583
|
-
filePath = adapter.getFilePath(t);
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
if (!filePath) return;
|
|
587
|
-
|
|
588
|
-
if (!fs.existsSync(filePath)) {
|
|
589
|
-
debug('Source file not found:', filePath);
|
|
590
|
-
return;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
const contents = fs.readFileSync(filePath).toString();
|
|
594
|
-
const code = fetchSourceCode(contents, { ...t, lang: this.getLanguage() });
|
|
595
|
-
if (code) {
|
|
596
|
-
t.code = code;
|
|
597
|
-
debug('Fetched code for test %s', t.title);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
// Don't override an id already taken from a @TmsLink — the link is the
|
|
601
|
-
// explicit, source-independent match key the client maintains.
|
|
602
|
-
const testId = fetchIdFromCode(contents, { lang: this.getLanguage() });
|
|
603
|
-
if (testId && !t.test_id) {
|
|
604
|
-
t.test_id = testId;
|
|
605
|
-
debug('Fetched test id %s for test %s', testId, t.title);
|
|
606
|
-
}
|
|
607
|
-
} catch (err) {
|
|
608
|
-
debug('Failed to fetch source code:', err.message);
|
|
609
|
-
}
|
|
610
|
-
});
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
getLanguage() {
|
|
614
|
-
if (this._tests.length === 0) return null;
|
|
615
|
-
return this._tests[0].meta?.language || this.opts.lang;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
async uploadArtifacts() {
|
|
619
|
-
for (const test of this._tests.filter(t => t.files && t.files.length > 0)) {
|
|
620
|
-
const runId = this.runId || this.store.runId || Date.now().toString();
|
|
621
|
-
const artifacts = await Promise.all(
|
|
622
|
-
test.files.map(f => this.uploader.uploadFileByPath(f, [runId, test.rid, path.basename(f)])),
|
|
623
|
-
);
|
|
624
|
-
test.artifacts = artifacts.filter(a => a && a.link).map(a => a.link);
|
|
625
|
-
delete test.files;
|
|
626
|
-
if (test.artifacts.length > 0) {
|
|
627
|
-
console.log(APP_PREFIX, `🗄️ Uploaded ${pc.bold(`${test.artifacts.length} artifacts`)} for test ${test.title}`);
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
async uploadData() {
|
|
633
|
-
await this.uploadArtifacts();
|
|
634
|
-
this.calculateStats();
|
|
635
|
-
this.fetchSourceCode();
|
|
636
|
-
|
|
637
|
-
this.pipes = this.pipes || (await this.pipesPromise);
|
|
638
|
-
|
|
639
|
-
const finishData = {
|
|
640
|
-
api_key: this.requestParams.apiKey,
|
|
641
|
-
status: 'finished',
|
|
642
|
-
duration: this.stats.duration,
|
|
643
|
-
};
|
|
644
|
-
|
|
645
|
-
if (!this._tests || !Array.isArray(this._tests) || this._tests.length === 0) {
|
|
646
|
-
debug('No tests to upload, finishing run');
|
|
647
|
-
return Promise.all(this.pipes.map(p => p.finishRun(finishData)));
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
// Upload tests in size-limited chunks (max 1MB each), exactly like XmlReader.
|
|
651
|
-
// The testomatio pipe runs in MANUAL batch mode, so addTest only buffers tests and
|
|
652
|
-
// sync() flushes one batch request per chunk. There is no setInterval auto-upload,
|
|
653
|
-
// so each test is sent exactly once (no double-send) and requests stay under the limit.
|
|
654
|
-
const testChunks = splitTestsIntoChunks(this._tests);
|
|
655
|
-
|
|
656
|
-
const totalChunks = testChunks.length;
|
|
657
|
-
const totalTests = this._tests.length;
|
|
658
|
-
|
|
659
|
-
debug(`Split ${totalTests} tests into ${totalChunks} chunks (max 1MB per chunk)`);
|
|
660
|
-
|
|
661
|
-
let uploadedTests = 0;
|
|
662
|
-
for (let i = 0; i < testChunks.length; i++) {
|
|
663
|
-
const chunk = testChunks[i];
|
|
664
|
-
|
|
665
|
-
if (totalChunks > 1) {
|
|
666
|
-
debug(`Uploading chunk ${i + 1}/${totalChunks} (${chunk.length} tests)`);
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
// Buffer each test in the chunk, then flush the whole chunk as a single batch
|
|
670
|
-
for (const test of chunk) {
|
|
671
|
-
await Promise.all(this.pipes.map(p => p.addTest(test)));
|
|
672
|
-
}
|
|
673
|
-
await Promise.all(this.pipes.map(p => p.sync()));
|
|
674
|
-
|
|
675
|
-
uploadedTests += chunk.length;
|
|
676
|
-
debug(`Uploaded ${uploadedTests}/${totalTests} tests`);
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
if (totalChunks > 1) {
|
|
680
|
-
console.log(APP_PREFIX, `✅ Successfully uploaded ${uploadedTests} tests in ${totalChunks} chunks`);
|
|
681
|
-
} else {
|
|
682
|
-
console.log(APP_PREFIX, `✅ Successfully uploaded ${uploadedTests} tests`);
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
debug('Uploaded %d tests, finishing run', this._tests.length);
|
|
686
|
-
|
|
687
|
-
return Promise.all(this.pipes.map(p => p.finishRun(finishData)));
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
export default AllureReader;
|
|
@@ -1,48 +0,0 @@
|
|
|
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;
|