@testomatio/reporter 1.0.0 → 1.0.1-6.1

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/lib/logger.js ADDED
@@ -0,0 +1,319 @@
1
+ const chalk = require('chalk');
2
+ const debug = require('debug')('@testomatio/reporter:logger');
3
+ const { DataStorage } = require('./dataStorage');
4
+
5
+ const LOG_METHODS = ['assert', 'debug', 'error', 'info', 'log', 'trace', 'warn'];
6
+ const LEVELS = {
7
+ ALL: { severity: 1, color: '' },
8
+ VERBOSE: { severity: 3, color: 'grey' },
9
+ TRACE: { severity: 5, color: 'grey' },
10
+ DEBUG: { severity: 7, color: 'cyan' },
11
+ INFO: { severity: 9, color: 'black' },
12
+ LOG: { severity: 11, color: 'black' },
13
+ WARN: { severity: 13, color: 'yellow' },
14
+ ERROR: { severity: 15, color: 'red' },
15
+ };
16
+
17
+ // ! DON'T use console.log, console.warn, etc in this file, because it will lead to infinite loop
18
+ // use debug() instead
19
+
20
+ /**
21
+ * Logger allows to intercept logs from any logger (console.log, tracer, pino, etc)
22
+ * and save in the testomatio reporter.
23
+ * Supports different syntaxes to satisfy any user preferences.
24
+ */
25
+ class Logger {
26
+ // set default logger to be used in log, warn, error, etc methods
27
+ #originalUserLogger = { ...console };
28
+
29
+ #dataStorage = new DataStorage('log');
30
+
31
+ logLevel = process?.env?.LOG_LEVEL?.toUpperCase() || 'ALL';
32
+
33
+ constructor() {
34
+ // intercept console by default
35
+ this.intercept(console);
36
+
37
+ // singleton
38
+ if (!Logger.instance) {
39
+ Logger.instance = this;
40
+ }
41
+
42
+ // add beforeEach hook for mocha. it does not override existing hook, just add new one
43
+ try {
44
+ // @ts-ignore
45
+ if (!beforeEach) return;
46
+ // @ts-ignore-next-line
47
+ beforeEach(function () {
48
+ if (this.currentTest?.__mocha_id__) {
49
+ global.testTitle = this.currentTest.fullTitle();
50
+ }
51
+ });
52
+ } catch (e) {
53
+ // ignore
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Allows you to define a step inside a test. Step name is attached to the report and
59
+ * helps to understand the test flow.
60
+ * @param {*} strings
61
+ * @param {...any} values
62
+ */
63
+ step(strings, ...values) {
64
+ // get testId for mocha
65
+ const context = global.testTitle ?? null;
66
+
67
+ let logs = '';
68
+ for (let i = 0; i < strings.length; i++) {
69
+ logs += strings[i];
70
+ if (i < values.length) {
71
+ logs += values[i];
72
+ }
73
+ }
74
+ logs = chalk.blue(`> ${logs}`);
75
+ this.#dataStorage.putData(logs, context);
76
+ }
77
+
78
+ /**
79
+ *
80
+ * @param {*} context testId or test context from test runner
81
+ * @returns
82
+ */
83
+ getLogs(context) {
84
+ const logs = this.#dataStorage.getData(context);
85
+ return logs || '';
86
+ }
87
+
88
+ #stringifyLogs(...args) {
89
+ const logs = [];
90
+ // stringify everything except strings
91
+ for (const arg of args) {
92
+ // ignore empty strings
93
+ if (arg === '') continue;
94
+ if (typeof arg === 'string') {
95
+ logs.push(arg);
96
+ } else if (Array.isArray(arg)) {
97
+ logs.push(arg.join(' '));
98
+ } else {
99
+ try {
100
+ // eslint-disable-next-line no-unused-expressions
101
+ this.prettyObjects ? logs.push(JSON.stringify(arg, null, 2)) : logs.push(JSON.stringify(arg));
102
+ } catch (e) {
103
+ debug('Error while stringify object', e);
104
+ logs.push(arg);
105
+ }
106
+ }
107
+ }
108
+ return logs.join(' ');
109
+ }
110
+
111
+ /**
112
+ * Tagget template literal. Allows to use different syntaxes:
113
+ * 1. Tagget template: log`text ${someVar}`
114
+ * 2. Standard: log(`text ${someVar}`)
115
+ * 3. Standard with multiple arguments: log('text', someVar)
116
+ */
117
+ templateLiteralLog(strings, ...args) {
118
+ if (Array.isArray(strings)) strings = strings.filter(item => item !== '').map(item => item.trim());
119
+ if (Array.isArray(args)) args = args.filter(item => item !== '');
120
+ // entity which is used to define testId
121
+ let context = null;
122
+
123
+ // get testId for mocha
124
+ context = global.testTitle ?? null;
125
+
126
+ let logs;
127
+ // this block means tagged template is used (syntax like $`text ${someVar}`)
128
+ if (Array.isArray(strings) && strings.length === args.length + 1) {
129
+ logs = strings.reduce(
130
+ (result, current, index) =>
131
+ result +
132
+ current +
133
+ // strings are splitted by args when use tagged template, thus we add arg after each string
134
+ // it looks like: `string1 arg1 string2 arg2 string3`
135
+ (args[index] !== undefined // eslint-disable-line no-nested-ternary
136
+ ? typeof args[index] === 'string'
137
+ ? args[index] // add arg as it is
138
+ : this.#stringifyLogs(args[index]) // stringify arg
139
+ : ''),
140
+ // initial accumulator value
141
+ '',
142
+ );
143
+ } else {
144
+ // this block means arguments syntax is used (syntax like $('text', someVar))
145
+ // in this case strings represents just a first argument
146
+ logs = this.#stringifyLogs(strings, ...args);
147
+ }
148
+ this.#originalUserLogger.log(logs);
149
+ this.#dataStorage.putData(logs, context);
150
+ }
151
+
152
+ /**
153
+ * This function is a wrapper for each logging methods (log, warn, error etc) (not to repeat the same code)
154
+ * @param {*} argsArray
155
+ * @param {*} level
156
+ * @returns
157
+ */
158
+ #logWrapper(argsArray, level) {
159
+ if (!argsArray.length) return;
160
+
161
+ // entity which is used to define testId
162
+ let context = null;
163
+
164
+ // get context for mocha
165
+ context = global.testTitle ?? null;
166
+
167
+ const severity = LEVELS[level].severity;
168
+ if (severity < LEVELS[this.logLevel]?.severity) return;
169
+
170
+ const logs = this.#stringifyLogs(...argsArray);
171
+ const colorizedLogs = chalk[LEVELS[level].color](logs);
172
+ this.#dataStorage.putData(colorizedLogs, context);
173
+ try {
174
+ // level.toLowerCase() represents method name (log, warn, error, etc)
175
+ this.#originalUserLogger[level.toLowerCase()](colorizedLogs);
176
+ } catch (e) {
177
+ // method could be unexisting, ignore error
178
+ }
179
+ }
180
+
181
+ assert(...args) {
182
+ this.#logWrapper(args, 'ERROR');
183
+ }
184
+
185
+ debug(...args) {
186
+ this.#logWrapper(args, 'DEBUG');
187
+ }
188
+
189
+ error(...args) {
190
+ this.#logWrapper(args, 'ERROR');
191
+ }
192
+
193
+ info(...args) {
194
+ this.#logWrapper(args, 'INFO');
195
+ }
196
+
197
+ log(...args) {
198
+ this.#logWrapper(args, 'LOG');
199
+ }
200
+
201
+ trace(...args) {
202
+ this.#logWrapper(args, 'TRACE');
203
+ }
204
+
205
+ warn(...args) {
206
+ this.#logWrapper(args, 'WARN');
207
+ }
208
+
209
+ /**
210
+ * Intercepts user logger messages.
211
+ * When call this method, Logger start to control the user logger
212
+ * @param {*} userLogger
213
+ */
214
+ intercept(userLogger) {
215
+ /* prevent multiple console interceptions (cause of infinite loop)
216
+ actual only for "console", because its used as default output and is intercepted by default */
217
+ const isUserLoggerConsole = userLogger.toString?.().toLowerCase() === '[object console]';
218
+ if (isUserLoggerConsole && process.env.TESTOMATIO_LOGGER_CONSOLE_INTERCEPTED) {
219
+ debug(`Try to intercept console, but it is already intercepted`);
220
+ return;
221
+ }
222
+
223
+ process.env.TESTOMATIO_LOGGER_CONSOLE_INTERCEPTED = 'true';
224
+ debug(isUserLoggerConsole ? 'console intercepted' : 'User logger intercepted');
225
+
226
+ // override user logger (any, e.g. console) methods to intercept log messages
227
+ for (const method of LOG_METHODS) {
228
+ /*
229
+ its better to create method even if it does not exist in user logger;
230
+ on method invocation, we will store the data anyway and catch block will prevent potential errors
231
+ while trying to output the message to terminal
232
+ */
233
+ // if (!this._loggerToIntercept[method]) continue;
234
+ userLogger[method] = (...args) => this[method](...args);
235
+ }
236
+
237
+ /* Playwright
238
+ Playwright intercepts console messages by default. Thus when we intercept any logger and provide output by console,
239
+ messages are intercepted by Playwright.
240
+ */
241
+
242
+ /*
243
+ Initial idea was to intercept any logger (tracer, pino, etc),
244
+ intercept message and provide output by the same logger.
245
+ But reality brings some problems: the same messages are intercepted multiple times
246
+ (because of multiple loggers are created at the same terminal process).
247
+ Also its difficult to understand (actually did not find the way to do it) if logger was already intercepted or not.
248
+ Thus, decided to intercept only console by default and provide output by default console.
249
+ It means, if user uses his own logger, its messages will be intercepted,
250
+ but the output will be provided by console.
251
+ TODO: try to implement the providing output to terminal by user logger
252
+ */
253
+ }
254
+
255
+ /**
256
+ * Allows to configure logger. Make sure you do it before the logger usage in your code.
257
+ *
258
+ * @param {Object} [config={}] - The configuration object.
259
+ * @param {string} [config.logLevel] - The desired log level. Valid values are 'DEBUG', 'INFO', 'WARN', and 'ERROR'.
260
+ * @param {boolean} [config.prettyObjects] - Specifies whether to enable pretty printing of objects.
261
+ * @returns {void}
262
+ */
263
+ configure(config = {}) {
264
+ if (!config) return;
265
+ if (config.prettyObjects === false || config.prettyObjects === true) this.prettyObjects = config.prettyObjects;
266
+ if (config.logLevel) this.logLevel = config.logLevel.toUpperCase();
267
+ }
268
+ }
269
+
270
+ Logger.instance = null;
271
+
272
+ // module.exports.Logger = Logger;
273
+ const logger = new Logger();
274
+
275
+ module.exports = logger;
276
+
277
+ // TODO: parse passed arguments as {level: 'str', message: 'str'} because some loggers use such syntax;
278
+ // upd: did not face such loggers, but still could be useful
279
+
280
+ /* Cypress
281
+ There is no listener like "after:test" in cypress, only "after:spec" is available.
282
+ Thus, cannot separate logs even when I gather them (because I don't know when the test is done, just know about suite).
283
+ Also there is no easy way to access the message from cy.log() function.
284
+ (Using testomatio logger – logger.log() is not convenient because Cypress chains its commands,
285
+ thus such command will interrupt the chain.)
286
+
287
+ (Could not implement intercepting of cy.log('message'));
288
+ I found the only ability to get any logs using .task('log', 'message') (this is custom, not default cypress command)
289
+ and then intercept it with:
290
+ on('task', {
291
+ log (message) {
292
+ console.log(message)
293
+ return null
294
+ }
295
+ })
296
+
297
+ but:
298
+ 1) it does not solve problem with getting current running testId;
299
+ 2) leads to warning "Warning: Multiple attempts to register the following task(s):".)
300
+
301
+ My way to get test id:
302
+ add cypress command to save test title to file)):
303
+ Cypress.Commands.add('writeTestTitleToFile', () => {
304
+ const testTitle = cy.state('runnable').title;
305
+ cy.writeFile('testomatio_test_title', testTitle);
306
+ });
307
+
308
+ Finally, in the test it will look like:
309
+ cy
310
+ .writeTestTitleToFile() // <<<
311
+ .task('log', 'This is a log message from the test') // <<<
312
+
313
+ .get('element)
314
+ .type('text')
315
+ .click()
316
+
317
+
318
+ Parallelization in Cypress is only available if using Cypress Dashboard??
319
+ */
package/lib/pipe/csv.js CHANGED
@@ -4,7 +4,7 @@ const fs = require('fs');
4
4
  const csvWriter = require('csv-writer');
