test(playwright): make ApiKey name-commit deterministic under full-suite load (flake fix)

This commit is contained in:
Joseph Doherty
2026-06-06 16:06:24 -04:00
parent 8f63ef08eb
commit c6b682c82f
@@ -61,9 +61,28 @@ public sealed class ApiKeyCrudTests : IClassFixture<ApiSurfaceFixture>
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<ApiSurfaceFixture>
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 <input> 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.");