${(()=>{
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 @@
+