Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions TUnit.Engine.Tests/GitHubReporterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,53 @@ await FeedTestMessages(reporter,
output.ShouldContain("CancelledTest");
}

[Test]
public async Task AfterRunAsync_SourceLink_Uses_OneBased_Line_Without_Extra_Increment()
{
// Regression: source line numbers are already 1-based when they reach the reporter
// (via [CallerLineNumber] / Roslyn span + 1), so the GitHub blob link must NOT add
// another +1 — that pointed the link one line below the actual test.
var (reporter, outputFile) = await SetupReporter();
Environment.SetEnvironmentVariable("GITHUB_REPOSITORY", "thomhurst/TUnit");
Environment.SetEnvironmentVariable("GITHUB_SHA", "abc123");
Environment.SetEnvironmentVariable("GITHUB_WORKSPACE", "/work/TUnit");

try
{
var message = new TestNodeUpdateMessage(
sessionUid: new SessionUid("test-session"),
testNode: new TestNode
{
Uid = new TestNodeUid("loc-1"),
DisplayName = "FailingTest",
Properties = new PropertyBag(
new FailedTestNodeStateProperty(new Exception("boom"), "boom"),
new TestMethodIdentifierProperty(
@namespace: "TestNamespace",
assemblyFullName: "TestAssembly",
typeName: "SampleTests",
methodName: "FailingTest",
parameterTypeFullNames: [],
returnTypeFullName: "System.Void",
methodArity: 0),
new TestFileLocationProperty(
"/work/TUnit/src/SampleTests.cs",
new LinePositionSpan(new LinePosition(12, 0), new LinePosition(20, 0))))
});

await FeedTestMessages(reporter, message);
await reporter.AfterRunAsync(1, CancellationToken.None);

var output = await File.ReadAllTextAsync(outputFile);
output.ShouldContain("/thomhurst/TUnit/blob/abc123/src/SampleTests.cs#L12");
output.ShouldNotContain("#L13");
}
finally
{
Environment.SetEnvironmentVariable("GITHUB_WORKSPACE", null);
}
}

private string CreateTempFile()
{
var path = Path.GetTempFileName();
Expand Down
118 changes: 118 additions & 0 deletions TUnit.Engine.Tests/HtmlReporterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Shouldly;
using TUnit.Core;
using TUnit.Core.Enums;
using TUnit.Engine.Reporters;
using TUnit.Engine.Reporters.Html;

namespace TUnit.Engine.Tests;
Expand Down Expand Up @@ -462,4 +463,121 @@ public void FilterEngineNotices_PassesThroughWhenNoTUnitPrefix()
EndTime = startTime,
RetryAttempt = 0,
};

[Test]
public void GitHubSourceLink_Strips_Workspace_Prefix()
{
var relative = GitHubSourceLink.ToRepoRelativePath(
@"C:\actions-runner\_work\TUnit\TUnit\src\Tests\SampleTests.cs",
workspace: "C:/actions-runner/_work/TUnit/TUnit",
repo: "thomhurst/TUnit");

relative.ShouldBe("src/Tests/SampleTests.cs");
}

[Test]
public void GitHubSourceLink_Falls_Back_To_Repo_Name_When_No_Workspace()
{
// No workspace given; locate the repo name segment within the path instead.
var relative = GitHubSourceLink.ToRepoRelativePath(
"/home/user/code/TUnit/src/Tests/SampleTests.cs",
workspace: null,
repo: "thomhurst/TUnit");

relative.ShouldBe("src/Tests/SampleTests.cs");
}

[Test]
[Arguments(null, "owner/repo")] // no file path
[Arguments("/some/unrelated/path/File.cs", "owner/repo")] // repo name not in path, no workspace
[Arguments("/x/repo/File.cs", null)] // no repo slug
public void GitHubSourceLink_Returns_Null_When_Unresolvable(string? filePath, string? repo)
{
GitHubSourceLink.ToRepoRelativePath(filePath, workspace: null, repo: repo).ShouldBeNull();
}

