feat(central-ui): Flow Debugger refreshes pending outbound outcomes after a Live run
After a successful Live run whose graph has Pending outbound steps the page re-reads the tree through the existing Replay command every 5 s for up to 2 min (page constants), merging by replacing the graph and keeping the selection by event id; never in Replay mode; cancelled by a new run, a mode switch or Dispose. Status strip gains a Pending count, a 'Refresh outcomes' button (both modes) and an 'outcomes still arriving' note while polling. The run's own timing and the Completed chip are left alone — pending outbound work is not a failed run. Plan 2026-09-06 D5. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -209,4 +209,96 @@ public class FlowDebuggerPageTests : BunitContext
|
||||
|
||||
cut.WaitForElement("[data-test='flow-site-not-traced']");
|
||||
}
|
||||
|
||||
// ---- Outbound calls (2026-09-06 plan, Task 9): pending refresh after a Live run ----------
|
||||
|
||||
/// <summary>Inbound → route → site run → cached API call; the call is Pending unless <paramref name="resolved"/>.</summary>
|
||||
private static FlowDebugOutcome OutboundOutcome(Guid root, bool resolved)
|
||||
{
|
||||
var child = Guid.NewGuid();
|
||||
var corr = Guid.NewGuid();
|
||||
var t0 = DateTime.UtcNow.AddSeconds(-10);
|
||||
var events = new List<AuditEvent>
|
||||
{
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100, responseSummary: "{}"),
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.Process", executionId: root, durationMs: 90,
|
||||
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()),
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0.AddMilliseconds(2), target: "Process", executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80),
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted,
|
||||
occurredAtUtc: t0.AddMilliseconds(3), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "Sim01"),
|
||||
};
|
||||
if (resolved)
|
||||
events.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0.AddSeconds(4), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "Sim01"));
|
||||
|
||||
var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1));
|
||||
return new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveRun_WithPendingOutbound_PollsReplayUntilTerminal()
|
||||
{
|
||||
RegisterServices();
|
||||
var root = Guid.NewGuid();
|
||||
_flow.RunMethodAsync(4, Arg.Any<JsonElement?>(), Arg.Any<CancellationToken>()).Returns(OutboundOutcome(root, resolved: false));
|
||||
_flow.ReplayAsync(root, Arg.Any<CancellationToken>()).Returns(OutboundOutcome(root, resolved: false), OutboundOutcome(root, resolved: true));
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
cut.Instance.Delay = (_, _) => Task.CompletedTask;
|
||||
cut.Find("select[data-test='flow-method-select']").Change("4");
|
||||
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
|
||||
|
||||
cut.Find("button[data-test='flow-run']").Click();
|
||||
|
||||
cut.WaitForAssertion(() => _flow.Received(2).ReplayAsync(root, Arg.Any<CancellationToken>()));
|
||||
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("[data-test='flow-pending-count']")));
|
||||
Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim());
|
||||
Assert.Empty(cut.FindAll("[data-test='flow-pending-note']"));
|
||||
Assert.Contains(cut.FindAll("[data-test='flow-node-pill']"), p => p.TextContent.Trim() == "Ok");
|
||||
Assert.NotNull(cut.Find("button[data-test='flow-refresh']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveRun_WithNoPendingStep_NeverPolls()
|
||||
{
|
||||
RegisterServices();
|
||||
var root = Guid.NewGuid();
|
||||
_flow.RunMethodAsync(4, Arg.Any<JsonElement?>(), Arg.Any<CancellationToken>()).Returns(OutboundOutcome(root, resolved: true));
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
cut.Instance.Delay = (_, _) => Task.CompletedTask;
|
||||
cut.Find("select[data-test='flow-method-select']").Change("4");
|
||||
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
|
||||
|
||||
cut.Find("button[data-test='flow-run']").Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
|
||||
_flow.DidNotReceive().ReplayAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||
Assert.Empty(cut.FindAll("[data-test='flow-pending-count']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplayMode_NeverPolls_ButRefreshButtonReplaysOnce()
|
||||
{
|
||||
RegisterServices();
|
||||
var root = Guid.NewGuid();
|
||||
_flow.ReplayAsync(root, Arg.Any<CancellationToken>()).Returns(OutboundOutcome(root, resolved: false));
|
||||
var nav = Services.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo($"/deployment/flow-debugger?executionId={root}");
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
cut.Instance.Delay = (_, _) => Task.CompletedTask;
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
|
||||
Assert.Equal("1", cut.Find("[data-test='flow-pending-count']").TextContent.Trim());
|
||||
_flow.Received(1).ReplayAsync(root, Arg.Any<CancellationToken>());
|
||||
|
||||
cut.Find("button[data-test='flow-refresh']").Click();
|
||||
|
||||
cut.WaitForAssertion(() => _flow.Received(2).ReplayAsync(root, Arg.Any<CancellationToken>()));
|
||||
Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user