testomatio-reporter-cli 2.14.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-reporter-cli",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "description": "Yarn Berry compatible standalone CLI for @testomatio/reporter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -301,7 +301,7 @@ function CodeceptReporter(config) {
301
301
  });
302
302
 
303
303
  event.dispatcher.on(event.step.started, step => {
304
- const stepText = `${repeat(output.stepShift)} ${step.toCliStyled ? step.toCliStyled() : step.toString()}`;
304
+ const stepText = `${repeat(output.stepShift)} ${formatStepLog(step)}`;
305
305
  dataStorage.putData('log', stepText);
306
306
  });
307
307
 
@@ -369,6 +369,18 @@ function repeat(num) {
369
369
  return ''.padStart(num, ' ');
370
370
  }
371
371
 
372
+ function formatStepLog(step) {
373
+ if (typeof step?.toCliStyled === 'function') return step.toCliStyled();
374
+
375
+ if (typeof step?.toString === 'function' && step.toString !== Object.prototype.toString) {
376
+ return step.toString();
377
+ }
378
+
379
+ const title = step?.title || '';
380
+ const args = Array.isArray(step?.args) ? step.args.join(', ') : '';
381
+ return `${title}${args ? ` ${args}` : ''}`.trim();
382
+ }
383
+
372
384
  // Helper functions for cleaner event handling
373
385
  function initializeTestDataStore() {
374
386
  if (!global.testomatioDataStore) global.testomatioDataStore = {};
package/src/bin/cli.js CHANGED
@@ -16,7 +16,7 @@ import { filesize as prettyBytes } from 'filesize';
16
16
  import dotenv from 'dotenv';
17
17
  import Replay from '../replay.js';
18
18
  import { log } from '../utils/log.js';
19
- import { formatFilterListIds } from '../utils/pipe_utils.js';
19
+ import { formatFilterListIds, formatRunOutput } from '../utils/pipe_utils.js';
20
20
  import fs from 'fs';
21
21
  import path from 'path';
22
22
 
@@ -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
  }
@@ -53,7 +55,7 @@ program
53
55
  .description('Start a new run and return its ID')
54
56
  .option('--kind <type>', 'Specify run type: automated, manual, mixed, or detect')
55
57
  .option('--filter <filter>', 'Scope the prepared run to tests matching the filter (no execution)')
56
- .option('--format <format>', 'Machine-readable output: print only the run id to stdout (e.g. --format id)')
58
+ .option('--format <format>', 'Machine-readable output: the run id (--format id) or run details (--format json)')
57
59
  .option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only)')
58
60
  .action(async opts => {
59
61
  cleanLatestRunId();
@@ -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);
@@ -93,8 +95,8 @@ program
93
95
  const plannedTests = (client.pipeStore.preparedTestIds || []).map(id => ({ test_id: id, title: id }));
94
96
  await client.updateRunStatus('pending', { tests: plannedTests });
95
97
 
96
- // stdout carries ONLY the run id so it can be captured: RUN_ID=$(reporter start)
97
- console.log(runId);
98
+ // stdout carries ONLY the run data so it can be captured: RUN_ID=$(reporter start)
99
+ console.log(formatRunOutput(client.pipeStore, opts.format));
98
100
  process.exit(0);
99
101
  });
100
102
 
@@ -129,7 +131,10 @@ program
129
131
  .argument('[command]', 'Test runner command')
130
132
  .option('--filter <filter>', 'Additional execution filter')
131
133
  .option('--filter-list <filter>', 'Get a list of all tests by filter before running')
132
- .option('--format <format>', 'Machine-readable output format for --filter-list (grep, json, newline, ids)')
134
+ .option(
135
+ '--format <format>',
136
+ 'Machine-readable output: test ids for --filter-list (grep, json, newline, ids), or the run created (id, json)',
137
+ )
133
138
  .option('--kind <type>', 'Specify run type: automated, manual, mixed, or detect')
134
139
  .option('--remote <profile>', 'Trigger run on the named Testomat.io CI profile instead of executing locally')
135
140
  .option(
@@ -230,6 +235,8 @@ program
230
235
 
231
236
  log.info(`🚀 CI build triggered on profile ${pc.cyan(opts.remote)}`);
232
237
  log.info(`📊 Report URL: ${pc.magenta(client.pipeStore.runUrl)}`);
238
+ const remoteOutput = formatRunOutput(client.pipeStore, opts.format);
239
+ if (opts.format && remoteOutput) console.log(remoteOutput);
233
240
  return process.exit(0);
234
241
  }
