466 lines
25 KiB
C#
466 lines
25 KiB
C#
using System.CommandLine;
|
|
using System.Text.Json;
|
|
using ZB.MOM.WW.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.CLI;
|
|
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.Debugging;
|
|
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>
|
|
[Collection("Console")]
|
|
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_RegistersTheFourSubcommands_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-script", "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);
|
|
Assert.Contains(runMethod.Options, o => o.Name == "--timeout-seconds" && !o.Required);
|
|
Assert.DoesNotContain(runMethod.Options, o => o.Name == "--wait-seconds");
|
|
|
|
var runScript = group.Subcommands.Single(c => c.Name == "run-script");
|
|
Assert.Contains(runScript.Options, o => o.Name == "--instance-id" && o.Required);
|
|
Assert.Contains(runScript.Options, o => o.Name == "--script" && o.Required);
|
|
Assert.Contains(runScript.Options, o => o.Name == "--params" && !o.Required);
|
|
Assert.Contains(runScript.Options, o => o.Name == "--value" && !o.Required);
|
|
Assert.Contains(runScript.Options, o => o.Name == "--wait-seconds" && !o.Required);
|
|
Assert.Contains(runScript.Options, o => o.Name == "--timeout-seconds" && !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);
|
|
Assert.DoesNotContain(runValueChange.Options, o => o.Name == "--timeout-seconds");
|
|
|
|
var replay = group.Subcommands.Single(c => c.Name == "replay");
|
|
Assert.Contains(replay.Options, o => o.Name == "--execution-id" && o.Required);
|
|
}
|
|
|
|
[Fact]
|
|
public void RunScript_Defaults_WaitSeconds45_TimeoutSeconds120()
|
|
{
|
|
var group = FlowDebugCommands.Build(Url, Format, Username, Password);
|
|
var parse = group.Parse("run-script --instance-id 7 --script Manual --params {\"n\":3}");
|
|
Assert.Empty(parse.Errors);
|
|
var cmd = group.Subcommands.Single(c => c.Name == "run-script");
|
|
Assert.Equal(45, parse.GetValue((Option<int>)cmd.Options.Single(o => o.Name == "--wait-seconds")));
|
|
Assert.Equal(120, parse.GetValue((Option<int>)cmd.Options.Single(o => o.Name == "--timeout-seconds")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunScript_WaitSecondsZero_IsRejectedWithInvalidArgument()
|
|
{
|
|
var group = FlowDebugCommands.Build(Url, Format, Username, Password);
|
|
|
|
var exitCode = await group.Parse("run-script --instance-id 7 --script Manual --wait-seconds 0").InvokeAsync();
|
|
|
|
Assert.Equal(1, exitCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunScript_TimeoutSecondsZero_IsRejectedWithInvalidArgument()
|
|
{
|
|
var group = FlowDebugCommands.Build(Url, Format, Username, Password);
|
|
|
|
var exitCode = await group.Parse("run-script --instance-id 7 --script Manual --timeout-seconds 0").InvokeAsync();
|
|
|
|
Assert.Equal(1, exitCode);
|
|
}
|
|
|
|
[Fact]
|
|
public void RunMethod_TimeoutSecondsDefaultsTo120()
|
|
{
|
|
var group = FlowDebugCommands.Build(Url, Format, Username, Password);
|
|
var parse = group.Parse("run-method --method-id 4");
|
|
|
|
Assert.Empty(parse.Errors);
|
|
var timeoutOption = (Option<int>)parse.CommandResult.Command.Options.Single(o => o.Name == "--timeout-seconds");
|
|
Assert.Equal(120, parse.GetValue(timeoutOption));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunMethod_TimeoutSecondsZero_IsRejectedWithInvalidArgument()
|
|
{
|
|
var group = FlowDebugCommands.Build(Url, Format, Username, Password);
|
|
|
|
var exitCode = await group.Parse("run-method --method-id 4 --timeout-seconds 0").InvokeAsync();
|
|
|
|
Assert.Equal(1, exitCode);
|
|
}
|
|
|
|
[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(FlowDebugRunScriptCommand))));
|
|
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, bool logsTruncated = false)
|
|
{
|
|
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)), logsTruncated);
|
|
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_LogsTruncatedRun_NotesThatReplayPullsTheRest()
|
|
{
|
|
// Final review M8: `logsTruncated` is a SEPARATE signal from `truncated` (the inline
|
|
// collector sheds low-priority ScriptLog rows before it sheds step rows). The page shows
|
|
// it; the CLI used to drop it silently.
|
|
var projected = FlowDebugTableProjection.ProjectEvents(
|
|
RealPayload(completed: true, null, null, truncated: false, logsTruncated: true));
|
|
|
|
using var doc = JsonDocument.Parse(projected);
|
|
var rows = doc.RootElement.EnumerateArray().ToList();
|
|
Assert.Equal(5, rows.Count); // the four steps plus the note
|
|
Assert.Equal("inline log lines capped — replay pulls the rest", rows[^1].GetProperty("note").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public void Projection_BothTruncationFlags_AreReportedTogether()
|
|
{
|
|
var projected = FlowDebugTableProjection.ProjectEvents(
|
|
RealPayload(completed: true, null, null, truncated: true, logsTruncated: true));
|
|
|
|
using var doc = JsonDocument.Parse(projected);
|
|
var note = doc.RootElement.EnumerateArray().Last().GetProperty("note").GetString();
|
|
Assert.Contains("event list truncated", note);
|
|
Assert.Contains("inline log lines capped — replay pulls the rest", note);
|
|
}
|
|
|
|
[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]"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Projection_FoldsAnOutboundLifecycle_IntoOneExternalLaneRow()
|
|
{
|
|
var root = Guid.NewGuid();
|
|
var child = Guid.NewGuid();
|
|
var corr = Guid.NewGuid();
|
|
var t0 = new DateTime(2026, 9, 6, 9, 0, 0, 0, DateTimeKind.Utc);
|
|
var events = new List<AuditEvent>
|
|
{
|
|
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
|
|
occurredAtUtc: t0, target: "DelmiaSim01.OutboundDemo", executionId: root, durationMs: 300,
|
|
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()),
|
|
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
|
occurredAtUtc: t0.AddMilliseconds(10), target: "OutboundDemo", executionId: child, parentExecutionId: root,
|
|
sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", durationMs: 250),
|
|
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered,
|
|
occurredAtUtc: t0.AddMilliseconds(20), target: "RigRest.Echo", executionId: child, parentExecutionId: root,
|
|
sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", durationMs: 40),
|
|
ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted,
|
|
occurredAtUtc: t0.AddMilliseconds(70), target: "RigDb", correlationId: corr, executionId: child, parentExecutionId: root,
|
|
sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01"),
|
|
ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.DbWriteCached, AuditStatus.Attempted,
|
|
occurredAtUtc: t0.AddMilliseconds(90), target: "RigDb", correlationId: corr, executionId: child, parentExecutionId: root,
|
|
sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", durationMs: 15),
|
|
ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.CachedResolve, AuditStatus.Delivered,
|
|
occurredAtUtc: t0.AddMilliseconds(120), target: "RigDb", correlationId: corr, executionId: child, parentExecutionId: root,
|
|
sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01"),
|
|
};
|
|
var result = new FlowDebugRunResult(root, true, null, null, events, false, new DateTimeOffset(t0), new DateTimeOffset(t0.AddSeconds(1)));
|
|
|
|
var projected = FlowDebugTableProjection.ProjectEvents(JsonSerializer.Serialize(result, CamelCase));
|
|
|
|
using var doc = JsonDocument.Parse(projected);
|
|
var rows = doc.RootElement.EnumerateArray().ToList();
|
|
Assert.Equal(4, rows.Count); // route, run, sync api call, ONE folded db write
|
|
|
|
Assert.Equal("ApiCall", rows[2].GetProperty("kind").GetString());
|
|
Assert.Equal("External", rows[2].GetProperty("lane").GetString());
|
|
Assert.Equal("RigRest.Echo", rows[2].GetProperty("target").GetString());
|
|
Assert.Equal("1 row", rows[2].GetProperty("lifecycle").GetString());
|
|
Assert.Equal(40, rows[2].GetProperty("durationMs").GetInt32());
|
|
|
|
Assert.Equal("DbCall", rows[3].GetProperty("kind").GetString());
|
|
Assert.Equal("External", rows[3].GetProperty("lane").GetString());
|
|
Assert.Equal("RigDb", rows[3].GetProperty("target").GetString());
|
|
Assert.Equal("3 rows", rows[3].GetProperty("lifecycle").GetString());
|
|
Assert.Equal(corr.ToString("D"), rows[3].GetProperty("correlation").GetString());
|
|
Assert.Equal("Delivered", rows[3].GetProperty("status").GetString());
|
|
Assert.Equal(50, rows[3].GetProperty("durationMs").GetInt32());
|
|
Assert.Equal("09:00:00.070", rows[3].GetProperty("occurred").GetString());
|
|
|
|
Assert.Equal("DelmiaSim01.OutboundDemo", rows[1].GetProperty("target").GetString());
|
|
Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("lifecycle").ValueKind);
|
|
Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("correlation").ValueKind);
|
|
}
|
|
|
|
/// <summary>Builds a run whose script step carries two <c>ScriptLog</c> rows and fails with an exception detail (step-logs plan 2026-09-09, Task 14).</summary>
|
|
private static string StepLogsPayload(out DateTime log1Time, out DateTime log2Time)
|
|
{
|
|
var root = Guid.NewGuid();
|
|
var child = Guid.NewGuid();
|
|
var t0 = new DateTime(2026, 9, 9, 10, 0, 0, 0, DateTimeKind.Utc);
|
|
log1Time = t0.AddMilliseconds(30);
|
|
log2Time = t0.AddMilliseconds(31);
|
|
var events = new List<AuditEvent>
|
|
{
|
|
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
|
|
occurredAtUtc: t0, target: "DelmiaSim01.ProcessRecipeDownload", executionId: root, durationMs: 100,
|
|
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()),
|
|
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Failed,
|
|
occurredAtUtc: t0.AddMilliseconds(10), target: "ProcessRecipeDownload", executionId: child, parentExecutionId: root,
|
|
sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", sourceScript: "ProcessRecipeDownload", durationMs: 50,
|
|
errorMessage: "boom", errorDetail: "System.InvalidOperationException: boom\n at X\n at Y\n at Z"),
|
|
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered,
|
|
occurredAtUtc: log1Time, target: "ProcessRecipeDownload", executionId: child,
|
|
requestSummary: "hello", extra: new ScriptTraceExtra { LogLevel = "warn", LogSource = "script", Seq = 1 }.ToJson()),
|
|
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered,
|
|
occurredAtUtc: log2Time, target: "ProcessRecipeDownload", executionId: child,
|
|
requestSummary: "world", extra: new ScriptTraceExtra { LogLevel = "warn", LogSource = "script", Seq = 2 }.ToJson()),
|
|
};
|
|
|
|
var result = new FlowDebugRunResult(root, true, null, null, events, false, new DateTimeOffset(t0), new DateTimeOffset(t0.AddSeconds(1)));
|
|
return JsonSerializer.Serialize(result, CamelCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void Projection_LogsColumn_CountsAttachedLogLines()
|
|
{
|
|
var projected = FlowDebugTableProjection.ProjectEvents(StepLogsPayload(out _, out _));
|
|
|
|
using var doc = JsonDocument.Parse(projected);
|
|
var rows = doc.RootElement.EnumerateArray().ToList();
|
|
Assert.Equal(2, rows.Count); // RouteCall + ScriptRun — no note rows without --show-logs
|
|
|
|
Assert.Equal(0, rows[0].GetProperty("logs").GetInt32());
|
|
Assert.Equal(2, rows[1].GetProperty("logs").GetInt32());
|
|
Assert.All(rows, r => Assert.False(r.TryGetProperty("note", out _)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Projection_ShowLogsTrue_PrintsIndentedLogLinesAndExceptionDetail_UnderTheStepRow()
|
|
{
|
|
var json = StepLogsPayload(out var log1Time, out var log2Time);
|
|
var projected = FlowDebugTableProjection.ProjectEvents(json, showLogs: true);
|
|
|
|
using var doc = JsonDocument.Parse(projected);
|
|
var rows = doc.RootElement.EnumerateArray().ToList();
|
|
|
|
// RouteCall, ScriptRun, 2 log lines, 3 exception-detail lines (capped at 3 of the 4 available).
|
|
Assert.Equal(7, rows.Count);
|
|
|
|
var expectedLog1 = $" {log1Time:HH:mm:ss.fff} WARN script hello";
|
|
var expectedLog2 = $" {log2Time:HH:mm:ss.fff} WARN script world";
|
|
Assert.Equal(expectedLog1, rows[2].GetProperty("note").GetString());
|
|
Assert.Equal(expectedLog2, rows[3].GetProperty("note").GetString());
|
|
|
|
Assert.Equal(" exception: System.InvalidOperationException: boom", rows[4].GetProperty("note").GetString());
|
|
Assert.Equal(" " + " at X", rows[5].GetProperty("note").GetString());
|
|
Assert.Equal(" " + " at Y", rows[6].GetProperty("note").GetString());
|
|
|
|
Assert.DoesNotContain("at Z", projected);
|
|
}
|
|
|
|
[Fact]
|
|
public void Projection_ShowLogsFalse_OmitsLogAndExceptionDetailLines()
|
|
{
|
|
var projected = FlowDebugTableProjection.ProjectEvents(StepLogsPayload(out _, out _), showLogs: false);
|
|
|
|
Assert.DoesNotContain("WARN", projected);
|
|
Assert.DoesNotContain("exception:", projected);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drives the real table renderer (<see cref="CommandHelpers.HandleResponse"/>'s table
|
|
/// branch, which is what every <c>flow-debug</c> command actually prints to the terminal)
|
|
/// rather than only inspecting the intermediate JSON — a code review of Task 14 found the
|
|
/// generic column-union renderer padded the log/exception <c>note</c> rows into a far-right
|
|
/// "note" column instead of printing them as raw indented lines under their step, which the
|
|
/// JSON-level assertions above could not have caught (Task 14 review, 2026-09-09).
|
|
/// </summary>
|
|
[Fact]
|
|
public void HandleResponse_TableFormat_ShowLogsTrue_PrintsLogLineRawUnderScriptRunRow_NoNoteHeader()
|
|
{
|
|
var json = StepLogsPayload(out var log1Time, out var log2Time);
|
|
var projected = FlowDebugTableProjection.ProjectEvents(json, showLogs: true);
|
|
var response = new ManagementResponse(200, projected, null, null);
|
|
|
|
var writer = new StringWriter();
|
|
Console.SetOut(writer);
|
|
try
|
|
{
|
|
var exitCode = CommandHelpers.HandleResponse(response, "table");
|
|
Assert.Equal(0, exitCode);
|
|
}
|
|
finally
|
|
{
|
|
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
|
}
|
|
|
|
var lines = writer.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
// The header row must not gain a "note" column — a raw log/exception line has nowhere
|
|
// sensible to sit in a column table, so it must never become one.
|
|
Assert.DoesNotContain("note", lines[0]);
|
|
|
|
var scriptRunLineIndex = Array.FindIndex(lines, l => l.Contains("ScriptRun"));
|
|
Assert.True(scriptRunLineIndex >= 0, "expected a ScriptRun row in the rendered table");
|
|
|
|
var expectedLog1 = $" {log1Time:HH:mm:ss.fff} WARN script hello";
|
|
var expectedLog2 = $" {log2Time:HH:mm:ss.fff} WARN script world";
|
|
|
|
// The two log lines print as raw, unpadded lines immediately under the ScriptRun row —
|
|
// not aligned into any table column.
|
|
var log1Line = lines[scriptRunLineIndex + 1];
|
|
Assert.StartsWith(" ", log1Line);
|
|
Assert.Contains("WARN", log1Line);
|
|
Assert.Equal(expectedLog1, log1Line);
|
|
Assert.Equal(expectedLog2, lines[scriptRunLineIndex + 2]);
|
|
|
|
// The exception detail follows, also raw and capped at 3 of the 4 available lines.
|
|
Assert.Equal(" exception: System.InvalidOperationException: boom", lines[scriptRunLineIndex + 3]);
|
|
Assert.Equal(" at X", lines[scriptRunLineIndex + 4]);
|
|
Assert.Equal(" at Y", lines[scriptRunLineIndex + 5]);
|
|
Assert.DoesNotContain("at Z", writer.ToString());
|
|
}
|
|
}
|