@testomatio/reporter 2.20.0 → 2.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/adapter/codecept.js +25 -2
- package/lib/adapter/e2e.js +5 -1
- package/lib/adapter/playwright.js +32 -1
- package/lib/adapter/vitest.js +4 -1
- package/lib/adapter/webdriver.js +44 -2
- package/lib/bin/cli.js +180 -6
- 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/reporter-functions.js +39 -0
- package/lib/uploader.d.ts +16 -0
- package/lib/uploader.js +54 -0
- package/lib/utils/detectRunner.d.ts +23 -0
- package/lib/utils/detectRunner.js +149 -0
- package/package.json +2 -2
- package/src/adapter/codecept.js +22 -2
- package/src/adapter/e2e.js +4 -1
- package/src/adapter/playwright.js +31 -1
- package/src/adapter/vitest.js +4 -1
- package/src/adapter/webdriver.js +44 -2
- package/src/bin/cli.js +216 -7
- package/src/fetch/report.js +86 -0
- package/src/fetch/testruns.js +283 -0
- package/src/pipe/markdown.js +7 -0
- package/src/reporter-functions.js +41 -0
- package/src/uploader.js +51 -0
- package/src/utils/detectRunner.js +151 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
export const TESTRUNS_DEFAULT_LIMIT: 50;
|
|
2
|
+
export const TESTRUNS_MAX_LIMIT: 100;
|
|
3
|
+
export const TESTRUN_STATUSES: string[];
|
|
4
|
+
/**
|
|
5
|
+
* Read-only client for a single run and its testruns in API v2.
|
|
6
|
+
*/
|
|
7
|
+
export class RunFetcher {
|
|
8
|
+
/**
|
|
9
|
+
* @param {{baseUrl: string, project: string, runId: string, apiKey: string}} params
|
|
10
|
+
*/
|
|
11
|
+
constructor({ baseUrl, project, runId, apiKey }: {
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
project: string;
|
|
14
|
+
runId: string;
|
|
15
|
+
apiKey: string;
|
|
16
|
+
});
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
project: string;
|
|
19
|
+
runId: string;
|
|
20
|
+
apiKey: string;
|
|
21
|
+
get runUrl(): string;
|
|
22
|
+
get(apiPath: any, params?: any[]): Promise<any>;
|
|
23
|
+
fetchRun(): Promise<any>;
|
|
24
|
+
/**
|
|
25
|
+
* @param {string[]} statuses
|
|
26
|
+
* @returns {string[][]}
|
|
27
|
+
*/
|
|
28
|
+
testrunParams(statuses?: string[]): string[][];
|
|
29
|
+
/**
|
|
30
|
+
* @param {string[]} [statuses]
|
|
31
|
+
* @returns {Promise<number>}
|
|
32
|
+
*/
|
|
33
|
+
countTestruns(statuses?: string[]): Promise<number>;
|
|
34
|
+
/**
|
|
35
|
+
* @returns {Promise<{total: number, byStatus: Record<string, number>}>}
|
|
36
|
+
*/
|
|
37
|
+
countTestrunsByStatus(): Promise<{
|
|
38
|
+
total: number;
|
|
39
|
+
byStatus: Record<string, number>;
|
|
40
|
+
}>;
|
|
41
|
+
/**
|
|
42
|
+
* Testruns with failures first; the list has no stack and no artifacts.
|
|
43
|
+
*
|
|
44
|
+
* @param {{statuses?: string[], limit: number}} params
|
|
45
|
+
* @returns {Promise<{testruns: object[], total: number}>}
|
|
46
|
+
*/
|
|
47
|
+
listTestruns({ statuses, limit }: {
|
|
48
|
+
statuses?: string[];
|
|
49
|
+
limit: number;
|
|
50
|
+
}): Promise<{
|
|
51
|
+
testruns: object[];
|
|
52
|
+
total: number;
|
|
53
|
+
}>;
|
|
54
|
+
/**
|
|
55
|
+
* Add `stack` and `artifacts` to each testrun; a testrun that fails to load is kept as is.
|
|
56
|
+
*
|
|
57
|
+
* @param {object[]} testruns
|
|
58
|
+
* @param {(testrun: object, err: Error) => void} onError
|
|
59
|
+
*/
|
|
60
|
+
addTestrunDetails(testruns: object[], onError: (testrun: object, err: Error) => void): Promise<any[]>;
|
|
61
|
+
/**
|
|
62
|
+
* Shared S3 credentials of the project, returned by an empty update of the run.
|
|
63
|
+
* Skipped for scheduled runs (the update launches them) and mixed runs (it marks automated tests as started).
|
|
64
|
+
*
|
|
65
|
+
* @param {{status?: string, kind?: string}} run
|
|
66
|
+
* @returns {Promise<boolean>} whether credentials were applied
|
|
67
|
+
*/
|
|
68
|
+
applySharedS3Credentials(run: {
|
|
69
|
+
status?: string;
|
|
70
|
+
kind?: string;
|
|
71
|
+
}): Promise<boolean>;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Resolve the run argument of `fetch status|count|report`: a run id or a run URL.
|
|
75
|
+
* A URL also sets the server and the project.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} runArg
|
|
78
|
+
* @param {{project?: string}} opts
|
|
79
|
+
* @returns {{baseUrl: string, project: string, runId: string}}
|
|
80
|
+
*/
|
|
81
|
+
export function resolveRunTarget(runArg: string, opts: {
|
|
82
|
+
project?: string;
|
|
83
|
+
}): {
|
|
84
|
+
baseUrl: string;
|
|
85
|
+
project: string;
|
|
86
|
+
runId: string;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Collect the statuses passed with --only and --only-failed.
|
|
90
|
+
*
|
|
91
|
+
* @param {{only?: string, onlyFailed?: boolean}} opts
|
|
92
|
+
* @returns {string[]}
|
|
93
|
+
*/
|
|
94
|
+
export function parseStatuses(opts: {
|
|
95
|
+
only?: string;
|
|
96
|
+
onlyFailed?: boolean;
|
|
97
|
+
}): string[];
|
|
98
|
+
/**
|
|
99
|
+
* @param {string} [value] - Value of the CLI --limit option.
|
|
100
|
+
* @returns {{limit: number, capped: boolean}}
|
|
101
|
+
*/
|
|
102
|
+
export function parseTestrunsLimit(value?: string): {
|
|
103
|
+
limit: number;
|
|
104
|
+
capped: boolean;
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Run `fn` over `items` with at most `limit` calls in flight; results keep the order of `items`.
|
|
108
|
+
*
|
|
109
|
+
* @template T, R
|
|
110
|
+
* @param {T[]} items
|
|
111
|
+
* @param {number} limit
|
|
112
|
+
* @param {(item: T) => Promise<R>} fn
|
|
113
|
+
* @returns {Promise<R[]>}
|
|
114
|
+
*/
|
|
115
|
+
export function mapWithLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]>;
|
|
116
|
+
/**
|
|
117
|
+
* Download the artifacts of each testrun to `<outDir>/<testrun id>/<file name>`.
|
|
118
|
+
* Each artifact becomes `{name, href}`, where `href` is the local path relative to the current directory,
|
|
119
|
+
* or the remote URL when the download failed.
|
|
120
|
+
*
|
|
121
|
+
* @param {object[]} testruns
|
|
122
|
+
* @param {string} outDir
|
|
123
|
+
* @param {(url: string, err: Error) => void} onError
|
|
124
|
+
*/
|
|
125
|
+
export function downloadArtifacts(testruns: object[], outDir: string, onError: (url: string, err: Error) => void): Promise<any[]>;
|
|
126
|
+
/**
|
|
127
|
+
* @param {{title?: string}} run
|
|
128
|
+
* @param {string} runId
|
|
129
|
+
* @returns {string}
|
|
130
|
+
*/
|
|
131
|
+
export function defaultArtifactsDir(run: {
|
|
132
|
+
title?: string;
|
|
133
|
+
}, runId: string): string;
|
|
@@ -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;
|
package/lib/pipe/markdown.d.ts
CHANGED
package/lib/pipe/markdown.js
CHANGED
|
@@ -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;
|
|
@@ -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;
|
|
@@ -56,6 +65,13 @@ export class S3Uploader {
|
|
|
56
65
|
*/
|
|
57
66
|
uploadFileAsBuffer(buffer: Buffer, pathInS3: string[]): Promise<any>;
|
|
58
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>;
|
|
59
75
|
getS3LocationLink(out: any): Promise<any>;
|
|
60
76
|
#private;
|
|
61
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");
|
|
@@ -290,6 +291,32 @@ class S3Uploader {
|
|
|
290
291
|
maxTimeout: intervalMs,
|
|
291
292
|
});
|
|
292
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
|
+
}
|
|
293
320
|
async getS3LocationLink(out) {
|
|
294
321
|
const response = await out.done();
|
|
295
322
|
let s3Location = response?.Location?.trim();
|
|
@@ -337,5 +364,32 @@ class S3Uploader {
|
|
|
337
364
|
}
|
|
338
365
|
}
|
|
339
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;
|
|
340
394
|
|
|
341
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[];
|