@testomatio/reporter 2.19.0 → 2.21.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.
Files changed (62) hide show
  1. package/lib/adapter/codecept.js +26 -2
  2. package/lib/adapter/cucumber/current.js +1 -0
  3. package/lib/adapter/cypress-plugin/index.js +1 -0
  4. package/lib/adapter/e2e.d.ts +49 -0
  5. package/lib/adapter/e2e.js +254 -0
  6. package/lib/adapter/jasmine.js +1 -0
  7. package/lib/adapter/jest.js +1 -0
  8. package/lib/adapter/mocha.d.ts +4 -1
  9. package/lib/adapter/mocha.js +123 -122
  10. package/lib/adapter/nightwatch.js +1 -0
  11. package/lib/adapter/playwright.js +33 -1
  12. package/lib/adapter/vitest.js +5 -1
  13. package/lib/adapter/webdriver.js +45 -2
  14. package/lib/allureReader.d.ts +4 -1
  15. package/lib/allureReader.js +2 -1
  16. package/lib/bin/cli.js +212 -19
  17. package/lib/client.d.ts +1 -0
  18. package/lib/client.js +12 -3
  19. package/lib/data-storage.js +4 -3
  20. package/lib/fetch/report.d.ts +31 -0
  21. package/lib/fetch/report.js +81 -0
  22. package/lib/fetch/testruns.d.ts +133 -0
  23. package/lib/fetch/testruns.js +286 -0
  24. package/lib/pipe/markdown.d.ts +1 -0
  25. package/lib/pipe/markdown.js +7 -0
  26. package/lib/pipe/testomatio.js +17 -1
  27. package/lib/replay.js +1 -0
  28. package/lib/reporter-functions.js +39 -0
  29. package/lib/uploader.d.ts +17 -0
  30. package/lib/uploader.js +75 -1
  31. package/lib/utils/detectRunner.d.ts +23 -0
  32. package/lib/utils/detectRunner.js +149 -0
  33. package/lib/utils/pipe_utils.d.ts +16 -3
  34. package/lib/utils/pipe_utils.js +45 -4
  35. package/lib/xmlReader.js +2 -0
  36. package/package.json +8 -3
  37. package/src/adapter/codecept.js +24 -2
  38. package/src/adapter/cucumber/current.js +2 -0
  39. package/src/adapter/cypress-plugin/index.js +2 -0
  40. package/src/adapter/e2e.js +248 -0
  41. package/src/adapter/jasmine.js +2 -0
  42. package/src/adapter/jest.js +2 -0
  43. package/src/adapter/mocha.js +146 -145
  44. package/src/adapter/nightwatch.js +2 -0
  45. package/src/adapter/playwright.js +33 -1
  46. package/src/adapter/vitest.js +6 -1
  47. package/src/adapter/webdriver.js +46 -2
  48. package/src/allureReader.js +2 -1
  49. package/src/bin/cli.js +246 -18
  50. package/src/client.js +11 -3
  51. package/src/data-storage.js +4 -3
  52. package/src/fetch/report.js +86 -0
  53. package/src/fetch/testruns.js +283 -0
  54. package/src/pipe/markdown.js +7 -0
  55. package/src/pipe/testomatio.js +27 -1
  56. package/src/replay.js +1 -0
  57. package/src/reporter-functions.js +41 -0
  58. package/src/uploader.js +81 -1
  59. package/src/utils/detectRunner.js +151 -0
  60. package/src/utils/pipe_utils.js +45 -4
  61. package/src/xmlReader.js +3 -0
  62. package/types/types.d.ts +1 -0
package/src/bin/cli.js CHANGED
@@ -9,6 +9,7 @@ import XmlReader from '../xmlReader.js';
9
9
  import AllureReader from '../allureReader.js';
10
10
  import { APP_PREFIX, STATUS, DEBUG_FILE, BATCH_MODE } from '../constants.js';
11
11
  import { cleanLatestRunId, getPackageVersion, applyFilter, transformEnvVarToBoolean } from '../utils/utils.js';
12
+ import { isPlaceholderCommand, runnerHint, buildFilterWarning } from '../utils/detectRunner.js';
12
13
  import { config } from '../config.js';
13
14
  import { readLatestRunId } from '../utils/utils.js';
