@testomatio/reporter 2.12.0 → 2.14.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.
@@ -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) {
@@ -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)
@@ -290,6 +290,7 @@ class TestomatioPipe {
290
290
  shared_run: this.sharedRun,
291
291
  shared_run_timeout: this.sharedRunTimeout,
292
292
  kind: params.kind,
293
+ status: params.status,
293
294
  configuration,
294
295
  description,
295
296
  ci,
@@ -336,6 +337,9 @@ class TestomatioPipe {
336
337
  this.store.runUrl = this.runUrl;
337
338
  this.store.runPublicUrl = this.runPublicUrl;
338
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;
339
343
  log_js_1.log.info('📊 Report created. Report ID:', this.runId);
340
344
  process.env.runId = this.runId;
341
345
  debug('Run created', this.runId);
@@ -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.
@@ -225,6 +233,75 @@ function formatFilterListIds(ids, format) {
225
233
  default: return ids.join(',');
226
234
  }
227
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
+ }
228
305
 
229
306
  module.exports.updateFilterType = updateFilterType;
230
307
 
@@ -238,6 +315,14 @@ module.exports.statusEmoji = statusEmoji;
238
315
 
239
316
  module.exports.fullName = fullName;
240
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
+
241
326
  module.exports.parsePipeOptions = parsePipeOptions;
242
327
 
243
328
  module.exports.formatFilterListIds = formatFilterListIds;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.12.0",
3
+ "version": "2.14.0-beta",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -91,7 +91,7 @@
91
91
  "@types/cross-spawn": "^6.0.6",
92
92
  "@types/cucumber": "^7.0.0",
93
93
  "@types/mocha": "^10.0.7",
94
- "@wdio/reporter": "^7.16.13",
94
+ "@wdio/reporter": "^8.43.0",
95
95
  "chai": "^4.3.6",
96
96
  "codeceptjs": "^3.6.5",
97
97
  "cucumber": "^6.0.7",
@@ -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) {
@@ -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)
@@ -323,6 +323,7 @@ class TestomatioPipe {
323
323
  shared_run: this.sharedRun,
324
324
  shared_run_timeout: this.sharedRunTimeout,
325
325
  kind: params.kind,
326
+ status: params.status,
326
327
  configuration,
327
328
  description,
328
329
  ci,
@@ -373,6 +374,8 @@ class TestomatioPipe {
373
374
  this.store.runUrl = this.runUrl;
374
375
  this.store.runPublicUrl = this.runPublicUrl;
375
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;
376
379
  log.info('📊 Report created. Report ID:', this.runId);
377
380
  process.env.runId = this.runId;
378
381
  debug('Run created', this.runId);
@@ -1,3 +1,4 @@
1
+ import humanizeDuration from 'humanize-duration';
1
2
  import { log } from './log.js';
2
3
 
3
4
  /**
@@ -229,6 +230,80 @@ function formatFilterListIds(ids, format) {
229
230
  }
230
231
  }
231
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
+
232
307
  export {
233
308
  updateFilterType,
234
309
  parseFilterParams,
@@ -236,6 +311,10 @@ export {
236
311
  setS3Credentials,
237
312
  statusEmoji,
238
313
  fullName,
314
+ markdownTable,
315
+ runSummary,
316
+ totalDuration,
317
+ plannedTestsLabel,
239
318
  parsePipeOptions,
240
319
  formatFilterListIds,
241
320
  getObjectSize,
package/types/types.d.ts CHANGED
@@ -346,6 +346,9 @@ export interface CreateRunParams {
346
346
  /** Run configuration merged into the server-side run configuration. */
347
347
  configuration?: Record<string, any>;
348
348
 
349
+ /** Initial run status. `scheduled` marks a prepared run; the server flips it to running. */
350
+ status?: 'scheduled';
351
+
349
352
  /** Override batch upload mode. */
350
353
  batchMode?: BatchMode;
351
354
  }