c810bd0f7c
#120: Akka ILoggingAdapter lines used to bypass Serilog and go to stdout only. - BuildHocon sets akka.loggers = [Host.Actors.SerilogAkkaLogger] and keeps akka.loglevel = INFO, so Akka Debug chatter is still filtered at the adapter. The INFO+ lines that used to reach stdout now go through the Serilog pipeline instead, with no extra volume. - Lines reach the file sinks with the node enrichers. SourceContext is the logging class's full name, AkkaSource is the actor path, Akka's own timestamp is kept, and format arguments become structured properties. - The Akka logger actor writes on its own thread, after the logging actor's LogContext is gone. To keep the ExecutionId, the two DCL actors log through ExecutionAwareLogging.GetLogger(Context), which records ExecutionLogContext.Current (a new AsyncLocal mirror of the pushed id) in an ExecutionTaggedLogMessage. The logger actor pushes that id again while it writes the line. #56 remainder (coordinator decision: no replicated schema change): - The host pipeline gains ExecutionLogCaptureSink. - While a run is traced, its step logger registers its ExecutionId: the site ScriptLogger from the launcher (plus each inline CallShared child id), and the central InboundScriptLogger from the executor. - Each tagged line becomes a ScriptLog row with LogSource "node" and the new ScriptTraceExtra.LogCategory. - The rows count against the existing MaxLogLinesPerRun cap, are clipped at 2 000 chars, ride the low-priority collector lane and pass the same redactor. Untraced runs cost one volatile read. - Lines are never copied twice. The step loggers' own forwards run inside ExecutionLogCapture.Suppress(), and the sink suppresses itself while it calls a target. The registration is declared inside the launcher's and executor's try, so it ends before the catch blocks log the outcome, and FlushDropped closes it. - FlowGraphBuilder lists node lines in FlowGraph.NodeLogs and never folds them onto a step. - The Flow Debugger shows them in a Node log card (FlowNodeLogPanel): grouped per execution under its run step, with an All | Warn + filter. Live and Replay both read the same rows, so site scope and role rules apply unchanged. - CLI --show-logs prints them as a trailing block. Tests: Commons ExecutionLogCaptureTests, ExecutionLogContext.Current, FlowGraphBuilder node lines. SiteRuntime NodeLogCaptureTests covers cross-actor capture via the launcher, untraced runs capturing nothing, no double capture of script and runtime lines or of the launcher failure line, the shared cap, collector redaction, shared-script child ids and FlushDropped closing capture. InboundNodeLogCaptureTests covers the central path. DCL ExecutionAwareLoggingTests covers the DataConnectionActor write-path Debug line being tagged and an uncorrelated line not being tagged. Host SerilogAkkaLoggerTests covers mapping, the ExecutionId crossing the logger hop, HOCON, ActorSystem boot and the Akka line reaching a traced run's step log end to end. Host LoggerConfigurationTests checks that the sink is wired. bUnit FlowNodeLogPanelTests and a CLI projection test cover the UI and CLI. Every new assertion was mutation-proven. Docs: step-logs design section 12, Component-SiteRuntime, CentralUI, InboundAPI, Host, CLI README, CLAUDE.md. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
528 lines
30 KiB
C#
528 lines
30 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 };
|
|
|
|
/// <summary>Verifies that building the command registers the four subcommands with their required options.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that run-script defaults to a 45-second wait and a 120-second timeout.</summary>
|
|
[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")));
|
|
}
|
|
|
|
/// <summary>Verifies that run-script rejects a wait of zero seconds as an invalid argument.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that run-script rejects a timeout of zero seconds as an invalid argument.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that run-method defaults to a 120-second timeout.</summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that run-method rejects a timeout of zero seconds as an invalid argument.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that run-value-change defaults to a 45-second wait, matching the command record.</summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that the flow-debug command records resolve in the management command registry.</summary>
|
|
[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))));
|
|
}
|
|
|
|
/// <summary>Verifies that parsing blank parameters yields null and that a JSON object is cloned.</summary>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>Verifies that parsing parameters rejects malformed or non-object input.</summary>
|
|
/// <param name="input">The raw parameter text to parse.</param>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that the projection emits one row per step with its kind, lane, target, duration and status.</summary>
|
|
[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 _)));
|
|
}
|
|
|
|
/// <summary>Verifies that the projection of an incomplete run appends a note row carrying the error code verbatim.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that the projection of a truncated run notes that the audit rows remain complete.</summary>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>Verifies that the projection of a run with truncated logs notes that a replay pulls the remainder.</summary>
|
|
[Fact]
|
|
public void Projection_LogsTruncatedRun_NotesThatReplayPullsTheRest()
|
|
{
|
|
// `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());
|
|
}
|
|
|
|
/// <summary>Verifies that the projection reports both truncation flags together.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that the projection leaves response bodies that do not match the expected shape untouched.</summary>
|
|
[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]"));
|
|
}
|
|
|
|
/// <summary>Verifies that the projection folds an outbound call lifecycle into a single external-lane row.</summary>
|
|
[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.</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);
|
|
}
|
|
|
|
/// <summary>Verifies that the logs column counts the log lines attached to a step.</summary>
|
|
[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 _)));
|
|
}
|
|
|
|
/// <summary>Verifies that showing logs prints indented log lines and exception detail beneath the step row.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// ScadaBridge#56: node lines are not on a step (the logs column does not count them); with
|
|
/// --show-logs they print as one trailing block, each naming its logger without the namespace.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Projection_ShowLogs_PrintsNodeLinesAsATrailingBlock()
|
|
{
|
|
var root = Guid.NewGuid();
|
|
var t0 = new DateTime(2026, 11, 10, 10, 0, 0, DateTimeKind.Utc);
|
|
var events = new List<AuditEvent>
|
|
{
|
|
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
|
occurredAtUtc: t0, target: "Runner", executionId: root, sourceSiteId: "site-a",
|
|
sourceInstanceId: "M1", sourceScript: "Runner", durationMs: 50),
|
|
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered,
|
|
occurredAtUtc: t0.AddMilliseconds(5), target: "Runner", executionId: root, requestSummary: "Writing to tag T1",
|
|
extra: new ScriptTraceExtra
|
|
{
|
|
LogLevel = "warn", LogSource = "node", Seq = 1,
|
|
LogCategory = "ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors.DataConnectionActor",
|
|
}.ToJson()),
|
|
};
|
|
var json = JsonSerializer.Serialize(
|
|
new FlowDebugRunResult(root, true, null, null, events, false, new DateTimeOffset(t0), new DateTimeOffset(t0.AddSeconds(1))),
|
|
CamelCase);
|
|
|
|
using var plain = JsonDocument.Parse(FlowDebugTableProjection.ProjectEvents(json));
|
|
var row = Assert.Single(plain.RootElement.EnumerateArray());
|
|
Assert.Equal(0, row.GetProperty("logs").GetInt32());
|
|
|
|
using var shown = JsonDocument.Parse(FlowDebugTableProjection.ProjectEvents(json, showLogs: true));
|
|
var rows = shown.RootElement.EnumerateArray().ToList();
|
|
Assert.Equal(3, rows.Count);
|
|
Assert.Equal("node log (1 line):", rows[1].GetProperty("note").GetString());
|
|
Assert.Equal($" {t0.AddMilliseconds(5):HH:mm:ss.fff} WARN [DataConnectionActor] Writing to tag T1",
|
|
rows[2].GetProperty("note").GetString());
|
|
}
|
|
|
|
/// <summary>Verifies that hiding logs omits the log and exception detail lines.</summary>
|
|
[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 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.
|
|
/// </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());
|
|
}
|
|
}
|