@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.
- package/lib/allureReader.d.ts +19 -1
- package/lib/allureReader.js +64 -22
- package/lib/bin/cli.js +18 -7
- package/lib/pipe/html.d.ts +1 -0
- package/lib/pipe/html.js +12 -10
- package/lib/utils/pipe_utils.d.ts +13 -0
- package/lib/utils/pipe_utils.js +24 -0
- package/lib/xmlReader.js +4 -2
- package/package.json +1 -1
- package/src/allureReader.js +70 -24
- package/src/bin/cli.js +19 -8
- package/src/pipe/html.js +13 -10
- package/src/utils/pipe_utils.js +22 -0
- package/src/xmlReader.js +4 -2
- package/types/types.d.ts +2 -2
package/lib/allureReader.d.ts
CHANGED
|
@@ -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
|
}
|
package/lib/allureReader.js
CHANGED
|
@@ -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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
841
|
+
for (const test of this._tests) {
|
|
803
842
|
const runId = this.runId || this.store.runId || Date.now().toString();
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
808
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
124
|
-
.option('--kind <type>', 'Specify run type: automated, manual, or
|
|
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)
|
|
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();
|
package/lib/pipe/html.d.ts
CHANGED
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:
|
|
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
|
-
*
|
|
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
|
|
606
|
-
const
|
|
607
|
-
const
|
|
608
|
-
const
|
|
609
|
-
const
|
|
610
|
-
const
|
|
611
|
-
const
|
|
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
|
package/lib/utils/pipe_utils.js
CHANGED
|
@@ -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
|
-
|
|
463
|
-
|
|
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
package/src/allureReader.js
CHANGED
|
@@ -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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
920
|
+
for (const test of this._tests) {
|
|
881
921
|
const runId = this.runId || this.store.runId || Date.now().toString();
|
|
882
|
-
|
|
883
|
-
|
|
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 =
|
|
927
|
+
test.artifacts = links.filter(link => !!link);
|
|
886
928
|
delete test.files;
|
|
887
|
-
|
|
888
|
-
|
|
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
|
|
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:
|
|
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
|
|
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(
|
|
133
|
-
|
|
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)
|
|
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:
|
|
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
|
-
*
|
|
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
|
|
729
|
-
const
|
|
730
|
-
const
|
|
731
|
-
const
|
|
732
|
-
const
|
|
733
|
-
const
|
|
734
|
-
const
|
|
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
|
}
|
package/src/utils/pipe_utils.js
CHANGED
|
@@ -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
|
-
|
|
537
|
-
|
|
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;
|