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;
+ }
+}