@testomatio/reporter 2.18.0 → 2.19.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.
@@ -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;
@@ -175,7 +175,8 @@ class BitbucketPipe {
175
175
  });
176
176
  let body = summary;
177
177
  if (this.description) {
178
- body += `\n\n> ${(0, utils_js_1.truncate)(this.description, 1024).replace(/\r?\n/g, '\n> ')}`;
178
+ // Bitbucket strips HTML, so no <details> here
179
+ body += `\n\n**šŸ“ Description**\n\n${(0, utils_js_1.truncate)(this.description, 1024)}\n`;
179
180
  }
180
181
  if (failures.length) {
181
182
  body += `\n🟄 **Failures (${failures.length})**\n\n* ${failures.join('\n* ')}\n`;
@@ -183,6 +184,9 @@ class BitbucketPipe {
183
184
  body += `\n> Notice: Only the first 10 failures are shown.`;
184
185
  }
185
186
  }
187
+ for (const { title, list } of (0, pipe_utils_js_1.smallRunSections)(this.tests)) {
188
+ body += `\n\n**${title}**\n\n${list.join('\n')}`;
189
+ }
186
190
  if (this.tests.length > 0 && !isPendingRun) {
187
191
  body += `\n\n**🐢 Slowest Tests**\n\n`;
188
192
  body += this.tests
@@ -163,7 +163,8 @@ class GitHubPipe {
163
163
  });
164
164
  let body = summary;
165
165
  if (this.description) {
166
- body += `\n\n> ${(0, utils_js_1.truncate)(this.description, 1024).replace(/\r?\n/g, '\n> ')}`;
166
+ body += '\n\n<details>\n<summary><h3>šŸ“ Description</h3></summary>\n\n';
167
+ body += `${(0, utils_js_1.truncate)(this.description, 1024)}\n\n</details>`;
167
168
  }
168
169
  const coverageConfiguration = this.store?.coverageConfiguration;
169
170
  const isManualRun = this.store?.runKind === 'manual';
@@ -187,6 +188,9 @@ class GitHubPipe {
187
188
  }
188
189
  body += '\n\n</details>';
189
190
  }
191
+ for (const { title, list } of (0, pipe_utils_js_1.smallRunSections)(this.tests)) {
192
+ body += `\n<details>\n<summary><h3>${title}</h3></summary>\n\n${list.join('\n')}\n</details>`;
193
+ }
190
194
  if (this.tests.length > 0 && !isPendingRun) {
191
195
  body += '\n<details>\n<summary><h3>🐢 Slowest Tests</h3></summary>\n\n';
192
196
  body += this.tests
@@ -122,7 +122,8 @@ class GitLabPipe {
122
122
  });
123
123
  let body = summary;
124
124
  if (this.description) {
125
- body += `\n\n> ${(0, utils_js_1.truncate)(this.description, 1024).replace(/\r?\n/g, '\n> ')}`;
125
+ body += '\n\n<details>\n<summary><h3>šŸ“ Description</h3></summary>\n\n';
126
+ body += `${(0, utils_js_1.truncate)(this.description, 1024)}\n\n</details>`;
126
127
  }
127
128
  if (failures.length) {
128
129
  body += `\n<details>\n<summary><h3>🟄 Failures (${failures.length})</h4></summary>\n\n${failures.join('\n')}\n`;
@@ -131,6 +132,9 @@ class GitLabPipe {
131
132
  }
132
133
  body += '\n\n</details>';
133
134
  }
