@testomatio/reporter 2.8.5-beta.2-yarn → 2.8.6-beta-fix-xml-batch

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.
@@ -3,6 +3,8 @@ declare class PlaywrightReporter {
3
3
  constructor(config?: {});
4
4
  client: TestomatioClient;
5
5
  uploads: any[];
6
+ reportTestPromises: any[];
7
+ runPromise: Promise<void>;
6
8
  onBegin(config: any, suite: any): void;
7
9
  suite: any;
8
10
  config: any;
@@ -21,11 +21,12 @@ const playwright_js_1 = require("./utils/playwright.js");
21
21
  Object.defineProperty(exports, "fetchLinksFromLogs", { enumerable: true, get: function () { return playwright_js_1.fetchLinksFromLogs; } });
22
22
  const step_formatter_js_1 = require("./utils/step-formatter.js");
23
23
  const log_js_1 = require("../utils/log.js");
24
- const reportTestPromises = [];
25
24
  class PlaywrightReporter {
26
25
  constructor(config = {}) {
27
26
  this.client = new client_js_1.default({ apiKey: config?.apiKey });
28
27
  this.uploads = [];
28
+ this.reportTestPromises = [];
29
+ this.runPromise = Promise.resolve();
29
30
  }
30
31
  onBegin(config, suite) {
31
32
  // clean data storage
@@ -34,7 +35,9 @@ class PlaywrightReporter {
34
35
  return;
35
36
  this.suite = suite;
36
37
  this.config = config;
37
- this.client.createRun();
38
+ this.uploads = [];
39
+ this.reportTestPromises = [];
40
+ this.runPromise = this.client.createRun();
38
41
  }
39
42
  onTestBegin(testInfo) {
40
43
  const fullTestTitle = getTestContextName(testInfo);
@@ -44,6 +47,7 @@ class PlaywrightReporter {
44
47
  // test.parent.project().__projectId
45
48
  if (!this.client)
46
49
  return;
50
+ await this.runPromise;
47
51
  const { title } = test;
48
52
  const { error, duration } = result;
49
53
  const pwAttachments = (result.attachments || []).filter(a => a.body || a.path);
@@ -126,7 +130,12 @@ class PlaywrightReporter {
126
130
  ...meta,
127
131
  ...project.metadata, // metadata has any type (in playwright), but we will stringify it in client.js
128
132
  ...test.annotations?.reduce((acc, annotation) => {
129
- acc[annotation.type] = annotation.description;
133
+ if (acc[annotation.type]) {
134
+ acc[annotation.type] = `${acc[annotation.type]}, ${annotation.description}`;
135
+ }
136
+ else {
137
+ acc[annotation.type] = annotation.description;
138
+ }
130
139
  return acc;
131
140
  }, {}),
132
141
  },
@@ -140,7 +149,7 @@ class PlaywrightReporter {
140
149
  });
141
150
  // remove empty uploads
142
151
  this.uploads = this.uploads.filter(anUpload => anUpload.files.length);
143
- reportTestPromises.push(reportTestPromise);
152
+ this.reportTestPromises.push(reportTestPromise);
144
153
  }
145
154
  #getArtifactPath(artifact) {
146
155
  if (artifact.path) {
@@ -164,7 +173,8 @@ class PlaywrightReporter {
164
173
  async onEnd(result) {
165
174
  if (!this.client)
166
175
  return;
167
- await Promise.all(reportTestPromises);
176
+ await this.runPromise;
177
+ await Promise.all(this.reportTestPromises);
168
178
  if (this.uploads.length) {
169
179
  if (this.client.uploader.isEnabled)
170
180
  log_js_1.log.info(`🎞️ Uploading ${this.uploads.length} files...`);
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;
@@ -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-beta.2-yarn",
3
+ "version": "2.8.6-beta-fix-xml-batch",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -14,13 +14,13 @@ import { fetchLinksFromLogs } from './utils/playwright.js';
14
14
  import { formatStep, addStatusToStep, addArtifactsToStep } from './utils/step-formatter.js';
15
15
  import { log } from '../utils/log.js';
16
16
 
17
- const reportTestPromises = [];
18
-
19
17
  class PlaywrightReporter {
20
18
  constructor(config = {}) {
21
19
  this.client = new TestomatioClient({ apiKey: config?.apiKey });
22
20
 
23
21
  this.uploads = [];
22
+ this.reportTestPromises = [];
23
+ this.runPromise = Promise.resolve();
24
24
  }
25
25
 
26
26
  onBegin(config, suite) {
@@ -29,7 +29,9 @@ class PlaywrightReporter {
29
29
  if (!this.client) return;
30
30
  this.suite = suite;
31
31
  this.config = config;
32
- this.client.createRun();
32
+ this.uploads = [];
33
+ this.reportTestPromises = [];
34
+ this.runPromise = this.client.createRun();
33
35
  }
34
36
 
35
37
  onTestBegin(testInfo) {
@@ -41,6 +43,7 @@ class PlaywrightReporter {
41
43
  // test.parent.project().__projectId
42
44
 
43
45
  if (!this.client) return;
46
+ await this.runPromise;
44
47
 
45
48
  const { title } = test;
46
49
  const { error, duration } = result;
@@ -133,7 +136,11 @@ class PlaywrightReporter {
133
136
  ...meta,
134
137
  ...project.metadata, // metadata has any type (in playwright), but we will stringify it in client.js
135
138
  ...test.annotations?.reduce((acc, annotation) => {
136
- acc[annotation.type] = annotation.description;
139
+ if (acc[annotation.type]) {
140
+ acc[annotation.type] = `${acc[annotation.type]}, ${annotation.description}`;
141
+ } else {
142
+ acc[annotation.type] = annotation.description;
143
+ }
137
144
  return acc;
138
145
  }, {}),
139
146
  },
@@ -149,7 +156,7 @@ class PlaywrightReporter {
149
156
  // remove empty uploads
150
157
  this.uploads = this.uploads.filter(anUpload => anUpload.files.length);
151
158
 
152
- reportTestPromises.push(reportTestPromise);
159
+ this.reportTestPromises.push(reportTestPromise);
153
160
  }
154
161
 
155
162
  #getArtifactPath(artifact) {
@@ -173,7 +180,8 @@ class PlaywrightReporter {
173
180
  async onEnd(result) {
174
181
  if (!this.client) return;
175
182
 
176
- await Promise.all(reportTestPromises);
183
+ await this.runPromise;
184
+ await Promise.all(this.reportTestPromises);
177
185
 
178
186
  if (this.uploads.length) {
179
187
  if (this.client.uploader.isEnabled) log.info(`🎞️ Uploading ${this.uploads.length} files...`);
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,
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
  /**