[Test]
public void ExtractTestResult_Populates_EndLineNumber_When_Span_Spans_Multiple_Lines()
{
var node = new TestNode
{
Uid = new TestNodeUid("src-1"),
DisplayName = "Test",
Properties = new PropertyBag(
PassedTestNodeStateProperty.CachedInstance,
new TestFileLocationProperty(
@"C:\repo\SampleTests.cs",
new LinePositionSpan(new LinePosition(12, 5), new LinePosition(20, 6))))
};

var result = HtmlReporter.ExtractTestResult("src-1", node, traceId: null, spanId: null, retryAttempt: 0, additionalTraceIds: null);

result.LineNumber.ShouldBe(12);
result.EndLineNumber.ShouldBe(20);
}

[Test]
public void ExtractTestResult_Omits_EndLineNumber_When_End_Equals_Start()
{
// Reflection mode has no method end line, so the span collapses to a single line;
// we emit null rather than a redundant endLine so the client falls back to a window.
var node = new TestNode
{
Uid = new TestNodeUid("src-2"),
DisplayName = "Test",
Properties = new PropertyBag(
PassedTestNodeStateProperty.CachedInstance,
new TestFileLocationProperty(
@"C:\repo\SampleTests.cs",
new LinePositionSpan(new LinePosition(12, 0), new LinePosition(12, 0))))
};

var result = HtmlReporter.ExtractTestResult("src-2", node, traceId: null, spanId: null, retryAttempt: 0, additionalTraceIds: null);

result.LineNumber.ShouldBe(12);
result.EndLineNumber.ShouldBeNull();
}

[Test]
public void GenerateHtml_RoundTrips_ServerUrl_And_Source_EndLine_And_RelativePath()
{
var html = HtmlReportGenerator.GenerateHtml(new ReportData
{
AssemblyName = "Tests",
MachineName = "machine",
Timestamp = "2026-05-07T09:26:24.0000000Z",
TUnitVersion = "1.0.0",
OperatingSystem = "Linux",
RuntimeVersion = ".NET 10.0",
TotalDurationMs = 0,
Summary = new ReportSummary(),
ServerUrl = "https://github.com",
Groups =
[
new ReportTestGroup
{
ClassName = "SampleTests",
Namespace = "Tests",
Summary = new ReportSummary(),
Tests =
[
new ReportTestResult
{
Id = "t1", DisplayName = "t1", MethodName = "t1",
ClassName = "SampleTests", Status = "passed",
FilePath = @"C:\repo\src\SampleTests.cs",
LineNumber = 12,
EndLineNumber = 20,
SourceRelativePath = "src/SampleTests.cs",
},
],
},
],
});

var embedded = ExtractEmbeddedReportJson(html);
embedded.ShouldContain("\"serverUrl\":\"https://github.com\"");
embedded.ShouldContain("\"endLine\":20");
embedded.ShouldContain("\"relativePath\":\"src/SampleTests.cs\"");
}
}
5 changes: 5 additions & 0 deletions TUnit.Engine/Configuration/EnvironmentConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,9 @@ internal static class EnvironmentConstants
public const string GitHubRef = "GITHUB_REF";
public const string GitHubHeadRef = "GITHUB_HEAD_REF";
public const string GitHubEventName = "GITHUB_EVENT_NAME";
public const string GitHubWorkspace = "GITHUB_WORKSPACE";
public const string GitHubServerUrl = "GITHUB_SERVER_URL";

// Default GitHub server (overridden by GITHUB_SERVER_URL on GitHub Enterprise)
public const string GitHubDefaultServerUrl = "https://github.com";
}
27 changes: 8 additions & 19 deletions TUnit.Engine/Reporters/GitHubReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,8 @@ public Task AfterRunAsync(int exitCode, CancellationToken cancellation)
// Cache env vars for source links (read once, not per test)
var githubRepo = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubRepository);
var githubSha = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubSha);
var githubWorkspace = Environment.GetEnvironmentVariable("GITHUB_WORKSPACE")?.Replace('\\', '/');
var githubServerUrl = Environment.GetEnvironmentVariable("GITHUB_SERVER_URL") ?? "https://github.com";
var githubWorkspace = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubWorkspace)?.Replace('\\', '/');
var githubServerUrl = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubServerUrl) ?? EnvironmentConstants.GitHubDefaultServerUrl;

