Skip to content

Migrating to the Vitest reporter

Whatever you come from, the reporter is one entry in reporters. Nothing goes in setupFiles or runner: the reporter adds its own setup file and runner to each Vitest project, in Node and in browser mode.

// vitest.config.mts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
reporters: ['default', ['@probara/vitest-reporter', { projectId: 'SHOP' }]],
},
});

The reporter sends what the Vitest integrations of Qase, Allure, ReportPortal and TestRail send, wherever Probara has a place for it: case links, the title, suite, fields, tags and steps of a new case, parameters, comments, nested steps, files, links, and the run’s name, plan, environment and configurations. It also sends every attempt of a retried or repeated test, which none of them does, and keeps one run for a whole watch session. What it does not port is under not supported, each with its reason.

You come fromJump to
Vitest’s JUnit output and probara import junitThe JUnit import
vitest-qase-reporterQase
allure-vitestAllure
@reportportal/agent-js-vitestReportPortal
Vitest’s JUnit output and trcli parse_junitTestRail

If your pipeline writes Vitest’s JUnit output and sends it with probara import junit, the reporter can replace both steps without unlinking a single case: it gives each test the automation key and the case the import gives it, on Vitest 3, 4 and 5. Then each test sends what the XML cannot hold: every attempt, steps, files, parameters and the probara.* calls. The reporter is the recommended path; keep the import for a job that only has the XML.

  1. Add the reporter to the Vitest config (get started). There is nothing else to set: Vitest’s JUnit output always names the test file, as the reporter’s default keys do (keyIncludesFile: true).
  2. Check the keys of both on the same tests: they must be equal.
  3. In CI, drop --reporter=junit and the import step, and give the test step PROBARA_API_TOKEN (in CI).
Vitest’s JUnit outputReporter setting
--reporter=junit, with or without addFileAttribute: trueNothing: keyIncludesFile: true is the default
Written with another ancestorSeparator (Vitest 5)Different keys: link the tests by case ids (linking)

Run vitest from the same root as before: the file of each key is relative to it, on both paths. keyIncludesFile: false has no JUnit counterpart: it gives the keys of cases made by hand or by another tool, without the file (without the file).

Run the suite once with Vitest’s JUnit output and once with the reporter’s results file, reporting off, and compare the dry runs of both: each prints one line per result, with its status, its case and its key. No token is needed. For this test file:

tests/refund.test.ts
import { describe, expect, test } from 'vitest';
describe('refund', () => {
test('SHOP-12 refunds an order', () => {
expect(25 - 25).toBe(0);
});
test('keeps the receipt', () => {
expect('receipt').toHaveLength(7);
});
test.todo('refunds a gift card');
});
$ npx vitest run --reporter=junit --outputFile=reports/vitest.xml
$ PROBARA_PROJECT=SHOP npx @probara/cli import junit reports/vitest.xml --dry-run
passed SHOP-12 tests/refund.test.ts > refund > refunds an order
passed - tests/refund.test.ts > refund > keeps the receipt
skipped - tests/refund.test.ts > refund > refunds a gift card
Total: 3 results from 1 file (2 passed, 0 failed, 1 skipped, 0 blocked)
$ PROBARA_ENABLED=false PROBARA_PROJECT=SHOP PROBARA_RESULTS_FILE=probara-results.json npx vitest run
[probara] Wrote 3 results to /work/shop/probara-results.json: send them with probara import results /work/shop/probara-results.json
$ npx @probara/cli import results probara-results.json --dry-run
passed SHOP-12 tests/refund.test.ts > refund > refunds an order
passed - tests/refund.test.ts > refund > keeps the receipt
skipped - tests/refund.test.ts > refund > refunds a gift card
Total: 3 results from 1 file (2 passed, 0 failed, 1 skipped, 0 blocked)

The lines are the same: the same keys, cases and statuses. --reporter=junit replaces the config’s reporters for that run, so the reporter sends nothing then. On a real suite, write each dry run to a file and diff them: a line whose key changed is a test that would get a new case (check what would be sent). Delete reports/ and the results file afterwards.

Before, two steps: Vitest writes the report, and the CLI sends it.

- name: Run the tests
run: npx vitest run --reporter=junit --outputFile=reports/vitest.xml
- name: Send the results
if: ${{ !cancelled() }}
run: npx @probara/cli import junit reports/vitest.xml
env:
PROBARA_API_TOKEN: ${{ secrets.PROBARA_API_TOKEN }}
PROBARA_PROJECT: SHOP

After, one step: the reporter, registered in the Vitest config, sends every result when the run ends.

- name: Run the tests
run: npx vitest run
env:
PROBARA_API_TOKEN: ${{ secrets.PROBARA_API_TOKEN }}
PROBARA_PROJECT: SHOP

Remove --reporter=junit from the command: it replaces the reporters of the config, the reporter’s included. When another tool still reads the XML (a test summary of the CI), list junit beside the reporter instead:

reporters: ['default', 'junit', '@probara/vitest-reporter'],
outputFile: { junit: 'reports/vitest.xml' },

The run is named, sourced and closed as the import did it (the run).

The keys stay; what each test sends grows, and a few results become more exact:

