Skip to content

Commit 45441b5

Browse files
committed
Fix UI test shared context orchestration
1 parent ffe7602 commit 45441b5

8 files changed

Lines changed: 174 additions & 27 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +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` with `LocalLimit = 15`; do not keep lower per-project local parallel caps unless the user explicitly asks for an exception.
117117
- Browser-suite CI parallelism is user-tunable. When suite duration becomes a bottleneck and the user asks for higher throughput, prefer splitting work into `4` or `8` parallel GitHub Actions test jobs before reaching for timeout increases; only keep lower `CiLimit` caps when a specific flake requires them.
118-
- Local regression verification must include solution-level `dotnet test --solution ./PrompterOne.slnx -m:1` so test-project split changes are proven under the real all-tests entrypoint, not only as isolated per-project runs.
118+
- 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.
119119
- 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.
120120
- Selector-contract remediation requests must be handled repo-wide across all relevant test files (`Web.Tests` and `Web.UITests`), not as partial per-file cleanups.
121121
- Repo-wide quality audits and agent-generated review handoff artifacts must be written as root-level task files so other coding agents can pick them up quickly; do not bury those temporary audit results under `docs/` unless the task is explicitly about durable product documentation.
@@ -129,7 +129,7 @@ Rule format:
129129
### Commands
130130

131131
- `build`: `dotnet build ./PrompterOne.slnx -warnaserror`
132-
- `test`: `dotnet test --solution ./PrompterOne.slnx -m:1`
132+
- `test`: `dotnet test --solution ./PrompterOne.slnx --max-parallel-test-modules 1`
133133
- `format`: `dotnet format ./PrompterOne.slnx`
134134
- `coverage`: `dotnet test --project ./tests/PrompterOne.Core.Tests/PrompterOne.Core.Tests.csproj -- --coverage --coverage-output-format cobertura && dotnet test --project ./tests/PrompterOne.Web.Tests/PrompterOne.Web.Tests.csproj -- --coverage --coverage-output-format cobertura && dotnet test --project ./tests/PrompterOne.Web.UITests.Shell/PrompterOne.Web.UITests.Shell.csproj -- --coverage --coverage-output-format cobertura && dotnet test --project ./tests/PrompterOne.Web.UITests.Studio/PrompterOne.Web.UITests.Studio.csproj -- --coverage --coverage-output-format cobertura && dotnet test --project ./tests/PrompterOne.Web.UITests.Editor/PrompterOne.Web.UITests.Editor.csproj -- --coverage --coverage-output-format cobertura && dotnet test --project ./tests/PrompterOne.Web.UITests.Reader/PrompterOne.Web.UITests.Reader.csproj -- --coverage --coverage-output-format cobertura`
135135

@@ -146,7 +146,7 @@ Useful focused commands:
146146
- app run: `cd ./src/PrompterOne.Web && dotnet run`
147147
- core tests: `dotnet test --project ./tests/PrompterOne.Core.Tests/PrompterOne.Core.Tests.csproj`
148148
- component tests: `dotnet test --project ./tests/PrompterOne.Web.Tests/PrompterOne.Web.Tests.csproj`
149-
- all tests: `dotnet test --solution ./PrompterOne.slnx -m:1`
149+
- all tests: `dotnet test --solution ./PrompterOne.slnx --max-parallel-test-modules 1`
150150
- ui tests: `dotnet test --project ./tests/PrompterOne.Web.UITests.Shell/PrompterOne.Web.UITests.Shell.csproj && dotnet test --project ./tests/PrompterOne.Web.UITests.Studio/PrompterOne.Web.UITests.Studio.csproj && dotnet test --project ./tests/PrompterOne.Web.UITests.Editor/PrompterOne.Web.UITests.Editor.csproj && dotnet test --project ./tests/PrompterOne.Web.UITests.Reader/PrompterOne.Web.UITests.Reader.csproj`
151151
- ui shell tests: `dotnet test --project ./tests/PrompterOne.Web.UITests.Shell/PrompterOne.Web.UITests.Shell.csproj`
152152
- ui studio tests: `dotnet test --project ./tests/PrompterOne.Web.UITests.Studio/PrompterOne.Web.UITests.Studio.csproj`

tests/PrompterOne.Testing/EnvironmentAwareParallelLimitBase.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ namespace PrompterOne.Testing;
77
/// </summary>
88
public abstract class EnvironmentAwareParallelLimitBase : IParallelLimit
99
{
10-
protected virtual int CiLimit { get; } = 4;
11-
protected virtual int LocalLimit { get; } = 10;
10+
protected virtual int CiLimit { get; } = 2;
11+
protected virtual int LocalLimit { get; } = 15;
1212

1313
public int Limit => ResolveLimit();
1414

tests/PrompterOne.Testing/PrompterOne.Testing.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4+
<IsTestProject>false</IsTestProject>
45
<IsPackable>false</IsPackable>
56
<TUnitImplicitUsings>false</TUnitImplicitUsings>
67
<TUnitAssertionsImplicitUsings>false</TUnitAssertionsImplicitUsings>

tests/PrompterOne.Web.UITests.Reader/Reader/ReaderPlaybackTimingTests.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,11 @@ await Expect(page.GetByTestId(UiTestIds.Learn.SpeedValue))
235235
await InstallWordRecorderAsync(page, UiTestIds.Learn.Word);
236236
await page.GetByTestId(UiTestIds.Learn.PlayToggle).ClickAsync();
237237

238-
return await WaitForRecordedSamplesAsync(page, expectedSampleCount);
238+
var samples = await WaitForRecordedSamplesAsync(page, expectedSampleCount);
239+
await Expect(page.GetByTestId(UiTestIds.Learn.PlayToggle))
240+
.ToHaveAttributeAsync("aria-pressed", bool.FalseString.ToLowerInvariant());
241+
242+
return samples;
239243
}
240244

241245
private static Task SeedLearnSpeedAsync(IPage page, int targetWpm) =>

tests/PrompterOne.Web.UITests.Shell/Infrastructure/DynamicHostPortTests.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
using Microsoft.Playwright;
22
using PrompterOne.Shared.Contracts;
3+
using PrompterOne.Shared.Services;
34
using static Microsoft.Playwright.Assertions;
45

56
namespace PrompterOne.Web.UITests;
67

78
[ClassDataSource<StandaloneAppFixture>(Shared = SharedType.PerClass)]
89
public sealed class DynamicHostPortTests(StandaloneAppFixture fixture)
910
{
11+
private const string FixtureStorageProbeKey = "fixture-shared-context-probe";
12+
private const string FixtureStorageProbeValue = "shared-context-visible";
13+
private const string ReadLocalStorageScript = "(key) => window.localStorage.getItem(key) ?? ''";
1014
private const int RepeatedBootstrapPageCount = 10;
1115
private readonly StandaloneAppFixture _fixture = fixture;
1216

@@ -59,4 +63,56 @@ await Expect(page.GetByTestId(UiTestIds.Library.Page))
5963
}
6064
}
6165
}
66+
67+
[Test]
68+
public async Task NewPageAsync_DefaultPath_ReusesSharedBrowserStorage()
69+
{
70+
var primaryPage = await _fixture.NewPageAsync();
71+
var secondaryPage = await _fixture.NewPageAsync();
72+
73+
try
74+
{
75+
await primaryPage.GotoAsync(UiTestHostConstants.BlankPagePath);
76+
await primaryPage.EvaluateAsync(
77+
BrowserTestConstants.Localization.SetLocalStorageScript,
78+
new[] { FixtureStorageProbeKey, FixtureStorageProbeValue });
79+
80+
await secondaryPage.GotoAsync(UiTestHostConstants.BlankPagePath);
81+
var storedValue = await secondaryPage.EvaluateAsync<string>(ReadLocalStorageScript, FixtureStorageProbeKey);
82+
var seededLibrary = await secondaryPage.EvaluateAsync<string>(ReadLocalStorageScript, BrowserStorageKeys.DocumentLibrary);
83+
84+
await Assert.That(storedValue).IsEqualTo(FixtureStorageProbeValue);
85+
await Assert.That(string.IsNullOrWhiteSpace(seededLibrary)).IsFalse();
86+
}
87+
finally
88+
{
89+
await primaryPage.Context.CloseAsync();
90+
}
91+
}
92+
93+
[Test]
94+
public async Task NewPageAsync_AdditionalContext_KeepsBrowserStorageIsolated()
95+
{
96+
var sharedPage = await _fixture.NewPageAsync();
97+
var isolatedPage = await _fixture.NewPageAsync(additionalContext: true);
98+
99+
try
100+
{
101+
await sharedPage.GotoAsync(UiTestHostConstants.BlankPagePath);
102+
await sharedPage.EvaluateAsync(
103+
BrowserTestConstants.Localization.SetLocalStorageScript,
104+
new[] { FixtureStorageProbeKey, FixtureStorageProbeValue });
105+
106+
var storedValue = await isolatedPage.EvaluateAsync<string>(ReadLocalStorageScript, FixtureStorageProbeKey);
107+
var seededLibrary = await isolatedPage.EvaluateAsync<string>(ReadLocalStorageScript, BrowserStorageKeys.DocumentLibrary);
108+
109+
await Assert.That(storedValue).IsEqualTo(string.Empty);
110+
await Assert.That(string.IsNullOrWhiteSpace(seededLibrary)).IsFalse();
111+
}
112+
finally
113+
{
114+
await sharedPage.Context.CloseAsync();
115+
await isolatedPage.Context.CloseAsync();
116+
}
117+
}
62118
}

tests/PrompterOne.Web.UITests/Infrastructure/StandaloneAppFixture.cs

Lines changed: 105 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Collections.Concurrent;
22
using System.Net;
3+
using System.Runtime.CompilerServices;
34
using Microsoft.Playwright;
45

56
namespace PrompterOne.Web.UITests;
@@ -10,6 +11,7 @@ public sealed partial class StandaloneAppFixture : IAsyncInitializer, IAsyncDisp
1011
private const int ServerStartupTimeoutSeconds = 60;
1112
private const int ServerProbeDelayMilliseconds = 500;
1213
private readonly ConcurrentDictionary<IBrowserContext, byte> _contexts = [];
14+
private readonly ConcurrentDictionary<string, IBrowserContext> _sharedContexts = new(StringComparer.Ordinal);
1315
private SharedRuntimeHandle? _runtimeHandle;
1416

1517
public string BaseAddress => _runtimeHandle?.BaseAddress ?? throw new InvalidOperationException("UI test runtime is not initialized.");
@@ -25,6 +27,7 @@ public async Task InitializeAsync()
2527
public async ValueTask DisposeAsync()
2628
{
2729
await DisposeTrackedContextsAsync();
30+
_sharedContexts.Clear();
2831

2932
if (_runtimeHandle is not null)
3033
{
@@ -36,50 +39,114 @@ public async ValueTask DisposeAsync()
3639
public async Task ResetRuntimeAsync()
3740
{
3841
await DisposeTrackedContextsAsync();
42+
_sharedContexts.Clear();
3943

4044
_runtimeHandle = null;
4145
await SharedRuntime.ResetAsync();
4246
}
4347

44-
public async Task<IPage> NewPageAsync()
48+
public Task<IPage> NewPageAsync(
49+
bool additionalContext = false,
50+
[CallerMemberName] string contextKey = "")
4551
{
46-
var context = await NewContextAsync();
47-
var page = await context.NewPageAsync();
48-
PreparePage(page);
49-
await PrimeIsolatedBrowserStorageAsync(page);
50-
return page;
52+
return additionalContext
53+
? CreateAdditionalPageAsync()
54+
: CreateSharedPageAsync(contextKey);
5155
}
5256

53-
public async Task<IReadOnlyList<IPage>> NewSharedPagesAsync(int pageCount)
57+
public async Task<IReadOnlyList<IPage>> NewSharedPagesAsync(
58+
int pageCount,
59+
[CallerMemberName] string contextKey = "")
5460
{
5561
ArgumentOutOfRangeException.ThrowIfLessThan(pageCount, MinimumPageCount);
5662

57-
var context = await NewContextAsync();
5863
var pages = new List<IPage>(pageCount);
5964

6065
for (var pageIndex = 0; pageIndex < pageCount; pageIndex++)
6166
{
62-
var page = await context.NewPageAsync();
63-
PreparePage(page);
64-
pages.Add(page);
67+
pages.Add(await NewPageAsync(contextKey: contextKey));
6568
}
6669

67-
await PrimeIsolatedBrowserStorageAsync(pages[0]);
6870
return pages;
6971
}
7072

71-
private async Task<IBrowserContext> NewContextAsync()
73+
private async Task<IPage> CreateAdditionalPageAsync()
74+
{
75+
var context = await CreateTrackedContextAsync();
76+
var page = await context.NewPageAsync();
77+
PreparePage(page);
78+
await PrimeIsolatedBrowserStorageAsync(page);
79+
return page;
80+
}
81+
82+
private async Task<IPage> CreateSharedPageAsync(string contextKey)
83+
{
84+
while (true)
85+
{
86+
var (context, isNewSharedContext) = await GetOrCreateSharedContextAsync(contextKey);
87+
88+
try
89+
{
90+
var page = await context.NewPageAsync();
91+
PreparePage(page);
92+
93+
await page.GotoAsync($"{BaseAddress}{UiTestHostConstants.BlankPagePath}");
94+
95+
if (isNewSharedContext)
96+
{
97+
await page.EvaluateAsync(
98+
UiTestHostConstants.ResetBrowserStorageScript,
99+
UiTestHostConstants.BrowserStorageDatabaseName);
100+
await page.EvaluateAsync(BrowserTestLibrarySeedData.CreateInitializationScript());
101+
}
102+
103+
return page;
104+
}
105+
catch (PlaywrightException exception) when (IsBrowserClosedException(exception))
106+
{
107+
RemoveSharedContext(context);
108+
}
109+
}
110+
}
111+
112+
private async Task<(IBrowserContext Context, bool IsNew)> GetOrCreateSharedContextAsync(string contextKey)
113+
{
114+
if (_sharedContexts.TryGetValue(contextKey, out var existingContext))
115+
{
116+
EnsureContextTracked(existingContext);
117+
return (existingContext, false);
118+
}
119+
120+
var newContext = await CreateTrackedContextAsync();
121+
122+
if (_sharedContexts.TryAdd(contextKey, newContext))
123+
{
124+
return (newContext, true);
125+
}
126+
127+
try
128+
{
129+
await newContext.DisposeAsync();
130+
}
131+
catch
132+
{
133+
}
134+
135+
return (_sharedContexts[contextKey], false);
136+
}
137+
138+
private async Task<IBrowserContext> CreateTrackedContextAsync()
72139
{
73140
var runtime = await EnsureRuntimeHandleAsync();
74-
var context = await CreateContextAsync(runtime.Browser);
141+
var context = await CreateBrowserContextAsync(runtime.Browser);
75142
await context.AddInitScriptAsync(BrowserTestLibrarySeedData.CreateInitializationScript());
76143
await context.AddInitScriptAsync(UiTestHostConstants.RuntimeTelemetryHarnessInitializationScript);
77144
await context.GrantPermissionsAsync(UiTestHostConstants.GrantedPermissions, new BrowserContextGrantPermissionsOptions
78145
{
79146
Origin = runtime.BaseAddress
80147
});
81148
await ConfigureMediaHarnessAsync(context);
82-
TrackContextLifecycle(context);
149+
EnsureContextTracked(context);
83150
return context;
84151
}
85152

@@ -102,10 +169,29 @@ private async Task DisposeTrackedContextsAsync()
102169
}
103170
}
104171

105-
private void TrackContextLifecycle(IBrowserContext context)
172+
private void EnsureContextTracked(IBrowserContext context)
173+
{
174+
if (!_contexts.TryAdd(context, 0))
175+
{
176+
return;
177+
}
178+
179+
context.Close += (_, _) =>
180+
{
181+
_contexts.TryRemove(context, out _);
182+
RemoveSharedContext(context);
183+
};
184+
}
185+
186+
private void RemoveSharedContext(IBrowserContext context)
106187
{
107-
_contexts.TryAdd(context, 0);
108-
context.Close += (_, _) => _contexts.TryRemove(context, out _);
188+
foreach (var entry in _sharedContexts)
189+
{
190+
if (ReferenceEquals(entry.Value, context))
191+
{
192+
_sharedContexts.TryRemove(entry.Key, out _);
193+
}
194+
}
109195
}
110196

111197
private async Task<SharedRuntimeHandle> EnsureRuntimeHandleAsync()
@@ -120,7 +206,7 @@ private async Task<SharedRuntimeHandle> EnsureRuntimeHandleAsync()
120206
return _runtimeHandle;
121207
}
122208

123-
private async Task<IBrowserContext> CreateContextAsync(IBrowser browser)
209+
private async Task<IBrowserContext> CreateBrowserContextAsync(IBrowser browser)
124210
{
125211
try
126212
{

tests/PrompterOne.Web.UITests/PrompterOne.Web.UITests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4+
<IsTestProject>false</IsTestProject>
45
<IsPackable>false</IsPackable>
56
<TUnitImplicitUsings>false</TUnitImplicitUsings>
67
<TUnitAssertionsImplicitUsings>false</TUnitAssertionsImplicitUsings>
@@ -10,7 +11,6 @@
1011
<PackageReference Include="Microsoft.Playwright" />
1112
<PackageReference Include="TUnit.Assertions" />
1213
<PackageReference Include="TUnit.Core" />
13-
<PackageReference Include="TUnit.Playwright" />
1414
</ItemGroup>
1515

1616
<ItemGroup>

tests/PrompterOne.Web.UITests/Support/AppUiTestBase.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ protected async Task<T> RunPageAsync<T>(
2020
Func<IPage, Task<T>> scenario,
2121
[CallerMemberName] string testName = "")
2222
{
23-
var page = await _fixture.NewPageAsync();
23+
var page = await _fixture.NewPageAsync(additionalContext: true, contextKey: testName);
2424

2525
try
2626
{

0 commit comments

Comments
 (0)