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
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Comment thread
Evangelink marked this conversation as resolved.
}

Expand All@@ -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))
{
Expand All@@ -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)
Expand DownExpand Up@@ -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))
{
Expand All@@ -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);

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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}",
Comment thread
Evangelink marked this conversation as resolved.
message);
}

private static int CurrentLine([CallerLineNumber] int line = 0) => line;

private static IFileSystem CreateFileSystemWhereEveryFileExists()
{
var fileSystem = new Mock<IFileSystem>();
Expand All@@ -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;
}
Expand Down