testomatio-reporter-cli 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-reporter-cli",
3
- "version": "2.12.0",
3
+ "version": "2.14.0-beta",
4
4
  "description": "Yarn Berry compatible standalone CLI for @testomatio/reporter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
  }