Wait for Blazor to boot before driving the E2E sign-in flow #210
Loading…
Reference in a new issue
No description provided.
Delete branch "fix-e2e-signin-races"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes the flaky E2E suite that has
mainred.The defect.
PlaceMark.WebUIis pure Blazor WebAssembly with no prerendering, so every top-level navigation —GotoAsync,ReloadAsync, or an external redirect chain (OIDC) landing back on the WebUI — reboots the whole runtime in the browser.JourneySteps.RegisterAndSignInAsyncdid two fullpage.GotoAsyncloads and started filling fields and clicking the moment Playwright's own actionability checks passed. Nothing anywhere in the suite waited for the app to become interactive.Run #880 on an idle runner failed 4 of 59, all inside that shared step, with
System.TimeoutException: Timeout 30000ms exceeded. A different set of tests failed on each run — the signature of flaky shared setup, not a broken journey.The fix.
App.razor.cssets<html data-app-ready="true">fromOnAfterRenderAsync(firstRender: true)viaappReady.js— a signal the app actually exposes, not a sleep.JourneySteps.WaitForAppReadyAsyncwaits on it after every navigation in the suite that reboots the runtime:GotoAsync, everypage.ReloadAsync()(now routed through aJourneySteps.ReloadAsyncwrapper so a future reload can't omit it), and the OIDC sign-in redirect chain's own landing back on the WebUI — a reboot driven by neither Playwright method, found in review and now covered at the one point control returns.RegisterAndSignInAsync's secondGotoAsyncis removed outright:/loginis now reached by clickingRegister.razor's own "Sign in" link, a client-side navigation, so a journey boots the runtime once instead of twice.Every wait is watched to fail before being trusted: CDP
Emulation.setCPUThrottlingRatereproduces the exactTimeoutExceptionfrom Run #880 on an idle box, and moves the failure inside the wait once it's added — proof it genuinely blocks. The OIDC-landing wait needed the same proof independently, under the same throttling technique, once a first attempt (delayingApp's ownOnAfterRenderAsyncdirectly) turned out not to gate at all — Blazor's rendering is concurrent, so delaying one component's after-render hook doesn't delay a sibling page's own render.Task 273 (
el.placeMarkMapread beforecreateMap's interop call assigns it) is closed for every place in the suite that reads it:RegisterAndSignInAsyncwaits for it directly, and everyReloadAsyncsite does too, after a full audit found three reload sites without the wait review's first pass missed.Boot-path enumeration. ADR-0173 names every mechanism this app's runtime can reboot through today —
GotoAsync,ReloadAsync, an external-redirect return (OIDC), and a fourth,NavigationManager.NavigateTo(..., forceLoad: true)on an auth-expiry redirect, which exists in production code but is exercised by no E2E test today — and says plainly that this is not a standing guarantee, not a fourth claim of completeness.RateLimiting__Auth__PermitLimitgoes 100 to 200, test-fixture only (PlaceMarkAppFixture.cs, no productionappsettings*.jsontouched). It's a consequence of the fix, not a workaround: a faster suite concentrates its register/sign-in traffic into less wall-clock time, more often landing inside oneFixedWindowRateLimiterwindow.Verified on the rebased tree, pinned SDK 10.0.100: E2E 60/60 twice (~1.85 min each, confirmed against this branch's own freshly spawned subprocesses and Postgres container, not any already-running instance), Architecture 148/148, WebUI 916/920 (4 pre-existing skips), Api 622/622, Infrastructure 360/360, Domain 39/39, Contracts 139/139. Coverage ratchet (Cobertura interface) — WebUI holds at 91.6%, no assembly below baseline.
Verdict: changes needed
The core mechanism (App-level ready marker + map-readiness poll, second GotoAsync replaced by an in-app link click) is sound and well-reasoned, but the "closes task 273 suite-wide" claim in both the PR description and ADR-0173's Consequences is false — see the inline comment on JourneySteps.cs. That's a live, reproducible race left in the shipped suite, of exactly the class this PR exists to close, so it blocks.
Verification limits, stated plainly: I could not reproduce the CPU-throttle falsification myself.
PlaceMarkAppFixturebinds fixed ports (7117/5017/5169), which were already occupied by Rob's own long-running dev API/WebUI processes from the primary working copy (PIDs 2177528/2177783, up since Aug 20). My test run against the worktree completed in a suspicious 1s with no new Testcontainers Postgres container created — it silently rode on the already-running dev instance rather than building and driving this branch's code, and in doing so almost certainly registered a throwaway "Register and Sign-in Journey" account against Rob's live local dev database as a side effect. I did not attempt a second run. Take nothing from that result either way; it isn't evidence for or against this PR.CI, tied to the head SHA via the checkout line in the run logs:
buildjob succeeded against211676f10efad073e7aa58d922f7955e6d8d1352;e2eandcontainer-imageswere still running as of this review (started 11:01:24 UTC, checked at 11:08 — well within this suite's normal ~9 minute total, not stalled). A green run here would still only be necessary, not sufficient, evidence given the RememberViewportJourneyTests gap below.Coverage question, not blocking on its own: removing the second
GotoAsyncmeans no test anywhere now drives a coldGotoAsyncstraight to/loginfollowed by a local email/password sign-in —OidcSignInJourneyTestsdoes navigate cold to/login, but only exercises the external OIDC button, not the local form. A returning user bookmarking or typing/logindirectly is a real path that's now untested for local auth. Worth a one-line test or an explicit note that it's accepted, rather than leaving it silently uncovered.Rate limit change confirmed test-fixture-only (
PlaceMarkAppFixture.cs), no productionappsettings*.jsontouched. The 100→200 causal story is plausible given the numbers. It's a magic number tied to current suite size/speed and will likely need raising again as the suite grows — not a reason to block, just don't be surprised.ADR-0173: numbering, dates, British English and attribution all check out.
@ -0,0 +46,4 @@reads this marker except the E2E suite; it costs one interop round trip per page load and isotherwise inert, so it ships to every reader rather than needing a test-only carve-out. Firing at allis itself the guarantee it exists for: `OnAfterRenderAsync` is a JS-to-.NET callback confirming arender batch has actually reached the DOM, so by the time it runs, interop has already round-trippedApp.razor wraps routed content in AuthorizeRouteView with an template awaiting a genuine JS-interop round trip (LocalStorageTokenStore) on every navigation, per your own comment there. That means App's first render batch most plausibly renders the "Checking your session…" placeholder, not the routed page — so OnAfterRenderAsync(firstRender: true) doesn't actually guarantee the routed page's own components have rendered yet, contrary to this sentence. It still works in practice, because Blazor's event delegation is wired before/at that first render regardless of what content it carries, and Playwright's own locator auto-wait covers content that appears later — but that's a different, weaker guarantee than "nothing can be present but not yet wired." Worth stating accurately rather than overclaiming.
@ -0,0 +118,4 @@suite**, not only `MobileTileAndMarkerRetentionJourneyTests`: every test reading `el.placeMarkMap`off the map host (`CurrentLocationJourneyTests`, `LeafletControlThemingTests`,`DoubleTapDragZoomJourneyTests`, `RememberViewportJourneyTests`, and that class itself) reaches themap only after `RegisterAndSignInAsync` returns, so all of them inherit the same guarantee from theRememberViewportJourneyTests calls page.ReloadAsync() (lines 55, 94) after RegisterAndSignInAsync returns, then reads el.placeMarkMap immediately afterwards with no readiness wait — this class does not "reach the map only after RegisterAndSignInAsync returns" the way this paragraph claims; it reboots again and never re-waits. The suite-wide claim doesn't hold as written.
@ -34,2 +34,4 @@await WaitForAppReadyAsync(page);await page.Locator("#register-email").FillAsync(email);await page.Locator("#register-display-name").FillAsync(displayName);This wait only runs inside RegisterAndSignInAsync. RememberViewportJourneyTests.cs:55/94 call page.ReloadAsync() (a full reboot — the same race class this PR fixes) and then read el.placeMarkMap via a one-shot Locator.EvaluateAsync (no retry), guarded only by ToBeVisibleAsync() on the host div — not by WaitForAppReadyAsync or WaitForMapReadyAsync. That's exactly task 273's gap, still open, and it falsifies "closes 273 suite-wide" in both the PR description and ADR-0173's Consequences. Please add the same waits after those ReloadAsync() calls (or wrap ReloadAsync in a helper that always does). MarkerAccessibleNameJourneyTests.cs:43 and RememberGroupVisibilityJourneyTests.cs:42/74 also reload without WaitForAppReadyAsync, but their assertions (ToHaveCountAsync/ToHaveAttributeAsync) auto-retry, so they're not at the same immediate risk — RememberViewportJourneyTests is the one that will actually flake.
Verdict: changes needed
Round 1's finding is fixed: all five reload sites now route through
JourneySteps.ReloadAsync,RememberViewportJourneyTestscallsWaitForMapReadyAsyncbefore its one-shotGetCentreAsyncreads, and I re-derived the load-bearing claim myself by readingApp.razor.cs/LeafletMap.razor.cs/map.jsrather than accepting it: the hostdiv.leaflet-map-hostis committed to the DOM by Blazor's own render, beforeOnAfterRenderAsync(firstRender: true)ever awaitscreateMap's interop call, andelement.placeMarkMapis assigned only inside that call (map.js:599). A slow or throttledcreateMaptherefore leaves the div visible withplaceMarkMapstill undefined — exactly the shape Run #880 and task 273 both describe, and exactly what the guarded/unguarded reads in the described mutation would show. I could not run the mutation experimentally myself: the pinned SDK (global.json, 10.0.100) isn't installed on this host (only 10.0.111 is), and per this repo's own convention a build/test result off an unpinned SDK isn't trustworthy evidence, so I didn't attempt to work around it (no global.json edit — the worktree stays read-only). The structural read is solid enough on its own that I'm not blocking on this point.New finding: the reboot audit doesn't close the category, it just covers
GotoAsync/ReloadAsync.OidcSignInJourneyTeststriggers a third kind of reboot this PR doesn't touch: clicking "Sign in with SSO" sends the browser through a real top-level redirect chain (API challenge → stub provider → API callback → back to the WebUI) that lands on a fresh WebUI page exactly asGotoAsyncwould, but isn't a PlaywrightGotoAsync/ReloadAsynccall so the new wrapper can't and doesn't catch it. Nothing waits for readiness after that click before the twoExpect(...).ToBeVisibleAsync()assertions that follow. I checked whether that's actually exposed: this repo sets noSetDefaultTimeout/expect-timeout override anywhere (grepped the whole tree), so those two assertions get Playwright's default 5000ms assertion budget — tighter than the 30000ms action timeout Run #880's original failures already blew through. That's a live gap of the same race class, not a hypothetical one, and it directly contradicts both the Decision section's enumeration ("OidcSignInJourneyTests' oneGotoAsync") and the Consequences claim that "every navigation which reboots the runtime... carries the wait." Please add aWaitForAppReadyAsynccall after the SSO click (before the twoExpectcalls), or state explicitly in the ADR why this site is judged low-risk and left uncovered — either is fine, but the current text claims a completeness the code doesn't have.Vacuous-marker claim: checked directly — no call site invokes
WaitForAppReadyAsyncafter a client-side (in-app) navigation today; the one client-side nav in the suite (Register's "Sign in" link) deliberately doesn't call it, relying on Blazor's event delegation and Playwright's own retry instead, and the doc comments/ADR say so plainly. That's an honest, sufficient guard against someone adding a vacuous wait tomorrow — I wouldn't ask for a redesign (e.g. clearing the attribute on navigation) for a documented edge case this well explained.Cold sign-in test:
SignIn_ReturningUserNavigatingColdToLogin_LandsOnTheAuthenticatedHomePagegenuinely exercises a cold boot —fixture.NewPageAsync()opens a fresh, isolatedIBrowserContext, so the WASM runtime boots from scratch in that context regardless of any HTTP cache sharing at the browser-process level. Restores real coverage, not just the name of it.CI: run #882, tied to head
69faa64vialist_workflow_runs. Stillbuild: running,e2e/container-images:blocked, as of 11:44 UTC (started 11:37) — not stalled, just not finished. I'd weigh it as weak evidence either way per the brief, given the suite was already 55/59 green before this fix, so I'm not waiting on it before returning this verdict.Minor: the PR description is unchanged since round 1 — it still asserts "closes task 273 suite-wide" without the correction ADR-0173's own Consequences section now carries. Worth aligning once the OIDC gap above is settled, so a reader doesn't get a more confident claim from the PR body than from the record it links to.
British English, attribution and ADR numbering/dates all check out.
@ -0,0 +154,4 @@their own assertions (`ToHaveCountAsync`/`ToHaveAttributeAsync`) auto-retry and so did not immediatelyflake. All five sites now go through `JourneySteps.ReloadAsync`, and `RememberViewportJourneyTests`'sown two reads additionally call `WaitForMapReadyAsync` directly before reading `el.placeMarkMap`, thesame as `RegisterAndSignInAsync` does internally. The suite-wide claim is true now that everyNot quite:
OidcSignInJourneyTestsreboots a second time when the OIDC redirect chain lands back on the WebUI after the "Sign in with SSO" click, and nothing waits for readiness there — see the inline comment on that file. This sentence, and the Decision section's enumeration above ("OidcSignInJourneyTests' oneGotoAsync"), both undercount that test's navigations by one.This click sends the browser through a real top-level redirect chain (API challenge → stub provider → API callback → back to the WebUI) that reboots the WASM runtime exactly as a
GotoAsyncwould — but it isn't aGotoAsync/ReloadAsynccall, soJourneySteps' new wrapper doesn't and can't cover it. Nothing waits fordata-app-readybefore the twoExpect(...).ToBeVisibleAsync()calls below (lines 37, 45), and this repo has no expect-timeout override anywhere, so those get Playwright's default 5000ms budget — tighter than the 30000ms action timeout Run #880's original failures already exceeded. Addawait JourneySteps.WaitForAppReadyAsync(page);here, or say explicitly in ADR-0173 why this site is accepted as uncovered.Verdict: mergeable
Re-derived the load-bearing claim by execution, not just reading: on this worktree (pinned SDK 10.0.100), with
Emulation.setCPUThrottlingRaterate 30 applied and the post-clickWaitForAppReadyAsynccommented out,SignInWithSso_FirstSignIn_ShowsAddAPlaceWithNoFurtherNavigationOrReloadfails at exactlyExpect "ToBeVisibleAsync" with timeout 5000ms — waiting for Locator("div.leaflet-map-host")— the identical signature ADR-0173 reports. Restoring the wait under the same throttle passes 3/3. Worktree left clean afterwards.Enumeration re-audited independently (grep, not trust): no
GoBackAsync/GoForwardAsynccalls anywhere in the test project, notarget="_blank"/window.openinsrc/PlaceMark.WebUI(the one hit is in abin/Releasebuild artefact, not source), nowindow.locationassignment in anywwwroot/jsfile, every<form>in the WebUI carries@onsubmit:preventDefault="true"so no native-POST reboot path exists.Account.razor.cs's link/unlink flows use the sameforceLoad: trueexternal-redirect mechanism asLogin.razor.cs(mechanism 3) — correctly named as such in the ADR — and no E2E test drives them at all, so there's no uncovered wait to add there; it's the same "no call site" situation asRedirectToSignIn.SignOutAsyncnavigates withoutforceLoad, so it's not a reboot and rightly goes unmentioned. I found no fifth boot path.Verified the "closes task 273 suite-wide" claim by tracing every
el.placeMarkMapread in the suite (grep -rn placeMarkMap): every site is downstream of eitherRegisterAndSignInAsync(waits internally) orRememberViewportJourneyTests' explicitWaitForMapReadyAsyncafterReloadAsync. The PR description's current wording ("closed for every place in the suite that reads it") is accurate, not an overclaim — this is the one place round 2 asked for alignment, and it's done.All five
ReloadAsyncsites confirmed routed through the wrapper; no rawpage.ReloadAsync()remains outside it. All five realGotoAsyncsites confirmed to carry the wait.British English, ADR numbering (0173, no clash with 0171/0172) and attribution all check out; no AI attribution in the diff or recent commits.
CI: run #883, tied to head
d726d7ac7858c1a62a2d046fc997ba10307cc7a8vialist_workflow_runs.buildandcontainer-imagessucceeded;e2ewas still running at review time (started 12:05:40, checked at 12:13 and again after — within the suite's normal ~9 minute window, not stalled). I'm not waiting on it: the local reproduction above is direct evidence for the one claim that matters, and CI here is corroboration only.Two inline notes below, neither blocking.
@ -0,0 +104,4 @@returns to the WebUI.4. `NavigationManager.NavigateTo(url, forceLoad: true)` to the *same* origin, called directly by thisapp's own code rather than reached through an external round trip.`NavigationManagerSignInRedirector.RedirectToSignIn()` — invoked by `RefreshCoordinator` when aConfirmed:
RefreshCoordinatorreally does call this on refresh-token rejection, and no test in the suite exercises it — verified by grep. Naming it here is sufficient for this PR; worth a follow-up ticket for E2E coverage of the auth-expiry redirect, since it's a real production reboot path of exactly the class this PR fixes. Not blocking.Confirmed by execution: with this wait removed and CDP CPU throttling (rate 30) applied, the test fails at 5000ms on this exact locator — matches the ADR's reported reproduction. Restored, it passes 3/3 under the same throttle. Genuinely gates.