testomatio-reporter-cli 2.19.0 → 2.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/adapter/codecept.js +24 -2
- package/src/adapter/cucumber/current.js +2 -0
- package/src/adapter/cypress-plugin/index.js +2 -0
- package/src/adapter/e2e.js +248 -0
- package/src/adapter/jasmine.js +2 -0
- package/src/adapter/jest.js +2 -0
- package/src/adapter/mocha.js +146 -145
- package/src/adapter/nightwatch.js +2 -0
- package/src/adapter/playwright.js +33 -1
- package/src/adapter/vitest.js +6 -1
- package/src/adapter/webdriver.js +46 -2
- package/src/allureReader.js +2 -1
- package/src/bin/cli.js +246 -18
- package/src/client.js +11 -3
- package/src/data-storage.js +4 -3
- package/src/fetch/report.js +86 -0
- package/src/fetch/testruns.js +283 -0
- package/src/pipe/markdown.js +7 -0
- package/src/pipe/testomatio.js +27 -1
- package/src/replay.js +1 -0
- package/src/reporter-functions.js +41 -0
- package/src/uploader.js +81 -1
- package/src/utils/detectRunner.js +151 -0
- package/src/utils/pipe_utils.js +45 -4
- package/src/xmlReader.js +3 -0
- package/types/types.d.ts +1 -0
|
@@ -3,6 +3,8 @@ import { config } from '../config.js';
|
|
|
3
3
|
import { STATUS } from '../constants.js';
|
|
4
4
|
import { getTestomatIdFromTestTitle } from '../utils/utils.js';
|
|
5
5
|
|
|
6
|
+
global.TESTOMATIO_ADAPTER = 'nightwatch';
|
|
7
|
+
|
|
6
8
|
const apiKey = config.TESTOMATIO;
|
|
7
9
|
const client = new TestomatClient({ apiKey });
|
|
8
10
|
|
|
@@ -14,6 +14,8 @@ import { fetchLinksFromLogs } from './utils/playwright.js';
|
|
|
14
14
|
import { formatStep, addStatusToStep, addArtifactsToStep } from './utils/step-formatter.js';
|
|
15
15
|
import { log } from '../utils/log.js';
|
|
16
16
|
|
|
17
|
+
global.TESTOMATIO_ADAPTER = 'playwright';
|
|
18
|
+
|
|
17
19
|
class PlaywrightReporter {
|
|
18
20
|
constructor(config = {}) {
|
|
19
21
|
this.client = new TestomatioClient({ apiKey: config?.apiKey });
|
|
@@ -108,7 +110,11 @@ class PlaywrightReporter {
|
|
|
108
110
|
if (result.status === 'passed') status = 'failed';
|
|
109
111
|
}
|
|
110
112
|
|
|
111
|
-
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, {
|
|
112
118
|
rid: `${rid}-${project.name}`,
|
|
113
119
|
error,
|
|
114
120
|
test_id: getTestomatIdFromTestTitle(`${title} ${tags.join(' ')}`),
|
|
@@ -126,6 +132,7 @@ class PlaywrightReporter {
|
|
|
126
132
|
isMobile: project.isMobile,
|
|
127
133
|
project: project.name,
|
|
128
134
|
projectDependencies: project.dependencies?.length ? project.dependencies : null,
|
|
135
|
+
failedUrl,
|
|
129
136
|
...testMeta,
|
|
130
137
|
...meta,
|
|
131
138
|
...project.metadata, // metadata has any type (in playwright), but we will stringify it in client.js
|
|
@@ -242,6 +249,31 @@ function isScreenshotArtifact(attachment) {
|
|
|
242
249
|
return attachment?.contentType === 'image/png' && attachment?.name === 'screenshot';
|
|
243
250
|
}
|
|
244
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
|
+
|
|
245
277
|
function appendStep(step, shift = 0) {
|
|
246
278
|
// nesting too deep, ignore those steps
|
|
247
279
|
if (shift >= 10) return;
|
package/src/adapter/vitest.js
CHANGED
|
@@ -6,6 +6,8 @@ import createDebugMessages from 'debug';
|
|
|
6
6
|
|
|
7
7
|
const debug = createDebugMessages('@testomatio/reporter:adapter-jest');
|
|
8
8
|
|
|
9
|
+
global.TESTOMATIO_ADAPTER = 'vitest';
|
|
10
|
+
|
|
9
11
|
/**
|
|
10
12
|
* @typedef {import('../../types/types.js').VitestTest} VitestTest
|
|
11
13
|
* @typedef {import('../../types/types.js').VitestTestFile} VitestTestFile
|
|
@@ -396,7 +398,10 @@ function normalizeVitestTest(test) {
|
|
|
396
398
|
file,
|
|
397
399
|
suiteTitle,
|
|
398
400
|
logs: '',
|
|
399
|
-
meta:
|
|
401
|
+
meta: {
|
|
402
|
+
...(test.task?.meta || {}),
|
|
403
|
+
...(typeof test.meta === 'function' ? test.meta() : test.meta || {}),
|
|
404
|
+
},
|
|
400
405
|
};
|
|
401
406
|
}
|
|
402
407
|
|
package/src/adapter/webdriver.js
CHANGED
|
@@ -4,6 +4,11 @@ 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');
|
|
10
|
+
|
|
11
|
+
global.TESTOMATIO_ADAPTER = 'webdriverio';
|
|
7
12
|
|
|
8
13
|
class WebdriverReporter extends WDIOReporter {
|
|
9
14
|
constructor(options) {
|
|
@@ -120,7 +125,8 @@ class WebdriverReporter extends WDIOReporter {
|
|
|
120
125
|
async addTest(test) {
|
|
121
126
|
if (!this.client) return;
|
|
122
127
|
|
|
123
|
-
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;
|
|
124
130
|
|
|
125
131
|
const testId = getTestomatIdFromTestTitle(title);
|
|
126
132
|
|
|
@@ -131,6 +137,11 @@ class WebdriverReporter extends WDIOReporter {
|
|
|
131
137
|
|
|
132
138
|
const rid = stringToMD5Hash(test.fullTitle);
|
|
133
139
|
|
|
140
|
+
if (state === 'failed') {
|
|
141
|
+
const failedUrl = (await grabCurrentUrl()) || lastNavigatedUrl(output);
|
|
142
|
+
if (failedUrl) meta = { ...meta, failedUrl };
|
|
143
|
+
}
|
|
144
|
+
|
|
134
145
|
await this.client.addTestRun(state, {
|
|
135
146
|
rid,
|
|
136
147
|
manuallyAttachedArtifacts: test.artifacts,
|
|
@@ -148,7 +159,7 @@ class WebdriverReporter extends WDIOReporter {
|
|
|
148
159
|
/**
|
|
149
160
|
* @param {import('../../types/types.js').WebdriverIOScenario} scenario
|
|
150
161
|
*/
|
|
151
|
-
addBddScenario(scenario) {
|
|
162
|
+
async addBddScenario(scenario) {
|
|
152
163
|
if (!this.client) return;
|
|
153
164
|
|
|
154
165
|
const { title, _duration: duration } = scenario;
|
|
@@ -167,6 +178,12 @@ class WebdriverReporter extends WDIOReporter {
|
|
|
167
178
|
|
|
168
179
|
const tags = scenario.tags.map(tag => tag.name);
|
|
169
180
|
|
|
181
|
+
let meta;
|
|
182
|
+
if (scenarioState === 'failed') {
|
|
183
|
+
const failedUrl = await grabCurrentUrl();
|
|
184
|
+
if (failedUrl) meta = { failedUrl };
|
|
185
|
+
}
|
|
186
|
+
|
|
170
187
|
return this.client.addTestRun(scenarioState, {
|
|
171
188
|
error: error ? Error(error) : null,
|
|
172
189
|
title,
|
|
@@ -174,11 +191,38 @@ class WebdriverReporter extends WDIOReporter {
|
|
|
174
191
|
time: duration,
|
|
175
192
|
tags,
|
|
176
193
|
file: scenario.file,
|
|
194
|
+
meta,
|
|
177
195
|
// filesBuffers: screenshotsBuffers,
|
|
178
196
|
});
|
|
179
197
|
}
|
|
180
198
|
}
|
|
181
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
|
+
|
|
182
226
|
/**
|
|
183
227
|
*
|
|
184
228
|
* @param {*} fullTestTitle
|
package/src/allureReader.js
CHANGED
|
@@ -186,7 +186,8 @@ class AllureReader {
|
|
|
186
186
|
steps: this.convertSteps(result.steps || [], resultsDir),
|
|
187
187
|
message: result.statusDetails?.message || '',
|
|
188
188
|
stack: result.statusDetails?.trace || '',
|
|
189
|
-
meta
|
|
189
|
+
// meta must always include reporter version and adapter used
|
|
190
|
+
meta: { ...this.extractMeta(result), reporterVersion: this.version, adapter: 'allure' },
|
|
190
191
|
links: this.extractLinks(result),
|
|
191
192
|
artifacts: [],
|
|
192
193
|
create: true,
|
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';
|
|
@@ -16,9 +17,21 @@ import { filesize as prettyBytes } from 'filesize';
|
|
|
16
17
|
import dotenv from 'dotenv';
|
|
17
18
|
import Replay from '../replay.js';
|
|
18
19
|
import { log } from '../utils/log.js';
|
|
19
|
-
import { formatFilterListIds, formatRunOutput, formatFetchRunsOutput } from '../utils/pipe_utils.js';
|
|
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,44 +145,63 @@ 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')
|
|
151
|
+
.argument('[url]', 'URL of a run or of a (filtered) runs page in Testomat.io')
|
|
133
152
|
.option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
|
|
153
|
+
.option('--run <id>', 'Fetch a single run by its id')
|
|
134
154
|
.option('--title <text>', 'Filter by run title')
|
|
135
155
|
.option('--tql <query>', 'Filter using Testomat Query Language')
|
|
136
156
|
.option('--rungroup <uid>', 'Filter by rungroup id')
|
|
157
|
+
// no default value: fetch parses --limit for its subcommands too, see createRunFetcher
|
|
137
158
|
.option(
|
|
138
159
|
'--limit <number>',
|
|
139
|
-
`Max number of runs to return (max ${FETCH_RUNS_MAX_LIMIT})`,
|
|
140
|
-
String(FETCH_RUNS_DEFAULT_LIMIT),
|
|
160
|
+
`Max number of runs to return (default ${FETCH_RUNS_DEFAULT_LIMIT}, max ${FETCH_RUNS_MAX_LIMIT})`,
|
|
141
161
|
)
|
|
142
162
|
.option('--latest', 'Only fetch the most recent run (shorthand for --limit 1)')
|
|
143
163
|
.option(
|
|
144
164
|
'--format <format>',
|
|
145
165
|
'Machine-readable output: run ids, one per line (--format id) or run details (--format json)',
|
|
146
166
|
)
|
|
147
|
-
.action(async opts => {
|
|
167
|
+
.action(async (pageUrl, opts) => {
|
|
148
168
|
const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config.TESTOMATIO;
|
|
149
169
|
if (!apiKey) {
|
|
150
170
|
log.error(pc.red('⚠️ TESTOMATIO API key required'));
|
|
151
171
|
process.exit(1);
|
|
152
172
|
}
|
|
153
173
|
|
|
154
|
-
|
|
174
|
+
let page = {};
|
|
175
|
+
if (pageUrl) {
|
|
176
|
+
page = parseRunsUrl(pageUrl);
|
|
177
|
+
if (!page) {
|
|
178
|
+
log.error(pc.red(`⚠️ Not a Testomat.io run or runs page URL: ${pageUrl}`));
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const project = opts.project || page.project || process.env.TESTOMATIO_PROJECT;
|
|
155
184
|
if (!project) {
|
|
156
185
|
log.error(pc.red('⚠️ --project (or TESTOMATIO_PROJECT) is required'));
|
|
157
186
|
process.exit(1);
|
|
158
187
|
}
|
|
159
188
|
|
|
160
|
-
const baseUrl = process.env.TESTOMATIO_URL || 'https://app.testomat.io';
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
189
|
+
const baseUrl = page.baseUrl || process.env.TESTOMATIO_URL || 'https://app.testomat.io';
|
|
190
|
+
const runId = opts.run || page.runId;
|
|
191
|
+
let url;
|
|
192
|
+
|
|
193
|
+
if (runId) {
|
|
194
|
+
url = new URL(`/api/v2/${project}/runs/${runId}`, baseUrl);
|
|
195
|
+
} else {
|
|
196
|
+
url = new URL(`/api/v2/${project}/runs`, baseUrl);
|
|
197
|
+
for (const [key, value] of page.params || []) url.searchParams.append(key, value);
|
|
198
|
+
if (opts.title) url.searchParams.set('search', opts.title);
|
|
199
|
+
if (opts.tql) url.searchParams.set('tql', opts.tql);
|
|
200
|
+
if (opts.rungroup) url.searchParams.set('groupId', opts.rungroup);
|
|
201
|
+
const requestedLimit = opts.latest ? 1 : parseInt(opts.limit, 10) || FETCH_RUNS_DEFAULT_LIMIT;
|
|
202
|
+
const limit = Math.min(Math.max(requestedLimit, 1), FETCH_RUNS_MAX_LIMIT);
|
|
203
|
+
url.searchParams.set('per_page', String(limit));
|
|
204
|
+
}
|
|
168
205
|
|
|
169
206
|
const response = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
|
|
170
207
|
const body = await response.json();
|
|
@@ -177,6 +214,166 @@ program
|
|
|
177
214
|
console.log(formatFetchRunsOutput(body, opts.format));
|
|
178
215
|
});
|
|
179
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
|
+
|
|
180
377
|
program
|
|
181
378
|
.command('run')
|
|
182
379
|
.alias('test')
|
|
@@ -196,8 +393,9 @@ program
|
|
|
196
393
|
(value, prev) => prev.concat([value]),
|
|
197
394
|
[],
|
|
198
395
|
)
|
|
199
|
-
.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')
|
|
200
397
|
.action(async (command, opts) => {
|
|
398
|
+
opts.warn ||= transformEnvVarToBoolean(process.env.TESTOMATIO_WARN);
|
|
201
399
|
if (opts.remote) {
|
|
202
400
|
if (opts.filterList) {
|
|
203
401
|
log.warn(pc.red('⚠️ --filter-list cannot be combined with --remote'));
|
|
@@ -213,6 +411,8 @@ program
|
|
|
213
411
|
const title = process.env.TESTOMATIO_TITLE;
|
|
214
412
|
const client = new TestomatClient({ apiKey, title });
|
|
215
413
|
|
|
414
|
+
let filterApplied = false;
|
|
415
|
+
|
|
216
416
|
if (opts.filter || opts.filterList) {
|
|
217
417
|
log.info('Filtering tests...');
|
|
218
418
|
// Example of use: npx @testomatio/reporter run "npx jest" --filter "testomatio:tag-name=frontend"
|
|
@@ -250,6 +450,7 @@ program
|
|
|
250
450
|
|
|
251
451
|
if (command && command.split && !opts.remote) {
|
|
252
452
|
command = applyFilter(command, tests);
|
|
453
|
+
filterApplied = true;
|
|
253
454
|
}
|
|
254
455
|
}
|
|
255
456
|
catch (err) {
|
|
@@ -326,6 +527,14 @@ program
|
|
|
326
527
|
return process.exit(0);
|
|
327
528
|
}
|
|
328
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
|
+
|
|
329
538
|
log.info( `🚀 Running`, pc.green(command));
|
|
330
539
|
|
|
331
540
|
const runTests = async () => {
|
|
@@ -335,9 +544,28 @@ program
|
|
|
335
544
|
env: { ...process.env, TESTOMATIO_PROCEED: 'true', runId: client.runId, TESTOMATIO_RUN: client.runId },
|
|
336
545
|
});
|
|
337
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
|
+
|
|
338
562
|
cmd.on('close', async code => {
|
|
563
|
+
if (errored) return;
|
|
339
564
|
const emoji = code === 0 ? '🟢' : '🔴';
|
|
340
565
|
log.info( emoji, `Runner exited with ${pc.bold(code)}`);
|
|
566
|
+
if (code !== 0 && filterApplied) {
|
|
567
|
+
buildFilterWarning(command).forEach(line => log.warn(line));
|
|
568
|
+
}
|
|
341
569
|
if (apiKey) {
|
|
342
570
|
const status = code === 0 ? 'passed' : 'failed';
|
|
343
571
|
await client.updateRunStatus(status);
|
package/src/client.js
CHANGED
|
@@ -36,6 +36,8 @@ class Client {
|
|
|
36
36
|
this.pipeStore = {};
|
|
37
37
|
this.runId = '';
|
|
38
38
|
this.queue = Promise.resolve();
|
|
39
|
+
// adapter name is set by the adapter module itself on load (global.TESTOMATIO_ADAPTER)
|
|
40
|
+
this.adapterName = params.adapter || global.TESTOMATIO_ADAPTER;
|
|
39
41
|
|
|
40
42
|
// @ts-ignore this line will be removed in compiled code, because __dirname is defined in commonjs
|
|
41
43
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -244,7 +246,9 @@ class Client {
|
|
|
244
246
|
overwrite,
|
|
245
247
|
tags,
|
|
246
248
|
} = testData;
|
|
247
|
-
let { files = [], manuallyAttachedArtifacts, message = ''
|
|
249
|
+
let { files = [], manuallyAttachedArtifacts, message = '' } = testData;
|
|
250
|
+
/** @type {Record<string, any>} */
|
|
251
|
+
let meta = testData.meta || {};
|
|
248
252
|
|
|
249
253
|
if (stepArtifactPaths.size) {
|
|
250
254
|
const isStepArtifact = item => stepArtifactPaths.has(typeof item === 'object' ? item?.path : item);
|
|
@@ -261,6 +265,10 @@ class Client {
|
|
|
261
265
|
return acc;
|
|
262
266
|
}, {});
|
|
263
267
|
|
|
268
|
+
// meta must always include reporter version and adapter used
|
|
269
|
+
if (this.version) meta.reporterVersion = this.version;
|
|
270
|
+
if (this.adapterName) meta.adapter = this.adapterName;
|
|
271
|
+
|
|
264
272
|
const testContext = suite_title ? `${suite_title} ${title}` : title;
|
|
265
273
|
|
|
266
274
|
let errorFormatted = '';
|
|
@@ -389,8 +397,8 @@ class Client {
|
|
|
389
397
|
this.queue = this.queue
|
|
390
398
|
.then(() => Promise.all(this.pipes.map(p => p.finishRun(runParams))))
|
|
391
399
|
.then(() => {
|
|
392
|
-
|
|
393
|
-
|
|
400
|
+
// no early return when uploading is disabled:
|
|
401
|
+
// skipped artifacts (e.g. no S3 credentials obtained) must still be reported to the user
|
|
394
402
|
const filesizeStrMaxLength = 7;
|
|
395
403
|
|
|
396
404
|
if (this.uploader.successfulUploads.length) {
|
package/src/data-storage.js
CHANGED
|
@@ -190,9 +190,10 @@ class DataStorage {
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
function stringToMD5Hash(str) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
193
|
+
// sha256 instead of md5 for FIPS-enabled hosts compatibility
|
|
194
|
+
const sha256 = crypto.createHash('sha256');
|
|
195
|
+
sha256.update(str);
|
|
196
|
+
const hash = sha256.digest('hex');
|
|
196
197
|
return `${process.env.runId || 'run'}_${hash}`;
|
|
197
198
|
}
|
|
198
199
|
|