@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
@@ -0,0 +1,286 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.RunFetcher = exports.TESTRUN_STATUSES = exports.TESTRUNS_MAX_LIMIT = exports.TESTRUNS_DEFAULT_LIMIT = void 0;
7
+ exports.resolveRunTarget = resolveRunTarget;
8
+ exports.parseStatuses = parseStatuses;
9
+ exports.parseTestrunsLimit = parseTestrunsLimit;
10
+ exports.mapWithLimit = mapWithLimit;
11
+ exports.downloadArtifacts = downloadArtifacts;
12
+ module.exportsArtifactsDir = defaultArtifactsDir;
13
+ const debug_1 = __importDefault(require("debug"));
14
+ const path_1 = __importDefault(require("path"));
15
+ const pipe_utils_js_1 = require("../utils/pipe_utils.js");
16
+ const uploader_js_1 = require("../uploader.js");
17
+ const debug = (0, debug_1.default)('@testomatio/reporter:fetch');
18
+ const TESTRUNS_DEFAULT_LIMIT = 50;
19
+ exports.TESTRUNS_DEFAULT_LIMIT = TESTRUNS_DEFAULT_LIMIT;
20
+ const TESTRUNS_MAX_LIMIT = 100;
21
+ exports.TESTRUNS_MAX_LIMIT = TESTRUNS_MAX_LIMIT;
22
+ const TESTRUN_STATUSES = ['passed', 'failed', 'skipped', 'pending'];
23
+ exports.TESTRUN_STATUSES = TESTRUN_STATUSES;
24
+ const PARALLEL_REQUESTS = 5;
25
+ /**
26
+ * Resolve the run argument of `fetch status|count|report`: a run id or a run URL.
27
+ * A URL also sets the server and the project.
28
+ *
29
+ * @param {string} runArg
30
+ * @param {{project?: string}} opts
31
+ * @returns {{baseUrl: string, project: string, runId: string}}
32
+ */
33
+ function resolveRunTarget(runArg, opts) {
34
+ if (/^https?:\/\//i.test(runArg)) {
35
+ const page = (0, pipe_utils_js_1.parseRunsUrl)(runArg);
36
+ if (!page?.runId)
37
+ throw new Error(`Not a Testomat.io run URL: ${runArg}`);
38
+ return { baseUrl: page.baseUrl, project: opts.project || page.project, runId: page.runId };
39
+ }
40
+ const project = opts.project || process.env.TESTOMATIO_PROJECT;
41
+ if (!project)
42
+ throw new Error('--project (or TESTOMATIO_PROJECT) is required');
43
+ return { baseUrl: process.env.TESTOMATIO_URL || 'https://app.testomat.io', project, runId: runArg };
44
+ }
45
+ /**
46
+ * Collect the statuses passed with --only and --only-failed.
47
+ *
48
+ * @param {{only?: string, onlyFailed?: boolean}} opts
49
+ * @returns {string[]}
50
+ */
51
+ function parseStatuses(opts) {
52
+ const statuses = String(opts.only || '')
53
+ .split(',')
54
+ .map(status => status.trim().toLowerCase())
55
+ .filter(Boolean);
56
+ if (opts.onlyFailed)
57
+ statuses.push('failed');
58
+ const unknown = statuses.filter(status => !TESTRUN_STATUSES.includes(status));
59
+ if (unknown.length) {
60
+ throw new Error(`Unknown status: ${unknown.join(', ')}. Use one of: ${TESTRUN_STATUSES.join(', ')}`);
61
+ }
62
+ return [...new Set(statuses)];
63
+ }
64
+ /**
65
+ * @param {string} [value] - Value of the CLI --limit option.
66
+ * @returns {{limit: number, capped: boolean}}
67
+ */
68
+ function parseTestrunsLimit(value) {
69
+ const requested = Math.max(parseInt(value, 10) || TESTRUNS_DEFAULT_LIMIT, 1);
70
+ return { limit: Math.min(requested, TESTRUNS_MAX_LIMIT), capped: requested > TESTRUNS_MAX_LIMIT };
71
+ }
72
+ /**
73
+ * Run `fn` over `items` with at most `limit` calls in flight; results keep the order of `items`.
74
+ *
75
+ * @template T, R
76
+ * @param {T[]} items
77
+ * @param {number} limit
78
+ * @param {(item: T) => Promise<R>} fn
79
+ * @returns {Promise<R[]>}
80
+ */
81
+ async function mapWithLimit(items, limit, fn) {
82
+ const results = new Array(items.length);
83
+ let next = 0;
84
+ const worker = async () => {
85
+ while (next < items.length) {
86
+ const index = next++;
87
+ results[index] = await fn(items[index]);
88
+ }
89
+ };
90
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
91
+ return results;
92
+ }
93
+ /**
94
+ * Read-only client for a single run and its testruns in API v2.
95
+ */
96
+ class RunFetcher {
97
+ /**
98
+ * @param {{baseUrl: string, project: string, runId: string, apiKey: string}} params
99
+ */
100
+ constructor({ baseUrl, project, runId, apiKey }) {
101
+ this.baseUrl = baseUrl;
102
+ this.project = project;
103
+ this.runId = runId;
104
+ this.apiKey = apiKey;
105
+ }
106
+ get runUrl() {
107
+ return new URL(`/projects/${this.project}/runs/${this.runId}`, this.baseUrl).toString();
108
+ }
109
+ async get(apiPath, params = []) {
110
+ const url = new URL(`/api/v2/${this.project}${apiPath}`, this.baseUrl);
111
+ for (const [key, value] of params)
112
+ url.searchParams.append(key, value);
113
+ debug('GET', url.toString());
114
+ const response = await fetch(url, { headers: { Authorization: `Bearer ${this.apiKey}` } });
115
+ const body = await response.json().catch(() => ({}));
116
+ if (response.status === 404 && apiPath === `/runs/${this.runId}`) {
117
+ throw new Error(`Run ${this.runId} not found in project ${this.project}`);
118
+ }
119
+ if (!response.ok)
120
+ throw new Error(`${response.status} ${body.error || response.statusText}`);
121
+ return body;
122
+ }
123
+ async fetchRun() {
124
+ const body = await this.get(`/runs/${this.runId}`);
125
+ return body.data;
126
+ }
127
+ /**
128
+ * @param {string[]} statuses
129
+ * @returns {string[][]}
130
+ */
131
+ testrunParams(statuses = []) {
132
+ const params = [['run_id', this.runId]];
133
+ for (const status of statuses)
134
+ params.push(['filter[status][]', status]);
135
+ return params;
136
+ }
137
+ /**
138
+ * @param {string[]} [statuses]
139
+ * @returns {Promise<number>}
140
+ */
141
+ async countTestruns(statuses = []) {
142
+ const body = await this.get('/testruns', [...this.testrunParams(statuses), ['count', 'true']]);
143
+ return body.meta?.total || 0;
144
+ }
145
+ /**
146
+ * @returns {Promise<{total: number, byStatus: Record<string, number>}>}
147
+ */
148
+ async countTestrunsByStatus() {
149
+ const body = await this.get('/testruns', [...this.testrunParams(), ['count', 'true'], ['group_by', 'status']]);
150
+ return { total: body.meta?.total || 0, byStatus: body.meta?.group_by || {} };
151
+ }
152
+ /**
153
+ * Testruns with failures first; the list has no stack and no artifacts.
154
+ *
155
+ * @param {{statuses?: string[], limit: number}} params
156
+ * @returns {Promise<{testruns: object[], total: number}>}
157
+ */
158
+ async listTestruns({ statuses = [], limit }) {
159
+ const body = await this.get('/testruns', [
160
+ ...this.testrunParams(statuses),
161
+ ['sort', 'failure'],
162
+ ['order', 'desc'],
163
+ ['per_page', String(limit)],
164
+ ]);
165
+ return { testruns: body.data || [], total: body.meta?.total || 0 };
166
+ }
167
+ /**
168
+ * Add `stack` and `artifacts` to each testrun; a testrun that fails to load is kept as is.
169
+ *
170
+ * @param {object[]} testruns
171
+ * @param {(testrun: object, err: Error) => void} onError
172
+ */
173
+ async addTestrunDetails(testruns, onError) {
174
+ return mapWithLimit(testruns, PARALLEL_REQUESTS, async (testrun) => {
175
+ try {
176
+ const body = await this.get(`/testruns/${testrun.id}`);
177
+ return { ...testrun, stack: body.data?.stack, artifacts: body.data?.artifacts || [] };
178
+ }
179
+ catch (err) {
180
+ onError(testrun, err);
181
+ return testrun;
182
+ }
183
+ });
184
+ }
185
+ /**
186
+ * Shared S3 credentials of the project, returned by an empty update of the run.
187
+ * Skipped for scheduled runs (the update launches them) and mixed runs (it marks automated tests as started).
188
+ *
189
+ * @param {{status?: string, kind?: string}} run
190
+ * @returns {Promise<boolean>} whether credentials were applied
191
+ */
192
+ async applySharedS3Credentials(run) {
193
+ if (run.status === 'scheduled' || run.kind === 'mixed') {
194
+ debug(`Shared S3 credentials are not requested for a ${run.status} ${run.kind} run`);
195
+ return false;
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
+ const { artifacts } = await response.json();
208
+ if (!artifacts || !Object.keys(artifacts).length)
209
+ return false;
210
+ (0, pipe_utils_js_1.setS3Credentials)(artifacts);
211
+ return true;
212
+ }
213
+ }
214
+ exports.RunFetcher = RunFetcher;
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 uploader_js_1.S3Uploader();
226
+ const jobs = [];
227
+ // the file name is parsed inside mapWithLimit, so a malformed artifact URL only skips that artifact
228
+ for (const testrun of testruns) {
229
+ const usedNames = new Set();
230
+ for (const url of testrun.artifacts || []) {
231
+ jobs.push({ testrun, url, outDir, usedNames });
232
+ }
233
+ }
234
+ const downloaded = await mapWithLimit(jobs, PARALLEL_REQUESTS, async (job) => {
235
+ let name = 'artifact';
236
+ try {
237
+ name = decodeURIComponent(path_1.default.basename(new URL(job.url).pathname)) || 'artifact';
238
+ if (job.usedNames.has(name))
239
+ name = `${job.usedNames.size}-${name}`;
240
+ job.usedNames.add(name);
241
+ const filePath = path_1.default.join(job.outDir, String(job.testrun.id), name);
242
+ await uploader.downloadFile(job.url, filePath);
243
+ const href = `./${path_1.default.relative(process.cwd(), filePath).split(path_1.default.sep).join('/')}`;
244
+ return { testrun: job.testrun, href, name };
245
+ }
246
+ catch (err) {
247
+ onError(job.url, err);
248
+ return { testrun: job.testrun, href: job.url, name: `${name} (not downloaded)` };
249
+ }
250
+ });
251
+ return testruns.map(testrun => ({
252
+ ...testrun,
253
+ artifacts: downloaded.filter(job => job.testrun === testrun).map(({ name, href }) => ({ name, href })),
254
+ }));
255
+ }
256
+ /**
257
+ * @param {{title?: string}} run
258
+ * @param {string} runId
259
+ * @returns {string}
260
+ */
261
+ function defaultArtifactsDir(run, runId) {
262
+ const slug = String(run.title || 'run')
263
+ .toLowerCase()
264
+ .replace(/[^a-z0-9]+/g, '-')
265
+ .replace(/^-+|-+$/g, '')
266
+ .slice(0, 60);
267
+ return `./${slug || 'run'}-${runId}`;
268
+ }
269
+
270
+ module.exports.resolveRunTarget = resolveRunTarget;
271
+
272
+ module.exports.parseStatuses = parseStatuses;
273
+
274
+ module.exports.parseTestrunsLimit = parseTestrunsLimit;
275
+
276
+ module.exports.mapWithLimit = mapWithLimit;
277
+
278
+ module.exports.downloadArtifacts = downloadArtifacts;
279
+
280
+ module.exports.TESTRUNS_DEFAULT_LIMIT = TESTRUNS_DEFAULT_LIMIT;
281
+
282
+ module.exports.TESTRUNS_MAX_LIMIT = TESTRUNS_MAX_LIMIT;
283
+
284
+ module.exports.TESTRUN_STATUSES = TESTRUN_STATUSES;
285
+
286
+ module.exports.RunFetcher = RunFetcher;
@@ -1,4 +1,5 @@
1
1
  export default MarkdownPipe;