235
242
 
@@ -245,13 +252,20 @@ program
245
252
 
246
253
  if (apiKey) {
247
254
  await client.createRun(createRunParams);
248
- 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
+
249
262
  if (client.pipeStore.runUrl) log.info( `📊 Report URL: ${pc.magenta(client.pipeStore.runUrl)}`);
250
263
 
251
264
  if (opts.kind !== 'manual') {
252
265
  log.info( `No command passed, so you need to run tests yourself:`);
253
266
  log.info( `TESTOMATIO_RUN=${runId} <command>`);
254
267
  }
268
+ if (opts.format) console.log(formatRunOutput(client.pipeStore, opts.format));
255
269
  } else {
256
270
  log.info( '⚠️ No API key provided. Cannot create run without TESTOMATIO key.');
257
271
  process.exit(1);
@@ -288,7 +302,11 @@ program
288
302
  }
289
303
 
290
304
  if (apiKey) {
291
- await client.createRun(createRunParams).then(runTests);
305
+ await client.createRun(createRunParams);
306
+ // the runner inherits stdout, so the run data is printed first, on its own line
307
+ const createdOutput = formatRunOutput(client.pipeStore, opts.format);
308
+ if (opts.format && createdOutput) console.log(createdOutput);
309
+ await runTests();
292
310
  } else {
293
311
  await runTests();
294
312
  }
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
 
@@ -380,16 +381,15 @@ class TestomatioPipe {
380
381
  process.env.runId = this.runId;
381
382
  debug('Run created', this.runId);
382
383
  } catch (err) {
383
- if (!this.apiKey) console.error('Testomat.io API key is not set');
384
+ if (!this.apiKey) log.error('Testomat.io API key is not set');
384
385
  const errorText = err.response?.data?.message || err.message;
385
386
  debug('Error creating run', err);
386
- console.log(APP_PREFIX, errorText || err);
387
+ log.error(errorText || err);
387
388
  if (err.response?.status === 403) this.#disablePipe();
388
389
 
389
390
  this.#logFailedResponse(err);
390
391
 
391
- console.error(
392
- APP_PREFIX,
392
+ log.error(
393
393
  'Error creating Testomat.io report (see details above), please check if your API key is valid. Skipping report',
394
394
  );
395
395
  printCreateIssue();
@@ -409,7 +409,7 @@ class TestomatioPipe {
409
409
  this.reportingCanceledDueToReqFailures = true;
410
410
  let errorMessage = `⚠️ ${process.env.TESTOMATIO_MAX_REQUEST_FAILURES}`;
411
411
  errorMessage += ' requests were failed, reporting to Testomat aborted.';
412
- console.warn(`${APP_PREFIX} ${pc.yellow(errorMessage)}`);
412
+ log.warn(pc.yellow(errorMessage));
413
413
  }
414
414
  return cancelReporting;
415
415
  }
@@ -557,7 +557,7 @@ class TestomatioPipe {
557
557
  const errorMessage = pc.red(
558
558
  `⚠️ Due to request failures, ${this.notReportedTestsCount} test(s) were not reported to Testomat.io`,
559
559
  );
560
- console.warn(`${APP_PREFIX} ${errorMessage}`);
560
+ log.warn(errorMessage);
561
561
  }
562
562
 
563
563
  const { status } = params;
@@ -621,7 +621,7 @@ class TestomatioPipe {
621
621
  );
622
622
  }
623
623
  } catch (err) {
624
- console.log(APP_PREFIX, 'Error updating status, skipping...', err);
624
+ log.error('Error updating status, skipping...', err.message || err);
625
625
  this.#logFailedResponse(err);
626
626
  printCreateIssue();
627
627
  }