Topicprobara import junit on Vitest’s JUnit outputThe reporter
Retries (retry)One result per test: a pass on a retry is a plain pass; a test that fails every attempt keeps its first failureEvery attempt, in order, each with its own errors; the run case keeps the last outcome (retries)
Repeats (repeats)One result per testEvery execution (repeats)
test.todoskippedskipped, with the note Todo (statuses)
context.skip('why')skipped: Vitest writes no reasonskipped, with the note Skipped: why
expect.soft failuresThe first failure’s messageEvery failure of the attempt, in the notes
Console output--attach-output: the test’s <system-out> and <system-err>captureOutput: stdout.log and stderr.log for each attempt (console output)
Vitest projectsNo project: Vitest’s JUnit output does not name itA project parameter on each result, when the config has more than one (Vitest projects)
Unhandled errors (Vitest 5)Left out, with one warning per file; --fail-on-failed-tests exits 3 for themLeft out, with one warning; the exit code stays Vitest’s (errors outside a test)
When the results goAfter the whole run, in a later stepAt the end of the run, from the test step; a results file keeps what could not be sent

The same on both paths, so no case moves:

  • The statuses of passed, failed and skipped tests, test.fails included, and the tests .only or -t leave out, which are skipped on both.
  • The failed results of a beforeAll or afterAll that throws, and of a file that fails to load, named as Vitest’s JUnit writer names them (errors outside a test).
  • Case ids in test and describe titles, the names of test.each and test.for rows, and a > with a space on each side inside a title (names as Vitest writes them).

The XML holds each test’s name, outcome, time and output. What the tests tell the reporter, and what Vitest gives it beyond its JUnit output, never reaches the import:

  • Every attempt of a retried or repeated test, each with its status, duration and errors.
  • Case ids from probara.id(), which live in the test’s own metadata, not in its title (linking).
  • Steps, nested, with their status and duration, and the steps of a new case (steps).
  • Files: probara.attach(), the files of Vitest’s own context.annotate(), failure screenshots in browser mode (attachments).
  • Parameters, comments, links and the fields of a new case: the probara.* helpers (metadata and links).
  • Run selection: run only the tests of a Probara run (run selection).

Keep the import where the reporter cannot run: a job that only receives the XML of another job, or a Vitest older than 3.2, which the reporter does not support (the import is tested with Vitest 3.2, 4.1 and 5.0). The import’s side is in the CLI and in Vitest JUnit.

probara import junit reads Vitest’s output with its vitest dialect, detected from the report’s root (vitest tests), with no option. Versions of the CLI before that dialect (0.3.0 and earlier) read the same XML as generic JUnit, which gives every test the same key, with two differences:

ResultGeneric JUnit (the CLI 0.3.0 and earlier)The reporter, and the vitest dialect
A failure named after its file, such as a file that fails to loadKeyed by the file alone: tests/broken.test.tsThe file twice: tests/broken.test.ts > tests/broken.test.ts
An unhandled error (Vitest 5)A failed result of its ownLeft out, with a warning

Only those results move: the next failure of such a file creates a case under its new key, and the old case gets no more results. Every other test keeps its case.

Moving from vitest-qase-reporter is mostly renaming: the reporter entry, its settings, and the ctx.qase calls, which become probara.* calls without a withQase() wrapper. This section maps each one as Qase’s code (version 1.5.1) has it, which differs from Qase’s documentation in places (documented, but not in the code), then lists what works differently and what is not ported, with the reasons.

  1. npm uninstall vitest-qase-reporter && npm i -D @probara/vitest-reporter.
  2. In vitest.config.mts, replace ['vitest-qase-reporter', {...}] with ['@probara/vitest-reporter', {...}], translating the options (below). Delete qase.config.json and .qaserc: the reporter reads only its options and PROBARA_* variables. Take vitest-qase-reporter/setup out of setupFiles if you added it: the reporter adds its own setup file.
  3. Replace QASE_* variables with PROBARA_* ones in CI: at least PROBARA_API_TOKEN (an app token from the Vitest card, get started) and PROBARA_PROJECT.
  4. Import probara from @probara/vitest-reporter, turn each withQase(async ({ qase }) => {...}) back into a plain test function, and translate the calls (below). Case ids become Probara display ids (SHOP-12), not numbers.

Before:

// vitest.config.mts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
reporters: [
'default',
[
'vitest-qase-reporter',
{
mode: 'testops',
testops: {
project: 'SHOP',
run: { title: 'Nightly', complete: true, tags: ['nightly'] },
uploadAttachments: true,
},
},
],
],
},
});

After:

// vitest.config.mts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
reporters: [
'default',
[
'@probara/vitest-reporter',
{ projectId: 'SHOP', run: { name: 'Nightly', tags: ['nightly'] }, captureOutput: true },
],
],
},
});

captureOutput is optional: it attaches each attempt’s console output, which Qase’s captureLogs never did for Vitest (console output). The run is closed at the end without complete: true, and uploadAttachments is on by default.

