@testomatio/reporter 2.14.0-beta → 2.15.0-beta.1-json-output

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.
@@ -139,7 +139,7 @@ declare class AllureReader {
139
139
  * @returns {string|null}
140
140
  */
141
141
  extractTmsIdFromSource(contents: string, test: object): string | null;
142
- convertSteps(steps: any, depth?: number): any;
142
+ convertSteps(steps: any, resultsDir?: string, depth?: number): any;
143
143
  /**
144
144
  * Check whether any step in the given (already converted) subtree already
145
145
  * carries an `error`. Used to keep the failure message on the deepest failed
@@ -211,6 +211,24 @@ declare class AllureReader {
211
211
  * @returns {string[]}
212
212
  */
213
213
  sourceCandidatesForTest(t: object, index: Map<string, string[]>): string[];
214
+ /**
215
+ * @param {Array<{source?: string}>|undefined} attachments
216
+ * @param {string} resultsDir
217
+ * @returns {string[]} paths of attachments that exist on disk
218
+ */
219
+ resolveAttachments(attachments: Array<{
220
+ source?: string;
221
+ }> | undefined, resultsDir: string): string[];
222
+ /**
223
+ * Replaces step artifact paths with S3 links, dropping failed uploads (a local path
224
+ * would be a dead link in the UI).
225
+ *
226
+ * @param {Array<object>|undefined} steps
227
+ * @param {string} runId
228
+ * @param {string} rid
229
+ * @returns {Promise<number>} number of uploaded step artifacts
230
+ */
231
+ uploadStepArtifacts(steps: Array<object> | undefined, runId: string, rid: string): Promise<number>;
214
232
  uploadArtifacts(): Promise<void>;
215
233
  uploadData(): Promise<any[]>;
216
234
  }
