@testomatio/reporter 2.6.4-beta.1 → 2.7.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.
@@ -115,7 +115,7 @@ function CodeceptReporter(config) {
115
115
  });
116
116
  // mark as failed all tests inside the failed hook
117
117
  event.dispatcher.on(event.hook.failed, hook => {
118
- if (hook.name !== 'BeforeSuiteHook')
118
+ if (hook.name !== 'BeforeSuiteHook' && hook.name !== 'BeforeHook')
119
119
  return;
120
120
  const suite = hook.runnable.parent;
121
121
  if (!suite)
@@ -19,6 +19,8 @@ function MochaReporter(runner, opts) {
19
19
  // let artifactStore;
20
20
  const apiKey = opts?.reporterOptions?.apiKey || config_js_1.config.TESTOMATIO;
21
21
  const client = new client_js_1.default({ apiKey });
22
+ // Track hook failures
23
+ const hookFailures = new Map();
22
24
  runner.on(EVENT_RUN_BEGIN, () => {
23
25
  client.createRun();
24
26
  // clear dir with artifacts/logs
@@ -27,7 +29,30 @@ function MochaReporter(runner, opts) {
27
29
  runner.on(EVENT_SUITE_BEGIN, async (suite) => {
28
30
  index_js_1.services.setContext(suite.fullTitle());
29
31
  });
30
- runner.on(EVENT_SUITE_END, async () => {
32
+ runner.on(EVENT_SUITE_END, async (suite) => {
33
+ if (hookFailures.has(suite.fullTitle())) {
34
+ const { error, suiteTitle } = hookFailures.get(suite.fullTitle());
35
+ for (const test of suite.tests) {
36
+ if (test.state === 'pending' || !test.state) {
37
+ const testId = (0, utils_js_1.getTestomatIdFromTestTitle)(test.title);
38
+ const artifacts = index_js_1.services.artifacts.get(test.fullTitle());
39
+ const keyValues = index_js_1.services.keyValues.get(test.fullTitle());
40
+ const links = index_js_1.services.links.get(test.fullTitle());
41
+ client.addTestRun(constants_js_1.STATUS.FAILED, {
42
+ error,
43
+ suite_title: suiteTitle || suite.title,
44
+ file: suite.file,
45
+ test_id: testId,
46
+ title: test.title,
47
+ code: process.env.TESTOMATIO_UPDATE_CODE ? test.body.toString() : '',
48
+ time: 0,
49
+ manuallyAttachedArtifacts: artifacts,
50
+ meta: keyValues,
51
+ links,
52
+ });
53
+ }
54
+ }
55
+ }
31
56
  index_js_1.services.setContext(null);
32
57
  });
33
58
  runner.on(EVENT_TEST_BEGIN, async (test) => {
@@ -79,6 +104,14 @@ function MochaReporter(runner, opts) {
79
104
  runner.on(EVENT_TEST_FAIL, async (test, err) => {
80
105
  failures += 1;
81
106
  console.log(picocolors_1.default.bold(picocolors_1.default.red('✖')), test.fullTitle(), picocolors_1.default.gray(err.message));
107
+ const isHookFailure = test.title.includes('before each') || test.title.includes('after each');
108
+ if (isHookFailure && test.parent) {
109
+ hookFailures.set(test.parent.fullTitle(), {
110
+ error: err,
111
+ suiteTitle: getSuiteTitle(test),
112
+ });
113
+ return;
114
+ }
82
115
  const testId = (0, utils_js_1.getTestomatIdFromTestTitle)(test.title);
83
116
  const logs = getTestLogs(test);
84
117
  const artifacts = index_js_1.services.artifacts.get(test.fullTitle());
@@ -4,15 +4,22 @@ declare class WebdriverReporter extends WDIOReporter {
4
4
  client: TestomatClient;
5
5
  _addTestPromises: any[];
6
6
  _isSynchronising: boolean;
7
+ hooksEnhancer: any;
8
+ /**
9
+ * Initialize the hooks enhancer
10
+ * @private
11
+ */
12
+ private _initializeHooksEnhancer;
7
13
  /**
8
14
  *
9
15
  * @param {RunnerStats} runData
10
16
  */
11
17
  onRunnerEnd(runData: RunnerStats): Promise<void>;
12
18
  onRunnerStart(): void;
19
+ onHookEnd(hook: any): void;
20
+ onSuiteEnd(suiteOrScenario: any): Promise<void>;
13
21
  onTestStart(test: any): void;
14
22
  onTestEnd(test: any): void;
15
- onSuiteEnd(scerario: any): void;
16
23
  addTest(test: any): Promise<void>;
17
24
  /**
18
25
  * @param {import('../../types/types.js').WebdriverIOScenario} scenario
@@ -49,6 +49,11 @@ class WebdriverReporter extends reporter_1.default {
49
49
  options = Object.assign(options, { stdout: true });
50
50
  this._addTestPromises = [];
51
51
  this._isSynchronising = false;
52
+ // Optional hooks enhancer for beforeEach failure handling
53
+ this.hooksEnhancer = null;
54
+ if (options?.enableHooksEnhancer) {
55
+ this._initializeHooksEnhancer();
56
+ }
52
57
  // run is created by cli, if enabling the row below, it mat lead to multiple runs being created
53
58
  // thus, need to check if process.env.runId is set and/or add more checks to avoid creating multiple runs
54
59
  // this.client.createRun();
@@ -56,6 +61,27 @@ class WebdriverReporter extends reporter_1.default {
56
61
  get isSynchronised() {
57
62
  return this._isSynchronising === false;
58
63
  }
64
+ /**
65
+ * Initialize the hooks enhancer
66
+ * @private
67
+ */
68
+ async _initializeHooksEnhancer() {
69
+ try {
70
+ // Dynamic import to avoid hard dependency
71
+ // Resolve package path from the project's node_modules
72
+ const { createRequire } = await Promise.resolve().then(() => __importStar(require('module')));
73
+ const projectRequire = createRequire(process.cwd() + '/package.json');
74
+ // Import the hooks enhancer package
75
+ const packagePath = projectRequire.resolve('@testomatio/webdriver-hooks-enhancer');
76
+ const hooksEnhancerModule = await Promise.resolve(`${packagePath}`).then(s => __importStar(require(s)));
77
+ const { createHooksEnhancer } = hooksEnhancerModule;
78
+ this.hooksEnhancer = createHooksEnhancer(this);
79
+ console.log('[TESTOMATIO] WebdriverIO Hooks Enhancer enabled');
80
+ }
81
+ catch (error) {
82
+ console.warn('[TESTOMATIO] Could not enable WebdriverIO Hooks Enhancer.', 'Install @testomatio/webdriver-hooks-enhancer to use this feature:', error.message);
83
+ }
84
+ }
59
85
  /**
60
86
  *
61
87
  * @param {RunnerStats} runData
@@ -72,6 +98,22 @@ class WebdriverReporter extends reporter_1.default {
72
98
  // clear dir with artifacts/logs
73
99
  utils_js_1.fileSystem.clearDir(constants_js_1.TESTOMAT_TMP_STORAGE_DIR);
74
100
  }
101
+ onHookEnd(hook) {
102
+ // Hooks enhancer will handle this if enabled
103
+ if (this.hooksEnhancer) {
104
+ this.hooksEnhancer.trackHookFailure(hook);
105
+ }
106
+ }
107
+ async onSuiteEnd(suiteOrScenario) {
108
+ // Handle hook failures for regular suites using enhancer
109
+ if (this.hooksEnhancer && suiteOrScenario.type !== 'scenario') {
110
+ await this.hooksEnhancer.handleSuiteEnd(suiteOrScenario, this.client, utils_js_1.getTestomatIdFromTestTitle);
111
+ }
112
+ // Handle BDD scenarios (cucumber)
113
+ if (suiteOrScenario.type === 'scenario') {
114
+ this._addTestPromises.push(this.addBddScenario(suiteOrScenario));
115
+ }
116
+ }
75
117
  onTestStart(test) {
76
118
  index_js_1.services.setContext(test.fullTitle);
77
119
  }
@@ -84,12 +126,6 @@ class WebdriverReporter extends reporter_1.default {
84
126
  test.logs = logs;
85
127
  this._addTestPromises.push(this.addTest(test));
86
128
  }
87
- // wdio-cucumber does not trigger onTestEnd hook, thus, using this one
88
- onSuiteEnd(scerario) {
89
- if (scerario.type === 'scenario') {
90
- this._addTestPromises.push(this.addBddScenario(scerario));
91
- }
92
- }
93
129
  async addTest(test) {
94
130
  if (!this.client)
95
131
  return;
@@ -17,6 +17,7 @@ export class BitbucketPipe {
17
17
  createRun(): Promise<void>;
18
18
  addTest(test: any): void;
19
19
  finishRun(runParams: any): Promise<void>;
20
+ sync(): Promise<void>;
20
21
  toString(): string;
21
22
  updateRun(): void;
22
23
  }
@@ -198,6 +198,10 @@ class BitbucketPipe {
198
198
  Request data: ${body}`);
199
199
  }
200
200
  }
201
+ async sync() {
202
+ // BitbucketPipe doesn't buffer tests, so sync is a no-op
203
+ // Reserved for future use if needed
204
+ }
201
205
  toString() {
202
206
  return 'Bitbucket Reporter';
203
207
  }
@@ -25,6 +25,7 @@ declare class CoveragePipe {
25
25
  createRun(): Promise<void>;
26
26
  updateRun(): void;
27
27
  finishRun(runParams: any): Promise<void>;
28
+ sync(): Promise<void>;
28
29
  toString(): string;
29
30
  /**
30
31
  * Retrieves the list of files changed in the current Git working directory
@@ -162,6 +162,10 @@ class CoveragePipe {
162
162
  async createRun() { }
163
163
  updateRun() { }
164
164
  async finishRun(runParams) { }
165
+ async sync() {
166
+ // CoveragePipe doesn't buffer tests, so sync is a no-op
167
+ // Reserved for future use if needed
168
+ }
165
169
  toString() {
166
170
  return 'Coverage Reporter';
167
171
  }
package/lib/pipe/csv.d.ts CHANGED
@@ -43,5 +43,6 @@ declare class CsvPipe implements Pipe {
43
43
  finishRun(runParams: {
44
44
  tests?: TestData[];
45
45
  }): Promise<void>;
46
+ sync(): Promise<void>;
46
47
  toString(): string;
47
48
  }
package/lib/pipe/csv.js CHANGED
@@ -113,6 +113,9 @@ class CsvPipe {
113
113
  return;
114
114
  if (runParams.tests)
115
115
  runParams.tests.forEach(t => this.addTest(t));
116
+ await this.sync();
117
+ }
118
+ async sync() {
116
119
  // Save results based on the default headers
117
120
  if (this.isEnabled) {
118
121
  await this.saveToCsv(this.results, constants_js_1.CSV_HEADERS);
@@ -25,5 +25,6 @@ export class DebugPipe {
25
25
  createRun(params?: {}): Promise<{}>;
26
26
  addTest(data: any): Promise<void>;
27
27
  finishRun(params: any): Promise<void>;
28
+ sync(): Promise<void>;
28
29
  toString(): string;
29
30
  }
package/lib/pipe/debug.js CHANGED
@@ -114,12 +114,17 @@ class DebugPipe {
114
114
  async finishRun(params) {
115
115
  if (!this.isEnabled)
116
116
  return;
117
- await this.batchUpload();
117
+ await this.sync();
118
118
  if (this.batch.intervalFunction)
119
119
  clearInterval(this.batch.intervalFunction);
120
120
  this.logToFile({ action: 'finishRun', params });
121
121
  console.log(constants_js_1.APP_PREFIX, '🪲 Debug Saved to', this.logFilePath);
122
122
  }
123
+ async sync() {
124
+ if (!this.isEnabled)
125
+ return;
126
+ await this.batchUpload();
127
+ }
123
128
  toString() {
124
129
  return 'Debug Reporter';
125
130
  }
@@ -26,5 +26,6 @@ declare class GitHubPipe implements Pipe {
26
26
  octokit: import("@octokit/core").Octokit & import("@octokit/plugin-rest-endpoint-methods/dist-types/generated/method-types.js").RestEndpointMethods & import("@octokit/plugin-rest-endpoint-methods").Api & {
27
27
  paginate: import("@octokit/plugin-paginate-rest").PaginateInterface;
28
28
  };
29
+ sync(): Promise<void>;
29
30
  toString(): string;
30
31
  }
@@ -203,6 +203,10 @@ class GitHubPipe {
203
203
  console.log(constants_js_1.APP_PREFIX, picocolors_1.default.yellow('GitHub'), `Couldn't create GitHub report ${err}`);
204
204
  }
205
205
  }
206
+ async sync() {
207
+ // GitHubPipe doesn't buffer tests, so sync is a no-op
208
+ // Reserved for future use if needed
209
+ }
206
210
  toString() {
207
211
  return 'GitHub Reporter';
208
212
  }
@@ -19,6 +19,7 @@ declare class GitLabPipe {
19
19
  createRun(): Promise<void>;
20
20
  addTest(test: any): void;
21
21
  finishRun(runParams: any): Promise<void>;
22
+ sync(): Promise<void>;
22
23
  toString(): string;
23
24
  updateRun(): void;
24
25
  }
@@ -155,6 +155,10 @@ class GitLabPipe {
155
155
  Request data: ${body}`);
156
156
  }
157
157
  }
158
+ async sync() {
159
+ // GitLabPipe doesn't buffer tests, so sync is a no-op
160
+ // Reserved for future use if needed
161
+ }
158
162
  toString() {
159
163
  return 'GitLab Reporter';
160
164
  }
@@ -29,6 +29,7 @@ declare class HtmlPipe {
29
29
  * @returns {void} - This function does not return anything.
30
30
  */
31
31
  buildReport(opts: object): void;
32
+ sync(): Promise<void>;
32
33
  toString(): string;
33
34
  #private;
34
35
  }
package/lib/pipe/html.js CHANGED
@@ -321,6 +321,10 @@ class HtmlPipe {
321
321
  return Object.keys(obj).length;
322
322
  });
323
323
  }
324
+ async sync() {
325
+ // HtmlPipe doesn't buffer tests, so sync is a no-op
326
+ // Reserved for future use if needed
327
+ }
324
328
  toString() {
325
329
  return 'HTML Reporter';
326
330
  }
@@ -60,6 +60,11 @@ declare class TestomatioPipe implements Pipe {
60
60
  * Adds a test to the batch uploader (or reports a single test if batch uploading is disabled)
61
61
  */
62
62
  addTest(data: any): Promise<void | import("gaxios").GaxiosResponse<any>>;
63
+ /**
64
+ * Syncs / flushes buffered tests by uploading them as a batch
65
+ * This is used to manually trigger batch upload (e.g., after all tests are added)
66
+ */
67
+ sync(): Promise<void>;
63
68
  /**
64
69
  * @param {import('../../types/types.js').RunData} params
65
70
  * @returns
@@ -264,6 +264,8 @@ class TestomatioPipe {
264
264
  const errorText = err.response?.data?.message || err.message;
265
265
  debug('Error creating run', err);
266
266
  console.log(errorText || err);
267
+ if (err.response?.status === 403)
268
+ this.#disablePipe();
267
269
  if (!this.apiKey)
268
270
  console.error('Testomat.io API key is not set');
269
271
  if (!this.apiKey?.startsWith('tstmt'))
@@ -312,6 +314,8 @@ class TestomatioPipe {
312
314
  maxContentLength: Infinity,
313
315
  })
314
316
  .catch(err => {
317
+ if (err.response?.status === 403)
318
+ this.#disablePipe();
315
319
  this.requestFailures++;
316
320
  this.notReportedTestsCount++;
317
321
  if (err.response) {
@@ -363,6 +367,8 @@ class TestomatioPipe {
363
367
  maxContentLength: Infinity,
364
368
  })
365
369
  .catch(err => {
370
+ if (err.response?.status === 403)
371
+ this.#disablePipe();
366
372
  this.requestFailures++;
367
373
  this.notReportedTestsCount += testsToSend.length;
368
374
  if (err.response) {
@@ -398,6 +404,15 @@ class TestomatioPipe {
398
404
  // return promise to be able to wait for it
399
405
  return uploading;
400
406
  }
407
+ /**
408
+ * Syncs / flushes buffered tests by uploading them as a batch
409
+ * This is used to manually trigger batch upload (e.g., after all tests are added)
410
+ */
411
+ async sync() {
412
+ if (!this.isEnabled)
413
+ return;
414
+ await this.#batchUpload();
415
+ }
401
416
  /**
402
417
  * @param {import('../../types/types.js').RunData} params
403
418
  * @returns
@@ -472,6 +487,17 @@ class TestomatioPipe {
472
487
  }
473
488
  debug('Run finished');
474
489
  }
490
+ #disablePipe() {
491
+ this.isEnabled = false;
492
+ this.apiKey = null;
493
+ // clear interval function, otherwise the proccess will continue indefinitely
494
+ if (this.batch.intervalFunction) {
495
+ clearInterval(this.batch.intervalFunction);
496
+ this.batch.intervalFunction = null;
497
+ this.batch.isEnabled = false;
498
+ }
499
+ this.batch.tests = [];
500
+ }
475
501
  #logFailedResponse(error) {
476
502
  let responseBody = stringify(error.response?.data ?? error.response ?? error, { pretty: true });
477
503
  if (!responseBody)
@@ -93,6 +93,7 @@ declare class XmlReader {
93
93
  pipes: any;
94
94
  uploadData(): Promise<any[]>;
95
95
  _finishRun(): Promise<any[]>;
96
+ #private;
96
97
  }
97
98
  import { XMLParser } from 'fast-xml-parser';
98
99
  import { S3Uploader } from './uploader.js';
package/lib/xmlReader.js CHANGED
@@ -452,6 +452,43 @@ class XmlReader {
452
452
  this.uploader.checkEnabled();
453
453
  return run;
454
454
  }
455
+ /**
456
+ * Calculate the approximate size of data in bytes (JSON stringified length)
457
+ * @param {Object} data - Data to measure
458
+ * @returns {number} Size in bytes
459
+ */
460
+ #getObjectSize(data) {
461
+ const body = JSON.stringify(data);
462
+ return new TextEncoder().encode(body).length;
463
+ }
464
+ /**
465
+ * Split tests array into chunks based on data size
466
+ * @param {Array} tests - Array of tests to split
467
+ * @returns {Array<Array>} Array of test chunks
468
+ */
469
+ #splitTestsIntoChunks(tests) {
470
+ const maxSizeBytes = 1 * 1024 * 1024;
471
+ const chunks = [];
472
+ let currentChunk = [];
473
+ let currentChunkSize = 0;
474
+ for (const test of tests) {
475
+ const testSize = this.#getObjectSize(test);
476
+ const wouldExceedSize = currentChunkSize + testSize > maxSizeBytes;
477
+ if (wouldExceedSize) {
478
+ if (currentChunk.length > 0) {
479
+ chunks.push(currentChunk);
480
+ }
481
+ currentChunk = [];
482
+ currentChunkSize = 0;
483
+ }
484
+ currentChunk.push(test);
485
+ currentChunkSize += testSize;
486
+ }
487
+ if (currentChunk.length > 0) {
488
+ chunks.push(currentChunk);
489
+ }
490
+ return chunks;
491
+ }
455
492
  async uploadData() {
456
493
  await this.uploadArtifacts();
457
494
  this.calculateStats();
@@ -459,16 +496,51 @@ class XmlReader {
459
496
  this.fetchSourceCode();
460
497
  this.formatErrors();
461
498
  this.formatTests();
462
- const dataString = {
463
- ...this.stats,
499
+ this.pipes = this.pipes || (await this.pipesPromise);
500
+ // Create run before uploading tests to ensure runId is set
501
+ await this.createRun();
502
+ if (!this.tests || !Array.isArray(this.tests) || this.tests.length === 0) {
503
+ debug('No tests to upload, finishing run');
504
+ const finishData = {
505
+ api_key: this.requestParams.apiKey,
506
+ status: 'finished',
507
+ duration: this.stats.duration,
508
+ detach: this.requestParams.detach,
509
+ };
510
+ return Promise.all(this.pipes.map(p => p.finishRun(finishData)));
511
+ }
512
+ const testChunks = this.#splitTestsIntoChunks(this.tests);
513
+ const totalChunks = testChunks.length;
514
+ const totalTests = this.tests.length;
515
+ debug(`Split ${totalTests} tests into ${totalChunks} chunks (max 1MB per chunk)`);
516
+ let uploadedTests = 0;
517
+ for (let i = 0; i < testChunks.length; i++) {
518
+ const chunk = testChunks[i];
519
+ const chunkNum = i + 1;
520
+ if (totalChunks > 1) {
521
+ debug(`Uploading chunk ${chunkNum}/${totalChunks} (${chunk.length} tests)`);
522
+ }
523
+ for (const test of chunk) {
524
+ await Promise.all(this.pipes.map(p => p.addTest(test)));
525
+ }
526
+ await Promise.all(this.pipes.map(p => p.sync()));
527
+ uploadedTests += chunk.length;
528
+ debug(`Uploaded ${uploadedTests}/${totalTests} tests`);
529
+ }
530
+ if (totalChunks > 1) {
531
+ console.log(constants_js_1.APP_PREFIX, `✅ Successfully uploaded ${uploadedTests} tests in ${totalChunks} chunks`);
532
+ }
533
+ else {
534
+ console.log(constants_js_1.APP_PREFIX, `✅ Successfully uploaded ${uploadedTests} tests`);
535
+ }
536
+ const finishData = {
464
537
  api_key: this.requestParams.apiKey,
465
538
  status: 'finished',
466
539
  duration: this.stats.duration,
467
- tests: this.tests,
540
+ detach: this.requestParams.detach,
468
541
  };
469
- debug('Uploading data', dataString);
470
- this.pipes = this.pipes || (await this.pipesPromise);
471
- return Promise.all(this.pipes.map(p => p.finishRun(dataString)));
542
+ debug('Finishing run with status:', finishData.status);
543
+ return Promise.all(this.pipes.map(p => p.finishRun(finishData)));
472
544
  }
473
545
  async _finishRun() {
474
546
  this.pipes = this.pipes || (await this.pipesPromise);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.6.4-beta.1",
3
+ "version": "2.7.0",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -37,7 +37,7 @@
37
37
  "json-cycle": "^1.3.0",
38
38
  "lodash.memoize": "^4.1.2",
39
39
  "lodash.merge": "^4.6.2",
40
- "minimatch": "^9.0.3",
40
+ "minimatch": "^10.2.4",
41
41
  "picocolors": "^1.0.1",
42
42
  "pretty-ms": "^7.0.1",
43
43
  "promise-retry": "^2.0.1",
@@ -133,7 +133,7 @@ function CodeceptReporter(config) {
133
133
 
134
134
  // mark as failed all tests inside the failed hook
135
135
  event.dispatcher.on(event.hook.failed, hook => {
136
- if (hook.name !== 'BeforeSuiteHook') return;
136
+ if (hook.name !== 'BeforeSuiteHook' && hook.name !== 'BeforeHook') return;
137
137
  const suite = hook.runnable.parent;
138
138
 
139
139
  if (!suite) return;
@@ -29,6 +29,9 @@ function MochaReporter(runner, opts) {
29
29
 
30
30
  const client = new TestomatClient({ apiKey });
31
31
 
32
+ // Track hook failures
33
+ const hookFailures = new Map();
34
+
32
35
  runner.on(EVENT_RUN_BEGIN, () => {
33
36
  client.createRun();
34
37
 
@@ -40,7 +43,33 @@ function MochaReporter(runner, opts) {
40
43
  services.setContext(suite.fullTitle());
41
44
  });
42
45
 
43
- runner.on(EVENT_SUITE_END, async () => {
46
+ runner.on(EVENT_SUITE_END, async suite => {
47
+ if (hookFailures.has(suite.fullTitle())) {
48
+ const { error, suiteTitle } = hookFailures.get(suite.fullTitle());
49
+
50
+ for (const test of suite.tests) {
51
+ if (test.state === 'pending' || !test.state) {
52
+ const testId = getTestomatIdFromTestTitle(test.title);
53
+ const artifacts = services.artifacts.get(test.fullTitle());
54
+ const keyValues = services.keyValues.get(test.fullTitle());
55
+ const links = services.links.get(test.fullTitle());
56
+
57
+ client.addTestRun(STATUS.FAILED, {
58
+ error,
59
+ suite_title: suiteTitle || suite.title,
60
+ file: suite.file,
61
+ test_id: testId,
62
+ title: test.title,
63
+ code: process.env.TESTOMATIO_UPDATE_CODE ? test.body.toString() : '',
64
+ time: 0,
65
+ manuallyAttachedArtifacts: artifacts,
66
+ meta: keyValues,
67
+ links,
68
+ });
69
+ }
70
+ }
71
+ }
72
+
44
73
  services.setContext(null);
45
74
  });
46
75
 
@@ -101,6 +130,17 @@ function MochaReporter(runner, opts) {
101
130
  runner.on(EVENT_TEST_FAIL, async (test, err) => {
102
131
  failures += 1;
103
132
  console.log(pc.bold(pc.red('✖')), test.fullTitle(), pc.gray(err.message));
133
+
134
+ const isHookFailure = test.title.includes('before each') || test.title.includes('after each');
135
+
136
+ if (isHookFailure && test.parent) {
137
+ hookFailures.set(test.parent.fullTitle(), {
138
+ error: err,
139
+ suiteTitle: getSuiteTitle(test),
140
+ });
141
+ return;
142
+ }
143
+
104
144
  const testId = getTestomatIdFromTestTitle(test.title);
105
145
 
106
146
  const logs = getTestLogs(test);
@@ -16,6 +16,12 @@ class WebdriverReporter extends WDIOReporter {
16
16
 
17
17
  this._isSynchronising = false;
18
18
 
19
+ // Optional hooks enhancer for beforeEach failure handling
20
+ this.hooksEnhancer = null;
21
+ if (options?.enableHooksEnhancer) {
22
+ this._initializeHooksEnhancer();
23
+ }
24
+
19
25
  // run is created by cli, if enabling the row below, it mat lead to multiple runs being created
20
26
  // thus, need to check if process.env.runId is set and/or add more checks to avoid creating multiple runs
21
27
  // this.client.createRun();
@@ -25,6 +31,32 @@ class WebdriverReporter extends WDIOReporter {
25
31
  return this._isSynchronising === false;
26
32
  }
27
33
 
34
+ /**
35
+ * Initialize the hooks enhancer
36
+ * @private
37
+ */
38
+ async _initializeHooksEnhancer() {
39
+ try {
40
+ // Dynamic import to avoid hard dependency
41
+ // Resolve package path from the project's node_modules
42
+ const { createRequire } = await import('module');
43
+ const projectRequire = createRequire(process.cwd() + '/package.json');
44
+ // Import the hooks enhancer package
45
+ const packagePath = projectRequire.resolve('@testomatio/webdriver-hooks-enhancer');
46
+ const hooksEnhancerModule = await import(packagePath);
47
+ const { createHooksEnhancer } = hooksEnhancerModule;
48
+
49
+ this.hooksEnhancer = createHooksEnhancer(this);
50
+ console.log('[TESTOMATIO] WebdriverIO Hooks Enhancer enabled');
51
+ } catch (error) {
52
+ console.warn(
53
+ '[TESTOMATIO] Could not enable WebdriverIO Hooks Enhancer.',
54
+ 'Install @testomatio/webdriver-hooks-enhancer to use this feature:',
55
+ error.message
56
+ );
57
+ }
58
+ }
59
+
28
60
  /**
29
61
  *
30
62
  * @param {RunnerStats} runData
@@ -46,6 +78,29 @@ class WebdriverReporter extends WDIOReporter {
46
78
  fileSystem.clearDir(TESTOMAT_TMP_STORAGE_DIR);
47
79
  }
48
80
 
81
+ onHookEnd(hook) {
82
+ // Hooks enhancer will handle this if enabled
83
+ if (this.hooksEnhancer) {
84
+ this.hooksEnhancer.trackHookFailure(hook);
85
+ }
86
+ }
87
+
88
+ async onSuiteEnd(suiteOrScenario) {
89
+ // Handle hook failures for regular suites using enhancer
90
+ if (this.hooksEnhancer && suiteOrScenario.type !== 'scenario') {
91
+ await this.hooksEnhancer.handleSuiteEnd(
92
+ suiteOrScenario,
93
+ this.client,
94
+ getTestomatIdFromTestTitle
95
+ );
96
+ }
97
+
98
+ // Handle BDD scenarios (cucumber)
99
+ if (suiteOrScenario.type === 'scenario') {
100
+ this._addTestPromises.push(this.addBddScenario(suiteOrScenario));
101
+ }
102
+ }
103
+
49
104
  onTestStart(test) {
50
105
  services.setContext(test.fullTitle);
51
106
  }
@@ -62,13 +117,6 @@ class WebdriverReporter extends WDIOReporter {
62
117
  this._addTestPromises.push(this.addTest(test));
63
118
  }
64
119
 
65
- // wdio-cucumber does not trigger onTestEnd hook, thus, using this one
66
- onSuiteEnd(scerario) {
67
- if (scerario.type === 'scenario') {
68
- this._addTestPromises.push(this.addBddScenario(scerario));
69
- }
70
- }
71
-
72
120
  async addTest(test) {
73
121
  if (!this.client) return;
74
122
 
@@ -205,6 +205,11 @@ export class BitbucketPipe {
205
205
  }
206
206
  }
207
207
 
208
+ async sync() {
209
+ // BitbucketPipe doesn't buffer tests, so sync is a no-op
210
+ // Reserved for future use if needed
211
+ }
212
+
208
213
  toString() {
209
214
  return 'Bitbucket Reporter';
210
215
  }
@@ -192,6 +192,11 @@ class CoveragePipe { // or Changes for the future???
192
192
 
193
193
  async finishRun(runParams) {}
194
194
 
195
+ async sync() {
196
+ // CoveragePipe doesn't buffer tests, so sync is a no-op
197
+ // Reserved for future use if needed
198
+ }
199
+
195
200
  toString() {
196
201
  return 'Coverage Reporter';
197
202
  }
package/src/pipe/csv.js CHANGED
@@ -125,6 +125,10 @@ class CsvPipe {
125
125
  if (!this.isEnabled) return;
126
126
 
127
127
  if (runParams.tests) runParams.tests.forEach(t => this.addTest(t));
128
+ await this.sync();
129
+ }
130
+
131
+ async sync() {
128
132
  // Save results based on the default headers
129
133
  if (this.isEnabled) {
130
134
  await this.saveToCsv(this.results, CSV_HEADERS);
package/src/pipe/debug.js CHANGED
@@ -112,12 +112,17 @@ export class DebugPipe {
112
112
 
113
113
  async finishRun(params) {
114
114
  if (!this.isEnabled) return;
115
- await this.batchUpload();
115
+ await this.sync();
116
116
  if (this.batch.intervalFunction) clearInterval(this.batch.intervalFunction);
117
117
  this.logToFile({ action: 'finishRun', params });
118
118
  console.log(APP_PREFIX, '🪲 Debug Saved to', this.logFilePath);
119
119
  }
120
120
 
121
+ async sync() {
122
+ if (!this.isEnabled) return;
123
+ await this.batchUpload();
124
+ }
125
+
121
126
  toString() {
122
127
  return 'Debug Reporter';
123
128
  }
@@ -197,6 +197,11 @@ class GitHubPipe {
197
197
  }
198
198
  }
199
199
 
200
+ async sync() {
201
+ // GitHubPipe doesn't buffer tests, so sync is a no-op
202
+ // Reserved for future use if needed
203
+ }
204
+
200
205
  toString() {
201
206
  return 'GitHub Reporter';
202
207
  }
@@ -195,6 +195,11 @@ class GitLabPipe {
195
195
  }
196
196
  }
197
197
 
198
+ async sync() {
199
+ // GitLabPipe doesn't buffer tests, so sync is a no-op
200
+ // Reserved for future use if needed
201
+ }
202
+
198
203
  toString() {
199
204
  return 'GitLab Reporter';
200
205
  }
package/src/pipe/html.js CHANGED
@@ -402,6 +402,11 @@ class HtmlPipe {
402
402
  });
403
403
  }
404
404
 
405
+ async sync() {
406
+ // HtmlPipe doesn't buffer tests, so sync is a no-op
407
+ // Reserved for future use if needed
408
+ }
409
+
405
410
  toString() {
406
411
  return 'HTML Reporter';
407
412
  }
@@ -294,6 +294,7 @@ class TestomatioPipe {
294
294
  const errorText = err.response?.data?.message || err.message;
295
295
  debug('Error creating run', err);
296
296
  console.log(errorText || err);
297
+ if (err.response?.status === 403) this.#disablePipe();
297
298
  if (!this.apiKey) console.error('Testomat.io API key is not set');
298
299
  if (!this.apiKey?.startsWith('tstmt')) console.error('Testomat.io API key is invalid');
299
300
 
@@ -347,6 +348,7 @@ class TestomatioPipe {
347
348
  maxContentLength: Infinity,
348
349
  })
349
350
  .catch(err => {
351
+ if (err.response?.status === 403) this.#disablePipe();
350
352
  this.requestFailures++;
351
353
  this.notReportedTestsCount++;
352
354
  if (err.response) {
@@ -397,6 +399,7 @@ class TestomatioPipe {
397
399
  maxContentLength: Infinity,
398
400
  })
399
401
  .catch(err => {
402
+ if (err.response?.status === 403) this.#disablePipe();
400
403
  this.requestFailures++;
401
404
  this.notReportedTestsCount += testsToSend.length;
402
405
  if (err.response) {
@@ -434,6 +437,15 @@ class TestomatioPipe {
434
437
  return uploading;
435
438
  }
436
439
 
440
+ /**
441
+ * Syncs / flushes buffered tests by uploading them as a batch
442
+ * This is used to manually trigger batch upload (e.g., after all tests are added)
443
+ */
444
+ async sync() {
445
+ if (!this.isEnabled) return;
446
+ await this.#batchUpload();
447
+ }
448
+
437
449
  /**
438
450
  * @param {import('../../types/types.js').RunData} params
439
451
  * @returns
@@ -520,6 +532,19 @@ class TestomatioPipe {
520
532
  debug('Run finished');
521
533
  }
522
534
 
535
+ #disablePipe() {
536
+ this.isEnabled = false;
537
+ this.apiKey = null;
538
+
539
+ // clear interval function, otherwise the proccess will continue indefinitely
540
+ if (this.batch.intervalFunction) {
541
+ clearInterval(this.batch.intervalFunction);
542
+ this.batch.intervalFunction = null;
543
+ this.batch.isEnabled = false;
544
+ }
545
+ this.batch.tests = [];
546
+ }
547
+
523
548
  #logFailedResponse(error) {
524
549
  let responseBody = stringify(error.response?.data ?? error.response ?? error, { pretty: true });
525
550
  if (!responseBody) responseBody = '<empty>';
package/src/xmlReader.js CHANGED
@@ -529,6 +529,52 @@ class XmlReader {
529
529
  return run;
530
530
  }
531
531
 
532
+ /**
533
+ * Calculate the approximate size of data in bytes (JSON stringified length)
534
+ * @param {Object} data - Data to measure
535
+ * @returns {number} Size in bytes
536
+ */
537
+ #getObjectSize(data) {
538
+ const body = JSON.stringify(data);
539
+ return new TextEncoder().encode(body).length;
540
+ }
541
+
542
+ /**
543
+ * Split tests array into chunks based on data size
544
+ * @param {Array} tests - Array of tests to split
545
+ * @returns {Array<Array>} Array of test chunks
546
+ */
547
+ #splitTestsIntoChunks(tests) {
548
+ const maxSizeBytes = 1 * 1024 * 1024;
549
+
550
+ const chunks = [];
551
+ let currentChunk = [];
552
+ let currentChunkSize = 0;
553
+
554
+ for (const test of tests) {
555
+ const testSize = this.#getObjectSize(test);
556
+
557
+ const wouldExceedSize = currentChunkSize + testSize > maxSizeBytes;
558
+
559
+ if (wouldExceedSize) {
560
+ if (currentChunk.length > 0) {
561
+ chunks.push(currentChunk);
562
+ }
563
+ currentChunk = [];
564
+ currentChunkSize = 0;
565
+ }
566
+
567
+ currentChunk.push(test);
568
+ currentChunkSize += testSize;
569
+ }
570
+
571
+ if (currentChunk.length > 0) {
572
+ chunks.push(currentChunk);
573
+ }
574
+
575
+ return chunks;
576
+ }
577
+
532
578
  async uploadData() {
533
579
  await this.uploadArtifacts();
534
580
  this.calculateStats();
@@ -537,18 +583,63 @@ class XmlReader {
537
583
  this.formatErrors();
538
584
  this.formatTests();
539
585
 
540
- const dataString = {
541
- ...this.stats,
586
+ this.pipes = this.pipes || (await this.pipesPromise);
587
+
588
+ // Create run before uploading tests to ensure runId is set
589
+ await this.createRun();
590
+
591
+ if (!this.tests || !Array.isArray(this.tests) || this.tests.length === 0) {
592
+ debug('No tests to upload, finishing run');
593
+ const finishData = {
594
+ api_key: this.requestParams.apiKey,
595
+ status: 'finished',
596
+ duration: this.stats.duration,
597
+ detach: this.requestParams.detach,
598
+ };
599
+ return Promise.all(this.pipes.map(p => p.finishRun(finishData)));
600
+ }
601
+
602
+ const testChunks = this.#splitTestsIntoChunks(this.tests);
603
+
604
+ const totalChunks = testChunks.length;
605
+ const totalTests = this.tests.length;
606
+
607
+ debug(`Split ${totalTests} tests into ${totalChunks} chunks (max 1MB per chunk)`);
608
+
609
+ let uploadedTests = 0;
610
+ for (let i = 0; i < testChunks.length; i++) {
611
+ const chunk = testChunks[i];
612
+ const chunkNum = i + 1;
613
+
614
+ if (totalChunks > 1) {
615
+ debug(`Uploading chunk ${chunkNum}/${totalChunks} (${chunk.length} tests)`);
616
+ }
617
+
618
+ for (const test of chunk) {
619
+ await Promise.all(this.pipes.map(p => p.addTest(test)));
620
+ }
621
+
622
+ await Promise.all(this.pipes.map(p => p.sync()));
623
+
624
+ uploadedTests += chunk.length;
625
+ debug(`Uploaded ${uploadedTests}/${totalTests} tests`);
626
+ }
627
+
628
+ if (totalChunks > 1) {
629
+ console.log(APP_PREFIX, `✅ Successfully uploaded ${uploadedTests} tests in ${totalChunks} chunks`);
630
+ } else {
631
+ console.log(APP_PREFIX, `✅ Successfully uploaded ${uploadedTests} tests`);
632
+ }
633
+
634
+ const finishData = {
542
635
  api_key: this.requestParams.apiKey,
543
636
  status: 'finished',
544
637
  duration: this.stats.duration,
545
- tests: this.tests,
638
+ detach: this.requestParams.detach,
546
639
  };
547
640
 
548
- debug('Uploading data', dataString);
549
-
550
- this.pipes = this.pipes || (await this.pipesPromise);
551
- return Promise.all(this.pipes.map(p => p.finishRun(dataString)));
641
+ debug('Finishing run with status:', finishData.status);
642
+ return Promise.all(this.pipes.map(p => p.finishRun(finishData)));
552
643
  }
553
644
 
554
645
  async _finishRun() {
package/types/types.d.ts CHANGED
@@ -290,6 +290,9 @@ export interface Pipe {
290
290
  /** adds a test to the current run */
291
291
  addTest(test: TestData): any;
292
292
 
293
+ /** syncs / flushes buffered data (e.g., uploads batched tests) */
294
+ sync(): Promise<void>;
295
+
293
296
  /** ends the run */
294
297
  finishRun(runParams: RunData): Promise<void>;
295
298