Qase option (QASE_* variable)Probara option (PROBARA_* variable)
mode: 'testops' (QASE_MODE)On whenever a token and a project are set
mode: 'off'enabled: false (PROBARA_ENABLED=false), or no token
mode: 'report', fallback (QASE_FALLBACK)resultsFile (PROBARA_RESULTS_FILE) (results file)
report.connections.local.path, .format (QASE_REPORT_CONNECTION_PATH, _FORMAT)resultsFile, the path of a JSON file
mode: 'testops_multi', testops_multi.*projectId plus projects and run.ulids (several projects)
debug (QASE_DEBUG)debug (PROBARA_DEBUG)
environment (QASE_ENVIRONMENT), a slug, ignored when not foundrun.environment (PROBARA_ENVIRONMENT), a name, created when missing
rootSuite (QASE_ROOT_SUITE)suiteUlid (PROBARA_SUITE_ULID): an existing suite, by ULID
captureLogs (QASE_CAPTURE_LOGS): ignored by Qase’s Vitest reportercaptureOutput (PROBARA_CAPTURE_OUTPUT)
statusMapping (QASE_STATUS_MAPPING)statusMapping (PROBARA_STATUS_MAPPING), without invalid and disabled
logging.console, logging.file (QASE_LOGGING_CONSOLE, QASE_LOGGING_FILE)Logs go to stderr; the logger option for code
testops.api.token (QASE_TESTOPS_API_TOKEN)PROBARA_API_TOKEN (apiToken for code)
testops.api.host (QASE_TESTOPS_API_HOST)baseUrl (PROBARA_BASE_URL), a full URL
testops.api.timeout (QASE_TESTOPS_API_TIMEOUT), in secondstimeoutMs, in milliseconds, for every request
testops.api.retries, testops.api.retryBackoffmaxRetries, for every request; the backoff is fixed (retries and backoff)
testops.project (QASE_TESTOPS_PROJECT)projectId (PROBARA_PROJECT)
testops.uploadAttachments (QASE_TESTOPS_UPLOAD_ATTACHMENTS)uploadAttachments (PROBARA_UPLOAD_ATTACHMENTS)
testops.attachments.concurrency, .timeoutattachmentConcurrency; uploads get at least 120 s (timeouts)
testops.run.id (QASE_TESTOPS_RUN_ID)run.ulid (PROBARA_RUN_ULID)
testops.run.title, .description, .tagsrun.name, run.description, run.tags (PROBARA_RUN_*)
testops.run.complete (QASE_TESTOPS_RUN_COMPLETE)closeRun (PROBARA_CLOSE_RUN); a created run is closed by default
testops.plan.id (QASE_TESTOPS_PLAN_ID)run.plan (PROBARA_PLAN): the plan’s display id or name
testops.configurations.values (group=value)run.configurations (PROBARA_CONFIGURATIONS=Group=Name)
testops.statusFilter (QASE_TESTOPS_STATUS_FILTER)statusFilter (PROBARA_STATUS_FILTER)
testops.batch.size (QASE_TESTOPS_BATCH_SIZE)chunkSize (1 to 500)
(none)run.milestone (PROBARA_MILESTONE): Qase’s reporter has no milestone

Qase settings without a counterpart are in not ported. Every Probara option is on the configuration page.

QaseProbara
test(addQaseId('pays', [12]), ...), (Qase ID: 12) in a titletest('SHOP-12 pays', ...): an id in the title or a describe (linking)
addQaseId('pays', [12, 13])'SHOP-12 SHOP-13 pays', or probara.id(['SHOP-12', 'SHOP-13'])
addQaseProjects('pays', { WEB: [3] }), (Qase WEB: 3) in a titleWEB-3 in the title, with projects: ['WEB'] (several projects)
withQase(async ({ qase }) => {...})The test function itself: probara finds the running test on its own
await qase.title('...')probara.title('...')
await qase.suite('Payments - Cards')probara.suite(['Payments', 'Cards'])
await qase.fields({ severity: 'critical' })probara.fields({ severity: 'critical' })
await qase.tags('smoke', 'checkout')probara.tags('smoke', 'checkout')
await qase.comment('...')probara.comment('...')
await qase.parameters({...}), await qase.groupParameters({...})probara.parameters({...})
await qase.step('Pay', fn, 'Paid', 'card=visa')await probara.step('Pay', fn, { expected: 'Paid', data: 'card=visa' })
await qase.attach({ paths: [a, b] })await probara.attach({ name, path }), once per file
await qase.attach({ name, content, type })await probara.attach({ name, body, contentType })
await qase.annotate(message, options)await context.annotate(message, options): Vitest’s own; its file is sent (Vitest annotations)
(none: Qase’s Vitest reporter has no ignore)probara.ignore()
(none)probara.link() and probara.issue() (links and issues)

Remove the (Qase ID: 12) markers from the titles: the reporter reads them as text, so they would stay in the automation key.

Before:

tests/checkout.test.ts
import { test } from 'vitest';
import { addQaseId, withQase } from 'vitest-qase-reporter/vitest';
test(
addQaseId('pays with a card', [12]),
withQase(async ({ qase }) => {
await qase.fields({ severity: 'critical' });
await qase.tags('smoke');
await qase.step('Pay', async () => {}, 'The order is paid');
await qase.attach({ name: 'order.json', content: '{"id":1042}', type: 'application/json' });
}),
);

After:

