@testomatio/reporter 2.9.1 → 2.9.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.
- package/lib/adapter/codecept.js +1 -0
- package/lib/adapter/playwright.js +19 -8
- package/lib/bin/cli.js +87 -1
- package/lib/pipe/debug.d.ts +6 -0
- package/lib/pipe/debug.js +11 -0
- package/lib/replay.js +21 -0
- package/lib/template/testomatio.hbs +77 -11
- package/package.json +1 -1
- package/src/adapter/codecept.js +2 -0
- package/src/adapter/playwright.js +22 -9
- package/src/bin/cli.js +96 -1
- package/src/pipe/debug.js +11 -0
- package/src/replay.js +21 -0
- package/src/template/testomatio.hbs +77 -11
package/lib/adapter/codecept.js
CHANGED
|
@@ -237,6 +237,7 @@ async function uploadAttachments(client, attachments, messagePrefix, attachmentT
|
|
|
237
237
|
if (client.uploader.isEnabled) {
|
|
238
238
|
log_js_1.log.info(`Attachments: ${messagePrefix} ${attachments.length} ${attachmentType} ...`);
|
|
239
239
|
}
|
|
240
|
+
await Promise.all(client.pipes?.map(pipe => pipe.addArtifacts?.(attachments)) || []);
|
|
240
241
|
const promises = attachments.map(async (attachment) => {
|
|
241
242
|
const { rid, title, path, type } = attachment;
|
|
242
243
|
const file = { path, type, title };
|
|
@@ -51,13 +51,8 @@ class PlaywrightReporter {
|
|
|
51
51
|
const { title } = test;
|
|
52
52
|
const { error, duration } = result;
|
|
53
53
|
const pwAttachments = (result.attachments || []).filter(a => a.body || a.path);
|
|
54
|
-
const files = pwAttachments
|
|
55
|
-
|
|
56
|
-
path: this.#getArtifactPath(att),
|
|
57
|
-
title: att.name || title,
|
|
58
|
-
type: att.contentType,
|
|
59
|
-
}))
|
|
60
|
-
.filter(f => f.path);
|
|
54
|
+
const files = buildArtifactFiles(pwAttachments, attachment => this.#getArtifactPath(attachment), title);
|
|
55
|
+
const artifactsForUpload = processArtifactsForUpload(pwAttachments);
|
|
61
56
|
const suite_title = test.parent ? test.parent?.title : path_1.default.basename(test?.location?.file);
|
|
62
57
|
const rid = test.id || test.testId || (0, uuid_1.v4)();
|
|
63
58
|
/**
|
|
@@ -144,7 +139,7 @@ class PlaywrightReporter {
|
|
|
144
139
|
this.uploads.push({
|
|
145
140
|
rid: `${rid}-${project.name}`,
|
|
146
141
|
title: test.title,
|
|
147
|
-
files:
|
|
142
|
+
files: artifactsForUpload,
|
|
148
143
|
file: test.location?.file,
|
|
149
144
|
});
|
|
150
145
|
// remove empty uploads
|
|
@@ -210,6 +205,22 @@ function checkStatus(status) {
|
|
|
210
205
|
passed: constants_js_1.STATUS.PASSED,
|
|
211
206
|
}[status] || constants_js_1.STATUS.FAILED);
|
|
212
207
|
}
|
|
208
|
+
function buildArtifactFiles(attachments, getArtifactPath, title) {
|
|
209
|
+
return attachments
|
|
210
|
+
.filter(isScreenshotArtifact)
|
|
211
|
+
.map(attachment => ({
|
|
212
|
+
path: getArtifactPath(attachment),
|
|
213
|
+
title: attachment.name || title,
|
|
214
|
+
type: attachment.contentType,
|
|
215
|
+
}))
|
|
216
|
+
.filter(file => file.path);
|
|
217
|
+
}
|
|
218
|
+
function processArtifactsForUpload(attachments) {
|
|
219
|
+
return attachments.filter(attachment => !isScreenshotArtifact(attachment));
|
|
220
|
+
}
|
|
221
|
+
function isScreenshotArtifact(attachment) {
|
|
222
|
+
return attachment?.contentType === 'image/png' && attachment?.name === 'screenshot';
|
|
223
|
+
}
|
|
213
224
|
function appendStep(step, shift = 0) {
|
|
214
225
|
// nesting too deep, ignore those steps
|
|
215
226
|
if (shift >= 10)
|
package/lib/bin/cli.js
CHANGED
|
@@ -20,6 +20,10 @@ const dotenv_1 = __importDefault(require("dotenv"));
|
|
|
20
20
|
const replay_js_1 = __importDefault(require("../replay.js"));
|
|
21
21
|
const log_js_1 = require("../utils/log.js");
|
|
22
22
|
const pipe_utils_js_1 = require("../utils/pipe_utils.js");
|
|
23
|
+
const fs_1 = __importDefault(require("fs"));
|
|
24
|
+
const path_1 = __importDefault(require("path"));
|
|
25
|
+
const gaxios_1 = require("gaxios");
|
|
26
|
+
const step_formatter_js_1 = require("../adapter/utils/step-formatter.js");
|
|
23
27
|
const debug = (0, debug_1.default)('@testomatio/reporter:cli');
|
|
24
28
|
const version = (0, utils_js_1.getPackageVersion)();
|
|
25
29
|
const program = new commander_1.Command();
|
|
@@ -321,9 +325,91 @@ program
|
|
|
321
325
|
program
|
|
322
326
|
.command('upload-artifacts')
|
|
323
327
|
.description('Upload artifacts to Testomat.io')
|
|
328
|
+
.argument('[jsonl-file]', 'Path to JSONL debug file (optional)')
|
|
324
329
|
.option('--force', 'Re-upload artifacts even if they were uploaded before')
|
|
325
|
-
.action(async (opts) => {
|
|
330
|
+
.action(async (jsonlFile, opts) => {
|
|
326
331
|
const apiKey = config_js_1.config.TESTOMATIO;
|
|
332
|
+
// JSONL file mode: upload artifacts from debug file
|
|
333
|
+
if (jsonlFile) {
|
|
334
|
+
if (!fs_1.default.existsSync(jsonlFile)) {
|
|
335
|
+
log_js_1.log.error(`JSONL file not found: ${jsonlFile}`);
|
|
336
|
+
return process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
const replay = new replay_js_1.default({ apiKey });
|
|
339
|
+
const { tests, runId } = replay.parseDebugFile(jsonlFile);
|
|
340
|
+
if (!runId) {
|
|
341
|
+
log_js_1.log.error('runId not found in JSONL file');
|
|
342
|
+
return process.exit(1);
|
|
343
|
+
}
|
|
344
|
+
log_js_1.log.info(`Processing ${tests.length} tests from ${jsonlFile}`);
|
|
345
|
+
const client = new client_js_1.default({
|
|
346
|
+
apiKey,
|
|
347
|
+
runId,
|
|
348
|
+
batchMode: constants_js_1.BATCH_MODE.DISABLED,
|
|
349
|
+
});
|
|
350
|
+
await client.createRun();
|
|
351
|
+
client.uploader.checkEnabled();
|
|
352
|
+
client.uploader.disableLogStorage();
|
|
353
|
+
const apiUrl = process.env.TESTOMATIO_URL || 'https://app.testomat.io';
|
|
354
|
+
const http = new gaxios_1.Gaxios();
|
|
355
|
+
let uploadedCount = 0;
|
|
356
|
+
let failedCount = 0;
|
|
357
|
+
for (const test of tests) {
|
|
358
|
+
const testId = test.test_id;
|
|
359
|
+
if (!testId)
|
|
360
|
+
continue;
|
|
361
|
+
const artifacts = [];
|
|
362
|
+
const collect = (items) => {
|
|
363
|
+
for (const item of items || []) {
|
|
364
|
+
const p = typeof item === 'object' ? item?.path : item;
|
|
365
|
+
if (p && fs_1.default.existsSync(p))
|
|
366
|
+
artifacts.push(p);
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
collect(test.files);
|
|
370
|
+
const walkSteps = (steps) => {
|
|
371
|
+
for (const step of steps || []) {
|
|
372
|
+
collect(step.artifacts);
|
|
373
|
+
walkSteps(step.steps);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
walkSteps(test.steps);
|
|
377
|
+
if (artifacts.length === 0)
|
|
378
|
+
continue;
|
|
379
|
+
const urls = [];
|
|
380
|
+
for (const artifact of artifacts) {
|
|
381
|
+
try {
|
|
382
|
+
const s3Id = test.rid || testId.replace('@', '');
|
|
383
|
+
const filename = (0, step_formatter_js_1.generateShortFilename)(artifact);
|
|
384
|
+
const result = await client.uploader.uploadFileByPath(artifact, [runId, s3Id, filename]);
|
|
385
|
+
if (result)
|
|
386
|
+
urls.push(typeof result === 'string' ? result : result.link);
|
|
387
|
+
}
|
|
388
|
+
catch (e) {
|
|
389
|
+
debug(`Failed to upload ${artifact}:`, e.message);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (urls.length === 0)
|
|
393
|
+
continue;
|
|
394
|
+
try {
|
|
395
|
+
await http.request({
|
|
396
|
+
method: 'POST',
|
|
397
|
+
url: `${apiUrl}/api/reporter/${runId}/testrun?api_key=${apiKey}`,
|
|
398
|
+
data: { test_id: testId, artifacts: urls },
|
|
399
|
+
});
|
|
400
|
+
uploadedCount++;
|
|
401
|
+
}
|
|
402
|
+
catch (e) {
|
|
403
|
+
log_js_1.log.error(`Failed ${testId}: ${e.message}`);
|
|
404
|
+
failedCount++;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
log_js_1.log.info(`🗄️ ${uploadedCount} tests with artifacts uploaded`);
|
|
408
|
+
if (failedCount > 0) {
|
|
409
|
+
log_js_1.log.warn(`⚠️ ${failedCount} tests failed to upload artifacts`);
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
327
413
|
process.env.TESTOMATIO_DISABLE_ARTIFACTS = '';
|
|
328
414
|
const runId = process.env.TESTOMATIO_RUN || process.env.runId || (0, utils_js_2.readLatestRunId)();
|
|
329
415
|
if (!runId) {
|
package/lib/pipe/debug.d.ts
CHANGED
|
@@ -22,6 +22,12 @@ export class DebugPipe {
|
|
|
22
22
|
createRun(params?: {}): Promise<void>;
|
|
23
23
|
addTest(data: any): Promise<void>;
|
|
24
24
|
finishRun(params: any): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Logs artifacts data to the debug file.
|
|
27
|
+
* Used for trace.zip, video files and other artifacts uploaded after tests finish.
|
|
28
|
+
* @param {Array} artifacts - Array of artifacts with { rid, title, path, type }
|
|
29
|
+
*/
|
|
30
|
+
addArtifacts(artifacts: any[]): void;
|
|
25
31
|
sync(): Promise<void>;
|
|
26
32
|
/**
|
|
27
33
|
* Writes any buffered tests to the debug file as a single batch.
|
package/lib/pipe/debug.js
CHANGED
|
@@ -109,6 +109,17 @@ class DebugPipe {
|
|
|
109
109
|
log_js_1.log.info(`🪲 Debug file: ${this.rootPath}`);
|
|
110
110
|
log_js_1.log.info(`History: ${this.historyDir}`);
|
|
111
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Logs artifacts data to the debug file.
|
|
114
|
+
* Used for trace.zip, video files and other artifacts uploaded after tests finish.
|
|
115
|
+
* @param {Array} artifacts - Array of artifacts with { rid, title, path, type }
|
|
116
|
+
*/
|
|
117
|
+
addArtifacts(artifacts) {
|
|
118
|
+
if (!this.isEnabled || !artifacts?.length)
|
|
119
|
+
return;
|
|
120
|
+
const logData = { action: 'addArtifacts', artifacts, runId: this.store.runId };
|
|
121
|
+
this.logToFile(logData);
|
|
122
|
+
}
|
|
112
123
|
async sync() {
|
|
113
124
|
this.flushBufferedTests();
|
|
114
125
|
}
|
package/lib/replay.js
CHANGED
|
@@ -49,6 +49,7 @@ class Replay {
|
|
|
49
49
|
const testsWithoutRid = []; // For tests without rid (backward compatibility)
|
|
50
50
|
const envVars = {};
|
|
51
51
|
let runId = null;
|
|
52
|
+
const artifactsToAdd = []; // Store artifacts to add after all tests are loaded
|
|
52
53
|
// Parse debug file line by line
|
|
53
54
|
for (const [lineIndex, line] of lines.entries()) {
|
|
54
55
|
try {
|
|
@@ -125,6 +126,12 @@ class Replay {
|
|
|
125
126
|
testsWithoutRid.push({ ...test });
|
|
126
127
|
}
|
|
127
128
|
}
|
|
129
|
+
else if (logEntry.action === 'addArtifacts' && logEntry.artifacts) {
|
|
130
|
+
if (logEntry.runId && !runId) {
|
|
131
|
+
runId = logEntry.runId;
|
|
132
|
+
}
|
|
133
|
+
artifactsToAdd.push(...logEntry.artifacts);
|
|
134
|
+
}
|
|
128
135
|
else if (logEntry.action === 'finishRun') {
|
|
129
136
|
finishParams = logEntry.params || {};
|
|
130
137
|
if (logEntry.runId && !runId) {
|
|
@@ -143,6 +150,20 @@ class Replay {
|
|
|
143
150
|
if (parseErrors > 3) {
|
|
144
151
|
this.onError(`${parseErrors - 3} more parse errors occurred`);
|
|
145
152
|
}
|
|
153
|
+
for (const artifact of artifactsToAdd) {
|
|
154
|
+
if (artifact.rid) {
|
|
155
|
+
const ridToFind = artifact.rid;
|
|
156
|
+
const fullRid = runId ? `${runId}-${ridToFind}` : ridToFind;
|
|
157
|
+
const test = testsMap.get(fullRid) || testsMap.get(ridToFind);
|
|
158
|
+
if (test && artifact.path) {
|
|
159
|
+
if (!test.files)
|
|
160
|
+
test.files = [];
|
|
161
|
+
if (!test.files.includes(artifact.path)) {
|
|
162
|
+
test.files.push(artifact.path);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
146
167
|
// Combine tests with rid and tests without rid
|
|
147
168
|
const allTests = [...Array.from(testsMap.values()), ...testsWithoutRid];
|
|
148
169
|
return {
|
|
@@ -681,8 +681,9 @@
|
|
|
681
681
|
|
|
682
682
|
.test-group-header {
|
|
683
683
|
padding: 20px;
|
|
684
|
-
background:
|
|
685
|
-
color:
|
|
684
|
+
background: var(--gray-50);
|
|
685
|
+
color: var(--gray-800);
|
|
686
|
+
border-left: 4px solid var(--primary-color);
|
|
686
687
|
cursor: pointer;
|
|
687
688
|
display: flex;
|
|
688
689
|
justify-content: space-between;
|
|
@@ -691,7 +692,37 @@
|
|
|
691
692
|
}
|
|
692
693
|
|
|
693
694
|
.test-group-header:hover {
|
|
694
|
-
|
|
695
|
+
background: var(--gray-100);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
.test-group-header.has-failures {
|
|
699
|
+
background: #fef2f2;
|
|
700
|
+
color: var(--gray-900);
|
|
701
|
+
border-left-color: var(--danger-color);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
.test-group-header.has-failures:hover {
|
|
705
|
+
background: #fee2e2;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
.test-group-header.has-skips {
|
|
709
|
+
background: #fffbeb;
|
|
710
|
+
color: var(--gray-900);
|
|
711
|
+
border-left-color: var(--warning-color);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
.test-group-header.has-skips:hover {
|
|
715
|
+
background: #fef3c7;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
.test-group-header.all-passed {
|
|
719
|
+
background: #ecfdf5;
|
|
720
|
+
color: var(--gray-900);
|
|
721
|
+
border-left-color: var(--success-color);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.test-group-header.all-passed:hover {
|
|
725
|
+
background: #d1fae5;
|
|
695
726
|
}
|
|
696
727
|
|
|
697
728
|
.test-group-title {
|
|
@@ -707,28 +738,56 @@
|
|
|
707
738
|
}
|
|
708
739
|
|
|
709
740
|
.test-group-count {
|
|
710
|
-
background:
|
|
741
|
+
background: white;
|
|
742
|
+
color: var(--gray-600);
|
|
711
743
|
padding: 6px 16px;
|
|
712
744
|
border-radius: 20px;
|
|
713
745
|
font-size: 14px;
|
|
714
746
|
font-weight: 600;
|
|
747
|
+
border: 1px solid var(--gray-200);
|
|
715
748
|
}
|
|
716
749
|
|
|
717
750
|
.test-group-stats {
|
|
718
751
|
display: flex;
|
|
719
|
-
gap:
|
|
752
|
+
gap: 8px;
|
|
720
753
|
font-size: 14px;
|
|
721
754
|
margin-right: 10px;
|
|
755
|
+
flex-wrap: wrap;
|
|
756
|
+
justify-content: flex-end;
|
|
722
757
|
}
|
|
723
758
|
|
|
724
759
|
.test-group-stat {
|
|
725
760
|
display: flex;
|
|
726
761
|
align-items: center;
|
|
727
762
|
gap: 6px;
|
|
763
|
+
padding: 6px 10px;
|
|
764
|
+
border-radius: 999px;
|
|
765
|
+
font-weight: 700;
|
|
766
|
+
background: white;
|
|
767
|
+
border: 1px solid currentColor;
|
|
728
768
|
}
|
|
729
769
|
|
|
730
|
-
.test-group-
|
|
770
|
+
.test-group-stat.passed {
|
|
771
|
+
color: var(--success-color);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
.test-group-stat.failed {
|
|
731
775
|
color: white;
|
|
776
|
+
background: var(--danger-color);
|
|
777
|
+
border-color: var(--danger-color);
|
|
778
|
+
box-shadow: 0 1px 3px rgba(239, 68, 68, 0.35);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
.test-group-stat.skipped {
|
|
782
|
+
color: var(--warning-color);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
.test-group-stat.todo {
|
|
786
|
+
color: #8b5cf6;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
.test-group-expand {
|
|
790
|
+
color: var(--gray-500);
|
|
732
791
|
transition: var(--transition);
|
|
733
792
|
font-size: 18px;
|
|
734
793
|
}
|
|
@@ -3366,11 +3425,18 @@
|
|
|
3366
3425
|
const failedCount = group.tests.filter(t => t.status.toLowerCase() === 'failed').length;
|
|
3367
3426
|
const skippedCount = group.tests.filter(t => t.status.toLowerCase() === 'skipped').length;
|
|
3368
3427
|
const todoCount = group.tests.filter(t => t.status.toLowerCase() === 'todo').length;
|
|
3428
|
+
const groupStateClass = failedCount > 0
|
|
3429
|
+
? 'has-failures'
|
|
3430
|
+
: skippedCount > 0 || todoCount > 0
|
|
3431
|
+
? 'has-skips'
|
|
3432
|
+
: passedCount > 0
|
|
3433
|
+
? 'all-passed'
|
|
3434
|
+
: '';
|
|
3369
3435
|
|
|
3370
3436
|
const groupIcon = group.type === 'suite' ? 'layer-group' : 'file-code';
|
|
3371
3437
|
|
|
3372
3438
|
const header = document.createElement('div');
|
|
3373
|
-
header.className = 'test-group-header';
|
|
3439
|
+
header.className = ['test-group-header', groupStateClass].filter(Boolean).join(' ');
|
|
3374
3440
|
header.innerHTML = `
|
|
3375
3441
|
<div class='test-group-title'>
|
|
3376
3442
|
<i class='fas fa-${groupIcon}'></i>
|
|
@@ -3378,10 +3444,10 @@
|
|
|
3378
3444
|
<div class='test-group-count'>${group.tests.length} tests</div>
|
|
3379
3445
|
</div>
|
|
3380
3446
|
<div class='test-group-stats'>
|
|
3381
|
-
${passedCount > 0 ? `<div class='test-group-stat
|
|
3382
|
-
${failedCount > 0 ? `<div class='test-group-stat
|
|
3383
|
-
${skippedCount > 0 ? `<div class='test-group-stat
|
|
3384
|
-
${todoCount > 0 ? `<div class='test-group-stat
|
|
3447
|
+
${passedCount > 0 ? `<div class='test-group-stat passed'><i class='fas fa-check-circle'></i> ${passedCount}</div>` : ''}
|
|
3448
|
+
${failedCount > 0 ? `<div class='test-group-stat failed'><i class='fas fa-times-circle'></i> ${failedCount}</div>` : ''}
|
|
3449
|
+
${skippedCount > 0 ? `<div class='test-group-stat skipped'><i class='fas fa-forward'></i> ${skippedCount}</div>` : ''}
|
|
3450
|
+
${todoCount > 0 ? `<div class='test-group-stat todo'><i class='fas fa-clock'></i> ${todoCount}</div>` : ''}
|
|
3385
3451
|
</div>
|
|
3386
3452
|
<i class='fas fa-chevron-down test-group-expand'></i>
|
|
3387
3453
|
`;
|
package/package.json
CHANGED
package/src/adapter/codecept.js
CHANGED
|
@@ -278,6 +278,8 @@ async function uploadAttachments(client, attachments, messagePrefix, attachmentT
|
|
|
278
278
|
log.info(`Attachments: ${messagePrefix} ${attachments.length} ${attachmentType} ...`);
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
await Promise.all(client.pipes?.map(pipe => pipe.addArtifacts?.(attachments)) || []);
|
|
282
|
+
|
|
281
283
|
const promises = attachments.map(async attachment => {
|
|
282
284
|
const { rid, title, path, type } = attachment;
|
|
283
285
|
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
|
|
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:
|
|
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
|
@@ -16,6 +16,10 @@ import dotenv from 'dotenv';
|
|
|
16
16
|
import Replay from '../replay.js';
|
|
17
17
|
import { log } from '../utils/log.js';
|
|
18
18
|
import { formatFilterListIds } from '../utils/pipe_utils.js';
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import { Gaxios } from 'gaxios';
|
|
22
|
+
import { generateShortFilename } from '../adapter/utils/step-formatter.js';
|
|
19
23
|
|
|
20
24
|
const debug = createDebugMessages('@testomatio/reporter:cli');
|
|
21
25
|
const version = getPackageVersion();
|
|
@@ -354,10 +358,101 @@ program
|
|
|
354
358
|
program
|
|
355
359
|
.command('upload-artifacts')
|
|
356
360
|
.description('Upload artifacts to Testomat.io')
|
|
361
|
+
.argument('[jsonl-file]', 'Path to JSONL debug file (optional)')
|
|
357
362
|
.option('--force', 'Re-upload artifacts even if they were uploaded before')
|
|
358
|
-
.action(async opts => {
|
|
363
|
+
.action(async (jsonlFile, opts) => {
|
|
359
364
|
const apiKey = config.TESTOMATIO;
|
|
360
365
|
|
|
366
|
+
// JSONL file mode: upload artifacts from debug file
|
|
367
|
+
if (jsonlFile) {
|
|
368
|
+
if (!fs.existsSync(jsonlFile)) {
|
|
369
|
+
log.error(`JSONL file not found: ${jsonlFile}`);
|
|
370
|
+
return process.exit(1);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const replay = new Replay({ apiKey });
|
|
374
|
+
const { tests, runId } = replay.parseDebugFile(jsonlFile);
|
|
375
|
+
|
|
376
|
+
if (!runId) {
|
|
377
|
+
log.error('runId not found in JSONL file');
|
|
378
|
+
return process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
log.info(`Processing ${tests.length} tests from ${jsonlFile}`);
|
|
382
|
+
|
|
383
|
+
const client = new TestomatClient({
|
|
384
|
+
apiKey,
|
|
385
|
+
runId,
|
|
386
|
+
batchMode: BATCH_MODE.DISABLED,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
await client.createRun();
|
|
390
|
+
client.uploader.checkEnabled();
|
|
391
|
+
client.uploader.disableLogStorage();
|
|
392
|
+
|
|
393
|
+
const apiUrl = process.env.TESTOMATIO_URL || 'https://app.testomat.io';
|
|
394
|
+
const http = new Gaxios();
|
|
395
|
+
let uploadedCount = 0;
|
|
396
|
+
let failedCount = 0;
|
|
397
|
+
|
|
398
|
+
for (const test of tests) {
|
|
399
|
+
const testId = test.test_id;
|
|
400
|
+
if (!testId) continue;
|
|
401
|
+
|
|
402
|
+
const artifacts = [];
|
|
403
|
+
|
|
404
|
+
const collect = (items) => {
|
|
405
|
+
for (const item of items || []) {
|
|
406
|
+
const p = typeof item === 'object' ? item?.path : item;
|
|
407
|
+
if (p && fs.existsSync(p)) artifacts.push(p);
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
collect(test.files);
|
|
411
|
+
|
|
412
|
+
const walkSteps = (steps) => {
|
|
413
|
+
for (const step of steps || []) {
|
|
414
|
+
collect(step.artifacts);
|
|
415
|
+
walkSteps(step.steps);
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
walkSteps(test.steps);
|
|
419
|
+
|
|
420
|
+
if (artifacts.length === 0) continue;
|
|
421
|
+
|
|
422
|
+
const urls = [];
|
|
423
|
+
for (const artifact of artifacts) {
|
|
424
|
+
try {
|
|
425
|
+
const s3Id = test.rid || testId.replace('@', '');
|
|
426
|
+
const filename = generateShortFilename(artifact);
|
|
427
|
+
const result = await client.uploader.uploadFileByPath(artifact, [runId, s3Id, filename]);
|
|
428
|
+
if (result) urls.push(typeof result === 'string' ? result : result.link);
|
|
429
|
+
} catch (e) {
|
|
430
|
+
debug(`Failed to upload ${artifact}:`, e.message);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (urls.length === 0) continue;
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
await http.request({
|
|
438
|
+
method: 'POST',
|
|
439
|
+
url: `${apiUrl}/api/reporter/${runId}/testrun?api_key=${apiKey}`,
|
|
440
|
+
data: { test_id: testId, artifacts: urls },
|
|
441
|
+
});
|
|
442
|
+
uploadedCount++;
|
|
443
|
+
} catch (e) {
|
|
444
|
+
log.error(`Failed ${testId}: ${e.message}`);
|
|
445
|
+
failedCount++;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
log.info(`🗄️ ${uploadedCount} tests with artifacts uploaded`);
|
|
450
|
+
if (failedCount > 0) {
|
|
451
|
+
log.warn(`⚠️ ${failedCount} tests failed to upload artifacts`);
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
361
456
|
process.env.TESTOMATIO_DISABLE_ARTIFACTS = '';
|
|
362
457
|
const runId = process.env.TESTOMATIO_RUN || process.env.runId || readLatestRunId();
|
|
363
458
|
|
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/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
|
|
|
@@ -681,8 +681,9 @@
|
|
|
681
681
|
|
|
682
682
|
.test-group-header {
|
|
683
683
|
padding: 20px;
|
|
684
|
-
background:
|
|
685
|
-
color:
|
|
684
|
+
background: var(--gray-50);
|
|
685
|
+
color: var(--gray-800);
|
|
686
|
+
border-left: 4px solid var(--primary-color);
|
|
686
687
|
cursor: pointer;
|
|
687
688
|
display: flex;
|
|
688
689
|
justify-content: space-between;
|
|
@@ -691,7 +692,37 @@
|
|
|
691
692
|
}
|
|
692
693
|
|
|
693
694
|
.test-group-header:hover {
|
|
694
|
-
|
|
695
|
+
background: var(--gray-100);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
.test-group-header.has-failures {
|
|
699
|
+
background: #fef2f2;
|
|
700
|
+
color: var(--gray-900);
|
|
701
|
+
border-left-color: var(--danger-color);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
.test-group-header.has-failures:hover {
|
|
705
|
+
background: #fee2e2;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
.test-group-header.has-skips {
|
|
709
|
+
background: #fffbeb;
|
|
710
|
+
color: var(--gray-900);
|
|
711
|
+
border-left-color: var(--warning-color);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
.test-group-header.has-skips:hover {
|
|
715
|
+
background: #fef3c7;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
.test-group-header.all-passed {
|
|
719
|
+
background: #ecfdf5;
|
|
720
|
+
color: var(--gray-900);
|
|
721
|
+
border-left-color: var(--success-color);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.test-group-header.all-passed:hover {
|
|
725
|
+
background: #d1fae5;
|
|
695
726
|
}
|
|
696
727
|
|
|
697
728
|
.test-group-title {
|
|
@@ -707,28 +738,56 @@
|
|
|
707
738
|
}
|
|
708
739
|
|
|
709
740
|
.test-group-count {
|
|
710
|
-
background:
|
|
741
|
+
background: white;
|
|
742
|
+
color: var(--gray-600);
|
|
711
743
|
padding: 6px 16px;
|
|
712
744
|
border-radius: 20px;
|
|
713
745
|
font-size: 14px;
|
|
714
746
|
font-weight: 600;
|
|
747
|
+
border: 1px solid var(--gray-200);
|
|
715
748
|
}
|
|
716
749
|
|
|
717
750
|
.test-group-stats {
|
|
718
751
|
display: flex;
|
|
719
|
-
gap:
|
|
752
|
+
gap: 8px;
|
|
720
753
|
font-size: 14px;
|
|
721
754
|
margin-right: 10px;
|
|
755
|
+
flex-wrap: wrap;
|
|
756
|
+
justify-content: flex-end;
|
|
722
757
|
}
|
|
723
758
|
|
|
724
759
|
.test-group-stat {
|
|
725
760
|
display: flex;
|
|
726
761
|
align-items: center;
|
|
727
762
|
gap: 6px;
|
|
763
|
+
padding: 6px 10px;
|
|
764
|
+
border-radius: 999px;
|
|
765
|
+
font-weight: 700;
|
|
766
|
+
background: white;
|
|
767
|
+
border: 1px solid currentColor;
|
|
728
768
|
}
|
|
729
769
|
|
|
730
|
-
.test-group-
|
|
770
|
+
.test-group-stat.passed {
|
|
771
|
+
color: var(--success-color);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
.test-group-stat.failed {
|
|
731
775
|
color: white;
|
|
776
|
+
background: var(--danger-color);
|
|
777
|
+
border-color: var(--danger-color);
|
|
778
|
+
box-shadow: 0 1px 3px rgba(239, 68, 68, 0.35);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
.test-group-stat.skipped {
|
|
782
|
+
color: var(--warning-color);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
.test-group-stat.todo {
|
|
786
|
+
color: #8b5cf6;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
.test-group-expand {
|
|
790
|
+
color: var(--gray-500);
|
|
732
791
|
transition: var(--transition);
|
|
733
792
|
font-size: 18px;
|
|
734
793
|
}
|
|
@@ -3366,11 +3425,18 @@
|
|
|
3366
3425
|
const failedCount = group.tests.filter(t => t.status.toLowerCase() === 'failed').length;
|
|
3367
3426
|
const skippedCount = group.tests.filter(t => t.status.toLowerCase() === 'skipped').length;
|
|
3368
3427
|
const todoCount = group.tests.filter(t => t.status.toLowerCase() === 'todo').length;
|
|
3428
|
+
const groupStateClass = failedCount > 0
|
|
3429
|
+
? 'has-failures'
|
|
3430
|
+
: skippedCount > 0 || todoCount > 0
|
|
3431
|
+
? 'has-skips'
|
|
3432
|
+
: passedCount > 0
|
|
3433
|
+
? 'all-passed'
|
|
3434
|
+
: '';
|
|
3369
3435
|
|
|
3370
3436
|
const groupIcon = group.type === 'suite' ? 'layer-group' : 'file-code';
|
|
3371
3437
|
|
|
3372
3438
|
const header = document.createElement('div');
|
|
3373
|
-
header.className = 'test-group-header';
|
|
3439
|
+
header.className = ['test-group-header', groupStateClass].filter(Boolean).join(' ');
|
|
3374
3440
|
header.innerHTML = `
|
|
3375
3441
|
<div class='test-group-title'>
|
|
3376
3442
|
<i class='fas fa-${groupIcon}'></i>
|
|
@@ -3378,10 +3444,10 @@
|
|
|
3378
3444
|
<div class='test-group-count'>${group.tests.length} tests</div>
|
|
3379
3445
|
</div>
|
|
3380
3446
|
<div class='test-group-stats'>
|
|
3381
|
-
${passedCount > 0 ? `<div class='test-group-stat
|
|
3382
|
-
${failedCount > 0 ? `<div class='test-group-stat
|
|
3383
|
-
${skippedCount > 0 ? `<div class='test-group-stat
|
|
3384
|
-
${todoCount > 0 ? `<div class='test-group-stat
|
|
3447
|
+
${passedCount > 0 ? `<div class='test-group-stat passed'><i class='fas fa-check-circle'></i> ${passedCount}</div>` : ''}
|
|
3448
|
+
${failedCount > 0 ? `<div class='test-group-stat failed'><i class='fas fa-times-circle'></i> ${failedCount}</div>` : ''}
|
|
3449
|
+
${skippedCount > 0 ? `<div class='test-group-stat skipped'><i class='fas fa-forward'></i> ${skippedCount}</div>` : ''}
|
|
3450
|
+
${todoCount > 0 ? `<div class='test-group-stat todo'><i class='fas fa-clock'></i> ${todoCount}</div>` : ''}
|
|
3385
3451
|
</div>
|
|
3386
3452
|
<i class='fas fa-chevron-down test-group-expand'></i>
|
|
3387
3453
|
`;
|