Merge wt/step-logs-t11: Task 11

This commit is contained in:
Joseph Doherty
2026-09-09 11:53:47 -04:00
4 changed files with 271 additions and 16 deletions
@@ -566,4 +566,153 @@ public class FlowGraphViewTests : BunitContext
var edge = Assert.Single(layout.Edges);
Assert.True(edge.Async);
}
// ── Step logs + exception subtitle (step-logs plan 2026-09-09, D8) ──
private static FlowLogLine Line(string level, string message = "m", int seq = 0)
=> new(Guid.NewGuid(), Root, T0.AddMilliseconds(seq), level, "script", message, null, null, null, seq);
/// <summary>A one-node Site-lane graph whose single step carries the given logs / error shape.</summary>
private static FlowGraph LogGraph(
FlowStepOutcome outcome = FlowStepOutcome.Ok,
string? errorMessage = null,
string? errorDetail = null,
IReadOnlyList<FlowLogLine>? logs = null)
{
var step = new FlowStep(Guid.NewGuid(), Root, null, FlowStepKind.ScriptRun, FlowLane.Site, "DoWork", "trigger: manual",
T0, 5, outcome, null, null, errorMessage, "site-a", "node-a", "M1", "DoWork", null, null, null, errorDetail, logs);
return new FlowGraph([step], [], false, false);
}
[Fact]
public void Render_StepWithNoLogLines_HasNoLogBadge()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, LogGraph()));
Assert.Empty(cut.FindAll("[data-test='flow-node-logs']"));
// The footer still exists and holds the subtitle.
Assert.NotNull(cut.Find("g[data-test='flow-node'] .fg-foot [data-test='flow-node-subtitle']"));
}
[Fact]
public void Render_StepWithMixedLevels_ShowsCountsHighestSeverityFirst_DebugCountedAsInfo()
{
var logs = new List<FlowLogLine>
{
Line("error"), Line("warn"), Line("WARN"), Line("info"), Line("info"), Line("info"), Line("debug"),
};
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, LogGraph(logs: logs)));
var badge = cut.Find("[data-test='flow-node-logs']");
Assert.Equal(["✕ 1", "! 2", "≡ 4"], badge.QuerySelectorAll("i").Select(i => i.TextContent.Trim()));
Assert.Equal(["e", "w", "i"], badge.QuerySelectorAll("i").Select(i => i.GetAttribute("class")));
Assert.Equal("1 error, 2 warnings, 4 info", badge.GetAttribute("title"));
}
[Fact]
public void Render_StepWithOnlyInfoLines_ShowsASingleFaintCount()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, LogGraph(logs: [Line("info"), Line("debug")])));
var badge = cut.Find("[data-test='flow-node-logs']");
var item = Assert.Single(badge.QuerySelectorAll("i"));
Assert.Equal("≡ 2", item.TextContent.Trim());
Assert.Equal("i", item.GetAttribute("class"));
Assert.Equal("2 info", badge.GetAttribute("title"));
}
[Fact]
public void Render_FailedStepWithErrorDetail_SubtitleIsTheExceptionTypeAndMessage()
{
var graph = LogGraph(FlowStepOutcome.Failed, errorMessage: "Recipe 42 rejected",
errorDetail: "System.InvalidOperationException: Recipe 42 rejected\n at X");
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, graph));
Assert.Equal("InvalidOperationException: Recipe 42 rejected",
cut.Find("[data-test='flow-node-subtitle']").TextContent.Trim());
}
[Fact]
public void Render_FailedStepWithoutDetail_FallsBackToTheErrorMessage()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, LogGraph(FlowStepOutcome.Failed, errorMessage: "timed out talking to MesDb")));
Assert.Equal("timed out talking to MesDb", cut.Find("[data-test='flow-node-subtitle']").TextContent.Trim());
}
[Fact]
public void Render_OkStepWithAnErrorDetail_KeepsItsNormalSubtitle()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, LogGraph(errorDetail: "System.InvalidOperationException: stale")));
Assert.Equal("trigger: manual", cut.Find("[data-test='flow-node-subtitle']").TextContent.Trim());
}
[Fact]
public void LogCounts_FoldsLevelsCaseInsensitively_AndTreatsEverythingElseAsInfo()
{
Assert.Equal((0, 0, 0), FlowGraphView.LogCounts(null));
Assert.Equal((0, 0, 0), FlowGraphView.LogCounts(LogGraph().Steps[0]));
var step = LogGraph(logs: [Line("Error"), Line("error"), Line("Warn"), Line("info"), Line("debug"), Line("trace")]).Steps[0];
Assert.Equal((2, 1, 3), FlowGraphView.LogCounts(step));
}
[Theory]
[InlineData(FlowStepOutcome.Failed, "System.InvalidOperationException: Recipe 42 rejected\n at X", "boom", "InvalidOperationException: Recipe 42 rejected")]
[InlineData(FlowStepOutcome.TimedOut, "System.TimeoutException: took too long", "boom", "TimeoutException: took too long")]
[InlineData(FlowStepOutcome.Failed, "SomethingWentWrong", "boom", "SomethingWentWrong")]
[InlineData(FlowStepOutcome.Failed, " ", "boom", "boom")]
[InlineData(FlowStepOutcome.Failed, null, "boom", "boom")]
[InlineData(FlowStepOutcome.Ok, "System.InvalidOperationException: nope", "boom", null)]
// A wrapper chains its inner exceptions on the first line — the innermost is the one to act on.
[InlineData(FlowStepOutcome.Failed,
"System.AggregateException: One or more errors occurred. (Recipe 42 rejected) ---> System.InvalidOperationException: Recipe 42 rejected\n at X",
"boom", "InvalidOperationException: Recipe 42 rejected")]
[InlineData(FlowStepOutcome.Failed,
"A.Outer: outer ---> B.Middle: middle ---> C.Innermost: the real one",
"boom", "Innermost: the real one")]
// The namespace is stripped only from the head of the name; a generic suffix stays verbatim.
[InlineData(FlowStepOutcome.Failed, "MyApp.RecipeException`1[System.String]: msg", "boom", "RecipeException`1[System.String]: msg")]
[InlineData(FlowStepOutcome.Failed, "RecipeException`1[System.String]: msg", "boom", "RecipeException`1[System.String]: msg")]
public void ExceptionSubtitle_ParsesTheFirstLineOfTheDetail_OnlyForAFailedStep(
FlowStepOutcome outcome, string? detail, string? message, string? expected)
{
var step = LogGraph(outcome, errorMessage: message, errorDetail: detail).Steps[0];
Assert.Equal(expected, FlowGraphView.ExceptionSubtitle(step));
}
[Fact]
public void LogTitle_NamesOnlyTheNonZeroLevels_WithSingularAndPlural()
{
Assert.Equal("1 error", FlowGraphView.LogTitle((1, 0, 0)));
Assert.Equal("2 errors, 1 warning", FlowGraphView.LogTitle((2, 1, 0)));
Assert.Equal("1 warning, 2 info", FlowGraphView.LogTitle((0, 1, 2)));
Assert.Equal("3 info", FlowGraphView.LogTitle((0, 0, 3)));
Assert.Equal(string.Empty, FlowGraphView.LogTitle((0, 0, 0)));
}
[Fact]
public void Render_OutboundNodeWithLogLines_ShowsBothThePillAndTheBadgeInTheFooter()
{
var correlation = Guid.NewGuid();
var step = new FlowStep(Guid.NewGuid(), Root, null, FlowStepKind.ApiCall, FlowLane.External, "Delmia.AckRecipe",
"Delivered · sync", T0, 212, FlowStepOutcome.Ok, null, null, null, "site-a", "node-a", "M1", "Delmia.AckRecipe", null,
[new FlowLifecycleRow(Guid.NewGuid(), "ApiCall", "Delivered", T0, "node-a", null, 212)],
correlation, null, [Line("warn"), Line("info")]);
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, new FlowGraph([step], [], false, false)));
var foot = cut.Find("g[data-test='flow-node'][data-kind='ApiCall'] .fg-foot");
Assert.Equal("Ok", foot.QuerySelector("[data-test='flow-node-pill']")!.TextContent.Trim());
Assert.Equal("· sync", foot.QuerySelector(".fg-mode")!.TextContent.Trim());
var badge = foot.QuerySelector("[data-test='flow-node-logs']")!;
Assert.Equal(["! 1", "≡ 1"], badge.QuerySelectorAll("i").Select(i => i.TextContent.Trim()));
Assert.Equal("1 warning, 1 info", badge.GetAttribute("title"));
// A pill node has no subtitle line — the pill occupies the footer's left half.
Assert.Null(foot.QuerySelector("[data-test='flow-node-subtitle']"));
}
}