Facts checked against docs.reqnroll.net and the reqnroll/Reqnroll source on GitHub. Version context: latest Reqnroll release v3.3.4 (2026-03-23). Formatters require v3.0+; output-path substitution variables require v3.3+.
- Built into the core
Reqnrollpackage — no extra NuGet reference needed. Transitive deps:Cucumber.Messages32.3.1,Cucumber.HtmlFormatter23.1.0. - Enabled via
reqnroll.json(omitting the section disables the formatter):
{
"$schema": "https://schemas.reqnroll.net/reqnroll-config-latest.json",
"formatters": {
"message": { "outputFilePath": "reports\\run_{timestamp}.ndjson" }
}
}- Default output:
reqnroll_report.ndjson, relative to the project output folder (bin/Debug/netX/). Substitution variables (v3.3+):{timestamp},{buildNumber},{revision},{branch},{tag},{env:VARNAME}. - Env-var overrides exist (
REQNROLL_FORMATTERS_MESSAGE=outputFilePath=...,REQNROLL_FORMATTERS_DISABLED=true) — useful for CI without touching the repo. - A custom formatter extension point exists (
ICucumberMessageFormatter, registered via runtime plugin), but the docs explicitly warn the interface may change significantly even in minor versions. This confirms SPEC §9's non-goal: consume the built-inmessageformatter's file output; do not build on the unstable formatter API.
meta, source, gherkinDocument, pickle, stepDefinition, hook, parameterType,
testRunStarted/Finished, testCase, testCaseStarted/Finished, testStepStarted/Finished,
testRunHookStarted/Finished, attachment, suggestion. It never emits externalAttachment
or parseError.
testStepResult: status ∈ UNKNOWN|PASSED|SKIPPED|PENDING|UNDEFINED|AMBIGUOUS|FAILED,
duration = {seconds, nanos}, message (only for TestError/UndefinedStep, message text only),
exception = {type, message, stackTrace}.
GitHub issue #892 (AfterStep not firing for skipped steps) is closed as by-design and
will not change. But the message stream itself is complete: Reqnroll emits
testStepFinished with SKIPPED for steps after a failure, independent of hooks. So:
- The renderer's primary source for skips is the stream itself.
- SPEC §4.4's inference rule (steps after first failure = skipped) is a defensive fallback for incomplete streams (crashed runs), not the primary mechanism.
- The capture package must never rely on
AfterStepfiring for every step.
- API:
IReqnrollOutputHelper(namespaceReqnroll), obtained via constructor injection:void AddAttachment(string filePath)andWriteLine(string). - Step-scoped: Reqnroll stamps attachments with the current
testStepId+testCaseStartedIdautomatically. Outside a scenario they attach to the test-run hook scope. AddAttachmentreads the file and inlines it base64 into the NDJSON (contentEncoding: BASE64), with no size cap and no external-file option. Consequences:- Capture must keep screenshots small (JPEG, quality-capped) — every byte lands in the NDJSON.
- The renderer/CLI is the place to externalize (
--assets external), matching the Cucumber schema's intended consumer-sideExternalAttachmentpattern.
- Media type is inferred from the file extension (
FileExtensionToMIMETypeMap); the API offers no way to set a custom media type. Soapplication/vnd.specreport.http+jsonis impossible via the public API → the evidence contract needs an in-payload discriminator (see SPEC §3.2 revision). WriteLineoutput becomes an attachment withmediaType: "text/x.cucumber.log+plain",contentEncoding: IDENTITY.- Runner-level display of attachments works on MSTest/NUnit; xUnit lacks runner attachment support — but the Cucumber Messages envelope is still emitted either way, which is all SpecReport needs.
[BeforeStep]/[AfterStep]/[BeforeScenario]/[AfterScenario]— instance methods, constructor injection,async Tasksupported (neverasync void).Orderproperty: lower runs first, default 10000. Tag-scoped hooks OR their tags.- Context objects:
ScenarioContext(→ScenarioInfo.CombinedTags— use this, it includes feature-inherited tags;ScenarioExecutionStatus;TestError),ScenarioStepContext(→StepInfo,Status,StepError). ⚠️ StepInfohas noKeywordproperty — the Gherkin keyword lives onStepInfo.StepInstance.Keyword.StepInfo.PickleStepIdisinternal— not accessible without reflection. We don't need it: attachment→step correlation is handled by Reqnroll (see §3), so Capture never joins ids itself.
- Implement
Reqnroll.Plugins.IRuntimePlugin+[assembly: RuntimePlugin(typeof(...))]. - The assembly file name must end in
.ReqnrollPlugin.dllor Reqnroll won't auto-load it. → assembly nameSpecReport.Capture.ReqnrollPlugin, NuGet package id staysSpecReport.Capture. - Target netstandard2.0 for maximum consumer compatibility.
- DI hooks:
RegisterGlobalDependencies(add new),CustomizeScenarioDependencies(per-scenario services — where the HTTP capture handler's step-context accessor lives). - Note: plain hook classes in a referenced assembly also need
bindingAssembliesconfig in the consumer's reqnroll.json — the runtime-plugin route withCustomizeScenarioDependenciesavoids forcing config on consumers. Decide in M3 design.
Everything above was read from docs and source. Everything below was measured against
tests/fixtures/sample-run-01.ndjson (Reqnroll 3.3.4 / MSTest / .NET 10, protocolVersion
30.1.0). Where the two disagree, this section wins.
| Assumption | Result |
|---|---|
| Attachments are step-scoped | ✅ All 10 attachments carry both testStepId and testCaseStartedId |
| Media type inferred from file extension | ✅ .png→image/png, .json→application/json. No way to set a custom one → the in-payload $specreport discriminator (SPEC §3.2) is necessary, not merely convenient |
WriteLine output |
✅ text/x.cucumber.log+plain, contentEncoding: IDENTITY. One attachment envelope per WriteLine call |
| Binary attachments | ✅ contentEncoding: BASE64, whole file inlined, no size cap |
| Skipped steps reach the stream | ✅ 7 × testStepFinished with SKIPPED, despite step hooks not firing for them. SPEC §4.4's inference stays a fallback |
| Failure detail | ✅ exception: { type, message, stackTrace }; type is the real exception class name (BasketAssertionException), so it is worth surfacing |
| Hook steps are distinguishable | ✅ testCase.testSteps[] entries carry either pickleStepId or hookId — never both. SPEC §4.3 hook rows are implementable exactly as designed |
meta has no timestamp |
✅ Run time comes from testRunStarted.timestamp (+ it also carries an id) |
| Pickle steps carry no keyword | ✅ Only text, type (Context/Action/Outcome), and astNodeIds → the gherkinDocument AST join is mandatory, as SPEC §4.3 assumed |
Rule: needs its own grouping slot |
✅ Appears as feature.children[].rule, with its own children (including a per-rule Background). One nesting level |
hook.nameis empty. Thehookenvelope carriesid,type(BEFORE_TEST_RUN/BEFORE_TEST_CASE/AFTER_TEST_CASE/AFTER_TEST_RUN), andtagExpression— but no method name. Hook rows in the timeline therefore cannot be labelled "FailingBeforeHook". Label from type + tag expression instead:Before hook/Before hook (@fail-before-hook).- Gherkin-derived ids are deterministic; execution ids are not. Measured across two
consecutive runs: all 28
pickle.ids identical, all 28testCase.ids different. Pickle/AST ids are content hashes (32-char hex); execution ids are random per run (22-char base64url). Consequence for SPEC §6.2: derived identity (URI + rule + name + params) stays primary because it survives step-text edits, butpickle.idis a valid corroborating key and a fast path when feature files are unchanged. - Outline example rows are directly identifiable. An outline pickle's
astNodeIdsis[scenarioNodeId, exampleRowId]— the row id is right there, and two byte-identical example rows get different ids. SPEC §6.2's "example-row index tiebreaker" is therefore cheaper than expected: useastNodeIds[1]. Keep the positional index as the fallback for streams where it is absent. - AST step keywords carry a trailing space —
"Given ","Angenommen ". Trim for display.keywordType(Context/Action/Outcome/Conjunction) is the styling key, as SPEC §4.3 specified — verified against German:Angenommen→Context,Wenn→Action,Dann→Outcome. meta.ciis absent on a local run. The report must degrade gracefully rather than assume commit/branch/build metadata exists — which is exactly why therunmeta/1attachment (SPEC §3.1) earns its place.testCaseStarted.attemptexists (0), butworkerIdis NEVER populated — including under parallel execution. The sample runs with[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)], and the fixture proves the run really was concurrent:testCaseStartedtimestamps arrive out of monotonic order (…716830400 then …706765000), which only happens across threads. Yet all 28 entries lackworkerId. Conclusion: no worker/shard attribution is available from Reqnroll today. Any "which worker ran this" UI is off the table until Reqnroll emits it; shard identity has to come from therunmeta/1attachment instead (SPEC §3.1).- The fixture is a parallel-execution fixture. That is deliberate and worth keeping: it
means the parser is exercised against interleaved
testStepStarted/testStepFinishedenvelopes from the very first test, rather than a tidy sequential stream that would hide ordering bugs until much later.
suggestionenvelope: for undefined steps Reqnroll emits a ready-made C# step-definition snippet. Rendering "here's the binding you're missing" directly in the report is a cheap, genuinely useful feature the built-in HTML formatter doesn't surface. Candidate for M3.- Build-time
.ndjsonfiles appear inobj/Debug/<tfm>/Features/*.feature.ndjson— these are per-feature Gherkin artifacts from code generation, not the runtime stream. Don't confuse them with the formatter output;.gitignoreexcludes them.
This invalidated a load-bearing assumption in SPEC §3.4 and was only found by checking the recorded bytes.
§3 above establishes that Reqnroll stamps attachments with the current step id. True — but
"current" during an [AfterStep] hook is the hook's own step, not the Gherkin step that
just finished:
- Reqnroll emits
BEFORE_TEST_STEP/AFTER_TEST_STEPhook envelopes (absent from fixture-01, which had no step hooks — hence the earlier gap in these notes). testCase.testSteps[]interleaves them with Gherkin steps:[beforeCase hook, beforeStep hook, PICKLE STEP, afterStep hook, beforeStep hook, …].- An attachment added from
[AfterStep]is stamped with anAFTER_TEST_STEPentry, whosepickleStepIdis empty. Worse, in the measured run all four exchanges in a scenario shared a single such id — sotestStepIdcannot distinguish which Gherkin step produced them.
Consequence: evidence captured after a step must carry its own step identity.
SpecReport.Capture stamps every exchange with stepIndex (a per-scenario counter incremented
in [BeforeStep]) and stepText, at capture time. Attribution is then ours and deterministic,
independent of a Reqnroll internal we don't control.
An http/1 payload can carry its own stepIndex, but a JPEG cannot — and the stamping
problem is identical. Measured on the full-stack sample: a screenshot taken after the failing
step (step 5 of 5) was stamped with the AFTER_TEST_STEP hook id belonging to step 1, and
rendered there.
AddAttachment(filePath) copies Path.GetFileName(filePath) into the envelope verbatim (§3),
so the file name is the available channel. Capture writes screenshots as
specreport-step-0004-{rand}.jpg and RunParser.StepOrdinalFromFileName reads the ordinal
back. The prefix is a contract between the two packages — changing it on one side alone
silently misplaces every screenshot, with nothing failing.
Note this also rules out the obvious alternative diagnosis: the failure-detection condition was
tried first (StepContext.Status, then StepContext.StepError) and neither moved the
attachment, because the screenshot was always being taken at the right moment. Only the
stamping was wrong.
StepInfo.PickleStepId is internal (§6), so joining to the message stream by id isn't
available to third-party code anyway — the index/text pair is the practical alternative.
Injecting it into a binding class throws
ObjectContainerException: Primitive types or structs cannot be resolved: Reqnroll.Bindings.StepDefinitionType (… ScenarioStepContext → StepInfo).
Inject ScenarioContext and read scenario.StepContext?.StepInfo?.Text instead.
InvariantGlobalization=true breaks Reqnroll at startup:
ConfigurationLoader.DefaultFeatureLanguage calls CultureInfo.GetCultureInfo("en-us"), which
throws under invariant mode and aborts the whole run before a single scenario executes. Culture
data is load-bearing for any Reqnroll consumer — and doubly so for SpecReport, which supports
localized Gherkin. Directory.Build.props pins it to false with a comment.