@testomatio/reporter 2.7.2-beta.1 → 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,18 +18,46 @@ 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
  })[];
26
+ _finalized: boolean;
27
+ _finalizing: boolean;
28
+ _runStartedAtMs: number;
29
+ _runStartedAtMicros: number;
30
+ _reportedTestKeys: Set<any>;
31
+ _liveQueue: Promise<void>;
27
32
  onInit(): void;
33
+ /**
34
+ * Vitest 3/4 callback fired when test run starts.
35
+ */
36
+ onTestRunStart(): void;
28
37
  /**
29
38
  * @param {VitestTestFile[] | undefined} files // array with results;
30
39
  * @param {unknown[] | undefined} errors // errors does not contain errors from tests; probably its testrunner errors
31
40
  */
32
41
  onFinished(files: VitestTestFile[] | undefined, errors: unknown[] | undefined): Promise<void>;
42
+ /**
43
+ * Vitest 4+ reporter API callback.
44
+ *
45
+ * @param {Array<unknown> | undefined} testModules
46
+ * @param {unknown[] | undefined} errors
47
+ */
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>;
33
61
  #private;
34
62
  }
35
63
  import { Client as TestomatioClient } from '../client.js';
@@ -22,47 +22,119 @@ 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 = [];
27
+ this._finalized = false;
28
+ this._finalizing = false;
29
+ this._runStartedAtMs = null;
30
+ this._runStartedAtMicros = null;
31
+ this._reportedTestKeys = new Set();
32
+ this._liveQueue = Promise.resolve();
29
33
  }
30
34
  // on run start
31
35
  onInit() {
36
+ const now = Date.now();
37
+ this._finalized = false;
38
+ this._finalizing = false;
39
+ this._runStartedAtMs = now;
40
+ this._runStartedAtMicros = now * 1000;
41
+ this._reportedTestKeys = new Set();
42
+ this._liveQueue = Promise.resolve();
32
43
  this.client.createRun();
33
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
+ }
34
53
  /**
35
54
  * @param {VitestTestFile[] | undefined} files // array with results;
36
55
  * @param {unknown[] | undefined} errors // errors does not contain errors from tests; probably its testrunner errors
37
56
  */
38
57
  async onFinished(files, errors) {
39
- if (!files || !files.length)
40
- console.info('No tests executed');
41
- files.forEach(file => {
42
- // task could be test or suite
43
- file.tasks.forEach(taskOrSuite => {
44
- if (taskOrSuite.type === 'test') {
45
- const test = taskOrSuite;
46
- this.tests.push(this.#getDataFromTest(test));
47
- }
48
- else if (taskOrSuite.type === 'suite') {
49
- const suite = taskOrSuite;
50
- this.#processTasksOfSuite(suite);
51
- }
52
- else {
53
- throw new Error('Unprocessed case. Unknown task type');
54
- }
58
+ if (this._finalized || this._finalizing)
59
+ return;
60
+ this._finalizing = true;
61
+ try {
62
+ this.tests = [];
63
+ if (!files || !files.length) {
64
+ console.info('No tests executed');
65
+ return;
66
+ }
67
+ files.forEach(file => {
68
+ // task could be test or suite
69
+ getTasks(file).forEach(taskOrSuite => {
70
+ if (taskOrSuite.type === 'test') {
71
+ const test = taskOrSuite;
72
+ this.tests.push(this.#getDataFromTest(test));
73
+ }
74
+ else if (taskOrSuite.type === 'suite') {
75
+ const suite = taskOrSuite;
76
+ this.#processTasksOfSuite(suite);
77
+ }
78
+ else {
79
+ throw new Error('Unprocessed case. Unknown task type');
80
+ }
81
+ });
55
82
  });
56
- });
57
- debug(this.tests.length, 'tests collected');
58
- // send tests to Testomat.io
59
- for (const test of this.tests) {
60
- await this.client.addTestRun(test.status, test);
83
+ debug(this.tests.length, 'tests collected');
84
+ // send tests to Testomat.io
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);
90
+ await this.client.addTestRun(test.status, test);
91
+ }
92
+ await this._liveQueue;
93
+ console.log('finished');
94
+ if (errors.length)
95
+ console.error('Vitest adapter errors:', errors);
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 });
99
+ this._finalized = true;
100
+ }
101
+ finally {
102
+ this._finalizing = false;
103
+ }
104
+ }
105
+ /**
106
+ * Vitest 4+ reporter API callback.
107
+ *
108
+ * @param {Array<unknown> | undefined} testModules
109
+ * @param {unknown[] | undefined} errors
110
+ */
111
+ async onTestRunEnd(testModules, errors) {
112
+ const files = (testModules || [])
113
+ .map(module => module && ( /** @type {any} */(module).task || module))
114
+ .filter(Boolean);
115
+ await this.onFinished(files, errors);
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);
61
137
  }
