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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-reporter-cli",
3
- "version": "2.9.3-beta.4-allure-links",
3
+ "version": "2.9.4",
4
4
  "description": "Yarn Berry compatible standalone CLI for @testomatio/reporter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
 
@@ -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 files = pwAttachments
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: pwAttachments,
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('allure')
357
- .description('Parse Allure result files and upload to Testomat.io')
358
- .argument('<pattern>', 'Allure result directory pattern')
359
- .option('-d, --dir <dir>', 'Project directory')
360
- .option('--timelimit <time>', 'default time limit in seconds to kill a stuck process')
361
- .option('--with-package', 'Keep full package path in file names (default: strip package prefix)')
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
- const runReader = new AllureReader({ withPackage: opts.withPackage, javaTests, lang: opts.lang });
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
- let timeoutTimer;
374
- if (opts.timelimit) {
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
- try {
387
- await runReader.parse(pattern);
388
- await runReader.createRun();
389
- await runReader.uploadData();
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
- if (timeoutTimer) clearTimeout(timeoutTimer);
395
- });
379
+ log.info(`Processing ${tests.length} tests from ${jsonlFile}`);
396
380
 
397
- program
398
- .command('upload-artifacts')
399
- .description('Upload artifacts to Testomat.io')
400
- .option('--force', 'Re-upload artifacts even if they were uploaded before')
401
- .action(async opts => {
402
- const apiKey = config.TESTOMATIO;
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
- if (typeof artifact === 'string' && !isHttpUrl(artifact)) {
163
- const filename = generateShortFilename(artifact);
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
- artifact,
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, '&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
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