Skip to content

Commit 3402af6

Browse files
committed
Propagate test method name and harden tracing
Add TestMethodNameContext to carry the active test method name across async flows. TestExecutionService now sets/clears the context around test invocation so MotusTestBase (MSTest/NUnit/xUnit) and BrowserContextFixture can resolve per-method PerformanceBudget attributes when framework TestContext/MethodInfo is not available. Update sample PerformanceTests to use real navigations, add extra assertions and an integration category. Harden Tracing by cancelling lingering pumps, using a local TaskCompletionSource for tracingComplete, adding a 30s timeout, and ensuring pumps are cancelled to avoid races and hangs.
1 parent 94514ae commit 3402af6

File tree

7 files changed

+108
-38
lines changed

7 files changed

+108
-38
lines changed
Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,25 @@
11
namespace Motus.Samples.Tests;
22

33
/// <summary>
4-
/// Performance testing showcase: budget assertions, individual metric assertions,
5-
/// the [PerformanceBudget] attribute, and .Not negation. Uses inline HTML fixtures
6-
/// that trigger real navigations so the performance metrics collector fires.
4+
/// Performance testing showcase demonstrating Phase 2 features:
5+
/// budget assertions, individual metric assertions (LCP, FCP, TTFB, CLS),
6+
/// the [PerformanceBudget] attribute with class/method override, and .Not negation.
7+
///
8+
/// Uses real HTTP navigations so the PerformanceObserver collects actual web vitals.
9+
/// When run in the visual runner, navigate steps appear as annotated timeline markers
10+
/// with LCP, FCP, and CLS values, and the step detail panel shows a Performance section.
711
/// </summary>
812
[TestClass]
13+
[TestCategory("Integration")]
914
[PerformanceBudget(Lcp = 5000, Fcp = 5000, Cls = 1.0)]
1015
public class PerformanceTests : MotusTestBase
1116
{
12-
private const string SimplePage = """
13-
<!DOCTYPE html>
14-
<html lang="en">
15-
<head><title>Performance Sample</title></head>
16-
<body>
17-
<main>
18-
<h1>Performance Test Page</h1>
19-
<p>A lightweight page for budget validation.</p>
20-
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" alt="pixel" />
21-
</main>
22-
</body>
23-
</html>
24-
""";
17+
private const string ExampleUrl = "https://example.com";
2518

2619
[TestMethod]
27-
public async Task SimplePage_MeetsPerformanceBudget()
20+
public async Task ExampleDotCom_MeetsPerformanceBudget()
2821
{
29-
await Fixtures.SetPageContentAsync(Page, SimplePage);
22+
await Page.GotoAsync(ExampleUrl);
3023
await Expect.That(Page).ToMeetPerformanceBudgetAsync();
3124
}
3225

@@ -35,36 +28,55 @@ public async Task SimplePage_MeetsPerformanceBudget()
3528
public async Task MethodAttribute_OverridesClassBudget()
3629
{
3730
// The method-level budget (LCP = 10000ms) overrides the class-level budget.
38-
await Fixtures.SetPageContentAsync(Page, SimplePage);
31+
await Page.GotoAsync(ExampleUrl);
3932
await Expect.That(Page).ToMeetPerformanceBudgetAsync();
4033
}
4134

4235
[TestMethod]
4336
public async Task LcpBelow_IndividualMetricAssertion()
4437
{
45-
await Fixtures.SetPageContentAsync(Page, SimplePage);
38+
await Page.GotoAsync(ExampleUrl);
4639
await Expect.That(Page).ToHaveLcpBelowAsync(5000);
4740
}
4841

4942
[TestMethod]
5043
public async Task FcpBelow_IndividualMetricAssertion()
5144
{
52-
await Fixtures.SetPageContentAsync(Page, SimplePage);
45+
await Page.GotoAsync(ExampleUrl);
5346
await Expect.That(Page).ToHaveFcpBelowAsync(5000);
5447
}
5548

49+
[TestMethod]
50+
public async Task TtfbBelow_ServerResponseTime()
51+
{
52+
await Page.GotoAsync(ExampleUrl);
53+
await Expect.That(Page).ToHaveTtfbBelowAsync(3000);
54+
}
55+
5656
[TestMethod]
5757
public async Task ClsBelow_NoLayoutShift()
5858
{
59-
await Fixtures.SetPageContentAsync(Page, SimplePage);
59+
await Page.GotoAsync(ExampleUrl);
6060
await Expect.That(Page).ToHaveClsBelowAsync(0.5);
6161
}
6262

6363
[TestMethod]
6464
public async Task Not_LcpBelow_NegationAssertsThatLcpIsAtLeastThreshold()
6565
{
66-
await Fixtures.SetPageContentAsync(Page, SimplePage);
67-
// A 1x1 inline page will have LCP well above 0ms
66+
await Page.GotoAsync(ExampleUrl);
67+
// A real page will have LCP well above 0ms
6868
await Expect.That(Page).Not.ToHaveLcpBelowAsync(0);
6969
}
70+
71+
[TestMethod]
72+
public async Task MultipleNavigations_EachProducesTimelineMetrics()
73+
{
74+
// Each GotoAsync fires the PerformanceMetricsCollector, creating a
75+
// separate annotated navigate marker on the visual runner timeline.
76+
await Page.GotoAsync(ExampleUrl);
77+
await Expect.That(Page).ToHaveLcpBelowAsync(5000);
78+
79+
await Page.GotoAsync("https://www.iana.org/domains/reserved");
80+
await Expect.That(Page).ToMeetPerformanceBudgetAsync();
81+
}
7082
}

