Skip to content

Commit c4acc2f

Browse files
committed
Add performance budget support
Introduce performance budget functionality: add PerformanceBudgetAttribute to declare per-test/class thresholds and map sentinel values to nullable PerformanceBudget. Implement PerformanceBudgetContext (AsyncLocal) to hold ambient budget for the current async test flow. Add PageAssertions methods (ToMeetPerformanceBudgetAsync and individual metric checks) that resolve budgets from the ambient context or motus.config.json and format failures. Integrate budget push/clear into MSTest/NUnit/xUnit test setup/teardown to apply method/class attributes. Extend configuration with MotusPerformanceConfig, JSON context generation, env var parsing, and ConfigMerge.ToBudget/merging logic. Add comprehensive unit tests for assertions, attribute mapping, context behavior and config deserialization. Also include small cleanups: tweak BrowserFinder comment and refine Download cancellation exception message.
1 parent a4255aa commit c4acc2f

15 files changed

Lines changed: 811 additions & 3 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
namespace Motus.Abstractions;
2+
3+
/// <summary>
4+
/// Declares performance budget thresholds for a test method or class.
5+
/// Only specified metrics (values >= 0) are enforced. Method-level attributes
6+
/// override class-level attributes.
7+
/// </summary>
8+
[AttributeUsage(
9+
AttributeTargets.Method | AttributeTargets.Class,
10+
AllowMultiple = false,
11+
Inherited = true)]
12+
public sealed class PerformanceBudgetAttribute : Attribute
13+
{
14+
/// <summary>Maximum Largest Contentful Paint in milliseconds. -1 means not set.</summary>
15+
public double Lcp { get; set; } = -1;
16+
17+
/// <summary>Maximum First Contentful Paint in milliseconds. -1 means not set.</summary>
18+
public double Fcp { get; set; } = -1;
19+
20+
/// <summary>Maximum Time to First Byte in milliseconds. -1 means not set.</summary>
21+
public double Ttfb { get; set; } = -1;
22+
23+
/// <summary>Maximum Cumulative Layout Shift score. -1 means not set.</summary>
24+
public double Cls { get; set; } = -1;
25+
26+
/// <summary>Maximum Interaction to Next Paint in milliseconds. -1 means not set.</summary>
27+
public double Inp { get; set; } = -1;
28+
29+
/// <summary>Maximum JavaScript heap size in bytes. -1 means not set.</summary>
30+
public long JsHeapSize { get; set; } = -1;
31+
32+
/// <summary>Maximum DOM node count. -1 means not set.</summary>
33+
public int DomNodeCount { get; set; } = -1;
34+
35+
/// <summary>
36+
/// Converts the attribute values to a <see cref="PerformanceBudget"/> record.
37+
/// Sentinel values (-1) are mapped to null (not enforced).
38+
/// </summary>
39+
public PerformanceBudget ToBudget() => new()
40+
{
41+
Lcp = Lcp >= 0 ? Lcp : null,
42+
Fcp = Fcp >= 0 ? Fcp : null,
43+
Ttfb = Ttfb >= 0 ? Ttfb : null,
44+
Cls = Cls >= 0 ? Cls : null,
45+
Inp = Inp >= 0 ? Inp : null,
46+
JsHeapSize = JsHeapSize >= 0 ? JsHeapSize : null,
47+
DomNodeCount = DomNodeCount >= 0 ? DomNodeCount : null,
48+
};
49+
}

src/Motus.Testing.MSTest/MotusTestBase.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Reflection;
12
using Microsoft.VisualStudio.TestTools.UnitTesting;
23
using Motus.Abstractions;
34

@@ -73,6 +74,15 @@ public async Task MotusTestInitialize()
7374

7475
_failureTracing = new FailureTracing();
7576
await _failureTracing.StartIfEnabledAsync(_context).ConfigureAwait(false);
77+
78+
var testMethodName = TestContext?.TestName;
79+
var methodInfo = testMethodName is not null
80+
? GetType().GetMethod(testMethodName, BindingFlags.Public | BindingFlags.Instance)
81+
: null;
82+
var methodAttr = methodInfo?.GetCustomAttribute<PerformanceBudgetAttribute>();
83+
var classAttr = GetType().GetCustomAttribute<PerformanceBudgetAttribute>();
84+
var activeAttr = methodAttr ?? classAttr;
85+
PerformanceBudgetContext.Push(activeAttr?.ToBudget());
7686
}
7787

7888
[TestCleanup]
@@ -88,5 +98,7 @@ public async Task MotusTestCleanup()
8898
_context = null;
8999
_page = null;
90100
}
101+
102+
PerformanceBudgetContext.Clear();
91103
}
92104
}

src/Motus.Testing.NUnit/MotusTestBase.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Reflection;
12
using Motus.Abstractions;
23
using NUnit.Framework;
34

