@testomatio/reporter 2.20.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.
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';
@@ -19,6 +20,18 @@ import { log } from '../utils/log.js';
19
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,7 +145,7 @@ 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')
133
151
  .argument('[url]', 'URL of a run or of a (filtered) runs page in Testomat.io')
@@ -136,10 +154,10 @@ program
136
154
  .option('--title <text>', 'Filter by run title')
137
155
  .option('--tql <query>', 'Filter using Testomat Query Language')
138
156
  .option('--rungroup <uid>', 'Filter by rungroup id')
157
+ // no default value: fetch parses --limit for its subcommands too, see createRunFetcher
139
158
  .option(
140
159
  '--limit <number>',
141
- `Max number of runs to return (max ${FETCH_RUNS_MAX_LIMIT})`,
142
- String(FETCH_RUNS_DEFAULT_LIMIT),
160
+ `Max number of runs to return (default ${FETCH_RUNS_DEFAULT_LIMIT}, max ${FETCH_RUNS_MAX_LIMIT})`,
143
161
  )
144
162
  .option('--latest', 'Only fetch the most recent run (shorthand for --limit 1)')
145
163
  .option(
@@ -196,6 +214,166 @@ program
196
214
  console.log(formatFetchRunsOutput(body, opts.format));
197
215
  });
198
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
+
199
377
  program
200
378
  .command('run')
201
379
  .alias('test')
@@ -215,8 +393,9 @@ program
215
393
  (value, prev) => prev.concat([value]),
216
394
  [],
217
395
  )
