@testomatio/reporter 2.15.0-beta.1-json-output → 2.16.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.
@@ -34,7 +34,8 @@ const HOOK_EXECUTION_ORDER = {
34
34
  };
35
35
  // codeceptjs workers are self-contained
36
36
  data_storage_js_1.dataStorage.isFileStorage = false;
37
- const DATA_REGEXP = /[|\s]+?(\{".*\}|\[.*\])/;
37
+ // CodeceptJS appends the serialized data row of a data-driven test to its title
38
+ const DATA_REGEXP = / \| (\{.*\}|\[.*\]|null|"(?:\\.|[^"\\])*")((?:\s+@[a-zA-Z0-9-_]+)*)$/;
38
39
  if (MAJOR_VERSION < 3) {
39
40
  console.log('🔴 This reporter works with CodeceptJS 3+, please update your tests');
40
41
  }
@@ -259,7 +260,7 @@ function CodeceptReporter(config) {
259
260
  processArtifactsForUpload(artifacts, uid, title, videos, traces);
260
261
  });
261
262
  event.dispatcher.on(event.step.started, step => {
262
- const stepText = `${repeat(output.stepShift)} ${step.toCliStyled ? step.toCliStyled() : step.toString()}`;
263
+ const stepText = `${repeat(output.stepShift)} ${formatStepLog(step)}`;
263
264
  data_storage_js_1.dataStorage.putData('log', stepText);
264
265
  });
265
266
  event.dispatcher.on(event.step.finished, step => {
@@ -298,16 +299,31 @@ function stripExampleFromTitle(title) {
298
299
  const res = title.match(DATA_REGEXP);
299
300
  if (!res)
300
301
  return { title, example: null };
302
+ let example = null;
303
+ let exampleParsed = false;
301
304
  try {
302
- const example = JSON.parse(res[1]);
303
- title = title.replace(DATA_REGEXP, '').trim();
304
- return { title, example };
305
+ example = JSON.parse(res[1]);
306
+ exampleParsed = true;
305
307
  }
306
308
  catch (e) {
307
- // If JSON parsing fails, return title without example
308
- debug('Failed to parse example JSON:', res[1], e.message);
309
- return { title: title.replace(DATA_REGEXP, '').trim(), example: null };
309
+ try {
310
+ example = JSON.parse(res[1].slice(1, -1));
311
+ exampleParsed = true;
312
+ }
313
+ catch (e2) {
314
+ debug('Failed to parse example from title:', res[1]);
315
+ }
316
+ }
317
+ let baseTitle = title.slice(0, res.index).trim();
318
+ if (exampleParsed && baseTitle.includes('${current}')) {
319
+ baseTitle = baseTitle.replaceAll('${current}', formatExample(example));
310
320
  }
321
+ return { title: `${baseTitle}${res[2]}`, example };
322
+ }
323
+ function formatExample(example) {
324
+ if (example !== null && typeof example === 'object')
325
+ return JSON.stringify(example);
326
+ return String(example);
311
327
  }
312
328
  function stripTagsFromTitle(title) {
313
329
  // Remove @tags from the end of titles (e.g., "Hooks Test Suite @hooks" -> "Hooks Test Suite")
@@ -316,6 +332,16 @@ function stripTagsFromTitle(title) {
316
332
  function repeat(num) {
317
333
  return ''.padStart(num, ' ');
318
334
  }
335
+ function formatStepLog(step) {
336
+ if (typeof step?.toCliStyled === 'function')
337
+ return step.toCliStyled();
338
+ if (typeof step?.toString === 'function' && step.toString !== Object.prototype.toString) {
339
+ return step.toString();
340
+ }
341
+ const title = step?.title || '';
342
+ const args = Array.isArray(step?.args) ? step.args.join(', ') : '';
343
+ return `${title}${args ? ` ${args}` : ''}`.trim();
344
+ }
319
345
  // Helper functions for cleaner event handling
320
346
  function initializeTestDataStore() {
321
347
  if (!global.testomatioDataStore)
package/lib/bin/cli.js CHANGED
@@ -45,6 +45,9 @@ program
45
45
  if (subOpts.filterList || subOpts.format) {
46
46
  process.env.TESTOMATIO_LOG_STDERR = '1';
47
47
  process.env.TESTOMATIO_LOG_LEVEL ||= 'WARN';
48
+ // with --format json the logs are machine-readable too: one JSON object per line on stderr
49
+ if (subOpts.format === 'json')
50
+ process.env.TESTOMATIO_LOG_JSON = '1';
48
51
  }
49
52
  else {
50
53
  console.log(picocolors_1.default.cyan(picocolors_1.default.bold(` 🤩 Testomat.io Reporter v${version}`)));
@@ -80,7 +83,7 @@ program
80
83
  };
81
84
  }
82
85
  await client.createRun(createRunParams);
83
- const runId = client.pipeStore.runId || process.env.runId;
86
+ const runId = client.pipeStore.runId;
84
87
  if (!runId) {
85
88
  log_js_1.log.error(picocolors_1.default.red('Failed to create run on Testomat.io.'));
86
89
  process.exit(1);
@@ -90,7 +93,7 @@ program
90
93
  const plannedTests = (client.pipeStore.preparedTestIds || []).map(id => ({ test_id: id, title: id }));
91
94
  await client.updateRunStatus('pending', { tests: plannedTests });
92
95
  // stdout carries ONLY the run data so it can be captured: RUN_ID=$(reporter start)
93
- console.log((0, pipe_utils_js_1.formatRunOutput)({ ...client.pipeStore, runId }, opts.format));
96
+ console.log((0, pipe_utils_js_1.formatRunOutput)(client.pipeStore, opts.format));
94
97
  process.exit(0);
95
98
  });
96
99
  program
@@ -226,16 +229,19 @@ program
226
229
  }
227
230
  if (apiKey) {
228
231
  await client.createRun(createRunParams);
229
- const runId = process.env.TESTOMATIO_RUN || process.env.runId;
232
+ const runId = client.pipeStore.runId;
233
+ if (!runId) {
234
+ log_js_1.log.error(picocolors_1.default.red('Failed to create run on Testomat.io.'));
235
+ process.exit(1);
236
+ }
230
237
  if (client.pipeStore.runUrl)
231
238
  log_js_1.log.info(`📊 Report URL: ${picocolors_1.default.magenta(client.pipeStore.runUrl)}`);
232
239
  if (opts.kind !== 'manual') {
233
240
  log_js_1.log.info(`No command passed, so you need to run tests yourself:`);
234
241
  log_js_1.log.info(`TESTOMATIO_RUN=${runId} <command>`);
235
242
  }
236
- const runOutput = (0, pipe_utils_js_1.formatRunOutput)({ ...client.pipeStore, runId }, opts.format);
237
- if (opts.format && runOutput)
238
- console.log(runOutput);
243
+ if (opts.format)
244
+ console.log((0, pipe_utils_js_1.formatRunOutput)(client.pipeStore, opts.format));
239
245
  }
240
246
  else {
241
247
  log_js_1.log.info('⚠️ No API key provided. Cannot create run without TESTOMATIO key.');
package/lib/client.js CHANGED
@@ -175,7 +175,7 @@ class Client {
175
175
  }
176
176
  }
177
177
  catch (err) {
178
- console.error(constants_js_1.APP_PREFIX, 'Error in uploadStepArtifacts for testRid', testRid, ':', err);
178
+ log_js_1.log.error('Error in uploadStepArtifacts for testRid', testRid, ':', err.message || err);
179
179
  throw err;
180
180
  }
181
181
  }
@@ -209,7 +209,7 @@ class Client {
209
209
  await this.uploadStepArtifacts(steps, rid);
210
210
  }
211
211
  catch (err) {
212
- console.log(constants_js_1.APP_PREFIX, 'Failed to upload step artifacts:', err);
212
+ log_js_1.log.error('Failed to upload step artifacts:', err.message || err);
213
213
  }
214
214
  const uploadedFiles = [];
215
215
  const stackArtifactsEnabled = (0, utils_js_1.transformEnvVarToBoolean)(process.env.TESTOMATIO_STACK_ARTIFACTS);
@@ -357,7 +357,7 @@ class Client {
357
357
  }));