@@ -160,7 +160,7 @@ class AllureReader {
160
160
  suite_title: this.extractSuiteTitle(result),
161
161
  file: this.extractFile(result),
162
162
  run_time: this.calculateRunTime(result),
163
- steps: this.convertSteps(result.steps || []),
163
+ steps: this.convertSteps(result.steps || [], resultsDir),
164
164
  message: result.statusDetails?.message || '',
165
165
  stack: result.statusDetails?.trace || '',
166
166
  meta: this.extractMeta(result),
@@ -182,20 +182,9 @@ class AllureReader {
182
182
  if (result.parameters && result.parameters.length > 0) {
183
183
  test.example = this.convertParameters(result.parameters);
184
184
  }
185
- if (result.attachments && result.attachments.length > 0) {
186
- const attachments = result.attachments
187
- .map(att => {
188
- const fullPath = path_1.default.join(resultsDir, att.source);
189
- if (fs_1.default.existsSync(fullPath)) {
190
- return fullPath;
191
- }
192
- debug('Attachment file not found:', fullPath);
193
- return null;
194
- })
195
- .filter(Boolean);
196
- if (attachments.length > 0) {
197
- test.files = attachments;
198
- }
185
+ const attachments = this.resolveAttachments(result.attachments, resultsDir);
186
+ if (attachments.length > 0) {
187
+ test.files = attachments;
199
188
  }
200
189
  return test;
201
190
  }
@@ -479,7 +468,7 @@ class AllureReader {
479
468
  extractTmsIdFromSource(contents, test) {
480
469
  return this.extractTmsIdsFromSource(contents, test)[0] || null;
481
470
  }
482
- convertSteps(steps, depth = 0) {
471
+ convertSteps(steps, resultsDir = '', depth = 0) {
483
472
  if (depth >= 10)
484
473
  return null;
485
474
  return steps
@@ -489,8 +478,13 @@ class AllureReader {
489
478
  title: step.name || step.title || 'Unknown step',
490
479
  status: this.mapStepStatus(step.status),
491
480
  duration: this.calculateRunTime(step),
492
- steps: this.convertSteps(step.steps || [], depth + 1),
481
+ steps: this.convertSteps(step.steps || [], resultsDir, depth + 1),
493
482
  };
483
+ // step attachments stay on the step; uploadArtifacts() swaps the paths for links
484
+ const attachments = this.resolveAttachments(step.attachments, resultsDir);
485
+ if (attachments.length > 0) {
486
+ convertedStep.artifacts = attachments;
487
+ }
494
488
  // Attach the failure description (error message + trace with the failing
495
489
  // code line) straight onto the failed step. Testomat.io renders a step's
496
490
  // `error` inline in the step tree, so the failure shows up on the exact
@@ -798,14 +792,62 @@ class AllureReader {
798
792
  }
799
793
  return paths;
800
794
  }
795
+ /**
796
+ * @param {Array<{source?: string}>|undefined} attachments
797
+ * @param {string} resultsDir
798
+ * @returns {string[]} paths of attachments that exist on disk
799
+ */
800
+ resolveAttachments(attachments, resultsDir) {
801
+ if (!attachments || !attachments.length)
802
+ return [];
803
+ return attachments
804
+ .map(att => {
805
+ if (!att?.source)
806
+ return null;
807
+ const fullPath = path_1.default.join(resultsDir || '', att.source);
808
+ if (fs_1.default.existsSync(fullPath))
809
+ return fullPath;
810
+ debug('Attachment file not found:', fullPath);
811
+ return null;
812
+ })
813
+ .filter(Boolean);
814
+ }
815
+ /**
816
+ * Replaces step artifact paths with S3 links, dropping failed uploads (a local path
817
+ * would be a dead link in the UI).
818
+ *
819
+ * @param {Array<object>|undefined} steps
820
+ * @param {string} runId
821
+ * @param {string} rid
822
+ * @returns {Promise<number>} number of uploaded step artifacts
823
+ */
824
+ async uploadStepArtifacts(steps, runId, rid) {
825
+ if (!steps || !steps.length)
826
+ return 0;
827
+ let uploaded = 0;
828
+ for (const step of steps) {
829
+ if (step.artifacts?.length) {
830
+ const links = await Promise.all(step.artifacts.map(f => this.uploader.uploadFileByPath(f, [runId, rid, 'steps', path_1.default.basename(f)])));
831
+ step.artifacts = links.filter(link => !!link);
832
+ uploaded += step.artifacts.length;
833
+ if (!step.artifacts.length)
834
+ delete step.artifacts;
835
+ }
836
+ uploaded += await this.uploadStepArtifacts(step.steps, runId, rid);
837
+ }
838
+ return uploaded;
839
+ }
801
840
  async uploadArtifacts() {
802
- for (const test of this._tests.filter(t => t.files && t.files.length > 0)) {
841
+ for (const test of this._tests) {
803
842
  const runId = this.runId || this.store.runId || Date.now().toString();
804
- const artifacts = await Promise.all(test.files.map(f => this.uploader.uploadFileByPath(f, [runId, test.rid, path_1.default.basename(f)])));
805
- test.artifacts = artifacts.filter(a => a && a.link).map(a => a.link);
843
+ // uploadFileByPath resolves to a link string, or undefined if skipped or failed
844
+ const links = await Promise.all((test.files || []).map(f => this.uploader.uploadFileByPath(f, [runId, test.rid, path_1.default.basename(f)])));
845
+ test.artifacts = links.filter(link => !!link);
806
846
  delete test.files;
807
- if (test.artifacts.length > 0) {
808
- console.log(constants_js_1.APP_PREFIX, `🗄️ Uploaded ${picocolors_1.default.bold(`${test.artifacts.length} artifacts`)} for test ${test.title}`);
847
+ const stepArtifacts = await this.uploadStepArtifacts(test.steps, runId, test.rid);
848
+ const total = test.artifacts.length + stepArtifacts;
849
+ if (total > 0) {
850
+ console.log(constants_js_1.APP_PREFIX, `🗄️ Uploaded ${picocolors_1.default.bold(`${total} artifacts`)} for test ${test.title}`);
809
851
  }
810
852
  }
811
853
  }
package/lib/bin/cli.js CHANGED
@@ -53,9 +53,9 @@ program
53
53
  program
54
54
  .command('start')
55
55
  .description('Start a new run and return its ID')
56
- .option('--kind <type>', 'Specify run type: automated, manual, or mixed')
56
+ .option('--kind <type>', 'Specify run type: automated, manual, mixed, or detect')
57
57
  .option('--filter <filter>', 'Scope the prepared run to tests matching the filter (no execution)')
58
- .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)')
59
59
  .option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only)')
60
60
  .action(async (opts) => {
61
61
  (0, utils_js_1.cleanLatestRunId)();
@@ -89,8 +89,8 @@ program
89
89
  // pipes add their report now and replace it when the run is finished
90
90
  const plannedTests = (client.pipeStore.preparedTestIds || []).map(id => ({ test_id: id, title: id }));
91
91
  await client.updateRunStatus('pending', { tests: plannedTests });
92
- // stdout carries ONLY the run id so it can be captured: RUN_ID=$(reporter start)
93
- console.log(runId);
92
+ // 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));
94
94
  process.exit(0);
95
95
  });
96
96
  program
@@ -120,8 +120,8 @@ program
120
120
  .argument('[command]', 'Test runner command')
121
121
  .option('--filter <filter>', 'Additional execution filter')
122
122
  .option('--filter-list <filter>', 'Get a list of all tests by filter before running')
123
- .option('--format <format>', 'Machine-readable output format for --filter-list (grep, json, newline, ids)')
124
- .option('--kind <type>', 'Specify run type: automated, manual, or mixed')
123
+ .option('--format <format>', 'Machine-readable output: test ids for --filter-list (grep, json, newline, ids), or the run created (id, json)')
124
+ .option('--kind <type>', 'Specify run type: automated, manual, mixed, or detect')
125
125
  .option('--remote <profile>', 'Trigger run on the named Testomat.io CI profile instead of executing locally')
126
126
  .option('--remote-param <kv>', 'key=value pair forwarded to the CI profile config (repeat for multiple)', (value, prev) => prev.concat([value]), [])
127
127
  .option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only)')
@@ -210,6 +210,9 @@ program
210
210
  }
211
211
  log_js_1.log.info(`🚀 CI build triggered on profile ${picocolors_1.default.cyan(opts.remote)}`);
212
212
  log_js_1.log.info(`📊 Report URL: ${picocolors_1.default.magenta(client.pipeStore.runUrl)}`);
213
+ const remoteOutput = (0, pipe_utils_js_1.formatRunOutput)(client.pipeStore, opts.format);
214
+ if (opts.format && remoteOutput)
215
+ console.log(remoteOutput);
213
216
  return process.exit(0);
214
217
  }
215
218
  // just create a run (wich tests which match filters) without executing tests
@@ -230,6 +233,9 @@ program
230
233
  log_js_1.log.info(`No command passed, so you need to run tests yourself:`);
231
234
  log_js_1.log.info(`TESTOMATIO_RUN=${runId} <command>`);
232
235
  }
