From 1f8c5810ca5195248ead11c52afe129bb193b207 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 6 Sep 2026 11:11:46 -0400 Subject: [PATCH] test(central-ui): Playwright Flow Debugger live run + localStorage restore against the rig An ephemeral inbound method (created and deleted through the CLI, with a never-taken Route.To branch so prediction resolves a route target) is run live in a real browser through the fixture's /auth/login; the reload test drives the OnAfterRenderAsync localStorage restore and then proves the page is still interactive. Both fail on the pre-fix image at the 'Predicted' wait (the circuit deadlock) and pass on the fixed one. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29 --- ...9-06-flow-debugger-followups.md.tasks.json | 4 +- .../Cluster/CliRunner.Helpers.cs | 11 +- .../Deployment/FlowDebuggerFixture.cs | 75 +++++++++++ .../Deployment/FlowDebuggerTests.cs | 117 ++++++++++++++++++ 4 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerFixture.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs diff --git a/docs/plans/2026-09-06-flow-debugger-followups.md.tasks.json b/docs/plans/2026-09-06-flow-debugger-followups.md.tasks.json index a950397b..743ce6f9 100644 --- a/docs/plans/2026-09-06-flow-debugger-followups.md.tasks.json +++ b/docs/plans/2026-09-06-flow-debugger-followups.md.tasks.json @@ -74,7 +74,7 @@ { "id": 9, "subject": "Task 9: Playwright \u2014 browser Live run + localStorage restore (rig)", - "status": "pending", + "status": "completed", "blockedBy": [ 8, 4, @@ -90,5 +90,5 @@ ] } ], - "lastUpdated": "2026-09-06T16:05:00Z" + "lastUpdated": "2026-09-06T16:30:00Z" } \ No newline at end of file diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs index a58fc0ab..8de0c747 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs @@ -406,9 +406,16 @@ public static partial class CliRunner /// Creates an inbound API method via api-method create (so it appears as a checkbox in the /// API-key form) and returns its new id. /// - public static async Task CreateApiMethodAsync(string name, string script = "return null;") + public static async Task CreateApiMethodAsync(string name, string script = "return null;", string? parameters = null) { - using var doc = await RunJsonAsync("api-method", "create", "--name", name, "--script", script); + var args = new List { "api-method", "create", "--name", name, "--script", script }; + if (!string.IsNullOrEmpty(parameters)) + { + args.Add("--parameters"); + args.Add(parameters); + } + + using var doc = await RunJsonAsync([.. args]); return RequireId(doc, "api-method create"); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerFixture.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerFixture.cs new file mode 100644 index 00000000..38dc3508 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerFixture.cs @@ -0,0 +1,75 @@ +using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment; + +/// +/// fixture for the Flow Debugger E2E tests: provisions ONE ephemeral +/// inbound API method through the CLI and deletes it on dispose, so the tests never depend on +/// rig-seeded methods (the FlowOutboundDemo / DelmiaRecipeDownload stand-ins) and +/// never need the login-disabled override — the Playwright fixture logs in through +/// /auth/login like every other page test. +/// +/// +/// The method takes one Text parameter and echoes it. Its script also carries a +/// Route.To(...).Call(...) on a branch that never executes (the target instance does not +/// exist): the Flow Debugger's prediction walks that branch anyway and resolves the route +/// target through the repository, which is exactly the path that used to deadlock the Blazor +/// circuit (sync-over-async in FlowDebugService.PredictMethodAsync, fixed 2026-09-06) — a +/// method with no route target would not exercise it. +/// +/// +/// +/// Gated on : when the dev cluster is down, +/// is , nothing is provisioned and the consuming +/// tests skip. +/// +/// +public sealed class FlowDebuggerFixture : IAsyncLifetime +{ + /// The parameter-definitions JSON of the ephemeral method (one required string). + private const string ParametersJson = + """{"type":"object","properties":{"Text":{"type":"string"}},"required":["Text"]}"""; + + /// Whether the cluster was reachable at setup and the method exists. + public bool Available { get; private set; } + + /// Id of the ephemeral inbound method (0 when unavailable). + public int MethodId { get; private set; } + + /// Name of the ephemeral inbound method (its POST /api/{name} segment). + public string MethodName { get; private set; } = string.Empty; + + /// + public async Task InitializeAsync() + { + Available = await ClusterAvailability.IsAvailableAsync(); + if (!Available) + { + return; + } + + MethodName = CliRunner.UniqueName("flow"); + const string script = + """ + var text = Parameters.ContainsKey("Text") ? Parameters["Text"]?.ToString() : null; + if (text == "route-me") + { + return await Route.To("zztest-no-such-instance").Call("Process", new { }); + } + return new { ok = true, echo = text }; + """; + try + { + MethodId = await CliRunner.CreateApiMethodAsync(MethodName, script, ParametersJson); + } + catch + { + Available = false; + throw; + } + } + + /// + public Task DisposeAsync() => + MethodId > 0 ? CliRunner.DeleteApiMethodAsync(MethodId) : Task.CompletedTask; +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs new file mode 100644 index 00000000..10b9a374 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs @@ -0,0 +1,117 @@ +using System.Globalization; +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, 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 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. +/// +[Collection("Playwright")] +public class FlowDebuggerTests : IClassFixture +{ + 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 typed input form for the method's parameter appears. + await Assertions.Expect(page.Locator("[data-test='flow-state']")).ToHaveTextAsync("Predicted", new() { Timeout = 15_000 }); + var input = page.Locator("input[data-test='flow-input-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)); + } + + 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; + } +}