Install BDTest.Reporting alongside your adapter to export JSON, generate HTML or upload reports.
Use matching package versions; see reporting package migration.
Sending results
Uploads use schema-versioned contracts. Upgrade the host to the matching contracts-based package generation before sending schema-version-1 reports. The new host also reads persisted legacy reports.
For built-in adapters, pass the configured assembly run to report/utility helpers. Every export and store utility requires the owning run. Export all reports before disposing through the assembly owner.
At the end of the test run, call BDTestReportUploader.UploadReportAsync. It requires a BDTestRun, uploads only that run and returns BDTest.Contracts.BDTestReportUploadResult: a ReportUrl (Uri) and the server-assigned ReportId (string?). UploadAsync remains available and returns just the URL as Task<Uri>. Both methods finish uploading attachments before returning.
The snapshot includes scenarios run through BDTest (Given(...).When(...).Then(...)) and pending definitions owned by the selected run.
Example with TUnit
using BDTest;
public class SendResultsToReportServer
{
[After(HookType.Assembly)]
public static async Task AfterAllTests()
{
using var client = new HttpClient();
var assembly = typeof(SendResultsToReportServer).Assembly;
try
{
var options = BDTestReportUploadOptions.FromEnvironment(
new Uri("https://reports.example.com"), "BDTEST_REPORT_UPLOAD_API_KEY");
var uploader = new BDTestReportUploader(client, options);
var run = BDTest.TUnitBDTestAssembly.GetRun(assembly);
var result = await uploader.UploadReportAsync(run,
new BDTestRunDescriptor
{
Tag = "API acceptance tests",
BranchName = Environment.GetEnvironmentVariable("BRANCH_NAME"),
Environment = "Pre-production",
});
Console.WriteLine($"BDTest report: {result.ReportUrl} (ID: {result.ReportId})");
}
finally { BDTest.TUnitBDTestAssembly.Dispose(assembly); }
}
}
For other frameworks, put the same code in the framework's after-all hook. See Test frameworks.
The upload key
The report server requires the X-BDTest-Upload-Key header on every upload. Set BDTestReportUploadOptions.UploadKey or explicitly call FromEnvironment. The uploader adds the key to report and attachment requests. FromEnvironment defaults to BDTEST_UPLOAD_KEY; the example explicitly selects BDTEST_REPORT_UPLOAD_API_KEY. Missing or blank environment values fail immediately; no environment variables are read implicitly. A null UploadKey leaves authentication to the caller's client. A configured key overrides that client's default upload-key header for these requests. The static API still requires the caller to set the header.
- Keep the key in a CI secret and pass it as an environment variable.
- Use HTTPS.
- A missing, wrong or duplicated key returns HTTP 401. So does a server with no key configured.
Uploader options
var options = new BDTestReportUploadOptions(new Uri("https://reports.example.com/team/reports"))
{
UploadKey = uploadKey,
Timeout = TimeSpan.FromSeconds(30),
UseGzip = true,
};
var uploader = new BDTestReportUploader(httpClient, options);
var reportUri = await uploader.UploadAsync(run, descriptor, cancellationToken);
The uploader validates and copies options at construction. Later option changes do not affect it. The server address must be absolute HTTP(S), with no user credentials, query or fragment. A bare origin uses /bdtest; a supplied path is the complete external report prefix. Keys must be nonblank with no control characters when specified. Timeout defaults to two minutes per attempt and accepts a positive duration up to Int32.MaxValue milliseconds, or Timeout.InfiniteTimeSpan. A shorter caller HttpClient.Timeout still applies, including response body reading. The uploader never changes or disposes the supplied client; reuse and dispose it in your own application lifecycle.
JSON streams directly using generated serialization metadata, with gzip enabled by default. Set UseGzip = false for plain streamed JSON. Content length is unknown until serialization, so the transport may use chunked requests. No warm-up ping is sent. Attachment bytes stream without gzip and each retry reopens the selected run's capture. Run IDs, pending definitions and empty-run timing use the same snapshot builder as JSON and HTML exports. Export before disposing the run.
Response protocol and compatibility
Both upload methods send X-BDTest-Upload-Protocol: 1 and a per-request Accept: application/json. They override a caller's default Accept header only for the report request and leave HttpClient.DefaultRequestHeaders unchanged. The explicit protocol header selects the response; Accept alone does not opt in.
A current server returns HTTP 201 Created, Content-Type: application/json, a Location header pointing to the report URL, and this body:
{
"reportUrl": "https://reports.example.com/bdtest/report/0123456789abcdef0123456789abcdef/summary",
"reportId": "0123456789abcdef0123456789abcdef"
}
ReportId identifies the stored report, not the selected run's local ReportId. The existing X-BDTest-Report-Id and X-BDTest-Attachment-Token receipt headers continue to support attachment uploads. The public result never exposes the attachment token.
For older clients that omit the protocol header, the current server returns HTTP 200 OK and the URL as text/plain, even when Accept asks for JSON. A new client also accepts an older server's plain-text URL or JSON string response. It obtains ReportId from the receipt header when available, or returns null when the older server supplies no ID; it does not infer an ID from the URL. Response compatibility does not make an older server understand schema-versioned reports. Use matching contracts-based host and client package generations.
Static overloads
The compatibility methods are named BDTestReportServer.SendDataAndGetReportUriAsync.
| Overload | Use |
|---|---|
(BDTestRun run, Uri serverAddress, BDTestRunDescriptor descriptor) | Upload an explicit run. |
(Uri serverAddress, BDTestRunDescriptor descriptor, HttpClient httpClient, BDTestRun run) | Explicit run with your own client. |
All four signatures return Task<Uri>, the summary page of the new report. Each overload also has a version with a final CancellationToken cancellationToken parameter. They remain for one release as [Obsolete] forwarders to BDTestReportUploader; migrate now, especially when treating warnings as errors. Overloads without a token forward with CancellationToken.None.
The forwarders now use streaming gzip and the negotiated response protocol, with no warm-up request. They preserve caller-owned client lifetime, caller-configured authentication, explicit-run isolation, cancellation and retries. For plain JSON, construct an uploader with UseGzip = false; see migration guidance.
Cancellation
Pass a token to stop HTTP requests (including streamed JSON, response buffering and attachments) and retry waits:
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var reportUri = await uploader.UploadAsync(run, descriptor, cancellation.Token);
Caller cancellation propagates as OperationCanceledException and is not retried as a transport timeout. A pre-cancelled token sends no requests. An HttpClient timeout without caller cancellation retains the existing transient retry behavior. Use the caller token to bound the whole upload, including retry delays; HttpClient.Timeout applies to individual requests, including response buffering. Buffering remains cancellable on .NET Framework even when custom response content does not observe the token; cancellation disposes that response. Snapshot creation remains synchronous; cancellation is checked around it. The uploader streams JSON with the attempt token, including on .NET Framework. Gzip finalization also writes asynchronously and observes that token; on older targets only the final compressed bytes are temporarily buffered. The obsolete static forwarders use the same streaming path. The overloads accepting an HttpClient preserve its configured timeout, including values longer than two minutes and Timeout.InfiniteTimeSpan, for both report and attachment requests. The overloads that create their own client use a two-minute timeout.
Await the upload before disposing the run. Cancellation releases active upload requests and attachment streams, keeps your supplied HttpClient usable, and retains the run's captured files. It may leave a report with incomplete attachments on the server. Retrying the whole method creates a new report; cancellation does not roll back an accepted request.
What happens during an upload
- The uploader snapshots the selected run. A bare origin uses
/bdtest; a supplied path is the full report prefix and is preserved for every request; see path prefixes. No warm-up request is sent. - The report is posted as JSON to
<report-base>/data(POST /bdtest/databy default). - On HTTP 408, 429, 500, 502, 503 or 504, a network error or an HTTP client timeout (without caller cancellation), the client retries, up to three attempts in total. It honours a
Retry-Afterheader given in seconds or as an HTTP date. - The server creates a new ID and returns its URL and ID using the negotiated response.
X-BDTest-Report-Idalso identifies it. - For reports with attachments, the response also contains
X-BDTest-Attachment-Token. The client sends that receipt and the upload key toPOST /bdtest/data/{reportId}/attachments/{attachmentId}, once for each referenced attachment. Files stream from the selected run; each transient retry opens a fresh stream. The method returns only after every attachment succeeds.
The server ignores any client-supplied report ID. Each accepted upload, including a retry after a lost response, creates a separate report.
Server-side limits
MaxCompressedUploadByteslimits request body bytes before decompression;MaxUploadByteslimits decompressed JSON and its UTF-8 representation. Both default to 10 MiB, including chunked requests. Exceeding either byte limit returns HTTP 413.POST /bdtest/dataaccepts plain JSON or a singleContent-Encoding: gzip. The uploader and obsolete static forwarders default to gzip. Unsupported encodings return 415; malformed gzip or JSON returns 400.- Content type must be
application/json,text/jsonorapplication/*+json, in UTF-8 or UTF-16. - The rate limit is
UploadsPerMinute(default 60) authenticated uploads per minute per server instance. Excess requests get HTTP 429 withRetry-After. - Invalid JSON returns HTTP 400.
- In cache-only hosting, a report larger than the cache budget returns HTTP 413. See Caching and limits.
After the upload, open the report server. The newest run is at the top of the test run list.
Attachment protocol and limits
Configure an attachment store before uploading captures. Report JSON records the complete manifest before any attachment bytes are accepted. Each ID must be unique, each attempt number positive, and names must contain 1–255 characters without control characters or path separators. MIME types must be concrete, valid types of at most 256 characters, without control characters. Uploaded attachment Uri values must be null. The server validates per-file bytes, total report bytes and attachment count against MaximumAttachmentBytes (10 MiB), MaximumReportAttachmentBytes (100 MiB) and MaximumAttachmentsPerReport (1000). These limits accept zero and reject negative configuration values. The JSON request retains its separate 10 MiB limit.
Attachment requests require the same upload key and share the upload rate limit. Their body must match the manifest length exactly, including chunked requests. The server stages content on disk with bounded memory before passing it to storage. Size and metadata failures do not publish partial content. Attachment bodies do not need JSON encoding, multipart wrapping, base64 or local paths; metadata comes only from the stored report.
Receiving an attachment body does not block other uploads or report deletion. After staging, the server rechecks the report and receipt under a per-report gate before writing. Deleting or expiring the report during staging makes the upload return 404. Store writes coordinate with deletion for that report while other reports can continue independently.
Upload limits apply to admission of new reports and attachment content. Lowering these limits does not hide previously stored downloads; downloads still validate identifiers and metadata. Both upload headers are required independently of interactive login cookies, so machine uploads do not use browser antiforgery tokens.
Treat the receipt token as a secret and never log it. The server overwrites any supplied AttachmentUploadTokenHash with a SHA-256 hash of a new random token; only that hash is persisted in report JSON. Reading a report does not grant write access. A receipt remains valid after a server restart when the report is persisted. A missing or incorrect token returns 401; an unknown report, attachment or missing content returns 404. A receipt cannot write another report, even when attachment IDs match. Report and attachment uploads are separate operations: failure can leave an incomplete report, and retrying the entire upload creates another report. See attachment lifetime and cleanup.