236
+ const runOutput = (0, pipe_utils_js_1.formatRunOutput)({ ...client.pipeStore, runId }, opts.format);
237
+ if (opts.format && runOutput)
238
+ console.log(runOutput);
233
239
  }
234
240
  else {
235
241
  log_js_1.log.info('⚠️ No API key provided. Cannot create run without TESTOMATIO key.');
@@ -262,7 +268,12 @@ program
262
268
  createRunParams.kind = opts.kind;
263
269
  }
264
270
  if (apiKey) {
265
- await client.createRun(createRunParams).then(runTests);
271
+ await client.createRun(createRunParams);
272
+ // the runner inherits stdout, so the run data is printed first, on its own line
273
+ const createdOutput = (0, pipe_utils_js_1.formatRunOutput)(client.pipeStore, opts.format);
274
+ if (opts.format && createdOutput)
275
+ console.log(createdOutput);
276
+ await runTests();
266
277
  }
267
278
  else {
268
279
  await runTests();
@@ -12,6 +12,7 @@ declare class HtmlPipe {
12
12
  filenameMsg: string;
13
13
  tests: any[];
14
14
  configuration: any;
15
+ startedAt: any;
15
16
  htmlReportDir: any;
16
17
  htmlReportName: string;
17
18
  templateFolderPath: string;
package/lib/pipe/html.js CHANGED
@@ -31,6 +31,7 @@ class HtmlPipe {
31
31
  this.filenameMsg = '';
32
32
  this.tests = [];
33
33
  this.configuration = null;
34
+ this.startedAt = null;
34
35
  if (this.isHtml) {
35
36
  this.isEnabled = true;
36
37
  this.htmlReportDir = params.reportDir || process.env.TESTOMATIO_HTML_REPORT_FOLDER || constants_js_1.HTML_REPORT.FOLDER;
@@ -58,6 +59,7 @@ class HtmlPipe {
58
59
  }
59
60
  }
60
61
  async createRun(params = {}) {
62
+ this.startedAt ??= new Date();
61
63
  if (params?.configuration && typeof params.configuration === 'object') {
62
64
  this.configuration = { ...(this.configuration || {}), ...params.configuration };
63
65
  }
@@ -215,7 +217,7 @@ class HtmlPipe {
215
217
  parallel: runParams.isParallel || 'No parallel info',
216
218
  runUrl: this.store.runUrl || '',
217
219
  executionTime: testExecutionSumTime(aggregatedTests),
218
- executionDate: getCurrentDateTimeFormatted(),
220
+ executionDate: getDateTimeFormatted(this.startedAt || new Date()),
219
221
  description: [this.description, runParams.description || this.store.coverageDescription || this.store.description]
220
222
  .filter(Boolean)
221
223
  .join('\n\n') || '',
@@ -599,17 +601,17 @@ function formatDuration(duration) {
599
601
  return `${hours}h ${minutes}m ${seconds}s ${milliseconds}ms`;
600
602
  }
601
603
  /**
602
- * Retrieves the current date and time in a formatted string.
604
+ * Formats a date and time for display in the report.
605
+ * @param {Date} date - Date and time to format.
603
606
  * @returns {string} - The formatted date and time string (e.g., "(01/01/2023 12:00:00)").
604
607
  */
605
- function getCurrentDateTimeFormatted() {
606
- const currentDate = new Date();
607
- const day = currentDate.getDate().toString().padStart(2, '0');
608
- const month = (currentDate.getMonth() + 1).toString().padStart(2, '0');
609
- const year = currentDate.getFullYear();
610
- const hours = currentDate.getHours().toString().padStart(2, '0');
611
- const minutes = currentDate.getMinutes().toString().padStart(2, '0');
612
- const seconds = currentDate.getSeconds().toString().padStart(2, '0');
608
+ function getDateTimeFormatted(date) {
609
+ const day = date.getDate().toString().padStart(2, '0');
610
+ const month = (date.getMonth() + 1).toString().padStart(2, '0');
611
+ const year = date.getFullYear();
612
+ const hours = date.getHours().toString().padStart(2, '0');
613
+ const minutes = date.getMinutes().toString().padStart(2, '0');
614
+ const seconds = date.getSeconds().toString().padStart(2, '0');
613
615
  return `(${day}/${month}/${year} ${hours}:${minutes}:${seconds})`;
614
616
  }
615
617
  /**
@@ -108,6 +108,19 @@ export function parsePipeOptions(optionsStr?: string): any;
108
108
  * @returns {string} Empty string if no ids; otherwise the formatted output.
109
109
  */
110
110
  export function formatFilterListIds(ids: string[], format: "grep" | "json" | "newline" | "ids"): string;
111
+ /**
112
+ * Format the created run for machine-readable output of `start` and `run`.
113
+ * `json` prints an object with the run details, any other format prints the bare run id.
114
+ *
115
+ * @param {{runId?: string, runUrl?: string, runPublicUrl?: string}} store - Pipe store of the client.
116
+ * @param {string} [format] - Value of the CLI `--format` option.
117
+ * @returns {string} Empty string if there is no run id.
118
+ */
119
+ export function formatRunOutput(store: {
120
+ runId?: string;
121
+ runUrl?: string;
122
+ runPublicUrl?: string;
123
+ }, format?: string): string;
111
124
  /**
112
125
  * Calculate the approximate size of data in bytes (JSON stringified, UTF-8 encoded length).
113
126
  * @param {Object} data - Data to measure
@@ -15,6 +15,7 @@ exports.totalDuration = totalDuration;
15
15
  exports.plannedTestsLabel = plannedTestsLabel;
16
16
  exports.parsePipeOptions = parsePipeOptions;
17
17
  exports.formatFilterListIds = formatFilterListIds;
18
+ exports.formatRunOutput = formatRunOutput;
18
19
  exports.getObjectSize = getObjectSize;
19
20
  exports.splitTestsIntoChunks = splitTestsIntoChunks;
20
21
  const humanize_duration_1 = __importDefault(require("humanize-duration"));
@@ -302,6 +303,27 @@ function plannedTestsLabel(tests, testsCount) {
302
303
  return `**${suitesCount}** suites planned`;
303
304
  return `**${knownTestsCount}** tests and **${suitesCount}** suites planned`;
304
305
  }
306
+ /**
307
+ * Format the created run for machine-readable output of `start` and `run`.
308
+ * `json` prints an object with the run details, any other format prints the bare run id.
309
+ *
310
+ * @param {{runId?: string, runUrl?: string, runPublicUrl?: string}} store - Pipe store of the client.
311
+ * @param {string} [format] - Value of the CLI `--format` option.
312
+ * @returns {string} Empty string if there is no run id.
313
+ */
314
+ function formatRunOutput(store, format) {
315
+ const runId = store?.runId;
316
+ if (!runId)
317
+ return '';
318
+ if (format !== 'json')
319
+ return runId;
320
+ const output = { runId };
321
+ if (store.runUrl)
322
+ output.runUrl = store.runUrl;
323
+ if (store.runPublicUrl)
324
+ output.runPublicUrl = store.runPublicUrl;
325
+ return JSON.stringify(output);
326
+ }
305
327
 
306
328
  module.exports.updateFilterType = updateFilterType;
307
329
 
@@ -327,6 +349,8 @@ module.exports.parsePipeOptions = parsePipeOptions;
327
349
 
328
350
  module.exports.formatFilterListIds = formatFilterListIds;
329
351
 
352
+ module.exports.formatRunOutput = formatRunOutput;
353
+
330
354
  module.exports.getObjectSize = getObjectSize;
331
355
 
332
356
  module.exports.splitTestsIntoChunks = splitTestsIntoChunks;
package/lib/xmlReader.js CHANGED
@@ -459,8 +459,10 @@ class XmlReader {
459
459
  if (!files.length)
460
460
  continue;
461
461
  const runId = this.runId || this.store.runId || Date.now().toString();
462
- test.artifacts = await Promise.all(files.map(f => this.uploader.uploadFileByPath(f, [runId, path_1.default.basename(f)])));
463
- log_js_1.log.info(`🗄️ Uploaded ${picocolors_1.default.bold(`${files.length} artifacts`)} for test ${test.title}`);
462
+ // undefined for skipped/failed uploads; keeping those serializes as `null` links
463
+ const links = await Promise.all(files.map(f => this.uploader.uploadFileByPath(f, [runId, path_1.default.basename(f)])));
464
+ test.artifacts = links.filter(link => !!link);
465
+ log_js_1.log.info(`🗄️ Uploaded ${picocolors_1.default.bold(`${test.artifacts.length} artifacts`)} for test ${test.title}`);
464
466
  }
465
467
  }
466
468
  async createRun() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.14.0-beta",
3
+ "version": "2.15.0-beta.1-json-output",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -183,7 +183,7 @@ class AllureReader {
183
183
  suite_title: this.extractSuiteTitle(result),
184
184
  file: this.extractFile(result),
185
185
  run_time: this.calculateRunTime(result),
186
- steps: this.convertSteps(result.steps || []),
186
+ steps: this.convertSteps(result.steps || [], resultsDir),
187
187
  message: result.statusDetails?.message || '',
188
188
  stack: result.statusDetails?.trace || '',
189
189
  meta: this.extractMeta(result),
@@ -209,21 +209,9 @@ class AllureReader {
209
209
  test.example = this.convertParameters(result.parameters);
210
210
  }
211
211
 
212
- if (result.attachments && result.attachments.length > 0) {
213
- const attachments = result.attachments
214
- .map(att => {
215
- const fullPath = path.join(resultsDir, att.source);
216
- if (fs.existsSync(fullPath)) {
217
- return fullPath;
218
- }
219
- debug('Attachment file not found:', fullPath);
220
- return null;
221
- })
222
- .filter(Boolean);
223
-
224
- if (attachments.length > 0) {
225
- test.files = attachments;
226
- }
212
+ const attachments = this.resolveAttachments(result.attachments, resultsDir);
213
+ if (attachments.length > 0) {
214
+ test.files = attachments;
227
215
  }
228
216
 
229
217
  return test;
@@ -531,7 +519,7 @@ class AllureReader {
531
519
  return this.extractTmsIdsFromSource(contents, test)[0] || null;
532
520
  }
533
521
 
534
- convertSteps(steps, depth = 0) {
522
+ convertSteps(steps, resultsDir = '', depth = 0) {
535
523
  if (depth >= 10) return null;
536
524
 
537
525
  return steps
@@ -541,9 +529,15 @@ class AllureReader {
541
529
  title: step.name || step.title || 'Unknown step',
542
530
  status: this.mapStepStatus(step.status),
543
531
  duration: this.calculateRunTime(step),
544
- steps: this.convertSteps(step.steps || [], depth + 1),
532
+ steps: this.convertSteps(step.steps || [], resultsDir, depth + 1),
545
533
  };
546
534
 
535
+ // step attachments stay on the step; uploadArtifacts() swaps the paths for links
536
+ const attachments = this.resolveAttachments(step.attachments, resultsDir);
537
+ if (attachments.length > 0) {
538
+ convertedStep.artifacts = attachments;
539
+ }
540
+
547
541
  // Attach the failure description (error message + trace with the failing
548
542
  // code line) straight onto the failed step. Testomat.io renders a step's
549
543
  // `error` inline in the step tree, so the failure shows up on the exact
@@ -876,16 +870,68 @@ class AllureReader {
876
870
  return paths;
877
871
  }
878
872
 
873
+ /**
874
+ * @param {Array<{source?: string}>|undefined} attachments
875
+ * @param {string} resultsDir
876
+ * @returns {string[]} paths of attachments that exist on disk
877
+ */
878
+ resolveAttachments(attachments, resultsDir) {
879
+ if (!attachments || !attachments.length) return [];
880
+
881
+ return attachments
882
+ .map(att => {
883
+ if (!att?.source) return null;
884
+ const fullPath = path.join(resultsDir || '', att.source);
885
+ if (fs.existsSync(fullPath)) return fullPath;
886
+ debug('Attachment file not found:', fullPath);
887
+ return null;
888
+ })
889
+ .filter(Boolean);
890
+ }
891
+
892
+ /**
893
+ * Replaces step artifact paths with S3 links, dropping failed uploads (a local path
894
+ * would be a dead link in the UI).
895
+ *
896
+ * @param {Array<object>|undefined} steps
897
+ * @param {string} runId
898
+ * @param {string} rid
899
+ * @returns {Promise<number>} number of uploaded step artifacts
900
+ */
901
+ async uploadStepArtifacts(steps, runId, rid) {
902
+ if (!steps || !steps.length) return 0;
903
+
904
+ let uploaded = 0;
905
+ for (const step of steps) {
906
+ if (step.artifacts?.length) {
907
+ const links = await Promise.all(
908
+ step.artifacts.map(f => this.uploader.uploadFileByPath(f, [runId, rid, 'steps', path.basename(f)])),
909
+ );
910
+ step.artifacts = links.filter(link => !!link);
911
+ uploaded += step.artifacts.length;
912
+ if (!step.artifacts.length) delete step.artifacts;
913
+ }
914
+ uploaded += await this.uploadStepArtifacts(step.steps, runId, rid);
915
+ }
916
+ return uploaded;
917
+ }
918
+
879
919
  async uploadArtifacts() {
880
- for (const test of this._tests.filter(t => t.files && t.files.length > 0)) {
920
+ for (const test of this._tests) {
881
921
  const runId = this.runId || this.store.runId || Date.now().toString();
882
- const artifacts = await Promise.all(
883
- test.files.map(f => this.uploader.uploadFileByPath(f, [runId, test.rid, path.basename(f)])),
922
+
923
+ // uploadFileByPath resolves to a link string, or undefined if skipped or failed
924
+ const links = await Promise.all(
925
+ (test.files || []).map(f => this.uploader.uploadFileByPath(f, [runId, test.rid, path.basename(f)])),
884
926
  );
885
- test.artifacts = artifacts.filter(a => a && a.link).map(a => a.link);
927
+ test.artifacts = links.filter(link => !!link);
886
928
  delete test.files;
887
- if (test.artifacts.length > 0) {
888
- console.log(APP_PREFIX, `🗄️ Uploaded ${pc.bold(`${test.artifacts.length} artifacts`)} for test ${test.title}`);
929
+
930
+ const stepArtifacts = await this.uploadStepArtifacts(test.steps, runId, test.rid);
931
+
932
+ const total = test.artifacts.length + stepArtifacts;
933
+ if (total > 0) {
934
+ console.log(APP_PREFIX, `🗄️ Uploaded ${pc.bold(`${total} artifacts`)} for test ${test.title}`);
889
935
  }
890
936
  }
891
937
  }
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
 
@@ -51,9 +51,9 @@ program
51
51
  program
52
52
  .command('start')
53
53
  .description('Start a new run and return its ID')
54
- .option('--kind <type>', 'Specify run type: automated, manual, or mixed')
54
+ .option('--kind <type>', 'Specify run type: automated, manual, mixed, or detect')
55
55
  .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)')
56
+ .option('--format <format>', 'Machine-readable output: the run id (--format id) or run details (--format json)')
57
57
  .option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only)')
58
58
  .action(async opts => {
59
59
  cleanLatestRunId();
@@ -93,8 +93,8 @@ program
93
93
  const plannedTests = (client.pipeStore.preparedTestIds || []).map(id => ({ test_id: id, title: id }));
94
94
  await client.updateRunStatus('pending', { tests: plannedTests });
95
95
 
96
- // stdout carries ONLY the run id so it can be captured: RUN_ID=$(reporter start)
97
- console.log(runId);
96
+ // stdout carries ONLY the run data so it can be captured: RUN_ID=$(reporter start)
97
+ console.log(formatRunOutput({ ...client.pipeStore, runId }, opts.format));
98
98
  process.exit(0);
99
99
  });
100
100
 
@@ -129,8 +129,11 @@ program
129
129
  .argument('[command]', 'Test runner command')
130
130
  .option('--filter <filter>', 'Additional execution filter')
131
131
  .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)')
133
- .option('--kind <type>', 'Specify run type: automated, manual, or mixed')
132
+ .option(
133
+ '--format <format>',
134
+ 'Machine-readable output: test ids for --filter-list (grep, json, newline, ids), or the run created (id, json)',
135
+ )
136
+ .option('--kind <type>', 'Specify run type: automated, manual, mixed, or detect')
134
137
  .option('--remote <profile>', 'Trigger run on the named Testomat.io CI profile instead of executing locally')
135
138
  .option(
136
139
  '--remote-param <kv>',
@@ -230,6 +233,8 @@ program
230
233
 
231
234
  log.info(`🚀 CI build triggered on profile ${pc.cyan(opts.remote)}`);
232
235
  log.info(`📊 Report URL: ${pc.magenta(client.pipeStore.runUrl)}`);
236
+ const remoteOutput = formatRunOutput(client.pipeStore, opts.format);
237
+ if (opts.format && remoteOutput) console.log(remoteOutput);
233
238
  return process.exit(0);
234
239
  }
235
240
 
@@ -252,6 +257,8 @@ program
252
257
  log.info( `No command passed, so you need to run tests yourself:`);
253
258
  log.info( `TESTOMATIO_RUN=${runId} <command>`);
254
259
  }
260
+ const runOutput = formatRunOutput({ ...client.pipeStore, runId }, opts.format);
261
+ if (opts.format && runOutput) console.log(runOutput);
255
262
  } else {
256
263
  log.info( '⚠️ No API key provided. Cannot create run without TESTOMATIO key.');
257
264
  process.exit(1);
@@ -288,7 +295,11 @@ program
288
295
  }
289
296
 
290
297
  if (apiKey) {
291
- await client.createRun(createRunParams).then(runTests);
298
+ await client.createRun(createRunParams);
299
+ // the runner inherits stdout, so the run data is printed first, on its own line
300
+ const createdOutput = formatRunOutput(client.pipeStore, opts.format);
301
+ if (opts.format && createdOutput) console.log(createdOutput);
302
+ await runTests();
292
303
  } else {
293
304
  await runTests();
294
305
  }
package/src/pipe/html.js CHANGED
@@ -32,6 +32,7 @@ class HtmlPipe {
32
32
  this.filenameMsg = '';
33
33
  this.tests = [];
34
34
  this.configuration = null;
35
+ this.startedAt = null;
35
36
 
36
37
  if (this.isHtml) {
37
38
  this.isEnabled = true;
@@ -70,6 +71,8 @@ class HtmlPipe {
70
71
  }
71
72
 
72
73
  async createRun(params = {}) {
74
+ this.startedAt ??= new Date();
75
+
73
76
  if (params?.configuration && typeof params.configuration === 'object') {
74
77
  this.configuration = { ...(this.configuration || {}), ...params.configuration };
75
78
  }
@@ -266,7 +269,7 @@ class HtmlPipe {
266
269
  parallel: runParams.isParallel || 'No parallel info',
267
270
  runUrl: this.store.runUrl || '',
268
271
  executionTime: testExecutionSumTime(aggregatedTests),
269
- executionDate: getCurrentDateTimeFormatted(),
272
+ executionDate: getDateTimeFormatted(this.startedAt || new Date()),
270
273
  description:
271
274
  [this.description, runParams.description || this.store.coverageDescription || this.store.description]
272
275
  .filter(Boolean)
@@ -722,17 +725,17 @@ function formatDuration(duration) {
722
725
  }
723
726
 
724
727
  /**
725
- * Retrieves the current date and time in a formatted string.
728
+ * Formats a date and time for display in the report.
729
+ * @param {Date} date - Date and time to format.
726
730
  * @returns {string} - The formatted date and time string (e.g., "(01/01/2023 12:00:00)").
727
731
  */
728
- function getCurrentDateTimeFormatted() {
729
- const currentDate = new Date();
730
- const day = currentDate.getDate().toString().padStart(2, '0');
731
- const month = (currentDate.getMonth() + 1).toString().padStart(2, '0');
732
- const year = currentDate.getFullYear();
733
- const hours = currentDate.getHours().toString().padStart(2, '0');
734
- const minutes = currentDate.getMinutes().toString().padStart(2, '0');
735
- const seconds = currentDate.getSeconds().toString().padStart(2, '0');
732
+ function getDateTimeFormatted(date) {
733
+ const day = date.getDate().toString().padStart(2, '0');
734
+ const month = (date.getMonth() + 1).toString().padStart(2, '0');
735
+ const year = date.getFullYear();
736
+ const hours = date.getHours().toString().padStart(2, '0');
737
+ const minutes = date.getMinutes().toString().padStart(2, '0');
738
+ const seconds = date.getSeconds().toString().padStart(2, '0');
736
739
 
737
740
  return `(${day}/${month}/${year} ${hours}:${minutes}:${seconds})`;
738
741
  }
@@ -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
  };
package/src/xmlReader.js CHANGED
@@ -533,8 +533,10 @@ class XmlReader {
533
533
  if (!files.length) continue;
534
534
 
535
535
  const runId = this.runId || this.store.runId || Date.now().toString();
536
- test.artifacts = await Promise.all(files.map(f => this.uploader.uploadFileByPath(f, [runId, path.basename(f)])));
537
- log.info(`🗄️ Uploaded ${pc.bold(`${files.length} artifacts`)} for test ${test.title}`);
536
+ // undefined for skipped/failed uploads; keeping those serializes as `null` links
537
+ const links = await Promise.all(files.map(f => this.uploader.uploadFileByPath(f, [runId, path.basename(f)])));
538
+ test.artifacts = links.filter(link => !!link);
539
+ log.info(`🗄️ Uploaded ${pc.bold(`${test.artifacts.length} artifacts`)} for test ${test.title}`);
538
540
  }
539
541
  }
540
542
 
package/types/types.d.ts CHANGED
@@ -337,8 +337,8 @@ export interface PipeResult {
337
337
  * `TESTOMATIO_CI_PROFILE` and `TESTOMATIO_CI_OVERRIDE`.
338
338
  */
339
339
  export interface CreateRunParams {
340
- /** Run kind. Defaults to `automated` server-side. */
341
- kind?: 'automated' | 'manual' | 'mixed';
340
+ /** Run kind. Defaults to `automated` server-side. `detect` resolves to one of the other three from the scoped tests. */
341
+ kind?: 'automated' | 'manual' | 'mixed' | 'detect';
342
342
 
343
343
  /** Run title. */
344
344
  title?: string;