218
- .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')
219
397
  .action(async (command, opts) => {
398
+ opts.warn ||= transformEnvVarToBoolean(process.env.TESTOMATIO_WARN);
220
399
  if (opts.remote) {
221
400
  if (opts.filterList) {
222
401
  log.warn(pc.red('⚠️ --filter-list cannot be combined with --remote'));
@@ -232,6 +411,8 @@ program
232
411
  const title = process.env.TESTOMATIO_TITLE;
233
412
  const client = new TestomatClient({ apiKey, title });
234
413
 
414
+ let filterApplied = false;
415
+
235
416
  if (opts.filter || opts.filterList) {
236
417
  log.info('Filtering tests...');
237
418
  // Example of use: npx @testomatio/reporter run "npx jest" --filter "testomatio:tag-name=frontend"
@@ -269,6 +450,7 @@ program
269
450
 
270
451
  if (command && command.split && !opts.remote) {
271
452
  command = applyFilter(command, tests);
453
+ filterApplied = true;
272
454
  }
273
455
  }
274
456
  catch (err) {
@@ -345,6 +527,14 @@ program
345
527
  return process.exit(0);
346
528
  }
347
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
+
348
538
  log.info( `🚀 Running`, pc.green(command));
349
539
 
350
540
  const runTests = async () => {
@@ -354,9 +544,28 @@ program
354
544
  env: { ...process.env, TESTOMATIO_PROCEED: 'true', runId: client.runId, TESTOMATIO_RUN: client.runId },
355
545
  });
356
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
+
357
562
  cmd.on('close', async code => {
563
+ if (errored) return;
358
564
  const emoji = code === 0 ? '🟢' : '🔴';
359
565
  log.info( emoji, `Runner exited with ${pc.bold(code)}`);
566
+ if (code !== 0 && filterApplied) {
567
+ buildFilterWarning(command).forEach(line => log.warn(line));
568
+ }
360
569
  if (apiKey) {
361
570
  const status = code === 0 ? 'passed' : 'failed';
362
571
  await client.updateRunStatus(status);
@@ -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 };
@@ -0,0 +1,283 @@
1
+ import createDebugMessages from 'debug';
2
+ import path from 'path';
3
+ import { parseRunsUrl, setS3Credentials } from '../utils/pipe_utils.js';
4
+ import { S3Uploader } from '../uploader.js';
5
+
6
+ const debug = createDebugMessages('@testomatio/reporter:fetch');
7
+
8
+ const TESTRUNS_DEFAULT_LIMIT = 50;
9
+ const TESTRUNS_MAX_LIMIT = 100;
10
+ const TESTRUN_STATUSES = ['passed', 'failed', 'skipped', 'pending'];
11
+ const PARALLEL_REQUESTS = 5;
12
+
13
+ /**
14
+ * Resolve the run argument of `fetch status|count|report`: a run id or a run URL.
15
+ * A URL also sets the server and the project.
16
+ *
17
+ * @param {string} runArg
18
+ * @param {{project?: string}} opts
19
+ * @returns {{baseUrl: string, project: string, runId: string}}
20
+ */
21
+ function resolveRunTarget(runArg, opts) {
22
+ if (/^https?:\/\//i.test(runArg)) {
23
+ const page = parseRunsUrl(runArg);
24
+ if (!page?.runId) throw new Error(`Not a Testomat.io run URL: ${runArg}`);
25
+ return { baseUrl: page.baseUrl, project: opts.project || page.project, runId: page.runId };
26
+ }
27
+
28
+ const project = opts.project || process.env.TESTOMATIO_PROJECT;
29
+ if (!project) throw new Error('--project (or TESTOMATIO_PROJECT) is required');
30
+
31
+ return { baseUrl: process.env.TESTOMATIO_URL || 'https://app.testomat.io', project, runId: runArg };
32
+ }
33
+
34
+ /**
35
+ * Collect the statuses passed with --only and --only-failed.
36
+ *
37
+ * @param {{only?: string, onlyFailed?: boolean}} opts
38
+ * @returns {string[]}
39
+ */
40
+ function parseStatuses(opts) {
41
+ const statuses = String(opts.only || '')
42
+ .split(',')
43
+ .map(status => status.trim().toLowerCase())
44
+ .filter(Boolean);
45
+ if (opts.onlyFailed) statuses.push('failed');
46
+
47
+ const unknown = statuses.filter(status => !TESTRUN_STATUSES.includes(status));
48
+ if (unknown.length) {
49
+ throw new Error(`Unknown status: ${unknown.join(', ')}. Use one of: ${TESTRUN_STATUSES.join(', ')}`);
50
+ }
51
+
52
+ return [...new Set(statuses)];
53
+ }
54
+
55
+ /**
56
+ * @param {string} [value] - Value of the CLI --limit option.
57
+ * @returns {{limit: number, capped: boolean}}
58
+ */
59
+ function parseTestrunsLimit(value) {
60
+ const requested = Math.max(parseInt(value, 10) || TESTRUNS_DEFAULT_LIMIT, 1);
61
+ return { limit: Math.min(requested, TESTRUNS_MAX_LIMIT), capped: requested > TESTRUNS_MAX_LIMIT };
62
+ }
63
+
64
+ /**
65
+ * Run `fn` over `items` with at most `limit` calls in flight; results keep the order of `items`.
66
+ *
67
+ * @template T, R
68
+ * @param {T[]} items
69
+ * @param {number} limit
70
+ * @param {(item: T) => Promise<R>} fn
71
+ * @returns {Promise<R[]>}
72
+ */
73
+ async function mapWithLimit(items, limit, fn) {
74
+ const results = new Array(items.length);
75
+ let next = 0;
76
+ const worker = async () => {
77
+ while (next < items.length) {
78
+ const index = next++;
79
+ results[index] = await fn(items[index]);
80
+ }
81
+ };
82
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
83
+ return results;
84
+ }
85
+
86
+ /**
87
+ * Read-only client for a single run and its testruns in API v2.
88
+ */
89
+ class RunFetcher {
90
+ /**
91
+ * @param {{baseUrl: string, project: string, runId: string, apiKey: string}} params
92
+ */
93
+ constructor({ baseUrl, project, runId, apiKey }) {
94
+ this.baseUrl = baseUrl;
95
+ this.project = project;
96
+ this.runId = runId;
97
+ this.apiKey = apiKey;
98
+ }
99
+
100
+ get runUrl() {
101
+ return new URL(`/projects/${this.project}/runs/${this.runId}`, this.baseUrl).toString();
102
+ }
103
+
104
+ async get(apiPath, params = []) {
105
+ const url = new URL(`/api/v2/${this.project}${apiPath}`, this.baseUrl);
106
+ for (const [key, value] of params) url.searchParams.append(key, value);
107
+ debug('GET', url.toString());
108
+
109
+ const response = await fetch(url, { headers: { Authorization: `Bearer ${this.apiKey}` } });
110
+ const body = await response.json().catch(() => ({}));
111
+ if (response.status === 404 && apiPath === `/runs/${this.runId}`) {
112
+ throw new Error(`Run ${this.runId} not found in project ${this.project}`);
113
+ }
114
+ if (!response.ok) throw new Error(`${response.status} ${body.error || response.statusText}`);
115
+ return body;
116
+ }
117
+
118
+ async fetchRun() {
119
+ const body = await this.get(`/runs/${this.runId}`);
120
+ return body.data;
121
+ }
122
+
123
+ /**
124
+ * @param {string[]} statuses
125
+ * @returns {string[][]}
126
+ */
127
+ testrunParams(statuses = []) {
128
+ const params = [['run_id', this.runId]];
129
+ for (const status of statuses) params.push(['filter[status][]', status]);
130
+ return params;
131
+ }
132
+
133
+ /**
134
+ * @param {string[]} [statuses]
135
+ * @returns {Promise<number>}
136
+ */
137
+ async countTestruns(statuses = []) {
138
+ const body = await this.get('/testruns', [...this.testrunParams(statuses), ['count', 'true']]);
139
+ return body.meta?.total || 0;
140
+ }
141
+
142
+ /**
143
+ * @returns {Promise<{total: number, byStatus: Record<string, number>}>}
144
+ */
145
+ async countTestrunsByStatus() {
146
+ const body = await this.get('/testruns', [...this.testrunParams(), ['count', 'true'], ['group_by', 'status']]);
147
+ return { total: body.meta?.total || 0, byStatus: body.meta?.group_by || {} };
148
+ }
149
+
150
+ /**
151
+ * Testruns with failures first; the list has no stack and no artifacts.
152
+ *
153
+ * @param {{statuses?: string[], limit: number}} params
154
+ * @returns {Promise<{testruns: object[], total: number}>}
155
+ */
156
+ async listTestruns({ statuses = [], limit }) {
157
+ const body = await this.get('/testruns', [
158
+ ...this.testrunParams(statuses),
159
+ ['sort', 'failure'],
160
+ ['order', 'desc'],
161
+ ['per_page', String(limit)],
162
+ ]);
163
+ return { testruns: body.data || [], total: body.meta?.total || 0 };
164
+ }
165
+
166
+ /**
167
+ * Add `stack` and `artifacts` to each testrun; a testrun that fails to load is kept as is.
168
+ *
169
+ * @param {object[]} testruns
170
+ * @param {(testrun: object, err: Error) => void} onError
171
+ */
172
+ async addTestrunDetails(testruns, onError) {
173
+ return mapWithLimit(testruns, PARALLEL_REQUESTS, async testrun => {
174
+ try {
175
+ const body = await this.get(`/testruns/${testrun.id}`);
176
+ return { ...testrun, stack: body.data?.stack, artifacts: body.data?.artifacts || [] };
177
+ } catch (err) {
178
+ onError(testrun, err);
179
+ return testrun;
180
+ }
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Shared S3 credentials of the project, returned by an empty update of the run.
186
+ * Skipped for scheduled runs (the update launches them) and mixed runs (it marks automated tests as started).
187
+ *
188
+ * @param {{status?: string, kind?: string}} run
189
+ * @returns {Promise<boolean>} whether credentials were applied
190
+ */
191
+ async applySharedS3Credentials(run) {
192
+ if (run.status === 'scheduled' || run.kind === 'mixed') {
193
+ debug(`Shared S3 credentials are not requested for a ${run.status} ${run.kind} run`);
194
+ return false;
195
+ }
196
+
197
+ const url = new URL(`/api/reporter/${this.runId}`, this.baseUrl);
198
+ const response = await fetch(url, {
199
+ method: 'PUT',
200
+ headers: { 'Content-Type': 'application/json' },
201
+ body: JSON.stringify({ api_key: this.apiKey }),
202
+ });
203
+ if (!response.ok) {
204
+ debug('Failed to get shared S3 credentials:', response.status);
205
+ return false;
206
+ }
207
+
208
+ const { artifacts } = await response.json();
209
+ if (!artifacts || !Object.keys(artifacts).length) return false;
210
+ setS3Credentials(artifacts);
211
+ return true;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Download the artifacts of each testrun to `<outDir>/<testrun id>/<file name>`.
217
+ * Each artifact becomes `{name, href}`, where `href` is the local path relative to the current directory,
218
+ * or the remote URL when the download failed.
219
+ *
220
+ * @param {object[]} testruns
221
+ * @param {string} outDir
222
+ * @param {(url: string, err: Error) => void} onError
223
+ */
224
+ async function downloadArtifacts(testruns, outDir, onError) {
225
+ const uploader = new S3Uploader();
226
+ const jobs = [];
227
+
228
+ // the file name is parsed inside mapWithLimit, so a malformed artifact URL only skips that artifact
229
+ for (const testrun of testruns) {
230
+ const usedNames = new Set();
231
+ for (const url of testrun.artifacts || []) {
232
+ jobs.push({ testrun, url, outDir, usedNames });
233
+ }
234
+ }
235
+
236
+ const downloaded = await mapWithLimit(jobs, PARALLEL_REQUESTS, async job => {
237
+ let name = 'artifact';
238
+ try {
239
+ name = decodeURIComponent(path.basename(new URL(job.url).pathname)) || 'artifact';
240
+ if (job.usedNames.has(name)) name = `${job.usedNames.size}-${name}`;
241
+ job.usedNames.add(name);
242
+ const filePath = path.join(job.outDir, String(job.testrun.id), name);
243
+ await uploader.downloadFile(job.url, filePath);
244
+ const href = `./${path.relative(process.cwd(), filePath).split(path.sep).join('/')}`;
245
+ return { testrun: job.testrun, href, name };
246
+ } catch (err) {
247
+ onError(job.url, err);
248
+ return { testrun: job.testrun, href: job.url, name: `${name} (not downloaded)` };
249
+ }
250
+ });
251
+
252
+ return testruns.map(testrun => ({
253
+ ...testrun,
254
+ artifacts: downloaded.filter(job => job.testrun === testrun).map(({ name, href }) => ({ name, href })),
255
+ }));
256
+ }
257
+
258
+ /**
259
+ * @param {{title?: string}} run
260
+ * @param {string} runId
261
+ * @returns {string}
262
+ */
263
+ function defaultArtifactsDir(run, runId) {
264
+ const slug = String(run.title || 'run')
265
+ .toLowerCase()
266
+ .replace(/[^a-z0-9]+/g, '-')
267
+ .replace(/^-+|-+$/g, '')
268
+ .slice(0, 60);
269
+ return `./${slug || 'run'}-${runId}`;
270
+ }
271
+
272
+ export {
273
+ TESTRUNS_DEFAULT_LIMIT,
274
+ TESTRUNS_MAX_LIMIT,
275
+ TESTRUN_STATUSES,
276
+ RunFetcher,
277
+ resolveRunTarget,
278
+ parseStatuses,
279
+ parseTestrunsLimit,
280
+ mapWithLimit,
281
+ downloadArtifacts,
282
+ defaultArtifactsDir,
283
+ };
@@ -521,6 +521,11 @@ function normalizeArtifact(raw) {
521
521
  return { name: path.basename(abs), href, isImage: looksLikeImage(abs) };
522
522
  }
523
523
 
524
+ // already resolved link, e.g. a downloaded artifact relative to the report
525
+ if (typeof raw.href === 'string' && raw.href) {
526
+ return { name: raw.name || path.basename(raw.href), href: raw.href, isImage: looksLikeImage(raw.href) };
527
+ }
528
+
524
529
  const rawPath = raw.path || raw.link || raw.url;
525
530
  if (!rawPath || typeof rawPath !== 'string') return null;
526
531
 
@@ -750,4 +755,6 @@ function aggregateTestRetries(tests) {
750
755
  return aggregated;
751
756
  }
752
757
 
758
+ export { renderTests };
759
+
753
760
  export default MarkdownPipe;