diff --git a/TUnit.Engine.Tests/GitHubReporterTests.cs b/TUnit.Engine.Tests/GitHubReporterTests.cs index 19a25df118..44aee46b43 100644 --- a/TUnit.Engine.Tests/GitHubReporterTests.cs +++ b/TUnit.Engine.Tests/GitHubReporterTests.cs @@ -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(); diff --git a/TUnit.Engine.Tests/HtmlReporterTests.cs b/TUnit.Engine.Tests/HtmlReporterTests.cs index ded0ba1eb1..ca556860a7 100644 --- a/TUnit.Engine.Tests/HtmlReporterTests.cs +++ b/TUnit.Engine.Tests/HtmlReporterTests.cs @@ -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; @@ -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 + { + ["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 + { + ["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 + { + ["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 + { + ["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(); + } } diff --git a/TUnit.Engine/Configuration/EnvironmentConstants.cs b/TUnit.Engine/Configuration/EnvironmentConstants.cs index db37e60c20..c929bbf1aa 100644 --- a/TUnit.Engine/Configuration/EnvironmentConstants.cs +++ b/TUnit.Engine/Configuration/EnvironmentConstants.cs @@ -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"; } diff --git a/TUnit.Engine/Reporters/GitHubReporter.cs b/TUnit.Engine/Reporters/GitHubReporter.cs index da742484a0..96a4c0333f 100644 --- a/TUnit.Engine/Reporters/GitHubReporter.cs +++ b/TUnit.Engine/Reporters/GitHubReporter.cs @@ -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(); @@ -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().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; } diff --git a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs index 51069173da..10e775693b 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; } + + [JsonIgnore] + public SourceLinkTemplates? SourceLinks { get; init; } } internal sealed class ReportSummary @@ -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; } diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs index 66857ab5f0..02eee6edb5 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs @@ -184,6 +184,17 @@ 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 (data.SourceLinks is { } links) + { + // URL templates for the detected source-control provider ({path}/{line}/{start}/{end} + // are filled client-side per test). rawUrl is omitted when inline fetch is unsupported. + w.WritePropertyName("sourceLinks"); + w.WriteStartObject(); + w.WriteString("lineUrl", links.LineUrl); + w.WriteString("rangeUrl", links.RangeUrl); + if (!string.IsNullOrEmpty(links.RawUrl)) w.WriteString("rawUrl", links.RawUrl); + w.WriteEndObject(); + } if (!string.IsNullOrEmpty(data.Filter)) w.WriteString("filter", data.Filter); w.WriteNumber("startMs", runStartMs); @@ -529,6 +540,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 edbe1c138e..453dde957b 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -17,6 +17,7 @@ using TUnit.Engine.Exceptions; using TUnit.Engine.Framework; using TUnit.Engine.Helpers; +using TUnit.Engine.Reporters; #pragma warning disable TPEXP @@ -237,6 +238,9 @@ private ReportData BuildReportData() var spanLookup = (Dictionary?)null; #endif + // Resolve source-control context once; reused for per-test source links and report metadata below. + var ci = SourceControlContext.Detect(Environment.GetEnvironmentVariable); + foreach (var kvp in lastUpdates) { var testNode = kvp.Value.TestNode; @@ -293,7 +297,7 @@ private ReportData BuildReportData() string[]? additionalTraceIdsForResult = null; #endif - var testResult = ExtractTestResult(kvp.Key, testNode, traceId, spanId, retryAttempt, additionalTraceIdsForResult, attempts); + var testResult = ExtractTestResult(kvp.Key, testNode, traceId, spanId, retryAttempt, additionalTraceIdsForResult, attempts, ci.RepositorySlug, ci.Workspace); AccumulateStatus(summary, testResult); @@ -376,8 +380,6 @@ private ReportData BuildReportData() } #endif - var (commitSha, branch, prNumber, repoSlug) = GetCiContext(); - return new ReportData { AssemblyName = assemblyName, @@ -391,47 +393,14 @@ private ReportData BuildReportData() Summary = summary, Groups = groups, Spans = spans, - CommitSha = commitSha, - Branch = branch, - PullRequestNumber = prNumber, - RepositorySlug = repoSlug, + CommitSha = ci.CommitSha, + Branch = ci.Branch, + PullRequestNumber = ci.PullRequestNumber, + RepositorySlug = ci.RepositorySlug, + SourceLinks = ci.Links, }; } - private static (string? CommitSha, string? Branch, string? PullRequestNumber, string? RepositorySlug) GetCiContext() - { - if (Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubActions) is not "true") - { - return (null, null, null, null); - } - - var commitSha = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubSha); - var repoSlug = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubRepository); - - // Branch: prefer GITHUB_HEAD_REF (set on PRs), fallback to GITHUB_REF (strip refs/heads/) - var branch = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubHeadRef); - if (string.IsNullOrEmpty(branch)) - { - var ghRef = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubRef); - if (ghRef is not null && ghRef.StartsWith("refs/heads/", StringComparison.Ordinal)) - { - branch = ghRef.Substring("refs/heads/".Length); - } - } - - // PR number: parse from GITHUB_REF if it matches refs/pull/{n}/merge - string? prNumber = null; - var refValue = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubRef); - if (refValue is not null && - refValue.StartsWith("refs/pull/", StringComparison.Ordinal) && - refValue.EndsWith("/merge", StringComparison.Ordinal)) - { - prNumber = refValue.Substring("refs/pull/".Length, refValue.Length - "refs/pull/".Length - "/merge".Length); - } - - return (commitSha, branch, prNumber, repoSlug); - } - private static void AccumulateStatus(ReportSummary summary, ReportTestResult testResult) { summary.Total++; @@ -501,7 +470,7 @@ private static DateTimeOffset ParseStartTimeForSort(string? raw) : DateTimeOffset.MaxValue; } - internal static ReportTestResult ExtractTestResult(string testId, TestNode testNode, string? traceId, string? spanId, int retryAttempt, string[]? additionalTraceIds, ReportAttempt[]? attempts = null) + internal static ReportTestResult ExtractTestResult(string testId, TestNode testNode, string? traceId, string? spanId, int retryAttempt, string[]? additionalTraceIds, ReportAttempt[]? attempts = null, string? ciRepo = null, string? ciWorkspace = null) { IProperty? stateProperty = null; TestMethodIdentifierProperty? testMethodIdentifier = null; @@ -581,6 +550,10 @@ internal static ReportTestResult ExtractTestResult(string testId, TestNode testN CustomProperties = customPropertiesArray is { Length: > 0 } ? customPropertiesArray : null, FilePath = fileLocation?.FilePath, LineNumber = fileLocation?.LineSpan.Start.Line, + // Line numbers are already 1-based here. Emit an end line only when the span covers + // more than the declaration line (source-gen) — reflection has none, so leave it null. + EndLineNumber = fileLocation?.LineSpan.End.Line is { } endLine && endLine > fileLocation.LineSpan.Start.Line ? endLine : null, + SourceRelativePath = SourcePathResolver.ToRepoRelativePath(fileLocation?.FilePath, ciWorkspace, ciRepo), SkipReason = skipReason, RetryAttempt = retryAttempt, Attempts = attempts, @@ -794,7 +767,7 @@ private async Task TryGitHubIntegrationAsync(string filePath, CancellationToken } else if (artifactId is not null && !string.IsNullOrEmpty(repo) && !string.IsNullOrEmpty(runId)) { - var serverUrl = (Environment.GetEnvironmentVariable("GITHUB_SERVER_URL") ?? "https://github.com").TrimEnd('/'); + var serverUrl = (Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubServerUrl) ?? EnvironmentConstants.GitHubDefaultServerUrl).TrimEnd('/'); _githubReporter.ArtifactUrl = $"{serverUrl}/{repo}/actions/runs/{runId}/artifacts/{artifactId}"; } } diff --git a/TUnit.Engine/Reporters/Html/TestReport.template.html b/TUnit.Engine/Reporters/Html/TestReport.template.html index 9ea3edb95b..84a79967d5 100644 --- a/TUnit.Engine/Reporters/Html/TestReport.template.html +++ b/TUnit.Engine/Reporters/Html/TestReport.template.html @@ -557,6 +557,37 @@ } .source-path { color: var(--text); word-break: break-all; } .source-path .ln { color: var(--accent); } + .source-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; } + .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; + } + .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 +1664,71 @@

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 links & snippet lazy loading ---------- */ +// REPORT.sourceLinks holds pre-built URL templates for the detected provider (GitHub/GitLab/ +// Bitbucket/...). Server, repo and commit are already baked in; we only fill {path}/{line}/ +// {start}/{end} here, so there is zero provider-specific logic in this report. +function canLinkSource(t) { return !!(t.source && t.source.relativePath && REPORT.sourceLinks && REPORT.sourceLinks.lineUrl); } +function canShowSnippet(t) { return !!(t.source && t.source.relativePath && REPORT.sourceLinks && REPORT.sourceLinks.rawUrl); } +function fillSourceTemplate(tmpl, t) { + const end = (t.source.endLine && t.source.endLine > t.source.line) ? t.source.endLine : t.source.line; + // encodeURI keeps '/' as a path separator but escapes spaces and other unsafe chars. + return tmpl + .replace('{path}', encodeURI(t.source.relativePath)) + .replace('{line}', t.source.line) + .replace('{start}', t.source.line) + .replace('{end}', end); +} +function sourceJumpUrl(t) { + const sl = REPORT.sourceLinks; + const tmpl = (t.source.endLine && t.source.endLine > t.source.line && sl.rangeUrl) ? sl.rangeUrl : sl.lineUrl; + return fillSourceTemplate(tmpl, t); +} +const _snippetCache = new Map(); +const SNIPPET_FALLBACK_LINES = 40; +function loadSourceSnippet(detail) { + const container = detail.querySelector('.source-snippet-container'); + if (!container || container.dataset.loaded) return; + container.dataset.loaded = '1'; + + const rawUrl = container.dataset.rawUrl; + const path = container.dataset.path; + const startLine = parseInt(container.dataset.line, 10) || 1; + const endLine = parseInt(container.dataset.endLine, 10) || 0; + + const render = (text) => { + // Strip a UTF-8 BOM (renders as a stray glyph on line 1) and split on both + // LF and CRLF so Windows-authored files don't keep a trailing \r per line. + const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/); + // from: convert 1-based startLine to 0-based index. + const from = Math.max(0, startLine - 1); + // Source-gen mode supplies an end line (full method). Reflection mode does not, + // so fall back to a fixed window starting at the declaration line. + // endLine is 1-based; slice()'s upper bound is exclusive, so a 1-based endLine + // is already the correct exclusive index. + const to = endLine > startLine + ? Math.min(lines.length, endLine) + : Math.min(lines.length, from + SNIPPET_FALLBACK_LINES); + const snippet = lines.slice(from, to); + const fileName = path.split('/').pop(); + const rangeLabel = `lines ${startLine}–${from + snippet.length}`; + container.innerHTML = + `
${esc(fileName)} (${rangeLabel})
` + + `
${snippet.map((l, i) => + `
${from + i + 1}${esc(l)}
` + ).join('')}
`; + }; + + if (_snippetCache.has(rawUrl)) { render(_snippetCache.get(rawUrl)); return; } + + fetch(rawUrl) + .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) + .then(text => { _snippetCache.set(rawUrl, text); render(text); }) + .catch(() => { + container.innerHTML = '
Source unavailable — 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 +2352,16 @@

${esc(namePart)}${argsPart ? `${esc(
${esc(t.source.path)}:${t.source.line}
- +
+ ${canLinkSource(t) + ? `Jump to source ↗` + : ''} + +
+ ${canShowSnippet(t) + ? `
Loading source…
` + : ''}
`; @@ -2276,6 +2380,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 diff --git a/TUnit.Engine/Reporters/SourceControlContext.cs b/TUnit.Engine/Reporters/SourceControlContext.cs new file mode 100644 index 0000000000..e9018bab2e --- /dev/null +++ b/TUnit.Engine/Reporters/SourceControlContext.cs @@ -0,0 +1,132 @@ +using TUnit.Engine.Configuration; + +namespace TUnit.Engine.Reporters; + +/// +/// URL templates for a detected source-control provider. Server, repository slug and +/// commit are already baked in; the placeholders {path}, {line}, +/// {start} and {end} are filled per test by the report's client script. +/// +/// Blob link to a single line (the "Jump to source" button). +/// Blob link to a line range, for tests with a known end line. +/// +/// Raw-content URL for the inline snippet. Set only for providers whose raw endpoint +/// allows cross-origin fetch from the static report (GitHub.com, Bitbucket Cloud); +/// means link-only (e.g. GitLab raw sends no CORS header). +/// +internal sealed record SourceLinkTemplates(string LineUrl, string RangeUrl, string? RawUrl); + +/// +/// CI / source-control context resolved from environment variables: enough to render +/// report metadata and, when a supported provider is detected, source-link templates. +/// +internal sealed record SourceControlContext( + string? CommitSha, + string? Branch, + string? PullRequestNumber, + string? RepositorySlug, + string? Workspace, + SourceLinkTemplates? Links) +{ + public static readonly SourceControlContext Empty = new(null, null, null, null, null, null); + + /// + /// Detects the active provider from and builds its context. + /// Providers are probed by their unique sentinel variable; the first match wins. + /// + public static SourceControlContext Detect(Func getEnv) + { + if (getEnv(EnvironmentConstants.GitHubActions) is "true") + { + return BuildGitHub(getEnv); + } + + if (getEnv(EnvironmentConstants.GitLabCi) is "true") + { + return BuildGitLab(getEnv); + } + + if (!string.IsNullOrEmpty(getEnv(EnvironmentConstants.BitbucketBuildNumber))) + { + return BuildBitbucket(getEnv); + } + + return Empty; + } + + private static SourceControlContext BuildGitHub(Func getEnv) + { + var sha = getEnv(EnvironmentConstants.GitHubSha); + var slug = getEnv(EnvironmentConstants.GitHubRepository); + var server = getEnv(EnvironmentConstants.GitHubServerUrl)?.TrimEnd('/'); + var workspace = getEnv(EnvironmentConstants.GitHubWorkspace); + + // Branch: prefer GITHUB_HEAD_REF (set on PRs), fall back to GITHUB_REF (strip refs/heads/). + var branch = getEnv(EnvironmentConstants.GitHubHeadRef); + var gitRef = getEnv(EnvironmentConstants.GitHubRef); + if (string.IsNullOrEmpty(branch) && gitRef is not null && gitRef.StartsWith("refs/heads/", StringComparison.Ordinal)) + { + branch = gitRef["refs/heads/".Length..]; + } + + // PR number: parse from GITHUB_REF when it matches refs/pull/{n}/merge. + string? prNumber = null; + if (gitRef is not null && gitRef.StartsWith("refs/pull/", StringComparison.Ordinal) && gitRef.EndsWith("/merge", StringComparison.Ordinal)) + { + prNumber = gitRef["refs/pull/".Length..^"/merge".Length]; + } + + SourceLinkTemplates? links = null; + if (!string.IsNullOrEmpty(server) && !string.IsNullOrEmpty(slug) && !string.IsNullOrEmpty(sha)) + { + var blob = $"{server}/{slug}/blob/{sha}/{{path}}"; + // raw.githubusercontent.com is the only CORS-enabled raw host; Enterprise raw is link-only. + var raw = server == EnvironmentConstants.GitHubDefaultServerUrl + ? $"https://raw.githubusercontent.com/{slug}/{sha}/{{path}}" + : null; + links = new SourceLinkTemplates($"{blob}#L{{line}}", $"{blob}#L{{start}}-L{{end}}", raw); + } + + return new SourceControlContext(sha, branch, prNumber, slug, workspace, links); + } + + private static SourceControlContext BuildGitLab(Func getEnv) + { + var sha = getEnv(EnvironmentConstants.GitLabCommitSha); + var slug = getEnv(EnvironmentConstants.GitLabProjectPath); + var server = getEnv(EnvironmentConstants.GitLabServerUrl)?.TrimEnd('/'); + var workspace = getEnv(EnvironmentConstants.GitLabProjectDir); + var branch = getEnv(EnvironmentConstants.GitLabBranch); + + SourceLinkTemplates? links = null; + if (!string.IsNullOrEmpty(server) && !string.IsNullOrEmpty(slug) && !string.IsNullOrEmpty(sha)) + { + var blob = $"{server}/{slug}/-/blob/{sha}/{{path}}"; + // GitLab's raw endpoint sends no Access-Control-Allow-Origin, so the in-report + // fetch is blocked — link only, no inline snippet. + links = new SourceLinkTemplates($"{blob}#L{{line}}", $"{blob}#L{{start}}-{{end}}", null); + } + + return new SourceControlContext(sha, branch, null, slug, workspace, links); + } + + private static SourceControlContext BuildBitbucket(Func getEnv) + { + var sha = getEnv(EnvironmentConstants.BitbucketCommit); + var slug = getEnv(EnvironmentConstants.BitbucketRepoFullName); + var workspace = getEnv(EnvironmentConstants.BitbucketCloneDir); + var branch = getEnv(EnvironmentConstants.BitbucketBranch); + const string server = EnvironmentConstants.BitbucketServerUrl; + + SourceLinkTemplates? links = null; + if (!string.IsNullOrEmpty(slug) && !string.IsNullOrEmpty(sha)) + { + var blob = $"{server}/{slug}/src/{sha}/{{path}}"; + // Bitbucket Cloud's raw endpoint sends Access-Control-Allow-Origin: *, so snippets work. + var raw = $"{server}/{slug}/raw/{sha}/{{path}}"; + links = new SourceLinkTemplates($"{blob}#lines-{{line}}", $"{blob}#lines-{{start}}:{{end}}", raw); + } + + return new SourceControlContext(sha, branch, null, slug, workspace, links); + } +} diff --git a/TUnit.Engine/Reporters/SourcePathResolver.cs b/TUnit.Engine/Reporters/SourcePathResolver.cs new file mode 100644 index 0000000000..faf4e95a5c --- /dev/null +++ b/TUnit.Engine/Reporters/SourcePathResolver.cs @@ -0,0 +1,70 @@ +using System.Text.RegularExpressions; + +namespace TUnit.Engine.Reporters; + +/// +/// Shared, provider-agnostic helper for turning a local test source file path into a +/// repository-relative path. Used by every reporter that emits source links (GitHub +/// markdown summary, HTML report for GitHub/GitLab/Bitbucket). +/// +internal static partial class SourcePathResolver +{ + // Deterministic builds (ContinuousIntegrationBuild=true, which CI enables) remap each + // SourceRoot to "/_/", "/_1/", "/_2/", ... via PathMap, so test file paths look like + // "/_/TUnit.Engine/Foo.cs" — they no longer contain the real workspace or repo name. + // The remainder after the prefix is already the repo-relative path. +#if NET + [GeneratedRegex(@"^/_\d*/")] + private static partial Regex DeterministicRootRegex(); +#else + private static readonly Regex _deterministicRootRegex = new(@"^/_\d*/", RegexOptions.Compiled); + private static Regex DeterministicRootRegex() => _deterministicRootRegex; +#endif + + /// + /// Converts an absolute source file path to a repository-relative path. + /// Strips the deterministic-build source-root prefix when present; otherwise prefers + /// stripping the GITHUB_WORKSPACE prefix and falls back to locating the + /// repository name within the path. Returns when the path + /// cannot be resolved to a repository-relative location. + /// + internal static string? ToRepoRelativePath(string? filePath, string? workspace, string? repo) + { + if (string.IsNullOrEmpty(filePath) || string.IsNullOrEmpty(repo)) + { + return null; + } + + var normalized = filePath!.Replace('\\', '/'); + + // Deterministic builds collapse the workspace to "/_/", so check this first — in CI + // the path will never start with the real GITHUB_WORKSPACE. + var deterministicMatch = DeterministicRootRegex().Match(normalized); + if (deterministicMatch.Success) + { + return normalized[deterministicMatch.Length..]; + } + + // Normalize the workspace here too so callers don't have to — keeps the prefix + // match working regardless of which slash style the caller passes in. + var normalizedWorkspace = workspace?.Replace('\\', '/').TrimEnd('/'); + + // Prefer GITHUB_WORKSPACE for reliable path stripping; fall back to repo name matching. + if (!string.IsNullOrEmpty(normalizedWorkspace) && normalized.StartsWith(normalizedWorkspace!, StringComparison.OrdinalIgnoreCase)) + { + return normalized[normalizedWorkspace!.Length..].TrimStart('/'); + } + + var repoName = repo!.Split('/').LastOrDefault(); + if (!string.IsNullOrEmpty(repoName)) + { + var repoIndex = normalized.IndexOf($"/{repoName}/", StringComparison.OrdinalIgnoreCase); + if (repoIndex >= 0) + { + return normalized[(repoIndex + repoName.Length + 2)..]; + } + } + + return null; + } +}