diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs index 245043adeb..ec19d823b3 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs @@ -93,6 +93,9 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella // workflow Annotations tab alongside failures, rather than being silently absent. if (nodeState is SkippedTestNodeStateProperty skipped) { + // GetTestName is computed lazily at the call sites so the common passing/in-progress path + // (which returns below without annotating) does not walk the property bag or allocate the + // formatted name it would immediately discard. await WriteSkippedAnnotationAsync(GetTestName(nodeUpdateMessage.TestNode), skipped.Explanation, cancellationToken).ConfigureAwait(false); } @@ -114,7 +117,7 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella } } - private async Task WriteAnnotationAsync(string testName, string? explanation, Exception? exception, CancellationToken cancellationToken) + private Task WriteAnnotationAsync(string testName, string? explanation, Exception? exception, CancellationToken cancellationToken) { if (_logger.IsEnabled(LogLevel.Trace)) { @@ -129,7 +132,7 @@ private async Task WriteAnnotationAsync(string testName, string? explanation, Ex _logger.LogTrace($"Showing failure annotation '{line}'."); } - await DisplayAnnotationLineAsync(line, cancellationToken).ConfigureAwait(false); + return DisplayAnnotationLineAsync(line, cancellationToken); } internal static /* for testing */ string GetErrorAnnotation(string testName, string? explanation, Exception? exception, string? repoRoot, IFileSystem fileSystem, ILogger logger, bool skipAssertionFrames) @@ -158,7 +161,7 @@ private async Task WriteAnnotationAsync(string testName, string? explanation, Ex GitHubActionsEscaper.EscapeData(message)); } - private async Task WriteSkippedAnnotationAsync(string testName, string? explanation, CancellationToken cancellationToken) + private Task WriteSkippedAnnotationAsync(string testName, string? explanation, CancellationToken cancellationToken) { if (_logger.IsEnabled(LogLevel.Trace)) { @@ -172,15 +175,15 @@ private async Task WriteSkippedAnnotationAsync(string testName, string? explanat _logger.LogTrace($"Showing skip annotation '{line}'."); } - await DisplayAnnotationLineAsync(line, cancellationToken).ConfigureAwait(false); + return DisplayAnnotationLineAsync(line, cancellationToken); } - // Prepend a newline so the workflow command ('::error' or '::warning') always starts at column 0 on its - // own line. In CI the terminal output device runs in SimpleAnsi mode and emits a color reset ('\e[m') - // WITHOUT a trailing newline after the preceding colored test block. Emitting the annotation directly - // would yield "\e[m::error ..." and GitHub only recognizes a workflow command when the line begins with - // '::', so the dangling reset would silently drop the annotation. The leading newline pushes the reset - // onto its own (ignored) line and keeps the annotation parseable. + // Prepend a newline so every workflow command annotation ('::error' or '::warning') starts at column 0 + // on its own line. In CI the terminal output device runs in SimpleAnsi mode and emits a color reset + // ('\e[m') WITHOUT a trailing newline after the preceding colored test block. Emitting the annotation + // directly would yield "\e[m::error ..." and GitHub only recognizes a workflow command when the line + // begins with '::', so the dangling reset would silently drop the annotation. The leading newline pushes + // the reset onto its own (ignored) line and keeps the annotation parseable. private Task DisplayAnnotationLineAsync(string line, CancellationToken cancellationToken) => _outputDisplay.DisplayAsync(this, new FormattedTextOutputDeviceData($"\n{line}"), cancellationToken); diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs index fccbd56e12..32caf6b7d6 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs @@ -103,24 +103,21 @@ public void GetSkippedAnnotation_FallsBackToDefaultReason_WhenNoExplanation() Assert.AreEqual("::warning title=Test skipped%3A MyNamespace.MyTest::The test was skipped without providing a reason.", text); } - // Throws (and catches) an exception, reporting the exact line of the throw statement so tests can assert the - // resolved line without hard-coding a physical number that shifts whenever code above changes. - private static Exception CaptureException(string message, out int throwLine) + // Produces an exception whose stack trace deterministically points at this test file at a fixed line, so + // tests can assert the resolved file and line. A real 'throw' was previously used, but the runtime-reported + // line of a thrown exception shifts under Release JIT optimization (observed differing between .NET + // Framework net462 and net472), which made the exact-line assertion flaky in CI (see #9658). A synthetic + // frame keeps both the resolved file and line stable while still exercising the resolver's real + // repo-root/'/_/' path relativization: [CallerFilePath] yields this file's path (mapped to '/_/test/...' + // in deterministic CI builds, or an absolute path locally), exactly as a genuine frame would. + private static Exception CaptureException(string message, out int throwLine, [CallerFilePath] string filePath = "") { - throwLine = 0; - try - { - throwLine = CurrentLine() + 1; - throw new Exception(message); - } - catch (Exception ex) - { - return ex; - } + throwLine = 12345; + return new StackTraceException( + $" at Microsoft.Testing.Extensions.UnitTests.GitHubActionsAnnotationReporterTests.CaptureException() in {filePath}:line {throwLine}", + message); } - private static int CurrentLine([CallerLineNumber] int line = 0) => line; - private static IFileSystem CreateFileSystemWhereEveryFileExists() { var fileSystem = new Mock(); @@ -134,7 +131,9 @@ private sealed class StackTraceException : Exception { private readonly string _stackTrace; - public StackTraceException(string stackTrace) => _stackTrace = stackTrace; + public StackTraceException(string stackTrace, string? message = null) + : base(message) + => _stackTrace = stackTrace; public override string? StackTrace => _stackTrace; }