testomatio-reporter-cli 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/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 +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/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,18 @@ 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
|
+
TESTRUNS_MAX_LIMIT,
|
|
32
|
+
TESTRUN_STATUSES,
|
|
33
|
+
} from '../fetch/testruns.js';
|
|
34
|
+
import { renderRunReport } from '../fetch/report.js';
|
|
22
35
|
|
|
23
36
|
const debug = createDebugMessages('@testomatio/reporter:cli');
|
|
24
37
|
const version = getPackageVersion();
|
|
@@ -42,8 +55,12 @@ program
|
|
|
42
55
|
// remaining output to stderr, skip the banner, and silence info-level logs so
|
|
43
56
|
// stdout stays clean for capture (e.g. RUN_ID=$(... start --format id)).
|
|
44
57
|
// Set TESTOMATIO_LOG_LEVEL=INFO to re-enable progress logs for debugging.
|
|
45
|
-
const subOpts = actionCommand.
|
|
46
|
-
|
|
58
|
+
const subOpts = actionCommand.optsWithGlobals();
|
|
59
|
+
// fetch status|count|report print their result on stdout, so the rest goes to stderr too
|
|
60
|
+
const isFetchSubcommand = actionCommand.parent?.name() === 'fetch';
|
|
61
|
+
if (isFetchSubcommand && !subOpts.format) {
|
|
62
|
+
process.env.TESTOMATIO_LOG_STDERR = '1';
|
|
63
|
+
} else if (subOpts.filterList || subOpts.format) {
|
|
47
64
|
process.env.TESTOMATIO_LOG_STDERR = '1';
|
|
48
65
|
process.env.TESTOMATIO_LOG_LEVEL ||= 'WARN';
|
|
49
66
|
// with --format json the logs are machine-readable too: one JSON object per line on stderr
|
|
@@ -59,9 +76,10 @@ program
|
|
|
59
76
|
.option('--kind <type>', 'Specify run type: automated, manual, mixed, or detect')
|
|
60
77
|
.option('--filter <filter>', 'Scope the prepared run to tests matching the filter (no execution)')
|
|
61
78
|
.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)')
|
|
79
|
+
.option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only); env: TESTOMATIO_WARN')
|
|
63
80
|
.action(async opts => {
|
|
64
81
|
cleanLatestRunId();
|
|
82
|
+
opts.warn ||= transformEnvVarToBoolean(process.env.TESTOMATIO_WARN);
|
|
65
83
|
|
|
66
84
|
log.info('Starting a new Run on Testomat.io...');
|
|
67
85
|
const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config.TESTOMATIO;
|
|
@@ -127,7 +145,7 @@ program
|
|
|
127
145
|
});
|
|
128
146
|
});
|
|
129
147
|
|
|
130
|
-
program
|
|
148
|
+
const fetchCommand = program
|
|
131
149
|
.command('fetch')
|
|
132
150
|
.description('Fetch runs from Testomat.io API v2')
|
|
133
151
|
.argument('[url]', 'URL of a run or of a (filtered) runs page in Testomat.io')
|
|
@@ -136,10 +154,10 @@ program
|
|
|
136
154
|
.option('--title <text>', 'Filter by run title')
|
|
137
155
|
.option('--tql <query>', 'Filter using Testomat Query Language')
|
|
138
156
|
.option('--rungroup <uid>', 'Filter by rungroup id')
|
|
157
|
+
// no default value: fetch parses --limit for its subcommands too, see createRunFetcher
|
|
139
158
|
.option(
|
|
140
159
|
'--limit <number>',
|
|
141
|
-
`Max number of runs to return (max ${FETCH_RUNS_MAX_LIMIT})`,
|
|
142
|
-
String(FETCH_RUNS_DEFAULT_LIMIT),
|
|
160
|
+
`Max number of runs to return (default ${FETCH_RUNS_DEFAULT_LIMIT}, max ${FETCH_RUNS_MAX_LIMIT})`,
|
|
143
161
|
)
|
|
144
162
|
.option('--latest', 'Only fetch the most recent run (shorthand for --limit 1)')
|
|
145
163
|
.option(
|
|
@@ -196,6 +214,166 @@ program
|
|
|
196
214
|
console.log(formatFetchRunsOutput(body, opts.format));
|
|
197
215
|
});
|
|
198
216
|
|
|
217
|
+
// fetch parses the options it shares with its subcommands (--project, --format, --limit) wherever they are placed,
|
|
218
|
+
// so subcommands declare them only for help and read them with optsWithGlobals()
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Build a RunFetcher for the run argument of fetch status|count|report, or exit with an error.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} runArg
|
|
224
|
+
* @param {{project?: string}} opts
|
|
225
|
+
* @returns {RunFetcher}
|
|
226
|
+
*/
|
|
227
|
+
function createRunFetcher(runArg, opts) {
|
|
228
|
+
const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config.TESTOMATIO;
|
|
229
|
+
if (!apiKey) {
|
|
230
|
+
log.error(pc.red('⚠️ TESTOMATIO API key required'));
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
return new RunFetcher({ ...resolveRunTarget(runArg, opts), apiKey });
|
|
236
|
+
} catch (err) {
|
|
237
|
+
log.error(pc.red(`⚠️ ${err.message}`));
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {{only?: string, onlyFailed?: boolean}} opts
|
|
244
|
+
* @returns {string[]}
|
|
245
|
+
*/
|
|
246
|
+
function getStatusFilter(opts) {
|
|
247
|
+
try {
|
|
248
|
+
return parseStatuses(opts);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
log.error(pc.red(`⚠️ ${err.message}`));
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* @param {Error} err
|
|
257
|
+
* @returns {never}
|
|
258
|
+
*/
|
|
259
|
+
function exitOnFetchError(err) {
|
|
260
|
+
log.error(pc.red(`⚠️ Failed to fetch: ${err.message}`));
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const RUN_ARGUMENT_DESCRIPTION = 'Run id, or URL of a run in Testomat.io';
|
|
265
|
+
const STATUS_FILTER_DESCRIPTION = `Only testruns with these statuses, comma-separated (${TESTRUN_STATUSES.join(', ')})`;
|
|
266
|
+
|
|
267
|
+
fetchCommand
|
|
268
|
+
.command('status')
|
|
269
|
+
.description('Show a run with the number of its testruns by status')
|
|
270
|
+
.argument('<run>', RUN_ARGUMENT_DESCRIPTION)
|
|
271
|
+
.option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
|
|
272
|
+
.option('--format <format>', 'Machine-readable output: run status and counts as JSON (--format json)')
|
|
273
|
+
.action(async (runArg, _opts, command) => {
|
|
274
|
+
const opts = command.optsWithGlobals();
|
|
275
|
+
const fetcher = createRunFetcher(runArg, opts);
|
|
276
|
+
|
|
277
|
+
const [run, summary] = await Promise.all([fetcher.fetchRun(), fetcher.countTestrunsByStatus()]).catch(
|
|
278
|
+
exitOnFetchError,
|
|
279
|
+
);
|
|
280
|
+
const counts = Object.fromEntries(TESTRUN_STATUSES.map(status => [status, summary.byStatus[status] || 0]));
|
|
281
|
+
|
|
282
|
+
if (opts.format === 'json') {
|
|
283
|
+
const data = { id: run.id, title: run.title, status: run.status, url: fetcher.runUrl, total: summary.total };
|
|
284
|
+
console.log(JSON.stringify({ ...data, ...counts }, null, 2));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const lines = [`run: ${run.title}`, `status: ${run.status}`, `url: ${fetcher.runUrl}`, `total: ${summary.total}`];
|
|
289
|
+
for (const [status, count] of Object.entries(counts)) lines.push(`${status}: ${count}`);
|
|
290
|
+
console.log(lines.join('\n'));
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
fetchCommand
|
|
294
|
+
.command('count')
|
|
295
|
+
.description('Print the number of testruns of a run, to pick --limit and filters for fetch report')
|
|
296
|
+
.argument('<run>', RUN_ARGUMENT_DESCRIPTION)
|
|
297
|
+
.option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
|
|
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, opts);
|
|
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('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
|
|
314
|
+
.option('--only <statuses>', STATUS_FILTER_DESCRIPTION)
|
|
315
|
+
.option('--only-failed', 'Only failed testruns (shorthand for --only failed)')
|
|
316
|
+
.option('--stack', 'Include stack traces')
|
|
317
|
+
.option('--artifacts', 'Download artifacts to --out and link them from the report')
|
|
318
|
+
.option('--out <dir>', 'Directory for downloaded artifacts (default: ./<run-title>-<run-id>)')
|
|
319
|
+
.option(
|
|
320
|
+
'--limit <number>',
|
|
321
|
+
`Max number of testruns, failed first (default ${TESTRUNS_DEFAULT_LIMIT}, max ${TESTRUNS_MAX_LIMIT})`,
|
|
322
|
+
)
|
|
323
|
+
.action(async (runArg, _opts, command) => {
|
|
324
|
+
const opts = command.optsWithGlobals();
|
|
325
|
+
const fetcher = createRunFetcher(runArg, opts);
|
|
326
|
+
const statuses = getStatusFilter(opts);
|
|
327
|
+
const { limit, capped } = parseTestrunsLimit(opts.limit);
|
|
328
|
+
if (capped) log.warn(pc.yellow(`⚠️ --limit is capped at ${TESTRUNS_MAX_LIMIT}`));
|
|
329
|
+
|
|
330
|
+
const run = await fetcher.fetchRun().catch(exitOnFetchError);
|
|
331
|
+
|
|
332
|
+
if (opts.artifacts) {
|
|
333
|
+
const hasSharedCredentials = await fetcher.applySharedS3Credentials(run);
|
|
334
|
+
if (!hasSharedCredentials && !process.env.S3_BUCKET) {
|
|
335
|
+
log.error(pc.red('⚠️ --artifacts needs S3 access: share S3 credentials in project settings or set S3_BUCKET'));
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const [summary, list] = await Promise.all([
|
|
341
|
+
fetcher.countTestrunsByStatus(),
|
|
342
|
+
fetcher.listTestruns({ statuses, limit }),
|
|
343
|
+
]).catch(exitOnFetchError);
|
|
344
|
+
|
|
345
|
+
let { testruns } = list;
|
|
346
|
+
if (list.total > testruns.length) {
|
|
347
|
+
log.warn(
|
|
348
|
+
pc.yellow(
|
|
349
|
+
`⚠️ Showing ${testruns.length} of ${list.total} testruns. Use --limit or --only-failed to narrow it.`,
|
|
350
|
+
),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (opts.stack || opts.artifacts) {
|
|
355
|
+
testruns = await fetcher.addTestrunDetails(testruns, (testrun, err) => {
|
|
356
|
+
log.warn(pc.yellow(`⚠️ Failed to fetch details of testrun ${testrun.id}: ${err.message}`));
|
|
357
|
+
});
|
|
358
|
+
if (!opts.stack) testruns = testruns.map(({ stack, ...testrun }) => testrun);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (opts.artifacts) {
|
|
362
|
+
const outDir = opts.out || defaultArtifactsDir(run, fetcher.runId);
|
|
363
|
+
const artifactsCount = testruns.reduce((sum, testrun) => sum + (testrun.artifacts?.length || 0), 0);
|
|
364
|
+
log.info(`Downloading ${artifactsCount} artifacts to ${outDir}`);
|
|
365
|
+
testruns = await downloadArtifacts(testruns, outDir, (url, err) => {
|
|
366
|
+
log.warn(pc.yellow(`⚠️ Failed to download ${url}: ${err.message}`));
|
|
367
|
+
});
|
|
368
|
+
} else {
|
|
369
|
+
testruns = testruns.map(({ artifacts, ...testrun }) => testrun);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
process.stdout.write(
|
|
373
|
+
renderRunReport({ run, runUrl: fetcher.runUrl, summary, testruns, matched: list.total, statuses }),
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
|
|
199
377
|
program
|
|
200
378
|
.command('run')
|
|
201
379
|
.alias('test')
|
|
@@ -215,8 +393,9 @@ program
|
|
|
215
393
|
(value, prev) => prev.concat([value]),
|
|
216
394
|
[],
|
|
217
395
|
)
|
|
218
|
-
.option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only)')
|
|
396
|
+
.option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only); env: TESTOMATIO_WARN')
|
|
219
397
|
.action(async (command, opts) => {
|
|
398
|
+
opts.warn ||= transformEnvVarToBoolean(process.env.TESTOMATIO_WARN);
|
|
220
399
|
if (opts.remote) {
|
|
221
400
|
if (opts.filterList) {
|
|
222
401
|
log.warn(pc.red('⚠️ --filter-list cannot be combined with --remote'));
|
|
@@ -232,6 +411,8 @@ program
|
|
|
232
411
|
const title = process.env.TESTOMATIO_TITLE;
|
|
233
412
|
const client = new TestomatClient({ apiKey, title });
|
|
234
413
|
|
|
414
|
+
let filterApplied = false;
|
|
415
|
+
|
|
235
416
|
if (opts.filter || opts.filterList) {
|
|
236
417
|
log.info('Filtering tests...');
|
|
237
418
|
// Example of use: npx @testomatio/reporter run "npx jest" --filter "testomatio:tag-name=frontend"
|
|
@@ -269,6 +450,7 @@ program
|
|
|
269
450
|
|
|
270
451
|
if (command && command.split && !opts.remote) {
|
|
271
452
|
command = applyFilter(command, tests);
|
|
453
|
+
filterApplied = true;
|
|
272
454
|
}
|
|
273
455
|
}
|
|
274
456
|
catch (err) {
|
|
@@ -345,6 +527,14 @@ program
|
|
|
345
527
|
return process.exit(0);
|
|
346
528
|
}
|
|
347
529
|
|
|
530
|
+
if (isPlaceholderCommand(command)) {
|
|
531
|
+
log.error(pc.red(`⚠️ The command "${command}" looks like a placeholder.`));
|
|
532
|
+
log.error('The example command from Testomat.io must be edited:');
|
|
533
|
+
log.error('replace the placeholder with your actual test runner command.');
|
|
534
|
+
log.error(runnerHint());
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
|
|
348
538
|
log.info( `🚀 Running`, pc.green(command));
|
|
349
539
|
|
|
350
540
|
const runTests = async () => {
|
|
@@ -354,9 +544,28 @@ program
|
|
|
354
544
|
env: { ...process.env, TESTOMATIO_PROCEED: 'true', runId: client.runId, TESTOMATIO_RUN: client.runId },
|
|
355
545
|
});
|
|
356
546
|
|
|
547
|
+
let errored = false;
|
|
548
|
+
|
|
549
|
+
cmd.on('error', err => {
|
|
550
|
+
errored = true;
|
|
551
|
+
const spawnError = /** @type {NodeJS.ErrnoException} */ (err);
|
|
552
|
+
if (spawnError.code === 'ENOENT') {
|
|
553
|
+
log.error(pc.red(`🔴 Command not found: ${pc.bold(testCmds[0])}`));
|
|
554
|
+
log.error(runnerHint());
|
|
555
|
+
} else {
|
|
556
|
+
log.error(pc.red(`🔴 Failed to start command: ${spawnError.message}`));
|
|
557
|
+
}
|
|
558
|
+
const finish = apiKey ? client.updateRunStatus('failed').catch(() => {}) : Promise.resolve();
|
|
559
|
+
finish.then(() => process.exit(1));
|
|
560
|
+
});
|
|
561
|
+
|
|
357
562
|
cmd.on('close', async code => {
|
|
563
|
+
if (errored) return;
|
|
358
564
|
const emoji = code === 0 ? '🟢' : '🔴';
|
|
359
565
|
log.info( emoji, `Runner exited with ${pc.bold(code)}`);
|
|
566
|
+
if (code !== 0 && filterApplied) {
|
|
567
|
+
buildFilterWarning(command).forEach(line => log.warn(line));
|
|
568
|
+
}
|
|
360
569
|
if (apiKey) {
|
|
361
570
|
const status = code === 0 ? 'passed' : 'failed';
|
|
362
571
|
await client.updateRunStatus(status);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { renderTests } from '../pipe/markdown.js';
|
|
2
|
+
|
|
3
|
+
const TITLE_SEPARATOR = ' ⇒ ';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Map a testrun of API v2 to the test shape of the Markdown pipe.
|
|
7
|
+
* Without a suite, the API returns the title as "<suite> ⇒ <title>".
|
|
8
|
+
*
|
|
9
|
+
* @param {object} testrun
|
|
10
|
+
* @returns {object}
|
|
11
|
+
*/
|
|
12
|
+
function toReportTest(testrun) {
|
|
13
|
+
let title = String(testrun.test_title || '').trim();
|
|
14
|
+
let suite = testrun.suiteTitle;
|
|
15
|
+
|
|
16
|
+
if (!suite && title.includes(TITLE_SEPARATOR)) {
|
|
17
|
+
const [suitePart, ...titleParts] = title.split(TITLE_SEPARATOR);
|
|
18
|
+
suite = suitePart.trim();
|
|
19
|
+
title = titleParts.join(TITLE_SEPARATOR).trim();
|
|
20
|
+
}
|
|
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
|
+
/**
|
|
35
|
+
* Render a fetched run with its testruns as a Markdown report.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} params
|
|
38
|
+
* @param {object} params.run - Run from GET /api/v2/:project/runs/:id
|
|
39
|
+
* @param {string} params.runUrl
|
|
40
|
+
* @param {{total: number, byStatus: Record<string, number>}} params.summary - Counts of all testruns of the run
|
|
41
|
+
* @param {object[]} params.testruns
|
|
42
|
+
* @param {number} params.matched - Number of testruns matching the filter
|
|
43
|
+
* @param {string[]} params.statuses - Status filter, empty for all testruns
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function renderRunReport({ run, runUrl, summary, testruns, matched, statuses }) {
|
|
47
|
+
const status = String(run.status || 'unknown');
|
|
48
|
+
const sections = [`# ${run.title || run.id} — ${status.toUpperCase()}`];
|
|
49
|
+
|
|
50
|
+
const rows = [
|
|
51
|
+
['Run ID', `\`${run.id}\``],
|
|
52
|
+
['Run URL', `<${runUrl}>`],
|
|
53
|
+
['Status', status],
|
|
54
|
+
];
|
|
55
|
+
if (run.env) rows.push(['Environment', run.env]);
|
|
56
|
+
if (run.launched_at) rows.push(['Started', run.launched_at]);
|
|
57
|
+
if (run.finished_at) rows.push(['Finished', run.finished_at]);
|
|
58
|
+
|
|
59
|
+
let shown = `${testruns.length} of ${matched} shown`;
|
|
60
|
+
if (statuses.length) shown += ` (only: ${statuses.join(', ')})`;
|
|
61
|
+
rows.push(['Testruns', shown]);
|
|
62
|
+
|
|
63
|
+
sections.push(
|
|
64
|
+
['| Key | Value |', '| --- | ----- |', ...rows.map(([key, value]) => `| ${key} | ${value} |`)].join('\n'),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (run.description?.trim()) sections.push(`## Description\n\n${run.description.trim()}`);
|
|
68
|
+
|
|
69
|
+
const counts = Object.entries(summary.byStatus).sort(([, a], [, b]) => b - a);
|
|
70
|
+
sections.push(
|
|
71
|
+
[
|
|
72
|
+
'## Summary',
|
|
73
|
+
'',
|
|
74
|
+
'| Status | Count |',
|
|
75
|
+
'| ------ | ----- |',
|
|
76
|
+
...counts.map(([name, count]) => `| ${name} | ${count} |`),
|
|
77
|
+
`| total | ${summary.total} |`,
|
|
78
|
+
].join('\n'),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
sections.push(renderTests(testruns.map(toReportTest)));
|
|
82
|
+
|
|
83
|
+
return `${sections.join('\n\n')}\n`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export { renderRunReport, toReportTest };
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import createDebugMessages from 'debug';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { parseRunsUrl, setS3Credentials } from '../utils/pipe_utils.js';
|
|
4
|
+
import { S3Uploader } from '../uploader.js';
|
|
5
|
+
|
|
6
|
+
const debug = createDebugMessages('@testomatio/reporter:fetch');
|
|
7
|
+
|
|
8
|
+
const TESTRUNS_DEFAULT_LIMIT = 50;
|
|
9
|
+
const TESTRUNS_MAX_LIMIT = 100;
|
|
10
|
+
const TESTRUN_STATUSES = ['passed', 'failed', 'skipped', 'pending'];
|
|
11
|
+
const PARALLEL_REQUESTS = 5;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the run argument of `fetch status|count|report`: a run id or a run URL.
|
|
15
|
+
* A URL also sets the server and the project.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} runArg
|
|
18
|
+
* @param {{project?: string}} opts
|
|
19
|
+
* @returns {{baseUrl: string, project: string, runId: string}}
|
|
20
|
+
*/
|
|
21
|
+
function resolveRunTarget(runArg, opts) {
|
|
22
|
+
if (/^https?:\/\//i.test(runArg)) {
|
|
23
|
+
const page = parseRunsUrl(runArg);
|
|
24
|
+
if (!page?.runId) throw new Error(`Not a Testomat.io run URL: ${runArg}`);
|
|
25
|
+
return { baseUrl: page.baseUrl, project: opts.project || page.project, runId: page.runId };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const project = opts.project || process.env.TESTOMATIO_PROJECT;
|
|
29
|
+
if (!project) throw new Error('--project (or TESTOMATIO_PROJECT) is required');
|
|
30
|
+
|
|
31
|
+
return { baseUrl: process.env.TESTOMATIO_URL || 'https://app.testomat.io', project, runId: runArg };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Collect the statuses passed with --only and --only-failed.
|
|
36
|
+
*
|
|
37
|
+
* @param {{only?: string, onlyFailed?: boolean}} opts
|
|
38
|
+
* @returns {string[]}
|
|
39
|
+
*/
|
|
40
|
+
function parseStatuses(opts) {
|
|
41
|
+
const statuses = String(opts.only || '')
|
|
42
|
+
.split(',')
|
|
43
|
+
.map(status => status.trim().toLowerCase())
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
if (opts.onlyFailed) statuses.push('failed');
|
|
46
|
+
|
|
47
|
+
const unknown = statuses.filter(status => !TESTRUN_STATUSES.includes(status));
|
|
48
|
+
if (unknown.length) {
|
|
49
|
+
throw new Error(`Unknown status: ${unknown.join(', ')}. Use one of: ${TESTRUN_STATUSES.join(', ')}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return [...new Set(statuses)];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} [value] - Value of the CLI --limit option.
|
|
57
|
+
* @returns {{limit: number, capped: boolean}}
|
|
58
|
+
*/
|
|
59
|
+
function parseTestrunsLimit(value) {
|
|
60
|
+
const requested = Math.max(parseInt(value, 10) || TESTRUNS_DEFAULT_LIMIT, 1);
|
|
61
|
+
return { limit: Math.min(requested, TESTRUNS_MAX_LIMIT), capped: requested > TESTRUNS_MAX_LIMIT };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Run `fn` over `items` with at most `limit` calls in flight; results keep the order of `items`.
|
|
66
|
+
*
|
|
67
|
+
* @template T, R
|
|
68
|
+
* @param {T[]} items
|
|
69
|
+
* @param {number} limit
|
|
70
|
+
* @param {(item: T) => Promise<R>} fn
|
|
71
|
+
* @returns {Promise<R[]>}
|
|
72
|
+
*/
|
|
73
|
+
async function mapWithLimit(items, limit, fn) {
|
|
74
|
+
const results = new Array(items.length);
|
|
75
|
+
let next = 0;
|
|
76
|
+
const worker = async () => {
|
|
77
|
+
while (next < items.length) {
|
|
78
|
+
const index = next++;
|
|
79
|
+
results[index] = await fn(items[index]);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read-only client for a single run and its testruns in API v2.
|
|
88
|
+
*/
|
|
89
|
+
class RunFetcher {
|
|
90
|
+
/**
|
|
91
|
+
* @param {{baseUrl: string, project: string, runId: string, apiKey: string}} params
|
|
92
|
+
*/
|
|
93
|
+
constructor({ baseUrl, project, runId, apiKey }) {
|
|
94
|
+
this.baseUrl = baseUrl;
|
|
95
|
+
this.project = project;
|
|
96
|
+
this.runId = runId;
|
|
97
|
+
this.apiKey = apiKey;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
get runUrl() {
|
|
101
|
+
return new URL(`/projects/${this.project}/runs/${this.runId}`, this.baseUrl).toString();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async get(apiPath, params = []) {
|
|
105
|
+
const url = new URL(`/api/v2/${this.project}${apiPath}`, this.baseUrl);
|
|
106
|
+
for (const [key, value] of params) url.searchParams.append(key, value);
|
|
107
|
+
debug('GET', url.toString());
|
|
108
|
+
|
|
109
|
+
const response = await fetch(url, { headers: { Authorization: `Bearer ${this.apiKey}` } });
|
|
110
|
+
const body = await response.json().catch(() => ({}));
|
|
111
|
+
if (response.status === 404 && apiPath === `/runs/${this.runId}`) {
|
|
112
|
+
throw new Error(`Run ${this.runId} not found in project ${this.project}`);
|
|
113
|
+
}
|
|
114
|
+
if (!response.ok) throw new Error(`${response.status} ${body.error || response.statusText}`);
|
|
115
|
+
return body;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async fetchRun() {
|
|
119
|
+
const body = await this.get(`/runs/${this.runId}`);
|
|
120
|
+
return body.data;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {string[]} statuses
|
|
125
|
+
* @returns {string[][]}
|
|
126
|
+
*/
|
|
127
|
+
testrunParams(statuses = []) {
|
|
128
|
+
const params = [['run_id', this.runId]];
|
|
129
|
+
for (const status of statuses) params.push(['filter[status][]', status]);
|
|
130
|
+
return params;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {string[]} [statuses]
|
|
135
|
+
* @returns {Promise<number>}
|
|
136
|
+
*/
|
|
137
|
+
async countTestruns(statuses = []) {
|
|
138
|
+
const body = await this.get('/testruns', [...this.testrunParams(statuses), ['count', 'true']]);
|
|
139
|
+
return body.meta?.total || 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @returns {Promise<{total: number, byStatus: Record<string, number>}>}
|
|
144
|
+
*/
|
|
145
|
+
async countTestrunsByStatus() {
|
|
146
|
+
const body = await this.get('/testruns', [...this.testrunParams(), ['count', 'true'], ['group_by', 'status']]);
|
|
147
|
+
return { total: body.meta?.total || 0, byStatus: body.meta?.group_by || {} };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Testruns with failures first; the list has no stack and no artifacts.
|
|
152
|
+
*
|
|
153
|
+
* @param {{statuses?: string[], limit: number}} params
|
|
154
|
+
* @returns {Promise<{testruns: object[], total: number}>}
|
|
155
|
+
*/
|
|
156
|
+
async listTestruns({ statuses = [], limit }) {
|
|
157
|
+
const body = await this.get('/testruns', [
|
|
158
|
+
...this.testrunParams(statuses),
|
|
159
|
+
['sort', 'failure'],
|
|
160
|
+
['order', 'desc'],
|
|
161
|
+
['per_page', String(limit)],
|
|
162
|
+
]);
|
|
163
|
+
return { testruns: body.data || [], total: body.meta?.total || 0 };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Add `stack` and `artifacts` to each testrun; a testrun that fails to load is kept as is.
|
|
168
|
+
*
|
|
169
|
+
* @param {object[]} testruns
|
|
170
|
+
* @param {(testrun: object, err: Error) => void} onError
|
|
171
|
+
*/
|
|
172
|
+
async addTestrunDetails(testruns, onError) {
|
|
173
|
+
return mapWithLimit(testruns, PARALLEL_REQUESTS, async testrun => {
|
|
174
|
+
try {
|
|
175
|
+
const body = await this.get(`/testruns/${testrun.id}`);
|
|
176
|
+
return { ...testrun, stack: body.data?.stack, artifacts: body.data?.artifacts || [] };
|
|
177
|
+
} catch (err) {
|
|
178
|
+
onError(testrun, err);
|
|
179
|
+
return testrun;
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Shared S3 credentials of the project, returned by an empty update of the run.
|
|
186
|
+
* Skipped for scheduled runs (the update launches them) and mixed runs (it marks automated tests as started).
|
|
187
|
+
*
|
|
188
|
+
* @param {{status?: string, kind?: string}} run
|
|
189
|
+
* @returns {Promise<boolean>} whether credentials were applied
|
|
190
|
+
*/
|
|
191
|
+
async applySharedS3Credentials(run) {
|
|
192
|
+
if (run.status === 'scheduled' || run.kind === 'mixed') {
|
|
193
|
+
debug(`Shared S3 credentials are not requested for a ${run.status} ${run.kind} run`);
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const url = new URL(`/api/reporter/${this.runId}`, this.baseUrl);
|
|
198
|
+
const response = await fetch(url, {
|
|
199
|
+
method: 'PUT',
|
|
200
|
+
headers: { 'Content-Type': 'application/json' },
|
|
201
|
+
body: JSON.stringify({ api_key: this.apiKey }),
|
|
202
|
+
});
|
|
203
|
+
if (!response.ok) {
|
|
204
|
+
debug('Failed to get shared S3 credentials:', response.status);
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const { artifacts } = await response.json();
|
|
209
|
+
if (!artifacts || !Object.keys(artifacts).length) return false;
|
|
210
|
+
setS3Credentials(artifacts);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Download the artifacts of each testrun to `<outDir>/<testrun id>/<file name>`.
|
|
217
|
+
* Each artifact becomes `{name, href}`, where `href` is the local path relative to the current directory,
|
|
218
|
+
* or the remote URL when the download failed.
|
|
219
|
+
*
|
|
220
|
+
* @param {object[]} testruns
|
|
221
|
+
* @param {string} outDir
|
|
222
|
+
* @param {(url: string, err: Error) => void} onError
|
|
223
|
+
*/
|
|
224
|
+
async function downloadArtifacts(testruns, outDir, onError) {
|
|
225
|
+
const uploader = new S3Uploader();
|
|
226
|
+
const jobs = [];
|
|
227
|
+
|
|
228
|
+
// the file name is parsed inside mapWithLimit, so a malformed artifact URL only skips that artifact
|
|
229
|
+
for (const testrun of testruns) {
|
|
230
|
+
const usedNames = new Set();
|
|
231
|
+
for (const url of testrun.artifacts || []) {
|
|
232
|
+
jobs.push({ testrun, url, outDir, usedNames });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const downloaded = await mapWithLimit(jobs, PARALLEL_REQUESTS, async job => {
|
|
237
|
+
let name = 'artifact';
|
|
238
|
+
try {
|
|
239
|
+
name = decodeURIComponent(path.basename(new URL(job.url).pathname)) || 'artifact';
|
|
240
|
+
if (job.usedNames.has(name)) name = `${job.usedNames.size}-${name}`;
|
|
241
|
+
job.usedNames.add(name);
|
|
242
|
+
const filePath = path.join(job.outDir, String(job.testrun.id), name);
|
|
243
|
+
await uploader.downloadFile(job.url, filePath);
|
|
244
|
+
const href = `./${path.relative(process.cwd(), filePath).split(path.sep).join('/')}`;
|
|
245
|
+
return { testrun: job.testrun, href, name };
|
|
246
|
+
} catch (err) {
|
|
247
|
+
onError(job.url, err);
|
|
248
|
+
return { testrun: job.testrun, href: job.url, name: `${name} (not downloaded)` };
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return testruns.map(testrun => ({
|
|
253
|
+
...testrun,
|
|
254
|
+
artifacts: downloaded.filter(job => job.testrun === testrun).map(({ name, href }) => ({ name, href })),
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @param {{title?: string}} run
|
|
260
|
+
* @param {string} runId
|
|
261
|
+
* @returns {string}
|
|
262
|
+
*/
|
|
263
|
+
function defaultArtifactsDir(run, runId) {
|
|
264
|
+
const slug = String(run.title || 'run')
|
|
265
|
+
.toLowerCase()
|
|
266
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
267
|
+
.replace(/^-+|-+$/g, '')
|
|
268
|
+
.slice(0, 60);
|
|
269
|
+
return `./${slug || 'run'}-${runId}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export {
|
|
273
|
+
TESTRUNS_DEFAULT_LIMIT,
|
|
274
|
+
TESTRUNS_MAX_LIMIT,
|
|
275
|
+
TESTRUN_STATUSES,
|
|
276
|
+
RunFetcher,
|
|
277
|
+
resolveRunTarget,
|
|
278
|
+
parseStatuses,
|
|
279
|
+
parseTestrunsLimit,
|
|
280
|
+
mapWithLimit,
|
|
281
|
+
downloadArtifacts,
|
|
282
|
+
defaultArtifactsDir,
|
|
283
|
+
};
|
package/src/pipe/markdown.js
CHANGED
|
@@ -521,6 +521,11 @@ function normalizeArtifact(raw) {
|
|
|
521
521
|
return { name: path.basename(abs), href, isImage: looksLikeImage(abs) };
|
|
522
522
|
}
|
|
523
523
|
|
|
524
|
+
// already resolved link, e.g. a downloaded artifact relative to the report
|
|
525
|
+
if (typeof raw.href === 'string' && raw.href) {
|
|
526
|
+
return { name: raw.name || path.basename(raw.href), href: raw.href, isImage: looksLikeImage(raw.href) };
|
|
527
|
+
}
|
|
528
|
+
|
|
524
529
|
const rawPath = raw.path || raw.link || raw.url;
|
|
525
530
|
if (!rawPath || typeof rawPath !== 'string') return null;
|
|
526
531
|
|
|
@@ -750,4 +755,6 @@ function aggregateTestRetries(tests) {
|
|
|
750
755
|
return aggregated;
|
|
751
756
|
}
|
|
752
757
|
|
|
758
|
+
export { renderTests };
|
|
759
|
+
|
|
753
760
|
export default MarkdownPipe;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
1
2
|
import { playwrightLogsMarkers } from './adapter/utils/playwright.js';
|
|
2
3
|
import { isPlaywright } from './helpers.js';
|
|
3
4
|
import { services } from './services/index.js';
|
|
4
5
|
import pc from 'picocolors';
|
|
5
6
|
|
|
7
|
+
const requireModule = createRequire(`${process.cwd()}/package.json`);
|
|
8
|
+
|
|
6
9
|
/**
|
|
7
10
|
* Stores path to file as artifact and uploads it to the S3 storage
|
|
8
11
|
* @param {string | {path: string, type: string, name: string}} data - path to file or object with path, type and name
|
|
@@ -44,6 +47,36 @@ function addStep(message, logs) {
|
|
|
44
47
|
else services.logger.step(message, logs);
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Returns the currently running Vitest test task, so `meta()` (and similar
|
|
52
|
+
* functions) can attach data to it. The task `.meta` object is serialized by
|
|
53
|
+
* Vitest back to the reporter (works across all pools: threads, forks, vmThreads).
|
|
54
|
+
*
|
|
55
|
+
* Resolution order matters:
|
|
56
|
+
* 1. `globalThis.__vitest_worker__.current` — set by Vitest in every worker and
|
|
57
|
+
* independent of module resolution, so it works even when the reporter is the
|
|
58
|
+
* externalized package and `@vitest/runner` is deduped to a different instance
|
|
59
|
+
* (monorepos, pnpm, hoisting). This is also populated inside hooks (afterEach),
|
|
60
|
+
* pointing at the test that the hook belongs to.
|
|
61
|
+
* 2. `@vitest/runner` `getCurrentTest()` — fallback for setups/versions where the
|
|
62
|
+
* worker global is not exposed.
|
|
63
|
+
*
|
|
64
|
+
* @returns {any | null} Vitest test task or null when no test is active
|
|
65
|
+
*/
|
|
66
|
+
function getCurrentVitestTest() {
|
|
67
|
+
// @ts-ignore - injected by Vitest into the worker global scope
|
|
68
|
+
const worker = globalThis.__vitest_worker__;
|
|
69
|
+
const current = worker?.current;
|
|
70
|
+
if (current && (current.type === 'test' || current.type === undefined)) return current;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const test = requireModule('@vitest/runner').getCurrentTest?.();
|
|
74
|
+
if (test) return test;
|
|
75
|
+
} catch {}
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
47
80
|
/**
|
|
48
81
|
* Add key-value pair(s) to the test report
|
|
49
82
|
* @param {{[key: string]: string} | string} keyValue - object { key: value } (multiple props allowed) OR key (string)
|
|
@@ -67,6 +100,14 @@ function setKeyValue(keyValue, value = undefined) {
|
|
|
67
100
|
return;
|
|
68
101
|
}
|
|
69
102
|
|
|
103
|
+
if (process.env.VITEST || process.env.VITEST_WORKER_ID) {
|
|
104
|
+
const vitestTest = getCurrentVitestTest();
|
|
105
|
+
if (vitestTest) {
|
|
106
|
+
Object.assign((vitestTest.meta ||= {}), keyValue);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
70
111
|
// in this case keyValue is expected to be an object
|
|
71
112
|
services.keyValues.put(keyValue);
|
|
72
113
|
}
|
package/src/uploader.js
CHANGED
|
@@ -347,6 +347,33 @@ export class S3Uploader {
|
|
|
347
347
|
);
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Download an artifact by its link: from the configured bucket through S3, otherwise over plain HTTP.
|
|
352
|
+
*
|
|
353
|
+
* @param {string} link
|
|
354
|
+
* @param {string} filePath
|
|
355
|
+
*/
|
|
356
|
+
async downloadFile(link, filePath) {
|
|
357
|
+
const { S3_BUCKET } = this.getConfig();
|
|
358
|
+
const Key = getS3KeyFromUrl(link, S3_BUCKET);
|
|
359
|
+
let body;
|
|
360
|
+
|
|
361
|
+
if (Key) {
|
|
362
|
+
debug('Downloading from S3:', Key);
|
|
363
|
+
const s3 = new S3(this.#getS3Config());
|
|
364
|
+
const object = await s3.getObject({ Bucket: S3_BUCKET, Key });
|
|
365
|
+
body = Buffer.from(await object.Body.transformToByteArray());
|
|
366
|
+
} else {
|
|
367
|
+
debug('Downloading over HTTP:', link);
|
|
368
|
+
const response = await fetch(link);
|
|
369
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
370
|
+
body = Buffer.from(await response.arrayBuffer());
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
374
|
+
fs.writeFileSync(filePath, body);
|
|
375
|
+
}
|
|
376
|
+
|
|
350
377
|
async getS3LocationLink(out) {
|
|
351
378
|
const response = await out.done();
|
|
352
379
|
|
|
@@ -409,3 +436,27 @@ export class S3Uploader {
|
|
|
409
436
|
return cfg;
|
|
410
437
|
}
|
|
411
438
|
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Object key of an artifact link in the given bucket, for both virtual-hosted (bucket.host/key)
|
|
442
|
+
* and path style (host/bucket/key) links; null when the link points to another bucket.
|
|
443
|
+
*
|
|
444
|
+
* @param {string} link
|
|
445
|
+
* @param {string} [bucket]
|
|
446
|
+
* @returns {string|null}
|
|
447
|
+
*/
|
|
448
|
+
export function getS3KeyFromUrl(link, bucket) {
|
|
449
|
+
if (!bucket) return null;
|
|
450
|
+
|
|
451
|
+
let url;
|
|
452
|
+
try {
|
|
453
|
+
url = new URL(link);
|
|
454
|
+
} catch (e) {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const objectPath = decodeURIComponent(url.pathname.slice(1));
|
|
459
|
+
if (url.hostname.startsWith(`${bucket}.`)) return objectPath || null;
|
|
460
|
+
if (objectPath.startsWith(`${bucket}/`)) return objectPath.slice(bucket.length + 1) || null;
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
|
|
5
|
+
const LAUNCHER_PREFIXES = ['npx', 'npm', 'yarn', 'pnpm', 'bun', 'node', 'bunx'];
|
|
6
|
+
|
|
7
|
+
const PLACEHOLDER_PHRASE_RE = /^<?-*(?:run|your|test)\s+(?:[\w-]+\s+)?(?:tests?|command|runner)>?$/i;
|
|
8
|
+
|
|
9
|
+
const RUNNERS = [
|
|
10
|
+
{
|
|
11
|
+
name: 'Playwright',
|
|
12
|
+
configs: ['playwright.config.js', 'playwright.config.ts', 'playwright.config.mjs', 'playwright.config.cjs'],
|
|
13
|
+
dependencies: ['@playwright/test'],
|
|
14
|
+
command: 'npx playwright test',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: 'CodeceptJS',
|
|
18
|
+
configs: ['codecept.conf.js', 'codecept.conf.ts'],
|
|
19
|
+
dependencies: ['codeceptjs'],
|
|
20
|
+
command: 'npx codeceptjs run',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'WebdriverIO',
|
|
24
|
+
configs: ['wdio.conf.js', 'wdio.conf.ts'],
|
|
25
|
+
dependencies: ['@wdio/cli'],
|
|
26
|
+
dependencyPrefix: '@wdio/',
|
|
27
|
+
command: 'npx wdio run ./wdio.conf.js',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'Cypress',
|
|
31
|
+
configs: ['cypress.config.js', 'cypress.config.ts'],
|
|
32
|
+
dependencies: ['cypress'],
|
|
33
|
+
command: 'npx cypress run',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'Jest',
|
|
37
|
+
configs: ['jest.config.js', 'jest.config.ts', 'jest.config.mjs', 'jest.config.cjs', 'jest.config.json'],
|
|
38
|
+
dependencies: ['jest'],
|
|
39
|
+
command: 'npx jest',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'Vitest',
|
|
43
|
+
configs: ['vitest.config.js', 'vitest.config.ts', 'vitest.config.mjs'],
|
|
44
|
+
dependencies: ['vitest'],
|
|
45
|
+
command: 'npx vitest run',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'Mocha',
|
|
49
|
+
configs: ['.mocharc.js', '.mocharc.cjs', '.mocharc.json', '.mocharc.yml', '.mocharc.yaml'],
|
|
50
|
+
dependencies: ['mocha'],
|
|
51
|
+
command: 'npx mocha',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'Cucumber',
|
|
55
|
+
configs: ['cucumber.js', 'cucumber.cjs'],
|
|
56
|
+
dependencies: ['@cucumber/cucumber'],
|
|
57
|
+
command: 'npx cucumber-js',
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} command
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
function isPlaceholderCommand(command) {
|
|
66
|
+
const cmd = (command || '').trim();
|
|
67
|
+
if (!cmd) return false;
|
|
68
|
+
|
|
69
|
+
if (cmd.includes('<') || cmd.includes('>')) return true;
|
|
70
|
+
|
|
71
|
+
const firstToken = cmd.split(/\s+/)[0].toLowerCase();
|
|
72
|
+
if (LAUNCHER_PREFIXES.includes(firstToken)) return false;
|
|
73
|
+
|
|
74
|
+
return PLACEHOLDER_PHRASE_RE.test(cmd);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} cwd
|
|
79
|
+
* @returns {Record<string, string>}
|
|
80
|
+
*/
|
|
81
|
+
function readPackageDependencies(cwd) {
|
|
82
|
+
try {
|
|
83
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
84
|
+
return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
85
|
+
} catch {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} [cwd]
|
|
92
|
+
* @returns {{ name: string, command: string } | null}
|
|
93
|
+
*/
|
|
94
|
+
function detectRunner(cwd = process.cwd()) {
|
|
95
|
+
const dependencies = readPackageDependencies(cwd);
|
|
96
|
+
|
|
97
|
+
for (const runner of RUNNERS) {
|
|
98
|
+
const config = runner.configs.find(file => fs.existsSync(path.join(cwd, file)));
|
|
99
|
+
|
|
100
|
+
const hasDependency = runner.dependencies?.some(dep => dependencies[dep])
|
|
101
|
+
|| (runner.dependencyPrefix && Object.keys(dependencies).some(dep => dep.startsWith(runner.dependencyPrefix)));
|
|
102
|
+
|
|
103
|
+
if (!config && !hasDependency) continue;
|
|
104
|
+
|
|
105
|
+
let command = runner.command;
|
|
106
|
+
if (runner.name === 'WebdriverIO') {
|
|
107
|
+
command = `npx wdio run ./${config || 'wdio.conf.js'}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { name: runner.name, command };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {string} [cwd]
|
|
118
|
+
* @returns {string}
|
|
119
|
+
*/
|
|
120
|
+
function runnerHint(cwd = process.cwd()) {
|
|
121
|
+
const runner = detectRunner(cwd);
|
|
122
|
+
if (runner) {
|
|
123
|
+
return `💡 Looks like this project uses ${runner.name}. Try: ${pc.green(runner.command)}`;
|
|
124
|
+
}
|
|
125
|
+
return `💡 Replace the placeholder with your test runner command, e.g. ${pc.green('npx playwright test')}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {string} command
|
|
130
|
+
* @returns {string[]}
|
|
131
|
+
*/
|
|
132
|
+
function buildFilterWarning(command) {
|
|
133
|
+
return [
|
|
134
|
+
'⚠️ The runner exited with a non-zero code while a Testomat.io filter was applied.',
|
|
135
|
+
'Your command was modified to run only tests matching Testomat IDs:',
|
|
136
|
+
` ${pc.bold(command)}`,
|
|
137
|
+
'This usually means no local tests matched the filter:',
|
|
138
|
+
' - tests are not annotated with the matching @Txxxxxx IDs in their titles',
|
|
139
|
+
' - the filter selected tests from another repository or branch',
|
|
140
|
+
' - the test runner does not support the appended --grep / --testNamePattern option',
|
|
141
|
+
'💡 Hint: run the printed command without the appended filter option to see all your tests,',
|
|
142
|
+
'then annotate them with Testomat IDs.',
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export {
|
|
147
|
+
isPlaceholderCommand,
|
|
148
|
+
detectRunner,
|
|
149
|
+
runnerHint,
|
|
150
|
+
buildFilterWarning,
|
|
151
|
+
};
|