@testomatio/reporter 2.7.2 → 2.7.3-beta.1-vitest

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.
@@ -18,15 +18,22 @@ export type TestData = import("../../types/types.js").TestData;
18
18
  export class VitestReporter {
19
19
  constructor(config?: {});
20
20
  client: TestomatioClient;
21
- /**
22
- * @type {(TestData & {status: string})[]} tests
23
- */
21
+ /** @type {(TestData & {status: string, _reportKey?: string | null})[]} tests */
24
22
  tests: (TestData & {
25
23
  status: string;
24
+ _reportKey?: string | null;
26
25
  })[];
27
26
  _finalized: boolean;
28
27
  _finalizing: boolean;
28
+ _runStartedAtMs: number;
29
+ _runStartedAtMicros: number;
30
+ _reportedTestKeys: Set<any>;
31
+ _liveQueue: Promise<void>;
29
32
  onInit(): void;
33
+ /**
34
+ * Vitest 3/4 callback fired when test run starts.
35
+ */
36
+ onTestRunStart(): void;
30
37
  /**
31
38
  * @param {VitestTestFile[] | undefined} files // array with results;
32
39
  * @param {unknown[] | undefined} errors // errors does not contain errors from tests; probably its testrunner errors
@@ -39,6 +46,18 @@ export class VitestReporter {
39
46
  * @param {unknown[] | undefined} errors
40
47
  */
41
48
  onTestRunEnd(testModules: Array<unknown> | undefined, errors: unknown[] | undefined): Promise<void>;
49
+ /**
50
+ * Vitest 4 callback fired when single test case is finished.
51
+ *
52
+ * @param {unknown} testCase
53
+ */
54
+ onTestCaseResult(testCase: unknown): Promise<void>;
55
+ /**
56
+ * Vitest 3 fallback callback with task updates.
57
+ *
58
+ * @param {unknown[] | undefined} packs
59
+ */
60
+ onTaskUpdate(packs: unknown[] | undefined): Promise<void>;
42
61
  #private;
43
62
  }
44
63
  import { Client as TestomatioClient } from '../client.js';
