Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
51 changes: 51 additions & 0 deletions TUnit.Engine.Tests/GitHubReporterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,57 @@ 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");
Environment.SetEnvironmentVariable("GITHUB_SERVER_URL", "https://github.com");

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_REPOSITORY", null);
Environment.SetEnvironmentVariable("GITHUB_SHA", null);
Environment.SetEnvironmentVariable("GITHUB_WORKSPACE", null);
Environment.SetEnvironmentVariable("GITHUB_SERVER_URL", null);
}
}

private string CreateTempFile()
{
var path = Path.GetTempFileName();
Expand Down
224 changes: 224 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,227 @@ public void FilterEngineNotices_PassesThroughWhenNoTUnitPrefix()
EndTime = startTime,
RetryAttempt = 0,
};

[Test]
public void SourcePathResolver_Strips_Workspace_Prefix()
{
var relative = SourcePathResolver.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 SourcePathResolver_Strips_Workspace_Prefix_When_Workspace_Has_Backslashes()
{
// Workspace is passed in with Windows backslashes (un-normalized); the method
// must normalize it internally so the prefix still matches.
var relative = SourcePathResolver.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 SourcePathResolver_Falls_Back_To_Repo_Name_When_No_Workspace()
{
// No workspace given; locate the repo name segment within the path instead.
var relative = SourcePathResolver.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 SourcePathResolver_Returns_Null_When_Unresolvable(string? filePath, string? repo)
{
SourcePathResolver.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_SourceLinks_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(),
SourceLinks = new SourceLinkTemplates(
"https://github.com/o/r/blob/sha/{path}#L{line}",
"https://github.com/o/r/blob/sha/{path}#L{start}-L{end}",
"https://raw.githubusercontent.com/o/r/sha/{path}"),
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("\"sourceLinks\":{");
embedded.ShouldContain("\"lineUrl\":\"https://github.com/o/r/blob/sha/{path}#L{line}\"");
embedded.ShouldContain("\"rawUrl\":\"https://raw.githubusercontent.com/o/r/sha/{path}\"");
embedded.ShouldContain("\"endLine\":20");
embedded.ShouldContain("\"relativePath\":\"src/SampleTests.cs\"");
}

[Test]
public void SourceControlContext_GitHub_Builds_Blob_And_Raw_Templates()
{
var env = new Dictionary<string, string?>
{
["GITHUB_ACTIONS"] = "true",
["GITHUB_SERVER_URL"] = "https://github.com",
["GITHUB_REPOSITORY"] = "thomhurst/TUnit",
["GITHUB_SHA"] = "abc123",
["GITHUB_WORKSPACE"] = "/work/TUnit",
};

var ctx = SourceControlContext.Detect(k => env.GetValueOrDefault(k));

ctx.RepositorySlug.ShouldBe("thomhurst/TUnit");
ctx.Links.ShouldNotBeNull();
ctx.Links!.LineUrl.ShouldBe("https://github.com/thomhurst/TUnit/blob/abc123/{path}#L{line}");
ctx.Links.RangeUrl.ShouldBe("https://github.com/thomhurst/TUnit/blob/abc123/{path}#L{start}-L{end}");
// github.com raw is CORS-enabled, so a snippet template is provided.
ctx.Links.RawUrl.ShouldBe("https://raw.githubusercontent.com/thomhurst/TUnit/abc123/{path}");
}

[Test]
public void SourceControlContext_GitHubEnterprise_Omits_Raw_Template()
{
var env = new Dictionary<string, string?>
{
["GITHUB_ACTIONS"] = "true",
["GITHUB_SERVER_URL"] = "https://github.acme.corp",
["GITHUB_REPOSITORY"] = "team/app",
["GITHUB_SHA"] = "deadbeef",
};

var ctx = SourceControlContext.Detect(k => env.GetValueOrDefault(k));

ctx.Links.ShouldNotBeNull();
ctx.Links!.LineUrl.ShouldBe("https://github.acme.corp/team/app/blob/deadbeef/{path}#L{line}");
// Enterprise raw host CORS is unknown — link only, no inline snippet.
ctx.Links.RawUrl.ShouldBeNull();
}

[Test]
public void SourceControlContext_GitLab_Is_Link_Only()
{
var env = new Dictionary<string, string?>
{
["GITLAB_CI"] = "true",
["CI_SERVER_URL"] = "https://gitlab.com",
["CI_PROJECT_PATH"] = "group/proj",
["CI_COMMIT_SHA"] = "f00d",
};

var ctx = SourceControlContext.Detect(k => env.GetValueOrDefault(k));

ctx.Links.ShouldNotBeNull();
ctx.Links!.LineUrl.ShouldBe("https://gitlab.com/group/proj/-/blob/f00d/{path}#L{line}");
ctx.Links.RangeUrl.ShouldBe("https://gitlab.com/group/proj/-/blob/f00d/{path}#L{start}-{end}");
// GitLab raw sends no CORS header, so inline snippets are not supported.
ctx.Links.RawUrl.ShouldBeNull();
}

[Test]
public void SourceControlContext_Bitbucket_Supports_Snippet()
{
var env = new Dictionary<string, string?>
{
["BITBUCKET_BUILD_NUMBER"] = "42",
["BITBUCKET_REPO_FULL_NAME"] = "team/repo",
["BITBUCKET_COMMIT"] = "cafe",
};

var ctx = SourceControlContext.Detect(k => env.GetValueOrDefault(k));

ctx.Links.ShouldNotBeNull();
ctx.Links!.LineUrl.ShouldBe("https://bitbucket.org/team/repo/src/cafe/{path}#lines-{line}");
ctx.Links.RangeUrl.ShouldBe("https://bitbucket.org/team/repo/src/cafe/{path}#lines-{start}:{end}");
ctx.Links.RawUrl.ShouldBe("https://bitbucket.org/team/repo/raw/cafe/{path}");
}

[Test]
public void SourceControlContext_NoCi_Is_Empty()
{
var ctx = SourceControlContext.Detect(_ => null);

ctx.ShouldBe(SourceControlContext.Empty);
ctx.Links.ShouldBeNull();
}
}
20 changes: 20 additions & 0 deletions TUnit.Engine/Configuration/EnvironmentConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,24 @@ 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";

// GitLab CI context (for source links in reports)
public const string GitLabServerUrl = "CI_SERVER_URL";
public const string GitLabProjectPath = "CI_PROJECT_PATH";
public const string GitLabCommitSha = "CI_COMMIT_SHA";
public const string GitLabProjectDir = "CI_PROJECT_DIR";
public const string GitLabBranch = "CI_COMMIT_REF_NAME";

// Bitbucket Pipelines context (for source links in reports)
public const string BitbucketBuildNumber = "BITBUCKET_BUILD_NUMBER";
public const string BitbucketRepoFullName = "BITBUCKET_REPO_FULL_NAME";
public const string BitbucketCommit = "BITBUCKET_COMMIT";
public const string BitbucketCloneDir = "BITBUCKET_CLONE_DIR";
public const string BitbucketBranch = "BITBUCKET_BRANCH";
public const string BitbucketServerUrl = "https://bitbucket.org";
}
35 changes: 13 additions & 22 deletions TUnit.Engine/Reporters/GitHubReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,10 @@ 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";
// Not normalized here: ToRepoRelativePath normalizes the workspace internally.
var githubWorkspace = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubWorkspace);
// No default: if the server is unknown, omit the link rather than guess github.com.
var githubServerUrl = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubServerUrl)?.TrimEnd('/');

// 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 @@ -585,34 +587,23 @@ private static string GetTestDisplayName(TestNode testNode)
return string.IsNullOrEmpty(className) ? displayName : $"{className}.{displayName}";
}

private static string? GetSourceLink(TestNode testNode, string? repo, string? sha, string? workspace, string serverUrl)
private static string? GetSourceLink(TestNode testNode, string? repo, string? sha, string? workspace, string? serverUrl)
{
var fileLocation = testNode.Properties.AsEnumerable()
.OfType<TestFileLocationProperty>().FirstOrDefault();
if (fileLocation is null) return null;

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

var filePath = fileLocation.FilePath.Replace('\\', '/');
// Strip to a repo-relative path; fall back to the full normalized path when unresolvable.
var filePath = SourcePathResolver.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})";
return $"[{fileName}:{line}]({serverUrl}/{repo}/blob/{sha}/{filePath}#L{line})";
}

public string? Filter { get; set; }
Expand Down
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; }

[JsonIgnore]
public SourceLinkTemplates? SourceLinks { 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
Loading
Loading