feat(cli): flow-debug run-method|run-value-change|replay
New top-level command group for the Flow Debugger (design 2026-09-05 §7.1). run-method / run-value-change post the FlowDebugRun* management commands with a 2-minute client wait; replay posts FlowDebugReplayCommand. A table projection unwraps the FlowDebugRunResult envelope to one row per step (occurred | kind | lane | target | durationMs | status | error) using the Commons FlowStepProjector's kind/lane resolution, with a trailing note row for an incomplete run (error code verbatim, e.g. STANDBY_NODE) or a truncated event list. --params must be a JSON object (INVALID_PARAMS otherwise). README section added; command-tree test bumped to 18 groups. Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>flow-debug</c> command group (Flow Debugger design 2026-09-05 §7.1): a Live run of
|
||||
/// an inbound API method or a value-change trigger with every routed site run traced, and a
|
||||
/// Replay of a past execution chain from the central audit store. All three are Deployer
|
||||
/// commands answered by the node the CLI is pointed at; a standby central refuses the two
|
||||
/// run commands with <c>STANDBY_NODE</c> (Replay is served from any node).
|
||||
/// </summary>
|
||||
public static class FlowDebugCommands
|
||||
{
|
||||
/// <summary>Client-side wait for the run commands — must exceed the server's run grace + trigger window.</summary>
|
||||
internal static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>Builds the <c>flow-debug</c> command with its subcommands using the given shared CLI options.</summary>
|
||||
/// <param name="urlOption">Shared management URL option.</param>
|
||||
/// <param name="formatOption">Shared output format option.</param>
|
||||
/// <param name="usernameOption">Shared username option for authentication.</param>
|
||||
/// <param name="passwordOption">Shared password option for authentication.</param>
|
||||
/// <returns>The configured <c>flow-debug</c> command.</returns>
|
||||
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var command = new Command("flow-debug") { Description = "Flow Debugger: trace an inbound API method or value-change flow end to end, or replay a past one" };
|
||||
|
||||
command.Add(BuildRunMethod(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildRunValueChange(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildReplay(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildRunMethod(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var methodIdOption = new Option<int>("--method-id") { Description = "Inbound API method ID", Required = true };
|
||||
var paramsOption = new Option<string?>("--params") { Description = "Flat JSON object of method parameters (validated against the method's schema)" };
|
||||
var cmd = new Command("run-method") { Description = "Run an inbound API method on the active central node with full tracing and print the flow's steps" };
|
||||
cmd.Add(methodIdOption);
|
||||
cmd.Add(paramsOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
JsonElement? parameters;
|
||||
try
|
||||
{
|
||||
parameters = ParseParameters(result.GetValue(paramsOption));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
OutputFormatter.WriteError($"--params is not valid JSON: {ex.Message}", "INVALID_PARAMS");
|
||||
return 1;
|
||||
}
|
||||
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new FlowDebugRunMethodCommand(result.GetValue(methodIdOption), parameters),
|
||||
timeout: RunTimeout,
|
||||
tableProjector: FlowDebugTableProjection.ProjectEvents);
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildRunValueChange(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var instanceIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
|
||||
var attributeOption = new Option<string>("--attribute") { Description = "Canonical attribute name to write", Required = true };
|
||||
var valueOption = new Option<string>("--value") { Description = "Value to write (canonical string form)", Required = true };
|
||||
var waitOption = new Option<int>("--wait-seconds") { Description = "How long to wait for a triggered script run before reporting NO_TRIGGER_WITHIN_WINDOW", DefaultValueFactory = _ => 45 };
|
||||
var cmd = new Command("run-value-change") { Description = "Write an attribute through the traced path and capture the script run(s) the change triggers" };
|
||||
cmd.Add(instanceIdOption);
|
||||
cmd.Add(attributeOption);
|
||||
cmd.Add(valueOption);
|
||||
cmd.Add(waitOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var waitSeconds = result.GetValue(waitOption);
|
||||
if (waitSeconds <= 0)
|
||||
{
|
||||
OutputFormatter.WriteError("--wait-seconds must be greater than zero.", "INVALID_ARGUMENT");
|
||||
return 1;
|
||||
}
|
||||
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new FlowDebugRunValueChangeCommand(
|
||||
result.GetValue(instanceIdOption),
|
||||
result.GetValue(attributeOption)!,
|
||||
result.GetValue(valueOption)!,
|
||||
waitSeconds),
|
||||
timeout: RunTimeout,
|
||||
tableProjector: FlowDebugTableProjection.ProjectEvents);
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildReplay(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var executionIdOption = new Option<Guid>("--execution-id") { Description = "Any execution ID in the chain (from an audit row)", Required = true };
|
||||
var cmd = new Command("replay") { Description = "Replay a past flow from the central audit store" };
|
||||
cmd.Add(executionIdOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new FlowDebugReplayCommand(result.GetValue(executionIdOption)),
|
||||
tableProjector: FlowDebugTableProjection.ProjectEvents);
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the <c>--params</c> value into a detached <see cref="JsonElement"/>; null/blank
|
||||
/// means "no parameters". Throws <see cref="JsonException"/> on malformed input or a
|
||||
/// non-object root (the inbound API body is always a flat JSON object).
|
||||
/// </summary>
|
||||
internal static JsonElement? ParseParameters(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
throw new JsonException("expected a JSON object at the root");
|
||||
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Compact table projection for the <c>flow-debug</c> commands.
|
||||
///
|
||||
/// <para>
|
||||
/// The handler returns a <c>FlowDebugRunResult</c> — <c>{ rootExecutionId, completed,
|
||||
/// error, errorCode, events: [ … ], truncated, … }</c> whose <c>events</c> are canonical
|
||||
/// audit events (each carrying its domain fields inside a <c>detailsJson</c> string). Handed
|
||||
/// to the generic renderer unprojected, the object takes the Property/Value branch and the
|
||||
/// whole event array lands in one cell as raw JSON. This unwraps it to one row per step —
|
||||
/// <c>occurred | kind | lane | target | durationMs | status | error</c> — in the same
|
||||
/// chronological order the Flow Debugger page draws them, so a terminal user can read the
|
||||
/// swimlane top-to-bottom. A trailing <c>note</c> row reports a failed / incomplete run
|
||||
/// (with the error code verbatim, e.g. <c>STANDBY_NODE</c>) or a truncated event list.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>JSON output is never touched — <c>--format json</c> gets the full raw result.</para>
|
||||
/// </summary>
|
||||
internal static class FlowDebugTableProjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Projects a <c>FlowDebugRunResult</c> body to the row array the table renderer
|
||||
/// consumes. Returns the input unchanged when it is not JSON or not that shape, so the
|
||||
/// renderer's own fallbacks still apply.
|
||||
/// </summary>
|
||||
/// <param name="json">The raw success JSON body from the management API.</param>
|
||||
/// <returns>Compact JSON suitable for table rendering.</returns>
|
||||
internal static string ProjectEvents(string json)
|
||||
{
|
||||
JsonDocument doc;
|
||||
try
|
||||
{
|
||||
doc = JsonDocument.Parse(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return json;
|
||||
}
|
||||
|
||||
using (doc)
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| !TryGetPropertyCI(root, "events", out var events)
|
||||
|| events.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return json;
|
||||
}
|
||||
|
||||
var rows = new JsonArray();
|
||||
foreach (var evt in events.EnumerateArray())
|
||||
rows.Add(ProjectEvent(evt));
|
||||
|
||||
var completed = TryGetPropertyCI(root, "completed", out var c) && c.ValueKind == JsonValueKind.True;
|
||||
var errorCode = StrValue(root, "errorCode");
|
||||
var error = StrValue(root, "error");
|
||||
var truncated = TryGetPropertyCI(root, "truncated", out var t) && t.ValueKind == JsonValueKind.True;
|
||||
|
||||
var notes = new List<string>();
|
||||
if (!completed || errorCode != null || error != null)
|
||||
{
|
||||
notes.Add(errorCode != null
|
||||
? $"{errorCode}: {error ?? "run did not complete"}"
|
||||
: error ?? "run did not complete");
|
||||
}
|
||||
|
||||
if (truncated)
|
||||
notes.Add("event list truncated — the audit rows remain complete (audit tree --execution-id <root>)");
|
||||
|
||||
if (notes.Count > 0)
|
||||
rows.Add(new JsonObject { ["note"] = JsonValue.Create(string.Join("; ", notes)) });
|
||||
|
||||
return rows.ToJsonString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Projects one canonical audit event (camelCase JSON) to its compact row.</summary>
|
||||
private static JsonNode ProjectEvent(JsonElement evt)
|
||||
{
|
||||
if (evt.ValueKind != JsonValueKind.Object)
|
||||
return JsonValue.Create(evt.ToString())!;
|
||||
|
||||
var details = AuditDetailsCodec.Deserialize(StrValue(evt, "detailsJson"));
|
||||
var kind = FlowStepProjector.ResolveKind(details.Channel, details.Kind);
|
||||
var lane = FlowStepProjector.ResolveLane(kind, details.SourceSiteId);
|
||||
|
||||
string? occurred = null;
|
||||
if (TryGetPropertyCI(evt, "occurredAtUtc", out var o) && o.ValueKind == JsonValueKind.String
|
||||
&& o.TryGetDateTimeOffset(out var when))
|
||||
{
|
||||
occurred = when.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
var target = StrValue(evt, "target");
|
||||
var instance = details.SourceInstanceId;
|
||||
var shownTarget = target ?? details.SourceScript;
|
||||
if (instance != null && shownTarget != null && !shownTarget.StartsWith(instance, StringComparison.Ordinal))
|
||||
shownTarget = $"{instance}.{shownTarget}";
|
||||
|
||||
return new JsonObject
|
||||
{
|
||||
["occurred"] = occurred,
|
||||
["kind"] = kind.ToString(),
|
||||
["lane"] = lane.ToString(),
|
||||
["target"] = shownTarget ?? instance,
|
||||
["durationMs"] = details.DurationMs,
|
||||
["status"] = details.Status,
|
||||
["error"] = details.ErrorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryGetPropertyCI(JsonElement obj, string name, out JsonElement value)
|
||||
{
|
||||
foreach (var prop in obj.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = prop.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? StrValue(JsonElement obj, string name)
|
||||
=> TryGetPropertyCI(obj, name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption,
|
||||
rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
rootCommand.Add(CachedCallCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
rootCommand.Add(SecuredWriteCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
rootCommand.Add(FlowDebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
rootCommand.SetAction(_ =>
|
||||
{
|
||||
|
||||
@@ -2270,6 +2270,85 @@ scadabridge --url <url> cached-call discard --site-id <string> --tracked-operati
|
||||
|
||||
---
|
||||
|
||||
### `flow-debug` — Trace an inbound API or value-change flow end to end
|
||||
|
||||
The CLI face of the **Flow Debugger** (design `docs/plans/2026-09-05-flow-debugger-design.md`).
|
||||
A Live run executes the real thing — the method script on the active central node, or a
|
||||
real attribute write at the site — with tracing switched on for the whole chain, so every
|
||||
routed site script run, attribute write and attribute wait it causes is recorded as a
|
||||
`ScriptTrace` audit row and returned inline. Replay reads those rows back for any past
|
||||
execution. All three sub-commands require the **Deployer** role.
|
||||
|
||||
The two run commands are answered by the node the CLI is pointed at. Through the Traefik
|
||||
load balancer that is the active node; pointed at a standby node directly they refuse with
|
||||
error code `STANDBY_NODE` rather than run the script somewhere it would not run in
|
||||
production. `replay` is served from any node.
|
||||
|
||||
Trace steps are ordinary audit rows: a run leaves them in the central `AuditLog`
|
||||
(channel `ScriptTrace`), so `audit tree --execution-id <rootExecutionId>` shows the same
|
||||
chain after the fact.
|
||||
|
||||
Table output prints one row per step in chronological order —
|
||||
`occurred | kind | lane | target | durationMs | status | error` — with a trailing `note`
|
||||
row when the run did not complete (the error code verbatim) or the inline event list was
|
||||
truncated. `--format json` returns the raw `FlowDebugRunResult` (`rootExecutionId`,
|
||||
`completed`, `error`, `errorCode`, `events`, `truncated`, `startedAtUtc`, `finishedAtUtc`).
|
||||
|
||||
#### `flow-debug run-method`
|
||||
|
||||
Run an inbound API method with the given parameters, exactly as an external caller would,
|
||||
and print the traced flow.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> flow-debug run-method --method-id <int> [--params <json>]
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--method-id` | yes | Inbound API method ID (`api-method list`) |
|
||||
| `--params` | no | Flat JSON object of parameters, validated against the method's parameter schema. Omit for a parameterless method. |
|
||||
|
||||
The client waits up to 2 minutes. Parameters that fail schema validation are reported as
|
||||
`VALIDATION_FAILED` without running the script.
|
||||
|
||||
#### `flow-debug run-value-change`
|
||||
|
||||
Write an attribute on a deployed instance through the traced routed-write path, then wait
|
||||
for the script run(s) the change triggers at the site and print them.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> flow-debug run-value-change --instance-id <int> --attribute <name> --value <string> [--wait-seconds 45]
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--instance-id` | yes | Instance ID |
|
||||
| `--attribute` | yes | Canonical attribute name to write |
|
||||
| `--value` | yes | Value to write, in canonical string form |
|
||||
| `--wait-seconds` | no | How long to wait for a triggered run before reporting `NO_TRIGGER_WITHIN_WINDOW` (default 45) |
|
||||
|
||||
The write itself is real — the attribute changes — and is the root step of the flow.
|
||||
A static attribute triggers directly; a data-sourced attribute triggers when the device
|
||||
echoes the write back, which the site's trace arm (`SiteRuntime:TraceArmWindowSeconds`,
|
||||
default 30) correlates back to this run.
|
||||
|
||||
#### `flow-debug replay`
|
||||
|
||||
Rebuild a past flow from the central audit store.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> flow-debug replay --execution-id <guid>
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--execution-id` | yes | Any execution ID in the chain — the root is resolved server-side |
|
||||
|
||||
Only flows whose site legs were traced (a Live run, or a method with `--trace-all-calls`
|
||||
set) have site steps to replay; an untraced call shows its central `RouteCall` step alone.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
The CLI connects to the Central cluster over **HTTP** (`ManagementHttpClient`), posting to the `/management` endpoints at the configured `managementUrl` — normally the Traefik load balancer, which routes to the active central node. It carries HTTP **Basic** credentials from `--username`/`--password`. Central's endpoint handler asks the `ManagementActor` in-process via `ManagementActorHolder`.
|
||||
|
||||
@@ -41,6 +41,7 @@ public class CommandTreeTests
|
||||
ApiMethodCommands.Build(Url, Format, Username, Password),
|
||||
BundleCommands.Build(Url, Format, Username, Password),
|
||||
CachedCallCommands.Build(Url, Format, Username, Password),
|
||||
FlowDebugCommands.Build(Url, Format, Username, Password),
|
||||
};
|
||||
|
||||
private static IEnumerable<Command> LeafCommands(Command command)
|
||||
@@ -61,11 +62,11 @@ public class CommandTreeTests
|
||||
{
|
||||
var groups = AllCommandGroups().ToList();
|
||||
// CLI-022: bump this count whenever a new top-level command group is
|
||||
// registered in Program.cs. Current registered groups (17):
|
||||
// registered in Program.cs. Current registered groups (18):
|
||||
// template, instance, site, deploy, data-connection, external-system,
|
||||
// notification, security, audit-config, audit, health, debug,
|
||||
// shared-script, db-connection, api-method, bundle, cached-call.
|
||||
Assert.Equal(17, groups.Count);
|
||||
// shared-script, db-connection, api-method, bundle, cached-call, flow-debug.
|
||||
Assert.Equal(18, groups.Count);
|
||||
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>flow-debug</c> command group (Flow Debugger design 2026-09-05 §7.1):
|
||||
/// the command tree shape, <c>--params</c> parsing, and the table projection that turns a
|
||||
/// <see cref="FlowDebugRunResult"/> into one row per step.
|
||||
/// </summary>
|
||||
public class FlowDebugCommandsTests
|
||||
{
|
||||
private static readonly Option<string> Url = new("--url") { Recursive = true };
|
||||
private static readonly Option<string> Username = new("--username") { Recursive = true };
|
||||
private static readonly Option<string> Password = new("--password") { Recursive = true };
|
||||
private static readonly Option<string> Format = CliOptions.CreateFormatOption();
|
||||
|
||||
private static readonly JsonSerializerOptions CamelCase = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
|
||||
[Fact]
|
||||
public void Build_RegistersTheThreeSubcommands_WithTheirRequiredOptions()
|
||||
{
|
||||
var group = FlowDebugCommands.Build(Url, Format, Username, Password);
|
||||
|
||||
Assert.Equal("flow-debug", group.Name);
|
||||
var names = group.Subcommands.Select(c => c.Name).ToList();
|
||||
Assert.Equal(["run-method", "run-value-change", "replay"], names);
|
||||
|
||||
var runMethod = group.Subcommands.Single(c => c.Name == "run-method");
|
||||
Assert.Contains(runMethod.Options, o => o.Name == "--method-id" && o.Required);
|
||||
Assert.Contains(runMethod.Options, o => o.Name == "--params" && !o.Required);
|
||||
|
||||
var runValueChange = group.Subcommands.Single(c => c.Name == "run-value-change");
|
||||
Assert.Contains(runValueChange.Options, o => o.Name == "--instance-id" && o.Required);
|
||||
Assert.Contains(runValueChange.Options, o => o.Name == "--attribute" && o.Required);
|
||||
Assert.Contains(runValueChange.Options, o => o.Name == "--value" && o.Required);
|
||||
Assert.Contains(runValueChange.Options, o => o.Name == "--wait-seconds" && !o.Required);
|
||||
|
||||
var replay = group.Subcommands.Single(c => c.Name == "replay");
|
||||
Assert.Contains(replay.Options, o => o.Name == "--execution-id" && o.Required);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunValueChange_WaitSecondsDefaultsTo45_MatchingTheCommandRecord()
|
||||
{
|
||||
var group = FlowDebugCommands.Build(Url, Format, Username, Password);
|
||||
var parse = group.Parse("run-value-change --instance-id 7 --attribute Speed --value 42");
|
||||
|
||||
Assert.Empty(parse.Errors);
|
||||
var waitOption = (Option<int>)parse.CommandResult.Command.Options.Single(o => o.Name == "--wait-seconds");
|
||||
Assert.Equal(new FlowDebugRunValueChangeCommand(7, "Speed", "42").WaitSeconds, parse.GetValue(waitOption));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlowDebugCommandRecords_ResolveInTheManagementCommandRegistry()
|
||||
{
|
||||
// The CLI derives the wire command name from the record type; a rename on either
|
||||
// side would throw here rather than at runtime.
|
||||
Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugRunMethodCommand))));
|
||||
Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugRunValueChangeCommand))));
|
||||
Assert.False(string.IsNullOrWhiteSpace(ManagementCommandRegistry.GetCommandName(typeof(FlowDebugReplayCommand))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseParameters_Blank_IsNull_AndObject_IsCloned()
|
||||
{
|
||||
Assert.Null(FlowDebugCommands.ParseParameters(null));
|
||||
Assert.Null(FlowDebugCommands.ParseParameters(" "));
|
||||
|
||||
var parsed = FlowDebugCommands.ParseParameters("""{"code":"Z28061","qty":3}""");
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(JsonValueKind.Object, parsed!.Value.ValueKind);
|
||||
Assert.Equal("Z28061", parsed.Value.GetProperty("code").GetString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not json")]
|
||||
[InlineData("[1,2]")]
|
||||
[InlineData("\"scalar\"")]
|
||||
public void ParseParameters_RejectsMalformedOrNonObjectInput(string input)
|
||||
{
|
||||
Assert.ThrowsAny<JsonException>(() => FlowDebugCommands.ParseParameters(input));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The real server shape, serialised from the REAL <see cref="FlowDebugRunResult"/>
|
||||
/// and canonical <see cref="AuditEvent"/> types with the server's camelCase policy, so
|
||||
/// a rename on either fails this test instead of silently blanking a column.
|
||||
/// </summary>
|
||||
private static string RealPayload(bool completed, string? errorCode, string? error, bool truncated)
|
||||
{
|
||||
var root = Guid.NewGuid();
|
||||
var child = Guid.NewGuid();
|
||||
var t0 = new DateTime(2026, 9, 5, 14, 3, 7, 120, DateTimeKind.Utc);
|
||||
var events = new List<AuditEvent>
|
||||
{
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0, actor: "flow-debugger", target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 812),
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0.AddMilliseconds(5), actor: "inbound-api", target: "DelmiaSim01.ProcessRecipeDownload",
|
||||
executionId: root, durationMs: 790),
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0.AddMilliseconds(20), target: "ProcessRecipeDownload", executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", sourceScript: "ProcessRecipeDownload", durationMs: 700),
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWait, AuditStatus.Failed,
|
||||
occurredAtUtc: t0.AddMilliseconds(40), target: "DelmiaSim01.RecipeAck", executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", durationMs: 600, errorMessage: "timed out after 600 ms"),
|
||||
};
|
||||
|
||||
var result = new FlowDebugRunResult(root, completed, error, errorCode, events, truncated,
|
||||
new DateTimeOffset(t0), new DateTimeOffset(t0.AddSeconds(1)));
|
||||
return JsonSerializer.Serialize(result, CamelCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Projection_EmitsOneRowPerStep_WithKindLaneTargetDurationStatus()
|
||||
{
|
||||
var projected = FlowDebugTableProjection.ProjectEvents(RealPayload(completed: true, null, null, truncated: false));
|
||||
|
||||
using var doc = JsonDocument.Parse(projected);
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
var rows = doc.RootElement.EnumerateArray().ToList();
|
||||
Assert.Equal(4, rows.Count);
|
||||
|
||||
Assert.Equal("14:03:07.120", rows[0].GetProperty("occurred").GetString());
|
||||
Assert.Equal("InboundRequest", rows[0].GetProperty("kind").GetString());
|
||||
Assert.Equal("Central", rows[0].GetProperty("lane").GetString());
|
||||
Assert.Equal("POST /api/DelmiaRecipeDownload", rows[0].GetProperty("target").GetString());
|
||||
Assert.Equal(812, rows[0].GetProperty("durationMs").GetInt32());
|
||||
Assert.Equal("Delivered", rows[0].GetProperty("status").GetString());
|
||||
|
||||
Assert.Equal("RouteCall", rows[1].GetProperty("kind").GetString());
|
||||
Assert.Equal("Central", rows[1].GetProperty("lane").GetString());
|
||||
|
||||
Assert.Equal("ScriptRun", rows[2].GetProperty("kind").GetString());
|
||||
Assert.Equal("Site", rows[2].GetProperty("lane").GetString());
|
||||
Assert.Equal("DelmiaSim01.ProcessRecipeDownload", rows[2].GetProperty("target").GetString());
|
||||
|
||||
Assert.Equal("AttributeWait", rows[3].GetProperty("kind").GetString());
|
||||
Assert.Equal("Site", rows[3].GetProperty("lane").GetString()); // waits sit on the Site lane; writes on Tags
|
||||
Assert.Equal("Failed", rows[3].GetProperty("status").GetString());
|
||||
Assert.Equal("timed out after 600 ms", rows[3].GetProperty("error").GetString());
|
||||
|
||||
// A completed, untruncated run has no trailing note row.
|
||||
Assert.All(rows, r => Assert.False(r.TryGetProperty("note", out _)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Projection_IncompleteRun_AppendsANoteRowWithTheErrorCodeVerbatim()
|
||||
{
|
||||
var projected = FlowDebugTableProjection.ProjectEvents(
|
||||
RealPayload(completed: false, "STANDBY_NODE", "This node is standby; run on the active central node.", truncated: false));
|
||||
|
||||
using var doc = JsonDocument.Parse(projected);
|
||||
var last = doc.RootElement.EnumerateArray().Last();
|
||||
var note = last.GetProperty("note").GetString();
|
||||
Assert.StartsWith("STANDBY_NODE: ", note);
|
||||
Assert.Contains("standby", note);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Projection_TruncatedRun_NotesThatTheAuditRowsRemainComplete()
|
||||
{
|
||||
var projected = FlowDebugTableProjection.ProjectEvents(RealPayload(completed: true, null, null, truncated: true));
|
||||
|
||||
using var doc = JsonDocument.Parse(projected);
|
||||
var last = doc.RootElement.EnumerateArray().Last();
|
||||
Assert.Contains("truncated", last.GetProperty("note").GetString());
|
||||
Assert.Contains("audit tree", last.GetProperty("note").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Projection_LeavesNonMatchingBodiesUntouched()
|
||||
{
|
||||
Assert.Equal("<html>proxy</html>", FlowDebugTableProjection.ProjectEvents("<html>proxy</html>"));
|
||||
Assert.Equal("""{"error":"x"}""", FlowDebugTableProjection.ProjectEvents("""{"error":"x"}"""));
|
||||
Assert.Equal("[1]", FlowDebugTableProjection.ProjectEvents("[1]"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user