@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/lib/bin/cli.js CHANGED
@@ -13,6 +13,7 @@ const xmlReader_js_1 = __importDefault(require("../xmlReader.js"));
13
13
  const allureReader_js_1 = __importDefault(require("../allureReader.js"));
14
14
  const constants_js_1 = require("../constants.js");
15
15
  const utils_js_1 = require("../utils/utils.js");
16
+ const detectRunner_js_1 = require("../utils/detectRunner.js");
16
17
  const config_js_1 = require("../config.js");
17
18
  const utils_js_2 = require("../utils/utils.js");
18
19
  const picocolors_1 = __importDefault(require("picocolors"));
@@ -23,6 +24,8 @@ const log_js_1 = require("../utils/log.js");
23
24
  const pipe_utils_js_1 = require("../utils/pipe_utils.js");
24
25
  const fs_1 = __importDefault(require("fs"));
25
26
  const path_1 = __importDefault(require("path"));
27
+ const testruns_js_1 = require("../fetch/testruns.js");
28
+ const report_js_1 = require("../fetch/report.js");
26
29
  const debug = (0, debug_1.default)('@testomatio/reporter:cli');
27
30
  const version = (0, utils_js_1.getPackageVersion)();
28
31
  const program = new commander_1.Command();
@@ -43,8 +46,13 @@ program
43
46
  // remaining output to stderr, skip the banner, and silence info-level logs so
44
47
  // stdout stays clean for capture (e.g. RUN_ID=$(... start --format id)).
45
48
  // Set TESTOMATIO_LOG_LEVEL=INFO to re-enable progress logs for debugging.
46
- const subOpts = actionCommand.opts();
47
- if (subOpts.filterList || subOpts.format) {
49
+ const subOpts = actionCommand.optsWithGlobals();
50
+ // fetch status|count|report print their result on stdout, so the rest goes to stderr too
51
+ const isFetchSubcommand = actionCommand.parent?.name() === 'fetch';
52
+ if (isFetchSubcommand && !subOpts.format) {
53
+ process.env.TESTOMATIO_LOG_STDERR = '1';
54
+ }
55
+ else if (subOpts.filterList || subOpts.format) {
48
56
  process.env.TESTOMATIO_LOG_STDERR = '1';
49
57
  process.env.TESTOMATIO_LOG_LEVEL ||= 'WARN';
50
58
  // with --format json the logs are machine-readable too: one JSON object per line on stderr
@@ -61,9 +69,10 @@ program
61
69
  .option('--kind <type>', 'Specify run type: automated, manual, mixed, or detect')
62
70
  .option('--filter <filter>', 'Scope the prepared run to tests matching the filter (no execution)')
63
71
  .option('--format <format>', 'Machine-readable output: the run id (--format id) or run details (--format json)')
64
- .option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only)')
72
+ .option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only); env: TESTOMATIO_WARN')
65
73
  .action(async (opts) => {
66
74
  (0, utils_js_1.cleanLatestRunId)();
75
+ opts.warn ||= (0, utils_js_1.transformEnvVarToBoolean)(process.env.TESTOMATIO_WARN);
67
76
  log_js_1.log.info('Starting a new Run on Testomat.io...');
68
77
  const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config_js_1.config.TESTOMATIO;
69
78
  const client = new client_js_1.default({ apiKey });
@@ -118,38 +127,58 @@ program
118
127
  process.exit(0);
119
128
  });
120
129
  });
121
- program
130
+ const fetchCommand = program
122
131
  .command('fetch')
123
132
  .description('Fetch runs from Testomat.io API v2')
133
+ .argument('[url]', 'URL of a run or of a (filtered) runs page in Testomat.io')
124
134
  .option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
135
+ .option('--run <id>', 'Fetch a single run by its id')
125
136
  .option('--title <text>', 'Filter by run title')
126
137
  .option('--tql <query>', 'Filter using Testomat Query Language')
127
138
  .option('--rungroup <uid>', 'Filter by rungroup id')
128
- .option('--limit <number>', `Max number of runs to return (max ${FETCH_RUNS_MAX_LIMIT})`, String(FETCH_RUNS_DEFAULT_LIMIT))
139
+ // no default value: fetch parses --limit for its subcommands too, see createRunFetcher
140
+ .option('--limit <number>', `Max number of runs to return (default ${FETCH_RUNS_DEFAULT_LIMIT}, max ${FETCH_RUNS_MAX_LIMIT})`)
129
141
  .option('--latest', 'Only fetch the most recent run (shorthand for --limit 1)')
