@testomatio/reporter 2.9.3-beta.4-allure-links → 2.9.4
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/README.md +2 -4
- package/lib/adapter/codecept.js +33 -14
- package/lib/adapter/playwright.js +19 -8
- package/lib/bin/cli.js +68 -34
- package/lib/client.js +4 -3
- package/lib/junit-adapter/index.js +0 -4
- package/lib/pipe/debug.d.ts +6 -0
- package/lib/pipe/debug.js +11 -0
- package/lib/pipe/html.d.ts +1 -0
- package/lib/pipe/html.js +152 -45
- package/lib/replay.js +21 -0
- package/lib/template/testomatio.hbs +77 -11
- package/lib/utils/pipe_utils.d.ts +0 -17
- package/lib/utils/pipe_utils.js +0 -45
- package/lib/utils/utils.js +0 -9
- package/lib/xmlReader.d.ts +1 -0
- package/lib/xmlReader.js +38 -2
- package/package.json +1 -1
- package/src/adapter/codecept.js +35 -12
- package/src/adapter/playwright.js +22 -9
- package/src/bin/cli.js +74 -43
- package/src/client.js +4 -3
- package/src/junit-adapter/index.js +0 -4
- package/src/pipe/debug.js +11 -0
- package/src/pipe/html.js +171 -52
- package/src/replay.js +21 -0
- package/src/template/testomatio.hbs +77 -11
- package/src/utils/pipe_utils.js +0 -49
- package/src/utils/utils.js +0 -5
- package/src/xmlReader.js +47 -2
- package/lib/allureReader.d.ts +0 -217
- package/lib/allureReader.js +0 -860
- package/lib/junit-adapter/kotlin.d.ts +0 -5
- package/lib/junit-adapter/kotlin.js +0 -46
- package/src/allureReader.js +0 -954
- package/src/junit-adapter/kotlin.js +0 -48
package/README.md
CHANGED
|
@@ -76,7 +76,6 @@ npx testomatio-reporter <command> [options]
|
|
|
76
76
|
| [TestCafe](./docs/frameworks.md#testcafe) | [Detox](./docs/frameworks.md#detox) | [Codeception](https://github.com/testomatio/php-reporter) |
|
|
77
77
|
| [Newman (Postman)](./docs/frameworks.md#newman) | [JUnit](./docs/junit.md#junit) | [NUnit](./docs/junit.md#nunit) |
|
|
78
78
|
| [PyTest](./docs/junit.md#pytest) | [PHPUnit](./docs/junit.md#phpunit) | [Protractor](./docs/frameworks.md#protractor) |
|
|
79
|
-
| [Allure](./docs/allure.md) | | |
|
|
80
79
|
|
|
81
80
|
or **any [other via JUnit](./docs/junit.md)** report....
|
|
82
81
|
|
|
@@ -138,10 +137,9 @@ Bring this reporter on CI and never lose test results again!
|
|
|
138
137
|
- [HTML report](./docs/pipes/html.md)
|
|
139
138
|
- [Markdown report](./docs/pipes/markdown.md)
|
|
140
139
|
- [Bitbucket](./docs/pipes/bitbucket.md)
|
|
141
|
-
- 📓 [JUnit Reports](./docs/junit.md)
|
|
142
|
-
- 🗄️ [Artifacts](./docs/artifacts.md)
|
|
143
|
-
- 🔬 [Allure Reports](./docs/allure.md)
|
|
144
140
|
- 🔗 [Linking Tests](./docs/linking-tests.md)
|
|
141
|
+
- 📓 [JUnit](./docs/junit.md)
|
|
142
|
+
- 🗄️ [Artifacts](./docs/artifacts.md)
|
|
145
143
|
- 🔂 [Workflows](./docs/workflows.md)
|
|
146
144
|
- 🖊️ [Logger](./docs/logger.md)
|
|
147
145
|
- 🪲 [Debug File Format](./docs/debug-file-format.md)
|
package/lib/adapter/codecept.js
CHANGED
|
@@ -135,20 +135,36 @@ function CodeceptReporter(config) {
|
|
|
135
135
|
});
|
|
136
136
|
// mark as failed all tests inside the failed hook
|
|
137
137
|
event.dispatcher.on(event.hook.failed, hook => {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
138
|
+
const error = hook?.ctx?.currentTest?.err || hook?.err;
|
|
139
|
+
// Handle BeforeSuite and Before hooks: mark all tests in suite as failed
|
|
140
|
+
if (hook.name === 'BeforeSuiteHook' || hook.name === 'BeforeHook') {
|
|
141
|
+
const suite = hook.runnable.parent;
|
|
142
|
+
if (!suite)
|
|
143
|
+
return;
|
|
144
|
+
for (const test of suite.tests) {
|
|
145
|
+
reportedTestUids.add(test.uid);
|
|
146
|
+
const reportTestPromise = client.addTestRun('failed', {
|
|
147
|
+
...stripExampleFromTitle(test.title),
|
|
148
|
+
rid: test.uid,
|
|
149
|
+
test_id: (0, utils_js_1.getTestomatIdFromTestTitle)(test.title),
|
|
150
|
+
suite_title: stripTagsFromTitle(suite.title),
|
|
151
|
+
error,
|
|
152
|
+
time: hook?.runnable?.duration,
|
|
153
|
+
});
|
|
154
|
+
reportTestPromises.push(reportTestPromise);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (hook.name === 'AfterSuiteHook') {
|
|
158
|
+
const suite = hook.runnable?.parent || hook.suite;
|
|
159
|
+
const suiteTitle = suite ? stripTagsFromTitle(suite.title) : 'Unknown Suite';
|
|
160
|
+
const stepHierarchy = buildUnifiedStepHierarchy(null, hookSteps);
|
|
161
|
+
// Report the hook failure as a separate test entry
|
|
146
162
|
const reportTestPromise = client.addTestRun('failed', {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
test_id: (0, utils_js_1.getTestomatIdFromTestTitle)(test.title),
|
|
150
|
-
suite_title: stripTagsFromTitle(suite.title),
|
|
163
|
+
title: 'AfterSuite Hook',
|
|
164
|
+
suite_title: suiteTitle,
|
|
151
165
|
error,
|
|
166
|
+
message: error?.message || 'AfterSuite hook failed',
|
|
167
|
+
steps: stepHierarchy,
|
|
152
168
|
time: hook?.runnable?.duration,
|
|
153
169
|
});
|
|
154
170
|
reportTestPromises.push(reportTestPromise);
|
|
@@ -169,8 +185,10 @@ function CodeceptReporter(config) {
|
|
|
169
185
|
testTimeMap[test.uid] = Date.now();
|
|
170
186
|
});
|
|
171
187
|
event.dispatcher.on(event.all.after, () => {
|
|
172
|
-
|
|
173
|
-
|
|
188
|
+
setImmediate(() => {
|
|
189
|
+
finalizeRun('all.after').catch(err => {
|
|
190
|
+
debug('Error finalizing run:', err);
|
|
191
|
+
});
|
|
174
192
|
});
|
|
175
193
|
});
|
|
176
194
|
event.dispatcher.on(event.test.skipped, test => {
|
|
@@ -237,6 +255,7 @@ async function uploadAttachments(client, attachments, messagePrefix, attachmentT
|
|
|
237
255
|
if (client.uploader.isEnabled) {
|
|
238
256
|
log_js_1.log.info(`Attachments: ${messagePrefix} ${attachments.length} ${attachmentType} ...`);
|
|
239
257
|
}
|
|
258
|
+
await Promise.all(client.pipes?.map(pipe => pipe.addArtifacts?.(attachments)) || []);
|
|
240
259
|
const promises = attachments.map(async (attachment) => {
|
|
241
260
|
const { rid, title, path, type } = attachment;
|
|
242
261
|
const file = { path, type, title };
|
|
@@ -51,13 +51,8 @@ class PlaywrightReporter {
|
|
|
51
51
|
const { title } = test;
|
|
52
52
|
const { error, duration } = result;
|
|
53
53
|
const pwAttachments = (result.attachments || []).filter(a => a.body || a.path);
|
|
54
|
-
const files = pwAttachments
|
|
55
|
-
|
|
56
|
-
path: this.#getArtifactPath(att),
|
|
57
|
-
title: att.name || title,
|
|
58
|
-
type: att.contentType,
|
|
59
|
-
}))
|
|
60
|
-
.filter(f => f.path);
|
|
54
|
+
const files = buildArtifactFiles(pwAttachments, attachment => this.#getArtifactPath(attachment), title);
|
|
55
|
+
const artifactsForUpload = processArtifactsForUpload(pwAttachments);
|
|
61
56
|
const suite_title = test.parent ? test.parent?.title : path_1.default.basename(test?.location?.file);
|
|
62
57
|
const rid = test.id || test.testId || (0, uuid_1.v4)();
|
|
63
58
|
/**
|
|
@@ -144,7 +139,7 @@ class PlaywrightReporter {
|
|
|
144
139
|
this.uploads.push({
|
|
145
140
|
rid: `${rid}-${project.name}`,
|
|
146
141
|
title: test.title,
|
|
147
|
-
files:
|
|
142
|
+
files: artifactsForUpload,
|
|
148
143
|
file: test.location?.file,
|
|
149
144
|
});
|
|
150
145
|
// remove empty uploads
|
|
@@ -210,6 +205,22 @@ function checkStatus(status) {
|
|
|
210
205
|
passed: constants_js_1.STATUS.PASSED,
|
|
211
206
|
}[status] || constants_js_1.STATUS.FAILED);
|
|
212
207
|
}
|
|
208
|
+
function buildArtifactFiles(attachments, getArtifactPath, title) {
|
|
209
|
+
return attachments
|
|
210
|
+
.filter(isScreenshotArtifact)
|
|
211
|
+
.map(attachment => ({
|
|
212
|
+
path: getArtifactPath(attachment),
|
|
213
|
+
title: attachment.name || title,
|
|
214
|
+
type: attachment.contentType,
|
|
215
|
+
}))
|
|
216
|
+
.filter(file => file.path);
|
|
217
|
+
}
|
|
218
|
+
function processArtifactsForUpload(attachments) {
|
|
219
|
+
return attachments.filter(attachment => !isScreenshotArtifact(attachment));
|
|
220
|
+
}
|
|
221
|
+
function isScreenshotArtifact(attachment) {
|
|
222
|
+
return attachment?.contentType === 'image/png' && attachment?.name === 'screenshot';
|
|
223
|
+
}
|
|
213
224
|
function appendStep(step, shift = 0) {
|
|
214
225
|
// nesting too deep, ignore those steps
|
|
215
226
|
if (shift >= 10)
|
package/lib/bin/cli.js
CHANGED
|
@@ -10,7 +10,6 @@ const glob_1 = require("glob");
|
|
|
10
10
|
const debug_1 = __importDefault(require("debug"));
|
|
11
11
|
const client_js_1 = __importDefault(require("../client.js"));
|
|
12
12
|
const xmlReader_js_1 = __importDefault(require("../xmlReader.js"));
|
|
13
|
-
const allureReader_js_1 = __importDefault(require("../allureReader.js"));
|
|
14
13
|
const constants_js_1 = require("../constants.js");
|
|
15
14
|
const utils_js_1 = require("../utils/utils.js");
|
|
16
15
|
const config_js_1 = require("../config.js");
|
|
@@ -21,6 +20,8 @@ const dotenv_1 = __importDefault(require("dotenv"));
|
|
|
21
20
|
const replay_js_1 = __importDefault(require("../replay.js"));
|
|
22
21
|
const log_js_1 = require("../utils/log.js");
|
|
23
22
|
const pipe_utils_js_1 = require("../utils/pipe_utils.js");
|
|
23
|
+
const fs_1 = __importDefault(require("fs"));
|
|
24
|
+
const path_1 = __importDefault(require("path"));
|
|
24
25
|
const debug = (0, debug_1.default)('@testomatio/reporter:cli');
|
|
25
26
|
const version = (0, utils_js_1.getPackageVersion)();
|
|
26
27
|
const program = new commander_1.Command();
|
|
@@ -319,44 +320,54 @@ program
|
|
|
319
320
|
if (timeoutTimer)
|
|
320
321
|
clearTimeout(timeoutTimer);
|
|
321
322
|
});
|
|
322
|
-
program
|
|
323
|
-
.command('allure')
|
|
324
|
-
.description('Parse Allure result files and upload to Testomat.io')
|
|
325
|
-
.argument('<pattern>', 'Allure result directory pattern')
|
|
326
|
-
.option('-d, --dir <dir>', 'Project directory')
|
|
327
|
-
.option('--timelimit <time>', 'default time limit in seconds to kill a stuck process')
|
|
328
|
-
.option('--with-package', 'Keep full package path in file names (default: strip package prefix)')
|
|
329
|
-
.option('--java-tests [path]', 'Path to test sources; links cases from @TmsLink in source for skipped tests (default: src/test)')
|
|
330
|
-
.option('--lang <lang>', 'Language used (java, kotlin, ...)')
|
|
331
|
-
.action(async (pattern, opts) => {
|
|
332
|
-
let javaTests = opts.javaTests;
|
|
333
|
-
if (javaTests === true)
|
|
334
|
-
javaTests = 'src/test';
|
|
335
|
-
const runReader = new allureReader_js_1.default({ withPackage: opts.withPackage, javaTests, lang: opts.lang });
|
|
336
|
-
let timeoutTimer;
|
|
337
|
-
if (opts.timelimit) {
|
|
338
|
-
timeoutTimer = setTimeout(() => {
|
|
339
|
-
console.log(`⚠️ Reached timeout of ${opts.timelimit}s. Exiting... (Exit code is 0 to not fail the pipeline)`);
|
|
340
|
-
process.exit(0);
|
|
341
|
-
}, parseInt(opts.timelimit, 10) * 1000);
|
|
342
|
-
}
|
|
343
|
-
try {
|
|
344
|
-
await runReader.parse(pattern);
|
|
345
|
-
await runReader.createRun();
|
|
346
|
-
await runReader.uploadData();
|
|
347
|
-
}
|
|
348
|
-
catch (err) {
|
|
349
|
-
console.log(constants_js_1.APP_PREFIX, 'Error uploading Allure results:', err);
|
|
350
|
-
}
|
|
351
|
-
if (timeoutTimer)
|
|
352
|
-
clearTimeout(timeoutTimer);
|
|
353
|
-
});
|
|
354
323
|
program
|
|
355
324
|
.command('upload-artifacts')
|
|
356
325
|
.description('Upload artifacts to Testomat.io')
|
|
326
|
+
.argument('[jsonl-file]', 'Path to JSONL debug file (optional)')
|
|
357
327
|
.option('--force', 'Re-upload artifacts even if they were uploaded before')
|
|
358
|
-
.action(async (opts) => {
|
|
328
|
+
.action(async (jsonlFile, opts) => {
|
|
359
329
|
const apiKey = config_js_1.config.TESTOMATIO;
|
|
330
|
+
// JSONL file mode: upload artifacts from debug file
|
|
331
|
+
if (jsonlFile) {
|
|
332
|
+
if (!fs_1.default.existsSync(jsonlFile)) {
|
|
333
|
+
log_js_1.log.error(`JSONL file not found: ${jsonlFile}`);
|
|
334
|
+
return process.exit(1);
|
|
335
|
+
}
|
|
336
|
+
const replay = new replay_js_1.default({ apiKey });
|
|
337
|
+
const { tests, runId } = replay.parseDebugFile(jsonlFile);
|
|
338
|
+
if (!runId) {
|
|
339
|
+
log_js_1.log.error('runId not found in JSONL file');
|
|
340
|
+
return process.exit(1);
|
|
341
|
+
}
|
|
342
|
+
log_js_1.log.info(`Processing ${tests.length} tests from ${jsonlFile}`);
|
|
343
|
+
const client = new client_js_1.default({
|
|
344
|
+
apiKey,
|
|
345
|
+
runId,
|
|
346
|
+
batchMode: constants_js_1.BATCH_MODE.DISABLED,
|
|
347
|
+
});
|
|
348
|
+
await client.createRun();
|
|
349
|
+
client.uploader.checkEnabled();
|
|
350
|
+
client.uploader.disableLogStorage();
|
|
351
|
+
let uploadedCount = 0;
|
|
352
|
+
let failedCount = 0;
|
|
353
|
+
for (const test of tests) {
|
|
354
|
+
if (!hasExistingArtifacts(test))
|
|
355
|
+
continue;
|
|
356
|
+
try {
|
|
357
|
+
await client.addTestRun(undefined, { ...test, overwrite: true });
|
|
358
|
+
uploadedCount++;
|
|
359
|
+
}
|
|
360
|
+
catch (e) {
|
|
361
|
+
log_js_1.log.error(`Failed ${test.test_id || test.rid || test.title}: ${e.message}`);
|
|
362
|
+
failedCount++;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
log_js_1.log.info(`🗄️ ${uploadedCount} tests with artifacts uploaded`);
|
|
366
|
+
if (failedCount > 0) {
|
|
367
|
+
log_js_1.log.warn(`⚠️ ${failedCount} tests failed to upload artifacts`);
|
|
368
|
+
}
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
360
371
|
process.env.TESTOMATIO_DISABLE_ARTIFACTS = '';
|
|
361
372
|
const runId = process.env.TESTOMATIO_RUN || process.env.runId || (0, utils_js_2.readLatestRunId)();
|
|
362
373
|
if (!runId) {
|
|
@@ -459,6 +470,29 @@ program
|
|
|
459
470
|
process.exit(1);
|
|
460
471
|
}
|
|
461
472
|
});
|
|
473
|
+
function hasExistingArtifacts(test) {
|
|
474
|
+
const hasExistingFile = items => {
|
|
475
|
+
for (const item of items || []) {
|
|
476
|
+
const artifactPath = typeof item === 'object' ? item?.path : item;
|
|
477
|
+
if (artifactPath && fs_1.default.existsSync(artifactPath))
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
return false;
|
|
481
|
+
};
|
|
482
|
+
if (hasExistingFile(test.files))
|
|
483
|
+
return true;
|
|
484
|
+
const stack = [...(test.steps || [])];
|
|
485
|
+
while (stack.length) {
|
|
486
|
+
const step = stack.pop();
|
|
487
|
+
if (!step)
|
|
488
|
+
continue;
|
|
489
|
+
if (hasExistingFile(step.artifacts))
|
|
490
|
+
return true;
|
|
491
|
+
if (Array.isArray(step.steps))
|
|
492
|
+
stack.push(...step.steps);
|
|
493
|
+
}
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
462
496
|
program.parse(process.argv);
|
|
463
497
|
if (!process.argv.slice(2).length) {
|
|
464
498
|
program.outputHelp();
|
package/lib/client.js
CHANGED
|
@@ -148,10 +148,11 @@ class Client {
|
|
|
148
148
|
}
|
|
149
149
|
const uploadedArtifacts = [];
|
|
150
150
|
for (const artifact of step.artifacts) {
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
const artifactPath = typeof artifact === 'object' ? artifact?.path : artifact;
|
|
152
|
+
if (typeof artifactPath === 'string' && !(0, utils_js_1.isHttpUrl)(artifactPath)) {
|
|
153
|
+
const filename = (0, step_formatter_js_1.generateShortFilename)(artifactPath);
|
|
153
154
|
try {
|
|
154
|
-
const uploadResult = await this.uploader.uploadFileByPath(
|
|
155
|
+
const uploadResult = await this.uploader.uploadFileByPath(artifactPath, [this.runId, testRid, 'steps', filename]);
|
|
155
156
|
if (uploadResult) {
|
|
156
157
|
uploadedArtifacts.push(uploadResult);
|
|
157
158
|
}
|
|
@@ -9,7 +9,6 @@ const java_js_1 = __importDefault(require("./java.js"));
|
|
|
9
9
|
const python_js_1 = __importDefault(require("./python.js"));
|
|
10
10
|
const ruby_js_1 = __importDefault(require("./ruby.js"));
|
|
11
11
|
const csharp_js_1 = __importDefault(require("./csharp.js"));
|
|
12
|
-
const kotlin_js_1 = __importDefault(require("./kotlin.js"));
|
|
13
12
|
function AdapterFactory(lang, opts) {
|
|
14
13
|
if (lang === 'java') {
|
|
15
14
|
return new java_js_1.default(opts);
|
|
@@ -26,9 +25,6 @@ function AdapterFactory(lang, opts) {
|
|
|
26
25
|
if (lang === 'c#' || lang === 'csharp') {
|
|
27
26
|
return new csharp_js_1.default(opts);
|
|
28
27
|
}
|
|
29
|
-
if (lang === 'kotlin') {
|
|
30
|
-
return new kotlin_js_1.default(opts);
|
|
31
|
-
}
|
|
32
28
|
return new adapter_js_1.default(opts);
|
|
33
29
|
}
|
|
34
30
|
module.exports = AdapterFactory;
|
package/lib/pipe/debug.d.ts
CHANGED
|
@@ -22,6 +22,12 @@ export class DebugPipe {
|
|
|
22
22
|
createRun(params?: {}): Promise<void>;
|
|
23
23
|
addTest(data: any): Promise<void>;
|
|
24
24
|
finishRun(params: any): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Logs artifacts data to the debug file.
|
|
27
|
+
* Used for trace.zip, video files and other artifacts uploaded after tests finish.
|
|
28
|
+
* @param {Array} artifacts - Array of artifacts with { rid, title, path, type }
|
|
29
|
+
*/
|
|
30
|
+
addArtifacts(artifacts: any[]): void;
|
|
25
31
|
sync(): Promise<void>;
|
|
26
32
|
/**
|
|
27
33
|
* Writes any buffered tests to the debug file as a single batch.
|
package/lib/pipe/debug.js
CHANGED
|
@@ -109,6 +109,17 @@ class DebugPipe {
|
|
|
109
109
|
log_js_1.log.info(`🪲 Debug file: ${this.rootPath}`);
|
|
110
110
|
log_js_1.log.info(`History: ${this.historyDir}`);
|
|
111
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Logs artifacts data to the debug file.
|
|
114
|
+
* Used for trace.zip, video files and other artifacts uploaded after tests finish.
|
|
115
|
+
* @param {Array} artifacts - Array of artifacts with { rid, title, path, type }
|
|
116
|
+
*/
|
|
117
|
+
addArtifacts(artifacts) {
|
|
118
|
+
if (!this.isEnabled || !artifacts?.length)
|
|
119
|
+
return;
|
|
120
|
+
const logData = { action: 'addArtifacts', artifacts, runId: this.store.runId };
|
|
121
|
+
this.logToFile(logData);
|
|
122
|
+
}
|
|
112
123
|
async sync() {
|
|
113
124
|
this.flushBufferedTests();
|
|
114
125
|
}
|
package/lib/pipe/html.d.ts
CHANGED
package/lib/pipe/html.js
CHANGED
|
@@ -15,6 +15,7 @@ const utils_js_1 = require("../utils/utils.js");
|
|
|
15
15
|
const constants_js_1 = require("../constants.js");
|
|
16
16
|
const node_url_1 = require("node:url");
|
|
17
17
|
const debug = (0, debug_1.default)('@testomatio/reporter:pipe:html');
|
|
18
|
+
const HTML_ARTIFACTS_DIR = 'artifacts';
|
|
18
19
|
// @ts-ignore – this line will be removed in compiled code (already defined in the global scope of commonjs)
|
|
19
20
|
class HtmlPipe {
|
|
20
21
|
constructor(params, store = {}) {
|
|
@@ -23,6 +24,7 @@ class HtmlPipe {
|
|
|
23
24
|
this.description = params.description || process.env.TESTOMATIO_DESCRIPTION;
|
|
24
25
|
this.apiKey = params.apiKey || process.env.TESTOMATIO;
|
|
25
26
|
this.isHtml = params.html ?? process.env.TESTOMATIO_HTML_REPORT_SAVE;
|
|
27
|
+
this.htmlCopyArtifacts = params.htmlCopyArtifacts;
|
|
26
28
|
debug('HTML Pipe: ', this.apiKey ? 'API KEY' : '*no api key provided*');
|
|
27
29
|
this.isEnabled = false;
|
|
28
30
|
this.htmlOutputPath = '';
|
|
@@ -124,6 +126,8 @@ class HtmlPipe {
|
|
|
124
126
|
console.log(picocolors_1.default.blue(msg));
|
|
125
127
|
}
|
|
126
128
|
const aggregatedTests = aggregateTestRetries(tests);
|
|
129
|
+
const copyLocalArtifacts = resolveHtmlCopyArtifacts(this.htmlCopyArtifacts);
|
|
130
|
+
const portableArtifacts = new Map();
|
|
127
131
|
aggregatedTests.forEach(test => {
|
|
128
132
|
const logsRaw = test.logs || test.meta?.logs || test.meta?.console || test.meta?.stdout || test.meta?.stderr || '';
|
|
129
133
|
const stackRaw = test.stack || '';
|
|
@@ -180,7 +184,11 @@ class HtmlPipe {
|
|
|
180
184
|
if (!test.title?.trim()) {
|
|
181
185
|
test.title = 'Unknown test title';
|
|
182
186
|
}
|
|
187
|
+
test.title = buildExampleTitle(test.title, test.example);
|
|
183
188
|
test.artifacts = normalizeArtifacts(test);
|
|
189
|
+
if (copyLocalArtifacts) {
|
|
190
|
+
makeLocalArtifactsPortable(test, outputPath, portableArtifacts);
|
|
191
|
+
}
|
|
184
192
|
const allPossibleArtifacts = [
|
|
185
193
|
...(test.artifacts || []),
|
|
186
194
|
...(test.manuallyAttachedArtifacts || []),
|
|
@@ -373,6 +381,20 @@ function parseRetryInfo(test) {
|
|
|
373
381
|
test.retries.retryCount = n;
|
|
374
382
|
}
|
|
375
383
|
}
|
|
384
|
+
function buildExampleTitle(title, example) {
|
|
385
|
+
if (example == null)
|
|
386
|
+
return title;
|
|
387
|
+
let exampleText;
|
|
388
|
+
try {
|
|
389
|
+
exampleText = JSON.stringify(example);
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
return title;
|
|
393
|
+
}
|
|
394
|
+
if (!exampleText || title.includes(exampleText))
|
|
395
|
+
return title;
|
|
396
|
+
return `${title} | ${exampleText}`;
|
|
397
|
+
}
|
|
376
398
|
function escapeHtml(str = '') {
|
|
377
399
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
378
400
|
}
|
|
@@ -864,51 +886,7 @@ function normalizeArtifacts(test) {
|
|
|
864
886
|
...(test.meta?.manuallyAttachedArtifacts || []),
|
|
865
887
|
];
|
|
866
888
|
return allArtifacts
|
|
867
|
-
.map(
|
|
868
|
-
if (typeof artifact === 'string') {
|
|
869
|
-
if (/^https?:\/\//i.test(artifact)) {
|
|
870
|
-
const base = path_1.default.basename(new URL(artifact).pathname) || artifact;
|
|
871
|
-
return {
|
|
872
|
-
name: base,
|
|
873
|
-
title: base,
|
|
874
|
-
path: artifact,
|
|
875
|
-
fsPath: null,
|
|
876
|
-
relativePath: artifact,
|
|
877
|
-
};
|
|
878
|
-
}
|
|
879
|
-
const abs = path_1.default.isAbsolute(artifact) ? artifact : path_1.default.resolve(process.cwd(), artifact);
|
|
880
|
-
const href = artifact.startsWith('file://') ? artifact : (0, file_url_1.default)(abs, { resolve: true });
|
|
881
|
-
const base = path_1.default.basename(abs);
|
|
882
|
-
return {
|
|
883
|
-
name: base,
|
|
884
|
-
title: base,
|
|
885
|
-
path: href,
|
|
886
|
-
fsPath: abs,
|
|
887
|
-
relativePath: artifact,
|
|
888
|
-
};
|
|
889
|
-
}
|
|
890
|
-
if (artifact?.path) {
|
|
891
|
-
const raw = String(artifact.path);
|
|
892
|
-
const isFileUrl = raw.startsWith('file://');
|
|
893
|
-
const isHttpUrl = /^https?:\/\//i.test(raw);
|
|
894
|
-
const abs = isFileUrl || isHttpUrl ? null : path_1.default.isAbsolute(raw) ? raw : path_1.default.resolve(process.cwd(), raw);
|
|
895
|
-
const href = isFileUrl || isHttpUrl ? raw : (0, file_url_1.default)(abs, { resolve: true });
|
|
896
|
-
const base = abs
|
|
897
|
-
? path_1.default.basename(abs)
|
|
898
|
-
: isHttpUrl
|
|
899
|
-
? path_1.default.basename(new URL(raw).pathname) || artifact.name || artifact.title || 'attachment'
|
|
900
|
-
: artifact.name || artifact.title || 'attachment';
|
|
901
|
-
return {
|
|
902
|
-
...artifact,
|
|
903
|
-
name: artifact.name || artifact.title || base,
|
|
904
|
-
title: artifact.title || artifact.name || base,
|
|
905
|
-
path: href,
|
|
906
|
-
fsPath: abs || artifact.fsPath || null,
|
|
907
|
-
relativePath: artifact.relativePath || raw,
|
|
908
|
-
};
|
|
909
|
-
}
|
|
910
|
-
return artifact;
|
|
911
|
-
})
|
|
889
|
+
.map(normalizeArtifact)
|
|
912
890
|
.filter(Boolean)
|
|
913
891
|
.filter(artifact => {
|
|
914
892
|
const isTrace = (artifact.title === 'trace' || artifact.name === 'trace') &&
|
|
@@ -918,6 +896,135 @@ function normalizeArtifacts(test) {
|
|
|
918
896
|
return !isTrace;
|
|
919
897
|
});
|
|
920
898
|
}
|
|
899
|
+
function resolveHtmlCopyArtifacts(value) {
|
|
900
|
+
if (value !== undefined)
|
|
901
|
+
return (0, utils_js_1.transformEnvVarToBoolean)(value);
|
|
902
|
+
if (process.env.TESTOMATIO_HTML_REPORT_COPY_ARTIFACTS !== undefined) {
|
|
903
|
+
return (0, utils_js_1.transformEnvVarToBoolean)(process.env.TESTOMATIO_HTML_REPORT_COPY_ARTIFACTS);
|
|
904
|
+
}
|
|
905
|
+
return !isS3ArtifactsUploadEnabled();
|
|
906
|
+
}
|
|
907
|
+
function isS3ArtifactsUploadEnabled() {
|
|
908
|
+
return Boolean(process.env.S3_BUCKET && !(0, utils_js_1.transformEnvVarToBoolean)(process.env.TESTOMATIO_DISABLE_ARTIFACTS));
|
|
909
|
+
}
|
|
910
|
+
function normalizeArtifact(artifact) {
|
|
911
|
+
if (typeof artifact === 'string') {
|
|
912
|
+
return normalizeArtifactPath({ raw: artifact });
|
|
913
|
+
}
|
|
914
|
+
if (artifact?.path) {
|
|
915
|
+
return normalizeArtifactPath({
|
|
916
|
+
raw: String(artifact.path),
|
|
917
|
+
artifact,
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
return artifact;
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* @param {{
|
|
924
|
+
* raw: string,
|
|
925
|
+
* artifact?: { name?: string, title?: string, relativePath?: string, [key: string]: any }
|
|
926
|
+
* }} params
|
|
927
|
+
*/
|
|
928
|
+
function normalizeArtifactPath({ raw, artifact = {} }) {
|
|
929
|
+
const url = parseUrl(raw);
|
|
930
|
+
const fsPath = getLocalArtifactPath(raw, url);
|
|
931
|
+
const base = getArtifactBaseName({ raw, url, fsPath, artifact });
|
|
932
|
+
const href = fsPath && url?.protocol !== 'file:' ? (0, file_url_1.default)(fsPath, { resolve: true }) : raw;
|
|
933
|
+
return {
|
|
934
|
+
...artifact,
|
|
935
|
+
name: artifact.name || artifact.title || base,
|
|
936
|
+
title: artifact.title || artifact.name || base,
|
|
937
|
+
path: href,
|
|
938
|
+
fsPath,
|
|
939
|
+
relativePath: artifact.relativePath || raw,
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
function makeLocalArtifactsPortable(test, outputPath, portableArtifacts) {
|
|
943
|
+
const reportDir = path_1.default.dirname(path_1.default.resolve(outputPath));
|
|
944
|
+
test.artifacts = makeArtifactsPortable(test.artifacts, reportDir, portableArtifacts);
|
|
945
|
+
if (Array.isArray(test.stepsArray)) {
|
|
946
|
+
makeStepArtifactsPortable(test.stepsArray, reportDir, portableArtifacts);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
function makeStepArtifactsPortable(steps, reportDir, portableArtifacts) {
|
|
950
|
+
steps.forEach(step => {
|
|
951
|
+
if (Array.isArray(step.artifacts)) {
|
|
952
|
+
const normalizedArtifacts = step.artifacts.map(normalizeArtifact).filter(Boolean);
|
|
953
|
+
step.artifacts = makeArtifactsPortable(normalizedArtifacts, reportDir, portableArtifacts);
|
|
954
|
+
}
|
|
955
|
+
if (Array.isArray(step.steps)) {
|
|
956
|
+
makeStepArtifactsPortable(step.steps, reportDir, portableArtifacts);
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
function makeArtifactsPortable(artifacts, reportDir, portableArtifacts) {
|
|
961
|
+
if (!Array.isArray(artifacts))
|
|
962
|
+
return artifacts;
|
|
963
|
+
return artifacts.map(artifact => {
|
|
964
|
+
const fsPath = artifact?.fsPath;
|
|
965
|
+
if (!fsPath || !fs_1.default.existsSync(fsPath))
|
|
966
|
+
return artifact;
|
|
967
|
+
const relativePath = copyArtifactToReport(fsPath, reportDir, portableArtifacts);
|
|
968
|
+
return { ...artifact, path: relativePath, relativePath };
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
function copyArtifactToReport(fsPath, reportDir, portableArtifacts) {
|
|
972
|
+
const absoluteSource = path_1.default.resolve(fsPath);
|
|
973
|
+
if (portableArtifacts.has(absoluteSource))
|
|
974
|
+
return portableArtifacts.get(absoluteSource);
|
|
975
|
+
const artifactsDir = path_1.default.join(reportDir, HTML_ARTIFACTS_DIR);
|
|
976
|
+
const ext = path_1.default.extname(absoluteSource);
|
|
977
|
+
const baseName = path_1.default.basename(absoluteSource, ext) || 'attachment';
|
|
978
|
+
let destinationPath = path_1.default.join(artifactsDir, `${baseName}${ext}`);
|
|
979
|
+
let index = 1;
|
|
980
|
+
while (Array.from(portableArtifacts.values()).includes(getRelativeArtifactPath(reportDir, destinationPath))) {
|
|
981
|
+
destinationPath = path_1.default.join(artifactsDir, `${baseName}-${index}${ext}`);
|
|
982
|
+
index += 1;
|
|
983
|
+
}
|
|
984
|
+
fs_1.default.mkdirSync(path_1.default.dirname(destinationPath), { recursive: true });
|
|
985
|
+
fs_1.default.copyFileSync(absoluteSource, destinationPath);
|
|
986
|
+
const relativePath = getRelativeArtifactPath(reportDir, destinationPath);
|
|
987
|
+
portableArtifacts.set(absoluteSource, relativePath);
|
|
988
|
+
return relativePath;
|
|
989
|
+
}
|
|
990
|
+
function getLocalArtifactPath(raw, url = parseUrl(raw)) {
|
|
991
|
+
if (path_1.default.isAbsolute(raw))
|
|
992
|
+
return raw;
|
|
993
|
+
if (isRemoteUrl(url))
|
|
994
|
+
return null;
|
|
995
|
+
if (url?.protocol === 'file:') {
|
|
996
|
+
try {
|
|
997
|
+
return (0, node_url_1.fileURLToPath)(url);
|
|
998
|
+
}
|
|
999
|
+
catch (_) {
|
|
1000
|
+
return null;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
if (url)
|
|
1004
|
+
return null;
|
|
1005
|
+
return path_1.default.resolve(process.cwd(), raw);
|
|
1006
|
+
}
|
|
1007
|
+
function getArtifactBaseName({ raw, url, fsPath, artifact }) {
|
|
1008
|
+
if (fsPath)
|
|
1009
|
+
return path_1.default.basename(fsPath);
|
|
1010
|
+
if (url?.pathname)
|
|
1011
|
+
return path_1.default.basename(url.pathname) || artifact.name || artifact.title || 'attachment';
|
|
1012
|
+
return artifact.name || artifact.title || path_1.default.basename(raw) || 'attachment';
|
|
1013
|
+
}
|
|
1014
|
+
function parseUrl(value) {
|
|
1015
|
+
try {
|
|
1016
|
+
return new URL(String(value));
|
|
1017
|
+
}
|
|
1018
|
+
catch (_) {
|
|
1019
|
+
return null;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
function isRemoteUrl(url) {
|
|
1023
|
+
return url?.protocol === 'http:' || url?.protocol === 'https:';
|
|
1024
|
+
}
|
|
1025
|
+
function getRelativeArtifactPath(reportDir, artifactPath) {
|
|
1026
|
+
return `./${String(path_1.default.relative(reportDir, artifactPath)).split(path_1.default.sep).join('/')}`;
|
|
1027
|
+
}
|
|
921
1028
|
/**
|
|
922
1029
|
* Loads trace files from test.files and converts them to base64 data URLs
|
|
923
1030
|
* @param {object} test - Test object with files array
|
package/lib/replay.js
CHANGED
|
@@ -49,6 +49,7 @@ class Replay {
|
|
|
49
49
|
const testsWithoutRid = []; // For tests without rid (backward compatibility)
|
|
50
50
|
const envVars = {};
|
|
51
51
|
let runId = null;
|
|
52
|
+
const artifactsToAdd = []; // Store artifacts to add after all tests are loaded
|
|
52
53
|
// Parse debug file line by line
|
|
53
54
|
for (const [lineIndex, line] of lines.entries()) {
|
|
54
55
|
try {
|
|
@@ -125,6 +126,12 @@ class Replay {
|
|
|
125
126
|
testsWithoutRid.push({ ...test });
|
|
126
127
|
}
|
|
127
128
|
}
|
|
129
|
+
else if (logEntry.action === 'addArtifacts' && logEntry.artifacts) {
|
|
130
|
+
if (logEntry.runId && !runId) {
|
|
131
|
+
runId = logEntry.runId;
|
|
132
|
+
}
|
|
133
|
+
artifactsToAdd.push(...logEntry.artifacts);
|
|
134
|
+
}
|
|
128
135
|
else if (logEntry.action === 'finishRun') {
|
|
129
136
|
finishParams = logEntry.params || {};
|
|
130
137
|
if (logEntry.runId && !runId) {
|
|
@@ -143,6 +150,20 @@ class Replay {
|
|
|
143
150
|
if (parseErrors > 3) {
|
|
144
151
|
this.onError(`${parseErrors - 3} more parse errors occurred`);
|
|
145
152
|
}
|
|
153
|
+
for (const artifact of artifactsToAdd) {
|
|
154
|
+
if (artifact.rid) {
|
|
155
|
+
const ridToFind = artifact.rid;
|
|
156
|
+
const fullRid = runId ? `${runId}-${ridToFind}` : ridToFind;
|
|
157
|
+
const test = testsMap.get(fullRid) || testsMap.get(ridToFind);
|
|
158
|
+
if (test && artifact.path) {
|
|
159
|
+
if (!test.files)
|
|
160
|
+
test.files = [];
|
|
161
|
+
if (!test.files.includes(artifact.path)) {
|
|
162
|
+
test.files.push(artifact.path);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
146
167
|
// Combine tests with rid and tests without rid
|
|
147
168
|
const allTests = [...Array.from(testsMap.values()), ...testsWithoutRid];
|
|
148
169
|
return {
|