@testomatio/reporter 2.7.4-beta.allure-1 → 2.7.5

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.
@@ -741,6 +741,18 @@
741
741
  min-width: 0;
742
742
  }
743
743
 
744
+ .step-header {
745
+ display: flex;
746
+ align-items: center;
747
+ gap: 12px;
748
+ width: 100%;
749
+ }
750
+
751
+ .step-main {
752
+ flex: 1;
753
+ min-width: 0;
754
+ }
755
+
744
756
  .step-text {
745
757
  font-size: 14px;
746
758
  color: var(--gray-700);
@@ -755,6 +767,35 @@
755
767
  margin-top: 4px;
756
768
  }
757
769
 
770
+ .step-toggle {
771
+ width: 24px;
772
+ height: 24px;
773
+ border: none;
774
+ border-radius: 6px;
775
+ background: var(--gray-100);
776
+ color: var(--gray-600);
777
+ display: inline-flex;
778
+ align-items: center;
779
+ justify-content: center;
780
+ cursor: pointer;
781
+ flex-shrink: 0;
782
+ transition: var(--transition);
783
+ margin-top: 2px;
784
+ }
785
+
786
+ .step-toggle:hover {
787
+ background: var(--gray-200);
788
+ color: var(--gray-700);
789
+ }
790
+
791
+ .step-toggle.collapsed i {
792
+ transform: rotate(-90deg);
793
+ }
794
+
795
+ .step-toggle i {
796
+ transition: transform 0.2s ease;
797
+ }
798
+
758
799
  .step-status {
759
800
  width: 8px;
760
801
  height: 8px;
@@ -790,6 +831,10 @@
790
831
  padding-left: 0;
791
832
  }
792
833
 
834
+ .step-children.collapsed {
835
+ display: none;
836
+ }
837
+
793
838
  .step-children::before {
794
839
  content: '';
795
840
  position: absolute;
@@ -867,6 +912,30 @@
867
912
  border-left-color: var(--info-color);
868
913
  }
869
914
 
