wdio-test-ledger-service 9.0.2 → 9.2.1

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.
Files changed (3) hide show
  1. package/README.md +40 -0
  2. package/package.json +1 -1
  3. package/src/index.js +285 -1
package/README.md CHANGED
@@ -23,6 +23,9 @@ services: [['test-ledger', {
23
23
  projectId : 123, // Only needed if using more than one project
24
24
  appVersion : `2.8.10`, // The code version can also be set here
25
25
  enableFlaky : 1, // Will mark tests as flaky if it detects them based on previous runs
26
+ uploadArtifacts : true, // Enable screenshot/video artifact uploads
27
+ screenshotDir : `./screenshots`, // Directory containing screenshots
28
+ videoDir : `./_results_/videos`, // Directory containing videos (e.g. from wdio-video-reporter)
26
29
  }]],
27
30
  ```
28
31
 
@@ -40,6 +43,43 @@ reporters : [[`test-ledger`, {
40
43
  }]]
41
44
  ```
42
45
 
46
+ ## Artifact Uploads (Screenshots & Videos)
47
+
48
+ The service can upload screenshots and videos to Test Ledger, making them viewable directly in the UI alongside test results.
49
+
50
+ ### Configuration
51
+
52
+ | Option | Type | Description |
53
+ |--------|------|-------------|
54
+ | `uploadArtifacts` | `boolean` | Enable artifact uploads. Default: `false` |
55
+ | `screenshotDir` | `string` | Directory containing screenshot files (png, jpg, jpeg, gif, webp) |
56
+ | `videoDir` | `string` | Directory containing video files (webm, mp4, mov) |
57
+
58
+ ### Example with wdio-video-reporter
59
+
60
+ ```javascript
61
+ reporters: [
62
+ ['video', {
63
+ saveAllVideos : false, // Only save videos for failed tests
64
+ videoSlowdownMultiplier : 3,
65
+ outputDir : './_results_/videos'
66
+ }],
67
+ ['test-ledger', {
68
+ outputDir : './testledger'
69
+ }]
70
+ ],
71
+ services: [['test-ledger', {
72
+ reporterOutputDir : './testledger',
73
+ username : 'your-email@example.com',
74
+ apiToken : 'your-api-token',
75
+ uploadArtifacts : true,
76
+ screenshotDir : './screenshots',
77
+ videoDir : './_results_/videos'
78
+ }]]
79
+ ```
80
+
81
+ Artifacts are matched to test suites based on filename. The service looks for the spec file name within the artifact filename.
82
+
43
83
  ## Environment variables
44
84
 
45
85
  Environment variables can be set when running tests that the server will use to add to the results
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wdio-test-ledger-service",
3
- "version": "9.0.2",
3
+ "version": "9.2.1",
4
4
  "description": "WebdriverIO server to send reporter data to testledger.dev",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -5,6 +5,18 @@ import { SevereServiceError } from 'webdriverio';
5
5
 
6
6
  const api_url = `https://app-api.testledger.dev`;
7
7
 
