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
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -406,9 +406,16 @@ public static partial class CliRunner
|
||||
/// Creates an inbound API method via <c>api-method create</c> (so it appears as a checkbox in the
|
||||
/// API-key form) and returns its new <c>id</c>.
|
||||
/// </summary>
|
||||
public static async Task<int> CreateApiMethodAsync(string name, string script = "return null;")
|
||||
public static async Task<int> 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<string> { "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");
|
||||
}
|
||||
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IAsyncLifetime"/> 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 <c>FlowOutboundDemo</c> / <c>DelmiaRecipeDownload</c> stand-ins) and
|
||||
/// never need the login-disabled override — the Playwright fixture logs in through
|
||||
/// <c>/auth/login</c> like every other page test.
|
||||
///
|
||||
/// <para>
|
||||
/// The method takes one <c>Text</c> parameter and echoes it. Its script also carries a
|
||||
/// <c>Route.To(...).Call(...)</c> on a branch that never executes (the target instance does not
|
||||
/// exist): the Flow Debugger's <em>prediction</em> 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 <c>FlowDebugService.PredictMethodAsync</c>, fixed 2026-09-06) — a
|
||||
/// method with no route target would not exercise it.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Gated on <see cref="ClusterAvailability.IsAvailableAsync"/>: when the dev cluster is down,
|
||||
/// <see cref="Available"/> is <see langword="false"/>, nothing is provisioned and the consuming
|
||||
/// tests skip.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class FlowDebuggerFixture : IAsyncLifetime
|
||||
{
|
||||
/// <summary>The parameter-definitions JSON of the ephemeral method (one required string).</summary>
|
||||
private const string ParametersJson =
|
||||
"""{"type":"object","properties":{"Text":{"type":"string"}},"required":["Text"]}""";
|
||||
|
||||
/// <summary>Whether the cluster was reachable at setup and the method exists.</summary>
|
||||
public bool Available { get; private set; }
|
||||
|
||||
/// <summary>Id of the ephemeral inbound method (0 when unavailable).</summary>
|
||||
public int MethodId { get; private set; }
|
||||
|
||||
/// <summary>Name of the ephemeral inbound method (its <c>POST /api/{name}</c> segment).</summary>
|
||||
public string MethodName { get; private set; } = string.Empty;
|
||||
|
||||
/// <inheritdoc/>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task DisposeAsync() =>
|
||||
MethodId > 0 ? CliRunner.DeleteApiMethodAsync(MethodId) : Task.CompletedTask;
|
||||
}
|
||||
+117
@@ -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;
|
||||
|
||||
/// <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.
|
||||
/// </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 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<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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user