@testomatio/reporter 2.17.0 → 2.19.0-beta

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.
@@ -38,3 +38,4 @@ export namespace REPORTER_REQUEST_RETRIES {
38
38
  }
39
39
  export const SCREENSHOTS_ON_STEPS: boolean;
40
40
  export const DEBUG_FILE: "testomatio.debug";
41
+ export const S3_CREDENTIALS_REFRESH_INTERVAL_MS: number;
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.BATCH_MODE = exports.STATUS = exports.CSV_HEADERS = exports.TESTOMAT_TMP_STORAGE_DIR = exports.APP_PREFIX = void 0;
6
+ exports.S3_CREDENTIALS_REFRESH_INTERVAL_MS = 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"));
@@ -69,6 +69,10 @@ const REPORTER_REQUEST_RETRIES = {
69
69
  exports.REPORTER_REQUEST_RETRIES = REPORTER_REQUEST_RETRIES;
70
70
  const DEBUG_FILE = 'testomatio.debug';
71
71
  exports.DEBUG_FILE = DEBUG_FILE;
72
+ // temporary S3 tokens live ~1 hour; refresh credentials well before that so artifact
73
+ // uploads keep working on long runs
74
+ const S3_CREDENTIALS_REFRESH_INTERVAL_MS = 40 * 60 * 1000;
75
+ exports.S3_CREDENTIALS_REFRESH_INTERVAL_MS = S3_CREDENTIALS_REFRESH_INTERVAL_MS;
72
76
  function getCreateRunRequestTimeout() {
73
77
  return Math.max(REQUEST_TIMEOUT, 80 * 1000);
74
78
  }
@@ -98,3 +102,5 @@ module.exports.testomatLogoURL = testomatLogoURL;
98
102
  module.exports.REPORTER_REQUEST_RETRIES = REPORTER_REQUEST_RETRIES;
99
103
 
100
104
  module.exports.DEBUG_FILE = DEBUG_FILE;
105
+
106
+ module.exports.S3_CREDENTIALS_REFRESH_INTERVAL_MS = S3_CREDENTIALS_REFRESH_INTERVAL_MS;
@@ -30,6 +30,8 @@ declare class TestomatioPipe implements Pipe {
30
30
  retriesTimestamps: any[];
31
31
  reportingCanceledDueToReqFailures: boolean;
32
32
  notReportedTestsCount: number;
33
+ /** @type {NodeJS.Timeout | null} */
34
+ s3CredentialsRefreshTimer: NodeJS.Timeout | null;
33
35
  isEnabled: boolean;
34
36
  url: any;
35
37
  apiKey: any;
@@ -63,6 +63,8 @@ class TestomatioPipe {
63
63
  this.retriesTimestamps = [];
64
64
  this.reportingCanceledDueToReqFailures = false;
65
65
  this.notReportedTestsCount = 0;
66
+ /** @type {NodeJS.Timeout | null} */
67
+ this.s3CredentialsRefreshTimer = null;
66
68
  this.isEnabled = false;
67
69
  this.url = params.testomatioUrl || process.env.TESTOMATIO_URL || 'https://app.testomat.io';
68
70
  this.apiKey = params.apiKey || config_js_1.config.TESTOMATIO;
@@ -311,8 +313,10 @@ class TestomatioPipe {
311
313
  timeout: (0, constants_js_1.getCreateRunRequestTimeout)(),
312
314
  responseType: 'json',
313
315
  });
314
- if (resp.data.artifacts)
316
+ if (resp.data.artifacts) {
315
317
  (0, pipe_utils_js_1.setS3Credentials)(resp.data.artifacts);
318
+ this.#startS3CredentialsRefresh(runParams);
319
+ }
316
320
  if (resp.data.url) {
317
321
  const respUrl = new URL(resp.data.url);
318
322
  this.runUrl = `${this.url}${respUrl.pathname}`;
@@ -337,8 +341,10 @@ class TestomatioPipe {
337
341
  this.runId = resp.data.uid;
338
342
  this.runUrl = `${this.url}/${resp.data.url.split('/').splice(3).join('/')}`;
339
343
  this.runPublicUrl = resp.data.public_url;
340
- if (resp.data.artifacts)
344
+ if (resp.data.artifacts) {
341
345
  (0, pipe_utils_js_1.setS3Credentials)(resp.data.artifacts);
346
+ this.#startS3CredentialsRefresh(runParams);
347
+ }
342
348
  this.store.runUrl = this.runUrl;
343
349
  this.store.runPublicUrl = this.runPublicUrl;
344
350
  this.store.runId = this.runId;
@@ -363,6 +369,49 @@ class TestomatioPipe {
363
369
  }
364
370
  debug('"createRun" function finished');
365
371
  }
372
+ /**
373
+ * Starts periodic refreshing of S3 credentials for long runs.
374
+ * Temporary S3 tokens (STS) expire after ~1 hour, so without refreshing,
375
+ * artifact uploads fail for every test reported later in the run.
376
+ * The same run params are re-sent on refresh (same as updating an existing run),
377
+ * and fresh credentials are taken from the response's `artifacts` field.
378
+ * @param {Record<string, any>} runParams
379
+ */
380
+ #startS3CredentialsRefresh(runParams) {
381
+ if (this.s3CredentialsRefreshTimer)
382
+ return;
383
+ this.s3CredentialsRefreshTimer = setInterval(() => {
384
+ this.#refreshS3Credentials(runParams);
385
+ }, constants_js_1.S3_CREDENTIALS_REFRESH_INTERVAL_MS);
386
+ this.s3CredentialsRefreshTimer.unref?.();
387
+ debug(`S3 credentials refresh started (every ${constants_js_1.S3_CREDENTIALS_REFRESH_INTERVAL_MS / 60000} min)`);
388
+ }
389
+ #refreshS3Credentials = async (runParams) => {
390
+ if (!this.isEnabled || !this.runId)
391
+ return;
392
+ const { ci, access_event, ...refreshParams } = runParams;
393
+ try {
394
+ const resp = await this.client.request({
395
+ method: 'PUT',
396
+ url: `/api/reporter/${this.runId}`,
397
+ data: { ...refreshParams, api_key: this.apiKey },
398
+ responseType: 'json',
399
+ });
400
+ if (resp.data?.artifacts) {
401
+ (0, pipe_utils_js_1.setS3Credentials)(resp.data.artifacts);
402
+ debug('S3 credentials refreshed');
403
+ }
404
+ }
405
+ catch (err) {
406
+ debug('Failed to refresh S3 credentials:', err.message || err);
407
+ }
408
+ };
409
+ #stopS3CredentialsRefresh() {
410
+ if (this.s3CredentialsRefreshTimer) {
411
+ clearInterval(this.s3CredentialsRefreshTimer);
412
+ this.s3CredentialsRefreshTimer = null;
413
+ }
414
+ }
366
415
  /**
367
416
  * Decides whether to skip test reporting in case of too many request failures
368
417
  * @returns {boolean}
@@ -506,6 +555,7 @@ class TestomatioPipe {
506
555
  async finishRun(params) {
507
556
  if (!this.isEnabled)
508
557
  return;
558
+ this.#stopS3CredentialsRefresh();
509
559
  await this.#batchUpload();
510
560
  if (this.batch.intervalFunction) {
511
561
  clearInterval(this.batch.intervalFunction);
@@ -577,6 +627,7 @@ class TestomatioPipe {
577
627
  #disablePipe() {
578
628
  this.isEnabled = false;
579
629
  this.apiKey = null;
630
+ this.#stopS3CredentialsRefresh();
580
631
  // clear interval function, otherwise the proccess will continue indefinitely
581
632
  if (this.batch.intervalFunction) {
582
633
  clearInterval(this.batch.intervalFunction);
@@ -786,6 +786,10 @@
786
786
  color: #8b5cf6;
787
787
  }
788
788
 
789
+ .test-group-stat.duration {
790
+ color: var(--gray-600);
791
+ }
792
+
789
793
  .test-group-expand {
790
794
  color: var(--gray-500);
791
795
  transition: var(--transition);
@@ -2826,6 +2830,24 @@
2826
2830
  }
2827
2831
  }
2828
2832
 
2833
+ function formatCompactDuration(milliseconds) {
2834
+ if (!milliseconds || milliseconds <= 0) return '0ms';
2835
+
2836
+ const totalSeconds = Math.floor(milliseconds / 1000);
2837
+ if (totalSeconds < 1) return `${Math.round(milliseconds)}ms`;
2838
+
2839
+ const hours = Math.floor(totalSeconds / 3600);
2840
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
2841
+ const seconds = totalSeconds % 60;
2842
+
2843
+ const parts = [];
2844
+ if (hours > 0) parts.push(`${hours}h`);
2845
+ if (hours > 0 || minutes > 0) parts.push(`${minutes}m`);
2846
+ parts.push(`${seconds}s`);
2847
+
2848
+ return parts.join(' ');
2849
+ }
2850
+
2829
2851
  function hasMeaningfulMessage(message) {
2830
2852
  return typeof message === 'string' && message.trim().length > 0 && message !== 'No message available';
2831
2853
  }
@@ -3435,6 +3457,12 @@
3435
3457
 
3436
3458
  const groupIcon = group.type === 'suite' ? 'layer-group' : 'file-code';
3437
3459
 
3460
+ const totalDuration = group.tests.reduce((sum, t) => {
3461
+ const status = t.status.toLowerCase();
3462
+ if (status === 'skipped' || status === 'todo') return sum;
3463
+ return sum + (Number(t.run_time) || 0);
3464
+ }, 0);
3465
+
3438
3466
  const header = document.createElement('div');
3439
3467
  header.className = ['test-group-header', groupStateClass].filter(Boolean).join(' ');
3440
3468
  header.innerHTML = `
@@ -3444,6 +3472,7 @@
3444
3472
  <div class='test-group-count'>${group.tests.length} tests</div>
3445
3473
  </div>
3446
3474
  <div class='test-group-stats'>
3475
+ ${totalDuration > 0 ? `<div class='test-group-stat duration' title='Total duration of tests in this ${group.type}'><i class='fas fa-stopwatch'></i> ${formatCompactDuration(totalDuration)}</div>` : ''}
3447
3476
  ${passedCount > 0 ? `<div class='test-group-stat passed'><i class='fas fa-check-circle'></i> ${passedCount}</div>` : ''}
3448
3477
  ${failedCount > 0 ? `<div class='test-group-stat failed'><i class='fas fa-times-circle'></i> ${failedCount}</div>` : ''}
3449
3478
  ${skippedCount > 0 ? `<div class='test-group-stat skipped'><i class='fas fa-forward'></i> ${skippedCount}</div>` : ''}
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.17.0",
3
+ "version": "2.19.0-beta",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
- "node": ">=18"
6
+ "node": ">=22"
7
7
  },
8
8
  "main": "lib/reporter.js",
9
9
  "module": "src/reporter.js",
@@ -15,7 +15,7 @@
15
15
  "dependencies": {
16
16
  "@aws-sdk/client-s3": "^3.279.0",
17
17
  "@aws-sdk/lib-storage": "^3.279.0",
18
- "@cucumber/cucumber": "^10.9.0",
18
+ "@cucumber/cucumber": "^13.2.1",
19
19
  "@octokit/rest": "^21.1.1",
20
20
  "callsite-record": "^4.1.4",
21
21
  "commander": "^12",
@@ -42,7 +42,7 @@
42
42
  "pretty-ms": "^7.0.1",
43
43
  "promise-retry": "^2.0.1",
44
44
  "strip-ansi": "7.1.0",
45
- "uuid": "^9.0.0"
45
+ "uuid": "^11.1.1"
46
46
  },
47
47
  "overrides": {
48
48
  "fast-xml-parser": "^5.3.4"
package/src/constants.js CHANGED
@@ -60,6 +60,10 @@ const REPORTER_REQUEST_RETRIES = {
60
60
 
61
61
  const DEBUG_FILE = 'testomatio.debug';
62
62
 
63
+ // temporary S3 tokens live ~1 hour; refresh credentials well before that so artifact
64
+ // uploads keep working on long runs
65
+ const S3_CREDENTIALS_REFRESH_INTERVAL_MS = 40 * 60 * 1000;
66
+
63
67
  function getCreateRunRequestTimeout() {
64
68
  return Math.max(REQUEST_TIMEOUT, 80 * 1000);
65
69
  }
@@ -78,4 +82,5 @@ export {
78
82
  REPORTER_REQUEST_RETRIES,
79
83
  SCREENSHOTS_ON_STEPS,
80
84
  DEBUG_FILE,
85
+ S3_CREDENTIALS_REFRESH_INTERVAL_MS,
81
86
  };
@@ -9,6 +9,7 @@ import {
9
9
  REQUEST_TIMEOUT,
10
10
  getCreateRunRequestTimeout,
11
11
  REPORTER_REQUEST_RETRIES,
12
+ S3_CREDENTIALS_REFRESH_INTERVAL_MS,
12
13
  } from '../constants.js';
13
14
  import {
14
15
  isValidUrl,
@@ -72,6 +73,8 @@ class TestomatioPipe {
72
73
  this.retriesTimestamps = [];
73
74
  this.reportingCanceledDueToReqFailures = false;
74
75
  this.notReportedTestsCount = 0;
76
+ /** @type {NodeJS.Timeout | null} */
77
+ this.s3CredentialsRefreshTimer = null;
75
78
 