5
5
  const chalk = require('chalk');
6
6
  const merge = require('lodash.merge');
7
- const { isSameTest, getCurrentDateTime } = require('../util');
7
+ const { isSameTest, getCurrentDateTime } = require('../utils/utils');
8
8
  const { CSV_HEADERS } = require('../constants');
9
9
 
10
10
  /**
@@ -40,6 +40,9 @@ class CsvPipe {
40
40
  }
41
41
  }
42
42
 
43
+ // TODO: to using SET opts as argument => prepareRun(opts)
44
+ async prepareRun() {}
45
+
43
46
  async createRun() {
44
47
  // empty
45
48
  }
@@ -5,7 +5,8 @@ const humanizeDuration = require('humanize-duration');
5
5
  const merge = require('lodash.merge');
6
6
  const { Octokit } = require('@octokit/rest');
7
7
  const { APP_PREFIX } = require('../constants');
8
- const { ansiRegExp, isSameTest } = require('../util');
8
+ const { ansiRegExp, isSameTest } = require('../utils/utils');
9
+ const { statusEmoji, fullName } = require('../utils/pipe_utils');
9
10
 
10
11
  /**
11
12
  * @typedef {import('../../types').Pipe} Pipe
@@ -21,7 +22,8 @@ class GitHubPipe {
21
22
  this.token = params.GH_PAT || process.env.GH_PAT;
22
23
  this.ref = process.env.GITHUB_REF;
23
24
  this.repo = process.env.GITHUB_REPOSITORY;
24
- this.hiddenCommentData = `<!--- testomat.io report ${process.env.GITHUB_WORKFLOW || ''} -->`;
25
+ this.jobKey = `${process.env.GITHUB_WORKFLOW || ''} / ${process.env.GITHUB_JOB || ''}`;
26
+ this.hiddenCommentData = `<!--- testomat.io report ${this.jobKey} -->`;
25
27
 
26
28
  debug('GitHub Pipe: ', this.token ? 'TOKEN' : '*no token*', 'Ref:', this.ref, 'Repo:', this.repo);
27
29
 
@@ -36,6 +38,9 @@ class GitHubPipe {
36
38
  debug('GitHub Pipe: Enabled');
37
39
  }
38
40
 
41
+ // TODO: to using SET opts as argument => prepareRun(opts)
42
+ async prepareRun() {}
43
+
39
44
  async createRun() {}
40
45
 
41
46
  addTest(test) {
@@ -73,35 +78,32 @@ class GitHubPipe {
73
78
  let summary = `${this.hiddenCommentData}
74
79
 
75
80
  | [![Testomat.io Report](https://avatars.githubusercontent.com/u/59105116?s=36&v=4)](https://testomat.io) | ${
76
- statusEmoji(runParams.status,)} ${runParams.status.toUpperCase()} ${statusEmoji(runParams.status)} |
81
+ statusEmoji(runParams.status,)} ${`${process.env.GITHUB_JOB} ${runParams.status}`.toUpperCase()} |
77
82
  | --- | --- |
78
83
  | Tests | ✔️ **${this.tests.length}** tests run |
79
- | Summary | ${statusEmoji('failed')} **${failedCount}** failed; ${statusEmoji(
84
+ | Summary | ${failedCount ? `${statusEmoji('failed')} **${failedCount}** failed; ` : ''} ${statusEmoji(
80
85
  'passed',
81
86
  )} **${passedCount}** passed; **${statusEmoji('skipped')}** ${skippedCount} skipped |
82
- | Duration | 🕐 **${humanizeDuration(parseInt(this.tests.reduce((a, t) => a + (t.run_time || 0), 0), 10), {
83
- maxDecimalPoints: 0,
84
- })}** |
85
- `;
87
+ | Duration | 🕐 **${humanizeDuration(
88
+ parseInt(
89
+ this.tests.reduce((a, t) => a + (t.run_time || 0), 0),
90
+ 10,
91
+ ),
92
+ {
93
+ maxDecimalPoints: 0,
94
+ },
95
+ )}** |`;
96
+
86
97
  if (this.store.runUrl) {
87
- summary += `| Testomat.io Report | 📊 [Run #${this.store.runId}](${this.store.runUrl}) | `;
98
+ summary += `\n| Testomat.io Report | 📊 [Run #${this.store.runId}](${this.store.runUrl}) | `;
88
99
  }
89
100
  if (process.env.GITHUB_WORKFLOW) {
90
- summary += `| Workflow | 🗂️ ${process.env.GITHUB_WORKFLOW} | `;
101
+ summary += `\n| Job | 🗂️ [${this.jobKey}](${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${
102
+ this.repo
103
+ }/actions/runs/${process.env.GITHUB_RUN_ID}) | `;
91
104
  }
92
105
  if (process.env.RUNNER_OS) {
93
- summary += `| Operating System | 🖥️ \`${process.env.RUNNER_OS}\` ${process.env.RUNNER_ARCH || ''} | `;
94
- }
95
- if (process.env.GITHUB_HEAD_REF) {
96
- summary += `| Branch | 🌳 \`${process.env.GITHUB_HEAD_REF}\` | `;
97
- }
98
- if (process.env.GITHUB_RUN_ATTEMPT) {
99
- summary += `| Run Attempt | 🌒 \`${process.env.GITHUB_RUN_ATTEMPT}\` | `;
100
- }
101
- if (process.env.GITHUB_RUN_ID) {
102
- summary += `| Build Log | ✒️ ${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${
103
- this.repo
104
- }/actions/runs/${process.env.GITHUB_RUN_ID} | `;
106
+ summary += `\n| Operating System | 🖥️ \`${process.env.RUNNER_OS}\` ${process.env.RUNNER_ARCH || ''} | `;
105
107
  }
106
108
 
107
109
  const failures = this.tests
@@ -184,21 +186,6 @@ class GitHubPipe {
184
186
  }
185
187
  }
186
188
 
187
- function statusEmoji(status) {
188
- if (status === 'passed') return '🟢';
189
- if (status === 'failed') return '🔴';
190
- if (status === 'skipped') return '🟡';
191
- return '';
192
- }
193
-
194
- function fullName(t) {
195
- let line = '';
196
- if (t.suite_title) line = `${t.suite_title}: `;
197
- line += `**${t.title}**`;
198
- if (t.example) line += ` \`[${Object.values(t.example)}]\``;
199
- return line;
200
- }
201
-
202
189
  async function deletePreviousReport(octokit, owner, repo, issue, hiddenCommentData) {
203
190
  if (process.env.GH_KEEP_OUTDATED_REPORTS) return;
204
191
 
@@ -5,7 +5,8 @@ const humanizeDuration = require('humanize-duration');
5
5
  const merge = require('lodash.merge');
6
6
  const path = require('path');
7
7
  const { APP_PREFIX } = require('../constants');
8
- const { ansiRegExp, isSameTest } = require('../util');
8
+ const { ansiRegExp, isSameTest } = require('../utils/utils');
9
+ const { statusEmoji, fullName } = require('../utils/pipe_utils');
9
10
 
10
11
  //! GITLAB_PAT environment variable is required for this functionality to work
11
12
  //! and your pipeline trigger should be merge_request
@@ -46,6 +47,9 @@ class GitLabPipe {
46
47
  debug('GitLab Pipe: Enabled');
47
48
  }
48
49
 
50
+ // TODO: to using SET opts as argument => prepareRun(opts)
51
+ async prepareRun() {}
52
+
49
53
  async createRun() {}
50
54
 
51
55
  addTest(test) {
@@ -179,21 +183,6 @@ class GitLabPipe {
179
183
  updateRun() {}
180
184
  }
181
185
 
182
- function statusEmoji(status) {
183
- if (status === 'passed') return '🟢';
184
- if (status === 'failed') return '🔴';
185
- if (status === 'skipped') return '🟡';
186
- return '';
187
- }
188
-
189
- function fullName(t) {
190
- let line = '';
191
- if (t.suite_title) line = `${t.suite_title}: `;
192
- line += `**${t.title}**`;
193
- if (t.example) line += ` \`[${Object.values(t.example)}]\``;
194
- return line;
195
- }
196
-
197
186
  async function deletePreviousReport(axiosInstance, commentsRequestURL, hiddenCommentData, token) {
198
187
  if (process.env.GITLAB_KEEP_OUTDATED_REPORTS) return;
199
188