915
+ .message-block.passed {
916
+ border-left-color: var(--success-color);
917
+ }
918
+
919
+ .message-block.skipped,
920
+ .message-block.todo {
921
+ border-left-color: var(--warning-color);
922
+ }
923
+
924
+ .message-block.failed {
925
+ border-left-color: var(--danger-color);
926
+ }
927
+
928
+ .step-attachments {
929
+ display: grid;
930
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
931
+ gap: 12px;
932
+ margin-top: 12px;
933
+ }
934
+
935
+ .step-attachments .attachment-item {
936
+ min-height: 120px;
937
+ }
938
+
870
939
  .metadata-grid {
871
940
  display: grid;
872
941
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
@@ -2377,6 +2446,11 @@
2377
2446
  const isTodo = statusClass === 'todo';
2378
2447
  const isSkippedOrTodo = statusClass === 'skipped' || statusClass === 'todo';
2379
2448
  const displayTime = isSkippedOrTodo ? '-' : formatDuration(test.run_time || 0);
2449
+ const hasMessage = hasMeaningfulMessage(test.message);
2450
+ const initialTab = getInitialTestTab({ isTodo, hasMessage, hasSteps: test.stepsArray?.length || test.steps });
2451
+ const initialMessageClass = initialTab === 'message' ? ' active' : '';
2452
+ const initialStepsClass = initialTab === 'steps' ? ' active' : '';
2453
+ const initialInfoClass = initialTab === 'info' ? ' active' : '';
2380
2454
 
2381
2455
  testItem.innerHTML = `
2382
2456
  <div class='test-header' onclick='toggleTest(this)'>
@@ -2411,14 +2485,14 @@
2411
2485
  <div class='test-content'>
2412
2486
  <div class='test-tabs'>
2413
2487
  ${isTodo ? `
2414
- <button class='test-tab active' onclick='showTestTab(this, \"info\")'>
2488
+ <button class='test-tab${initialInfoClass}' onclick='showTestTab(this, \"info\")'>
2415
2489
  <i class='fas fa-info-circle'></i> Info
2416
2490
  </button>
2417
2491
  ` : `
2418
- <button class='test-tab active' onclick='showTestTab(this, \"steps\")'>
2492
+ <button class='test-tab${initialStepsClass}' onclick='showTestTab(this, \"steps\")'>
2419
2493
  <i class='fas fa-list-ol'></i> Steps
2420
2494
  </button>
2421
- <button class='test-tab' onclick='showTestTab(this, \"message\")'>
2495
+ <button class='test-tab${initialMessageClass}' onclick='showTestTab(this, \"message\")'>
2422
2496
  <i class='fas fa-comment-dots'></i> Message
2423
2497
  </button>
2424
2498
  ${hasStack ? `<button class='test-tab' onclick='showTestTab(this, \"stack\")'>
@@ -2439,7 +2513,7 @@
2439
2513
  `}
2440
2514
  </div>
2441
2515
  ${isTodo ? `
2442
- <div class='test-tab-content active' data-tab='info'>
2516
+ <div class='test-tab-content${initialInfoClass}' data-tab='info'>
2443
2517
  <div class='todo-info'>
2444
2518
  <div class='todo-title'>
2445
2519
  <i class='fas fa-list-ul'></i>
@@ -2456,7 +2530,7 @@
2456
2530
  </div>
2457
2531
  </div>
2458
2532
  ` : `
2459
- <div class='test-tab-content active' data-tab='steps'>
2533
+ <div class='test-tab-content${initialStepsClass}' data-tab='steps'>
2460
2534
  <div class='steps-container' id='steps-${index}'>
2461
2535
  ${(() => {
2462
2536
  const hasStepsArray = test.stepsArray && Array.isArray(test.stepsArray) && test.stepsArray.length > 0;
@@ -2476,7 +2550,7 @@
2476
2550
  })()}
2477
2551
  </div>
2478
2552
  </div>
2479
- <div class='test-tab-content' data-tab='message'>
2553
+ <div class='test-tab-content${initialMessageClass}' data-tab='message'>
2480
2554
  <div class='message-block ${statusClass}'>
2481
2555
  <strong>${statusClass.toUpperCase()}:</strong><br>
2482
2556
  ${test.message || 'No message available'}
@@ -2542,6 +2616,16 @@
2542
2616
  }
2543
2617
  }
2544
2618
 
2619
+ function hasMeaningfulMessage(message) {
2620
+ return typeof message === 'string' && message.trim().length > 0 && message !== 'No message available';
2621
+ }
2622
+
2623
+ function getInitialTestTab({ isTodo, hasMessage }) {
2624
+ if (isTodo) return 'info';
2625
+ if (hasMessage) return 'message';
2626
+ return 'steps';
2627
+ }
2628
+
2545
2629
  function parseStepsToHtml(stepsText) {
2546
2630
  if (!stepsText || stepsText === 'No steps recorded') {
2547
2631
  return `<div class="step-item">
@@ -2629,25 +2713,33 @@
2629
2713
  const status = step.status || 'passed';
2630
2714
  const duration = step.duration || 0;
2631
2715
  const category = step.category || '';
2716
+ const hasArtifacts = Array.isArray(step.artifacts) && step.artifacts.length > 0;
2632
2717
 
2633
2718
  html += `
2634
2719
  <div class="step-item step-level-${depth}" data-step-id="${stepId}">
2635
- <div class="step-number">${stepNumber}</div>
2636
2720
  <div class="step-content">
2637
- <p class="step-text">${step.title || 'Untitled step'}</p>
2638
- ${category && category !== 'user' ? `<div class="step-category">${category}</div>` : ''}
2639
- <div class="step-time">
2640
- Step ${stepNumber}
2641
- ${duration > 0 ? `<span class="step-duration"><i class="fas fa-clock"></i> ${formatDuration(duration)}</span>` : ''}
2721
+ <div class="step-header">
2722
+ <div class="step-number">${stepNumber}</div>
2723
+ <div class="step-main">
2724
+ <p class="step-text">${step.title || 'Untitled step'}</p>
2725
+ ${category && category !== 'user' ? `<div class="step-category">${category}</div>` : ''}
2726
+ ${duration > 0 ? `<div class="step-time"><span class="step-duration"><i class="fas fa-clock"></i> ${formatDuration(duration)}</span></div>` : ''}
2727
+ ${hasArtifacts ? `<div class="step-attachments">${createAttachmentItems(step.artifacts)}</div>` : ''}
2728
+ </div>
2729
+ ${hasChildren ? `
2730
+ <button class="step-toggle" type="button" aria-expanded="true" onclick="toggleStepChildren(this)">
2731
+ <i class="fas fa-chevron-down"></i>
2732
+ </button>
2733
+ ` : ''}
2734
+ <div class="step-status ${status}"></div>
2642
2735
  </div>
2643
2736
  </div>
2644
- <div class="step-status ${status}"></div>
2645
2737
  </div>
2646
2738
  `;
2647
2739
 
2648
2740
  if (hasChildren) {
2649
2741
  html += `
2650
- <div class="step-children">
2742
+ <div class="step-children" data-parent-step-id="${stepId}">
2651
2743
  ${renderStepsTree(step.steps, depth + 1)}
2652
2744
  </div>
2653
2745
  `;
@@ -2673,19 +2765,38 @@
2673
2765
  function createStepHtml(text, number, status = null, category = null) {
2674
2766
  const statusClass = status || 'passed';
2675
2767
  const categoryText = category && category !== 'user' ? `<div class="step-category">${category}</div>` : '';
2676
- const durationIcon = status ? `<div class="step-duration"><i class="fas fa-clock"></i> auto</div>` : '';
2768
+ const durationIcon = status ? `<div class="step-time"><span class="step-duration"><i class="fas fa-clock"></i> auto</span></div>` : '';
2677
2769
 
2678
2770
  return `<div class="step-item">
2679
- <div class="step-number">${number}</div>
2680
2771
  <div class="step-content">
2681
- <p class="step-text">${text}</p>
2682
- ${categoryText}
2683
- <div class="step-time">Step ${number}${durationIcon}</div>
2772
+ <div class="step-header">
2773
+ <div class="step-number">${number}</div>
2774
+ <div class="step-main">
2775
+ <p class="step-text">${text}</p>
2776
+ ${categoryText}
2777
+ ${durationIcon}
2778
+ </div>
2779
+ <div class="step-status ${statusClass}"></div>
2780
+ </div>
2684
2781
  </div>
2685
- <div class="step-status ${statusClass}"></div>
2686
2782
  </div>`;
2687
2783
  }
2688
2784
 
2785
+ function toggleStepChildren(button) {
2786
+ const stepItem = button.closest('.step-item');
2787
+ if (!stepItem) return;
2788
+
2789
+ const stepId = stepItem.getAttribute('data-step-id');
2790
+ if (!stepId) return;
2791
+
2792
+ const children = stepItem.parentElement.querySelector(`.step-children[data-parent-step-id="${stepId}"]`);
2793
+ if (!children) return;
2794
+
2795
+ const collapsed = children.classList.toggle('collapsed');
2796
+ button.classList.toggle('collapsed', collapsed);
2797
+ button.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
2798
+ }
2799
+
2689
2800
  function createRetryAttempts(retries) {
2690
2801
  const attempts = Array.isArray(retries?.attempts) ? retries.attempts : [];
2691
2802
  if (!attempts.length) return '';
@@ -324,15 +324,6 @@ const fetchSourceCode = (contents, opts = {}) => {
324
324
  if (lineIndex === -1)
325
325
  lineIndex = lines.findIndex(l => l.includes(`${title}(`));
326
326
  }
327
- else if (opts.lang === 'kotlin') {
328
- lineIndex = lines.findIndex(l => l.includes(`fun test${title}`));
329
- if (lineIndex === -1)
330
- lineIndex = lines.findIndex(l => l.includes(`@DisplayName("${title}`));
331
- if (lineIndex === -1)
332
- lineIndex = lines.findIndex(l => l.includes(`fun ${title}`));
333
- if (lineIndex === -1)
334
- lineIndex = lines.findIndex(l => l.includes(`${title}(`));
335
- }
336
327
  else if (opts.lang === 'csharp') {
337
328
  // Find the method declaration line
338
329
  let methodLineIndex = lines.findIndex(l => l.includes(`public void ${title}(`));
package/lib/xmlReader.js CHANGED
@@ -661,16 +661,18 @@ function reduceTestCases(prev, item) {
661
661
  function processTestSuite(testsuite) {
662
662
  if (!testsuite)
663
663
  return [];
664
- if (testsuite.testsuite)
664
+ if (testsuite.testsuite && !testsuite.testcase)
665
665
  return processTestSuite(testsuite.testsuite);
666
666
  if (testsuite['test-suite'] && !testsuite['test-case'])
667
667
  return processTestSuite(testsuite['test-suite']);
668
668
  let suites = testsuite;
669
- if (!Array.isArray(testsuite)) {
669
+ if (!Array.isArray(testsuite))
670
670
  suites = [testsuite];
671
- }
672
- const subSuites = suites.filter(s => s['test-suite'] && !testsuite['test-case']);
673
- return [...subSuites.map(s => processTestSuite(s['test-suite'])), ...suites.reduce(reduceTestCases, [])].flat();
671
+ const subSuites = suites.filter(s => (s['test-suite'] || s.testsuite) && !(s['test-case'] || s.testcase));
672
+ return [
673
+ ...suites.reduce(reduceTestCases, []),
674
+ ...subSuites.map(s => processTestSuite(s['test-suite'] || s.testsuite)),
675
+ ].flat();
674
676
  }
675
677
  function fetchProperties(item) {
676
678
  const tags = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.7.4-beta.allure-1",
3
+ "version": "2.7.5",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -19,21 +19,37 @@ const debug = createDebugMessages('@testomatio/reporter:adapter-jest');
19
19
  class VitestReporter {
20
20
  constructor(config = {}) {
21
21
  this.client = new TestomatioClient({ apiKey: config?.apiKey });
22
- /**
23
- * @type {(TestData & {status: string})[]} tests
24
- */
22
+ /** @type {(TestData & {status: string, _reportKey?: string | null})[]} tests */
25
23
  this.tests = [];
26
24
  this._finalized = false;
27
25
  this._finalizing = false;
26
+ this._runStartedAtMs = null;
27
+ this._runStartedAtMicros = null;
28
+ this._reportedTestKeys = new Set();
29
+ this._liveQueue = Promise.resolve();
28
30
  }
29
31
 
30
32
  // on run start
31
33
  onInit() {
34
+ const now = Date.now();
32
35
  this._finalized = false;
33
36
  this._finalizing = false;
37
+ this._runStartedAtMs = now;
38
+ this._runStartedAtMicros = now * 1000;
39
+ this._reportedTestKeys = new Set();
40
+ this._liveQueue = Promise.resolve();
34
41
  this.client.createRun();
35
42
  }
36
43
 
44
+ /**
45
+ * Vitest 3/4 callback fired when test run starts.
46
+ */
47
+ onTestRunStart() {
48
+ const now = Date.now();
49
+ this._runStartedAtMs = now;
50
+ this._runStartedAtMicros = now * 1000;
51
+ }
52
+
37
53
  /**
38
54
  * @param {VitestTestFile[] | undefined} files // array with results;
39
55
  * @param {unknown[] | undefined} errors // errors does not contain errors from tests; probably its testrunner errors
@@ -68,13 +84,18 @@ class VitestReporter {
68
84
 
69
85
  // send tests to Testomat.io
70
86
  for (const test of this.tests) {
87
+ if (test._reportKey && this._reportedTestKeys.has(test._reportKey)) continue;
88
+ if (test._reportKey) this._reportedTestKeys.add(test._reportKey);
71
89
  await this.client.addTestRun(test.status, test);
72
90
  }
91
+ await this._liveQueue;
73
92
 
74
93
  console.log('finished');
75
94
  if (errors.length) console.error('Vitest adapter errors:', errors);
76
95
 
77
- await this.client.updateRunStatus(getRunStatusFromResults(files));
96
+ const startedAtMs = this._runStartedAtMs || getEarliestTestStartMs(files) || Date.now();
97
+ const duration = Math.max(0, (Date.now() - startedAtMs) / 1000);
98
+ await this.client.updateRunStatus(getRunStatusFromResults(files), { duration });
78
99
  this._finalized = true;
79
100
  } finally {
80
101
  this._finalizing = false;
@@ -94,6 +115,28 @@ class VitestReporter {
94
115
  await this.onFinished(files, errors);
95
116
  }
96
117
 
118
+ /**
119
+ * Vitest 4 callback fired when single test case is finished.
120
+ *
121
+ * @param {unknown} testCase
122
+ */
123
+ async onTestCaseResult(testCase) {
124
+ await this.#reportLive(testCase);
125
+ }
126
+
127
+ /**
128
+ * Vitest 3 fallback callback with task updates.
129
+ *
130
+ * @param {unknown[] | undefined} packs
131
+ */
132
+ async onTaskUpdate(packs) {
133
+ if (!Array.isArray(packs) || !packs.length) return;
134
+ for (const pack of packs) {
135
+ const test = getTestFromTaskUpdatePack(pack);
136
+ if (test) await this.#reportLive(test);
137
+ }
138
+ }
139
+
97
140
  /* non-used listeners
98
141
  onUserConsoleLog(log) {}
99
142
  onPathsCollected(paths) {} // paths array to files with tests
@@ -128,25 +171,52 @@ class VitestReporter {
128
171
  /**
129
172
  * Processes task and returns test data ready to be sent to Testomat.io
130
173
  *
131
- * @param {VitestTest} test
174
+ * @param {any} test
132
175
  *
133
- * @returns {TestData & {status: 'passed' | 'failed' | 'skipped'}}
176
+ * @returns {TestData & {status: 'passed' | 'failed' | 'skipped', _reportKey?: string | null}}
134
177
  */
135
178
  #getDataFromTest(test) {
179
+ const normalized = normalizeVitestTest(test);
180
+ const reportKey = getReportKey(test, normalized);
181
+ const startMicros =
182
+ typeof normalized.startTime === 'number'
183
+ ? Math.floor(normalized.startTime * 1000)
184
+ : this._runStartedAtMicros || undefined;
185
+
136
186
  return {
137
- error: test.result?.errors ? test.result.errors[0] : undefined,
138
- file: test.file?.name || test.file?.filepath || '',
139
- logs: test.logs ? transformLogsToString(test.logs) : '',
140
- meta: test.meta,
187
+ _reportKey: reportKey,
188
+ error: normalized.error,
189
+ file: normalized.file,
190
+ logs: normalized.logs,
191
+ meta: normalized.meta,
141
192
  // @ts-ignore - STATUS values are string literals but type system sees them as string
142
- status: getTestStatus(test),
143
- suite_title: test.suite?.name || test.file?.name || test.file?.filepath,
144
- test_id: getTestomatIdFromTestTitle(test.name),
145
- time: test.result?.duration || 0,
146
- title: test.name,
193
+ status: getTestStatus(normalized.state, normalized.mode),
194
+ suite_title: normalized.suiteTitle,
195
+ test_id: getTestomatIdFromTestTitle(normalized.name),
196
+ time: normalized.duration,
197
+ timestamp: startMicros,
198
+ title: normalized.name,
147
199
  // testomatio functions (artifacts, logs, steps, meta) are not supported
148
200
  };
149
201
  }
202
+
203
+ /**
204
+ * @param {unknown} testCase
205
+ */
206
+ async #reportLive(testCase) {
207
+ if (this._finalized || this._finalizing) return;
208
+ const normalized = normalizeVitestTest(testCase);
209
+ if (!isLiveReportableState(normalized.state, normalized.mode)) return;
210
+
211
+ const data = this.#getDataFromTest(testCase);
212
+ if (!data._reportKey || this._reportedTestKeys.has(data._reportKey)) return;
213
+ this._reportedTestKeys.add(data._reportKey);
214
+
215
+ this._liveQueue = this._liveQueue
216
+ .then(() => this.client.addTestRun(data.status, data))
217
+ .catch(() => undefined);
218
+ await this._liveQueue;
219
+ }
150
220
  }
151
221
 
152
222
  /**
@@ -162,17 +232,16 @@ function getRunStatusFromResults(files) {
162
232
  let status = 'finished'; // default status (if no failed or passed tests)
163
233
 
164
234
  files.forEach(file => {
165
- // search for failed tests
166
- file.tasks.forEach(taskOrSuite => {
167
- if (taskOrSuite.result?.state === 'fail') {
235
+ getTasks(file).forEach(taskOrSuite => {
236
+ if (isFailedState(taskOrSuite?.result?.state)) {
168
237
  status = 'failed'; // set status to failed if any test failed
169
238
  }
170
239
  });
171
240
 
172
241
  // if there are no failed tests > search for passed tests
173
242
  if (status !== 'failed') {
174
- file.tasks.forEach(taskOrSuite => {
175
- if (taskOrSuite.result?.state === 'pass') {
243
+ getTasks(file).forEach(taskOrSuite => {
244
+ if (isPassedState(taskOrSuite?.result?.state)) {
176
245
  status = 'passed'; // set status to passed if any test passed (and there are no failed tests)
177
246
  }
178
247
  });
@@ -185,14 +254,15 @@ function getRunStatusFromResults(files) {
185
254
  /**
186
255
  * Returns test status in Testomat.io format
187
256
  *
188
- * @param {VitestTest} test
257
+ * @param {string | undefined} state
258
+ * @param {string | undefined} mode
189
259
  * @returns 'passed' | 'failed' | 'skipped'
190
260
  */
191
- function getTestStatus(test) {
192
- if (test.result?.state === 'fail') return STATUS.FAILED;
193
- if (test.result?.state === 'pass') return STATUS.PASSED;
194
- if (test.result?.state === 'skip' || (!test.result && test.mode === 'skip')) return STATUS.SKIPPED;
195
- console.error(pc.red('Unprocessed case for defining test status. Contact dev team. Test:'), test);
261
+ function getTestStatus(state, mode) {
262
+ if (isFailedState(state)) return STATUS.FAILED;
263
+ if (isPassedState(state)) return STATUS.PASSED;
264
+ if (isSkippedState(state) || (!state && mode === 'skip')) return STATUS.SKIPPED;
265
+ console.error(pc.red('Unprocessed case for defining test status. Contact dev team. State:'), state);
196
266
  return STATUS.SKIPPED;
197
267
  }
198
268
 
@@ -220,9 +290,166 @@ function getTasks(node) {
220
290
  if (!node) return [];
221
291
  if (Array.isArray(node.tasks)) return node.tasks;
222
292
  if (Array.isArray(node.children)) return node.children;
293
+ if (node.children && typeof node.children[Symbol.iterator] === 'function') return Array.from(node.children);
223
294
  if (node.task) return [node.task];
224
295
  return [];
225
296
  }
226
297
 
298
+ /**
299
+ * @param {string | undefined} state
300
+ * @returns {boolean}
301
+ */
302
+ function isFailedState(state) {
303
+ return state === 'fail' || state === 'failed';
304
+ }
305
+
306
+ /**
307
+ * @param {string | undefined} state
308
+ * @returns {boolean}
309
+ */
310
+ function isPassedState(state) {
311
+ return state === 'pass' || state === 'passed';
312
+ }
313
+
314
+ /**
315
+ * @param {string | undefined} state
316
+ * @returns {boolean}
317
+ */
318
+ function isSkippedState(state) {
319
+ return state === 'skip' || state === 'skipped' || state === 'todo';
320
+ }
321
+
322
+ /**
323
+ * Accept only completed test states for live upload to avoid reporting
324
+ * intermediate task updates as skipped.
325
+ *
326
+ * @param {string | undefined} state
327
+ * @param {string | undefined} mode
328
+ * @returns {boolean}
329
+ */
330
+ function isLiveReportableState(state, mode) {
331
+ if (isFailedState(state) || isPassedState(state) || isSkippedState(state)) return true;
332
+ if (!state && mode === 'skip') return true;
333
+ return false;
334
+ }
335
+
336
+ /**
337
+ * @param {VitestTestFile[] | undefined} files
338
+ * @returns {number | null}
339
+ */
340
+ function getEarliestTestStartMs(files) {
341
+ let earliest = null;
342
+ const walk = node => {
343
+ if (!node) return;
344
+ const startTime = node?.result?.startTime;
345
+ if (typeof startTime === 'number' && !Number.isNaN(startTime)) {
346
+ if (earliest == null || startTime < earliest) earliest = startTime;
347
+ }
348
+ getTasks(node).forEach(walk);
349
+ };
350
+ (files || []).forEach(walk);
351
+ return earliest;
352
+ }
353
+
354
+ /**
355
+ * @param {any} test
356
+ * @returns {{
357
+ * name: string,
358
+ * state: string | undefined,
359
+ * mode: string | undefined,
360
+ * duration: number,
361
+ * startTime: number | undefined,
362
+ * error: any,
363
+ * file: string,
364
+ * suiteTitle: string,
365
+ * logs: string,
366
+ * meta: any
367
+ * }}
368
+ */
369
+ function normalizeVitestTest(test) {
370
+ if (test && typeof test.result === 'function') {
371
+ const result = test.result();
372
+ const diagnostic = typeof test.diagnostic === 'function' ? test.diagnostic() : undefined;
373
+ const state = result?.state;
374
+ const duration = diagnostic?.duration || 0;
375
+ const startTime = diagnostic?.startTime;
376
+ const error = Array.isArray(result?.errors) ? result.errors[0] : undefined;
377
+ const file =
378
+ test.module?.relativeModuleId ||
379
+ test.module?.moduleId ||
380
+ test.task?.file?.name ||
381
+ test.task?.file?.filepath ||
382
+ '';
383
+ const suiteTitle =
384
+ (test.parent?.type === 'suite' ? test.parent?.name : null) ||
385
+ test.task?.suite?.name ||
386
+ test.task?.file?.name ||
387
+ file;
388
+
389
+ return {
390
+ name: test.name || test.task?.name || '',
391
+ state,
392
+ mode: test.options?.mode || test.task?.mode,
393
+ duration,
394
+ startTime,
395
+ error,
396
+ file,
397
+ suiteTitle,
398
+ logs: '',
399
+ meta: typeof test.meta === 'function' ? test.meta() : {},
400
+ };
401
+ }
402
+
403
+ return {
404
+ name: test?.name || '',
405
+ state: test?.result?.state,
406
+ mode: test?.mode,
407
+ duration: test?.result?.duration || 0,
408
+ startTime: test?.result?.startTime,
409
+ error: test?.result?.errors ? test.result.errors[0] : undefined,
410
+ file: test?.file?.name || test?.file?.filepath || '',
411
+ suiteTitle: test?.suite?.name || test?.file?.name || test?.file?.filepath || '',
412
+ logs: test?.logs ? transformLogsToString(test.logs) : '',
413
+ meta: test?.meta,
414
+ };
415
+ }
416
+
417
+ /**
418
+ * @param {any} test
419
+ * @param {{file: string, suiteTitle: string, name: string, startTime?: number}} normalized
420
+ * @returns {string | null}
421
+ */
422
+ function getReportKey(test, normalized) {
423
+ if (test?.id) return String(test.id);
424
+ if (test?.task?.id) return String(test.task.id);
425
+ if (!normalized?.name) return null;
426
+ const loc = test?.location || test?.task?.location;
427
+ const locationKey = loc ? `${loc.line || ''}:${loc.column || ''}` : '';
428
+ const startKey =
429
+ typeof normalized.startTime === 'number' && !Number.isNaN(normalized.startTime) ? String(normalized.startTime) : '';
430
+ return `${normalized.file}::${normalized.suiteTitle}::${normalized.name}::${locationKey}::${startKey}`;
431
+ }
432
+
433
+ /**
434
+ * Vitest can pass task updates as tuples. Try to extract a test-like object.
435
+ *
436
+ * @param {unknown} pack
437
+ * @returns {any | null}
438
+ */
439
+ function getTestFromTaskUpdatePack(pack) {
440
+ if (!pack) return null;
441
+
442
+ if (Array.isArray(pack)) {
443
+ if (pack[2]?.type === 'test') return pack[2];
444
+ if (pack[1]?.type === 'test') return pack[1];
445
+ if (pack[0]?.type === 'test') return pack[0];
446
+ return null;
447
+ }
448
+
449
+ const objectPack = /** @type {any} */ (pack);
450
+ if (typeof objectPack === 'object' && objectPack?.type === 'test') return objectPack;
451
+ return null;
452
+ }
453
+
227
454
  export default VitestReporter;
228
455
  export { VitestReporter };