130
142
  .option('--format <format>', 'Machine-readable output: run ids, one per line (--format id) or run details (--format json)')
131
- .action(async (opts) => {
143
+ .action(async (pageUrl, opts) => {
132
144
  const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config_js_1.config.TESTOMATIO;
133
145
  if (!apiKey) {
134
146
  log_js_1.log.error(picocolors_1.default.red('⚠️ TESTOMATIO API key required'));
135
147
  process.exit(1);
136
148
  }
137
- const project = opts.project || process.env.TESTOMATIO_PROJECT;
149
+ let page = {};
150
+ if (pageUrl) {
151
+ page = (0, pipe_utils_js_1.parseRunsUrl)(pageUrl);
152
+ if (!page) {
153
+ log_js_1.log.error(picocolors_1.default.red(`⚠️ Not a Testomat.io run or runs page URL: ${pageUrl}`));
154
+ process.exit(1);
155
+ }
156
+ }
157
+ const project = opts.project || page.project || process.env.TESTOMATIO_PROJECT;
138
158
  if (!project) {
139
159
  log_js_1.log.error(picocolors_1.default.red('⚠️ --project (or TESTOMATIO_PROJECT) is required'));
140
160
  process.exit(1);
141
161
  }
142
- const baseUrl = process.env.TESTOMATIO_URL || 'https://app.testomat.io';
143
- const url = new URL(`/api/v2/${project}/runs`, baseUrl);
144
- if (opts.title)
145
- url.searchParams.set('search', opts.title);
146
- if (opts.tql)
147
- url.searchParams.set('tql', opts.tql);
148
- if (opts.rungroup)
149
- url.searchParams.set('groupId', opts.rungroup);
150
- const requestedLimit = opts.latest ? 1 : parseInt(opts.limit, 10) || FETCH_RUNS_DEFAULT_LIMIT;
151
- const limit = Math.min(Math.max(requestedLimit, 1), FETCH_RUNS_MAX_LIMIT);
152
- url.searchParams.set('per_page', String(limit));
162
+ const baseUrl = page.baseUrl || process.env.TESTOMATIO_URL || 'https://app.testomat.io';
163
+ const runId = opts.run || page.runId;
164
+ let url;
165
+ if (runId) {
166
+ url = new URL(`/api/v2/${project}/runs/${runId}`, baseUrl);
167
+ }
168
+ else {
169
+ url = new URL(`/api/v2/${project}/runs`, baseUrl);
170
+ for (const [key, value] of page.params || [])
171
+ url.searchParams.append(key, value);
172
+ if (opts.title)
173
+ url.searchParams.set('search', opts.title);
174
+ if (opts.tql)
175
+ url.searchParams.set('tql', opts.tql);
176
+ if (opts.rungroup)
177
+ url.searchParams.set('groupId', opts.rungroup);
178
+ const requestedLimit = opts.latest ? 1 : parseInt(opts.limit, 10) || FETCH_RUNS_DEFAULT_LIMIT;
179
+ const limit = Math.min(Math.max(requestedLimit, 1), FETCH_RUNS_MAX_LIMIT);
180
+ url.searchParams.set('per_page', String(limit));
181
+ }
153
182
  const response = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
154
183
  const body = await response.json();
155
184
  if (!response.ok) {
@@ -158,6 +187,141 @@ program
158
187
  }
159
188
  console.log((0, pipe_utils_js_1.formatFetchRunsOutput)(body, opts.format));
160
189
  });
190
+ // fetch parses the options it shares with its subcommands (--project, --format, --limit) wherever they are placed,
191
+ // so subcommands declare them only for help and read them with optsWithGlobals()
192
+ /**
193
+ * Build a RunFetcher for the run argument of fetch status|count|report, or exit with an error.
194
+ *
195
+ * @param {string} runArg
196
+ * @param {{project?: string}} opts
197
+ * @returns {RunFetcher}
198
+ */
199
+ function createRunFetcher(runArg, opts) {
200
+ const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config_js_1.config.TESTOMATIO;
201
+ if (!apiKey) {
202
+ log_js_1.log.error(picocolors_1.default.red('⚠️ TESTOMATIO API key required'));
203
+ process.exit(1);
204
+ }
205
+ try {
206
+ return new testruns_js_1.RunFetcher({ ...(0, testruns_js_1.resolveRunTarget)(runArg, opts), apiKey });
207
+ }
208
+ catch (err) {
209
+ log_js_1.log.error(picocolors_1.default.red(`⚠️ ${err.message}`));
210
+ process.exit(1);
211
+ }
212
+ }
213
+ /**
214
+ * @param {{only?: string, onlyFailed?: boolean}} opts
215
+ * @returns {string[]}
216
+ */
217
+ function getStatusFilter(opts) {
218
+ try {
219
+ return (0, testruns_js_1.parseStatuses)(opts);
220
+ }
221
+ catch (err) {
222
+ log_js_1.log.error(picocolors_1.default.red(`⚠️ ${err.message}`));
223
+ process.exit(1);
224
+ }
225
+ }
226
+ /**
227
+ * @param {Error} err
228
+ * @returns {never}
229
+ */
230
+ function exitOnFetchError(err) {
231
+ log_js_1.log.error(picocolors_1.default.red(`⚠️ Failed to fetch: ${err.message}`));
232
+ process.exit(1);
233
+ }
234
+ const RUN_ARGUMENT_DESCRIPTION = 'Run id, or URL of a run in Testomat.io';
235
+ const STATUS_FILTER_DESCRIPTION = `Only testruns with these statuses, comma-separated (${testruns_js_1.TESTRUN_STATUSES.join(', ')})`;
236
+ fetchCommand
237
+ .command('status')
238
+ .description('Show a run with the number of its testruns by status')
239
+ .argument('<run>', RUN_ARGUMENT_DESCRIPTION)
240
+ .option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
241
+ .option('--format <format>', 'Machine-readable output: run status and counts as JSON (--format json)')
242
+ .action(async (runArg, _opts, command) => {
243
+ const opts = command.optsWithGlobals();
244
+ const fetcher = createRunFetcher(runArg, opts);
245
+ const [run, summary] = await Promise.all([fetcher.fetchRun(), fetcher.countTestrunsByStatus()]).catch(exitOnFetchError);
246
+ const counts = Object.fromEntries(testruns_js_1.TESTRUN_STATUSES.map(status => [status, summary.byStatus[status] || 0]));
247
+ if (opts.format === 'json') {
248
+ const data = { id: run.id, title: run.title, status: run.status, url: fetcher.runUrl, total: summary.total };
249
+ console.log(JSON.stringify({ ...data, ...counts }, null, 2));
250
+ return;
251
+ }
252
+ const lines = [`run: ${run.title}`, `status: ${run.status}`, `url: ${fetcher.runUrl}`, `total: ${summary.total}`];
253
+ for (const [status, count] of Object.entries(counts))
254
+ lines.push(`${status}: ${count}`);
255
+ console.log(lines.join('\n'));
256
+ });
257
+ fetchCommand
258
+ .command('count')
259
+ .description('Print the number of testruns of a run, to pick --limit and filters for fetch report')
260
+ .argument('<run>', RUN_ARGUMENT_DESCRIPTION)
261
+ .option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
262
+ .option('--only <statuses>', STATUS_FILTER_DESCRIPTION)
263
+ .option('--only-failed', 'Only failed testruns (shorthand for --only failed)')
264
+ .action(async (runArg, _opts, command) => {
265
+ const opts = command.optsWithGlobals();
266
+ const fetcher = createRunFetcher(runArg, opts);
267
+ const statuses = getStatusFilter(opts);
268
+ const total = await fetcher.countTestruns(statuses).catch(exitOnFetchError);
269
+ console.log(total);
270
+ });
271
+ fetchCommand
272
+ .command('report')
273
+ .description('Print a run with its testruns as a Markdown report')
274
+ .argument('<run>', RUN_ARGUMENT_DESCRIPTION)
275
+ .option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
276
+ .option('--only <statuses>', STATUS_FILTER_DESCRIPTION)
277
+ .option('--only-failed', 'Only failed testruns (shorthand for --only failed)')
278
+ .option('--stack', 'Include stack traces')
279
+ .option('--artifacts', 'Download artifacts to --out and link them from the report')
280
+ .option('--out <dir>', 'Directory for downloaded artifacts (default: ./<run-title>-<run-id>)')
281
+ .option('--limit <number>', `Max number of testruns, failed first (default ${testruns_js_1.TESTRUNS_DEFAULT_LIMIT}, max ${testruns_js_1.TESTRUNS_MAX_LIMIT})`)
282
+ .action(async (runArg, _opts, command) => {
283
+ const opts = command.optsWithGlobals();
284
+ const fetcher = createRunFetcher(runArg, opts);
285
+ const statuses = getStatusFilter(opts);
286
+ const { limit, capped } = (0, testruns_js_1.parseTestrunsLimit)(opts.limit);
287
+ if (capped)
288
+ log_js_1.log.warn(picocolors_1.default.yellow(`⚠️ --limit is capped at ${testruns_js_1.TESTRUNS_MAX_LIMIT}`));
289
+ const run = await fetcher.fetchRun().catch(exitOnFetchError);
290
+ if (opts.artifacts) {
291
+ const hasSharedCredentials = await fetcher.applySharedS3Credentials(run);
292
+ if (!hasSharedCredentials && !process.env.S3_BUCKET) {
293
+ log_js_1.log.error(picocolors_1.default.red('⚠️ --artifacts needs S3 access: share S3 credentials in project settings or set S3_BUCKET'));
294
+ process.exit(1);
295
+ }
296
+ }
297
+ const [summary, list] = await Promise.all([
298
+ fetcher.countTestrunsByStatus(),
299
+ fetcher.listTestruns({ statuses, limit }),
300
+ ]).catch(exitOnFetchError);
301
+ let { testruns } = list;
302
+ if (list.total > testruns.length) {
303
+ log_js_1.log.warn(picocolors_1.default.yellow(`⚠️ Showing ${testruns.length} of ${list.total} testruns. Use --limit or --only-failed to narrow it.`));
304
+ }
305
+ if (opts.stack || opts.artifacts) {
306
+ testruns = await fetcher.addTestrunDetails(testruns, (testrun, err) => {
307
+ log_js_1.log.warn(picocolors_1.default.yellow(`⚠️ Failed to fetch details of testrun ${testrun.id}: ${err.message}`));
308
+ });
309
+ if (!opts.stack)
310
+ testruns = testruns.map(({ stack, ...testrun }) => testrun);
311
+ }
312
+ if (opts.artifacts) {
313
+ const outDir = opts.out || (0, testruns_js_1.defaultArtifactsDir)(run, fetcher.runId);
314
+ const artifactsCount = testruns.reduce((sum, testrun) => sum + (testrun.artifacts?.length || 0), 0);
315
+ log_js_1.log.info(`Downloading ${artifactsCount} artifacts to ${outDir}`);
316
+ testruns = await (0, testruns_js_1.downloadArtifacts)(testruns, outDir, (url, err) => {
317
+ log_js_1.log.warn(picocolors_1.default.yellow(`⚠️ Failed to download ${url}: ${err.message}`));
318
+ });
319
+ }
320
+ else {
321
+ testruns = testruns.map(({ artifacts, ...testrun }) => testrun);
322
+ }
323
+ process.stdout.write((0, report_js_1.renderRunReport)({ run, runUrl: fetcher.runUrl, summary, testruns, matched: list.total, statuses }));
324
+ });
161
325
  program
162
326
  .command('run')
163
327
  .alias('test')
@@ -169,8 +333,9 @@ program
169
333
  .option('--kind <type>', 'Specify run type: automated, manual, mixed, or detect')
170
334
  .option('--remote <profile>', 'Trigger run on the named Testomat.io CI profile instead of executing locally')
171
335
  .option('--remote-param <kv>', 'key=value pair forwarded to the CI profile config (repeat for multiple)', (value, prev) => prev.concat([value]), [])
172
- .option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only)')
336
+ .option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only); env: TESTOMATIO_WARN')
173
337
  .action(async (command, opts) => {
338
+ opts.warn ||= (0, utils_js_1.transformEnvVarToBoolean)(process.env.TESTOMATIO_WARN);
174
339
  if (opts.remote) {
175
340
  if (opts.filterList) {
176
341
  log_js_1.log.warn(picocolors_1.default.red('⚠️ --filter-list cannot be combined with --remote'));
@@ -184,6 +349,7 @@ program
184
349
  const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config_js_1.config.TESTOMATIO;
185
350
  const title = process.env.TESTOMATIO_TITLE;
186
351
  const client = new client_js_1.default({ apiKey, title });
352
+ let filterApplied = false;
187
353
  if (opts.filter || opts.filterList) {
188
354
  log_js_1.log.info('Filtering tests...');
189
355
  // Example of use: npx @testomatio/reporter run "npx jest" --filter "testomatio:tag-name=frontend"
@@ -218,6 +384,7 @@ program
218
384
  }
219
385
  if (command && command.split && !opts.remote) {
220
386
  command = (0, utils_js_1.applyFilter)(command, tests);
387
+ filterApplied = true;
221
388
  }
222
389
  }
223
390
  catch (err) {
@@ -291,6 +458,13 @@ program
291
458
  }
292
459
  return process.exit(0);
293
460
  }
461
+ if ((0, detectRunner_js_1.isPlaceholderCommand)(command)) {
462
+ log_js_1.log.error(picocolors_1.default.red(`⚠️ The command "${command}" looks like a placeholder.`));
463
+ log_js_1.log.error('The example command from Testomat.io must be edited:');
464
+ log_js_1.log.error('replace the placeholder with your actual test runner command.');
465
+ log_js_1.log.error((0, detectRunner_js_1.runnerHint)());
466
+ process.exit(1);
467
+ }
294
468
  log_js_1.log.info(`🚀 Running`, picocolors_1.default.green(command));
295
469
  const runTests = async () => {
296
470
  const testCmds = command.split(' ');
@@ -298,9 +472,28 @@ program
298
472
  stdio: 'inherit',
299
473
  env: { ...process.env, TESTOMATIO_PROCEED: 'true', runId: client.runId, TESTOMATIO_RUN: client.runId },
300
474
  });
475
+ let errored = false;
476
+ cmd.on('error', err => {
477
+ errored = true;
478
+ const spawnError = /** @type {NodeJS.ErrnoException} */ (err);
479
+ if (spawnError.code === 'ENOENT') {
480
+ log_js_1.log.error(picocolors_1.default.red(`🔴 Command not found: ${picocolors_1.default.bold(testCmds[0])}`));
481
+ log_js_1.log.error((0, detectRunner_js_1.runnerHint)());
482
+ }
483
+ else {
484
+ log_js_1.log.error(picocolors_1.default.red(`🔴 Failed to start command: ${spawnError.message}`));
485
+ }
486
+ const finish = apiKey ? client.updateRunStatus('failed').catch(() => { }) : Promise.resolve();
487
+ finish.then(() => process.exit(1));
488
+ });
301
489
  cmd.on('close', async (code) => {
490
+ if (errored)
491
+ return;
302
492
  const emoji = code === 0 ? '🟢' : '🔴';
303
493
  log_js_1.log.info(emoji, `Runner exited with ${picocolors_1.default.bold(code)}`);
494
+ if (code !== 0 && filterApplied) {
495
+ (0, detectRunner_js_1.buildFilterWarning)(command).forEach(line => log_js_1.log.warn(line));
496
+ }
304
497
  if (apiKey) {
305
498
  const status = code === 0 ? 'passed' : 'failed';
306
499
  await client.updateRunStatus(status);
package/lib/client.d.ts CHANGED
@@ -15,6 +15,7 @@ export class Client {
15
15
  pipeStore: {};
16
16
  runId: string;
17
17
  queue: Promise<void>;
18
+ adapterName: any;
18
19
  version: any;
19
20
  executionList: Promise<void>;
20
21
  uploader: S3Uploader;
package/lib/client.js CHANGED
@@ -37,6 +37,8 @@ class Client {
37
37
  this.pipeStore = {};
38
38
  this.runId = '';
39
39
  this.queue = Promise.resolve();
40
+ // adapter name is set by the adapter module itself on load (global.TESTOMATIO_ADAPTER)
41
+ this.adapterName = params.adapter || global.TESTOMATIO_ADAPTER;
40
42
  // @ts-ignore this line will be removed in compiled code, because __dirname is defined in commonjs
41
43
  const pathToPackageJSON = path_1.default.join(__dirname, '../package.json');
42
44
  try {
@@ -214,7 +216,9 @@ class Client {
214
216
  const uploadedFiles = [];
215
217
  const stackArtifactsEnabled = (0, utils_js_1.transformEnvVarToBoolean)(process.env.TESTOMATIO_STACK_ARTIFACTS);
216
218
  const { time = 0, example = null, filesBuffers = [], code = null, file, suite_id, test_id, timestamp, links, overwrite, tags, } = testData;
217
- let { files = [], manuallyAttachedArtifacts, message = '', meta = {} } = testData;
219
+ let { files = [], manuallyAttachedArtifacts, message = '' } = testData;
220
+ /** @type {Record<string, any>} */
221
+ let meta = testData.meta || {};
218
222
  if (stepArtifactPaths.size) {
219
223
  const isStepArtifact = item => stepArtifactPaths.has(typeof item === 'object' ? item?.path : item);
220
224
  files = files.filter(f => !isStepArtifact(f));
@@ -229,6 +233,11 @@ class Client {
229
233
  acc[key] = value;
230
234
  return acc;
231
235
  }, {});
236
+ // meta must always include reporter version and adapter used
237
+ if (this.version)
238
+ meta.reporterVersion = this.version;
239
+ if (this.adapterName)
240
+ meta.adapter = this.adapterName;
232
241
  const testContext = suite_title ? `${suite_title} ${title}` : title;
233
242
  let errorFormatted = '';
234
243
  if (error) {
@@ -335,8 +344,8 @@ class Client {
335
344
  this.queue = this.queue
336
345
  .then(() => Promise.all(this.pipes.map(p => p.finishRun(runParams))))
337
346
  .then(() => {
338
- if (!this.uploader.isEnabled)
339
- return;
347
+ // no early return when uploading is disabled:
348
+ // skipped artifacts (e.g. no S3 credentials obtained) must still be reported to the user
340
349
  const filesizeStrMaxLength = 7;
341
350
  if (this.uploader.successfulUploads.length) {
342
351
  debug('\n', constants_js_1.APP_PREFIX, `🗄️ ${this.uploader.successfulUploads.length} artifacts uploaded to S3 bucket`);
@@ -220,9 +220,10 @@ class DataStorage {
220
220
  }
221
221
  }
222
222
  function stringToMD5Hash(str) {
223
- const md5 = crypto_1.default.createHash('md5');
224
- md5.update(str);
225
- const hash = md5.digest('hex');
223
+ // sha256 instead of md5 for FIPS-enabled hosts compatibility
224
+ const sha256 = crypto_1.default.createHash('sha256');
225
+ sha256.update(str);
226
+ const hash = sha256.digest('hex');
226
227
  return `${process.env.runId || 'run'}_${hash}`;
227
228
  }
228
229
  exports.dataStorage = DataStorage.getInstance();
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Render a fetched run with its testruns as a Markdown report.
3
+ *
4
+ * @param {object} params
5
+ * @param {object} params.run - Run from GET /api/v2/:project/runs/:id
6
+ * @param {string} params.runUrl
7
+ * @param {{total: number, byStatus: Record<string, number>}} params.summary - Counts of all testruns of the run
8
+ * @param {object[]} params.testruns
9
+ * @param {number} params.matched - Number of testruns matching the filter
10
+ * @param {string[]} params.statuses - Status filter, empty for all testruns
11
+ * @returns {string}
12
+ */
13
+ export function renderRunReport({ run, runUrl, summary, testruns, matched, statuses }: {
14
+ run: object;
15
+ runUrl: string;
16
+ summary: {
17
+ total: number;
18
+ byStatus: Record<string, number>;
19
+ };
20
+ testruns: object[];
21
+ matched: number;
22
+ statuses: string[];
23
+ }): string;
24
+ /**
25
+ * Map a testrun of API v2 to the test shape of the Markdown pipe.
26
+ * Without a suite, the API returns the title as "<suite> ⇒ <title>".
27
+ *
28
+ * @param {object} testrun
29
+ * @returns {object}
30
+ */
31
+ export function toReportTest(testrun: object): object;
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderRunReport = renderRunReport;
4
+ exports.toReportTest = toReportTest;
5
+ const markdown_js_1 = require("../pipe/markdown.js");
6
+ const TITLE_SEPARATOR = ' ⇒ ';
7
+ /**
8
+ * Map a testrun of API v2 to the test shape of the Markdown pipe.
9
+ * Without a suite, the API returns the title as "<suite> ⇒ <title>".
10
+ *
11
+ * @param {object} testrun
12
+ * @returns {object}
13
+ */
14
+ function toReportTest(testrun) {
15
+ let title = String(testrun.test_title || '').trim();
16
+ let suite = testrun.suiteTitle;
17
+ if (!suite && title.includes(TITLE_SEPARATOR)) {
18
+ const [suitePart, ...titleParts] = title.split(TITLE_SEPARATOR);
19
+ suite = suitePart.trim();
20
+ title = titleParts.join(TITLE_SEPARATOR).trim();
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
+ * Render a fetched run with its testruns as a Markdown report.
35
+ *
36
+ * @param {object} params
37
+ * @param {object} params.run - Run from GET /api/v2/:project/runs/:id
38
+ * @param {string} params.runUrl
39
+ * @param {{total: number, byStatus: Record<string, number>}} params.summary - Counts of all testruns of the run
40
+ * @param {object[]} params.testruns
41
+ * @param {number} params.matched - Number of testruns matching the filter
42
+ * @param {string[]} params.statuses - Status filter, empty for all testruns
43
+ * @returns {string}
44
+ */
45
+ function renderRunReport({ run, runUrl, summary, testruns, matched, statuses }) {
46
+ const status = String(run.status || 'unknown');
47
+ const sections = [`# ${run.title || run.id} — ${status.toUpperCase()}`];
48
+ const rows = [
49
+ ['Run ID', `\`${run.id}\``],
50
+ ['Run URL', `<${runUrl}>`],
51
+ ['Status', status],
52
+ ];
53
+ if (run.env)
54
+ rows.push(['Environment', run.env]);
55
+ if (run.launched_at)
56
+ rows.push(['Started', run.launched_at]);
57
+ if (run.finished_at)
58
+ rows.push(['Finished', run.finished_at]);
59
+ let shown = `${testruns.length} of ${matched} shown`;
60
+ if (statuses.length)
61
+ shown += ` (only: ${statuses.join(', ')})`;
62
+ rows.push(['Testruns', shown]);
63
+ sections.push(['| Key | Value |', '| --- | ----- |', ...rows.map(([key, value]) => `| ${key} | ${value} |`)].join('\n'));
64
+ if (run.description?.trim())
65
+ sections.push(`## Description\n\n${run.description.trim()}`);
66
+ const counts = Object.entries(summary.byStatus).sort(([, a], [, b]) => b - a);
67
+ sections.push([
68
+ '## Summary',
69
+ '',
70
+ '| Status | Count |',
71
+ '| ------ | ----- |',
72
+ ...counts.map(([name, count]) => `| ${name} | ${count} |`),
73
+ `| total | ${summary.total} |`,
74
+ ].join('\n'));
75
+ sections.push((0, markdown_js_1.renderTests)(testruns.map(toReportTest)));
76
+ return `${sections.join('\n\n')}\n`;
77
+ }
78
+
79
+ module.exports.renderRunReport = renderRunReport;
80
+
81
+ module.exports.toReportTest = toReportTest;
@@ -0,0 +1,133 @@
1
+ export const TESTRUNS_DEFAULT_LIMIT: 50;
2
+ export const TESTRUNS_MAX_LIMIT: 100;
3
+ export const TESTRUN_STATUSES: string[];
4
+ /**
5
+ * Read-only client for a single run and its testruns in API v2.
6
+ */
7
+ export class RunFetcher {
8
+ /**
9
+ * @param {{baseUrl: string, project: string, runId: string, apiKey: string}} params
10
+ */
11
+ constructor({ baseUrl, project, runId, apiKey }: {
12
+ baseUrl: string;
13
+ project: string;
14
+ runId: string;
15
+ apiKey: string;
16
+ });
17
+ baseUrl: string;
18
+ project: string;
19
+ runId: string;
20
+ apiKey: string;
21
+ get runUrl(): string;
22
+ get(apiPath: any, params?: any[]): Promise<any>;
23
+ fetchRun(): Promise<any>;
24
+ /**
25
+ * @param {string[]} statuses
26
+ * @returns {string[][]}
27
+ */
28
+ testrunParams(statuses?: string[]): string[][];
29
+ /**
30
+ * @param {string[]} [statuses]
31
+ * @returns {Promise<number>}
32
+ */
33
+ countTestruns(statuses?: string[]): Promise<number>;
34
+ /**
35
+ * @returns {Promise<{total: number, byStatus: Record<string, number>}>}
36
+ */
37
+ countTestrunsByStatus(): Promise<{
38
+ total: number;
39
+ byStatus: Record<string, number>;
40
+ }>;
41
+ /**
42
+ * Testruns with failures first; the list has no stack and no artifacts.
43
+ *
44
+ * @param {{statuses?: string[], limit: number}} params
45
+ * @returns {Promise<{testruns: object[], total: number}>}
46
+ */
47
+ listTestruns({ statuses, limit }: {
48
+ statuses?: string[];
49
+ limit: number;
50
+ }): Promise<{
51
+ testruns: object[];
52
+ total: number;
53
+ }>;
54
+ /**
55
+ * Add `stack` and `artifacts` to each testrun; a testrun that fails to load is kept as is.
56
+ *
57
+ * @param {object[]} testruns
58
+ * @param {(testrun: object, err: Error) => void} onError
59
+ */
60
+ addTestrunDetails(testruns: object[], onError: (testrun: object, err: Error) => void): Promise<any[]>;
61
+ /**
62
+ * Shared S3 credentials of the project, returned by an empty update of the run.
63
+ * Skipped for scheduled runs (the update launches them) and mixed runs (it marks automated tests as started).
64
+ *
65
+ * @param {{status?: string, kind?: string}} run
66
+ * @returns {Promise<boolean>} whether credentials were applied
67
+ */
68
+ applySharedS3Credentials(run: {
69
+ status?: string;
70
+ kind?: string;
71
+ }): Promise<boolean>;
72
+ }
73
+ /**
74
+ * Resolve the run argument of `fetch status|count|report`: a run id or a run URL.
75
+ * A URL also sets the server and the project.
76
+ *
77
+ * @param {string} runArg
78
+ * @param {{project?: string}} opts
79
+ * @returns {{baseUrl: string, project: string, runId: string}}
80
+ */
81
+ export function resolveRunTarget(runArg: string, opts: {
82
+ project?: string;
83
+ }): {
84
+ baseUrl: string;
85
+ project: string;
86
+ runId: string;
87
+ };
88
+ /**
89
+ * Collect the statuses passed with --only and --only-failed.
90
+ *
91
+ * @param {{only?: string, onlyFailed?: boolean}} opts
92
+ * @returns {string[]}
93
+ */
94
+ export function parseStatuses(opts: {
95
+ only?: string;
96
+ onlyFailed?: boolean;
97
+ }): string[];
98
+ /**
99
+ * @param {string} [value] - Value of the CLI --limit option.
100
+ * @returns {{limit: number, capped: boolean}}
101
+ */
102
+ export function parseTestrunsLimit(value?: string): {
103
+ limit: number;
104
+ capped: boolean;
105
+ };
106
+ /**
107
+ * Run `fn` over `items` with at most `limit` calls in flight; results keep the order of `items`.
108
+ *
109
+ * @template T, R
110
+ * @param {T[]} items
111
+ * @param {number} limit
112
+ * @param {(item: T) => Promise<R>} fn
113
+ * @returns {Promise<R[]>}
114
+ */
115
+ export function mapWithLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]>;
116
+ /**
117
+ * Download the artifacts of each testrun to `<outDir>/<testrun id>/<file name>`.
118
+ * Each artifact becomes `{name, href}`, where `href` is the local path relative to the current directory,
119
+ * or the remote URL when the download failed.
120
+ *
121
+ * @param {object[]} testruns
122
+ * @param {string} outDir
123
+ * @param {(url: string, err: Error) => void} onError
124
+ */
125
+ export function downloadArtifacts(testruns: object[], outDir: string, onError: (url: string, err: Error) => void): Promise<any[]>;
126
+ /**
127
+ * @param {{title?: string}} run
128
+ * @param {string} runId
129
+ * @returns {string}
130
+ */
131
+ export function defaultArtifactsDir(run: {
132
+ title?: string;
133
+ }, runId: string): string;