@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.
@@ -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
- .map(att => ({
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: pwAttachments,
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) {
@@ -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: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-hover) 100%);
685
- color: white;
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
- filter: brightness(1.1);
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: rgba(255, 255, 255, 0.2);
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: 20px;
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-expand {
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' style='color: #10b981;'><i class='fas fa-check-circle'></i> ${passedCount}</div>` : ''}
3382
- ${failedCount > 0 ? `<div class='test-group-stat' style='color: #ef4444;'><i class='fas fa-times-circle'></i> ${failedCount}</div>` : ''}
3383
- ${skippedCount > 0 ? `<div class='test-group-stat' style='color: #f59e0b;'><i class='fas fa-forward'></i> ${skippedCount}</div>` : ''}
3384
- ${todoCount > 0 ? `<div class='test-group-stat' style='color: #8b5cf6;'><i class='fas fa-clock'></i> ${todoCount}</div>` : ''}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.9.1",
3
+ "version": "2.9.2",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -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 files = pwAttachments
53
- .map(att => ({
54
- path: this.#getArtifactPath(att),
55
- title: att.name || title,
56
- type: att.contentType,
57
- }))
58
- .filter(f => f.path);
51
+ const files = buildArtifactFiles(pwAttachments, attachment => this.#getArtifactPath(attachment), title);
52
+ const artifactsForUpload = processArtifactsForUpload(pwAttachments);
59
53
 
60
54
  const suite_title = test.parent ? test.parent?.title : path.basename(test?.location?.file);
61
55
 
@@ -150,7 +144,7 @@ class PlaywrightReporter {
150
144
  this.uploads.push({
151
145
  rid: `${rid}-${project.name}`,
152
146
  title: test.title,
153
- files: pwAttachments,
147
+ files: artifactsForUpload,
154
148
  file: test.location?.file,
155
149
  });
156
150
  // remove empty uploads
@@ -229,6 +223,25 @@ function checkStatus(status) {
229
223
  );
230
224
  }
231
225
 
226
+ function buildArtifactFiles(attachments, getArtifactPath, title) {
227
+ return attachments
228
+ .filter(isScreenshotArtifact)
229
+ .map(attachment => ({
230
+ path: getArtifactPath(attachment),
231
+ title: attachment.name || title,
232
+ type: attachment.contentType,
233
+ }))
234
+ .filter(file => file.path);
235
+ }
236
+
237
+ function processArtifactsForUpload(attachments) {
238
+ return attachments.filter(attachment => !isScreenshotArtifact(attachment));
239
+ }
240
+
241
+ function isScreenshotArtifact(attachment) {
242
+ return attachment?.contentType === 'image/png' && attachment?.name === 'screenshot';
243
+ }
244
+
232
245
  function appendStep(step, shift = 0) {
233
246
  // nesting too deep, ignore those steps
234
247
  if (shift >= 10) return;
package/src/bin/cli.js CHANGED
@@ -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: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-hover) 100%);
685
- color: white;
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
- filter: brightness(1.1);
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: rgba(255, 255, 255, 0.2);
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: 20px;
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-expand {
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' style='color: #10b981;'><i class='fas fa-check-circle'></i> ${passedCount}</div>` : ''}
3382
- ${failedCount > 0 ? `<div class='test-group-stat' style='color: #ef4444;'><i class='fas fa-times-circle'></i> ${failedCount}</div>` : ''}
3383
- ${skippedCount > 0 ? `<div class='test-group-stat' style='color: #f59e0b;'><i class='fas fa-forward'></i> ${skippedCount}</div>` : ''}
3384
- ${todoCount > 0 ? `<div class='test-group-stat' style='color: #8b5cf6;'><i class='fas fa-clock'></i> ${todoCount}</div>` : ''}
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
  `;