@testomatio/reporter 2.10.1 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/bin/cli.js CHANGED
@@ -84,6 +84,10 @@ program
84
84
  log_js_1.log.error(picocolors_1.default.red('Failed to create run on Testomat.io.'));
85
85
  process.exit(1);
86
86
  }
87
+ // tests are executed later, so report the run as pending with the tests it was scoped to:
88
+ // pipes add their report now and replace it when the run is finished
89
+ const plannedTests = (client.pipeStore.preparedTestIds || []).map(id => ({ test_id: id, title: id }));
90
+ await client.updateRunStatus('pending', { tests: plannedTests });
87
91
  // stdout carries ONLY the run id so it can be captured: RUN_ID=$(reporter start)
88
92
  console.log(runId);
89
93
  process.exit(0);
@@ -100,8 +104,11 @@ program
100
104
  console.log('Finishing Run on Testomat.io...');
101
105
  const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config_js_1.config.TESTOMATIO;
102
106
  const client = new client_js_1.default({ apiKey });
107
+ const finishParams = {};
108
+ if ((0, utils_js_1.transformEnvVarToBoolean)(process.env.TESTOMATIO_FINISH_SHARED_RUN))
109
+ finishParams.force_finish_shared_run = true;
103
110
  // @ts-ignore
104
- client.updateRunStatus(constants_js_1.STATUS.FINISHED).then(() => {
111
+ client.updateRunStatus(constants_js_1.STATUS.FINISHED, finishParams).then(() => {
105
112
  process.exit(0);
106
113
  });
107
114
  });
package/lib/client.d.ts CHANGED
@@ -64,11 +64,11 @@ export class Client {
64
64
  /**
65
65
  *
66
66
  * Updates the status of the current test run and finishes the run.
67
- * @param {'passed' | 'failed' | 'skipped' | 'finished'} status - The status of the current test run.
67
+ * @param {'passed' | 'failed' | 'skipped' | 'finished' | 'pending'} status - The status of the current test run.
68
68
  * @param {Partial<import('../types/types.js').RunData>} [params] - Additional run params (e.g. duration).
69
69
  * Must be one of "passed", "failed", or "finished"
70
70
  * @returns {Promise<any>} - A Promise that resolves when finishes the run.
71
71
  */
72
- updateRunStatus(status: "passed" | "failed" | "skipped" | "finished", params?: Partial<import("../types/types.js").RunData>): Promise<any>;
72
+ updateRunStatus(status: "passed" | "failed" | "skipped" | "finished" | "pending", params?: Partial<import("../types/types.js").RunData>): Promise<any>;
73
73
  }
74
74
  import { S3Uploader } from './uploader.js';
package/lib/client.js CHANGED
@@ -192,9 +192,9 @@ class Client {
192
192
  title: 'Unknown test',
193
193
  suite_title: 'Unknown suite',
194
194
  };
195
- // Add timestamp if not already present (microseconds since Unix epoch)
195
+ // Add timestamp if not already present (Unix seconds)
196
196
  if (!testData.timestamp && !process.env.TESTOMATIO_NO_TIMESTAMP) {
197
- testData.timestamp = Math.floor((performance.timeOrigin + performance.now()) * 1000);
197
+ testData.timestamp = Math.floor((performance.timeOrigin + performance.now()) / 1000);
198
198
  }
