@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
package/lib/bin/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ const xmlReader_js_1 = __importDefault(require("../xmlReader.js"));
|
|
|
13
13
|
const allureReader_js_1 = __importDefault(require("../allureReader.js"));
|
|
14
14
|
const constants_js_1 = require("../constants.js");
|
|
15
15
|
const utils_js_1 = require("../utils/utils.js");
|
|
16
|
+
const detectRunner_js_1 = require("../utils/detectRunner.js");
|
|
16
17
|
const config_js_1 = require("../config.js");
|
|
17
18
|
const utils_js_2 = require("../utils/utils.js");
|
|
18
19
|
const picocolors_1 = __importDefault(require("picocolors"));
|
|
@@ -23,6 +24,8 @@ const log_js_1 = require("../utils/log.js");
|
|
|
23
24
|
const pipe_utils_js_1 = require("../utils/pipe_utils.js");
|
|
24
25
|
const fs_1 = __importDefault(require("fs"));
|
|
25
26
|
const path_1 = __importDefault(require("path"));
|
|
27
|
+
const testruns_js_1 = require("../fetch/testruns.js");
|
|
28
|
+
const report_js_1 = require("../fetch/report.js");
|
|
26
29
|
const debug = (0, debug_1.default)('@testomatio/reporter:cli');
|
|
27
30
|
const version = (0, utils_js_1.getPackageVersion)();
|
|
28
31
|
const program = new commander_1.Command();
|
|
@@ -43,8 +46,13 @@ program
|
|
|
43
46
|
// remaining output to stderr, skip the banner, and silence info-level logs so
|
|
44
47
|
// stdout stays clean for capture (e.g. RUN_ID=$(... start --format id)).
|
|
45
48
|
// Set TESTOMATIO_LOG_LEVEL=INFO to re-enable progress logs for debugging.
|
|
46
|
-
const subOpts = actionCommand.
|
|
47
|
-
|
|
49
|
+
const subOpts = actionCommand.optsWithGlobals();
|
|
50
|
+
// fetch status|count|report print their result on stdout, so the rest goes to stderr too
|
|
51
|
+
const isFetchSubcommand = actionCommand.parent?.name() === 'fetch';
|
|
52
|
+
if (isFetchSubcommand && !subOpts.format) {
|
|
53
|
+
process.env.TESTOMATIO_LOG_STDERR = '1';
|
|
54
|
+
}
|
|
55
|
+
else if (subOpts.filterList || subOpts.format) {
|
|
48
56
|
process.env.TESTOMATIO_LOG_STDERR = '1';
|
|
49
57
|
process.env.TESTOMATIO_LOG_LEVEL ||= 'WARN';
|
|
50
58
|
// with --format json the logs are machine-readable too: one JSON object per line on stderr
|
|
@@ -61,9 +69,10 @@ program
|
|
|
61
69
|
.option('--kind <type>', 'Specify run type: automated, manual, mixed, or detect')
|
|
62
70
|
.option('--filter <filter>', 'Scope the prepared run to tests matching the filter (no execution)')
|
|
63
71
|
.option('--format <format>', 'Machine-readable output: the run id (--format id) or run details (--format json)')
|
|
64
|
-
.option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only)')
|
|
72
|
+
.option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only); env: TESTOMATIO_WARN')
|
|
65
73
|
.action(async (opts) => {
|
|
66
74
|
(0, utils_js_1.cleanLatestRunId)();
|
|
75
|
+
opts.warn ||= (0, utils_js_1.transformEnvVarToBoolean)(process.env.TESTOMATIO_WARN);
|
|
67
76
|
log_js_1.log.info('Starting a new Run on Testomat.io...');
|
|
68
77
|
const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config_js_1.config.TESTOMATIO;
|
|
69
78
|
const client = new client_js_1.default({ apiKey });
|
|
@@ -118,38 +127,58 @@ program
|
|
|
118
127
|
process.exit(0);
|
|
119
128
|
});
|
|
120
129
|
});
|
|
121
|
-
program
|
|
130
|
+
const fetchCommand = program
|
|
122
131
|
.command('fetch')
|
|
123
132
|
.description('Fetch runs from Testomat.io API v2')
|
|
133
|
+
.argument('[url]', 'URL of a run or of a (filtered) runs page in Testomat.io')
|
|
124
134
|
.option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
|
|
135
|
+
.option('--run <id>', 'Fetch a single run by its id')
|
|
125
136
|
.option('--title <text>', 'Filter by run title')
|
|
126
137
|
.option('--tql <query>', 'Filter using Testomat Query Language')
|
|
127
138
|
.option('--rungroup <uid>', 'Filter by rungroup id')
|
|
128
|
-
|
|
139
|
+
// no default value: fetch parses --limit for its subcommands too, see createRunFetcher
|
|
140
|
+
.option('--limit <number>', `Max number of runs to return (default ${FETCH_RUNS_DEFAULT_LIMIT}, max ${FETCH_RUNS_MAX_LIMIT})`)
|
|
129
141
|
.option('--latest', 'Only fetch the most recent run (shorthand for --limit 1)')
|
|
130
142
|
.option('--format <format>', 'Machine-readable output: run ids, one per line (--format id) or run details (--format json)')
|
|
131
|
-
.action(async (opts) => {
|
|
143
|
+
.action(async (pageUrl, opts) => {
|
|
132
144
|
const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config_js_1.config.TESTOMATIO;
|
|
133
145
|
if (!apiKey) {
|
|
134
146
|
log_js_1.log.error(picocolors_1.default.red('⚠️ TESTOMATIO API key required'));
|
|
135
147
|
process.exit(1);
|
|
136
148
|
}
|
|
137
|
-
|
|
149
|
+
let page = {};
|
|
150
|
+
if (pageUrl) {
|
|
151
|
+
page = (0, pipe_utils_js_1.parseRunsUrl)(pageUrl);
|
|
152
|
+
if (!page) {
|
|
153
|
+
log_js_1.log.error(picocolors_1.default.red(`⚠️ Not a Testomat.io run or runs page URL: ${pageUrl}`));
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const project = opts.project || page.project || process.env.TESTOMATIO_PROJECT;
|
|
138
158
|
if (!project) {
|
|
139
159
|
log_js_1.log.error(picocolors_1.default.red('⚠️ --project (or TESTOMATIO_PROJECT) is required'));
|
|
140
160
|
process.exit(1);
|
|
141
161
|
}
|
|
142
|
-
const baseUrl = process.env.TESTOMATIO_URL || 'https://app.testomat.io';
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
url
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
162
|
+
const baseUrl = page.baseUrl || process.env.TESTOMATIO_URL || 'https://app.testomat.io';
|
|
163
|
+
const runId = opts.run || page.runId;
|
|
164
|
+
let url;
|
|
165
|
+
if (runId) {
|
|
166
|
+
url = new URL(`/api/v2/${project}/runs/${runId}`, baseUrl);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
url = new URL(`/api/v2/${project}/runs`, baseUrl);
|
|
170
|
+
for (const [key, value] of page.params || [])
|
|
171
|
+
url.searchParams.append(key, value);
|
|
172
|
+
if (opts.title)
|
|
173
|
+
url.searchParams.set('search', opts.title);
|
|
174
|
+
if (opts.tql)
|
|
175
|
+
url.searchParams.set('tql', opts.tql);
|
|
176
|
+
if (opts.rungroup)
|
|
177
|
+
url.searchParams.set('groupId', opts.rungroup);
|
|
178
|
+
const requestedLimit = opts.latest ? 1 : parseInt(opts.limit, 10) || FETCH_RUNS_DEFAULT_LIMIT;
|
|
179
|
+
const limit = Math.min(Math.max(requestedLimit, 1), FETCH_RUNS_MAX_LIMIT);
|
|
180
|
+
url.searchParams.set('per_page', String(limit));
|
|
181
|
+
}
|
|
153
182
|
const response = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
|
|
154
183
|
const body = await response.json();
|
|
155
184
|
if (!response.ok) {
|
|
@@ -158,6 +187,141 @@ program
|
|
|
158
187
|
}
|
|
159
188
|
console.log((0, pipe_utils_js_1.formatFetchRunsOutput)(body, opts.format));
|
|
160
189
|
});
|
|
190
|
+
// fetch parses the options it shares with its subcommands (--project, --format, --limit) wherever they are placed,
|
|
191
|
+
// so subcommands declare them only for help and read them with optsWithGlobals()
|
|
192
|
+
/**
|
|
193
|
+
* Build a RunFetcher for the run argument of fetch status|count|report, or exit with an error.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} runArg
|
|
196
|
+
* @param {{project?: string}} opts
|
|
197
|
+
* @returns {RunFetcher}
|
|
198
|
+
*/
|
|
199
|
+
function createRunFetcher(runArg, opts) {
|
|
200
|
+
const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config_js_1.config.TESTOMATIO;
|
|
201
|
+
if (!apiKey) {
|
|
202
|
+
log_js_1.log.error(picocolors_1.default.red('⚠️ TESTOMATIO API key required'));
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
return new testruns_js_1.RunFetcher({ ...(0, testruns_js_1.resolveRunTarget)(runArg, opts), apiKey });
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
log_js_1.log.error(picocolors_1.default.red(`⚠️ ${err.message}`));
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* @param {{only?: string, onlyFailed?: boolean}} opts
|
|
215
|
+
* @returns {string[]}
|
|
216
|
+
*/
|
|
217
|
+
function getStatusFilter(opts) {
|
|
218
|
+
try {
|
|
219
|
+
return (0, testruns_js_1.parseStatuses)(opts);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
log_js_1.log.error(picocolors_1.default.red(`⚠️ ${err.message}`));
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* @param {Error} err
|
|
228
|
+
* @returns {never}
|
|
229
|
+
*/
|
|
230
|
+
function exitOnFetchError(err) {
|
|
231
|
+
log_js_1.log.error(picocolors_1.default.red(`⚠️ Failed to fetch: ${err.message}`));
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
const RUN_ARGUMENT_DESCRIPTION = 'Run id, or URL of a run in Testomat.io';
|
|
235
|
+
const STATUS_FILTER_DESCRIPTION = `Only testruns with these statuses, comma-separated (${testruns_js_1.TESTRUN_STATUSES.join(', ')})`;
|
|
236
|
+
fetchCommand
|
|
237
|
+
.command('status')
|
|
238
|
+
.description('Show a run with the number of its testruns by status')
|
|
239
|
+
.argument('<run>', RUN_ARGUMENT_DESCRIPTION)
|
|
240
|
+
.option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
|
|
241
|
+
.option('--format <format>', 'Machine-readable output: run status and counts as JSON (--format json)')
|
|
242
|
+
.action(async (runArg, _opts, command) => {
|
|
243
|
+
const opts = command.optsWithGlobals();
|
|
244
|
+
const fetcher = createRunFetcher(runArg, opts);
|
|
245
|
+
const [run, summary] = await Promise.all([fetcher.fetchRun(), fetcher.countTestrunsByStatus()]).catch(exitOnFetchError);
|
|
246
|
+
const counts = Object.fromEntries(testruns_js_1.TESTRUN_STATUSES.map(status => [status, summary.byStatus[status] || 0]));
|
|
247
|
+
if (opts.format === 'json') {
|
|
248
|
+
const data = { id: run.id, title: run.title, status: run.status, url: fetcher.runUrl, total: summary.total };
|
|
249
|
+
console.log(JSON.stringify({ ...data, ...counts }, null, 2));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const lines = [`run: ${run.title}`, `status: ${run.status}`, `url: ${fetcher.runUrl}`, `total: ${summary.total}`];
|
|
253
|
+
for (const [status, count] of Object.entries(counts))
|
|
254
|
+
lines.push(`${status}: ${count}`);
|
|
255
|
+
console.log(lines.join('\n'));
|
|
256
|
+
});
|
|
257
|
+
fetchCommand
|
|
258
|
+
.command('count')
|
|
259
|
+
.description('Print the number of testruns of a run, to pick --limit and filters for fetch report')
|
|
260
|
+
.argument('<run>', RUN_ARGUMENT_DESCRIPTION)
|
|
261
|
+
.option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
|
|
262
|
+
.option('--only <statuses>', STATUS_FILTER_DESCRIPTION)
|
|
263
|
+
.option('--only-failed', 'Only failed testruns (shorthand for --only failed)')
|
|
264
|
+
.action(async (runArg, _opts, command) => {
|
|
265
|
+
const opts = command.optsWithGlobals();
|
|
266
|
+
const fetcher = createRunFetcher(runArg, opts);
|
|
267
|
+
const statuses = getStatusFilter(opts);
|
|
268
|
+
const total = await fetcher.countTestruns(statuses).catch(exitOnFetchError);
|
|
269
|
+
console.log(total);
|
|
270
|
+
});
|
|
271
|
+
fetchCommand
|
|
272
|
+
.command('report')
|
|
273
|
+
.description('Print a run with its testruns as a Markdown report')
|
|
274
|
+
.argument('<run>', RUN_ARGUMENT_DESCRIPTION)
|
|
275
|
+
.option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
|
|
276
|
+
.option('--only <statuses>', STATUS_FILTER_DESCRIPTION)
|
|
277
|
+
.option('--only-failed', 'Only failed testruns (shorthand for --only failed)')
|
|
278
|
+
.option('--stack', 'Include stack traces')
|
|
279
|
+
.option('--artifacts', 'Download artifacts to --out and link them from the report')
|
|
280
|
+
.option('--out <dir>', 'Directory for downloaded artifacts (default: ./<run-title>-<run-id>)')
|
|
281
|
+
.option('--limit <number>', `Max number of testruns, failed first (default ${testruns_js_1.TESTRUNS_DEFAULT_LIMIT}, max ${testruns_js_1.TESTRUNS_MAX_LIMIT})`)
|
|
282
|
+
.action(async (runArg, _opts, command) => {
|
|
283
|
+
const opts = command.optsWithGlobals();
|
|
284
|
+
const fetcher = createRunFetcher(runArg, opts);
|
|
285
|
+
const statuses = getStatusFilter(opts);
|
|
286
|
+
const { limit, capped } = (0, testruns_js_1.parseTestrunsLimit)(opts.limit);
|
|
287
|
+
if (capped)
|
|
288
|
+
log_js_1.log.warn(picocolors_1.default.yellow(`⚠️ --limit is capped at ${testruns_js_1.TESTRUNS_MAX_LIMIT}`));
|
|
289
|
+
const run = await fetcher.fetchRun().catch(exitOnFetchError);
|
|
290
|
+
if (opts.artifacts) {
|
|
291
|
+
const hasSharedCredentials = await fetcher.applySharedS3Credentials(run);
|
|
292
|
+
if (!hasSharedCredentials && !process.env.S3_BUCKET) {
|
|
293
|
+
log_js_1.log.error(picocolors_1.default.red('⚠️ --artifacts needs S3 access: share S3 credentials in project settings or set S3_BUCKET'));
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const [summary, list] = await Promise.all([
|
|
298
|
+
fetcher.countTestrunsByStatus(),
|
|
299
|
+
fetcher.listTestruns({ statuses, limit }),
|
|
300
|
+
]).catch(exitOnFetchError);
|
|
301
|
+
let { testruns } = list;
|
|
302
|
+
if (list.total > testruns.length) {
|
|
303
|
+
log_js_1.log.warn(picocolors_1.default.yellow(`⚠️ Showing ${testruns.length} of ${list.total} testruns. Use --limit or --only-failed to narrow it.`));
|
|
304
|
+
}
|
|
305
|
+
if (opts.stack || opts.artifacts) {
|
|
306
|
+
testruns = await fetcher.addTestrunDetails(testruns, (testrun, err) => {
|
|
307
|
+
log_js_1.log.warn(picocolors_1.default.yellow(`⚠️ Failed to fetch details of testrun ${testrun.id}: ${err.message}`));
|
|
308
|
+
});
|
|
309
|
+
if (!opts.stack)
|
|
310
|
+
testruns = testruns.map(({ stack, ...testrun }) => testrun);
|
|
311
|
+
}
|
|
312
|
+
if (opts.artifacts) {
|
|
313
|
+
const outDir = opts.out || (0, testruns_js_1.defaultArtifactsDir)(run, fetcher.runId);
|
|
314
|
+
const artifactsCount = testruns.reduce((sum, testrun) => sum + (testrun.artifacts?.length || 0), 0);
|
|
315
|
+
log_js_1.log.info(`Downloading ${artifactsCount} artifacts to ${outDir}`);
|
|
316
|
+
testruns = await (0, testruns_js_1.downloadArtifacts)(testruns, outDir, (url, err) => {
|
|
317
|
+
log_js_1.log.warn(picocolors_1.default.yellow(`⚠️ Failed to download ${url}: ${err.message}`));
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
testruns = testruns.map(({ artifacts, ...testrun }) => testrun);
|
|
322
|
+
}
|
|
323
|
+
process.stdout.write((0, report_js_1.renderRunReport)({ run, runUrl: fetcher.runUrl, summary, testruns, matched: list.total, statuses }));
|
|
324
|
+
});
|
|
161
325
|
program
|
|
162
326
|
.command('run')
|
|
163
327
|
.alias('test')
|
|
@@ -169,8 +333,9 @@ program
|
|
|
169
333
|
.option('--kind <type>', 'Specify run type: automated, manual, mixed, or detect')
|
|
170
334
|
.option('--remote <profile>', 'Trigger run on the named Testomat.io CI profile instead of executing locally')
|
|
171
335
|
.option('--remote-param <kv>', 'key=value pair forwarded to the CI profile config (repeat for multiple)', (value, prev) => prev.concat([value]), [])
|
|
172
|
-
.option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only)')
|
|
336
|
+
.option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only); env: TESTOMATIO_WARN')
|
|
173
337
|
.action(async (command, opts) => {
|
|
338
|
+
opts.warn ||= (0, utils_js_1.transformEnvVarToBoolean)(process.env.TESTOMATIO_WARN);
|
|
174
339
|
if (opts.remote) {
|
|
175
340
|
if (opts.filterList) {
|
|
176
341
|
log_js_1.log.warn(picocolors_1.default.red('⚠️ --filter-list cannot be combined with --remote'));
|
|
@@ -184,6 +349,7 @@ program
|
|
|
184
349
|
const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config_js_1.config.TESTOMATIO;
|
|
185
350
|
const title = process.env.TESTOMATIO_TITLE;
|
|
186
351
|
const client = new client_js_1.default({ apiKey, title });
|
|
352
|
+
let filterApplied = false;
|
|
187
353
|
if (opts.filter || opts.filterList) {
|
|
188
354
|
log_js_1.log.info('Filtering tests...');
|
|
189
355
|
// Example of use: npx @testomatio/reporter run "npx jest" --filter "testomatio:tag-name=frontend"
|
|
@@ -218,6 +384,7 @@ program
|
|
|
218
384
|
}
|
|
219
385
|
if (command && command.split && !opts.remote) {
|
|
220
386
|
command = (0, utils_js_1.applyFilter)(command, tests);
|
|
387
|
+
filterApplied = true;
|
|
221
388
|
}
|
|
222
389
|
}
|
|
223
390
|
catch (err) {
|
|
@@ -291,6 +458,13 @@ program
|
|
|
291
458
|
}
|
|
292
459
|
return process.exit(0);
|
|
293
460
|
}
|
|
461
|
+
if ((0, detectRunner_js_1.isPlaceholderCommand)(command)) {
|
|
462
|
+
log_js_1.log.error(picocolors_1.default.red(`⚠️ The command "${command}" looks like a placeholder.`));
|
|
463
|
+
log_js_1.log.error('The example command from Testomat.io must be edited:');
|
|
464
|
+
log_js_1.log.error('replace the placeholder with your actual test runner command.');
|
|
465
|
+
log_js_1.log.error((0, detectRunner_js_1.runnerHint)());
|
|
466
|
+
process.exit(1);
|
|
467
|
+
}
|
|
294
468
|
log_js_1.log.info(`🚀 Running`, picocolors_1.default.green(command));
|
|
295
469
|
const runTests = async () => {
|
|
296
470
|
const testCmds = command.split(' ');
|
|
@@ -298,9 +472,28 @@ program
|
|
|
298
472
|
stdio: 'inherit',
|
|
299
473
|
env: { ...process.env, TESTOMATIO_PROCEED: 'true', runId: client.runId, TESTOMATIO_RUN: client.runId },
|
|
300
474
|
});
|
|
475
|
+
let errored = false;
|
|
476
|
+
cmd.on('error', err => {
|
|
477
|
+
errored = true;
|
|
478
|
+
const spawnError = /** @type {NodeJS.ErrnoException} */ (err);
|
|
479
|
+
if (spawnError.code === 'ENOENT') {
|
|
480
|
+
log_js_1.log.error(picocolors_1.default.red(`🔴 Command not found: ${picocolors_1.default.bold(testCmds[0])}`));
|
|
481
|
+
log_js_1.log.error((0, detectRunner_js_1.runnerHint)());
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
log_js_1.log.error(picocolors_1.default.red(`🔴 Failed to start command: ${spawnError.message}`));
|
|
485
|
+
}
|
|
486
|
+
const finish = apiKey ? client.updateRunStatus('failed').catch(() => { }) : Promise.resolve();
|
|
487
|
+
finish.then(() => process.exit(1));
|
|
488
|
+
});
|
|
301
489
|
cmd.on('close', async (code) => {
|
|
490
|
+
if (errored)
|
|
491
|
+
return;
|
|
302
492
|
const emoji = code === 0 ? '🟢' : '🔴';
|
|
303
493
|
log_js_1.log.info(emoji, `Runner exited with ${picocolors_1.default.bold(code)}`);
|
|
494
|
+
if (code !== 0 && filterApplied) {
|
|
495
|
+
(0, detectRunner_js_1.buildFilterWarning)(command).forEach(line => log_js_1.log.warn(line));
|
|
496
|
+
}
|
|
304
497
|
if (apiKey) {
|
|
305
498
|
const status = code === 0 ? 'passed' : 'failed';
|
|
306
499
|
await client.updateRunStatus(status);
|
package/lib/client.d.ts
CHANGED
package/lib/client.js
CHANGED
|
@@ -37,6 +37,8 @@ class Client {
|
|
|
37
37
|
this.pipeStore = {};
|
|
38
38
|
this.runId = '';
|
|
39
39
|
this.queue = Promise.resolve();
|
|
40
|
+
// adapter name is set by the adapter module itself on load (global.TESTOMATIO_ADAPTER)
|
|
41
|
+
this.adapterName = params.adapter || global.TESTOMATIO_ADAPTER;
|
|
40
42
|
// @ts-ignore this line will be removed in compiled code, because __dirname is defined in commonjs
|
|
41
43
|
const pathToPackageJSON = path_1.default.join(__dirname, '../package.json');
|
|
42
44
|
try {
|
|
@@ -214,7 +216,9 @@ class Client {
|
|
|
214
216
|
const uploadedFiles = [];
|
|
215
217
|
const stackArtifactsEnabled = (0, utils_js_1.transformEnvVarToBoolean)(process.env.TESTOMATIO_STACK_ARTIFACTS);
|
|
216
218
|
const { time = 0, example = null, filesBuffers = [], code = null, file, suite_id, test_id, timestamp, links, overwrite, tags, } = testData;
|
|
217
|
-
let { files = [], manuallyAttachedArtifacts, message = ''
|
|
219
|
+
let { files = [], manuallyAttachedArtifacts, message = '' } = testData;
|
|
220
|
+
/** @type {Record<string, any>} */
|
|
221
|
+
let meta = testData.meta || {};
|
|
218
222
|
if (stepArtifactPaths.size) {
|
|
219
223
|
const isStepArtifact = item => stepArtifactPaths.has(typeof item === 'object' ? item?.path : item);
|
|
220
224
|
files = files.filter(f => !isStepArtifact(f));
|
|
@@ -229,6 +233,11 @@ class Client {
|
|
|
229
233
|
acc[key] = value;
|
|
230
234
|
return acc;
|
|
231
235
|
}, {});
|
|
236
|
+
// meta must always include reporter version and adapter used
|
|
237
|
+
if (this.version)
|
|
238
|
+
meta.reporterVersion = this.version;
|
|
239
|
+
if (this.adapterName)
|
|
240
|
+
meta.adapter = this.adapterName;
|
|
232
241
|
const testContext = suite_title ? `${suite_title} ${title}` : title;
|
|
233
242
|
let errorFormatted = '';
|
|
234
243
|
if (error) {
|
|
@@ -335,8 +344,8 @@ class Client {
|
|
|
335
344
|
this.queue = this.queue
|
|
336
345
|
.then(() => Promise.all(this.pipes.map(p => p.finishRun(runParams))))
|
|
337
346
|
.then(() => {
|
|
338
|
-
|
|
339
|
-
|
|
347
|
+
// no early return when uploading is disabled:
|
|
348
|
+
// skipped artifacts (e.g. no S3 credentials obtained) must still be reported to the user
|
|
340
349
|
const filesizeStrMaxLength = 7;
|
|
341
350
|
if (this.uploader.successfulUploads.length) {
|
|
342
351
|
debug('\n', constants_js_1.APP_PREFIX, `🗄️ ${this.uploader.successfulUploads.length} artifacts uploaded to S3 bucket`);
|
package/lib/data-storage.js
CHANGED
|
@@ -220,9 +220,10 @@ class DataStorage {
|
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
222
|
function stringToMD5Hash(str) {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
223
|
+
// sha256 instead of md5 for FIPS-enabled hosts compatibility
|
|
224
|
+
const sha256 = crypto_1.default.createHash('sha256');
|
|
225
|
+
sha256.update(str);
|
|
226
|
+
const hash = sha256.digest('hex');
|
|
226
227
|
return `${process.env.runId || 'run'}_${hash}`;
|
|
227
228
|
}
|
|
228
229
|
exports.dataStorage = DataStorage.getInstance();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a fetched run with its testruns as a Markdown report.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} params
|
|
5
|
+
* @param {object} params.run - Run from GET /api/v2/:project/runs/:id
|
|
6
|
+
* @param {string} params.runUrl
|
|
7
|
+
* @param {{total: number, byStatus: Record<string, number>}} params.summary - Counts of all testruns of the run
|
|
8
|
+
* @param {object[]} params.testruns
|
|
9
|
+
* @param {number} params.matched - Number of testruns matching the filter
|
|
10
|
+
* @param {string[]} params.statuses - Status filter, empty for all testruns
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function renderRunReport({ run, runUrl, summary, testruns, matched, statuses }: {
|
|
14
|
+
run: object;
|
|
15
|
+
runUrl: string;
|
|
16
|
+
summary: {
|
|
17
|
+
total: number;
|
|
18
|
+
byStatus: Record<string, number>;
|
|
19
|
+
};
|
|
20
|
+
testruns: object[];
|
|
21
|
+
matched: number;
|
|
22
|
+
statuses: string[];
|
|
23
|
+
}): string;
|
|
24
|
+
/**
|
|
25
|
+
* Map a testrun of API v2 to the test shape of the Markdown pipe.
|
|
26
|
+
* Without a suite, the API returns the title as "<suite> ⇒ <title>".
|
|
27
|
+
*
|
|
28
|
+
* @param {object} testrun
|
|
29
|
+
* @returns {object}
|
|
30
|
+
*/
|
|
31
|
+
export function toReportTest(testrun: object): object;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderRunReport = renderRunReport;
|
|
4
|
+
exports.toReportTest = toReportTest;
|
|
5
|
+
const markdown_js_1 = require("../pipe/markdown.js");
|
|
6
|
+
const TITLE_SEPARATOR = ' ⇒ ';
|
|
7
|
+
/**
|
|
8
|
+
* Map a testrun of API v2 to the test shape of the Markdown pipe.
|
|
9
|
+
* Without a suite, the API returns the title as "<suite> ⇒ <title>".
|
|
10
|
+
*
|
|
11
|
+
* @param {object} testrun
|
|
12
|
+
* @returns {object}
|
|
13
|
+
*/
|
|
14
|
+
function toReportTest(testrun) {
|
|
15
|
+
let title = String(testrun.test_title || '').trim();
|
|
16
|
+
let suite = testrun.suiteTitle;
|
|
17
|
+
if (!suite && title.includes(TITLE_SEPARATOR)) {
|
|
18
|
+
const [suitePart, ...titleParts] = title.split(TITLE_SEPARATOR);
|
|
19
|
+
suite = suitePart.trim();
|
|
20
|
+
title = titleParts.join(TITLE_SEPARATOR).trim();
|
|
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
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Render a fetched run with its testruns as a Markdown report.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} params
|
|
37
|
+
* @param {object} params.run - Run from GET /api/v2/:project/runs/:id
|
|
38
|
+
* @param {string} params.runUrl
|
|
39
|
+
* @param {{total: number, byStatus: Record<string, number>}} params.summary - Counts of all testruns of the run
|
|
40
|
+
* @param {object[]} params.testruns
|
|
41
|
+
* @param {number} params.matched - Number of testruns matching the filter
|
|
42
|
+
* @param {string[]} params.statuses - Status filter, empty for all testruns
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
function renderRunReport({ run, runUrl, summary, testruns, matched, statuses }) {
|
|
46
|
+
const status = String(run.status || 'unknown');
|
|
47
|
+
const sections = [`# ${run.title || run.id} — ${status.toUpperCase()}`];
|
|
48
|
+
const rows = [
|
|
49
|
+
['Run ID', `\`${run.id}\``],
|
|
50
|
+
['Run URL', `<${runUrl}>`],
|
|
51
|
+
['Status', status],
|
|
52
|
+
];
|
|
53
|
+
if (run.env)
|
|
54
|
+
rows.push(['Environment', run.env]);
|
|
55
|
+
if (run.launched_at)
|
|
56
|
+
rows.push(['Started', run.launched_at]);
|
|
57
|
+
if (run.finished_at)
|
|
58
|
+
rows.push(['Finished', run.finished_at]);
|
|
59
|
+
let shown = `${testruns.length} of ${matched} shown`;
|
|
60
|
+
if (statuses.length)
|
|
61
|
+
shown += ` (only: ${statuses.join(', ')})`;
|
|
62
|
+
rows.push(['Testruns', shown]);
|
|
63
|
+
sections.push(['| Key | Value |', '| --- | ----- |', ...rows.map(([key, value]) => `| ${key} | ${value} |`)].join('\n'));
|
|
64
|
+
if (run.description?.trim())
|
|
65
|
+
sections.push(`## Description\n\n${run.description.trim()}`);
|
|
66
|
+
const counts = Object.entries(summary.byStatus).sort(([, a], [, b]) => b - a);
|
|
67
|
+
sections.push([
|
|
68
|
+
'## Summary',
|
|
69
|
+
'',
|
|
70
|
+
'| Status | Count |',
|
|
71
|
+
'| ------ | ----- |',
|
|
72
|
+
...counts.map(([name, count]) => `| ${name} | ${count} |`),
|
|
73
|
+
`| total | ${summary.total} |`,
|
|
74
|
+
].join('\n'));
|
|
75
|
+
sections.push((0, markdown_js_1.renderTests)(testruns.map(toReportTest)));
|
|
76
|
+
return `${sections.join('\n\n')}\n`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports.renderRunReport = renderRunReport;
|
|
80
|
+
|
|
81
|
+
module.exports.toReportTest = toReportTest;
|
|
@@ -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;
|