From 9a8c0e6b06ac557da1f0a0e7eaae7ae9a92b1b98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 22:05:48 +0000 Subject: [PATCH 1/6] Initial plan From bd0498c5ece77a2c35a1c8acd0372d52f83a5ffe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 22:12:00 +0000 Subject: [PATCH 2/6] Add source link and code snippet to HTML report source tab - Add EndLineNumber and SourceRelativePath to ReportTestResult - Compute relative path from GITHUB_WORKSPACE in HtmlReporter - Emit endLine and relativePath in JSON source object - Add "Jump to source" link in HTML template when repo/commit available - Add lazy-loading code snippet fetched from GitHub raw content - Add CSS styles for source link button and code snippet display - Skip source link features when CI context is unavailable Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/af6eceeb-ba8d-4040-b606-cf27c4a1d3ac Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> --- .../Reporters/Html/HtmlReportDataModel.cs | 6 ++ .../Reporters/Html/HtmlReportGenerator.cs | 2 + TUnit.Engine/Reporters/Html/HtmlReporter.cs | 38 ++++++++ .../Reporters/Html/TestReport.template.html | 96 ++++++++++++++++++- 4 files changed, 141 insertions(+), 1 deletion(-) diff --git a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs index 51069173da..4858fd35e0 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs @@ -147,6 +147,12 @@ internal sealed class ReportTestResult [JsonPropertyName("lineNumber")] public int? LineNumber { get; init; } + [JsonPropertyName("endLineNumber")] + public int? EndLineNumber { get; init; } + + [JsonPropertyName("sourceRelativePath")] + public string? SourceRelativePath { get; init; } + [JsonPropertyName("skipReason")] public string? SkipReason { get; init; } diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs index 4298f11857..33e14cf905 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs @@ -529,6 +529,8 @@ private static void WriteTest( w.WriteStartObject(); if (!string.IsNullOrEmpty(t.FilePath)) w.WriteString("path", t.FilePath); if (t.LineNumber is { } ln) w.WriteNumber("line", ln); + if (t.EndLineNumber is { } endLn) w.WriteNumber("endLine", endLn); + if (!string.IsNullOrEmpty(t.SourceRelativePath)) w.WriteString("relativePath", t.SourceRelativePath); w.WriteEndObject(); if (t.RetryAttempt > 0) diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index 88e8cca37c..ee34e557b9 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -580,6 +580,8 @@ internal static ReportTestResult ExtractTestResult(string testId, TestNode testN CustomProperties = customPropertiesArray is { Length: > 0 } ? customPropertiesArray : null, FilePath = fileLocation?.FilePath, LineNumber = fileLocation?.LineSpan.Start.Line, + EndLineNumber = fileLocation?.LineSpan.End.Line is > 0 ? fileLocation.LineSpan.End.Line : null, + SourceRelativePath = ComputeSourceRelativePath(fileLocation?.FilePath), SkipReason = skipReason, RetryAttempt = retryAttempt, Attempts = attempts, @@ -589,6 +591,42 @@ internal static ReportTestResult ExtractTestResult(string testId, TestNode testN }; } + private static string? ComputeSourceRelativePath(string? filePath) + { + if (string.IsNullOrEmpty(filePath)) + { + return null; + } + + if (Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubActions) is not "true") + { + return null; + } + + var repo = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubRepository); + if (string.IsNullOrEmpty(repo)) + { + return null; + } + + var normalized = filePath!.Replace('\\', '/'); + + var workspace = Environment.GetEnvironmentVariable("GITHUB_WORKSPACE")?.Replace('\\', '/'); + if (!string.IsNullOrEmpty(workspace) && normalized.StartsWith(workspace!, StringComparison.OrdinalIgnoreCase)) + { + return normalized[workspace!.Length..].TrimStart('/'); + } + + var repoName = repo!.Split('/').LastOrDefault() ?? ""; + var repoIndex = normalized.IndexOf($"/{repoName}/", StringComparison.OrdinalIgnoreCase); + if (repoIndex >= 0) + { + return normalized[(repoIndex + repoName.Length + 2)..]; + } + + return null; + } + internal static string? TruncateOutput(string? value) { if (value is null || value.Length <= MaxOutputLength) diff --git a/TUnit.Engine/Reporters/Html/TestReport.template.html b/TUnit.Engine/Reporters/Html/TestReport.template.html index 9ea3edb95b..555fd64772 100644 --- a/TUnit.Engine/Reporters/Html/TestReport.template.html +++ b/TUnit.Engine/Reporters/Html/TestReport.template.html @@ -557,6 +557,38 @@ } .source-path { color: var(--text); word-break: break-all; } .source-path .ln { color: var(--accent); } + .source-link-btn { + display: inline-flex; align-items: center; gap: 4px; + padding: 5px 10px; border-radius: 5px; font-size: 11px; font-weight: 500; + background: var(--accent-soft); color: var(--accent); border: 1px solid var(--accent-border); + text-decoration: none; white-space: nowrap; + } + .source-link-btn:hover { background: var(--accent); color: #fff; } + .source-snippet-container { + margin-top: 12px; border: 1px solid var(--border); border-radius: 8px; + overflow: hidden; background: var(--surface); + } + .source-snippet-placeholder { + padding: 16px; color: var(--text-dim); font-size: 12px; text-align: center; + } + .source-snippet-header { + padding: 8px 14px; font-size: 11px; color: var(--text-dim); + background: var(--surface-2); border-bottom: 1px solid var(--border); + display: flex; justify-content: space-between; align-items: center; + } + .source-snippet-code { + padding: 12px 0; overflow-x: auto; font-family: 'JetBrains Mono', monospace; + font-size: 12px; line-height: 1.6; counter-reset: line-number; + } + .source-snippet-code .line { + display: flex; padding: 0 14px; + } + .source-snippet-code .line:hover { background: var(--surface-2); } + .source-snippet-code .line-num { + user-select: none; min-width: 3ch; text-align: right; + color: var(--text-dim); opacity: 0.5; margin-right: 16px; flex-shrink: 0; + } + .source-snippet-code .line-content { white-space: pre; color: var(--text); } /* ============================== run view ============================== */ .run-wrap { max-width: 1280px; margin: 0 auto; padding: 28px 28px 80px; } @@ -1633,6 +1665,52 @@

Categories

function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } +/* ---------- source snippet lazy loading ---------- */ +const _snippetCache = new Map(); +function loadSourceSnippet(detail) { + const container = detail.querySelector('.source-snippet-container'); + if (!container || container.dataset.loaded) return; + container.dataset.loaded = '1'; + + const repo = container.dataset.repo; + const commit = container.dataset.commit; + const path = container.dataset.path; + const startLine = parseInt(container.dataset.line, 10); + const endLine = parseInt(container.dataset.endLine, 10) || startLine; + + const cacheKey = repo + '/' + commit + '/' + path; + const render = (text) => { + const lines = text.split('\n'); + const from = Math.max(0, startLine - 1); + const to = Math.min(lines.length, endLine); + const snippet = lines.slice(from, to); + const fileName = path.split('/').pop(); + container.innerHTML = + '
' + esc(fileName) + ' (lines ' + startLine + '\u2013' + endLine + ')
' + + '
' + + snippet.map((l, i) => + '
' + (from + i + 1) + '' + esc(l) + '
' + ).join('') + + '
'; + }; + + if (_snippetCache.has(cacheKey)) { + render(_snippetCache.get(cacheKey)); + return; + } + + const rawUrl = 'https://raw.githubusercontent.com/' + repo + '/' + commit + '/' + path; + fetch(rawUrl).then(r => { + if (!r.ok) throw new Error(r.status); + return r.text(); + }).then(text => { + _snippetCache.set(cacheKey, text); + render(text); + }).catch(() => { + container.innerHTML = '
Source unavailable \u2014 could not fetch from repository.
'; + }); +} + const SERVICE_COLORS = { 'test': 'oklch(0.62 0.20 270)', 'http.client': 'oklch(0.62 0.18 235)', @@ -2256,8 +2334,23 @@

${esc(namePart)}${argsPart ? `${esc(
${esc(t.source.path)}:${t.source.line}
- +
+ ${(()=>{ + if (!t.source.relativePath || !REPORT.commit || !REPORT.repository) return ''; + const serverUrl = 'https://github.com'; + const lineRef = t.source.endLine && t.source.endLine > t.source.line + ? '#L' + t.source.line + '-L' + t.source.endLine + : '#L' + t.source.line; + const href = serverUrl + '/' + REPORT.repository + '/blob/' + REPORT.commit + '/' + t.source.relativePath + lineRef; + return 'Jump to source ↗'; + })()} + +
+ ${(()=>{ + if (!t.source.relativePath || !REPORT.commit || !REPORT.repository) return ''; + return '
Loading source…
'; + })()}
`; @@ -2276,6 +2369,7 @@

${esc(namePart)}${argsPart ? `${esc( detail.querySelectorAll('.tab').forEach(x => x.classList.toggle('active', x === b)); const tn = b.dataset.tab; detail.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.dataset.panel === tn)); + if (tn === 'source') loadSourceSnippet(detail); })); // copy From 2f339daf00e3474044992b216ab9abd893515f42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 22:15:14 +0000 Subject: [PATCH 3/6] Support GitHub Enterprise server URL and add line clamping - Add ServerUrl to ReportData and serialize it in JSON - Use GITHUB_SERVER_URL for Enterprise support (fallback to github.com) - Derive raw content URL based on server (github.com vs Enterprise) - Add line number clamping to prevent invalid ranges in snippet display Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/af6eceeb-ba8d-4040-b606-cf27c4a1d3ac Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> --- TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs | 3 +++ TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs | 1 + TUnit.Engine/Reporters/Html/HtmlReporter.cs | 11 +++++++---- TUnit.Engine/Reporters/Html/TestReport.template.html | 10 +++++++--- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs index 4858fd35e0..28b321242d 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs @@ -53,6 +53,9 @@ internal sealed class ReportData [JsonPropertyName("repositorySlug")] public string? RepositorySlug { get; init; } + + [JsonPropertyName("serverUrl")] + public string? ServerUrl { get; init; } } internal sealed class ReportSummary diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs index 33e14cf905..960e35993e 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs @@ -184,6 +184,7 @@ private static string SerializeReport(ReportData data) if (!string.IsNullOrEmpty(data.CommitSha)) w.WriteString("commit", data.CommitSha); if (!string.IsNullOrEmpty(data.PullRequestNumber)) w.WriteString("pr", "#" + data.PullRequestNumber); if (!string.IsNullOrEmpty(data.RepositorySlug)) w.WriteString("repository", data.RepositorySlug); + if (!string.IsNullOrEmpty(data.ServerUrl)) w.WriteString("serverUrl", data.ServerUrl); if (!string.IsNullOrEmpty(data.Filter)) w.WriteString("filter", data.Filter); w.WriteNumber("startMs", runStartMs); diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index ee34e557b9..943ae4a9c1 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -375,7 +375,7 @@ private ReportData BuildReportData() } #endif - var (commitSha, branch, prNumber, repoSlug) = GetCiContext(); + var (commitSha, branch, prNumber, repoSlug, serverUrl) = GetCiContext(); return new ReportData { @@ -394,14 +394,15 @@ private ReportData BuildReportData() Branch = branch, PullRequestNumber = prNumber, RepositorySlug = repoSlug, + ServerUrl = serverUrl, }; } - private static (string? CommitSha, string? Branch, string? PullRequestNumber, string? RepositorySlug) GetCiContext() + private static (string? CommitSha, string? Branch, string? PullRequestNumber, string? RepositorySlug, string? ServerUrl) GetCiContext() { if (Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubActions) is not "true") { - return (null, null, null, null); + return (null, null, null, null, null); } var commitSha = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubSha); @@ -428,7 +429,9 @@ private static (string? CommitSha, string? Branch, string? PullRequestNumber, st prNumber = refValue.Substring("refs/pull/".Length, refValue.Length - "refs/pull/".Length - "/merge".Length); } - return (commitSha, branch, prNumber, repoSlug); + var serverUrl = (Environment.GetEnvironmentVariable("GITHUB_SERVER_URL") ?? "https://github.com").TrimEnd('/'); + + return (commitSha, branch, prNumber, repoSlug, serverUrl); } private static void AccumulateStatus(ReportSummary summary, ReportTestResult testResult) diff --git a/TUnit.Engine/Reporters/Html/TestReport.template.html b/TUnit.Engine/Reporters/Html/TestReport.template.html index 555fd64772..046066a52f 100644 --- a/TUnit.Engine/Reporters/Html/TestReport.template.html +++ b/TUnit.Engine/Reporters/Html/TestReport.template.html @@ -1682,7 +1682,7 @@

Categories

const render = (text) => { const lines = text.split('\n'); const from = Math.max(0, startLine - 1); - const to = Math.min(lines.length, endLine); + const to = Math.min(lines.length, Math.max(from + 1, endLine)); const snippet = lines.slice(from, to); const fileName = path.split('/').pop(); container.innerHTML = @@ -1699,7 +1699,11 @@

Categories

return; } - const rawUrl = 'https://raw.githubusercontent.com/' + repo + '/' + commit + '/' + path; + const serverUrl = REPORT.serverUrl || 'https://github.com'; + const isGitHubCom = serverUrl === 'https://github.com'; + const rawUrl = isGitHubCom + ? 'https://raw.githubusercontent.com/' + repo + '/' + commit + '/' + path + : serverUrl + '/' + repo + '/raw/' + commit + '/' + path; fetch(rawUrl).then(r => { if (!r.ok) throw new Error(r.status); return r.text(); @@ -2337,7 +2341,7 @@

${esc(namePart)}${argsPart ? `${esc(
${(()=>{ if (!t.source.relativePath || !REPORT.commit || !REPORT.repository) return ''; - const serverUrl = 'https://github.com'; + const serverUrl = REPORT.serverUrl || 'https://github.com'; const lineRef = t.source.endLine && t.source.endLine > t.source.line ? '#L' + t.source.line + '-L' + t.source.endLine : '#L' + t.source.line; From e73902dbc218a2cbd5b5d49c61465362db7e21fd Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 23 May 2026 00:04:51 +0100 Subject: [PATCH 4/6] Fix HTML report source snippet ranges --- TUnit.Engine.Tests/HtmlReporterTests.cs | 91 +++++++ TUnit.Engine/Reporters/Html/HtmlReporter.cs | 257 +++++++++++++++++++- 2 files changed, 346 insertions(+), 2 deletions(-) diff --git a/TUnit.Engine.Tests/HtmlReporterTests.cs b/TUnit.Engine.Tests/HtmlReporterTests.cs index ded0ba1eb1..c4f7a88050 100644 --- a/TUnit.Engine.Tests/HtmlReporterTests.cs +++ b/TUnit.Engine.Tests/HtmlReporterTests.cs @@ -316,6 +316,73 @@ public void ExtractTestResult_SortsTestMetadataProperty_Into_Categories_And_Cust result.CustomProperties[0].Value.ShouldBe("TeamA"); } + [Test] + public void ExtractTestResult_Infers_Source_EndLine_When_FileLocation_Only_Points_To_Test_Attribute() + { + var sourceFile = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.cs"); + File.WriteAllLines(sourceFile, + [ + "namespace Sample;", + "", + "public class SampleTests", + "{", + " [Test]", + " [Arguments(1)]", + " public async Task Admin_Has_Full_Access(", + " int value)", + " {", + " var text = \"{\";", + " // }", + " await Task.CompletedTask;", + " }", + "}" + ]); + + try + { + var node = CreateNodeWithLocation(sourceFile, "Admin_Has_Full_Access", startLine: 5, endLine: 5); + + var result = HtmlReporter.ExtractTestResult("source-range-1", node, traceId: null, spanId: null, retryAttempt: 0, additionalTraceIds: null); + + result.LineNumber.ShouldBe(5); + result.EndLineNumber.ShouldBe(13); + } + finally + { + File.Delete(sourceFile); + } + } + + [Test] + public void ExtractTestResult_Infers_Source_EndLine_For_Expression_Bodied_Tests() + { + var sourceFile = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.cs"); + File.WriteAllLines(sourceFile, + [ + "namespace Sample;", + "public class SampleTests", + "{", + " [Test]", + " public Task Expression_Test()", + " => Task.CompletedTask;", + "}" + ]); + + try + { + var node = CreateNodeWithLocation(sourceFile, "Expression_Test", startLine: 4, endLine: 4); + + var result = HtmlReporter.ExtractTestResult("source-range-2", node, traceId: null, spanId: null, retryAttempt: 0, additionalTraceIds: null); + + result.LineNumber.ShouldBe(4); + result.EndLineNumber.ShouldBe(6); + } + finally + { + File.Delete(sourceFile); + } + } + [Test] public void GenerateHtml_StripsSampleDataGeneratorBlock_FromShippedReports() { @@ -462,4 +529,28 @@ public void FilterEngineNotices_PassesThroughWhenNoTUnitPrefix() EndTime = startTime, RetryAttempt = 0, }; + + private static TestNode CreateNodeWithLocation(string filePath, string methodName, int startLine, int endLine) + { + return new TestNode + { + Uid = new TestNodeUid(methodName), + DisplayName = methodName, + Properties = new PropertyBag( + PassedTestNodeStateProperty.CachedInstance, + new TestMethodIdentifierProperty( + @namespace: "Sample", + assemblyFullName: "SampleAssembly", + typeName: "SampleTests", + methodName: methodName, + parameterTypeFullNames: [], + returnTypeFullName: "System.Threading.Tasks.Task", + methodArity: 0), + new TestFileLocationProperty( + filePath, + new LinePositionSpan( + new LinePosition(startLine, 0), + new LinePosition(endLine, 0)))) + }; + } } diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index 943ae4a9c1..2f607d8e7f 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -31,6 +31,8 @@ internal sealed class HtmlReporter(IExtension extension) : IDataConsumer, IDataP private IMessageBus? _messageBus; private string _resultsDirectory = "TestResults"; private readonly ConcurrentDictionary> _updates = []; + private static readonly ConcurrentDictionary SourceLinesCache = new( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); private GitHubReporter? _githubReporter; #if NET @@ -566,6 +568,11 @@ internal static ReportTestResult ExtractTestResult(string testId, TestNode testN var startTime = timingProperty?.GlobalTiming.StartTime; var endTime = startTime.HasValue ? startTime.Value + timingProperty!.GlobalTiming.Duration : (DateTimeOffset?)null; + var sourceStartLine = fileLocation?.LineSpan.Start.Line; + var sourceEndLine = fileLocation is null || sourceStartLine is null + ? null + : ResolveSourceEndLine(fileLocation.FilePath, sourceStartLine.Value, fileLocation.LineSpan.End.Line, methodName); + return new ReportTestResult { Id = testId, @@ -582,8 +589,8 @@ internal static ReportTestResult ExtractTestResult(string testId, TestNode testN Categories = categoriesArray is { Length: > 0 } ? categoriesArray : null, CustomProperties = customPropertiesArray is { Length: > 0 } ? customPropertiesArray : null, FilePath = fileLocation?.FilePath, - LineNumber = fileLocation?.LineSpan.Start.Line, - EndLineNumber = fileLocation?.LineSpan.End.Line is > 0 ? fileLocation.LineSpan.End.Line : null, + LineNumber = sourceStartLine, + EndLineNumber = sourceEndLine, SourceRelativePath = ComputeSourceRelativePath(fileLocation?.FilePath), SkipReason = skipReason, RetryAttempt = retryAttempt, @@ -594,6 +601,252 @@ internal static ReportTestResult ExtractTestResult(string testId, TestNode testN }; } + private static int? ResolveSourceEndLine(string? filePath, int startLine, int reportedEndLine, string methodName) + { + if (reportedEndLine > startLine) + { + return reportedEndLine; + } + + return TryInferSourceEndLine(filePath, startLine, methodName) + ?? (reportedEndLine > 0 ? reportedEndLine : null); + } + + private static int? TryInferSourceEndLine(string? filePath, int startLine, string methodName) + { + if (string.IsNullOrEmpty(filePath) || startLine <= 0 || string.IsNullOrEmpty(methodName)) + { + return null; + } + + if (!TryReadSourceLines(filePath!, out var lines)) + { + return null; + } + + if (startLine > lines.Length) + { + return null; + } + + var methodLineIndex = FindMethodDeclarationLine(lines, startLine - 1, methodName); + if (methodLineIndex < 0) + { + return null; + } + + var expressionBodyLine = FindExpressionBodyEndLine(lines, methodLineIndex); + if (expressionBodyLine is not null) + { + return expressionBodyLine; + } + + var bodyStart = FindFirstCharacter(lines, methodLineIndex, '{'); + return bodyStart is null ? null : FindMatchingBraceEndLine(lines, bodyStart.Value.LineIndex, bodyStart.Value.ColumnIndex); + } + + private static bool TryReadSourceLines(string filePath, out string[] lines) + { + if (SourceLinesCache.TryGetValue(filePath, out lines!)) + { + return true; + } + + try + { + if (!File.Exists(filePath)) + { + lines = []; + return false; + } + + lines = File.ReadAllLines(filePath); + SourceLinesCache.TryAdd(filePath, lines); + return true; + } + catch + { + lines = []; + return false; + } + } + + private static int FindMethodDeclarationLine(string[] lines, int startLineIndex, string methodName) + { + for (var i = startLineIndex; i < lines.Length; i++) + { + if (ContainsIdentifier(lines[i], methodName)) + { + return i; + } + } + + return -1; + } + + private static bool ContainsIdentifier(string line, string identifier) + { + var index = line.IndexOf(identifier, StringComparison.Ordinal); + while (index >= 0) + { + var before = index == 0 ? '\0' : line[index - 1]; + var afterIndex = index + identifier.Length; + var after = afterIndex >= line.Length ? '\0' : line[afterIndex]; + if (!IsIdentifierChar(before) && !IsIdentifierChar(after)) + { + return true; + } + + index = line.IndexOf(identifier, index + identifier.Length, StringComparison.Ordinal); + } + + return false; + } + + private static bool IsIdentifierChar(char c) => char.IsLetterOrDigit(c) || c == '_'; + + private static int? FindExpressionBodyEndLine(string[] lines, int methodLineIndex) + { + for (var i = methodLineIndex; i < lines.Length; i++) + { + var line = lines[i]; + var bodyIndex = line.IndexOf('{'); + var expressionIndex = line.IndexOf("=>", StringComparison.Ordinal); + if (bodyIndex >= 0 && (expressionIndex < 0 || bodyIndex < expressionIndex)) + { + return null; + } + + if (expressionIndex < 0) + { + continue; + } + + for (var j = i; j < lines.Length; j++) + { + if (lines[j].IndexOf(';') >= 0) + { + return j + 1; + } + } + + return null; + } + + return null; + } + + private static (int LineIndex, int ColumnIndex)? FindFirstCharacter(string[] lines, int startLineIndex, char character) + { + for (var i = startLineIndex; i < lines.Length; i++) + { + var index = lines[i].IndexOf(character); + if (index >= 0) + { + return (i, index); + } + } + + return null; + } + + private static int? FindMatchingBraceEndLine(string[] lines, int startLineIndex, int startColumnIndex) + { + var depth = 0; + var inBlockComment = false; + + for (var i = startLineIndex; i < lines.Length; i++) + { + var line = lines[i]; + for (var j = i == startLineIndex ? startColumnIndex : 0; j < line.Length; j++) + { + if (inBlockComment) + { + if (j + 1 < line.Length && line[j] == '*' && line[j + 1] == '/') + { + inBlockComment = false; + j++; + } + + continue; + } + + if (j + 1 < line.Length && line[j] == '/' && line[j + 1] == '/') + { + break; + } + + if (j + 1 < line.Length && line[j] == '/' && line[j + 1] == '*') + { + inBlockComment = true; + j++; + continue; + } + + if (line[j] is '"' or '\'') + { + j = SkipQuotedLiteral(line, j); + continue; + } + + if (line[j] == '{') + { + depth++; + } + else if (line[j] == '}') + { + depth--; + if (depth == 0) + { + return i + 1; + } + } + } + } + + return null; + } + + private static int SkipQuotedLiteral(string line, int startIndex) + { + var quote = line[startIndex]; + var isVerbatim = quote == '"' && startIndex > 0 && line[startIndex - 1] == '@'; + + for (var i = startIndex + 1; i < line.Length; i++) + { + if (line[i] != quote) + { + continue; + } + + if (isVerbatim && i + 1 < line.Length && line[i + 1] == '"') + { + i++; + continue; + } + + if (!isVerbatim && IsEscaped(line, i)) + { + continue; + } + + return i; + } + + return line.Length - 1; + } + + private static bool IsEscaped(string line, int index) + { + var slashCount = 0; + for (var i = index - 1; i >= 0 && line[i] == '\\'; i--) + { + slashCount++; + } + + return slashCount % 2 == 1; + } + private static string? ComputeSourceRelativePath(string? filePath) { if (string.IsNullOrEmpty(filePath)) From 53adccc3f7551e4c1b0ef276da11d409a6a67b22 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 23 May 2026 00:17:19 +0100 Subject: [PATCH 5/6] Resolve reflection source ranges during discovery --- TUnit.Engine.Tests/HtmlReporterTests.cs | 90 ------ TUnit.Engine.Tests/TestNodeLocationTests.cs | 35 +++ .../Discovery/ReflectionTestDataCollector.cs | 53 ++-- .../Discovery/SourceLocationResolver.cs | 261 ++++++++++++++++++ TUnit.Engine/Reporters/Html/HtmlReporter.cs | 257 +---------------- 5 files changed, 327 insertions(+), 369 deletions(-) create mode 100644 TUnit.Engine/Discovery/SourceLocationResolver.cs diff --git a/TUnit.Engine.Tests/HtmlReporterTests.cs b/TUnit.Engine.Tests/HtmlReporterTests.cs index c4f7a88050..d475c1985d 100644 --- a/TUnit.Engine.Tests/HtmlReporterTests.cs +++ b/TUnit.Engine.Tests/HtmlReporterTests.cs @@ -316,73 +316,6 @@ public void ExtractTestResult_SortsTestMetadataProperty_Into_Categories_And_Cust result.CustomProperties[0].Value.ShouldBe("TeamA"); } - [Test] - public void ExtractTestResult_Infers_Source_EndLine_When_FileLocation_Only_Points_To_Test_Attribute() - { - var sourceFile = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.cs"); - File.WriteAllLines(sourceFile, - [ - "namespace Sample;", - "", - "public class SampleTests", - "{", - " [Test]", - " [Arguments(1)]", - " public async Task Admin_Has_Full_Access(", - " int value)", - " {", - " var text = \"{\";", - " // }", - " await Task.CompletedTask;", - " }", - "}" - ]); - - try - { - var node = CreateNodeWithLocation(sourceFile, "Admin_Has_Full_Access", startLine: 5, endLine: 5); - - var result = HtmlReporter.ExtractTestResult("source-range-1", node, traceId: null, spanId: null, retryAttempt: 0, additionalTraceIds: null); - - result.LineNumber.ShouldBe(5); - result.EndLineNumber.ShouldBe(13); - } - finally - { - File.Delete(sourceFile); - } - } - - [Test] - public void ExtractTestResult_Infers_Source_EndLine_For_Expression_Bodied_Tests() - { - var sourceFile = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.cs"); - File.WriteAllLines(sourceFile, - [ - "namespace Sample;", - "public class SampleTests", - "{", - " [Test]", - " public Task Expression_Test()", - " => Task.CompletedTask;", - "}" - ]); - - try - { - var node = CreateNodeWithLocation(sourceFile, "Expression_Test", startLine: 4, endLine: 4); - - var result = HtmlReporter.ExtractTestResult("source-range-2", node, traceId: null, spanId: null, retryAttempt: 0, additionalTraceIds: null); - - result.LineNumber.ShouldBe(4); - result.EndLineNumber.ShouldBe(6); - } - finally - { - File.Delete(sourceFile); - } - } - [Test] public void GenerateHtml_StripsSampleDataGeneratorBlock_FromShippedReports() { @@ -530,27 +463,4 @@ public void FilterEngineNotices_PassesThroughWhenNoTUnitPrefix() RetryAttempt = 0, }; - private static TestNode CreateNodeWithLocation(string filePath, string methodName, int startLine, int endLine) - { - return new TestNode - { - Uid = new TestNodeUid(methodName), - DisplayName = methodName, - Properties = new PropertyBag( - PassedTestNodeStateProperty.CachedInstance, - new TestMethodIdentifierProperty( - @namespace: "Sample", - assemblyFullName: "SampleAssembly", - typeName: "SampleTests", - methodName: methodName, - parameterTypeFullNames: [], - returnTypeFullName: "System.Threading.Tasks.Task", - methodArity: 0), - new TestFileLocationProperty( - filePath, - new LinePositionSpan( - new LinePosition(startLine, 0), - new LinePosition(endLine, 0)))) - }; - } } diff --git a/TUnit.Engine.Tests/TestNodeLocationTests.cs b/TUnit.Engine.Tests/TestNodeLocationTests.cs index c1d218e323..639cfb3fbb 100644 --- a/TUnit.Engine.Tests/TestNodeLocationTests.cs +++ b/TUnit.Engine.Tests/TestNodeLocationTests.cs @@ -3,6 +3,7 @@ using Microsoft.Testing.Platform.Extensions.Messages; using Shouldly; using TUnit.Core; +using TUnit.Engine.Discovery; using TUnit.Engine.Extensions; namespace TUnit.Engine.Tests; @@ -60,6 +61,25 @@ public void ToTestNode_Falls_Back_To_Start_Line_When_End_Line_Is_Unavailable() location.LineSpan.End.Column.ShouldBe(0); } + [Test] + public void SourceLocationResolver_Finds_EndLine_For_Block_Bodied_Reflection_Tests() + { + var method = typeof(TestNodeLocationTests).GetMethod( + nameof(SourceLocationResolver_Finds_EndLine_For_Block_Bodied_Reflection_Tests))!; + + var location = SourceLocationResolver.Resolve(method); + var lines = File.ReadAllLines(location.FilePath); + var snippet = string.Join('\n', lines.Skip(location.LineNumber - 1).Take(location.EndLineNumber - location.LineNumber + 1)); + + lines[location.LineNumber - 1].Trim().ShouldBe("[Test]"); + location.EndLineNumber.ShouldBeGreaterThan(location.LineNumber); + snippet.ShouldContain("SourceLocationResolver.Resolve(method);"); + } + + [Test] + public Task SourceLocationResolver_Finds_EndLine_For_Expression_Bodied_Reflection_Tests() + => AssertExpressionBodySourceLocationAsync(); + private static TestContext CreateTestContext( string testId, string filePath, @@ -138,6 +158,21 @@ private static TestContext CreateTestContext( return context; } + private static Task AssertExpressionBodySourceLocationAsync() + { + var method = typeof(TestNodeLocationTests).GetMethod( + nameof(SourceLocationResolver_Finds_EndLine_For_Expression_Bodied_Reflection_Tests))!; + + var location = SourceLocationResolver.Resolve(method); + var lines = File.ReadAllLines(location.FilePath); + + lines[location.LineNumber - 1].Trim().ShouldBe("[Test]"); + location.EndLineNumber.ShouldBeGreaterThan(location.LineNumber); + lines[location.EndLineNumber - 1].ShouldContain("AssertExpressionBodySourceLocationAsync();"); + + return Task.CompletedTask; + } + private sealed class EmptyServiceProvider : IServiceProvider { public static EmptyServiceProvider Instance { get; } = new(); diff --git a/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs b/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs index 230c422a06..7c7f7a3cf5 100644 --- a/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs +++ b/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs @@ -1041,6 +1041,8 @@ private static TestMetadata BuildTestMetadata( try { + var sourceLocation = SourceLocationResolver.Resolve(testMethod); + return new ReflectionTestMetadata(testClass, testMethod) { TestName = testName, @@ -1054,8 +1056,11 @@ private static TestMetadata BuildTestMetadata( PropertyDataSources = ReflectionAttributeExtractor.ExtractPropertyDataSources(testClass), InstanceFactory = CreateInstanceFactory(testClass)!, TestInvoker = CreateTestInvoker(testClass, testMethod), - FilePath = ExtractFilePath(testMethod) ?? "Unknown", - LineNumber = ExtractLineNumber(testMethod) ?? 0, + FilePath = sourceLocation.FilePath, + LineNumber = sourceLocation.LineNumber, + StartColumnNumber = sourceLocation.StartColumnNumber, + EndLineNumber = sourceLocation.EndLineNumber, + EndColumnNumber = sourceLocation.EndColumnNumber, MethodMetadata = ReflectionMetadataBuilder.CreateMethodMetadata(testClass, testMethod), GenericTypeInfo = ReflectionGenericTypeResolver.ExtractGenericTypeInfo(typeForGenericResolution), GenericMethodInfo = ReflectionGenericTypeResolver.ExtractGenericMethodInfo(testMethod), @@ -1314,14 +1319,18 @@ private static TestMetadata CreateFailedMethodGenericMetadata( var testName = $"[GENERIC METHOD RESOLUTION FAILED] {type.FullName}.{method.Name}"; var displayName = $"{testName} - {errorMessage}"; var exception = new InvalidOperationException(errorMessage); + var sourceLocation = SourceLocationResolver.Resolve(method); return new FailedTestMetadata(exception, displayName) { TestName = testName, TestClassType = type, TestMethodName = method.Name, - FilePath = ExtractFilePath(method) ?? "Unknown", - LineNumber = ExtractLineNumber(method) ?? 0, + FilePath = sourceLocation.FilePath, + LineNumber = sourceLocation.LineNumber, + StartColumnNumber = sourceLocation.StartColumnNumber, + EndLineNumber = sourceLocation.EndLineNumber, + EndColumnNumber = sourceLocation.EndColumnNumber, MethodMetadata = ReflectionMetadataBuilder.CreateMethodMetadata(type, method), AttributeFactory = () => method.GetCustomAttributes().ToArray(), DataSources = [], @@ -1330,16 +1339,6 @@ private static TestMetadata CreateFailedMethodGenericMetadata( }; } - private static string? ExtractFilePath(MethodInfo method) - { - return method.GetCustomAttribute()?.File; - } - - private static int? ExtractLineNumber(MethodInfo method) - { - return method.GetCustomAttribute()?.Line; - } - private static TestMetadata CreateFailedTestMetadataForAssembly(Assembly assembly, Exception ex) { var testName = $"[ASSEMBLY SCAN FAILED] {assembly.GetName().Name}"; @@ -1379,6 +1378,7 @@ private static TestMetadata CreateFailedTestMetadata( { var testName = $"[DISCOVERY FAILED] {type.FullName}.{method.Name}"; var displayName = $"{testName} - {ex.Message}"; + var sourceLocation = SourceLocationResolver.Resolve(method); // Create a special metadata that will yield a failed data combination return new FailedTestMetadata(ex, displayName) @@ -1386,8 +1386,11 @@ private static TestMetadata CreateFailedTestMetadata( TestName = testName, TestClassType = type, TestMethodName = method.Name, - FilePath = ExtractFilePath(method) ?? "Unknown", - LineNumber = ExtractLineNumber(method) ?? 0, + FilePath = sourceLocation.FilePath, + LineNumber = sourceLocation.LineNumber, + StartColumnNumber = sourceLocation.StartColumnNumber, + EndLineNumber = sourceLocation.EndLineNumber, + EndColumnNumber = sourceLocation.EndColumnNumber, MethodMetadata = ReflectionMetadataBuilder.CreateMethodMetadata(type, method), AttributeFactory = () => method.GetCustomAttributes() .ToArray(), @@ -2071,11 +2074,10 @@ private async Task> ExecuteDynamicTestBuilder(Type testClass, var dynamicTests = new List(50); // Extract file path and line number from the DynamicTestBuilderAttribute if possible - var filePath = ExtractFilePath(builderMethod) ?? "Unknown"; - var lineNumber = ExtractLineNumber(builderMethod) ?? 0; + var sourceLocation = SourceLocationResolver.Resolve(builderMethod); // Create context - var context = new DynamicTestBuilderContext(filePath, lineNumber); + var context = new DynamicTestBuilderContext(sourceLocation.FilePath, sourceLocation.LineNumber); // Create instance if needed object? instance = null; @@ -2123,11 +2125,10 @@ private async IAsyncEnumerable ExecuteDynamicTestBuilderStreamingA try { // Extract file path and line number from the DynamicTestBuilderAttribute if possible - var filePath = ExtractFilePath(builderMethod) ?? "Unknown"; - var lineNumber = ExtractLineNumber(builderMethod) ?? 0; + var sourceLocation = SourceLocationResolver.Resolve(builderMethod); // Create context - var context = new DynamicTestBuilderContext(filePath, lineNumber); + var context = new DynamicTestBuilderContext(sourceLocation.FilePath, sourceLocation.LineNumber); // Create instance if needed object? instance = null; @@ -2330,14 +2331,18 @@ private static TestMetadata CreateFailedTestMetadataForDynamicBuilder( { var testName = $"[DYNAMIC BUILDER FAILED] {type.FullName}.{method.Name}"; var displayName = $"{testName} - {ex.Message}"; + var sourceLocation = SourceLocationResolver.Resolve(method); return new FailedTestMetadata(ex, displayName) { TestName = testName, TestClassType = type, TestMethodName = method.Name, - FilePath = ExtractFilePath(method) ?? "Unknown", - LineNumber = ExtractLineNumber(method) ?? 0, + FilePath = sourceLocation.FilePath, + LineNumber = sourceLocation.LineNumber, + StartColumnNumber = sourceLocation.StartColumnNumber, + EndLineNumber = sourceLocation.EndLineNumber, + EndColumnNumber = sourceLocation.EndColumnNumber, MethodMetadata = ReflectionMetadataBuilder.CreateMethodMetadata(type, method), AttributeFactory = () => method.GetCustomAttributes().ToArray(), DataSources = [], diff --git a/TUnit.Engine/Discovery/SourceLocationResolver.cs b/TUnit.Engine/Discovery/SourceLocationResolver.cs new file mode 100644 index 0000000000..7dfa5f77f2 --- /dev/null +++ b/TUnit.Engine/Discovery/SourceLocationResolver.cs @@ -0,0 +1,261 @@ +using System.Collections.Concurrent; +using System.Reflection; +using TUnit.Core; + +namespace TUnit.Engine.Discovery; + +internal static class SourceLocationResolver +{ + private static readonly ConcurrentDictionary SourceLinesCache = new(StringComparer.Ordinal); + + internal static SourceLocation Resolve(MethodInfo method) + { + var testAttribute = method.GetCustomAttributes().OfType().FirstOrDefault(); + var filePath = testAttribute?.File ?? "Unknown"; + var lineNumber = testAttribute?.Line ?? 0; + + return new SourceLocation( + filePath, + lineNumber, + StartColumnNumber: 0, + EndLineNumber: TryInferSourceEndLine(filePath, lineNumber, method.Name) ?? lineNumber, + EndColumnNumber: 0); + } + + private static int? TryInferSourceEndLine(string filePath, int startLine, string methodName) + { + if (string.IsNullOrEmpty(filePath) || startLine <= 0 || string.IsNullOrEmpty(methodName)) + { + return null; + } + + if (!TryReadSourceLines(filePath, out var lines) || startLine > lines.Length) + { + return null; + } + + var methodLineIndex = FindMethodDeclarationLine(lines, startLine - 1, methodName); + if (methodLineIndex < 0) + { + return null; + } + + var expressionBodyLine = FindExpressionBodyEndLine(lines, methodLineIndex); + if (expressionBodyLine is not null) + { + return expressionBodyLine; + } + + var bodyStart = FindFirstCharacter(lines, methodLineIndex, '{'); + return bodyStart is null ? null : FindMatchingBraceEndLine(lines, bodyStart.Value.LineIndex, bodyStart.Value.ColumnIndex); + } + + private static bool TryReadSourceLines(string filePath, out string[] lines) + { + if (SourceLinesCache.TryGetValue(filePath, out lines!)) + { + return true; + } + + try + { + if (!File.Exists(filePath)) + { + lines = []; + return false; + } + + lines = File.ReadAllLines(filePath); + SourceLinesCache.TryAdd(filePath, lines); + return true; + } + catch + { + lines = []; + return false; + } + } + + private static int FindMethodDeclarationLine(string[] lines, int startLineIndex, string methodName) + { + for (var i = startLineIndex; i < lines.Length; i++) + { + if (ContainsIdentifier(lines[i], methodName)) + { + return i; + } + } + + return -1; + } + + private static bool ContainsIdentifier(string line, string identifier) + { + var index = line.IndexOf(identifier, StringComparison.Ordinal); + while (index >= 0) + { + var before = index == 0 ? '\0' : line[index - 1]; + var afterIndex = index + identifier.Length; + var after = afterIndex >= line.Length ? '\0' : line[afterIndex]; + if (!IsIdentifierChar(before) && !IsIdentifierChar(after)) + { + return true; + } + + index = line.IndexOf(identifier, index + identifier.Length, StringComparison.Ordinal); + } + + return false; + } + + private static bool IsIdentifierChar(char c) => char.IsLetterOrDigit(c) || c == '_'; + + private static int? FindExpressionBodyEndLine(string[] lines, int methodLineIndex) + { + for (var i = methodLineIndex; i < lines.Length; i++) + { + var line = lines[i]; + var bodyIndex = line.IndexOf('{'); + var expressionIndex = line.IndexOf("=>", StringComparison.Ordinal); + if (bodyIndex >= 0 && (expressionIndex < 0 || bodyIndex < expressionIndex)) + { + return null; + } + + if (expressionIndex < 0) + { + continue; + } + + for (var j = i; j < lines.Length; j++) + { + if (lines[j].IndexOf(';') >= 0) + { + return j + 1; + } + } + + return null; + } + + return null; + } + + private static (int LineIndex, int ColumnIndex)? FindFirstCharacter(string[] lines, int startLineIndex, char character) + { + for (var i = startLineIndex; i < lines.Length; i++) + { + var index = lines[i].IndexOf(character); + if (index >= 0) + { + return (i, index); + } + } + + return null; + } + + private static int? FindMatchingBraceEndLine(string[] lines, int startLineIndex, int startColumnIndex) + { + var depth = 0; + var inBlockComment = false; + + for (var i = startLineIndex; i < lines.Length; i++) + { + var line = lines[i]; + for (var j = i == startLineIndex ? startColumnIndex : 0; j < line.Length; j++) + { + if (inBlockComment) + { + if (j + 1 < line.Length && line[j] == '*' && line[j + 1] == '/') + { + inBlockComment = false; + j++; + } + + continue; + } + + if (j + 1 < line.Length && line[j] == '/' && line[j + 1] == '/') + { + break; + } + + if (j + 1 < line.Length && line[j] == '/' && line[j + 1] == '*') + { + inBlockComment = true; + j++; + continue; + } + + if (line[j] is '"' or '\'') + { + j = SkipQuotedLiteral(line, j); + continue; + } + + if (line[j] == '{') + { + depth++; + } + else if (line[j] == '}') + { + depth--; + if (depth == 0) + { + return i + 1; + } + } + } + } + + return null; + } + + private static int SkipQuotedLiteral(string line, int startIndex) + { + var quote = line[startIndex]; + var isVerbatim = quote == '"' && startIndex > 0 && line[startIndex - 1] == '@'; + + for (var i = startIndex + 1; i < line.Length; i++) + { + if (line[i] != quote) + { + continue; + } + + if (isVerbatim && i + 1 < line.Length && line[i + 1] == '"') + { + i++; + continue; + } + + if (!isVerbatim && IsEscaped(line, i)) + { + continue; + } + + return i; + } + + return line.Length - 1; + } + + private static bool IsEscaped(string line, int index) + { + var slashCount = 0; + for (var i = index - 1; i >= 0 && line[i] == '\\'; i--) + { + slashCount++; + } + + return slashCount % 2 == 1; + } +} + +internal readonly record struct SourceLocation( + string FilePath, + int LineNumber, + int StartColumnNumber, + int EndLineNumber, + int EndColumnNumber); diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index 2f607d8e7f..943ae4a9c1 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -31,8 +31,6 @@ internal sealed class HtmlReporter(IExtension extension) : IDataConsumer, IDataP private IMessageBus? _messageBus; private string _resultsDirectory = "TestResults"; private readonly ConcurrentDictionary> _updates = []; - private static readonly ConcurrentDictionary SourceLinesCache = new( - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); private GitHubReporter? _githubReporter; #if NET @@ -568,11 +566,6 @@ internal static ReportTestResult ExtractTestResult(string testId, TestNode testN var startTime = timingProperty?.GlobalTiming.StartTime; var endTime = startTime.HasValue ? startTime.Value + timingProperty!.GlobalTiming.Duration : (DateTimeOffset?)null; - var sourceStartLine = fileLocation?.LineSpan.Start.Line; - var sourceEndLine = fileLocation is null || sourceStartLine is null - ? null - : ResolveSourceEndLine(fileLocation.FilePath, sourceStartLine.Value, fileLocation.LineSpan.End.Line, methodName); - return new ReportTestResult { Id = testId, @@ -589,8 +582,8 @@ internal static ReportTestResult ExtractTestResult(string testId, TestNode testN Categories = categoriesArray is { Length: > 0 } ? categoriesArray : null, CustomProperties = customPropertiesArray is { Length: > 0 } ? customPropertiesArray : null, FilePath = fileLocation?.FilePath, - LineNumber = sourceStartLine, - EndLineNumber = sourceEndLine, + LineNumber = fileLocation?.LineSpan.Start.Line, + EndLineNumber = fileLocation?.LineSpan.End.Line is > 0 ? fileLocation.LineSpan.End.Line : null, SourceRelativePath = ComputeSourceRelativePath(fileLocation?.FilePath), SkipReason = skipReason, RetryAttempt = retryAttempt, @@ -601,252 +594,6 @@ internal static ReportTestResult ExtractTestResult(string testId, TestNode testN }; } - private static int? ResolveSourceEndLine(string? filePath, int startLine, int reportedEndLine, string methodName) - { - if (reportedEndLine > startLine) - { - return reportedEndLine; - } - - return TryInferSourceEndLine(filePath, startLine, methodName) - ?? (reportedEndLine > 0 ? reportedEndLine : null); - } - - private static int? TryInferSourceEndLine(string? filePath, int startLine, string methodName) - { - if (string.IsNullOrEmpty(filePath) || startLine <= 0 || string.IsNullOrEmpty(methodName)) - { - return null; - } - - if (!TryReadSourceLines(filePath!, out var lines)) - { - return null; - } - - if (startLine > lines.Length) - { - return null; - } - - var methodLineIndex = FindMethodDeclarationLine(lines, startLine - 1, methodName); - if (methodLineIndex < 0) - { - return null; - } - - var expressionBodyLine = FindExpressionBodyEndLine(lines, methodLineIndex); - if (expressionBodyLine is not null) - { - return expressionBodyLine; - } - - var bodyStart = FindFirstCharacter(lines, methodLineIndex, '{'); - return bodyStart is null ? null : FindMatchingBraceEndLine(lines, bodyStart.Value.LineIndex, bodyStart.Value.ColumnIndex); - } - - private static bool TryReadSourceLines(string filePath, out string[] lines) - { - if (SourceLinesCache.TryGetValue(filePath, out lines!)) - { - return true; - } - - try - { - if (!File.Exists(filePath)) - { - lines = []; - return false; - } - - lines = File.ReadAllLines(filePath); - SourceLinesCache.TryAdd(filePath, lines); - return true; - } - catch - { - lines = []; - return false; - } - } - - private static int FindMethodDeclarationLine(string[] lines, int startLineIndex, string methodName) - { - for (var i = startLineIndex; i < lines.Length; i++) - { - if (ContainsIdentifier(lines[i], methodName)) - { - return i; - } - } - - return -1; - } - - private static bool ContainsIdentifier(string line, string identifier) - { - var index = line.IndexOf(identifier, StringComparison.Ordinal); - while (index >= 0) - { - var before = index == 0 ? '\0' : line[index - 1]; - var afterIndex = index + identifier.Length; - var after = afterIndex >= line.Length ? '\0' : line[afterIndex]; - if (!IsIdentifierChar(before) && !IsIdentifierChar(after)) - { - return true; - } - - index = line.IndexOf(identifier, index + identifier.Length, StringComparison.Ordinal); - } - - return false; - } - - private static bool IsIdentifierChar(char c) => char.IsLetterOrDigit(c) || c == '_'; - - private static int? FindExpressionBodyEndLine(string[] lines, int methodLineIndex) - { - for (var i = methodLineIndex; i < lines.Length; i++) - { - var line = lines[i]; - var bodyIndex = line.IndexOf('{'); - var expressionIndex = line.IndexOf("=>", StringComparison.Ordinal); - if (bodyIndex >= 0 && (expressionIndex < 0 || bodyIndex < expressionIndex)) - { - return null; - } - - if (expressionIndex < 0) - { - continue; - } - - for (var j = i; j < lines.Length; j++) - { - if (lines[j].IndexOf(';') >= 0) - { - return j + 1; - } - } - - return null; - } - - return null; - } - - private static (int LineIndex, int ColumnIndex)? FindFirstCharacter(string[] lines, int startLineIndex, char character) - { - for (var i = startLineIndex; i < lines.Length; i++) - { - var index = lines[i].IndexOf(character); - if (index >= 0) - { - return (i, index); - } - } - - return null; - } - - private static int? FindMatchingBraceEndLine(string[] lines, int startLineIndex, int startColumnIndex) - { - var depth = 0; - var inBlockComment = false; - - for (var i = startLineIndex; i < lines.Length; i++) - { - var line = lines[i]; - for (var j = i == startLineIndex ? startColumnIndex : 0; j < line.Length; j++) - { - if (inBlockComment) - { - if (j + 1 < line.Length && line[j] == '*' && line[j + 1] == '/') - { - inBlockComment = false; - j++; - } - - continue; - } - - if (j + 1 < line.Length && line[j] == '/' && line[j + 1] == '/') - { - break; - } - - if (j + 1 < line.Length && line[j] == '/' && line[j + 1] == '*') - { - inBlockComment = true; - j++; - continue; - } - - if (line[j] is '"' or '\'') - { - j = SkipQuotedLiteral(line, j); - continue; - } - - if (line[j] == '{') - { - depth++; - } - else if (line[j] == '}') - { - depth--; - if (depth == 0) - { - return i + 1; - } - } - } - } - - return null; - } - - private static int SkipQuotedLiteral(string line, int startIndex) - { - var quote = line[startIndex]; - var isVerbatim = quote == '"' && startIndex > 0 && line[startIndex - 1] == '@'; - - for (var i = startIndex + 1; i < line.Length; i++) - { - if (line[i] != quote) - { - continue; - } - - if (isVerbatim && i + 1 < line.Length && line[i + 1] == '"') - { - i++; - continue; - } - - if (!isVerbatim && IsEscaped(line, i)) - { - continue; - } - - return i; - } - - return line.Length - 1; - } - - private static bool IsEscaped(string line, int index) - { - var slashCount = 0; - for (var i = index - 1; i >= 0 && line[i] == '\\'; i--) - { - slashCount++; - } - - return slashCount % 2 == 1; - } - private static string? ComputeSourceRelativePath(string? filePath) { if (string.IsNullOrEmpty(filePath)) From 0795f952201208352d597ee06447eae245f72a3a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 23 May 2026 00:27:34 +0100 Subject: [PATCH 6/6] Restore CloudShop source generation --- examples/CloudShop/CloudShop.Tests/CloudShop.Tests.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/CloudShop/CloudShop.Tests/CloudShop.Tests.csproj b/examples/CloudShop/CloudShop.Tests/CloudShop.Tests.csproj index 276935a16a..683691e327 100644 --- a/examples/CloudShop/CloudShop.Tests/CloudShop.Tests.csproj +++ b/examples/CloudShop/CloudShop.Tests/CloudShop.Tests.csproj @@ -21,6 +21,7 @@ +