testomatio-reporter-cli 2.9.0 → 2.9.1-beta.1-allure-chunking

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-reporter-cli",
3
- "version": "2.9.0",
3
+ "version": "2.9.1-beta.1-allure-chunking",
4
4
  "description": "Yarn Berry compatible standalone CLI for @testomatio/reporter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,574 @@
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
+ // Add description if present
197
+ if (result.description) {
198
+ test.description = result.description;
199
+ }
200
+
201
+ if (result.parameters && result.parameters.length > 0) {
202
+ test.example = this.convertParameters(result.parameters);
203
+ }
204
+
205
+ if (result.attachments && result.attachments.length > 0) {
206
+ const attachments = result.attachments
207
+ .map(att => {
208
+ const fullPath = path.join(resultsDir, att.source);
209
+ if (fs.existsSync(fullPath)) {
210
+ return fullPath;
211
+ }
212
+ debug('Attachment file not found:', fullPath);
213
+ return null;
214
+ })
215
+ .filter(Boolean);
216
+
217
+ if (attachments.length > 0) {
218
+ test.files = attachments;
219
+ }
220
+ }
221
+
222
+ return test;
223
+ }
224
+
225
+ mapStatus(status) {
226
+ const statusMap = {
227
+ passed: 'passed',
228
+ failed: 'failed',
229
+ broken: 'failed',
230
+ skipped: 'skipped',
231
+ pending: 'skipped',
232
+ };
233
+ return statusMap[status] || 'failed';
234
+ }
235
+
236
+ extractSuiteTitle(result) {
237
+ const labels = result.labels || [];
238
+
239
+ // Only use suite label for suite_title
240
+ // Epic and Feature are sent as separate labels in meta
241
+ const suiteLabel = labels.find(l => l.name === 'suite')?.value;
242
+
243
+ if (suiteLabel) {
244
+ return this.stripNamespace(suiteLabel);
245
+ }
246
+
247
+ // Fallback to parentSuite or subSuite if no suite label
248
+ const parentSuite = labels.find(l => l.name === 'parentSuite')?.value;
249
+ const subSuite = labels.find(l => l.name === 'subSuite')?.value;
250
+
251
+ if (parentSuite && subSuite) {
252
+ return `${parentSuite} / ${subSuite}`;
253
+ }
254
+ if (parentSuite) return parentSuite;
255
+ if (subSuite) return subSuite;
256
+
257
+ return 'Default Suite';
258
+ }
259
+
260
+ stripNamespace(suiteName) {
261
+ if (suiteName && suiteName.includes('.')) {
262
+ return suiteName.split('.').pop();
263
+ }
264
+ return suiteName;
265
+ }
266
+
267
+ extractFile(result) {
268
+ const labels = result.labels || [];
269
+ const packageLabel = labels.find(l => l.name === 'package')?.value;
270
+ const testClassLabel = labels.find(l => l.name === 'testClass')?.value;
271
+
272
+ if (!packageLabel) {
273
+ return null;
274
+ }
275
+
276
+ const ext = this.getFileExtension(result);
277
+ let className;
278
+
279
+ if (testClassLabel) {
280
+ className = testClassLabel.split('.').pop();
281
+ } else if (result.fullName) {
282
+ const fullNameParts = result.fullName.split('.');
283
+ className = fullNameParts[fullNameParts.length - 2] || fullNameParts[fullNameParts.length - 1];
284
+ } else {
285
+ return null;
286
+ }
287
+
288
+ if (this.withPackage) {
289
+ const parts = packageLabel.split('.');
290
+ return `${parts.join('/')}/${className}.${ext}`;
291
+ }
292
+
293
+ return `${className}.${ext}`;
294
+ }
295
+
296
+ getFileExtension(result) {
297
+ const labels = result.labels || [];
298
+ const languageLabel = labels.find(l => l.name === 'language')?.value;
299
+
300
+ const extMap = {
301
+ java: 'java',
302
+ kotlin: 'kt',
303
+ javascript: 'js',
304
+ typescript: 'ts',
305
+ python: 'py',
306
+ ruby: 'rb',
307
+ 'c#': 'cs',
308
+ php: 'php',
309
+ };
310
+
311
+ return extMap[languageLabel?.toLowerCase()] || 'java';
312
+ }
313
+
314
+ extractMeta(result) {
315
+ const labels = result.labels || [];
316
+ const excludedLabels = [
317
+ 'suite', 'package', 'parentSuite', 'subSuite',
318
+ 'testClass', 'testMethod', 'epic', 'feature',
319
+ ];
320
+
321
+ const meta = {};
322
+ labels.forEach(label => {
323
+ if (!excludedLabels.includes(label.name)) {
324
+ meta[label.name] = label.value;
325
+ }
326
+ });
327
+
328
+ return meta;
329
+ }
330
+
331
+ extractLinks(result) {
332
+ const labels = result.labels || [];
333
+ const links = [];
334
+
335
+ const epicLabel = labels.find(l => l.name === 'epic');
336
+ const featureLabel = labels.find(l => l.name === 'feature');
337
+
338
+ if (epicLabel?.value) {
339
+ links.push({ label: `epic:${epicLabel.value}` });
340
+ }
341
+
342
+ if (featureLabel?.value) {
343
+ links.push({ label: `feature:${featureLabel.value}` });
344
+ }
345
+
346
+ return links.length > 0 ? links : undefined;
347
+ }
348
+
349
+ convertSteps(steps, depth = 0) {
350
+ if (depth >= 10) return null;
351
+
352
+ return steps
353
+ .map(step => {
354
+ const convertedStep = {
355
+ category: 'user',
356
+ title: step.name || step.title || 'Unknown step',
357
+ duration: this.calculateRunTime(step),
358
+ steps: this.convertSteps(step.steps || [], depth + 1),
359
+ };
360
+
361
+ if (convertedStep.steps && convertedStep.steps.length === 0) {
362
+ delete convertedStep.steps;
363
+ }
364
+
365
+ if (convertedStep.duration === 0) {
366
+ delete convertedStep.duration;
367
+ }
368
+
369
+ return convertedStep;
370
+ })
371
+ .filter(Boolean);
372
+ }
373
+
374
+ calculateRunTime(item) {
375
+ if (item.start && item.stop) {
376
+ const durationMs = item.stop - item.start;
377
+ return durationMs / 1000;
378
+ }
379
+ return null;
380
+ }
381
+
382
+ convertParameters(parameters) {
383
+ const example = {};
384
+ parameters.forEach(param => {
385
+ if (param.name) {
386
+ example[param.name] = param.value;
387
+ }
388
+ });
389
+ return example;
390
+ }
391
+
392
+ combineRetryAttempts(attempts) {
393
+ attempts.sort((a, b) => (a._stop || 0) - (b._stop || 0));
394
+
395
+ const finalTest = attempts[attempts.length - 1];
396
+ const retryCount = attempts.length - 1;
397
+
398
+ if (retryCount > 0) {
399
+ finalTest.retries = retryCount;
400
+ }
401
+
402
+ const failedAttempts = attempts.filter(t => t.status === 'failed');
403
+
404
+ if (failedAttempts.length === 0) {
405
+ return finalTest;
406
+ }
407
+
408
+ const failureMessages = [];
409
+ const failureStacks = [];
410
+
411
+ for (const failed of failedAttempts) {
412
+ const attemptNum = attempts.indexOf(failed) + 1;
413
+ if (failed.message) {
414
+ failureMessages.push(`[Attempt ${attemptNum}] ${failed.message}`);
415
+ }
416
+ if (failed.stack) {
417
+ failureStacks.push(`\n--- Attempt ${attemptNum} ---\n${failed.stack}`);
418
+ }
419
+ }
420
+
421
+ if (failureMessages.length > 0) {
422
+ finalTest.message = failureMessages.join('\n');
423
+ }
424
+ if (failureStacks.length > 0) {
425
+ finalTest.stack = failureStacks.join('\n');
426
+ }
427
+
428
+ if (finalTest.status === 'passed') {
429
+ finalTest.status = 'failed';
430
+ const retryMsg = `Test passed after ${failedAttempts.length} retries. Previous failures:\n`;
431
+ finalTest.message = retryMsg + finalTest.message;
432
+ }
433
+
434
+ return finalTest;
435
+ }
436
+
437
+ calculateStats() {
438
+ this.stats = {
439
+ create_tests: true,
440
+ tests_count: this._tests.length,
441
+ passed_count: 0,
442
+ failed_count: 0,
443
+ skipped_count: 0,
444
+ duration: 0,
445
+ status: 'passed',
446
+ tests: this._tests,
447
+ };
448
+ this._tests.forEach(t => {
449
+ if (t.status === 'passed') this.stats.passed_count++;
450
+ if (t.status === 'failed') this.stats.failed_count++;
451
+ if (t.status === 'skipped') this.stats.skipped_count++;
452
+ });
453
+ this.stats.duration = this._tests.reduce((acc, t) => acc + (t.run_time || 0), 0);
454
+ if (this.stats.failed_count) this.stats.status = 'failed';
455
+
456
+ debug('Stats:', this.stats);
457
+ return this.stats;
458
+ }
459
+
460
+ fetchSourceCode() {
461
+ const adapter = this.adapter || adapterFactory(this.getLanguage(), this.opts);
462
+
463
+ this._tests.forEach(t => {
464
+ try {
465
+ let filePath = t.file;
466
+
467
+ if (adapter && adapter.getFilePath) {
468
+ filePath = adapter.getFilePath(t);
469
+ }
470
+
471
+ if (!filePath) return;
472
+
473
+ if (!fs.existsSync(filePath)) {
474
+ debug('Source file not found:', filePath);
475
+ return;
476
+ }
477
+
478
+ const contents = fs.readFileSync(filePath).toString();
479
+ const code = fetchSourceCode(contents, { ...t, lang: this.getLanguage() });
480
+ if (code) {
481
+ t.code = code;
482
+ debug('Fetched code for test %s', t.title);
483
+ }
484
+
485
+ const testId = fetchIdFromCode(contents, { lang: this.getLanguage() });
486
+ if (testId) {
487
+ t.test_id = testId;
488
+ debug('Fetched test id %s for test %s', testId, t.title);
489
+ }
490
+ } catch (err) {
491
+ debug('Failed to fetch source code:', err.message);
492
+ }
493
+ });
494
+ }
495
+
496
+ getLanguage() {
497
+ if (this._tests.length === 0) return null;
498
+ return this._tests[0].meta?.language || this.opts.lang;
499
+ }
500
+
501
+ async uploadArtifacts() {
502
+ for (const test of this._tests.filter(t => t.files && t.files.length > 0)) {
503
+ const runId = this.runId || this.store.runId || Date.now().toString();
504
+ const artifacts = await Promise.all(
505
+ test.files.map(f => this.uploader.uploadFileByPath(f, [runId, test.rid, path.basename(f)])),
506
+ );
507
+ test.artifacts = artifacts.filter(a => a && a.link).map(a => a.link);
508
+ delete test.files;
509
+ if (test.artifacts.length > 0) {
510
+ console.log(APP_PREFIX, `🗄️ Uploaded ${pc.bold(`${test.artifacts.length} artifacts`)} for test ${test.title}`);
511
+ }
512
+ }
513
+ }
514
+
515
+ async uploadData() {
516
+ await this.uploadArtifacts();
517
+ this.calculateStats();
518
+ this.fetchSourceCode();
519
+
520
+ this.pipes = this.pipes || (await this.pipesPromise);
521
+
522
+ const finishData = {
523
+ api_key: this.requestParams.apiKey,
524
+ status: 'finished',
525
+ duration: this.stats.duration,
526
+ };
527
+
528
+ if (!this._tests || !Array.isArray(this._tests) || this._tests.length === 0) {
529
+ debug('No tests to upload, finishing run');
530
+ return Promise.all(this.pipes.map(p => p.finishRun(finishData)));
531
+ }
532
+
533
+ // Upload tests in size-limited chunks (max 1MB each), exactly like XmlReader.
534
+ // The testomatio pipe runs in MANUAL batch mode, so addTest only buffers tests and
535
+ // sync() flushes one batch request per chunk. There is no setInterval auto-upload,
536
+ // so each test is sent exactly once (no double-send) and requests stay under the limit.
537
+ const testChunks = splitTestsIntoChunks(this._tests);
538
+
539
+ const totalChunks = testChunks.length;
540
+ const totalTests = this._tests.length;
541
+
542
+ debug(`Split ${totalTests} tests into ${totalChunks} chunks (max 1MB per chunk)`);
543
+
544
+ let uploadedTests = 0;
545
+ for (let i = 0; i < testChunks.length; i++) {
546
+ const chunk = testChunks[i];
547
+
548
+ if (totalChunks > 1) {
549
+ debug(`Uploading chunk ${i + 1}/${totalChunks} (${chunk.length} tests)`);
550
+ }
551
+
552
+ // Buffer each test in the chunk, then flush the whole chunk as a single batch
553
+ for (const test of chunk) {
554
+ await Promise.all(this.pipes.map(p => p.addTest(test)));
555
+ }
556
+ await Promise.all(this.pipes.map(p => p.sync()));
557
+
558
+ uploadedTests += chunk.length;
559
+ debug(`Uploaded ${uploadedTests}/${totalTests} tests`);
560
+ }
561
+
562
+ if (totalChunks > 1) {
563
+ console.log(APP_PREFIX, `✅ Successfully uploaded ${uploadedTests} tests in ${totalChunks} chunks`);
564
+ } else {
565
+ console.log(APP_PREFIX, `✅ Successfully uploaded ${uploadedTests} tests`);
566
+ }
567
+
568
+ debug('Uploaded %d tests, finishing run', this._tests.length);
569
+
570
+ return Promise.all(this.pipes.map(p => p.finishRun(finishData)));
571
+ }
572
+ }
573
+
574
+ 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';
@@ -351,6 +352,40 @@ program
351
352
  if (timeoutTimer) clearTimeout(timeoutTimer);
352
353
  });
353
354
 
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
+
354
389
  program
355
390
  .command('upload-artifacts')
356
391
  .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;
@@ -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
  };
@@ -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 = this.#splitTestsIntoChunks(this.tests);
581
+ const testChunks = splitTestsIntoChunks(this.tests);
627
582
 
628
583
  const totalChunks = testChunks.length;
629
584
  const totalTests = this.tests.length;