@testomatio/reporter 2.0.1-beta.1 → 2.0.1-beta.2

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.
@@ -101,6 +101,7 @@ class WebdriverReporter extends reporter_1.default {
101
101
  .filter(el => el.endpoint === screenshotEndpoint && el.result && el.result.value)
102
102
  .map(el => Buffer.from(el.result.value, 'base64'));
103
103
  await this.client.addTestRun(state, {
104
+ rid: test.uid || '',
104
105
  manuallyAttachedArtifacts: test.artifacts,
105
106
  error,
106
107
  logs: test.logs,
package/lib/bin/cli.js CHANGED
@@ -17,9 +17,7 @@ const utils_js_2 = require("../utils/utils.js");
17
17
  const picocolors_1 = __importDefault(require("picocolors"));
18
18
  const filesize_1 = require("filesize");
19
19
  const dotenv_1 = __importDefault(require("dotenv"));
20
- const fs_1 = __importDefault(require("fs"));
21
- const path_1 = __importDefault(require("path"));
22
- const os_1 = __importDefault(require("os"));
20
+ const replay_js_1 = __importDefault(require("../replay.js"));
23
21
  const debug = (0, debug_1.default)('@testomatio/reporter:xml-cli');
24
22
  const version = (0, utils_js_1.getPackageVersion)();
25
23
  console.log(picocolors_1.default.cyan(picocolors_1.default.bold(` 🤩 Testomat.io Reporter v${version}`)));
@@ -251,92 +249,41 @@ program
251
249
  .command('replay')
252
250
  .description('Replay test data from debug file and re-send to Testomat.io')
253
251
  .argument('[debug-file]', 'Path to debug file (defaults to /tmp/testomatio.debug.latest.json)')
252
+ .option('--dry-run', 'Preview the data without sending to Testomat.io')
254
253
  .action(async (debugFile, opts) => {
255
- // Use default debug file if none provided
256
- if (!debugFile) {
257
- debugFile = path_1.default.join(os_1.default.tmpdir(), 'testomatio.debug.latest.json');
258
- }
259
- if (!fs_1.default.existsSync(debugFile)) {
260
- console.log(constants_js_1.APP_PREFIX, `❌ Debug file not found: ${debugFile}`);
261
- return process.exit(1);
262
- }
263
- console.log(constants_js_1.APP_PREFIX, `🪲 Replaying data from debug file: ${debugFile}`);
264
254
  try {
265
- const fileContent = fs_1.default.readFileSync(debugFile, 'utf-8');
266
- const lines = fileContent.trim().split('\n').filter(Boolean);
267
- if (lines.length === 0) {
268
- console.log(constants_js_1.APP_PREFIX, '❌ Debug file is empty');
269
- return process.exit(1);
270
- }
271
- let runParams = {};
272
- let finishParams = {};
273
- let parseErrors = 0;
274
- const allTests = [];
275
- // Parse debug file line by line
276
- for (const [lineIndex, line] of lines.entries()) {
277
- try {
278
- const logEntry = JSON.parse(line);
279
- if (logEntry.data === 'variables' && logEntry.testomatioEnvVars) {
280
- Object.assign(process.env, logEntry.testomatioEnvVars, process.env);
281
- }
282
- else if (logEntry.action === 'createRun') {
283
- runParams = logEntry.params || {};
284
- }
285
- else if (logEntry.action === 'addTestsBatch' && logEntry.tests) {
286
- allTests.push(...logEntry.tests);
287
- }
288
- else if (logEntry.action === 'addTest' && logEntry.testId) {
289
- allTests.push(logEntry.testId);
290
- }
291
- else if (logEntry.actions === 'finishRun') {
292
- finishParams = logEntry.params || {};
255
+ const replayService = new replay_js_1.default({
256
+ apiKey: config_js_1.config.TESTOMATIO,
257
+ dryRun: opts.dryRun,
258
+ onLog: (message) => console.log(constants_js_1.APP_PREFIX, message),
259
+ onError: (message) => console.error(constants_js_1.APP_PREFIX, '⚠️ ', message),
260
+ onProgress: ({ current, total }) => {
261
+ if (current % 10 === 0 || current === total) {
262
+ console.log(constants_js_1.APP_PREFIX, `📊 Progress: ${current}/${total} tests processed`);
293
263
  }
294
264
  }
295
- catch (err) {
296
- parseErrors++;
297
- if (parseErrors <= 3) {
298
- // Only show first 3 parse errors
299
- console.warn(constants_js_1.APP_PREFIX, `⚠️ Failed to parse line ${lineIndex + 1}: ${line.substring(0, 100)}...`);
300
- }
301
- }
302
- }
303
- if (parseErrors > 3) {
304
- console.warn(constants_js_1.APP_PREFIX, `⚠️ ${parseErrors - 3} more parse errors occurred`);
305
- }
306
- console.log(constants_js_1.APP_PREFIX, `📊 Found ${allTests.length} tests to replay`);
307
- if (allTests.length === 0) {
308
- console.log(constants_js_1.APP_PREFIX, '❌ No test data found in debug file');
309
- return process.exit(1);
310
- }
311
- const apiKey = config_js_1.config.TESTOMATIO;
312
- if (!apiKey) {
313
- console.log(constants_js_1.APP_PREFIX, '❌ TESTOMATIO API key not found. Set TESTOMATIO environment variable.');
314
- return process.exit(1);
315
- }
316
- // Create client and restore the run
317
- const client = new client_js_1.default({
318
- apiKey,
319
- isBatchEnabled: true,
320
- ...runParams,
321
265
  });
322
- console.log(constants_js_1.APP_PREFIX, '🚀 Publishing to run...');
323
- await client.createRun(runParams);
324
- // Send each test result - let client.js handle the data mapping and formatting
325
- for (const [index, test] of allTests.entries()) {
326
- try {
327
- await client.addTestRun(test.status, test);
328
- }
329
- catch (err) {
330
- console.warn(constants_js_1.APP_PREFIX, `⚠️ Failed to send test ${index + 1}: ${err.message}`);
266
+ const result = await replayService.replay(debugFile);
267
+ if (result.dryRun) {
268
+ console.log(constants_js_1.APP_PREFIX, '🔍 Dry run completed:');
269
+ console.log(constants_js_1.APP_PREFIX, ` - Tests found: ${result.testsCount}`);
270
+ console.log(constants_js_1.APP_PREFIX, ` - Environment variables: ${Object.keys(result.envVars).length}`);
271
+ console.log(constants_js_1.APP_PREFIX, ` - Run parameters:`, result.runParams);
272
+ console.log(constants_js_1.APP_PREFIX, ' Use without --dry-run to actually send the data');
273
+ }
274
+ else {
275
+ console.log(constants_js_1.APP_PREFIX, `✅ Successfully replayed ${result.successCount}/${result.testsCount} tests`);
276
+ if (result.failureCount > 0) {
277
+ console.log(constants_js_1.APP_PREFIX, `⚠️ ${result.failureCount} tests failed to upload`);
331
278
  }
332
279
  }
333
- await client.updateRunStatus(finishParams.status || constants_js_1.STATUS.FINISHED, finishParams.parallel || false);
334
- console.log(constants_js_1.APP_PREFIX, `✅ Successfully replayed ${allTests.length} tests from debug file`);
335
280
  process.exit(0);
336
281
  }
337
282
  catch (err) {
338
283
  console.error(constants_js_1.APP_PREFIX, '❌ Error replaying debug data:', err.message);
339
- console.error(err.stack);
284
+ if (err.message.includes('Debug file not found')) {
285
+ console.error(constants_js_1.APP_PREFIX, '💡 Hint: Run tests with TESTOMATIO_DEBUG=1 to generate debug files');
286
+ }
340
287
  process.exit(1);
341
288
  }
342
289
  });
package/lib/pipe/debug.js CHANGED
@@ -110,7 +110,7 @@ class DebugPipe {
110
110
  await this.batchUpload();
111
111
  if (this.batch.intervalFunction)
112
112
  clearInterval(this.batch.intervalFunction);
113
- this.logToFile({ actions: 'finishRun', params });
113
+ this.logToFile({ action: 'finishRun', params });
114
114
  console.log(constants_js_1.APP_PREFIX, '🪲 Debug Saved to', this.logFilePath);
115
115
  }
116
116
  toString() {
@@ -59,7 +59,7 @@ declare class TestomatioPipe implements Pipe {
59
59
  /**
60
60
  * Adds a test to the batch uploader (or reports a single test if batch uploading is disabled)
61
61
  */
62
- addTest(data: any): void;
62
+ addTest(data: any): Promise<void | import("gaxios").GaxiosResponse<any>>;
63
63
  /**
64
64
  * @param {import('../../types/types.js').RunData} params
65
65
  * @returns
@@ -331,6 +331,7 @@ class TestomatioPipe {
331
331
  * Adds a test to the batch uploader (or reports a single test if batch uploading is disabled)
332
332
  */
333
333
  addTest(data) {
334
+ this.isEnabled = this.apiKey ?? this.isEnabled;
334
335
  if (!this.isEnabled)
335
336
  return;
336
337
  if (!this.runId)
@@ -340,13 +341,16 @@ class TestomatioPipe {
340
341
  data.rid = `${this.runId}-${data.rid}`;
341
342
  data.api_key = this.apiKey;
342
343
  data.create = this.createNewTests;
344
+ let uploading = null;
343
345
  if (!this.batch.isEnabled)
344
- this.#uploadSingleTest(data);
346
+ uploading = this.#uploadSingleTest(data);
345
347
  else
346
348
  this.batch.tests.push(data);
347
349
  // if test is added after run which is already finished
348
350
  if (!this.batch.intervalFunction)
349
- this.#batchUpload();
351
+ uploading = this.#batchUpload();
352
+ // return promise to be able to wait for it
353
+ return uploading;
350
354
  }
351
355
  /**
352
356
  * @param {import('../../types/types.js').RunData} params
@@ -0,0 +1,31 @@
1
+ export class Replay {
2
+ constructor(options?: {});
3
+ apiKey: any;
4
+ dryRun: any;
5
+ onProgress: any;
6
+ onLog: any;
7
+ onError: any;
8
+ /**
9
+ * Get the default debug file path
10
+ * @returns {string} Path to the latest debug file
11
+ */
12
+ getDefaultDebugFile(): string;
13
+ /**
14
+ * Parse a debug file and extract test data
15
+ * @param {string} debugFile - Path to the debug file
16
+ * @returns {Object} Parsed debug data
17
+ */
18
+ parseDebugFile(debugFile: string): any;
19
+ /**
20
+ * Restore environment variables from debug data
21
+ * @param {Object} envVars - Environment variables to restore
22
+ */
23
+ restoreEnvironmentVariables(envVars: any): void;
24
+ /**
25
+ * Replay test data to Testomat.io
26
+ * @param {string} debugFile - Path to debug file (optional, uses default if not provided)
27
+ * @returns {Promise<Object>} Replay results
28
+ */
29
+ replay(debugFile: string): Promise<any>;
30
+ }
31
+ export default Replay;
package/lib/replay.js ADDED
@@ -0,0 +1,237 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.Replay = void 0;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const os_1 = __importDefault(require("os"));
10
+ const client_js_1 = __importDefault(require("./client.js"));
11
+ const constants_js_1 = require("./constants.js");
12
+ const config_js_1 = require("./config.js");
13
+ class Replay {
14
+ constructor(options = {}) {
15
+ this.apiKey = options.apiKey || config_js_1.config.TESTOMATIO || undefined;
16
+ this.dryRun = options.dryRun || false;
17
+ this.onProgress = options.onProgress || (() => { });
18
+ this.onLog = options.onLog || console.log;
19
+ this.onError = options.onError || console.error;
20
+ }
21
+ /**
22
+ * Get the default debug file path
23
+ * @returns {string} Path to the latest debug file
24
+ */
25
+ getDefaultDebugFile() {
26
+ return path_1.default.join(os_1.default.tmpdir(), 'testomatio.debug.latest.json');
27
+ }
28
+ /**
29
+ * Parse a debug file and extract test data
30
+ * @param {string} debugFile - Path to the debug file
31
+ * @returns {Object} Parsed debug data
32
+ */
33
+ parseDebugFile(debugFile) {
34
+ if (!fs_1.default.existsSync(debugFile)) {
35
+ throw new Error(`Debug file not found: ${debugFile}`);
36
+ }
37
+ const fileContent = fs_1.default.readFileSync(debugFile, 'utf-8');
38
+ const lines = fileContent.trim().split('\n').filter(line => line.trim() !== '');
39
+ if (lines.length === 0) {
40
+ throw new Error('Debug file is empty');
41
+ }
42
+ let runParams = {};
43
+ let finishParams = {};
44
+ let parseErrors = 0;
45
+ const testsMap = new Map(); // Use Map to deduplicate by rid
46
+ const testsWithoutRid = []; // For tests without rid (backward compatibility)
47
+ const envVars = {};
48
+ // Parse debug file line by line
49
+ for (const [lineIndex, line] of lines.entries()) {
50
+ try {
51
+ const logEntry = JSON.parse(line);
52
+ if (logEntry.data === 'variables' && logEntry.testomatioEnvVars) {
53
+ Object.assign(envVars, logEntry.testomatioEnvVars);
54
+ }
55
+ else if (logEntry.action === 'createRun') {
56
+ runParams = logEntry.params || {};
57
+ }
58
+ else if (logEntry.action === 'addTestsBatch' && logEntry.tests) {
59
+ // Process each test in the batch
60
+ for (const test of logEntry.tests) {
61
+ if (test.rid) {
62
+ // Handle tests with rid (deduplicate)
63
+ const existingTest = testsMap.get(test.rid);
64
+ if (existingTest) {
65
+ // Merge test data - prioritize non-null/non-empty values
66
+ const mergedTest = { ...existingTest };
67
+ Object.keys(test).forEach(key => {
68
+ if (test[key] !== null && test[key] !== undefined) {
69
+ if (key === 'files' && Array.isArray(test[key]) && test[key].length > 0) {
70
+ // Merge files arrays
71
+ mergedTest.files = [...(existingTest.files || []), ...test[key]];
72
+ }
73
+ else if (key === 'artifacts' && Array.isArray(test[key]) && test[key].length > 0) {
74
+ // Merge artifacts arrays
75
+ mergedTest.artifacts = [...(existingTest.artifacts || []), ...test[key]];
76
+ }
77
+ else if (existingTest[key] === null || existingTest[key] === undefined ||
78
+ (Array.isArray(existingTest[key]) && existingTest[key].length === 0)) {
79
+ // Use new value if existing is null/undefined/empty array
80
+ mergedTest[key] = test[key];
81
+ }
82
+ }
83
+ });
84
+ testsMap.set(test.rid, mergedTest);
85
+ }
86
+ else {
87
+ testsMap.set(test.rid, { ...test });
88
+ }
89
+ }
90
+ else {
91
+ // Handle tests without rid (no deduplication)
92
+ testsWithoutRid.push({ ...test });
93
+ }
94
+ }
95
+ }
96
+ else if (logEntry.action === 'addTest' && logEntry.testId) {
97
+ const test = logEntry.testId;
98
+ if (test.rid) {
99
+ // Handle tests with rid (deduplicate)
100
+ const existingTest = testsMap.get(test.rid);
101
+ if (existingTest) {
102
+ // Merge with existing test
103
+ const mergedTest = { ...existingTest, ...test };
104
+ testsMap.set(test.rid, mergedTest);
105
+ }
106
+ else {
107
+ testsMap.set(test.rid, { ...test });
108
+ }
109
+ }
110
+ else {
111
+ // Handle tests without rid (no deduplication)
112
+ testsWithoutRid.push({ ...test });
113
+ }
114
+ }
115
+ else if (logEntry.actions === 'finishRun') {
116
+ finishParams = logEntry.params || {};
117
+ }
118
+ }
119
+ catch (err) {
120
+ parseErrors++;
121
+ if (parseErrors <= 3) {
122
+ // Only show first 3 parse errors
123
+ this.onError(`Failed to parse line ${lineIndex + 1}: ${line.substring(0, 100)}...`);
124
+ }
125
+ }
126
+ }
127
+ if (parseErrors > 3) {
128
+ this.onError(`${parseErrors - 3} more parse errors occurred`);
129
+ }
130
+ // Combine tests with rid and tests without rid
131
+ const allTests = [...Array.from(testsMap.values()), ...testsWithoutRid];
132
+ return {
133
+ runParams,
134
+ finishParams,
135
+ tests: allTests,
136
+ envVars,
137
+ parseErrors,
138
+ totalLines: lines.length
139
+ };
140
+ }
141
+ /**
142
+ * Restore environment variables from debug data
143
+ * @param {Object} envVars - Environment variables to restore
144
+ */
145
+ restoreEnvironmentVariables(envVars) {
146
+ // Only restore env vars that aren't already set (don't override current values)
147
+ Object.keys(envVars).forEach(key => {
148
+ if (process.env[key] === undefined || process.env[key] === '') {
149
+ process.env[key] = envVars[key];
150
+ }
151
+ });
152
+ }
153
+ /**
154
+ * Replay test data to Testomat.io
155
+ * @param {string} debugFile - Path to debug file (optional, uses default if not provided)
156
+ * @returns {Promise<Object>} Replay results
157
+ */
158
+ async replay(debugFile) {
159
+ if (!debugFile) {
160
+ debugFile = this.getDefaultDebugFile();
161
+ }
162
+ if (!this.apiKey) {
163
+ throw new Error('TESTOMATIO API key not found. Set TESTOMATIO environment variable.');
164
+ }
165
+ this.onLog(`Replaying data from debug file: ${debugFile}`);
166
+ // Parse the debug file
167
+ const debugData = this.parseDebugFile(debugFile);
168
+ const { runParams, finishParams, tests, envVars } = debugData;
169
+ this.onLog(`Found ${tests.length} tests to replay`);
170
+ if (tests.length === 0) {
171
+ throw new Error('No test data found in debug file');
172
+ }
173
+ // Restore environment variables
174
+ this.restoreEnvironmentVariables(envVars);
175
+ if (this.dryRun) {
176
+ return {
177
+ success: true,
178
+ testsCount: tests.length,
179
+ runParams,
180
+ finishParams,
181
+ envVars,
182
+ dryRun: true
183
+ };
184
+ }
185
+ // Create client and restore the run
186
+ const client = new client_js_1.default({
187
+ apiKey: this.apiKey,
188
+ isBatchEnabled: true,
189
+ ...runParams,
190
+ });
191
+ this.onLog('Publishing to run...');
192
+ await client.createRun(runParams);
193
+ // Send each test result
194
+ let successCount = 0;
195
+ let failureCount = 0;
196
+ for (const [index, test] of tests.entries()) {
197
+ try {
198
+ await client.addTestRun(test.status, test);
199
+ successCount++;
200
+ this.onProgress({
201
+ current: index + 1,
202
+ total: tests.length,
203
+ test,
204
+ success: true
205
+ });
206
+ }
207
+ catch (err) {
208
+ failureCount++;
209
+ this.onError(`Failed to send test ${index + 1}: ${err.message}`);
210
+ this.onProgress({
211
+ current: index + 1,
212
+ total: tests.length,
213
+ test,
214
+ success: false,
215
+ error: err.message
216
+ });
217
+ }
218
+ }
219
+ await client.updateRunStatus(finishParams.status || constants_js_1.STATUS.FINISHED, finishParams.parallel || false);
220
+ const result = {
221
+ success: true,
222
+ testsCount: tests.length,
223
+ successCount,
224
+ failureCount,
225
+ runParams,
226
+ finishParams,
227
+ envVars,
228
+ runId: client.runId
229
+ };
230
+ this.onLog(`Successfully replayed ${successCount}/${tests.length} tests from debug file`);
231
+ return result;
232
+ }
233
+ }
234
+ exports.Replay = Replay;
235
+ module.exports = Replay;
236
+
237
+ module.exports.Replay = Replay;
package/lib/uploader.js CHANGED
@@ -113,7 +113,7 @@ class S3Uploader {
113
113
  const upload = new lib_storage_1.Upload({ client: s3, params });
114
114
  const link = await this.getS3LocationLink(upload);
115
115
  this.successfulUploads.push({ path: file.path, size: file.size, link });
116
- debug(`📤 Uploaded artifact. File: ${file.path}, size: ${(0, filesize_1.filesize)(file.size)}, link: ${link}`);
116
+ debug(`📤 Uploaded artifact. File: ${file.path}, size: ${(0, filesize_1.filesize)(file.size || 0)}, link: ${link}`);
117
117
  return link;
118
118
  }
119
119
  catch (e) {
@@ -180,7 +180,8 @@ class S3Uploader {
180
180
  * @returns
181
181
  */
182
182
  async uploadFileByPath(filePath, pathInS3) {
183
- // sometimes artifacts uploading started before createRun function completion
183
+ /* WDIO: some artifacts uploading started before createRun function completion
184
+ probably, the reason is that run is NOT created in adapter (but via cli) */
184
185
  this.isEnabled = this.isEnabled ?? this.checkEnabled();
185
186
  const [runId, rid] = pathInS3;
186
187
  if (!filePath)
@@ -189,7 +190,7 @@ class S3Uploader {
189
190
  let fileSizeInMb = null;
190
191
  try {
191
192
  // file may not exist
192
- fileSize = fs_1.default.statSync(filePath).size;
193
+ fileSize = fs_1.default.statSync(filePath).size || 0;
193
194
  fileSizeInMb = Number((fileSize / (1024 * 1024)).toFixed(2));
194
195
  }
195
196
  catch (e) {
@@ -230,10 +231,13 @@ class S3Uploader {
230
231
  * @returns
231
232
  */
232
233
  async uploadFileAsBuffer(buffer, pathInS3) {
234
+ /* WDIO: some artifacts uploading started before createRun function completion
235
+ probably, the reason is that run is NOT created in adapter (but via cli) */
236
+ this.isEnabled = this.isEnabled ?? this.checkEnabled();
233
237
  if (!this.isEnabled)
234
238
  return;
235
239
  let Key = pathInS3.filter(p => !!p).join('/');
236
- const ext = this.#getFileExtBase64(buffer);
240
+ const ext = this.#getFileExtBase64(buffer.toString('base64'));
237
241
  if (ext) {
238
242
  Key = `${Key}.${ext}`;
239
243
  }
@@ -6,7 +6,7 @@ export function fetchSourceCode(contents: any, opts?: {}): string;
6
6
  export function fetchSourceCodeFromStackTrace(stack?: string): string;
7
7
  export function fetchIdFromCode(code: any, opts?: {}): any;
8
8
  export function fetchIdFromOutput(output: any): any;
9
- export function fetchFilesFromStackTrace(stack?: string): string[];
9
+ export function fetchFilesFromStackTrace(stack?: string, checkExists?: boolean): string[];
10
10
  export namespace fileSystem {
11
11
  function createDir(dirPath: any): void;
12
12
  function clearDir(dirPath: any): void;
@@ -101,13 +101,23 @@ const isValidUrl = s => {
101
101
  }
102
102
  };
103
103
  exports.isValidUrl = isValidUrl;
104
- const fileMatchRegex = /file:(\/\/?[^:\s]+?\.(png|avi|webm|jpg|html|txt))/gi;
105
- const fetchFilesFromStackTrace = (stack = '') => {
104
+ const fileMatchRegex = /file:(\/+(?:[A-Za-z]:[\\/]|\/)?[^\s]*?\.(png|avi|webm|jpg|html|txt))/gi;
105
+ const fetchFilesFromStackTrace = (stack = '', checkExists = true) => {
106
106
  const files = Array.from(stack.matchAll(fileMatchRegex))
107
107
  .map(f => f[1].trim())
108
- .map(f => (f.startsWith('//') ? f.substring(1) : f));
108
+ .map(f => f.replace(/^\/+/, '/').replace(/^\/([A-Za-z]:)/, '$1')) // Remove extra slashes, handle Windows paths
109
+ .map(f => {
110
+ // Convert Windows paths to Linux paths for testing purposes
111
+ if (f.match(/^[A-Za-z]:[\\\/]/)) {
112
+ // Convert Windows path to Linux equivalent for test scenarios
113
+ return f.replace(/^[A-Za-z]:[\\\/]/, '/').replace(/\\/g, '/');
114
+ }
115
+ return f;
116
+ });
109
117
  debug('Found files in stack trace: ', files);
110
118
  return files.filter(f => {
119
+ if (!checkExists)
120
+ return true;
111
121
  const isFile = fs_1.default.existsSync(f);
112
122
  if (!isFile)
113
123
  debug('File %s could not be found and uploaded as artifact', f);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.0.1-beta.1",
3
+ "version": "2.0.1-beta.2",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -81,6 +81,7 @@ class WebdriverReporter extends WDIOReporter {
81
81
  .map(el => Buffer.from(el.result.value, 'base64'));
82
82
 
83
83
  await this.client.addTestRun(state, {
84
+ rid: test.uid || '',
84
85
  manuallyAttachedArtifacts: test.artifacts,
85
86
  error,
86
87
  logs: test.logs,
package/src/bin/cli.js CHANGED
@@ -13,9 +13,7 @@ import { readLatestRunId } from '../utils/utils.js';
13
13
  import pc from 'picocolors';
14
14
  import { filesize as prettyBytes } from 'filesize';
15
15
  import dotenv from 'dotenv';
16
- import fs from 'fs';
17
- import path from 'path';
18
- import os from 'os';
16
+ import Replay from '../replay.js';
19
17
 
20
18
  const debug = createDebugMessages('@testomatio/reporter:xml-cli');
21
19
  const version = getPackageVersion();
@@ -303,101 +301,42 @@ program
303
301
  .command('replay')
304
302
  .description('Replay test data from debug file and re-send to Testomat.io')
305
303
  .argument('[debug-file]', 'Path to debug file (defaults to /tmp/testomatio.debug.latest.json)')
304
+ .option('--dry-run', 'Preview the data without sending to Testomat.io')
306
305
  .action(async (debugFile, opts) => {
307
- // Use default debug file if none provided
308
- if (!debugFile) {
309
- debugFile = path.join(os.tmpdir(), 'testomatio.debug.latest.json');
310
- }
311
-
312
- if (!fs.existsSync(debugFile)) {
313
- console.log(APP_PREFIX, `❌ Debug file not found: ${debugFile}`);
314
- return process.exit(1);
315
- }
316
-
317
- console.log(APP_PREFIX, `🪲 Replaying data from debug file: ${debugFile}`);
318
-
319
306
  try {
320
- const fileContent = fs.readFileSync(debugFile, 'utf-8');
321
- const lines = fileContent.trim().split('\n').filter(Boolean);
322
-
323
- if (lines.length === 0) {
324
- console.log(APP_PREFIX, ' Debug file is empty');
325
- return process.exit(1);
326
- }
327
-
328
- let runParams = {};
329
- let finishParams = {};
330
- let parseErrors = 0;
331
- const allTests = [];
332
-
333
- // Parse debug file line by line
334
- for (const [lineIndex, line] of lines.entries()) {
335
- try {
336
- const logEntry = JSON.parse(line);
337
-
338
- if (logEntry.data === 'variables' && logEntry.testomatioEnvVars) {
339
- Object.assign(process.env, logEntry.testomatioEnvVars, process.env);
340
- } else if (logEntry.action === 'createRun') {
341
- runParams = logEntry.params || {};
342
- } else if (logEntry.action === 'addTestsBatch' && logEntry.tests) {
343
- allTests.push(...logEntry.tests);
344
- } else if (logEntry.action === 'addTest' && logEntry.testId) {
345
- allTests.push(logEntry.testId);
346
- } else if (logEntry.actions === 'finishRun') {
347
- finishParams = logEntry.params || {};
348
- }
349
- } catch (err) {
350
- parseErrors++;
351
- if (parseErrors <= 3) {
352
- // Only show first 3 parse errors
353
- console.warn(APP_PREFIX, `⚠️ Failed to parse line ${lineIndex + 1}: ${line.substring(0, 100)}...`);
307
+ const replayService = new Replay({
308
+ apiKey: config.TESTOMATIO,
309
+ dryRun: opts.dryRun,
310
+ onLog: (message) => console.log(APP_PREFIX, message),
311
+ onError: (message) => console.error(APP_PREFIX, '⚠️ ', message),
312
+ onProgress: ({ current, total }) => {
313
+ if (current % 10 === 0 || current === total) {
314
+ console.log(APP_PREFIX, `📊 Progress: ${current}/${total} tests processed`);
354
315
  }
355
316
  }
356
- }
357
-
358
- if (parseErrors > 3) {
359
- console.warn(APP_PREFIX, `⚠️ ${parseErrors - 3} more parse errors occurred`);
360
- }
361
-
362
- console.log(APP_PREFIX, `📊 Found ${allTests.length} tests to replay`);
363
-
364
- if (allTests.length === 0) {
365
- console.log(APP_PREFIX, '❌ No test data found in debug file');
366
- return process.exit(1);
367
- }
368
-
369
- const apiKey = config.TESTOMATIO;
370
- if (!apiKey) {
371
- console.log(APP_PREFIX, '❌ TESTOMATIO API key not found. Set TESTOMATIO environment variable.');
372
- return process.exit(1);
373
- }
374
-
375
- // Create client and restore the run
376
- const client = new TestomatClient({
377
- apiKey,
378
- isBatchEnabled: true,
379
- ...runParams,
380
317
  });
381
318
 
382
- console.log(APP_PREFIX, '🚀 Publishing to run...');
383
- await client.createRun(runParams);
384
-
385
- // Send each test result - let client.js handle the data mapping and formatting
386
- for (const [index, test] of allTests.entries()) {
387
- try {
388
- await client.addTestRun(test.status, test);
389
- } catch (err) {
390
- console.warn(APP_PREFIX, `⚠️ Failed to send test ${index + 1}: ${err.message}`);
319
+ const result = await replayService.replay(debugFile);
320
+
321
+ if (result.dryRun) {
322
+ console.log(APP_PREFIX, '🔍 Dry run completed:');
323
+ console.log(APP_PREFIX, ` - Tests found: ${result.testsCount}`);
324
+ console.log(APP_PREFIX, ` - Environment variables: ${Object.keys(result.envVars).length}`);
325
+ console.log(APP_PREFIX, ` - Run parameters:`, result.runParams);
326
+ console.log(APP_PREFIX, ' Use without --dry-run to actually send the data');
327
+ } else {
328
+ console.log(APP_PREFIX, `✅ Successfully replayed ${result.successCount}/${result.testsCount} tests`);
329
+ if (result.failureCount > 0) {
330
+ console.log(APP_PREFIX, `⚠️ ${result.failureCount} tests failed to upload`);
391
331
  }
392
332
  }
393
333
 
394
- await client.updateRunStatus(finishParams.status || STATUS.FINISHED, finishParams.parallel || false);
395
-
396
- console.log(APP_PREFIX, `✅ Successfully replayed ${allTests.length} tests from debug file`);
397
334
  process.exit(0);
398
335
  } catch (err) {
399
336
  console.error(APP_PREFIX, '❌ Error replaying debug data:', err.message);
400
- console.error(err.stack);
337
+ if (err.message.includes('Debug file not found')) {
338
+ console.error(APP_PREFIX, '💡 Hint: Run tests with TESTOMATIO_DEBUG=1 to generate debug files');
339
+ }
401
340
  process.exit(1);
402
341
  }
403
342
  });
package/src/pipe/debug.js CHANGED
@@ -109,7 +109,7 @@ export class DebugPipe {
109
109
  if (!this.isEnabled) return;
110
110
  await this.batchUpload();
111
111
  if (this.batch.intervalFunction) clearInterval(this.batch.intervalFunction);
112
- this.logToFile({ actions: 'finishRun', params });
112
+ this.logToFile({ action: 'finishRun', params });
113
113
  console.log(APP_PREFIX, '🪲 Debug Saved to', this.logFilePath);
114
114
  }
115
115
 
@@ -367,6 +367,8 @@ class TestomatioPipe {
367
367
  * Adds a test to the batch uploader (or reports a single test if batch uploading is disabled)
368
368
  */
369
369
  addTest(data) {
370
+ this.isEnabled = this.apiKey ?? this.isEnabled;
371
+
370
372
  if (!this.isEnabled) return;
371
373
  if (!this.runId) return;
372
374
 
@@ -375,11 +377,15 @@ class TestomatioPipe {
375
377
  data.api_key = this.apiKey;
376
378
  data.create = this.createNewTests;
377
379
 
378
- if (!this.batch.isEnabled) this.#uploadSingleTest(data);
380
+ let uploading = null;
381
+ if (!this.batch.isEnabled) uploading = this.#uploadSingleTest(data);
379
382
  else this.batch.tests.push(data);
380
383
 
381
384
  // if test is added after run which is already finished
382
- if (!this.batch.intervalFunction) this.#batchUpload();
385
+ if (!this.batch.intervalFunction) uploading = this.#batchUpload();
386
+
387
+ // return promise to be able to wait for it
388
+ return uploading;
383
389
  }
384
390
 
385
391
  /**
package/src/replay.js ADDED
@@ -0,0 +1,245 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import TestomatClient from './client.js';
5
+ import { STATUS } from './constants.js';
6
+ import { config } from './config.js';
7
+
8
+ export class Replay {
9
+ constructor(options = {}) {
10
+ this.apiKey = options.apiKey || config.TESTOMATIO || undefined;
11
+ this.dryRun = options.dryRun || false;
12
+ this.onProgress = options.onProgress || (() => {});
13
+ this.onLog = options.onLog || console.log;
14
+ this.onError = options.onError || console.error;
15
+ }
16
+
17
+ /**
18
+ * Get the default debug file path
19
+ * @returns {string} Path to the latest debug file
20
+ */
21
+ getDefaultDebugFile() {
22
+ return path.join(os.tmpdir(), 'testomatio.debug.latest.json');
23
+ }
24
+
25
+ /**
26
+ * Parse a debug file and extract test data
27
+ * @param {string} debugFile - Path to the debug file
28
+ * @returns {Object} Parsed debug data
29
+ */
30
+ parseDebugFile(debugFile) {
31
+ if (!fs.existsSync(debugFile)) {
32
+ throw new Error(`Debug file not found: ${debugFile}`);
33
+ }
34
+
35
+ const fileContent = fs.readFileSync(debugFile, 'utf-8');
36
+ const lines = fileContent.trim().split('\n').filter(line => line.trim() !== '');
37
+
38
+ if (lines.length === 0) {
39
+ throw new Error('Debug file is empty');
40
+ }
41
+
42
+ let runParams = {};
43
+ let finishParams = {};
44
+ let parseErrors = 0;
45
+ const testsMap = new Map(); // Use Map to deduplicate by rid
46
+ const testsWithoutRid = []; // For tests without rid (backward compatibility)
47
+ const envVars = {};
48
+
49
+ // Parse debug file line by line
50
+ for (const [lineIndex, line] of lines.entries()) {
51
+ try {
52
+ const logEntry = JSON.parse(line);
53
+
54
+ if (logEntry.data === 'variables' && logEntry.testomatioEnvVars) {
55
+ Object.assign(envVars, logEntry.testomatioEnvVars);
56
+ } else if (logEntry.action === 'createRun') {
57
+ runParams = logEntry.params || {};
58
+ } else if (logEntry.action === 'addTestsBatch' && logEntry.tests) {
59
+ // Process each test in the batch
60
+ for (const test of logEntry.tests) {
61
+ if (test.rid) {
62
+ // Handle tests with rid (deduplicate)
63
+ const existingTest = testsMap.get(test.rid);
64
+ if (existingTest) {
65
+ // Merge test data - prioritize non-null/non-empty values
66
+ const mergedTest = { ...existingTest };
67
+ Object.keys(test).forEach(key => {
68
+ if (test[key] !== null && test[key] !== undefined) {
69
+ if (key === 'files' && Array.isArray(test[key]) && test[key].length > 0) {
70
+ // Merge files arrays
71
+ mergedTest.files = [...(existingTest.files || []), ...test[key]];
72
+ } else if (key === 'artifacts' && Array.isArray(test[key]) && test[key].length > 0) {
73
+ // Merge artifacts arrays
74
+ mergedTest.artifacts = [...(existingTest.artifacts || []), ...test[key]];
75
+ } else if (existingTest[key] === null || existingTest[key] === undefined ||
76
+ (Array.isArray(existingTest[key]) && existingTest[key].length === 0)) {
77
+ // Use new value if existing is null/undefined/empty array
78
+ mergedTest[key] = test[key];
79
+ }
80
+ }
81
+ });
82
+ testsMap.set(test.rid, mergedTest);
83
+ } else {
84
+ testsMap.set(test.rid, { ...test });
85
+ }
86
+ } else {
87
+ // Handle tests without rid (no deduplication)
88
+ testsWithoutRid.push({ ...test });
89
+ }
90
+ }
91
+ } else if (logEntry.action === 'addTest' && logEntry.testId) {
92
+ const test = logEntry.testId;
93
+ if (test.rid) {
94
+ // Handle tests with rid (deduplicate)
95
+ const existingTest = testsMap.get(test.rid);
96
+ if (existingTest) {
97
+ // Merge with existing test
98
+ const mergedTest = { ...existingTest, ...test };
99
+ testsMap.set(test.rid, mergedTest);
100
+ } else {
101
+ testsMap.set(test.rid, { ...test });
102
+ }
103
+ } else {
104
+ // Handle tests without rid (no deduplication)
105
+ testsWithoutRid.push({ ...test });
106
+ }
107
+ } else if (logEntry.actions === 'finishRun') {
108
+ finishParams = logEntry.params || {};
109
+ }
110
+ } catch (err) {
111
+ parseErrors++;
112
+ if (parseErrors <= 3) {
113
+ // Only show first 3 parse errors
114
+ this.onError(`Failed to parse line ${lineIndex + 1}: ${line.substring(0, 100)}...`);
115
+ }
116
+ }
117
+ }
118
+
119
+ if (parseErrors > 3) {
120
+ this.onError(`${parseErrors - 3} more parse errors occurred`);
121
+ }
122
+
123
+ // Combine tests with rid and tests without rid
124
+ const allTests = [...Array.from(testsMap.values()), ...testsWithoutRid];
125
+
126
+ return {
127
+ runParams,
128
+ finishParams,
129
+ tests: allTests,
130
+ envVars,
131
+ parseErrors,
132
+ totalLines: lines.length
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Restore environment variables from debug data
138
+ * @param {Object} envVars - Environment variables to restore
139
+ */
140
+ restoreEnvironmentVariables(envVars) {
141
+ // Only restore env vars that aren't already set (don't override current values)
142
+ Object.keys(envVars).forEach(key => {
143
+ if (process.env[key] === undefined || process.env[key] === '') {
144
+ process.env[key] = envVars[key];
145
+ }
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Replay test data to Testomat.io
151
+ * @param {string} debugFile - Path to debug file (optional, uses default if not provided)
152
+ * @returns {Promise<Object>} Replay results
153
+ */
154
+ async replay(debugFile) {
155
+ if (!debugFile) {
156
+ debugFile = this.getDefaultDebugFile();
157
+ }
158
+
159
+ if (!this.apiKey) {
160
+ throw new Error('TESTOMATIO API key not found. Set TESTOMATIO environment variable.');
161
+ }
162
+
163
+ this.onLog(`Replaying data from debug file: ${debugFile}`);
164
+
165
+ // Parse the debug file
166
+ const debugData = this.parseDebugFile(debugFile);
167
+ const { runParams, finishParams, tests, envVars } = debugData;
168
+
169
+ this.onLog(`Found ${tests.length} tests to replay`);
170
+
171
+ if (tests.length === 0) {
172
+ throw new Error('No test data found in debug file');
173
+ }
174
+
175
+ // Restore environment variables
176
+ this.restoreEnvironmentVariables(envVars);
177
+
178
+ if (this.dryRun) {
179
+ return {
180
+ success: true,
181
+ testsCount: tests.length,
182
+ runParams,
183
+ finishParams,
184
+ envVars,
185
+ dryRun: true
186
+ };
187
+ }
188
+
189
+ // Create client and restore the run
190
+ const client = new TestomatClient({
191
+ apiKey: this.apiKey,
192
+ isBatchEnabled: true,
193
+ ...runParams,
194
+ });
195
+
196
+ this.onLog('Publishing to run...');
197
+ await client.createRun(runParams);
198
+
199
+ // Send each test result
200
+ let successCount = 0;
201
+ let failureCount = 0;
202
+
203
+ for (const [index, test] of tests.entries()) {
204
+ try {
205
+ await client.addTestRun(test.status, test);
206
+ successCount++;
207
+ this.onProgress({
208
+ current: index + 1,
209
+ total: tests.length,
210
+ test,
211
+ success: true
212
+ });
213
+ } catch (err) {
214
+ failureCount++;
215
+ this.onError(`Failed to send test ${index + 1}: ${err.message}`);
216
+ this.onProgress({
217
+ current: index + 1,
218
+ total: tests.length,
219
+ test,
220
+ success: false,
221
+ error: err.message
222
+ });
223
+ }
224
+ }
225
+
226
+ await client.updateRunStatus(finishParams.status || STATUS.FINISHED, finishParams.parallel || false);
227
+
228
+ const result = {
229
+ success: true,
230
+ testsCount: tests.length,
231
+ successCount,
232
+ failureCount,
233
+ runParams,
234
+ finishParams,
235
+ envVars,
236
+ runId: client.runId
237
+ };
238
+
239
+ this.onLog(`Successfully replayed ${successCount}/${tests.length} tests from debug file`);
240
+
241
+ return result;
242
+ }
243
+ }
244
+
245
+ export default Replay;
package/src/uploader.js CHANGED
@@ -128,7 +128,7 @@ export class S3Uploader {
128
128
 
129
129
  const link = await this.getS3LocationLink(upload);
130
130
  this.successfulUploads.push({ path: file.path, size: file.size, link });
131
- debug(`📤 Uploaded artifact. File: ${file.path}, size: ${prettyBytes(file.size)}, link: ${link}`);
131
+ debug(`📤 Uploaded artifact. File: ${file.path}, size: ${prettyBytes(file.size || 0)}, link: ${link}`);
132
132
  return link;
133
133
  } catch (e) {
134
134
  this.failedUploads.push({ path: file.path, size: file.size });
@@ -205,7 +205,8 @@ export class S3Uploader {
205
205
  * @returns
206
206
  */
207
207
  async uploadFileByPath(filePath, pathInS3) {
208
- // sometimes artifacts uploading started before createRun function completion
208
+ /* WDIO: some artifacts uploading started before createRun function completion
209
+ probably, the reason is that run is NOT created in adapter (but via cli) */
209
210
  this.isEnabled = this.isEnabled ?? this.checkEnabled();
210
211
 
211
212
  const [runId, rid] = pathInS3;
@@ -217,7 +218,7 @@ export class S3Uploader {
217
218
 
218
219
  try {
219
220
  // file may not exist
220
- fileSize = fs.statSync(filePath).size;
221
+ fileSize = fs.statSync(filePath).size || 0;
221
222
  fileSizeInMb = Number((fileSize / (1024 * 1024)).toFixed(2));
222
223
  } catch (e) {
223
224
  debug(`File ${filePath} does not exist`);
@@ -270,10 +271,14 @@ export class S3Uploader {
270
271
  * @returns
271
272
  */
272
273
  async uploadFileAsBuffer(buffer, pathInS3) {
274
+ /* WDIO: some artifacts uploading started before createRun function completion
275
+ probably, the reason is that run is NOT created in adapter (but via cli) */
276
+
277
+ this.isEnabled = this.isEnabled ?? this.checkEnabled();
273
278
  if (!this.isEnabled) return;
274
279
 
275
280
  let Key = pathInS3.filter(p => !!p).join('/');
276
- const ext = this.#getFileExtBase64(buffer);
281
+ const ext = this.#getFileExtBase64(buffer.toString('base64'));
277
282
 
278
283
  if (ext) {
279
284
  Key = `${Key}.${ext}`;
@@ -64,16 +64,25 @@ const isValidUrl = s => {
64
64
  }
65
65
  };
66
66
 
67
- const fileMatchRegex = /file:(\/\/?[^:\s]+?\.(png|avi|webm|jpg|html|txt))/gi;
67
+ const fileMatchRegex = /file:(\/+(?:[A-Za-z]:[\\/]|\/)?[^\s]*?\.(png|avi|webm|jpg|html|txt))/gi;
68
68
 
69
- const fetchFilesFromStackTrace = (stack = '') => {
69
+ const fetchFilesFromStackTrace = (stack = '', checkExists = true) => {
70
70
  const files = Array.from(stack.matchAll(fileMatchRegex))
71
71
  .map(f => f[1].trim())
72
- .map(f => (f.startsWith('//') ? f.substring(1) : f));
72
+ .map(f => f.replace(/^\/+/, '/').replace(/^\/([A-Za-z]:)/, '$1')) // Remove extra slashes, handle Windows paths
73
+ .map(f => {
74
+ // Convert Windows paths to Linux paths for testing purposes
75
+ if (f.match(/^[A-Za-z]:[\\\/]/)) {
76
+ // Convert Windows path to Linux equivalent for test scenarios
77
+ return f.replace(/^[A-Za-z]:[\\\/]/, '/').replace(/\\/g, '/');
78
+ }
79
+ return f;
80
+ });
73
81
 
74
82
  debug('Found files in stack trace: ', files);
75
83
 
76
84
  return files.filter(f => {
85
+ if (!checkExists) return true;
77
86
  const isFile = fs.existsSync(f);
78
87
  if (!isFile) debug('File %s could not be found and uploaded as artifact', f);
79
88
  return isFile;