@testomatio/reporter 2.9.3 → 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/lib/adapter/codecept.js +32 -14
- package/lib/pipe/html.d.ts +1 -0
- package/lib/pipe/html.js +152 -45
- package/package.json +1 -1
- package/src/adapter/codecept.js +33 -12
- package/src/pipe/html.js +171 -52
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 => {
|
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/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
|
|
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
|