testomatio-reporter-cli 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/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/src/allureReader.js +0 -954
- package/src/junit-adapter/kotlin.js +0 -48
package/package.json
CHANGED
package/src/adapter/codecept.js
CHANGED
|
@@ -154,21 +154,40 @@ function CodeceptReporter(config) {
|
|
|
154
154
|
|
|
155
155
|
// mark as failed all tests inside the failed hook
|
|
156
156
|
event.dispatcher.on(event.hook.failed, hook => {
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
const error = hook?.ctx?.currentTest?.err || hook?.err;
|
|
158
|
+
|
|
159
|
+
// Handle BeforeSuite and Before hooks: mark all tests in suite as failed
|
|
160
|
+
if (hook.name === 'BeforeSuiteHook' || hook.name === 'BeforeHook') {
|
|
161
|
+
const suite = hook.runnable.parent;
|
|
162
|
+
if (!suite) return;
|
|
163
|
+
|
|
164
|
+
for (const test of suite.tests) {
|
|
165
|
+
reportedTestUids.add(test.uid);
|
|
166
|
+
const reportTestPromise = client.addTestRun('failed', {
|
|
167
|
+
...stripExampleFromTitle(test.title),
|
|
168
|
+
rid: test.uid,
|
|
169
|
+
test_id: getTestomatIdFromTestTitle(test.title),
|
|
170
|
+
suite_title: stripTagsFromTitle(suite.title),
|
|
171
|
+
error,
|
|
172
|
+
time: hook?.runnable?.duration,
|
|
173
|
+
});
|
|
174
|
+
reportTestPromises.push(reportTestPromise);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
159
177
|
|
|
160
|
-
if (
|
|
178
|
+
if (hook.name === 'AfterSuiteHook') {
|
|
179
|
+
const suite = hook.runnable?.parent || hook.suite;
|
|
180
|
+
const suiteTitle = suite ? stripTagsFromTitle(suite.title) : 'Unknown Suite';
|
|
161
181
|
|
|
162
|
-
|
|
182
|
+
const stepHierarchy = buildUnifiedStepHierarchy(null, hookSteps);
|
|
163
183
|
|
|
164
|
-
|
|
165
|
-
reportedTestUids.add(test.uid);
|
|
184
|
+
// Report the hook failure as a separate test entry
|
|
166
185
|
const reportTestPromise = client.addTestRun('failed', {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
test_id: getTestomatIdFromTestTitle(test.title),
|
|
170
|
-
suite_title: stripTagsFromTitle(suite.title),
|
|
186
|
+
title: 'AfterSuite Hook',
|
|
187
|
+
suite_title: suiteTitle,
|
|
171
188
|
error,
|
|
189
|
+
message: error?.message || 'AfterSuite hook failed',
|
|
190
|
+
steps: stepHierarchy,
|
|
172
191
|
time: hook?.runnable?.duration,
|
|
173
192
|
});
|
|
174
193
|
reportTestPromises.push(reportTestPromise);
|
|
@@ -194,8 +213,10 @@ function CodeceptReporter(config) {
|
|
|
194
213
|
});
|
|
195
214
|
|
|
196
215
|
event.dispatcher.on(event.all.after, () => {
|
|
197
|
-
|
|
198
|
-
|
|
216
|
+
setImmediate(() => {
|
|
217
|
+
finalizeRun('all.after').catch(err => {
|
|
218
|
+
debug('Error finalizing run:', err);
|
|
219
|
+
});
|
|
199
220
|
});
|
|
200
221
|
});
|
|
201
222
|
|
|
@@ -278,6 +299,8 @@ async function uploadAttachments(client, attachments, messagePrefix, attachmentT
|
|
|
278
299
|
log.info(`Attachments: ${messagePrefix} ${attachments.length} ${attachmentType} ...`);
|
|
279
300
|
}
|
|
280
301
|
|
|
302
|
+
await Promise.all(client.pipes?.map(pipe => pipe.addArtifacts?.(attachments)) || []);
|
|
303
|
+
|
|
281
304
|
const promises = attachments.map(async attachment => {
|
|
282
305
|
const { rid, title, path, type } = attachment;
|
|
283
306
|
const file = { path, type, title };
|
|
@@ -48,14 +48,8 @@ class PlaywrightReporter {
|
|
|
48
48
|
const { title } = test;
|
|
49
49
|
const { error, duration } = result;
|
|
50
50
|
const pwAttachments = (result.attachments || []).filter(a => a.body || a.path);
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
.map(att => ({
|
|
54
|
-
path: this.#getArtifactPath(att),
|
|
55
|
-
title: att.name || title,
|
|
56
|
-
type: att.contentType,
|
|
57
|
-
}))
|
|
58
|
-
.filter(f => f.path);
|
|
51
|
+
const files = buildArtifactFiles(pwAttachments, attachment => this.#getArtifactPath(attachment), title);
|
|
52
|
+
const artifactsForUpload = processArtifactsForUpload(pwAttachments);
|
|
59
53
|
|
|
60
54
|
const suite_title = test.parent ? test.parent?.title : path.basename(test?.location?.file);
|
|
61
55
|
|
|
@@ -150,7 +144,7 @@ class PlaywrightReporter {
|
|
|
150
144
|
this.uploads.push({
|
|
151
145
|
rid: `${rid}-${project.name}`,
|
|
152
146
|
title: test.title,
|
|
153
|
-
files:
|
|
147
|
+
files: artifactsForUpload,
|
|
154
148
|
file: test.location?.file,
|
|
155
149
|
});
|
|
156
150
|
// remove empty uploads
|
|
@@ -229,6 +223,25 @@ function checkStatus(status) {
|
|
|
229
223
|
);
|
|
230
224
|
}
|
|
231
225
|
|
|
226
|
+
function buildArtifactFiles(attachments, getArtifactPath, title) {
|
|
227
|
+
return attachments
|
|
228
|
+
.filter(isScreenshotArtifact)
|
|
229
|
+
.map(attachment => ({
|
|
230
|
+
path: getArtifactPath(attachment),
|
|
231
|
+
title: attachment.name || title,
|
|
232
|
+
type: attachment.contentType,
|
|
233
|
+
}))
|
|
234
|
+
.filter(file => file.path);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function processArtifactsForUpload(attachments) {
|
|
238
|
+
return attachments.filter(attachment => !isScreenshotArtifact(attachment));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function isScreenshotArtifact(attachment) {
|
|
242
|
+
return attachment?.contentType === 'image/png' && attachment?.name === 'screenshot';
|
|
243
|
+
}
|
|
244
|
+
|
|
232
245
|
function appendStep(step, shift = 0) {
|
|
233
246
|
// nesting too deep, ignore those steps
|
|
234
247
|
if (shift >= 10) return;
|
package/src/bin/cli.js
CHANGED
|
@@ -6,7 +6,6 @@ import { glob } from 'glob';
|
|
|
6
6
|
import createDebugMessages from 'debug';
|
|
7
7
|
import TestomatClient from '../client.js';
|
|
8
8
|
import XmlReader from '../xmlReader.js';
|
|
9
|
-
import AllureReader from '../allureReader.js';
|
|
10
9
|
import { APP_PREFIX, STATUS, DEBUG_FILE, BATCH_MODE } from '../constants.js';
|
|
11
10
|
import { cleanLatestRunId, getPackageVersion, applyFilter } from '../utils/utils.js';
|
|
12
11
|
import { config } from '../config.js';
|
|
@@ -17,6 +16,8 @@ import dotenv from 'dotenv';
|
|
|
17
16
|
import Replay from '../replay.js';
|
|
18
17
|
import { log } from '../utils/log.js';
|
|
19
18
|
import { formatFilterListIds } from '../utils/pipe_utils.js';
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import path from 'path';
|
|
20
21
|
|
|
21
22
|
const debug = createDebugMessages('@testomatio/reporter:cli');
|
|
22
23
|
const version = getPackageVersion();
|
|
@@ -353,53 +354,61 @@ program
|
|
|
353
354
|
});
|
|
354
355
|
|
|
355
356
|
program
|
|
356
|
-
.command('
|
|
357
|
-
.description('
|
|
358
|
-
.argument('
|
|
359
|
-
.option('
|
|
360
|
-
.
|
|
361
|
-
|
|
362
|
-
.option(
|
|
363
|
-
'--java-tests [path]',
|
|
364
|
-
'Path to test sources; links cases from @TmsLink in source for skipped tests (default: src/test)',
|
|
365
|
-
)
|
|
366
|
-
.option('--lang <lang>', 'Language used (java, kotlin, ...)')
|
|
367
|
-
.action(async (pattern, opts) => {
|
|
368
|
-
let javaTests = opts.javaTests;
|
|
369
|
-
if (javaTests === true) javaTests = 'src/test';
|
|
357
|
+
.command('upload-artifacts')
|
|
358
|
+
.description('Upload artifacts to Testomat.io')
|
|
359
|
+
.argument('[jsonl-file]', 'Path to JSONL debug file (optional)')
|
|
360
|
+
.option('--force', 'Re-upload artifacts even if they were uploaded before')
|
|
361
|
+
.action(async (jsonlFile, opts) => {
|
|
362
|
+
const apiKey = config.TESTOMATIO;
|
|
370
363
|
|
|
371
|
-
|
|
364
|
+
// JSONL file mode: upload artifacts from debug file
|
|
365
|
+
if (jsonlFile) {
|
|
366
|
+
if (!fs.existsSync(jsonlFile)) {
|
|
367
|
+
log.error(`JSONL file not found: ${jsonlFile}`);
|
|
368
|
+
return process.exit(1);
|
|
369
|
+
}
|
|
372
370
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
timeoutTimer = setTimeout(
|
|
376
|
-
() => {
|
|
377
|
-
console.log(
|
|
378
|
-
`⚠️ Reached timeout of ${opts.timelimit}s. Exiting... (Exit code is 0 to not fail the pipeline)`,
|
|
379
|
-
);
|
|
380
|
-
process.exit(0);
|
|
381
|
-
},
|
|
382
|
-
parseInt(opts.timelimit, 10) * 1000,
|
|
383
|
-
);
|
|
384
|
-
}
|
|
371
|
+
const replay = new Replay({ apiKey });
|
|
372
|
+
const { tests, runId } = replay.parseDebugFile(jsonlFile);
|
|
385
373
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
} catch (err) {
|
|
391
|
-
console.log(APP_PREFIX, 'Error uploading Allure results:', err);
|
|
392
|
-
}
|
|
374
|
+
if (!runId) {
|
|
375
|
+
log.error('runId not found in JSONL file');
|
|
376
|
+
return process.exit(1);
|
|
377
|
+
}
|
|
393
378
|
|
|
394
|
-
|
|
395
|
-
});
|
|
379
|
+
log.info(`Processing ${tests.length} tests from ${jsonlFile}`);
|
|
396
380
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
381
|
+
const client = new TestomatClient({
|
|
382
|
+
apiKey,
|
|
383
|
+
runId,
|
|
384
|
+
batchMode: BATCH_MODE.DISABLED,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await client.createRun();
|
|
388
|
+
client.uploader.checkEnabled();
|
|
389
|
+
client.uploader.disableLogStorage();
|
|
390
|
+
|
|
391
|
+
let uploadedCount = 0;
|
|
392
|
+
let failedCount = 0;
|
|
393
|
+
|
|
394
|
+
for (const test of tests) {
|
|
395
|
+
if (!hasExistingArtifacts(test)) continue;
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
await client.addTestRun(undefined, { ...test, overwrite: true });
|
|
399
|
+
uploadedCount++;
|
|
400
|
+
} catch (e) {
|
|
401
|
+
log.error(`Failed ${test.test_id || test.rid || test.title}: ${e.message}`);
|
|
402
|
+
failedCount++;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
log.info(`🗄️ ${uploadedCount} tests with artifacts uploaded`);
|
|
407
|
+
if (failedCount > 0) {
|
|
408
|
+
log.warn(`⚠️ ${failedCount} tests failed to upload artifacts`);
|
|
409
|
+
}
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
403
412
|
|
|
404
413
|
process.env.TESTOMATIO_DISABLE_ARTIFACTS = '';
|
|
405
414
|
const runId = process.env.TESTOMATIO_RUN || process.env.runId || readLatestRunId();
|
|
@@ -543,6 +552,28 @@ program
|
|
|
543
552
|
}
|
|
544
553
|
});
|
|
545
554
|
|
|
555
|
+
function hasExistingArtifacts(test) {
|
|
556
|
+
const hasExistingFile = items => {
|
|
557
|
+
for (const item of items || []) {
|
|
558
|
+
const artifactPath = typeof item === 'object' ? item?.path : item;
|
|
559
|
+
if (artifactPath && fs.existsSync(artifactPath)) return true;
|
|
560
|
+
}
|
|
561
|
+
return false;
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
if (hasExistingFile(test.files)) return true;
|
|
565
|
+
|
|
566
|
+
const stack = [...(test.steps || [])];
|
|
567
|
+
while (stack.length) {
|
|
568
|
+
const step = stack.pop();
|
|
569
|
+
if (!step) continue;
|
|
570
|
+
if (hasExistingFile(step.artifacts)) return true;
|
|
571
|
+
if (Array.isArray(step.steps)) stack.push(...step.steps);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
|
|
546
577
|
program.parse(process.argv);
|
|
547
578
|
|
|
548
579
|
if (!process.argv.slice(2).length) {
|
package/src/client.js
CHANGED
|
@@ -159,11 +159,12 @@ class Client {
|
|
|
159
159
|
|
|
160
160
|
const uploadedArtifacts = [];
|
|
161
161
|
for (const artifact of step.artifacts) {
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
const artifactPath = typeof artifact === 'object' ? artifact?.path : artifact;
|
|
163
|
+
if (typeof artifactPath === 'string' && !isHttpUrl(artifactPath)) {
|
|
164
|
+
const filename = generateShortFilename(artifactPath);
|
|
164
165
|
try {
|
|
165
166
|
const uploadResult = await this.uploader.uploadFileByPath(
|
|
166
|
-
|
|
167
|
+
artifactPath,
|
|
167
168
|
[this.runId, testRid, 'steps', filename]
|
|
168
169
|
);
|
|
169
170
|
if (uploadResult) {
|
|
@@ -4,7 +4,6 @@ import JavaAdapter from './java.js';
|
|
|
4
4
|
import PythonAdapter from './python.js';
|
|
5
5
|
import RubyAdapter from './ruby.js';
|
|
6
6
|
import CSharpAdapter from './csharp.js';
|
|
7
|
-
import KotlinAdapter from './kotlin.js';
|
|
8
7
|
|
|
9
8
|
function AdapterFactory(lang, opts) {
|
|
10
9
|
if (lang === 'java') {
|
|
@@ -22,9 +21,6 @@ function AdapterFactory(lang, opts) {
|
|
|
22
21
|
if (lang === 'c#' || lang === 'csharp') {
|
|
23
22
|
return new CSharpAdapter(opts);
|
|
24
23
|
}
|
|
25
|
-
if (lang === 'kotlin') {
|
|
26
|
-
return new KotlinAdapter(opts);
|
|
27
|
-
}
|
|
28
24
|
|
|
29
25
|
return new Adapter(opts);
|
|
30
26
|
}
|
package/src/pipe/debug.js
CHANGED
|
@@ -111,6 +111,17 @@ export class DebugPipe {
|
|
|
111
111
|
log.info(`History: ${this.historyDir}`);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Logs artifacts data to the debug file.
|
|
116
|
+
* Used for trace.zip, video files and other artifacts uploaded after tests finish.
|
|
117
|
+
* @param {Array} artifacts - Array of artifacts with { rid, title, path, type }
|
|
118
|
+
*/
|
|
119
|
+
addArtifacts(artifacts) {
|
|
120
|
+
if (!this.isEnabled || !artifacts?.length) return;
|
|
121
|
+
const logData = { action: 'addArtifacts', artifacts, runId: this.store.runId };
|
|
122
|
+
this.logToFile(logData);
|
|
123
|
+
}
|
|
124
|
+
|
|
114
125
|
async sync() {
|
|
115
126
|
this.flushBufferedTests();
|
|
116
127
|
}
|
package/src/pipe/html.js
CHANGED
|
@@ -6,11 +6,12 @@ import pc from 'picocolors';
|
|
|
6
6
|
import handlebars from 'handlebars';
|
|
7
7
|
import { marked } from 'marked';
|
|
8
8
|
import fileUrl from 'file-url';
|
|
9
|
-
import { fileSystem, isSameTest, ansiRegExp, formatStep } from '../utils/utils.js';
|
|
9
|
+
import { fileSystem, isSameTest, ansiRegExp, formatStep, transformEnvVarToBoolean } from '../utils/utils.js';
|
|
10
10
|
import { HTML_REPORT } from '../constants.js';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
|
|
13
13
|
const debug = createDebugMessages('@testomatio/reporter:pipe:html');
|
|
14
|
+
const HTML_ARTIFACTS_DIR = 'artifacts';
|
|
14
15
|
|
|
15
16
|
// @ts-ignore – this line will be removed in compiled code (already defined in the global scope of commonjs)
|
|
16
17
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -22,6 +23,7 @@ class HtmlPipe {
|
|
|
22
23
|
this.description = params.description || process.env.TESTOMATIO_DESCRIPTION;
|
|
23
24
|
this.apiKey = params.apiKey || process.env.TESTOMATIO;
|
|
24
25
|
this.isHtml = params.html ?? process.env.TESTOMATIO_HTML_REPORT_SAVE;
|
|
26
|
+
this.htmlCopyArtifacts = params.htmlCopyArtifacts;
|
|
25
27
|
|
|
26
28
|
debug('HTML Pipe: ', this.apiKey ? 'API KEY' : '*no api key provided*');
|
|
27
29
|
|
|
@@ -151,6 +153,8 @@ class HtmlPipe {
|
|
|
151
153
|
|
|
152
154
|
const aggregatedTests = aggregateTestRetries(tests);
|
|
153
155
|
|
|
156
|
+
const copyLocalArtifacts = resolveHtmlCopyArtifacts(this.htmlCopyArtifacts);
|
|
157
|
+
const portableArtifacts = new Map();
|
|
154
158
|
aggregatedTests.forEach(test => {
|
|
155
159
|
const logsRaw =
|
|
156
160
|
test.logs || test.meta?.logs || test.meta?.console || test.meta?.stdout || test.meta?.stderr || '';
|
|
@@ -228,7 +232,12 @@ class HtmlPipe {
|
|
|
228
232
|
test.title = 'Unknown test title';
|
|
229
233
|
}
|
|
230
234
|
|
|
235
|
+
test.title = buildExampleTitle(test.title, test.example);
|
|
236
|
+
|
|
231
237
|
test.artifacts = normalizeArtifacts(test);
|
|
238
|
+
if (copyLocalArtifacts) {
|
|
239
|
+
makeLocalArtifactsPortable(test, outputPath, portableArtifacts);
|
|
240
|
+
}
|
|
232
241
|
|
|
233
242
|
const allPossibleArtifacts = [
|
|
234
243
|
...(test.artifacts || []),
|
|
@@ -460,6 +469,21 @@ function parseRetryInfo(test) {
|
|
|
460
469
|
}
|
|
461
470
|
}
|
|
462
471
|
|
|
472
|
+
function buildExampleTitle(title, example) {
|
|
473
|
+
if (example == null) return title;
|
|
474
|
+
|
|
475
|
+
let exampleText;
|
|
476
|
+
try {
|
|
477
|
+
exampleText = JSON.stringify(example);
|
|
478
|
+
} catch (e) {
|
|
479
|
+
return title;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (!exampleText || title.includes(exampleText)) return title;
|
|
483
|
+
|
|
484
|
+
return `${title} | ${exampleText}`;
|
|
485
|
+
}
|
|
486
|
+
|
|
463
487
|
function escapeHtml(str = '') {
|
|
464
488
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
465
489
|
}
|
|
@@ -1013,57 +1037,7 @@ function normalizeArtifacts(test) {
|
|
|
1013
1037
|
];
|
|
1014
1038
|
|
|
1015
1039
|
return allArtifacts
|
|
1016
|
-
.map(
|
|
1017
|
-
if (typeof artifact === 'string') {
|
|
1018
|
-
if (/^https?:\/\//i.test(artifact)) {
|
|
1019
|
-
const base = path.basename(new URL(artifact).pathname) || artifact;
|
|
1020
|
-
|
|
1021
|
-
return {
|
|
1022
|
-
name: base,
|
|
1023
|
-
title: base,
|
|
1024
|
-
path: artifact,
|
|
1025
|
-
fsPath: null,
|
|
1026
|
-
relativePath: artifact,
|
|
1027
|
-
};
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
const abs = path.isAbsolute(artifact) ? artifact : path.resolve(process.cwd(), artifact);
|
|
1031
|
-
const href = artifact.startsWith('file://') ? artifact : fileUrl(abs, { resolve: true });
|
|
1032
|
-
const base = path.basename(abs);
|
|
1033
|
-
|
|
1034
|
-
return {
|
|
1035
|
-
name: base,
|
|
1036
|
-
title: base,
|
|
1037
|
-
path: href,
|
|
1038
|
-
fsPath: abs,
|
|
1039
|
-
relativePath: artifact,
|
|
1040
|
-
};
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
if (artifact?.path) {
|
|
1044
|
-
const raw = String(artifact.path);
|
|
1045
|
-
const isFileUrl = raw.startsWith('file://');
|
|
1046
|
-
const isHttpUrl = /^https?:\/\//i.test(raw);
|
|
1047
|
-
const abs = isFileUrl || isHttpUrl ? null : path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
|
|
1048
|
-
const href = isFileUrl || isHttpUrl ? raw : fileUrl(abs, { resolve: true });
|
|
1049
|
-
const base = abs
|
|
1050
|
-
? path.basename(abs)
|
|
1051
|
-
: isHttpUrl
|
|
1052
|
-
? path.basename(new URL(raw).pathname) || artifact.name || artifact.title || 'attachment'
|
|
1053
|
-
: artifact.name || artifact.title || 'attachment';
|
|
1054
|
-
|
|
1055
|
-
return {
|
|
1056
|
-
...artifact,
|
|
1057
|
-
name: artifact.name || artifact.title || base,
|
|
1058
|
-
title: artifact.title || artifact.name || base,
|
|
1059
|
-
path: href,
|
|
1060
|
-
fsPath: abs || artifact.fsPath || null,
|
|
1061
|
-
relativePath: artifact.relativePath || raw,
|
|
1062
|
-
};
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
return artifact;
|
|
1066
|
-
})
|
|
1040
|
+
.map(normalizeArtifact)
|
|
1067
1041
|
.filter(Boolean)
|
|
1068
1042
|
.filter(artifact => {
|
|
1069
1043
|
const isTrace = (artifact.title === 'trace' || artifact.name === 'trace') &&
|
|
@@ -1074,6 +1048,151 @@ function normalizeArtifacts(test) {
|
|
|
1074
1048
|
});
|
|
1075
1049
|
}
|
|
1076
1050
|
|
|
1051
|
+
function resolveHtmlCopyArtifacts(value) {
|
|
1052
|
+
if (value !== undefined) return transformEnvVarToBoolean(value);
|
|
1053
|
+
if (process.env.TESTOMATIO_HTML_REPORT_COPY_ARTIFACTS !== undefined) {
|
|
1054
|
+
return transformEnvVarToBoolean(process.env.TESTOMATIO_HTML_REPORT_COPY_ARTIFACTS);
|
|
1055
|
+
}
|
|
1056
|
+
return !isS3ArtifactsUploadEnabled();
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function isS3ArtifactsUploadEnabled() {
|
|
1060
|
+
return Boolean(process.env.S3_BUCKET && !transformEnvVarToBoolean(process.env.TESTOMATIO_DISABLE_ARTIFACTS));
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function normalizeArtifact(artifact) {
|
|
1064
|
+
if (typeof artifact === 'string') {
|
|
1065
|
+
return normalizeArtifactPath({ raw: artifact });
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (artifact?.path) {
|
|
1069
|
+
return normalizeArtifactPath({
|
|
1070
|
+
raw: String(artifact.path),
|
|
1071
|
+
artifact,
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
return artifact;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* @param {{
|
|
1080
|
+
* raw: string,
|
|
1081
|
+
* artifact?: { name?: string, title?: string, relativePath?: string, [key: string]: any }
|
|
1082
|
+
* }} params
|
|
1083
|
+
*/
|
|
1084
|
+
function normalizeArtifactPath({ raw, artifact = {} }) {
|
|
1085
|
+
const url = parseUrl(raw);
|
|
1086
|
+
const fsPath = getLocalArtifactPath(raw, url);
|
|
1087
|
+
const base = getArtifactBaseName({ raw, url, fsPath, artifact });
|
|
1088
|
+
const href = fsPath && url?.protocol !== 'file:' ? fileUrl(fsPath, { resolve: true }) : raw;
|
|
1089
|
+
|
|
1090
|
+
return {
|
|
1091
|
+
...artifact,
|
|
1092
|
+
name: artifact.name || artifact.title || base,
|
|
1093
|
+
title: artifact.title || artifact.name || base,
|
|
1094
|
+
path: href,
|
|
1095
|
+
fsPath,
|
|
1096
|
+
relativePath: artifact.relativePath || raw,
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function makeLocalArtifactsPortable(test, outputPath, portableArtifacts) {
|
|
1101
|
+
const reportDir = path.dirname(path.resolve(outputPath));
|
|
1102
|
+
|
|
1103
|
+
test.artifacts = makeArtifactsPortable(test.artifacts, reportDir, portableArtifacts);
|
|
1104
|
+
|
|
1105
|
+
if (Array.isArray(test.stepsArray)) {
|
|
1106
|
+
makeStepArtifactsPortable(test.stepsArray, reportDir, portableArtifacts);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function makeStepArtifactsPortable(steps, reportDir, portableArtifacts) {
|
|
1111
|
+
steps.forEach(step => {
|
|
1112
|
+
if (Array.isArray(step.artifacts)) {
|
|
1113
|
+
const normalizedArtifacts = step.artifacts.map(normalizeArtifact).filter(Boolean);
|
|
1114
|
+
step.artifacts = makeArtifactsPortable(normalizedArtifacts, reportDir, portableArtifacts);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
if (Array.isArray(step.steps)) {
|
|
1118
|
+
makeStepArtifactsPortable(step.steps, reportDir, portableArtifacts);
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function makeArtifactsPortable(artifacts, reportDir, portableArtifacts) {
|
|
1124
|
+
if (!Array.isArray(artifacts)) return artifacts;
|
|
1125
|
+
|
|
1126
|
+
return artifacts.map(artifact => {
|
|
1127
|
+
const fsPath = artifact?.fsPath;
|
|
1128
|
+
if (!fsPath || !fs.existsSync(fsPath)) return artifact;
|
|
1129
|
+
|
|
1130
|
+
const relativePath = copyArtifactToReport(fsPath, reportDir, portableArtifacts);
|
|
1131
|
+
return { ...artifact, path: relativePath, relativePath };
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function copyArtifactToReport(fsPath, reportDir, portableArtifacts) {
|
|
1136
|
+
const absoluteSource = path.resolve(fsPath);
|
|
1137
|
+
if (portableArtifacts.has(absoluteSource)) return portableArtifacts.get(absoluteSource);
|
|
1138
|
+
|
|
1139
|
+
const artifactsDir = path.join(reportDir, HTML_ARTIFACTS_DIR);
|
|
1140
|
+
const ext = path.extname(absoluteSource);
|
|
1141
|
+
const baseName = path.basename(absoluteSource, ext) || 'attachment';
|
|
1142
|
+
let destinationPath = path.join(artifactsDir, `${baseName}${ext}`);
|
|
1143
|
+
let index = 1;
|
|
1144
|
+
|
|
1145
|
+
while (Array.from(portableArtifacts.values()).includes(getRelativeArtifactPath(reportDir, destinationPath))) {
|
|
1146
|
+
destinationPath = path.join(artifactsDir, `${baseName}-${index}${ext}`);
|
|
1147
|
+
index += 1;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
|
1151
|
+
fs.copyFileSync(absoluteSource, destinationPath);
|
|
1152
|
+
|
|
1153
|
+
const relativePath = getRelativeArtifactPath(reportDir, destinationPath);
|
|
1154
|
+
portableArtifacts.set(absoluteSource, relativePath);
|
|
1155
|
+
return relativePath;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function getLocalArtifactPath(raw, url = parseUrl(raw)) {
|
|
1159
|
+
if (path.isAbsolute(raw)) return raw;
|
|
1160
|
+
if (isRemoteUrl(url)) return null;
|
|
1161
|
+
|
|
1162
|
+
if (url?.protocol === 'file:') {
|
|
1163
|
+
try {
|
|
1164
|
+
return fileURLToPath(url);
|
|
1165
|
+
} catch (_) {
|
|
1166
|
+
return null;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
if (url) return null;
|
|
1171
|
+
return path.resolve(process.cwd(), raw);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function getArtifactBaseName({ raw, url, fsPath, artifact }) {
|
|
1175
|
+
if (fsPath) return path.basename(fsPath);
|
|
1176
|
+
if (url?.pathname) return path.basename(url.pathname) || artifact.name || artifact.title || 'attachment';
|
|
1177
|
+
return artifact.name || artifact.title || path.basename(raw) || 'attachment';
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function parseUrl(value) {
|
|
1181
|
+
try {
|
|
1182
|
+
return new URL(String(value));
|
|
1183
|
+
} catch (_) {
|
|
1184
|
+
return null;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function isRemoteUrl(url) {
|
|
1189
|
+
return url?.protocol === 'http:' || url?.protocol === 'https:';
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function getRelativeArtifactPath(reportDir, artifactPath) {
|
|
1193
|
+
return `./${String(path.relative(reportDir, artifactPath)).split(path.sep).join('/')}`;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1077
1196
|
/**
|
|
1078
1197
|
* Loads trace files from test.files and converts them to base64 data URLs
|
|
1079
1198
|
* @param {object} test - Test object with files array
|
package/src/replay.js
CHANGED
|
@@ -49,6 +49,7 @@ export 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
|
|
|
53
54
|
// Parse debug file line by line
|
|
54
55
|
for (const [lineIndex, line] of lines.entries()) {
|
|
@@ -119,6 +120,11 @@ export class Replay {
|
|
|
119
120
|
// Handle tests without rid (no deduplication)
|
|
120
121
|
testsWithoutRid.push({ ...test });
|
|
121
122
|
}
|
|
123
|
+
} else if (logEntry.action === 'addArtifacts' && logEntry.artifacts) {
|
|
124
|
+
if (logEntry.runId && !runId) {
|
|
125
|
+
runId = logEntry.runId;
|
|
126
|
+
}
|
|
127
|
+
artifactsToAdd.push(...logEntry.artifacts);
|
|
122
128
|
} else if (logEntry.action === 'finishRun') {
|
|
123
129
|
finishParams = logEntry.params || {};
|
|
124
130
|
if (logEntry.runId && !runId) {
|
|
@@ -138,6 +144,21 @@ export class Replay {
|
|
|
138
144
|
this.onError(`${parseErrors - 3} more parse errors occurred`);
|
|
139
145
|
}
|
|
140
146
|
|
|
147
|
+
for (const artifact of artifactsToAdd) {
|
|
148
|
+
if (artifact.rid) {
|
|
149
|
+
const ridToFind = artifact.rid;
|
|
150
|
+
const fullRid = runId ? `${runId}-${ridToFind}` : ridToFind;
|
|
151
|
+
|
|
152
|
+
const test = testsMap.get(fullRid) || testsMap.get(ridToFind);
|
|
153
|
+
if (test && artifact.path) {
|
|
154
|
+
if (!test.files) test.files = [];
|
|
155
|
+
if (!test.files.includes(artifact.path)) {
|
|
156
|
+
test.files.push(artifact.path);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
141
162
|
// Combine tests with rid and tests without rid
|
|
142
163
|
const allTests = [...Array.from(testsMap.values()), ...testsWithoutRid];
|
|
143
164
|
|