@testomatio/reporter 1.5.0-beta-vitest β†’ 1.5.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/README.md CHANGED
@@ -11,7 +11,7 @@ Testomat.io Reporter (this npm package) supports:
11
11
  - πŸ„ Integarion with all popular [JavaScript/TypeScript frameworks](./docs/frameworks.md)
12
12
  - πŸ—„οΈ Screenshots, videos, traces [uploaded into S3 bucket](./docs/artifacts.md)
13
13
  - πŸ”Ž [Stack traces](./docs/stacktrace.md) and error messages
14
- - πŸ™ [GitHub](./docs/pipes/github.md) & [GitLab](./docs/pipes/gitlab.md) integration
14
+ - πŸ™ [GitHub](./docs/pipes/github.md), [GitLab](./docs/pipes/gitlab.md) & [Bitbucket](./docs/pipes/bitbucket.md) integration
15
15
  - πŸš… Realtime reports
16
16
  - πŸ—ƒοΈ Other test frameworks supported via [JUNit XML](./docs/junit.md)
17
17
  - πŸšΆβ€β™€οΈ Steps _(work in progress)_
@@ -128,6 +128,7 @@ Bring this reporter on CI and never lose test results again!
128
128
  - [Gitlab](./docs/pipes/gitlab.md)
129
129
  - [CSV](./docs/pipes/csv.md)
130
130
  - [HTML report](./docs/pipes/html.md)
131
+ - [Bitbucket](./docs/pipes/bitbucket.md)
131
132
  - πŸ““ [JUnit](./docs/junit.md)
132
133
  - πŸ—„οΈ [Artifacts](./docs/artifacts.md)
133
134
  - πŸ”‚ [Workflows](./docs/workflows.md)
