testomatio-reporter-cli 2.19.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-reporter-cli",
3
- "version": "2.19.0",
3
+ "version": "2.20.0",
4
4
  "description": "Yarn Berry compatible standalone CLI for @testomatio/reporter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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;
@@ -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 = {};
@@ -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;
@@ -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
- function MochaReporter(runner, opts) {
22
- Mocha.reporters.Base.call(this, runner);
23
- let passes = 0;
24
- let failures = 0;
25
- let skipped = 0;
26
- // let artifactStore;
27
-
28
- const apiKey = opts?.reporterOptions?.apiKey || config.TESTOMATIO;
29
-
30
- const client = new TestomatClient({ apiKey });
31
-
32
- // Track hook failures
33
- const hookFailures = new Map();
34
-
35
- runner.on(EVENT_RUN_BEGIN, () => {
36
- client.createRun();
37
-
38
- // clear dir with artifacts/logs
39
- fileSystem.clearDir(TESTOMAT_TMP_STORAGE_DIR);
40
- });
41
-
42
- runner.on(EVENT_SUITE_BEGIN, async suite => {
43
- services.setContext(suite.fullTitle());
44
- });
45
-
46
- runner.on(EVENT_SUITE_END, async suite => {
47
- if (hookFailures.has(suite.fullTitle())) {
48
- const { error, suiteTitle } = hookFailures.get(suite.fullTitle());
49
-
50
- for (const test of suite.tests) {
51
- if (test.state === 'pending' || !test.state) {
52
- const testId = getTestomatIdFromTestTitle(test.title);
53
- const artifacts = services.artifacts.get(test.fullTitle());
54
- const keyValues = services.keyValues.get(test.fullTitle());
55
- const links = services.links.get(test.fullTitle());
56
-
57
- client.addTestRun(STATUS.FAILED, {
58
- error,
59
- suite_title: suiteTitle || suite.title,
60
- file: suite.file,
61
- test_id: testId,
62
- title: test.title,
63
- code: process.env.TESTOMATIO_UPDATE_CODE ? test.body.toString() : '',
64
- time: 0,
65
- manuallyAttachedArtifacts: artifacts,
66
- meta: keyValues,
67
- links,
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
- services.setContext(null);
74
- });
75
-
76
- runner.on(EVENT_TEST_BEGIN, async test => {
77
- services.setContext(test.fullTitle());
78
- });
79
-
80
- runner.on(EVENT_TEST_END, async () => {
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
- runner.on(EVENT_TEST_PENDING, test => {
110
- skipped += 1;
111
- console.log('skip: %s', test.fullTitle());
112
- const testId = getTestomatIdFromTestTitle(test.title);
113
- const artifacts = services.artifacts.get(test.fullTitle());
114
- const keyValues = services.keyValues.get(test.fullTitle());
115
- const links = services.links.get(test.fullTitle());
116
-
117
- client.addTestRun(STATUS.SKIPPED, {
118
- title: getTestName(test),
119
- suite_title: getSuiteTitle(test),
120
- code: process.env.TESTOMATIO_UPDATE_CODE ? test.body.toString() : '',
121
- file: getFile(test),
122
- test_id: testId,
123
- time: test.duration,
124
- manuallyAttachedArtifacts: artifacts,
125
- meta: keyValues,
126
- links,
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
- runner.on(EVENT_TEST_FAIL, async (test, err) => {
131
- failures += 1;
132
- console.log(pc.bold(pc.red('✖')), test.fullTitle(), pc.gray(err.message));
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
- const isHookFailure = test.title.includes('before each') || test.title.includes('after each');
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
- if (isHookFailure && test.parent) {
137
- hookFailures.set(test.parent.fullTitle(), {
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
- suiteTitle: getSuiteTitle(test),
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
- runner.on(EVENT_RUN_END, () => {
167
- const status = failures === 0 ? STATUS.PASSED : STATUS.FAILED;
168
- console.log(pc.bold(status), `${passes} passed, ${failures} failed, ${skipped} skipped`);
169
- // @ts-ignore
170
- client.updateRunStatus(status);
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 });
@@ -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
@@ -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);
@@ -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: this.extractMeta(result),
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
- const project = opts.project || process.env.TESTOMATIO_PROJECT;
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 url = new URL(`/api/v2/${project}/runs`, baseUrl);
162
- if (opts.title) url.searchParams.set('search', opts.title);
163
- if (opts.tql) url.searchParams.set('tql', opts.tql);
164
- if (opts.rungroup) url.searchParams.set('groupId', opts.rungroup);
165
- const requestedLimit = opts.latest ? 1 : parseInt(opts.limit, 10) || FETCH_RUNS_DEFAULT_LIMIT;
166
- const limit = Math.min(Math.max(requestedLimit, 1), FETCH_RUNS_MAX_LIMIT);
167
- url.searchParams.set('per_page', String(limit));
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 = '', meta = {} } = testData;
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
- if (!this.uploader.isEnabled) return;
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) {
@@ -190,9 +190,10 @@ class DataStorage {
190
190
  }
191
191
 
192
192
  function stringToMD5Hash(str) {
193
- const md5 = crypto.createHash('md5');
194
- md5.update(str);
195
- const hash = md5.digest('hex');
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
 
@@ -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
- debug('Failed to refresh S3 credentials:', err.message || err);
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
@@ -237,6 +237,7 @@ export class Replay {
237
237
 
238
238
  const client = new TestomatClient({
239
239
  apiKey: this.apiKey,
240
+ adapter: 'replay',
240
241
  batchMode: BATCH_MODE.AUTO,
241
242
  ...runParams,
242
243
  ...(runId && { runId }),
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) return;
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'));
@@ -366,17 +366,18 @@ function formatRunOutput(store, format) {
366
366
 
367
367
  /**
368
368
  * Format the runs fetched via the `fetch` command for machine-readable output.
369
- * `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;
370
370
  * any other format prints a human-readable multi-line summary per run.
371
371
  *
372
- * @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].
373
373
  * @param {string} [format] - Value of the CLI `--format` option.
374
374
  * @returns {string}
375
375
  */
376
376
  function formatFetchRunsOutput(body, format) {
377
- const runs = body?.data || [];
377
+ const data = body?.data || [];
378
+ const runs = [data].flat();
378
379
 
379
- if (format === 'json') return JSON.stringify(runs, null, 2);
380
+ if (format === 'json') return JSON.stringify(data, null, 2);
380
381
 
381
382
  if (format === 'id') return runs.map(run => run.id).filter(Boolean).join('\n');
382
383
 
@@ -384,6 +385,7 @@ function formatFetchRunsOutput(body, format) {
384
385
  .map(run => {
385
386
  const lines = [`* ID: ${run.id}`];
386
387
  if (run.title) lines.push(` title: ${run.title}`);
388
+ if (run.rungroup_id) lines.push(` rungroup: ${run.rungroup_id}`);
387
389
  if (run.launched_at) lines.push(` started at: ${run.launched_at}`);
388
390
  if (run.finished_at) lines.push(` finished at: ${run.finished_at}`);
389
391
  if (run.passed_count != null) lines.push(` passed: ${run.passed_count} tests`);
@@ -397,6 +399,44 @@ function formatFetchRunsOutput(body, format) {
397
399
  .join('\n');
398
400
  }
399
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
+
400
440
  export {
401
441
  updateFilterType,
402
442
  parseFilterParams,
@@ -413,6 +453,7 @@ export {
413
453
  formatFilterListIds,
414
454
  formatRunOutput,
415
455
  formatFetchRunsOutput,
456
+ parseRunsUrl,
416
457
  getObjectSize,
417
458
  splitTestsIntoChunks,
418
459
  };
package/src/xmlReader.js CHANGED
@@ -499,6 +499,9 @@ class XmlReader {
499
499
  this.adapter.formatTest(t);
500
500
 
501
501
  t.title = humanize(t.title);
502
+
503
+ // meta must always include reporter version and adapter used
504
+ t.meta = { ...t.meta, reporterVersion: this.version, adapter: 'xml' };
502
505
  });
503
506
  }
504
507
 
package/types/types.d.ts CHANGED
@@ -374,6 +374,7 @@ declare global {
374
374
  testomatioDataStore?: any;
375
375
  TESTOMATIO_LOGGER_CONSOLE_INTERCEPTED?: boolean;
376
376
  testomatioTestTitle?: string;
377
+ TESTOMATIO_ADAPTER?: string;
377
378
  }
378
379
  }
379
380
  }