199
199
  /**
200
200
  * @type {TestData}
@@ -319,7 +319,7 @@ class Client {
319
319
  /**
320
320
  *
321
321
  * Updates the status of the current test run and finishes the run.
322
- * @param {'passed' | 'failed' | 'skipped' | 'finished'} status - The status of the current test run.
322
+ * @param {'passed' | 'failed' | 'skipped' | 'finished' | 'pending'} status - The status of the current test run.
323
323
  * @param {Partial<import('../types/types.js').RunData>} [params] - Additional run params (e.g. duration).
324
324
  * Must be one of "passed", "failed", or "finished"
325
325
  * @returns {Promise<any>} - A Promise that resolves when finishes the run.
@@ -0,0 +1,5 @@
1
+ export default DartAdapter;
2
+ declare class DartAdapter extends Adapter {
3
+ getFilePath(t: any): string;
4
+ }
5
+ import Adapter from './adapter.js';
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const path_1 = __importDefault(require("path"));
7
+ const adapter_js_1 = __importDefault(require("./adapter.js"));
8
+ class DartAdapter extends adapter_js_1.default {
9
+ getFilePath(t) {
10
+ if (t.title.startsWith('runDartTest[')) {
11
+ // Android: runDartTest[tests.path.to.test Test name]
12
+ const fileName = namespaceToFileName(t.title.split('[')[1].split(' ')[0]);
13
+ return fileName;
14
+ }
15
+ // iOS: RunnerUITests tests.path.to.test Test name
16
+ const parts = t.title.split(' ');
17
+ const pathToken = parts.length > 1 ? parts[1] : parts[0];
18
+ return namespaceToFileName(pathToken);
19
+ }
20
+ formatTest(t) {
21
+ if (t.title.startsWith('runDartTest[')) {
22
+ // Android: runDartTest[<path> <name>]
23
+ // First space is between the path and the test name; strip up to and including it, remove trailing ']'
24
+ const spaceIndex = t.title.indexOf(' ');
25
+ if (spaceIndex > -1) {
26
+ const pathToken = t.title.slice('runDartTest['.length, spaceIndex);
27
+ t.file = namespaceToFileName(pathToken);
28
+ t.title = t.title.slice(spaceIndex + 1).replace(/\]$/, '');
29
+ }
30
+ }
31
+ else {
32
+ // iOS: <ClassName> <test.path> <test name>
33
+ // Skip both the classname token and the dot-separated test path token
34
+ const parts = t.title.split(' ');
35
+ if (parts.length > 2 && parts[1] && parts[1].includes('.')) {
36
+ t.file = namespaceToFileName(parts[1]);
37
+ t.title = parts.slice(2).join(' ');
38
+ }
39
+ else {
40
+ const spaceIndex = t.title.indexOf(' ');
41
+ if (spaceIndex > -1) {
42
+ t.title = t.title.slice(spaceIndex + 1);
43
+ }
44
+ }
45
+ }
46
+ // classname: cut everything after the last dot (inclusive)
47
+ if (t.suite_title && t.suite_title.includes('.')) {
48
+ const lastDot = t.suite_title.lastIndexOf('.');
49
+ t.suite_title = t.suite_title.slice(0, lastDot);
50
+ }
51
+ if (!t.file) {
52
+ t.file = namespaceToFileName(t.suite_title || '');
53
+ }
54
+ // detect params
55
+ const paramMatches = t.title.match(/\[(.*?)\]/g);
56
+ if (paramMatches) {
57
+ const params = paramMatches.map((_match, index) => `param${index + 1}`);
58
+ if (params.length === 1)
59
+ params[0] = 'param';
60
+ let paramIndex = 0;
61
+ t.title = t.title.replace(/\[(.*?)\]/g, () => {
62
+ if (params.length < 2)
63
+ return `\${param}`;
64
+ const paramName = params[paramIndex] || `param${paramIndex + 1}`;
65
+ paramIndex++;
66
+ return `\${${paramName}}`;
67
+ });
68
+ const example = {};
69
+ paramMatches.forEach((match, index) => {
70
+ example[params[index]] = match.replace(/[[\]]/g, '');
71
+ });
72
+ t.example = example;
73
+ }
74
+ return t;
75
+ }
76
+ }
77
+ function namespaceToFileName(fileName) {
78
+ let testName = fileName;
79
+ // If it's already an extracted test name (contains dots but no runDartTest prefix)
80
+ if (fileName.includes('.') && !fileName.startsWith('runDartTest')) {
81
+ // Take only the first part before any spaces (the actual test path)
82
+ testName = fileName.split(' ')[0];
83
+ }
84
+ else {
85
+ // Legacy handling for full runDartTest[...] format
86
+ const dartMatch = fileName.match(/runDartTest\[(.+?) /);
87
+ if (dartMatch) {
88
+ testName = dartMatch[1];
89
+ }
90
+ else {
91
+ const parts = fileName.split(' ');
92
+ if (parts.length > 1) {
93
+ testName = parts[1];
94
+ }
95
+ }
96
+ }
97
+ const fileParts = testName.split('.');
98
+ fileParts[fileParts.length - 1] = fileParts[fileParts.length - 1]?.replace(/\$.*/, '');
99
+ return `${fileParts.join(path_1.default.sep)}.dart`;
100
+ }
101
+ module.exports = DartAdapter;
@@ -10,6 +10,7 @@ const python_js_1 = __importDefault(require("./python.js"));
10
10
  const ruby_js_1 = __importDefault(require("./ruby.js"));