@@ -129,8 +129,8 @@ function CodeceptReporter(config) {
129
129
  await Promise.all(reportTestPromises);
130
130
 
131
131
  if (upload.isArtifactsEnabled()) {
132
- uploadAttachments(client, videos, '🎞️ Uploading', 'video');
133
- uploadAttachments(client, traces, 'πŸ“ Uploading', 'trace');
132
+ await uploadAttachments(client, videos, '🎞️ Uploading', 'video');
133
+ await uploadAttachments(client, traces, 'πŸ“ Uploading', 'trace');
134
134
  }
135
135
 
136
136
  const status = failedTests.length === 0 ? STATUS.PASSED : STATUS.FAILED;
@@ -142,7 +142,6 @@ function CodeceptReporter(config) {
142
142
  if (id && failedTests.includes(id)) {
143
143
  failedTests = failedTests.filter(failed => id !== failed);
144
144
  }
145
- const testId = getTestomatIdFromTestTitle(`${title} ${tags?.join(' ')}`);
146
145
  const testObj = getTestAndMessage(title);
147
146
 
148
147
  const logs = getTestLogs(test);
@@ -152,11 +151,12 @@ function CodeceptReporter(config) {
152
151
 
153
152
  client.addTestRun(STATUS.PASSED, {
154
153
  ...stripExampleFromTitle(title),
154
+ rid: id,
155
155
  suite_title: test.parent && test.parent.title,
156
156
  message: testObj.message,
157
157
  time: getDuration(test),
158
158
  steps: global.testomatioDataStore.steps.join('\n') || null,
159
- test_id: testId,
159
+ test_id: getTestomatIdFromTestTitle(`${title} ${tags?.join(' ')}`),
160
160
  logs,
161
161
  manuallyAttachedArtifacts,
162
162
  meta: keyValues,
@@ -179,6 +179,7 @@ function CodeceptReporter(config) {
179
179
  const testId = getTestomatIdFromTestTitle(`${title} ${tags?.join(' ')}`);
180
180
 
181
181
  client.addTestRun(STATUS.FAILED, {
182
+ rid: id,
182
183
  ...stripExampleFromTitle(title),
183
184
  suite_title: suite.title,
184
185
  test_id: testId,
@@ -194,7 +195,6 @@ function CodeceptReporter(config) {
194
195
  if (test.err) error = test.err;
195
196
  const { id, tags, title, artifacts } = test;
196
197
  failedTests.push(id || title);
197
- let testId = getTestomatIdFromTestTitle(`${title} ${tags?.join(' ')}`);
198
198
  const testObj = getTestAndMessage(title);
199
199
 
200
200
  const files = [];
@@ -206,32 +206,27 @@ function CodeceptReporter(config) {
206
206
  const keyValues = services.keyValues.get(test.fullTitle());
207
207
  services.setContext(null);
208
208
 
209
- const reportTestPromise = client
210
- .addTestRun(STATUS.FAILED, {
211
- ...stripExampleFromTitle(title),
212
- test_id: testId,
213
- suite_title: test.parent && test.parent.title,
214
- error,
215
- message: testObj.message,
216
- time: getDuration(test),
217
- files,
218
- steps: global.testomatioDataStore?.steps?.join('\n') || null,
219
- logs,
220
- manuallyAttachedArtifacts,
221
- meta: keyValues,
222
- })
223
- .then(pipes => {
224
- testId = pipes.filter(p => p.pipe.includes('Testomatio'))[0]?.result?.data?.test_id;
225
-
226
- debug('artifacts', artifacts);
227
-
228
- for (const aid in artifacts) {
229
- if (aid.startsWith('video')) videos.push({ testId, title, path: artifacts[aid], type: 'video/webm' });
230
- if (aid.startsWith('trace')) traces.push({ testId, title, path: artifacts[aid], type: 'application/zip' });
231
- }
232
- });
209
+ client.addTestRun(STATUS.FAILED, {
210
+ ...stripExampleFromTitle(title),
211
+ rid: id,
212
+ test_id: getTestomatIdFromTestTitle(`${title} ${tags?.join(' ')}`),
213
+ suite_title: test.parent && test.parent.title,
214
+ error,
215
+ message: testObj.message,
216
+ time: getDuration(test),
217
+ files,
218
+ steps: global.testomatioDataStore?.steps?.join('\n') || null,
219
+ logs,
220
+ manuallyAttachedArtifacts,
221
+ meta: keyValues,
222
+ });
223
+
224
+ debug('artifacts', artifacts);
233
225
 
234
- reportTestPromises.push(reportTestPromise);
226
+ for (const aid in artifacts) {
227
+ if (aid.startsWith('video')) videos.push({ rid: id, title, path: artifacts[aid], type: 'video/webm' });
228
+ if (aid.startsWith('trace')) traces.push({ rid: id, title, path: artifacts[aid], type: 'application/zip' });
229
+ }
235
230
 
236
231
  // output.stop();
237
232
  });
@@ -240,11 +235,11 @@ function CodeceptReporter(config) {
240
235
  const { id, tags, title } = test;
241
236
  if (failedTests.includes(id || title)) return;
242
237
 
243
- const testId = getTestomatIdFromTestTitle(`${title} ${tags?.join(' ')}`);
244
238
  const testObj = getTestAndMessage(title);
245
239
  client.addTestRun(STATUS.SKIPPED, {
240
+ rid: id,
246
241
  ...stripExampleFromTitle(title),
247
- test_id: testId,
242
+ test_id: getTestomatIdFromTestTitle(`${title} ${tags?.join(' ')}`),
248
243
  suite_title: test.parent && test.parent.title,
249
244
  message: testObj.message,
250
245
  time: getDuration(test),
@@ -309,21 +304,21 @@ function CodeceptReporter(config) {
309
304
  }
310
305
 
311
306
  async function uploadAttachments(client, attachments, messagePrefix, attachmentType) {
312
- if (attachments.length > 0) {
313
- console.log(APP_PREFIX, `Attachments: ${messagePrefix} ${attachments.length} ${attachmentType}/-s ...`);
307
+ if (!attachments?.length) return;
314
308
 
315
- const promises = attachments.map(async attachment => {
316
- const { testId, title, path, type } = attachment;
317
- const file = { path, type, title };
318
- return client.addTestRun(undefined, {
319
- ...stripExampleFromTitle(title),
320
- test_id: testId,
321
- files: [file],
322
- });
309
+ console.log(APP_PREFIX, `Attachments: ${messagePrefix} ${attachments.length} ${attachmentType} ...`);
310
+
311
+ const promises = attachments.map(async attachment => {
312
+ const { rid, title, path, type } = attachment;
313
+ const file = { path, type, title };
314
+ return client.addTestRun(undefined, {
315
+ ...stripExampleFromTitle(title),
316
+ rid,
317
+ files: [file],
323
318
  });
319
+ });
324
320
 
325
- await Promise.all(promises);
326
- }
321
+ await Promise.all(promises);
327
322
  }
328
323
 
329
324
  function getTestAndMessage(title) {
@@ -2,6 +2,7 @@ const chalk = require('chalk');
2
2
  const crypto = require('crypto');
3
3
  const os = require('os');
4
4
  const path = require('path');
5
+ const { v4: uuidv4 } = require('uuid');
5
6
  const fs = require('fs');
6
7
  const { APP_PREFIX, STATUS: Status, TESTOMAT_TMP_STORAGE_DIR } = require('../constants');
7
8
  const TestomatioClient = require('../client');
@@ -38,16 +39,15 @@ class PlaywrightReporter {
38
39
  if (!this.client) return;
39
40
 
40
41
  const { title } = test;
41
-
42
- let testId = getTestomatIdFromTestTitle(`${title} ${test.tags?.join(' ')}`);
43
-
44
42
  const { error, duration } = result;
45
-
46
43
  const suite_title = test.parent ? test.parent?.title : path.basename(test?.location?.file);
47
44
 
48
45
  const steps = [];
49
46
  for (const step of result.steps) {
50
- appendStep(step, steps);
47
+ const appendedStep = appendStep(step);
48
+ if (appendedStep) {
49
+ steps.push(appendedStep);
50
+ }
51
51
  }
52
52
 
53
53
  const fullTestTitle = getTestContextName(test);
@@ -57,33 +57,30 @@ class PlaywrightReporter {
57
57
  }
58
58
  const manuallyAttachedArtifacts = services.artifacts.get(fullTestTitle);
59
59
  const keyValues = services.keyValues.get(fullTestTitle);
60
+ const rid = test.id || test.testId || uuidv4();
61
+
62
+ const reportTestPromise = this.client.addTestRun(checkStatus(result.status), {
63
+ rid,
64
+ error,
65
+ test_id: getTestomatIdFromTestTitle(`${title} ${test.tags?.join(' ')}`),
66
+ suite_title,
67
+ title,
68
+ steps: steps.length ? steps : undefined,
69
+ time: duration,
70
+ logs,
71
+ manuallyAttachedArtifacts,
72
+ meta: keyValues,
73
+ file: test.location?.file,
74
+ });
60
75
 
61
- const reportTestPromise = this.client
62
- .addTestRun(checkStatus(result.status), {
63
- error,
64
- test_id: testId,
65
- suite_title,
66
- title,
67
- steps: steps.join('\n'),
68
- time: duration,
69
- logs,
70
- manuallyAttachedArtifacts,
71
- meta: keyValues,
72
- file: test.location?.file,
73
- })
74
- .then(pipes => {
75
- testId = pipes?.filter(p => p.pipe.includes('Testomatio'))[0]?.result?.data?.test_id;
76
-
77
- this.uploads.push({
78
- testId,
79
- title,
80
- suite_title,
81
- files: result.attachments.filter(a => a.body || a.path),
82
- file: test.location?.file,
83
- });
84
- // remove empty uploads
85
- this.uploads = this.uploads.filter(upload => upload.files.length);
86
- });
76
+ this.uploads.push({
77
+ rid,
78
+ title: test.title,
79
+ files: result.attachments.filter(a => a.body || a.path),
80
+ file: test.location?.file,
81
+ });
82
+ // remove empty uploads
83
+ this.uploads = this.uploads.filter(upload => upload.files.length);
87
84
 
88
85
  reportTestPromises.push(reportTestPromise);
89
86
  }
@@ -115,7 +112,7 @@ class PlaywrightReporter {
115
112
  const promises = [];
116
113
 
117
114
  for (const upload of this.uploads) {
118
- const { title, testId, suite_title } = upload;
115
+ const { rid, file, title } = upload;
119
116
 
120
117
  const files = upload.files.map(attachment => ({
121
118
  path: this.#getArtifactPath(attachment),
@@ -125,11 +122,10 @@ class PlaywrightReporter {
125
122
 
126
123
  promises.push(
127
124
  this.client.addTestRun(undefined, {
128
- test_id: testId,
125
+ rid,
129
126
  title,
130
- suite_title,
131
127
  files,
132
- file: upload.file,
128
+ file,
133
129
  }),
134
130
  );
135
131
  }
@@ -150,18 +146,47 @@ function checkStatus(status) {
150
146
  );
151
147
  }
152
148
 
153
- function appendStep(step, steps = [], shift = 0) {
154
- const prefix = ' '.repeat(shift);
155
-
156
- if (step.error) {
157
- steps.push(`${prefix}${chalk.red(step.title)} ${chalk.gray(`${step.duration}ms`)}`);
158
- } else {
159
- steps.push(`${prefix}${step.title} ${chalk.gray(`${step.duration}ms`)}`);
149
+ function appendStep(step, shift = 0) {
150
+ // nesting too deep, ignore those steps
151
+ if (shift >= 10) return;
152
+
153
+ let newCategory = step.category;
154
+ switch (newCategory) {
155
+ case 'test.step':
156
+ newCategory = 'user';
157
+ break;
158
+ case 'hook':
159
+ newCategory = 'hook';
160
+ break;
161
+ case 'attach':
162
+ return null; // Skip steps with category 'attach'
163
+ default:
164
+ newCategory = 'framework';
160
165
  }
161
166
 
167
+ const formattedSteps = [];
162
168
  for (const child of step.steps || []) {
163
- appendStep(child, steps, shift + 2);
169
+ const appendedChild = appendStep(child, shift + 2);
170
+ if (appendedChild) {
171
+ formattedSteps.push(appendedChild);
172
+ }
173
+ }
174
+
175
+ const resultStep = {
176
+ category: newCategory,
177
+ title: step.title,
178
+ duration: step.duration,
179
+ };
180
+
181
+ if (formattedSteps.length) {
182
+ resultStep.steps = formattedSteps.filter(s => !!s);
164
183
  }
184
+
185
+ if (step.error !== undefined) {
186
+ resultStep.error = step.error;
187
+ }
188
+
189
+ return resultStep;
165
190
  }
166
191
 
167
192
  function tmpFile(prefix = 'tmp.') {
@@ -44,15 +44,10 @@ program
44
44
 
45
45
  let timeoutTimer;
46
46
  if (opts.timelimit) {
47
- timeoutTimer = setTimeout(
48
- () => {
49
- console.log(
50
- `⚠️ Reached timeout of ${opts.timelimit}s. Exiting... (Exit code is 0 to not fail the pipeline)`,
51
- );
52
- process.exit(0);
53
- },
54
- parseInt(opts.timelimit, 10) * 1000,
55
- );
47
+ timeoutTimer = setTimeout(() => {
48
+ console.log(`⚠️ Reached timeout of ${opts.timelimit}s. Exiting... (Exit code is 0 to not fail the pipeline)`);
49
+ process.exit(0);
50
+ }, parseInt(opts.timelimit, 10) * 1000);
56
51
  }
57
52
 
58
53
  try {
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const { spawn } = require('child_process');
2
+ const spawn = require('cross-spawn');
3
3
  const program = require('commander');
4
4
  const chalk = require('chalk');
5
5
  const TestomatClient = require('../client');
@@ -45,7 +45,7 @@ program
45
45
 
46
46
  const client = new TestomatClient({ apiKey });
47
47
 
48
- client.updateRunStatus(STATUS.FINISHED, true).then(() => {
48
+ client.updateRunStatus(STATUS.FINISHED).then(() => {
49
49
  console.log(chalk.yellow(`Run ${process.env.TESTOMATIO_RUN} was finished`));
50
50
  process.exit(0);
51
51
  });
package/lib/client.js CHANGED
@@ -137,6 +137,7 @@ class Client {
137
137
  * @type {TestData}
138
138
  */
139
139
  const {
140
+ rid,
140
141
  error = null,
141
142
  time = 0,
142
143
  example = null,
@@ -187,6 +188,7 @@ class Client {
187
188
  this.totalUploaded += artifacts.length;
188
189
 
189
190
  const data = {
191
+ rid,
190
192
  files,
191
193
  steps,
192
194
  status,
@@ -225,7 +227,7 @@ class Client {
225
227
  /**
226
228
  *
227
229
  * Updates the status of the current test run and finishes the run.
228
- * @param {'passed' | 'failed' | 'finished'} status - The status of the current test run.
230
+ * @param {'passed' | 'failed' | 'skipped' | 'finished'} status - The status of the current test run.
229
231
  * Must be one of "passed", "failed", or "finished"
230
232
  * @param {boolean} [isParallel] - Whether the current test run was executed in parallel with other tests.
231
233
  * @returns {Promise<any>} - A Promise that resolves when finishes the run.
@@ -274,9 +276,15 @@ class Client {
274
276
  */
275
277
  formatLogs({ error, steps, logs }) {
276
278
  error = error?.trim();
277
- steps = steps?.trim();
278
279
  logs = logs?.trim();
279
280
 
281
+ if (Array.isArray(steps)) {
282
+ steps = steps
283
+ .map(step => formatStep(step))
284
+ .flat()
285
+ .join('\n');
286
+ }
287
+
280
288
  let testLogs = '';
281
289
  if (steps) testLogs += `${chalk.bold.blue('################[ Steps ]################')}\n${steps}\n\n`;
282
290
  if (logs) testLogs += `${chalk.bold.gray('################[ Logs ]################')}\n${logs}\n\n`;
@@ -297,6 +305,7 @@ class Client {
297
305
  stack += `${message}\n`;
298
306
 
299
307
  if (error.diff) {
308
+ // diff for vitest
300
309
  stack += error.diff;
301
310
  stack += '\n\n';
302
311
  } else if (error.actual && error.expected && error.actual !== error.expected) {
@@ -339,6 +348,24 @@ function isNotInternalFrame(frame) {
339
348
  );
340
349
  }
341
350
 
351
+ function formatStep(step, shift = 0) {
352
+ const prefix = ' '.repeat(shift);
353
+
354
+ const lines = [];
355
+
356
+ if (step.error) {
357
+ lines.push(`${prefix}${chalk.red(step.title)} ${chalk.gray(`${step.duration}ms`)}`);
358
+ } else {
359
+ lines.push(`${prefix}${step.title} ${chalk.gray(`${step.duration}ms`)}`);
360
+ }
361
+
362
+ for (const child of step.steps || []) {
363
+ lines.push(...formatStep(child, shift + 2));
364
+ }
365
+
366
+ return lines;
367
+ }
368
+
342
369
  /**
343
370
  *
344
371
  * @param {TestData} testData
@@ -0,0 +1,254 @@
1
+ const debug = require('debug')('@testomatio/reporter:pipe:bitbucket');
2
+ const { default: axios } = require('axios');
3
+ const chalk = require('chalk');
4
+ const humanizeDuration = require('humanize-duration');
5
+ const merge = require('lodash.merge');
6
+ const path = require('path');
7
+ const { APP_PREFIX, testomatLogoURL } = require('../constants');
8
+ const { ansiRegExp, isSameTest } = require('../utils/utils');
9
+ const { statusEmoji, fullName } = require('../utils/pipe_utils');
10
+
11
+ //! BITBUCKET_ACCESS_TOKEN environment variable is required for this functionality to work
12
+ //! and your pipeline trigger should be a pull request
13
+
14
+ /**
15
+ * @class BitbucketPipe
16
+ * @typedef {import('../../types').Pipe} Pipe
17
+ * @typedef {import('../../types').TestData} TestData
18
+ */
19
+ class BitbucketPipe {
20
+ constructor(params, store = {}) {
21
+ this.isEnabled = false;
22
+ this.ENV = process.env;
23
+ this.store = store;
24
+ this.tests = [];
25
+ // Bitbucket PAT looks like bbpat-*****
26
+ this.token = params.BITBUCKET_ACCESS_TOKEN || process.env.BITBUCKET_ACCESS_TOKEN || this.ENV.BITBUCKET_ACCESS_TOKEN;
27
+ this.hiddenCommentData = `Testomat.io report: ${process.env.BITBUCKET_BRANCH || ''}`;
28
+
29
+ debug(
30
+ chalk.yellow('Bitbucket Pipe:'),
31
+ this.token ? 'TOKEN passed' : '*no token*',
32
+ `Project key: ${this.ENV.BITBUCKET_PROJECT_KEY}, Pull request ID: ${this.ENV.BITBUCKET_PR_ID}`,
33
+ );
34
+
35
+ if (!this.token) {
36
+ debug(`Hint: Bitbucket CI variables are unavailable for unprotected branches by default.`);
37
+ return;
38
+ }
39
+
40
+ this.isEnabled = true;
41
+
42
+ debug('Bitbucket Pipe: Enabled');
43
+ }
44
+
45
+ async cleanLog(log) {
46
+ const stripAnsi = (await import('strip-ansi')).default;
47
+ return stripAnsi(log);
48
+ }
49
+
50
+ // Prepare the run (if needed)
51
+ async prepareRun() {}
52
+
53
+ // Create a new run (if needed)
54
+ async createRun() {}
55
+
56
+ addTest(test) {
57
+ if (!this.isEnabled) return;
58
+
59
+ const index = this.tests.findIndex(t => isSameTest(t, test));
60
+ // Update if they were already added
61
+ if (index >= 0) {
62
+ this.tests[index] = merge(this.tests[index], test);
63
+ return;
64
+ }
65
+
66
+ this.tests.push(test);
67
+ }
68
+
69
+ async finishRun(runParams) {
70
+ if (!this.isEnabled) return;
71
+
72
+ if (runParams.tests) runParams.tests.forEach(t => this.addTest(t));
73
+
74
+ // Clean up the logs from ANSI codes
75
+ for (let i = 0; i < this.tests.length; i++) {
76
+ this.tests[i].message = await this.cleanLog(this.tests[i].message || '');
77
+ this.tests[i].stack = await this.cleanLog(this.tests[i].stack || '');
78
+ }
79
+
80
+ // Create a comment on Bitbucket
81
+ const passedCount = this.tests.filter(t => t.status === 'passed').length;
82
+ const failedCount = this.tests.filter(t => t.status === 'failed').length;
83
+ const skippedCount = this.tests.filter(t => t.status === 'skipped').length;
84
+
85
+ // Constructing the table
86
+ let summary = `${this.hiddenCommentData}
87
+
88
+ | ![Testomat.io Report](${testomatLogoURL}) | ${statusEmoji(
89
+ runParams.status,
90
+ )} ${runParams.status.toUpperCase()} ${statusEmoji(runParams.status)} |
91
+ | --- | --- |
92
+ | **Tests** | βœ”οΈ **${this.tests.length}** tests run |
93
+ | **Summary** | ${statusEmoji('failed')} **${failedCount}** failed; ${statusEmoji(
94
+ 'passed',
95
+ )} **${passedCount}** passed; **${statusEmoji('skipped')}** ${skippedCount} skipped |
96
+ | **Duration** | πŸ• **${humanizeDuration(
97
+ parseInt(
98
+ this.tests.reduce((a, t) => a + (t.run_time || 0), 0),
99
+ 10,
100
+ ),
101
+ {
102
+ maxDecimalPoints: 0,
103
+ },
104
+ )}** |
105
+ `;
106
+
107
+ if (this.ENV.BITBUCKET_BRANCH && this.ENV.BITBUCKET_COMMIT) {
108
+ // eslint-disable-next-line max-len
109
+ summary += `| **Job** | πŸ‘· [#${this.ENV.BITBUCKET_BUILD_NUMBER}](https://bitbucket.org/${this.ENV.BITBUCKET_REPO_FULL_NAME}/pipelines/results/${this.ENV.BITBUCKET_BUILD_NUMBER}") by commit: **${this.ENV.BITBUCKET_COMMIT}** |`;
110
+ }
111
+
112
+ const failures = this.tests
113
+ .filter(t => t.status === 'failed')
114
+ .slice(0, 20)
115
+ .map(t => {
116
+ let text = `${statusEmoji('failed')} ${fullName(t)}\n`;
117
+ if (t.message) {
118
+ text += `> ${t.message
119
+ .replace(/[^\x20-\x7E]/g, '')
120
+ .replace(ansiRegExp(), '')
121
+ .trim()}\n`;
122
+ }
123
+ if (t.stack) {
124
+ text += `\n\`\`\`diff\n${t.stack
125
+ .replace(ansiRegExp(), '')
126
+ .replace(
127
+ /^[\s\S]*################\[ Failure \]################/g,
128
+ '################[ Failure ]################',
129
+ )
130
+ .trim()}\n\`\`\`\n`;
131
+ }
132
+ if (t.artifacts && t.artifacts.length && !this.ENV.TESTOMATIO_PRIVATE_ARTIFACTS) {
133
+ t.artifacts
134
+ .filter(f => !!f)
135
+ .forEach(f => {
136
+ if (f.endsWith('.png')) {
137
+ text += `![Image](${f})\n`;
138
+ } else {
139
+ text += `[πŸ“„ ${path.basename(f)}](${f})\n`;
140
+ }
141
+ });
142
+ }
143
+ text += `\n---\n`;
144
+ return text;
145
+ });
146
+
147
+ let body = summary;
148
+
149
+ if (failures.length) {
150
+ body += `\nπŸŸ₯ **Failures (${failures.length})**\n\n* ${failures.join('\n* ')}\n`;
151
+ if (failures.length > 10) {
152
+ body += `\n> Notice: Only the first 10 failures are shown.`;
153
+ }
154
+ }
155
+
156
+ if (this.tests.length > 0) {
157
+ body += `\n\n**🐒 Slowest Tests**\n\n`;
158
+ body += this.tests
159
+ .sort((a, b) => b.run_time - a.run_time)
160
+ .slice(0, 5)
161
+ .map(t => `* **${fullName(t)}** (${humanizeDuration(parseFloat(t.run_time))})`)
162
+ .join('\n');
163
+ }
164
+
165
+ // Construct Bitbucket API URL for comments
166
+ // eslint-disable-next-line max-len
167
+ const commentsRequestURL = `https://api.bitbucket.org/2.0/repositories/${this.ENV.BITBUCKET_WORKSPACE}/${this.ENV.BITBUCKET_REPO_SLUG}/pullrequests/${this.ENV.BITBUCKET_PR_ID}/comments`;
168
+
169
+ // Delete previous report
170
+ await deletePreviousReport(axios, commentsRequestURL, this.hiddenCommentData, this.token);
171
+
172
+ // Add current report
173
+ debug(`Adding comment via URL: ${commentsRequestURL}`);
174
+ debug(`Final Bitbucket API call body: ${body}`);
175
+
176
+ try {
177
+ const addCommentResponse = await axios.post(
178
+ commentsRequestURL,
179
+ { content: { raw: body } },
180
+ {
181
+ headers: {
182
+ Authorization: `Bearer ${this.token}`,
183
+ 'Content-Type': 'application/json',
184
+ },
185
+ },
186
+ );
187
+
188
+ const commentID = addCommentResponse.data.id;
189
+ // eslint-disable-next-line max-len
190
+ const commentURL = `https://bitbucket.org/${this.ENV.BITBUCKET_WORKSPACE}/${this.ENV.BITBUCKET_REPO_SLUG}/pull-requests/${this.ENV.BITBUCKET_PR_ID}#comment-${commentID}`;
191
+
192
+ console.log(APP_PREFIX, chalk.yellow('Bitbucket'), `Report created: ${chalk.magenta(commentURL)}`);
193
+ } catch (err) {
194
+ console.error(
195
+ APP_PREFIX,
196
+ chalk.yellow('Bitbucket'),
197
+ `Couldn't create Bitbucket report\n${err}.
198
+ Request URL: ${commentsRequestURL}
199
+ Request data: ${body}`,
200
+ );
201
+ }
202
+ }
203
+
204
+ toString() {
205
+ return 'Bitbucket Reporter';
206
+ }
207
+
208
+ updateRun() {}
209
+ }
210
+
211
+ async function deletePreviousReport(axiosInstance, commentsRequestURL, hiddenCommentData, token) {
212
+ if (process.env.BITBUCKET_KEEP_OUTDATED_REPORTS) return;
213
+
214
+ // Get comments
215
+ let comments = [];
216
+
217
+ try {
218
+ const response = await axiosInstance.get(commentsRequestURL, {
219
+ headers: {
220
+ Authorization: `Bearer ${token}`,
221
+ 'Content-Type': 'application/json',
222
+ },
223
+ });
224
+ comments = response.data.values;
225
+ } catch (e) {
226
+ console.error('Error while attempting to retrieve comments on Bitbucket Pull Request:\n', e);
227
+ }
228
+
229
+ if (!comments.length) return;
230
+
231
+ for (const comment of comments) {
232
+ // If comment was left by the same workflow
233
+ if (comment.content.raw.includes(hiddenCommentData)) {
234
+ try {
235
+ // Delete previous comment
236
+ const deleteCommentURL = `${commentsRequestURL}/${comment.id}`;
237
+ await axiosInstance.delete(deleteCommentURL, {
238
+ headers: {
239
+ Authorization: `Bearer ${token}`,
240
+ 'Content-Type': 'application/json',
241
+ },
242
+ });
243
+ } catch (e) {
244
+ console.warn(`Can't delete previously added comment with testomat.io report. Ignored.`);
245
+ }
246
+ // Pass next env var if need to clear all previous reports;
247
+ // only the last one is removed by default
248
+ if (!process.env.BITBUCKET_REMOVE_ALL_OUTDATED_REPORTS) break;
249
+ // TODO: in case of many reports should implement pagination
250
+ }
251
+ }
252
+ }
253
+
254
+ module.exports = BitbucketPipe;
@@ -79,13 +79,13 @@ class GitLabPipe {
79
79
  let summary = `${this.hiddenCommentData}
80
80
 
81
81
  | [![Testomat.io Report](${testomatLogoURL})](https://testomat.io) | ${statusEmoji(
82
- runParams.status,
83
- )} ${runParams.status.toUpperCase()} ${statusEmoji(runParams.status)} |
82
+ runParams.status,
83
+ )} ${runParams.status.toUpperCase()} ${statusEmoji(runParams.status)} |
84
84
  | --- | --- |
85
85
  | Tests | βœ”οΈ **${this.tests.length}** tests run |
86
86
  | Summary | ${statusEmoji('failed')} **${failedCount}** failed; ${statusEmoji(
87
- 'passed',
88
- )} **${passedCount}** passed; **${statusEmoji('skipped')}** ${skippedCount} skipped |
87
+ 'passed',
88
+ )} **${passedCount}** passed; **${statusEmoji('skipped')}** ${skippedCount} skipped |
89
89
  | Duration | πŸ• **${humanizeDuration(
90
90
  parseInt(
91
91
  this.tests.reduce((a, t) => a + (t.run_time || 0), 0),
package/lib/pipe/index.js CHANGED
@@ -7,6 +7,7 @@ const GitHubPipe = require('./github');
7
7
  const GitLabPipe = require('./gitlab');
8
8
  const CsvPipe = require('./csv');
9
9
  const HtmlPipe = require('./html');
10
+ const BitbucketPipe = require('./bitbucket');
10
11
 
11
12
  function PipeFactory(params, opts) {
12
13
  const extraPipes = [];
@@ -45,6 +46,7 @@ function PipeFactory(params, opts) {
45
46
  new GitLabPipe(params, opts),
46
47
  new CsvPipe(params, opts),
47
48
  new HtmlPipe(params, opts),
49
+ new BitbucketPipe(params, opts),
48
50
  ...extraPipes,
49
51
  ];
50
52
 
@@ -42,10 +42,15 @@ class TestomatioPipe {
42
42
  return;
43
43
  }
44
44
  debug('Testomatio Pipe: Enabled');
45
+
46
+ const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY;
47
+ const proxy = proxyUrl ? new URL(proxyUrl) : null;
48
+
45
49
  this.parallel = params.parallel;
46
50
  this.store = store || {};
47
51
  this.title = params.title || process.env.TESTOMATIO_TITLE;
48
52
  this.sharedRun = !!process.env.TESTOMATIO_SHARED_RUN;
53
+ this.sharedRunTimeout = !!process.env.TESTOMATIO_SHARED_RUN_TIMEOUT;
49
54
  this.groupTitle = params.groupTitle || process.env.TESTOMATIO_RUNGROUP_TITLE;
50
55
  this.env = process.env.TESTOMATIO_ENV;
51
56
  this.label = process.env.TESTOMATIO_LABEL;
@@ -53,6 +58,11 @@ class TestomatioPipe {
53
58
  this.axios = axios.create({
54
59
  baseURL: `${this.url.trim()}`,
55
60
  timeout: AXIOS_TIMEOUT,
61
+ proxy: proxy ? {
62
+ host: proxy.hostname,
63
+ port: proxy.port,
64
+ protocol: proxy.protocol,
65
+ } : false,
56
66
  });
57
67
 
58
68
  // Pass the axios instance to the retry function
@@ -175,6 +185,7 @@ class TestomatioPipe {
175
185
  title: this.title,
176
186
  label: this.label,
177
187
  shared_run: this.sharedRun,
188
+ shared_run_timeout: this.sharedRunTimeout,
178
189
  }).filter(([, value]) => !!value),
179
190
  );
180
191
  debug('Run params', JSON.stringify(runParams, null, 2));
@@ -287,7 +298,7 @@ class TestomatioPipe {
287
298
  }
288
299
  });
289
300
  };
290
-
301
+
291
302
 
292
303
  /**
293
304
  * Uploads tests as a batch (multiple tests at once). Intended to be used with a setInterval
@@ -301,6 +312,8 @@ class TestomatioPipe {
301
312
  const testsToSend = this.batch.tests.splice(0);
302
313
  debug('πŸ“¨ Batch upload', testsToSend.length, 'tests');
303
314
 
315
+ testsToSend.forEach(debug);
316
+
304
317
  return this.axios
305
318
  .post(
306
319
  `/api/reporter/${this.runId}/testrun`,
@@ -339,6 +352,9 @@ class TestomatioPipe {
339
352
  addTest(data) {
340
353
  if (!this.isEnabled) return;
341
354
  if (!this.runId) return;
355
+
356
+ // add test ID + run ID
357
+ if (data.rid) data.rid = `${this.runId}-${data.rid}`;
342
358
  data.api_key = this.apiKey;
343
359
  data.create = this.createNewTests;
344
360
 
@@ -355,7 +371,7 @@ class TestomatioPipe {
355
371
  */
356
372
  async finishRun(params) {
357
373
  if (!this.isEnabled) return;
358
-
374
+
359
375
  await this.#batchUpload();
360
376
  if (this.batch.intervalFunction) clearInterval(this.batch.intervalFunction);
361
377
 
package/lib/xmlReader.js CHANGED
@@ -2,6 +2,7 @@ const debug = require('debug')('@testomatio/reporter:xml');
2
2
  const path = require('path');
3
3
  const chalk = require('chalk');
4
4
  const fs = require('fs');
5
+ const { randomUUID } = require('crypto');
5
6
  const { XMLParser } = require('fast-xml-parser');
6
7
  const { APP_PREFIX, STATUS } = require('./constants');
7
8
  const {
@@ -17,6 +18,8 @@ const pipesFactory = require('./pipe');
17
18
  const adapterFactory = require('./junit-adapter');
18
19
  const config = require('./config');
19
20
 
21
+ const ridRunId = randomUUID();
22
+
20
23
  const TESTOMATIO_URL = process.env.TESTOMATIO_URL || 'https://app.testomat.io';
21
24
  const { TESTOMATIO_RUNGROUP_TITLE, TESTOMATIO_TITLE, TESTOMATIO_ENV, TESTOMATIO_RUN } = process.env;
22
25
 
@@ -457,16 +460,20 @@ function reduceTestCases(prev, item) {
457
460
  if (testCaseItem.error && testCaseItem.error['#text']) stack = testCaseItem.error['#text'];
458
461
  if (!message) message = stack.trim().split('\n')[0];
459
462
 
463
+ const isParametrized = item.type === 'ParameterizedMethod';
464
+ const preferClassname = reduceOptions.preferClassname || isParametrized;
465
+
460
466
  // SpecFlow config
461
- let { title, tags } = fetchProperties(item.type === 'ParameterizedMethod' ? item : testCaseItem);
467
+ let { title, tags } = fetchProperties(isParametrized ? item : testCaseItem);
462
468
  let example = null;
469
+ const suiteTitle = preferClassname ? testCaseItem.classname : item.name || testCaseItem.classname;
463
470
 
464
471
  title ||= testCaseItem.name || testCaseItem.methodname || testCaseItem.classname;
465
472
  tags ||= [];
466
473
 
467
- const exampleMatches = title.match(/\((.*?)\)/);
474
+ const exampleMatches = testCaseItem.name?.match(/\S\((.*?)\)/);
468
475
  if (exampleMatches) {
469
- example = { ...exampleMatches[1].split(',').map(v => v.replace(/^['"]|['"]$/g, '')) };
476
+ example = { ...exampleMatches[1].split(',').map(v => v.trim().replace(/[^\w\s-]/g, '')) };
470
477
  title = title.replace(/\(.*?\)/, '').trim();
471
478
  }
472
479
 
@@ -480,12 +487,16 @@ function reduceTestCases(prev, item) {
480
487
  if ('failure' in testCaseItem || 'error' in testCaseItem) status = STATUS.FAILED;
481
488
  if ('skipped' in testCaseItem) status = STATUS.SKIPPED;
482
489
 
490
+ let rid = null;
491
+ if (testCaseItem.id) rid = `${ridRunId}-${testCaseItem.id}`;
492
+
483
493
  prev.push({
484
- create: true,
494
+ rid,
485
495
  file,
486
496
  stack,
487
497
  example,
488
498
  tags,
499
+ create: true,
489
500
  test_id: testId,
490
501
  message,
491
502
  line: testCaseItem.lineno,
@@ -493,7 +504,7 @@ function reduceTestCases(prev, item) {
493
504
  run_time: parseFloat(testCaseItem.time || testCaseItem.duration) * 1000,
494
505
  status,
495
506
  title,
496
- suite_title: reduceOptions.preferClassname ? testCaseItem.classname : item.name || testCaseItem.classname,
507
+ suite_title: suiteTitle,
497
508
  });
498
509
  });
499
510
  return prev;
@@ -509,9 +520,9 @@ function processTestSuite(testsuite) {
509
520
  suites = [testsuite];
510
521
  }
511
522
 
512
- const res = suites.flat().reduce(reduceTestCases, []);
523
+ const subSuites = suites.filter(s => s['test-suite'] && !testsuite['test-case']);
513
524
 
514
- return res;
525
+ return [...subSuites.map(s => processTestSuite(s['test-suite'])), ...suites.reduce(reduceTestCases, [])].flat();
515
526
  }
516
527
 
517
528
  function fetchProperties(item) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "1.5.0-beta-vitest",
3
+ "version": "1.5.0",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "main": "./lib/reporter.js",
6
6
  "typings": "typings/index.d.ts",
@@ -17,10 +17,11 @@
17
17
  "callsite-record": "^4.1.4",
18
18
  "chalk": "^4.1.0",
19
19
  "commander": "^4.1.1",
20
+ "cross-spawn": "^7.0.3",
20
21
  "csv-writer": "^1.6.0",
21
22
  "debug": "^4.3.4",
22
23
  "dotenv": "^16.0.1",
23
- "fast-xml-parser": "^4.0.8",
24
+ "fast-xml-parser": "^4.4.1",
24
25
  "file-url": "3.0.0",
25
26
  "glob": "^10.3",
26
27
  "handlebars": "^4.7.8",
@@ -32,6 +33,7 @@
32
33
  "lodash.merge": "^4.6.2",
33
34
  "minimatch": "^9.0.3",
34
35
  "promise-retry": "^2.0.1",
36
+ "strip-ansi": "^7.1.0",
35
37
  "uuid": "^9.0.0"
36
38
  },
37
39
  "files": [
@@ -63,7 +65,7 @@
63
65
  "@redocly/cli": "^1.0.0-beta.125",
64
66
  "@wdio/reporter": "^7.16.13",
65
67
  "chai": "^4.3.6",
66
- "codeceptjs": "latest",
68
+ "codeceptjs": "^3.5.11",
67
69
  "cucumber": "^6.0.7",
68
70
  "eslint": "^8.7.0",
69
71
  "eslint-config-airbnb-base": "^15.0.0",
@@ -76,7 +78,7 @@
76
78
  "mock-http-server": "^1.4.5",
77
79
  "pino": "^8.15.0",
78
80
  "prettier": "^3.2.5",
79
- "puppeteer": "^13.1.2"
81
+ "puppeteer": "^22.15.0"
80
82
  },
81
83
  "bin": {
82
84
  "report-xml": "./lib/bin/reportXml.js",