62
- console.log('finished');
63
- if (errors.length)
64
- console.error('Vitest adapter errors:', errors);
65
- await this.client.updateRunStatus(getRunStatusFromResults(files));
66
138
  }
67
139
  /* non-used listeners
68
140
  onUserConsoleLog(log) {}
@@ -81,7 +153,7 @@ class VitestReporter {
81
153
  * @param {VitestSuite} suite
82
154
  */
83
155
  #processTasksOfSuite(suite) {
84
- suite.tasks.forEach(taskOrSuite => {
156
+ getTasks(suite).forEach(taskOrSuite => {
85
157
  if (taskOrSuite.type === 'test') {
86
158
  const test = taskOrSuite;
87
159
  this.tests.push(this.#getDataFromTest(test));
@@ -98,25 +170,50 @@ class VitestReporter {
98
170
  /**
99
171
  * Processes task and returns test data ready to be sent to Testomat.io
100
172
  *
101
- * @param {VitestTest} test
173
+ * @param {any} test
102
174
  *
103
- * @returns {TestData & {status: 'passed' | 'failed' | 'skipped'}}
175
+ * @returns {TestData & {status: 'passed' | 'failed' | 'skipped', _reportKey?: string | null}}
104
176
  */
105
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;
106
183
  return {
107
- error: test.result?.errors ? test.result.errors[0] : undefined,
108
- file: test.file.name,
109
- logs: test.logs ? transformLogsToString(test.logs) : '',
110
- meta: test.meta,
184
+ _reportKey: reportKey,
185
+ error: normalized.error,
186
+ file: normalized.file,
187
+ logs: normalized.logs,
188
+ meta: normalized.meta,
111
189
  // @ts-ignore - STATUS values are string literals but type system sees them as string
112
- status: getTestStatus(test),
113
- suite_title: test.suite.name || test.file?.name,
114
- test_id: (0, utils_js_1.getTestomatIdFromTestTitle)(test.name),
115
- time: test.result?.duration || 0,
116
- 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,
117
196
  // testomatio functions (artifacts, logs, steps, meta) are not supported
118
197
  };
119
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
+ }
120
217
  }
121
218
  exports.VitestReporter = VitestReporter;
122
219
  /**
@@ -131,16 +228,15 @@ function getRunStatusFromResults(files) {
131
228
  */
132
229
  let status = 'finished'; // default status (if no failed or passed tests)
133
230
  files.forEach(file => {
134
- // search for failed tests
135
- file.tasks.forEach(taskOrSuite => {
136
- if (taskOrSuite.result?.state === 'fail') {
231
+ getTasks(file).forEach(taskOrSuite => {
232
+ if (isFailedState(taskOrSuite?.result?.state)) {
137
233
  status = 'failed'; // set status to failed if any test failed
138
234
  }
139
235
  });
140
236
  // if there are no failed tests > search for passed tests
141
237
  if (status !== 'failed') {
142
- file.tasks.forEach(taskOrSuite => {
143
- if (taskOrSuite.result?.state === 'pass') {
238
+ getTasks(file).forEach(taskOrSuite => {
239
+ if (isPassedState(taskOrSuite?.result?.state)) {
144
240
  status = 'passed'; // set status to passed if any test passed (and there are no failed tests)
145
241
  }
146
242
  });
@@ -151,17 +247,19 @@ function getRunStatusFromResults(files) {
151
247
  /**
152
248
  * Returns test status in Testomat.io format
153
249
  *
154
- * @param {VitestTest} test
250
+ * @param {string | undefined} state
251
+ * @param {string | undefined} mode
155
252
  * @returns 'passed' | 'failed' | 'skipped'
156
253
  */
157
- function getTestStatus(test) {
158
- if (test.result?.state === 'fail')
254
+ function getTestStatus(state, mode) {
255
+ if (isFailedState(state))
159
256
  return constants_js_1.STATUS.FAILED;
160
- if (test.result?.state === 'pass')
257
+ if (isPassedState(state))
161
258
  return constants_js_1.STATUS.PASSED;
162
- if (!test.result && test.mode === 'skip')
259
+ if (isSkippedState(state) || (!state && mode === 'skip'))
163
260
  return constants_js_1.STATUS.SKIPPED;
164
- 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);
262
+ return constants_js_1.STATUS.SKIPPED;
165
263
  }
166
264
  /**
167
265
  * @param {VitestTestLogs[]} logs
@@ -179,6 +277,178 @@ function transformLogsToString(logs) {
179
277
  });
180
278
  return logsStr;
181
279
  }
280
+ /**
281
+ * Supports both old and new Vitest task tree shapes.
282
+ *
283
+ * @param {any} node
284
+ * @returns {any[]}
285
+ */
286
+ function getTasks(node) {
287
+ if (!node)
288
+ return [];
289
+ if (Array.isArray(node.tasks))
290
+ return node.tasks;
291
+ if (Array.isArray(node.children))
292
+ return node.children;
293
+ if (node.children && typeof node.children[Symbol.iterator] === 'function')
294
+ return Array.from(node.children);
295
+ if (node.task)
296
+ return [node.task];
297
+ return [];
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
+ }
182
452
  module.exports = VitestReporter;
183
453
 
184
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-beta.1",
3
+ "version": "2.7.3-beta.1-vitest",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -19,50 +19,122 @@ 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 = [];
24
+ this._finalized = false;
25
+ this._finalizing = false;
26
+ this._runStartedAtMs = null;
27
+ this._runStartedAtMicros = null;
28
+ this._reportedTestKeys = new Set();
29
+ this._liveQueue = Promise.resolve();
26
30
  }
27
31
 
28
32
  // on run start
29
33
  onInit() {
34
+ const now = Date.now();
35
+ this._finalized = false;
36
+ this._finalizing = false;
37
+ this._runStartedAtMs = now;
38
+ this._runStartedAtMicros = now * 1000;
39
+ this._reportedTestKeys = new Set();
40
+ this._liveQueue = Promise.resolve();
30
41
  this.client.createRun();
31
42
  }
32
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
+
33
53
  /**
34
54
  * @param {VitestTestFile[] | undefined} files // array with results;
35
55
  * @param {unknown[] | undefined} errors // errors does not contain errors from tests; probably its testrunner errors
36
56
  */
37
57
  async onFinished(files, errors) {
38
- if (!files || !files.length) console.info('No tests executed');
39
-
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
- }
58
+ if (this._finalized || this._finalizing) return;
59
+ this._finalizing = true;
60
+
61
+ try {
62
+ this.tests = [];
63
+ if (!files || !files.length) {
64
+ console.info('No tests executed');
65
+ return;
66
+ }
67
+
68
+ files.forEach(file => {
69
+ // task could be test or suite
70
+ getTasks(file).forEach(taskOrSuite => {
71
+ if (taskOrSuite.type === 'test') {
72
+ const test = taskOrSuite;
73
+ this.tests.push(this.#getDataFromTest(test));
74
+ } else if (taskOrSuite.type === 'suite') {
75
+ const suite = taskOrSuite;
76
+ this.#processTasksOfSuite(suite);
77
+ } else {
78
+ throw new Error('Unprocessed case. Unknown task type');
79
+ }
80
+ });
52
81
  });
53
- });
54
82
 
55
- debug(this.tests.length, 'tests collected');
83
+ debug(this.tests.length, 'tests collected');
84
+
85
+ // send tests to Testomat.io
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);
89
+ await this.client.addTestRun(test.status, test);
90
+ }
91
+ await this._liveQueue;
56
92
 
57
- // send tests to Testomat.io
58
- for (const test of this.tests) {
59
- await this.client.addTestRun(test.status, test);
93
+ console.log('finished');
94
+ if (errors.length) console.error('Vitest adapter errors:', errors);
95
+
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 });
99
+ this._finalized = true;
100
+ } finally {
101
+ this._finalizing = false;
60
102
  }
103
+ }
61
104
 
62
- console.log('finished');
63
- if (errors.length) console.error('Vitest adapter errors:', errors);
105
+ /**
106
+ * Vitest 4+ reporter API callback.
107
+ *
108
+ * @param {Array<unknown> | undefined} testModules
109
+ * @param {unknown[] | undefined} errors
110
+ */
111
+ async onTestRunEnd(testModules, errors) {
112
+ const files = (testModules || [])
113
+ .map(module => module && (/** @type {any} */ (module).task || module))
114
+ .filter(Boolean);
115
+ await this.onFinished(files, errors);
116
+ }
64
117
 
65
- await this.client.updateRunStatus(getRunStatusFromResults(files));
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
+ }
66
138
  }
67
139
 
68
140
  /* non-used listeners
@@ -83,7 +155,7 @@ class VitestReporter {
83
155
  * @param {VitestSuite} suite
84
156
  */
85
157
  #processTasksOfSuite(suite) {
86
- suite.tasks.forEach(taskOrSuite => {
158
+ getTasks(suite).forEach(taskOrSuite => {
87
159
  if (taskOrSuite.type === 'test') {
88
160
  const test = taskOrSuite;
89
161
  this.tests.push(this.#getDataFromTest(test));
@@ -99,25 +171,52 @@ class VitestReporter {
99
171
  /**
100
172
  * Processes task and returns test data ready to be sent to Testomat.io
101
173
  *
102
- * @param {VitestTest} test
174
+ * @param {any} test
103
175
  *
104
- * @returns {TestData & {status: 'passed' | 'failed' | 'skipped'}}
176
+ * @returns {TestData & {status: 'passed' | 'failed' | 'skipped', _reportKey?: string | null}}
105
177
  */
106
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
+
107
186
  return {
108
- error: test.result?.errors ? test.result.errors[0] : undefined,
109
- file: test.file.name,
110
- logs: test.logs ? transformLogsToString(test.logs) : '',
111
- meta: test.meta,
187
+ _reportKey: reportKey,
188
+ error: normalized.error,
189
+ file: normalized.file,
190
+ logs: normalized.logs,
191
+ meta: normalized.meta,
112
192
  // @ts-ignore - STATUS values are string literals but type system sees them as string
113
- status: getTestStatus(test),
114
- suite_title: test.suite.name || test.file?.name,
115
- test_id: getTestomatIdFromTestTitle(test.name),
116
- time: test.result?.duration || 0,
117
- 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,
118
199
  // testomatio functions (artifacts, logs, steps, meta) are not supported
119
200
  };
120
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
+ }
121
220
  }
122
221
 
123
222
  /**
@@ -133,17 +232,16 @@ function getRunStatusFromResults(files) {
133
232
  let status = 'finished'; // default status (if no failed or passed tests)
134
233
 
135
234
  files.forEach(file => {
136
- // search for failed tests
137
- file.tasks.forEach(taskOrSuite => {
138
- if (taskOrSuite.result?.state === 'fail') {
235
+ getTasks(file).forEach(taskOrSuite => {
236
+ if (isFailedState(taskOrSuite?.result?.state)) {
139
237
  status = 'failed'; // set status to failed if any test failed
140
238
  }
141
239
  });
142
240
 
143
241
  // if there are no failed tests > search for passed tests
144
242
  if (status !== 'failed') {
145
- file.tasks.forEach(taskOrSuite => {
146
- if (taskOrSuite.result?.state === 'pass') {
243
+ getTasks(file).forEach(taskOrSuite => {
244
+ if (isPassedState(taskOrSuite?.result?.state)) {
147
245
  status = 'passed'; // set status to passed if any test passed (and there are no failed tests)
148
246
  }
149
247
  });
@@ -156,14 +254,16 @@ function getRunStatusFromResults(files) {
156
254
  /**
157
255
  * Returns test status in Testomat.io format
158
256
  *
159
- * @param {VitestTest} test
257
+ * @param {string | undefined} state
258
+ * @param {string | undefined} mode
160
259
  * @returns 'passed' | 'failed' | 'skipped'
161
260
  */
162
- function getTestStatus(test) {
163
- if (test.result?.state === 'fail') return STATUS.FAILED;
164
- if (test.result?.state === 'pass') return STATUS.PASSED;
165
- if (!test.result && test.mode === 'skip') return STATUS.SKIPPED;
166
- 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);
266
+ return STATUS.SKIPPED;
167
267
  }
168
268
 
169
269
  /**
@@ -180,5 +280,176 @@ function transformLogsToString(logs) {
180
280
  return logsStr;
181
281
  }
182
282
 
283
+ /**
284
+ * Supports both old and new Vitest task tree shapes.
285
+ *
286
+ * @param {any} node
287
+ * @returns {any[]}
288
+ */
289
+ function getTasks(node) {
290
+ if (!node) return [];
291
+ if (Array.isArray(node.tasks)) return node.tasks;
292
+ if (Array.isArray(node.children)) return node.children;
293
+ if (node.children && typeof node.children[Symbol.iterator] === 'function') return Array.from(node.children);
294
+ if (node.task) return [node.task];
295
+ return [];
296
+ }
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
+
183
454
  export default VitestReporter;
184
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))))