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.
- package/README.md +40 -0
- package/package.json +1 -1
- 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
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
|
|
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 {};
|