test(rig): flow debugger script entry — Playwright direct-call run + verify-delmia-notifier step H2
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -5,7 +5,7 @@ set -euo pipefail
|
|||||||
# crash (docker kill = SIGKILL, so auto-down failover is exercised, not CoordinatedShutdown's
|
# 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
|
# 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
|
# 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
|
# 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
|
# 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"
|
# G reject path (DownloadPath *.bad) -> NO + "Simulated reject"
|
||||||
# H Flow Debugger live run + replay -> YES (flow-debug run-method returns the traced
|
# 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)
|
# 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 —
|
# 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
|
# returns ApiOutbound + DbOutbound + Notification rows inline: the sync ApiCall Delivered, the cached
|
||||||
# DB write Submitted or — DB reachable, immediate-completion path — already Delivered, the
|
# DB write Submitted or — DB reachable, immediate-completion path — already Delivered, the
|
||||||
@@ -77,7 +81,7 @@ for n in a b; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [ "${FLOW_ONLY:-}" = 1 ]; then
|
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
|
else
|
||||||
echo "== A baseline (active first)"; write_cfg "$(url_of "$ACT"),$(url_of "$STB")" "$DELMIA_API_KEY"; notify A YES
|
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
|
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 " replay: $H_REPLAY"
|
||||||
echo "H live+replay -> YES"
|
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,
|
# 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
|
# 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).
|
# sync ApiCall is already Delivered, the cached write and the notification are Submitted (pending).
|
||||||
|
|||||||
+176
@@ -12,6 +12,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
|
|||||||
/// prediction path that used to deadlock the circuit (see <see cref="FlowDebuggerFixture"/>) —
|
/// prediction path that used to deadlock the circuit (see <see cref="FlowDebuggerFixture"/>) —
|
||||||
/// under that bug the state chip never reached <c>Predicted</c> and every later click was lost,
|
/// under that bug the state chip never reached <c>Predicted</c> and every later click was lost,
|
||||||
/// so these tests are the browser-level guard for that fix as well.
|
/// so these tests are the browser-level guard for that fix as well.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The script entry (design 2026-09-08, Task 11) adds two more: a Live direct call of the rig's
|
||||||
|
/// <c>OutboundDemo</c> Call script on <c>DelmiaSim01</c>, 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.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection("Playwright")]
|
[Collection("Playwright")]
|
||||||
public class FlowDebuggerTests : IClassFixture<FlowDebuggerFixture>
|
public class FlowDebuggerTests : IClassFixture<FlowDebuggerFixture>
|
||||||
@@ -91,6 +98,175 @@ public class FlowDebuggerTests : IClassFixture<FlowDebuggerFixture>
|
|||||||
Assert.DoesNotContain(consoleErrors, e => e.Contains("JSInterop", StringComparison.OrdinalIgnoreCase));
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Rig instance the script-entry tests drive (<c>docker/seed-delmia-sim.sh</c>).</summary>
|
||||||
|
private const string RigInstanceName = "DelmiaSim01";
|
||||||
|
|
||||||
|
/// <summary>Call script on that instance (<c>docker/seed-flow-outbound-demo.sh</c>).</summary>
|
||||||
|
private const string RigCallScript = "OutboundDemo";
|
||||||
|
|
||||||
|
/// <summary>ValueChange script on that instance, and the attribute its trigger names.</summary>
|
||||||
|
private const string RigValueChangeScript = "DelmiaReceiverSimulator";
|
||||||
|
private const string RigValueChangeAttribute = "DelmiaReceiver.RecipeDownloadFlag";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks the script entry's three pickers (site → instance → script) to the named script on the
|
||||||
|
/// rig's <see cref="RigInstanceName"/>, 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
|
||||||
|
/// <c>site-a</c>); the script is matched by option VALUE, which is its canonical name.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<ILocator> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Polls a <c><select></c>'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.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<string?> WaitForOptionLabelAsync(
|
||||||
|
IPage page, ILocator select, Func<string, bool> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Polls until <paramref name="locator"/> matches at least one element, or the timeout expires.</summary>
|
||||||
|
private static async Task<bool> 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 string MethodIdText => _flow.MethodId.ToString(CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
private async Task SelectMethodAsync(IPage page)
|
private async Task SelectMethodAsync(IPage page)
|
||||||
|
|||||||
Reference in New Issue
Block a user