src/Motus.Runner/Services/TestExecutionService.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,10 @@ private static async Task<TestNodeState> ExecuteTestAsync(DiscoveredTest test, I
208208
{
209209
instance = Activator.CreateInstance(test.TestClass)!;
210210

211+
// Publish the test method name so MotusTestBase can resolve
212+
// per-method attributes (e.g. [PerformanceBudget]) without TestContext
213+
TestMethodNameContext.Set(test.TestMethod.Name);
214+
211215
// Run [TestInitialize] methods
212216
await ExecuteLifecycleMethodAsync(instance, test.TestClass, "TestInitializeAttribute");
213217
// Also support NUnit [SetUp]
@@ -252,6 +256,8 @@ private static async Task<TestNodeState> ExecuteTestAsync(DiscoveredTest test, I
252256
else if (instance is IDisposable disposable)
253257
disposable.Dispose();
254258
}
259+
260+
TestMethodNameContext.Clear();
255261
}
256262
}
257263
}

src/Motus.Testing.MSTest/MotusTestBase.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ public async Task MotusTestInitialize()
7575
_failureTracing = new FailureTracing();
7676
await _failureTracing.StartIfEnabledAsync(_context).ConfigureAwait(false);
7777

78-
var testMethodName = TestContext?.TestName;
78+
var testMethodName = TestContext?.TestName ?? TestMethodNameContext.Current;
7979
var methodInfo = testMethodName is not null
8080
? GetType().GetMethod(testMethodName, BindingFlags.Public | BindingFlags.Instance)
8181
: null;

src/Motus.Testing.NUnit/MotusTestBase.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,10 @@ public async Task SetUp()
6868
_failureTracing = new FailureTracing();
6969
await _failureTracing.StartIfEnabledAsync(_context);
7070

71-
var methodInfo = TestContext.CurrentContext.Test.Method?.MethodInfo;
71+
var testMethodName = TestMethodNameContext.Current;
72+
var methodInfo = testMethodName is not null
73+
? GetType().GetMethod(testMethodName, BindingFlags.Public | BindingFlags.Instance)
74+
: TestContext.CurrentContext.Test.Method?.MethodInfo;
7275
var methodAttr = methodInfo?.GetCustomAttribute<PerformanceBudgetAttribute>();
7376
var classAttr = GetType().GetCustomAttribute<PerformanceBudgetAttribute>();
7477
var activeAttr = methodAttr ?? classAttr;

src/Motus.Testing.xUnit/BrowserContextFixture.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,14 @@ public async Task InitializeAsync()
4444
_context = await _browserFixture.NewContextAsync(ContextOptions);
4545
_page = await _context.NewPageAsync();
4646

47+
var testMethodName = TestMethodNameContext.Current;
48+
var methodInfo = testMethodName is not null
49+
? GetType().GetMethod(testMethodName, BindingFlags.Public | BindingFlags.Instance)
50+
: null;
51+
var methodAttr = methodInfo?.GetCustomAttribute<PerformanceBudgetAttribute>();
4752
var classAttr = GetType().GetCustomAttribute<PerformanceBudgetAttribute>();
48-
var budget = classAttr?.ToBudget();
53+
var activeAttr = methodAttr ?? classAttr;
54+
var budget = activeAttr?.ToBudget();
4955
PerformanceBudgetContext.Push(budget);
5056
PerformanceBudgetContext.SetBudget(_page, budget);
5157
}

src/Motus/Context/Tracing.cs

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,19 +48,24 @@ public async Task StartAsync(TracingStartOptions? options = null)
4848
categories.Add("disabled-by-default-devtools.timeline.picture");
4949
}
5050

51+
// Cancel any lingering pumps from a previous run before starting new ones
52+
_pumpCts?.Cancel();
53+
5154
// Drain any leftover data from a previous run
5255
while (_dataChannel.Reader.TryRead(out _)) { }
5356

