@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
package/lib/adapter/codecept.js
CHANGED
|
@@ -23,7 +23,7 @@ if (!global.codeceptjs) {
|
|
|
23
23
|
global.codeceptjs = codeceptjs_1.default;
|
|
24
24
|
}
|
|
25
25
|
// @ts-ignore
|
|
26
|
-
const { event, recorder, codecept, output } = global.codeceptjs;
|
|
26
|
+
const { event, recorder, codecept, output, container } = global.codeceptjs;
|
|
27
27
|
const [, MAJOR_VERSION, MINOR_VERSION] = codecept
|
|
28
28
|
.version()
|
|
29
29
|
.match(/(\d+)\.(\d+)/)
|
|
@@ -52,6 +52,7 @@ function CodeceptReporter(config) {
|
|
|
52
52
|
const reportTestPromises = [];
|
|
53
53
|
let isRunFinalized = false;
|
|
54
54
|
const testTimeMap = {};
|
|
55
|
+
const failedUrls = new Map();
|
|
55
56
|
const clientConfig = buildCodeceptClientConfig(config);
|
|
56
57
|
const client = new client_js_1.default(clientConfig);
|
|
57
58
|
// Store original output methods for fallback
|
|
@@ -212,6 +213,24 @@ function CodeceptReporter(config) {
|
|
|
212
213
|
await finalizeRun('all.after');
|
|
213
214
|
}, true);
|
|
214
215
|
});
|
|
216
|
+
event.dispatcher.on(event.test.failed, (test, _err, hookName) => {
|
|
217
|
+
// no browser in suite hooks
|
|
218
|
+
if (hookName === 'BeforeSuite' || hookName === 'AfterSuite')
|
|
219
|
+
return;
|
|
220
|
+
const helper = Object.values(container.helpers()).find(h => typeof h.grabCurrentUrl === 'function');
|
|
221
|
+
if (!helper)
|
|
222
|
+
return;
|
|
223
|
+
recorder.add('url of failed test', async () => {
|
|
224
|
+
try {
|
|
225
|
+
const url = await helper.grabCurrentUrl();
|
|
226
|
+
if (url)
|
|
227
|
+
failedUrls.set(test.uid, url);
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
debug('Failed to grab url of failed test', err?.message || err);
|
|
231
|
+
}
|
|
232
|
+
}, true);
|
|
233
|
+
});
|
|
215
234
|
event.dispatcher.on(event.test.skipped, test => {
|
|
216
235
|
const { uid, tags, title } = test.simplify();
|
|
217
236
|
if (uid && reportedTestUids.has(uid))
|
|
@@ -240,6 +259,10 @@ function CodeceptReporter(config) {
|
|
|
240
259
|
const keyValues = index_js_1.services.keyValues.get(test.fullTitle());
|
|
241
260
|
const links = index_js_1.services.links.get(test.fullTitle());
|
|
242
261
|
const screenshotOnFailPath = artifacts.screenshot || null;
|
|
262
|
+
const meta = { ...keyValues, ...test.meta };
|
|
263
|
+
if (test.state === constants_js_1.STATUS.FAILED && failedUrls.has(uid))
|
|
264
|
+
meta.failedUrl = failedUrls.get(uid);
|
|
265
|
+
failedUrls.delete(uid);
|
|
243
266
|
// Build step hierarchy with screenshot from screenshotOnFail
|
|
244
267
|
const stepHierarchy = buildUnifiedStepHierarchy(test.steps, hookSteps, screenshotOnFailPath);
|
|
245
268
|
index_js_1.services.setContext(null);
|
|
@@ -256,7 +279,7 @@ function CodeceptReporter(config) {
|
|
|
256
279
|
logs,
|
|
257
280
|
links,
|
|
258
281
|
manuallyAttachedArtifacts,
|
|
259
|
-
meta
|
|
282
|
+
meta,
|
|
260
283
|
});
|
|
261
284
|
reportTestPromises.push(reportTestPromise);
|
|
262
285
|
processArtifactsForUpload(artifacts, uid, title, videos, traces);
|
package/lib/adapter/e2e.js
CHANGED
|
@@ -98,6 +98,9 @@ function toTestData(result, groups, artifactsRoot) {
|
|
|
98
98
|
meta.retryCount = attempts.length - 1;
|
|
99
99
|
if (result.status === 'flaky')
|
|
100
100
|
meta.isFlaky = true;
|
|
101
|
+
const status = testStatus(result.status);
|
|
102
|
+
if (status === constants_js_1.STATUS.FAILED && final?.failure?.url)
|
|
103
|
+
meta.failedUrl = final.failure.url;
|
|
101
104
|
const filesById = new Map();
|
|
102
105
|
for (const artifact of final?.artifacts || []) {
|
|
103
106
|
if (artifact.path) {
|
|
@@ -129,7 +132,7 @@ function toTestData(result, groups, artifactsRoot) {
|
|
|
129
132
|
files: files.length ? files : undefined,
|
|
130
133
|
meta,
|
|
131
134
|
};
|
|
132
|
-
return { status
|
|
135
|
+
return { status, data };
|
|
133
136
|
}
|
|
134
137
|
/** Attempts of a result; a serial member keeps its part of the group's attempts. */
|
|
135
138
|
function attemptsOf(result, groups) {
|
|
@@ -141,6 +144,7 @@ function attemptsOf(result, groups) {
|
|
|
141
144
|
return {
|
|
142
145
|
status: member?.status || attempt.status,
|
|
143
146
|
error: member?.error || attempt.error,
|
|
147
|
+
failure: member?.failure || attempt.failure,
|
|
144
148
|
steps: member?.steps || [],
|
|
145
149
|
appLog: member?.appLog || [],
|
|
146
150
|
skip: member?.skip,
|
|
@@ -104,7 +104,11 @@ class PlaywrightReporter {
|
|
|
104
104
|
if (result.status === 'passed')
|
|
105
105
|
status = 'failed';
|
|
106
106
|
}
|
|
107
|
-
const
|
|
107
|
+
const testStatus = checkStatus(status);
|
|
108
|
+
let failedUrl;
|
|
109
|
+
if (testStatus === constants_js_1.STATUS.FAILED)
|
|
110
|
+
failedUrl = lastNavigatedUrl(result.steps, test.parent.project().use.baseURL);
|
|
111
|
+
const reportTestPromise = this.client.addTestRun(testStatus, {
|
|
108
112
|
rid: `${rid}-${project.name}`,
|
|
109
113
|
error,
|
|
110
114
|
test_id: (0, utils_js_1.getTestomatIdFromTestTitle)(`${title} ${tags.join(' ')}`),
|
|
@@ -122,6 +126,7 @@ class PlaywrightReporter {
|
|
|
122
126
|
isMobile: project.isMobile,
|
|
123
127
|
project: project.name,
|
|
124
128
|
projectDependencies: project.dependencies?.length ? project.dependencies : null,
|
|
129
|
+
failedUrl,
|
|
125
130
|
...testMeta,
|
|
126
131
|
...meta,
|
|
127
132
|
...project.metadata, // metadata has any type (in playwright), but we will stringify it in client.js
|
|
@@ -222,6 +227,32 @@ function processArtifactsForUpload(attachments) {
|
|
|
222
227
|
function isScreenshotArtifact(attachment) {
|
|
223
228
|
return attachment?.contentType === 'image/png' && attachment?.name === 'screenshot';
|
|
224
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* Last url the test navigated to; the reporter has no access to the page itself.
|
|
232
|
+
* @returns {string|undefined}
|
|
233
|
+
*/
|
|
234
|
+
function lastNavigatedUrl(steps, baseURL) {
|
|
235
|
+
const url = findLastNavigation(steps);
|
|
236
|
+
if (!url)
|
|
237
|
+
return;
|
|
238
|
+
try {
|
|
239
|
+
return new URL(url, baseURL).href;
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return url;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function findLastNavigation(steps = []) {
|
|
246
|
+
let url;
|
|
247
|
+
for (const step of steps) {
|
|
248
|
+
// 'Navigate to "url"' since Playwright 1.53, 'page.goto(url)' before
|
|
249
|
+
const match = step.title.match(/^Navigate to "(.+)"$/) || step.title.match(/^page\.goto\((.+)\)$/);
|
|
250
|
+
if (step.category === 'pw:api' && match)
|
|
251
|
+
url = match[1];
|
|
252
|
+
url = findLastNavigation(step.steps) || url;
|
|
253
|
+
}
|
|
254
|
+
return url;
|
|
255
|
+
}
|
|
225
256
|
function appendStep(step, shift = 0) {
|
|
226
257
|
// nesting too deep, ignore those steps
|
|
227
258
|
if (shift >= 10)
|
package/lib/adapter/vitest.js
CHANGED
|
@@ -394,7 +394,10 @@ function normalizeVitestTest(test) {
|
|
|
394
394
|
file,
|
|
395
395
|
suiteTitle,
|
|
396
396
|
logs: '',
|
|
397
|
-
meta:
|
|
397
|
+
meta: {
|
|
398
|
+
...(test.task?.meta || {}),
|
|
399
|
+
...(typeof test.meta === 'function' ? test.meta() : test.meta || {}),
|
|
400
|
+
},
|
|
398
401
|
};
|
|
399
402
|
}
|
|
400
403
|
return {
|
package/lib/adapter/webdriver.js
CHANGED
|
@@ -42,6 +42,8 @@ const utils_js_1 = require("../utils/utils.js");
|
|
|
42
42
|
const index_js_1 = require("../services/index.js");
|
|
43
43
|
const constants_js_1 = require("../constants.js");
|
|
44
44
|
const data_storage_js_1 = require("../data-storage.js");
|
|
45
|
+
const debug_1 = __importDefault(require("debug"));
|
|
46
|
+
const debug = (0, debug_1.default)('@testomatio/reporter:adapter:webdriver');
|
|
45
47
|
global.TESTOMATIO_ADAPTER = 'webdriverio';
|
|
46
48
|
class WebdriverReporter extends reporter_1.default {
|
|
47
49
|
constructor(options) {
|
|
@@ -130,13 +132,19 @@ class WebdriverReporter extends reporter_1.default {
|
|
|
130
132
|
async addTest(test) {
|
|
131
133
|
if (!this.client)
|
|
132
134
|
return;
|
|
133
|
-
const { title, _duration: duration, state, error, output, links, artifacts,
|
|
135
|
+
const { title, _duration: duration, state, error, output, links, artifacts, logs } = test;
|
|
136
|
+
let { meta } = test;
|
|
134
137
|
const testId = (0, utils_js_1.getTestomatIdFromTestTitle)(title);
|
|
135
138
|
const screenshotEndpoint = '/session/:sessionId/screenshot';
|
|
136
139
|
const screenshotsBuffers = output
|
|
137
140
|
.filter(el => el.endpoint === screenshotEndpoint && el.result && el.result.value)
|
|
138
141
|
.map(el => Buffer.from(el.result.value, 'base64'));
|
|
139
142
|
const rid = (0, data_storage_js_1.stringToMD5Hash)(test.fullTitle);
|
|
143
|
+
if (state === 'failed') {
|
|
144
|
+
const failedUrl = (await grabCurrentUrl()) || lastNavigatedUrl(output);
|
|
145
|
+
if (failedUrl)
|
|
146
|
+
meta = { ...meta, failedUrl };
|
|
147
|
+
}
|
|
140
148
|
await this.client.addTestRun(state, {
|
|
141
149
|
rid,
|
|
142
150
|
manuallyAttachedArtifacts: test.artifacts,
|
|
@@ -153,7 +161,7 @@ class WebdriverReporter extends reporter_1.default {
|
|
|
153
161
|
/**
|
|
154
162
|
* @param {import('../../types/types.js').WebdriverIOScenario} scenario
|
|
155
163
|
*/
|
|
156
|
-
addBddScenario(scenario) {
|
|
164
|
+
async addBddScenario(scenario) {
|
|
157
165
|
if (!this.client)
|
|
158
166
|
return;
|
|
159
167
|
const { title, _duration: duration } = scenario;
|
|
@@ -168,6 +176,12 @@ class WebdriverReporter extends reporter_1.default {
|
|
|
168
176
|
.filter(Boolean);
|
|
169
177
|
const error = errors.join('\n');
|
|
170
178
|
const tags = scenario.tags.map(tag => tag.name);
|
|
179
|
+
let meta;
|
|
180
|
+
if (scenarioState === 'failed') {
|
|
181
|
+
const failedUrl = await grabCurrentUrl();
|
|
182
|
+
if (failedUrl)
|
|
183
|
+
meta = { failedUrl };
|
|
184
|
+
}
|
|
171
185
|
return this.client.addTestRun(scenarioState, {
|
|
172
186
|
error: error ? Error(error) : null,
|
|
173
187
|
title,
|
|
@@ -175,10 +189,38 @@ class WebdriverReporter extends reporter_1.default {
|
|
|
175
189
|
time: duration,
|
|
176
190
|
tags,
|
|
177
191
|
file: scenario.file,
|
|
192
|
+
meta,
|
|
178
193
|
// filesBuffers: screenshotsBuffers,
|
|
179
194
|
});
|
|
180
195
|
}
|
|
181
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Current url of the browser in this worker; reporters run in the worker process.
|
|
199
|
+
* @returns {Promise<string|undefined>}
|
|
200
|
+
*/
|
|
201
|
+
async function grabCurrentUrl() {
|
|
202
|
+
// @ts-ignore
|
|
203
|
+
const browser = globalThis.browser;
|
|
204
|
+
if (typeof browser?.getUrl !== 'function')
|
|
205
|
+
return;
|
|
206
|
+
try {
|
|
207
|
+
const url = await browser.getUrl();
|
|
208
|
+
// multiremote returns a url per browser
|
|
209
|
+
if (typeof url === 'string')
|
|
210
|
+
return url;
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
debug('Failed to grab url of failed test', err?.message || err);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Last url opened by the test, taken from its command log.
|
|
218
|
+
* @returns {string|undefined}
|
|
219
|
+
*/
|
|
220
|
+
function lastNavigatedUrl(output = []) {
|
|
221
|
+
const navigations = output.filter(el => el.endpoint === '/session/:sessionId/url' && el.body?.url);
|
|
222
|
+
return navigations.at(-1)?.body.url;
|
|
223
|
+
}
|
|
182
224
|
/**
|
|
183
225
|
*
|
|
184
226
|
* @param {*} fullTestTitle
|
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,7 +127,7 @@ 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')
|
|
124
133
|
.argument('[url]', 'URL of a run or of a (filtered) runs page in Testomat.io')
|
|
@@ -127,7 +136,8 @@ program
|
|
|
127
136
|
.option('--title <text>', 'Filter by run title')
|
|
128
137
|
.option('--tql <query>', 'Filter using Testomat Query Language')
|
|
129
138
|
.option('--rungroup <uid>', 'Filter by rungroup id')
|
|
130
|
-
|
|
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})`)
|
|
131
141
|
.option('--latest', 'Only fetch the most recent run (shorthand for --limit 1)')
|
|
132
142
|
.option('--format <format>', 'Machine-readable output: run ids, one per line (--format id) or run details (--format json)')
|
|
133
143
|
.action(async (pageUrl, opts) => {
|
|
@@ -177,6 +187,141 @@ program
|
|
|
177
187
|
}
|
|
178
188
|
console.log((0, pipe_utils_js_1.formatFetchRunsOutput)(body, opts.format));
|
|
179
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
|
+
});
|
|
180
325
|
program
|
|
181
326
|
.command('run')
|
|
182
327
|
.alias('test')
|
|
@@ -188,8 +333,9 @@ program
|
|
|
188
333
|
.option('--kind <type>', 'Specify run type: automated, manual, mixed, or detect')
|
|
189
334
|
.option('--remote <profile>', 'Trigger run on the named Testomat.io CI profile instead of executing locally')
|
|
190
335
|
.option('--remote-param <kv>', 'key=value pair forwarded to the CI profile config (repeat for multiple)', (value, prev) => prev.concat([value]), [])
|
|
191
|
-
.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')
|
|
192
337
|
.action(async (command, opts) => {
|
|
338
|
+
opts.warn ||= (0, utils_js_1.transformEnvVarToBoolean)(process.env.TESTOMATIO_WARN);
|
|
193
339
|
if (opts.remote) {
|
|
194
340
|
if (opts.filterList) {
|
|
195
341
|
log_js_1.log.warn(picocolors_1.default.red('⚠️ --filter-list cannot be combined with --remote'));
|
|
@@ -203,6 +349,7 @@ program
|
|
|
203
349
|
const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config_js_1.config.TESTOMATIO;
|
|
204
350
|
const title = process.env.TESTOMATIO_TITLE;
|
|
205
351
|
const client = new client_js_1.default({ apiKey, title });
|
|
352
|
+
let filterApplied = false;
|
|
206
353
|
if (opts.filter || opts.filterList) {
|
|
207
354
|
log_js_1.log.info('Filtering tests...');
|
|
208
355
|
// Example of use: npx @testomatio/reporter run "npx jest" --filter "testomatio:tag-name=frontend"
|
|
@@ -237,6 +384,7 @@ program
|
|
|
237
384
|
}
|
|
238
385
|
if (command && command.split && !opts.remote) {
|
|
239
386
|
command = (0, utils_js_1.applyFilter)(command, tests);
|
|
387
|
+
filterApplied = true;
|
|
240
388
|
}
|
|
241
389
|
}
|
|
242
390
|
catch (err) {
|
|
@@ -310,6 +458,13 @@ program
|
|
|
310
458
|
}
|
|
311
459
|
return process.exit(0);
|
|
312
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
|
+
}
|
|
313
468
|
log_js_1.log.info(`🚀 Running`, picocolors_1.default.green(command));
|
|
314
469
|
const runTests = async () => {
|
|
315
470
|
const testCmds = command.split(' ');
|
|
@@ -317,9 +472,28 @@ program
|
|
|
317
472
|
stdio: 'inherit',
|
|
318
473
|
env: { ...process.env, TESTOMATIO_PROCEED: 'true', runId: client.runId, TESTOMATIO_RUN: client.runId },
|
|
319
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
|
+
});
|
|
320
489
|
cmd.on('close', async (code) => {
|
|
490
|
+
if (errored)
|
|
491
|
+
return;
|
|
321
492
|
const emoji = code === 0 ? '🟢' : '🔴';
|
|
322
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
|
+
}
|
|
323
497
|
if (apiKey) {
|
|
324
498
|
const status = code === 0 ? 'passed' : 'failed';
|
|
325
499
|
await client.updateRunStatus(status);
|
|
@@ -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;
|