Skip to content

Commit 9187967

Browse files
committed
Stabilize browser test route bootstrap
1 parent 4bea2cd commit 9187967

28 files changed

Lines changed: 336 additions & 290 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ Rule format:
115115
- Shared test-support libraries that contain no runnable test cases must not reference the TUnit engine package directly; keep them on non-engine TUnit packages so solution-level `dotnet test` does not discover zero-test support DLLs as runnable test apps.
116116
- Every runnable test project must declare `MaxParallelTestsForPipeline : EnvironmentAwareParallelLimitBase` and rely on the base class limits by default; do not add per-suite `LocalLimit` or `CiLimit` overrides unless a specific suite has a documented exception.
117117
- Browser-suite CI parallelism is user-tunable. When the user wants higher throughput, keep in-suite browser-test CI parallelism in the `6-9` worker range when the harness can sustain it, and prefer extra GitHub Actions job splitting on top of that before reaching for timeout increases; do not pin browser suites back to `2` workers as a blanket fix.
118+
- Browser UI tests must be data-isolated and storage-isolated by default: each scenario should create or import its own script or workspace data, avoid shared persisted document IDs or browser-local state, and remain parallel-safe when multiple pages or contexts hit the app at the same time.
118119
- Local regression verification must include solution-level `dotnet test --solution ./PrompterOne.slnx --max-parallel-test-modules 1` so test-project split changes are proven under the real all-tests entrypoint, not only as isolated per-project runs.
119120
- When the user explicitly asks to validate a test fix in actual GitHub Actions, do not spend more time on local `CI=true` emulation; push the fix and monitor the real CI run instead.
120121
- Do not use local `CI=true` browser-suite runs as a primary reproduction or validation signal unless the user explicitly asks for that exact experiment; in this repo it mostly changes in-suite parallelism and can hide the real CI failure mode instead of reproducing it.

full-suite-stabilization.plan.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,13 @@ Tracked failing tests from the current baseline:
8888
- the studio suite is hitting the same cold-start bootstrap pressure as `Shell`, but the failures fan out through GoLive/media helpers because those helpers seed state only after the first shell route becomes interactive
8989
Fix path:
9090
- reuse the shared-runtime warmup so the first library/settings/go-live routes are already booted before studio scenarios start seeding or asserting
91-
- [ ] `Release Pipeline` run `24203100281` still reports cold fresh-context bootstrap failures after the shared-runtime warmup
91+
- [x] `Release Pipeline` run `24203100281` still reports cold fresh-context bootstrap failures after the shared-runtime warmup
9292
Symptom:
9393
- `Shell` and `Studio` still fail on the first visible page assertion for a newly created browser context, and editor-only scenarios can still hit context/page closure while opening the first routed page inside a fresh context
9494
Root cause:
95-
- the one-time shared-runtime warmup removed process-level cold start, but each brand-new Playwright browser context on CI still pays its own first-route browser bootstrap cost before the test scenario begins
95+
- the earlier hypothesis was incomplete; the added CI-only sacrificial per-context warmup made CI bootstrap behavior diverge from local runs and added extra routed app startup work before every returned test page
9696
Fix path:
97-
- add a CI-only per-context warmup in the browser harness, then reset browser storage again so each returned page is both warmed and isolated
97+
- remove the CI-only per-context routed warmup, keep only the one-time shared-runtime warmup, and make every returned test page follow the same bootstrap path locally and on CI
9898
- [ ] `StandaloneAppFixture` build break: `WarmUpContextPageIfNeededAsync` missing in `StandaloneAppFixture.cs`
9999
Symptom:
100100
- compiler reports `CS0103` for `WarmUpContextPageIfNeededAsync` from `tests/PrompterOne.Web.UITests/Infrastructure/StandaloneAppFixture.cs`
@@ -114,6 +114,18 @@ Tracked failing tests from the current baseline:
114114
Fix path:
115115
- preserve and inspect CI screenshot artifacts for each failing run, then harden the fresh-context bootstrap so local and CI both receive the same blank primed page
116116
- remove mutable library/settings seeding from `AddInitScript`, keep explicit isolated reset+seed only, and make blocked IndexedDB reset fail fast instead of silently succeeding
117+
- [ ] `Release Pipeline` run `24218745809` fails in `Shell` (`27` tests) and `Studio` (`5` tests) after the direct `main` push on commit `4bea2cd`
118+
Symptom:
119+
- `Shell` now fails broadly on the very first routed page visibility checks for `library-page` and `settings-page`, with screenshots showing the shell UI eventually rendered after the assertion already timed out
120+
- `Studio` fails on initial `library-page` visibility in go-live setup plus a smaller cluster of route-return clicks that stay on `/go-live?id=...`
121+
Root cause:
122+
- the CI-only sacrificial per-context warmup still makes every new context pay extra routed bootstrap work that local runs never execute
123+
- `Shell` still opens many routes with raw `GotoAsync(...)` plus one-shot `ToBeVisibleAsync(...)` checks instead of the shared retrying route driver, so slower CI bootstrap falls straight into suite-wide flakes
124+
- `Studio` route-open and go-live-return flows still rely on root-page readiness alone instead of a stronger page-ready contract before clicking or waiting for downstream session state
125+
Fix path:
126+
- remove the per-context routed warmup so local and CI use the same isolated-page bootstrap path
127+
- introduce shared shell route-open helpers and migrate raw shell route opens onto `BrowserRouteDriver`
128+
- strengthen go-live route readiness in the studio helpers before interaction-heavy assertions
117129
- [x] `Release Pipeline` run `24199073318` reports a `Reader` bootstrap cluster (`163` failing tests)
118130
Symptom:
119131
- learn, teleprompter, responsive, and route-visibility tests all fail at the initial `learn-page`, `teleprompter-page`, `settings-page`, `go-live-page`, `editor-page`, or `library-page` visibility gate
@@ -156,6 +168,13 @@ Tracked failing tests from the current baseline:
156168
- the test mixed a fixed wall-clock sleep with a CSS-delay contract, so slower solution-level execution could consume enough time between `HoverAsync` and the assertion for the tooltip fade-in to have already started
157169
Fix path:
158170
- assert the low-opacity state immediately after hover, then rely on the existing tooltip driver to wait for the real visible state instead of stacking extra fixed settle sleeps
171+
- [x] `Reader` route bootstrap/readiness hardening after the `24218745809` CI failures
172+
Symptom:
173+
- remote reader failures clustered around first-route `learn-page` visibility timeouts while the same commit stayed green locally
174+
Root cause:
175+
- the shared reader route helper treated the routed page root as "ready" too early, so slower CI could advance into shortcut/playback assertions before the interactive learn or teleprompter controls were actually present
176+
Fix path:
177+
- strengthen `ReaderRouteDriver` to wait for production-owned interactive sentinels (`learn` progress/play controls, teleprompter stage/play toggle, settings title) instead of stopping at the top-level page shell
159178

