Reporters

A run publishes events to every configured reporter at once. Pick them on the command line or in the config file; they compose freely, so a CI job can print a live line view, annotate the PR, and write JUnit XML from one run.

ferridriver test --reporter line --reporter junit --reporter html
[[test.reporter]]
name = "list"

[[test.reporter]]
name = "junit"
outputFile = "reports/junit.xml"
includeProjectInTestName = true

Options may sit beside name, as above, or in an explicit table — both are read, and the table wins on a collision:

[[test.reporter]]
name = "junit"
[test.reporter.options]
outputFile = "reports/junit.xml"

A reporter with no options can be named on its own:

reporter = ["list", "html"]

Built-in reporters

NameOutputNotes
list (terminal, default)terminalOne line per test, then the numbered failure bodies and summary. The default.
lineterminalOne self-rewriting status line; failures print above it as they happen. Best for large suites.
dotterminalOne character per test, then the same failure epilogue.
progressterminaldot plus a running done/total counter.
jsonresults.jsonPlaywright's JSONReport shape.
junitjunit.xmlJUnit XML with Xray-style <properties>.
htmlreport.htmlSelf-contained report: every attempt, artifacts, steps.
blobreport.zipThe event stream itself — input to merge-reports.
githubannotationsGitHub Actions ::error / ::warning / ::notice, wrapping list.
markdownreport.mdSummary table plus per-failure detail; also appended to $GITHUB_STEP_SUMMARY.
tap / tap-flatterminalTAP version 13, nested per file or one flat plan.
teamcityterminalJetBrains service messages, streamed live.
ctrfctrf-report.jsonCommon Test Report Format.
perfettoperfetto.jsonChrome trace events: one lane per worker, tests and their steps as nested slices. Opens in ui.perfetto.dev; an outputFile ending in .gz is gzipped.
allureallure-results/Allure 2.x results plus attachments.
rerun@rerun.txtFailed test locations for --last-failed. Always on.
cucumber-jsoncucumber.jsonCucumber JSON, for BDD runs.
messagescucumber-messages.ndjsonCucumber Messages NDJSON.
usageterminalStep-definition call counts and timings (BDD).
null (empty)nothingSilences reporting entirely.

Every terminal reporter ends a run the same way: the numbered failure bodies, the slow-file warning, then the counts. A dot run that fails prints the same diagnostics a list run does.

Where files go

A file reporter resolves its path in this order, first match wins:

  1. FERRIDRIVER_<REPORTER>_OUTPUT_FILE — e.g. FERRIDRIVER_JUNIT_OUTPUT_FILE=/tmp/j.xml
  2. the reporter's outputFile option
  3. FERRIDRIVER_<REPORTER>_OUTPUT_DIR or the outputDir option, joined with FERRIDRIVER_<REPORTER>_OUTPUT_NAME or the fileName option
  4. the run's outputDir plus the reporter's default name

The environment variables are what a CI matrix uses to give each shard its own file without editing the config.

Reporter options

Options go beside the reporter's name (or in its options table), and boolean ones can also be flipped from the environment as FERRIDRIVER_<REPORTER>_<OPTION> (e.g. FERRIDRIVER_JUNIT_INCLUDE_RETRIES=1).

junit

OptionEffect
includeProjectInTestNamePrefix each <testcase name> with [project].
includeRetriesReport each retry as <flakyFailure> / <rerunFailure> instead of collapsing to the final attempt.
stripANSIControlSequencesDrop terminal escapes from attributes and text.
omitTagsLeave tag annotations out of <properties>.
suiteId, suiteNameFill the <testsuites> id / name attributes.

html

OptionEffect
opennever (default), always, or on-failure — whether to open the finished report in a browser.
mergeFilesGroup tests by their outermost describe instead of the file they are in, so a suite split over several files reads as one. Also FERRIDRIVER_HTML_MERGE_FILES=1.

blob

OptionEffect
pathWhere the zip is written.
shardIndex, shardTotalRecorded in the blob header so a merge keeps the run boundary.

allure

OptionEffect
outputDirResults directory (default allure-results).
suiteTitleOverrides the suite label.

Sharded runs and merge-reports

