Skip to content

Commit 883e3b0

Browse files
committed
Add code coverage collection and aggregation
Introduce a code-coverage subsystem: CoverageCollector (CDP-based plugin to collect JS/CSS coverage per page), CoverageAggregator (merges ranges, computes script/stylesheet/file summaries), and CoverageSink (AsyncLocal accumulator for per-async-flow coverage). Add unit tests for aggregator, collector, and sink covering merging, summarization, CDP interactions, and concurrent flows. Also adjust .gitignore entry for coverage artifacts.
1 parent e4ff0ce commit 883e3b0

7 files changed

Lines changed: 962 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Directory.Build.override.targets
4444
TestResult.xml
4545

4646
## Coverage
47-
coverage/
47+
/coverage/
4848
*.coverage
4949
*.coveragexml
5050
/starter
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
using Motus.Abstractions;
2+
3+
namespace Motus;
4+
5+
/// <summary>
6+
/// Merges coverage ranges across multiple page sessions and computes line-level
7+
/// (script) and rule-level (stylesheet) summary statistics.
8+
/// </summary>
9+
internal static class CoverageAggregator
10+
{
11+
/// <summary>
12+
/// Merges overlapping/adjacent ranges into a sorted, non-overlapping list using a
13+
/// sweep over distinct offsets. Counts of overlapping inputs are summed.
14+
/// </summary>
15+
internal static IReadOnlyList<CoverageRange> MergeRanges(IEnumerable<CoverageRange> ranges)
16+
{
17+
var input = ranges as IList<CoverageRange> ?? ranges.ToList();
18+
if (input.Count == 0)
19+
return Array.Empty<CoverageRange>();
20+
21+
var offsets = new SortedSet<int>();
22+
foreach (var r in input)
23+
{
24+
if (r.EndOffset <= r.StartOffset)
25+
continue;
26+
offsets.Add(r.StartOffset);
27+
offsets.Add(r.EndOffset);
28+
}
29+
30+
if (offsets.Count < 2)
31+
return Array.Empty<CoverageRange>();
32+
33+
var sorted = offsets.ToArray();
34+
var result = new List<CoverageRange>();
35+
for (int i = 0; i < sorted.Length - 1; i++)
36+
{
37+
int s = sorted[i], e = sorted[i + 1];
38+
if (s >= e)
39+
continue;
40+
41+
int total = 0;
42+
bool covered = false;
43+
foreach (var r in input)
44+
{
45+
if (r.StartOffset <= s && r.EndOffset >= e)
46+
{
47+
covered = true;
48+
total += r.Count;
49+
}
50+
}
51+
52+
if (!covered)
53+
continue;
54+
55+
if (result.Count > 0 && result[^1].EndOffset == s && result[^1].Count == total)
56+
result[^1] = new CoverageRange(result[^1].StartOffset, e, total);
57+
else
58+
result.Add(new CoverageRange(s, e, total));
59+
}
60+
61+
return result;
62+
}
63+
64+
/// <summary>
65+
/// Computes line-level coverage by splitting the source on '\n' and marking each line
66+
/// as covered if any range with count &gt; 0 intersects it.
67+
/// </summary>
68+
internal static FileCoverageStats SummarizeScript(string source, IReadOnlyList<CoverageRange> ranges)
69+
{
70+
if (string.IsNullOrEmpty(source))
71+
return new FileCoverageStats(0, 0, 0);
72+
73+
var lineStarts = new List<int> { 0 };
74+
for (int i = 0; i < source.Length; i++)
75+
{
76+
if (source[i] == '\n')
77+
lineStarts.Add(i + 1);
78+
}
79+
80+
int totalLines = lineStarts.Count;
81+
if (lineStarts[^1] >= source.Length)
82+
totalLines--;
83+
84+
if (totalLines <= 0)
85+
return new FileCoverageStats(0, 0, 0);
86+
87+
var covered = new List<CoverageRange>();
88+
foreach (var r in ranges)
89+
{
90+
if (r.Count > 0 && r.EndOffset > r.StartOffset)
91+
covered.Add(r);
92+
}
93+
94+
int coveredLines = 0;
95+
for (int i = 0; i < totalLines; i++)
96+
{
97+
int lineStart = lineStarts[i];
98+
int lineEnd = i + 1 < lineStarts.Count ? lineStarts[i + 1] - 1 : source.Length;
99+
if (lineEnd <= lineStart)
100+
continue;
101+
102+
foreach (var r in covered)
103+
{
104+
if (r.StartOffset < lineEnd && r.EndOffset > lineStart)
105+
{
106+
coveredLines++;
107+
break;
108+
}
109+
}
110+
}
111+
112+
double pct = totalLines > 0 ? coveredLines * 100.0 / totalLines : 0;
113+
return new FileCoverageStats(totalLines, coveredLines, pct);
114+
}
115+
116+
/// <summary>
117+
/// Computes rule-level coverage from a list of CSS rule usage entries.
118+
/// Each rule is one unit; the rule is "covered" if <see cref="CssRuleUsage.Used"/> is true.
119+
/// </summary>
120+
internal static FileCoverageStats SummarizeStylesheet(IReadOnlyList<CssRuleUsage> rules)
121+
{
122+
int total = rules.Count;
123+
if (total == 0)
124+
return new FileCoverageStats(0, 0, 0);
125+
126+
int used = 0;
127+
foreach (var r in rules)
128+
{
129+
if (r.Used) used++;
130+
}
131+
132+
double pct = used * 100.0 / total;
133+
return new FileCoverageStats(total, used, pct);
134+
}
135+
136+
/// <summary>
137+
/// Merges multiple <see cref="ScriptCoverage"/> snapshots by URL. Ranges from the same
138+
/// URL are unioned via <see cref="MergeRanges"/>; per-script line stats are recomputed.
139+
/// Source text is taken from the first snapshot for each URL.
140+
/// </summary>
141+
internal static IReadOnlyList<ScriptCoverage> MergeScripts(IEnumerable<ScriptCoverage> snapshots)
142+
{
143+
var byUrl = new Dictionary<string, (string Source, List<CoverageRange> Ranges)>(StringComparer.Ordinal);
144+
foreach (var s in snapshots)
145+
{
146+
if (!byUrl.TryGetValue(s.Url, out var entry))
147+
{
148+
entry = (s.Source, new List<CoverageRange>());
149+
byUrl[s.Url] = entry;
150+
}
151+
entry.Ranges.AddRange(s.Ranges);
152+
}
153+
154+
var result = new List<ScriptCoverage>(byUrl.Count);
155+
foreach (var (url, entry) in byUrl)
156+
{
157+
var merged = MergeRanges(entry.Ranges);
158+
var stats = SummarizeScript(entry.Source, merged);
159+
result.Add(new ScriptCoverage(url, entry.Source, merged, stats));
160+
}
161+
return result;
162+
}
163+
164+
/// <summary>
165+
/// Merges multiple <see cref="StylesheetCoverage"/> snapshots by URL. A rule is reported
166+
/// as used if it was used in any snapshot.
167+
/// </summary>
168+
internal static IReadOnlyList<StylesheetCoverage> MergeStylesheets(IEnumerable<StylesheetCoverage> snapshots)
169+
{
170+
var byUrl = new Dictionary<string, (string Source, Dictionary<(int Start, int End), bool> Rules)>(StringComparer.Ordinal);
171+
foreach (var s in snapshots)
172+
{
173+
if (!byUrl.TryGetValue(s.Url, out var entry))
174+
{
175+
entry = (s.Source, new Dictionary<(int, int), bool>());
176+
byUrl[s.Url] = entry;
177+
}
178+
foreach (var rule in s.Rules)
179+
{
180+
var key = (rule.StartOffset, rule.EndOffset);
181+
entry.Rules[key] = entry.Rules.TryGetValue(key, out var prev) ? prev || rule.Used : rule.Used;
182+
}
183+
}
184+
185+
var result = new List<StylesheetCoverage>(byUrl.Count);
186+
foreach (var (url, entry) in byUrl)
187+
{
188+
var rules = entry.Rules
189+
.OrderBy(kv => kv.Key.Start)
190+
.ThenBy(kv => kv.Key.End)
191+
.Select(kv => new CssRuleUsage(kv.Key.Start, kv.Key.End, kv.Value))
192+
.ToList();
193+
var stats = SummarizeStylesheet(rules);
194+
result.Add(new StylesheetCoverage(url, entry.Source, rules, stats));
195+
}
196+
return result;
197+
}
198+
199+
/// <summary>
200+
/// Builds the cross-file summary across all scripts and stylesheets.
201+
/// </summary>
202+
internal static CoverageSummary BuildSummary(
203+
IReadOnlyList<ScriptCoverage> scripts,
204+
IReadOnlyList<StylesheetCoverage> stylesheets)
205+
{
206+
int totalLines = 0, coveredLines = 0;
207+
foreach (var s in scripts)
208+
{
209+
totalLines += s.Stats.TotalLines;
210+
coveredLines += s.Stats.CoveredLines;
211+
}
212+
213+
int totalRules = 0, usedRules = 0;
214+
foreach (var s in stylesheets)
215+
{
216+
totalRules += s.Stats.TotalLines;
217+
usedRules += s.Stats.CoveredLines;
218+
}
219+
220+
double linePct = totalLines > 0 ? coveredLines * 100.0 / totalLines : 0;
221+
double rulePct = totalRules > 0 ? usedRules * 100.0 / totalRules : 0;
222+
223+
return new CoverageSummary(totalLines, coveredLines, linePct, totalRules, usedRules, rulePct);
224+
}
225+
}

0 commit comments

Comments
 (0)