@@ -665,18 +665,32 @@ class TestomatioPipe {
665
665
 
666
666
  message += `\t${pc.bold('response: ')}${pc.gray(responseBody)}\n`;
667
667
 
668
- const requestBody = hideTestomatioToken(stringify(error.response?.config?.data));
668
+ let requestBody = hideTestomatioToken(stringify(error.response?.config?.data));
669
+ let requestTruncated = false;
669
670
  if (process.env.DEBUG || process.env.TESTOMATIO_DEBUG || requestBody.length < 1000) {
670
671
  // full body
671
672
  message += `\t${pc.bold('request: ')}${pc.gray(requestBody)}\n`;
672
673
  } else {
673
674
  // cut body
674
- const requestBodyCut = requestBody.slice(0, 1000);
675
- message += `\t${pc.bold('request: ')}${pc.gray(`${requestBodyCut}...`)}\n`;
675
+ requestTruncated = true;
676
+ requestBody = `${requestBody.slice(0, 1000)}...`;
677
+ message += `\t${pc.bold('request: ')}${pc.gray(requestBody)}\n`;
676
678
  message += '\trequest body is cut, run with TESTOMATIO_DEBUG=1 to see full body\n';
677
679
  }
678
680
 
679
- console.log(message);
681
+ // the JSON line is built from the same values as the text message, with the token already hidden
682
+ const fields = {
683
+ status: statusCode,
684
+ method,
685
+ url,
686
+ error: apiMessage || statusText || undefined,
687
+ response: parseIfJson(responseBody),
688
+ request: parseIfJson(requestBody),
689
+ };
690
+ // a cut body is no longer valid JSON, so consumers are told why `request` is a string
691
+ if (requestTruncated) fields.requestTruncated = true;
692
+
693
+ log.errorWithFields(fields, message);
680
694
 
681
695
  if (error.response?.data?.message?.includes('could not be matched')) {
682
696
  this.hasUnmatchedTests = true;
@@ -693,8 +707,7 @@ function printCreateIssue() {
693
707
  if (registeredErrorHints) return;
694
708
  registeredErrorHints = true;
695
709
  process.on('exit', () => {
696
- console.log(
697
- APP_PREFIX,
710
+ log.error(
698
711
  'There was an error reporting to Testomat.io.\n',
699
712
  pc.yellow(
700
713
  'If you think this is a bug please create an issue: https://github.com/testomatio/reporter/issues/new.',
@@ -704,18 +717,6 @@ function printCreateIssue() {
704
717
  });
705
718
  }
706
719
 
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
720
  /**
720
721
  * Stringifies provided data
721
722
  *
@@ -727,4 +728,18 @@ function stringify(anything, opts = { pretty: false }) {
727
728
  return typeof anything === 'string' ? anything : JSON.stringify(anything, null, opts.pretty ? 2 : undefined);
728
729
  }
729
730
 
731
+ /**
732
+ * Turn a JSON string back into an object for structured logs; keeps the string if it is not JSON.
733
+ *
734
+ * @param {string} data
735
+ * @returns {any}
736
+ */
737
+ function parseIfJson(data) {
738
+ try {
739
+ return JSON.parse(data);
740
+ } catch {
741
+ return data;
742
+ }
743
+ }
744
+
730
745
  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,
@@ -304,6 +304,27 @@ function plannedTestsLabel(tests, testsCount) {
304
304
  return `**${knownTestsCount}** tests and **${suitesCount}** suites planned`;
305
305
  }
306
306
 
307
+ /**
308
+ * Format the created run for machine-readable output of `start` and `run`.
309
+ * `json` prints an object with the run details, any other format prints the bare run id.
310
+ *
311
+ * @param {{runId?: string, runUrl?: string, runPublicUrl?: string}} store - Pipe store of the client.
312
+ * @param {string} [format] - Value of the CLI `--format` option.
313
+ * @returns {string} Empty string if there is no run id.
314
+ */
315
+ function formatRunOutput(store, format) {
316
+ const runId = store?.runId;
317
+ if (!runId) return '';
318
+
319
+ if (format !== 'json') return runId;
320
+
321
+ const output = { runId };
322
+ if (store.runUrl) output.runUrl = store.runUrl;
323
+ if (store.runPublicUrl) output.runPublicUrl = store.runPublicUrl;
324
+
325
+ return JSON.stringify(output);
326
+ }
327
+
307
328
  export {
308
329
  updateFilterType,
309
330
  parseFilterParams,
@@ -317,6 +338,7 @@ export {
317
338
  plannedTestsLabel,
318
339
  parsePipeOptions,
319
340
  formatFilterListIds,
341
+ formatRunOutput,
320
342
  getObjectSize,
321
343
  splitTestsIntoChunks,
322
344
  };