Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs
T
Joseph Doherty b5eb08f1a9 fix(flow-debugger): method pane takes structured parameters via ParameterValueForm + raw-JSON escape hatch (#37)
The Run-inbound-API-method pane rendered one text box per top-level schema
field and coerced the text on the page; an array/object field went through
JsonNode.Parse and, on failure, was SENT AS A STRING, while an empty box was
silently dropped. A required array-of-object parameter (MesMoveOut WorkOrders)
could not be entered at all — QA runs reached the site with "WorkOrders":[].

- The method pane now renders the same shared ParameterValueForm the script
  pane uses (typed inputs, nested groups, add/remove list item editors,
  $ref:lib: resolution, inline validation), keyed on the method id so a change
  of method remounts it. The page keeps ONE value tree and BuildParameters()
  serializes it as-is; the flat _inputs / Coerce() / SetInput path is deleted.
- Form | Raw JSON toggle: raw mode is a Monaco json editor for the whole
  request body, given the method's root schema for completion, parsed and
  schema-validated (InboundApiSchema.Validate) on every edit with the error
  inline. Entering raw mode seeds the editor from the tree; leaving it
  re-mounts the form over the last valid tree.
- Run is disabled while the form or the raw document is invalid (a required
  field left empty disables Run before the first keystroke).
- localStorage per-method entry stores the value tree; on restore it is
  validated against the schema and dropped if invalid, which also retires the
  pre-#37 flat string-per-field entries.
- ParameterValueForm.ToJsonSchema / JsonElementToObject made internal static
  for page reuse.
- Tests: 7 new bUnit tests (WorkOrders array-of-object round-trip to the
  RunMethodAsync payload, required list-item gating, invalid raw JSON blocks
  Run + shows parse/schema/non-object errors, valid raw JSON sent verbatim and
  survives the switch back to the form, method switch resets, persisted tree
  restored, legacy flat entry dropped); existing method-pane tests moved to
  the form's #param-<field> ids and fill the required field before Run.
  Playwright smoke selector moved to #param-Text.
- Docs: design doc, Component-CentralUI.md entry-points bullet, CLAUDE.md.

CentralUI.Tests 1425/1425 green; Playwright project builds.

Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 14:54:21 -04:00

307 lines
16 KiB
C#

using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.Playwright;
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
/// <summary>
/// Real-browser coverage for the Flow Debugger page (follow-ups plan 2026-09-06, Task 9):
/// a Live run of an inbound method completes and draws a graph, and the method choice survives
/// a reload through the page's <c>localStorage</c> restore path. Both go through the same
/// 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,
/// 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>
[Collection("Playwright")]
public class FlowDebuggerTests : IClassFixture<FlowDebuggerFixture>
{
private const string FlowDebuggerUrl = "/deployment/flow-debugger";
private readonly PlaywrightFixture _pw;
private readonly FlowDebuggerFixture _flow;
public FlowDebuggerTests(PlaywrightFixture pw, FlowDebuggerFixture flow)
{
_pw = pw;
_flow = flow;
}
[SkippableFact]
public async Task LiveRun_InBrowser_CompletesAndDrawsTheGraph()
{
Skip.IfNot(_flow.Available, ClusterAvailability.SkipReason);
var page = await _pw.NewAuthenticatedPageAsync();
var consoleErrors = CaptureConsoleErrors(page);
await page.GotoAsync(PlaywrightFixture.BaseUrl + FlowDebuggerUrl);
await SelectMethodAsync(page);
// The prediction (a Roslyn walk + the route-target resolve) finished: the state chip flips
// to Predicted and the method's ParameterValueForm (#37: the same typed form the script
// pane uses, inputs addressed by field id) appears.
await Assertions.Expect(page.Locator("[data-test='flow-state']")).ToHaveTextAsync("Predicted", new() { Timeout = 15_000 });
var input = page.Locator("#param-Text");
await Assertions.Expect(input).ToBeVisibleAsync();
// @bind commits on change (blur), so commit the value as its own circuit message before Run.
await input.FillAsync("hello");
await input.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 request + response steps, got {stepCount}");
// Selecting a node fills the detail panel.
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 MethodChoice_SurvivesReload_ViaLocalStorage()
{
Skip.IfNot(_flow.Available, ClusterAvailability.SkipReason);
var page = await _pw.NewAuthenticatedPageAsync();
var consoleErrors = CaptureConsoleErrors(page);
await page.GotoAsync(PlaywrightFixture.BaseUrl + FlowDebuggerUrl);
await SelectMethodAsync(page);
await Assertions.Expect(page.Locator("[data-test='flow-state']")).ToHaveTextAsync("Predicted", new() { Timeout = 15_000 });
// The page persisted the choice; a fresh circuit restores it in OnAfterRenderAsync
// through localStorage and re-runs the prediction.
await page.ReloadAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var select = page.Locator("select[data-test='flow-method-select']");
await Assertions.Expect(select).ToHaveValueAsync(MethodIdText, new() { Timeout = 15_000 });
await Assertions.Expect(page.Locator("[data-test='flow-state']")).ToHaveTextAsync("Predicted", new() { Timeout = 15_000 });
// And the page is still interactive afterwards (the deadlock left it inert right here).
await page.ClickAsync("button[data-test='flow-mode-replay']");
await Assertions.Expect(page.Locator("input[data-test='flow-execution-id']")).ToBeVisibleAsync();
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 instance goes through the shared <c>InstancePicker</c> combobox (typed
/// name filters the tree, exact-text row click selects); 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! });
// Instance selection goes through the shared InstancePicker combobox (prefix
// "flow-instance"): typing the name filters the area tree and expands its
// ancestors, then the exact-text row is clicked to select and close the panel. The
// test only knows the instance NAME (not its id), so the row is matched through the
// data-test contract (flow-instance-row) with an exact-text regex rather than a
// free-text GetByText scan.
var instanceInput = page.Locator("[data-test='flow-instance-input']");
await instanceInput.FillAsync(RigInstanceName);
var instanceRow = page.Locator("[data-test='flow-instance-row']",
new() { HasTextRegex = new Regex($"^\\s*{Regex.Escape(RigInstanceName)}\\s*$") });
Skip.IfNot(
await WaitForCountAsync(page, instanceRow),
$"Instance {RigInstanceName} is not on the rig's site-a — run docker/seed-delmia-sim.sh.");
await instanceRow.ClickAsync();
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>&lt;select&gt;</c>'s option labels until one matches, returning it (or null on
/// timeout). Used for the site select, whose options are filled by a server round-trip after
/// the page loads, 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 async Task SelectMethodAsync(IPage page)
{
var select = page.Locator("select[data-test='flow-method-select']");
await Assertions.Expect(select).ToBeVisibleAsync();
// The option list is loaded server-side on init; wait for our method to be present.
await Assertions.Expect(select.Locator($"option[value='{MethodIdText}']")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
await select.SelectOptionAsync(MethodIdText);
}
private static List<string> CaptureConsoleErrors(IPage page)
{
var errors = new List<string>();
page.Console += (_, msg) =>
{
if (string.Equals(msg.Type, "error", StringComparison.OrdinalIgnoreCase))
{
lock (errors) errors.Add(msg.Text);
}
};
return errors;
}
}