@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.
@@ -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
- if (hook.name !== 'BeforeSuiteHook' && hook.name !== 'BeforeHook')
139
- return;
140
- const suite = hook.runnable.parent;
141
- if (!suite)
142
- return;
143
- const error = hook?.ctx?.currentTest?.err;
144
- for (const test of suite.tests) {
145
- reportedTestUids.add(test.uid);
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
- ...stripExampleFromTitle(test.title),
148
- rid: test.uid,
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
- recorder.add('Finishing run', async () => {
173
- await finalizeRun('all.after');
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 => {
@@ -6,6 +6,7 @@ declare class HtmlPipe {
6
6
  description: any;
7
7
  apiKey: any;
8
8
  isHtml: any;
9
+ htmlCopyArtifacts: any;
9
10
  isEnabled: boolean;
10
11
  htmlOutputPath: string;
11
12
  filenameMsg: string;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
378
400
  }
@@ -864,51 +886,7 @@ function normalizeArtifacts(test) {
864
886
  ...(test.meta?.manuallyAttachedArtifacts || []),
865
887
  ];
866
888
  return allArtifacts
867
- .map(artifact => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.9.3",
3
+ "version": "2.9.4",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -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
- if (hook.name !== 'BeforeSuiteHook' && hook.name !== 'BeforeHook') return;
158
- const suite = hook.runnable.parent;
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 (!suite) return;
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
- const error = hook?.ctx?.currentTest?.err;
182
+ const stepHierarchy = buildUnifiedStepHierarchy(null, hookSteps);
163
183
 
164
- for (const test of suite.tests) {
165
- reportedTestUids.add(test.uid);
184
+ // Report the hook failure as a separate test entry
166
185
  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),
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
- recorder.add('Finishing run', async () => {
198
- await finalizeRun('all.after');
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
465
489
  }
@@ -1013,57 +1037,7 @@ function normalizeArtifacts(test) {
1013
1037
  ];
1014
1038
 
1015
1039
  return allArtifacts
1016
- .map(artifact => {
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