@testomatio/reporter 2.7.1 → 2.7.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.
Files changed (52) hide show
  1. package/README.md +2 -1
  2. package/lib/adapter/codecept.js +81 -26
  3. package/lib/adapter/playwright.d.ts +1 -1
  4. package/lib/adapter/playwright.js +54 -34
  5. package/lib/adapter/utils/step-formatter.d.ts +134 -0
  6. package/lib/adapter/utils/step-formatter.js +237 -0
  7. package/lib/adapter/vitest.d.ts +9 -0
  8. package/lib/adapter/vitest.js +75 -29
  9. package/lib/bin/cli.js +28 -31
  10. package/lib/bin/reportXml.js +5 -6
  11. package/lib/bin/uploadArtifacts.js +6 -6
  12. package/lib/client.d.ts +8 -0
  13. package/lib/client.js +71 -10
  14. package/lib/constants.d.ts +1 -0
  15. package/lib/constants.js +7 -1
  16. package/lib/pipe/bitbucket.js +2 -1
  17. package/lib/pipe/coverage.js +16 -15
  18. package/lib/pipe/debug.js +3 -3
  19. package/lib/pipe/github.js +3 -2
  20. package/lib/pipe/gitlab.js +2 -1
  21. package/lib/pipe/index.js +5 -5
  22. package/lib/pipe/testomatio.js +21 -24
  23. package/lib/uploader.js +3 -2
  24. package/lib/utils/log.d.ts +45 -0
  25. package/lib/utils/log.js +98 -0
  26. package/lib/utils/pipe_utils.js +5 -5
  27. package/lib/utils/utils.d.ts +10 -0
  28. package/lib/utils/utils.js +16 -1
  29. package/lib/xmlReader.js +5 -4
  30. package/package.json +1 -1
  31. package/src/adapter/codecept.js +99 -29
  32. package/src/adapter/playwright.js +64 -39
  33. package/src/adapter/utils/step-formatter.js +232 -0
  34. package/src/adapter/vitest.js +70 -26
  35. package/src/bin/cli.js +34 -31
  36. package/src/bin/reportXml.js +5 -6
  37. package/src/bin/uploadArtifacts.js +6 -6
  38. package/src/client.js +76 -26
  39. package/src/constants.js +4 -0
  40. package/src/pipe/bitbucket.js +2 -1
  41. package/src/pipe/coverage.js +16 -15
  42. package/src/pipe/debug.js +3 -3
  43. package/src/pipe/github.js +4 -3
  44. package/src/pipe/gitlab.js +2 -1
  45. package/src/pipe/index.js +5 -7
  46. package/src/pipe/testomatio.js +32 -25
  47. package/src/uploader.js +3 -2
  48. package/src/utils/log.js +87 -0
  49. package/src/utils/pipe_utils.js +5 -5
  50. package/src/utils/utils.js +14 -0
  51. package/src/xmlReader.js +5 -4
  52. package/types/types.d.ts +3 -0
@@ -3,14 +3,16 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import { v4 as uuidv4 } from 'uuid';
5
5
  import fs from 'fs';
6
- import { APP_PREFIX, STATUS as Status, TESTOMAT_TMP_STORAGE_DIR } from '../constants.js';
6
+ import { APP_PREFIX, STATUS as Status, TESTOMAT_TMP_STORAGE_DIR, SCREENSHOTS_ON_STEPS } from '../constants.js';
7
7
  import TestomatioClient from '../client.js';
8
- import { getTestomatIdFromTestTitle, fileSystem } from '../utils/utils.js';
8
+ import { getTestomatIdFromTestTitle, fileSystem, truncate } from '../utils/utils.js';
9
9
  import { services } from '../services/index.js';
10
10
  import { dataStorage } from '../data-storage.js';
11
11
  import { extensionMap } from '../utils/constants.js';
12
12
  import pc from 'picocolors';
13
13
  import { fetchLinksFromLogs } from './utils/playwright.js';
14
+ import { formatStep, addStatusToStep, addArtifactsToStep } from './utils/step-formatter.js';
15
+ import { log } from '../utils/log.js';
14
16
 
15
17
  const reportTestPromises = [];
16
18
 
@@ -35,7 +37,7 @@ class PlaywrightReporter {
35
37
  dataStorage.setContext(fullTestTitle);
36
38
  }
