testomatio-reporter-cli 2.19.0-beta → 2.20.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 +2 -0
- package/src/adapter/cucumber/current.js +2 -0
- package/src/adapter/cypress-plugin/index.js +2 -0
- package/src/adapter/e2e.js +245 -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 +2 -0
- package/src/adapter/vitest.js +2 -0
- package/src/adapter/webdriver.js +2 -0
- package/src/allureReader.js +2 -1
- package/src/bin/cli.js +30 -11
- package/src/client.js +11 -3
- package/src/data-storage.js +4 -3
- package/src/pipe/bitbucket.js +7 -1
- package/src/pipe/github.js +7 -1
- package/src/pipe/gitlab.js +7 -1
- package/src/pipe/testomatio.js +27 -1
- package/src/replay.js +1 -0
- package/src/uploader.js +30 -1
- package/src/utils/pipe_utils.js +85 -4
- package/src/xmlReader.js +3 -0
- package/types/types.d.ts +1 -0
package/package.json
CHANGED
package/src/adapter/codecept.js
CHANGED
|
@@ -11,6 +11,8 @@ import codeceptjs from 'codeceptjs';
|
|
|
11
11
|
import { log } from '../utils/log.js';
|
|
12
12
|
|
|
13
13
|
const debug = createDebugMessages('@testomatio/reporter:adapter:codeceptjs');
|
|
14
|
+
|
|
15
|
+
global.TESTOMATIO_ADAPTER = 'codeceptjs';
|
|
14
16
|
// @ts-ignore
|
|
15
17
|
if (!global.codeceptjs) {
|
|
16
18
|
// @ts-ignore
|
|
@@ -8,6 +8,8 @@ import { getTestomatIdFromTestTitle, fileSystem } from '../../utils/utils.js';
|
|
|
8
8
|
import { config } from '../../config.js';
|
|
9
9
|
import { services } from '../../services/index.js';
|
|
10
10
|
|
|
11
|
+
global.TESTOMATIO_ADAPTER = 'cucumber';
|
|
12
|
+
|
|
11
13
|
const { GherkinDocumentParser, PickleParser } = formatterHelpers;
|
|
12
14
|
const { getGherkinScenarioLocationMap, getGherkinStepMap } = GherkinDocumentParser;
|
|
13
15
|
const { getPickleStepMap } = PickleParser;
|
|
@@ -3,6 +3,8 @@ import { getTestomatIdFromTestTitle, parseSuite } from '../../utils/utils.js';
|
|
|
3
3
|
import TestomatClient from '../../client.js';
|
|
4
4
|
import { config } from '../../config.js';
|
|
5
5
|
|
|
6
|
+
global.TESTOMATIO_ADAPTER = 'cypress';
|
|
7
|
+
|
|
6
8
|
const testomatioReporter = on => {
|
|
7
9
|
if (!config.TESTOMATIO) {
|
|
8
10
|
console.log('TESTOMATIO key is empty, ignoring reports');
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import createDebugMessages from 'debug';
|
|
3
|
+
import TestomatioClient from '../client.js';
|
|
4
|
+
import { STATUS, SCREENSHOTS_ON_STEPS } from '../constants.js';
|
|
5
|
+
import { getTestomatIdFromTestTitle, truncate } from '../utils/utils.js';
|
|
6
|
+
import { formatStep, addStatusToStep, addArtifactsToStep } from './utils/step-formatter.js';
|
|
7
|
+
import { log } from '../utils/log.js';
|
|
8
|
+
|
|
9
|
+
const debug = createDebugMessages('@testomatio/reporter:adapter-e2e');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reporter for the e2e framework (https://github.com/tester-army/e2e).
|
|
13
|
+
* Add it to `reporters` in `e2e.config.ts`: `reporters: ['list', testomatio()]`.
|
|
14
|
+
*
|
|
15
|
+
* The config is loaded by every worker, so the client is created only inside
|
|
16
|
+
* handlers, which run in the runner process.
|
|
17
|
+
*
|
|
18
|
+
* @param {{ apiKey?: string }} [options]
|
|
19
|
+
*/
|
|
20
|
+
export function testomatio(options = {}) {
|
|
21
|
+
let client = null;
|
|
22
|
+
let runPromise = null;
|
|
23
|
+
|
|
24
|
+
function getClient() {
|
|
25
|
+
if (!client) client = new TestomatioClient({ apiKey: options.apiKey });
|
|
26
|
+
return client;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function startRun() {
|
|
30
|
+
if (!runPromise) {
|
|
31
|
+
runPromise = getClient()
|
|
32
|
+
.createRun()
|
|
33
|
+
.catch(err => log.error('Failed to create Testomat.io run:', err?.message || err));
|
|
34
|
+
}
|
|
35
|
+
return runPromise;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
name: 'testomatio',
|
|
40
|
+
|
|
41
|
+
onEvent(event) {
|
|
42
|
+
try {
|
|
43
|
+
if (event?.type === 'run-started') startRun();
|
|
44
|
+
} catch (err) {
|
|
45
|
+
debug('onEvent failed', err);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
async onRunFinished(run, signal) {
|
|
50
|
+
const reporter = getClient();
|
|
51
|
+
await startRun();
|
|
52
|
+
|
|
53
|
+
const { report, artifactsRoot } = run;
|
|
54
|
+
const groups = new Map((report.run.serialGroups || []).map(group => [group.id, group]));
|
|
55
|
+
|
|
56
|
+
const promises = [];
|
|
57
|
+
for (const result of report.run.results) {
|
|
58
|
+
if (signal?.aborted) break;
|
|
59
|
+
// results left out by `--last-failed` belong to the previous run
|
|
60
|
+
if (result.selected === false) continue;
|
|
61
|
+
const { status, data } = toTestData(result, groups, artifactsRoot);
|
|
62
|
+
promises.push(reporter.addTestRun(status, data));
|
|
63
|
+
}
|
|
64
|
+
await Promise.all(promises);
|
|
65
|
+
|
|
66
|
+
const duration = (Date.parse(report.run.finishedAt) - Date.parse(report.run.startedAt)) / 1000;
|
|
67
|
+
await reporter.updateRunStatus(runStatus(report.run.status), { duration });
|
|
68
|
+
|
|
69
|
+
const runUrl = reporter.pipeStore?.runUrl;
|
|
70
|
+
if (runUrl) return [{ label: 'Testomat.io', text: runUrl }];
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Converts one report-1 result into Testomat.io test data.
|
|
77
|
+
* Only the final attempt is reported; earlier attempts are counted as retries.
|
|
78
|
+
*/
|
|
79
|
+
export function toTestData(result, groups, artifactsRoot) {
|
|
80
|
+
const attempts = attemptsOf(result, groups);
|
|
81
|
+
const final = attempts.at(-1);
|
|
82
|
+
const titlePath = [...result.titlePath];
|
|
83
|
+
const title = titlePath.pop();
|
|
84
|
+
|
|
85
|
+
let suiteTitle = titlePath.join(' › ');
|
|
86
|
+
if (!suiteTitle) suiteTitle = path.basename(result.file);
|
|
87
|
+
|
|
88
|
+
const meta = {
|
|
89
|
+
target: result.targetId,
|
|
90
|
+
platform: result.platform,
|
|
91
|
+
browser: final?.environment?.browser,
|
|
92
|
+
};
|
|
93
|
+
if (result.agent && result.agent !== 'default') meta.agent = result.agent;
|
|
94
|
+
if (result.repeat) meta.repeat = result.repeat;
|
|
95
|
+
if (attempts.length > 1) meta.retryCount = attempts.length - 1;
|
|
96
|
+
if (result.status === 'flaky') meta.isFlaky = true;
|
|
97
|
+
|
|
98
|
+
const filesById = new Map();
|
|
99
|
+
for (const artifact of final?.artifacts || []) {
|
|
100
|
+
if (artifact.path) {
|
|
101
|
+
filesById.set(artifact.id, {
|
|
102
|
+
path: path.join(artifactsRoot, artifact.path),
|
|
103
|
+
type: artifact.mediaType,
|
|
104
|
+
title: artifact.kind,
|
|
105
|
+
});
|
|
106
|
+
} else if (artifact.url) {
|
|
107
|
+
// hosted engines keep videos remotely
|
|
108
|
+
meta[artifact.kind] = artifact.url;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const files = [...filesById.values()];
|
|
112
|
+
|
|
113
|
+
const steps = (final?.steps || []).map(step => toStep(step, filesById));
|
|
114
|
+
|
|
115
|
+
const data = {
|
|
116
|
+
rid: result.id,
|
|
117
|
+
title,
|
|
118
|
+
suite_title: suiteTitle,
|
|
119
|
+
file: result.file,
|
|
120
|
+
test_id: getTestomatIdFromTestTitle(title),
|
|
121
|
+
tags: result.tags || [],
|
|
122
|
+
time: final?.durationMs || 0,
|
|
123
|
+
timestamp: timestampOf(final),
|
|
124
|
+
error: toError(final?.error, final?.secondaryErrors),
|
|
125
|
+
steps: steps.length ? steps : undefined,
|
|
126
|
+
logs: logsOf(result, final),
|
|
127
|
+
files: files.length ? files : undefined,
|
|
128
|
+
meta,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return { status: testStatus(result.status), data };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Attempts of a result; a serial member keeps its part of the group's attempts. */
|
|
135
|
+
function attemptsOf(result, groups) {
|
|
136
|
+
if (!result.serialGroupId) return result.attempts || [];
|
|
137
|
+
const groupAttempts = groups.get(result.serialGroupId)?.attempts || [];
|
|
138
|
+
return groupAttempts.map(attempt => {
|
|
139
|
+
const member = attempt.members?.find(candidate => candidate.testId === result.testId);
|
|
140
|
+
return {
|
|
141
|
+
status: member?.status || attempt.status,
|
|
142
|
+
error: member?.error || attempt.error,
|
|
143
|
+
steps: member?.steps || [],
|
|
144
|
+
appLog: member?.appLog || [],
|
|
145
|
+
skip: member?.skip,
|
|
146
|
+
durationMs: member?.durationMs,
|
|
147
|
+
startedAt: member?.startedAt || attempt.startedAt,
|
|
148
|
+
artifacts: attempt.artifacts || [],
|
|
149
|
+
secondaryErrors: [...(member?.secondaryErrors || []), ...(attempt.secondaryErrors || [])],
|
|
150
|
+
environment: attempt.environment,
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function timestampOf(attempt) {
|
|
156
|
+
if (!attempt?.startedAt) return undefined;
|
|
157
|
+
return Date.parse(attempt.startedAt);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function testStatus(status) {
|
|
161
|
+
if (status === 'passed' || status === 'flaky') return STATUS.PASSED;
|
|
162
|
+
if (status === 'skipped') return STATUS.SKIPPED;
|
|
163
|
+
// failed, timed-out, interrupted: the test reached no passing verdict
|
|
164
|
+
return STATUS.FAILED;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function runStatus(status) {
|
|
168
|
+
if (status === 'passed') return STATUS.PASSED;
|
|
169
|
+
return STATUS.FAILED;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function toError(error, secondaryErrors = []) {
|
|
173
|
+
if (!error) return undefined;
|
|
174
|
+
/** @type {Error & { expected?: string, actual?: string }} */
|
|
175
|
+
const err = new Error(error.message);
|
|
176
|
+
err.name = error.code;
|
|
177
|
+
err.stack = `${error.code}: ${error.message}\n${error.stack || sourceFrame(error.source)}`;
|
|
178
|
+
if (error.details?.expected && error.details?.observed) {
|
|
179
|
+
err.expected = error.details.expected;
|
|
180
|
+
err.actual = error.details.observed;
|
|
181
|
+
}
|
|
182
|
+
for (const secondary of secondaryErrors) {
|
|
183
|
+
err.stack += `\n\nAlso failed (${secondary.phase || secondary.category}) ${secondary.code}: ${secondary.message}`;
|
|
184
|
+
}
|
|
185
|
+
return err;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function sourceFrame(source) {
|
|
189
|
+
if (!source) return '';
|
|
190
|
+
return ` at ${source.file}:${source.line}:${source.column}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function toStep(step, filesById) {
|
|
194
|
+
let category = 'user';
|
|
195
|
+
if (step.phase === 'beforeEach' || step.phase === 'afterEach') category = 'hook';
|
|
196
|
+
|
|
197
|
+
let title = step.api;
|
|
198
|
+
if (step.label) title = `${step.api} ${step.label}`;
|
|
199
|
+
|
|
200
|
+
const formatted = formatStep({
|
|
201
|
+
category,
|
|
202
|
+
title,
|
|
203
|
+
duration: step.durationMs,
|
|
204
|
+
log: stepLog(step),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
let status = 'failed';
|
|
208
|
+
if (step.status === 'passed') status = 'passed';
|
|
209
|
+
addStatusToStep(formatted, status, step.error);
|
|
210
|
+
|
|
211
|
+
if (step.error) {
|
|
212
|
+
formatted.error = { message: truncate(`${step.error.code}: ${step.error.message}`, 250) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (SCREENSHOTS_ON_STEPS) {
|
|
216
|
+
const screenshot = (step.artifacts || []).map(id => filesById.get(id)).find(file => file?.type === 'image/png');
|
|
217
|
+
if (screenshot) addArtifactsToStep(formatted, { screenshot: screenshot.path });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return formatted;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Agent reasoning and non-binary step statuses, which a passed/failed step would lose. */
|
|
224
|
+
function stepLog(step) {
|
|
225
|
+
const lines = [];
|
|
226
|
+
if (step.status !== 'passed' && step.status !== 'failed') lines.push(`status: ${step.status}`);
|
|
227
|
+
if (step.explanation) lines.push(step.explanation);
|
|
228
|
+
for (const event of step.events || []) {
|
|
229
|
+
if (event.reasoning) lines.push(event.reasoning);
|
|
230
|
+
}
|
|
231
|
+
return lines.join('\n');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function logsOf(result, final) {
|
|
235
|
+
const lines = [];
|
|
236
|
+
const skip = final?.skip || result.skip;
|
|
237
|
+
if (skip) lines.push(`Skipped (${skip.cause}): ${skip.reason}`);
|
|
238
|
+
if (final?.appLog?.length) {
|
|
239
|
+
lines.push('App log:');
|
|
240
|
+
for (const line of final.appLog) lines.push(`[${line.source}] ${line.level}: ${line.text}`);
|
|
241
|
+
}
|
|
242
|
+
return lines.join('\n');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export default testomatio;
|
package/src/adapter/jasmine.js
CHANGED
|
@@ -2,6 +2,8 @@ import TestomatClient from '../client.js';
|
|
|
2
2
|
import { getTestomatIdFromTestTitle, ansiRegExp } from '../utils/utils.js';
|
|
3
3
|
import { STATUS } from '../constants.js';
|
|
4
4
|
|
|
5
|
+
global.TESTOMATIO_ADAPTER = 'jasmine';
|
|
6
|
+
|
|
5
7
|
class JasmineReporter {
|
|
6
8
|
constructor(options) {
|
|
7
9
|
this.testTimeMap = {};
|
package/src/adapter/jest.js
CHANGED
|
@@ -8,6 +8,8 @@ import createDebugMessages from 'debug';
|
|
|
8
8
|
|
|
9
9
|
const debug = createDebugMessages('@testomatio/reporter:adapter-jest');
|
|
10
10
|
|
|
11
|
+
global.TESTOMATIO_ADAPTER = 'jest';
|
|
12
|
+
|
|
11
13
|
export class JestReporter {
|
|
12
14
|
constructor(globalConfig, options) {
|
|
13
15
|
this._globalConfig = globalConfig;
|
package/src/adapter/mocha.js
CHANGED
|
@@ -6,6 +6,8 @@ import { config } from '../config.js';
|
|
|
6
6
|
import { services } from '../services/index.js';
|
|
7
7
|
import pc from 'picocolors';
|
|
8
8
|
|
|
9
|
+
global.TESTOMATIO_ADAPTER = 'mocha';
|
|
10
|
+
|
|
9
11
|
const {
|
|
10
12
|
EVENT_RUN_BEGIN,
|
|
11
13
|
EVENT_RUN_END,
|
|
@@ -18,157 +20,160 @@ const {
|
|
|
18
20
|
EVENT_TEST_END,
|
|
19
21
|
} = Mocha.Runner.constants;
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
23
|
+
// ES class so it works with mocha 12, where Base is a class and utils.inherits is gone
|
|
24
|
+
class MochaReporter extends Mocha.reporters.Base {
|
|
25
|
+
constructor(runner, opts) {
|
|
26
|
+
super(runner, opts);
|
|
27
|
+
let passes = 0;
|
|
28
|
+
let failures = 0;
|
|
29
|
+
let skipped = 0;
|
|
30
|
+
// let artifactStore;
|
|
31
|
+
|
|
32
|
+
const apiKey = opts?.reporterOptions?.apiKey || config.TESTOMATIO;
|
|
33
|
+
|
|
34
|
+
const client = new TestomatClient({ apiKey });
|
|
35
|
+
|
|
36
|
+
// Track hook failures
|
|
37
|
+
const hookFailures = new Map();
|
|
38
|
+
|
|
39
|
+
runner.on(EVENT_RUN_BEGIN, () => {
|
|
40
|
+
client.createRun();
|
|
41
|
+
|
|
42
|
+
// clear dir with artifacts/logs
|
|
43
|
+
fileSystem.clearDir(TESTOMAT_TMP_STORAGE_DIR);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
runner.on(EVENT_SUITE_BEGIN, async suite => {
|
|
47
|
+
services.setContext(suite.fullTitle());
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
runner.on(EVENT_SUITE_END, async suite => {
|
|
51
|
+
if (hookFailures.has(suite.fullTitle())) {
|
|
52
|
+
const { error, suiteTitle } = hookFailures.get(suite.fullTitle());
|
|
53
|
+
|
|
54
|
+
for (const test of suite.tests) {
|
|
55
|
+
if (test.state === 'pending' || !test.state) {
|
|
56
|
+
const testId = getTestomatIdFromTestTitle(test.title);
|
|
57
|
+
const artifacts = services.artifacts.get(test.fullTitle());
|
|
58
|
+
const keyValues = services.keyValues.get(test.fullTitle());
|
|
59
|
+
const links = services.links.get(test.fullTitle());
|
|
60
|
+
|
|
61
|
+
client.addTestRun(STATUS.FAILED, {
|
|
62
|
+
error,
|
|
63
|
+
suite_title: suiteTitle || suite.title,
|
|
64
|
+
file: suite.file,
|
|
65
|
+
test_id: testId,
|
|
66
|
+
title: test.title,
|
|
67
|
+
code: process.env.TESTOMATIO_UPDATE_CODE ? test.body.toString() : '',
|
|
68
|
+
time: 0,
|
|
69
|
+
manuallyAttachedArtifacts: artifacts,
|
|
70
|
+
meta: keyValues,
|
|
71
|
+
links,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
69
74
|
}
|
|
70
75
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
services.setContext(null);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
runner.on(EVENT_TEST_PASS, async test => {
|
|
85
|
-
passes += 1;
|
|
86
|
-
|
|
87
|
-
console.log(pc.bold(pc.green('✔')), test.fullTitle());
|
|
88
|
-
const testId = getTestomatIdFromTestTitle(test.title);
|
|
89
|
-
|
|
90
|
-
const logs = getTestLogs(test);
|
|
91
|
-
const artifacts = services.artifacts.get(test.fullTitle());
|
|
92
|
-
const keyValues = services.keyValues.get(test.fullTitle());
|
|
93
|
-
const links = services.links.get(test.fullTitle());
|
|
94
|
-
|
|
95
|
-
client.addTestRun(STATUS.PASSED, {
|
|
96
|
-
test_id: testId,
|
|
97
|
-
suite_title: getSuiteTitle(test),
|
|
98
|
-
title: getTestName(test),
|
|
99
|
-
code: process.env.TESTOMATIO_UPDATE_CODE ? test.body.toString() : '',
|
|
100
|
-
file: getFile(test),
|
|
101
|
-
time: test.duration,
|
|
102
|
-
logs,
|
|
103
|
-
manuallyAttachedArtifacts: artifacts,
|
|
104
|
-
meta: keyValues,
|
|
105
|
-
links,
|
|
76
|
+
|
|
77
|
+
services.setContext(null);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
runner.on(EVENT_TEST_BEGIN, async test => {
|
|
81
|
+
services.setContext(test.fullTitle());
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
runner.on(EVENT_TEST_END, async () => {
|
|
85
|
+
services.setContext(null);
|
|
106
86
|
});
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
87
|
+
|
|
88
|
+
runner.on(EVENT_TEST_PASS, async test => {
|
|
89
|
+
passes += 1;
|
|
90
|
+
|
|
91
|
+
console.log(pc.bold(pc.green('✔')), test.fullTitle());
|
|
92
|
+
const testId = getTestomatIdFromTestTitle(test.title);
|
|
93
|
+
|
|
94
|
+
const logs = getTestLogs(test);
|
|
95
|
+
const artifacts = services.artifacts.get(test.fullTitle());
|
|
96
|
+
const keyValues = services.keyValues.get(test.fullTitle());
|
|
97
|
+
const links = services.links.get(test.fullTitle());
|
|
98
|
+
|
|
99
|
+
client.addTestRun(STATUS.PASSED, {
|
|
100
|
+
test_id: testId,
|
|
101
|
+
suite_title: getSuiteTitle(test),
|
|
102
|
+
title: getTestName(test),
|
|
103
|
+
code: process.env.TESTOMATIO_UPDATE_CODE ? test.body.toString() : '',
|
|
104
|
+
file: getFile(test),
|
|
105
|
+
time: test.duration,
|
|
106
|
+
logs,
|
|
107
|
+
manuallyAttachedArtifacts: artifacts,
|
|
108
|
+
meta: keyValues,
|
|
109
|
+
links,
|
|
110
|
+
});
|
|
127
111
|
});
|
|
128
|
-
});
|
|
129
112
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
113
|
+
runner.on(EVENT_TEST_PENDING, test => {
|
|
114
|
+
skipped += 1;
|
|
115
|
+
console.log('skip: %s', test.fullTitle());
|
|
116
|
+
const testId = getTestomatIdFromTestTitle(test.title);
|
|
117
|
+
const artifacts = services.artifacts.get(test.fullTitle());
|
|
118
|
+
const keyValues = services.keyValues.get(test.fullTitle());
|
|
119
|
+
const links = services.links.get(test.fullTitle());
|
|
120
|
+
|
|
121
|
+
client.addTestRun(STATUS.SKIPPED, {
|
|
122
|
+
title: getTestName(test),
|
|
123
|
+
suite_title: getSuiteTitle(test),
|
|
124
|
+
code: process.env.TESTOMATIO_UPDATE_CODE ? test.body.toString() : '',
|
|
125
|
+
file: getFile(test),
|
|
126
|
+
test_id: testId,
|
|
127
|
+
time: test.duration,
|
|
128
|
+
manuallyAttachedArtifacts: artifacts,
|
|
129
|
+
meta: keyValues,
|
|
130
|
+
links,
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
runner.on(EVENT_TEST_FAIL, async (test, err) => {
|
|
135
|
+
failures += 1;
|
|
136
|
+
console.log(pc.bold(pc.red('✖')), test.fullTitle(), pc.gray(err.message));
|
|
137
|
+
|
|
138
|
+
const isHookFailure = test.title.includes('before each') || test.title.includes('after each');
|
|
133
139
|
|
|
134
|
-
|
|
140
|
+
if (isHookFailure && test.parent) {
|
|
141
|
+
hookFailures.set(test.parent.fullTitle(), {
|
|
142
|
+
error: err,
|
|
143
|
+
suiteTitle: getSuiteTitle(test),
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const testId = getTestomatIdFromTestTitle(test.title);
|
|
135
149
|
|
|
136
|
-
|
|
137
|
-
|
|
150
|
+
const logs = getTestLogs(test);
|
|
151
|
+
const artifacts = services.artifacts.get(test.fullTitle());
|
|
152
|
+
const keyValues = services.keyValues.get(test.fullTitle());
|
|
153
|
+
const links = services.links.get(test.fullTitle());
|
|
154
|
+
|
|
155
|
+
client.addTestRun(STATUS.FAILED, {
|
|
138
156
|
error: err,
|
|
139
|
-
|
|
157
|
+
suite_title: getSuiteTitle(test),
|
|
158
|
+
file: getFile(test),
|
|
159
|
+
test_id: testId,
|
|
160
|
+
title: getTestName(test),
|
|
161
|
+
code: process.env.TESTOMATIO_UPDATE_CODE ? test.body.toString() : '',
|
|
162
|
+
time: test.duration,
|
|
163
|
+
logs,
|
|
164
|
+
manuallyAttachedArtifacts: artifacts,
|
|
165
|
+
meta: keyValues,
|
|
166
|
+
links,
|
|
140
167
|
});
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const testId = getTestomatIdFromTestTitle(test.title);
|
|
145
|
-
|
|
146
|
-
const logs = getTestLogs(test);
|
|
147
|
-
const artifacts = services.artifacts.get(test.fullTitle());
|
|
148
|
-
const keyValues = services.keyValues.get(test.fullTitle());
|
|
149
|
-
const links = services.links.get(test.fullTitle());
|
|
150
|
-
|
|
151
|
-
client.addTestRun(STATUS.FAILED, {
|
|
152
|
-
error: err,
|
|
153
|
-
suite_title: getSuiteTitle(test),
|
|
154
|
-
file: getFile(test),
|
|
155
|
-
test_id: testId,
|
|
156
|
-
title: getTestName(test),
|
|
157
|
-
code: process.env.TESTOMATIO_UPDATE_CODE ? test.body.toString() : '',
|
|
158
|
-
time: test.duration,
|
|
159
|
-
logs,
|
|
160
|
-
manuallyAttachedArtifacts: artifacts,
|
|
161
|
-
meta: keyValues,
|
|
162
|
-
links,
|
|
163
168
|
});
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
169
|
+
|
|
170
|
+
runner.on(EVENT_RUN_END, () => {
|
|
171
|
+
const status = failures === 0 ? STATUS.PASSED : STATUS.FAILED;
|
|
172
|
+
console.log(pc.bold(status), `${passes} passed, ${failures} failed, ${skipped} skipped`);
|
|
173
|
+
// @ts-ignore
|
|
174
|
+
client.updateRunStatus(status);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
172
177
|
}
|
|
173
178
|
|
|
174
179
|
function getTestLogs(test) {
|
|
@@ -204,8 +209,4 @@ function getTestName(test) {
|
|
|
204
209
|
return test.title;
|
|
205
210
|
}
|
|
206
211
|
|
|
207
|
-
// To have this reporter "extend" a built-in reporter uncomment the following line:
|
|
208
|
-
// @ts-ignore
|
|
209
|
-
Mocha.utils.inherits(MochaReporter, Mocha.reporters.Spec);
|
|
210
|
-
|
|
211
212
|
export default MochaReporter;
|
|
@@ -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 });
|
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
|
package/src/adapter/webdriver.js
CHANGED
|
@@ -5,6 +5,8 @@ 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
7
|
|
|
8
|
+
global.TESTOMATIO_ADAPTER = 'webdriverio';
|
|
9
|
+
|
|
8
10
|
class WebdriverReporter extends WDIOReporter {
|
|
9
11
|
constructor(options) {
|
|
10
12
|
super(options);
|
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
|
@@ -16,7 +16,7 @@ import { filesize as prettyBytes } from 'filesize';
|
|
|
16
16
|
import dotenv from 'dotenv';
|
|
17
17
|
import Replay from '../replay.js';
|
|
18
18
|
import { log } from '../utils/log.js';
|
|
19
|
-
import { formatFilterListIds, formatRunOutput, formatFetchRunsOutput } from '../utils/pipe_utils.js';
|
|
19
|
+
import { formatFilterListIds, formatRunOutput, formatFetchRunsOutput, parseRunsUrl } from '../utils/pipe_utils.js';
|
|
20
20
|
import fs from 'fs';
|
|
21
21
|
import path from 'path';
|
|
22
22
|
|
|
@@ -130,7 +130,9 @@ program
|
|
|
130
130
|
program
|
|
131
131
|
.command('fetch')
|
|
132
132
|
.description('Fetch runs from Testomat.io API v2')
|
|
133
|
+
.argument('[url]', 'URL of a run or of a (filtered) runs page in Testomat.io')
|
|
133
134
|
.option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
|
|
135
|
+
.option('--run <id>', 'Fetch a single run by its id')
|
|
134
136
|
.option('--title <text>', 'Filter by run title')
|
|
135
137
|
.option('--tql <query>', 'Filter using Testomat Query Language')
|
|
136
138
|
.option('--rungroup <uid>', 'Filter by rungroup id')
|
|
@@ -144,27 +146,44 @@ program
|
|
|
144
146
|
'--format <format>',
|
|
145
147
|
'Machine-readable output: run ids, one per line (--format id) or run details (--format json)',
|
|
146
148
|
)
|
|
147
|
-
.action(async opts => {
|
|
149
|
+
.action(async (pageUrl, opts) => {
|
|
148
150
|
const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config.TESTOMATIO;
|
|
149
151
|
if (!apiKey) {
|
|
150
152
|
log.error(pc.red('⚠️ TESTOMATIO API key required'));
|
|
151
153
|
process.exit(1);
|
|
152
154
|
}
|
|
153
155
|
|
|
154
|
-
|
|
156
|
+
let page = {};
|
|
157
|
+
if (pageUrl) {
|
|
158
|
+
page = parseRunsUrl(pageUrl);
|
|
159
|
+
if (!page) {
|
|
160
|
+
log.error(pc.red(`⚠️ Not a Testomat.io run or runs page URL: ${pageUrl}`));
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const project = opts.project || page.project || process.env.TESTOMATIO_PROJECT;
|
|
155
166
|
if (!project) {
|
|
156
167
|
log.error(pc.red('⚠️ --project (or TESTOMATIO_PROJECT) is required'));
|
|
157
168
|
process.exit(1);
|
|
158
169
|
}
|
|
159
170
|
|
|
160
|
-
const baseUrl = process.env.TESTOMATIO_URL || 'https://app.testomat.io';
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
171
|
+
const baseUrl = page.baseUrl || process.env.TESTOMATIO_URL || 'https://app.testomat.io';
|
|
172
|
+
const runId = opts.run || page.runId;
|
|
173
|
+
let url;
|
|
174
|
+
|
|
175
|
+
if (runId) {
|
|
176
|
+
url = new URL(`/api/v2/${project}/runs/${runId}`, baseUrl);
|
|
177
|
+
} else {
|
|
178
|
+
url = new URL(`/api/v2/${project}/runs`, baseUrl);
|
|
179
|
+
for (const [key, value] of page.params || []) url.searchParams.append(key, value);
|
|
180
|
+
if (opts.title) url.searchParams.set('search', opts.title);
|
|
181
|
+
if (opts.tql) url.searchParams.set('tql', opts.tql);
|
|
182
|
+
if (opts.rungroup) url.searchParams.set('groupId', opts.rungroup);
|
|
183
|
+
const requestedLimit = opts.latest ? 1 : parseInt(opts.limit, 10) || FETCH_RUNS_DEFAULT_LIMIT;
|
|
184
|
+
const limit = Math.min(Math.max(requestedLimit, 1), FETCH_RUNS_MAX_LIMIT);
|
|
185
|
+
url.searchParams.set('per_page', String(limit));
|
|
186
|
+
}
|
|
168
187
|
|
|
169
188
|
const response = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
|
|
170
189
|
const body = await response.json();
|
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
|
|
package/src/pipe/bitbucket.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
markdownTable,
|
|
8
8
|
runSummary,
|
|
9
9
|
totalDuration,
|
|
10
|
+
smallRunSections,
|
|
10
11
|
} from '../utils/pipe_utils.js';
|
|
11
12
|
import { Gaxios } from 'gaxios';
|
|
12
13
|
import pc from 'picocolors';
|
|
@@ -174,7 +175,8 @@ export class BitbucketPipe {
|
|
|
174
175
|
let body = summary;
|
|
175
176
|
|
|
176
177
|
if (this.description) {
|
|
177
|
-
|
|
178
|
+
// Bitbucket strips HTML, so no <details> here
|
|
179
|
+
body += `\n\n**📝 Description**\n\n${truncate(this.description, 1024)}\n`;
|
|
178
180
|
}
|
|
179
181
|
|
|
180
182
|
if (failures.length) {
|
|
@@ -184,6 +186,10 @@ export class BitbucketPipe {
|
|
|
184
186
|
}
|
|
185
187
|
}
|
|
186
188
|
|
|
189
|
+
for (const { title, list } of smallRunSections(this.tests)) {
|
|
190
|
+
body += `\n\n**${title}**\n\n${list.join('\n')}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
187
193
|
if (this.tests.length > 0 && !isPendingRun) {
|
|
188
194
|
body += `\n\n**🐢 Slowest Tests**\n\n`;
|
|
189
195
|
body += this.tests
|
package/src/pipe/github.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
markdownTable,
|
|
13
13
|
runSummary,
|
|
14
14
|
totalDuration,
|
|
15
|
+
smallRunSections,
|
|
15
16
|
} from '../utils/pipe_utils.js';
|
|
16
17
|
import { log } from '../utils/log.js';
|
|
17
18
|
|
|
@@ -147,7 +148,8 @@ class GitHubPipe {
|
|
|
147
148
|
|
|
148
149
|
let body = summary;
|
|
149
150
|
if (this.description) {
|
|
150
|
-
body +=
|
|
151
|
+
body += '\n\n<details>\n<summary><h3>📝 Description</h3></summary>\n\n';
|
|
152
|
+
body += `${truncate(this.description, 1024)}\n\n</details>`;
|
|
151
153
|
}
|
|
152
154
|
const coverageConfiguration = this.store?.coverageConfiguration;
|
|
153
155
|
const isManualRun = this.store?.runKind === 'manual';
|
|
@@ -172,6 +174,10 @@ class GitHubPipe {
|
|
|
172
174
|
body += '\n\n</details>';
|
|
173
175
|
}
|
|
174
176
|
|
|
177
|
+
for (const { title, list } of smallRunSections(this.tests)) {
|
|
178
|
+
body += `\n<details>\n<summary><h3>${title}</h3></summary>\n\n${list.join('\n')}\n</details>`;
|
|
179
|
+
}
|
|
180
|
+
|
|
175
181
|
if (this.tests.length > 0 && !isPendingRun) {
|
|
176
182
|
body += '\n<details>\n<summary><h3>🐢 Slowest Tests</h3></summary>\n\n';
|
|
177
183
|
body += this.tests
|
package/src/pipe/gitlab.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
markdownTable,
|
|
14
14
|
runSummary,
|
|
15
15
|
totalDuration,
|
|
16
|
+
smallRunSections,
|
|
16
17
|
} from '../utils/pipe_utils.js';
|
|
17
18
|
import { log } from '../utils/log.js';
|
|
18
19
|
|
|
@@ -148,7 +149,8 @@ class GitLabPipe {
|
|
|
148
149
|
let body = summary;
|
|
149
150
|
|
|
150
151
|
if (this.description) {
|
|
151
|
-
body +=
|
|
152
|
+
body += '\n\n<details>\n<summary><h3>📝 Description</h3></summary>\n\n';
|
|
153
|
+
body += `${truncate(this.description, 1024)}\n\n</details>`;
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
if (failures.length) {
|
|
@@ -159,6 +161,10 @@ class GitLabPipe {
|
|
|
159
161
|
body += '\n\n</details>';
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
for (const { title, list } of smallRunSections(this.tests)) {
|
|
165
|
+
body += `\n<details>\n<summary><h3>${title}</h3></summary>\n\n${list.join('\n')}\n</details>`;
|
|
166
|
+
}
|
|
167
|
+
|
|
162
168
|
if (this.tests.length > 0 && !isPendingRun) {
|
|
163
169
|
body += '\n<details>\n<summary><h3>🐢 Slowest Tests</h3></summary>\n\n';
|
|
164
170
|
body += this.tests
|
package/src/pipe/testomatio.js
CHANGED
|
@@ -352,6 +352,8 @@ class TestomatioPipe {
|
|
|
352
352
|
if (resp.data.artifacts) {
|
|
353
353
|
setS3Credentials(resp.data.artifacts);
|
|
354
354
|
this.#startS3CredentialsRefresh(runParams);
|
|
355
|
+
} else {
|
|
356
|
+
this.#warnNoS3Credentials();
|
|
355
357
|
}
|
|
356
358
|
if (resp.data.url) {
|
|
357
359
|
const respUrl = new URL(resp.data.url);
|
|
@@ -383,6 +385,8 @@ class TestomatioPipe {
|
|
|
383
385
|
if (resp.data.artifacts) {
|
|
384
386
|
setS3Credentials(resp.data.artifacts);
|
|
385
387
|
this.#startS3CredentialsRefresh(runParams);
|
|
388
|
+
} else {
|
|
389
|
+
this.#warnNoS3Credentials();
|
|
386
390
|
}
|
|
387
391
|
|
|
388
392
|
this.store.runUrl = this.runUrl;
|
|
@@ -442,10 +446,32 @@ class TestomatioPipe {
|
|
|
442
446
|
debug('S3 credentials refreshed');
|
|
443
447
|
}
|
|
444
448
|
} catch (err) {
|
|
445
|
-
|
|
449
|
+
log.warn(
|
|
450
|
+
'⚠️ Failed to refresh S3 credentials:',
|
|
451
|
+
err.message || err,
|
|
452
|
+
'\nArtifact uploads may fail once the current credentials expire.',
|
|
453
|
+
);
|
|
446
454
|
}
|
|
447
455
|
};
|
|
448
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Warns the user when Testomat.io did not return S3 credentials for artifacts upload.
|
|
459
|
+
* Skipped when artifacts uploading was intentionally disabled or a custom S3 storage is configured via env.
|
|
460
|
+
*/
|
|
461
|
+
#warnNoS3Credentials() {
|
|
462
|
+
if (process.env.TESTOMATIO_DISABLE_ARTIFACTS || process.env.S3_BUCKET) return;
|
|
463
|
+
|
|
464
|
+
const command = `TESTOMATIO=<your_api_key> TESTOMATIO_RUN=${
|
|
465
|
+
this.runId || '<run_id>'
|
|
466
|
+
} npx @testomatio/reporter upload-artifacts`;
|
|
467
|
+
log.warn(
|
|
468
|
+
`⚠️ ${pc.yellow('Testomat.io did not return S3 credentials for artifacts upload.')}`,
|
|
469
|
+
'Artifacts will not be uploaded.',
|
|
470
|
+
'Please check the artifact sharing settings of your project.',
|
|
471
|
+
`\nRun "${pc.magenta(command)}" later with valid S3 credentials to re-upload artifacts to this run.`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
449
475
|
#stopS3CredentialsRefresh() {
|
|
450
476
|
if (this.s3CredentialsRefreshTimer) {
|
|
451
477
|
clearInterval(this.s3CredentialsRefreshTimer);
|
package/src/replay.js
CHANGED
package/src/uploader.js
CHANGED
|
@@ -23,6 +23,7 @@ export class S3Uploader {
|
|
|
23
23
|
*/
|
|
24
24
|
this.skippedUploads = [];
|
|
25
25
|
this.failedUploads = [];
|
|
26
|
+
this.warnedDisabledUpload = false;
|
|
26
27
|
/**
|
|
27
28
|
* @type {{path: string, size: number, link: string}[]}
|
|
28
29
|
*/
|
|
@@ -90,6 +91,30 @@ export class S3Uploader {
|
|
|
90
91
|
this.storeEnabled = false;
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Warns once when artifact uploading is disabled not intentionally by the user,
|
|
96
|
+
* e.g. S3 credentials were not obtained from Testomat.io and no custom S3 storage is configured.
|
|
97
|
+
* Without this warning artifacts are skipped silently.
|
|
98
|
+
*/
|
|
99
|
+
#warnDisabledUpload(runId) {
|
|
100
|
+
if (this.warnedDisabledUpload) return;
|
|
101
|
+
|
|
102
|
+
const { S3_BUCKET, TESTOMATIO_DISABLE_ARTIFACTS } = this.getConfig();
|
|
103
|
+
// uploading was intentionally disabled by the user or a custom S3 storage is used, nothing to warn about
|
|
104
|
+
if (TESTOMATIO_DISABLE_ARTIFACTS || S3_BUCKET) return;
|
|
105
|
+
|
|
106
|
+
this.warnedDisabledUpload = true;
|
|
107
|
+
const command = `TESTOMATIO=<your_api_key> TESTOMATIO_RUN=${
|
|
108
|
+
runId || '<run_id>'
|
|
109
|
+
} npx @testomatio/reporter upload-artifacts`;
|
|
110
|
+
log.warn(
|
|
111
|
+
`🗄️ ${pc.yellow('Artifacts uploading is disabled: S3 credentials were not obtained from Testomat.io')}`,
|
|
112
|
+
pc.yellow('(and no custom S3 storage is configured).'),
|
|
113
|
+
'Artifacts will not be uploaded.',
|
|
114
|
+
`\nRun "${pc.magenta(command)}" later with valid S3 credentials to re-upload artifacts to this run.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
93
118
|
/**
|
|
94
119
|
*
|
|
95
120
|
* @param {*} Body
|
|
@@ -231,6 +256,7 @@ export class S3Uploader {
|
|
|
231
256
|
}
|
|
232
257
|
|
|
233
258
|
if (!this.isEnabled) {
|
|
259
|
+
this.#warnDisabledUpload(runId);
|
|
234
260
|
this.storeUploadedFile(filePath, runId, rid, false);
|
|
235
261
|
this.skippedUploads.push({ path: filePath, size: fileSize });
|
|
236
262
|
return;
|
|
@@ -281,7 +307,10 @@ export class S3Uploader {
|
|
|
281
307
|
probably, the reason is that run is NOT created in adapter (but via cli) */
|
|
282
308
|
|
|
283
309
|
this.isEnabled = this.isEnabled ?? this.checkEnabled();
|
|
284
|
-
if (!this.isEnabled)
|
|
310
|
+
if (!this.isEnabled) {
|
|
311
|
+
this.#warnDisabledUpload(pathInS3[0]);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
285
314
|
|
|
286
315
|
let Key = pathInS3.filter(p => !!p).join('/');
|
|
287
316
|
const ext = this.#getFileExtBase64(buffer.toString('base64'));
|
package/src/utils/pipe_utils.js
CHANGED
|
@@ -122,6 +122,14 @@ function statusEmoji(status) {
|
|
|
122
122
|
return '';
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* @param {string} str
|
|
127
|
+
* @returns {string} The string with its first letter in upper case, e.g. `passed` → `Passed`.
|
|
128
|
+
*/
|
|
129
|
+
function capitalize(str) {
|
|
130
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
131
|
+
}
|
|
132
|
+
|
|
125
133
|
/**
|
|
126
134
|
* Generate a full name string based on the provided test object.
|
|
127
135
|
* @param {object} t - The test object.
|
|
@@ -260,6 +268,37 @@ function totalDuration(tests) {
|
|
|
260
268
|
return humanizeDuration(Math.trunc(milliseconds), { maxDecimalPoints: 0 });
|
|
261
269
|
}
|
|
262
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Runs with fewer tests than this list every test in the report, not only the failed ones.
|
|
273
|
+
*/
|
|
274
|
+
const SMALL_RUN_TESTS_LIMIT = 10;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Sections listing the passed, skipped and not yet run tests of a small run; failures have their own
|
|
278
|
+
* section. Not yet run tests (no status, or `pending`) are listed without any status label.
|
|
279
|
+
*
|
|
280
|
+
* @param {Array<{status?: string, title?: string, suite_title?: string, run_time?: number}>} tests
|
|
281
|
+
* @returns {Array<{title: string, list: string[]}>} Non-empty sections; none if the run is not small.
|
|
282
|
+
*/
|
|
283
|
+
function smallRunSections(tests) {
|
|
284
|
+
if (tests.length >= SMALL_RUN_TESTS_LIMIT) return [];
|
|
285
|
+
|
|
286
|
+
const sections = ['passed', 'skipped'].map(status => {
|
|
287
|
+
const list = tests
|
|
288
|
+
.filter(t => t.status === status)
|
|
289
|
+
.map(t => {
|
|
290
|
+
const runTime = t.run_time ? ` (${humanizeDuration(t.run_time)})` : '';
|
|
291
|
+
return `* ${statusEmoji(status)} ${fullName(t)}${runTime}`;
|
|
292
|
+
});
|
|
293
|
+
return { title: `${statusEmoji(status)} ${capitalize(status)} (${list.length})`, list };
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const notRun = tests.filter(t => !t.status || t.status === 'pending').map(t => `* ${fullName(t)}`);
|
|
297
|
+
sections.push({ title: `📋 Tests (${notRun.length})`, list: notRun });
|
|
298
|
+
|
|
299
|
+
return sections.filter(section => section.list.length);
|
|
300
|
+
}
|
|
301
|
+
|
|
263
302
|
/**
|
|
264
303
|
* Render a two-column markdown table. Rows with an empty value are skipped, so optional rows
|
|
265
304
|
* need no surrounding `if`.
|
|
@@ -327,17 +366,18 @@ function formatRunOutput(store, format) {
|
|
|
327
366
|
|
|
328
367
|
/**
|
|
329
368
|
* Format the runs fetched via the `fetch` command for machine-readable output.
|
|
330
|
-
* `json` prints the full array of run objects; `id` prints one run id per line;
|
|
369
|
+
* `json` prints the full array of run objects (a single object for one run); `id` prints one run id per line;
|
|
331
370
|
* any other format prints a human-readable multi-line summary per run.
|
|
332
371
|
*
|
|
333
|
-
* @param {{data?: Array}} body - Parsed response body from GET /api/v2/:project/runs.
|
|
372
|
+
* @param {{data?: Array|Object}} body - Parsed response body from GET /api/v2/:project/runs[/:id].
|
|
334
373
|
* @param {string} [format] - Value of the CLI `--format` option.
|
|
335
374
|
* @returns {string}
|
|
336
375
|
*/
|
|
337
376
|
function formatFetchRunsOutput(body, format) {
|
|
338
|
-
const
|
|
377
|
+
const data = body?.data || [];
|
|
378
|
+
const runs = [data].flat();
|
|
339
379
|
|
|
340
|
-
if (format === 'json') return JSON.stringify(
|
|
380
|
+
if (format === 'json') return JSON.stringify(data, null, 2);
|
|
341
381
|
|
|
342
382
|
if (format === 'id') return runs.map(run => run.id).filter(Boolean).join('\n');
|
|
343
383
|
|
|
@@ -345,6 +385,7 @@ function formatFetchRunsOutput(body, format) {
|
|
|
345
385
|
.map(run => {
|
|
346
386
|
const lines = [`* ID: ${run.id}`];
|
|
347
387
|
if (run.title) lines.push(` title: ${run.title}`);
|
|
388
|
+
if (run.rungroup_id) lines.push(` rungroup: ${run.rungroup_id}`);
|
|
348
389
|
if (run.launched_at) lines.push(` started at: ${run.launched_at}`);
|
|
349
390
|
if (run.finished_at) lines.push(` finished at: ${run.finished_at}`);
|
|
350
391
|
if (run.passed_count != null) lines.push(` passed: ${run.passed_count} tests`);
|
|
@@ -358,6 +399,44 @@ function formatFetchRunsOutput(body, format) {
|
|
|
358
399
|
.join('\n');
|
|
359
400
|
}
|
|
360
401
|
|
|
402
|
+
// filterParam keys of the runs page which are API params on their own; the rest go under filter[...]
|
|
403
|
+
const RUNS_PAGE_PARAMS = ['search', 'tql', 'groupId'];
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Parse a Testomat.io app URL of a run (/projects/:project/runs/:id)
|
|
407
|
+
* or of a runs page (/projects/:project/runs?filterParam=...) into API v2 request parts.
|
|
408
|
+
*
|
|
409
|
+
* @param {string} pageUrl
|
|
410
|
+
* @returns {{baseUrl: string, project: string, runId?: string, params: string[][]}|null}
|
|
411
|
+
*/
|
|
412
|
+
function parseRunsUrl(pageUrl) {
|
|
413
|
+
let url;
|
|
414
|
+
try {
|
|
415
|
+
url = new URL(pageUrl);
|
|
416
|
+
} catch (e) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const match = url.pathname.match(/\/projects\/([^/]+)\/runs(?:\/([^/]+))?\/?$/);
|
|
421
|
+
if (!match) return null;
|
|
422
|
+
|
|
423
|
+
const [, project, runId] = match;
|
|
424
|
+
const params = [];
|
|
425
|
+
|
|
426
|
+
for (const [key, value] of new URLSearchParams(url.searchParams.get('filterParam') || '')) {
|
|
427
|
+
// "groups" only switches the page to the grouped view
|
|
428
|
+
if (!value || key === 'groups') continue;
|
|
429
|
+
if (RUNS_PAGE_PARAMS.includes(key)) {
|
|
430
|
+
params.push([key, value]);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const [name, ...nested] = key.split('[');
|
|
434
|
+
params.push([`filter[${name}]${nested.map(part => `[${part}`).join('')}`, value]);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return { baseUrl: url.origin, project, runId, params };
|
|
438
|
+
}
|
|
439
|
+
|
|
361
440
|
export {
|
|
362
441
|
updateFilterType,
|
|
363
442
|
parseFilterParams,
|
|
@@ -368,11 +447,13 @@ export {
|
|
|
368
447
|
markdownTable,
|
|
369
448
|
runSummary,
|
|
370
449
|
totalDuration,
|
|
450
|
+
smallRunSections,
|
|
371
451
|
plannedTestsLabel,
|
|
372
452
|
parsePipeOptions,
|
|
373
453
|
formatFilterListIds,
|
|
374
454
|
formatRunOutput,
|
|
375
455
|
formatFetchRunsOutput,
|
|
456
|
+
parseRunsUrl,
|
|
376
457
|
getObjectSize,
|
|
377
458
|
splitTestsIntoChunks,
|
|
378
459
|
};
|
package/src/xmlReader.js
CHANGED