Each shard writes a blob; one merge step turns them into the report the unsharded run would have produced.

# on each CI machine
FERRIDRIVER_BLOB_OUTPUT_FILE=blobs/report-$SHARD.zip \
  ferridriver test --reporter blob --shard $SHARD/$TOTAL

# once, after collecting blobs/
ferridriver merge-reports blobs --reporter html --reporter junit

The blob is lossless: steps, attachments, annotations, stacks, worker indexes and start times all round-trip, so a merged HTML or JUnit report carries everything a direct run's would. Inline artifacts (a failure screenshot) are stored as entries inside the zip rather than inlined into the event stream.

merge-reports exits non-zero when any test in the merged run failed, so it gates a pipeline the same way the shards do.

Writing to CI

github emits an annotation per failure pointing at the line the error actually came from — parsed out of the stack, not the test's declaration — plus a run-summary notice and a warning per slow file. It wraps the list reporter, so the job log stays readable:

ferridriver test --reporter github

markdown writes report.md and, when GITHUB_STEP_SUMMARY is set, appends the same text there — the summary renders on the job page with no extra workflow step.

Custom reporters

A reporter name that is not one of the built-ins is a path to a module, resolved against the working directory and then against testDir. The module's default export is the reporter class; it is bundled and compiled through the same TypeScript pipeline the specs take, and constructed once per run with the entry's own options plus configDir and outputDir:

[[test.reporter]]
name = "./reporters/failure-reporter.ts"
outputFile = "reports/failures.json"
import type { Reporter, ReporterFullConfig, ReporterSuite, ReporterTestCase } from '@ferridriver/test';

export default class FailureReporter implements Reporter {
  private readonly failed: string[] = [];

  constructor(private readonly options: { outputFile?: string } = {}) {}

  onBegin(config: ReporterFullConfig, suite: ReporterSuite): void {
    console.log(`${suite.allTests().length} tests on ${config.workers} workers`);
  }

  onTestEnd(test: ReporterTestCase): void {
    if (test.outcome() === 'unexpected') this.failed.push(test.titlePath().join(' > '));
  }

  async onEnd(): Promise<void> {
    await fs.promises.writeFile(this.options.outputFile ?? 'failures.json', JSON.stringify(this.failed));
  }
}

A reporter runs in the same sandbox every other script does: fs is rooted at the project directory and fetch obeys the [scripting] network policy, so a reporter that posts to a webhook needs that host allowed like any other outbound call.

The module is compiled before the first test runs, so a reporter that does not resolve, does not parse, or throws while constructing fails the command rather than the run. Once the run has started, a reporter is isolated: a hook that throws is reported and the run carries on, exactly as it does upstream.

The two interfaces

There are two, and which one a class implements is decided by version():

ReporterReporterV2
version()absentreturns 'v2'
onConfigure(config)never calledcalled first
onBegin(...)(config, suite)(suite)

Everything else is the same. Reporter is the interface third-party Playwright reporters implement, so an existing one works unchanged.

What a hook is handed

The objects are Playwright's: a Suite tree of root → project → file → describe with titlePath(), entries(), allTests() and project(); a TestCase with outcome() and ok() computed from the results it has so far; a TestResult whose status is undefined until the attempt ends, and whose steps fill in as onStepBegin fires; and attachments whose body is a Buffer.

Deciding whether the run printed anything

printsToStdio() says whether the reporter writes to the terminal. If nothing in the whole set does — a run configured with only json and a custom file reporter, say — a line reporter (or dot under CI) is put in FRONT of the set, so the run is never silent. A reporter that does not declare printsToStdio counts as one that prints.

Editing the run, and deciding how it ended

preprocess({ config, suite, testRun }) runs once before the first test with the whole corpus in hand. testRun.exclude(target) drops a case or a whole suite, testRun.skip / fixme / fail annotate one, and testRun.skipSharding() says the reporter has taken sharding over. Unlike every other hook, an error here is not swallowed — it aborts the run rather than letting a half-applied edit reach the workers. The handle is dead the moment preprocess returns.

onEnd(result) may return { status } to change how the run is reported to have ended, and the process exit code follows it. The last reporter to answer wins.