135
+ for (const { title, list } of (0, pipe_utils_js_1.smallRunSections)(this.tests)) {
136
+ body += `\n<details>\n<summary><h3>${title}</h3></summary>\n\n${list.join('\n')}\n</details>`;
137
+ }
134
138
  if (this.tests.length > 0 && !isPendingRun) {
135
139
  body += '\n<details>\n<summary><h3>🐢 Slowest Tests</h3></summary>\n\n';
136
140
  body += this.tests
@@ -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);
@@ -72,6 +72,22 @@ export function runSummary(tests: Array<{
72
72
  export function totalDuration(tests: Array<{
73
73
  run_time?: number;
74
74
  }>): string;
75
+ /**
76
+ * Sections listing the passed, skipped and not yet run tests of a small run; failures have their own
77
+ * section. Not yet run tests (no status, or `pending`) are listed without any status label.
78
+ *
79
+ * @param {Array<{status?: string, title?: string, suite_title?: string, run_time?: number}>} tests
80
+ * @returns {Array<{title: string, list: string[]}>} Non-empty sections; none if the run is not small.
81
+ */
82
+ export function smallRunSections(tests: Array<{
83
+ status?: string;
84
+ title?: string;
85
+ suite_title?: string;
86
+ run_time?: number;
87
+ }>): Array<{
88
+ title: string;
89
+ list: string[];
90
+ }>;
75
91
  /**
76
92
  * Describe the scope of a run that was prepared but not executed yet. Prefers the server's count;
77
93
  * without it, falls back to the scoped ids, which mix tests (`T…`) and suites (`S…`).
@@ -12,6 +12,7 @@ exports.fullName = fullName;
12
12
  exports.markdownTable = markdownTable;
13
13
  exports.runSummary = runSummary;
14
14
  exports.totalDuration = totalDuration;
15
+ exports.smallRunSections = smallRunSections;
15
16
  exports.plannedTestsLabel = plannedTestsLabel;
16
17
  exports.parsePipeOptions = parsePipeOptions;
17
18
  exports.formatFilterListIds = formatFilterListIds;
@@ -135,6 +136,13 @@ function statusEmoji(status) {
135
136
  return 'šŸ•';
136
137
  return '';
137
138
  }
139
+ /**
140
+ * @param {string} str
141
+ * @returns {string} The string with its first letter in upper case, e.g. `passed` → `Passed`.
142
+ */
143
+ function capitalize(str) {
144
+ return str.charAt(0).toUpperCase() + str.slice(1);
145
+ }
138
146
  /**
139
147
  * Generate a full name string based on the provided test object.
140
148
  * @param {object} t - The test object.
@@ -262,6 +270,33 @@ function totalDuration(tests) {
262
270
  const milliseconds = tests.reduce((total, t) => total + (t.run_time || 0), 0);
263
271
  return (0, humanize_duration_1.default)(Math.trunc(milliseconds), { maxDecimalPoints: 0 });
264
272
  }
273
+ /**
274
+ * Runs with fewer tests than this list every test in the report, not only the failed ones.
275
+ */
276
+ const SMALL_RUN_TESTS_LIMIT = 10;
277
+ /**
278
+ * Sections listing the passed, skipped and not yet run tests of a small run; failures have their own
279
+ * section. Not yet run tests (no status, or `pending`) are listed without any status label.
280
+ *
281
+ * @param {Array<{status?: string, title?: string, suite_title?: string, run_time?: number}>} tests
282
+ * @returns {Array<{title: string, list: string[]}>} Non-empty sections; none if the run is not small.
283
+ */
284
+ function smallRunSections(tests) {
285
+ if (tests.length >= SMALL_RUN_TESTS_LIMIT)
286
+ return [];
287
+ const sections = ['passed', 'skipped'].map(status => {
288
+ const list = tests
289
+ .filter(t => t.status === status)
290
+ .map(t => {
291
+ const runTime = t.run_time ? ` (${(0, humanize_duration_1.default)(t.run_time)})` : '';
292
+ return `* ${statusEmoji(status)} ${fullName(t)}${runTime}`;
293
+ });
294
+ return { title: `${statusEmoji(status)} ${capitalize(status)} (${list.length})`, list };
295
+ });
296
+ const notRun = tests.filter(t => !t.status || t.status === 'pending').map(t => `* ${fullName(t)}`);
297
+ sections.push({ title: `šŸ“‹ Tests (${notRun.length})`, list: notRun });
298
+ return sections.filter(section => section.list.length);
299
+ }
265
300
  /**
266
301
  * Render a two-column markdown table. Rows with an empty value are skipped, so optional rows
267
302
  * need no surrounding `if`.
@@ -384,6 +419,8 @@ module.exports.runSummary = runSummary;
384
419
 
385
420
  module.exports.totalDuration = totalDuration;
386
421
 
422
+ module.exports.smallRunSections = smallRunSections;
423
+
387
424
  module.exports.plannedTestsLabel = plannedTestsLabel;
388
425
 
389
426
  module.exports.parsePipeOptions = parsePipeOptions;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=22"
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
  };
@@ -7,6 +7,7 @@ import {
7
7
  markdownTable,
8
8
  runSummary,
9
9
  totalDuration,
10
+ smallRunSections,
10
11
  } from '../utils/pipe_utils.js';
11
12
  import { Gaxios } from 'gaxios';
12
13
  import pc from 'picocolors';
@@ -174,7 +175,8 @@ export class BitbucketPipe {
174
175
  let body = summary;
175
176
 
176
177
  if (this.description) {
177
- body += `\n\n> ${truncate(this.description, 1024).replace(/\r?\n/g, '\n> ')}`;
178
+ // Bitbucket strips HTML, so no <details> here
179
+ body += `\n\n**šŸ“ Description**\n\n${truncate(this.description, 1024)}\n`;
178
180
  }
179
181
 
180
182
  if (failures.length) {
@@ -184,6 +186,10 @@ export class BitbucketPipe {
184
186
  }
185
187
  }
186
188
 
189
+ for (const { title, list } of smallRunSections(this.tests)) {
190
+ body += `\n\n**${title}**\n\n${list.join('\n')}`;
191
+ }
192
+
187
193
  if (this.tests.length > 0 && !isPendingRun) {
188
194
  body += `\n\n**🐢 Slowest Tests**\n\n`;
189
195
  body += this.tests
@@ -12,6 +12,7 @@ import {
12
12
  markdownTable,
13
13
  runSummary,
14
14
  totalDuration,
15
+ smallRunSections,
15
16
  } from '../utils/pipe_utils.js';
16
17
  import { log } from '../utils/log.js';
17
18
 
@@ -147,7 +148,8 @@ class GitHubPipe {
147
148
 
148
149
  let body = summary;
149
150
  if (this.description) {
150
- body += `\n\n> ${truncate(this.description, 1024).replace(/\r?\n/g, '\n> ')}`;
151
+ body += '\n\n<details>\n<summary><h3>šŸ“ Description</h3></summary>\n\n';
152
+ body += `${truncate(this.description, 1024)}\n\n</details>`;
151
153
  }
152
154
  const coverageConfiguration = this.store?.coverageConfiguration;
153
155
  const isManualRun = this.store?.runKind === 'manual';
@@ -172,6 +174,10 @@ class GitHubPipe {
172
174
  body += '\n\n</details>';
173
175
  }
174
176
 
177
+ for (const { title, list } of smallRunSections(this.tests)) {
178
+ body += `\n<details>\n<summary><h3>${title}</h3></summary>\n\n${list.join('\n')}\n</details>`;
179
+ }
180
+
175
181
  if (this.tests.length > 0 && !isPendingRun) {
176
182
  body += '\n<details>\n<summary><h3>🐢 Slowest Tests</h3></summary>\n\n';
177
183
  body += this.tests
@@ -13,6 +13,7 @@ import {
13
13
  markdownTable,
14
14
  runSummary,
15
15
  totalDuration,
16
+ smallRunSections,
16
17
  } from '../utils/pipe_utils.js';
17
18
  import { log } from '../utils/log.js';
18
19
 
@@ -148,7 +149,8 @@ class GitLabPipe {
148
149
  let body = summary;
149
150
 
150
151
  if (this.description) {
151
- body += `\n\n> ${truncate(this.description, 1024).replace(/\r?\n/g, '\n> ')}`;
152
+ body += '\n\n<details>\n<summary><h3>šŸ“ Description</h3></summary>\n\n';
153
+ body += `${truncate(this.description, 1024)}\n\n</details>`;
152
154
  }
153
155
 
154
156
  if (failures.length) {
@@ -159,6 +161,10 @@ class GitLabPipe {
159
161
  body += '\n\n</details>';
160
162
  }
161
163
 
164
+ for (const { title, list } of smallRunSections(this.tests)) {
165
+ body += `\n<details>\n<summary><h3>${title}</h3></summary>\n\n${list.join('\n')}\n</details>`;
166
+ }
167
+
162
168
  if (this.tests.length > 0 && !isPendingRun) {
163
169
  body += '\n<details>\n<summary><h3>🐢 Slowest Tests</h3></summary>\n\n';
164
170
  body += this.tests
@@ -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);
@@ -122,6 +122,14 @@ function statusEmoji(status) {
122
122
  return '';
123
123
  }
124
124
 
125
+ /**
126
+ * @param {string} str
127
+ * @returns {string} The string with its first letter in upper case, e.g. `passed` → `Passed`.
128
+ */
129
+ function capitalize(str) {
130
+ return str.charAt(0).toUpperCase() + str.slice(1);
131
+ }
132
+
125
133
  /**
126
134
  * Generate a full name string based on the provided test object.
127
135
  * @param {object} t - The test object.
@@ -260,6 +268,37 @@ function totalDuration(tests) {
260
268
  return humanizeDuration(Math.trunc(milliseconds), { maxDecimalPoints: 0 });
261
269
  }
262
270
 
271
+ /**
272
+ * Runs with fewer tests than this list every test in the report, not only the failed ones.
273
+ */
274
+ const SMALL_RUN_TESTS_LIMIT = 10;
275
+
276
+ /**
277
+ * Sections listing the passed, skipped and not yet run tests of a small run; failures have their own
278
+ * section. Not yet run tests (no status, or `pending`) are listed without any status label.
279
+ *
280
+ * @param {Array<{status?: string, title?: string, suite_title?: string, run_time?: number}>} tests
281
+ * @returns {Array<{title: string, list: string[]}>} Non-empty sections; none if the run is not small.
282
+ */
283
+ function smallRunSections(tests) {
284
+ if (tests.length >= SMALL_RUN_TESTS_LIMIT) return [];
285
+
286
+ const sections = ['passed', 'skipped'].map(status => {
287
+ const list = tests
288
+ .filter(t => t.status === status)
289
+ .map(t => {
290
+ const runTime = t.run_time ? ` (${humanizeDuration(t.run_time)})` : '';
291
+ return `* ${statusEmoji(status)} ${fullName(t)}${runTime}`;
292
+ });
293
+ return { title: `${statusEmoji(status)} ${capitalize(status)} (${list.length})`, list };
294
+ });
295
+
296
+ const notRun = tests.filter(t => !t.status || t.status === 'pending').map(t => `* ${fullName(t)}`);
297
+ sections.push({ title: `šŸ“‹ Tests (${notRun.length})`, list: notRun });
298
+
299
+ return sections.filter(section => section.list.length);
300
+ }
301
+
263
302
  /**
264
303
  * Render a two-column markdown table. Rows with an empty value are skipped, so optional rows
265
304
  * need no surrounding `if`.
@@ -368,6 +407,7 @@ export {
368
407
  markdownTable,
369
408
  runSummary,
370
409
  totalDuration,
410
+ smallRunSections,
371
411
  plannedTestsLabel,
372
412
  parsePipeOptions,
373
413
  formatFilterListIds,