diff --git a/TUnit.Engine.Tests/HtmlReporterTests.cs b/TUnit.Engine.Tests/HtmlReporterTests.cs index ded0ba1eb1..d475c1985d 100644 --- a/TUnit.Engine.Tests/HtmlReporterTests.cs +++ b/TUnit.Engine.Tests/HtmlReporterTests.cs @@ -462,4 +462,5 @@ public void FilterEngineNotices_PassesThroughWhenNoTUnitPrefix() EndTime = startTime, RetryAttempt = 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/HtmlReportDataModel.cs b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs index 51069173da..28b321242d 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs @@ -53,6 +53,9 @@ internal sealed class ReportData [JsonPropertyName("repositorySlug")] public string? RepositorySlug { get; init; } + + [JsonPropertyName("serverUrl")] + public string? ServerUrl { get; init; } } internal sealed class ReportSummary @@ -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 4298f11857..960e35993e 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs @@ -184,6 +184,7 @@ private static string SerializeReport(ReportData data) if (!string.IsNullOrEmpty(data.CommitSha)) w.WriteString("commit", data.CommitSha); if (!string.IsNullOrEmpty(data.PullRequestNumber)) w.WriteString("pr", "#" + data.PullRequestNumber); if (!string.IsNullOrEmpty(data.RepositorySlug)) w.WriteString("repository", data.RepositorySlug); + if (!string.IsNullOrEmpty(data.ServerUrl)) w.WriteString("serverUrl", data.ServerUrl); if (!string.IsNullOrEmpty(data.Filter)) w.WriteString("filter", data.Filter); w.WriteNumber("startMs", runStartMs); @@ -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) diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index 88e8cca37c..943ae4a9c1 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -375,7 +375,7 @@ private ReportData BuildReportData() } #endif - var (commitSha, branch, prNumber, repoSlug) = GetCiContext(); + var (commitSha, branch, prNumber, repoSlug, serverUrl) = GetCiContext(); return new ReportData { @@ -394,14 +394,15 @@ private ReportData BuildReportData() Branch = branch, PullRequestNumber = prNumber, RepositorySlug = repoSlug, + ServerUrl = serverUrl, }; } - private static (string? CommitSha, string? Branch, string? PullRequestNumber, string? RepositorySlug) GetCiContext() + private static (string? CommitSha, string? Branch, string? PullRequestNumber, string? RepositorySlug, string? ServerUrl) GetCiContext() { if (Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubActions) is not "true") { - return (null, null, null, null); + return (null, null, null, null, null); } var commitSha = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubSha); @@ -428,7 +429,9 @@ private static (string? CommitSha, string? Branch, string? PullRequestNumber, st prNumber = refValue.Substring("refs/pull/".Length, refValue.Length - "refs/pull/".Length - "/merge".Length); } - return (commitSha, branch, prNumber, repoSlug); + var serverUrl = (Environment.GetEnvironmentVariable("GITHUB_SERVER_URL") ?? "https://github.com").TrimEnd('/'); + + return (commitSha, branch, prNumber, repoSlug, serverUrl); } private static void AccumulateStatus(ReportSummary summary, ReportTestResult testResult) @@ -580,6 +583,8 @@ internal static ReportTestResult ExtractTestResult(string testId, TestNode testN CustomProperties = customPropertiesArray is { Length: > 0 } ? customPropertiesArray : null, FilePath = fileLocation?.FilePath, LineNumber = fileLocation?.LineSpan.Start.Line, + EndLineNumber = fileLocation?.LineSpan.End.Line is > 0 ? fileLocation.LineSpan.End.Line : null, + SourceRelativePath = ComputeSourceRelativePath(fileLocation?.FilePath), SkipReason = skipReason, RetryAttempt = retryAttempt, Attempts = attempts, @@ -589,6 +594,42 @@ internal static ReportTestResult ExtractTestResult(string testId, TestNode testN }; } + private static string? ComputeSourceRelativePath(string? filePath) + { + if (string.IsNullOrEmpty(filePath)) + { + return null; + } + + if (Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubActions) is not "true") + { + return null; + } + + var repo = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubRepository); + if (string.IsNullOrEmpty(repo)) + { + return null; + } + + var normalized = filePath!.Replace('\\', '/'); + + var workspace = Environment.GetEnvironmentVariable("GITHUB_WORKSPACE")?.Replace('\\', '/'); + if (!string.IsNullOrEmpty(workspace) && normalized.StartsWith(workspace!, StringComparison.OrdinalIgnoreCase)) + { + return normalized[workspace!.Length..].TrimStart('/'); + } + + var repoName = repo!.Split('/').LastOrDefault() ?? ""; + var repoIndex = normalized.IndexOf($"/{repoName}/", StringComparison.OrdinalIgnoreCase); + if (repoIndex >= 0) + { + return normalized[(repoIndex + repoName.Length + 2)..]; + } + + return null; + } + internal static string? TruncateOutput(string? value) { if (value is null || value.Length <= MaxOutputLength) diff --git a/TUnit.Engine/Reporters/Html/TestReport.template.html b/TUnit.Engine/Reporters/Html/TestReport.template.html index 9ea3edb95b..046066a52f 100644 --- a/TUnit.Engine/Reporters/Html/TestReport.template.html +++ b/TUnit.Engine/Reporters/Html/TestReport.template.html @@ -557,6 +557,38 @@ } .source-path { color: var(--text); word-break: break-all; } .source-path .ln { color: var(--accent); } + .source-link-btn { + display: inline-flex; align-items: center; gap: 4px; + padding: 5px 10px; border-radius: 5px; font-size: 11px; font-weight: 500; + background: var(--accent-soft); color: var(--accent); border: 1px solid var(--accent-border); + text-decoration: none; white-space: nowrap; + } + .source-link-btn:hover { background: var(--accent); color: #fff; } + .source-snippet-container { + margin-top: 12px; border: 1px solid var(--border); border-radius: 8px; + overflow: hidden; background: var(--surface); + } + .source-snippet-placeholder { + padding: 16px; color: var(--text-dim); font-size: 12px; text-align: center; + } + .source-snippet-header { + padding: 8px 14px; font-size: 11px; color: var(--text-dim); + background: var(--surface-2); border-bottom: 1px solid var(--border); + display: flex; justify-content: space-between; align-items: center; + } + .source-snippet-code { + padding: 12px 0; overflow-x: auto; font-family: 'JetBrains Mono', monospace; + font-size: 12px; line-height: 1.6; counter-reset: line-number; + } + .source-snippet-code .line { + display: flex; padding: 0 14px; + } + .source-snippet-code .line:hover { background: var(--surface-2); } + .source-snippet-code .line-num { + user-select: none; min-width: 3ch; text-align: right; + color: var(--text-dim); opacity: 0.5; margin-right: 16px; flex-shrink: 0; + } + .source-snippet-code .line-content { white-space: pre; color: var(--text); } /* ============================== run view ============================== */ .run-wrap { max-width: 1280px; margin: 0 auto; padding: 28px 28px 80px; } @@ -1633,6 +1665,56 @@

Categories

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

${esc(namePart)}${argsPart ? `${esc(
${esc(t.source.path)}:${t.source.line}
- +
+ ${(()=>{ + if (!t.source.relativePath || !REPORT.commit || !REPORT.repository) return ''; + 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; + const href = serverUrl + '/' + REPORT.repository + '/blob/' + REPORT.commit + '/' + t.source.relativePath + lineRef; + return 'Jump to source ↗'; + })()} + +
+ ${(()=>{ + if (!t.source.relativePath || !REPORT.commit || !REPORT.repository) return ''; + return '
Loading source…
'; + })()}
`; @@ -2276,6 +2373,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/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 @@ +