@@ -22,19 +22,34 @@ const debug = (0, debug_1.default)('@testomatio/reporter:adapter-jest');
22
22
  class VitestReporter {
23
23
  constructor(config = {}) {
24
24
  this.client = new client_js_1.Client({ apiKey: config?.apiKey });
25
- /**
26
- * @type {(TestData & {status: string})[]} tests
27
- */
25
+ /** @type {(TestData & {status: string, _reportKey?: string | null})[]} tests */
28
26
  this.tests = [];
29
27
  this._finalized = false;
30
28
  this._finalizing = false;
29
+ this._runStartedAtMs = null;
30
+ this._runStartedAtMicros = null;
31
+ this._reportedTestKeys = new Set();
32
+ this._liveQueue = Promise.resolve();
31
33
  }
32
34
  // on run start
33
35
  onInit() {
36
+ const now = Date.now();
34
37
  this._finalized = false;
35
38
  this._finalizing = false;
39
+ this._runStartedAtMs = now;
40
+ this._runStartedAtMicros = now * 1000;
41
+ this._reportedTestKeys = new Set();
42
+ this._liveQueue = Promise.resolve();
36
43
  this.client.createRun();
37
44
  }
45
+ /**
46
+ * Vitest 3/4 callback fired when test run starts.
47
+ */
48
+ onTestRunStart() {
49
+ const now = Date.now();
50
+ this._runStartedAtMs = now;
51
+ this._runStartedAtMicros = now * 1000;
52
+ }
38
53
  /**
39
54
  * @param {VitestTestFile[] | undefined} files // array with results;
40
55
  * @param {unknown[] | undefined} errors // errors does not contain errors from tests; probably its testrunner errors
@@ -68,12 +83,19 @@ class VitestReporter {
68
83
  debug(this.tests.length, 'tests collected');
69
84
  // send tests to Testomat.io
70
85
  for (const test of this.tests) {
86
+ if (test._reportKey && this._reportedTestKeys.has(test._reportKey))
87
+ continue;
88
+ if (test._reportKey)
89
+ this._reportedTestKeys.add(test._reportKey);
71
90
  await this.client.addTestRun(test.status, test);
72
91
  }
92
+ await this._liveQueue;
73
93
  console.log('finished');
74
94
  if (errors.length)
75
95
  console.error('Vitest adapter errors:', errors);
76
- 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 });
77
99
  this._finalized = true;
78
100
  }
79
101
  finally {
@@ -92,6 +114,28 @@ class VitestReporter {
92
114
  .filter(Boolean);
93
115
  await this.onFinished(files, errors);
94
116
  }
117
+ /**
118
+ * Vitest 4 callback fired when single test case is finished.
119
+ *
120
+ * @param {unknown} testCase
121
+ */
122
+ async onTestCaseResult(testCase) {
123
+ await this.#reportLive(testCase);
124
+ }
125
+ /**
126
+ * Vitest 3 fallback callback with task updates.
127
+ *
128
+ * @param {unknown[] | undefined} packs
129
+ */
130
+ async onTaskUpdate(packs) {
131
+ if (!Array.isArray(packs) || !packs.length)
132
+ return;
133
+ for (const pack of packs) {
134
+ const test = getTestFromTaskUpdatePack(pack);
135
+ if (test)
136
+ await this.#reportLive(test);
137
+ }
138
+ }
95
139
  /* non-used listeners
96
140
  onUserConsoleLog(log) {}
97
141
  onPathsCollected(paths) {} // paths array to files with tests
@@ -126,25 +170,50 @@ class VitestReporter {
126
170
  /**
127
171
  * Processes task and returns test data ready to be sent to Testomat.io
128
172
  *
129
- * @param {VitestTest} test
173
+ * @param {any} test
130
174
  *
131
- * @returns {TestData & {status: 'passed' | 'failed' | 'skipped'}}
175
+ * @returns {TestData & {status: 'passed' | 'failed' | 'skipped', _reportKey?: string | null}}
132
176
  */
133
177
  #getDataFromTest(test) {
178
+ const normalized = normalizeVitestTest(test);
179
+ const reportKey = getReportKey(test, normalized);
180
+ const startMicros = typeof normalized.startTime === 'number'
181
+ ? Math.floor(normalized.startTime * 1000)
182
+ : this._runStartedAtMicros || undefined;
134
183
  return {
135
- error: test.result?.errors ? test.result.errors[0] : undefined,
136
- file: test.file?.name || test.file?.filepath || '',
137
- logs: test.logs ? transformLogsToString(test.logs) : '',
138
- meta: test.meta,
184
+ _reportKey: reportKey,
185
+ error: normalized.error,
186
+ file: normalized.file,
187
+ logs: normalized.logs,
188
+ meta: normalized.meta,
139
189
  // @ts-ignore - STATUS values are string literals but type system sees them as string
140
- status: getTestStatus(test),
141
- suite_title: test.suite?.name || test.file?.name || test.file?.filepath,
142
- test_id: (0, utils_js_1.getTestomatIdFromTestTitle)(test.name),
143
- time: test.result?.duration || 0,
144
- title: test.name,
190
+ status: getTestStatus(normalized.state, normalized.mode),
191
+ suite_title: normalized.suiteTitle,
192
+ test_id: (0, utils_js_1.getTestomatIdFromTestTitle)(normalized.name),
193
+ time: normalized.duration,
194
+ timestamp: startMicros,
195
+ title: normalized.name,
145
196
  // testomatio functions (artifacts, logs, steps, meta) are not supported
146
197
  };
147
198
  }
