(p => p.Add(x => x.Step, step));
Assert.Contains("active", cut.Find("[data-test=flow-step-tab-log]").ClassName);
var exc = cut.Find("[data-test=flow-step-exception]");
Assert.Equal("System.InvalidOperationException", exc.QuerySelector(".fsd-exc-type")!.TextContent.Trim());
Assert.Equal("Recipe 42 rejected by Galaxy (code 7)", exc.QuerySelector(".fsd-exc-message")!.TextContent.Trim());
Assert.Contains("ScriptRunLauncher.RunScriptAsync", exc.QuerySelector("pre")!.TextContent);
Assert.NotNull(cut.Find("[data-test=flow-step-exception-copy]"));
}
[Fact]
public void WrappedException_TheBlockHeaderNamesTheINNERMOSTType_InFull()
{
// Final review M1: the panel used to parse the OUTERMOST exception of a " ---> " chain
// while the graph node's subtitle showed the innermost โ the two now share
// ExceptionHeader, which always follows the chain to its end. The panel still shows the
// type with its namespace (only the node badge shortens it), and the raw detail is
// untouched, so the wrapper is still visible in the .
const string wrapped =
"System.AggregateException: One or more errors occurred. (Recipe 42 rejected by Galaxy (code 7)) " +
"---> ZB.Scada.RecipeRejectedException: Recipe 42 rejected by Galaxy (code 7)\n" +
" at Submission#0.<>d__0.MoveNext()";
var step = ScriptStep(FlowStepOutcome.Failed, "ProcessRecipeDownload", Guid.NewGuid(), [], wrapped);
var cut = Render(p => p.Add(x => x.Step, step));
var exc = cut.Find("[data-test=flow-step-exception]");
Assert.Equal("ZB.Scada.RecipeRejectedException", exc.QuerySelector(".fsd-exc-type")!.TextContent.Trim());
Assert.Equal("Recipe 42 rejected by Galaxy (code 7)", exc.QuerySelector(".fsd-exc-message")!.TextContent.Trim());
Assert.Contains("System.AggregateException", exc.QuerySelector("pre")!.TextContent);
}
[Fact]
public void OkStepWithLines_OpensOnInput()
{
var step = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid(), [Line("info", "hello", seq: 1)]);
var cut = Render(p => p.Add(x => x.Step, step));
Assert.Contains("active", cut.Find("[data-test=flow-step-tab-input]").ClassName);
Assert.DoesNotContain("active", cut.Find("[data-test=flow-step-tab-log]").ClassName);
Assert.Empty(cut.FindAll("[data-test=flow-step-exception]"));
}
[Fact]
public void WarnFilter_LeavesOnlyWarnAndErrorRows()
{
var step = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid(),
[
Line("info", "Recipe 42 requested", seq: 1),
Line("debug", "Writing header attributes", seq: 2),
Line("warn", "ResultCode 7 is outside the accepted set", seq: 3),
Line("error", "Rejecting recipe 42", seq: 4),
]);
var cut = Render(p => p.Add(x => x.Step, step));
cut.Find("[data-test=flow-step-tab-log]").Click();
Assert.Equal(4, cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]").Count);
cut.Find("[data-test=flow-step-log-filter-warn]").Click();
var rows = cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]");
Assert.Equal(2, rows.Count);
Assert.Equal("warn", rows[0].GetAttribute("data-level"));
Assert.Equal("error", rows[1].GetAttribute("data-level"));
cut.Find("[data-test=flow-step-log-filter-all]").Click();
Assert.Equal(4, cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]").Count);
}
[Fact]
public void Hint_NamesAnotherStepOfTheSameExecution_AndSelectsIt()
{
var exec = Guid.NewGuid();
var selected = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", exec, [Line("info", "a", seq: 1)]);
var other = ScriptStep(FlowStepOutcome.Ok, "Write RecipeId, RecipeName", exec, [Line("warn", "acked Uncertain", seq: 2)]);
var elsewhere = ScriptStep(FlowStepOutcome.Ok, "Another execution", Guid.NewGuid(), [Line("info", "c", seq: 3)]);
var graph = new FlowGraph([selected, other, elsewhere], [], false, false);
FlowStep? picked = null;
var cut = Render(p => p
.Add(x => x.Step, selected)
.Add(x => x.Graph, graph)
.Add(x => x.OnSelectStep, s => picked = s));
cut.Find("[data-test=flow-step-tab-log]").Click();
var hint = cut.Find("[data-test=flow-step-log-hint]");
Assert.Contains("1 more on", hint.TextContent);
Assert.Contains("Write RecipeId, RecipeName", hint.TextContent);
Assert.DoesNotContain("Another execution", hint.TextContent);
hint.QuerySelector("button")!.Click();
Assert.Same(other, picked);
}
[Fact]
public void Hint_WithoutASelectCallback_IsPlainText()
{
var exec = Guid.NewGuid();
var selected = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", exec, [Line("info", "a", seq: 1)]);
var other = ScriptStep(FlowStepOutcome.Ok, "Write RecipeId, RecipeName", exec, [Line("warn", "b", seq: 2)]);
var graph = new FlowGraph([selected, other], [], false, false);
var cut = Render(p => p.Add(x => x.Step, selected).Add(x => x.Graph, graph));
cut.Find("[data-test=flow-step-tab-log]").Click();
var hint = cut.Find("[data-test=flow-step-log-hint]");
Assert.Contains("Write RecipeId, RecipeName", hint.TextContent);
Assert.Null(hint.QuerySelector("button"));
}
[Fact]
public void OutputAlert_SeeExceptionLink_SwitchesToTheLogTab()
{
var step = ScriptStep(FlowStepOutcome.Failed, "ProcessRecipeDownload", Guid.NewGuid(),
[Line("error", "Rejecting recipe 42", seq: 1)], ExceptionDetail);
var cut = Render(p => p.Add(x => x.Step, step));
cut.Find("[data-test=flow-step-tab-output]").Click();
Assert.Contains("Recipe 42 rejected", cut.Find("[data-test=flow-step-error]").TextContent);
cut.Find("[data-test=flow-step-error-see-log]").Click();
Assert.Contains("active", cut.Find("[data-test=flow-step-tab-log]").ClassName);
Assert.NotNull(cut.Find("[data-test=flow-step-exception]"));
}
[Fact]
public void RuntimeSourcedLine_ShowsItsSource_AndAnAttachedException()
{
var step = ScriptStep(FlowStepOutcome.Ok, "Write RecipeId", Guid.NewGuid(),
[
Line("warn", "acked with quality Uncertain", source: "runtime", seq: 1,
exceptionMessage: "Tag write was not confirmed", exceptionDetail: "System.TimeoutException: Tag write was not confirmed\n at Dcl"),
]);
var cut = Render(p => p.Add(x => x.Step, step));
cut.Find("[data-test=flow-step-tab-log]").Click();
var row = cut.Find("[data-test=flow-step-log-lines] tr[data-level=warn]");
Assert.Contains("runtime", row.TextContent);
Assert.Contains("Tag write was not confirmed", row.QuerySelector(".fsd-line-exc")!.TextContent);
Assert.Contains("System.TimeoutException", row.QuerySelector("details pre")!.TextContent);
}
[Fact]
public void SwitchingSteps_KeepsAStillValidTab()
{
var a = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid());
var b = ScriptStep(FlowStepOutcome.Ok, "AckRecipe", Guid.NewGuid());
var cut = Render(p => p.Add(x => x.Step, a));
cut.Find("[data-test=flow-step-tab-timing]").Click();
Assert.Contains("active", cut.Find("[data-test=flow-step-tab-timing]").ClassName);
cut.Render(p => p.Add(x => x.Step, b));
Assert.Contains("active", cut.Find("[data-test=flow-step-tab-timing]").ClassName);
Assert.DoesNotContain("active", cut.Find("[data-test=flow-step-tab-input]").ClassName);
}
[Fact]
public void RefreshingTheSameStep_KeepsTheTabAndTheLevelFilter()
{
// The page re-reads outcomes after a Live run and hands back a NEW FlowStep instance carrying
// the same EventId plus whatever rows have landed since โ that must not disturb the panel.
var eventId = Guid.NewGuid();
var exec = Guid.NewGuid();
FlowStep Refreshed(IReadOnlyList logs)
=> new(eventId, exec, null, FlowStepKind.ScriptRun, FlowLane.Site, "ProcessRecipeDownload",
"sub", T0, 1731, FlowStepOutcome.Failed, "{}", "{}", "Recipe 42 rejected by Galaxy (code 7)",
"site-a", "node-a", "DelmiaSim01", "ProcessRecipeDownload", null, null, null, ExceptionDetail, logs);
var first = Refreshed([Line("info", "Recipe 42 requested", seq: 1), Line("warn", "ResultCode 7", seq: 2)]);
var cut = Render(p => p.Add(x => x.Step, first));
Assert.Equal("Log (2)", cut.Find("[data-test=flow-step-tab-log]").TextContent.Trim());
cut.Find("[data-test=flow-step-log-filter-warn]").Click();
Assert.Single(cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]"));
cut.Find("[data-test=flow-step-tab-timing]").Click();
cut.Render(p => p.Add(x => x.Step, Refreshed(
[
Line("info", "Recipe 42 requested", seq: 1),
Line("warn", "ResultCode 7", seq: 2),
Line("error", "Rejecting recipe 42", seq: 3),
])));
Assert.Contains("active", cut.Find("[data-test=flow-step-tab-timing]").ClassName);
Assert.Equal("Log (3)", cut.Find("[data-test=flow-step-tab-log]").TextContent.Trim());
cut.Find("[data-test=flow-step-tab-log]").Click();
Assert.Contains("btn-secondary", cut.Find("[data-test=flow-step-log-filter-warn]").ClassName);
var rows = cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]");
Assert.Equal(2, rows.Count);
Assert.Equal("warn", rows[0].GetAttribute("data-level"));
Assert.Equal("error", rows[1].GetAttribute("data-level"));
}
}