358
358
  const pathPadding = Math.max(...failedUploads.map(upload => upload.relativePath.length)) + 1;
359
359
  failedUploads.forEach(upload => {
360
- console.log(` ${picocolors_1.default.gray('|')} 🔴 ${upload.relativePath.padEnd(pathPadding)} ${picocolors_1.default.gray(`| ${upload.sizePretty.padStart(filesizeStrMaxLength)} |`)}`);
360
+ log_js_1.log.info(` ${picocolors_1.default.gray('|')} 🔴 ${upload.relativePath.padEnd(pathPadding)} ${picocolors_1.default.gray(`| ${upload.sizePretty.padStart(filesizeStrMaxLength)} |`)}`);
361
361
  });
362
362
  }
363
363
  if (this.uploader.skippedUploads.length) {
@@ -368,7 +368,7 @@ class Client {
368
368
  }));
369
369
  const pathPadding = Math.max(...skippedUploads.map(upload => upload.relativePath.length)) + 1;
370
370
  skippedUploads.forEach(upload => {
371
- console.log(` ${picocolors_1.default.gray('|')} 🟡 ${upload.relativePath.padEnd(pathPadding)} ${picocolors_1.default.gray(`| ${upload.sizePretty.padStart(filesizeStrMaxLength)} |`)}`);
371
+ log_js_1.log.info(` ${picocolors_1.default.gray('|')} 🟡 ${upload.relativePath.padEnd(pathPadding)} ${picocolors_1.default.gray(`| ${upload.sizePretty.padStart(filesizeStrMaxLength)} |`)}`);
372
372
  });
373
373
  }
374
374
  if (this.uploader.skippedUploads.length || this.uploader.failedUploads.length) {
package/lib/pipe/csv.js CHANGED
@@ -11,6 +11,7 @@ const picocolors_1 = __importDefault(require("picocolors"));
11
11
  const lodash_merge_1 = __importDefault(require("lodash.merge"));
12
12
  const utils_js_1 = require("../utils/utils.js");
13
13
  const constants_js_1 = require("../constants.js");
14
+ const log_js_1 = require("../utils/log.js");
14
15
  const debug = (0, debug_1.default)('@testomatio/reporter:pipe:csv');
15
16
  /**
16
17
  * @typedef {import('../../types/types.js').Pipe} Pipe
@@ -68,10 +69,10 @@ class CsvPipe {
68
69
  // First, we check whether the export directory exists: if yes - OK, no - create it.
69
70
  this.checkExportDir();
70
71
  if (!this.outputFile) {
71
- console.log(picocolors_1.default.yellow(`⚠️ CSV file is not set, ignoring`));
72
+ log_js_1.log.warn(picocolors_1.default.yellow(`⚠️ CSV file is not set, ignoring`));
72
73
  return;
73
74
  }
74
- console.log(picocolors_1.default.yellow(`⏳ The test results will be added to the csv. It will take some time...`));
75
+ log_js_1.log.info(picocolors_1.default.yellow(`⏳ The test results will be added to the csv. It will take some time...`));
75
76
  try {
76
77
  // Create csv writer object
77
78
  const writer = (0, csv_writer_1.createObjectCsvWriter)({
@@ -82,7 +83,7 @@ class CsvPipe {
82
83
  return await writer.writeRecords(data);
83
84
  }
84
85
  catch (e) {
85
- console.log('Unknown csv error: ', e);
86
+ log_js_1.log.error('Unknown csv error: ', e);
86
87
  }
87
88
  }
88
89
  /**
@@ -122,7 +123,7 @@ class CsvPipe {
122
123
  // Save results based on the default headers
123
124
  if (this.isEnabled) {
124
125
  await this.saveToCsv(this.results, constants_js_1.CSV_HEADERS);
125
- console.log(picocolors_1.default.green(`🗃️ Recording completed! You can check the result in file = ${this.outputFile}`));
126
+ log_js_1.log.info(picocolors_1.default.green(`🗃️ Recording completed! You can check the result in file = ${this.outputFile}`));
126
127
  }
127
128
  }
128
129
  toString() {
package/lib/pipe/html.js CHANGED
@@ -13,6 +13,7 @@ const marked_1 = require("marked");
13
13
  const file_url_1 = __importDefault(require("file-url"));
14
14
  const utils_js_1 = require("../utils/utils.js");
15
15
  const constants_js_1 = require("../constants.js");
16
+ const log_js_1 = require("../utils/log.js");
16
17
  const node_url_1 = require("node:url");
17
18
  const debug = (0, debug_1.default)('@testomatio/reporter:pipe:html');
18
19
  const HTML_ARTIFACTS_DIR = 'artifacts';
@@ -120,12 +121,12 @@ class HtmlPipe {
120
121
  const { runParams, tests, outputPath, templatePath, warningMsg: msg } = opts;
121
122
  debug('HTML tests data:', tests);
122
123
  if (!outputPath) {
123
- console.log(picocolors_1.default.yellow(`🚨 HTML export path is not set, ignoring...`));
124
+ log_js_1.log.warn(picocolors_1.default.yellow(`🚨 HTML export path is not set, ignoring...`));
124
125
  return;
125
126
  }
126
- console.log(picocolors_1.default.yellow(`⏳ The test results will be added to the HTML report. It will take some time...`));
127
+ log_js_1.log.info(picocolors_1.default.yellow(`⏳ The test results will be added to the HTML report. It will take some time...`));
127
128
  if (msg) {
128
- console.log(picocolors_1.default.blue(msg));
129
+ log_js_1.log.info(picocolors_1.default.blue(msg));
129
130
  }
130
131
  const aggregatedTests = aggregateTestRetries(tests);
131
132
  const copyLocalArtifacts = resolveHtmlCopyArtifacts(this.htmlCopyArtifacts);
@@ -237,10 +238,10 @@ class HtmlPipe {
237
238
  // Convert the file path to a file URL
238
239
  const fileUrlPath = (0, file_url_1.default)(absolutePath, { resolve: true });
239
240
  debug('HTML tests data:', fileUrlPath);
240
- console.log(picocolors_1.default.green(`📊 The HTML report was successfully generated. Full filepath: ${fileUrlPath}`));
241
+ log_js_1.log.info(picocolors_1.default.green(`📊 The HTML report was successfully generated. Full filepath: ${fileUrlPath}`));
241
242
  }
242
243
  else {
243
- console.log(picocolors_1.default.red(`🚨 Failed to generate the HTML report.`));
244
+ log_js_1.log.error(picocolors_1.default.red(`🚨 Failed to generate the HTML report.`));
244
245
  }
245
246
  }
246
247
  /**
@@ -251,7 +252,7 @@ class HtmlPipe {
251
252
  */
252
253
  #generateHTMLReport(data, templatePath = '') {
253
254
  if (!templatePath) {
254
- console.log(picocolors_1.default.red(`🚨 HTML template not found. Report generation is impossible!`));
255
+ log_js_1.log.error(picocolors_1.default.red(`🚨 HTML template not found. Report generation is impossible!`));
255
256
  return;
256
257
  }
257
258
  const templateSource = fs_1.default.readFileSync(templatePath, 'utf8');
@@ -261,8 +262,8 @@ class HtmlPipe {
261
262
  return template(data);
262
263
  }
263
264
  catch (e) {
264
- console.log(picocolors_1.default.red('❌ Oops! An unknown error occurred when generating an HTML report'));
265
- console.log(picocolors_1.default.red(e));
265
+ log_js_1.log.error(picocolors_1.default.red('❌ Oops! An unknown error occurred when generating an HTML report'));
266
+ log_js_1.log.error(picocolors_1.default.red(e));
266
267
  }
267
268
  }
