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; /// /// Real-browser coverage for the Flow Debugger page (follow-ups plan 2026-09-06): /// a Live run of an inbound method completes and draws a graph, and the method choice survives /// a reload through the page's localStorage restore path. Both go through the same /// 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) 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 { private const string FlowDebuggerUrl = "/deployment/flow-debugger"; private readonly PlaywrightFixture _pw; private readonly FlowDebuggerFixture _flow; /// Initializes a new instance of the FlowDebuggerTests class. /// The shared Playwright fixture. /// The Flow Debugger fixture. public FlowDebuggerTests(PlaywrightFixture pw, FlowDebuggerFixture flow) { _pw = pw; _flow = flow; } /// Verifies that a live run in the browser completes and draws the flow graph. /// A task that represents the asynchronous operation. [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 (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)); } /// Verifies that the chosen inbound API method survives a page reload through local storage. /// A task that represents the asynchronous operation. [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)); } /// Verifies that a live run of a call script from the script entry completes in the browser. /// A task that represents the asynchronous operation. [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)); } /// Verifies that selecting a write-kind script shows the value field for its trigger attribute. /// A task that represents the asynchronous operation. [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 instance goes through the shared InstancePicker combobox (typed /// name filters the tree, exact-text row click selects); 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! }); // 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; } /// /// Polls a <select>'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. /// 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) { 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 CaptureConsoleErrors(IPage page) { var errors = new List(); page.Console += (_, msg) => { if (string.Equals(msg.Type, "error", StringComparison.OrdinalIgnoreCase)) { lock (errors) errors.Add(msg.Text); } }; return errors; } }