tests/checkout.test.ts
import { probara } from '@probara/vitest-reporter';
import { test } from 'vitest';
test('SHOP-12 pays with a card', async () => {
probara.fields({ severity: 'critical' }).tags('smoke');
await probara.step('Pay', async () => {}, { expected: 'The order is paid' });
await probara.attach({
name: 'order.json',
body: '{"id":1042}',
contentType: 'application/json',
});
});
[
{
"caseDisplayId": "SHOP-12",
"automationKey": "tests/checkout.test.ts > pays with a card",
"status": "passed",
"steps": [{ "action": "Pay", "expected": "The order is paid", "status": "passed" }],
"case": {
"tags": ["smoke"],
"fields": { "severity": "critical" },
"steps": [{ "action": "Pay", "expected": "The order is paid" }]
}
}
]

The result also carries order.json (application/json). The id leaves the title and the key: adding or removing it never changes which case the test matches (automation keys).

Qase’s README and guides show calls and settings that version 1.5.1 does not have, or has with another behavior. Code copied from them fails or does something else:

Qase’s documentationQase’s code (1.5.1)With Probara
test(qase(12, 'pays'), ...), with qase imported from vitest-qase-reporter or vitest-qase-reporter/vitestNo qase is exported: only withQase, addQaseId and addQaseProjectstest('SHOP-12 pays', ...)
qase.title(), qase.fields(), qase.suite() on that import, in any testOnly ctx.qase, in a function withQase() wrapsprobara.title(), probara.fields(), probara.suite(), in any test
qase.ignore()Not in the codeprobara.ignore()
qase.mute()Not in the codeNot ported (below)
setupFiles: ['vitest-qase-reporter/setup'], for the network profilerNot exported by the package: Vitest cannot load itNothing to add: the reporter adds its own setup file
qase.suite('Payments\tCards'), a tab between suitesSplit only on a dash with a space on each side ('Payments - Cards')probara.suite(['Payments', 'Cards'])
Steps nested in stepsA step that holds steps is lost; only the inner ones are keptNested as they ran (steps)
An attachment of a stepAlways the test’sprobara.attach() inside a step (files of a step)
The reporter’s options win over QASE_* variables, which win over qase.config.jsonVariables win over the file, which wins over the optionsOptions, then variables (precedence)
testops.run.complete is true by defaultUnset leaves the run openA created run is closed by default (closing)
report.connection.*report.connections.*resultsFile
Watch mode reports when you exit or run every test againEach re-run creates a new run, unless run.id is setOne run for the whole session (watch mode)
api.token per project of testops_multiOne token for every projectOne app token for every project of the organization
TopicQaseProbara
PrecedenceVariables win over qase.config.json, which wins over the reporter optionsOptions in the Vitest config, then PROBARA_* variables, then defaults: what the config says is what runs
Config fileqase.config.json or .qasercNone: a config file holding a token could be committed. Options live in the Vitest config
Case idsNumbers, in the test’s own title onlyDisplay ids (SHOP-12) in the title or any describe: the Project ID routes each id to its project
The helpersctx.qase, inside withQase(), until the test body ends: Vitest refuses its annotations in afterEachprobara, imported once, works in the body, beforeEach, afterEach, onTestFinished and the functions they call (where to call them)
StepsFlat; the expected result and data are encoded into the step name; no durationNested as they ran, each with its status, duration and error; the outermost become a new case’s steps (steps)
Statusesinvalid for a failure whose error does not look like an assertion (a guess from the message)passed, failed, skipped; blocked only through statusMapping. No guessing from error text (statuses)
ErrorsThe first error’s message, which comment() replaces; every stack in a field of its ownEvery error of the attempt in the notes, expect.soft failures included; a comment comes first and hides none
fields() and parameters() callsThe last call replaces the othersMerged by name
Case fieldsSent with every resultOnly when the report creates the case: an existing case keeps what people wrote (created cases only)
RetriesThe final attempt onlyEvery attempt is a result, with no flaky flag: the case’s history shows the flakiness (retries)
Closing the runOnly with run.complete: true; unset leaves it openA run the reporter creates is closed at the end, once everything was sent; a reused one stays open (closing)
ShardsEach shard makes its own run unless it gets a run id; closing a shared run is left to another stepOne run: vitest --merge-reports over the shards’ blobs, or probara run create with PROBARA_RUN_ULID (sharding)
When reporting failsfallback to a local reportA results file, sent later by probara import results into the same runs (results file)
Strings in attach()A string that looks like base64 is decoded ('Hello' uploads garbage)A string is sent as it is; pass a Buffer or a Uint8Array for binary content (probara.attach())
attach({ paths })A list of paths; the content type is type, not contentTypeOne file per call, path or body, and contentType
Qase featureWhy
Public report link (showPublicReportLink)A public link to test results is a sharing decision for the Probara app, not something a CI job should turn on
Automatic defects (testops.defect)Filing a defect for every failure floods the defect list; report defects from the result in Probara
Network profiler (profilers: ['network'], networkProfiler)Its steps are noise, up to the 200 steps of a result, and cannot be told apart when tests run in parallel; attach what matters with probara.attach() (no network profiler)
Jira link on runs (run.externalLink)Probara links Jira issues to cases and defects, where traceability lives; probara.issue() links a result to an issue
qase.mute()Documented by Qase, but not in its code; triage lives in Probara, on the results
Creating configurations (createIfNotExists)Configurations are part of a project’s planning; an unknown one is refused, so a typo never creates a new one
The invalid statusIt is a guess from the error text; Probara keeps four statuses and the whole error in the notes
Logging to a file (logging.file)Logs go to stderr, where CI keeps them; redirect stderr to keep a file
Per-project plan and environment (testops_multi.projects[].plan, .environment)Planning entities belong to one project, where an unknown name would refuse the report: create that project’s run first with probara run create and pass it in run.ulids (one run per project)
A config file (qase.config.json, .qaserc)A file holding a token could be committed: the token stays in the environment, the options in the Vitest config

