vouchfx Troubleshooting¶
This guide covers real failure modes, what they mean, and how to fix them.
Quick index: - Docker is not running or not reachable - Transient image pull corruption: "short read" or "unexpected EOF" - EnvironmentError: HealthGate timeout of 00:00:20 - Every port-publishing container fails at startup ("should have valid address at this point") - Private registry and air-gapped operation - Discovery root does not exist (dotnet run path resolution gotcha) - Script body and document size limits - Understanding the four verdicts - Secret leakage in exception messages - Build fails with warnings-as-errors - Aspire topology timeouts or hangs - Steps run but assertions fail - Capture fails or placeholder is empty - Kafka messages not consumed (ordering or timing) - Validation errors at authoring time
Docker is not running or not reachable¶
Symptom:
RunSuiteAsync: topology failed to start - Cannot connect to the Docker daemon at unix:///var/run/docker.sock
or
What it means: vouchfx orchestrates containers via Docker (Aspire + Testcontainers). If the Docker daemon is not running or not reachable, the topology cannot start.
Fix:
-
Check Docker is running:
You should see version and runtime information. If it fails, start Docker. -
On Windows / macOS: Start Docker Desktop.
-
On Linux: Ensure the
dockerddaemon is running: -
If Docker is running but still unreachable: Check permissions. On Linux, the current user must be in the
dockergroup: -
Verify the socket exists:
In CI: Ensure the CI runner has Docker available. GitHub Actions' ubuntu-latest includes Docker by default. For GitLab, use docker:dind service or a socket-bind mount (see README.md § CI integration with GitLab CI).
Transient image pull corruption: "short read" or "unexpected EOF"¶
Symptom:
ERROR: failed to build: failed to solve: failed to compute cache key:
short read: expected 12108405 bytes but got 11522155: unexpected EOF
docker pull / topology start failing with unexpected EOF or failed to register layer.
What it means: A layer download was corrupted mid-transfer — a registry or network hiccup, not a vouchfx or Dockerfile defect. When it happens during topology start (DCP's cold pull of a dependency image), vouchfx reports it as an EnvironmentError — per the verdict taxonomy, infrastructure breakage, never a test failure. When it happens while building your own application image, the build fails before vouchfx runs at all.
Fix:
- Retry the pull or build — a plain retry almost always succeeds; the corrupted partial layer is discarded and re-fetched.
- If it persists, pull the base image explicitly to isolate the failing layer: then check free disk space, and clear a corrupt build-cache entry with:
- In CI, re-run the job. To reduce exposure on cold runners, pre-warm the images the
suite needs (the reusable workflow's
prewarm-imagesinput does this; each pull is best-effort and non-fatal).
The Aspire orchestration component is not installed / could not be located¶
Symptom:
System.IO.FileNotFoundException: The Aspire orchestration component is not installed
at "/home/runner/.nuget/packages/aspire.hosting.orchestration.linux-x64/13.4.2/tools/dcp".
EnvironmentError: The Aspire DCP orchestration component could not be located. …
no 'aspire.hosting.orchestration.<rid>' version '13.4.2' package was found at '…'.
What it means: vouchfx orchestrates containers through .NET Aspire's DCP binary, which ships in the aspire.hosting.orchestration.<rid> NuGet package and is looked up in your per-user NuGet cache (NUGET_PACKAGES if set, otherwise ~/.nuget/packages/). The message means that package — at the exact pinned Aspire version and for your machine's platform — is not in your cache.
Fix: populate the cache once by restoring any project that carries Aspire.AppHost.Sdk at the pinned version, most simply this repository:
Version exactness matters: having restored some other Aspire version leaves a different version folder in the cache and does not resolve this error. The retired dotnet workload install aspire command does not help either — it installs Aspire 8.2.x packs outside the NuGet cache. If you keep DCP in a non-standard location, set the ASPIRE_DCP_PATH environment variable to the directory containing the dcp executable.
If the message shows a /home/runner/... path (first variant above): you are running vouchfx 1.0.0-alpha.5 or earlier from NuGet.org. Those pre-releases only consult a path baked in on the release build machine, so they fail on every other machine even with a fully populated cache. Fixed in 1.0.0-alpha.6 — upgrade (dotnet tool update --global vouchfx --prerelease), or build and run from source.
This failure is always classified as an EnvironmentError — infrastructure, not a product defect — so by default it does not break CI (see "Understanding the four verdicts" below).
For the full incident write-up — root cause, why the release pipeline missed it, and how regression is prevented — see the knowledge-base article KB: DCP orchestrator not found.
EnvironmentError: HealthGate timeout of 00:00:20¶
Symptom:
What it means: This timeout is not the vouchfx 120-second outer gate — it is an Aspire/DCP internal per-resource watchdog (approximately 20 seconds per resource). When a resource takes longer than 20 seconds to become healthy (e.g., a large image pull on a cold cache, or a slow container startup), DCP's watchdog expires.
This is NOT a vouchfx configuration knob (there is no --health-check-timeout flag). The 20-second window is built into Aspire/DCP and cannot be extended at runtime.
Fix:
-
Pre-warm Docker images locally before running the suite (most common fix):
Once an image is in the local Docker cache, DCP skips the pull and starts the container much faster. -
In CI, use the
prewarm-imagesworkflow input (GitHub Actions):
Or for GitLab CI, set VOUCHFX_PREWARM_IMAGES:
- Check container startup performance locally. If a container is consistently slow to start, it may have an expensive initialization step (e.g., schema migration, data load). Consider:
- Running the initialization ahead of time (in the container image build, not at startup).
- Using a healthcheck endpoint that returns quickly (not one that validates database connectivity, which adds latency).
-
Profiling the container with
docker logsto see where time is spent. -
Serialize topology startup if you are running tests in parallel locally. The
[assembly: CollectionBehavior(DisableTestParallelization=true)]attribute (on xUnit test projects that use Aspire) serialises test startup, preventing concurrent DCP resource startup from overwhelming the machine and triggering timeouts. This is already applied in the vouchfx test projects (seeVouchfx.Engine.Runtime.Testsfor the example). -
In CI, increase runner capacity or use a faster image pull network. If your CI runner has constrained network or disk bandwidth, image pulls take longer. Use a faster runner if available, or pre-cache the image in the runner's local Docker registry.
Why this happens: Aspire's Distributed Cloud Provisioning (DCP) watches each resource and gives it ~20 seconds to become healthy. This is a safety net to prevent tests from hanging indefinitely. If multiple resources start concurrently, they compete for I/O (disk, network), and a large image pull can exceed 20 seconds. Pre-warming the cache is the most reliable fix.
Every port-publishing container fails at startup ("should have valid address at this point")¶
Symptom: the topology never comes up, and the logs carry one or both of these lines:
warn: Unable to allocate a network port for service 'my-broker-https'; service may be unreachable
and its clients may not work properly.
fail: System.IO.InvalidDataException: Service my-broker-https should have valid address at this point
at Aspire.Hosting.Dcp.DcpModelUtilities.TryAddLocalhostAllocatedEndpoint(...)
Every service or dependency that publishes a container port fails; a suite that publishes none still runs, and a plain docker run -p 0:80 nginx:alpine still publishes a port perfectly well.
What it means — and the message points at the wrong layer. Nothing is wrong with ports. DCP's controller host fails to start: it refuses its state-store directory because that directory's owner does not match the current user or token owner, and exits with code 1 about 130 ms in. With the controller dead nothing allocates ports, so Aspire waits for a port allocation that will never arrive and gives up on a fixed 60-second timeout — twice, which is the constant ~2 minutes you wait before the throw. Unable to allocate a network port is Aspire's downstream wording for "the allocation never came", not a description of the fault. It is not a defect in your suite, not port exhaustion, and not transient: re-running will fail identically. Full evidence in issue #420.
The fix (Windows):
- Look in your
~/.dcpfolder. It holds one state-store directory per privilege level:statefor ordinary runs andstate.elevatedfor elevated ones. - Check the owner of each (
Get-Acl ~\.dcp\state.elevated | Select-Object Owner). The fault is one of them being owned by somebody other than you —BUILTIN\Administratorsrather than your account, in the case that was diagnosed. - Rename or delete the offending directory, then run from a non-elevated shell. Verified: renaming it aside took the previously-failing suite from a 2-minute failure to green in 23 seconds on the same host.
Deleting it is not enough on its own if you keep running elevated. Measured on the same host afterwards: an elevated run recreated state.elevated and Windows gave the new directory to BUILTIN\Administrators rather than to the running account, so DCP's own ownership check refused it again and the fault returned immediately. If you must run elevated, take ownership of state.elevated after DCP creates it rather than expecting a delete to stick:
Use $env:USERPROFILE, not %USERPROFILE%: PowerShell does not expand cmd-style %VAR% in the arguments it passes to a native executable, so takeown would receive the eleven literal characters and fail. /r recurses; /d Y pre-answers the per-directory prompt that recursion raises. From cmd.exe the %USERPROFILE% form is correct and the quotes are unnecessary.
Epistemic note, since this page labels the rest: the diagnosis is measured — the ownership refusal, DCP's exit code, the 60-second waits, and the fact that an elevated re-run recreates the directory under BUILTIN\Administrators. The takeown remedy is inferred from that mechanism, not executed: what was verified end to end is renaming the directory aside and running non-elevated, which took the failing suite to green in 23 seconds. If you run the takeown form, please say on issue #420 whether it held.
Why it looked intermittent. The two state stores are per-privilege-level, so an elevated shell and an ordinary one use different directories — only one of which may be broken. A fault that follows how you happened to launch the terminal looks like it comes and goes on its own. The mechanism is measured (the directory selection and the ownership of the recreated directory were both observed directly); attributing the earlier sessions' apparent intermittency to it is inferred, because nobody recorded whether those shells were elevated.
winnat and Docker Desktop restarts were the previously-suggested remedies. They are not required and do not address this cause; skip them.
The engine captures the diagnostics for you — and that is how this was diagnosed. Raising DCP's log level after the fault appears had failed twice, because the fault stopped reproducing before the instrumented run started. So the capture is always armed instead. Whenever a topology fails to become ready, vouchfx writes the DCP log traffic it buffered during that start — including the Debug-level line quoted above, which never reaches the console — to a per-user file:
| Platform | Capture file |
|---|---|
| Windows | %LOCALAPPDATA%\vouchfx\dcp-capture-<utc-timestamp>.log |
| Linux, macOS | ~/.local/share/vouchfx/dcp-capture-<utc-timestamp>.log |
On Linux the root follows XDG_DATA_HOME when you have set it, exactly as every other application using that convention does; ~/.local/share is the fallback, and is where the file lands on an unconfigured desktop or CI runner. Whether macOS honours XDG_DATA_HOME here is unverified — the engine asks .NET for the per-user local application data directory and does not implement the rule itself, and no macOS host was available to measure. ~/.local/share is the expected location there either way; if you have XDG_DATA_HOME set on macOS and find the capture somewhere else, that is worth reporting.
The twelve most recent captures are kept; older ones are pruned automatically. Twelve rather than a handful because #420's last occurrence was eight consecutive failures in one session, and retention below that would delete the very captures the session produced.
The capture is written whenever a topology fails to become ready — the start itself, the health gates that follow it, service discovery, the secured-endpoint probe and the seed — because this fault can surface at any of them. On a platform with no per-user directory nothing is written and the Environment error says so rather than falling back to a shared temporary directory.
The reported Environment error names the capture file on most of those paths, but not on all of them, and it is worth knowing which. When the failure is one the engine classifies itself — the topology start, a health gate, or service discovery — the error names the file as a platform token and quotes the last few warning lines inline, so the evidence survives in a CI log even when the runner's filesystem does not. A failure in the secured-endpoint probe or the seed reports its own message instead: those two build their error where they raise it, before the capture is written, so the file exists but nothing in the error points at it. If a run fails at either and you want the DCP traffic, look in the directory above — the newest capture is that run's.
On CI, redirect the captures or you will never see them. A build agent's filesystem is discarded when the job ends, so a capture written to the per-user directory is destroyed unread — and CI is exactly where this fault is hardest to reproduce. Set VOUCHFX_DCP_CAPTURE_DIR to a directory inside the workspace, and add an upload step for it — the reusable vouchfx-run.yml workflow uploads results.xml and report.html only, so captures are not carried out unless you upload them yourself:
env:
VOUCHFX_DCP_CAPTURE_DIR: ${{ github.workspace }}/vouchfx-captures
# ... and, after the run:
- uses: actions/upload-artifact@v4
if: always()
with:
name: vouchfx-dcp-captures
path: vouchfx-captures/
A CI artefact is as public as an issue comment. Actions does not mask secrets inside uploaded artefact contents, and on a public repository anyone can download them. Read the section below on what a capture can contain before uploading one from a job whose suite routes any credential in via ${env:NAME}.
The path is used exactly as given (no vouchfx subdirectory is appended) and must be absolute — a relative value is refused rather than resolved against the working directory, and the Environment error says so rather than quietly writing to the per-user directory instead. Note that a directory you choose keeps the permissions it already has — vouchfx narrows permissions only on a directory it creates itself, and never on one you already had. The owner-only default applies to the per-user location.
When the capture shows this specific refusal, the reported Environment error also carries the one-line cause and remedy inline, so you do not have to open the file to know what to do. If it does not — a different DCP failure, or a reworded one — the file is where the answer will be.
If you meet a variant of this, please attach the capture to issue #420 — with a skim first. A capture is a verbatim record of what Aspire logged while the topology was coming up, which includes container specifications and therefore container environment variables. Concretely, it can hold Aspire's generated per-run passwords for managed dependencies (throwaway, valid only for that run's container and destroyed with it), the fixed local test credentials your suite declares, any host environment value you routed in with ${env:NAME}, and absolute paths on your machine. It cannot hold a ${secret:...} value: the engine refuses that reference outright in both services[].env and dependencies[].env, so no resolved secret ever reaches a container specification for this layer to log.
Everything in that list is already visible to docker inspect for the same containers on the same machine, so the file adds no local exposure you did not already have — but attaching it to a public issue publishes it, which is why it is worth a skim rather than a warning to keep it to yourself.
The warning lines quoted inline in the Environment error are a separate question. They are part of that error's detail and pass through the engine's ordinary secret-redaction path, but they arrive there truncated and with any non-ASCII character replaced, and that redaction matches values exactly — so treat the inline tail as best-effort redacted rather than guaranteed. The capture file is not redacted at all.
A successful start writes nothing at all: the buffer is discarded the moment the topology is up, so a healthy run leaves no file and produces no extra output. If you need to switch the recorder off entirely, set VOUCHFX_DCP_CAPTURE=0 (that exact value; anything else leaves it armed).
Private registry and air-gapped operation¶
Symptom: Your organisation uses a private or internal container registry, and you need to run vouchfx tests without pulling images from Docker Hub or public registries. Alternatively, you work in an air-gapped environment where containers are pre-warmed locally and must not attempt external pulls.
What it means:
By default, vouchfx pulls container images from public registries (Docker Hub for unqualified names, or directly from the specified registry host). Teams on a private registry (Nexus, Artifactory, ECR, ACR) or in regulated/air-gapped environments need to redirect those pulls or enforce local-cache-only operation. The platform provides two complementary mechanisms: environment-level registry redirection (imageRegistry) for un-qualified images, and per-dependency image override (image: field) for explicit control.
Fix:
1. Redirect public images to an internal mirror (imageRegistry)¶
If your organisation mirrors public images on an internal registry, use the imageRegistry environment-level override to redirect un-qualified references:
environment:
imageRegistry: nexus.corp.local/docker-mirror
services:
my-api:
image: myco/myapi:latest # Will be redirected to nexus.corp.local/docker-mirror/myco/myapi:latest
dependencies:
db:
type: postgres
# Will use nexus.corp.local/docker-mirror/library/postgres:18.3 (Docker's library namespace; Aspire pins PostgreSQL 18.3)
The imageRegistry override applies to every un-qualified reference in both services and dependencies. Already-qualified references (those carrying a registry hostname) are never rewritten — they are pulled from their specified host as-is. When you specify any image: field on a dependency, the engine also clears any built-in registry default the provider might carry, preventing unintended double-prefixing. The guarantee is: an image: is used exactly as written (with the provider's registry default cleared), and imageRegistry is applied on top only when the image carries no registry hostname of its own.
2. Override dependency images with per-dependency image: field¶
For finer-grained control — when you need a specific version or a non-standard image for one dependency — use the image: field on individual dependencies:
environment:
dependencies:
orders-db:
type: postgres
image: nexus.corp.local:5000/platform/postgres:16-custom # Explicit override
events:
type: kafka
image: artifactory.mycompany.com/confluent/kafka:7.5.0 # Pulls from Artifactory
cache:
type: redis
version: "7" # Uses Aspire's default: redis:7
The per-dependency image: field bypasses Aspire's provisioned default entirely. An image: carrying no tag or digest must be paired with a version: field; version: supplies the tag. If both image: (with a tag) and version: are set, that is rejected as ambiguous. A tagless image: without a sibling version: is rejected with a clear error: it would silently float on :latest, defeating the determinism invariant.
3. Enforce local-cache-only operation¶
For fully air-gapped or pre-warmed environments, use imagePullPolicy: Never to prevent unexpected outbound pulls:
environment:
imagePullPolicy: Never # All images must be pre-warmed locally; no external pulls allowed
services:
my-api:
image: myco/myapi:v1.2.3
dependencies:
db:
type: postgres
version: "16"
If an image is not present locally, the topology will fail to start. Ensure all required images are pre-warmed on the host:
# Before running tests, pre-warm all images
docker pull myco/myapi:v1.2.3
docker pull postgres:16
docker pull redis:7
4. Built-in images and registry redirect scope¶
Some managed resources carry built-in container images. The scope of imageRegistry and per-dependency image: overrides varies by resource type:
kafka with schemaRegistry: true — vouchfx provisions a Confluent Schema Registry sidecar (pinned to confluentinc/cp-schema-registry:7.6.1). The sidecar image carries no embedded registry hostname, so imageRegistry does apply to it. If you set imageRegistry: artifactory.mycompany.com/docker-mirror, the sidecar will be pulled from artifactory.mycompany.com/docker-mirror/confluentinc/cp-schema-registry:7.6.1. There is no per-dependency image: override for this sidecar; authors must rely on imageRegistry redirection.
azureservicebus — vouchfx provisions two containers. The main emulator container (mcr.microsoft.com/azure-messaging/servicebus-emulator:1.1.2) and the SQL Server 2022 sidecar (mcr.microsoft.com/mssql/server:2022-latest) both embed mcr.microsoft.com. Because their images are fully qualified, imageRegistry does not apply to either — the rule "already-qualified references are never rewritten" protects them both. The main emulator can be redirected per-dependency with an image: field naming your own registry (for example image: artifactory.mycompany.com/azure-messaging/servicebus-emulator:1.1.2); version: will not help here, because it only replaces the tag on the provider's built-in repository and cannot change the registry. The SQL sidecar cannot be overridden by any author-controlled means.
Principle: An image: field overrides the main container only (never a sidecar) and is used exactly as written. imageRegistry reaches every un-qualified image (including the Kafka schema-registry sidecar) but not images with a registry hostname of their own. imagePullPolicy applies to all containers.
Best practices for private registry operation¶
-
Use
imageRegistryfor wholesale redirects when you have a private mirror of all public images. This keeps scenarios concise. -
Use per-dependency
image:when you need per-resource control, or when not all images are mirrored (only some dependencies go to the internal registry). -
Combine both:
imageRegistryas a safety default, and per-dependencyimage:for exceptions: -
Prefer digest pinning (
@sha256:…) over tags in air-gapped environments; digests are byte-stable and prevent accidental pulls of newer versions if a tag is reused. -
Document your registry strategy in your test suite README so teammates understand which images are sourced from where.
Discovery root does not exist (dotnet run path resolution gotcha)¶
Symptom:
when running:
What it means:
This gotcha is specific to running the CLI from source via dotnet run --project. When you use dotnet run, it changes the working directory to the project directory (src/Cli/Vouchfx.Cli/), not your current directory. So a relative path like ./tests/e2e resolves relative to that project directory, not your repository root, and fails.
If you are using the packaged vouchfx CLI (installed via dotnet tool install), you will not encounter this issue — the tool runs from your current working directory, so relative paths work as expected.
Fix:
Use one of these approaches:
- Use the packaged vouchfx CLI (recommended):
The packaged tool runs from your current directory, so relative paths behave as expected and this gotcha never applies.
- If you are contributing to vouchfx from source, build the CLI once and run the binary directly:
(On Windows:
# Build once dotnet build src/Cli/Vouchfx.Cli/Vouchfx.Cli.csproj -c Release # Run the built binary src/Cli/Vouchfx.Cli/bin/Release/net8.0/vouchfx run ./tests/e2esrc\Cli\Vouchfx.Cli\bin\Release\net8.0\vouchfx.exe run .\tests\e2e)
The binary runs from your current directory, so relative paths work as expected.
- Pass an absolute path to
dotnet run: (On Windows:dotnet run --project src/Cli/Vouchfx.Cli/Vouchfx.Cli.csproj -- run $((Get-Location).Path)/tests/e2e)
Best practice: For running vouchfx test suites, use the packaged CLI — it is simpler, requires no path-resolution workarounds, and works on any machine with .NET 8 installed. If you are working on vouchfx itself from source, build the CLI once and run the binary directly — it is faster (skips compilation) and avoids dotnet run quirks altogether.
Script body and document size limits¶
Symptom:
script.csharp: 'code' size 72000 characters exceeds the 65536-character limit (a plain resource bound, not a security control — see this file's header comment); reduce its size or split the script.
or
File size 1200000 bytes exceeds the 1048576-byte (1 MiB) limit for a single *.e2e.yaml document (a guard against pathological input); split the suite into smaller files.
What it means:
The engine enforces two resource-limit bounds before compilation: a maximum of 64 KiB per script.csharp step body (inline code or referenced file:), and 1 MiB per .e2e.yaml document. These are sanity bounds to prevent accidentally passing pathologically large files to the compiler, not a defence against deliberate crash or hang attempts — which can occur well under these sizes (e.g. a ~100-character nested string interpolation can hang the parse).
When a limit is exceeded, the scenario is marked Inconclusive on run and invalid on validate, exiting 4 on both — with or without --fail-on-inconclusive. The document cap is a parse failure (#425) and the script-body cap refuses before any topology is built (#369), so neither can report the run as clean. Validation completes normally and names the specific limit — it is not a crash.
Fix:
-
Script body too large? Split a large
script.csharpbody across multiple steps: -
Document too large? Split into separate
.e2e.yamlfiles:
Understanding the four verdicts¶
vouchfx distinguishes four outcomes (see docs/01 §12.1 for the full taxonomy):
| Verdict | Meaning | Example | Default CI exit code |
|---|---|---|---|
| Pass | All assertions passed. | A test runs end-to-end and all steps succeed. | 0 (success) |
| Fail | An assertion failed — a genuine product defect. | expect: { status: 200 } but the API returned 500. |
1 (always breaks CI) |
| EnvironmentError | Infrastructure problem, not a product defect. | Docker daemon unreachable, image pull fails, seed SQL fails. | 0 by default; 3 if --fail-on-env-error |
| Inconclusive | The engine could not decide; the assertion may pass if retried. | A RETRY step's polling window expires; a capture expression fails to match. | 0 by default; 4 if --fail-on-inconclusive; never 0 on a parse failure, or on an Inconclusive suite refused before anything ran |
Why the distinction?
In microservices, infrastructure is often brittle. A test might fail not because the code is wrong, but because Docker is slow or a database is down. The vouchfx verdict taxonomy lets your CI system handle each case independently:
- Fail (1) — Your code broke. Fix it.
- EnvironmentError (0 or 3) — The infrastructure is in trouble. Page on-call or escalate to DevOps.
- Inconclusive (0 or 4) — The engine could not decide; maybe timing was just off. Investigate or re-run.
By default, only Fail breaks CI. This reduces false positives and keeps developers focused on real defects, not infrastructure flakiness.
Two deliberate exceptions break CI regardless of the gating flags. First: a suite that declares a security: block the engine could not confirm exits non-zero with neither --fail-on-env-error nor --fail-on-inconclusive set — at whichever code that run's own verdict names, 3 or 4. That includes a secured .e2e.yaml the engine parsed and then refused for its contents (an unknown step type, a duplicate step id): such a file never becomes a scenario, so nothing ever confirmed its declaration, and it now reddens the run even when its siblings came up and confirmed the same target. The run prints a line on stdout saying the exit is the security rule's doing, so a job that reads only results.xml sees a bare non-zero exit with no explanation. Fix the file the run names. Second: any parse failure, or a suite that parsed and was then refused before any scenario executed, never exits 0. Note "never exits 0" rather than "exits 4": both rules are conditioned on the code so far being Success, so neither overrides a code another rule already chose — a parse failure beside a failing scenario exits 1, and beside a gated environment error, 3. And a run that executed nothing but carries an EnvironmentError — a topology that never started, or an unsecured suite whose scenarios declared divergent environment blocks — is outside the second rule and still exits 0 by default. (A secured suite refused by that same divergence guard exits 3 through the security rule above.) See CI integration for the full rule and the code each outcome carries.
Opt into stricter gating with flags:
# Fail breaks CI; environment errors do not (default)
vouchfx run ./tests
# Also break CI on environment errors
vouchfx run ./tests --fail-on-env-error
# Also break CI on inconclusive results
vouchfx run ./tests --fail-on-inconclusive
# Both
vouchfx run ./tests --fail-on-env-error --fail-on-inconclusive
Secret leakage in exception messages¶
Symptom:
A script.csharp step throws an exception with a secret value in the message. The exception message is recorded as an observation in the --events JSON Lines output, where it becomes visible.
Example:
- id: validate-token
type: script.csharp
code: |
var token = Vars.Secrets.Resolve("vault/api-token").Reveal();
if (!token.StartsWith("sk_test_"))
throw new Exception($"Invalid token: {token}"); // DANGER: reveals the token
What it means:
The --events JSON Lines stream passes through the same scrubbing as the terminal output and HTML report (both redact secret values and substitute declared paths). If a script throws an exception with a revealed secret in its message, that message is redacted before reaching the event stream, just as it is on the terminal.
Fix:
-
Never embed resolved secret values in exception messages. Use the reference path or a generic error message instead:
-
The only deliberate escape hatch is
SecretString.Reveal(). Call it only at the moment you inject the value into a sink (e.g. an HTTP Authorization header), never write the revealed value back intoVarsor any logged/serialised structure: -
Understand the three tiers of secret protection (see
docs/01§17): - Tier 1: No bake into IL — The secret value is never compiled into the C# source or IL (verified by SecretResolutionPipelineTests).
- Tier 2: Redaction in output — Terminal output, HTML report, JUnit XML, and
--eventsJSON Lines all redact secret values and substitute declared paths (values appear as***REDACTED***; resolved paths appear as the author's original declared text). - Tier 3: Author discipline — Beyond the engine's automated redaction, authors must still avoid embedding secrets in exception messages because the scrub cannot catch deliberately transformed values (base64, HMAC, substrings).
Best practice: Treat exception messages as user-visible; never embed secrets in them. The reproducibility envelope records the reference hash, not the value — use that for reproducibility without embedding secrets.
Build fails with warnings-as-errors¶
Symptom:
build.csproj : error : Treating warnings as errors.
CSC5001: warning CS8360: [details...] cannot have...
What it means:
The vouchfx codebase is compiled with /WarnAsError enabled (zero-warning policy). Any compiler warning (including #nullable, unused variable, etc.) is treated as a build error.
Fix:
- Address the root cause: Read the warning message and fix it. Common examples:
- Unused
usingstatement → remove it. - Null-reference warning → add a null check or
!operator. -
Unused variable → remove it or prefix with
_. -
If the warning is spurious or unavoidable: Suppress it locally with
#pragma: -
Run the format gate locally before pushing:
This catches formatting issues (and indirectly, some warnings) early.
Why the strict policy? Zero-warning builds improve code quality and make intentional (narrowly-scoped) suppressions stand out in code review.
Aspire topology timeouts or hangs¶
Symptom: Tests hang or timeout waiting for the topology to start. The Aspire dashboard (if enabled) shows resources stuck in a transitional state.
What it means: A service or dependency failed to start or became unhealthy. Common causes:
- A service depends on another service that is not declared. Aspire cannot auto-discover undeclared dependencies.
- A healthcheck endpoint is broken. vouchfx health-gates ports; if the port is open but the healthcheck fails, Aspire retries indefinitely.
- A container image does not exist or cannot be pulled.
docker pullfails silently in some Aspire configurations.
Fix:
-
Run with the
Then examine--eventsflag to inspect observations to see which resource is stuck:events.jsonlforstep-attemptrecords showing stuck health-check attempts or failed container starts. -
Verify all dependencies are declared in
environment.servicesandenvironment.dependencies. If a service tries to connect to a database not in the environment, the topology cannot satisfy it. -
Verify the healthcheck endpoint is fast and works. Test it manually:
If the healthcheck endpoint hangs or fails, the container will be marked unhealthy. -
Check image availability:
If the pull fails, the topology cannot start.
Steps run but assertions fail¶
Symptom: A step runs (e.g., an HTTP request succeeds with status 200) but the assertion fails:
What it means: The step executed successfully, but the response did not match the expected conditions. This is a Fail verdict — a genuine product defect, not an infrastructure problem.
Fix:
-
Review the assertion. Is it correct for the current test?
http.restasserts the status code only — to check a response body,capturethe value and assert it in a laterscript.csharpstep (or adb-assert). -
Examine the actual response. Run the step in isolation (if possible) and log the full response:
-
Check for state threading issues. If an earlier step's capture is empty, a placeholder substitution may fail:
-
Re-run the scenario with
The JSON Lines file contains every step observation; examine it for the actual response.--eventsto capture raw observations:
Capture fails or placeholder is empty¶
Symptom:
or a placeholder resolves to empty/null in a later step.
What it means: A capture expression (JSONPath or XPath) did not match the step result, or a placeholder references a non-existent variable.
Fix:
-
Verify the JSONPath is correct. Use a JSONPath tester (e.g., jsonpath.com) to test the expression against your response:
-
Check the response structure. The actual response may differ from what you expected:
-
Use optional captures if a field might not always be present:
-
Use
continueOnFailureif a capture is truly optional: -
Verify variable names match. Placeholders are case-sensitive:
Kafka messages not consumed (ordering or timing)¶
Symptom:
An mq-expect.kafka step fails to find a message that was published earlier, even with verifyMode: RETRY.
What it means: Common causes:
- Message was published before the consumer started listening. Kafka does not replay historical messages by default (unless
earliestis configured). - Topic does not exist. The message was published to a different topic.
- Key or match criteria are too strict. The message exists but does not match the filter.
- Timing issue. The publish step and expect step are running concurrently; the expect starts before the publish completes.
Fix:
-
Ensure the order: publish → expect. In your steps, publish first, then expect:
- id: publish-order-event type: mq-publish.kafka target: kafka topic: orders.created payload: | { "orderId": "123", "status": "new" } key: "123" # Later step (runs after publish completes) - id: expect-order-event type: mq-expect.kafka target: kafka topic: orders.created match: key: "123" verifyMode: RETRY timeout: 10s -
Use a consistent topic name. Verify the publish and expect steps reference the same topic:
-
Relax the match criteria temporarily to debug. Remove the
keyor other filters to see if the message exists: -
Use
verifyMode: RETRYwith a reasonabletimeout(e.g., 10–30 seconds): -
Check Kafka broker health. If the broker is slow or unhealthy, messages may be delayed:
-
Understand Kafka's offset management. By default, vouchfx's Kafka consumer seeks to the latest offset. If a publish step and an expect step both run in the same scenario, the consumer might miss the message if it subscribes before the message is published. Ensure publish runs first.
RabbitMQ: message not in queue (ownership, durability, publish-after-declare)¶
Symptom:
An mq-expect.rabbitmq step asserts on a queue but the message is not found, even though the publish step ran first.
What it means: Common causes:
- Queue does not exist. RabbitMQ routing requires the queue to exist before a message can be routed to it.
- Durability mismatch. The queue was declared as transient (auto-delete) in a previous test and was deleted when emptied.
- Wrong routing key. The routing key used in the publish step does not match the queue name or binding.
- Message was already consumed. Another consumer drained the queue before the assertion ran.
Fix:
-
Declare the queue explicitly in the environment. If your SUT declares the queue at startup, ensure the HTTP trigger step runs before the assertion:
steps: # Step 1: Trigger SUT to declare the queue - id: initialize-service type: http.rest target: rabbitmq-service method: POST path: /init expect: status: 200 # Step 2: Now the queue exists; publish a message - id: publish-message type: mq-publish.rabbitmq target: events-rmq routingKey: orders-notifications payload: '{"orderId":"123"}' # Step 3: Assert the message is in the queue - id: assert-message type: mq-expect.rabbitmq target: events-rmq queue: orders-notifications match: payloadContains: "123" verifyMode: RETRY timeout: 10s -
Use durable queues. In your SUT or topology setup, declare queues with the durable flag set to
true: -
Match the routing key to the queue name. When using the default exchange, the routing key must match the queue name exactly:
-
Use
verifyMode: RETRYto wait for eventual consistency:
NATS: Inconclusive verdict on mq-expect.nats (stream created after publish — lost message)¶
Symptom:
An mq-expect.nats step times out with an Inconclusive verdict, but the message was published in a preceding step.
What it means: NATS JetStream messages are only retained if the stream exists before they are published. If you publish to a subject before the stream is created, the message is lost and cannot be recovered. The step then polls indefinitely and times out.
Common scenario:
1. Publish step runs and tries to publish to orders.created.
2. The provider creates the stream on first use (lazy initialisation).
3. But if there is a race or ordering issue, the stream might not exist yet → message is lost.
4. Expect step runs later, creates the stream, and polls — but the message is gone.
Fix:
-
Ensure the stream exists before publishing. The simplest approach is to have an expect step run before the publish to warm up the stream:
steps: # Step 1: Warm up the stream (ensures it exists) - id: ensure-stream-exists type: mq-expect.nats target: nats-broker subject: orders.created match: payloadContains: "dummy" # A dummy criterion that will fail continueOnFailure: true # We expect this to fail; we just want the stream created # Step 2: Now publish (stream is guaranteed to exist) - id: publish-order-event type: mq-publish.nats target: nats-broker subject: orders.created payload: '{"orderId":"123"}' # Step 3: Assert (stream exists and message is retained) - id: assert-order-event type: mq-expect.nats target: nats-broker subject: orders.created match: payloadContains: "123" verifyMode: RETRY timeout: 10s -
Use explicit stream names to avoid derivation ambiguity. When two subjects might derive to the same stream name, use explicit names:
-
Understand stream name derivation. Subject names are converted to stream names by uppercasing and replacing non-alphanumeric characters (except
-) with underscores. For example: orders.created→ORDERS_CREATEDorders_created→ORDERS_CREATED(collision!)orders-created→ORDERS-CREATED(different fromorders.created)
When in doubt, use explicit stream names in both publish and expect steps.
- Use separate NATS dependencies per scenario. If you have multiple scenarios asserting on the same subject, give each scenario its own
natsdependency to avoid cross-scenario message bleed:
Azure Service Bus: entity not declared in dependency config → EnvironmentError¶
Symptom:
What it means:
Azure Service Bus Emulator (and real Azure Service Bus) requires queue and topic declarations to be made explicit in the test's environment.dependencies section. Queues and topics that are not declared will not be created, and messages sent to them will fail with an EnvironmentError.
Fix:
-
Declare all queues and topics in the environment. List them explicitly under the
azureservicebusdependency: -
Match queue and topic names exactly. Ensure the
mq-publishandmq-expectsteps reference the declared names: -
For topic subscriptions, declare both the topic and subscription. A subscription cannot be created without its parent topic:
-
Understand the entity declaration syntax.
- Queues are simple strings:
queues: [queue1, queue2, ...] - Topics are objects with a
nameand optionalsubscriptionsarray:topics: [{name: topic1, subscriptions: [sub1, sub2]}]
Redis: AuthenticationError from the SUT (missing password credential)¶
Symptom: Your system under test fails with an authentication error when connecting to Redis:
or
What it means: The managed Redis dependency (provided by Aspire/Testcontainers) has authentication enabled, and your SUT is either: 1. Not providing the password at all. 2. Using the wrong password or username.
Fix:
- Inject the Redis password via environment variable. The engine stages the Redis connection string (including password) as
conn::<target>. Inject it into your SUT:
Your SUT should then parse this connection string and use it to connect.
-
If your SUT requires host, port, and password separately, extract them:
-
For .NET applications using StackExchange.Redis, construct the connection options from the credentials:
-
If the error persists, verify the Redis container is healthy:
SQL Server: slow cold start; EnvironmentError on first pull¶
Symptom:
The SQL Server container takes a long time to start for the first time.
What it means: SQL Server is a large image (multiple gigabytes) and has a lengthy initialisation sequence. On the first run, Docker must pull the image from the registry, which can exceed the Aspire/DCP 20-second health check window.
Fix:
-
Pre-warm the SQL Server image locally before running the suite (most effective):
Once the image is in the local cache, subsequent container startups are much faster. -
In CI, use the
prewarm-imagesworkflow input (GitHub Actions): -
Ensure sufficient disk space and memory. SQL Server requires at least 2 GB RAM and adequate disk space. If your Docker daemon is resource-constrained, increase limits.
-
Understand the startup sequence. SQL Server's health check waits for the database engine to be ready, which involves filesystem initialisation. This is non-configurable; the 20-second Aspire window is a hard limit.
SMTP/Mailpit: mail not found (wrong target key — HTTP API vs. SMTP endpoint)¶
Symptom:
Your system sends an email via SMTP, but the mail-expect.smtp step finds no messages.
What it means: Common causes:
- Your SUT is not connecting to Mailpit. The SMTP endpoint is not injected or the SUT is ignoring it.
- Wrong environment variable names. The SUT expects different variable names than what the test provides.
- Mailpit HTTP API is not reachable. The step is querying the wrong endpoint.
Fix:
- Inject both the SMTP host and port separately. Mailpit exposes two endpoints:
- HTTP API for querying:
conn::mailpit(e.g.,http://localhost:8025) - SMTP server for sending:
svc::mailpit-smtpwith separate host and port
Ensure your SUT gets both:
environment:
services:
my-app:
image: myco/my-app:latest
env:
SMTP_HOST: ${conn:mail.host}
SMTP_PORT: ${conn:mail.port}
-
Verify the SUT is sending email. Add a debug HTTP step before the mail assertion to confirm the SUT is processing the request:
-
Use
verifyMode: RETRYto wait for SMTP delivery: -
Check Mailpit is running. Verify the container is healthy:
-
Understand match semantics. All criteria (to, subject-contains, body-contains) are AND-conjunctive:
If you are not finding a message, relax the criteria one by one to isolate the mismatch:
# First, check if ANY email exists to this address
match:
to: alice@example.com
# Then add subject filtering
match:
to: alice@example.com
subject-contains: "Welcome"
# Finally, add body filtering
match:
to: alice@example.com
subject-contains: "Welcome"
body-contains: "Thank you"
Cross-scenario state bleed on non-reset stores (DynamoDB, MinIO)¶
Symptom: When you run multiple scenarios sequentially on the same topology, data from one scenario is visible in the next scenario. This happens with datastores that are not automatically reset (DynamoDB and MinIO).
What it means: DynamoDB and MinIO are not cleared between sequential scenarios — any writes one scenario makes persist into the next. This is a deliberate limitation (out of scope). Other datastores (PostgreSQL, SQL Server, MySQL, MongoDB, Redis, Elasticsearch) are automatically reset between scenarios.
Solutions:
-
Run scenarios in parallel (preferred). Parallel execution isolates each scenario in its own topology, avoiding any cross-scenario state:
-
Add explicit cleanup steps. As the first step of each scenario, explicitly clean the datastore:
steps: # Scenario 2: start fresh - id: cleanup-dynamodb type: script.csharp description: Clear DynamoDB tables for this scenario. code: | var config = new Amazon.DynamoDBv2.AmazonDynamoDBConfig { ServiceURL = (string)Vars["conn::dynamo"] }; var client = new Amazon.DynamoDBv2.AmazonDynamoDBClient(config); try { // Delete items you wrote in the prior scenario (application-specific logic) } finally { client.Dispose(); }
Note:
script.csharpcontributes no compile references of its own — the AWS SDK types above compile only because a step from a provider that contributes them (such asdb-assert.dynamodb) appears somewhere in the same scenario. A scenario whose only DynamoDB interaction is this cleanup script will fail to compile; include at least onedb-assert.dynamodbstep, or perform the cleanup through your SUT's own API instead.
- Use scenario-scoped keys. Prefix your test data with a scenario identifier, then delete by prefix pattern rather than table-wide flush.
State reset failed (environment error naming a dependency)¶
Symptom: A scenario fails during the reset phase (between scenarios in a sequential suite) with an environment error message like:
What it means: The engine attempted to clear a dependency's state between scenarios (the reset runs after each scenario completes in a sequential multi-scenario suite), but the reset operation failed. This is an environment error — a failure of the test infrastructure, not a failure of the system under test. Possible causes:
- Store became unhealthy mid-suite — a container crashed, lost network connectivity, or hit a resource limit. Check Docker logs:
docker logs <container-id>. - SQL Server with exotic schemas — temporal (system-versioned) tables are handled, but Respawn may fail on other unusual patterns (cross-database foreign keys, graph tables); consult Respawn's documented limitations.
- MongoDB capped or time-series collections — these collections reject the document deletion the reset performs; remove them from the seeded data or use standard collections.
- Elasticsearch reporting per-document failures — a delete-by-query partially failed; check Elasticsearch logs and ensure the cluster has sufficient resources.
- Connection/authentication lost — the test infrastructure's credentials or network path to the store changed between scenarios.
Solutions:
-
Check the event stream for the full reset error message — it names the dependency and the specific failure stage (
init,create, orreset): -
Inspect container health — if a Docker container reset failed:
-
For MongoDB, remove capped and time-series collections from the test data or switch to standard collections.
-
For SQL Server, temporal tables are handled automatically; if the reset still fails, look for other exotic schema patterns (cross-database foreign keys, graph tables) and consult Respawn's documented limitations.
-
For Elasticsearch, ensure the cluster is healthy and has sufficient resources; check the cluster health status:
Validation errors at authoring time¶
Unknown step type (vouchfx validate or pre-compilation)
Symptom:
FAIL my-test.e2e.yaml
[Schema] (line 45) unknown step type 'db-assert.oracle' - not a registered provider (expected <family>.<provider>, e.g. 'db-assert.postgres').
What it means:
A step's type field is well-formed (matches the <family>.<provider> pattern) but does not match any registered provider. This is caught early by vouchfx validate (compile-level validation without Docker) with a precise line number and stage, so you can fix the provider name before attempting to run the suite. Note: malformed types like a bare postgres or db_assert.postgres (with underscore) are rejected by the schema's pattern check instead, reported as separate [Schema] errors — the "unknown step type" message specifically catches well-formed-but-unregistered types.
Fix:
-
Check the step type against the registered providers. Consult the Language Reference or run
vouchfx listto see all available step types: -
Correct the
typefield to a registered provider. Ensure it follows the<family>.<provider>naming convention and matches one of the Core providers:# Schema error: malformed (missing family) - id: my-step type: postgres # Schema error: malformed (wrong separator) - id: my-step type: db_assert.postgres # Unknown-type error (well-formed but not a Core provider) - id: my-step type: db-assert.oracle # Correct (matches a registered Core provider) - id: my-step type: db-assert.postgres -
Use
vouchfx validatebefore running. It catches both malformed types (schema pattern errors) and well-formed-but-unregistered types (unknown-step-type errors), plus missing required fields, without needing Docker:
See also¶
- Recipes — Task-oriented examples for common scenarios.
- vouchfx-samples — Real-world sample applications and test suites demonstrating patterns.
- Common Patterns — Authoring patterns and step structure.
- Language Reference — Complete field reference for every step type.
- Technical Architecture Blueprint — How the system works (Aspire, Roslyn, memory model, verdict taxonomy, secrets).
- Project README — Building and running vouchfx, CLI reference, exit codes.