@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.
- package/lib/adapter/codecept.js +26 -2
- package/lib/adapter/cucumber/current.js +1 -0
- package/lib/adapter/cypress-plugin/index.js +1 -0
- package/lib/adapter/e2e.d.ts +49 -0
- package/lib/adapter/e2e.js +254 -0
- package/lib/adapter/jasmine.js +1 -0
- package/lib/adapter/jest.js +1 -0
- package/lib/adapter/mocha.d.ts +4 -1
- package/lib/adapter/mocha.js +123 -122
- package/lib/adapter/nightwatch.js +1 -0
- package/lib/adapter/playwright.js +33 -1
- package/lib/adapter/vitest.js +5 -1
- package/lib/adapter/webdriver.js +45 -2
- package/lib/allureReader.d.ts +4 -1
- package/lib/allureReader.js +2 -1
- package/lib/bin/cli.js +212 -19
- package/lib/client.d.ts +1 -0
- package/lib/client.js +12 -3
- package/lib/data-storage.js +4 -3
- package/lib/fetch/report.d.ts +31 -0
- package/lib/fetch/report.js +81 -0
- package/lib/fetch/testruns.d.ts +133 -0
- package/lib/fetch/testruns.js +286 -0
- package/lib/pipe/markdown.d.ts +1 -0
- package/lib/pipe/markdown.js +7 -0
- package/lib/pipe/testomatio.js +17 -1
- package/lib/replay.js +1 -0
- package/lib/reporter-functions.js +39 -0
- package/lib/uploader.d.ts +17 -0
- package/lib/uploader.js +75 -1
- package/lib/utils/detectRunner.d.ts +23 -0
- package/lib/utils/detectRunner.js +149 -0
- package/lib/utils/pipe_utils.d.ts +16 -3
- package/lib/utils/pipe_utils.js +45 -4
- package/lib/xmlReader.js +2 -0
- package/package.json +8 -3
- package/src/adapter/codecept.js +24 -2
- package/src/adapter/cucumber/current.js +2 -0
- package/src/adapter/cypress-plugin/index.js +2 -0
- package/src/adapter/e2e.js +248 -0
- package/src/adapter/jasmine.js +2 -0
- package/src/adapter/jest.js +2 -0
- package/src/adapter/mocha.js +146 -145
- package/src/adapter/nightwatch.js +2 -0
- package/src/adapter/playwright.js +33 -1
- package/src/adapter/vitest.js +6 -1
- package/src/adapter/webdriver.js +46 -2
- package/src/allureReader.js +2 -1
- package/src/bin/cli.js +246 -18
- package/src/client.js +11 -3
- package/src/data-storage.js +4 -3
- package/src/fetch/report.js +86 -0
- package/src/fetch/testruns.js +283 -0
- package/src/pipe/markdown.js +7 -0
- package/src/pipe/testomatio.js +27 -1
- package/src/replay.js +1 -0
- package/src/reporter-functions.js +41 -0
- package/src/uploader.js +81 -1
- package/src/utils/detectRunner.js +151 -0
- package/src/utils/pipe_utils.js +45 -4
- package/src/xmlReader.js +3 -0
- package/types/types.d.ts +1 -0
|
@@ -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
|
+
};
|
package/src/pipe/markdown.js
CHANGED
|
@@ -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;
|
package/src/pipe/testomatio.js
CHANGED
|
@@ -352,6 +352,8 @@ class TestomatioPipe {
|
|
|
352
352
|
if (resp.data.artifacts) {
|
|
353
353
|
setS3Credentials(resp.data.artifacts);
|
|
354
354
|
this.#startS3CredentialsRefresh(runParams);
|
|
355
|
+
} else {
|
|
356
|
+
this.#warnNoS3Credentials();
|
|
355
357
|
}
|
|
356
358
|
if (resp.data.url) {
|
|
357
359
|
const respUrl = new URL(resp.data.url);
|
|
@@ -383,6 +385,8 @@ class TestomatioPipe {
|
|
|
383
385
|
if (resp.data.artifacts) {
|
|
384
386
|
setS3Credentials(resp.data.artifacts);
|
|
385
387
|
this.#startS3CredentialsRefresh(runParams);
|
|
388
|
+
} else {
|
|
389
|
+
this.#warnNoS3Credentials();
|
|
386
390
|
}
|
|
387
391
|
|
|
388
392
|
this.store.runUrl = this.runUrl;
|
|
@@ -442,10 +446,32 @@ class TestomatioPipe {
|
|
|
442
446
|
debug('S3 credentials refreshed');
|
|
443
447
|
}
|
|
444
448
|
} catch (err) {
|
|
445
|
-
|
|
449
|
+
log.warn(
|
|
450
|
+
'⚠️ Failed to refresh S3 credentials:',
|
|
451
|
+
err.message || err,
|
|
452
|
+
'\nArtifact uploads may fail once the current credentials expire.',
|
|
453
|
+
);
|
|
446
454
|
}
|
|
447
455
|
};
|
|
448
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Warns the user when Testomat.io did not return S3 credentials for artifacts upload.
|
|
459
|
+
* Skipped when artifacts uploading was intentionally disabled or a custom S3 storage is configured via env.
|
|
460
|
+
*/
|
|
461
|
+
#warnNoS3Credentials() {
|
|
462
|
+
if (process.env.TESTOMATIO_DISABLE_ARTIFACTS || process.env.S3_BUCKET) return;
|
|
463
|
+
|
|
464
|
+
const command = `TESTOMATIO=<your_api_key> TESTOMATIO_RUN=${
|
|
465
|
+
this.runId || '<run_id>'
|
|
466
|
+
} npx @testomatio/reporter upload-artifacts`;
|
|
467
|
+
log.warn(
|
|
468
|
+
`⚠️ ${pc.yellow('Testomat.io did not return S3 credentials for artifacts upload.')}`,
|
|
469
|
+
'Artifacts will not be uploaded.',
|
|
470
|
+
'Please check the artifact sharing settings of your project.',
|
|
471
|
+
`\nRun "${pc.magenta(command)}" later with valid S3 credentials to re-upload artifacts to this run.`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
449
475
|
#stopS3CredentialsRefresh() {
|
|
450
476
|
if (this.s3CredentialsRefreshTimer) {
|
|
451
477
|
clearInterval(this.s3CredentialsRefreshTimer);
|
package/src/replay.js
CHANGED
|
@@ -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
|
@@ -23,6 +23,7 @@ export class S3Uploader {
|
|
|
23
23
|
*/
|
|
24
24
|
this.skippedUploads = [];
|
|
25
25
|
this.failedUploads = [];
|
|
26
|
+
this.warnedDisabledUpload = false;
|
|
26
27
|
/**
|
|
27
28
|
* @type {{path: string, size: number, link: string}[]}
|
|
28
29
|
*/
|
|
@@ -90,6 +91,30 @@ export class S3Uploader {
|
|
|
90
91
|
this.storeEnabled = false;
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Warns once when artifact uploading is disabled not intentionally by the user,
|
|
96
|
+
* e.g. S3 credentials were not obtained from Testomat.io and no custom S3 storage is configured.
|
|
97
|
+
* Without this warning artifacts are skipped silently.
|
|
98
|
+
*/
|
|
99
|
+
#warnDisabledUpload(runId) {
|
|
100
|
+
if (this.warnedDisabledUpload) return;
|
|
101
|
+
|
|
102
|
+
const { S3_BUCKET, TESTOMATIO_DISABLE_ARTIFACTS } = this.getConfig();
|
|
103
|
+
// uploading was intentionally disabled by the user or a custom S3 storage is used, nothing to warn about
|
|
104
|
+
if (TESTOMATIO_DISABLE_ARTIFACTS || S3_BUCKET) return;
|
|
105
|
+
|
|
106
|
+
this.warnedDisabledUpload = true;
|
|
107
|
+
const command = `TESTOMATIO=<your_api_key> TESTOMATIO_RUN=${
|
|
108
|
+
runId || '<run_id>'
|
|
109
|
+
} npx @testomatio/reporter upload-artifacts`;
|
|
110
|
+
log.warn(
|
|
111
|
+
`🗄️ ${pc.yellow('Artifacts uploading is disabled: S3 credentials were not obtained from Testomat.io')}`,
|
|
112
|
+
pc.yellow('(and no custom S3 storage is configured).'),
|
|
113
|
+
'Artifacts will not be uploaded.',
|
|
114
|
+
`\nRun "${pc.magenta(command)}" later with valid S3 credentials to re-upload artifacts to this run.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
93
118
|
/**
|
|
94
119
|
*
|
|
95
120
|
* @param {*} Body
|
|
@@ -231,6 +256,7 @@ export class S3Uploader {
|
|
|
231
256
|
}
|
|
232
257
|
|
|
233
258
|
if (!this.isEnabled) {
|
|
259
|
+
this.#warnDisabledUpload(runId);
|
|
234
260
|
this.storeUploadedFile(filePath, runId, rid, false);
|
|
235
261
|
this.skippedUploads.push({ path: filePath, size: fileSize });
|
|
236
262
|
return;
|
|
@@ -281,7 +307,10 @@ export class S3Uploader {
|
|
|
281
307
|
probably, the reason is that run is NOT created in adapter (but via cli) */
|
|
282
308
|
|
|
283
309
|
this.isEnabled = this.isEnabled ?? this.checkEnabled();
|
|
284
|
-
if (!this.isEnabled)
|
|
310
|
+
if (!this.isEnabled) {
|
|
311
|
+
this.#warnDisabledUpload(pathInS3[0]);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
285
314
|
|
|
286
315
|
let Key = pathInS3.filter(p => !!p).join('/');
|
|
287
316
|
const ext = this.#getFileExtBase64(buffer.toString('base64'));
|
|
@@ -318,6 +347,33 @@ export class S3Uploader {
|
|
|
318
347
|
);
|
|
319
348
|
}
|
|
320
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
|
+
|
|
321
377
|
async getS3LocationLink(out) {
|
|
322
378
|
const response = await out.done();
|
|
323
379
|
|
|
@@ -380,3 +436,27 @@ export class S3Uploader {
|
|
|
380
436
|
return cfg;
|
|
381
437
|
}
|
|
382
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
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
|
|
5
|
+
const LAUNCHER_PREFIXES = ['npx', 'npm', 'yarn', 'pnpm', 'bun', 'node', 'bunx'];
|
|
6
|
+
|
|
7
|
+
const PLACEHOLDER_PHRASE_RE = /^<?-*(?:run|your|test)\s+(?:[\w-]+\s+)?(?:tests?|command|runner)>?$/i;
|
|
8
|
+
|
|
9
|
+
const RUNNERS = [
|
|
10
|
+
{
|
|
11
|
+
name: 'Playwright',
|
|
12
|
+
configs: ['playwright.config.js', 'playwright.config.ts', 'playwright.config.mjs', 'playwright.config.cjs'],
|
|
13
|
+
dependencies: ['@playwright/test'],
|
|
14
|
+
command: 'npx playwright test',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: 'CodeceptJS',
|
|
18
|
+
configs: ['codecept.conf.js', 'codecept.conf.ts'],
|
|
19
|
+
dependencies: ['codeceptjs'],
|
|
20
|
+
command: 'npx codeceptjs run',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'WebdriverIO',
|
|
24
|
+
configs: ['wdio.conf.js', 'wdio.conf.ts'],
|
|
25
|
+
dependencies: ['@wdio/cli'],
|
|
26
|
+
dependencyPrefix: '@wdio/',
|
|
27
|
+
command: 'npx wdio run ./wdio.conf.js',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'Cypress',
|
|
31
|
+
configs: ['cypress.config.js', 'cypress.config.ts'],
|
|
32
|
+
dependencies: ['cypress'],
|
|
33
|
+
command: 'npx cypress run',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'Jest',
|
|
37
|
+
configs: ['jest.config.js', 'jest.config.ts', 'jest.config.mjs', 'jest.config.cjs', 'jest.config.json'],
|
|
38
|
+
dependencies: ['jest'],
|
|
39
|
+
command: 'npx jest',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'Vitest',
|
|
43
|
+
configs: ['vitest.config.js', 'vitest.config.ts', 'vitest.config.mjs'],
|
|
44
|
+
dependencies: ['vitest'],
|
|
45
|
+
command: 'npx vitest run',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'Mocha',
|
|
49
|
+
configs: ['.mocharc.js', '.mocharc.cjs', '.mocharc.json', '.mocharc.yml', '.mocharc.yaml'],
|
|
50
|
+
dependencies: ['mocha'],
|
|
51
|
+
command: 'npx mocha',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'Cucumber',
|
|
55
|
+
configs: ['cucumber.js', 'cucumber.cjs'],
|
|
56
|
+
dependencies: ['@cucumber/cucumber'],
|
|
57
|
+
command: 'npx cucumber-js',
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} command
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
function isPlaceholderCommand(command) {
|
|
66
|
+
const cmd = (command || '').trim();
|
|
67
|
+
if (!cmd) return false;
|
|
68
|
+
|
|
69
|
+
if (cmd.includes('<') || cmd.includes('>')) return true;
|
|
70
|
+
|
|
71
|
+
const firstToken = cmd.split(/\s+/)[0].toLowerCase();
|
|
72
|
+
if (LAUNCHER_PREFIXES.includes(firstToken)) return false;
|
|
73
|
+
|
|
74
|
+
return PLACEHOLDER_PHRASE_RE.test(cmd);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} cwd
|
|
79
|
+
* @returns {Record<string, string>}
|
|
80
|
+
*/
|
|
81
|
+
function readPackageDependencies(cwd) {
|
|
82
|
+
try {
|
|
83
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
84
|
+
return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
85
|
+
} catch {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} [cwd]
|
|
92
|
+
* @returns {{ name: string, command: string } | null}
|
|
93
|
+
*/
|
|
94
|
+
function detectRunner(cwd = process.cwd()) {
|
|
95
|
+
const dependencies = readPackageDependencies(cwd);
|
|
96
|
+
|
|
97
|
+
for (const runner of RUNNERS) {
|
|
98
|
+
const config = runner.configs.find(file => fs.existsSync(path.join(cwd, file)));
|
|
99
|
+
|
|
100
|
+
const hasDependency = runner.dependencies?.some(dep => dependencies[dep])
|
|
101
|
+
|| (runner.dependencyPrefix && Object.keys(dependencies).some(dep => dep.startsWith(runner.dependencyPrefix)));
|
|
102
|
+
|
|
103
|
+
if (!config && !hasDependency) continue;
|
|
104
|
+
|
|
105
|
+
let command = runner.command;
|
|
106
|
+
if (runner.name === 'WebdriverIO') {
|
|
107
|
+
command = `npx wdio run ./${config || 'wdio.conf.js'}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { name: runner.name, command };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {string} [cwd]
|
|
118
|
+
* @returns {string}
|
|
119
|
+
*/
|
|
120
|
+
function runnerHint(cwd = process.cwd()) {
|
|
121
|
+
const runner = detectRunner(cwd);
|
|
122
|
+
if (runner) {
|
|
123
|
+
return `💡 Looks like this project uses ${runner.name}. Try: ${pc.green(runner.command)}`;
|
|
124
|
+
}
|
|
125
|
+
return `💡 Replace the placeholder with your test runner command, e.g. ${pc.green('npx playwright test')}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {string} command
|
|
130
|
+
* @returns {string[]}
|
|
131
|
+
*/
|
|
132
|
+
function buildFilterWarning(command) {
|
|
133
|
+
return [
|
|
134
|
+
'⚠️ The runner exited with a non-zero code while a Testomat.io filter was applied.',
|
|
135
|
+
'Your command was modified to run only tests matching Testomat IDs:',
|
|
136
|
+
` ${pc.bold(command)}`,
|
|
137
|
+
'This usually means no local tests matched the filter:',
|
|
138
|
+
' - tests are not annotated with the matching @Txxxxxx IDs in their titles',
|
|
139
|
+
' - the filter selected tests from another repository or branch',
|
|
140
|
+
' - the test runner does not support the appended --grep / --testNamePattern option',
|
|
141
|
+
'💡 Hint: run the printed command without the appended filter option to see all your tests,',
|
|
142
|
+
'then annotate them with Testomat IDs.',
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export {
|
|
147
|
+
isPlaceholderCommand,
|
|
148
|
+
detectRunner,
|
|
149
|
+
runnerHint,
|
|
150
|
+
buildFilterWarning,
|
|
151
|
+
};
|