@testomatio/reporter 2.11.0 → 2.13.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.
@@ -234,7 +234,8 @@ function appendStep(step, shift = 0) {
234
234
  newCategory = 'hook';
235
235
  break;
236
236
  case 'attach':
237
- return null; // Skip steps with category 'attach'
237
+ case 'test.attach':
238
+ return null; // Attachments are reported as artifacts, not standalone steps
238
239
  default:
239
240
  newCategory = 'framework';
240
241
  }
@@ -261,9 +262,11 @@ function appendStep(step, shift = 0) {
261
262
  if (step.log) {
262
263
  resultStep.log = (0, utils_js_1.truncate)(String(step.log), 250);
263
264
  }
264
- // Add artifacts from attachments
265
- if (step.attachments && step.attachments.length > 0 && constants_js_1.SCREENSHOTS_ON_STEPS) {
266
- const screenshotAttachment = step.attachments.find(att => att.contentType === 'image/png' && att.name === 'screenshot');
265
+ // Playwright also associates automatic failure screenshots with the active
266
+ // hook. Only attachments created for an explicit test.step belong to a step;
267
+ // hook and fixture attachments remain test-level result artifacts.
268
+ if (step.category === 'test.step' && step.attachments?.length && constants_js_1.SCREENSHOTS_ON_STEPS) {
269
+ const screenshotAttachment = step.attachments.find(isScreenshotArtifact);
267
270
  if (screenshotAttachment && screenshotAttachment.path) {
268
271
  const artifacts = { screenshot: screenshotAttachment.path };
269
272
  (0, step_formatter_js_1.addArtifactsToStep)(resultStep, artifacts);
package/lib/bin/cli.js CHANGED
@@ -62,7 +62,8 @@ program
62
62
  log_js_1.log.info('Starting a new Run on Testomat.io...');
63
63
  const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config_js_1.config.TESTOMATIO;
64
64
  const client = new client_js_1.default({ apiKey });
65
- const createRunParams = {};
65
+ // nothing is executed yet; the server flips it to running on the first reported test
66
+ const createRunParams = { status: 'scheduled' };
66
67
  if (opts.kind)
67
68
  createRunParams.kind = opts.kind;
68
69
  if (opts.filter) {
@@ -84,6 +85,10 @@ program
84
85
  log_js_1.log.error(picocolors_1.default.red('Failed to create run on Testomat.io.'));
85
86
  process.exit(1);
86
87
  }
88
+ // tests are executed later, so report the run as pending with the tests it was scoped to:
89
+ // pipes add their report now and replace it when the run is finished
90
+ const plannedTests = (client.pipeStore.preparedTestIds || []).map(id => ({ test_id: id, title: id }));
91
+ await client.updateRunStatus('pending', { tests: plannedTests });
87
92
  // stdout carries ONLY the run id so it can be captured: RUN_ID=$(reporter start)
88
93
  console.log(runId);
89
94
  process.exit(0);
package/lib/client.d.ts CHANGED
@@ -64,11 +64,11 @@ export class Client {
64
64
  /**
65
65
  *
66
66
  * Updates the status of the current test run and finishes the run.
67
- * @param {'passed' | 'failed' | 'skipped' | 'finished'} status - The status of the current test run.
67
+ * @param {'passed' | 'failed' | 'skipped' | 'finished' | 'pending'} status - The status of the current test run.
68
68
  * @param {Partial<import('../types/types.js').RunData>} [params] - Additional run params (e.g. duration).
69
69
  * Must be one of "passed", "failed", or "finished"
70
70
  * @returns {Promise<any>} - A Promise that resolves when finishes the run.
71
71
  */
72
- updateRunStatus(status: "passed" | "failed" | "skipped" | "finished", params?: Partial<import("../types/types.js").RunData>): Promise<any>;
72
+ updateRunStatus(status: "passed" | "failed" | "skipped" | "finished" | "pending", params?: Partial<import("../types/types.js").RunData>): Promise<any>;
73
73
  }
74
74
  import { S3Uploader } from './uploader.js';
package/lib/client.js CHANGED
@@ -319,7 +319,7 @@ class Client {
319
319
  /**
320
320
  *
321
321
  * Updates the status of the current test run and finishes the run.
322
- * @param {'passed' | 'failed' | 'skipped' | 'finished'} status - The status of the current test run.
322
+ * @param {'passed' | 'failed' | 'skipped' | 'finished' | 'pending'} status - The status of the current test run.
323
323
  * @param {Partial<import('../types/types.js').RunData>} [params] - Additional run params (e.g. duration).
324
324
  * Must be one of "passed", "failed", or "finished"
325
325
  * @returns {Promise<any>} - A Promise that resolves when finishes the run.
@@ -118,24 +118,29 @@ class BitbucketPipe {
118
118
  this.tests[i].stack = await this.cleanLog(this.tests[i].stack || '');
119
119
  }
120
120
  // Create a comment on Bitbucket
121
- const passedCount = this.tests.filter(t => t.status === 'passed').length;
122
- const failedCount = this.tests.filter(t => t.status === 'failed').length;
123
- const skippedCount = this.tests.filter(t => t.status === 'skipped').length;
124
- // Constructing the table
125
- let summary = `${this.hiddenCommentData}
126
-
127
- | ![Testomat.io Report](${constants_js_1.testomatLogoURL}) | ${(0, pipe_utils_js_1.statusEmoji)(runParams.status)} ${runParams.status.toUpperCase()} ${(0, pipe_utils_js_1.statusEmoji)(runParams.status)} |
128
- | --- | --- |
129
- | **Tests** | ✔️ **${this.tests.length}** tests run |
130
- | **Summary** | ${(0, pipe_utils_js_1.statusEmoji)('failed')} **${failedCount}** failed; ${(0, pipe_utils_js_1.statusEmoji)('passed')} **${passedCount}** passed; **${(0, pipe_utils_js_1.statusEmoji)('skipped')}** ${skippedCount} skipped |
131
- | **Duration** | 🕐 **${(0, humanize_duration_1.default)(parseInt(this.tests.reduce((a, t) => a + (t.run_time || 0), 0), 10), {
132
- maxDecimalPoints: 0,
133
- })}** |
134
- `;
121
+ // a scheduled run has no results yet: no counters, no duration
122
+ const isPendingRun = runParams.status === 'pending';
123
+ /** @type {Object<string, string>} */
124
+ const rows = {};
125
+ if (isPendingRun) {
126
+ if (this.tests.length)
127
+ rows.Tests = `⚪ ${(0, pipe_utils_js_1.plannedTestsLabel)(this.tests, this.store.runTestsCount)}`;
128
+ }
129
+ else {
130
+ rows.Tests = `✔️ **${this.tests.length}** tests run`;
131
+ rows.Summary = (0, pipe_utils_js_1.runSummary)(this.tests);
132
+ rows.Duration = `🕐 **${(0, pipe_utils_js_1.totalDuration)(this.tests)}**`;
133
+ }
135
134
  if (this.ENV.BITBUCKET_BRANCH && this.ENV.BITBUCKET_COMMIT) {
136
- // eslint-disable-next-line max-len
137
- summary += `| **Job** | 👷 [#${this.ENV.BITBUCKET_BUILD_NUMBER}](https://bitbucket.org/${this.ENV.BITBUCKET_REPO_FULL_NAME}/pipelines/results/${this.ENV.BITBUCKET_BUILD_NUMBER}") by commit: **${this.ENV.BITBUCKET_COMMIT}** |`;
135
+ const buildNumber = this.ENV.BITBUCKET_BUILD_NUMBER;
136
+ const buildUrl = `https://bitbucket.org/${this.ENV.BITBUCKET_REPO_FULL_NAME}/pipelines/results/${buildNumber}`;
137
+ rows.Job = `👷 [#${buildNumber}](${buildUrl}) by commit: **${this.ENV.BITBUCKET_COMMIT}**`;
138
138
  }
139
+ const header = [
140
+ `![Testomat.io Report](${constants_js_1.testomatLogoURL})`,
141
+ `${(0, pipe_utils_js_1.statusEmoji)(runParams.status)} ${runParams.status.toUpperCase()} ${(0, pipe_utils_js_1.statusEmoji)(runParams.status)}`,
142
+ ];
143
+ const summary = `${this.hiddenCommentData}\n\n${(0, pipe_utils_js_1.markdownTable)(header, rows, { boldLabels: true })}`;
139
144
  const failures = this.tests
140
145
  .filter(t => t.status === 'failed')
141
146
  .slice(0, 20)
@@ -178,7 +183,7 @@ class BitbucketPipe {
178
183
  body += `\n> Notice: Only the first 10 failures are shown.`;
179
184
  }
180
185
  }
181
- if (this.tests.length > 0) {
186
+ if (this.tests.length > 0 && !isPendingRun) {
182
187
  body += `\n\n**🐢 Slowest Tests**\n\n`;
183
188
  body += this.tests
184
189
  .sort((a, b) => b.run_time - a.run_time)
@@ -104,27 +104,34 @@ class GitHubPipe {
104
104
  if (!(owner || repo))
105
105
  return;
106
106
  // ... create a comment on GitHub
107
- const passedCount = this.tests.filter(t => t.status === 'passed').length;
108
- const failedCount = this.tests.filter(t => t.status === 'failed').length;
109
- const skippedCount = this.tests.filter(t => t.status === 'skipped').length;
110
- let summary = `${this.hiddenCommentData}
111
-
112
- | [![Testomat.io Report](${constants_js_1.testomatLogoURL})](https://testomat.io) | ${(0, pipe_utils_js_1.statusEmoji)(runParams.status)} ${`${process.env.GITHUB_JOB} ${runParams.status}`.toUpperCase()} |
113
- | --- | --- |
114
- | Tests | ✔️ **${this.tests.length}** tests run |
115
- | Summary | ${failedCount ? `${(0, pipe_utils_js_1.statusEmoji)('failed')} **${failedCount}** failed; ` : ''} ${(0, pipe_utils_js_1.statusEmoji)('passed')} **${passedCount}** passed; **${(0, pipe_utils_js_1.statusEmoji)('skipped')}** ${skippedCount} skipped |
116
- | Duration | 🕐 **${(0, humanize_duration_1.default)(parseInt(this.tests.reduce((a, t) => a + (t.run_time || 0), 0), 10), {
117
- maxDecimalPoints: 0,
118
- })}** |`;
107
+ // a scheduled run has no results yet: no counters, no duration
108
+ const isPendingRun = runParams.status === 'pending';
109
+ /** @type {Object<string, string>} */
110
+ const rows = {};
111
+ if (isPendingRun) {
112
+ if (this.tests.length)
113
+ rows.Tests = `⚪ ${(0, pipe_utils_js_1.plannedTestsLabel)(this.tests, this.store.runTestsCount)}`;
114
+ }
115
+ else {
116
+ rows.Tests = `✔️ **${this.tests.length}** tests run`;
117
+ rows.Summary = (0, pipe_utils_js_1.runSummary)(this.tests);
118
+ rows.Duration = `🕐 **${(0, pipe_utils_js_1.totalDuration)(this.tests)}**`;
119
+ }
119
120
  if (this.store.runUrl) {
120
- summary += `\n| Testomat.io Report | 📊 [Run #${this.store.runId}](${this.store.runUrl}) | `;
121
+ rows['Testomat.io Report'] = `📊 [Run #${this.store.runId}](${this.store.runUrl})`;
121
122
  }
122
123
  if (process.env.GITHUB_WORKFLOW) {
123
- summary += `\n| Job | 🗂️ [${this.jobKey}](${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${this.repo}/actions/runs/${process.env.GITHUB_RUN_ID}) | `;
124
+ const server = process.env.GITHUB_SERVER_URL || 'https://github.com';
125
+ rows.Job = `🗂️ [${this.jobKey}](${server}/${this.repo}/actions/runs/${process.env.GITHUB_RUN_ID})`;
124
126
  }
125
127
  if (process.env.RUNNER_OS) {
126
- summary += `\n| Operating System | 🖥️ \`${process.env.RUNNER_OS}\` ${process.env.RUNNER_ARCH || ''} | `;
128
+ rows['Operating System'] = `🖥️ \`${process.env.RUNNER_OS}\` ${process.env.RUNNER_ARCH || ''}`;
127
129
  }
130
+ const header = [
131
+ `[![Testomat.io Report](${constants_js_1.testomatLogoURL})](https://testomat.io)`,
132
+ `${(0, pipe_utils_js_1.statusEmoji)(runParams.status)} ${`${process.env.GITHUB_JOB} ${runParams.status}`.toUpperCase()}`,
133
+ ];
134
+ const summary = `${this.hiddenCommentData}\n\n${(0, pipe_utils_js_1.markdownTable)(header, rows)}`;
128
135
  const failures = this.tests
129
136
  .filter(t => t.status === 'failed')
130
137
  .slice(0, 20)
@@ -180,7 +187,7 @@ class GitHubPipe {
180
187
  }
181
188
  body += '\n\n</details>';
182
189
  }
183
- if (this.tests.length > 0) {
190
+ if (this.tests.length > 0 && !isPendingRun) {
184
191
  body += '\n<details>\n<summary><h3>🐢 Slowest Tests</h3></summary>\n\n';
185
192
  body += this.tests
186
193
  .sort((a, b) => b?.run_time - a?.run_time)
@@ -69,24 +69,28 @@ class GitLabPipe {
69
69
  if (runParams.tests)
70
70
  runParams.tests.forEach(t => this.addTest(t));
71
71
  // ... create a comment on GitLab
72
- const passedCount = this.tests.filter(t => t.status === 'passed').length;
73
- const failedCount = this.tests.filter(t => t.status === 'failed').length;
74
- const skippedCount = this.tests.filter(t => t.status === 'skipped').length;
75
- // constructing the table
76
- let summary = `${this.hiddenCommentData}
77
-
78
- | [![Testomat.io Report](${constants_js_1.testomatLogoURL})](https://testomat.io) | ${(0, pipe_utils_js_1.statusEmoji)(runParams.status)} ${runParams.status.toUpperCase()} ${(0, pipe_utils_js_1.statusEmoji)(runParams.status)} |
79
- | --- | --- |
80
- | Tests | ✔️ **${this.tests.length}** tests run |
81
- | Summary | ${(0, pipe_utils_js_1.statusEmoji)('failed')} **${failedCount}** failed; ${(0, pipe_utils_js_1.statusEmoji)('passed')} **${passedCount}** passed; **${(0, pipe_utils_js_1.statusEmoji)('skipped')}** ${skippedCount} skipped |
82
- | Duration | 🕐 **${(0, humanize_duration_1.default)(parseInt(this.tests.reduce((a, t) => a + (t.run_time || 0), 0), 10), {
83
- maxDecimalPoints: 0,
84
- })}** |
85
- `;
72
+ // a scheduled run has no results yet: no counters, no duration
73
+ const isPendingRun = runParams.status === 'pending';
74
+ /** @type {Object<string, string>} */
75
+ const rows = {};
76
+ if (isPendingRun) {
77
+ if (this.tests.length)
78
+ rows.Tests = `⚪ ${(0, pipe_utils_js_1.plannedTestsLabel)(this.tests, this.store.runTestsCount)}`;
79
+ }
80
+ else {
81
+ rows.Tests = `✔️ **${this.tests.length}** tests run`;
82
+ rows.Summary = (0, pipe_utils_js_1.runSummary)(this.tests);
83
+ rows.Duration = `🕐 **${(0, pipe_utils_js_1.totalDuration)(this.tests)}**`;
84
+ }
86
85
  if (this.ENV.CI_JOB_NAME && this.ENV.CI_JOB_ID) {
87
86
  // eslint-disable-next-line max-len
88
- summary += `| Job | 👷 [${this.ENV.CI_JOB_ID}](${this.ENV.CI_JOB_URL})<br>Name: **${this.ENV.CI_JOB_NAME}**<br>Stage: **${this.ENV.CI_JOB_STAGE}** | `;
87
+ rows.Job = `👷 [${this.ENV.CI_JOB_ID}](${this.ENV.CI_JOB_URL})<br>Name: **${this.ENV.CI_JOB_NAME}**<br>Stage: **${this.ENV.CI_JOB_STAGE}**`;
89
88
  }
89
+ const header = [
90
+ `[![Testomat.io Report](${constants_js_1.testomatLogoURL})](https://testomat.io)`,
91
+ `${(0, pipe_utils_js_1.statusEmoji)(runParams.status)} ${runParams.status.toUpperCase()} ${(0, pipe_utils_js_1.statusEmoji)(runParams.status)}`,
92
+ ];
93
+ const summary = `${this.hiddenCommentData}\n\n${(0, pipe_utils_js_1.markdownTable)(header, rows)}`;
90
94
  const failures = this.tests
91
95
  .filter(t => t.status === 'failed')
92
96
  .slice(0, 20)
@@ -127,7 +131,7 @@ class GitLabPipe {
127
131
  }
128
132
  body += '\n\n</details>';
129
133
  }
130
- if (this.tests.length > 0) {
134
+ if (this.tests.length > 0 && !isPendingRun) {
131
135
  body += '\n<details>\n<summary><h3>🐢 Slowest Tests</h3></summary>\n\n';
132
136
  body += this.tests
133
137
  .sort((a, b) => b?.run_time - a?.run_time)
package/lib/pipe/html.js CHANGED
@@ -216,7 +216,7 @@ class HtmlPipe {
216
216
  runUrl: this.store.runUrl || '',
217
217
  executionTime: testExecutionSumTime(aggregatedTests),
218
218
  executionDate: getCurrentDateTimeFormatted(),
219
- description: [runParams.description || this.store.coverageDescription || this.store.description, this.description]
219
+ description: [this.description, runParams.description || this.store.coverageDescription || this.store.description]
220
220
  .filter(Boolean)
221
221
  .join('\n\n') || '',
222
222
  configuration: buildDisplayConfiguration(this.configuration || this.store.configuration || runParams.configuration || null),
@@ -117,7 +117,7 @@ class MarkdownPipe {
117
117
  isParallel: runParams?.isParallel,
118
118
  executionTime: testExecutionSumTime(aggregated),
119
119
  executionDate: getCurrentDateTimeFormatted(),
120
- description: [runParams?.description || this.store.coverageDescription || this.store.description, this.description]
120
+ description: [this.description, runParams?.description || this.store.coverageDescription || this.store.description]
121
121
  .filter(Boolean)
122
122
  .join('\n\n') || '',
123
123
  configuration: this.configuration || this.store.configuration || runParams?.configuration || null,
@@ -247,8 +247,9 @@ class TestomatioPipe {
247
247
  suites: coverageConfiguration.suites?.map(id => id.replace(/^S/, '')) || [],
248
248
  };
249
249
  }
250
- // Run description: coverage-derived block (if any) with the user-provided TESTOMATIO_DESCRIPTION appended after it.
251
- const description = [coverageDescription, this.description].filter(Boolean).join('\n\n') || null;
250
+ // Run description: the user-provided TESTOMATIO_DESCRIPTION with the coverage-derived block
251
+ // (if any) added after it. Neither overrides the other.
252
+ const description = [this.description, coverageDescription].filter(Boolean).join('\n\n') || null;
252
253
  // Merge caller-supplied configuration (e.g. { exploratory: true }) into runParams.configuration.
253
254
  // Caller values win on key conflict; coverage-derived tests/suites lists are preserved when not overridden.
254
255
  if (params.configuration && typeof params.configuration === 'object') {
@@ -289,6 +290,7 @@ class TestomatioPipe {
289
290
  shared_run: this.sharedRun,
290
291
  shared_run_timeout: this.sharedRunTimeout,
291
292
  kind: params.kind,
293
+ status: params.status,
292
294
  configuration,
293
295
  description,
294
296
  ci,
@@ -335,6 +337,9 @@ class TestomatioPipe {
335
337
  this.store.runUrl = this.runUrl;
336
338
  this.store.runPublicUrl = this.runPublicUrl;
337
339
  this.store.runId = this.runId;
340
+ // only the server knows how many tests a configuration expands to; automated runs report 0
341
+ if (resp.data.tests_count > 0)
342
+ this.store.runTestsCount = resp.data.tests_count;
338
343
  log_js_1.log.info('📊 Report created. Report ID:', this.runId);
339
344
  process.env.runId = this.runId;
340
345
  debug('Run created', this.runId);
@@ -511,6 +516,9 @@ class TestomatioPipe {
511
516
  console.warn(`${constants_js_1.APP_PREFIX} ${errorMessage}`);
512
517
  }
513
518
  const { status } = params;
519
+ // a pending run was just created: nothing to update here, only other pipes report it
520
+ if (status === 'pending')
521
+ return;
514
522
  let status_event;
515
523
  if (status === constants_js_1.STATUS.FINISHED)
516
524
  status_event = 'finish';
@@ -39,6 +39,50 @@ export function statusEmoji(status: string): string;
39
39
  * @returns {string} - A formatted full name string for the test object.
40
40
  */
41
41
  export function fullName(t: object): string;
42
+ /**
43
+ * Render a two-column markdown table. Rows with an empty value are skipped, so optional rows
44
+ * need no surrounding `if`.
45
+ *
46
+ * @param {string[]} header - The two header cells.
47
+ * @param {Object<string, string>} rows - Label to value, rendered in insertion order.
48
+ * @param {{ boldLabels?: boolean }} [opts] - Set `boldLabels` to wrap every label in `**`.
49
+ * @returns {string} The table, with no trailing newline.
50
+ */
51
+ export function markdownTable(header: string[], rows: {
52
+ [x: string]: string;
53
+ }, opts?: {
54
+ boldLabels?: boolean;
55
+ }): string;
56
+ /**
57
+ * Summarize a finished run, e.g. `🔴 **1** failed; 🟢 **8** passed; 🟡 **1** skipped`.
58
+ * The failed part is omitted when nothing failed.
59
+ *
60
+ * @param {Array<{status?: string}>} tests
61
+ * @returns {string}
62
+ */
63
+ export function runSummary(tests: Array<{
64
+ status?: string;
65
+ }>): string;
66
+ /**
67
+ * Total run time of the given tests, humanized — e.g. `2 seconds`.
68
+ *
69
+ * @param {Array<{run_time?: number}>} tests
70
+ * @returns {string}
71
+ */
72
+ export function totalDuration(tests: Array<{
73
+ run_time?: number;
74
+ }>): string;
75
+ /**
76
+ * Describe the scope of a run that was prepared but not executed yet. Prefers the server's count;
77
+ * without it, falls back to the scoped ids, which mix tests (`T…`) and suites (`S…`).
78
+ *
79
+ * @param {Array<{test_id?: string}>} tests - Prepared tests the run was scoped to.
80
+ * @param {number} [testsCount] - Real number of tests, as reported by Testomat.io.
81
+ * @returns {string} Markdown label, e.g. `**159** tests planned` or `**6** suites planned`.
82
+ */
83
+ export function plannedTestsLabel(tests: Array<{
84
+ test_id?: string;
85
+ }>, testsCount?: number): string;
42
86
  /**
43
87
  * Parses a comma-separated list of key-value pairs into an options object.
44
88
  *
@@ -1,4 +1,7 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.updateFilterType = updateFilterType;
4
7
  exports.parseFilterParams = parseFilterParams;
@@ -6,10 +9,15 @@ exports.generateFilterRequestParams = generateFilterRequestParams;
6
9
  exports.setS3Credentials = setS3Credentials;
7
10
  exports.statusEmoji = statusEmoji;
8
11
  exports.fullName = fullName;
12
+ exports.markdownTable = markdownTable;
13
+ exports.runSummary = runSummary;
14
+ exports.totalDuration = totalDuration;
15
+ exports.plannedTestsLabel = plannedTestsLabel;
9
16
  exports.parsePipeOptions = parsePipeOptions;
10
17
  exports.formatFilterListIds = formatFilterListIds;
11
18
  exports.getObjectSize = getObjectSize;
12
19
  exports.splitTestsIntoChunks = splitTestsIntoChunks;
20
+ const humanize_duration_1 = __importDefault(require("humanize-duration"));
13
21
  const log_js_1 = require("./log.js");
14
22
  /**
15
23
  * Set S3 credentials from the provided artifacts object.
@@ -121,6 +129,8 @@ function statusEmoji(status) {
121
129
  return '🔴';
122
130
  if (status === 'skipped')
123
131
  return '🟡';
132
+ if (status === 'pending')
133
+ return '🕐';
124
134
  return '';
125
135
  }
126
136
  /**
@@ -223,6 +233,75 @@ function formatFilterListIds(ids, format) {
223
233
  default: return ids.join(',');
224
234
  }
225
235
  }
236
+ /**
237
+ * Summarize a finished run, e.g. `🔴 **1** failed; 🟢 **8** passed; 🟡 **1** skipped`.
238
+ * The failed part is omitted when nothing failed.
239
+ *
240
+ * @param {Array<{status?: string}>} tests
241
+ * @returns {string}
242
+ */
243
+ function runSummary(tests) {
244
+ const countOf = status => tests.filter(t => t.status === status).length;
245
+ const failedCount = countOf('failed');
246
+ const parts = [];
247
+ if (failedCount)
248
+ parts.push(`${statusEmoji('failed')} **${failedCount}** failed`);
249
+ parts.push(`${statusEmoji('passed')} **${countOf('passed')}** passed`);
250
+ parts.push(`${statusEmoji('skipped')} **${countOf('skipped')}** skipped`);
251
+ return parts.join('; ');
252
+ }
253
+ /**
254
+ * Total run time of the given tests, humanized — e.g. `2 seconds`.
255
+ *
256
+ * @param {Array<{run_time?: number}>} tests
257
+ * @returns {string}
258
+ */
259
+ function totalDuration(tests) {
260
+ const milliseconds = tests.reduce((total, t) => total + (t.run_time || 0), 0);
261
+ return (0, humanize_duration_1.default)(Math.trunc(milliseconds), { maxDecimalPoints: 0 });
262
+ }
263
+ /**
264
+ * Render a two-column markdown table. Rows with an empty value are skipped, so optional rows
265
+ * need no surrounding `if`.
266
+ *
267
+ * @param {string[]} header - The two header cells.
268
+ * @param {Object<string, string>} rows - Label to value, rendered in insertion order.
269
+ * @param {{ boldLabels?: boolean }} [opts] - Set `boldLabels` to wrap every label in `**`.
270
+ * @returns {string} The table, with no trailing newline.
271
+ */
272
+ function markdownTable(header, rows, opts = {}) {
273
+ const lines = [`| ${header[0]} | ${header[1]} |`, '| --- | --- |'];
274
+ for (const [label, value] of Object.entries(rows)) {
275
+ if (!value)
276
+ continue;
277
+ if (opts.boldLabels) {
278
+ lines.push(`| **${label}** | ${value} |`);
279
+ }
280
+ else {
281
+ lines.push(`| ${label} | ${value} |`);
282
+ }
283
+ }
284
+ return lines.join('\n');
285
+ }
286
+ /**
287
+ * Describe the scope of a run that was prepared but not executed yet. Prefers the server's count;
288
+ * without it, falls back to the scoped ids, which mix tests (`T…`) and suites (`S…`).
289
+ *
290
+ * @param {Array<{test_id?: string}>} tests - Prepared tests the run was scoped to.
291
+ * @param {number} [testsCount] - Real number of tests, as reported by Testomat.io.
292
+ * @returns {string} Markdown label, e.g. `**159** tests planned` or `**6** suites planned`.
293
+ */
294
+ function plannedTestsLabel(tests, testsCount) {
295
+ if (testsCount > 0)
296
+ return `**${testsCount}** tests planned`;
297
+ const suitesCount = tests.filter(t => `${t.test_id || ''}`.startsWith('S')).length;
298
+ const knownTestsCount = tests.length - suitesCount;
299
+ if (!suitesCount)
300
+ return `**${knownTestsCount}** tests planned`;
301
+ if (!knownTestsCount)
302
+ return `**${suitesCount}** suites planned`;
303
+ return `**${knownTestsCount}** tests and **${suitesCount}** suites planned`;
304
+ }
226
305
 
227
306
  module.exports.updateFilterType = updateFilterType;
228
307
 
@@ -236,6 +315,14 @@ module.exports.statusEmoji = statusEmoji;
236
315
 
237
316
  module.exports.fullName = fullName;
238
317
 
318
+ module.exports.markdownTable = markdownTable;
319
+
320
+ module.exports.runSummary = runSummary;
321
+
322
+ module.exports.totalDuration = totalDuration;
323
+
324
+ module.exports.plannedTestsLabel = plannedTestsLabel;
325
+
239
326
  module.exports.parsePipeOptions = parsePipeOptions;
240
327
 
241
328
  module.exports.formatFilterListIds = formatFilterListIds;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.11.0",
3
+ "version": "2.13.0",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -255,7 +255,8 @@ function appendStep(step, shift = 0) {
255
255
  newCategory = 'hook';
256
256
  break;
257
257
  case 'attach':
258
- return null; // Skip steps with category 'attach'
258
+ case 'test.attach':
259
+ return null; // Attachments are reported as artifacts, not standalone steps
259
260
  default:
260
261
  newCategory = 'framework';
261
262
  }
@@ -286,11 +287,11 @@ function appendStep(step, shift = 0) {
286
287
  resultStep.log = truncate(String(step.log), 250);
287
288
  }
288
289
 
289
- // Add artifacts from attachments
290
- if (step.attachments && step.attachments.length > 0 && SCREENSHOTS_ON_STEPS) {
291
- const screenshotAttachment = step.attachments.find(att =>
292
- att.contentType === 'image/png' && att.name === 'screenshot'
293
- );
290
+ // Playwright also associates automatic failure screenshots with the active
291
+ // hook. Only attachments created for an explicit test.step belong to a step;
292
+ // hook and fixture attachments remain test-level result artifacts.
293
+ if (step.category === 'test.step' && step.attachments?.length && SCREENSHOTS_ON_STEPS) {
294
+ const screenshotAttachment = step.attachments.find(isScreenshotArtifact);
294
295
  if (screenshotAttachment && screenshotAttachment.path) {
295
296
  const artifacts = { screenshot: screenshotAttachment.path };
296
297
  addArtifactsToStep(resultStep, artifacts);
package/src/bin/cli.js CHANGED
@@ -62,7 +62,8 @@ program
62
62
  const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config.TESTOMATIO;
63
63
  const client = new TestomatClient({ apiKey });
64
64
 
65
- const createRunParams = {};
65
+ // nothing is executed yet; the server flips it to running on the first reported test
66
+ const createRunParams = { status: 'scheduled' };
66
67
  if (opts.kind) createRunParams.kind = opts.kind;
67
68
 
68
69
  if (opts.filter) {
@@ -87,6 +88,11 @@ program
87
88
  process.exit(1);
88
89
  }
89
90
 
91
+ // tests are executed later, so report the run as pending with the tests it was scoped to:
92
+ // pipes add their report now and replace it when the run is finished
93
+ const plannedTests = (client.pipeStore.preparedTestIds || []).map(id => ({ test_id: id, title: id }));
94
+ await client.updateRunStatus('pending', { tests: plannedTests });
95
+
90
96
  // stdout carries ONLY the run id so it can be captured: RUN_ID=$(reporter start)
91
97
  console.log(runId);
92
98
  process.exit(0);
package/src/client.js CHANGED
@@ -371,7 +371,7 @@ class Client {
371
371
  /**
372
372
  *
373
373
  * Updates the status of the current test run and finishes the run.
374
- * @param {'passed' | 'failed' | 'skipped' | 'finished'} status - The status of the current test run.
374
+ * @param {'passed' | 'failed' | 'skipped' | 'finished' | 'pending'} status - The status of the current test run.
375
375
  * @param {Partial<import('../types/types.js').RunData>} [params] - Additional run params (e.g. duration).
376
376
  * Must be one of "passed", "failed", or "finished"
377
377
  * @returns {Promise<any>} - A Promise that resolves when finishes the run.
@@ -1,6 +1,13 @@
1
1
  import { APP_PREFIX, testomatLogoURL } from '../constants.js';
2
2
  import { ansiRegExp, isSameTest, truncate } from '../utils/utils.js';
3
- import { statusEmoji, fullName } from '../utils/pipe_utils.js';
3
+ import {
4
+ statusEmoji,
5
+ fullName,
6
+ plannedTestsLabel,
7
+ markdownTable,
8
+ runSummary,
9
+ totalDuration,
10
+ } from '../utils/pipe_utils.js';
4
11
  import { Gaxios } from 'gaxios';
5
12
  import pc from 'picocolors';
6
13
  import humanizeDuration from 'humanize-duration';
@@ -103,37 +110,32 @@ export class BitbucketPipe {
103
110
  }
104
111
 
105
112
  // Create a comment on Bitbucket
106
- const passedCount = this.tests.filter(t => t.status === 'passed').length;
107
- const failedCount = this.tests.filter(t => t.status === 'failed').length;
108
- const skippedCount = this.tests.filter(t => t.status === 'skipped').length;
109
-
110
- // Constructing the table
111
- let summary = `${this.hiddenCommentData}
112
-
113
- | ![Testomat.io Report](${testomatLogoURL}) | ${statusEmoji(
114
- runParams.status,
115
- )} ${runParams.status.toUpperCase()} ${statusEmoji(runParams.status)} |
116
- | --- | --- |
117
- | **Tests** | ✔️ **${this.tests.length}** tests run |
118
- | **Summary** | ${statusEmoji('failed')} **${failedCount}** failed; ${statusEmoji(
119
- 'passed',
120
- )} **${passedCount}** passed; **${statusEmoji('skipped')}** ${skippedCount} skipped |
121
- | **Duration** | 🕐 **${humanizeDuration(
122
- parseInt(
123
- this.tests.reduce((a, t) => a + (t.run_time || 0), 0),
124
- 10,
125
- ),
126
- {
127
- maxDecimalPoints: 0,
128
- },
129
- )}** |
130
- `;
113
+ // a scheduled run has no results yet: no counters, no duration
114
+ const isPendingRun = runParams.status === 'pending';
115
+
116
+ /** @type {Object<string, string>} */
117
+ const rows = {};
118
+
119
+ if (isPendingRun) {
120
+ if (this.tests.length) rows.Tests = `⚪ ${plannedTestsLabel(this.tests, this.store.runTestsCount)}`;
121
+ } else {
122
+ rows.Tests = `✔️ **${this.tests.length}** tests run`;
123
+ rows.Summary = runSummary(this.tests);
124
+ rows.Duration = `🕐 **${totalDuration(this.tests)}**`;
125
+ }
131
126
 
132
127
  if (this.ENV.BITBUCKET_BRANCH && this.ENV.BITBUCKET_COMMIT) {
133
- // eslint-disable-next-line max-len
134
- summary += `| **Job** | 👷 [#${this.ENV.BITBUCKET_BUILD_NUMBER}](https://bitbucket.org/${this.ENV.BITBUCKET_REPO_FULL_NAME}/pipelines/results/${this.ENV.BITBUCKET_BUILD_NUMBER}") by commit: **${this.ENV.BITBUCKET_COMMIT}** |`;
128
+ const buildNumber = this.ENV.BITBUCKET_BUILD_NUMBER;
129
+ const buildUrl = `https://bitbucket.org/${this.ENV.BITBUCKET_REPO_FULL_NAME}/pipelines/results/${buildNumber}`;
130
+ rows.Job = `👷 [#${buildNumber}](${buildUrl}) by commit: **${this.ENV.BITBUCKET_COMMIT}**`;
135
131
  }
136
132
 
133
+ const header = [
134
+ `![Testomat.io Report](${testomatLogoURL})`,
135
+ `${statusEmoji(runParams.status)} ${runParams.status.toUpperCase()} ${statusEmoji(runParams.status)}`,
136
+ ];
137
+ const summary = `${this.hiddenCommentData}\n\n${markdownTable(header, rows, { boldLabels: true })}`;
138
+
137
139
  const failures = this.tests
138
140
  .filter(t => t.status === 'failed')
139
141
  .slice(0, 20)
@@ -182,7 +184,7 @@ export class BitbucketPipe {
182
184
  }
183
185
  }
184
186
 
185
- if (this.tests.length > 0) {
187
+ if (this.tests.length > 0 && !isPendingRun) {
186
188
  body += `\n\n**🐢 Slowest Tests**\n\n`;
187
189
  body += this.tests
188
190
  .sort((a, b) => b.run_time - a.run_time)
@@ -5,7 +5,14 @@ import humanizeDuration from 'humanize-duration';
5
5
  import merge from 'lodash.merge';
6
6
  import { testomatLogoURL } from '../constants.js';
7
7
  import { ansiRegExp, isSameTest, truncate } from '../utils/utils.js';
8
- import { statusEmoji, fullName } from '../utils/pipe_utils.js';
8
+ import {
9
+ statusEmoji,
10
+ fullName,
11
+ plannedTestsLabel,
12
+ markdownTable,
13
+ runSummary,
14
+ totalDuration,
15
+ } from '../utils/pipe_utils.js';
9
16
  import { log } from '../utils/log.js';
10
17
 
11
18
  const debug = createDebugMessages('@testomatio/reporter:pipe:github');
@@ -75,42 +82,37 @@ class GitHubPipe {
75
82
  if (!(owner || repo)) return;
76
83
 
77
84
  // ... create a comment on GitHub
78
- const passedCount = this.tests.filter(t => t.status === 'passed').length;
79
- const failedCount = this.tests.filter(t => t.status === 'failed').length;
80
- const skippedCount = this.tests.filter(t => t.status === 'skipped').length;
81
-
82
- let summary = `${this.hiddenCommentData}
83
-
84
- | [![Testomat.io Report](${testomatLogoURL})](https://testomat.io) | ${statusEmoji(
85
- runParams.status,
86
- )} ${`${process.env.GITHUB_JOB} ${runParams.status}`.toUpperCase()} |
87
- | --- | --- |
88
- | Tests | ✔️ **${this.tests.length}** tests run |
89
- | Summary | ${failedCount ? `${statusEmoji('failed')} **${failedCount}** failed; ` : ''} ${statusEmoji(
90
- 'passed',
91
- )} **${passedCount}** passed; **${statusEmoji('skipped')}** ${skippedCount} skipped |
92
- | Duration | 🕐 **${humanizeDuration(
93
- parseInt(
94
- this.tests.reduce((a, t) => a + (t.run_time || 0), 0),
95
- 10,
96
- ),
97
- {
98
- maxDecimalPoints: 0,
99
- },
100
- )}** |`;
85
+ // a scheduled run has no results yet: no counters, no duration
86
+ const isPendingRun = runParams.status === 'pending';
87
+
88
+ /** @type {Object<string, string>} */
89
+ const rows = {};
90
+
91
+ if (isPendingRun) {
92
+ if (this.tests.length) rows.Tests = `⚪ ${plannedTestsLabel(this.tests, this.store.runTestsCount)}`;
93
+ } else {
94
+ rows.Tests = `✔️ **${this.tests.length}** tests run`;
95
+ rows.Summary = runSummary(this.tests);
96
+ rows.Duration = `🕐 **${totalDuration(this.tests)}**`;
97
+ }
101
98
 
102
99
  if (this.store.runUrl) {
103
- summary += `\n| Testomat.io Report | 📊 [Run #${this.store.runId}](${this.store.runUrl}) | `;
100
+ rows['Testomat.io Report'] = `📊 [Run #${this.store.runId}](${this.store.runUrl})`;
104
101
  }
105
102
  if (process.env.GITHUB_WORKFLOW) {
106
- summary += `\n| Job | 🗂️ [${this.jobKey}](${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${
107
- this.repo
108
- }/actions/runs/${process.env.GITHUB_RUN_ID}) | `;
103
+ const server = process.env.GITHUB_SERVER_URL || 'https://github.com';
104
+ rows.Job = `🗂️ [${this.jobKey}](${server}/${this.repo}/actions/runs/${process.env.GITHUB_RUN_ID})`;
109
105
  }
110
106
  if (process.env.RUNNER_OS) {
111
- summary += `\n| Operating System | 🖥️ \`${process.env.RUNNER_OS}\` ${process.env.RUNNER_ARCH || ''} | `;
107
+ rows['Operating System'] = `🖥️ \`${process.env.RUNNER_OS}\` ${process.env.RUNNER_ARCH || ''}`;
112
108
  }
113
109
 
110
+ const header = [
111
+ `[![Testomat.io Report](${testomatLogoURL})](https://testomat.io)`,
112
+ `${statusEmoji(runParams.status)} ${`${process.env.GITHUB_JOB} ${runParams.status}`.toUpperCase()}`,
113
+ ];
114
+ const summary = `${this.hiddenCommentData}\n\n${markdownTable(header, rows)}`;
115
+
114
116
  const failures = this.tests
115
117
  .filter(t => t.status === 'failed')
116
118
  .slice(0, 20)
@@ -170,7 +172,7 @@ class GitHubPipe {
170
172
  body += '\n\n</details>';
171
173
  }
172
174
 
173
- if (this.tests.length > 0) {
175
+ if (this.tests.length > 0 && !isPendingRun) {
174
176
  body += '\n<details>\n<summary><h3>🐢 Slowest Tests</h3></summary>\n\n';
175
177
  body += this.tests
176
178
  .sort((a, b) => b?.run_time - a?.run_time)
@@ -6,7 +6,14 @@ import merge from 'lodash.merge';
6
6
  import path from 'path';
7
7
  import { APP_PREFIX, testomatLogoURL } from '../constants.js';
8
8
  import { ansiRegExp, isSameTest, truncate } from '../utils/utils.js';
9
- import { statusEmoji, fullName } from '../utils/pipe_utils.js';
9
+ import {
10
+ statusEmoji,
11
+ fullName,
12
+ plannedTestsLabel,
13
+ markdownTable,
14
+ runSummary,
15
+ totalDuration,
16
+ } from '../utils/pipe_utils.js';
10
17
  import { log } from '../utils/log.js';
11
18
 
12
19
  const debug = createDebugMessages('@testomatio/reporter:pipe:gitlab');
@@ -81,37 +88,31 @@ class GitLabPipe {
81
88
  if (runParams.tests) runParams.tests.forEach(t => this.addTest(t));
82
89
 
83
90
  // ... create a comment on GitLab
84
- const passedCount = this.tests.filter(t => t.status === 'passed').length;
85
- const failedCount = this.tests.filter(t => t.status === 'failed').length;
86
- const skippedCount = this.tests.filter(t => t.status === 'skipped').length;
87
-
88
- // constructing the table
89
- let summary = `${this.hiddenCommentData}
90
-
91
- | [![Testomat.io Report](${testomatLogoURL})](https://testomat.io) | ${statusEmoji(
92
- runParams.status,
93
- )} ${runParams.status.toUpperCase()} ${statusEmoji(runParams.status)} |
94
- | --- | --- |
95
- | Tests | ✔️ **${this.tests.length}** tests run |
96
- | Summary | ${statusEmoji('failed')} **${failedCount}** failed; ${statusEmoji(
97
- 'passed',
98
- )} **${passedCount}** passed; **${statusEmoji('skipped')}** ${skippedCount} skipped |
99
- | Duration | 🕐 **${humanizeDuration(
100
- parseInt(
101
- this.tests.reduce((a, t) => a + (t.run_time || 0), 0),
102
- 10,
103
- ),
104
- {
105
- maxDecimalPoints: 0,
106
- },
107
- )}** |
108
- `;
91
+ // a scheduled run has no results yet: no counters, no duration
92
+ const isPendingRun = runParams.status === 'pending';
93
+
94
+ /** @type {Object<string, string>} */
95
+ const rows = {};
96
+
97
+ if (isPendingRun) {
98
+ if (this.tests.length) rows.Tests = `⚪ ${plannedTestsLabel(this.tests, this.store.runTestsCount)}`;
99
+ } else {
100
+ rows.Tests = `✔️ **${this.tests.length}** tests run`;
101
+ rows.Summary = runSummary(this.tests);
102
+ rows.Duration = `🕐 **${totalDuration(this.tests)}**`;
103
+ }
109
104
 
110
105
  if (this.ENV.CI_JOB_NAME && this.ENV.CI_JOB_ID) {
111
106
  // eslint-disable-next-line max-len
112
- summary += `| Job | 👷 [${this.ENV.CI_JOB_ID}](${this.ENV.CI_JOB_URL})<br>Name: **${this.ENV.CI_JOB_NAME}**<br>Stage: **${this.ENV.CI_JOB_STAGE}** | `;
107
+ rows.Job = `👷 [${this.ENV.CI_JOB_ID}](${this.ENV.CI_JOB_URL})<br>Name: **${this.ENV.CI_JOB_NAME}**<br>Stage: **${this.ENV.CI_JOB_STAGE}**`;
113
108
  }
114
109
 
110
+ const header = [
111
+ `[![Testomat.io Report](${testomatLogoURL})](https://testomat.io)`,
112
+ `${statusEmoji(runParams.status)} ${runParams.status.toUpperCase()} ${statusEmoji(runParams.status)}`,
113
+ ];
114
+ const summary = `${this.hiddenCommentData}\n\n${markdownTable(header, rows)}`;
115
+
115
116
  const failures = this.tests
116
117
  .filter(t => t.status === 'failed')
117
118
  .slice(0, 20)
@@ -158,7 +159,7 @@ class GitLabPipe {
158
159
  body += '\n\n</details>';
159
160
  }
160
161
 
161
- if (this.tests.length > 0) {
162
+ if (this.tests.length > 0 && !isPendingRun) {
162
163
  body += '\n<details>\n<summary><h3>🐢 Slowest Tests</h3></summary>\n\n';
163
164
  body += this.tests
164
165
  .sort((a, b) => b?.run_time - a?.run_time)
package/src/pipe/html.js CHANGED
@@ -268,7 +268,7 @@ class HtmlPipe {
268
268
  executionTime: testExecutionSumTime(aggregatedTests),
269
269
  executionDate: getCurrentDateTimeFormatted(),
270
270
  description:
271
- [runParams.description || this.store.coverageDescription || this.store.description, this.description]
271
+ [this.description, runParams.description || this.store.coverageDescription || this.store.description]
272
272
  .filter(Boolean)
273
273
  .join('\n\n') || '',
274
274
  configuration: buildDisplayConfiguration(
@@ -140,7 +140,7 @@ class MarkdownPipe {
140
140
  executionTime: testExecutionSumTime(aggregated),
141
141
  executionDate: getCurrentDateTimeFormatted(),
142
142
  description:
143
- [runParams?.description || this.store.coverageDescription || this.store.description, this.description]
143
+ [this.description, runParams?.description || this.store.coverageDescription || this.store.description]
144
144
  .filter(Boolean)
145
145
  .join('\n\n') || '',
146
146
  configuration: this.configuration || this.store.configuration || runParams?.configuration || null,
@@ -279,8 +279,9 @@ class TestomatioPipe {
279
279
  suites: coverageConfiguration.suites?.map(id => id.replace(/^S/, '')) || [],
280
280
  };
281
281
  }
282
- // Run description: coverage-derived block (if any) with the user-provided TESTOMATIO_DESCRIPTION appended after it.
283
- const description = [coverageDescription, this.description].filter(Boolean).join('\n\n') || null;
282
+ // Run description: the user-provided TESTOMATIO_DESCRIPTION with the coverage-derived block
283
+ // (if any) added after it. Neither overrides the other.
284
+ const description = [this.description, coverageDescription].filter(Boolean).join('\n\n') || null;
284
285
 
285
286
  // Merge caller-supplied configuration (e.g. { exploratory: true }) into runParams.configuration.
286
287
  // Caller values win on key conflict; coverage-derived tests/suites lists are preserved when not overridden.
@@ -322,6 +323,7 @@ class TestomatioPipe {
322
323
  shared_run: this.sharedRun,
323
324
  shared_run_timeout: this.sharedRunTimeout,
324
325
  kind: params.kind,
326
+ status: params.status,
325
327
  configuration,
326
328
  description,
327
329
  ci,
@@ -372,6 +374,8 @@ class TestomatioPipe {
372
374
  this.store.runUrl = this.runUrl;
373
375
  this.store.runPublicUrl = this.runPublicUrl;
374
376
  this.store.runId = this.runId;
377
+ // only the server knows how many tests a configuration expands to; automated runs report 0
378
+ if (resp.data.tests_count > 0) this.store.runTestsCount = resp.data.tests_count;
375
379
  log.info('📊 Report created. Report ID:', this.runId);
376
380
  process.env.runId = this.runId;
377
381
  debug('Run created', this.runId);
@@ -558,6 +562,9 @@ class TestomatioPipe {
558
562
 
559
563
  const { status } = params;
560
564
 
565
+ // a pending run was just created: nothing to update here, only other pipes report it
566
+ if (status === 'pending') return;
567
+
561
568
  let status_event;
562
569
 
563
570
  if (status === STATUS.FINISHED) status_event = 'finish';
@@ -1,3 +1,4 @@
1
+ import humanizeDuration from 'humanize-duration';
1
2
  import { log } from './log.js';
2
3
 
3
4
  /**
@@ -117,6 +118,7 @@ function statusEmoji(status) {
117
118
  if (status === 'passed') return '🟢';
118
119
  if (status === 'failed') return '🔴';
119
120
  if (status === 'skipped') return '🟡';
121
+ if (status === 'pending') return '🕐';
120
122
  return '';
121
123
  }
122
124
 
@@ -228,6 +230,80 @@ function formatFilterListIds(ids, format) {
228
230
  }
229
231
  }
230
232
 
233
+ /**
234
+ * Summarize a finished run, e.g. `🔴 **1** failed; 🟢 **8** passed; 🟡 **1** skipped`.
235
+ * The failed part is omitted when nothing failed.
236
+ *
237
+ * @param {Array<{status?: string}>} tests
238
+ * @returns {string}
239
+ */
240
+ function runSummary(tests) {
241
+ const countOf = status => tests.filter(t => t.status === status).length;
242
+ const failedCount = countOf('failed');
243
+
244
+ const parts = [];
245
+ if (failedCount) parts.push(`${statusEmoji('failed')} **${failedCount}** failed`);
246
+ parts.push(`${statusEmoji('passed')} **${countOf('passed')}** passed`);
247
+ parts.push(`${statusEmoji('skipped')} **${countOf('skipped')}** skipped`);
248
+
249
+ return parts.join('; ');
250
+ }
251
+
252
+ /**
253
+ * Total run time of the given tests, humanized — e.g. `2 seconds`.
254
+ *
255
+ * @param {Array<{run_time?: number}>} tests
256
+ * @returns {string}
257
+ */
258
+ function totalDuration(tests) {
259
+ const milliseconds = tests.reduce((total, t) => total + (t.run_time || 0), 0);
260
+ return humanizeDuration(Math.trunc(milliseconds), { maxDecimalPoints: 0 });
261
+ }
262
+
263
+ /**
264
+ * Render a two-column markdown table. Rows with an empty value are skipped, so optional rows
265
+ * need no surrounding `if`.
266
+ *
267
+ * @param {string[]} header - The two header cells.
268
+ * @param {Object<string, string>} rows - Label to value, rendered in insertion order.
269
+ * @param {{ boldLabels?: boolean }} [opts] - Set `boldLabels` to wrap every label in `**`.
270
+ * @returns {string} The table, with no trailing newline.
271
+ */
272
+ function markdownTable(header, rows, opts = {}) {
273
+ const lines = [`| ${header[0]} | ${header[1]} |`, '| --- | --- |'];
274
+
275
+ for (const [label, value] of Object.entries(rows)) {
276
+ if (!value) continue;
277
+
278
+ if (opts.boldLabels) {
279
+ lines.push(`| **${label}** | ${value} |`);
280
+ } else {
281
+ lines.push(`| ${label} | ${value} |`);
282
+ }
283
+ }
284
+
285
+ return lines.join('\n');
286
+ }
287
+
288
+ /**
289
+ * Describe the scope of a run that was prepared but not executed yet. Prefers the server's count;
290
+ * without it, falls back to the scoped ids, which mix tests (`T…`) and suites (`S…`).
291
+ *
292
+ * @param {Array<{test_id?: string}>} tests - Prepared tests the run was scoped to.
293
+ * @param {number} [testsCount] - Real number of tests, as reported by Testomat.io.
294
+ * @returns {string} Markdown label, e.g. `**159** tests planned` or `**6** suites planned`.
295
+ */
296
+ function plannedTestsLabel(tests, testsCount) {
297
+ if (testsCount > 0) return `**${testsCount}** tests planned`;
298
+
299
+ const suitesCount = tests.filter(t => `${t.test_id || ''}`.startsWith('S')).length;
300
+ const knownTestsCount = tests.length - suitesCount;
301
+
302
+ if (!suitesCount) return `**${knownTestsCount}** tests planned`;
303
+ if (!knownTestsCount) return `**${suitesCount}** suites planned`;
304
+ return `**${knownTestsCount}** tests and **${suitesCount}** suites planned`;
305
+ }
306
+
231
307
  export {
232
308
  updateFilterType,
233
309
  parseFilterParams,
@@ -235,6 +311,10 @@ export {
235
311
  setS3Credentials,
236
312
  statusEmoji,
237
313
  fullName,
314
+ markdownTable,
315
+ runSummary,
316
+ totalDuration,
317
+ plannedTestsLabel,
238
318
  parsePipeOptions,
239
319
  formatFilterListIds,
240
320
  getObjectSize,
package/types/types.d.ts CHANGED
@@ -289,6 +289,8 @@ export enum RunStatus {
289
289
  Passed = 'passed',
290
290
  Failed = 'failed',
291
291
  Finished = 'finished',
292
+ /** run created but not executed yet, reported by `reporter start` */
293
+ Pending = 'pending',
292
294
  }
293
295
 
294
296
  /** Batch upload strategy:
@@ -344,6 +346,9 @@ export interface CreateRunParams {
344
346
  /** Run configuration merged into the server-side run configuration. */
345
347
  configuration?: Record<string, any>;
346
348
 
349
+ /** Initial run status. `scheduled` marks a prepared run; the server flips it to running. */
350
+ status?: 'scheduled';
351
+
347
352
  /** Override batch upload mode. */
348
353
  batchMode?: BatchMode;
349
354
  }