product: assert audience: test-developer authority: normative
.NET Runner SDK
The Assert runner DOES NOT build your project. You MUST run
dotnet publishand commit the output toassemblies/before the runner can load your DLLs. Seereference/local-dev-commands.mdfor the exact command.
Project setup
Target framework: net10.0.
To return MeasurementPointBase objects (recommended for structured pass/fail limits),
reference WorkflowEngine.Contracts. When you create a .NET or Mixed package through
the TAT UI, WorkflowEngine.Contracts.dll IS automatically copied into assemblies/.
<Reference Include="WorkflowEngine.Contracts">
<HintPath>..\..\assemblies\WorkflowEngine.Contracts.dll</HintPath>
</Reference>
Class and method requirements
- The class MUST be
static, or a regular class with a public parameterless constructor. - The method MUST be
public. - Both
staticand instance methods are supported. async Task<T>andasync Taskare fully supported.
Parameter passing
The runner matches YAML parameters: keys to C# parameter names using reflection.
All values arrive as strings and are automatically converted to the declared type.
| C# type | Conversion |
|---|---|
string |
No conversion |
int |
int.Parse |
double |
double.Parse |
bool |
bool.Parse |
long |
long.Parse |
decimal |
decimal.Parse |
Parameters not present in parameters: are resolved from step-level variables.
Parameters with default values are optional in YAML.
Return types
| Situation | Recommended return type |
|---|---|
| Single measurement, limits in YAML | Scalar (double, bool, string) |
| Multiple output variables, limits in YAML | Dictionary<string, object> |
| Multiple measurements, limits in code | List<MeasurementPointBase> |
| Setup / teardown, no measurement | void / Task |
| Return type | Effect |
|---|---|
| Any scalar | Stored as output variable result — use {{result}} in YAML |
Dictionary<string, object> |
Keys become output variables |
void / Task |
Success, no outputs |
MeasurementPointBase subclass |
Single measurement point recorded |
IEnumerable<MeasurementPointBase> |
Multiple measurement points recorded |
Task<T> |
Async equivalent |
{{result}}IS the implicit variable name for scalar returns. Do NOT confuse{{result}}with{{value}}— the latter looks for a variable named "value".
Examples
using WorkflowEngine.Contracts.MeasurementPoints;
using static WorkflowEngine.Contracts.Types;
// Scalar return — use {{result}} in YAML
public static double MeasureVoltage(string rail)
=> ReadRail(rail);
// Output variables
public static Dictionary<string, object> MeasureVoltage(string channel)
=> new() { ["voltage"] = ReadChannel(channel) };
// No output (setup/teardown)
public static void PowerOn(string rail) => ApplyPower(rail);
// Single numeric measurement (limits in code)
public static NumericMeasurementPoint MeasureVoltage(string channel)
=> new NumericMeasurementPoint
{
Name = "VBOARD",
Value = ReadChannel(channel),
LowerLimit = 4.75,
UpperLimit = 5.25,
Units = "V",
ComparisonOperator = ComparisonOperatorType.GreaterThanOrEqual
};
// Multiple measurements
public static List<MeasurementPointBase> PowerTest(double voltage, double current)
=>
[
new NumericMeasurementPoint { Name = "VOLTAGE", Value = voltage,
LowerLimit = 4.5, UpperLimit = 5.5, Units = "V",
ComparisonOperator = ComparisonOperatorType.GreaterThanOrEqual },
new NumericMeasurementPoint { Name = "CURRENT", Value = current,
LowerLimit = 0.8, UpperLimit = 1.2, Units = "A",
ComparisonOperator = ComparisonOperatorType.GreaterThanOrEqual },
];
// Async
public static async Task<Dictionary<string, object>> MeasureAsync(string channel)
{
double voltage = await ReadChannelAsync(channel);
return new() { ["voltage"] = voltage };
}
ComparisonOperator values
| Value | Meaning |
|---|---|
Equal |
value == limit |
NotEqual |
value != expected |
GreaterThan |
value > LowerLimit |
GreaterThanOrEqual |
value >= LowerLimit |
LessThan |
value < UpperLimit |
LessThanOrEqual |
value <= UpperLimit |
Log |
Informational only — always PASS |
For a range check (low_limit ≤ value ≤ high_limit) use the YAML measurement:
block — the engine handles range comparison automatically.
Emitting Artifacts
A step attaches files (images, Plotly charts, CSV, logs) to the execution report by writing them into the artifact directory. There is no method to call: when the step returns, the runner enumerates that directory and the executor uploads what it finds, attributing each file to the step that produced it.
static readonly string ArtifactDir =
Environment.GetEnvironmentVariable("ARTIFACT_DIR") ?? "/data/artifacts";
Read the variable with that fallback rather than hard-coding the path: the env var is what
makes the assembly correct inside a station, the fallback is what keeps it runnable outside
one. See reference/environment.md.
There is no
EmitArtifact()and noEmitPlotly(). Revisions of this page before 2026-09-17 documented both. Neither has existed in any shipped runner - there is no such method on any type inWorkflowEngine.Contracts, so code calling one does not compile. Assert bug #13. Write files toARTIFACT_DIRinstead. (The Python runner does provideemit_artifact()/emit_plotly()from runner 1.2.0, Assert 2.3.0 - seesdk/python-sdk.md. The .NET runner does not, so a .NET artifact carries no description.)
The executor empties the directory before every step and collects only the files directly in it, not subdirectories.
The file name decides how the artifact is displayed
The reconcile path infers the content type from the extension alone, so the file name is the only thing that controls how the artifact appears in the report:
| Name it | Stored as | Report shows |
|---|---|---|
*.png, *.jpg, *.gif, *.svg |
image/* |
inline thumbnail, click for full size |
*.plotly.json |
application/json |
interactive Plotly chart |
*.csv, *.tsv |
text/csv, text/tab-separated-values |
rendered HTML table |
*.html |
text/html |
opened as a page |
*.txt, *.log, *.json, *.xml, *.pdf, *.zip |
the matching type | download link |
| anything else | application/octet-stream |
download link |
*.plotly.json is a double extension and it is load-bearing. A figure written as
chart.json is stored as application/json too, but fails the name test and renders as raw
JSON rather than a chart.
Inline image example
using System;
using System.IO;
public static class BodeSteps
{
static readonly string ArtifactDir =
Environment.GetEnvironmentVariable("ARTIFACT_DIR") ?? "/data/artifacts";
public static void PlotBode(string frequencies, string gains)
{
Directory.CreateDirectory(ArtifactDir);
var path = Path.Combine(ArtifactDir, "bode-plot.png");
SavePngTo(path); // any charting library that can write a PNG
}
}
The PNG is rendered inline as a thumbnail in the step row of the test report.
Interactive Plotly example
public static void BodeInteractive(string frequencies, string gains)
{
Directory.CreateDirectory(ArtifactDir);
// Any JSON matching the Plotly figure schema: { "data": [...], "layout": {...} }
var figureJson = BuildPlotlyFigureJson(frequencies, gains);
// .plotly.json, not .json - the double extension is what makes the UI render a chart.
File.WriteAllText(Path.Combine(ArtifactDir, "bode-plot.plotly.json"), figureJson);
}
Working example in the shipped samples: sample-advanced-features
(python_modules/bode_artifacts.py) writes a PNG, a Plotly chart and a CSV. The mechanism is
the same directory for both runners, so it is a valid reference for .NET steps too.
Error signalling
Throw any exception → runner catches it → ABORTED verdict. The full exception
message IS stored and displayed in the UI.
Returning null from a Dictionary<string, object> method DOES NOT fail the step —
it is treated as an empty output set.
Logging from test code
Write to Console.Out or Console.Error. Output IS captured and displayed in the
step log. Optional [LEVEL] prefixes are parsed:
Console.WriteLine("Connecting to " + address); // Information
Console.WriteLine("[DEBUG] Raw response: " + raw); // Debug
Console.Error.WriteLine("[WARNING] Retry 1 of 3"); // Warning
Supported prefixes: [INFO], [DEBUG], [WARNING] / [WARN], [ERROR] / [ERR], [TRACE].