From 32a132c3a179bb25b948e8ce58537a1c6c737e21 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 21 Sep 2026 21:37:20 -0400 Subject: [PATCH] Render flow summary as one bullet per step instead of one blob Matches the same fix already proven on feature/ev-ov-dv-multiname-certs: add FlowLogger.GetSummaryEntries(), which returns one dictionary entry per step (plus a header entry) instead of a single multi-line block. AttachFlowSummary now merges these into EnrollmentContext directly so Command's bulleted rendering shows a readable line per step, rather than one run-on entry with embedded newlines that don't render as separate bullets. --- .../CSCGlobalCAPluginTests.cs | 10 ++-- cscglobal-caplugin.Tests/FlowLoggerTests.cs | 45 ++++++++++++++++++ cscglobal-caplugin/CSCGlobalCAPlugin.cs | 6 ++- cscglobal-caplugin/FlowLogger.cs | 47 +++++++++++++++++++ 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index a062965..6443a8d 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -838,10 +838,11 @@ public async Task Enroll_New_Success_ReturnsExternalValidation() Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); Assert.Equal("uuid-new", result.CARequestID); // Command's enrollment UI doesn't surface StatusMessage on a successful/pending result - - // only EnrollmentContext is - so the flow summary must be attached there instead. + // only EnrollmentContext is - so the flow summary must be attached there instead, one + // bullet per step so it renders readably rather than as a single run-on blob. Assert.NotNull(result.EnrollmentContext); - Assert.True(result.EnrollmentContext.ContainsKey("Flow Summary")); - Assert.Contains("Enroll-New", result.EnrollmentContext["Flow Summary"]); + Assert.True(result.EnrollmentContext.ContainsKey("Flow: Enroll-New")); + Assert.True(result.EnrollmentContext.Keys.Count(k => k.StartsWith("Flow Step ")) > 1); } [Fact] @@ -867,7 +868,8 @@ public async Task Enroll_New_SuccessWithDcvDetails_KeepsDcvEntriesAlongsideFlowS RequestFormat.PKCS10, EnrollmentType.New); Assert.Equal("token", result.EnrollmentContext["_dnsauth.example.com"]); - Assert.True(result.EnrollmentContext.ContainsKey("Flow Summary")); + Assert.True(result.EnrollmentContext.ContainsKey("Flow: Enroll-New")); + Assert.True(result.EnrollmentContext.Keys.Count(k => k.StartsWith("Flow Step ")) > 1); } [Fact] diff --git a/cscglobal-caplugin.Tests/FlowLoggerTests.cs b/cscglobal-caplugin.Tests/FlowLoggerTests.cs index 2dea519..a9834fd 100644 --- a/cscglobal-caplugin.Tests/FlowLoggerTests.cs +++ b/cscglobal-caplugin.Tests/FlowLoggerTests.cs @@ -117,6 +117,51 @@ public void EndBranch_WithoutBranch_DoesNotThrow() flow.EndBranch(); } + [Fact] + public void GetSummaryEntries_OneEntryPerStepPlusHeader() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "MyFlow"); + flow.Step("StepOne"); + flow.Fail("StepTwo", "boom"); + + var entries = flow.GetSummaryEntries(); + + Assert.True(entries.ContainsKey("Flow: MyFlow")); + Assert.Contains("FAILED", entries["Flow: MyFlow"]); + Assert.Equal(3, entries.Count); // header + 2 steps + Assert.Contains(entries, e => e.Key.Contains("StepOne") && e.Value.Contains("OK")); + Assert.Contains(entries, e => e.Key.Contains("StepTwo") && e.Value.Contains("boom")); + } + + [Fact] + public void GetSummaryEntries_AllStepsSucceed_HeaderReportsOk() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "MyFlow"); + flow.Step("StepOne"); + flow.Step("StepTwo"); + + var entries = flow.GetSummaryEntries(); + + Assert.Contains("[OK]", entries["Flow: MyFlow"]); + } + + [Fact] + public void GetSummaryEntries_BranchChildren_IncludedAsSeparateEntries() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "MyFlow"); + flow.Branch("Inner"); + flow.Step("NestedStep"); + flow.Fail("NestedFail", "inner reason"); + flow.EndBranch(); + flow.Step("TopLevelStep"); + + var entries = flow.GetSummaryEntries(); + + Assert.Contains(entries, e => e.Key.Contains("NestedStep")); + Assert.Contains(entries, e => e.Key.Contains("NestedFail") && e.Value.Contains("inner reason")); + Assert.Contains(entries, e => e.Key.Contains("TopLevelStep")); + } + [Fact] public void Dispose_NoSteps_DoesNotThrow() { diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index c84f909..928e8d7 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -1011,8 +1011,12 @@ private static void AttachFlowSummary(EnrollmentResult? result, FlowLogger flow) return; } + // One EnrollmentContext entry per step (rather than one entry holding the whole + // multi-line summary) so Command's bulleted rendering shows a readable line per step + // instead of a single run-on blob. result.EnrollmentContext ??= new Dictionary(); - result.EnrollmentContext["Flow Summary"] = flow.GetSummary(); + foreach (var entry in flow.GetSummaryEntries()) + result.EnrollmentContext[entry.Key] = entry.Value; } //done diff --git a/cscglobal-caplugin/FlowLogger.cs b/cscglobal-caplugin/FlowLogger.cs index cd6718f..4ce4ef4 100644 --- a/cscglobal-caplugin/FlowLogger.cs +++ b/cscglobal-caplugin/FlowLogger.cs @@ -252,6 +252,53 @@ private static void AppendSummaryLine(StringBuilder sb, FlowStep step, int inden sb.AppendLine($"{indent}{icon} {step.Name}{elapsed}{detail}"); } + /// + /// Same information as , but as one entry per step instead of a + /// single multi-line block. Intended for callers (e.g. EnrollmentResult.EnrollmentContext) + /// whose rendering surface displays a dictionary as a bulleted list and doesn't respect + /// embedded newlines - each step becomes its own bullet instead of one run-on line. + /// + public Dictionary GetSummaryEntries() + { + var allSteps = _steps.Concat(_steps.SelectMany(s => s.Children)).ToList(); + var hasFailures = allSteps.Any(s => s.Status == FlowStepStatus.Failed); + var overallStatus = hasFailures ? "FAILED" : "OK"; + var succeeded = allSteps.Count(s => s.Status == FlowStepStatus.Success); + var failed = allSteps.Count(s => s.Status == FlowStepStatus.Failed); + var skipped = allSteps.Count(s => s.Status == FlowStepStatus.Skipped); + + var entries = new Dictionary + { + [$"Flow: {_flowName}"] = + $"[{overallStatus}] {_totalTimer.ElapsedMilliseconds}ms total - " + + $"{allSteps.Count} steps ({succeeded} ok, {failed} failed, {skipped} skipped)" + }; + + var stepNumber = 0; + foreach (var step in _steps) + { + stepNumber++; + AddSummaryEntry(entries, step, stepNumber, false); + + foreach (var child in step.Children) + { + stepNumber++; + AddSummaryEntry(entries, child, stepNumber, true); + } + } + + return entries; + } + + private static void AddSummaryEntry(Dictionary entries, FlowStep step, int stepNumber, bool indent) + { + var icon = GetStatusIcon(step.Status); + var time = step.ElapsedMs > 0 ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.Detail) ? $" - {step.Detail}" : ""; + var prefix = indent ? " " : ""; + entries[$"Flow Step {stepNumber:00}: {prefix}{step.Name}"] = $"{icon}{time}{detail}"; + } + private static string GetStatusIcon(FlowStepStatus status) { return status switch