54-
_completeTcs = new TaskCompletionSource<TracingTracingCompleteEvent>(
57+
var completeTcs = new TaskCompletionSource<TracingTracingCompleteEvent>(
5558
TaskCreationOptions.RunContinuationsAsynchronously);
59+
_completeTcs = completeTcs;
5660

5761
_pumpCts = new CancellationTokenSource();
5862

5963
// Start background pump for dataCollected events
6064
_ = PumpDataCollectedAsync(_pumpCts.Token);
6165

62-
// Subscribe to tracingComplete
63-
_ = PumpTracingCompleteAsync(_pumpCts.Token);
66+
// Subscribe to tracingComplete; capture the local TCS so cancellation
67+
// of an old pump cannot poison a newer TCS via the shared field.
68+
_ = PumpTracingCompleteAsync(completeTcs, _pumpCts.Token);
6469

6570
await _browserSession.SendAsync(
6671
"Tracing.start",
@@ -79,15 +84,33 @@ public async Task StopAsync(TracingStopOptions? options = null)
7984
return;
8085

8186
var completeTcs = _completeTcs!;
87+
var pumpCts = _pumpCts;
8288

8389
// Send Tracing.end
8490
await _browserSession.SendAsync(
8591
"Tracing.end",
8692
CdpJsonContext.Default.TracingEndResult,
8793
CancellationToken.None).ConfigureAwait(false);
8894

89-
// Wait for tracingComplete event
90-
var completeEvent = await completeTcs.Task.ConfigureAwait(false);
95+
// Wait for tracingComplete event with a timeout to prevent indefinite hangs
96+
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
97+
using var reg = timeoutCts.Token.Register(() => completeTcs.TrySetCanceled());
98+
99+
TracingTracingCompleteEvent completeEvent;
100+
try
101+
{
102+
completeEvent = await completeTcs.Task.ConfigureAwait(false);
103+
}
104+
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
105+
{
106+
throw new TimeoutException(
107+
"Timed out waiting for Tracing.tracingComplete event after 30 seconds.");
108+
}
109+
finally
110+
{
111+
// Cancel background pumps immediately so they don't race with future runs
112+
pumpCts?.Cancel();
113+
}
91114

92115
// Collect all accumulated data chunks
93116
var allEvents = new List<JsonElement>();
@@ -104,9 +127,6 @@ await _browserSession.SendAsync(
104127
allEvents.AddRange(streamEvents);
105128
}
106129

107-
// Cancel background pumps
108-
_pumpCts?.Cancel();
109-
110130
// Extract screenshots from trace events
111131
var screenshots = ExtractScreenshots(allEvents);
112132

@@ -139,7 +159,9 @@ private async Task PumpDataCollectedAsync(CancellationToken ct)
139159
catch (OperationCanceledException) { }
140160
}
141161

142-
private async Task PumpTracingCompleteAsync(CancellationToken ct)
162+
private async Task PumpTracingCompleteAsync(
163+
TaskCompletionSource<TracingTracingCompleteEvent> completeTcs,
164+
CancellationToken ct)
143165
{
144166
try
145167
{
@@ -148,13 +170,13 @@ private async Task PumpTracingCompleteAsync(CancellationToken ct)
148170
CdpJsonContext.Default.TracingTracingCompleteEvent,
149171
ct).ConfigureAwait(false))
150172
{
151-
_completeTcs?.TrySetResult(evt);
173+
completeTcs.TrySetResult(evt);
152174
break;
153175
}
154176
}
155177
catch (OperationCanceledException)
156178
{
157-
_completeTcs?.TrySetCanceled();
179+
completeTcs.TrySetCanceled();
158180
}
159181
}
160182

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
namespace Motus;
2+
3+
/// <summary>
4+
/// Carries the current test method name across async boundaries.
5+
/// The visual runner sets this before invoking test lifecycle methods so that
6+
/// <c>MotusTestBase</c> can resolve per-method attributes (e.g. <c>[PerformanceBudget]</c>)
7+
/// without requiring a framework-specific <c>TestContext</c>.
8+
/// </summary>
9+
public static class TestMethodNameContext
10+
{
11+
private static readonly AsyncLocal<string?> s_name = new();
12+
13+
/// <summary>Sets the active test method name for the current async flow.</summary>
14+
public static void Set(string? methodName) => s_name.Value = methodName;
15+
16+
/// <summary>Clears the active test method name.</summary>
17+
public static void Clear() => s_name.Value = null;
18+
19+
/// <summary>The active test method name, or null if not set.</summary>
20+
public static string? Current => s_name.Value;
21+
}

0 commit comments

Comments
 (0)