diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs index c1f0e170..71aa2098 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs @@ -255,6 +255,20 @@ internal static class CommandHelpers // element, in first-seen order. Using only items[0] would silently drop // columns for any later element with a different shape. var objectItems = items.Where(i => i.ValueKind == JsonValueKind.Object).ToList(); + + // A "note" property that appears ONLY on single-property rows (a trailing + // run-level note, or — Task 14 review, 2026-09-09 — a step's folded log/exception + // line) is not a real column: every other row's cell there would render blank, + // and column padding would swallow the raw line's own leading indentation. Such + // rows print as a raw unpadded line instead, at their position in the sequence, + // and "note" is excluded from the header union so nothing renders a blank column + // for it. + var noteOnlyRows = objectItems + .Where(i => i.EnumerateObject().Any(p => p.Name == "note")) + .ToList(); + var noteIsRawLine = noteOnlyRows.Count > 0 + && noteOnlyRows.All(i => i.EnumerateObject().Count() == 1); + string[] headers; if (objectItems.Count > 0) { @@ -262,8 +276,12 @@ internal static class CommandHelpers var known = new HashSet(StringComparer.Ordinal); foreach (var item in objectItems) foreach (var prop in item.EnumerateObject()) + { + if (noteIsRawLine && prop.Name == "note") + continue; if (known.Add(prop.Name)) seen.Add(prop.Name); + } headers = seen.ToArray(); } else @@ -275,12 +293,20 @@ internal static class CommandHelpers { if (item.ValueKind == JsonValueKind.Object) { - return headers.Select(h => + if (noteIsRawLine + && item.EnumerateObject().Count() == 1 + && item.TryGetProperty("note", out var noteValue)) + { + return OutputFormatter.TableRow.Raw( + noteValue.ValueKind == JsonValueKind.Null ? "" : noteValue.ToString()); + } + + return OutputFormatter.TableRow.Columns(headers.Select(h => item.TryGetProperty(h, out var val) ? val.ValueKind == JsonValueKind.Null ? "" : val.ToString() - : "").ToArray(); + : "").ToArray()); } - return new[] { item.ToString() }; + return OutputFormatter.TableRow.Columns(new[] { item.ToString() }); }); OutputFormatter.WriteTable(rows, headers); diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs index fc86ac84..62316187 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugCommands.cs @@ -51,7 +51,7 @@ public static class FlowDebugCommands + "(default 5 min), so a larger value still 504s at that ceiling", DefaultValueFactory = _ => 120, }; - var showLogsOption = new Option("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row" }; + var showLogsOption = new Option("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row (table output only)" }; 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); @@ -105,7 +105,7 @@ public static class FlowDebugCommands + "(default 5 min), so a larger value still 504s at that ceiling", DefaultValueFactory = _ => 120, }; - var showLogsOption = new Option("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row" }; + var showLogsOption = new Option("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row (table output only)" }; var cmd = new Command("run-script") { Description = "Run one site script with tracing — a ValueChange/Conditional script is fired by writing its trigger attribute (--value); any other script is called directly (--params)" }; cmd.Add(instanceIdOption); cmd.Add(scriptOption); @@ -162,7 +162,7 @@ public static class FlowDebugCommands var attributeOption = new Option("--attribute") { Description = "Canonical attribute name to write", Required = true }; var valueOption = new Option("--value") { Description = "Value to write (canonical string form)", Required = true }; var waitOption = new Option("--wait-seconds") { Description = "How long to wait for a triggered script run before reporting NO_TRIGGER_WITHIN_WINDOW", DefaultValueFactory = _ => 45 }; - var showLogsOption = new Option("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row" }; + var showLogsOption = new Option("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row (table output only)" }; var cmd = new Command("run-value-change") { Description = "Write an attribute through the traced path and capture the script run(s) the change triggers — the attribute-level path (also fires Expression triggers); prefer run-script to run a named script" }; cmd.Add(instanceIdOption); cmd.Add(attributeOption); @@ -195,7 +195,7 @@ public static class FlowDebugCommands private static Command BuildReplay(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var executionIdOption = new Option("--execution-id") { Description = "Any execution ID in the chain (from an audit row)", Required = true }; - var showLogsOption = new Option("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row" }; + var showLogsOption = new Option("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row (table output only)" }; var cmd = new Command("replay") { Description = "Replay a past flow from the central audit store" }; cmd.Add(executionIdOption); cmd.Add(showLogsOption); diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs index fa6576e3..13a59599 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs @@ -18,13 +18,18 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; /// 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 projects the events through the same /// the Flow Debugger page uses and prints one row per STEP — -/// occurred | kind | lane | target | durationMs | status | lifecycle | correlation | error — in the -/// order the page draws them, so a terminal user can read the swimlane top-to-bottom. An +/// occurred | kind | lane | target | durationMs | status | lifecycle | correlation | error | logs — +/// in the order the page draws them, so a terminal user can read the swimlane top-to-bottom. An /// outbound operation's rows (a cached call's Submit → Forwarded → Attempted → Resolve, a /// notification's Send → Deliver) fold into ONE row whose lifecycle column counts them -/// and whose status is the latest row's (outbound-calls plan 2026-09-06, D3). A trailing -/// note row reports a failed / incomplete run (with the error code verbatim, e.g. -/// STANDBY_NODE) or a truncated event list. +/// and whose status is the latest row's (outbound-calls plan 2026-09-06, D3). logs +/// counts the step's attached ScriptLog lines (step-logs design 2026-09-09); passing +/// showLogs additionally prints those lines, and a failed step's exception detail, as +/// raw indented lines immediately under that step's row — 's table +/// renderer special-cases a single-property { "note": … } row to print it that way +/// instead of padding it into a table cell. A trailing note row (the same special case) +/// reports a failed / incomplete run (with the error code verbatim, e.g. STANDBY_NODE) +/// or a truncated event list. /// /// /// JSON output is never touched — --format json gets the full raw result. @@ -179,7 +184,7 @@ internal static class FlowDebugTableProjection /// Formats one as HH:mm:ss.fff LEVEL source message (UTC, level upper-cased and padded to 5 chars). private static string FormatLogLine(FlowLogLine line) { - var time = line.OccurredAtUtc.UtcDateTime.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture); + var time = line.OccurredAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture); var level = (line.Level ?? "info").ToUpperInvariant().PadRight(5); return $" {time} {level} {line.Source} {line.Message}"; } diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/OutputFormatter.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/OutputFormatter.cs index e4e06043..38a94378 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/OutputFormatter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/OutputFormatter.cs @@ -31,19 +31,74 @@ public static class OutputFormatter /// Data rows; each inner array corresponds to a column in the same order as . /// Column header labels. public static void WriteTable(IEnumerable rows, string[] headers) + => WriteTable(rows.Select(TableRow.Columns), headers); + + /// + /// One row for the overload of WriteTable: either padded + /// column cells like the plain overload, or an already-formatted + /// line printed verbatim with no column padding/alignment at all (Task 14 review, + /// 2026-09-09) — used for a note/log line folded onto a step, which must render as a raw + /// indented line under that step's row rather than as a cell in a table column (every + /// other row's cell there would otherwise render blank, and the line's own leading + /// whitespace would be swallowed by column padding). + /// + internal readonly struct TableRow { - var allRows = new List { headers }; - allRows.AddRange(rows); + private TableRow(string[]? cells, string? rawLine) + { + Cells = cells; + RawLine = rawLine; + } + + internal string[]? Cells { get; } + + internal string? RawLine { get; } + + /// A normal row, rendered as padded columns aligned with the header set. + internal static TableRow Columns(string[] cells) => new(cells, null); + + /// An already-formatted line, printed exactly as given with no column alignment. + internal static TableRow Raw(string line) => new(null, line); + } + + /// + /// Writes a plain-text padded table like , + /// except a row is printed verbatim — unpadded, uninvolved in the + /// column-width computation — at its position in the row sequence, immediately after + /// whatever row precedes it. + /// + /// Data rows, each either padded columns or a raw line. + /// Column header labels. + internal static void WriteTable(IEnumerable rows, string[] headers) + { + var rowList = rows.ToList(); var widths = new int[headers.Length]; - foreach (var row in allRows) + + void Consider(string[] row) + { for (int i = 0; i < Math.Min(row.Length, widths.Length); i++) widths[i] = Math.Max(widths[i], (row[i] ?? "").Length); + } - foreach (var row in allRows) + Consider(headers); + foreach (var row in rowList) + if (row.Cells != null) + Consider(row.Cells); + + void PrintColumns(string[] row) { for (int i = 0; i < headers.Length; i++) Console.Write((i < row.Length ? row[i] ?? "" : "").PadRight(widths[i] + 2)); Console.WriteLine(); } + + PrintColumns(headers); + foreach (var row in rowList) + { + if (row.RawLine != null) + Console.WriteLine(row.RawLine); + else + PrintColumns(row.Cells!); + } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs index 06f01603..3fac25fe 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs @@ -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, --params parsing, and the table projection that turns a /// into one row per step. /// +[Collection("Console")] public class FlowDebugCommandsTests { private static readonly Option Url = new("--url") { Recursive = true }; @@ -378,4 +380,58 @@ public class FlowDebugCommandsTests Assert.DoesNotContain("WARN", projected); Assert.DoesNotContain("exception:", projected); } + + /// + /// Drives the real table renderer ('s table + /// branch, which is what every flow-debug 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 note 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). + /// + [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()); + } }