allure-vitest is a reporter that adds its setup file and its runner to each Vitest project, and writes result files into allure-results/; a separate step turns them into a report. The reporter sends the results to Probara instead, and adds its own setup file and runner the same way: swap the entries.

// vitest.config.mts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
reporters: [
'default',
[
'allure-vitest/reporter',
{
resultsDir: 'allure-results',
links: { issue: { urlTemplate: 'https://jira.example.com/browse/%s' } },
},
],
],
},
});
AllureProbara
resultsDirNothing: results go to Probara. A results file keeps what could not be sent
links.issue.urlTemplateissueUrlTemplate (PROBARA_ISSUE_URL_TEMPLATE) (probara.issue())
environmentInforun.environment (a name) and the run source, detected from the CI (the run)
globalLabels, ALLURE_LABEL_*run.tags (PROBARA_RUN_TAGS) for the run; the tags of a new case come from probara.tags()
ALLURE_TESTPLAN_PATHrunCasesOnly with PROBARA_RUN_ULID (run selection)
reportMatchers, categories, listenersNot ported (not supported)

Allure’s calls are async (await allure.tags('smoke')); Probara’s return at once, except probara.attach() and a probara.step() with an async body, which you await.

Allure (allure-js-commons)Probara (probara.*)
@allure.id:12 in a title, allureId('12')SHOP-12 in the title or a describe, or id('SHOP-12')
displayName('...')title('...')
description('...')fields({ description: '...' })
severity('critical'), layer('api')fields({ severity: 'critical', layer: 'api' })
tag('smoke'), tags(...)tags('smoke')
epic, feature, story, label(name, value)fields({ Feature: 'Checkout' }): a custom field of that name, or tags(...)
parentSuite, suite, subSuitesuite(['Payments', 'Cards'])
parameter('user', 'ana')parameters({ user: 'ana' })
step('Pay', async () => {...})step('Pay', async () => {...})
logStep('Paid')step('Paid'): a step without a body, passed
attachment(name, content, { contentType })attach({ name, body: content, contentType })
attachmentPath(name, path)attach({ name, path })
link(url, name)link(url, name)
issue('PAY-7')issue('PAY-7'), with issueUrlTemplate
tms('12')id('SHOP-12')
reporters: [
'default',
['@probara/vitest-reporter', { issueUrlTemplate: 'https://jira.example.com/browse/%s' }],
],
tests/payments.test.ts
import { probara } from '@probara/vitest-reporter';
import { test } from 'vitest';
test('refunds an order', async () => {
probara.fields({ severity: 'critical' }).issue('PAY-7');
await probara.step('Open the order', async () => {});
await probara.step('Refund it', async () => {});
});
[
{
"automationKey": "tests/payments.test.ts > refunds an order",
"steps": [
{ "action": "Open the order", "status": "passed" },
{ "action": "Refund it", "status": "passed" }
],
"links": [{ "url": "https://jira.example.com/browse/PAY-7", "name": "PAY-7" }],
"case": {
"fields": { "severity": "critical" },
"steps": [{ "action": "Open the order" }, { "action": "Refund it" }]
}
}
]
TopicAllureProbara
Statusesfailed for an assertion, broken for any other errorfailed for both, with the error in the notes: no guess from the error’s type (statuses)
test.skip, a skipped describeNot reportedSkipped results, as Vitest’s JUnit output has them
AssertionsEach expect is a step (reportMatchers, on by default)The steps are the probara.step() calls
A call where no test runsA call in beforeAll or a describe body goes to every test of the suiteLeft out, with one warning: no single test runs there (where to call the helpers)
A hook that throwsIts error is a global error of the report, beside the testsVitest’s verdict: a failing beforeEach or afterEach fails its test, a failing beforeAll or afterAll is a failed result for its describe or file (errors outside a test)
RetriesOne result per test, the final attemptEvery attempt is a result of the run (retries)
Watch modeNew result files on every re-run, which pile up in resultsDirOne run for the whole session (watch mode)
Case fieldsLabels on every resultFields and tags of the case a report creates; an existing case keeps its own (created cases only)
test.concurrent in browser modeA call can go to the wrong test after an await (its README says so)A call is dropped with a warning, never recorded in another test (browser mode)

@reportportal/agent-js-vitest is a reporter object in reporters, with a setup file you list in setupFiles for its ReportingApi. Replace both with the reporter entry above:

// vitest.config.mts
import RPReporter from '@reportportal/agent-js-vitest';
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
setupFiles: ['@reportportal/agent-js-vitest/setup'],
reporters: [
'default',
new RPReporter({
apiKey: process.env.RP_API_KEY,
endpoint: 'https://rp.example.com/api/v2',
project: 'shop',
launch: 'Nightly',
}),
],
},
});
ReportPortal (variable)Probara (variable)
apiKey, oauthPROBARA_API_TOKEN: an app token from the Vitest card (get started)
endpointbaseUrl (PROBARA_BASE_URL), the app’s URL without a path
projectprojectId (PROBARA_PROJECT)
launchrun.name (PROBARA_RUN_NAME)
descriptionrun.description (PROBARA_RUN_DESCRIPTION)
attributes ([{ key: 'build', value: '42' }])run.tags (PROBARA_RUN_TAGS=build:42,nightly): tags are plain names, a colon included
launchId (RP_LAUNCH_ID)run.ulid (PROBARA_RUN_ULID)
launchUuidPrintprobara run create prints the ULID every shard reports into (an existing run)
restClientConfig (timeout, retries, proxy)timeoutMs, maxRetries; a proxy through Node’s variables (network)
headersA fetch of your own (your own HTTP client)
debugdebug (PROBARA_DEBUG)
extendTestDescriptionWithLastErrorNothing to set: every error of an attempt is in its notes
skippedIssue, rerun, rerunOf, mode: 'DEBUG'Not ported (not supported)
ReportPortal (ReportingApi)Probara (probara.*)
ReportingApi.attachment(task, { name, type, content })attach({ name, body, contentType }), with a Buffer or text, or attach({ name, path })
ReportingApi.description(task, text)fields({ description: text }), for a new case
ReportingApi.attributes(task, [{ key, value }])parameters({ [key]: value }) on the result, or tags(value) on a new case
ReportingApi.testCaseId(task, id)Not ported: link a case by its id instead (linking)
ReportingApi.log(task, message, level)Not ported: comment() for a line in the notes, captureOutput for the console
console.log(), console.error(), as logscaptureOutput: stdout.log and stderr.log of each attempt (console output)
The global ReportingApi of the setup fileprobara, imported: there is no task to pass

A base64 string as content would be sent as text: give attach() the file’s path, or its bytes as a Buffer.

tests/checkout.test.ts
import { probara } from '@probara/vitest-reporter';
import { test } from 'vitest';
test('pays with a card', async () => {
probara.fields({ description: 'Pays with a saved card' }).parameters({ browser: 'chromium' });
await probara.attach({
name: 'response',
body: '{"status":"paid"}',
contentType: 'application/json',
});
});
[
{
"automationKey": "tests/checkout.test.ts > pays with a card",
"parameters": { "browser": "chromium" },
"case": { "description": "Pays with a saved card" }
}
]

The result also carries response.json (application/json).

TopicReportPortalProbara
In-test APIReportingApi, global from its setup file, or with the test’s task passed inprobara, imported; each call belongs to the test that made it (metadata)
test.concurrentThe global ReportingApi follows the last test that started, so a call can go to another testEach call goes to its own test
RetriesThe final attempt onlyEvery attempt is a result (retries)
test.todoSkipped, with the attribute todoSkipped, with the note Todo
A skipped describeOne skipped suite item; its tests are not reportedEach test is a skipped result
ErrorsThe first error, as a log and in the descriptionEvery error of the attempt, in the notes of its result
StructureA suite item per describeEach new case in a suite named after its file (missing cases)
Watch modeThe launch ends with the first run, and the re-runs have noneOne run for the whole session (watch mode)

TestRail has no Vitest reporter: a pipeline writes Vitest’s JUnit output, then TestRail’s Python CLI trcli parse_junit sends it after the run. The reporter replaces both steps, and sends what the XML cannot hold: steps, files, comments and every attempt.

Terminal window
npx vitest run --reporter=junit --outputFile=reports/vitest.xml
trcli -h https://example.testrail.io --project "Shop" -u ci@example.com -k "$TESTRAIL_KEY" \
parse_junit --title "Nightly" --case-matcher name -f reports/vitest.xml --close-run

With the reporter, the test step is enough:

Terminal window
npx vitest run
trcliProbara
-h (host), -u, -p, -kbaseUrl for a self-hosted Probara; PROBARA_API_TOKEN
--project (a name)projectId (PROBARA_PROJECT), the Project ID
--title, --run-descriptionrun.name, run.description
--run-idrun.ulid (PROBARA_RUN_ULID)
--plan-id, --config-ids, --milestone-idrun.plan, run.configurations, run.milestone, by display id or name (the run)
--close-runThe default for a run the reporter creates; closeRun for a reused one
--case-matcher name (C123 in the name)SHOP-12 in the title or a describe (linking)
--case-matcher auto (classname.name)The automation key: the file, the describes and the title
-y (create sections and cases)createMissingCases, on by default; suites from the test file
--suite-id, --section-idsuiteUlid (PROBARA_SUITE_ULID): the suite created cases go under
--case-fields type_id:3probara.fields({ type: 'Regression' }) in the test, by name (metadata)
-a, --assign (emails)assignFailedTo (PROBARA_ASSIGN_FAILED_TO) (assign failed results)
-b, --batch-sizechunkSize (1 to 500)
-t, --timeout (seconds)timeoutMs (milliseconds, for each attempt of a request)
--proxy, --noproxyHTTPS_PROXY and NO_PROXY with NODE_USE_ENV_PROXY=1 (proxies)
-v (verbose)debug (PROBARA_DEBUG)
testrail_result_comment propertyprobara.comment()
testrail_attachment propertyprobara.attach()
testrail_result_step propertyprobara.step()
Topictrcli on Vitest’s JUnit outputProbara
Skipped testsRetestSkipped
test.todoRetest: Vitest writes it as skippedSkipped, with the note Todo
RetriesOne result per testEvery attempt (retries)
Nested describesOne section per test file, flat: Vitest writes a suite per fileThe suite of a created case is its file; probara.suite() names nested suites
DurationsRounded to seconds (--allow-ms for milliseconds)Milliseconds, and the start of each attempt
An unknown case idAborts the uploadThat result is not recorded, and the log names it; the others are
A failed uploadRolls back the run, cases and sections it createdNothing is rolled back; a results file keeps what was not sent
ShardsOne upload of every shard’s file, with a globvitest --merge-reports over the blobs, or probara run create, PROBARA_RUN_ULID in every shard, probara run close (sharding)

Each of these is left out, with the reason:

FeatureInWhy
Updating existing casestrcli (--update-existing-cases, --update-strategy)A report never changes an existing case: people own what is written there (created cases only)
A flaky flagVitest (diagnostic().flaky)Every attempt is a result: a case whose history mixes failures and passes on one run is flaky, without a flag (retries)
Assertions as stepsAllure (reportMatchers)Each expect would be a step, up to the 200 steps of a result, crowding out the steps that say what the test does: wrap what matters in probara.step()
The broken and invalid statusesAllure (broken), Qase (invalid)Both are a guess from the error’s type or text; Probara keeps four statuses (passed, failed, skipped, blocked) and the whole error in the notes
Masked, hidden and excluded parametersAllure (the modes of parameter())Parameters are shown with the result and never part of the key: leave a secret out rather than masking it
Log levels and a log APIReportPortal (ReportingApi.log()), Allure (logStep() with a status)Probara has no log entity: captureOutput keeps each attempt’s console output, probara.comment() writes a line in the notes, and a step’s status is what its body did
Defects, Jira links on runs, public report linksQase (testops.defect, run.externalLink, showPublicReportLink)Defects and their triage live in Probara, on the results; probara.issue() links a result to an issue; a public link to results is a sharing decision, not a CI setting
A network profilerQase (profilers: ['network'])Its steps are noise, up to the 200 steps of a result, and cannot be told apart when tests run in parallel (no network profiler)
Categories, known issues, muted testsAllure (categories), ReportPortal (skippedIssue), Qase (qase.mute(), documented only)Triage lives in Probara, on the results and defects, not in a CI setting
Overriding the automation keyAllure (historyId(), testCaseId()), ReportPortal (testCaseId()), trcli (custom_automation_id)The key comes from the file and the names, the same as probara import junit’s: link a case by its id instead (automation keys)
An ownerAllure (owner())A Probara case has no owner field: assign failed results instead (assign failed results)
Result fieldstrcli (--result-fields, testrail_result_field)Probara results have no custom fields: parameters (probara.parameters()) and links (probara.link()) carry the extra data
Steps and calls from suite hooksAllure (a call in beforeAll goes to every test of the suite)A call where no single test runs belongs to no test: it is left out with a warning, not copied into each one. Make the steps that matter in the test or its beforeEach; a hook that throws still shows (errors outside a test)
A config fileQase (qase.config.json, .qaserc), trcli (config.yml)A file holding a token could be committed: the token stays in the environment, the options in the Vitest config (configuration)
Benchmarks and coverageVitest (vitest bench, --coverage)Probara has no benchmark or coverage results: the reporter sends test results
Type testsVitest (vitest typecheck)No test of the reporter covers them, so nothing is promised for them
Rerun and debug launchesReportPortal (rerun, rerunOf, mode: 'DEBUG')Report again into the same run with run.ulid: each run case keeps the last outcome and its history. To try a setup, check what would be sent
Signing in with OAuthReportPortal (oauth)CI reports with an app token of the organization, which uses no seat and outlives the person who made it
Global files and errorsAllure (globalAttachment(), globalError())Files and errors belong to a result: attach them from the test that produced them
HTML descriptionsAllure (descriptionHtml())The description is text; Probara formats it
ListenersAllure (listeners)No hook changes a result before it is sent; statusMapping and statusFilter cover statuses (status mapping)
Turning TLS verification offtrcli (--insecure)It cannot be turned off: give Node your certificate authority instead (custom certificate authorities)
Rolling back a failed uploadtrcliA result recorded is kept: rolling back would lose the ones that went through
References on runstrcli (--test-run-ref)The run records its CI build URL, branch and commit; issues link to results with probara.issue()