@@ -66,6 +67,12 @@ public async Task SetUp()
6667

6768
_failureTracing = new FailureTracing();
6869
await _failureTracing.StartIfEnabledAsync(_context);
70+
71+
var methodInfo = TestContext.CurrentContext.Test.Method?.MethodInfo;
72+
var methodAttr = methodInfo?.GetCustomAttribute<PerformanceBudgetAttribute>();
73+
var classAttr = GetType().GetCustomAttribute<PerformanceBudgetAttribute>();
74+
var activeAttr = methodAttr ?? classAttr;
75+
PerformanceBudgetContext.Push(activeAttr?.ToBudget());
6976
}
7077

7178
[TearDown]
@@ -81,5 +88,7 @@ public async Task TearDown()
8188
_context = null;
8289
_page = null;
8390
}
91+
92+
PerformanceBudgetContext.Clear();
8493
}
8594
}

src/Motus.Testing.xUnit/BrowserContextFixture.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Reflection;
12
using Motus.Abstractions;
23
using Xunit;
34

@@ -42,6 +43,9 @@ public async Task InitializeAsync()
4243
{
4344
_context = await _browserFixture.NewContextAsync(ContextOptions);
4445
_page = await _context.NewPageAsync();
46+
47+
var classAttr = GetType().GetCustomAttribute<PerformanceBudgetAttribute>();
48+
PerformanceBudgetContext.Push(classAttr?.ToBudget());
4549
}
4650

4751
public async Task DisposeAsync()
@@ -52,5 +56,7 @@ public async Task DisposeAsync()
5256
_context = null;
5357
_page = null;
5458
}
59+
60+
PerformanceBudgetContext.Clear();
5561
}
5662
}

src/Motus/Assertions/PageAssertions.cs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,105 @@ public async Task ToPassAccessibilityAuditAsync(
8181
}
8282
}
8383

84+
public async Task ToMeetPerformanceBudgetAsync(AssertionOptions? options = null)
85+
{
86+
var budget = ResolveBudget();
87+
88+
await RetryAsync(async ct =>
89+
{
90+
var metrics = _page.LastPerformanceMetrics;
91+
if (metrics is null)
92+
return (false, "<no metrics collected>");
93+
94+
var result = budget.Evaluate(metrics);
95+
var actual = result.Passed
96+
? "all metrics within budget"
97+
: FormatBudgetFailures(result);
98+
return (result.Passed, actual);
99+
}, "ToMeetPerformanceBudget", "all metrics within budget", options)
100+
.ConfigureAwait(false);
101+
}
102+
103+
public Task ToHaveLcpBelowAsync(double thresholdMs, AssertionOptions? options = null) =>
104+
RetryAsync(async ct =>
105+
{
106+
var metrics = _page.LastPerformanceMetrics;
107+
if (metrics?.Lcp is null)
108+
return (false, "<LCP not collected>");
109+
var actual = metrics.Lcp.Value;
110+
return (actual <= thresholdMs, $"{actual:F1}ms");
111+
}, "ToHaveLcpBelow", $"< {thresholdMs}ms", options);
112+
113+
public Task ToHaveFcpBelowAsync(double thresholdMs, AssertionOptions? options = null) =>
114+
RetryAsync(async ct =>
115+
{
116+
var metrics = _page.LastPerformanceMetrics;
117+
if (metrics?.Fcp is null)
118+
return (false, "<FCP not collected>");
119+
var actual = metrics.Fcp.Value;
120+
return (actual <= thresholdMs, $"{actual:F1}ms");
121+
}, "ToHaveFcpBelow", $"< {thresholdMs}ms", options);
122+
123+
public Task ToHaveTtfbBelowAsync(double thresholdMs, AssertionOptions? options = null) =>
124+
RetryAsync(async ct =>
125+
{
126+
var metrics = _page.LastPerformanceMetrics;
127+
if (metrics?.Ttfb is null)
128+
return (false, "<TTFB not collected>");
129+
var actual = metrics.Ttfb.Value;
130+
return (actual <= thresholdMs, $"{actual:F1}ms");
131+
}, "ToHaveTtfbBelow", $"< {thresholdMs}ms", options);
132+
133+
public Task ToHaveClsBelowAsync(double threshold, AssertionOptions? options = null) =>
134+
RetryAsync(async ct =>
135+
{
136+
var metrics = _page.LastPerformanceMetrics;
137+
if (metrics?.Cls is null)
138+
return (false, "<CLS not collected>");
139+
var actual = metrics.Cls.Value;
140+
return (actual <= threshold, $"{actual:F3}");
141+
}, "ToHaveClsBelow", $"< {threshold}", options);
142+
143+
public Task ToHaveInpBelowAsync(double thresholdMs, AssertionOptions? options = null) =>
144+
RetryAsync(async ct =>
145+
{
146+
var metrics = _page.LastPerformanceMetrics;
147+
if (metrics?.Inp is null)
148+
return (false, "<INP not collected>");
149+
var actual = metrics.Inp.Value;
150+
return (actual <= thresholdMs, $"{actual:F1}ms");
151+
}, "ToHaveInpBelow", $"< {thresholdMs}ms", options);
152+
153+
private static PerformanceBudget ResolveBudget()
154+
{
155+
if (PerformanceBudgetContext.Current is { } ambient)
156+
return ambient;
157+
158+
var configBudget = ConfigMerge.ToBudget(MotusConfigLoader.Config.Performance);
159+
if (configBudget is not null)
160+
return configBudget;
161+
162+
throw new InvalidOperationException(
163+
"No performance budget is active. Apply [PerformanceBudget] to the test method or class, " +
164+
"or configure budget thresholds in motus.config.json under the \"performance\" key.");
165+
}
166+
167+
private static string FormatBudgetFailures(PerformanceBudgetResult result)
168+
{
169+
var sb = new System.Text.StringBuilder();
170+
sb.AppendLine("Performance budget exceeded:");
171+
foreach (var entry in result.Entries)
172+
{
173+
if (!entry.Passed)
174+
{
175+
var actualStr = entry.ActualValue.HasValue ? $"{entry.ActualValue.Value:F1}" : "null";
176+
var deltaStr = entry.Delta.HasValue ? $"{entry.Delta.Value:F1}" : "?";
177+
sb.AppendLine($" {entry.MetricName}: {actualStr} (budget: {entry.Threshold:F1}, over by {deltaStr})");
178+
}
179+
}
180+
return sb.ToString().TrimEnd();
181+
}
182+
84183
private static IReadOnlyList<IAccessibilityRule> FilterRules(
85184
IReadOnlyList<IAccessibilityRule> rules, IReadOnlyList<string> skippedRules)
86185
{

src/Motus/Browser/BrowserFinder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ internal static class BrowserFinder
1010
{
1111
/// <summary>
1212
/// When set, this path is prepended to candidate lists for all channels.
13-
/// Used by the install system (Phase 3D) to register downloaded binaries.
13+
/// Used by the install system to register downloaded binaries.
1414
/// </summary>
1515
internal static string? InstalledBinariesPath { get; set; }
1616

src/Motus/Config/ConfigMerge.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,44 @@ internal static LaunchOptions ApplyConfig(LaunchOptions options)
4646
};
4747
}
4848