37
39
 
38
- onTestEnd(test, result) {
40
+ async onTestEnd(test, result) {
39
41
  // test.parent.project().__projectId
40
42
 
41
43
  if (!this.client) return;
@@ -54,13 +56,26 @@ class PlaywrightReporter {
54
56
 
55
57
  const suite_title = test.parent ? test.parent?.title : path.basename(test?.location?.file);
56
58
 
57
- const steps = [];
58
- for (const step of result.steps) {
59
- const appendedStep = appendStep(step);
60
- if (appendedStep) {
61
- steps.push(appendedStep);
62
- }
63
- }
59
+ const rid = test.id || test.testId || uuidv4();
60
+
61
+ /**
62
+ * @type {{
63
+ * browser?: string,
64
+ * dependencies: string[],
65
+ * isMobile?: boolean
66
+ * metadata: Record<string, any>,
67
+ * name: string,
68
+ * }}
69
+ */
70
+ const project = {
71
+ browser: test.parent.project().use.defaultBrowserType,
72
+ dependencies: test.parent.project().dependencies,
73
+ isMobile: test.parent.project().use.isMobile,
74
+ metadata: test.parent.project().metadata,
75
+ name: test.parent.project().name,
76
+ };
77
+
78
+ const steps = result.steps.map(step => appendStep(step, 0)).filter(step => step !== null);
64
79
 
65
80
  // Extract and normalize tags
66
81
  const tags = extractTags(test);
@@ -86,24 +101,6 @@ class PlaywrightReporter {
86
101
  */
87
102
  const manuallyAttachedArtifacts = services.artifacts.get(fullTestTitle);
88
103
  const testMeta = services.keyValues.get(fullTestTitle);
89
- const rid = test.id || test.testId || uuidv4();
90
-
91
- /**
92
- * @type {{
93
- * browser?: string,
94
- * dependencies: string[],
95
- * isMobile?: boolean
96
- * metadata: Record<string, any>,
97
- * name: string,
98
- * }}
99
- */
100
- const project = {
101
- browser: test.parent.project().use.defaultBrowserType,
102
- dependencies: test.parent.project().dependencies,
103
- isMobile: test.parent.project().use.isMobile,
104
- metadata: test.parent.project().metadata,
105
- name: test.parent.project().name,
106
- };
107
104
 
108
105
  let status = result.status;
109
106
  // process test.fail() annotation
@@ -179,7 +176,7 @@ class PlaywrightReporter {
179
176
  await Promise.all(reportTestPromises);
180
177
 
181
178
  if (this.uploads.length) {
182
- if (this.client.uploader.isEnabled) console.log(APP_PREFIX, `🎞️ Uploading ${this.uploads.length} files...`);
179
+ if (this.client.uploader.isEnabled) log.info(`🎞️ Uploading ${this.uploads.length} files...`);
183
180
 
184
181
  const promises = [];
185
182
 
@@ -242,6 +239,44 @@ function appendStep(step, shift = 0) {
242
239
  newCategory = 'framework';
243
240
  }
244
241
 
242
+ const resultStep = formatStep({
243
+ category: newCategory,
244
+ title: step.title,
245
+ duration: step.duration,
246
+ });
247
+
248
+ // Add status based on error
249
+ addStatusToStep(resultStep, step.error ? 'failed' : 'passed', step.error);
250
+
251
+ // Add error if present
252
+ if (step.error !== undefined) {
253
+ if (typeof step.error === 'object') {
254
+ resultStep.error = {
255
+ message: truncate(String(step.error.message), 250),
256
+ stack: truncate(String(step.error.stack || ''), 250),
257
+ };
258
+ } else {
259
+ resultStep.error = truncate(String(step.error), 250);
260
+ }
261
+ }
262
+
263
+ // Add log if present
264
+ if (step.log) {
265
+ resultStep.log = truncate(String(step.log), 250);
266
+ }
267
+
268
+ // Add artifacts from attachments
269
+ if (step.attachments && step.attachments.length > 0 && SCREENSHOTS_ON_STEPS) {
270
+ const screenshotAttachment = step.attachments.find(att =>
271
+ att.contentType === 'image/png' && att.name === 'screenshot'
272
+ );
273
+ if (screenshotAttachment && screenshotAttachment.path) {
274
+ const artifacts = { screenshot: screenshotAttachment.path };
275
+ addArtifactsToStep(resultStep, artifacts);
276
+ }
277
+ }
278
+
279
+ // Process nested steps
245
280
  const formattedSteps = [];
246
281
  for (const child of step.steps || []) {
247
282
  const appendedChild = appendStep(child, shift + 2);
@@ -250,20 +285,10 @@ function appendStep(step, shift = 0) {
250
285
  }
251
286
  }
252
287
 
253
- const resultStep = {
254
- category: newCategory,
255
- title: step.title,
256
- duration: step.duration,
257
- };
258
-
259
288
  if (formattedSteps.length) {
260
289
  resultStep.steps = formattedSteps.filter(s => !!s);
261
290
  }
262
291
 
263
- if (step.error !== undefined) {
264
- resultStep.error = step.error;
265
- }
266
-
267
292
  return resultStep;
268
293
  }
269
294
 
@@ -0,0 +1,232 @@
1
+ import { truncate } from '../../utils/utils.js';
2
+ import path from 'path';
3
+ import crypto from 'crypto';
4
+ import fs from 'fs';
5
+
6
+ /**
7
+ * Generates a short unique filename from screenshot path
8
+ * If original filename is too long, uses hash-based name
9
+ *
10
+ * @param {string} screenshotPath - Path to screenshot file
11
+ * @returns {string} Short filename (max 80 chars)
12
+ */
13
+ export function generateShortFilename(screenshotPath) {
14
+ const originalFilename = path.basename(screenshotPath);
15
+ const stepPrefix = originalFilename.match(/^(\d{3,4}_)/)?.[1] || '';
16
+
17
+ if (originalFilename.length < 40) {
18
+ return originalFilename;
19
+ }
20
+
21
+ const ext = path.extname(screenshotPath);
22
+
23
+ const hash = crypto
24
+ .createHash('sha256')
25
+ .update(screenshotPath)
26
+ .digest('hex')
27
+ .slice(0, 16);
28
+
29
+ return `${stepPrefix}screenshot_${hash}${ext}`;
30
+ }
31
+
32
+ /**
33
+ * Formats a step object according to Testomat.io Step Schema
34
+ *
35
+ * This function transforms a raw step object from test frameworks (CodeceptJS, Playwright, etc.)
36
+ * into a standardized format compatible with Testomat.io API. It ensures all text fields are
37
+ * truncated to 250 characters as defined in testomat-api-definition.yml.
38
+ *
39
+ * Processed fields:
40
+ * - category: step type (framework, user, hook) - defaults to 'user'
41
+ * - title: step name/description, truncated to 250 chars
42
+ * - duration: step execution time in seconds
43
+ * - log: optional log output, truncated to 250 chars
44
+ * - artifacts: optional array of artifact URLs (screenshots), each truncated to 250 chars
45
+ * - error: error details (message + stack) if step failed, each truncated to 250 chars
46
+ * - steps: recursively formats nested steps
47
+ *
48
+ * Schema reference: testomat-api-definition.yml (Step object)
49
+ *
50
+ * @param {Object} step - Raw step object from test framework
51
+ * @param {string} [step.category] - Step category: 'user', 'framework', or 'hook'
52
+ * @param {string} [step.title] - Step title/name
53
+ * @param {number} [step.duration] - Step duration in seconds
54
+ * @param {string} [step.log] - Log output for this step
55
+ * @param {string[]} [step.artifacts] - Array of artifact URLs (screenshots)
56
+ * @param {string|Object} [step.error] - Error details - can be string or object with message/stack
57
+ * @param {Object[]} [step.steps] - Array of nested child steps
58
+ * @returns {Object} Formatted step object matching Testomat.io Step Schema with:
59
+ * category, title, duration, and optional log, artifacts, error, and steps fields
60
+ *
61
+ * @example
62
+ * const rawStep = {
63
+ * category: 'user',
64
+ * title: 'I click on button',
65
+ * duration: 1.5,
66
+ * error: { message: 'Element not found', stack: 'at test.js:10:5' }
67
+ * };
68
+ * const formatted = formatStep(rawStep);
69
+ * // Returns: { category: 'user', title: 'I click on button', duration: 1.5, error: {...} }
70
+ */
71
+ export function formatStep(step) {
72
+ const formattedStep = {
73
+ category: step.category || 'user',
74
+ title: truncate(String(step.title || ''), 250),
75
+ duration: step.duration || 0,
76
+ };
77
+
78
+ if (step.log) {
79
+ formattedStep.log = truncate(String(step.log), 250);
80
+ }
81
+
82
+ if (step.artifacts && Array.isArray(step.artifacts)) {
83
+ formattedStep.artifacts = step.artifacts.map(artifact => truncate(String(artifact), 250));
84
+ }
85
+
86
+ if (step.error) {
87
+ if (typeof step.error === 'object') {
88
+ formattedStep.error = {
89
+ message: truncate(String(step.error.message || 'Step failed'), 250),
90
+ stack: truncate(String(step.error.stack || ''), 250),
91
+ };
92
+ } else {
93
+ formattedStep.error = truncate(String(step.error), 250);
94
+ }
95
+ }
96
+
97
+ if (step.steps && Array.isArray(step.steps)) {
98
+ formattedStep.steps = step.steps.map(s => formatStep(s));
99
+ }
100
+
101
+ return formattedStep;
102
+ }
103
+
104
+ /**
105
+ * Adds status field to step
106
+ *
107
+ * Normalizes step status from test frameworks to Testomat.io standard format.
108
+ * Maps framework-specific statuses ('success', 'failed', 'passed') to Testomat.io
109
+ * standard values ('passed', 'failed').
110
+ *
111
+ * Status mapping:
112
+ * - 'success' → 'passed'
113
+ * - 'passed' → 'passed'
114
+ * - 'failed' → 'failed'
115
+ * - Any other value → 'passed' (default)
116
+ *
117
+ * If step already has a status, it won't be overwritten. If error is provided
118
+ * and step doesn't have status, it will be set to 'failed'.
119
+ *
120
+ * Schema reference: testomat-api-definition.yml (Step.status enum)
121
+ *
122
+ * @param {Object} step - Step object to add status to (modified in place)
123
+ * @param {string} [step.status] - Existing status (won't be overwritten if present)
124
+ * @param {string} status - Status from test framework: 'success', 'failed', or 'passed'
125
+ * @param {Error|Object|null} err - Error object if step failed
126
+ * @returns {Object} The same step object with added status field
127
+ *
128
+ * @example
129
+ * const step = { title: 'Click button' };
130
+ * addStatusToStep(step, 'success', null);
131
+ * // step.status === 'passed'
132
+ *
133
+ * @example
134
+ * const step2 = { title: 'Find element' };
135
+ * addStatusToStep(step2, 'failed', new Error('Not found'));
136
+ * // step2.status === 'failed'
137
+ */
138
+ export function addStatusToStep(step, status, err) {
139
+ if (step.status) return step;
140
+
141
+ const statusMap = {
142
+ 'success': 'passed',
143
+ 'failed': 'failed',
144
+ 'passed': 'passed',
145
+ };
146
+
147
+ step.status = statusMap[status] || 'passed';
148
+
149
+ if (err && !step.status) {
150
+ step.status = 'failed';
151
+ }
152
+
153
+ return step;
154
+ }
155
+
156
+ /**
157
+ * Adds screenshot to step as artifacts array
158
+ *
159
+ * Extracts screenshot path from artifacts and adds it to the step's artifacts array.
160
+ * The actual upload will happen in the client's addTestRun method.
161
+ *
162
+ * Artifact format supports:
163
+ * - Array format: [{ screenshot: '/path/to/screenshot.png' }]
164
+ * - Object format: { screenshot: '/path/to/screenshot.png' }
165
+ *
166
+ * Screenshot path can be specified as:
167
+ * - Object with path property: { screenshot: { path: '/path/to/file.png' } }
168
+ * - Object with screenshot property: { screenshot: { screenshot: '/path/to/file.png' } }
169
+ * - Direct string path: { screenshot: '/path/to/file.png' }
170
+ *
171
+ * @param {Object} step - Step object to add artifacts to (modified in place)
172
+ * @param {string[]} [step.artifacts] - Existing artifacts array (won't be overwritten if present)
173
+ * @param {Object|Object[]|null} artifacts - Artifacts from test framework
174
+ * @returns {Object} The same step object with artifacts array added
175
+ *
176
+ * @example
177
+ * const step = { title: 'Click button' };
178
+ * const artifacts = { screenshot: '/tmp/screenshot.png' };
179
+ * addArtifactsToStep(step, artifacts);
180
+ * // step.artifacts === ['/tmp/screenshot.png']
181
+ */
182
+ export function addArtifactsToStep(step, artifacts) {
183
+ if (!artifacts) return step;
184
+
185
+ let screenshotPath = null;
186
+
187
+ if (Array.isArray(artifacts)) {
188
+ const screenshotArtifact = artifacts.find(a => a.screenshot);
189
+ if (screenshotArtifact && screenshotArtifact.path) {
190
+ screenshotPath = screenshotArtifact.path;
191
+ } else if (screenshotArtifact && screenshotArtifact.screenshot) {
192
+ screenshotPath = screenshotArtifact.screenshot;
193
+ }
194
+ } else if (artifacts.screenshot) {
195
+ screenshotPath = artifacts.screenshot;
196
+ }
197
+
198
+ if (screenshotPath && fs.existsSync(screenshotPath)) {
199
+ const truncatedPath = truncate(String(screenshotPath), 250);
200
+ if (step.artifacts && Array.isArray(step.artifacts)) {
201
+ step.artifacts.push(truncatedPath);
202
+ } else {
203
+ step.artifacts = [truncatedPath];
204
+ }
205
+ }
206
+
207
+ return step;
208
+ }
209
+
210
+ /**
211
+ * Appends one artifact path to a step.
212
+ *
213
+ * Unlike addArtifactsToStep, this helper accepts a direct path (or URL-like string)
214
+ * and does not check file existence, so callers can attach fallback artifacts
215
+ * collected from logs or async trace outputs.
216
+ *
217
+ * @param {Object} step - Step object to update (modified in place)
218
+ * @param {string} artifactPath - Artifact path to append
219
+ * @returns {Object} The same step object with updated artifacts
220
+ */
221
+ export function addArtifactPathToStep(step, artifactPath) {
222
+ if (!step || !artifactPath) return step;
223
+
224
+ const truncatedPath = truncate(String(artifactPath), 250);
225
+ if (step.artifacts && Array.isArray(step.artifacts)) {
226
+ if (!step.artifacts.includes(truncatedPath)) step.artifacts.push(truncatedPath);
227
+ } else {
228
+ step.artifacts = [truncatedPath];
229
+ }
230
+
231
+ return step;
232
+ }
@@ -23,10 +23,14 @@ class VitestReporter {
23
23
  * @type {(TestData & {status: string})[]} tests
24
24
  */
25
25
  this.tests = [];
26
+ this._finalized = false;
27
+ this._finalizing = false;
26
28
  }
27
29
 
28
30
  // on run start
29
31
  onInit() {
32
+ this._finalized = false;
33
+ this._finalizing = false;
30
34
  this.client.createRun();
31
35
  }
32
36
 
@@ -35,34 +39,59 @@ class VitestReporter {
35
39
  * @param {unknown[] | undefined} errors // errors does not contain errors from tests; probably its testrunner errors
36
40
  */
37
41
  async onFinished(files, errors) {
38
- if (!files || !files.length) console.info('No tests executed');
42
+ if (this._finalized || this._finalizing) return;
43
+ this._finalizing = true;
44
+
45
+ try {
46
+ this.tests = [];
47
+ if (!files || !files.length) {
48
+ console.info('No tests executed');
49
+ return;
50
+ }
39
51
 
40
- files.forEach(file => {
41
- // task could be test or suite
42
- file.tasks.forEach(taskOrSuite => {
43
- if (taskOrSuite.type === 'test') {
44
- const test = taskOrSuite;
45
- this.tests.push(this.#getDataFromTest(test));
46
- } else if (taskOrSuite.type === 'suite') {
47
- const suite = taskOrSuite;
48
- this.#processTasksOfSuite(suite);
49
- } else {
50
- throw new Error('Unprocessed case. Unknown task type');
51
- }
52
+ files.forEach(file => {
53
+ // task could be test or suite
54
+ getTasks(file).forEach(taskOrSuite => {
55
+ if (taskOrSuite.type === 'test') {
56
+ const test = taskOrSuite;
57
+ this.tests.push(this.#getDataFromTest(test));
58
+ } else if (taskOrSuite.type === 'suite') {
59
+ const suite = taskOrSuite;
60
+ this.#processTasksOfSuite(suite);
61
+ } else {
62
+ throw new Error('Unprocessed case. Unknown task type');
63
+ }
64
+ });
52
65
  });
53
- });
54
66
 
55
- debug(this.tests.length, 'tests collected');
67
+ debug(this.tests.length, 'tests collected');
56
68
 
57
- // send tests to Testomat.io
58
- for (const test of this.tests) {
59
- await this.client.addTestRun(test.status, test);
60
- }
69
+ // send tests to Testomat.io
70
+ for (const test of this.tests) {
71
+ await this.client.addTestRun(test.status, test);
72
+ }
61
73
 
62
- console.log('finished');
63
- if (errors.length) console.error('Vitest adapter errors:', errors);
74
+ console.log('finished');
75
+ if (errors.length) console.error('Vitest adapter errors:', errors);
64
76
 
65
- await this.client.updateRunStatus(getRunStatusFromResults(files));
77
+ await this.client.updateRunStatus(getRunStatusFromResults(files));
78
+ this._finalized = true;
79
+ } finally {
80
+ this._finalizing = false;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Vitest 4+ reporter API callback.
86
+ *
87
+ * @param {Array<unknown> | undefined} testModules
88
+ * @param {unknown[] | undefined} errors
89
+ */
90
+ async onTestRunEnd(testModules, errors) {
91
+ const files = (testModules || [])
92
+ .map(module => module && (/** @type {any} */ (module).task || module))
93
+ .filter(Boolean);
94
+ await this.onFinished(files, errors);
66
95
  }
67
96
 
68
97
  /* non-used listeners
@@ -83,7 +112,7 @@ class VitestReporter {
83
112
  * @param {VitestSuite} suite
84
113
  */
85
114
  #processTasksOfSuite(suite) {
86
- suite.tasks.forEach(taskOrSuite => {
115
+ getTasks(suite).forEach(taskOrSuite => {
87
116
  if (taskOrSuite.type === 'test') {
88
117
  const test = taskOrSuite;
89
118
  this.tests.push(this.#getDataFromTest(test));
@@ -106,12 +135,12 @@ class VitestReporter {
106
135
  #getDataFromTest(test) {
107
136
  return {
108
137
  error: test.result?.errors ? test.result.errors[0] : undefined,
109
- file: test.file.name,
138
+ file: test.file?.name || test.file?.filepath || '',
110
139
  logs: test.logs ? transformLogsToString(test.logs) : '',
111
140
  meta: test.meta,
112
141
  // @ts-ignore - STATUS values are string literals but type system sees them as string
113
142
  status: getTestStatus(test),
114
- suite_title: test.suite.name || test.file?.name,
143
+ suite_title: test.suite?.name || test.file?.name || test.file?.filepath,
115
144
  test_id: getTestomatIdFromTestTitle(test.name),
116
145
  time: test.result?.duration || 0,
117
146
  title: test.name,
@@ -162,8 +191,9 @@ function getRunStatusFromResults(files) {
162
191
  function getTestStatus(test) {
163
192
  if (test.result?.state === 'fail') return STATUS.FAILED;
164
193
  if (test.result?.state === 'pass') return STATUS.PASSED;
165
- if (!test.result && test.mode === 'skip') return STATUS.SKIPPED;
194
+ if (test.result?.state === 'skip' || (!test.result && test.mode === 'skip')) return STATUS.SKIPPED;
166
195
  console.error(pc.red('Unprocessed case for defining test status. Contact dev team. Test:'), test);
196
+ return STATUS.SKIPPED;
167
197
  }
168
198
 
169
199
  /**
@@ -180,5 +210,19 @@ function transformLogsToString(logs) {
180
210
  return logsStr;
181
211
  }
182
212
 
213
+ /**
214
+ * Supports both old and new Vitest task tree shapes.
215
+ *
216
+ * @param {any} node
217
+ * @returns {any[]}
218
+ */
219
+ function getTasks(node) {
220
+ if (!node) return [];
221
+ if (Array.isArray(node.tasks)) return node.tasks;
222
+ if (Array.isArray(node.children)) return node.children;
223
+ if (node.task) return [node.task];
224
+ return [];
225
+ }
226
+
183
227
  export default VitestReporter;
184
228
  export { VitestReporter };