8
+ // MIME type mapping for artifacts
9
+ const MIME_TYPES = {
10
+ '.png' : 'image/png',
11
+ '.jpg' : 'image/jpeg',
12
+ '.jpeg' : 'image/jpeg',
13
+ '.gif' : 'image/gif',
14
+ '.webp' : 'image/webp',
15
+ '.webm' : 'video/webm',
16
+ '.mp4' : 'video/mp4',
17
+ '.mov' : 'video/quicktime'
18
+ };
19
+
8
20
  class TestLedgerLauncher {
9
21
  constructor(options) {
10
22
  this.options = options;
@@ -20,6 +32,11 @@ class TestLedgerLauncher {
20
32
  if(!this.options.apiToken) {
21
33
  throw new SevereServiceError(`No apiToken specified`)
22
34
  }
35
+
36
+ // Artifact upload options
37
+ this.upload_artifacts = this.options.uploadArtifacts || false;
38
+ this.screenshot_dir = this.options.screenshotDir || null;
39
+ this.video_dir = this.options.videoDir || null;
23
40
  }
24
41
 
25
42
  onPrepare() {
@@ -34,8 +51,15 @@ class TestLedgerLauncher {
34
51
  const data = this.buildData(config);
35
52
 
36
53
  try {
37
- const tmp = await this.post(data);
54
+ const response = await this.post(data);
55
+ const result = await response.json();
56
+
38
57
  fs.writeFileSync(`${this.options.reporterOutputDir}/trio-onComplete-post.txt`, `onComplete-post`, { encoding : `utf-8` });
58
+
59
+ // Upload artifacts if enabled
60
+ if(this.upload_artifacts && result.status === 'success') {
61
+ await this.upload_all_artifacts(data, result);
62
+ }
39
63
  }
40
64
  catch(e) {
41
65
  fs.writeFileSync(`${this.options.reporterOutputDir}/trio-post-error.txt`, e.message, { encoding : `utf-8` });
@@ -200,6 +224,266 @@ class TestLedgerLauncher {
200
224
  this.options.apiToken,
201
225
  ].join(`:`));
202
226
  }
227
+
228
+ /**
229
+ * Upload all artifacts (screenshots and videos) after test run is posted
230
+ */
231
+ async upload_all_artifacts(data, run_result) {
232
+ const artifacts = this.collect_artifacts(data, run_result);
233
+
234
+ if(artifacts.length === 0) {
235
+ fs.writeFileSync(`${this.options.reporterOutputDir}/trio-no-artifacts.txt`, `No artifacts found to upload`, { encoding : `utf-8` });
236
+ return;
237
+ }
238
+
239
+ fs.writeFileSync(`${this.options.reporterOutputDir}/trio-artifacts-found.txt`, `Found ${artifacts.length} artifacts`, { encoding : `utf-8` });
240
+
241
+ try {
242
+ // Request presigned URLs
243
+ const presigned_response = await this.request_presigned_urls(artifacts);
244
+
245
+ if(!presigned_response.uploads || presigned_response.uploads.length === 0) {
246
+ fs.writeFileSync(`${this.options.reporterOutputDir}/trio-presigned-empty.txt`, `No presigned URLs returned`, { encoding : `utf-8` });
247
+ return;
248
+ }
249
+
250
+ // Upload each artifact to S3
251
+ const upload_results = await this.upload_to_s3(presigned_response.uploads, artifacts);
252
+
253
+ // Confirm successful uploads
254
+ const confirmed_ids = upload_results
255
+ .filter(r => r.success)
256
+ .map(r => r.artifact_id);
257
+
258
+ if(confirmed_ids.length > 0) {
259
+ await this.confirm_uploads(confirmed_ids);
260
+ }
261
+
262
+ fs.writeFileSync(`${this.options.reporterOutputDir}/trio-artifacts-complete.txt`, `Uploaded ${confirmed_ids.length}/${artifacts.length} artifacts`, { encoding : `utf-8` });
263
+ }
264
+ catch(e) {
265
+ fs.writeFileSync(`${this.options.reporterOutputDir}/trio-artifacts-error.txt`, e.message, { encoding : `utf-8` });
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Collect all artifact files and match them to suites/tests
271
+ */
272
+ collect_artifacts(data, run_result) {
273
+ const artifacts = [];
274
+
275
+ // Build lookup maps from run result
276
+ const suite_map = {};
277
+ for(const suite of run_result.suites) {
278
+ suite_map[suite.suite_key] = suite.id;
279
+ }
280
+
281
+ const test_map = {};
282
+ for(const test of run_result.tests) {
283
+ test_map[test.suite_test_key] = {
284
+ id : test.id,
285
+ test_run_suite_id : test.test_run_suite_id
286
+ };
287
+ }
288
+
289
+ // Collect screenshots
290
+ if(this.screenshot_dir && fs.existsSync(this.screenshot_dir)) {
291
+ const screenshot_files = this.find_files(this.screenshot_dir, ['.png', '.jpg', '.jpeg', '.gif', '.webp']);
292
+
293
+ for(const file_path of screenshot_files) {
294
+ const filename = path.basename(file_path);
295
+ const matched_suite = this.match_file_to_suite(filename, data.suites, suite_map);
296
+
297
+ if(matched_suite) {
298
+ artifacts.push({
299
+ type : 'screenshot',
300
+ filename : filename,
301
+ path : file_path,
302
+ mime_type : MIME_TYPES[path.extname(file_path).toLowerCase()] || 'image/png',
303
+ file_size : fs.statSync(file_path).size,
304
+ test_run_suite_id : matched_suite.suite_id,
305
+ test_run_suite_test_id : matched_suite.test_id || null
306
+ });
307
+ }
308
+ }
309
+ }
310
+
311
+ // Collect videos
312
+ if(this.video_dir && fs.existsSync(this.video_dir)) {
313
+ const video_files = this.find_files(this.video_dir, ['.webm', '.mp4', '.mov']);
314
+
315
+ for(const file_path of video_files) {
316
+ const filename = path.basename(file_path);
317
+ const matched_suite = this.match_file_to_suite(filename, data.suites, suite_map);
318
+
319
+ if(matched_suite) {
320
+ artifacts.push({
321
+ type : 'video',
322
+ filename : filename,
323
+ path : file_path,
324
+ mime_type : MIME_TYPES[path.extname(file_path).toLowerCase()] || 'video/webm',
325
+ file_size : fs.statSync(file_path).size,
326
+ test_run_suite_id : matched_suite.suite_id,
327
+ test_run_suite_test_id : matched_suite.test_id || null
328
+ });
329
+ }
330
+ }
331
+ }
332
+
333
+ return artifacts;
334
+ }
335
+
336
+ /**
337
+ * Find all files with given extensions in a directory (recursive)
338
+ */
339
+ find_files(dir, extensions) {
340
+ const files = [];
341
+
342
+ const items = fs.readdirSync(dir, { withFileTypes: true });
343
+ for(const item of items) {
344
+ const full_path = path.join(dir, item.name);
345
+
346
+ if(item.isDirectory()) {
347
+ files.push(...this.find_files(full_path, extensions));
348
+ }
349
+ else if(extensions.includes(path.extname(item.name).toLowerCase())) {
350
+ files.push(full_path);
351
+ }
352
+ }
353
+
354
+ return files;
355
+ }
356
+
357
+ /**
358
+ * Match an artifact filename to a suite based on spec file name
359
+ */
360
+ match_file_to_suite(filename, suites, suite_map) {
361
+ const lower_filename = filename.toLowerCase();
362
+
363
+ for(const suite of suites) {
364
+ // Extract spec file name without extension
365
+ const spec_base = path.basename(suite.spec_file, path.extname(suite.spec_file)).toLowerCase();
366
+
367
+ // Check if the artifact filename contains the spec name
368
+ if(lower_filename.includes(spec_base)) {
369
+ const suite_key = `${suite.title}:${suite.spec_file}:${suite.capabilities}`;
370
+ const suite_id = suite_map[suite_key];
371
+
372
+ if(suite_id) {
373
+ return {
374
+ suite_id : suite_id,
375
+ test_id : null // Could enhance to match specific tests
376
+ };
377
+ }
378
+ }
379
+ }
380
+
381
+ // If no match found, return the first suite as fallback
382
+ if(suites.length > 0) {
383
+ const suite = suites[0];
384
+ const suite_key = `${suite.title}:${suite.spec_file}:${suite.capabilities}`;
385
+ const suite_id = suite_map[suite_key];
386
+
387
+ if(suite_id) {
388
+ return {
389
+ suite_id : suite_id,
390
+ test_id : null
391
+ };
392
+ }
393
+ }
394
+
395
+ return null;
396
+ }
397
+
398
+ /**
399
+ * Request presigned URLs from Test Ledger API
400
+ */
401
+ async request_presigned_urls(artifacts) {
402
+ const payload = {
403
+ artifacts: artifacts.map(a => ({
404
+ test_run_suite_test_id : a.test_run_suite_test_id,
405
+ test_run_suite_id : a.test_run_suite_id,
406
+ artifact_type : a.type,
407
+ filename : a.filename,
408
+ mime_type : a.mime_type,
409
+ file_size : a.file_size
410
+ }))
411
+ };
412
+
413
+ const response = await fetch(`${this.getApiUrl()}/artifacts/presigned-upload`, {
414
+ method : 'POST',
415
+ headers : {
416
+ 'Content-Type' : 'application/json',
417
+ 'Authorization' : `Basic ${this.getAuthToken()}`
418
+ },
419
+ body : JSON.stringify(payload)
420
+ });
421
+
422
+ return response.json();
423
+ }
424
+
425
+ /**
426
+ * Upload artifacts to S3 using presigned URLs
427
+ */
428
+ async upload_to_s3(uploads, artifacts) {
429
+ const results = [];
430
+
431
+ for(let i = 0; i < uploads.length; i++) {
432
+ const upload = uploads[i];
433
+ const artifact = artifacts[i];
434
+
435
+ try {
436
+ const file_buffer = fs.readFileSync(artifact.path);
437
+
438
+ const response = await fetch(upload.presigned_url, {
439
+ method : 'PUT',
440
+ headers : {
441
+ 'Content-Type' : artifact.mime_type
442
+ },
443
+ body : file_buffer
444
+ });
445
+
446
+ if(response.ok) {
447
+ results.push({
448
+ artifact_id : upload.artifact_id,
449
+ success : true
450
+ });
451
+ }
452
+ else {
453
+ results.push({
454
+ artifact_id : upload.artifact_id,
455
+ success : false,
456
+ error : `HTTP ${response.status}`
457
+ });
458
+ }
459
+ }
460
+ catch(e) {
461
+ results.push({
462
+ artifact_id : upload.artifact_id,
463
+ success : false,
464
+ error : e.message
465
+ });
466
+ }
467
+ }
468
+
469
+ return results;
470
+ }
471
+
472
+ /**
473
+ * Confirm successful uploads with Test Ledger API
474
+ */
475
+ async confirm_uploads(artifact_ids) {
476
+ const response = await fetch(`${this.getApiUrl()}/artifacts/confirm`, {
477
+ method : 'POST',
478
+ headers : {
479
+ 'Content-Type' : 'application/json',
480
+ 'Authorization' : `Basic ${this.getAuthToken()}`
481
+ },
482
+ body : JSON.stringify({ artifact_ids: artifact_ids })
483
+ });
484
+
485
+ return response.json();
486
+ }
203
487
  }
204
488
 
205
489
  export default class TestReporterService {};