testomatio-reporter-cli 2.21.0 → 2.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-reporter-cli",
3
- "version": "2.21.0",
3
+ "version": "2.22.0",
4
4
  "description": "Yarn Berry compatible standalone CLI for @testomatio/reporter",
5
5
  "type": "module",
6
6
  "bin": {
package/src/bin/cli.js CHANGED
@@ -28,7 +28,6 @@ import {
28
28
  downloadArtifacts,
29
29
  defaultArtifactsDir,
30
30
  TESTRUNS_DEFAULT_LIMIT,
31
- TESTRUNS_MAX_LIMIT,
32
31
  TESTRUN_STATUSES,
33
32
  } from '../fetch/testruns.js';
34
33
  import { renderRunReport } from '../fetch/report.js';
@@ -149,7 +148,7 @@ const fetchCommand = program
149
148
  .command('fetch')
150
149
  .description('Fetch runs from Testomat.io API v2')
151
150
  .argument('[url]', 'URL of a run or of a (filtered) runs page in Testomat.io')
152
- .option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
151
+ .option('--project <slug>', 'Project slug for listing runs (or set TESTOMATIO_PROJECT)')
153
152
  .option('--run <id>', 'Fetch a single run by its id')
154
153
  .option('--title <text>', 'Filter by run title')
155
154
  .option('--tql <query>', 'Filter using Testomat Query Language')
@@ -180,29 +179,33 @@ const fetchCommand = program
180
179
  }
181
180
  }
182
181
 
183
- const project = opts.project || page.project || process.env.TESTOMATIO_PROJECT;
184
- if (!project) {
185
- log.error(pc.red('⚠️ --project (or TESTOMATIO_PROJECT) is required'));
186
- process.exit(1);
187
- }
188
-
189
182
  const baseUrl = page.baseUrl || process.env.TESTOMATIO_URL || 'https://app.testomat.io';
190
183
  const runId = opts.run || page.runId;
191
- let url;
192
184
 
193
185
  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));
186
+ const fetcher = new RunFetcher({ baseUrl, project: page.project, runId, apiKey });
187
+ const run = await fetcher.fetchRun().catch(exitOnFetchError);
188
+ console.log(formatFetchRunsOutput({ data: run }, opts.format));
189
+ return;
190
+ }
191
+
192
+ const project = opts.project || page.project || process.env.TESTOMATIO_PROJECT;
193
+ if (!project) {
194
+ log.error(
195
+ pc.red('⚠️ --project (or TESTOMATIO_PROJECT) is required to list runs, unless --run or a URL is passed'),
196
+ );
197
+ process.exit(1);
204
198
  }
205
199
 
200
+ const url = new URL(`/api/v2/${project}/runs`, baseUrl);
201
+ for (const [key, value] of page.params || []) url.searchParams.append(key, value);
202
+ if (opts.title) url.searchParams.set('search', opts.title);
203
+ if (opts.tql) url.searchParams.set('tql', opts.tql);
204
+ if (opts.rungroup) url.searchParams.set('groupId', opts.rungroup);
205
+ const requestedLimit = opts.latest ? 1 : parseInt(opts.limit, 10) || FETCH_RUNS_DEFAULT_LIMIT;
206
+ const limit = Math.min(Math.max(requestedLimit, 1), FETCH_RUNS_MAX_LIMIT);
207
+ url.searchParams.set('per_page', String(limit));
208
+
206
209
  const response = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
207
210
  const body = await response.json();
208
211
 
@@ -214,17 +217,16 @@ const fetchCommand = program
214
217
  console.log(formatFetchRunsOutput(body, opts.format));
215
218
  });
216
219
 
217
- // fetch parses the options it shares with its subcommands (--project, --format, --limit) wherever they are placed,
220
+ // fetch parses the options it shares with its subcommands (--format, --limit) wherever they are placed,
218
221
  // so subcommands declare them only for help and read them with optsWithGlobals()
219
222
 
220
223
  /**
221
224
  * Build a RunFetcher for the run argument of fetch status|count|report, or exit with an error.
222
225
  *
223
226
  * @param {string} runArg
224
- * @param {{project?: string}} opts
225
227
  * @returns {RunFetcher}
226
228
  */
227
- function createRunFetcher(runArg, opts) {
229
+ function createRunFetcher(runArg) {
228
230
  const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config.TESTOMATIO;
229
231
  if (!apiKey) {
230
232
  log.error(pc.red('⚠️ TESTOMATIO API key required'));
@@ -232,7 +234,7 @@ function createRunFetcher(runArg, opts) {
232
234
  }
233
235
 
234
236
  try {
235
- return new RunFetcher({ ...resolveRunTarget(runArg, opts), apiKey });
237
+ return new RunFetcher({ ...resolveRunTarget(runArg), apiKey });
236
238
  } catch (err) {
237
239
  log.error(pc.red(`⚠️ ${err.message}`));
238
240
  process.exit(1);
@@ -268,11 +270,10 @@ fetchCommand
268
270
  .command('status')
269
271
  .description('Show a run with the number of its testruns by status')
270
272
  .argument('<run>', RUN_ARGUMENT_DESCRIPTION)
271
- .option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
272
273
  .option('--format <format>', 'Machine-readable output: run status and counts as JSON (--format json)')
273
274
  .action(async (runArg, _opts, command) => {
274
275
  const opts = command.optsWithGlobals();
275
- const fetcher = createRunFetcher(runArg, opts);
276
+ const fetcher = createRunFetcher(runArg);
276
277
 
277
278
  const [run, summary] = await Promise.all([fetcher.fetchRun(), fetcher.countTestrunsByStatus()]).catch(
278
279
  exitOnFetchError,
@@ -294,12 +295,11 @@ fetchCommand
294
295
  .command('count')
295
296
  .description('Print the number of testruns of a run, to pick --limit and filters for fetch report')
296
297
  .argument('<run>', RUN_ARGUMENT_DESCRIPTION)
297
- .option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
298
298
  .option('--only <statuses>', STATUS_FILTER_DESCRIPTION)
299
299
  .option('--only-failed', 'Only failed testruns (shorthand for --only failed)')
300
300
  .action(async (runArg, _opts, command) => {
301
301
  const opts = command.optsWithGlobals();
302
- const fetcher = createRunFetcher(runArg, opts);
302
+ const fetcher = createRunFetcher(runArg);
303
303
  const statuses = getStatusFilter(opts);
304
304
 
305
305
  const total = await fetcher.countTestruns(statuses).catch(exitOnFetchError);
@@ -310,22 +310,19 @@ fetchCommand
310
310
  .command('report')
311
311
  .description('Print a run with its testruns as a Markdown report')
312
312
  .argument('<run>', RUN_ARGUMENT_DESCRIPTION)
313
- .option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
314
313
  .option('--only <statuses>', STATUS_FILTER_DESCRIPTION)
315
314
  .option('--only-failed', 'Only failed testruns (shorthand for --only failed)')
316
315
  .option('--stack', 'Include stack traces')
317
316
  .option('--artifacts', 'Download artifacts to --out and link them from the report')
318
317
  .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
- )
318
+ .option('--limit <number>', `Max number of testruns, failed first (default ${TESTRUNS_DEFAULT_LIMIT})`)
319
+ .option('--all', 'All matching testruns, fetched in chunks of 100 (overrides --limit)')
320
+ .option('--no-defects', 'Skip defects of the run and of its tests')
323
321
  .action(async (runArg, _opts, command) => {
324
322
  const opts = command.optsWithGlobals();
325
- const fetcher = createRunFetcher(runArg, opts);
323
+ const fetcher = createRunFetcher(runArg);
326
324
  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}`));
325
+ const limit = parseTestrunsLimit(opts);
329
326
 
330
327
  const run = await fetcher.fetchRun().catch(exitOnFetchError);
331
328
 
@@ -337,16 +334,25 @@ fetchCommand
337
334
  }
338
335
  }
339
336
 
340
- const [summary, list] = await Promise.all([
337
+ let runDefectsRequest;
338
+ if (opts.defects) {
339
+ runDefectsRequest = fetcher.listRunDefects().catch(err => {
340
+ log.warn(pc.yellow(`⚠️ Failed to fetch defects of the run: ${err.message}`));
341
+ return null;
342
+ });
343
+ }
344
+
345
+ const [summary, list, runDefects] = await Promise.all([
341
346
  fetcher.countTestrunsByStatus(),
342
347
  fetcher.listTestruns({ statuses, limit }),
348
+ runDefectsRequest,
343
349
  ]).catch(exitOnFetchError);
344
350
 
345
351
  let { testruns } = list;
346
352
  if (list.total > testruns.length) {
347
353
  log.warn(
348
354
  pc.yellow(
349
- `⚠️ Showing ${testruns.length} of ${list.total} testruns. Use --limit or --only-failed to narrow it.`,
355
+ `⚠️ Showing ${testruns.length} of ${list.total} testruns. Use --all, --limit or --only-failed.`,
350
356
  ),
351
357
  );
352
358
  }
@@ -358,6 +364,14 @@ fetchCommand
358
364
  if (!opts.stack) testruns = testruns.map(({ stack, ...testrun }) => testrun);
359
365
  }
360
366
 
367
+ if (opts.defects) {
368
+ testruns = await fetcher.addTestDefects(testruns, (testId, err) => {
369
+ log.warn(pc.yellow(`⚠️ Failed to fetch defects of test ${testId}: ${err.message}`));
370
+ });
371
+ } else {
372
+ testruns = testruns.map(({ defects, ...testrun }) => testrun);
373
+ }
374
+
361
375
  if (opts.artifacts) {
362
376
  const outDir = opts.out || defaultArtifactsDir(run, fetcher.runId);
363
377
  const artifactsCount = testruns.reduce((sum, testrun) => sum + (testrun.artifacts?.length || 0), 0);
@@ -370,7 +384,7 @@ fetchCommand
370
384
  }
371
385
 
372
386
  process.stdout.write(
373
- renderRunReport({ run, runUrl: fetcher.runUrl, summary, testruns, matched: list.total, statuses }),
387
+ renderRunReport({ run, runUrl: fetcher.runUrl, summary, testruns, matched: list.total, statuses, runDefects }),
374
388
  );
375
389
  });
376
390
 
@@ -1,4 +1,4 @@
1
- import { renderTests } from '../pipe/markdown.js';
1
+ import { renderTests, defectLink } from '../pipe/markdown.js';
2
2
 
3
3
  const TITLE_SEPARATOR = ' ⇒ ';
4
4
 
@@ -28,9 +28,27 @@ function toReportTest(testrun) {
28
28
  message: testrun.message,
29
29
  stack: testrun.stack,
30
30
  artifacts: testrun.artifacts,
31
+ defects: testrun.defects,
31
32
  };
32
33
  }
33
34
 
35
+ /**
36
+ * @param {{defects: object[], total: number}} runDefects - Defects of GET /api/v2/:project/defects?run_id=
37
+ * @returns {string}
38
+ */
39
+ function renderRunDefects({ defects, total }) {
40
+ const lines = ['## Defects', ''];
41
+ if (total > defects.length) lines.push(`${defects.length} of ${total} shown`, '');
42
+ lines.push('| Defect | Title | Status | Severity |', '| ------ | ----- | ------ | -------- |');
43
+ for (const defect of defects) {
44
+ const cells = [defectLink(defect), defect.title, defect.status, defect.severity].map(cell =>
45
+ String(cell || '').replace(/\|/g, '\\|'),
46
+ );
47
+ lines.push(`| ${cells.join(' | ')} |`);
48
+ }
49
+ return lines.join('\n');
50
+ }
51
+
34
52
  /**
35
53
  * Render a fetched run with its testruns as a Markdown report.
36
54
  *
@@ -41,9 +59,10 @@ function toReportTest(testrun) {
41
59
  * @param {object[]} params.testruns
42
60
  * @param {number} params.matched - Number of testruns matching the filter
43
61
  * @param {string[]} params.statuses - Status filter, empty for all testruns
62
+ * @param {{defects: object[], total: number} | null} [params.runDefects] - Defects of the run
44
63
  * @returns {string}
45
64
  */
46
- function renderRunReport({ run, runUrl, summary, testruns, matched, statuses }) {
65
+ function renderRunReport({ run, runUrl, summary, testruns, matched, statuses, runDefects }) {
47
66
  const status = String(run.status || 'unknown');
48
67
  const sections = [`# ${run.title || run.id} — ${status.toUpperCase()}`];
49
68
 
@@ -78,6 +97,8 @@ function renderRunReport({ run, runUrl, summary, testruns, matched, statuses })
78
97
  ].join('\n'),
79
98
  );
80
99
 
100
+ if (runDefects?.defects.length) sections.push(renderRunDefects(runDefects));
101
+
81
102
  sections.push(renderTests(testruns.map(toReportTest)));
82
103
 
83
104
  return `${sections.join('\n\n')}\n`;
@@ -6,29 +6,26 @@ import { S3Uploader } from '../uploader.js';
6
6
  const debug = createDebugMessages('@testomatio/reporter:fetch');
7
7
 
8
8
  const TESTRUNS_DEFAULT_LIMIT = 50;
9
- const TESTRUNS_MAX_LIMIT = 100;
9
+ const TESTRUNS_PAGE_SIZE = 100;
10
10
  const TESTRUN_STATUSES = ['passed', 'failed', 'skipped', 'pending'];
11
11
  const PARALLEL_REQUESTS = 5;
12
+ const FINISHED_RUN_STATUSES = ['passed', 'failed', 'terminated', 'finished'];
12
13
 
13
14
  /**
14
15
  * 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
+ * A URL also sets the server and the project; for a run id the project is resolved from the run by RunFetcher.
16
17
  *
17
18
  * @param {string} runArg
18
- * @param {{project?: string}} opts
19
- * @returns {{baseUrl: string, project: string, runId: string}}
19
+ * @returns {{baseUrl: string, project?: string, runId: string}}
20
20
  */
21
- function resolveRunTarget(runArg, opts) {
21
+ function resolveRunTarget(runArg) {
22
22
  if (/^https?:\/\//i.test(runArg)) {
23
23
  const page = parseRunsUrl(runArg);
24
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 };
25
+ return { baseUrl: page.baseUrl, project: page.project, runId: page.runId };
26
26
  }
27
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 };
28
+ return { baseUrl: process.env.TESTOMATIO_URL || 'https://app.testomat.io', runId: runArg };
32
29
  }
33
30
 
34
31
  /**
@@ -53,12 +50,12 @@ function parseStatuses(opts) {
53
50
  }
54
51
 
55
52
  /**
56
- * @param {string} [value] - Value of the CLI --limit option.
57
- * @returns {{limit: number, capped: boolean}}
53
+ * @param {{limit?: string, all?: boolean}} opts - Values of the CLI --limit and --all options.
54
+ * @returns {number} Infinity for --all
58
55
  */
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 };
56
+ function parseTestrunsLimit(opts) {
57
+ if (opts.all) return Infinity;
58
+ return Math.max(parseInt(opts.limit, 10) || TESTRUNS_DEFAULT_LIMIT, 1);
62
59
  }
63
60
 
64
61
  /**
@@ -88,13 +85,45 @@ async function mapWithLimit(items, limit, fn) {
88
85
  */
89
86
  class RunFetcher {
90
87
  /**
91
- * @param {{baseUrl: string, project: string, runId: string, apiKey: string}} params
88
+ * @param {{baseUrl: string, project?: string, runId: string, apiKey: string}} params
92
89
  */
93
90
  constructor({ baseUrl, project, runId, apiKey }) {
94
91
  this.baseUrl = baseUrl;
95
92
  this.project = project;
96
93
  this.runId = runId;
97
94
  this.apiKey = apiKey;
95
+ /** @type {Promise<any>|null} */
96
+ this.reporterRun = null;
97
+ }
98
+
99
+ /**
100
+ * Empty update of the run in the reporter API: it returns the project of the token and shared S3 credentials.
101
+ *
102
+ * @returns {Promise<{project?: {id: string}, artifacts?: object}>}
103
+ */
104
+ updateReporterRun() {
105
+ this.reporterRun ||= (async () => {
106
+ const url = new URL(`/api/reporter/${this.runId}`, this.baseUrl);
107
+ debug('PUT', url.toString());
108
+ const response = await fetch(url, {
109
+ method: 'PUT',
110
+ headers: { 'Content-Type': 'application/json' },
111
+ body: JSON.stringify({ api_key: this.apiKey }),
112
+ });
113
+ const body = await response.json().catch(() => ({}));
114
+ if (response.status === 404) throw new Error(`Run ${this.runId} not found`);
115
+ if (!response.ok) throw new Error(`${response.status} ${body.message || body.error || response.statusText}`);
116
+ return body;
117
+ })();
118
+ return this.reporterRun;
119
+ }
120
+
121
+ async resolveProject() {
122
+ if (this.project) return this.project;
123
+ const body = await this.updateReporterRun();
124
+ if (!body.project?.id) throw new Error(`Project of run ${this.runId} is unknown`);
125
+ this.project = body.project.id;
126
+ return this.project;
98
127
  }
99
128
 
100
129
  get runUrl() {
@@ -102,6 +131,7 @@ class RunFetcher {
102
131
  }
103
132
 
104
133
  async get(apiPath, params = []) {
134
+ await this.resolveProject();
105
135
  const url = new URL(`/api/v2/${this.project}${apiPath}`, this.baseUrl);
106
136
  for (const [key, value] of params) url.searchParams.append(key, value);
107
137
  debug('GET', url.toString());
@@ -148,19 +178,31 @@ class RunFetcher {
148
178
  }
149
179
 
150
180
  /**
151
- * Testruns with failures first; the list has no stack and no artifacts.
181
+ * Testruns with failures first, read in pages of TESTRUNS_PAGE_SIZE; the list has no stack and no artifacts.
152
182
  *
153
- * @param {{statuses?: string[], limit: number}} params
183
+ * @param {{statuses?: string[], limit: number}} params - limit is Infinity to read all testruns
154
184
  * @returns {Promise<{testruns: object[], total: number}>}
155
185
  */
156
186
  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 };
187
+ const perPage = Math.min(limit, TESTRUNS_PAGE_SIZE);
188
+ const getPage = async page => {
189
+ const body = await this.get('/testruns', [
190
+ ...this.testrunParams(statuses),
191
+ ['sort', 'failure'],
192
+ ['order', 'desc'],
193
+ ['per_page', String(perPage)],
194
+ ['page', String(page)],
195
+ ]);
196
+ return { testruns: body.data || [], total: body.meta?.total || 0 };
197
+ };
198
+
199
+ const first = await getPage(1);
200
+ const pagesCount = Math.ceil(Math.min(limit, first.total) / perPage);
201
+ const nextPages = Array.from({ length: Math.max(pagesCount - 1, 0) }, (_, index) => index + 2);
202
+ const rest = await mapWithLimit(nextPages, PARALLEL_REQUESTS, getPage);
203
+
204
+ const testruns = [first, ...rest].flatMap(page => page.testruns).slice(0, limit);
205
+ return { testruns, total: first.total };
164
206
  }
165
207
 
166
208
  /**
@@ -181,31 +223,70 @@ class RunFetcher {
181
223
  });
182
224
  }
183
225
 
226
+ /**
227
+ * Defects linked to the testruns of the run; one page of TESTRUNS_PAGE_SIZE.
228
+ *
229
+ * @returns {Promise<{defects: object[], total: number}>}
230
+ */
231
+ async listRunDefects() {
232
+ const body = await this.get('/defects', [
233
+ ['run_id', this.runId],
234
+ ['per_page', String(TESTRUNS_PAGE_SIZE)],
235
+ ]);
236
+ return { defects: body.data || [], total: body.meta?.total || 0 };
237
+ }
238
+
239
+ /**
240
+ * Add the defects linked to the test case of each testrun to the testrun's own defects.
241
+ * A test that fails to load keeps only the testrun defects.
242
+ *
243
+ * @param {object[]} testruns
244
+ * @param {(testId: string, err: Error) => void} onError
245
+ */
246
+ async addTestDefects(testruns, onError) {
247
+ const testIds = [...new Set(testruns.map(testrun => testrun.testId).filter(Boolean))];
248
+ const testDefects = await mapWithLimit(testIds, PARALLEL_REQUESTS, async testId => {
249
+ try {
250
+ const body = await this.get('/defects', [
251
+ ['test_id', testId],
252
+ ['per_page', String(TESTRUNS_PAGE_SIZE)],
253
+ ]);
254
+ return body.data || [];
255
+ } catch (err) {
256
+ onError(testId, err);
257
+ return [];
258
+ }
259
+ });
260
+ const defectsByTest = new Map(testIds.map((testId, index) => [testId, testDefects[index]]));
261
+
262
+ return testruns.map(testrun => {
263
+ const defects = [...(testrun.defects || []), ...(defectsByTest.get(testrun.testId) || [])];
264
+ const unique = [...new Map(defects.map(defect => [defect.id, defect])).values()];
265
+ return { ...testrun, defects: unique };
266
+ });
267
+ }
268
+
184
269
  /**
185
270
  * 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).
271
+ * Unless the run was already updated to resolve its project, skipped for scheduled runs (the update launches them)
272
+ * and unfinished mixed runs (it marks automated tests as started).
187
273
  *
188
274
  * @param {{status?: string, kind?: string}} run
189
275
  * @returns {Promise<boolean>} whether credentials were applied
190
276
  */
191
277
  async applySharedS3Credentials(run) {
192
- if (run.status === 'scheduled' || run.kind === 'mixed') {
278
+ const isUnfinishedMixed = run.kind === 'mixed' && !FINISHED_RUN_STATUSES.includes(run.status);
279
+ if (!this.reporterRun && (run.status === 'scheduled' || isUnfinishedMixed)) {
193
280
  debug(`Shared S3 credentials are not requested for a ${run.status} ${run.kind} run`);
194
281
  return false;
195
282
  }
196
283
 
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 }),
284
+ const body = await this.updateReporterRun().catch(err => {
285
+ debug('Failed to get shared S3 credentials:', err.message);
286
+ return { artifacts: null };
202
287
  });
203
- if (!response.ok) {
204
- debug('Failed to get shared S3 credentials:', response.status);
205
- return false;
206
- }
207
288
 
208
- const { artifacts } = await response.json();
289
+ const { artifacts } = body;
209
290
  if (!artifacts || !Object.keys(artifacts).length) return false;
210
291
  setS3Credentials(artifacts);
211
292
  return true;
@@ -271,7 +352,7 @@ function defaultArtifactsDir(run, runId) {
271
352
 
272
353
  export {
273
354
  TESTRUNS_DEFAULT_LIMIT,
274
- TESTRUNS_MAX_LIMIT,
355
+ TESTRUNS_PAGE_SIZE,
275
356
  TESTRUN_STATUSES,
276
357
  RunFetcher,
277
358
  resolveRunTarget,
@@ -332,6 +332,9 @@ function renderTest(test) {
332
332
  const logsBlock = renderLogs(test);
333
333
  if (logsBlock) lines.push(logsBlock);
334
334
 
335
+ const defectsBlock = renderDefects(test);
336
+ if (defectsBlock) lines.push(defectsBlock);
337
+
335
338
  const artifactsBlock = renderArtifacts(test);
336
339
  if (artifactsBlock) lines.push(artifactsBlock);
337
340
 
@@ -497,6 +500,26 @@ function renderArtifacts(test) {
497
500
  return lines.join('\n');
498
501
  }
499
502
 
503
+ function renderDefects(test) {
504
+ if (!Array.isArray(test.defects) || !test.defects.length) return '';
505
+
506
+ const lines = ['**Defects**', ''];
507
+ for (const defect of test.defects) {
508
+ let line = `- ${defectLink(defect)}`;
509
+ if (defect.sourceKey && defect.title) line += ` ${mdInline(defect.title)}`;
510
+ const details = [defect.status, defect.severity].filter(Boolean);
511
+ if (details.length) line += ` (${details.join(', ')})`;
512
+ lines.push(line);
513
+ }
514
+ return lines.join('\n');
515
+ }
516
+
517
+ function defectLink(defect) {
518
+ const key = mdInline(defect.sourceKey || defect.title || 'defect');
519
+ if (!defect.url) return key;
520
+ return `[${key}](${defect.url})`;
521
+ }
522
+
500
523
  function normalizeArtifact(raw) {
501
524
  if (raw == null) return null;
502
525
 
@@ -755,6 +778,6 @@ function aggregateTestRetries(tests) {
755
778
  return aggregated;
756
779
  }
757
780
 
758
- export { renderTests };
781
+ export { renderTests, defectLink };
759
782
 
760
783
  export default MarkdownPipe;