@probara/vitest-reporter follows semantic versioning. Read the changelog before you upgrade it. A new Vitest major can change the names it gives tests, and so their automation keys: before you upgrade Vitest, read from Vitest 4 to 5 and compare the keys.

  • 0.x (now): the reporter is young. A minor version (0.2.0) may change options, log lines or defaults; a patch version (0.1.1) only fixes bugs. Pin the version in package.json (or keep the lockfile) and upgrade on purpose.
  • 1.0 and later: breaking changes only in a major version.
  • @probara/vitest-reporter depends on @probara/core of the same release line; install the reporter alone and let it bring its core.

Any of these needs a major version from 1.0 on, and a minor one before:

  • An option or a PROBARA_* variable removed or renamed, or its meaning changed.
  • A default changed (closing runs, creating missing cases, keyIncludesFile, what gets attached).
  • A probara.* helper removed, or its behavior changed.
  • A status an attempt is sent with changed for the same attempt.
  • Any change to the automation key a test gets, with keyIncludesFile on or off. A different key unlinks the test from its case: the next run creates a new one.
  • Dropping a Vitest or Node.js version that is still supported upstream.

Adding an option, a helper, a log line or a file uploaded is not breaking. Log lines on stderr are for people: do not parse them.

The key algorithm is automation key v1, frozen in @probara/core and pinned by golden vectors: no version of the reporter changes it. What the reporter adds is how a Vitest test becomes the key’s parts, the way probara import junit reads Vitest’s JUnit output (keys):

  • the test file relative to rootDir (the Vitest root), when keyIncludesFile is on;
  • the describe titles and the test title, each a part, as Vitest’s JUnit output names them, the case ids removed;
  • a test.each or test.for row by the title Vitest formats for it.

That mapping is part of the contract too: a version of the reporter that changes the key of a test is a breaking change, named as such in the changelog. A version of Vitest that formats titles differently changes keys as well (below), for the reporter and for probara import junit alike.

To check an upgrade of either, compare what both versions would send: run the suite with reporting off once per version, each with its own results file, and diff the dry runs (check what would be sent). Any changed key is a test that would get a new case.

NeedsSupported
vitest3.2 and later (the peer range ^3.2.0 || ^4.0.0 || ^5.0.0): tested on 3.2.7, 4.1.11, 5.0.2
Browser modeThe provider of your vitest version: tested on 3.2.7, 4.0.18, 4.1.11 and 5.0.2
Node.js22.12 and later (the engines field)

Vitest 3.2 is the first version with test annotations, which the reporter reads, hence the peer range. Browser mode has its own setup per Vitest line (browser mode). 0.1.0 is the first version of the reporter: there is nothing to migrate from yet, and coming from probara import junit on Vitest’s JUnit output, the keys stay the same.

Some test.each and test.for titles change, and with them their keys. Vitest 5 formats the values it puts in a title in another way:

The testVitest 3 and 4Vitest 5
test.each([{ name: 'Ana' }])('greets $name')greets 'Ana'greets Ana
test.each([{ note }])('saves $note'), a note of 48 characterssaves 'Leave the parcel with the neighbour a…'saves Leave the parcel with the neighbour at …
test.each([[{ note }]])('stores %o')stores { note: 'Leave the parcel with the neighbour at number 12' }stores { …(1) }
test.each([[new Map([['eur', 12]])]])('reads %o')reads Map{ 'eur' => 12 }reads Map { 'eur' => 12 }
Numbers, short objects and arrays, %s of a stringThe sameThe same

The keys of those rows, as Vitest 5 names them:

tests/rows.test.ts
import { test } from 'vitest';
const note = 'Leave the parcel with the neighbour at number 12';
test.each([{ name: 'Ana' }])('greets $name', () => {});
test.each([[{ note }]])('stores %o', () => {});
[
{ "automationKey": "tests/rows.test.ts > greets Ana" },
{ "automationKey": "tests/rows.test.ts > stores { …(1) }" }
]
  • A changed key is a new case. The old case keeps its history, and the new key’s results go to a new case. Compare the keys before you upgrade, as above: each changed line is such a row.
  • Rows can collide. { …(1) } stands for any object of one key that is too long to print, so two such rows get the same title and share one case. Put a short value of each row in its title ('stores $id').
  • probara import junit changes the same way: it reads the names Vitest 5 writes in its JUnit output, so both paths keep giving a test the same key.
  • The default folders move: attachmentsDir from .vitest-attachments to .vitest/attachments (where the copies are kept), and the blobs of a sharded job from .vitest-reports to .vitest/blob, which vitest --merge-reports reads by default. A CI job that carries them, or cleans them, needs the new paths (carry the blobs and the attachments).
  • Browser mode has a new provider package, @vitest/browser-playwright, with provider: playwright() (browser mode).
  • Vitest tags (4.1 and later) join the tags of the cases a report creates; Vitest refuses a tag its config does not declare (Vitest tags).
  • Text bodies of context.annotate() need bodyEncoding: 'utf-8' from Vitest 4.1.3 on: Vitest labels a body base64 otherwise (Vitest annotations).

vitest --merge-reports reads only the blobs of its own Vitest version: upgrade the shards and the merge job together, in one change of the pipeline (the same Vitest everywhere).

Related: Get started · Linking · CLI · Source: migrating-from-junit.md, migrating-from-qase.md, coming-from-other-tools.md, upgrade.md