11
11
  const csharp_js_1 = __importDefault(require("./csharp.js"));
12
12
  const kotlin_js_1 = __importDefault(require("./kotlin.js"));
13
+ const dart_js_1 = __importDefault(require("./dart.js"));
13
14
  function AdapterFactory(lang, opts) {
14
15
  if (lang === 'java') {
15
16
  return new java_js_1.default(opts);
@@ -29,6 +30,9 @@ function AdapterFactory(lang, opts) {
29
30
  if (lang === 'kotlin') {
30
31
  return new kotlin_js_1.default(opts);
31
32
  }
33
+ if (lang === 'dart') {
34
+ return new dart_js_1.default(opts);
35
+ }
32
36
  return new adapter_js_1.default(opts);
33
37
  }
34
38
  module.exports = AdapterFactory;
package/lib/pipe/html.js CHANGED
@@ -216,7 +216,7 @@ class HtmlPipe {
216
216
  runUrl: this.store.runUrl || '',
217
217
  executionTime: testExecutionSumTime(aggregatedTests),
218
218
  executionDate: getCurrentDateTimeFormatted(),
219
- description: [runParams.description || this.store.coverageDescription || this.store.description, this.description]
219
+ description: [this.description, runParams.description || this.store.coverageDescription || this.store.description]
220
220
  .filter(Boolean)
221
221
  .join('\n\n') || '',
222
222
  configuration: buildDisplayConfiguration(this.configuration || this.store.configuration || runParams.configuration || null),
@@ -117,7 +117,7 @@ class MarkdownPipe {
117
117
  isParallel: runParams?.isParallel,
118
118
  executionTime: testExecutionSumTime(aggregated),
119
119
  executionDate: getCurrentDateTimeFormatted(),
120
- description: [runParams?.description || this.store.coverageDescription || this.store.description, this.description]
120
+ description: [this.description, runParams?.description || this.store.coverageDescription || this.store.description]
121
121
  .filter(Boolean)
122
122
  .join('\n\n') || '',
123
123
  configuration: this.configuration || this.store.configuration || runParams?.configuration || null,
@@ -147,6 +147,13 @@ class TestomatioPipe {
147
147
  // add test ID + run ID
148
148
  if (data.rid)
149
149
  data.rid = `${this.runId}-${data.rid}`;
150
+ // convert timestamp to seconds
151
+ if (data.timestamp > 1e11) {
152
+ if (data.timestamp > 1e14)
153
+ data.timestamp = Math.floor(data.timestamp / 1e6); // convert microseconds
154
+ else if (data.timestamp > 1e11)
155
+ data.timestamp = Math.floor(data.timestamp / 1e3); // convert milliseconds
156
+ }
150
157
  if (!process.env.TESTOMATIO_STACK_PASSED && data.status === constants_js_1.STATUS.PASSED) {
151
158
  data.stack = null;
152
159
  }
@@ -240,8 +247,9 @@ class TestomatioPipe {
240
247
  suites: coverageConfiguration.suites?.map(id => id.replace(/^S/, '')) || [],
241
248
  };
242
249
  }
243
- // Run description: coverage-derived block (if any) with the user-provided TESTOMATIO_DESCRIPTION appended after it.
244
- const description = [coverageDescription, this.description].filter(Boolean).join('\n\n') || null;
250
+ // Run description: the user-provided TESTOMATIO_DESCRIPTION with the coverage-derived block
251
+ // (if any) added after it. Neither overrides the other.
252
+ const description = [this.description, coverageDescription].filter(Boolean).join('\n\n') || null;
245
253
  // Merge caller-supplied configuration (e.g. { exploratory: true }) into runParams.configuration.
246
254
  // Caller values win on key conflict; coverage-derived tests/suites lists are preserved when not overridden.
247
255
  if (params.configuration && typeof params.configuration === 'object') {
@@ -504,6 +512,9 @@ class TestomatioPipe {
504
512
  console.warn(`${constants_js_1.APP_PREFIX} ${errorMessage}`);
505
513
  }
506
514
  const { status } = params;
515
+ // a pending run was just created: nothing to update here, only other pipes report it
516
+ if (status === 'pending')
517
+ return;
507
518
  let status_event;
508
519
  if (status === constants_js_1.STATUS.FINISHED)
509
520
  status_event = 'finish';
@@ -522,6 +533,7 @@ class TestomatioPipe {
522
533
  status_event,
523
534
  detach: params.detach,
524
535
  tests: params.tests,
536
+ ...(params.force_finish_shared_run && { force_finish_shared_run: true }),
525
537
  },
526
538
  });
527
539
  if (this.runUrl) {
@@ -121,6 +121,8 @@ function statusEmoji(status) {
121
121
  return '🔴';
122
122
  if (status === 'skipped')
123
123
  return '🟡';
124
+ if (status === 'pending')
125
+ return '🕐';
124
126
  return '';
125
127
  }
126
128
  /**
@@ -303,6 +303,24 @@ const fetchIdFromOutput = output => {
303
303
  return output.match(TID_FULL_PATTERN)?.[2];
304
304
  };
305
305
  exports.fetchIdFromOutput = fetchIdFromOutput;
306
+ const findDartTestLine = (lines, expectedTitle) => {
307
+ const testFunctions = ['patrolTest', 'testWidgets'];
308
+ const quotedTitles = [`'${expectedTitle}',`, `"${expectedTitle}",`, `r'${expectedTitle}',`, `r"${expectedTitle}",`];
309
+ return lines.findIndex((_line, index) => {
310
+ const declaration = lines
311
+ .slice(index, index + 5)
312
+ .map(line => line.trim())
313
+ .join(' ');
314
+ const functionName = testFunctions.find(name => declaration.startsWith(name));
315
+ if (!functionName)
316
+ return false;
317
+ const argumentsList = declaration.slice(functionName.length).trimStart();
318
+ if (!argumentsList.startsWith('('))
319
+ return false;
320
+ const firstArgument = argumentsList.slice(1).trimStart();
321
+ return quotedTitles.some(title => firstArgument.startsWith(title));
322
+ });
323
+ };
306
324
  const fetchSourceCode = (contents, opts = {}) => {
307
325
  if (!opts.title && !opts.line)
308
326
  return '';
@@ -387,6 +405,31 @@ const fetchSourceCode = (contents, opts = {}) => {
387
405
  }
388
406
  }
389
407
  }
408
+ else if (opts.lang === 'dart') {
409
+ // For Dart, locate the specific patrolTest/testWidgets block by title first —
410
+ // otherwise every test sharing the same main() gets the same @T comment.
411
+ const rawTitle = opts.title || '';
412
+ let dartTestTitle = '';
413
+ if (rawTitle.startsWith('runDartTest[')) {
414
+ // Android: runDartTest[<path> <test name>]
415
+ const spaceIndex = rawTitle.indexOf(' ');
416
+ if (spaceIndex > -1) {
417
+ dartTestTitle = rawTitle.slice(spaceIndex + 1).replace(/\]$/, '');
418
+ }
419
+ }
420
+ else {
421
+ // iOS: <ClassName> <test.path> <test name>
422
+ const parts = rawTitle.split(' ');
423
+ if (parts.length > 2 && parts[1] && parts[1].includes('.')) {
424
+ dartTestTitle = parts.slice(2).join(' ');
425
+ }
426
+ }
427
+ let testLineIndex = -1;
428
+ if (dartTestTitle) {
429
+ testLineIndex = findDartTestLine(lines, dartTestTitle);
430
+ }
431
+ lineIndex = testLineIndex;
432
+ }
390
433
  else {
391
434
  lineIndex = lines.findIndex(l => l.includes(title));
392
435
  }
@@ -401,8 +444,8 @@ const fetchSourceCode = (contents, opts = {}) => {
401
444
  for (let i = lineIndex; i < lineIndex + limit; i++) {
402
445
  if (lines[i] === undefined)
403
446
  continue;
404
- // Track brace depth for C# to stop after method closes
405
- if (opts.lang === 'csharp') {
447
+ // Track brace depth for C# and Dart to stop after method/main closes
448
+ if (opts.lang === 'csharp' || opts.lang === 'dart') {
406
449
  const line = lines[i];
407
450
  // Count opening and closing braces
408
451
  const openBraces = (line.match(/\{/g) || []).length;
package/lib/xmlReader.js CHANGED
@@ -405,6 +405,8 @@ class XmlReader {
405
405
  this.stats.language = 'ts';
406
406
  if (file.endsWith('.cs'))
407
407
  this.stats.language = 'csharp';
408
+ if (file.endsWith('.dart'))
409
+ this.stats.language = 'dart';
408
410
  }
409
411
  if (!fs_1.default.existsSync(file)) {
410
412
  debug('Failed to open file with the source code', file);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.10.1",
3
+ "version": "2.12.0",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
package/src/bin/cli.js CHANGED
@@ -8,7 +8,7 @@ import TestomatClient from '../client.js';
8
8
  import XmlReader from '../xmlReader.js';
9
9
  import AllureReader from '../allureReader.js';
10
10
  import { APP_PREFIX, STATUS, DEBUG_FILE, BATCH_MODE } from '../constants.js';
11
- import { cleanLatestRunId, getPackageVersion, applyFilter } from '../utils/utils.js';
11
+ import { cleanLatestRunId, getPackageVersion, applyFilter, transformEnvVarToBoolean } from '../utils/utils.js';
12
12
  import { config } from '../config.js';
13
13
  import { readLatestRunId } from '../utils/utils.js';
14
14
  import pc from 'picocolors';
@@ -87,6 +87,11 @@ program
87
87
  process.exit(1);
88
88
  }
89
89
 
90
+ // tests are executed later, so report the run as pending with the tests it was scoped to:
91
+ // pipes add their report now and replace it when the run is finished
92
+ const plannedTests = (client.pipeStore.preparedTestIds || []).map(id => ({ test_id: id, title: id }));
93
+ await client.updateRunStatus('pending', { tests: plannedTests });
94
+
90
95
  // stdout carries ONLY the run id so it can be captured: RUN_ID=$(reporter start)
91
96
  console.log(runId);
92
97
  process.exit(0);
@@ -107,8 +112,11 @@ program
107
112
  const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config.TESTOMATIO;
108
113
  const client = new TestomatClient({ apiKey });
109
114
 
115
+ const finishParams = {};
116
+ if (transformEnvVarToBoolean(process.env.TESTOMATIO_FINISH_SHARED_RUN)) finishParams.force_finish_shared_run = true;
117
+
110
118
  // @ts-ignore
111
- client.updateRunStatus(STATUS.FINISHED).then(() => {
119
+ client.updateRunStatus(STATUS.FINISHED, finishParams).then(() => {
112
120
  process.exit(0);
113
121
  });
114
122
  });
package/src/client.js CHANGED
@@ -206,9 +206,9 @@ class Client {
206
206
  suite_title: 'Unknown suite',
207
207
  };
208
208
 
209
- // Add timestamp if not already present (microseconds since Unix epoch)
209
+ // Add timestamp if not already present (Unix seconds)
210
210
  if (!testData.timestamp && !process.env.TESTOMATIO_NO_TIMESTAMP) {
211
- testData.timestamp = Math.floor((performance.timeOrigin + performance.now()) * 1000);
211
+ testData.timestamp = Math.floor((performance.timeOrigin + performance.now()) / 1000);
212
212
  }
213
213
 
214
214
  /**
@@ -371,7 +371,7 @@ class Client {
371
371
  /**
372
372
  *
373
373
  * Updates the status of the current test run and finishes the run.
374
- * @param {'passed' | 'failed' | 'skipped' | 'finished'} status - The status of the current test run.
374
+ * @param {'passed' | 'failed' | 'skipped' | 'finished' | 'pending'} status - The status of the current test run.
375
375
  * @param {Partial<import('../types/types.js').RunData>} [params] - Additional run params (e.g. duration).
376
376
  * Must be one of "passed", "failed", or "finished"
377
377
  * @returns {Promise<any>} - A Promise that resolves when finishes the run.
@@ -0,0 +1,103 @@
1
+ import path from 'path';
2
+ import Adapter from './adapter.js';
3
+
4
+ class DartAdapter extends Adapter {
5
+
6
+ getFilePath(t) {
7
+ if (t.title.startsWith('runDartTest[')) {
8
+ // Android: runDartTest[tests.path.to.test Test name]
9
+ const fileName = namespaceToFileName(t.title.split('[')[1].split(' ')[0]);
10
+ return fileName;
11
+ }
12
+ // iOS: RunnerUITests tests.path.to.test Test name
13
+ const parts = t.title.split(' ');
14
+ const pathToken = parts.length > 1 ? parts[1] : parts[0];
15
+ return namespaceToFileName(pathToken);
16
+ }
17
+
18
+ formatTest(t) {
19
+ if (t.title.startsWith('runDartTest[')) {
20
+ // Android: runDartTest[<path> <name>]
21
+ // First space is between the path and the test name; strip up to and including it, remove trailing ']'
22
+ const spaceIndex = t.title.indexOf(' ');
23
+ if (spaceIndex > -1) {
24
+ const pathToken = t.title.slice('runDartTest['.length, spaceIndex);
25
+ t.file = namespaceToFileName(pathToken);
26
+ t.title = t.title.slice(spaceIndex + 1).replace(/\]$/, '');
27
+ }
28
+ } else {
29
+ // iOS: <ClassName> <test.path> <test name>
30
+ // Skip both the classname token and the dot-separated test path token
31
+ const parts = t.title.split(' ');
32
+ if (parts.length > 2 && parts[1] && parts[1].includes('.')) {
33
+ t.file = namespaceToFileName(parts[1]);
34
+ t.title = parts.slice(2).join(' ');
35
+ } else {
36
+ const spaceIndex = t.title.indexOf(' ');
37
+ if (spaceIndex > -1) {
38
+ t.title = t.title.slice(spaceIndex + 1);
39
+ }
40
+ }
41
+ }
42
+
43
+ // classname: cut everything after the last dot (inclusive)
44
+ if (t.suite_title && t.suite_title.includes('.')) {
45
+ const lastDot = t.suite_title.lastIndexOf('.');
46
+ t.suite_title = t.suite_title.slice(0, lastDot);
47
+ }
48
+
49
+ if (!t.file) {
50
+ t.file = namespaceToFileName(t.suite_title || '');
51
+ }
52
+
53
+ // detect params
54
+ const paramMatches = t.title.match(/\[(.*?)\]/g);
55
+
56
+ if (paramMatches) {
57
+ const params = paramMatches.map((_match, index) => `param${index + 1}`);
58
+ if (params.length === 1) params[0] = 'param';
59
+ let paramIndex = 0;
60
+
61
+ t.title = t.title.replace(/\[(.*?)\]/g, () => {
62
+ if (params.length < 2) return `\${param}`;
63
+ const paramName = params[paramIndex] || `param${paramIndex + 1}`;
64
+ paramIndex++;
65
+ return `\${${paramName}}`;
66
+ });
67
+ const example = {};
68
+ paramMatches.forEach((match, index) => {
69
+ example[params[index]] = match.replace(/[[\]]/g, '');
70
+ });
71
+ t.example = example;
72
+ }
73
+
74
+ return t;
75
+ }
76
+ }
77
+
78
+ function namespaceToFileName(fileName) {
79
+ let testName = fileName;
80
+
81
+ // If it's already an extracted test name (contains dots but no runDartTest prefix)
82
+ if (fileName.includes('.') && !fileName.startsWith('runDartTest')) {
83
+ // Take only the first part before any spaces (the actual test path)
84
+ testName = fileName.split(' ')[0];
85
+ } else {
86
+ // Legacy handling for full runDartTest[...] format
87
+ const dartMatch = fileName.match(/runDartTest\[(.+?) /);
88
+ if (dartMatch) {
89
+ testName = dartMatch[1];
90
+ } else {
91
+ const parts = fileName.split(' ');
92
+ if (parts.length > 1) {
93
+ testName = parts[1];
94
+ }
95
+ }
96
+ }
97
+
98
+ const fileParts = testName.split('.');
99
+ fileParts[fileParts.length - 1] = fileParts[fileParts.length - 1]?.replace(/\$.*/, '');
100
+ return `${fileParts.join(path.sep)}.dart`;
101
+ }
102
+
103
+ export default DartAdapter;
@@ -5,6 +5,7 @@ import PythonAdapter from './python.js';
5
5
  import RubyAdapter from './ruby.js';
6
6
  import CSharpAdapter from './csharp.js';
7
7
  import KotlinAdapter from './kotlin.js';
8
+ import DartAdapter from './dart.js';
8
9
 
9
10
  function AdapterFactory(lang, opts) {
10
11
  if (lang === 'java') {
@@ -25,6 +26,9 @@ function AdapterFactory(lang, opts) {
25
26
  if (lang === 'kotlin') {
26
27
  return new KotlinAdapter(opts);
27
28
  }
29
+ if (lang === 'dart') {
30
+ return new DartAdapter(opts);
31
+ }
28
32
 
29
33
  return new Adapter(opts);
30
34
  }
package/src/pipe/html.js CHANGED
@@ -268,7 +268,7 @@ class HtmlPipe {
268
268
  executionTime: testExecutionSumTime(aggregatedTests),
269
269
  executionDate: getCurrentDateTimeFormatted(),
270
270
  description:
271
- [runParams.description || this.store.coverageDescription || this.store.description, this.description]
271
+ [this.description, runParams.description || this.store.coverageDescription || this.store.description]
272
272
  .filter(Boolean)
273
273
  .join('\n\n') || '',
274
274
  configuration: buildDisplayConfiguration(
@@ -140,7 +140,7 @@ class MarkdownPipe {
140
140
  executionTime: testExecutionSumTime(aggregated),
141
141
  executionDate: getCurrentDateTimeFormatted(),
142
142
  description:
143
- [runParams?.description || this.store.coverageDescription || this.store.description, this.description]
143
+ [this.description, runParams?.description || this.store.coverageDescription || this.store.description]
144
144
  .filter(Boolean)
145
145
  .join('\n\n') || '',
146
146
  configuration: this.configuration || this.store.configuration || runParams?.configuration || null,
@@ -167,6 +167,12 @@ class TestomatioPipe {
167
167
  // add test ID + run ID
168
168
  if (data.rid) data.rid = `${this.runId}-${data.rid}`;
169
169
 
170
+ // convert timestamp to seconds
171
+ if (data.timestamp > 1e11) {
172
+ if (data.timestamp > 1e14) data.timestamp = Math.floor(data.timestamp / 1e6); // convert microseconds
173
+ else if (data.timestamp > 1e11) data.timestamp = Math.floor(data.timestamp / 1e3); // convert milliseconds
174
+ }
175
+
170
176
  if (!process.env.TESTOMATIO_STACK_PASSED && data.status === STATUS.PASSED) {
171
177
  data.stack = null;
172
178
  }
@@ -273,8 +279,9 @@ class TestomatioPipe {
273
279
  suites: coverageConfiguration.suites?.map(id => id.replace(/^S/, '')) || [],
274
280
  };
275
281
  }
276
- // Run description: coverage-derived block (if any) with the user-provided TESTOMATIO_DESCRIPTION appended after it.
277
- const description = [coverageDescription, this.description].filter(Boolean).join('\n\n') || null;
282
+ // Run description: the user-provided TESTOMATIO_DESCRIPTION with the coverage-derived block
283
+ // (if any) added after it. Neither overrides the other.
284
+ const description = [this.description, coverageDescription].filter(Boolean).join('\n\n') || null;
278
285
 
279
286
  // Merge caller-supplied configuration (e.g. { exploratory: true }) into runParams.configuration.
280
287
  // Caller values win on key conflict; coverage-derived tests/suites lists are preserved when not overridden.
@@ -552,6 +559,9 @@ class TestomatioPipe {
552
559
 
553
560
  const { status } = params;
554
561
 
562
+ // a pending run was just created: nothing to update here, only other pipes report it
563
+ if (status === 'pending') return;
564
+
555
565
  let status_event;
556
566
 
557
567
  if (status === STATUS.FINISHED) status_event = 'finish';
@@ -569,6 +579,7 @@ class TestomatioPipe {
569
579
  status_event,
570
580
  detach: params.detach,
571
581
  tests: params.tests,
582
+ ...(params.force_finish_shared_run && { force_finish_shared_run: true }),
572
583
  },
573
584
  });
574
585
 
@@ -117,6 +117,7 @@ function statusEmoji(status) {
117
117
  if (status === 'passed') return '🟢';
118
118
  if (status === 'failed') return '🔴';
119
119
  if (status === 'skipped') return '🟡';
120
+ if (status === 'pending') return '🕐';
120
121
  return '';
121
122
  }
122
123
 
@@ -271,6 +271,26 @@ const fetchIdFromOutput = output => {
271
271
  return output.match(TID_FULL_PATTERN)?.[2];
272
272
  };
273
273
 
274
+ const findDartTestLine = (lines, expectedTitle) => {
275
+ const testFunctions = ['patrolTest', 'testWidgets'];
276
+ const quotedTitles = [`'${expectedTitle}',`, `"${expectedTitle}",`, `r'${expectedTitle}',`, `r"${expectedTitle}",`];
277
+
278
+ return lines.findIndex((_line, index) => {
279
+ const declaration = lines
280
+ .slice(index, index + 5)
281
+ .map(line => line.trim())
282
+ .join(' ');
283
+ const functionName = testFunctions.find(name => declaration.startsWith(name));
284
+ if (!functionName) return false;
285
+
286
+ const argumentsList = declaration.slice(functionName.length).trimStart();
287
+ if (!argumentsList.startsWith('(')) return false;
288
+
289
+ const firstArgument = argumentsList.slice(1).trimStart();
290
+ return quotedTitles.some(title => firstArgument.startsWith(title));
291
+ });
292
+ };
293
+
274
294
  const fetchSourceCode = (contents, opts = {}) => {
275
295
  if (!opts.title && !opts.line) return '';
276
296
 
@@ -354,6 +374,32 @@ const fetchSourceCode = (contents, opts = {}) => {
354
374
  }
355
375
  }
356
376
  }
377
+ } else if (opts.lang === 'dart') {
378
+ // For Dart, locate the specific patrolTest/testWidgets block by title first —
379
+ // otherwise every test sharing the same main() gets the same @T comment.
380
+ const rawTitle = opts.title || '';
381
+ let dartTestTitle = '';
382
+
383
+ if (rawTitle.startsWith('runDartTest[')) {
384
+ // Android: runDartTest[<path> <test name>]
385
+ const spaceIndex = rawTitle.indexOf(' ');
386
+ if (spaceIndex > -1) {
387
+ dartTestTitle = rawTitle.slice(spaceIndex + 1).replace(/\]$/, '');
388
+ }
389
+ } else {
390
+ // iOS: <ClassName> <test.path> <test name>
391
+ const parts = rawTitle.split(' ');
392
+ if (parts.length > 2 && parts[1] && parts[1].includes('.')) {
393
+ dartTestTitle = parts.slice(2).join(' ');
394
+ }
395
+ }
396
+
397
+ let testLineIndex = -1;
398
+ if (dartTestTitle) {
399
+ testLineIndex = findDartTestLine(lines, dartTestTitle);
400
+ }
401
+
402
+ lineIndex = testLineIndex;
357
403
  } else {
358
404
  lineIndex = lines.findIndex(l => l.includes(title));
359
405
  }
@@ -371,8 +417,8 @@ const fetchSourceCode = (contents, opts = {}) => {
371
417
  for (let i = lineIndex; i < lineIndex + limit; i++) {
372
418
  if (lines[i] === undefined) continue;
373
419
 
374
- // Track brace depth for C# to stop after method closes
375
- if (opts.lang === 'csharp') {
420
+ // Track brace depth for C# and Dart to stop after method/main closes
421
+ if (opts.lang === 'csharp' || opts.lang === 'dart') {
376
422
  const line = lines[i];
377
423
  // Count opening and closing braces
378
424
  const openBraces = (line.match(/\{/g) || []).length;
package/src/xmlReader.js CHANGED
@@ -472,6 +472,7 @@ class XmlReader {
472
472
  if (file.endsWith('.js')) this.stats.language = 'js';
473
473
  if (file.endsWith('.ts')) this.stats.language = 'ts';
474
474
  if (file.endsWith('.cs')) this.stats.language = 'csharp';
475
+ if (file.endsWith('.dart')) this.stats.language = 'dart';
475
476
  }
476
477
 
477
478
  if (!fs.existsSync(file)) {
package/types/types.d.ts CHANGED
@@ -274,6 +274,9 @@ export interface RunData {
274
274
  * An array of `TestData` objects representing the individual test cases in the test run.
275
275
  * Used for JUNit report when we don't send the tests in realtime but in a batch as a part of final result */
276
276
  tests?: TestData[];
277
+
278
+ /** Force-finish a shared run even if other participants haven't finished yet. Set via `TESTOMATIO_FINISH_SHARED_RUN`. */
279
+ force_finish_shared_run?: boolean;
277
280
  }
278
281
 
279
282
  export enum TestStatus {
@@ -286,6 +289,8 @@ export enum RunStatus {
286
289
  Passed = 'passed',
287
290
  Failed = 'failed',
288
291
  Finished = 'finished',
292
+ /** run created but not executed yet, reported by `reporter start` */
293
+ Pending = 'pending',
289
294
  }
290
295
 
291
296
  /** Batch upload strategy: