testomatio-reporter-cli 2.20.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.
@@ -0,0 +1,107 @@
1
+ import { renderTests, defectLink } 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
+ defects: testrun.defects,
32
+ };
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
+
52
+ /**
53
+ * Render a fetched run with its testruns as a Markdown report.
54
+ *
55
+ * @param {object} params
56
+ * @param {object} params.run - Run from GET /api/v2/:project/runs/:id
57
+ * @param {string} params.runUrl
58
+ * @param {{total: number, byStatus: Record<string, number>}} params.summary - Counts of all testruns of the run
59
+ * @param {object[]} params.testruns
60
+ * @param {number} params.matched - Number of testruns matching the filter
61
+ * @param {string[]} params.statuses - Status filter, empty for all testruns
62
+ * @param {{defects: object[], total: number} | null} [params.runDefects] - Defects of the run
63
+ * @returns {string}
64
+ */
65
+ function renderRunReport({ run, runUrl, summary, testruns, matched, statuses, runDefects }) {
66
+ const status = String(run.status || 'unknown');
67
+ const sections = [`# ${run.title || run.id} — ${status.toUpperCase()}`];
68
+
69
+ const rows = [
70
+ ['Run ID', `\`${run.id}\``],
71
+ ['Run URL', `<${runUrl}>`],
72
+ ['Status', status],
73
+ ];
74
+ if (run.env) rows.push(['Environment', run.env]);
75
+ if (run.launched_at) rows.push(['Started', run.launched_at]);
76
+ if (run.finished_at) rows.push(['Finished', run.finished_at]);
77
+
78
+ let shown = `${testruns.length} of ${matched} shown`;
79
+ if (statuses.length) shown += ` (only: ${statuses.join(', ')})`;
80
+ rows.push(['Testruns', shown]);
81
+
82
+ sections.push(
83
+ ['| Key | Value |', '| --- | ----- |', ...rows.map(([key, value]) => `| ${key} | ${value} |`)].join('\n'),
84
+ );
85
+
86
+ if (run.description?.trim()) sections.push(`## Description\n\n${run.description.trim()}`);
87
+
88
+ const counts = Object.entries(summary.byStatus).sort(([, a], [, b]) => b - a);
89
+ sections.push(
90
+ [
91
+ '## Summary',
92
+ '',
93
+ '| Status | Count |',
94
+ '| ------ | ----- |',
95
+ ...counts.map(([name, count]) => `| ${name} | ${count} |`),
96
+ `| total | ${summary.total} |`,
97
+ ].join('\n'),
98
+ );
99
+
100
+ if (runDefects?.defects.length) sections.push(renderRunDefects(runDefects));
101
+
102
+ sections.push(renderTests(testruns.map(toReportTest)));
103
+
104
+ return `${sections.join('\n\n')}\n`;
105
+ }
106
+
107
+ export { renderRunReport, toReportTest };
@@ -0,0 +1,364 @@
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_PAGE_SIZE = 100;
10
+ const TESTRUN_STATUSES = ['passed', 'failed', 'skipped', 'pending'];
11
+ const PARALLEL_REQUESTS = 5;
12
+ const FINISHED_RUN_STATUSES = ['passed', 'failed', 'terminated', 'finished'];
13
+
14
+ /**
15
+ * Resolve the run argument of `fetch status|count|report`: a run id or a run URL.
16
+ * A URL also sets the server and the project; for a run id the project is resolved from the run by RunFetcher.
17
+ *
18
+ * @param {string} runArg
19
+ * @returns {{baseUrl: string, project?: string, runId: string}}
20
+ */
21
+ function resolveRunTarget(runArg) {
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: page.project, runId: page.runId };
26
+ }
27
+
28
+ return { baseUrl: process.env.TESTOMATIO_URL || 'https://app.testomat.io', runId: runArg };
29
+ }
30
+
31
+ /**
32
+ * Collect the statuses passed with --only and --only-failed.
33
+ *
34
+ * @param {{only?: string, onlyFailed?: boolean}} opts
35
+ * @returns {string[]}
36
+ */
37
+ function parseStatuses(opts) {
38
+ const statuses = String(opts.only || '')
39
+ .split(',')
40
+ .map(status => status.trim().toLowerCase())
41
+ .filter(Boolean);
42
+ if (opts.onlyFailed) statuses.push('failed');
43
+
44
+ const unknown = statuses.filter(status => !TESTRUN_STATUSES.includes(status));
45
+ if (unknown.length) {
46
+ throw new Error(`Unknown status: ${unknown.join(', ')}. Use one of: ${TESTRUN_STATUSES.join(', ')}`);
47
+ }
48
+
49
+ return [...new Set(statuses)];
50
+ }
51
+
52
+ /**
53
+ * @param {{limit?: string, all?: boolean}} opts - Values of the CLI --limit and --all options.
54
+ * @returns {number} Infinity for --all
55
+ */
56
+ function parseTestrunsLimit(opts) {
57
+ if (opts.all) return Infinity;
58
+ return Math.max(parseInt(opts.limit, 10) || TESTRUNS_DEFAULT_LIMIT, 1);
59
+ }
60
+
61
+ /**
62
+ * Run `fn` over `items` with at most `limit` calls in flight; results keep the order of `items`.
63
+ *
64
+ * @template T, R
65
+ * @param {T[]} items
66
+ * @param {number} limit
67
+ * @param {(item: T) => Promise<R>} fn
68
+ * @returns {Promise<R[]>}
69
+ */
70
+ async function mapWithLimit(items, limit, fn) {
71
+ const results = new Array(items.length);
72
+ let next = 0;
73
+ const worker = async () => {
74
+ while (next < items.length) {
75
+ const index = next++;
76
+ results[index] = await fn(items[index]);
77
+ }
78
+ };
79
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
80
+ return results;
81
+ }
82
+
83
+ /**
84
+ * Read-only client for a single run and its testruns in API v2.
85
+ */
86
+ class RunFetcher {
87
+ /**
88
+ * @param {{baseUrl: string, project?: string, runId: string, apiKey: string}} params
89
+ */
90
+ constructor({ baseUrl, project, runId, apiKey }) {
91
+ this.baseUrl = baseUrl;
92
+ this.project = project;
93
+ this.runId = runId;
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;
127
+ }
128
+
129
+ get runUrl() {
130
+ return new URL(`/projects/${this.project}/runs/${this.runId}`, this.baseUrl).toString();
131
+ }
132
+
133
+ async get(apiPath, params = []) {
134
+ await this.resolveProject();
135
+ const url = new URL(`/api/v2/${this.project}${apiPath}`, this.baseUrl);
136
+ for (const [key, value] of params) url.searchParams.append(key, value);
137
+ debug('GET', url.toString());
138
+
139
+ const response = await fetch(url, { headers: { Authorization: `Bearer ${this.apiKey}` } });
140
+ const body = await response.json().catch(() => ({}));
141
+ if (response.status === 404 && apiPath === `/runs/${this.runId}`) {
142
+ throw new Error(`Run ${this.runId} not found in project ${this.project}`);
143
+ }
144
+ if (!response.ok) throw new Error(`${response.status} ${body.error || response.statusText}`);
145
+ return body;
146
+ }
147
+
148
+ async fetchRun() {
149
+ const body = await this.get(`/runs/${this.runId}`);
150
+ return body.data;
151
+ }
152
+
153
+ /**
154
+ * @param {string[]} statuses
155
+ * @returns {string[][]}
156
+ */
157
+ testrunParams(statuses = []) {
158
+ const params = [['run_id', this.runId]];
159
+ for (const status of statuses) params.push(['filter[status][]', status]);
160
+ return params;
161
+ }
162
+
163
+ /**
164
+ * @param {string[]} [statuses]
165
+ * @returns {Promise<number>}
166
+ */
167
+ async countTestruns(statuses = []) {
168
+ const body = await this.get('/testruns', [...this.testrunParams(statuses), ['count', 'true']]);
169
+ return body.meta?.total || 0;
170
+ }
171
+
172
+ /**
173
+ * @returns {Promise<{total: number, byStatus: Record<string, number>}>}
174
+ */
175
+ async countTestrunsByStatus() {
176
+ const body = await this.get('/testruns', [...this.testrunParams(), ['count', 'true'], ['group_by', 'status']]);
177
+ return { total: body.meta?.total || 0, byStatus: body.meta?.group_by || {} };
178
+ }
179
+
180
+ /**
181
+ * Testruns with failures first, read in pages of TESTRUNS_PAGE_SIZE; the list has no stack and no artifacts.
182
+ *
183
+ * @param {{statuses?: string[], limit: number}} params - limit is Infinity to read all testruns
184
+ * @returns {Promise<{testruns: object[], total: number}>}
185
+ */
186
+ async listTestruns({ statuses = [], limit }) {
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 };
206
+ }
207
+
208
+ /**
209
+ * Add `stack` and `artifacts` to each testrun; a testrun that fails to load is kept as is.
210
+ *
211
+ * @param {object[]} testruns
212
+ * @param {(testrun: object, err: Error) => void} onError
213
+ */
214
+ async addTestrunDetails(testruns, onError) {
215
+ return mapWithLimit(testruns, PARALLEL_REQUESTS, async testrun => {
216
+ try {
217
+ const body = await this.get(`/testruns/${testrun.id}`);
218
+ return { ...testrun, stack: body.data?.stack, artifacts: body.data?.artifacts || [] };
219
+ } catch (err) {
220
+ onError(testrun, err);
221
+ return testrun;
222
+ }
223
+ });
224
+ }
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
+
269
+ /**
270
+ * Shared S3 credentials of the project, returned by an empty update of the run.
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).
273
+ *
274
+ * @param {{status?: string, kind?: string}} run
275
+ * @returns {Promise<boolean>} whether credentials were applied
276
+ */
277
+ async applySharedS3Credentials(run) {
278
+ const isUnfinishedMixed = run.kind === 'mixed' && !FINISHED_RUN_STATUSES.includes(run.status);
279
+ if (!this.reporterRun && (run.status === 'scheduled' || isUnfinishedMixed)) {
280
+ debug(`Shared S3 credentials are not requested for a ${run.status} ${run.kind} run`);
281
+ return false;
282
+ }
283
+
284
+ const body = await this.updateReporterRun().catch(err => {
285
+ debug('Failed to get shared S3 credentials:', err.message);
286
+ return { artifacts: null };
287
+ });
288
+
289
+ const { artifacts } = body;
290
+ if (!artifacts || !Object.keys(artifacts).length) return false;
291
+ setS3Credentials(artifacts);
292
+ return true;
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Download the artifacts of each testrun to `<outDir>/<testrun id>/<file name>`.
298
+ * Each artifact becomes `{name, href}`, where `href` is the local path relative to the current directory,
299
+ * or the remote URL when the download failed.
300
+ *
301
+ * @param {object[]} testruns
302
+ * @param {string} outDir
303
+ * @param {(url: string, err: Error) => void} onError
304
+ */
305
+ async function downloadArtifacts(testruns, outDir, onError) {
306
+ const uploader = new S3Uploader();
307
+ const jobs = [];
308
+
309
+ // the file name is parsed inside mapWithLimit, so a malformed artifact URL only skips that artifact
310
+ for (const testrun of testruns) {
311
+ const usedNames = new Set();
312
+ for (const url of testrun.artifacts || []) {
313
+ jobs.push({ testrun, url, outDir, usedNames });
314
+ }
315
+ }
316
+
317
+ const downloaded = await mapWithLimit(jobs, PARALLEL_REQUESTS, async job => {
318
+ let name = 'artifact';
319
+ try {
320
+ name = decodeURIComponent(path.basename(new URL(job.url).pathname)) || 'artifact';
321
+ if (job.usedNames.has(name)) name = `${job.usedNames.size}-${name}`;
322
+ job.usedNames.add(name);
323
+ const filePath = path.join(job.outDir, String(job.testrun.id), name);
324
+ await uploader.downloadFile(job.url, filePath);
325
+ const href = `./${path.relative(process.cwd(), filePath).split(path.sep).join('/')}`;
326
+ return { testrun: job.testrun, href, name };
327
+ } catch (err) {
328
+ onError(job.url, err);
329
+ return { testrun: job.testrun, href: job.url, name: `${name} (not downloaded)` };
330
+ }
331
+ });
332
+
333
+ return testruns.map(testrun => ({
334
+ ...testrun,
335
+ artifacts: downloaded.filter(job => job.testrun === testrun).map(({ name, href }) => ({ name, href })),
336
+ }));
337
+ }
338
+
339
+ /**
340
+ * @param {{title?: string}} run
341
+ * @param {string} runId
342
+ * @returns {string}
343
+ */
344
+ function defaultArtifactsDir(run, runId) {
345
+ const slug = String(run.title || 'run')
346
+ .toLowerCase()
347
+ .replace(/[^a-z0-9]+/g, '-')
348
+ .replace(/^-+|-+$/g, '')
349
+ .slice(0, 60);
350
+ return `./${slug || 'run'}-${runId}`;
351
+ }
352
+
353
+ export {
354
+ TESTRUNS_DEFAULT_LIMIT,
355
+ TESTRUNS_PAGE_SIZE,
356
+ TESTRUN_STATUSES,
357
+ RunFetcher,
358
+ resolveRunTarget,
359
+ parseStatuses,
360
+ parseTestrunsLimit,
361
+ mapWithLimit,
362
+ downloadArtifacts,
363
+ defaultArtifactsDir,
364
+ };
@@ -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
 
@@ -521,6 +544,11 @@ function normalizeArtifact(raw) {
521
544
  return { name: path.basename(abs), href, isImage: looksLikeImage(abs) };
522
545
  }
523
546
 
547
+ // already resolved link, e.g. a downloaded artifact relative to the report
548
+ if (typeof raw.href === 'string' && raw.href) {
549
+ return { name: raw.name || path.basename(raw.href), href: raw.href, isImage: looksLikeImage(raw.href) };
550
+ }
551
+
524
552
  const rawPath = raw.path || raw.link || raw.url;
525
553
  if (!rawPath || typeof rawPath !== 'string') return null;
526
554
 
@@ -750,4 +778,6 @@ function aggregateTestRetries(tests) {
750
778
  return aggregated;
751
779
  }
752
780
 
781
+ export { renderTests, defectLink };
782
+
753
783
  export default MarkdownPipe;
@@ -1,8 +1,11 @@
1
+ import { createRequire } from 'module';
1
2
  import { playwrightLogsMarkers } from './adapter/utils/playwright.js';
2
3
  import { isPlaywright } from './helpers.js';
3
4
  import { services } from './services/index.js';
4
5
  import pc from 'picocolors';
5
6
 
7
+ const requireModule = createRequire(`${process.cwd()}/package.json`);
8
+
6
9
  /**
7
10
  * Stores path to file as artifact and uploads it to the S3 storage
8
11
  * @param {string | {path: string, type: string, name: string}} data - path to file or object with path, type and name
@@ -44,6 +47,36 @@ function addStep(message, logs) {
44
47
  else services.logger.step(message, logs);
45
48
  }
46
49
 
50
+ /**
51
+ * Returns the currently running Vitest test task, so `meta()` (and similar
52
+ * functions) can attach data to it. The task `.meta` object is serialized by
53
+ * Vitest back to the reporter (works across all pools: threads, forks, vmThreads).
54
+ *
55
+ * Resolution order matters:
56
+ * 1. `globalThis.__vitest_worker__.current` — set by Vitest in every worker and
57
+ * independent of module resolution, so it works even when the reporter is the
58
+ * externalized package and `@vitest/runner` is deduped to a different instance
59
+ * (monorepos, pnpm, hoisting). This is also populated inside hooks (afterEach),
60
+ * pointing at the test that the hook belongs to.
61
+ * 2. `@vitest/runner` `getCurrentTest()` — fallback for setups/versions where the
62
+ * worker global is not exposed.
63
+ *
64
+ * @returns {any | null} Vitest test task or null when no test is active
65
+ */
66
+ function getCurrentVitestTest() {
67
+ // @ts-ignore - injected by Vitest into the worker global scope
68
+ const worker = globalThis.__vitest_worker__;
69
+ const current = worker?.current;
70
+ if (current && (current.type === 'test' || current.type === undefined)) return current;
71
+
72
+ try {
73
+ const test = requireModule('@vitest/runner').getCurrentTest?.();
74
+ if (test) return test;
75
+ } catch {}
76
+
77
+ return null;
78
+ }
79
+
47
80
  /**
48
81
  * Add key-value pair(s) to the test report
49
82
  * @param {{[key: string]: string} | string} keyValue - object { key: value } (multiple props allowed) OR key (string)
@@ -67,6 +100,14 @@ function setKeyValue(keyValue, value = undefined) {
67
100
  return;
68
101
  }
69
102
 
103
+ if (process.env.VITEST || process.env.VITEST_WORKER_ID) {
104
+ const vitestTest = getCurrentVitestTest();
105
+ if (vitestTest) {
106
+ Object.assign((vitestTest.meta ||= {}), keyValue);
107
+ return;
108
+ }
109
+ }
110
+
70
111
  // in this case keyValue is expected to be an object
71
112
  services.keyValues.put(keyValue);
72
113
  }
package/src/uploader.js CHANGED
@@ -347,6 +347,33 @@ export class S3Uploader {
347
347
  );
348
348
  }
349
349
 
350
+ /**
351
+ * Download an artifact by its link: from the configured bucket through S3, otherwise over plain HTTP.
352
+ *
353
+ * @param {string} link
354
+ * @param {string} filePath
355
+ */
356
+ async downloadFile(link, filePath) {
357
+ const { S3_BUCKET } = this.getConfig();
358
+ const Key = getS3KeyFromUrl(link, S3_BUCKET);
359
+ let body;
360
+
361
+ if (Key) {
362
+ debug('Downloading from S3:', Key);
363
+ const s3 = new S3(this.#getS3Config());
364
+ const object = await s3.getObject({ Bucket: S3_BUCKET, Key });
365
+ body = Buffer.from(await object.Body.transformToByteArray());
366
+ } else {
367
+ debug('Downloading over HTTP:', link);
368
+ const response = await fetch(link);
369
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
370
+ body = Buffer.from(await response.arrayBuffer());
371
+ }
372
+
373
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
374
+ fs.writeFileSync(filePath, body);
375
+ }
376
+
350
377
  async getS3LocationLink(out) {
351
378
  const response = await out.done();
352
379
 
@@ -409,3 +436,27 @@ export class S3Uploader {
409
436
  return cfg;
410
437
  }
411
438
  }
439
+
440
+ /**
441
+ * Object key of an artifact link in the given bucket, for both virtual-hosted (bucket.host/key)
442
+ * and path style (host/bucket/key) links; null when the link points to another bucket.
443
+ *
444
+ * @param {string} link
445
+ * @param {string} [bucket]
446
+ * @returns {string|null}
447
+ */
448
+ export function getS3KeyFromUrl(link, bucket) {
449
+ if (!bucket) return null;
450
+
451
+ let url;
452
+ try {
453
+ url = new URL(link);
454
+ } catch (e) {
455
+ return null;
456
+ }
457
+
458
+ const objectPath = decodeURIComponent(url.pathname.slice(1));
459
+ if (url.hostname.startsWith(`${bucket}.`)) return objectPath || null;
460
+ if (objectPath.startsWith(`${bucket}/`)) return objectPath.slice(bucket.length + 1) || null;
461
+ return null;
462
+ }