From c6b682c82fa9ae01dbd18c86ff0aee5c4ce71e7c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 6 Jun 2026 16:06:24 -0400 Subject: [PATCH] test(playwright): make ApiKey name-commit deterministic under full-suite load (flake fix) --- .../Admin/ApiKeyCrudTests.cs | 54 ++++++++++++++++--- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiKeyCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiKeyCrudTests.cs index 615978c6..bb63e144 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiKeyCrudTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiKeyCrudTests.cs @@ -61,9 +61,28 @@ public sealed class ApiKeyCrudTests : IClassFixture var nameInput = page.Locator("input[type='text'].form-control.form-control-sm").First; await Assertions.Expect(nameInput).ToBeVisibleAsync(new() { Timeout = 15_000 }); + var saveButton = page.GetByRole(AriaRole.Button, new() { Name = "Save" }); + + // Same under-load hydration race as Create_NoMethods (see that test): this page is + // InteractiveServer with prerendering, so the form renders (and is "visible") before + // blazor.web.js hydrates the component and attaches its @bind onchange / @onchange + // handlers over the SignalR circuit. Under full-suite load that hydration lands late, + // so a name fill / checkbox check dispatched against the not-yet-live circuit is + // silently dropped — SaveKey then short-circuits on the empty _formName (or empty + // method set) and no key is created, so the token panel never appears. + // + // PROVE the circuit is interactive with an observable server round-trip first: clicking + // Save on the empty form forces SaveKey to run and render "Name is required." (harmless, + // creates nothing). Once that message appears the component is hydrated and its handlers + // are live, so the subsequent name fill + checkbox check are guaranteed to round-trip. + await saveButton.ClickAsync(); + await Assertions.Expect(page.Locator("div.text-danger.small")) + .ToContainTextAsync("Name is required.", new() { Timeout = 15_000 }); + await nameInput.FillAsync(keyName); + await nameInput.DispatchEventAsync("change"); await page.Locator($"#method-access-{_api.MethodId}").CheckAsync(); - await page.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync(); + await saveButton.ClickAsync(); await Assertions.Expect(page.Locator("[data-test='created-token']")) .ToBeVisibleAsync(new() { Timeout = 10_000 }); @@ -129,16 +148,35 @@ public sealed class ApiKeyCrudTests : IClassFixture var nameInput = page.Locator("input[type='text'].form-control.form-control-sm").First; await Assertions.Expect(nameInput).ToBeVisibleAsync(new() { Timeout = 15_000 }); - // Fill the name but leave ALL method checkboxes unchecked so only the method rule fires. - // The name input uses Blazor default @bind (onchange), which only commits to the server - // (_formName) on change — dispatch the change event explicitly to flush the bound value - // before Save rather than relying on blur + NetworkIdle timing (which flakes under load), - // otherwise the name-required rule short-circuits first. + var saveButton = page.GetByRole(AriaRole.Button, new() { Name = "Save" }); + var error = page.Locator("div.text-danger.small"); + + // ROOT CAUSE of the under-load flake: the page is InteractiveServer with prerendering, + // so the name renders (and is "visible") from the prerendered HTML BEFORE + // blazor.web.js hydrates this component and attaches its @bind onchange handler over the + // SignalR circuit. Under full-suite load (circuit pressure from the 4-context cap) that + // hydration lands late, so a name FillAsync + change dispatched against the not-yet-live + // circuit is silently dropped — _formName stays empty server-side and SaveKey short- + // circuits to "Name is required." (the rule checked BEFORE the methods rule in + // ApiKeyForm.SaveKey). In isolation the circuit hydrates fast enough that this never bites. + // + // Fix: PROVE the circuit is interactive with an observable server round-trip before we + // rely on the name commit. Clicking Save with an empty name forces SaveKey to run on the + // server and render the "Name is required." message — a circuit-driven re-render. Waiting + // for that message guarantees the component is hydrated and its onchange handler is live. + // Only THEN do we fill the name + dispatch change (now guaranteed to round-trip) and Save + // again, so _formName is committed and the methods rule — not the name rule — fires. + await saveButton.ClickAsync(); + await Assertions.Expect(error).ToBeVisibleAsync(new() { Timeout = 15_000 }); + await Assertions.Expect(error).ToContainTextAsync("Name is required."); + + // Circuit is now provably interactive. Commit the name as its own discrete change message + // (ordered before the Save click over the in-order SignalR channel), leaving ALL method + // checkboxes unchecked so only the method rule can fire. await nameInput.FillAsync(CliRunner.UniqueName("apikey")); await nameInput.DispatchEventAsync("change"); - await page.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync(); + await saveButton.ClickAsync(); - var error = page.Locator("div.text-danger.small"); await Assertions.Expect(error).ToBeVisibleAsync(new() { Timeout = 10_000 }); await Assertions.Expect(error).ToContainTextAsync("Select at least one API method for this key.");