@testomatio/reporter 2.8.5 → 2.9.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.
@@ -43,6 +43,7 @@ if (MAJOR_VERSION === 3 && MINOR_VERSION < 7) {
43
43
  }
44
44
  function CodeceptReporter(config) {
45
45
  const failedTests = [];
46
+ const reportedTestUids = new Set();
46
47
  let videos = [];
47
48
  let traces = [];
48
49
  const reportTestPromises = [];
@@ -141,6 +142,7 @@ function CodeceptReporter(config) {
141
142
  return;
142
143
  const error = hook?.ctx?.currentTest?.err;
143
144
  for (const test of suite.tests) {
145
+ reportedTestUids.add(test.uid);
144
146
  const reportTestPromise = client.addTestRun('failed', {
145
147
  ...stripExampleFromTitle(test.title),
146
148
  rid: test.uid,
@@ -171,9 +173,26 @@ function CodeceptReporter(config) {
171
173
  await finalizeRun('all.after');
172
174
  });
173
175
  });
176
+ event.dispatcher.on(event.test.skipped, test => {
177
+ const { uid, tags, title } = test.simplify();
178
+ if (uid && reportedTestUids.has(uid))
179
+ return;
180
+ index_js_1.services.setContext(null);
181
+ const reportTestPromise = client.addTestRun(constants_js_1.STATUS.SKIPPED, {
182
+ ...stripExampleFromTitle(title),
183
+ rid: uid,
184
+ test_id: (0, utils_js_1.getTestomatIdFromTestTitle)(`${title} ${tags?.join(' ')}`),
185
+ suite_title: test.parent && stripTagsFromTitle(stripExampleFromTitle(test.parent.title).title),
186
+ time: test.duration,
187
+ meta: test.meta,
188
+ });
189
+ reportTestPromises.push(reportTestPromise);
190
+ reportedTestUids.add(uid);
191
+ });
174
192
  event.dispatcher.on(event.test.after, test => {
175
193
  const { uid, tags, title, artifacts } = test.simplify();
176
194
  const error = test.err || null;
195
+ reportedTestUids.add(uid);
177
196
  failedTests.push(uid || title);
178
197
  const testObj = getTestAndMessage(title);
179
198
  const files = buildArtifactFiles(artifacts);
package/lib/bin/cli.js CHANGED
@@ -333,7 +333,7 @@ program
333
333
  const client = new client_js_1.default({
334
334
  apiKey,
335
335
  runId,
336
- isBatchEnabled: false,
336
+ batchMode: constants_js_1.BATCH_MODE.DISABLED,
337
337
  });
338
338
  let testruns = client.uploader.readUploadedFiles(runId);
339
339
  const numTotalArtifacts = testruns.length;
@@ -13,6 +13,7 @@ const config_js_1 = require("../config.js");
13
13
  const utils_js_2 = require("../utils/utils.js");
14
14
  const dotenv_1 = __importDefault(require("dotenv"));
15
15
  const log_js_1 = require("../utils/log.js");
16
+ const constants_js_1 = require("../constants.js");
16
17
  const debug = (0, debug_1.default)('@testomatio/reporter:upload-cli');
17
18
  const version = (0, utils_js_1.getPackageVersion)();
18
19
  console.log(picocolors_1.default.cyan(picocolors_1.default.bold(` 🤩 Testomat.io Reporter v${version}`)));
@@ -37,7 +38,7 @@ program
37
38
  const client = new client_js_1.default({
38
39
  apiKey,
39
40
  runId,
40
- isBatchEnabled: false,
41
+ batchMode: constants_js_1.BATCH_MODE.DISABLED,
41
42
  });
42
43
  let testruns = client.uploader.readUploadedFiles(process.env.TESTOMATIO_RUN);
43
44
  const numTotalArtifacts = testruns.length;
@@ -10,6 +10,12 @@ export namespace STATUS {
10
10
  let SKIPPED: string;
11
11
  let FINISHED: string;
12
12
  }
13
+ /** @type {{ AUTO: 'auto', MANUAL: 'manual', DISABLED: 'disabled' }} */
14
+ export const BATCH_MODE: {
15
+ AUTO: "auto";
16
+ MANUAL: "manual";
17
+ DISABLED: "disabled";
18
+ };
13
19
  export namespace HTML_REPORT {
14
20
  let FOLDER: string;
15
21
  let REPORT_DEFAULT_NAME: string;
package/lib/constants.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.DEBUG_FILE = exports.SCREENSHOTS_ON_STEPS = exports.REPORTER_REQUEST_RETRIES = exports.testomatLogoURL = exports.REQUEST_TIMEOUT = exports.MARKDOWN_REPORT = exports.HTML_REPORT = exports.STATUS = exports.CSV_HEADERS = exports.TESTOMAT_TMP_STORAGE_DIR = exports.APP_PREFIX = void 0;
6
+ exports.DEBUG_FILE = exports.SCREENSHOTS_ON_STEPS = exports.REPORTER_REQUEST_RETRIES = exports.testomatLogoURL = exports.REQUEST_TIMEOUT = exports.MARKDOWN_REPORT = exports.HTML_REPORT = exports.BATCH_MODE = exports.STATUS = exports.CSV_HEADERS = exports.TESTOMAT_TMP_STORAGE_DIR = exports.APP_PREFIX = void 0;
7
7
  exports.getCreateRunRequestTimeout = getCreateRunRequestTimeout;
8
8
  const picocolors_1 = __importDefault(require("picocolors"));
9
9
  const os_1 = __importDefault(require("os"));
@@ -37,6 +37,14 @@ const STATUS = {
37
37
  FINISHED: 'finished',
38
38
  };
39
39
  exports.STATUS = STATUS;
40
+ // batch upload mode
41
+ /** @type {{ AUTO: 'auto', MANUAL: 'manual', DISABLED: 'disabled' }} */
42
+ const BATCH_MODE = {
43
+ AUTO: 'auto',
44
+ MANUAL: 'manual',
45
+ DISABLED: 'disabled',
46
+ };
47
+ exports.BATCH_MODE = BATCH_MODE;
40
48
  // html pipe var
41
49
  const HTML_REPORT = {
42
50
  FOLDER: 'html-report',
@@ -79,6 +87,8 @@ module.exports.CSV_HEADERS = CSV_HEADERS;
79
87
 
80
88
  module.exports.STATUS = STATUS;
81
89
 
90
+ module.exports.BATCH_MODE = BATCH_MODE;
91
+
82
92
  module.exports.HTML_REPORT = HTML_REPORT;
83
93
 
84
94
  module.exports.MARKDOWN_REPORT = MARKDOWN_REPORT;
@@ -221,9 +221,10 @@ class CoveragePipe {
221
221
  */
222
222
  #getChangedFilesFromGit(cmd) {
223
223
  try {
224
+ // Capture stderr (instead of ignoring it) so Git's actual error is available for diagnostics
224
225
  const result = (0, child_process_1.execSync)(cmd, {
225
226
  encoding: 'utf-8',
226
- stdio: ['pipe', 'pipe', 'ignore']
227
+ stdio: ['pipe', 'pipe', 'pipe']
227
228
  });
228
229
  return result
229
230
  .split('\n')
@@ -231,15 +232,26 @@ class CoveragePipe {
231
232
  .filter(Boolean);
232
233
  }
233
234
  catch (err) {
234
- const errorMessage = err.message || '';
235
- // Git edge: Not a git repository or other error
235
+ // Prefer Git's own stderr output, fall back to the generic error message
236
+ const gitOutput = (err.stderr || '').toString().trim();
237
+ const errorMessage = gitOutput || err.message || '';
238
+ // Git edge: Not a git repository
236
239
  if (errorMessage.includes('Not a git repository')) {
237
240
  log_js_1.log.error('❌ Error: This folder is not a Git repository.');
241
+ return [];
238
242
  }
239
- else {
240
- throw new Error(`❌ Git command failed ("${cmd}"):\n`, errorMessage);
243
+ // Git edge: the branch/ref to diff against is not available locally.
244
+ // This is common in CI, where a shallow checkout fetches only the current branch.
245
+ if (errorMessage.includes('unknown revision') ||
246
+ errorMessage.includes('ambiguous argument') ||
247
+ errorMessage.includes('bad revision')) {
248
+ log_js_1.log.error(`❌ Git command failed ("${cmd}"):\n${errorMessage}`);
249
+ log_js_1.log.error(`🔍 Branch "${this.branch}" was not found locally. ` +
250
+ `In CI this usually means a shallow checkout — fetch full history first, e.g. ` +
251
+ `actions/checkout with "fetch-depth: 0", or run "git fetch origin ${this.branch}:${this.branch}".`);
252
+ return [];
241
253
  }
242
- return [];
254
+ throw new Error(`❌ Git command failed ("${cmd}"):\n${errorMessage}`);
243
255
  }
244
256
  }
245
257
  /**
@@ -3,18 +3,13 @@ export class DebugPipe {
3
3
  params: any;
4
4
  store: any;
5
5
  isEnabled: boolean;
6
- batch: {
7
- isEnabled: any;
8
- intervalFunction: any;
9
- intervalTime: number;
10
- tests: any[];
11
- batchIndex: number;
12
- };
6
+ tests: any[];
13
7
  logFilePath: string;
14
8
  rootPath: string;
15
9
  historyDir: string;
16
10
  testomatioEnvVars: {};
17
- batchUpload(): Promise<void>;
11
+ flushOnExit: () => void;
12
+ exitListenerAttached: boolean;
18
13
  /**
19
14
  * Logs data to a file if logging is enabled.
20
15
  *
@@ -24,9 +19,17 @@ export class DebugPipe {
24
19
  logToFile(logData: any): Promise<void>;
25
20
  lastActionTimestamp: number;
26
21
  prepareRun(opts: any): Promise<any[]>;
27
- createRun(params?: {}): Promise<{}>;
22
+ createRun(params?: {}): Promise<void>;
28
23
  addTest(data: any): Promise<void>;
29
24
  finishRun(params: any): Promise<void>;
30
25
  sync(): Promise<void>;
26
+ /**
27
+ * Writes any buffered tests to the debug file as a single batch.
28
+ * Runs synchronously so it can also be invoked from a process `exit` handler,
29
+ * which is the only chance to persist tests when a hook failure (e.g. a failing
30
+ * AfterSuite) prevents `finishRun` from being reached. Idempotent: the buffer is
31
+ * drained on flush, so a later `finishRun`/exit flush is a no-op.
32
+ */
33
+ flushBufferedTests(): void;
31
34
  toString(): string;
32
35
  }
package/lib/pipe/debug.js CHANGED
@@ -17,13 +17,7 @@ class DebugPipe {
17
17
  this.store = store || {};
18
18
  this.isEnabled = !!process.env.TESTOMATIO_DEBUG || !!process.env.DEBUG;
19
19
  if (this.isEnabled) {
20
- this.batch = {
21
- isEnabled: this.params.isBatchEnabled ?? !process.env.TESTOMATIO_DISABLE_BATCH_UPLOAD,
22
- intervalFunction: null,
23
- intervalTime: 5000,
24
- tests: [],
25
- batchIndex: 0,
26
- };
20
+ this.tests = [];
27
21
  const suffix = process.env.TESTOMATIO_REPLAY ? 'replay' : '';
28
22
  const paths = (0, debug_js_1.getDebugFilePath)(suffix);
29
23
  this.logFilePath = paths.tmp;
@@ -63,8 +57,12 @@ class DebugPipe {
63
57
  this.logToFile({ datetime: new Date().toISOString(), timestamp: Date.now() });
64
58
  this.logToFile({ data: 'variables', testomatioEnvVars: this.testomatioEnvVars });
65
59
  this.logToFile({ data: 'store', store: this.store || {} });
66
- // Bind batchUpload to the instance
67
- this.batchUpload = this.batchUpload.bind(this);
60
+ // Safety net for hook failures (e.g. a failing AfterSuite) that abort the run
61
+ // before finishRun: buffered tests would otherwise be lost. The handler is
62
+ // attached lazily when the first test is buffered and detached once flushed,
63
+ // so processes that create many pipes don't pile up `exit` listeners.
64
+ this.flushOnExit = () => this.flushBufferedTests();
65
+ this.exitListenerAttached = false;
68
66
  }
69
67
  }
70
68
  /**
@@ -89,46 +87,21 @@ class DebugPipe {
89
87
  async createRun(params = {}) {
90
88
  if (!this.isEnabled)
91
89
  return;
92
- if (params.isBatchEnabled === true || params.isBatchEnabled === false)
93
- this.batch.isEnabled = params.isBatchEnabled;
94
- if (!this.isEnabled)
95
- return {};
96
- if (this.batch.isEnabled)
97
- this.batch.intervalFunction = setInterval(this.batchUpload, this.batch.intervalTime);
98
90
  this.logToFile({ action: 'createRun', params });
99
91
  }
100
92
  async addTest(data) {
101
93
  if (!this.isEnabled)
102
94
  return;
103
- if (!this.batch.isEnabled) {
104
- const logData = { action: 'addTest', testId: data };
105
- if (this.store.runId)
106
- logData.runId = this.store.runId;
107
- this.logToFile(logData);
95
+ this.tests.push(data);
96
+ if (!this.exitListenerAttached) {
97
+ process.once('exit', this.flushOnExit);
98
+ this.exitListenerAttached = true;
108
99
  }
109
- else
110
- this.batch.tests.push(data);
111
- if (!this.batch.intervalFunction)
112
- await this.batchUpload();
113
- }
114
- async batchUpload() {
115
- this.batch.batchIndex++;
116
- if (!this.batch.isEnabled)
117
- return;
118
- if (!this.batch.tests.length)
119
- return;
120
- const testsToSend = this.batch.tests.splice(0);
121
- const logData = { action: 'addTestsBatch', tests: testsToSend };
122
- if (this.store.runId)
123
- logData.runId = this.store.runId;
124
- this.logToFile(logData);
125
100
  }
126
101
  async finishRun(params) {
127
102
  if (!this.isEnabled)
128
103
  return;
129
104
  await this.sync();
130
- if (this.batch.intervalFunction)
131
- clearInterval(this.batch.intervalFunction);
132
105
  const logData = { action: 'finishRun', params };
133
106
  if (this.store.runId)
134
107
  logData.runId = this.store.runId;
@@ -137,9 +110,27 @@ class DebugPipe {
137
110
  log_js_1.log.info(`History: ${this.historyDir}`);
138
111
  }
139
112
  async sync() {
140
- if (!this.isEnabled)
113
+ this.flushBufferedTests();
114
+ }
115
+ /**
116
+ * Writes any buffered tests to the debug file as a single batch.
117
+ * Runs synchronously so it can also be invoked from a process `exit` handler,
118
+ * which is the only chance to persist tests when a hook failure (e.g. a failing
119
+ * AfterSuite) prevents `finishRun` from being reached. Idempotent: the buffer is
120
+ * drained on flush, so a later `finishRun`/exit flush is a no-op.
121
+ */
122
+ flushBufferedTests() {
123
+ if (!this.isEnabled || !this.tests.length)
141
124
  return;
142
- await this.batchUpload();
125
+ const tests = this.tests.splice(0);
126
+ const logData = { action: 'addTestsBatch', tests };
127
+ if (this.store.runId)
128
+ logData.runId = this.store.runId;
129
+ this.logToFile(logData);
130
+ if (this.exitListenerAttached) {
131
+ process.removeListener('exit', this.flushOnExit);
132
+ this.exitListenerAttached = false;
133
+ }
143
134
  }
144
135
  toString() {
145
136
  return 'Debug Reporter';
@@ -1,16 +1,26 @@
1
1
  export default TestomatioPipe;
2
2
  export type Pipe = import("../../types/types.js").Pipe;
3
3
  export type TestData = import("../../types/types.js").TestData;
4
+ export type BatchMode = import("../../types/types.js").BatchMode;
5
+ export type CreateRunParams = import("../../types/types.js").CreateRunParams;
4
6
  /**
5
7
  * @typedef {import('../../types/types.js').Pipe} Pipe
6
8
  * @typedef {import('../../types/types.js').TestData} TestData
9
+ * @typedef {import('../../types/types.js').BatchMode} BatchMode
10
+ * @typedef {import('../../types/types.js').CreateRunParams} CreateRunParams
7
11
  * @class TestomatioPipe
8
12
  * @implements {Pipe}
9
13
  */
10
14
  declare class TestomatioPipe implements Pipe {
11
15
  constructor(params: any, store: any);
12
16
  batch: {
13
- isEnabled: any;
17
+ /** @type {BatchMode}
18
+ * Batch upload mode:
19
+ * - `auto`: upload tests automatically by time interval (e.g. every 5 seconds).
20
+ * - `manual`: buffer tests and upload only when `sync()` is invoked manually.
21
+ * - `disabled`: send one test per request, no batching.
22
+ */
23
+ mode: BatchMode;
14
24
  intervalFunction: any;
15
25
  intervalTime: number;
16
26
  tests: any[];
@@ -50,14 +60,10 @@ declare class TestomatioPipe implements Pipe {
50
60
  prepareRun(opts: any): Promise<string[]>;
51
61
  /**
52
62
  * Creates a new run on Testomat.io
53
- * @param {{isBatchEnabled?: boolean, kind?: string, configuration?: Record<string, any>}} params
63
+ * @param {CreateRunParams} params
54
64
  * @returns Promise<void>
55
65
  */
56
- createRun(params?: {
57
- isBatchEnabled?: boolean;
58
- kind?: string;
59
- configuration?: Record<string, any>;
60
- }): Promise<void>;
66
+ createRun(params?: CreateRunParams): Promise<void>;
61
67
  runUrl: string;
62
68
  runPublicUrl: any;
63
69
  /**
@@ -38,15 +38,23 @@ function parseCiParams(raw) {
38
38
  /**
39
39
  * @typedef {import('../../types/types.js').Pipe} Pipe
40
40
  * @typedef {import('../../types/types.js').TestData} TestData
41
+ * @typedef {import('../../types/types.js').BatchMode} BatchMode
42
+ * @typedef {import('../../types/types.js').CreateRunParams} CreateRunParams
41
43
  * @class TestomatioPipe
42
44
  * @implements {Pipe}
43
45
  */
44
46
  class TestomatioPipe {
45
47
  constructor(params, store) {
46
48
  this.batch = {
47
- isEnabled: params?.isBatchEnabled ?? !process.env.TESTOMATIO_DISABLE_BATCH_UPLOAD,
49
+ /** @type {BatchMode}
50
+ * Batch upload mode:
51
+ * - `auto`: upload tests automatically by time interval (e.g. every 5 seconds).
52
+ * - `manual`: buffer tests and upload only when `sync()` is invoked manually.
53
+ * - `disabled`: send one test per request, no batching.
54
+ */
55
+ mode: params.batchMode || (process.env.TESTOMATIO_DISABLE_BATCH_UPLOAD ? constants_js_1.BATCH_MODE.DISABLED : constants_js_1.BATCH_MODE.AUTO),
48
56
  intervalFunction: null, // will be created in createRun by setInterval function
49
- intervalTime: 5000, // how often tests are sent
57
+ intervalTime: 6000, // how often tests are sent
50
58
  tests: [], // array of tests in batch
51
59
  batchIndex: 0, // represents the current batch index (starts from 1 and increments by 1 for each batch)
52
60
  numberOfTimesCalledWithoutTests: 0, // how many times batch was called without tests
@@ -193,14 +201,15 @@ class TestomatioPipe {
193
201
  }
194
202
  /**
195
203
  * Creates a new run on Testomat.io
196
- * @param {{isBatchEnabled?: boolean, kind?: string, configuration?: Record<string, any>}} params
204
+ * @param {CreateRunParams} params
197
205
  * @returns Promise<void>
198
206
  */
199
207
  async createRun(params = {}) {
200
- this.batch.isEnabled = params.isBatchEnabled ?? this.batch.isEnabled;
208
+ if (params.batchMode)
209
+ this.batch.mode = params.batchMode;
201
210
  if (!this.isEnabled)
202
211
  return;
203
- if (this.batch.isEnabled && this.isEnabled)
212
+ if (this.batch.mode === constants_js_1.BATCH_MODE.AUTO && this.isEnabled)
204
213
  this.batch.intervalFunction = setInterval(this.#batchUpload, this.batch.intervalTime);
205
214
  if (this.store) {
206
215
  this.store.runKind = params.kind;
@@ -391,7 +400,7 @@ class TestomatioPipe {
391
400
  * Uploads tests as a batch (multiple tests at once). Intended to be used with a setInterval
392
401
  */
393
402
  #batchUpload = async () => {
394
- if (!this.batch.isEnabled)
403
+ if (this.batch.mode === constants_js_1.BATCH_MODE.DISABLED)
395
404
  return;
396
405
  if (!this.batch.tests.length)
397
406
  return;
@@ -401,7 +410,7 @@ class TestomatioPipe {
401
410
  if (this.batch.numberOfTimesCalledWithoutTests > 10) {
402
411
  debug('📨 Batch upload: no tests to send for 10 times, stopping batch');
403
412
  clearInterval(this.batch.intervalFunction);
404
- this.batch.isEnabled = false;
413
+ this.batch.mode = constants_js_1.BATCH_MODE.DISABLED;
405
414
  }
406
415
  if (!this.batch.tests.length) {
407
416
  debug('📨 Batch upload: no tests to send');
@@ -454,12 +463,12 @@ class TestomatioPipe {
454
463
  }
455
464
  this.#formatData(data);
456
465
  let uploading = null;
457
- if (!this.batch.isEnabled)
466
+ if (this.batch.mode === constants_js_1.BATCH_MODE.DISABLED)
458
467
  uploading = this.#uploadSingleTest(data);
459
468
  else
460
469
  this.batch.tests.push(data);
461
- // if test is added after run which is already finished
462
- if (!this.batch.intervalFunction)
470
+ // auto mode but no interval running yet (e.g. createRun hasn't started it): flush immediately
471
+ if (this.batch.mode === constants_js_1.BATCH_MODE.AUTO && !this.batch.intervalFunction)
463
472
  uploading = this.#batchUpload();
464
473
  // return promise to be able to wait for it
465
474
  return uploading;
@@ -487,7 +496,7 @@ class TestomatioPipe {
487
496
  // (e.g. if test has artifacts, add test function will be invoked only after artifacts are uploaded)
488
497
  // batch stops working after run is finished; thus, disable it to use single test uploading
489
498
  this.batch.intervalFunction = null;
490
- this.batch.isEnabled = false;
499
+ this.batch.mode = constants_js_1.BATCH_MODE.DISABLED;
491
500
  }
492
501
  debug('Finishing run...');
493
502
  if (this.reportingCanceledDueToReqFailures) {
@@ -549,7 +558,7 @@ class TestomatioPipe {
549
558
  if (this.batch.intervalFunction) {
550
559
  clearInterval(this.batch.intervalFunction);
551
560
  this.batch.intervalFunction = null;
552
- this.batch.isEnabled = false;
561
+ this.batch.mode = constants_js_1.BATCH_MODE.DISABLED;
553
562
  }
554
563
  this.batch.tests = [];
555
564
  }
package/lib/replay.js CHANGED
@@ -210,7 +210,7 @@ class Replay {
210
210
  process.env.TESTOMATIO_REPLAY = '1';
211
211
  const client = new client_js_1.default({
212
212
  apiKey: this.apiKey,
213
- isBatchEnabled: true,
213
+ batchMode: constants_js_1.BATCH_MODE.AUTO,
214
214
  ...runParams,
215
215
  ...(runId && { runId }),
216
216
  });
@@ -8,7 +8,7 @@ declare class XmlReader {
8
8
  env: string;
9
9
  group_title: string;
10
10
  detach: string;
11
- isBatchEnabled: boolean;
11
+ batchMode: "manual";
12
12
  };
13
13
  runId: any;
14
14
  adapter: import("./junit-adapter/adapter.js").default;
package/lib/xmlReader.js CHANGED
@@ -51,8 +51,7 @@ class XmlReader {
51
51
  env: TESTOMATIO_ENV,
52
52
  group_title: TESTOMATIO_RUNGROUP_TITLE,
53
53
  detach: TESTOMATIO_MARK_DETACHED,
54
- // batch uploading is implemented for xml already
55
- isBatchEnabled: false,
54
+ batchMode: constants_js_1.BATCH_MODE.MANUAL,
56
55
  };
57
56
  this.runId = opts.runId || TESTOMATIO_RUN;
58
57
  this.adapter = (0, index_js_2.default)(opts.lang?.toLowerCase(), opts);
@@ -467,7 +466,7 @@ class XmlReader {
467
466
  title: this.requestParams.title,
468
467
  env: this.requestParams.env,
469
468
  group_title: this.requestParams.group_title,
470
- isBatchEnabled: this.requestParams.isBatchEnabled,
469
+ batchMode: this.requestParams.batchMode,
471
470
  };
472
471
  debug('Run', runParams);
473
472
  this.pipes = this.pipes || (await this.pipesPromise);
@@ -521,7 +520,7 @@ class XmlReader {
521
520
  this.formatTests();
522
521
  this.pipes = this.pipes || (await this.pipesPromise);
523
522
  // Create run before uploading tests to ensure runId is set
524
- await this.createRun();
523
+ // await this.createRun(); // makes reporting stuck after finish, thus commenting out
525
524
  if (!this.tests || !Array.isArray(this.tests) || this.tests.length === 0) {
526
525
  debug('No tests to upload, finishing run');
527
526
  const finishData = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.8.5",
3
+ "version": "2.9.0",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -48,6 +48,7 @@ if (MAJOR_VERSION === 3 && MINOR_VERSION < 7) {
48
48
 
49
49
  function CodeceptReporter(config) {
50
50
  const failedTests = [];
51
+ const reportedTestUids = new Set();
51
52
  let videos = [];
52
53
  let traces = [];
53
54
  const reportTestPromises = [];
@@ -161,6 +162,7 @@ function CodeceptReporter(config) {
161
162
  const error = hook?.ctx?.currentTest?.err;
162
163
 
163
164
  for (const test of suite.tests) {
165
+ reportedTestUids.add(test.uid);
164
166
  const reportTestPromise = client.addTestRun('failed', {
165
167
  ...stripExampleFromTitle(test.title),
166
168
  rid: test.uid,
@@ -197,9 +199,29 @@ function CodeceptReporter(config) {
197
199
  });
198
200
  });
199
201
 
202
+ event.dispatcher.on(event.test.skipped, test => {
203
+ const { uid, tags, title } = test.simplify();
204
+
205
+ if (uid && reportedTestUids.has(uid)) return;
206
+
207
+ services.setContext(null);
208
+
209
+ const reportTestPromise = client.addTestRun(STATUS.SKIPPED, {
210
+ ...stripExampleFromTitle(title),
211
+ rid: uid,
212
+ test_id: getTestomatIdFromTestTitle(`${title} ${tags?.join(' ')}`),
213
+ suite_title: test.parent && stripTagsFromTitle(stripExampleFromTitle(test.parent.title).title),
214
+ time: test.duration,
215
+ meta: test.meta,
216
+ });
217
+ reportTestPromises.push(reportTestPromise);
218
+ reportedTestUids.add(uid);
219
+ });
220
+
200
221
  event.dispatcher.on(event.test.after, test => {
201
222
  const { uid, tags, title, artifacts } = test.simplify();
202
223
  const error = test.err || null;
224
+ reportedTestUids.add(uid);
203
225
  failedTests.push(uid || title);
204
226
  const testObj = getTestAndMessage(title);
205
227
  const files = buildArtifactFiles(artifacts);
@@ -211,8 +233,8 @@ function CodeceptReporter(config) {
211
233
 
212
234
  // Build step hierarchy with screenshot from screenshotOnFail
213
235
  const stepHierarchy = buildUnifiedStepHierarchy(
214
- test.steps,
215
- hookSteps,
236
+ test.steps,
237
+ hookSteps,
216
238
  screenshotOnFailPath
217
239
  );
218
240
 
package/src/bin/cli.js CHANGED
@@ -6,7 +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 { APP_PREFIX, STATUS, DEBUG_FILE } from '../constants.js';
9
+ import { APP_PREFIX, STATUS, DEBUG_FILE, BATCH_MODE } from '../constants.js';
10
10
  import { cleanLatestRunId, getPackageVersion, applyFilter } from '../utils/utils.js';
11
11
  import { config } from '../config.js';
12
12
  import { readLatestRunId } from '../utils/utils.js';
@@ -369,7 +369,7 @@ program
369
369
  const client = new TestomatClient({
370
370
  apiKey,
371
371
  runId,
372
- isBatchEnabled: false,
372
+ batchMode: BATCH_MODE.DISABLED,
373
373
  });
374
374
 
375
375
  let testruns = client.uploader.readUploadedFiles(runId);
@@ -9,6 +9,7 @@ import { config } from '../config.js';
9
9
  import { readLatestRunId } from '../utils/utils.js';
10
10
  import dotenv from 'dotenv';
11
11
  import { log } from '../utils/log.js';
12
+ import { BATCH_MODE } from '../constants.js';
12
13
 
13
14
  const debug = createDebugMessages('@testomatio/reporter:upload-cli');
14
15
  const version = getPackageVersion();
@@ -37,7 +38,7 @@ program
37
38
  const client = new TestomatClient({
38
39
  apiKey,
39
40
  runId,
40
- isBatchEnabled: false,
41
+ batchMode: BATCH_MODE.DISABLED,
41
42
  });
42
43
  let testruns = client.uploader.readUploadedFiles(process.env.TESTOMATIO_RUN);
43
44
 
package/src/constants.js CHANGED
@@ -28,6 +28,14 @@ const STATUS = {
28
28
  SKIPPED: 'skipped',
29
29
  FINISHED: 'finished',
30
30
  };
31
+
32
+ // batch upload mode
33
+ /** @type {{ AUTO: 'auto', MANUAL: 'manual', DISABLED: 'disabled' }} */
34
+ const BATCH_MODE = {
35
+ AUTO: 'auto',
36
+ MANUAL: 'manual',
37
+ DISABLED: 'disabled',
38
+ };
31
39
  // html pipe var
32
40
  const HTML_REPORT = {
33
41
  FOLDER: 'html-report',
@@ -61,6 +69,7 @@ export {
61
69
  TESTOMAT_TMP_STORAGE_DIR,
62
70
  CSV_HEADERS,
63
71
  STATUS,
72
+ BATCH_MODE,
64
73
  HTML_REPORT,
65
74
  MARKDOWN_REPORT,
66
75
  REQUEST_TIMEOUT,
@@ -261,9 +261,10 @@ class CoveragePipe { // or Changes for the future???
261
261
  */
262
262
  #getChangedFilesFromGit(cmd) {
263
263
  try {
264
+ // Capture stderr (instead of ignoring it) so Git's actual error is available for diagnostics
264
265
  const result = execSync(cmd, {
265
266
  encoding: 'utf-8',
266
- stdio: ['pipe', 'pipe', 'ignore']
267
+ stdio: ['pipe', 'pipe', 'pipe']
267
268
  });
268
269
 
269
270
  return result
@@ -272,16 +273,33 @@ class CoveragePipe { // or Changes for the future???
272
273
  .filter(Boolean);
273
274
  }
274
275
  catch (err) {
275
- const errorMessage = err.message || '';
276
- // Git edge: Not a git repository or other error
276
+ // Prefer Git's own stderr output, fall back to the generic error message
277
+ const gitOutput = (err.stderr || '').toString().trim();
278
+ const errorMessage = gitOutput || err.message || '';
279
+
280
+ // Git edge: Not a git repository
277
281
  if (errorMessage.includes('Not a git repository')) {
278
- log.error( '❌ Error: This folder is not a Git repository.');
282
+ log.error('❌ Error: This folder is not a Git repository.');
283
+ return [];
279
284
  }
280
- else {
281
- throw new Error(`❌ Git command failed ("${cmd}"):\n`, errorMessage);
285
+
286
+ // Git edge: the branch/ref to diff against is not available locally.
287
+ // This is common in CI, where a shallow checkout fetches only the current branch.
288
+ if (
289
+ errorMessage.includes('unknown revision') ||
290
+ errorMessage.includes('ambiguous argument') ||
291
+ errorMessage.includes('bad revision')
292
+ ) {
293
+ log.error(`❌ Git command failed ("${cmd}"):\n${errorMessage}`);
294
+ log.error(
295
+ `🔍 Branch "${this.branch}" was not found locally. ` +
296
+ `In CI this usually means a shallow checkout — fetch full history first, e.g. ` +
297
+ `actions/checkout with "fetch-depth: 0", or run "git fetch origin ${this.branch}:${this.branch}".`
298
+ );
299
+ return [];
282
300
  }
283
301
 
284
- return [];
302
+ throw new Error(`❌ Git command failed ("${cmd}"):\n${errorMessage}`);
285
303
  }
286
304
  }
287
305
 
package/src/pipe/debug.js CHANGED
@@ -14,13 +14,7 @@ export class DebugPipe {
14
14
 
15
15
  this.isEnabled = !!process.env.TESTOMATIO_DEBUG || !!process.env.DEBUG;
16
16
  if (this.isEnabled) {
17
- this.batch = {
18
- isEnabled: this.params.isBatchEnabled ?? !process.env.TESTOMATIO_DISABLE_BATCH_UPLOAD,
19
- intervalFunction: null,
20
- intervalTime: 5000,
21
- tests: [],
22
- batchIndex: 0,
23
- };
17
+ this.tests = [];
24
18
  const suffix = process.env.TESTOMATIO_REPLAY ? 'replay' : '';
25
19
  const paths = getDebugFilePath(suffix);
26
20
  this.logFilePath = paths.tmp;
@@ -60,8 +54,13 @@ export class DebugPipe {
60
54
  this.logToFile({ datetime: new Date().toISOString(), timestamp: Date.now() });
61
55
  this.logToFile({ data: 'variables', testomatioEnvVars: this.testomatioEnvVars });
62
56
  this.logToFile({ data: 'store', store: this.store || {} });
63
- // Bind batchUpload to the instance
64
- this.batchUpload = this.batchUpload.bind(this);
57
+
58
+ // Safety net for hook failures (e.g. a failing AfterSuite) that abort the run
59
+ // before finishRun: buffered tests would otherwise be lost. The handler is
60
+ // attached lazily when the first test is buffered and detached once flushed,
61
+ // so processes that create many pipes don't pile up `exit` listeners.
62
+ this.flushOnExit = () => this.flushBufferedTests();
63
+ this.exitListenerAttached = false;
65
64
  }
66
65
  }
67
66
 
@@ -88,42 +87,22 @@ export class DebugPipe {
88
87
 
89
88
  async createRun(params = {}) {
90
89
  if (!this.isEnabled) return;
91
- if (params.isBatchEnabled === true || params.isBatchEnabled === false) this.batch.isEnabled = params.isBatchEnabled;
92
-
93
- if (!this.isEnabled) return {};
94
- if (this.batch.isEnabled) this.batch.intervalFunction = setInterval(this.batchUpload, this.batch.intervalTime);
95
90
 
96
91
  this.logToFile({ action: 'createRun', params });
97
92
  }
98
93
 
99
94
  async addTest(data) {
100
95
  if (!this.isEnabled) return;
101
-
102
- if (!this.batch.isEnabled) {
103
- const logData = { action: 'addTest', testId: data };
104
- if (this.store.runId) logData.runId = this.store.runId;
105
- this.logToFile(logData);
106
- } else this.batch.tests.push(data);
107
-
108
- if (!this.batch.intervalFunction) await this.batchUpload();
109
- }
110
-
111
- async batchUpload() {
112
- this.batch.batchIndex++;
113
- if (!this.batch.isEnabled) return;
114
- if (!this.batch.tests.length) return;
115
-
116
- const testsToSend = this.batch.tests.splice(0);
117
-
118
- const logData = { action: 'addTestsBatch', tests: testsToSend };
119
- if (this.store.runId) logData.runId = this.store.runId;
120
- this.logToFile(logData);
96
+ this.tests.push(data);
97
+ if (!this.exitListenerAttached) {
98
+ process.once('exit', this.flushOnExit);
99
+ this.exitListenerAttached = true;
100
+ }
121
101
  }
122
102
 
123
103
  async finishRun(params) {
124
104
  if (!this.isEnabled) return;
125
105
  await this.sync();
126
- if (this.batch.intervalFunction) clearInterval(this.batch.intervalFunction);
127
106
  const logData = { action: 'finishRun', params };
128
107
  if (this.store.runId) logData.runId = this.store.runId;
129
108
  this.logToFile(logData);
@@ -133,8 +112,28 @@ export class DebugPipe {
133
112
  }
134
113
 
135
114
  async sync() {
136
- if (!this.isEnabled) return;
137
- await this.batchUpload();
115
+ this.flushBufferedTests();
116
+ }
117
+
118
+ /**
119
+ * Writes any buffered tests to the debug file as a single batch.
120
+ * Runs synchronously so it can also be invoked from a process `exit` handler,
121
+ * which is the only chance to persist tests when a hook failure (e.g. a failing
122
+ * AfterSuite) prevents `finishRun` from being reached. Idempotent: the buffer is
123
+ * drained on flush, so a later `finishRun`/exit flush is a no-op.
124
+ */
125
+ flushBufferedTests() {
126
+ if (!this.isEnabled || !this.tests.length) return;
127
+
128
+ const tests = this.tests.splice(0);
129
+ const logData = { action: 'addTestsBatch', tests };
130
+ if (this.store.runId) logData.runId = this.store.runId;
131
+ this.logToFile(logData);
132
+
133
+ if (this.exitListenerAttached) {
134
+ process.removeListener('exit', this.flushOnExit);
135
+ this.exitListenerAttached = false;
136
+ }
138
137
  }
139
138
 
140
139
  toString() {
@@ -5,6 +5,7 @@ import JsonCycle from 'json-cycle';
5
5
  import {
6
6
  APP_PREFIX,
7
7
  STATUS,
8
+ BATCH_MODE,
8
9
  REQUEST_TIMEOUT,
9
10
  getCreateRunRequestTimeout,
10
11
  REPORTER_REQUEST_RETRIES,
@@ -46,17 +47,25 @@ function parseCiParams(raw) {
46
47
  /**
47
48
  * @typedef {import('../../types/types.js').Pipe} Pipe
48
49
  * @typedef {import('../../types/types.js').TestData} TestData
50
+ * @typedef {import('../../types/types.js').BatchMode} BatchMode
51
+ * @typedef {import('../../types/types.js').CreateRunParams} CreateRunParams
49
52
  * @class TestomatioPipe
50
53
  * @implements {Pipe}
51
54
  */
52
55
  class TestomatioPipe {
53
56
  constructor(params, store) {
54
57
  this.batch = {
55
- isEnabled: params?.isBatchEnabled ?? !process.env.TESTOMATIO_DISABLE_BATCH_UPLOAD,
58
+ /** @type {BatchMode}
59
+ * Batch upload mode:
60
+ * - `auto`: upload tests automatically by time interval (e.g. every 5 seconds).
61
+ * - `manual`: buffer tests and upload only when `sync()` is invoked manually.
62
+ * - `disabled`: send one test per request, no batching.
63
+ */
64
+ mode: params.batchMode || (process.env.TESTOMATIO_DISABLE_BATCH_UPLOAD ? BATCH_MODE.DISABLED : BATCH_MODE.AUTO),
56
65
  intervalFunction: null, // will be created in createRun by setInterval function
57
- intervalTime: 5000, // how often tests are sent
66
+ intervalTime: 6000, // how often tests are sent
58
67
  tests: [], // array of tests in batch
59
- batchIndex: 0, // represents the current batch index (starts from 1 and increments by 1 for each batch)
68
+ batchIndex: 0, // represents the current batch index (starts from 1 and increments by 1 for each batch)
60
69
  numberOfTimesCalledWithoutTests: 0, // how many times batch was called without tests
61
70
  };
62
71
  this.retriesTimestamps = [];
@@ -222,13 +231,13 @@ class TestomatioPipe {
222
231
 
223
232
  /**
224
233
  * Creates a new run on Testomat.io
225
- * @param {{isBatchEnabled?: boolean, kind?: string, configuration?: Record<string, any>}} params
234
+ * @param {CreateRunParams} params
226
235
  * @returns Promise<void>
227
236
  */
228
237
  async createRun(params = {}) {
229
- this.batch.isEnabled = params.isBatchEnabled ?? this.batch.isEnabled;
238
+ if (params.batchMode) this.batch.mode = params.batchMode;
230
239
  if (!this.isEnabled) return;
231
- if (this.batch.isEnabled && this.isEnabled)
240
+ if (this.batch.mode === BATCH_MODE.AUTO && this.isEnabled)
232
241
  this.batch.intervalFunction = setInterval(this.#batchUpload, this.batch.intervalTime);
233
242
  if (this.store) {
234
243
  this.store.runKind = params.kind;
@@ -433,14 +442,14 @@ class TestomatioPipe {
433
442
  * Uploads tests as a batch (multiple tests at once). Intended to be used with a setInterval
434
443
  */
435
444
  #batchUpload = async () => {
436
- if (!this.batch.isEnabled) return;
445
+ if (this.batch.mode === BATCH_MODE.DISABLED) return;
437
446
  if (!this.batch.tests.length) return;
438
447
  if (this.#cancelTestReportingInCaseOfTooManyReqFailures()) return;
439
448
  // prevent infinite loop
440
449
  if (this.batch.numberOfTimesCalledWithoutTests > 10) {
441
450
  debug('📨 Batch upload: no tests to send for 10 times, stopping batch');
442
451
  clearInterval(this.batch.intervalFunction);
443
- this.batch.isEnabled = false;
452
+ this.batch.mode = BATCH_MODE.DISABLED;
444
453
  }
445
454
  if (!this.batch.tests.length) {
446
455
  debug('📨 Batch upload: no tests to send');
@@ -496,11 +505,11 @@ class TestomatioPipe {
496
505
  this.#formatData(data);
497
506
 
498
507
  let uploading = null;
499
- if (!this.batch.isEnabled) uploading = this.#uploadSingleTest(data);
508
+ if (this.batch.mode === BATCH_MODE.DISABLED) uploading = this.#uploadSingleTest(data);
500
509
  else this.batch.tests.push(data);
501
510
 
502
- // if test is added after run which is already finished
503
- if (!this.batch.intervalFunction) uploading = this.#batchUpload();
511
+ // auto mode but no interval running yet (e.g. createRun hasn't started it): flush immediately
512
+ if (this.batch.mode === BATCH_MODE.AUTO && !this.batch.intervalFunction) uploading = this.#batchUpload();
504
513
 
505
514
  // return promise to be able to wait for it
506
515
  return uploading;
@@ -529,7 +538,7 @@ class TestomatioPipe {
529
538
  // (e.g. if test has artifacts, add test function will be invoked only after artifacts are uploaded)
530
539
  // batch stops working after run is finished; thus, disable it to use single test uploading
531
540
  this.batch.intervalFunction = null;
532
- this.batch.isEnabled = false;
541
+ this.batch.mode = BATCH_MODE.DISABLED;
533
542
  }
534
543
 
535
544
  debug('Finishing run...');
@@ -613,7 +622,7 @@ class TestomatioPipe {
613
622
  if (this.batch.intervalFunction) {
614
623
  clearInterval(this.batch.intervalFunction);
615
624
  this.batch.intervalFunction = null;
616
- this.batch.isEnabled = false;
625
+ this.batch.mode = BATCH_MODE.DISABLED;
617
626
  }
618
627
  this.batch.tests = [];
619
628
  }
package/src/replay.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import TestomatClient from './client.js';
4
- import { STATUS, DEBUG_FILE } from './constants.js';
4
+ import { STATUS, DEBUG_FILE, BATCH_MODE } from './constants.js';
5
5
  import { config } from './config.js';
6
6
 
7
7
  export class Replay {
@@ -216,7 +216,7 @@ export class Replay {
216
216
 
217
217
  const client = new TestomatClient({
218
218
  apiKey: this.apiKey,
219
- isBatchEnabled: true,
219
+ batchMode: BATCH_MODE.AUTO,
220
220
  ...runParams,
221
221
  ...(runId && { runId }),
222
222
  });
package/src/xmlReader.js CHANGED
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import pc from 'picocolors';
4
4
  import fs from 'fs';
5
5
  import { XMLParser } from 'fast-xml-parser';
6
- import { APP_PREFIX, STATUS } from './constants.js';
6
+ import { APP_PREFIX, STATUS, BATCH_MODE } from './constants.js';
7
7
  import { randomUUID } from 'crypto';
8
8
  import { fileURLToPath } from 'url';
9
9
  import { NUnitXmlParser } from './junit-adapter/nunit-parser.js';
@@ -73,8 +73,7 @@ class XmlReader {
73
73
  env: TESTOMATIO_ENV,
74
74
  group_title: TESTOMATIO_RUNGROUP_TITLE,
75
75
  detach: TESTOMATIO_MARK_DETACHED,
76
- // batch uploading is implemented for xml already
77
- isBatchEnabled: false,
76
+ batchMode: BATCH_MODE.MANUAL,
78
77
  };
79
78
  this.runId = opts.runId || TESTOMATIO_RUN;
80
79
  this.adapter = adapterFactory(opts.lang?.toLowerCase(), opts);
@@ -543,7 +542,7 @@ class XmlReader {
543
542
  title: this.requestParams.title,
544
543
  env: this.requestParams.env,
545
544
  group_title: this.requestParams.group_title,
546
- isBatchEnabled: this.requestParams.isBatchEnabled,
545
+ batchMode: this.requestParams.batchMode,
547
546
  };
548
547
 
549
548
  debug('Run', runParams);
@@ -611,7 +610,7 @@ class XmlReader {
611
610
  this.pipes = this.pipes || (await this.pipesPromise);
612
611
 
613
612
  // Create run before uploading tests to ensure runId is set
614
- await this.createRun();
613
+ // await this.createRun(); // makes reporting stuck after finish, thus commenting out
615
614
 
616
615
  if (!this.tests || !Array.isArray(this.tests) || this.tests.length === 0) {
617
616
  debug('No tests to upload, finishing run');
package/types/types.d.ts CHANGED
@@ -234,7 +234,7 @@ export interface HtmlTestData extends TestData {
234
234
  /**
235
235
  * Extended test data for Markdown reporter.
236
236
  */
237
- export interface MarkdownTestData extends HtmlTestData {}
237
+ export interface MarkdownTestData extends HtmlTestData { }
238
238
 
239
239
  /**
240
240
  * Object representing a result of a Run.
@@ -288,6 +288,13 @@ export enum RunStatus {
288
288
  Finished = 'finished',
289
289
  }
290
290
 
291
+ /** Batch upload strategy:
292
+ * `auto` (by time interval, e.g. every 5 seconds),
293
+ * `manual` (send tests via manually invoking sync() ),
294
+ * `disabled` (one test per request, no batching).
295
+ */
296
+ export type BatchMode = 'auto' | 'manual' | 'disabled';
297
+
291
298
  export interface Pipe {
292
299
  isEnabled: boolean;
293
300
  store: {};
@@ -334,8 +341,8 @@ export interface CreateRunParams {
334
341
  /** Run configuration merged into the server-side run configuration. */
335
342
  configuration?: Record<string, any>;
336
343
 
337
- /** Override batch upload on/off. */
338
- isBatchEnabled?: boolean;
344
+ /** Override batch upload mode. */
345
+ batchMode?: BatchMode;
339
346
  }
340
347
 
341
348
  /**