// Separate failures from other non-passing tests (built once, used by both quick diagnosis and full rendering)
var failureMessages = new List<FailureEntry>();
Expand Down Expand Up @@ -593,24 +593,13 @@ private static string GetTestDisplayName(TestNode testNode)

if (string.IsNullOrEmpty(repo) || string.IsNullOrEmpty(sha)) return null;

var filePath = fileLocation.FilePath.Replace('\\', '/');
// Strip to a repo-relative path; fall back to the full normalized path when unresolvable.
var filePath = GitHubSourceLink.ToRepoRelativePath(fileLocation.FilePath, workspace, repo)
?? fileLocation.FilePath.Replace('\\', '/');

// Prefer GITHUB_WORKSPACE for reliable path stripping; fall back to repo name matching
if (!string.IsNullOrEmpty(workspace) && filePath.StartsWith(workspace!, StringComparison.OrdinalIgnoreCase))
{
filePath = filePath[workspace!.Length..].TrimStart('/');
}
else
{
var repoName = repo!.Split('/').LastOrDefault() ?? "";
var repoIndex = filePath.IndexOf($"/{repoName}/", StringComparison.OrdinalIgnoreCase);
if (repoIndex >= 0)
{
filePath = filePath[(repoIndex + repoName.Length + 2)..];
}
}

var line = fileLocation.LineSpan.Start.Line + 1; // 0-based to 1-based
// TUnit stores source line numbers 1-based (via [CallerLineNumber] / Roslyn span + 1),
// and they flow into LineSpan.Start.Line unchanged — so it is already 1-based here.
var line = fileLocation.LineSpan.Start.Line;
var fileName = Path.GetFileName(fileLocation.FilePath);
return $"[{fileName}:{line}]({serverUrl.TrimEnd('/')}/{repo}/blob/{sha}/{filePath}#L{line})";
}
Expand Down
42 changes: 42 additions & 0 deletions TUnit.Engine/Reporters/GitHubSourceLink.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace TUnit.Engine.Reporters;

/// <summary>
/// Shared helpers for turning a local test source file path into a GitHub
/// repository-relative path, used by both the GitHub and HTML reporters.
/// </summary>
internal static class GitHubSourceLink
{
/// <summary>
/// Converts an absolute source file path to a repository-relative path.
/// Prefers stripping the <c>GITHUB_WORKSPACE</c> prefix; falls back to locating
/// the repository name within the path. Returns <see langword="null"/> when the
/// path cannot be resolved to a repository-relative location.
/// </summary>
internal static string? ToRepoRelativePath(string? filePath, string? workspace, string? repo)
{
if (string.IsNullOrEmpty(filePath) || string.IsNullOrEmpty(repo))
{
return null;
}

var normalized = filePath!.Replace('\\', '/');

// Prefer GITHUB_WORKSPACE for reliable path stripping; fall back to repo name matching.
if (!string.IsNullOrEmpty(workspace) && normalized.StartsWith(workspace!, StringComparison.OrdinalIgnoreCase))
{
return normalized[workspace!.Length..].TrimStart('/');
}

var repoName = repo!.Split('/').LastOrDefault() ?? "";
if (repoName.Length > 0)
{
var repoIndex = normalized.IndexOf($"/{repoName}/", StringComparison.OrdinalIgnoreCase);
if (repoIndex >= 0)
{
return normalized[(repoIndex + repoName.Length + 2)..];
}
}

return null;
}
}
9 changes: 9 additions & 0 deletions TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -147,6 +150,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; }

Expand Down
3 changes: 3 additions & 0 deletions TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -529,6 +530,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)
Expand Down
Loading
Loading