diff --git a/docker/verify-delmia-notifier.sh b/docker/verify-delmia-notifier.sh index 219d7990..8fed7a74 100755 --- a/docker/verify-delmia-notifier.sh +++ b/docker/verify-delmia-notifier.sh @@ -5,7 +5,7 @@ set -euo pipefail # crash (docker kill = SIGKILL, so auto-down failover is exercised, not CoordinatedShutdown's # graceful handover) + rejoin. Requires: seed-delmia-sim.sh + seed-delmia-api.sh already run, and # DELMIA_API_KEY exported (the token printed by seed-delmia-api.sh). FLOW_ONLY=1 skips A-G (no -# notifier build, no docker kill) and runs the Flow Debugger steps H and I alone. +# notifier build, no docker kill) and runs the Flow Debugger steps H, H2 and I alone. # # HISTORY (2026-09-02): the first run of this script found that inbound API keys were a PER-NODE # SQLite store, so the key minted through the active node did not exist on the standby and every @@ -32,6 +32,10 @@ set -euo pipefail # G reject path (DownloadPath *.bad) -> NO + "Simulated reject" # H Flow Debugger live run + replay -> YES (flow-debug run-method returns the traced # site steps inline; flow-debug replay rebuilds the same chain from the central audit store) +# H2 Flow Debugger SCRIPT entry (2026-09-08) -> YES (flow-debug run-script calls $MACHINE's +# OutboundDemo Call script directly — no inbound method in front of it — and the reply leads with +# the operator-attributed RouteCall root, then the site's inline ScriptRun. Skipped with a note +# when the instance is not seeded.) # I Flow Debugger OUTBOUND calls (2026-09-06) -> YES (FlowOutboundDemo — seed-flow-outbound-demo.sh — # returns ApiOutbound + DbOutbound + Notification rows inline: the sync ApiCall Delivered, the cached # DB write Submitted or — DB reachable, immediate-completion path — already Delivered, the @@ -77,7 +81,7 @@ for n in a b; do done if [ "${FLOW_ONLY:-}" = 1 ]; then - echo "== FLOW_ONLY=1: skipping A-G (notifier + failover drill); running the Flow Debugger steps H and I only" + echo "== FLOW_ONLY=1: skipping A-G (notifier + failover drill); running the Flow Debugger steps H, H2 and I only" else echo "== A baseline (active first)"; write_cfg "$(url_of "$ACT"),$(url_of "$STB")" "$DELMIA_API_KEY"; notify A YES echo "== B standby first (relay)"; write_cfg "$(url_of "$STB"),$(url_of "$ACT")" "$DELMIA_API_KEY"; notify B YES @@ -142,6 +146,31 @@ done echo " replay: $H_REPLAY" echo "H live+replay -> YES" +# H2 — Flow Debugger SCRIPT entry (design 2026-09-08): flow-debug run-script invokes $MACHINE's +# OutboundDemo Call script directly through the traced RouteToCall path — no inbound method in front +# of it. The reply's first row is the operator-attributed RouteCall root written centrally, followed +# by the site's inline ScriptRun (and this script's outbound rows). OutboundDemo declares no +# ParameterDefinitions (seed-flow-outbound-demo.sh passes no --parameters; it reads an optional +# WorkOrder and defaults it), so no --params is needed. +echo "== H2 Flow Debugger script entry (direct call)" +H2_INSTANCE_ID=$("$CLI" instance list "${H_ARGS[@]}" | python3 -c 'import json,sys; xs=json.load(sys.stdin); print(next((i["id"] for i in xs if i.get("uniqueName")==sys.argv[1]), ""))' "$MACHINE") +if [ -z "$H2_INSTANCE_ID" ]; then + echo "H2 script entry -> SKIPPED ($MACHINE not seeded — run docker/seed-delmia-sim.sh)" +else + set +e; H2_RUN=$("$CLI" flow-debug run-script --instance-id "$H2_INSTANCE_ID" --script OutboundDemo "${H_ARGS[@]}"); H2_RC=$?; set -e + [ "$H2_RC" -eq 0 ] || { echo "FAIL: flow-debug run-script exited $H2_RC: $H2_RUN"; exit 1; } + H2_OK=$(echo "$H2_RUN" | python3 -c ' +import json, sys +r = json.load(sys.stdin) +kinds = [json.loads(e["detailsJson"]).get("kind") or "?" for e in r.get("events", [])] +ok = r.get("completed") is True and kinds[:1] == ["RouteCall"] and "ScriptRun" in kinds +print(("YES" if ok else "NO") + " root=" + str(r.get("rootExecutionId")) + " kinds=" + ",".join(kinds) + + (" error=" + str(r.get("errorCode")) if r.get("errorCode") else "")) +sys.exit(0 if ok else 1)') || { echo "FAIL: run-script incomplete: $H2_OK"; exit 1; } + echo " live: $H2_OK" + echo "H2 script entry (direct call) -> YES" +fi + # I — Flow Debugger outbound calls (plan 2026-09-06). The demo script makes a sync ExternalSystem.Call, # a Database.CachedWrite and a Notify.Send. Live: the site returns the rows it emitted inline — the # sync ApiCall is already Delivered, the cached write and the notification are Submitted (pending). diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs index 10b9a374..c48ea0c1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs @@ -12,6 +12,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment; /// prediction path that used to deadlock the circuit (see ) — /// under that bug the state chip never reached Predicted and every later click was lost, /// so these tests are the browser-level guard for that fix as well. +/// +/// +/// The script entry (design 2026-09-08, Task 11) adds two more: a Live direct call of the rig's +/// OutboundDemo Call script on DelmiaSim01, and the write-kind Inputs card naming the +/// trigger's own attribute. Unlike the method tests those drive RIG-SEEDED objects the fixture does +/// not provision, so each picker step skips with a "run docker/seed-…" message when it is absent. +/// /// [Collection("Playwright")] public class FlowDebuggerTests : IClassFixture @@ -91,6 +98,175 @@ public class FlowDebuggerTests : IClassFixture Assert.DoesNotContain(consoleErrors, e => e.Contains("JSInterop", StringComparison.OrdinalIgnoreCase)); } + [SkippableFact] + public async Task ScriptEntry_LiveRunOfACallScript_InBrowser_Completes() + { + // Flow Debugger script entry (design 2026-09-08 §5). Rig objects: DelmiaSim01 (SimMachine, site-a) + // carries the OutboundDemo Call script seeded by docker/seed-flow-outbound-demo.sh; skip when absent. + // A Call script is invoked DIRECTLY through the traced RouteToCall path, so this is the browser-level + // proof of the call-kind branch: picker -> prediction -> Inputs card -> real run -> graph. + Skip.IfNot(_flow.Available, ClusterAvailability.SkipReason); + + var page = await _pw.NewAuthenticatedPageAsync(); + var consoleErrors = CaptureConsoleErrors(page); + await page.GotoAsync(PlaywrightFixture.BaseUrl + FlowDebuggerUrl); + + await page.ClickAsync("button[data-test='flow-entry-script']"); + var scriptSelect = await SelectRigScriptAsync(page, RigCallScript); + + // The prediction ran server-side (a RouteCall root + the site script's predicted steps). + await Assertions.Expect(page.Locator("[data-test='flow-state']")).ToHaveTextAsync("Predicted", new() { Timeout = 15_000 }); + + // Call kinds render the shared ParameterValueForm, or the no-parameters note when the script + // declares none — OutboundDemo declares none today (the seed passes no --parameters), but it + // reads an optional WorkOrder, so fill the field if a later seed starts declaring it. + var paramsCard = page.Locator("[data-test='flow-script-params']"); + var noParamsNote = page.Locator("[data-test='flow-script-no-params']"); + await Assertions.Expect(paramsCard.Or(noParamsNote)).ToBeVisibleAsync(new() { Timeout = 15_000 }); + if (await paramsCard.IsVisibleAsync()) + { + var workOrder = page.Locator("#param-WorkOrder"); + if (await workOrder.CountAsync() > 0) + { + await workOrder.FillAsync("WO-PW"); + await workOrder.BlurAsync(); + await page.WaitForTimeoutAsync(300); + } + } + + await page.ClickAsync("button[data-test='flow-run']"); + + await Assertions.Expect(page.Locator("[data-test='flow-state']")).ToHaveTextAsync("Completed", new() { Timeout = 60_000 }); + var stepCountText = await page.Locator("[data-test='flow-step-count']").InnerTextAsync(); + var stepCount = int.Parse(stepCountText.Trim(), CultureInfo.InvariantCulture); + Assert.True(stepCount >= 2, $"expected at least the RouteCall root + the site's ScriptRun, got {stepCount}"); + + // The picker kept the choice, and selecting a node fills the detail panel. + await Assertions.Expect(scriptSelect).ToHaveValueAsync(RigCallScript); + await page.Locator("g[data-test='flow-node']").Last.ClickAsync(); + await Assertions.Expect(page.Locator("[data-test='flow-step-title']")).ToBeVisibleAsync(); + + Assert.DoesNotContain(consoleErrors, e => e.Contains("JSInterop", StringComparison.OrdinalIgnoreCase)); + } + + [SkippableFact] + public async Task ScriptEntry_WriteKindScript_ShowsTheValueFieldForTheTriggerAttribute() + { + // The write-kind branch of the same picker (design 2026-09-08 §5): a ValueChange/Conditional + // script is fired by writing ITS OWN trigger attribute, so the Inputs card offers one value + // field naming that attribute instead of a parameter form. DelmiaReceiverSimulator (ValueChange + // on DelmiaReceiver.RecipeDownloadFlag) is seeded by docker/seed-delmia-sim.sh. No run: the + // simulator has real side effects and the call-kind test above already covers execution. + Skip.IfNot(_flow.Available, ClusterAvailability.SkipReason); + + var page = await _pw.NewAuthenticatedPageAsync(); + var consoleErrors = CaptureConsoleErrors(page); + await page.GotoAsync(PlaywrightFixture.BaseUrl + FlowDebuggerUrl); + + await page.ClickAsync("button[data-test='flow-entry-script']"); + await SelectRigScriptAsync(page, RigValueChangeScript); + + var valueField = page.Locator("input[data-test='flow-input-value']"); + await Assertions.Expect(valueField).ToBeVisibleAsync(new() { Timeout = 15_000 }); + await Assertions.Expect(page.Locator("[data-test='flow-script-params']")).ToHaveCountAsync(0); + + // The card names the attribute the debugger would write — that is what makes the derived + // attribute (rather than an operator-picked one) visible to the operator. + var inputsCard = valueField.Locator("xpath=ancestor::div[contains(@class,'card-body')][1]"); + await Assertions.Expect(inputsCard).ToContainTextAsync(RigValueChangeAttribute); + + Assert.DoesNotContain(consoleErrors, e => e.Contains("JSInterop", StringComparison.OrdinalIgnoreCase)); + } + + /// Rig instance the script-entry tests drive (docker/seed-delmia-sim.sh). + private const string RigInstanceName = "DelmiaSim01"; + + /// Call script on that instance (docker/seed-flow-outbound-demo.sh). + private const string RigCallScript = "OutboundDemo"; + + /// ValueChange script on that instance, and the attribute its trigger names. + private const string RigValueChangeScript = "DelmiaReceiverSimulator"; + private const string RigValueChangeAttribute = "DelmiaReceiver.RecipeDownloadFlag"; + + /// + /// Walks the script entry's three pickers (site → instance → script) to the named script on the + /// rig's , skipping the test with a rig-shaped message whenever an + /// option is missing — none of these objects is provisioned by the fixture. Site is matched by + /// LABEL because its id differs per rig (the seed names it "Test Plant A", identifier + /// site-a); the script is matched by option VALUE, which is its canonical name. + /// + private static async Task SelectRigScriptAsync(IPage page, string scriptName) + { + var sites = page.Locator("select[data-test='flow-site-select']"); + await Assertions.Expect(sites).ToBeVisibleAsync(new() { Timeout = 15_000 }); + var siteLabel = await WaitForOptionLabelAsync(page, sites, l => + l.Contains("Plant A", StringComparison.OrdinalIgnoreCase) + || l.Contains("Site A", StringComparison.OrdinalIgnoreCase) + || l.Contains("site-a", StringComparison.OrdinalIgnoreCase)); + Skip.If(siteLabel is null, "The rig's site-a ('Test Plant A') is not in the Flow Debugger site list — run docker/seed-sites.sh."); + await sites.SelectOptionAsync(new SelectOptionValue { Label = siteLabel! }); + + var instances = page.Locator("select[data-test='flow-instance-select']"); + var instanceLabel = await WaitForOptionLabelAsync( + page, instances, l => string.Equals(l, RigInstanceName, StringComparison.Ordinal)); + Skip.If(instanceLabel is null, $"Instance {RigInstanceName} is not on the rig's site-a — run docker/seed-delmia-sim.sh."); + await instances.SelectOptionAsync(new SelectOptionValue { Label = instanceLabel! }); + + var scripts = page.Locator("select[data-test='flow-script-select']"); + var option = scripts.Locator($"option[value='{scriptName}']"); + Skip.IfNot( + await WaitForCountAsync(page, option), + $"Script {scriptName} is not on {RigInstanceName} — run docker/seed-delmia-sim.sh and docker/seed-flow-outbound-demo.sh."); + await scripts.SelectOptionAsync(scriptName); + return scripts; + } + + /// + /// Polls a <select>'s option labels until one matches, returning it (or null on + /// timeout). The instance and script lists are filled by a server round-trip after the previous + /// picker changes, so the options do not exist at first render. + /// + private static async Task WaitForOptionLabelAsync( + IPage page, ILocator select, Func match, int timeoutMs = 15_000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (true) + { + foreach (var text in await select.Locator("option").AllTextContentsAsync()) + { + var label = text.Trim(); + if (label.Length > 0 && match(label)) + { + return label; + } + } + + if (DateTime.UtcNow >= deadline) + { + return null; + } + + await page.WaitForTimeoutAsync(250); + } + } + + /// Polls until matches at least one element, or the timeout expires. + private static async Task WaitForCountAsync(IPage page, ILocator locator, int timeoutMs = 15_000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (await locator.CountAsync() == 0) + { + if (DateTime.UtcNow >= deadline) + { + return false; + } + + await page.WaitForTimeoutAsync(250); + } + + return true; + } + private string MethodIdText => _flow.MethodId.ToString(CultureInfo.InvariantCulture); private async Task SelectMethodAsync(IPage page)