268
269
  #loadReportHelpers() {
@@ -36,6 +36,7 @@ declare class TestomatioPipe implements Pipe {
36
36
  store: any;
37
37
  title: any;
38
38
  sharedRun: boolean;
39
+ sharedRunShards: number;
39
40
  sharedRunTimeout: number;
40
41
  groupTitle: any;
41
42
  env: string;
@@ -10,6 +10,7 @@ const json_cycle_1 = __importDefault(require("json-cycle"));
10
10
  const constants_js_1 = require("../constants.js");
11
11
  const utils_js_1 = require("../utils/utils.js");
12
12
  const pipe_utils_js_1 = require("../utils/pipe_utils.js");
13
+ const hide_token_js_1 = require("../utils/hide_token.js");
13
14
  const config_js_1 = require("../config.js");
14
15
  const log_js_1 = require("../utils/log.js");
15
16
  const debug = (0, debug_1.default)('@testomatio/reporter:pipe:testomatio');
@@ -75,11 +76,14 @@ class TestomatioPipe {
75
76
  this.store = store || {};
76
77
  this.title = params.title || process.env.TESTOMATIO_TITLE;
77
78
  this.sharedRun = !!process.env.TESTOMATIO_SHARED_RUN;
79
+ this.sharedRunShards = process.env.TESTOMATIO_SHARDS
80
+ ? parseInt(process.env.TESTOMATIO_SHARDS, 10) || undefined
81
+ : undefined;
78
82
  this.sharedRunTimeout = process.env.TESTOMATIO_SHARED_RUN_TIMEOUT
79
83
  ? parseInt(process.env.TESTOMATIO_SHARED_RUN_TIMEOUT, 10)
80
84
  : undefined;
81
- if (this.sharedRunTimeout && !this.sharedRun) {
82
- debug('Auto-enabling sharedRun because sharedRunTimeout is set');
85
+ if ((this.sharedRunTimeout || this.sharedRunShards) && !this.sharedRun) {
86
+ debug('Auto-enabling sharedRun because sharedRunTimeout or sharedRunShards is set');
83
87
  this.sharedRun = true;
84
88
  }
85
89
  if (!this.title && (this.sharedRun || this.sharedRunTimeout)) {
@@ -289,6 +293,7 @@ class TestomatioPipe {
289
293
  label: this.label,
290
294
  shared_run: this.sharedRun,
291
295
  shared_run_timeout: this.sharedRunTimeout,
296
+ shared_run_shards: this.sharedRunShards,
292
297
  kind: params.kind,
293
298
  status: params.status,
294
299
  configuration,
@@ -346,14 +351,14 @@ class TestomatioPipe {
346
351
  }
347
352
  catch (err) {
348
353
  if (!this.apiKey)
349
- console.error('Testomat.io API key is not set');
354
+ log_js_1.log.error('Testomat.io API key is not set');
350
355
  const errorText = err.response?.data?.message || err.message;
351
356
  debug('Error creating run', err);
352
- console.log(constants_js_1.APP_PREFIX, errorText || err);
357
+ log_js_1.log.error(errorText || err);
353
358
  if (err.response?.status === 403)
354
359
  this.#disablePipe();
355
360
  this.#logFailedResponse(err);
356
- console.error(constants_js_1.APP_PREFIX, 'Error creating Testomat.io report (see details above), please check if your API key is valid. Skipping report');
361
+ log_js_1.log.error('Error creating Testomat.io report (see details above), please check if your API key is valid. Skipping report');
357
362
  printCreateIssue();
358
363
  }
359
364
  debug('"createRun" function finished');
@@ -370,7 +375,7 @@ class TestomatioPipe {
370
375
  this.reportingCanceledDueToReqFailures = true;
371
376
  let errorMessage = `⚠️ ${process.env.TESTOMATIO_MAX_REQUEST_FAILURES}`;
372
377
  errorMessage += ' requests were failed, reporting to Testomat aborted.';
373
- console.warn(`${constants_js_1.APP_PREFIX} ${picocolors_1.default.yellow(errorMessage)}`);
378
+ log_js_1.log.warn(picocolors_1.default.yellow(errorMessage));
374
379
  }
375
380
  return cancelReporting;
376
381
  }
@@ -513,7 +518,7 @@ class TestomatioPipe {
513
518
  debug('Finishing run...');
514
519
  if (this.reportingCanceledDueToReqFailures) {
515
520
  const errorMessage = picocolors_1.default.red(`⚠️ Due to request failures, ${this.notReportedTestsCount} test(s) were not reported to Testomat.io`);
516
- console.warn(`${constants_js_1.APP_PREFIX} ${errorMessage}`);
521
+ log_js_1.log.warn(errorMessage);
517
522
  }
518
523
  const { status } = params;
519
524
  // a pending run was just created: nothing to update here, only other pipes report it
@@ -526,6 +531,8 @@ class TestomatioPipe {
526
531
  status_event = 'pass';
527
532
  if (status === constants_js_1.STATUS.FAILED)
528
533
  status_event = 'fail';
534
+ if (this.sharedRun)
535
+ status_event = 'finish';
529
536
  try {
530
537
  if (this.runId && !this.proceed) {
531
538
  await this.client.request({
@@ -561,7 +568,7 @@ class TestomatioPipe {
561
568
  }
562
569
  }
563
570
  catch (err) {
564
- console.log(constants_js_1.APP_PREFIX, 'Error updating status, skipping...', err);
571
+ log_js_1.log.error('Error updating status, skipping...', err.message || err);
565
572
  this.#logFailedResponse(err);
566
573
  printCreateIssue();
567
574
  }
@@ -582,7 +589,7 @@ class TestomatioPipe {
582
589
  let responseBody = stringify(error.response?.data ?? error.response ?? error, { pretty: true });
583
590
  if (!responseBody)
584
591
  responseBody = '<empty>';
585
- responseBody = hideTestomatioToken(responseBody);
592
+ responseBody = (0, hide_token_js_1.hideTestomatioToken)(responseBody);
586
593
  const statusCode = error.status || error.code || error.response?.status || '<unknown status code>';
587
594
  const method = error.response?.config?.method || '<unknown method>';
588
595
  const url = String(error.response?.config?.url || '<unknown url>');
@@ -600,18 +607,32 @@ class TestomatioPipe {
600
607
  message += `\t${picocolors_1.default.red(statusText)}\n`;
601
608
  }
602
609
  message += `\t${picocolors_1.default.bold('response: ')}${picocolors_1.default.gray(responseBody)}\n`;
603
- const requestBody = hideTestomatioToken(stringify(error.response?.config?.data));
610
+ let requestBody = (0, hide_token_js_1.hideTestomatioToken)(stringify(error.response?.config?.data));
611
+ let requestTruncated = false;
604
612
  if (process.env.DEBUG || process.env.TESTOMATIO_DEBUG || requestBody.length < 1000) {
605
613
  // full body
606
614
  message += `\t${picocolors_1.default.bold('request: ')}${picocolors_1.default.gray(requestBody)}\n`;
607
615
  }
608
616
  else {
609
617
  // cut body
610
- const requestBodyCut = requestBody.slice(0, 1000);
611
- message += `\t${picocolors_1.default.bold('request: ')}${picocolors_1.default.gray(`${requestBodyCut}...`)}\n`;
618
+ requestTruncated = true;
619
+ requestBody = `${requestBody.slice(0, 1000)}...`;
620
+ message += `\t${picocolors_1.default.bold('request: ')}${picocolors_1.default.gray(requestBody)}\n`;
612
621
  message += '\trequest body is cut, run with TESTOMATIO_DEBUG=1 to see full body\n';
613
622
  }
614
- console.log(message);
623
+ // the JSON line is built from the same values as the text message, with the token already hidden
624
+ const fields = {
625
+ status: statusCode,
626
+ method,
627
+ url,
628
+ error: apiMessage || statusText || undefined,
629
+ response: parseIfJson(responseBody),
630
+ request: parseIfJson(requestBody),
631
+ };
632
+ // a cut body is no longer valid JSON, so consumers are told why `request` is a string
633
+ if (requestTruncated)
634
+ fields.requestTruncated = true;
635
+ log_js_1.log.errorWithFields(fields, message);
615
636
  if (error.response?.data?.message?.includes('could not be matched')) {
616
637
  this.hasUnmatchedTests = true;
617
638
  }
@@ -626,20 +647,9 @@ function printCreateIssue() {
626
647
  return;
627
648
  registeredErrorHints = true;
628
649
  process.on('exit', () => {
629
- console.log(constants_js_1.APP_PREFIX, 'There was an error reporting to Testomat.io.\n', picocolors_1.default.yellow('If you think this is a bug please create an issue: https://github.com/testomatio/reporter/issues/new.'), picocolors_1.default.yellow('Provide the logs from above'));
650
+ log_js_1.log.error('There was an error reporting to Testomat.io.\n', picocolors_1.default.yellow('If you think this is a bug please create an issue: https://github.com/testomatio/reporter/issues/new.'), picocolors_1.default.yellow('Provide the logs from above'));
630
651
  });
631
652
  }
632
- /**
633
- * Removes Testomatio token from string data
634
- *
635
- * @param {string} data
636
- * @returns {string}
637
- */
638
- function hideTestomatioToken(data) {
639
- return (typeof data === 'string' ? data : '')
640
- .replace(/"api_key"\s*:\s*"[^"]+"/g, '"api_key": "<hidden>"')
641
- .replace(/"(tstmt_[^"]+)"/g, '"tstmt_***"');
642
- }
643
653
  /**
644
654
  * Stringifies provided data
645
655
  *
@@ -650,4 +660,18 @@ function hideTestomatioToken(data) {
650
660
  function stringify(anything, opts = { pretty: false }) {
651
661
  return typeof anything === 'string' ? anything : JSON.stringify(anything, null, opts.pretty ? 2 : undefined);
652
662
  }
663
+ /**
664
+ * Turn a JSON string back into an object for structured logs; keeps the string if it is not JSON.
665
+ *
666
+ * @param {string} data
667
+ * @returns {any}
668
+ */
669
+ function parseIfJson(data) {
670
+ try {
671
+ return JSON.parse(data);
672
+ }
673
+ catch {
674
+ return data;
675
+ }
676
+ }
653
677
  module.exports = TestomatioPipe;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Hides the Testomat.io API token in any data which is about to be printed or logged.
3
+ * Applied at the logger level, so a raw error object with a request body can't leak the token.
4
+ *
5
+ * @param {string} data
6
+ * @returns {string} The data with every token replaced, empty string if data is not a string.
7
+ */
8
+ export function hideTestomatioToken(data: string): string;
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hideTestomatioToken = hideTestomatioToken;
4
+ /**
5
+ * Hides the Testomat.io API token in any data which is about to be printed or logged.
6
+ * Applied at the logger level, so a raw error object with a request body can't leak the token.
7
+ *
8
+ * @param {string} data
9
+ * @returns {string} The data with every token replaced, empty string if data is not a string.
10
+ */
11
+ function hideTestomatioToken(data) {
12
+ if (typeof data !== 'string')
13
+ return '';
14
+ return data.replace(/"api_key"\s*:\s*"[^"]+"/g, '"api_key": "<hidden>"').replace(/tstmt_[\w-]+/g, 'tstmt_***');
15
+ }
16
+
17
+ module.exports.hideTestomatioToken = hideTestomatioToken;
@@ -12,6 +12,12 @@ export function getLogLevel(): number;
12
12
  * @returns {boolean} True if the message should be logged
13
13
  */
14
14
  export function shouldLog(messageLevel: number): boolean;
15
+ /**
16
+ * Check if logs should be printed as JSON lines instead of [TESTOMATIO] prefixed text.
17
+ * Enabled by the CLI for `--format json` so the whole output is machine-readable.
18
+ * @returns {boolean}
19
+ */
20
+ export function isJsonOutput(): boolean;
15
21
  /**
16
22
  * Log an info message with [TESTOMATIO] prefix.
17
23
  * Only logs when TESTOMATIO_LOG_LEVEL is INFO.
@@ -30,6 +36,13 @@ export function warn(...args: any[]): void;
30
36
  * @param {...any} args - Arguments to log
31
37
  */
32
38
  export function error(...args: any[]): void;
39
+ /**
40
+ * Log an error which carries structured data, e.g. a failed API request.
41
+ * The fields are added to the JSON line; in text mode only the message is printed.
42
+ * @param {Object} fields - Extra fields to add to the JSON object
43
+ * @param {...any} args - Arguments to log as text
44
+ */
45
+ export function errorWithFields(fields: any, ...args: any[]): void;
33
46
  export namespace LOG_LEVELS {
34
47
  let ERROR: number;
35
48
  let WARN: number;
@@ -39,6 +52,8 @@ export namespace log {
39
52
  export { info };
40
53
  export { warn };
41
54
  export { error };
55
+ export { errorWithFields };
56
+ export { isJsonOutput };
42
57
  export { getLogLevel };
43
58
  export { shouldLog };
44
59
  export { LOG_LEVELS };
package/lib/utils/log.js CHANGED
@@ -3,10 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.log = exports.LOG_LEVELS = void 0;
4
4
  exports.getLogLevel = getLogLevel;
5
5
  exports.shouldLog = shouldLog;
6
+ exports.isJsonOutput = isJsonOutput;
6
7
  exports.info = info;
7
8
  exports.warn = warn;
8
9
  exports.error = error;
10
+ exports.errorWithFields = errorWithFields;
11
+ const util_1 = require("util");
9
12
  const constants_js_1 = require("../constants.js");
13
+ const hide_token_js_1 = require("./hide_token.js");
10
14
  /**
11
15
  * Log levels for the Testomat.io reporter.
12
16
  * A message is logged if its level is <= the current log level.
@@ -39,16 +43,51 @@ function getLogLevel() {
39
43
  function shouldLog(messageLevel) {
40
44
  return messageLevel <= getLogLevel() || !!process.env.TESTOMATIO_DEBUG;
41
45
  }
46
+ /**
47
+ * Check if logs should be printed as JSON lines instead of [TESTOMATIO] prefixed text.
48
+ * Enabled by the CLI for `--format json` so the whole output is machine-readable.
49
+ * @returns {boolean}
50
+ */
51
+ function isJsonOutput() {
52
+ return process.env.TESTOMATIO_LOG_JSON === '1';
53
+ }
54
+ /**
55
+ * Render a log message as a single JSON line, e.g. `{"level":"error","message":"..."}`.
56
+ * Colors are stripped, so the message stays readable after parsing.
57
+ * @param {string} level - Log level name
58
+ * @param {any[]} args - Arguments as passed to the log function
59
+ * @param {Object} [fields] - Extra fields to add to the JSON object
60
+ * @returns {string}
61
+ */
62
+ function jsonLine(level, args, fields = {}) {
63
+ const message = (0, util_1.stripVTControlCharacters)((0, util_1.format)(...args)).trim();
64
+ return (0, hide_token_js_1.hideTestomatioToken)(JSON.stringify({ ...fields, level, message }));
65
+ }
66
+ /**
67
+ * Render the arguments of a log function as text, with the API token hidden.
68
+ * Errors and other objects are formatted the way console does it.
69
+ * @param {any[]} args - Arguments as passed to the log function
70
+ * @returns {string}
71
+ */
72
+ function textLine(args) {
73
+ return (0, hide_token_js_1.hideTestomatioToken)((0, util_1.format)(...args));
74
+ }
42
75
  /**
43
76
  * Log an info message with [TESTOMATIO] prefix.
44
77
  * Only logs when TESTOMATIO_LOG_LEVEL is INFO.
45
78
  * @param {...any} args - Arguments to log
46
79
  */
47
80
  function info(...args) {
48
- if (shouldLog(exports.LOG_LEVELS.INFO)) {
49
- const fn = process.env.TESTOMATIO_LOG_STDERR === '1' ? console.error : console.log;
50
- fn(constants_js_1.APP_PREFIX, ...args);
81
+ if (!shouldLog(exports.LOG_LEVELS.INFO))
82
+ return;
83
+ let fn = console.log;
84
+ if (process.env.TESTOMATIO_LOG_STDERR === '1')
85
+ fn = console.error;
86
+ if (isJsonOutput()) {
87
+ fn(jsonLine('info', args));
88
+ return;
51
89
  }
90
+ fn(constants_js_1.APP_PREFIX, textLine(args));
52
91
  }
53
92
  /**
54
93
  * Log a warning message with [TESTOMATIO] prefix.
@@ -56,9 +95,13 @@ function info(...args) {
56
95
  * @param {...any} args - Arguments to log
57
96
  */
58
97
  function warn(...args) {
59
- if (shouldLog(exports.LOG_LEVELS.WARN)) {
60
- console.warn(constants_js_1.APP_PREFIX, ...args);
98
+ if (!shouldLog(exports.LOG_LEVELS.WARN))
99
+ return;
100
+ if (isJsonOutput()) {
101
+ console.warn(jsonLine('warn', args));
102
+ return;
61
103
  }
104
+ console.warn(constants_js_1.APP_PREFIX, textLine(args));
62
105
  }
63
106
  /**
64
107
  * Log an error message with [TESTOMATIO] prefix.
@@ -66,9 +109,28 @@ function warn(...args) {
66
109
  * @param {...any} args - Arguments to log
67
110
  */
68
111
  function error(...args) {
69
- if (shouldLog(exports.LOG_LEVELS.ERROR)) {
70
- console.error(constants_js_1.APP_PREFIX, ...args);
112
+ if (!shouldLog(exports.LOG_LEVELS.ERROR))
113
+ return;
114
+ if (isJsonOutput()) {
115
+ console.error(jsonLine('error', args));
116
+ return;
71
117
  }
118
+ console.error(constants_js_1.APP_PREFIX, textLine(args));
119
+ }
120
+ /**
121
+ * Log an error which carries structured data, e.g. a failed API request.
122
+ * The fields are added to the JSON line; in text mode only the message is printed.
123
+ * @param {Object} fields - Extra fields to add to the JSON object
124
+ * @param {...any} args - Arguments to log as text
125
+ */
126
+ function errorWithFields(fields, ...args) {
127
+ if (!shouldLog(exports.LOG_LEVELS.ERROR))
128
+ return;
129
+ if (isJsonOutput()) {
130
+ console.error(jsonLine('error', args, fields));
131
+ return;
132
+ }
133
+ console.error(constants_js_1.APP_PREFIX, textLine(args));
72
134
  }
73
135
  /**
74
136
  * Logging utility for Testomat.io reporter.
@@ -83,6 +145,8 @@ exports.log = {
83
145
  info,
84
146
  warn,
85
147
  error,
148
+ errorWithFields,
149
+ isJsonOutput,
86
150
  getLogLevel,
87
151
  shouldLog,
88
152
  LOG_LEVELS: exports.LOG_LEVELS,
@@ -92,8 +156,12 @@ module.exports.getLogLevel = getLogLevel;
92
156
 
93
157
  module.exports.shouldLog = shouldLog;
94
158
 
159
+ module.exports.isJsonOutput = isJsonOutput;
160
+
95
161
  module.exports.info = info;
96
162
 
97
163
  module.exports.warn = warn;
98
164
 
99
165
  module.exports.error = error;
166
+
167
+ module.exports.errorWithFields = errorWithFields;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.15.0-beta.1-json-output",
3
+ "version": "2.16.0",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -34,7 +34,8 @@ const HOOK_EXECUTION_ORDER = {
34
34
  // codeceptjs workers are self-contained
35
35
  dataStorage.isFileStorage = false;
36
36
 
37
- const DATA_REGEXP = /[|\s]+?(\{".*\}|\[.*\])/;
37
+ // CodeceptJS appends the serialized data row of a data-driven test to its title
38
+ const DATA_REGEXP = / \| (\{.*\}|\[.*\]|null|"(?:\\.|[^"\\])*")((?:\s+@[a-zA-Z0-9-_]+)*)$/;
38
39
 
39
40
  if (MAJOR_VERSION < 3) {
40
41
  console.log('🔴 This reporter works with CodeceptJS 3+, please update your tests');
@@ -301,7 +302,7 @@ function CodeceptReporter(config) {
301
302
  });
302
303
 
303
304
  event.dispatcher.on(event.step.started, step => {
304
- const stepText = `${repeat(output.stepShift)} ${step.toCliStyled ? step.toCliStyled() : step.toString()}`;
305
+ const stepText = `${repeat(output.stepShift)} ${formatStepLog(step)}`;
305
306
  dataStorage.putData('log', stepText);
306
307
  });
307
308
 
@@ -349,15 +350,30 @@ function stripExampleFromTitle(title) {
349
350
  const res = title.match(DATA_REGEXP);
350
351
  if (!res) return { title, example: null };
351
352
 
353
+ let example = null;
354
+ let exampleParsed = false;
352
355
  try {
353
- const example = JSON.parse(res[1]);
354
- title = title.replace(DATA_REGEXP, '').trim();
355
- return { title, example };
356
+ example = JSON.parse(res[1]);
357
+ exampleParsed = true;
356
358
  } catch (e) {
357
- // If JSON parsing fails, return title without example
358
- debug('Failed to parse example JSON:', res[1], e.message);
359
- return { title: title.replace(DATA_REGEXP, '').trim(), example: null };
359
+ try {
360
+ example = JSON.parse(res[1].slice(1, -1));
361
+ exampleParsed = true;
362
+ } catch (e2) {
363
+ debug('Failed to parse example from title:', res[1]);
364
+ }
365
+ }
366
+
367
+ let baseTitle = title.slice(0, res.index).trim();
368
+ if (exampleParsed && baseTitle.includes('${current}')) {
369
+ baseTitle = baseTitle.replaceAll('${current}', formatExample(example));
360
370
  }
371
+ return { title: `${baseTitle}${res[2]}`, example };
372
+ }
373
+
374
+ function formatExample(example) {
375
+ if (example !== null && typeof example === 'object') return JSON.stringify(example);
376
+ return String(example);
361
377
  }
362
378
 
363
379
  function stripTagsFromTitle(title) {
@@ -369,6 +385,18 @@ function repeat(num) {
369
385
  return ''.padStart(num, ' ');
370
386
  }
371
387
 
388
+ function formatStepLog(step) {
389
+ if (typeof step?.toCliStyled === 'function') return step.toCliStyled();
390
+
391
+ if (typeof step?.toString === 'function' && step.toString !== Object.prototype.toString) {
392
+ return step.toString();
393
+ }
394
+
395
+ const title = step?.title || '';
396
+ const args = Array.isArray(step?.args) ? step.args.join(', ') : '';
397
+ return `${title}${args ? ` ${args}` : ''}`.trim();
398
+ }
399
+
372
400
  // Helper functions for cleaner event handling
373
401
  function initializeTestDataStore() {
374
402
  if (!global.testomatioDataStore) global.testomatioDataStore = {};
package/src/bin/cli.js CHANGED
@@ -43,6 +43,8 @@ program
43
43
  if (subOpts.filterList || subOpts.format) {
44
44
  process.env.TESTOMATIO_LOG_STDERR = '1';
45
45
  process.env.TESTOMATIO_LOG_LEVEL ||= 'WARN';
46
+ // with --format json the logs are machine-readable too: one JSON object per line on stderr
47
+ if (subOpts.format === 'json') process.env.TESTOMATIO_LOG_JSON = '1';
46
48
  } else {
47
49
  console.log(pc.cyan(pc.bold(` 🤩 Testomat.io Reporter v${version}`)));
48
50
  }
@@ -82,7 +84,7 @@ program
82
84
 
83
85
  await client.createRun(createRunParams);
84
86
 
85
- const runId = client.pipeStore.runId || process.env.runId;
87
+ const runId = client.pipeStore.runId;
86
88
  if (!runId) {
87
89
  log.error(pc.red('Failed to create run on Testomat.io.'));
88
90
  process.exit(1);
@@ -94,7 +96,7 @@ program
94
96
  await client.updateRunStatus('pending', { tests: plannedTests });
95
97
 
96
98
  // stdout carries ONLY the run data so it can be captured: RUN_ID=$(reporter start)
97
- console.log(formatRunOutput({ ...client.pipeStore, runId }, opts.format));
99
+ console.log(formatRunOutput(client.pipeStore, opts.format));
98
100
  process.exit(0);
99
101
  });
100
102
 
@@ -250,15 +252,20 @@ program
250
252
 
251
253
  if (apiKey) {
252
254
  await client.createRun(createRunParams);
253
- const runId = process.env.TESTOMATIO_RUN || process.env.runId;
255
+
256
+ const runId = client.pipeStore.runId;
257
+ if (!runId) {
258
+ log.error(pc.red('Failed to create run on Testomat.io.'));
259
+ process.exit(1);
260
+ }
261
+
254
262
  if (client.pipeStore.runUrl) log.info( `📊 Report URL: ${pc.magenta(client.pipeStore.runUrl)}`);
255
263
 
256
264
  if (opts.kind !== 'manual') {
257
265
  log.info( `No command passed, so you need to run tests yourself:`);
258
266
  log.info( `TESTOMATIO_RUN=${runId} <command>`);
259
267
  }
260
- const runOutput = formatRunOutput({ ...client.pipeStore, runId }, opts.format);
261
- if (opts.format && runOutput) console.log(runOutput);
268
+ if (opts.format) console.log(formatRunOutput(client.pipeStore, opts.format));
262
269
  } else {
263
270
  log.info( '⚠️ No API key provided. Cannot create run without TESTOMATIO key.');
264
271
  process.exit(1);
package/src/client.js CHANGED
@@ -187,7 +187,7 @@ class Client {
187
187
  }
188
188
 
189
189
  } catch (err) {
190
- console.error(APP_PREFIX, 'Error in uploadStepArtifacts for testRid', testRid, ':', err);
190
+ log.error('Error in uploadStepArtifacts for testRid', testRid, ':', err.message || err);
191
191
  throw err;
192
192
  }
193
193
  }
@@ -225,7 +225,7 @@ class Client {
225
225
  try {
226
226
  await this.uploadStepArtifacts(steps, rid);
227
227
  } catch (err) {
228
- console.log(APP_PREFIX, 'Failed to upload step artifacts:', err);
228
+ log.error('Failed to upload step artifacts:', err.message || err);
229
229
  }
230
230
 
231
231
  const uploadedFiles = [];
@@ -423,7 +423,7 @@ class Client {
423
423
  const pathPadding = Math.max(...failedUploads.map(upload => upload.relativePath.length)) + 1;
424
424
 
425
425
  failedUploads.forEach(upload => {
426
- console.log(
426
+ log.info(
427
427
  ` ${pc.gray('|')} 🔴 ${upload.relativePath.padEnd(pathPadding)} ${pc.gray(
428
428
  `| ${upload.sizePretty.padStart(filesizeStrMaxLength)} |`,
429
429
  )}`,
@@ -439,7 +439,7 @@ class Client {
439
439
  }));
440
440
  const pathPadding = Math.max(...skippedUploads.map(upload => upload.relativePath.length)) + 1;
441
441
  skippedUploads.forEach(upload => {
442
- console.log(
442
+ log.info(
443
443
  ` ${pc.gray('|')} 🟡 ${upload.relativePath.padEnd(pathPadding)} ${pc.gray(
444
444
  `| ${upload.sizePretty.padStart(filesizeStrMaxLength)} |`,
445
445
  )}`,
package/src/pipe/csv.js CHANGED
@@ -6,6 +6,7 @@ import pc from 'picocolors';
6
6
  import merge from 'lodash.merge';
7
7
  import { isSameTest, getCurrentDateTime, ansiRegExp } from '../utils/utils.js';
8
8
  import { CSV_HEADERS } from '../constants.js';
9
+ import { log } from '../utils/log.js';
9
10
 
10
11
  const debug = createDebugMessages('@testomatio/reporter:pipe:csv');
11
12
  /**
@@ -76,11 +77,11 @@ class CsvPipe {
76
77
  this.checkExportDir();
77
78
 
78
79
  if (!this.outputFile) {
79
- console.log(pc.yellow(`⚠️ CSV file is not set, ignoring`));
80
+ log.warn(pc.yellow(`⚠️ CSV file is not set, ignoring`));
80
81
  return;
81
82
  }
82
83
 
83
- console.log(pc.yellow(`⏳ The test results will be added to the csv. It will take some time...`));
84
+ log.info(pc.yellow(`⏳ The test results will be added to the csv. It will take some time...`));
84
85
 
85
86
  try {
86
87
  // Create csv writer object
@@ -91,7 +92,7 @@ class CsvPipe {
91
92
  // Save csv file based on the current data
92
93
  return await writer.writeRecords(data);
93
94
  } catch (e) {
94
- console.log('Unknown csv error: ', e);
95
+ log.error('Unknown csv error: ', e);
95
96
  }
96
97
  }
97
98
 
@@ -135,7 +136,7 @@ class CsvPipe {
135
136
  // Save results based on the default headers
136
137
  if (this.isEnabled) {
137
138
  await this.saveToCsv(this.results, CSV_HEADERS);
138
- console.log(pc.green(`🗃️ Recording completed! You can check the result in file = ${this.outputFile}`));
139
+ log.info(pc.green(`🗃️ Recording completed! You can check the result in file = ${this.outputFile}`));
139
140
  }
140
141
  }
141
142
 
package/src/pipe/html.js CHANGED
@@ -8,6 +8,7 @@ import { marked } from 'marked';
8
8
  import fileUrl from 'file-url';
9
9
  import { fileSystem, isSameTest, ansiRegExp, formatStep, transformEnvVarToBoolean } from '../utils/utils.js';
10
10
  import { HTML_REPORT } from '../constants.js';
11
+ import { log } from '../utils/log.js';
11
12
  import { fileURLToPath } from 'node:url';
12
13
 
13
14
  const debug = createDebugMessages('@testomatio/reporter:pipe:html');
@@ -144,14 +145,14 @@ class HtmlPipe {
144
145
  debug('HTML tests data:', tests);
145
146
 
146
147
  if (!outputPath) {
147
- console.log(pc.yellow(`🚨 HTML export path is not set, ignoring...`));
148
+ log.warn(pc.yellow(`🚨 HTML export path is not set, ignoring...`));
148
149
  return;
149
150
  }
150
151
 
151
- console.log(pc.yellow(`⏳ The test results will be added to the HTML report. It will take some time...`));
152
+ log.info(pc.yellow(`⏳ The test results will be added to the HTML report. It will take some time...`));
152
153
 
153
154
  if (msg) {
154
- console.log(pc.blue(msg));
155
+ log.info(pc.blue(msg));
155
156
  }
156
157
 
157
158
  const aggregatedTests = aggregateTestRetries(tests);
@@ -295,9 +296,9 @@ class HtmlPipe {
295
296
 
296
297
  debug('HTML tests data:', fileUrlPath);
297
298
 
298
- console.log(pc.green(`📊 The HTML report was successfully generated. Full filepath: ${fileUrlPath}`));
299
+ log.info(pc.green(`📊 The HTML report was successfully generated. Full filepath: ${fileUrlPath}`));
299
300
  } else {
300
- console.log(pc.red(`🚨 Failed to generate the HTML report.`));
301
+ log.error(pc.red(`🚨 Failed to generate the HTML report.`));
301
302
  }
302
303
  }
303
304
 
@@ -309,7 +310,7 @@ class HtmlPipe {
309
310
  */
310
311
  #generateHTMLReport(data, templatePath = '') {
311
312
  if (!templatePath) {
312
- console.log(pc.red(`🚨 HTML template not found. Report generation is impossible!`));
313
+ log.error(pc.red(`🚨 HTML template not found. Report generation is impossible!`));
313
314
  return;
314
315
  }
315
316
 
@@ -320,8 +321,8 @@ class HtmlPipe {
320
321
 
321
322
  return template(data);
322
323
  } catch (e) {
323
- console.log(pc.red('❌ Oops! An unknown error occurred when generating an HTML report'));
324
- console.log(pc.red(e));
324
+ log.error(pc.red('❌ Oops! An unknown error occurred when generating an HTML report'));
325
+ log.error(pc.red(e));
325
326
  }
326
327
  }
327
328
 
@@ -18,6 +18,7 @@ import {
18
18
  getGitCommitSha,
19
19
  } from '../utils/utils.js';
20
20
  import { parseFilterParams, generateFilterRequestParams, setS3Credentials } from '../utils/pipe_utils.js';
21
+ import { hideTestomatioToken } from '../utils/hide_token.js';
21
22
  import { config } from '../config.js';
22
23
  import { log } from '../utils/log.js';
23
24
 
@@ -87,12 +88,15 @@ class TestomatioPipe {
87
88
  this.store = store || {};
88
89
  this.title = params.title || process.env.TESTOMATIO_TITLE;
89
90
  this.sharedRun = !!process.env.TESTOMATIO_SHARED_RUN;
91
+ this.sharedRunShards = process.env.TESTOMATIO_SHARDS
92
+ ? parseInt(process.env.TESTOMATIO_SHARDS, 10) || undefined
93
+ : undefined;
90
94
  this.sharedRunTimeout = process.env.TESTOMATIO_SHARED_RUN_TIMEOUT
91
95
  ? parseInt(process.env.TESTOMATIO_SHARED_RUN_TIMEOUT, 10)
92
96
  : undefined;
93
97
 
94
- if (this.sharedRunTimeout && !this.sharedRun) {
95
- debug('Auto-enabling sharedRun because sharedRunTimeout is set');
98
+ if ((this.sharedRunTimeout || this.sharedRunShards) && !this.sharedRun) {
99
+ debug('Auto-enabling sharedRun because sharedRunTimeout or sharedRunShards is set');
96
100
  this.sharedRun = true;
97
101
  }
98
102
 
@@ -322,6 +326,7 @@ class TestomatioPipe {
322
326
  label: this.label,
323
327
  shared_run: this.sharedRun,
324
328
  shared_run_timeout: this.sharedRunTimeout,
329
+ shared_run_shards: this.sharedRunShards,
325
330
  kind: params.kind,
326
331
  status: params.status,
327
332
  configuration,
@@ -380,16 +385,15 @@ class TestomatioPipe {
380
385
  process.env.runId = this.runId;
381
386
  debug('Run created', this.runId);
382
387
  } catch (err) {
383
- if (!this.apiKey) console.error('Testomat.io API key is not set');
388
+ if (!this.apiKey) log.error('Testomat.io API key is not set');
384
389
  const errorText = err.response?.data?.message || err.message;
385
390
  debug('Error creating run', err);
386
- console.log(APP_PREFIX, errorText || err);
391
+ log.error(errorText || err);
387
392
  if (err.response?.status === 403) this.#disablePipe();
388
393
 
389
394
  this.#logFailedResponse(err);
390
395
 
391
- console.error(
392
- APP_PREFIX,
396
+ log.error(
393
397
  'Error creating Testomat.io report (see details above), please check if your API key is valid. Skipping report',
394
398
  );
395
399
  printCreateIssue();
@@ -409,7 +413,7 @@ class TestomatioPipe {
409
413
  this.reportingCanceledDueToReqFailures = true;
410
414
  let errorMessage = `⚠️ ${process.env.TESTOMATIO_MAX_REQUEST_FAILURES}`;
411
415
  errorMessage += ' requests were failed, reporting to Testomat aborted.';
412
- console.warn(`${APP_PREFIX} ${pc.yellow(errorMessage)}`);
416
+ log.warn(pc.yellow(errorMessage));
413
417
  }
414
418
  return cancelReporting;
415
419
  }
@@ -557,7 +561,7 @@ class TestomatioPipe {
557
561
  const errorMessage = pc.red(
558
562
  `⚠️ Due to request failures, ${this.notReportedTestsCount} test(s) were not reported to Testomat.io`,
559
563
  );
560
- console.warn(`${APP_PREFIX} ${errorMessage}`);
564
+ log.warn(errorMessage);
561
565
  }
562
566
 
563
567
  const { status } = params;
@@ -570,6 +574,7 @@ class TestomatioPipe {
570
574
  if (status === STATUS.FINISHED) status_event = 'finish';
571
575
  if (status === STATUS.PASSED) status_event = 'pass';
572
576
  if (status === STATUS.FAILED) status_event = 'fail';
577
+ if (this.sharedRun) status_event = 'finish';
573
578
 
574
579
  try {
575
580
  if (this.runId && !this.proceed) {
@@ -621,7 +626,7 @@ class TestomatioPipe {
621
626
  );
622
627
  }
623
628
  } catch (err) {
624
- console.log(APP_PREFIX, 'Error updating status, skipping...', err);
629
+ log.error('Error updating status, skipping...', err.message || err);
625
630
  this.#logFailedResponse(err);
626
631
  printCreateIssue();
627
632
  }
@@ -665,18 +670,32 @@ class TestomatioPipe {
665
670
 
666
671
  message += `\t${pc.bold('response: ')}${pc.gray(responseBody)}\n`;
667
672
 
668
- const requestBody = hideTestomatioToken(stringify(error.response?.config?.data));
673
+ let requestBody = hideTestomatioToken(stringify(error.response?.config?.data));
674
+ let requestTruncated = false;
669
675
  if (process.env.DEBUG || process.env.TESTOMATIO_DEBUG || requestBody.length < 1000) {
670
676
  // full body
671
677
  message += `\t${pc.bold('request: ')}${pc.gray(requestBody)}\n`;
672
678
  } else {
673
679
  // cut body
674
- const requestBodyCut = requestBody.slice(0, 1000);
675
- message += `\t${pc.bold('request: ')}${pc.gray(`${requestBodyCut}...`)}\n`;
680
+ requestTruncated = true;
681
+ requestBody = `${requestBody.slice(0, 1000)}...`;
682
+ message += `\t${pc.bold('request: ')}${pc.gray(requestBody)}\n`;
676
683
  message += '\trequest body is cut, run with TESTOMATIO_DEBUG=1 to see full body\n';
677
684
  }
678
685
 
679
- console.log(message);
686
+ // the JSON line is built from the same values as the text message, with the token already hidden
687
+ const fields = {
688
+ status: statusCode,
689
+ method,
690
+ url,
691
+ error: apiMessage || statusText || undefined,
692
+ response: parseIfJson(responseBody),
693
+ request: parseIfJson(requestBody),
694
+ };
695
+ // a cut body is no longer valid JSON, so consumers are told why `request` is a string
696
+ if (requestTruncated) fields.requestTruncated = true;
697
+
698
+ log.errorWithFields(fields, message);
680
699
 
681
700
  if (error.response?.data?.message?.includes('could not be matched')) {
682
701
  this.hasUnmatchedTests = true;
@@ -693,8 +712,7 @@ function printCreateIssue() {
693
712
  if (registeredErrorHints) return;
694
713
  registeredErrorHints = true;
695
714
  process.on('exit', () => {
696
- console.log(
697
- APP_PREFIX,
715
+ log.error(
698
716
  'There was an error reporting to Testomat.io.\n',
699
717
  pc.yellow(
700
718
  'If you think this is a bug please create an issue: https://github.com/testomatio/reporter/issues/new.',
@@ -704,18 +722,6 @@ function printCreateIssue() {
704
722
  });
705
723
  }
706
724
 
707
- /**
708
- * Removes Testomatio token from string data
709
- *
710
- * @param {string} data
711
- * @returns {string}
712
- */
713
- function hideTestomatioToken(data) {
714
- return (typeof data === 'string' ? data : '')
715
- .replace(/"api_key"\s*:\s*"[^"]+"/g, '"api_key": "<hidden>"')
716
- .replace(/"(tstmt_[^"]+)"/g, '"tstmt_***"');
717
- }
718
-
719
725
  /**
720
726
  * Stringifies provided data
721
727
  *
@@ -727,4 +733,18 @@ function stringify(anything, opts = { pretty: false }) {
727
733
  return typeof anything === 'string' ? anything : JSON.stringify(anything, null, opts.pretty ? 2 : undefined);
728
734
  }
729
735
 
736
+ /**
737
+ * Turn a JSON string back into an object for structured logs; keeps the string if it is not JSON.
738
+ *
739
+ * @param {string} data
740
+ * @returns {any}
741
+ */
742
+ function parseIfJson(data) {
743
+ try {
744
+ return JSON.parse(data);
745
+ } catch {
746
+ return data;
747
+ }
748
+ }
749
+
730
750
  export default TestomatioPipe;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Hides the Testomat.io API token in any data which is about to be printed or logged.
3
+ * Applied at the logger level, so a raw error object with a request body can't leak the token.
4
+ *
5
+ * @param {string} data
6
+ * @returns {string} The data with every token replaced, empty string if data is not a string.
7
+ */
8
+ export function hideTestomatioToken(data) {
9
+ if (typeof data !== 'string') return '';
10
+
11
+ return data.replace(/"api_key"\s*:\s*"[^"]+"/g, '"api_key": "<hidden>"').replace(/tstmt_[\w-]+/g, 'tstmt_***');
12
+ }
package/src/utils/log.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { format as formatArgs, stripVTControlCharacters } from 'util';
1
2
  import { APP_PREFIX } from '../constants.js';
3
+ import { hideTestomatioToken } from './hide_token.js';
2
4
 
3
5
  /**
4
6
  * Log levels for the Testomat.io reporter.
@@ -35,16 +37,54 @@ export function shouldLog(messageLevel) {
35
37
  return messageLevel <= getLogLevel() || !!process.env.TESTOMATIO_DEBUG;
36
38
  }
37
39
 
40
+ /**
41
+ * Check if logs should be printed as JSON lines instead of [TESTOMATIO] prefixed text.
42
+ * Enabled by the CLI for `--format json` so the whole output is machine-readable.
43
+ * @returns {boolean}
44
+ */
45
+ export function isJsonOutput() {
46
+ return process.env.TESTOMATIO_LOG_JSON === '1';
47
+ }
48
+
49
+ /**
50
+ * Render a log message as a single JSON line, e.g. `{"level":"error","message":"..."}`.
51
+ * Colors are stripped, so the message stays readable after parsing.
52
+ * @param {string} level - Log level name
53
+ * @param {any[]} args - Arguments as passed to the log function
54
+ * @param {Object} [fields] - Extra fields to add to the JSON object
55
+ * @returns {string}
56
+ */
57
+ function jsonLine(level, args, fields = {}) {
58
+ const message = stripVTControlCharacters(formatArgs(...args)).trim();
59
+ return hideTestomatioToken(JSON.stringify({ ...fields, level, message }));
60
+ }
61
+
62
+ /**
63
+ * Render the arguments of a log function as text, with the API token hidden.
64
+ * Errors and other objects are formatted the way console does it.
65
+ * @param {any[]} args - Arguments as passed to the log function
66
+ * @returns {string}
67
+ */
68
+ function textLine(args) {
69
+ return hideTestomatioToken(formatArgs(...args));
70
+ }
71
+
38
72
  /**
39
73
  * Log an info message with [TESTOMATIO] prefix.
40
74
  * Only logs when TESTOMATIO_LOG_LEVEL is INFO.
41
75
  * @param {...any} args - Arguments to log
42
76
  */
43
77
  export function info(...args) {
44
- if (shouldLog(LOG_LEVELS.INFO)) {
45
- const fn = process.env.TESTOMATIO_LOG_STDERR === '1' ? console.error : console.log;
46
- fn(APP_PREFIX, ...args);
78
+ if (!shouldLog(LOG_LEVELS.INFO)) return;
79
+
80
+ let fn = console.log;
81
+ if (process.env.TESTOMATIO_LOG_STDERR === '1') fn = console.error;
82
+
83
+ if (isJsonOutput()) {
84
+ fn(jsonLine('info', args));
85
+ return;
47
86
  }
87
+ fn(APP_PREFIX, textLine(args));
48
88
  }
49
89
 
50
90
  /**
@@ -53,9 +93,13 @@ export function info(...args) {
53
93
  * @param {...any} args - Arguments to log
54
94
  */
55
95
  export function warn(...args) {
56
- if (shouldLog(LOG_LEVELS.WARN)) {
57
- console.warn(APP_PREFIX, ...args);
96
+ if (!shouldLog(LOG_LEVELS.WARN)) return;
97
+
98
+ if (isJsonOutput()) {
99
+ console.warn(jsonLine('warn', args));
100
+ return;
58
101
  }
102
+ console.warn(APP_PREFIX, textLine(args));
59
103
  }
60
104
 
61
105
  /**
@@ -64,9 +108,29 @@ export function warn(...args) {
64
108
  * @param {...any} args - Arguments to log
65
109
  */
66
110
  export function error(...args) {
67
- if (shouldLog(LOG_LEVELS.ERROR)) {
68
- console.error(APP_PREFIX, ...args);
111
+ if (!shouldLog(LOG_LEVELS.ERROR)) return;
112
+
113
+ if (isJsonOutput()) {
114
+ console.error(jsonLine('error', args));
115
+ return;
116
+ }
117
+ console.error(APP_PREFIX, textLine(args));
118
+ }
119
+
120
+ /**
121
+ * Log an error which carries structured data, e.g. a failed API request.
122
+ * The fields are added to the JSON line; in text mode only the message is printed.
123
+ * @param {Object} fields - Extra fields to add to the JSON object
124
+ * @param {...any} args - Arguments to log as text
125
+ */
126
+ export function errorWithFields(fields, ...args) {
127
+ if (!shouldLog(LOG_LEVELS.ERROR)) return;
128
+
129
+ if (isJsonOutput()) {
130
+ console.error(jsonLine('error', args, fields));
131
+ return;
69
132
  }
133
+ console.error(APP_PREFIX, textLine(args));
70
134
  }
71
135
 
72
136
  /**
@@ -82,6 +146,8 @@ export const log = {
82
146
  info,
83
147
  warn,
84
148
  error,
149
+ errorWithFields,
150
+ isJsonOutput,
85
151
  getLogLevel,
86
152
  shouldLog,
87
153
  LOG_LEVELS,