14
15
  import pc from 'picocolors';
@@ -16,9 +17,21 @@ import { filesize as prettyBytes } from 'filesize';
16
17
  import dotenv from 'dotenv';
17
18
  import Replay from '../replay.js';
18
19
  import { log } from '../utils/log.js';
19
- import { formatFilterListIds, formatRunOutput, formatFetchRunsOutput } from '../utils/pipe_utils.js';
20
+ import { formatFilterListIds, formatRunOutput, formatFetchRunsOutput, parseRunsUrl } from '../utils/pipe_utils.js';
20
21
  import fs from 'fs';
21
22
  import path from 'path';
23
+ import {
24
+ RunFetcher,
25
+ resolveRunTarget,
26
+ parseStatuses,
27
+ parseTestrunsLimit,
28
+ downloadArtifacts,
29
+ defaultArtifactsDir,
30
+ TESTRUNS_DEFAULT_LIMIT,
31
+ TESTRUNS_MAX_LIMIT,
32
+ TESTRUN_STATUSES,
33
+ } from '../fetch/testruns.js';
34
+ import { renderRunReport } from '../fetch/report.js';
22
35
 
23
36
  const debug = createDebugMessages('@testomatio/reporter:cli');
24
37
  const version = getPackageVersion();
@@ -42,8 +55,12 @@ program
42
55
  // remaining output to stderr, skip the banner, and silence info-level logs so
43
56
  // stdout stays clean for capture (e.g. RUN_ID=$(... start --format id)).
44
57
  // Set TESTOMATIO_LOG_LEVEL=INFO to re-enable progress logs for debugging.
45
- const subOpts = actionCommand.opts();
46
- if (subOpts.filterList || subOpts.format) {
58
+ const subOpts = actionCommand.optsWithGlobals();
59
+ // fetch status|count|report print their result on stdout, so the rest goes to stderr too
60
+ const isFetchSubcommand = actionCommand.parent?.name() === 'fetch';
61
+ if (isFetchSubcommand && !subOpts.format) {
62
+ process.env.TESTOMATIO_LOG_STDERR = '1';
63
+ } else if (subOpts.filterList || subOpts.format) {
47
64
  process.env.TESTOMATIO_LOG_STDERR = '1';
48
65
  process.env.TESTOMATIO_LOG_LEVEL ||= 'WARN';
49
66
  // with --format json the logs are machine-readable too: one JSON object per line on stderr
@@ -59,9 +76,10 @@ program
59
76
  .option('--kind <type>', 'Specify run type: automated, manual, mixed, or detect')
60
77
  .option('--filter <filter>', 'Scope the prepared run to tests matching the filter (no execution)')
61
78
  .option('--format <format>', 'Machine-readable output: the run id (--format id) or run details (--format json)')
62
- .option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only)')
79
+ .option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only); env: TESTOMATIO_WARN')
63
80
  .action(async opts => {
64
81
  cleanLatestRunId();
82
+ opts.warn ||= transformEnvVarToBoolean(process.env.TESTOMATIO_WARN);
65
83
 
66
84
  log.info('Starting a new Run on Testomat.io...');
67
85
  const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config.TESTOMATIO;
@@ -127,44 +145,63 @@ program
127
145
  });
128
146
  });
129
147
 
130
- program
148
+ const fetchCommand = program
131
149
  .command('fetch')
132
150
  .description('Fetch runs from Testomat.io API v2')
151
+ .argument('[url]', 'URL of a run or of a (filtered) runs page in Testomat.io')
133
152
  .option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
153
+ .option('--run <id>', 'Fetch a single run by its id')
134
154
  .option('--title <text>', 'Filter by run title')
135
155
  .option('--tql <query>', 'Filter using Testomat Query Language')
136
156
  .option('--rungroup <uid>', 'Filter by rungroup id')
157
+ // no default value: fetch parses --limit for its subcommands too, see createRunFetcher
137
158
  .option(
138
159
  '--limit <number>',
139
- `Max number of runs to return (max ${FETCH_RUNS_MAX_LIMIT})`,
140
- String(FETCH_RUNS_DEFAULT_LIMIT),
160
+ `Max number of runs to return (default ${FETCH_RUNS_DEFAULT_LIMIT}, max ${FETCH_RUNS_MAX_LIMIT})`,
141
161
  )
