@testomatio/reporter 2.6.4-beta-acl → 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
@@ -404,6 +404,15 @@ class TestomatioPipe {
404
404
  // return promise to be able to wait for it
405
405
  return uploading;
406
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
+ }
407
416
  /**
408
417
  * @param {import('../../types/types.js').RunData} params
409
418
  * @returns
package/lib/uploader.js CHANGED
@@ -40,7 +40,6 @@ class S3Uploader {
40
40
  'TESTOMATIO_DISABLE_ARTIFACTS',
41
41
  'TESTOMATIO_PRIVATE_ARTIFACTS',
42
42
  'TESTOMATIO_ARTIFACT_MAX_SIZE_MB',
43
- 'TESTOMATIO_S3_NO_ACL',
44
43
  ];
45
44
  }
46
45
  resetConfig() {
@@ -92,7 +91,7 @@ class S3Uploader {
92
91
  * @returns
93
92
  */
94
93
  async #uploadToS3(Body, Key, file) {
95
- const { S3_BUCKET, TESTOMATIO_PRIVATE_ARTIFACTS, TESTOMATIO_S3_NO_ACL } = this.getConfig();
94
+ const { S3_BUCKET, TESTOMATIO_PRIVATE_ARTIFACTS } = this.getConfig();
96
95
  const ACL = TESTOMATIO_PRIVATE_ARTIFACTS ? 'private' : 'public-read';
97
96
  if (!S3_BUCKET || !Body) {
98
97
  console.log(constants_js_1.APP_PREFIX, picocolors_1.default.bold(picocolors_1.default.red(`Failed uploading '${Key}'. Please check S3 credentials`)), this.getMaskedConfig());
@@ -106,10 +105,8 @@ class S3Uploader {
106
105
  Key,
107
106
  Body,
108
107
  };
109
- // disable ACL for IAM roles or GCS (GCS doesn't support x-amz-acl header)
110
- // to precent potential issues, forece setting TESTOMATIO_S3_NO_ACL env var too in such case
111
- const isGCS = s3Config.endpoint && s3Config.endpoint.includes('storage.googleapis.com');
112
- if (!s3Config.credentials.sessionToken && !TESTOMATIO_S3_NO_ACL && !isGCS) {
108
+ // disable ACL for I AM roles
109
+ if (!s3Config.credentials.sessionToken) {
113
110
  params.ACL = ACL;
114
111
  }
115
112
  try {
@@ -122,7 +119,7 @@ class S3Uploader {
122
119
  catch (e) {
123
120
  this.failedUploads.push({ path: file.path, size: file.size });
124
121
  debug('S3 uploading error:', e);
125
- console.log(constants_js_1.APP_PREFIX, 'Upload failed:', e.message, `\nFile:\n ${file.path}, size: ${(0, filesize_1.filesize)(file.size || 0)}`, '\nConfig:\n', this.getMaskedConfig());
122
+ console.log(constants_js_1.APP_PREFIX, 'Upload failed:', e.message, '\nConfig:\n', this.getMaskedConfig());
126
123
  }
127
124
  }
128
125
  /**
@@ -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-acl",
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",
@@ -110,10 +110,10 @@
110
110
  "vitest": "^1.6.0"
111
111
  },
112
112
  "bin": {
113
- "testomatio/reporter": "./lib/bin/cli.js",
114
- "report-xml": "./lib/bin/reportXml.js",
115
- "start-test-run": "./lib/bin/startTest.js",
116
- "upload-artifacts": "./lib/bin/uploadArtifacts.js"
113
+ "testomatio/reporter": "./src/bin/cli.js",
114
+ "report-xml": "./src/bin/reportXml.js",
115
+ "start-test-run": "./src/bin/startTest.js",
116
+ "upload-artifacts": "./src/bin/uploadArtifacts.js"
117
117
  },
118
118
  "exports": {
119
119
  ".": {
@@ -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
  }
@@ -437,6 +437,15 @@ class TestomatioPipe {
437
437
  return uploading;
438
438
  }
439
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
+
440
449
  /**
441
450
  * @param {import('../../types/types.js').RunData} params
442
451
  * @returns
package/src/uploader.js CHANGED
@@ -38,7 +38,6 @@ export class S3Uploader {
38
38
  'TESTOMATIO_DISABLE_ARTIFACTS',
39
39
  'TESTOMATIO_PRIVATE_ARTIFACTS',
40
40
  'TESTOMATIO_ARTIFACT_MAX_SIZE_MB',
41
- 'TESTOMATIO_S3_NO_ACL',
42
41
  ];
43
42
  }
44
43
 
@@ -98,7 +97,7 @@ export class S3Uploader {
98
97
  * @returns
99
98
  */
100
99
  async #uploadToS3(Body, Key, file) {
101
- const { S3_BUCKET, TESTOMATIO_PRIVATE_ARTIFACTS, TESTOMATIO_S3_NO_ACL } = this.getConfig();
100
+ const { S3_BUCKET, TESTOMATIO_PRIVATE_ARTIFACTS } = this.getConfig();
102
101
  const ACL = TESTOMATIO_PRIVATE_ARTIFACTS ? 'private' : 'public-read';
103
102
 
104
103
  if (!S3_BUCKET || !Body) {
@@ -119,15 +118,14 @@ export class S3Uploader {
119
118
  Key,
120
119
  Body,
121
120
  };
122
- // disable ACL for IAM roles or GCS (GCS doesn't support x-amz-acl header)
123
- // to precent potential issues, forece setting TESTOMATIO_S3_NO_ACL env var too in such case
124
- const isGCS = s3Config.endpoint && s3Config.endpoint.includes('storage.googleapis.com');
125
- if (!s3Config.credentials.sessionToken && !TESTOMATIO_S3_NO_ACL && !isGCS) {
121
+ // disable ACL for I AM roles
122
+ if (!s3Config.credentials.sessionToken) {
126
123
  params.ACL = ACL;
127
124
  }
128
125
 
129
126
  try {
130
127
  const upload = new Upload({ client: s3, params });
128
+
131
129
  const link = await this.getS3LocationLink(upload);
132
130
  this.successfulUploads.push({ path: file.path, size: file.size, link });
133
131
  debug(`📤 Uploaded artifact. File: ${file.path}, size: ${prettyBytes(file.size || 0)}, link: ${link}`);
@@ -135,14 +133,7 @@ export class S3Uploader {
135
133
  } catch (e) {
136
134
  this.failedUploads.push({ path: file.path, size: file.size });
137
135
  debug('S3 uploading error:', e);
138
- console.log(
139
- APP_PREFIX,
140
- 'Upload failed:',
141
- e.message,
142
- `\nFile:\n ${file.path}, size: ${prettyBytes(file.size || 0)}`,
143
- '\nConfig:\n',
144
- this.getMaskedConfig(),
145
- );
136
+ console.log(APP_PREFIX, 'Upload failed:', e.message, '\nConfig:\n', this.getMaskedConfig());
146
137
  }
147
138
  }
148
139
 
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