49+
var perf = MotusConfigLoader.Config.Performance;
50+
if (perf is not null && options.Performance is null)
51+
{
52+
result = result with
53+
{
54+
Performance = new PerformanceOptions
55+
{
56+
Enable = perf.Enable ?? false,
57+
CollectAfterNavigation = perf.CollectAfterNavigation ?? true,
58+
}
59+
};
60+
}
61+
4962
return result;
5063
}
5164

65+
internal static PerformanceBudget? ToBudget(MotusPerformanceConfig? cfg)
66+
{
67+
if (cfg is null)
68+
return null;
69+
70+
if (cfg.Lcp is null && cfg.Fcp is null && cfg.Ttfb is null &&
71+
cfg.Cls is null && cfg.Inp is null && cfg.JsHeapSize is null &&
72+
cfg.DomNodeCount is null)
73+
return null;
74+
75+
return new PerformanceBudget
76+
{
77+
Lcp = cfg.Lcp,
78+
Fcp = cfg.Fcp,
79+
Ttfb = cfg.Ttfb,
80+
Cls = cfg.Cls,
81+
Inp = cfg.Inp,
82+
JsHeapSize = cfg.JsHeapSize,
83+
DomNodeCount = cfg.DomNodeCount,
84+
};
85+
}
86+
5287
internal static ContextOptions ApplyConfig(ContextOptions options)
5388
{
5489
var context = MotusConfigLoader.Config.Context;

src/Motus/Config/MotusConfig.cs

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@ internal sealed record MotusAccessibilityConfig(
4949
bool? IncludeWarnings = null,
5050
string[]? SkipRules = null);
5151

52+
internal sealed record MotusPerformanceConfig(
53+
bool? Enable = null,
54+
bool? CollectAfterNavigation = null,
55+
double? Lcp = null,
56+
double? Fcp = null,
57+
double? Ttfb = null,
58+
double? Cls = null,
59+
double? Inp = null,
60+
long? JsHeapSize = null,
61+
int? DomNodeCount = null);
62+
5263
internal sealed record MotusRootConfig(
5364
string? Motus = null,
5465
MotusLaunchConfig? Launch = null,
@@ -58,7 +69,8 @@ internal sealed record MotusRootConfig(
5869
MotusAssertionsConfig? Assertions = null,
5970
MotusRecorderConfig? Recorder = null,
6071
MotusFailureConfig? Failure = null,
61-
MotusAccessibilityConfig? Accessibility = null);
72+
MotusAccessibilityConfig? Accessibility = null,
73+
MotusPerformanceConfig? Performance = null);
6274

6375
internal static class MotusConfigLoader
6476
{
@@ -177,6 +189,33 @@ private static MotusRootConfig ApplyEnvironmentVariables(MotusRootConfig config,
177189
if (envReader("MOTUS_ACCESSIBILITY_MODE") is { Length: > 0 } a11yMode)
178190
{ accessibility = accessibility with { Mode = a11yMode }; accessibilityChanged = true; }
179191

192+
var performance = config.Performance ?? new MotusPerformanceConfig();
193+
var performanceChanged = false;
194+
195+
if (TryParseBool(envReader("MOTUS_PERFORMANCE_ENABLE"), out var perfEnable))
196+
{ performance = performance with { Enable = perfEnable }; performanceChanged = true; }
197+
198+
if (TryParseDouble(envReader("MOTUS_PERFORMANCE_LCP"), out var perfLcp))
199+
{ performance = performance with { Lcp = perfLcp }; performanceChanged = true; }
200+
201+
if (TryParseDouble(envReader("MOTUS_PERFORMANCE_FCP"), out var perfFcp))
202+
{ performance = performance with { Fcp = perfFcp }; performanceChanged = true; }
203+
204+
if (TryParseDouble(envReader("MOTUS_PERFORMANCE_TTFB"), out var perfTtfb))
205+
{ performance = performance with { Ttfb = perfTtfb }; performanceChanged = true; }
206+
207+
if (TryParseDouble(envReader("MOTUS_PERFORMANCE_CLS"), out var perfCls))
208+
{ performance = performance with { Cls = perfCls }; performanceChanged = true; }
209+
210+
if (TryParseDouble(envReader("MOTUS_PERFORMANCE_INP"), out var perfInp))
211+
{ performance = performance with { Inp = perfInp }; performanceChanged = true; }
212+
213+
if (TryParseLong(envReader("MOTUS_PERFORMANCE_JS_HEAP_SIZE"), out var perfHeap))
214+
{ performance = performance with { JsHeapSize = perfHeap }; performanceChanged = true; }
215+
216+
if (TryParseInt(envReader("MOTUS_PERFORMANCE_DOM_NODE_COUNT"), out var perfDom))
217+
{ performance = performance with { DomNodeCount = perfDom }; performanceChanged = true; }
218+
180219
return config with
181220
{
182221
Launch = launchChanged ? launch : config.Launch,
@@ -185,6 +224,7 @@ private static MotusRootConfig ApplyEnvironmentVariables(MotusRootConfig config,
185224
Assertions = assertionsChanged ? assertions : config.Assertions,
186225
Failure = failureChanged ? failure : config.Failure,
187226
Accessibility = accessibilityChanged ? accessibility : config.Accessibility,
227+
Performance = performanceChanged ? performance : config.Performance,
188228
};
189229
}
190230

@@ -207,4 +247,20 @@ private static bool TryParseInt(string? value, out int result)
207247
result = default;
208248
return false;
209249
}
250+
251+
private static bool TryParseDouble(string? value, out double result)
252+
{
253+
if (value is not null && double.TryParse(value.Trim(), System.Globalization.CultureInfo.InvariantCulture, out result))
254+
return true;
255+
result = default;
256+
return false;
257+
}
258+
259+
private static bool TryParseLong(string? value, out long result)
260+
{
261+
if (value is not null && long.TryParse(value.Trim(), out result))
262+
return true;
263+
result = default;
264+
return false;
265+
}
210266
}

src/Motus/Config/MotusConfigJsonContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ namespace Motus;
1313
[JsonSerializable(typeof(MotusReporterConfig))]
1414
[JsonSerializable(typeof(MotusRecorderConfig))]
1515
[JsonSerializable(typeof(MotusAccessibilityConfig))]
16+
[JsonSerializable(typeof(MotusPerformanceConfig))]
1617
[JsonSourceGenerationOptions(
1718
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
1819
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]

src/Motus/Download/Download.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public Task DeleteAsync()
6868
}
6969

7070
public Task CancelAsync()
71-
=> throw new NotSupportedException("Download cancellation requires Fetch domain (Phase 1J).");
71+
=> throw new NotSupportedException("Download cancellation requires Fetch domain support.");
7272

7373
private readonly record struct DownloadOutcome(bool Success, string? Error);
7474
}

0 commit comments

Comments
 (0)