2
+ export function renderTests(tests: any): string;
2
3
  declare class MarkdownPipe {
3
4
  constructor(params: any, store?: {});
4
5
  store: {};
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.renderTests = renderTests;
6
7
  const debug_1 = __importDefault(require("debug"));
7
8
  const lodash_merge_1 = __importDefault(require("lodash.merge"));
8
9
  const fs_1 = __importDefault(require("fs"));
@@ -481,6 +482,10 @@ function normalizeArtifact(raw) {
481
482
  }
482
483
  return { name: path_1.default.basename(abs), href, isImage: looksLikeImage(abs) };
483
484
  }
485
+ // already resolved link, e.g. a downloaded artifact relative to the report
486
+ if (typeof raw.href === 'string' && raw.href) {
487
+ return { name: raw.name || path_1.default.basename(raw.href), href: raw.href, isImage: looksLikeImage(raw.href) };
488
+ }
484
489
  const rawPath = raw.path || raw.link || raw.url;
485
490
  if (!rawPath || typeof rawPath !== 'string')
486
491
  return null;
@@ -712,3 +717,5 @@ function aggregateTestRetries(tests) {
712
717
  return aggregated;
713
718
  }
714
719
  module.exports = MarkdownPipe;
720
+
721
+ module.exports.renderTests = renderTests;
@@ -317,6 +317,9 @@ class TestomatioPipe {
317
317
  (0, pipe_utils_js_1.setS3Credentials)(resp.data.artifacts);
318
318
  this.#startS3CredentialsRefresh(runParams);
319
319
  }
