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:
@@ -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