testomatio-reporter-cli 2.20.0 → 2.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- 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 +248 -25
- package/src/fetch/report.js +107 -0
- package/src/fetch/testruns.js +364 -0
- package/src/pipe/markdown.js +30 -0
- package/src/reporter-functions.js +41 -0
- package/src/uploader.js +51 -0
- package/src/utils/detectRunner.js +151 -0
package/package.json
CHANGED
package/src/adapter/codecept.js
CHANGED
|
@@ -20,7 +20,7 @@ if (!global.codeceptjs) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
// @ts-ignore
|
|
23
|
-
const { event, recorder, codecept, output } = global.codeceptjs;
|
|
23
|
+
const { event, recorder, codecept, output, container } = global.codeceptjs;
|
|
24
24
|
|
|
25
25
|
const [, MAJOR_VERSION, MINOR_VERSION] = codecept
|
|
26
26
|
.version()
|
|
@@ -59,6 +59,7 @@ function CodeceptReporter(config) {
|
|
|
59
59
|
let isRunFinalized = false;
|
|
60
60
|
|
|
61
61
|
const testTimeMap = {};
|
|
62
|
+
const failedUrls = new Map();
|
|
62
63
|
const clientConfig = buildCodeceptClientConfig(config);
|
|
63
64
|
const client = new TestomatClient(clientConfig);
|
|
64
65
|
|
|
@@ -243,6 +244,22 @@ function CodeceptReporter(config) {
|
|
|
243
244
|
}, true);
|
|
244
245
|
});
|
|
245
246
|
|
|
247
|
+
event.dispatcher.on(event.test.failed, (test, _err, hookName) => {
|
|
248
|
+
// no browser in suite hooks
|
|
249
|
+
if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') return;
|
|
250
|
+
const helper = Object.values(container.helpers()).find(h => typeof h.grabCurrentUrl === 'function');
|
|
251
|
+
if (!helper) return;
|
|
252
|
+
|
|
253
|
+
recorder.add('url of failed test', async () => {
|
|
254
|
+
try {
|
|
255
|
+
const url = await helper.grabCurrentUrl();
|
|
256
|
+
if (url) failedUrls.set(test.uid, url);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
debug('Failed to grab url of failed test', err?.message || err);
|
|
259
|
+
}
|
|
260
|
+
}, true);
|
|
261
|
+
});
|
|
262
|
+
|
|
246
263
|
event.dispatcher.on(event.test.skipped, test => {
|
|
247
264
|
const { uid, tags, title } = test.simplify();
|
|
248
265
|
|
|
@@ -274,6 +291,9 @@ function CodeceptReporter(config) {
|
|
|
274
291
|
const keyValues = services.keyValues.get(test.fullTitle());
|
|
275
292
|
const links = services.links.get(test.fullTitle());
|
|
276
293
|
const screenshotOnFailPath = artifacts.screenshot || null;
|
|
294
|
+
const meta = { ...keyValues, ...test.meta };
|
|
295
|
+
if (test.state === STATUS.FAILED && failedUrls.has(uid)) meta.failedUrl = failedUrls.get(uid);
|
|
296
|
+
failedUrls.delete(uid);
|
|
277
297
|
|
|
278
298
|
// Build step hierarchy with screenshot from screenshotOnFail
|
|
279
299
|
const stepHierarchy = buildUnifiedStepHierarchy(
|
|
@@ -297,7 +317,7 @@ function CodeceptReporter(config) {
|
|
|
297
317
|
logs,
|
|
298
318
|
links,
|
|
299
319
|
manuallyAttachedArtifacts,
|
|
300
|
-
meta
|
|
320
|
+
meta,
|
|
301
321
|
});
|
|
302
322
|
reportTestPromises.push(reportTestPromise);
|
|
303
323
|
|
package/src/adapter/e2e.js
CHANGED
|
@@ -94,6 +94,8 @@ export function toTestData(result, groups, artifactsRoot) {
|
|
|
94
94
|
if (result.repeat) meta.repeat = result.repeat;
|
|
95
95
|
if (attempts.length > 1) meta.retryCount = attempts.length - 1;
|
|
96
96
|
if (result.status === 'flaky') meta.isFlaky = true;
|
|
97
|
+
const status = testStatus(result.status);
|
|
98
|
+
if (status === STATUS.FAILED && final?.failure?.url) meta.failedUrl = final.failure.url;
|
|
97
99
|
|
|
98
100
|
const filesById = new Map();
|
|
99
101
|
for (const artifact of final?.artifacts || []) {
|
|
@@ -128,7 +130,7 @@ export function toTestData(result, groups, artifactsRoot) {
|
|
|
128
130
|
meta,
|
|
129
131
|
};
|
|
130
132
|
|
|
131
|
-
return { status
|
|
133
|
+
return { status, data };
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
/** Attempts of a result; a serial member keeps its part of the group's attempts. */
|
|
@@ -140,6 +142,7 @@ function attemptsOf(result, groups) {
|
|
|
140
142
|
return {
|
|
141
143
|
status: member?.status || attempt.status,
|
|
142
144
|
error: member?.error || attempt.error,
|
|
145
|
+
failure: member?.failure || attempt.failure,
|
|
143
146
|
steps: member?.steps || [],
|
|
144
147
|
appLog: member?.appLog || [],
|
|
145
148
|
skip: member?.skip,
|
|
@@ -110,7 +110,11 @@ class PlaywrightReporter {
|
|
|
110
110
|
if (result.status === 'passed') status = 'failed';
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
const
|
|
113
|
+
const testStatus = checkStatus(status);
|
|
114
|
+
let failedUrl;
|
|
115
|
+
if (testStatus === Status.FAILED) failedUrl = lastNavigatedUrl(result.steps, test.parent.project().use.baseURL);
|
|
116
|
+
|
|
117
|
+
const reportTestPromise = this.client.addTestRun(testStatus, {
|
|
114
118
|
rid: `${rid}-${project.name}`,
|
|
115
119
|
error,
|
|
116
120
|
test_id: getTestomatIdFromTestTitle(`${title} ${tags.join(' ')}`),
|
|
@@ -128,6 +132,7 @@ class PlaywrightReporter {
|
|
|
128
132
|
isMobile: project.isMobile,
|
|
129
133
|
project: project.name,
|
|
130
134
|
projectDependencies: project.dependencies?.length ? project.dependencies : null,
|
|
135
|
+
failedUrl,
|
|
131
136
|
...testMeta,
|
|
132
137
|
...meta,
|
|
133
138
|
...project.metadata, // metadata has any type (in playwright), but we will stringify it in client.js
|
|
@@ -244,6 +249,31 @@ function isScreenshotArtifact(attachment) {
|
|
|
244
249
|
return attachment?.contentType === 'image/png' && attachment?.name === 'screenshot';
|
|
245
250
|
}
|
|
246
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Last url the test navigated to; the reporter has no access to the page itself.
|
|
254
|
+
* @returns {string|undefined}
|
|
255
|
+
*/
|
|
256
|
+
function lastNavigatedUrl(steps, baseURL) {
|
|
257
|
+
const url = findLastNavigation(steps);
|
|
258
|
+
if (!url) return;
|
|
259
|
+
try {
|
|
260
|
+
return new URL(url, baseURL).href;
|
|
261
|
+
} catch {
|
|
262
|
+
return url;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function findLastNavigation(steps = []) {
|
|
267
|
+
let url;
|
|
268
|
+
for (const step of steps) {
|
|
269
|
+
// 'Navigate to "url"' since Playwright 1.53, 'page.goto(url)' before
|
|
270
|
+
const match = step.title.match(/^Navigate to "(.+)"$/) || step.title.match(/^page\.goto\((.+)\)$/);
|
|
271
|
+
if (step.category === 'pw:api' && match) url = match[1];
|
|
272
|
+
url = findLastNavigation(step.steps) || url;
|
|
273
|
+
}
|
|
274
|
+
return url;
|
|
275
|
+
}
|
|
276
|
+
|
|
247
277
|
function appendStep(step, shift = 0) {
|
|
248
278
|
// nesting too deep, ignore those steps
|
|
249
279
|
if (shift >= 10) return;
|
package/src/adapter/vitest.js
CHANGED
|
@@ -398,7 +398,10 @@ function normalizeVitestTest(test) {
|
|
|
398
398
|
file,
|
|
399
399
|
suiteTitle,
|
|
400
400
|
logs: '',
|
|
401
|
-
meta:
|
|
401
|
+
meta: {
|
|
402
|
+
...(test.task?.meta || {}),
|
|
403
|
+
...(typeof test.meta === 'function' ? test.meta() : test.meta || {}),
|
|
404
|
+
},
|
|
402
405
|
};
|
|
403
406
|
}
|
|
404
407
|
|
package/src/adapter/webdriver.js
CHANGED
|
@@ -4,6 +4,9 @@ import { getTestomatIdFromTestTitle, fileSystem } from '../utils/utils.js';
|
|
|
4
4
|
import { services } from '../services/index.js';
|
|
5
5
|
import { TESTOMAT_TMP_STORAGE_DIR } from '../constants.js';
|
|
6
6
|
import { stringToMD5Hash } from '../data-storage.js';
|
|
7
|
+
import createDebugMessages from 'debug';
|
|
8
|
+
|
|
9
|
+
const debug = createDebugMessages('@testomatio/reporter:adapter:webdriver');
|
|
7
10
|
|
|
8
11
|
global.TESTOMATIO_ADAPTER = 'webdriverio';
|
|
9
12
|
|
|
@@ -122,7 +125,8 @@ class WebdriverReporter extends WDIOReporter {
|
|
|
122
125
|
async addTest(test) {
|
|
123
126
|
if (!this.client) return;
|
|
124
127
|
|
|
125
|
-
const { title, _duration: duration, state, error, output, links, artifacts,
|
|
128
|
+
const { title, _duration: duration, state, error, output, links, artifacts, logs } = test;
|
|
129
|
+
let { meta } = test;
|
|
126
130
|
|
|
127
131
|
const testId = getTestomatIdFromTestTitle(title);
|
|
128
132
|
|
|
@@ -133,6 +137,11 @@ class WebdriverReporter extends WDIOReporter {
|
|
|
133
137
|
|
|
134
138
|
const rid = stringToMD5Hash(test.fullTitle);
|
|
135
139
|
|
|
140
|
+
if (state === 'failed') {
|
|
141
|
+
const failedUrl = (await grabCurrentUrl()) || lastNavigatedUrl(output);
|
|
142
|
+
if (failedUrl) meta = { ...meta, failedUrl };
|
|
143
|
+
}
|
|
144
|
+
|
|
136
145
|
await this.client.addTestRun(state, {
|
|
137
146
|
rid,
|
|
138
147
|
manuallyAttachedArtifacts: test.artifacts,
|
|
@@ -150,7 +159,7 @@ class WebdriverReporter extends WDIOReporter {
|
|
|
150
159
|
/**
|
|
151
160
|
* @param {import('../../types/types.js').WebdriverIOScenario} scenario
|
|
152
161
|
*/
|
|
153
|
-
addBddScenario(scenario) {
|
|
162
|
+
async addBddScenario(scenario) {
|
|
154
163
|
if (!this.client) return;
|
|
155
164
|
|
|
156
165
|
const { title, _duration: duration } = scenario;
|
|
@@ -169,6 +178,12 @@ class WebdriverReporter extends WDIOReporter {
|
|
|
169
178
|
|
|
170
179
|
const tags = scenario.tags.map(tag => tag.name);
|
|
171
180
|
|
|
181
|
+
let meta;
|
|
182
|
+
if (scenarioState === 'failed') {
|
|
183
|
+
const failedUrl = await grabCurrentUrl();
|
|
184
|
+
if (failedUrl) meta = { failedUrl };
|
|
185
|
+
}
|
|
186
|
+
|
|
172
187
|
return this.client.addTestRun(scenarioState, {
|
|
173
188
|
error: error ? Error(error) : null,
|
|
174
189
|
title,
|
|
@@ -176,11 +191,38 @@ class WebdriverReporter extends WDIOReporter {
|
|
|
176
191
|
time: duration,
|
|
177
192
|
tags,
|
|
178
193
|
file: scenario.file,
|
|
194
|
+
meta,
|
|
179
195
|
// filesBuffers: screenshotsBuffers,
|
|
180
196
|
});
|
|
181
197
|
}
|
|
182
198
|
}
|
|
183
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Current url of the browser in this worker; reporters run in the worker process.
|
|
202
|
+
* @returns {Promise<string|undefined>}
|
|
203
|
+
*/
|
|
204
|
+
async function grabCurrentUrl() {
|
|
205
|
+
// @ts-ignore
|
|
206
|
+
const browser = globalThis.browser;
|
|
207
|
+
if (typeof browser?.getUrl !== 'function') return;
|
|
208
|
+
try {
|
|
209
|
+
const url = await browser.getUrl();
|
|
210
|
+
// multiremote returns a url per browser
|
|
211
|
+
if (typeof url === 'string') return url;
|
|
212
|
+
} catch (err) {
|
|
213
|
+
debug('Failed to grab url of failed test', err?.message || err);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Last url opened by the test, taken from its command log.
|
|
219
|
+
* @returns {string|undefined}
|
|
220
|
+
*/
|
|
221
|
+
function lastNavigatedUrl(output = []) {
|
|
222
|
+
const navigations = output.filter(el => el.endpoint === '/session/:sessionId/url' && el.body?.url);
|
|
223
|
+
return navigations.at(-1)?.body.url;
|
|
224
|
+
}
|
|
225
|
+
|
|
184
226
|
/**
|
|
185
227
|
*
|
|
186
228
|
* @param {*} fullTestTitle
|
package/src/bin/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import XmlReader from '../xmlReader.js';
|
|
|
9
9
|
import AllureReader from '../allureReader.js';
|
|
10
10
|
import { APP_PREFIX, STATUS, DEBUG_FILE, BATCH_MODE } from '../constants.js';
|
|
11
11
|
import { cleanLatestRunId, getPackageVersion, applyFilter, transformEnvVarToBoolean } from '../utils/utils.js';
|
|
12
|
+
import { isPlaceholderCommand, runnerHint, buildFilterWarning } from '../utils/detectRunner.js';
|
|
12
13
|
import { config } from '../config.js';
|
|
13
14
|
import { readLatestRunId } from '../utils/utils.js';
|
|
14
15
|
import pc from 'picocolors';
|
|
@@ -19,6 +20,17 @@ import { log } from '../utils/log.js';
|
|
|
19
20
|
import { formatFilterListIds, formatRunOutput, formatFetchRunsOutput, parseRunsUrl } from '../utils/pipe_utils.js';
|
|
20
21
|
import fs from 'fs';
|
|
21
22
|
import path from 'path';
|
|
23
|
+
import {
|
|
24
|
+
RunFetcher,
|
|
25
|
+
resolveRunTarget,
|
|
26
|
+
parseStatuses,
|
|
27
|
+
parseTestrunsLimit,
|
|
28
|
+
downloadArtifacts,
|
|
29
|
+
defaultArtifactsDir,
|
|
30
|
+
TESTRUNS_DEFAULT_LIMIT,
|
|
31
|
+
TESTRUN_STATUSES,
|
|
32
|
+
} from '../fetch/testruns.js';
|
|
33
|
+
import { renderRunReport } from '../fetch/report.js';
|
|
22
34
|
|
|
23
35
|
const debug = createDebugMessages('@testomatio/reporter:cli');
|
|
24
36
|
const version = getPackageVersion();
|
|
@@ -42,8 +54,12 @@ program
|
|
|
42
54
|
// remaining output to stderr, skip the banner, and silence info-level logs so
|
|
43
55
|
// stdout stays clean for capture (e.g. RUN_ID=$(... start --format id)).
|
|
44
56
|
// Set TESTOMATIO_LOG_LEVEL=INFO to re-enable progress logs for debugging.
|
|
45
|
-
const subOpts = actionCommand.
|
|
46
|
-
|
|
57
|
+
const subOpts = actionCommand.optsWithGlobals();
|
|
58
|
+
// fetch status|count|report print their result on stdout, so the rest goes to stderr too
|
|
59
|
+
const isFetchSubcommand = actionCommand.parent?.name() === 'fetch';
|
|
60
|
+
if (isFetchSubcommand && !subOpts.format) {
|
|
61
|
+
process.env.TESTOMATIO_LOG_STDERR = '1';
|
|
62
|
+
} else if (subOpts.filterList || subOpts.format) {
|
|
47
63
|
process.env.TESTOMATIO_LOG_STDERR = '1';
|
|
48
64
|
process.env.TESTOMATIO_LOG_LEVEL ||= 'WARN';
|
|
49
65
|
// with --format json the logs are machine-readable too: one JSON object per line on stderr
|
|
@@ -59,9 +75,10 @@ program
|
|
|
59
75
|
.option('--kind <type>', 'Specify run type: automated, manual, mixed, or detect')
|
|
60
76
|
.option('--filter <filter>', 'Scope the prepared run to tests matching the filter (no execution)')
|
|
61
77
|
.option('--format <format>', 'Machine-readable output: the run id (--format id) or run details (--format json)')
|
|
62
|
-
.option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only)')
|
|
78
|
+
.option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only); env: TESTOMATIO_WARN')
|
|
63
79
|
.action(async opts => {
|
|
64
80
|
cleanLatestRunId();
|
|
81
|
+
opts.warn ||= transformEnvVarToBoolean(process.env.TESTOMATIO_WARN);
|
|
65
82
|
|
|
66
83
|
log.info('Starting a new Run on Testomat.io...');
|
|
67
84
|
const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config.TESTOMATIO;
|
|
@@ -127,19 +144,19 @@ program
|
|
|
127
144
|
});
|
|
128
145
|
});
|
|
129
146
|
|
|
130
|
-
program
|
|
147
|
+
const fetchCommand = program
|
|
131
148
|
.command('fetch')
|
|
132
149
|
.description('Fetch runs from Testomat.io API v2')
|
|
133
150
|
.argument('[url]', 'URL of a run or of a (filtered) runs page in Testomat.io')
|
|
134
|
-
.option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
|
|
151
|
+
.option('--project <slug>', 'Project slug for listing runs (or set TESTOMATIO_PROJECT)')
|
|
135
152
|
.option('--run <id>', 'Fetch a single run by its id')
|
|
136
153
|
.option('--title <text>', 'Filter by run title')
|
|
137
154
|
.option('--tql <query>', 'Filter using Testomat Query Language')
|
|
138
155
|
.option('--rungroup <uid>', 'Filter by rungroup id')
|
|
156
|
+
// no default value: fetch parses --limit for its subcommands too, see createRunFetcher
|
|
139
157
|
.option(
|
|
140
158
|
'--limit <number>',
|
|
141
|
-
`Max number of runs to return (max ${FETCH_RUNS_MAX_LIMIT})`,
|
|
142
|
-
String(FETCH_RUNS_DEFAULT_LIMIT),
|
|
159
|
+
`Max number of runs to return (default ${FETCH_RUNS_DEFAULT_LIMIT}, max ${FETCH_RUNS_MAX_LIMIT})`,
|
|
143
160
|
)
|
|
144
161
|
.option('--latest', 'Only fetch the most recent run (shorthand for --limit 1)')
|
|
145
162
|
.option(
|
|
@@ -162,29 +179,33 @@ program
|
|
|
162
179
|
}
|
|
163
180
|
}
|
|
164
181
|
|
|
165
|
-
const project = opts.project || page.project || process.env.TESTOMATIO_PROJECT;
|
|
166
|
-
if (!project) {
|
|
167
|
-
log.error(pc.red('⚠️ --project (or TESTOMATIO_PROJECT) is required'));
|
|
168
|
-
process.exit(1);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
182
|
const baseUrl = page.baseUrl || process.env.TESTOMATIO_URL || 'https://app.testomat.io';
|
|
172
183
|
const runId = opts.run || page.runId;
|
|
173
|
-
let url;
|
|
174
184
|
|
|
175
185
|
if (runId) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (opts.title) url.searchParams.set('search', opts.title);
|
|
181
|
-
if (opts.tql) url.searchParams.set('tql', opts.tql);
|
|
182
|
-
if (opts.rungroup) url.searchParams.set('groupId', opts.rungroup);
|
|
183
|
-
const requestedLimit = opts.latest ? 1 : parseInt(opts.limit, 10) || FETCH_RUNS_DEFAULT_LIMIT;
|
|
184
|
-
const limit = Math.min(Math.max(requestedLimit, 1), FETCH_RUNS_MAX_LIMIT);
|
|
185
|
-
url.searchParams.set('per_page', String(limit));
|
|
186
|
+
const fetcher = new RunFetcher({ baseUrl, project: page.project, runId, apiKey });
|
|
187
|
+
const run = await fetcher.fetchRun().catch(exitOnFetchError);
|
|
188
|
+
console.log(formatFetchRunsOutput({ data: run }, opts.format));
|
|
189
|
+
return;
|
|
186
190
|
}
|
|
187
191
|
|
|
192
|
+
const project = opts.project || page.project || process.env.TESTOMATIO_PROJECT;
|
|
193
|
+
if (!project) {
|
|
194
|
+
log.error(
|
|
195
|
+
pc.red('⚠️ --project (or TESTOMATIO_PROJECT) is required to list runs, unless --run or a URL is passed'),
|
|
196
|
+
);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const url = new URL(`/api/v2/${project}/runs`, baseUrl);
|
|
201
|
+
for (const [key, value] of page.params || []) url.searchParams.append(key, value);
|
|
202
|
+
if (opts.title) url.searchParams.set('search', opts.title);
|
|
203
|
+
if (opts.tql) url.searchParams.set('tql', opts.tql);
|
|
204
|
+
if (opts.rungroup) url.searchParams.set('groupId', opts.rungroup);
|
|
205
|
+
const requestedLimit = opts.latest ? 1 : parseInt(opts.limit, 10) || FETCH_RUNS_DEFAULT_LIMIT;
|
|
206
|
+
const limit = Math.min(Math.max(requestedLimit, 1), FETCH_RUNS_MAX_LIMIT);
|
|
207
|
+
url.searchParams.set('per_page', String(limit));
|
|
208
|
+
|
|
188
209
|
const response = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
|
|
189
210
|
const body = await response.json();
|
|
190
211
|
|
|
@@ -196,6 +217,177 @@ program
|
|
|
196
217
|
console.log(formatFetchRunsOutput(body, opts.format));
|
|
197
218
|
});
|
|
198
219
|
|
|
220
|
+
// fetch parses the options it shares with its subcommands (--format, --limit) wherever they are placed,
|
|
221
|
+
// so subcommands declare them only for help and read them with optsWithGlobals()
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Build a RunFetcher for the run argument of fetch status|count|report, or exit with an error.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} runArg
|
|
227
|
+
* @returns {RunFetcher}
|
|
228
|
+
*/
|
|
229
|
+
function createRunFetcher(runArg) {
|
|
230
|
+
const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config.TESTOMATIO;
|
|
231
|
+
if (!apiKey) {
|
|
232
|
+
log.error(pc.red('⚠️ TESTOMATIO API key required'));
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
return new RunFetcher({ ...resolveRunTarget(runArg), apiKey });
|
|
238
|
+
} catch (err) {
|
|
239
|
+
log.error(pc.red(`⚠️ ${err.message}`));
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* @param {{only?: string, onlyFailed?: boolean}} opts
|
|
246
|
+
* @returns {string[]}
|
|
247
|
+
*/
|
|
248
|
+
function getStatusFilter(opts) {
|
|
249
|
+
try {
|
|
250
|
+
return parseStatuses(opts);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
log.error(pc.red(`⚠️ ${err.message}`));
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* @param {Error} err
|
|
259
|
+
* @returns {never}
|
|
260
|
+
*/
|
|
261
|
+
function exitOnFetchError(err) {
|
|
262
|
+
log.error(pc.red(`⚠️ Failed to fetch: ${err.message}`));
|
|
263
|
+
process.exit(1);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const RUN_ARGUMENT_DESCRIPTION = 'Run id, or URL of a run in Testomat.io';
|
|
267
|
+
const STATUS_FILTER_DESCRIPTION = `Only testruns with these statuses, comma-separated (${TESTRUN_STATUSES.join(', ')})`;
|
|
268
|
+
|
|
269
|
+
fetchCommand
|
|
270
|
+
.command('status')
|
|
271
|
+
.description('Show a run with the number of its testruns by status')
|
|
272
|
+
.argument('<run>', RUN_ARGUMENT_DESCRIPTION)
|
|
273
|
+
.option('--format <format>', 'Machine-readable output: run status and counts as JSON (--format json)')
|
|
274
|
+
.action(async (runArg, _opts, command) => {
|
|
275
|
+
const opts = command.optsWithGlobals();
|
|
276
|
+
const fetcher = createRunFetcher(runArg);
|
|
277
|
+
|
|
278
|
+
const [run, summary] = await Promise.all([fetcher.fetchRun(), fetcher.countTestrunsByStatus()]).catch(
|
|
279
|
+
exitOnFetchError,
|
|
280
|
+
);
|
|
281
|
+
const counts = Object.fromEntries(TESTRUN_STATUSES.map(status => [status, summary.byStatus[status] || 0]));
|
|
282
|
+
|
|
283
|
+
if (opts.format === 'json') {
|
|
284
|
+
const data = { id: run.id, title: run.title, status: run.status, url: fetcher.runUrl, total: summary.total };
|
|
285
|
+
console.log(JSON.stringify({ ...data, ...counts }, null, 2));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const lines = [`run: ${run.title}`, `status: ${run.status}`, `url: ${fetcher.runUrl}`, `total: ${summary.total}`];
|
|
290
|
+
for (const [status, count] of Object.entries(counts)) lines.push(`${status}: ${count}`);
|
|
291
|
+
console.log(lines.join('\n'));
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
fetchCommand
|
|
295
|
+
.command('count')
|
|
296
|
+
.description('Print the number of testruns of a run, to pick --limit and filters for fetch report')
|
|
297
|
+
.argument('<run>', RUN_ARGUMENT_DESCRIPTION)
|
|
298
|
+
.option('--only <statuses>', STATUS_FILTER_DESCRIPTION)
|
|
299
|
+
.option('--only-failed', 'Only failed testruns (shorthand for --only failed)')
|
|
300
|
+
.action(async (runArg, _opts, command) => {
|
|
301
|
+
const opts = command.optsWithGlobals();
|
|
302
|
+
const fetcher = createRunFetcher(runArg);
|
|
303
|
+
const statuses = getStatusFilter(opts);
|
|
304
|
+
|
|
305
|
+
const total = await fetcher.countTestruns(statuses).catch(exitOnFetchError);
|
|
306
|
+
console.log(total);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
fetchCommand
|
|
310
|
+
.command('report')
|
|
311
|
+
.description('Print a run with its testruns as a Markdown report')
|
|
312
|
+
.argument('<run>', RUN_ARGUMENT_DESCRIPTION)
|
|
313
|
+
.option('--only <statuses>', STATUS_FILTER_DESCRIPTION)
|
|
314
|
+
.option('--only-failed', 'Only failed testruns (shorthand for --only failed)')
|
|
315
|
+
.option('--stack', 'Include stack traces')
|
|
316
|
+
.option('--artifacts', 'Download artifacts to --out and link them from the report')
|
|
317
|
+
.option('--out <dir>', 'Directory for downloaded artifacts (default: ./<run-title>-<run-id>)')
|
|
318
|
+
.option('--limit <number>', `Max number of testruns, failed first (default ${TESTRUNS_DEFAULT_LIMIT})`)
|
|
319
|
+
.option('--all', 'All matching testruns, fetched in chunks of 100 (overrides --limit)')
|
|
320
|
+
.option('--no-defects', 'Skip defects of the run and of its tests')
|
|
321
|
+
.action(async (runArg, _opts, command) => {
|
|
322
|
+
const opts = command.optsWithGlobals();
|
|
323
|
+
const fetcher = createRunFetcher(runArg);
|
|
324
|
+
const statuses = getStatusFilter(opts);
|
|
325
|
+
const limit = parseTestrunsLimit(opts);
|
|
326
|
+
|
|
327
|
+
const run = await fetcher.fetchRun().catch(exitOnFetchError);
|
|
328
|
+
|
|
329
|
+
if (opts.artifacts) {
|
|
330
|
+
const hasSharedCredentials = await fetcher.applySharedS3Credentials(run);
|
|
331
|
+
if (!hasSharedCredentials && !process.env.S3_BUCKET) {
|
|
332
|
+
log.error(pc.red('⚠️ --artifacts needs S3 access: share S3 credentials in project settings or set S3_BUCKET'));
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let runDefectsRequest;
|
|
338
|
+
if (opts.defects) {
|
|
339
|
+
runDefectsRequest = fetcher.listRunDefects().catch(err => {
|
|
340
|
+
log.warn(pc.yellow(`⚠️ Failed to fetch defects of the run: ${err.message}`));
|
|
341
|
+
return null;
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const [summary, list, runDefects] = await Promise.all([
|
|
346
|
+
fetcher.countTestrunsByStatus(),
|
|
347
|
+
fetcher.listTestruns({ statuses, limit }),
|
|
348
|
+
runDefectsRequest,
|
|
349
|
+
]).catch(exitOnFetchError);
|
|
350
|
+
|
|
351
|
+
let { testruns } = list;
|
|
352
|
+
if (list.total > testruns.length) {
|
|
353
|
+
log.warn(
|
|
354
|
+
pc.yellow(
|
|
355
|
+
`⚠️ Showing ${testruns.length} of ${list.total} testruns. Use --all, --limit or --only-failed.`,
|
|
356
|
+
),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (opts.stack || opts.artifacts) {
|
|
361
|
+
testruns = await fetcher.addTestrunDetails(testruns, (testrun, err) => {
|
|
362
|
+
log.warn(pc.yellow(`⚠️ Failed to fetch details of testrun ${testrun.id}: ${err.message}`));
|
|
363
|
+
});
|
|
364
|
+
if (!opts.stack) testruns = testruns.map(({ stack, ...testrun }) => testrun);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (opts.defects) {
|
|
368
|
+
testruns = await fetcher.addTestDefects(testruns, (testId, err) => {
|
|
369
|
+
log.warn(pc.yellow(`⚠️ Failed to fetch defects of test ${testId}: ${err.message}`));
|
|
370
|
+
});
|
|
371
|
+
} else {
|
|
372
|
+
testruns = testruns.map(({ defects, ...testrun }) => testrun);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (opts.artifacts) {
|
|
376
|
+
const outDir = opts.out || defaultArtifactsDir(run, fetcher.runId);
|
|
377
|
+
const artifactsCount = testruns.reduce((sum, testrun) => sum + (testrun.artifacts?.length || 0), 0);
|
|
378
|
+
log.info(`Downloading ${artifactsCount} artifacts to ${outDir}`);
|
|
379
|
+
testruns = await downloadArtifacts(testruns, outDir, (url, err) => {
|
|
380
|
+
log.warn(pc.yellow(`⚠️ Failed to download ${url}: ${err.message}`));
|
|
381
|
+
});
|
|
382
|
+
} else {
|
|
383
|
+
testruns = testruns.map(({ artifacts, ...testrun }) => testrun);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
process.stdout.write(
|
|
387
|
+
renderRunReport({ run, runUrl: fetcher.runUrl, summary, testruns, matched: list.total, statuses, runDefects }),
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
|
|
199
391
|
program
|
|
200
392
|
.command('run')
|
|
201
393
|
.alias('test')
|
|
@@ -215,8 +407,9 @@ program
|
|
|
215
407
|
(value, prev) => prev.concat([value]),
|
|
216
408
|
[],
|
|
217
409
|
)
|
|
218
|
-
.option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only)')
|
|
410
|
+
.option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only); env: TESTOMATIO_WARN')
|
|
219
411
|
.action(async (command, opts) => {
|
|
412
|
+
opts.warn ||= transformEnvVarToBoolean(process.env.TESTOMATIO_WARN);
|
|
220
413
|
if (opts.remote) {
|
|
221
414
|
if (opts.filterList) {
|
|
222
415
|
log.warn(pc.red('⚠️ --filter-list cannot be combined with --remote'));
|
|
@@ -232,6 +425,8 @@ program
|
|
|
232
425
|
const title = process.env.TESTOMATIO_TITLE;
|
|
233
426
|
const client = new TestomatClient({ apiKey, title });
|
|
234
427
|
|
|
428
|
+
let filterApplied = false;
|
|
429
|
+
|
|
235
430
|
if (opts.filter || opts.filterList) {
|
|
236
431
|
log.info('Filtering tests...');
|
|
237
432
|
// Example of use: npx @testomatio/reporter run "npx jest" --filter "testomatio:tag-name=frontend"
|
|
@@ -269,6 +464,7 @@ program
|
|
|
269
464
|
|
|
270
465
|
if (command && command.split && !opts.remote) {
|
|
271
466
|
command = applyFilter(command, tests);
|
|
467
|
+
filterApplied = true;
|
|
272
468
|
}
|
|
273
469
|
}
|
|
274
470
|
catch (err) {
|
|
@@ -345,6 +541,14 @@ program
|
|
|
345
541
|
return process.exit(0);
|
|
346
542
|
}
|
|
347
543
|
|
|
544
|
+
if (isPlaceholderCommand(command)) {
|
|
545
|
+
log.error(pc.red(`⚠️ The command "${command}" looks like a placeholder.`));
|
|
546
|
+
log.error('The example command from Testomat.io must be edited:');
|
|
547
|
+
log.error('replace the placeholder with your actual test runner command.');
|
|
548
|
+
log.error(runnerHint());
|
|
549
|
+
process.exit(1);
|
|
550
|
+
}
|
|
551
|
+
|
|
348
552
|
log.info( `🚀 Running`, pc.green(command));
|
|
349
553
|
|
|
350
554
|
const runTests = async () => {
|
|
@@ -354,9 +558,28 @@ program
|
|
|
354
558
|
env: { ...process.env, TESTOMATIO_PROCEED: 'true', runId: client.runId, TESTOMATIO_RUN: client.runId },
|
|
355
559
|
});
|
|
356
560
|
|
|
561
|
+
let errored = false;
|
|
562
|
+
|
|
563
|
+
cmd.on('error', err => {
|
|
564
|
+
errored = true;
|
|
565
|
+
const spawnError = /** @type {NodeJS.ErrnoException} */ (err);
|
|
566
|
+
if (spawnError.code === 'ENOENT') {
|
|
567
|
+
log.error(pc.red(`🔴 Command not found: ${pc.bold(testCmds[0])}`));
|
|
568
|
+
log.error(runnerHint());
|
|
569
|
+
} else {
|
|
570
|
+
log.error(pc.red(`🔴 Failed to start command: ${spawnError.message}`));
|
|
571
|
+
}
|
|
572
|
+
const finish = apiKey ? client.updateRunStatus('failed').catch(() => {}) : Promise.resolve();
|
|
573
|
+
finish.then(() => process.exit(1));
|
|
574
|
+
});
|
|
575
|
+
|
|
357
576
|
cmd.on('close', async code => {
|
|
577
|
+
if (errored) return;
|
|
358
578
|
const emoji = code === 0 ? '🟢' : '🔴';
|
|
359
579
|
log.info( emoji, `Runner exited with ${pc.bold(code)}`);
|
|
580
|
+
if (code !== 0 && filterApplied) {
|
|
581
|
+
buildFilterWarning(command).forEach(line => log.warn(line));
|
|
582
|
+
}
|
|
360
583
|
if (apiKey) {
|
|
361
584
|
const status = code === 0 ? 'passed' : 'failed';
|
|
362
585
|
await client.updateRunStatus(status);
|