320
+ else {
321
+ this.#warnNoS3Credentials();
322
+ }
320
323
  if (resp.data.url) {
321
324
  const respUrl = new URL(resp.data.url);
322
325
  this.runUrl = `${this.url}${respUrl.pathname}`;
@@ -345,6 +348,9 @@ class TestomatioPipe {
345
348
  (0, pipe_utils_js_1.setS3Credentials)(resp.data.artifacts);
346
349
  this.#startS3CredentialsRefresh(runParams);
347
350
  }
351
+ else {
352
+ this.#warnNoS3Credentials();
353
+ }
348
354
  this.store.runUrl = this.runUrl;
349
355
  this.store.runPublicUrl = this.runPublicUrl;
350
356
  this.store.runId = this.runId;
@@ -403,9 +409,19 @@ class TestomatioPipe {
403
409
  }
404
410
  }
405
411
  catch (err) {
406
- debug('Failed to refresh S3 credentials:', err.message || err);
412
+ log_js_1.log.warn('⚠️ Failed to refresh S3 credentials:', err.message || err, '\nArtifact uploads may fail once the current credentials expire.');
407
413
  }
408
414
  };
415
+ /**
416
+ * Warns the user when Testomat.io did not return S3 credentials for artifacts upload.
417
+ * Skipped when artifacts uploading was intentionally disabled or a custom S3 storage is configured via env.
418
+ */
419
+ #warnNoS3Credentials() {
420
+ if (process.env.TESTOMATIO_DISABLE_ARTIFACTS || process.env.S3_BUCKET)
421
+ return;
422
+ const command = `TESTOMATIO=<your_api_key> TESTOMATIO_RUN=${this.runId || '<run_id>'} npx @testomatio/reporter upload-artifacts`;
423
+ log_js_1.log.warn(`⚠️ ${picocolors_1.default.yellow('Testomat.io did not return S3 credentials for artifacts upload.')}`, 'Artifacts will not be uploaded.', 'Please check the artifact sharing settings of your project.', `\nRun "${picocolors_1.default.magenta(command)}" later with valid S3 credentials to re-upload artifacts to this run.`);
424
+ }
409
425
  #stopS3CredentialsRefresh() {