76
79
  this.isEnabled = false;
77
80
  this.url = params.testomatioUrl || process.env.TESTOMATIO_URL || 'https://app.testomat.io';
@@ -346,7 +349,10 @@ class TestomatioPipe {
346
349
  timeout: getCreateRunRequestTimeout(),
347
350
  responseType: 'json',
348
351
  });
349
- if (resp.data.artifacts) setS3Credentials(resp.data.artifacts);
352
+ if (resp.data.artifacts) {
353
+ setS3Credentials(resp.data.artifacts);
354
+ this.#startS3CredentialsRefresh(runParams);
355
+ }
350
356
  if (resp.data.url) {
351
357
  const respUrl = new URL(resp.data.url);
352
358
  this.runUrl = `${this.url}${respUrl.pathname}`;
@@ -374,7 +380,10 @@ class TestomatioPipe {
374
380
  this.runUrl = `${this.url}/${resp.data.url.split('/').splice(3).join('/')}`;
375
381
  this.runPublicUrl = resp.data.public_url;
376
382
 
377
- if (resp.data.artifacts) setS3Credentials(resp.data.artifacts);
383
+ if (resp.data.artifacts) {
384
+ setS3Credentials(resp.data.artifacts);
385
+ this.#startS3CredentialsRefresh(runParams);
386
+ }
378
387
 
379
388
  this.store.runUrl = this.runUrl;
380
389
  this.store.runPublicUrl = this.runPublicUrl;
@@ -401,6 +410,49 @@ class TestomatioPipe {
401
410
  debug('"createRun" function finished');
402
411
  }
403
412
 
413
+ /**
414
+ * Starts periodic refreshing of S3 credentials for long runs.
415
+ * Temporary S3 tokens (STS) expire after ~1 hour, so without refreshing,
416
+ * artifact uploads fail for every test reported later in the run.
417
+ * The same run params are re-sent on refresh (same as updating an existing run),
418
+ * and fresh credentials are taken from the response's `artifacts` field.
419
+ * @param {Record<string, any>} runParams
420
+ */
421
+ #startS3CredentialsRefresh(runParams) {
422
+ if (this.s3CredentialsRefreshTimer) return;
423
+ this.s3CredentialsRefreshTimer = setInterval(() => {
424
+ this.#refreshS3Credentials(runParams);
425
+ }, S3_CREDENTIALS_REFRESH_INTERVAL_MS);
426
+ this.s3CredentialsRefreshTimer.unref?.();
427
+ debug(`S3 credentials refresh started (every ${S3_CREDENTIALS_REFRESH_INTERVAL_MS / 60000} min)`);
428
+ }
429
+
430
+ #refreshS3Credentials = async runParams => {
431
+ if (!this.isEnabled || !this.runId) return;
432
+ const { ci, access_event, ...refreshParams } = runParams;
433
+ try {
434
+ const resp = await this.client.request({
435
+ method: 'PUT',
436
+ url: `/api/reporter/${this.runId}`,
437
+ data: { ...refreshParams, api_key: this.apiKey },
438
+ responseType: 'json',
439
+ });
440
+ if (resp.data?.artifacts) {
441
+ setS3Credentials(resp.data.artifacts);
442
+ debug('S3 credentials refreshed');
443
+ }
444
+ } catch (err) {
445
+ debug('Failed to refresh S3 credentials:', err.message || err);
446
+ }
447
+ };
448
+
449
+ #stopS3CredentialsRefresh() {
450
+ if (this.s3CredentialsRefreshTimer) {
451
+ clearInterval(this.s3CredentialsRefreshTimer);
452
+ this.s3CredentialsRefreshTimer = null;
453
+ }
454
+ }
455
+
404
456
  /**
405
457
  * Decides whether to skip test reporting in case of too many request failures
406
458
  * @returns {boolean}
@@ -545,6 +597,7 @@ class TestomatioPipe {
545
597
  async finishRun(params) {
546
598
  if (!this.isEnabled) return;
547
599
 
600
+ this.#stopS3CredentialsRefresh();
548
601
  await this.#batchUpload();
549
602
  if (this.batch.intervalFunction) {
550
603
  clearInterval(this.batch.intervalFunction);
@@ -637,6 +690,7 @@ class TestomatioPipe {
637
690
  this.isEnabled = false;
638
691
  this.apiKey = null;
639
692
 
693
+ this.#stopS3CredentialsRefresh();
640
694
  // clear interval function, otherwise the proccess will continue indefinitely
641
695
  if (this.batch.intervalFunction) {
642
696
  clearInterval(this.batch.intervalFunction);
@@ -786,6 +786,10 @@
786
786
  color: #8b5cf6;
787
787
  }
788
788
 
789
+ .test-group-stat.duration {
790
+ color: var(--gray-600);
791
+ }
792
+
789
793
  .test-group-expand {
790
794
  color: var(--gray-500);
791
795
  transition: var(--transition);
@@ -2826,6 +2830,24 @@
2826
2830
  }
2827
2831
  }
2828
2832
 
2833
+ function formatCompactDuration(milliseconds) {
2834
+ if (!milliseconds || milliseconds <= 0) return '0ms';
2835
+
2836
+ const totalSeconds = Math.floor(milliseconds / 1000);
2837
+ if (totalSeconds < 1) return `${Math.round(milliseconds)}ms`;
2838
+
2839
+ const hours = Math.floor(totalSeconds / 3600);
2840
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
2841
+ const seconds = totalSeconds % 60;
2842
+
2843
+ const parts = [];
2844
+ if (hours > 0) parts.push(`${hours}h`);
2845
+ if (hours > 0 || minutes > 0) parts.push(`${minutes}m`);
2846
+ parts.push(`${seconds}s`);
2847
+
2848
+ return parts.join(' ');
2849
+ }
2850
+
2829
2851
  function hasMeaningfulMessage(message) {
2830
2852
  return typeof message === 'string' && message.trim().length > 0 && message !== 'No message available';
2831
2853
  }
@@ -3435,6 +3457,12 @@
3435
3457
 
3436
3458
  const groupIcon = group.type === 'suite' ? 'layer-group' : 'file-code';
3437
3459
 
3460
+ const totalDuration = group.tests.reduce((sum, t) => {
3461
+ const status = t.status.toLowerCase();
3462
+ if (status === 'skipped' || status === 'todo') return sum;
3463
+ return sum + (Number(t.run_time) || 0);
3464
+ }, 0);
3465
+
3438
3466
  const header = document.createElement('div');
3439
3467
  header.className = ['test-group-header', groupStateClass].filter(Boolean).join(' ');
3440
3468
  header.innerHTML = `
@@ -3444,6 +3472,7 @@
3444
3472
  <div class='test-group-count'>${group.tests.length} tests</div>
3445
3473
  </div>
3446
3474
  <div class='test-group-stats'>
3475
+ ${totalDuration > 0 ? `<div class='test-group-stat duration' title='Total duration of tests in this ${group.type}'><i class='fas fa-stopwatch'></i> ${formatCompactDuration(totalDuration)}</div>` : ''}
3447
3476
  ${passedCount > 0 ? `<div class='test-group-stat passed'><i class='fas fa-check-circle'></i> ${passedCount}</div>` : ''}
3448
3477
  ${failedCount > 0 ? `<div class='test-group-stat failed'><i class='fas fa-times-circle'></i> ${failedCount}</div>` : ''}
3449
3478
  ${skippedCount > 0 ? `<div class='test-group-stat skipped'><i class='fas fa-forward'></i> ${skippedCount}</div>` : ''}