199
+ /**
200
+ * @param {unknown} testCase
201
+ */
202
+ async #reportLive(testCase) {
203
+ if (this._finalized || this._finalizing)
204
+ return;
205
+ const normalized = normalizeVitestTest(testCase);
206
+ if (!isLiveReportableState(normalized.state, normalized.mode))
207
+ return;
208
+ const data = this.#getDataFromTest(testCase);
209
+ if (!data._reportKey || this._reportedTestKeys.has(data._reportKey))
210
+ return;
211
+ this._reportedTestKeys.add(data._reportKey);
212
+ this._liveQueue = this._liveQueue
213
+ .then(() => this.client.addTestRun(data.status, data))
214
+ .catch(() => undefined);
215
+ await this._liveQueue;
216
+ }
148
217
  }
149
218
  exports.VitestReporter = VitestReporter;
150
219
  /**
@@ -159,16 +228,15 @@ function getRunStatusFromResults(files) {
159
228
  */
160
229
  let status = 'finished'; // default status (if no failed or passed tests)
161
230
  files.forEach(file => {
162
- // search for failed tests
163
- file.tasks.forEach(taskOrSuite => {
164
- if (taskOrSuite.result?.state === 'fail') {
231
+ getTasks(file).forEach(taskOrSuite => {
232
+ if (isFailedState(taskOrSuite?.result?.state)) {
165
233
  status = 'failed'; // set status to failed if any test failed
166
234
  }
167
235
  });
168
236
  // if there are no failed tests > search for passed tests
169
237
  if (status !== 'failed') {
170
- file.tasks.forEach(taskOrSuite => {
171
- if (taskOrSuite.result?.state === 'pass') {
238
+ getTasks(file).forEach(taskOrSuite => {
239
+ if (isPassedState(taskOrSuite?.result?.state)) {
172
240
  status = 'passed'; // set status to passed if any test passed (and there are no failed tests)
173
241
  }
174
242
  });
@@ -179,17 +247,18 @@ function getRunStatusFromResults(files) {
179
247
  /**
180
248
  * Returns test status in Testomat.io format
181
249
  *
182
- * @param {VitestTest} test
250
+ * @param {string | undefined} state
251
+ * @param {string | undefined} mode
183
252
  * @returns 'passed' | 'failed' | 'skipped'
184
253
  */
185
- function getTestStatus(test) {
186
- if (test.result?.state === 'fail')
254
+ function getTestStatus(state, mode) {
255
+ if (isFailedState(state))
187
256
  return constants_js_1.STATUS.FAILED;
188
- if (test.result?.state === 'pass')
257
+ if (isPassedState(state))
189
258
  return constants_js_1.STATUS.PASSED;
190
- if (test.result?.state === 'skip' || (!test.result && test.mode === 'skip'))
259
+ if (isSkippedState(state) || (!state && mode === 'skip'))
191
260
  return constants_js_1.STATUS.SKIPPED;
192
- console.error(picocolors_1.default.red('Unprocessed case for defining test status. Contact dev team. Test:'), test);
261
+ console.error(picocolors_1.default.red('Unprocessed case for defining test status. Contact dev team. State:'), state);
193
262
  return constants_js_1.STATUS.SKIPPED;
194
263
  }
195
264
  /**
@@ -221,10 +290,165 @@ function getTasks(node) {
221
290
  return node.tasks;
222
291
  if (Array.isArray(node.children))
223
292
  return node.children;
293
+ if (node.children && typeof node.children[Symbol.iterator] === 'function')
294
+ return Array.from(node.children);
224
295
  if (node.task)
225
296
  return [node.task];
226
297
  return [];
227
298
  }
299
+ /**
300
+ * @param {string | undefined} state
301
+ * @returns {boolean}
302
+ */
303
+ function isFailedState(state) {
304
+ return state === 'fail' || state === 'failed';
305
+ }
306
+ /**
307
+ * @param {string | undefined} state
308
+ * @returns {boolean}
309
+ */
310
+ function isPassedState(state) {
311
+ return state === 'pass' || state === 'passed';
312
+ }
313
+ /**
314
+ * @param {string | undefined} state
315
+ * @returns {boolean}
316
+ */
317
+ function isSkippedState(state) {
318
+ return state === 'skip' || state === 'skipped' || state === 'todo';
319
+ }
320
+ /**
321
+ * Accept only completed test states for live upload to avoid reporting
322
+ * intermediate task updates as skipped.
323
+ *
324
+ * @param {string | undefined} state
325
+ * @param {string | undefined} mode
326
+ * @returns {boolean}
327
+ */
328
+ function isLiveReportableState(state, mode) {
329
+ if (isFailedState(state) || isPassedState(state) || isSkippedState(state))
330
+ return true;
331
+ if (!state && mode === 'skip')
332
+ return true;
333
+ return false;
334
+ }
335
+ /**
336
+ * @param {VitestTestFile[] | undefined} files
337
+ * @returns {number | null}
338
+ */
339
+ function getEarliestTestStartMs(files) {
340
+ let earliest = null;
341
+ const walk = node => {
342
+ if (!node)
343
+ return;
344
+ const startTime = node?.result?.startTime;
345
+ if (typeof startTime === 'number' && !Number.isNaN(startTime)) {
346
+ if (earliest == null || startTime < earliest)
347
+ earliest = startTime;
348
+ }
349
+ getTasks(node).forEach(walk);
350
+ };
351
+ (files || []).forEach(walk);
352
+ return earliest;
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 = test.module?.relativeModuleId ||
378
+ test.module?.moduleId ||
379
+ test.task?.file?.name ||
380
+ test.task?.file?.filepath ||
381
+ '';
382
+ const suiteTitle = (test.parent?.type === 'suite' ? test.parent?.name : null) ||
383
+ test.task?.suite?.name ||
384
+ test.task?.file?.name ||
385
+ file;
386
+ return {
387
+ name: test.name || test.task?.name || '',
388
+ state,
389
+ mode: test.options?.mode || test.task?.mode,
390
+ duration,
391
+ startTime,
392
+ error,
393
+ file,
394
+ suiteTitle,
395
+ logs: '',
396
+ meta: typeof test.meta === 'function' ? test.meta() : {},
397
+ };
398
+ }
399
+ return {
400
+ name: test?.name || '',
401
+ state: test?.result?.state,
402
+ mode: test?.mode,
403
+ duration: test?.result?.duration || 0,
404
+ startTime: test?.result?.startTime,
405
+ error: test?.result?.errors ? test.result.errors[0] : undefined,
406
+ file: test?.file?.name || test?.file?.filepath || '',
407
+ suiteTitle: test?.suite?.name || test?.file?.name || test?.file?.filepath || '',
408
+ logs: test?.logs ? transformLogsToString(test.logs) : '',
409
+ meta: test?.meta,
410
+ };
411
+ }
412
+ /**
413
+ * @param {any} test
414
+ * @param {{file: string, suiteTitle: string, name: string, startTime?: number}} normalized
415
+ * @returns {string | null}
416
+ */
417
+ function getReportKey(test, normalized) {
418
+ if (test?.id)
419
+ return String(test.id);
420
+ if (test?.task?.id)
421
+ return String(test.task.id);
422
+ if (!normalized?.name)
423
+ return null;
424
+ const loc = test?.location || test?.task?.location;
425
+ const locationKey = loc ? `${loc.line || ''}:${loc.column || ''}` : '';
426
+ const startKey = typeof normalized.startTime === 'number' && !Number.isNaN(normalized.startTime) ? String(normalized.startTime) : '';
427
+ return `${normalized.file}::${normalized.suiteTitle}::${normalized.name}::${locationKey}::${startKey}`;
428
+ }
429
+ /**
430
+ * Vitest can pass task updates as tuples. Try to extract a test-like object.
431
+ *
432
+ * @param {unknown} pack
433
+ * @returns {any | null}
434
+ */
435
+ function getTestFromTaskUpdatePack(pack) {
436
+ if (!pack)
437
+ return null;
438
+ if (Array.isArray(pack)) {
439
+ if (pack[2]?.type === 'test')
440
+ return pack[2];
441
+ if (pack[1]?.type === 'test')
442
+ return pack[1];
443
+ if (pack[0]?.type === 'test')
444
+ return pack[0];
445
+ return null;
446
+ }
447
+ const objectPack = /** @type {any} */ (pack);
448
+ if (typeof objectPack === 'object' && objectPack?.type === 'test')
449
+ return objectPack;
450
+ return null;
451
+ }
228
452
  module.exports = VitestReporter;
229
453
 
230
454
  module.exports.VitestReporter = VitestReporter;
package/lib/client.d.ts CHANGED
@@ -65,9 +65,10 @@ export class Client {
65
65
  *
66
66
  * Updates the status of the current test run and finishes the run.
67
67
  * @param {'passed' | 'failed' | 'skipped' | 'finished'} status - The status of the current test run.
68
+ * @param {Partial<import('../types/types.js').RunData>} [params] - Additional run params (e.g. duration).
68
69
  * Must be one of "passed", "failed", or "finished"
69
70
  * @returns {Promise<any>} - A Promise that resolves when finishes the run.
70
71
  */
71
- updateRunStatus(status: "passed" | "failed" | "skipped" | "finished"): Promise<any>;
72
+ updateRunStatus(status: "passed" | "failed" | "skipped" | "finished", params?: Partial<import("../types/types.js").RunData>): Promise<any>;
72
73
  }
73
74
  import { S3Uploader } from './uploader.js';
package/lib/client.js CHANGED
@@ -308,17 +308,18 @@ class Client {
308
308
  *
309
309
  * Updates the status of the current test run and finishes the run.
310
310
  * @param {'passed' | 'failed' | 'skipped' | 'finished'} status - The status of the current test run.
311
+ * @param {Partial<import('../types/types.js').RunData>} [params] - Additional run params (e.g. duration).
311
312
  * Must be one of "passed", "failed", or "finished"
312
313
  * @returns {Promise<any>} - A Promise that resolves when finishes the run.
313
314
  */
314
- async updateRunStatus(status) {
315
+ async updateRunStatus(status, params = {}) {
315
316
  this.pipes ||= await (0, index_js_1.pipesFactory)(this.paramsForPipesFactory || {}, this.pipeStore);
316
317
  this.runId ||= (0, utils_js_1.readLatestRunId)();
317
318
  debug('Updating run status...');
318
319
  // all pipes disabled, skipping
319
320
  if (!this.pipes?.filter(p => p.isEnabled).length)
320
321
  return Promise.resolve();
321
- const runParams = { status };
322
+ const runParams = { ...params, status };
322
323
  this.queue = this.queue
323
324
  .then(() => Promise.all(this.pipes.map(p => p.finishRun(runParams))))
324
325
  .then(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.7.2",
3
+ "version": "2.7.3-beta.1-vitest",
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 };
package/src/client.js CHANGED
@@ -360,10 +360,11 @@ class Client {
360
360
  *
361
361
  * Updates the status of the current test run and finishes the run.
362
362
  * @param {'passed' | 'failed' | 'skipped' | 'finished'} status - The status of the current test run.
363
+ * @param {Partial<import('../types/types.js').RunData>} [params] - Additional run params (e.g. duration).
363
364
  * Must be one of "passed", "failed", or "finished"
364
365
  * @returns {Promise<any>} - A Promise that resolves when finishes the run.
365
366
  */
366
- async updateRunStatus(status) {
367
+ async updateRunStatus(status, params = {}) {
367
368
  this.pipes ||= await pipesFactory(this.paramsForPipesFactory || {}, this.pipeStore);
368
369
  this.runId ||= readLatestRunId();
369
370
 
@@ -371,7 +372,7 @@ class Client {
371
372
  // all pipes disabled, skipping
372
373
  if (!this.pipes?.filter(p => p.isEnabled).length) return Promise.resolve();
373
374
 
374
- const runParams = { status };
375
+ const runParams = { ...params, status };
375
376
 
376
377
  this.queue = this.queue
377
378
  .then(() => Promise.all(this.pipes.map(p => p.finishRun(runParams))))