410
426
  if (this.s3CredentialsRefreshTimer) {
411
427
  clearInterval(this.s3CredentialsRefreshTimer);
package/lib/replay.js CHANGED
@@ -231,6 +231,7 @@ class Replay {
231
231
  process.env.TESTOMATIO_REPLAY = '1';
232
232
  const client = new client_js_1.default({
233
233
  apiKey: this.apiKey,
234
+ adapter: 'replay',
234
235
  batchMode: constants_js_1.BATCH_MODE.AUTO,
235
236
  ...runParams,
236
237
  ...(runId && { runId }),
@@ -3,10 +3,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ const module_1 = require("module");
6
7
  const playwright_js_1 = require("./adapter/utils/playwright.js");
7
8
  const helpers_js_1 = require("./helpers.js");
8
9
  const index_js_1 = require("./services/index.js");
9
10
  const picocolors_1 = __importDefault(require("picocolors"));
11
+ const requireModule = (0, module_1.createRequire)(`${process.cwd()}/package.json`);
10
12
  /**
11
13
  * Stores path to file as artifact and uploads it to the S3 storage
12
14
  * @param {string | {path: string, type: string, name: string}} data - path to file or object with path, type and name
@@ -47,6 +49,36 @@ function addStep(message, logs) {
47
49
  else
48
50
  index_js_1.services.logger.step(message, logs);
49
51
  }
52
+ /**
53
+ * Returns the currently running Vitest test task, so `meta()` (and similar
54
+ * functions) can attach data to it. The task `.meta` object is serialized by
55
+ * Vitest back to the reporter (works across all pools: threads, forks, vmThreads).
56
+ *
57
+ * Resolution order matters:
58
+ * 1. `globalThis.__vitest_worker__.current` — set by Vitest in every worker and
59
+ * independent of module resolution, so it works even when the reporter is the
60
+ * externalized package and `@vitest/runner` is deduped to a different instance
61
+ * (monorepos, pnpm, hoisting). This is also populated inside hooks (afterEach),
62
+ * pointing at the test that the hook belongs to.
63
+ * 2. `@vitest/runner` `getCurrentTest()` — fallback for setups/versions where the
64
+ * worker global is not exposed.
65
+ *
66
+ * @returns {any | null} Vitest test task or null when no test is active
67
+ */
68
+ function getCurrentVitestTest() {
69
+ // @ts-ignore - injected by Vitest into the worker global scope
70
+ const worker = globalThis.__vitest_worker__;
71
+ const current = worker?.current;
72
+ if (current && (current.type === 'test' || current.type === undefined))
73
+ return current;
74
+ try {
75
+ const test = requireModule('@vitest/runner').getCurrentTest?.();
76
+ if (test)
77
+ return test;
78
+ }
79
+ catch { }
80
+ return null;
81
+ }
50
82
  /**
51
83
  * Add key-value pair(s) to the test report
52
84
  * @param {{[key: string]: string} | string} keyValue - object { key: value } (multiple props allowed) OR key (string)
@@ -68,6 +100,13 @@ function setKeyValue(keyValue, value = undefined) {
68
100
  console.log(`${playwright_js_1.playwrightLogsMarkers.meta} ${JSON.stringify(keyValue)}`);
69
101
  return;
70
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
+ }
71
110
  // in this case keyValue is expected to be an object
72
111
  index_js_1.services.keyValues.put(keyValue);
73
112
  }
package/lib/uploader.d.ts CHANGED
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Object key of an artifact link in the given bucket, for both virtual-hosted (bucket.host/key)
3
+ * and path style (host/bucket/key) links; null when the link points to another bucket.
4
+ *
5
+ * @param {string} link
6
+ * @param {string} [bucket]
7
+ * @returns {string|null}
8
+ */
9
+ export function getS3KeyFromUrl(link: string, bucket?: string): string | null;
1
10
  export class S3Uploader {
2
11
  isEnabled: any;
3
12
  storeEnabled: boolean;
@@ -10,6 +19,7 @@ export class S3Uploader {
10
19
  size: number;
11
20
  }[];
12
21
  failedUploads: any[];
22
+ warnedDisabledUpload: boolean;
13
23
  /**
14
24
  * @type {{path: string, size: number, link: string}[]}
15
25
  */
@@ -55,6 +65,13 @@ export class S3Uploader {
55
65
  */
56
66
  uploadFileAsBuffer(buffer: Buffer, pathInS3: string[]): Promise<any>;
57
67
  checkArtifactExistsInFileSystem(filePath: any, attempts?: number, intervalMs?: number): Promise<any>;
68
+ /**
69
+ * Download an artifact by its link: from the configured bucket through S3, otherwise over plain HTTP.
70
+ *
71
+ * @param {string} link
72
+ * @param {string} filePath
73
+ */
74
+ downloadFile(link: string, filePath: string): Promise<void>;
58
75
  getS3LocationLink(out: any): Promise<any>;
59
76
  #private;
60
77
  }
package/lib/uploader.js CHANGED
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.S3Uploader = void 0;
7
+ exports.getS3KeyFromUrl = getS3KeyFromUrl;
7
8
  const debug_1 = __importDefault(require("debug"));
8
9
  const client_s3_1 = require("@aws-sdk/client-s3");
9
10
  const lib_storage_1 = require("@aws-sdk/lib-storage");
@@ -26,6 +27,7 @@ class S3Uploader {
26
27
  */
27
28
  this.skippedUploads = [];
28
29
  this.failedUploads = [];
30
+ this.warnedDisabledUpload = false;
29
31
  /**
30
32
  * @type {{path: string, size: number, link: string}[]}
31
33
  */
@@ -84,6 +86,22 @@ class S3Uploader {
84
86
  disableLogStorage() {
85
87
  this.storeEnabled = false;
86
88
  }
89
+ /**
90
+ * Warns once when artifact uploading is disabled not intentionally by the user,
91
+ * e.g. S3 credentials were not obtained from Testomat.io and no custom S3 storage is configured.
92
+ * Without this warning artifacts are skipped silently.
93
+ */
94
+ #warnDisabledUpload(runId) {
95
+ if (this.warnedDisabledUpload)
96
+ return;
97
+ const { S3_BUCKET, TESTOMATIO_DISABLE_ARTIFACTS } = this.getConfig();
98
+ // uploading was intentionally disabled by the user or a custom S3 storage is used, nothing to warn about
99
+ if (TESTOMATIO_DISABLE_ARTIFACTS || S3_BUCKET)
100
+ return;
101
+ this.warnedDisabledUpload = true;
102
+ const command = `TESTOMATIO=<your_api_key> TESTOMATIO_RUN=${runId || '<run_id>'} npx @testomatio/reporter upload-artifacts`;
103
+ log_js_1.log.warn(`🗄️ ${picocolors_1.default.yellow('Artifacts uploading is disabled: S3 credentials were not obtained from Testomat.io')}`, picocolors_1.default.yellow('(and no custom S3 storage is configured).'), 'Artifacts will not be uploaded.', `\nRun "${picocolors_1.default.magenta(command)}" later with valid S3 credentials to re-upload artifacts to this run.`);
104
+ }
87
105
  /**
88
106
  *
89
107
  * @param {*} Body
@@ -202,6 +220,7 @@ class S3Uploader {
202
220
  debug(`File ${filePath} does not exist`);
203
221
  }
204
222
  if (!this.isEnabled) {
223
+ this.#warnDisabledUpload(runId);
205
224
  this.storeUploadedFile(filePath, runId, rid, false);
206
225
  this.skippedUploads.push({ path: filePath, size: fileSize });
207
226
  return;
@@ -239,8 +258,10 @@ class S3Uploader {
239
258
  /* WDIO: some artifacts uploading started before createRun function completion
240
259
  probably, the reason is that run is NOT created in adapter (but via cli) */
241
260
  this.isEnabled = this.isEnabled ?? this.checkEnabled();
242
- if (!this.isEnabled)
261
+ if (!this.isEnabled) {
262
+ this.#warnDisabledUpload(pathInS3[0]);
243
263
  return;
264
+ }
244
265
  let Key = pathInS3.filter(p => !!p).join('/');
245
266
  const ext = this.#getFileExtBase64(buffer.toString('base64'));
246
267
  if (ext) {
@@ -270,6 +291,32 @@ class S3Uploader {
270
291
  maxTimeout: intervalMs,
271
292
  });
272
293
  }
294
+ /**
295
+ * Download an artifact by its link: from the configured bucket through S3, otherwise over plain HTTP.
296
+ *
297
+ * @param {string} link
298
+ * @param {string} filePath
299
+ */
300
+ async downloadFile(link, filePath) {
301
+ const { S3_BUCKET } = this.getConfig();
302
+ const Key = getS3KeyFromUrl(link, S3_BUCKET);
303
+ let body;
304
+ if (Key) {
305
+ debug('Downloading from S3:', Key);
306
+ const s3 = new client_s3_1.S3(this.#getS3Config());
307
+ const object = await s3.getObject({ Bucket: S3_BUCKET, Key });
308
+ body = Buffer.from(await object.Body.transformToByteArray());
309
+ }
310
+ else {
311
+ debug('Downloading over HTTP:', link);
312
+ const response = await fetch(link);
313
+ if (!response.ok)
314
+ throw new Error(`${response.status} ${response.statusText}`);
315
+ body = Buffer.from(await response.arrayBuffer());
316
+ }
317
+ fs_1.default.mkdirSync(path_1.default.dirname(filePath), { recursive: true });
318
+ fs_1.default.writeFileSync(filePath, body);
319
+ }
273
320
  async getS3LocationLink(out) {
274
321
  const response = await out.done();
275
322
  let s3Location = response?.Location?.trim();
@@ -317,5 +364,32 @@ class S3Uploader {
317
364
  }
318
365
  }
319
366
  exports.S3Uploader = S3Uploader;
367
+ /**
368
+ * Object key of an artifact link in the given bucket, for both virtual-hosted (bucket.host/key)
369
+ * and path style (host/bucket/key) links; null when the link points to another bucket.
370
+ *
371
+ * @param {string} link
372
+ * @param {string} [bucket]
373
+ * @returns {string|null}
374
+ */
375
+ function getS3KeyFromUrl(link, bucket) {
376
+ if (!bucket)
377
+ return null;
378
+ let url;
379
+ try {
380
+ url = new URL(link);
381
+ }
382
+ catch (e) {
383
+ return null;
384
+ }
385
+ const objectPath = decodeURIComponent(url.pathname.slice(1));
386
+ if (url.hostname.startsWith(`${bucket}.`))
387
+ return objectPath || null;
388
+ if (objectPath.startsWith(`${bucket}/`))
389
+ return objectPath.slice(bucket.length + 1) || null;
390
+ return null;
391
+ }
392
+
393
+ module.exports.getS3KeyFromUrl = getS3KeyFromUrl;
320
394
 
321
395
  module.exports.S3Uploader = S3Uploader;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @param {string} command
3
+ * @returns {boolean}
4
+ */
5
+ export function isPlaceholderCommand(command: string): boolean;
6
+ /**
7
+ * @param {string} [cwd]
8
+ * @returns {{ name: string, command: string } | null}
9
+ */
10
+ export function detectRunner(cwd?: string): {
11
+ name: string;
12
+ command: string;
13
+ } | null;
14
+ /**
15
+ * @param {string} [cwd]
16
+ * @returns {string}
17
+ */
18
+ export function runnerHint(cwd?: string): string;
19
+ /**
20
+ * @param {string} command
21
+ * @returns {string[]}
22
+ */
23
+ export function buildFilterWarning(command: string): string[];