@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 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)
@@ -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 => {
@@ -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
- .map(att => ({
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: pwAttachments,
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
- if (typeof artifact === 'string' && !(0, utils_js_1.isHttpUrl)(artifact)) {
152
- const filename = (0, step_formatter_js_1.generateShortFilename)(artifact);
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(artifact, [this.runId, testRid, 'steps', filename]);
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;
@@ -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
  }
@@ -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/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 {