Merge wt/step-logs-t14: Task 14

This commit is contained in:
Joseph Doherty
2026-09-09 12:19:36 -04:00
6 changed files with 308 additions and 25 deletions
@@ -1,6 +1,7 @@
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;
@@ -14,6 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// 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 };
@@ -302,4 +304,134 @@ public class FlowDebugCommandsTests
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());
}
}