142
162
  .option('--latest', 'Only fetch the most recent run (shorthand for --limit 1)')
143
163
  .option(
144
164
  '--format <format>',
145
165
  'Machine-readable output: run ids, one per line (--format id) or run details (--format json)',
146
166
  )
147
- .action(async opts => {
167
+ .action(async (pageUrl, opts) => {
148
168
  const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config.TESTOMATIO;
149
169
  if (!apiKey) {
150
170
  log.error(pc.red('⚠️ TESTOMATIO API key required'));
151
171
  process.exit(1);
152
172
  }
153
173
 
154
- const project = opts.project || process.env.TESTOMATIO_PROJECT;
174
+ let page = {};
175
+ if (pageUrl) {
176
+ page = parseRunsUrl(pageUrl);
177
+ if (!page) {
178
+ log.error(pc.red(`⚠️ Not a Testomat.io run or runs page URL: ${pageUrl}`));
179
+ process.exit(1);
180
+ }
181
+ }
182
+
183
+ const project = opts.project || page.project || process.env.TESTOMATIO_PROJECT;
155
184
  if (!project) {
156
185
  log.error(pc.red('⚠️ --project (or TESTOMATIO_PROJECT) is required'));
157
186
  process.exit(1);
158
187
  }
159
188
 
160
- const baseUrl = process.env.TESTOMATIO_URL || 'https://app.testomat.io';
161
- const url = new URL(`/api/v2/${project}/runs`, baseUrl);
162
- if (opts.title) url.searchParams.set('search', opts.title);
163
- if (opts.tql) url.searchParams.set('tql', opts.tql);
164
- if (opts.rungroup) url.searchParams.set('groupId', opts.rungroup);
165
- const requestedLimit = opts.latest ? 1 : parseInt(opts.limit, 10) || FETCH_RUNS_DEFAULT_LIMIT;
166
- const limit = Math.min(Math.max(requestedLimit, 1), FETCH_RUNS_MAX_LIMIT);
167
- url.searchParams.set('per_page', String(limit));
189
+ const baseUrl = page.baseUrl || process.env.TESTOMATIO_URL || 'https://app.testomat.io';
190
+ const runId = opts.run || page.runId;
191
+ let url;
192
+
193
+ if (runId) {
194
+ url = new URL(`/api/v2/${project}/runs/${runId}`, baseUrl);
195
+ } else {
196
+ url = new URL(`/api/v2/${project}/runs`, baseUrl);
197
+ for (const [key, value] of page.params || []) url.searchParams.append(key, value);
198
+ if (opts.title) url.searchParams.set('search', opts.title);
199
+ if (opts.tql) url.searchParams.set('tql', opts.tql);
200
+ if (opts.rungroup) url.searchParams.set('groupId', opts.rungroup);
201
+ const requestedLimit = opts.latest ? 1 : parseInt(opts.limit, 10) || FETCH_RUNS_DEFAULT_LIMIT;
202
+ const limit = Math.min(Math.max(requestedLimit, 1), FETCH_RUNS_MAX_LIMIT);
203
+ url.searchParams.set('per_page', String(limit));
204
+ }
168
205
 
169
206
  const response = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
170
207
  const body = await response.json();
@@ -177,6 +214,166 @@ program
177
214
  console.log(formatFetchRunsOutput(body, opts.format));
178
215
  });
179
216
 
217
+ // fetch parses the options it shares with its subcommands (--project, --format, --limit) wherever they are placed,
218
+ // so subcommands declare them only for help and read them with optsWithGlobals()
219
+
220
+ /**
221
+ * Build a RunFetcher for the run argument of fetch status|count|report, or exit with an error.
222
+ *
223
+ * @param {string} runArg
224
+ * @param {{project?: string}} opts
225
+ * @returns {RunFetcher}
226
+ */
227
+ function createRunFetcher(runArg, opts) {
228
+ const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config.TESTOMATIO;
229
+ if (!apiKey) {
230
+ log.error(pc.red('⚠️ TESTOMATIO API key required'));
231
+ process.exit(1);
232
+ }
233
+
234
+ try {
235
+ return new RunFetcher({ ...resolveRunTarget(runArg, opts), apiKey });
236
+ } catch (err) {
237
+ log.error(pc.red(`⚠️ ${err.message}`));
238
+ process.exit(1);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * @param {{only?: string, onlyFailed?: boolean}} opts
244
+ * @returns {string[]}
245
+ */
246
+ function getStatusFilter(opts) {
247
+ try {
248
+ return parseStatuses(opts);
249
+ } catch (err) {
250
+ log.error(pc.red(`⚠️ ${err.message}`));
251
+ process.exit(1);
252
+ }
253
+ }
254
+
255
+ /**
256
+ * @param {Error} err
257
+ * @returns {never}
258
+ */
259
+ function exitOnFetchError(err) {
260
+ log.error(pc.red(`⚠️ Failed to fetch: ${err.message}`));
261
+ process.exit(1);
262
+ }
263
+
264
+ const RUN_ARGUMENT_DESCRIPTION = 'Run id, or URL of a run in Testomat.io';
265
+ const STATUS_FILTER_DESCRIPTION = `Only testruns with these statuses, comma-separated (${TESTRUN_STATUSES.join(', ')})`;
266
+
267
+ fetchCommand
268
+ .command('status')
269
+ .description('Show a run with the number of its testruns by status')
270
+ .argument('<run>', RUN_ARGUMENT_DESCRIPTION)
271
+ .option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
272
+ .option('--format <format>', 'Machine-readable output: run status and counts as JSON (--format json)')
273
+ .action(async (runArg, _opts, command) => {
274
+ const opts = command.optsWithGlobals();
275
+ const fetcher = createRunFetcher(runArg, opts);
276
+
277
+ const [run, summary] = await Promise.all([fetcher.fetchRun(), fetcher.countTestrunsByStatus()]).catch(
278
+ exitOnFetchError,
279
+ );
280
+ const counts = Object.fromEntries(TESTRUN_STATUSES.map(status => [status, summary.byStatus[status] || 0]));
281
+
282
+ if (opts.format === 'json') {
283
+ const data = { id: run.id, title: run.title, status: run.status, url: fetcher.runUrl, total: summary.total };
284
+ console.log(JSON.stringify({ ...data, ...counts }, null, 2));
285
+ return;
286
+ }
287
+
288
+ const lines = [`run: ${run.title}`, `status: ${run.status}`, `url: ${fetcher.runUrl}`, `total: ${summary.total}`];
289
+ for (const [status, count] of Object.entries(counts)) lines.push(`${status}: ${count}`);
290
+ console.log(lines.join('\n'));
291
+ });
292
+
293
+ fetchCommand
294
+ .command('count')
295
+ .description('Print the number of testruns of a run, to pick --limit and filters for fetch report')
296
+ .argument('<run>', RUN_ARGUMENT_DESCRIPTION)
297
+ .option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
298
+ .option('--only <statuses>', STATUS_FILTER_DESCRIPTION)
299
+ .option('--only-failed', 'Only failed testruns (shorthand for --only failed)')
300
+ .action(async (runArg, _opts, command) => {
301
+ const opts = command.optsWithGlobals();
302
+ const fetcher = createRunFetcher(runArg, opts);
303
+ const statuses = getStatusFilter(opts);
304
+
305
+ const total = await fetcher.countTestruns(statuses).catch(exitOnFetchError);
306
+ console.log(total);
307
+ });
308
+
309
+ fetchCommand
310
+ .command('report')
311
+ .description('Print a run with its testruns as a Markdown report')
312
+ .argument('<run>', RUN_ARGUMENT_DESCRIPTION)
313
+ .option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
314
+ .option('--only <statuses>', STATUS_FILTER_DESCRIPTION)
315
+ .option('--only-failed', 'Only failed testruns (shorthand for --only failed)')
316
+ .option('--stack', 'Include stack traces')
317
+ .option('--artifacts', 'Download artifacts to --out and link them from the report')
318
+ .option('--out <dir>', 'Directory for downloaded artifacts (default: ./<run-title>-<run-id>)')
319
+ .option(
320
+ '--limit <number>',
321
+ `Max number of testruns, failed first (default ${TESTRUNS_DEFAULT_LIMIT}, max ${TESTRUNS_MAX_LIMIT})`,
322
+ )
323
+ .action(async (runArg, _opts, command) => {
324
+ const opts = command.optsWithGlobals();
325
+ const fetcher = createRunFetcher(runArg, opts);
326
+ const statuses = getStatusFilter(opts);
327
+ const { limit, capped } = parseTestrunsLimit(opts.limit);
328
+ if (capped) log.warn(pc.yellow(`⚠️ --limit is capped at ${TESTRUNS_MAX_LIMIT}`));
329
+
330
+ const run = await fetcher.fetchRun().catch(exitOnFetchError);
331
+
332
+ if (opts.artifacts) {
333
+ const hasSharedCredentials = await fetcher.applySharedS3Credentials(run);
334
+ if (!hasSharedCredentials && !process.env.S3_BUCKET) {
335
+ log.error(pc.red('⚠️ --artifacts needs S3 access: share S3 credentials in project settings or set S3_BUCKET'));
336
+ process.exit(1);
337
+ }
338
+ }
339
+
340
+ const [summary, list] = await Promise.all([
341
+ fetcher.countTestrunsByStatus(),
342
+ fetcher.listTestruns({ statuses, limit }),
343
+ ]).catch(exitOnFetchError);
344
+
345
+ let { testruns } = list;
346
+ if (list.total > testruns.length) {
347
+ log.warn(
348
+ pc.yellow(
349
+ `⚠️ Showing ${testruns.length} of ${list.total} testruns. Use --limit or --only-failed to narrow it.`,
350
+ ),
351
+ );
352
+ }
353
+
354
+ if (opts.stack || opts.artifacts) {
355
+ testruns = await fetcher.addTestrunDetails(testruns, (testrun, err) => {
356
+ log.warn(pc.yellow(`⚠️ Failed to fetch details of testrun ${testrun.id}: ${err.message}`));
357
+ });
358
+ if (!opts.stack) testruns = testruns.map(({ stack, ...testrun }) => testrun);
359
+ }
360
+
361
+ if (opts.artifacts) {
362
+ const outDir = opts.out || defaultArtifactsDir(run, fetcher.runId);
363
+ const artifactsCount = testruns.reduce((sum, testrun) => sum + (testrun.artifacts?.length || 0), 0);
364
+ log.info(`Downloading ${artifactsCount} artifacts to ${outDir}`);
365
+ testruns = await downloadArtifacts(testruns, outDir, (url, err) => {
366
+ log.warn(pc.yellow(`⚠️ Failed to download ${url}: ${err.message}`));
367
+ });
368
+ } else {
369
+ testruns = testruns.map(({ artifacts, ...testrun }) => testrun);
370
+ }
371
+
372
+ process.stdout.write(
373
+ renderRunReport({ run, runUrl: fetcher.runUrl, summary, testruns, matched: list.total, statuses }),
374
+ );
375
+ });
376
+
180
377
  program
181
378
  .command('run')
182
379
  .alias('test')
@@ -196,8 +393,9 @@ program
196
393
  (value, prev) => prev.concat([value]),
197
394
  [],
198
395
  )
199
- .option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only)')
396
+ .option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only); env: TESTOMATIO_WARN')
200
397
  .action(async (command, opts) => {
398
+ opts.warn ||= transformEnvVarToBoolean(process.env.TESTOMATIO_WARN);
201
399
  if (opts.remote) {
202
400
  if (opts.filterList) {
203
401
  log.warn(pc.red('⚠️ --filter-list cannot be combined with --remote'));
@@ -213,6 +411,8 @@ program
213
411
  const title = process.env.TESTOMATIO_TITLE;
214
412
  const client = new TestomatClient({ apiKey, title });
215
413
 
414
+ let filterApplied = false;
415
+
216
416
  if (opts.filter || opts.filterList) {
217
417
  log.info('Filtering tests...');
218
418
  // Example of use: npx @testomatio/reporter run "npx jest" --filter "testomatio:tag-name=frontend"
@@ -250,6 +450,7 @@ program
250
450
 
251
451
  if (command && command.split && !opts.remote) {
252
452
  command = applyFilter(command, tests);
453
+ filterApplied = true;
253
454
  }
254
455
  }
255
456
  catch (err) {
@@ -326,6 +527,14 @@ program
326
527
  return process.exit(0);
327
528
  }
328
529
 
530
+ if (isPlaceholderCommand(command)) {
531
+ log.error(pc.red(`⚠️ The command "${command}" looks like a placeholder.`));
532
+ log.error('The example command from Testomat.io must be edited:');
533
+ log.error('replace the placeholder with your actual test runner command.');
534
+ log.error(runnerHint());
535
+ process.exit(1);
536
+ }
537
+
329
538
  log.info( `🚀 Running`, pc.green(command));
330
539
 
331
540
  const runTests = async () => {
@@ -335,9 +544,28 @@ program
335
544
  env: { ...process.env, TESTOMATIO_PROCEED: 'true', runId: client.runId, TESTOMATIO_RUN: client.runId },
336
545
  });
337
546
 
547
+ let errored = false;
548
+
549
+ cmd.on('error', err => {
550
+ errored = true;
551
+ const spawnError = /** @type {NodeJS.ErrnoException} */ (err);
552
+ if (spawnError.code === 'ENOENT') {
553
+ log.error(pc.red(`🔴 Command not found: ${pc.bold(testCmds[0])}`));
554
+ log.error(runnerHint());
555
+ } else {
556
+ log.error(pc.red(`🔴 Failed to start command: ${spawnError.message}`));
557
+ }
558
+ const finish = apiKey ? client.updateRunStatus('failed').catch(() => {}) : Promise.resolve();
559
+ finish.then(() => process.exit(1));
560
+ });
561
+
338
562
  cmd.on('close', async code => {
563
+ if (errored) return;
339
564
  const emoji = code === 0 ? '🟢' : '🔴';
340
565
  log.info( emoji, `Runner exited with ${pc.bold(code)}`);
566
+ if (code !== 0 && filterApplied) {
567
+ buildFilterWarning(command).forEach(line => log.warn(line));
568
+ }
341
569
  if (apiKey) {
342
570
  const status = code === 0 ? 'passed' : 'failed';
343
571
  await client.updateRunStatus(status);
package/src/client.js CHANGED
@@ -36,6 +36,8 @@ class Client {
36
36
  this.pipeStore = {};
37
37
  this.runId = '';
38
38
  this.queue = Promise.resolve();
39
+ // adapter name is set by the adapter module itself on load (global.TESTOMATIO_ADAPTER)
40
+ this.adapterName = params.adapter || global.TESTOMATIO_ADAPTER;
39
41
 
40
42
  // @ts-ignore this line will be removed in compiled code, because __dirname is defined in commonjs
41
43
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -244,7 +246,9 @@ class Client {
244
246
  overwrite,
245
247
  tags,
246
248
  } = testData;
247
- let { files = [], manuallyAttachedArtifacts, message = '', meta = {} } = testData;
249
+ let { files = [], manuallyAttachedArtifacts, message = '' } = testData;
250
+ /** @type {Record<string, any>} */
251
+ let meta = testData.meta || {};
248
252
 
249
253
  if (stepArtifactPaths.size) {
250
254
  const isStepArtifact = item => stepArtifactPaths.has(typeof item === 'object' ? item?.path : item);
@@ -261,6 +265,10 @@ class Client {
261
265
  return acc;
262
266
  }, {});
263
267
 
268
+ // meta must always include reporter version and adapter used
269
+ if (this.version) meta.reporterVersion = this.version;
270
+ if (this.adapterName) meta.adapter = this.adapterName;
271
+
264
272
  const testContext = suite_title ? `${suite_title} ${title}` : title;
265
273
 
266
274
  let errorFormatted = '';
@@ -389,8 +397,8 @@ class Client {
389
397
  this.queue = this.queue
390
398
  .then(() => Promise.all(this.pipes.map(p => p.finishRun(runParams))))
391
399
  .then(() => {
392
- if (!this.uploader.isEnabled) return;
393
-
400
+ // no early return when uploading is disabled:
401
+ // skipped artifacts (e.g. no S3 credentials obtained) must still be reported to the user
394
402
  const filesizeStrMaxLength = 7;
395
403
 
396
404
  if (this.uploader.successfulUploads.length) {
@@ -190,9 +190,10 @@ class DataStorage {
190
190
  }
191
191
 
192
192
  function stringToMD5Hash(str) {
193
- const md5 = crypto.createHash('md5');
194
- md5.update(str);
195
- const hash = md5.digest('hex');
193
+ // sha256 instead of md5 for FIPS-enabled hosts compatibility
194
+ const sha256 = crypto.createHash('sha256');
195
+ sha256.update(str);
196
+ const hash = sha256.digest('hex');
196
197
  return `${process.env.runId || 'run'}_${hash}`;
197
198
  }
198
199
 
@@ -0,0 +1,86 @@
1
+ import { renderTests } from '../pipe/markdown.js';
2
+
3
+ const TITLE_SEPARATOR = ' ⇒ ';
4
+
5
+ /**
6
+ * Map a testrun of API v2 to the test shape of the Markdown pipe.
7
+ * Without a suite, the API returns the title as "<suite> ⇒ <title>".
8
+ *
9
+ * @param {object} testrun
10
+ * @returns {object}
11
+ */
12
+ function toReportTest(testrun) {
13
+ let title = String(testrun.test_title || '').trim();
14
+ let suite = testrun.suiteTitle;
15
+
16
+ if (!suite && title.includes(TITLE_SEPARATOR)) {
17
+ const [suitePart, ...titleParts] = title.split(TITLE_SEPARATOR);
18
+ suite = suitePart.trim();
19
+ title = titleParts.join(TITLE_SEPARATOR).trim();
20
+ }
21
+
22
+ return {
23
+ title,
24
+ suite_title: suite,
25
+ status: testrun.status,
26
+ run_time: testrun.run_time,
27
+ test_id: testrun.testId,
28
+ message: testrun.message,
29
+ stack: testrun.stack,
30
+ artifacts: testrun.artifacts,
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Render a fetched run with its testruns as a Markdown report.
36
+ *
37
+ * @param {object} params
38
+ * @param {object} params.run - Run from GET /api/v2/:project/runs/:id
39
+ * @param {string} params.runUrl
40
+ * @param {{total: number, byStatus: Record<string, number>}} params.summary - Counts of all testruns of the run
41
+ * @param {object[]} params.testruns
42
+ * @param {number} params.matched - Number of testruns matching the filter
43
+ * @param {string[]} params.statuses - Status filter, empty for all testruns
44
+ * @returns {string}
45
+ */
46
+ function renderRunReport({ run, runUrl, summary, testruns, matched, statuses }) {
47
+ const status = String(run.status || 'unknown');
48
+ const sections = [`# ${run.title || run.id} — ${status.toUpperCase()}`];
49
+
50
+ const rows = [
51
+ ['Run ID', `\`${run.id}\``],
52
+ ['Run URL', `<${runUrl}>`],
53
+ ['Status', status],
54
+ ];
55
+ if (run.env) rows.push(['Environment', run.env]);
56
+ if (run.launched_at) rows.push(['Started', run.launched_at]);
57
+ if (run.finished_at) rows.push(['Finished', run.finished_at]);
58
+
59
+ let shown = `${testruns.length} of ${matched} shown`;
60
+ if (statuses.length) shown += ` (only: ${statuses.join(', ')})`;
61
+ rows.push(['Testruns', shown]);
62
+
63
+ sections.push(
64
+ ['| Key | Value |', '| --- | ----- |', ...rows.map(([key, value]) => `| ${key} | ${value} |`)].join('\n'),
65
+ );
66
+
67
+ if (run.description?.trim()) sections.push(`## Description\n\n${run.description.trim()}`);
68
+
69
+ const counts = Object.entries(summary.byStatus).sort(([, a], [, b]) => b - a);
70
+ sections.push(
71
+ [
72
+ '## Summary',
73
+ '',
74
+ '| Status | Count |',
75
+ '| ------ | ----- |',
76
+ ...counts.map(([name, count]) => `| ${name} | ${count} |`),
77
+ `| total | ${summary.total} |`,
78
+ ].join('\n'),
79
+ );
80
+
81
+ sections.push(renderTests(testruns.map(toReportTest)));
82
+
83
+ return `${sections.join('\n\n')}\n`;
84
+ }
85
+
86
+ export { renderRunReport, toReportTest };