160179
## Ordered Plan
161180

@@ -189,6 +208,10 @@ Tracked failing tests from the current baseline:
189208
- local build passes
190209
- local full test pass count is green across all projects
191210
- formatting completes cleanly
211+
Result:
212+
- `dotnet format ./PrompterOne.slnx` passed
213+
- `dotnet build ./PrompterOne.slnx -warnaserror` passed
214+
- `dotnet test @./tests/dotnet-test-progress.rsp --solution ./PrompterOne.slnx --max-parallel-test-modules 1` passed with `1162/1162` green in `7m 42.943s`
192215

193216
- [ ] Step 5. Publish directly to `main`.
194217
Actions:
Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,37 @@
11
using System.Runtime.CompilerServices;
22
using Microsoft.Playwright;
33
using PrompterOne.Shared.Contracts;
4+
using static Microsoft.Playwright.Assertions;
45

56
namespace PrompterOne.Web.UITests;
67

78
internal static class ReaderRouteDriver
89
{
9-
internal static Task OpenLearnAsync(
10+
internal static async Task OpenLearnAsync(
1011
IPage page,
1112
string route,
12-
[CallerMemberName] string scenarioName = "") =>
13-
OpenAsync(page, route, UiTestIds.Learn.Page, scenarioName);
13+
[CallerMemberName] string scenarioName = "")
14+
{
15+
await OpenAsync(page, route, UiTestIds.Learn.Page, scenarioName);
16+
await WaitForLearnReadyAsync(page);
17+
}
1418

15-
internal static Task OpenTeleprompterAsync(
19+
internal static async Task OpenTeleprompterAsync(
1620
IPage page,
1721
string route,
18-
[CallerMemberName] string scenarioName = "") =>
19-
OpenAsync(page, route, UiTestIds.Teleprompter.Page, scenarioName);
22+
[CallerMemberName] string scenarioName = "")
23+
{
24+
await OpenAsync(page, route, UiTestIds.Teleprompter.Page, scenarioName);
25+
await WaitForTeleprompterReadyAsync(page);
26+
}
2027

21-
internal static Task OpenSettingsAsync(
28+
internal static async Task OpenSettingsAsync(
2229
IPage page,
23-
[CallerMemberName] string scenarioName = "") =>
24-
OpenAsync(page, BrowserTestConstants.Routes.Settings, UiTestIds.Settings.Page, scenarioName);
30+
[CallerMemberName] string scenarioName = "")
31+
{
32+
await OpenAsync(page, BrowserTestConstants.Routes.Settings, UiTestIds.Settings.Page, scenarioName);
33+
await WaitForSettingsReadyAsync(page);
34+
}
2535

2636
private static Task OpenAsync(
2737
IPage page,
@@ -33,4 +43,24 @@ private static Task OpenAsync(
3343
route,
3444
pageTestId,
3545
$"{scenarioName}-{pageTestId}");
46+
47+
private static async Task WaitForLearnReadyAsync(IPage page)
48+
{
49+
await Expect(page.GetByTestId(UiTestIds.Learn.Page)).ToBeVisibleAsync();
50+
await Expect(page.GetByTestId(UiTestIds.Learn.ProgressLabel)).ToBeVisibleAsync();
51+
await Expect(page.GetByTestId(UiTestIds.Learn.PlayToggle)).ToBeVisibleAsync();
52+
}
53+
54+
private static async Task WaitForTeleprompterReadyAsync(IPage page)
55+
{
56+
await Expect(page.GetByTestId(UiTestIds.Teleprompter.Page)).ToBeVisibleAsync();
57+
await Expect(page.GetByTestId(UiTestIds.Teleprompter.Stage)).ToBeVisibleAsync();
58+
await Expect(page.GetByTestId(UiTestIds.Teleprompter.PlayToggle)).ToBeVisibleAsync();
59+
}
60+
61+
private static async Task WaitForSettingsReadyAsync(IPage page)
62+
{
63+
await Expect(page.GetByTestId(UiTestIds.Settings.Page)).ToBeVisibleAsync();
64+
await Expect(page.GetByTestId(UiTestIds.Settings.Title)).ToBeVisibleAsync();
65+
}
3666
}

tests/PrompterOne.Web.UITests.Shell/AppShell/HeaderPlaybackLaunchFlowTests.cs

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,7 @@ await page.SetViewportSizeAsync(
1717
BrowserTestConstants.ResponsiveLayout.IphoneMediumWidth,
1818
BrowserTestConstants.ResponsiveLayout.IphoneMediumHeight);
1919

20-
await BrowserRouteDriver.OpenPageAsync(
21-
page,
22-
BrowserTestConstants.Routes.Library,
23-
UiTestIds.Library.Page,
24-
nameof(LibraryCardPlaybackActions_OpenScopedReaderRoutes_AndHeaderBackReturnsToEditor));
20+
await ShellRouteDriver.OpenLibraryAsync(page);
2521

2622
await page.GetByTestId(UiTestIds.Library.CardLearn(BrowserTestConstants.Scripts.QuantumId)).ClickAsync();
2723
await BrowserRouteDriver.WaitForRouteAsync(page, BrowserTestConstants.Routes.LearnQuantum);
@@ -44,8 +40,7 @@ await UiScenarioArtifacts.CapturePageAsync(
4440
BrowserTestConstants.AppShellFlow.LearnBackStep);
4541

4642
await page.GetByTestId(UiTestIds.Header.Home).ClickAsync();
47-
await BrowserRouteDriver.WaitForRouteAsync(page, AppRoutes.Library);
48-
await Expect(page.GetByTestId(UiTestIds.Library.Page)).ToBeVisibleAsync();
43+
await ShellRouteDriver.WaitForLibraryReadyAsync(page, AppRoutes.Library);
4944

5045
await page.GetByTestId(UiTestIds.Library.CardRead(BrowserTestConstants.Scripts.QuantumId)).ClickAsync();
5146
await BrowserRouteDriver.WaitForRouteAsync(page, BrowserTestConstants.Routes.TeleprompterQuantum);
@@ -78,8 +73,7 @@ await page.SetViewportSizeAsync(
7873
BrowserTestConstants.ResponsiveLayout.IphoneMediumWidth,
7974
BrowserTestConstants.ResponsiveLayout.IphoneMediumHeight);
8075

81-
await page.GotoAsync(BrowserTestConstants.Routes.GoLiveDemo);
82-
await Expect(page.GetByTestId(UiTestIds.GoLive.Page)).ToBeVisibleAsync();
76+
await ShellRouteDriver.OpenGoLiveRouteAsync(page, BrowserTestConstants.Routes.GoLiveDemo);
8377

8478
await page.GetByTestId(UiTestIds.GoLive.OpenSettings).ClickAsync();
8579
await BrowserRouteDriver.WaitForRouteAsync(page, BrowserTestConstants.Routes.Settings);
@@ -92,8 +86,7 @@ await UiScenarioArtifacts.CapturePageAsync(
9286
BrowserTestConstants.AppShellFlow.SettingsLaunchStep);
9387

9488
await page.GetByTestId(UiTestIds.Header.Back).ClickAsync();
95-
await BrowserRouteDriver.WaitForRouteAsync(page, BrowserTestConstants.Routes.GoLiveDemo);
96-
await Expect(page.GetByTestId(UiTestIds.GoLive.Page)).ToBeVisibleAsync();
89+
await ShellRouteDriver.WaitForGoLiveReadyAsync(page, BrowserTestConstants.Routes.GoLiveDemo);
9790
await UiScenarioArtifacts.CapturePageAsync(
9891
page,
9992
BrowserTestConstants.AppShellFlow.SettingsOriginScenario,

tests/PrompterOne.Web.UITests.Shell/AppShell/NavigationFlowTests.cs

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,13 @@ public async Task ShellHeader_OpensGoLive_FromLibraryAndSettings()
1919

2020
try
2121
{
22-
await page.GotoAsync(BrowserTestConstants.Routes.Library);
23-
await Expect(page.GetByTestId(UiTestIds.Library.Page)).ToBeVisibleAsync();
22+
await ShellRouteDriver.OpenLibraryAsync(page);
2423

2524
await page.GetByTestId(UiTestIds.Header.GoLive).ClickAsync();
2625
await BrowserRouteDriver.WaitForRouteAsync(page, AppRoutes.GoLive);
2726
await Expect(page.GetByTestId(UiTestIds.GoLive.Page)).ToBeVisibleAsync();
2827

29-
await page.GotoAsync(BrowserTestConstants.Routes.Settings);
30-
await Expect(page.GetByTestId(UiTestIds.Settings.Page)).ToBeVisibleAsync();
28+
await ShellRouteDriver.OpenSettingsAsync(page);
3129

3230
await page.GetByTestId(UiTestIds.Header.GoLive).ClickAsync();
3331
await BrowserRouteDriver.WaitForRouteAsync(page, AppRoutes.GoLive);
@@ -47,9 +45,10 @@ public async Task ScreenNavigation_UsesSpaRoutingWithoutReloadingBrowserContext(
4745

4846
try
4947
{
50-
await page.GotoAsync(BrowserTestConstants.Routes.EditorQuantum);
51-
await Expect(page.GetByTestId(UiTestIds.Editor.Page))
52-
.ToBeVisibleAsync(new() { Timeout = BrowserTestConstants.Timing.ExtendedVisibleTimeoutMs });
48+
await EditorRouteDriver.OpenReadyAsync(
49+
page,
50+
BrowserTestConstants.Routes.EditorQuantum,
51+
nameof(ScreenNavigation_UsesSpaRoutingWithoutReloadingBrowserContext));
5352

5453
await page.EvaluateAsync("value => window.__prompterSpaNonce = value", nonce);
5554

@@ -58,8 +57,10 @@ await Expect(page.GetByTestId(UiTestIds.Editor.Page))
5857
await Expect(page.GetByTestId(UiTestIds.Learn.Page)).ToBeVisibleAsync();
5958
await Assert.That(await page.EvaluateAsync<string>("() => window.__prompterSpaNonce")).IsEqualTo(nonce);
6059

61-
await page.GotoAsync(BrowserTestConstants.Routes.EditorQuantum);
62-
await Expect(page.GetByTestId(UiTestIds.Editor.Page)).ToBeVisibleAsync();
60+
await EditorRouteDriver.OpenReadyAsync(
61+
page,
62+
BrowserTestConstants.Routes.EditorQuantum,
63+
$"{nameof(ScreenNavigation_UsesSpaRoutingWithoutReloadingBrowserContext)}-return");
6364
await page.EvaluateAsync("value => window.__prompterSpaNonce = value", nonce);
6465

6566
await page.GetByTestId(UiTestIds.Header.EditorRead).ClickAsync();
@@ -85,15 +86,13 @@ public async Task ShellHeader_UsesConsistentNeutralGoLiveChrome_OnLibraryAndSett
8586

8687
try
8788
{
88-
await page.GotoAsync(BrowserTestConstants.Routes.Library);
89-
await Expect(page.GetByTestId(UiTestIds.Library.Page)).ToBeVisibleAsync();
89+
await ShellRouteDriver.OpenLibraryAsync(page);
9090
await Expect(page.GetByTestId(UiTestIds.Header.GoLive))
9191
.ToHaveAttributeAsync(BrowserTestConstants.GoLive.LiveStateAttributeName, GoLiveIndicatorStates.Idle);
9292

9393
var libraryChrome = await ReadGoLiveChromeAsync(page);
9494

95-
await page.GotoAsync(BrowserTestConstants.Routes.Settings);
96-
await Expect(page.GetByTestId(UiTestIds.Settings.Page)).ToBeVisibleAsync();
95+
await ShellRouteDriver.OpenSettingsAsync(page);
9796
await Expect(page.GetByTestId(UiTestIds.Header.GoLive))
9897
.ToHaveAttributeAsync(BrowserTestConstants.GoLive.LiveStateAttributeName, GoLiveIndicatorStates.Idle);
9998

tests/PrompterOne.Web.UITests.Shell/AppShell/OnboardingFlowTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public Task FirstRunOnboarding_WalksAcrossCoreProductRoutes_AndStaysDismissedAft
1717
{
1818
await SeedPendingOnboardingAsync(page);
1919

20-
await page.GotoAsync(BrowserTestConstants.Routes.Library);
20+
await ShellRouteDriver.OpenLibraryAsync(page);
2121

2222
await Expect(page.GetByTestId(UiTestIds.Onboarding.Surface)).ToBeVisibleAsync();
2323
await AssertOnboardingStepAsync(
@@ -73,7 +73,7 @@ public Task FirstRunOnboarding_DismissReturnsToLibrary_AndStaysHiddenAfterReload
7373
{
7474
await SeedPendingOnboardingAsync(page);
7575

76-
await page.GotoAsync(BrowserTestConstants.Routes.Library);
76+
await ShellRouteDriver.OpenLibraryAsync(page);
7777

7878
await Expect(page.GetByTestId(UiTestIds.Onboarding.Surface)).ToBeVisibleAsync();
7979

@@ -91,7 +91,7 @@ public Task OnboardingReopen_FromSettings_ReturnsToLibraryWithOverlay() =>
9191
{
9292
await SeedCompletedOnboardingAsync(page);
9393

94-
await page.GotoAsync(BrowserTestConstants.Routes.Settings);
94+
await ShellRouteDriver.OpenSettingsAsync(page);
9595
await page.GetByTestId(UiTestIds.Settings.NavAbout).ClickAsync();
9696

9797
await page.GetByTestId(UiTestIds.Settings.AboutOnboardingRestart).ClickAsync();
@@ -113,7 +113,7 @@ public Task FirstRunOnboarding_UsesUkrainianBrowserCulture_ForLocalizedCopy() =>
113113
await SeedPendingOnboardingAsync(page);
114114
await page.AddInitScriptAsync(BrowserTestConstants.Localization.BuildNavigatorLanguagesInitScript("uk-UA", "en-US"));
115115

116-
await page.GotoAsync(BrowserTestConstants.Routes.Library);
116+
await ShellRouteDriver.OpenLibraryAsync(page);
117117

118118
await Expect(page.GetByTestId(UiTestIds.Onboarding.Title))
119119
.ToHaveTextAsync(BrowserTestConstants.AppShellFlow.OnboardingUkrainianWelcomeTitle);

tests/PrompterOne.Web.UITests.Shell/AppShell/RuntimeTelemetryFlowTests.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
using System.Text.Json;
33
using System.Text.Json.Serialization;
44
using PrompterOne.Shared.Contracts;
5-
using static Microsoft.Playwright.Assertions;
65

76
namespace PrompterOne.Web.UITests;
87

@@ -80,8 +79,7 @@ await route.FulfillAsync(new()
8079

8180
await page.AddInitScriptAsync(UiTestHostConstants.RuntimeTelemetryAllowVendorLoadsScript);
8281

83-
await page.GotoAsync(BrowserTestConstants.Routes.Library);
84-
await Expect(page.GetByTestId(UiTestIds.Library.Page)).ToBeVisibleAsync();
82+
await ShellRouteDriver.OpenLibraryAsync(page);
8583
await WaitForTelemetryCollectionsAsync(
8684
page,
8785
BrowserTestConstants.Telemetry.MinimumInitializationCount,

0 commit comments

Comments
 (0)