diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLivePublishingModels.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLivePublishingModels.cs index b939985146..5ef5e02a08 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLivePublishingModels.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLivePublishingModels.cs @@ -15,6 +15,19 @@ internal static class AzureDevOpsLivePublishingConstants public const int MaxRunNameLength = 256; public const string NotExecutedTestOutcome = "NotExecuted"; public const string PassedTestOutcome = "Passed"; + + /// Maximum size (in bytes) of a single attachment uploaded to Azure DevOps. Files larger than this are skipped. + public const long MaxAttachmentSizeBytes = 16L * 1024 * 1024; + + /// Maximum size (in bytes) of an inline (string-based) attachment such as stdout/stderr. Content beyond this is truncated. + public const int MaxInlineAttachmentBytes = 256 * 1024; +} + +internal static class AzureDevOpsAttachmentTypes +{ + public const string CodeCoverage = "CodeCoverage"; + public const string ConsoleLog = "ConsoleLog"; + public const string GeneralAttachment = "GeneralAttachment"; } internal sealed record AzureDevOpsPublishConfiguration( @@ -37,6 +50,44 @@ internal sealed record AzureDevOpsTestCaseResult( [property: JsonPropertyName("startedDate")] DateTimeOffset? StartedDate, [property: JsonPropertyName("completedDate")] DateTimeOffset? CompletedDate); +/// A test case result bundled with optional attachments to upload after the result is published. +internal sealed record AzureDevOpsTestCaseResultWithAttachments( + AzureDevOpsTestCaseResult Result, + IReadOnlyList Attachments); + +/// +/// Describes an attachment to upload to Azure DevOps (either to a test result or to the test run). +/// The payload can come from a file on disk () or from inline string content (). +/// Exactly one of or is non-null. +/// +internal sealed class AzureDevOpsTestResultAttachment +{ + private AzureDevOpsTestResultAttachment(string fileName, string attachmentType, string? comment, string? filePath, string? inlineContent) + { + FileName = fileName; + AttachmentType = attachmentType; + Comment = comment; + FilePath = filePath; + InlineContent = inlineContent; + } + + public string FileName { get; } + + public string AttachmentType { get; } + + public string? Comment { get; } + + public string? FilePath { get; } + + public string? InlineContent { get; } + + public static AzureDevOpsTestResultAttachment FromFile(string filePath, string attachmentType, string? comment = null) + => new(Path.GetFileName(filePath), attachmentType, comment, filePath, inlineContent: null); + + public static AzureDevOpsTestResultAttachment FromString(string content, string fileName, string attachmentType, string? comment = null) + => new(fileName, attachmentType, comment, filePath: null, inlineContent: content); +} + internal sealed record AzureDevOpsTestResultsPublisherOptions( int BatchSize, TimeSpan FlushInterval, diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsClient.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsClient.cs index 32b6e55a98..16e8e744f4 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsClient.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsClient.cs @@ -4,6 +4,7 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Sockets; +using System.Security; using System.Text.Json; using System.Text.Json.Serialization; @@ -18,8 +19,10 @@ internal sealed class AzureDevOpsTestResultsClient : IAzureDevOpsTestResultsClie private const int MaxAttempts = 3; private const int BaseDelayMilliseconds = 500; private static readonly TimeSpan AttemptTimeout = TimeSpan.FromSeconds(15); - private static readonly TimeSpan HttpClientTimeout = TimeSpan.FromSeconds(30); private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(60); + + // Attachments can be up to 16 MB and base64 encoding bloats them ~4/3x. Allow generously longer timeouts. + private static readonly TimeSpan AttachmentRequestTimeout = TimeSpan.FromMinutes(5); private static readonly HttpMethod PatchMethod = new("PATCH"); private static readonly JsonSerializerOptions JsonSerializerOptions = new() { @@ -58,7 +61,9 @@ public async Task CreateTestRunAsync(AzureDevOpsPublishConfiguration config : throw new InvalidOperationException(AzureDevOpsResources.AzureDevOpsLivePublishingInvalidResponse); } - public Task PublishTestResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken) + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "Response types are internal, fixed, and controlled by this extension.")] + [UnconditionalSuppressMessage("Aot", "IL3050", Justification = "Response types are internal, fixed, and controlled by this extension.")] + public async Task?> PublishTestResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken) { using HttpRequestMessage request = CreateRequest( HttpMethod.Post, @@ -66,7 +71,74 @@ public Task PublishTestResultsAsync(AzureDevOpsPublishConfiguration configuratio configuration.AccessToken, results); - return SendAsync(request, cancellationToken); + // Transport/HTTP failures throw from SendCoreAsync — caller retries. + using var requestTimeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + requestTimeoutSource.CancelAfter(RequestTimeout); + using HttpResponseMessage response = await SendCoreAsync(request, requestTimeoutSource.Token, cancellationToken, AttemptTimeout).ConfigureAwait(false); + string payload = await ReadAsStringAsync(response.Content, requestTimeoutSource.Token).ConfigureAwait(false); + + // From this point on the AzDO server has accepted the results. Failing to parse the response + // must not cause the caller to retry the publish (that would duplicate result rows). + try + { + PublishTestResultsResponse? parsed = JsonSerializer.Deserialize(payload, JsonSerializerOptions); + if (parsed?.Value is null || parsed.Value.Length != results.Count) + { + return null; + } + + int[] ids = new int[results.Count]; + for (int i = 0; i < results.Count; i++) + { + if (parsed.Value[i].Id <= 0 + || !string.Equals(parsed.Value[i].AutomatedTestName, results[i].AutomatedTestName, StringComparison.Ordinal)) + { + return null; + } + + ids[i] = parsed.Value[i].Id; + } + + return ids; + } + catch (JsonException) + { + return null; + } + } + + public async Task UploadTestResultAttachmentAsync(AzureDevOpsPublishConfiguration configuration, int runId, int testCaseResultId, AzureDevOpsTestResultAttachment attachment, CancellationToken cancellationToken) + { + AttachmentRequest? payload = TryBuildAttachmentRequest(attachment); + if (payload is null) + { + return; + } + + using HttpRequestMessage request = CreateRequest( + HttpMethod.Post, + BuildResultAttachmentsUri(configuration.CollectionUri, configuration.Project, runId, testCaseResultId), + configuration.AccessToken, + payload); + + await SendAsync(request, cancellationToken, AttachmentRequestTimeout).ConfigureAwait(false); + } + + public async Task UploadTestRunAttachmentAsync(AzureDevOpsPublishConfiguration configuration, int runId, AzureDevOpsTestResultAttachment attachment, CancellationToken cancellationToken) + { + AttachmentRequest? payload = TryBuildAttachmentRequest(attachment); + if (payload is null) + { + return; + } + + using HttpRequestMessage request = CreateRequest( + HttpMethod.Post, + BuildRunAttachmentsUri(configuration.CollectionUri, configuration.Project, runId), + configuration.AccessToken, + payload); + + await SendAsync(request, cancellationToken, AttachmentRequestTimeout).ConfigureAwait(false); } public Task UpdateTestRunStateAsync(AzureDevOpsPublishConfiguration configuration, int runId, string state, CancellationToken cancellationToken) @@ -89,7 +161,7 @@ private static HttpClient CreateHttpClient() return new HttpClient(handler, disposeHandler: false) { - Timeout = HttpClientTimeout, + Timeout = Timeout.InfiniteTimeSpan, }; } @@ -102,6 +174,66 @@ private static Uri BuildRunUri(string collectionUri, string project, int runId) private static Uri BuildResultsUri(string collectionUri, string project, int runId) => new(new Uri(collectionUri, UriKind.Absolute), $"{Uri.EscapeDataString(project)}/_apis/test/runs/{runId}/results?api-version={ApiVersion}"); + private static Uri BuildResultAttachmentsUri(string collectionUri, string project, int runId, int testCaseResultId) + => new(new Uri(collectionUri, UriKind.Absolute), $"{Uri.EscapeDataString(project)}/_apis/test/runs/{runId}/results/{testCaseResultId}/attachments?api-version={ApiVersion}"); + + private static Uri BuildRunAttachmentsUri(string collectionUri, string project, int runId) + => new(new Uri(collectionUri, UriKind.Absolute), $"{Uri.EscapeDataString(project)}/_apis/test/runs/{runId}/attachments?api-version={ApiVersion}"); + + private static AttachmentRequest? TryBuildAttachmentRequest(AzureDevOpsTestResultAttachment attachment) + { + byte[]? bytes; + if (attachment.FilePath is { Length: > 0 } filePath) + { + FileInfo fileInfo; + try + { + fileInfo = new FileInfo(filePath); + if (!fileInfo.Exists) + { + return null; + } + + if (fileInfo.Length > AzureDevOpsLivePublishingConstants.MaxAttachmentSizeBytes) + { + return null; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or PathTooLongException) + { + return null; + } + + try + { + bytes = File.ReadAllBytes(filePath); + if (bytes.Length > AzureDevOpsLivePublishingConstants.MaxAttachmentSizeBytes) + { + return null; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or PathTooLongException) + { + return null; + } + } + else if (attachment.InlineContent is { } inline) + { + // Inline content (stdout/stderr) is already truncated by the publisher to MaxInlineAttachmentBytes. + bytes = Encoding.UTF8.GetBytes(inline); + } + else + { + return null; + } + + return new AttachmentRequest( + Convert.ToBase64String(bytes), + attachment.FileName, + attachment.Comment, + attachment.AttachmentType); + } + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "Payload types are internal, fixed, and controlled by this extension.")] [UnconditionalSuppressMessage("Aot", "IL3050", Justification = "Payload types are internal, fixed, and controlled by this extension.")] private static HttpRequestMessage CreateRequest(HttpMethod method, Uri uri, string accessToken, TPayload payload) @@ -126,7 +258,7 @@ private async Task SendAsync(HttpRequestMessage request, C using var requestTimeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); requestTimeoutSource.CancelAfter(RequestTimeout); - using HttpResponseMessage response = await SendCoreAsync(request, requestTimeoutSource.Token, cancellationToken).ConfigureAwait(false); + using HttpResponseMessage response = await SendCoreAsync(request, requestTimeoutSource.Token, cancellationToken, AttemptTimeout).ConfigureAwait(false); string payload = await ReadAsStringAsync(response.Content, requestTimeoutSource.Token).ConfigureAwait(false); TResponse? deserialized = JsonSerializer.Deserialize(payload, JsonSerializerOptions); return deserialized ?? throw new InvalidOperationException(AzureDevOpsResources.AzureDevOpsLivePublishingInvalidResponse); @@ -136,10 +268,17 @@ private async Task SendAsync(HttpRequestMessage request, CancellationToken cance { using var requestTimeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); requestTimeoutSource.CancelAfter(RequestTimeout); - using HttpResponseMessage ignoredResponse = await SendCoreAsync(request, requestTimeoutSource.Token, cancellationToken).ConfigureAwait(false); + using HttpResponseMessage ignoredResponse = await SendCoreAsync(request, requestTimeoutSource.Token, cancellationToken, AttemptTimeout).ConfigureAwait(false); } - private async Task SendCoreAsync(HttpRequestMessage request, CancellationToken requestCancellationToken, CancellationToken userCancellationToken) + private async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken, TimeSpan requestTimeout) + { + using var requestTimeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + requestTimeoutSource.CancelAfter(requestTimeout); + using HttpResponseMessage ignoredResponse = await SendCoreAsync(request, requestTimeoutSource.Token, cancellationToken, requestTimeout).ConfigureAwait(false); + } + + private async Task SendCoreAsync(HttpRequestMessage request, CancellationToken requestCancellationToken, CancellationToken userCancellationToken, TimeSpan attemptTimeout) { Exception? lastException = null; @@ -149,7 +288,7 @@ private async Task SendCoreAsync(HttpRequestMessage request { using HttpRequestMessage currentRequest = await CloneAsync(request, requestCancellationToken).ConfigureAwait(false); using var attemptTimeoutSource = CancellationTokenSource.CreateLinkedTokenSource(requestCancellationToken); - attemptTimeoutSource.CancelAfter(AttemptTimeout); + attemptTimeoutSource.CancelAfter(attemptTimeout); try { @@ -283,4 +422,18 @@ private sealed record BuildReference([property: JsonPropertyName("id")] int Id); private sealed record CreateTestRunResponse([property: JsonPropertyName("id")] int Id); private sealed record UpdateTestRunStateRequest([property: JsonPropertyName("state")] string State); + + private sealed record PublishTestResultsResponse( + [property: JsonPropertyName("count")] int Count, + [property: JsonPropertyName("value")] PublishedTestResult[]? Value); + + private sealed record PublishedTestResult( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("automatedTestName")] string? AutomatedTestName); + + private sealed record AttachmentRequest( + [property: JsonPropertyName("stream")] string Stream, + [property: JsonPropertyName("fileName")] string FileName, + [property: JsonPropertyName("comment")] string? Comment, + [property: JsonPropertyName("attachmentType")] string AttachmentType); } diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs index dc0e170595..25b4aed643 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Security; + using Microsoft.Testing.Extensions.AzureDevOpsReport.Resources; using Microsoft.Testing.Extensions.Reporting; using Microsoft.Testing.Platform; @@ -30,8 +32,9 @@ internal sealed class AzureDevOpsTestResultsPublisher : IDataConsumer, ITestSess private readonly ILogger _logger; private readonly AzureDevOpsTestResultsPublisherOptions _options; // Mutate only while holding _flushSemaphore. - private readonly Stack _retryResults = new(); - private readonly ConcurrentQueue _pendingResults = new(); + private readonly Stack _retryResults = new(); + private readonly ConcurrentQueue _pendingResults = new(); + private readonly ConcurrentQueue _pendingRunAttachments = new(); private readonly SemaphoreSlim _flushSemaphore = new(1, 1); private AzureDevOpsPublishConfiguration? _publishConfiguration; @@ -85,7 +88,7 @@ internal AzureDevOpsTestResultsPublisher( _lastFlushTime = clock.UtcNow; } - public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; + public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage), typeof(SessionFileArtifact)]; public string Uid => nameof(AzureDevOpsTestResultsPublisher); @@ -165,22 +168,31 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella { cancellationToken.ThrowIfCancellationRequested(); - if (_publishConfiguration is null || _runIdCoordinator is null || _coordinatedRun is null || CurrentRunId is null || value is not TestNodeUpdateMessage testNodeUpdateMessage) + if (_publishConfiguration is null || _runIdCoordinator is null || _coordinatedRun is null || CurrentRunId is null) { return; } try { - AzureDevOpsTestCaseResult? testCaseResult = CreateTestCaseResult(testNodeUpdateMessage.TestNode, _publishConfiguration.AutomatedTestStorage); - if (testCaseResult is null) + switch (value) { - return; - } + case TestNodeUpdateMessage testNodeUpdateMessage: + AzureDevOpsTestCaseResultWithAttachments? testCaseResult = CreateTestCaseResult(testNodeUpdateMessage.TestNode, _publishConfiguration.AutomatedTestStorage); + if (testCaseResult is null) + { + return; + } - await _runIdCoordinator.RenewLeaseAsync(_coordinatedRun, cancellationToken).ConfigureAwait(false); - _pendingResults.Enqueue(testCaseResult); - await FlushPendingResultsAsync(force: false, cancellationToken).ConfigureAwait(false); + await _runIdCoordinator.RenewLeaseAsync(_coordinatedRun, cancellationToken).ConfigureAwait(false); + _pendingResults.Enqueue(testCaseResult); + await FlushPendingResultsAsync(force: false, cancellationToken).ConfigureAwait(false); + break; + + case SessionFileArtifact sessionFileArtifact when TryCreateRunAttachment(sessionFileArtifact) is { } runAttachment: + _pendingRunAttachments.Enqueue(runAttachment); + break; + } } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -231,6 +243,19 @@ public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionCon _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingPublishResultsFailed} {ex.Message}"); } + try + { + await UploadPendingRunAttachmentsAsync(testSessionContext.CancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Best-effort: don't fail finalization if the session was canceled mid-upload. + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingRunAttachmentFailed} {ex.Message}"); + } + // Azure DevOps test runs use "Aborted" specifically for cancellation or session-level // infrastructure failures. Individual failing tests should still mark the run as // "Completed" — only treat process exit codes other than Success/AtLeastOneTestFailed as @@ -260,7 +285,7 @@ await _runIdCoordinator.FinalizeRunAsync( } } - internal static AzureDevOpsTestCaseResult? CreateTestCaseResult(TestNode testNode, string automatedTestStorage) + internal static AzureDevOpsTestCaseResultWithAttachments? CreateTestCaseResult(TestNode testNode, string automatedTestStorage) { TestNodeStateProperty? state = testNode.Properties.SingleOrDefault(); if (state is null or DiscoveredTestNodeStateProperty or InProgressTestNodeStateProperty) @@ -271,7 +296,7 @@ await _runIdCoordinator.FinalizeRunAsync( TimingProperty? timing = testNode.Properties.SingleOrDefault(); string automatedTestName = testNode.Uid.Value; - return state switch + AzureDevOpsTestCaseResult? result = state switch { PassedTestNodeStateProperty passed => CreateResult(testNode.DisplayName, automatedTestName, automatedTestStorage, AzureDevOpsLivePublishingConstants.PassedTestOutcome, passed.Explanation, null, timing), FailedTestNodeStateProperty failed => CreateResult(testNode.DisplayName, automatedTestName, automatedTestStorage, AzureDevOpsLivePublishingConstants.FailedTestOutcome, failed.Exception?.Message ?? failed.Explanation, failed.Exception?.StackTrace, timing), @@ -283,6 +308,216 @@ await _runIdCoordinator.FinalizeRunAsync( #pragma warning restore CS0618, MTP0001 // Type or member is obsolete _ => null, }; + + if (result is null) + { + return null; + } + + // Only attach artifacts for non-passing outcomes to avoid uploading large dumps/logs for every + // passing test. Users who want pass-time artifacts can use the pipeline-level + // `--report-azdo-upload-artifacts files` option instead. + bool isFailure = state is FailedTestNodeStateProperty or ErrorTestNodeStateProperty or TimeoutTestNodeStateProperty +#pragma warning disable CS0618, MTP0001 // Type or member is obsolete + or CancelledTestNodeStateProperty +#pragma warning restore CS0618, MTP0001 // Type or member is obsolete + ; + + IReadOnlyList attachments = isFailure + ? BuildAttachmentsFromTestNode(testNode) + : []; + + return new AzureDevOpsTestCaseResultWithAttachments(result, attachments); + } + + private static IReadOnlyList BuildAttachmentsFromTestNode(TestNode testNode) + { + List? attachments = null; + + foreach (FileArtifactProperty fileArtifact in testNode.Properties.OfType()) + { + string? fullPath; + try + { + fullPath = fileArtifact.FileInfo.FullName; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or PathTooLongException) + { + continue; + } + + attachments ??= []; + attachments.Add(AzureDevOpsTestResultAttachment.FromFile( + fullPath, + AzureDevOpsAttachmentTypes.GeneralAttachment, + comment: fileArtifact.Description ?? fileArtifact.DisplayName)); + } + + StandardOutputProperty? stdout = testNode.Properties.OfType().FirstOrDefault(); + if (stdout is not null && !RoslynString.IsNullOrEmpty(stdout.StandardOutput)) + { + attachments ??= []; + attachments.Add(AzureDevOpsTestResultAttachment.FromString( + TruncateInline(stdout.StandardOutput), + "stdout.log", + AzureDevOpsAttachmentTypes.ConsoleLog)); + } + + StandardErrorProperty? stderr = testNode.Properties.OfType().FirstOrDefault(); + if (stderr is not null && !RoslynString.IsNullOrEmpty(stderr.StandardError)) + { + attachments ??= []; + attachments.Add(AzureDevOpsTestResultAttachment.FromString( + TruncateInline(stderr.StandardError), + "stderr.log", + AzureDevOpsAttachmentTypes.GeneralAttachment)); + } + + return (IReadOnlyList?)attachments ?? []; + } + + private static string TruncateInline(string content) + { + int maxBytes = AzureDevOpsLivePublishingConstants.MaxInlineAttachmentBytes; + if (Encoding.UTF8.GetByteCount(content) <= maxBytes) + { + return content; + } + + // UTF-8 can use up to 4 bytes per char; reserve room for the truncation marker. + const string Marker = "\n...[truncated]"; + int markerBytes = Encoding.UTF8.GetByteCount(Marker); + int budget = maxBytes - markerBytes; + if (budget <= 0) + { + return Marker; + } + + int byteCount = 0; + int charCount = 0; + while (charCount < content.Length) + { + int charBytes = GetUtf8ByteCount(content, charCount, out int charsConsumed); + if (byteCount + charBytes > budget) + { + break; + } + + byteCount += charBytes; + charCount += charsConsumed; + } + + if (charCount > 0 && char.IsHighSurrogate(content[charCount - 1])) + { + charCount--; + } + + return content[..charCount] + Marker; + } + + private static int GetUtf8ByteCount(string content, int index, out int charsConsumed) + { + char ch = content[index]; + charsConsumed = 1; + if (ch < 0x80) + { + return 1; + } + + if (ch < 0x800) + { + return 2; + } + + if (char.IsHighSurrogate(ch) && index + 1 < content.Length && char.IsLowSurrogate(content[index + 1])) + { + charsConsumed = 2; + return 4; + } + + return 3; + } + + private static AzureDevOpsTestResultAttachment? TryCreateRunAttachment(SessionFileArtifact sessionFileArtifact) + { + FileInfo fileInfo = sessionFileArtifact.FileInfo; + string name; + string fullName; + try + { + name = fileInfo.Name; + fullName = fileInfo.FullName; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or PathTooLongException) + { + return null; + } + + if (RoslynString.IsNullOrEmpty(name)) + { + return null; + } + + bool isCoverage = name.EndsWith(".coverage", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".cobertura.xml", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".opencover.xml", StringComparison.OrdinalIgnoreCase); + + return !isCoverage + ? null + : AzureDevOpsTestResultAttachment.FromFile( + fullName, + AzureDevOpsAttachmentTypes.CodeCoverage, + comment: sessionFileArtifact.Description ?? sessionFileArtifact.DisplayName); + } + + private async Task UploadPendingRunAttachmentsAsync(CancellationToken cancellationToken) + { + if (_publishConfiguration is null || CurrentRunId is null) + { + return; + } + + while (_pendingRunAttachments.TryDequeue(out AzureDevOpsTestResultAttachment? attachment)) + { + try + { + await _client.UploadTestRunAttachmentAsync(_publishConfiguration, CurrentRunId.Value, attachment, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Cancellation aborts the drain; the attachment is lost. The only caller is session + // finishing, where cancellation means the test host is tearing down anyway. + throw; + } + catch (Exception ex) + { + _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingRunAttachmentFailed} {ex.Message}"); + } + } + } + + private async Task UploadResultAttachmentsAsync(int testCaseResultId, IReadOnlyList attachments, CancellationToken cancellationToken) + { + if (_publishConfiguration is null || CurrentRunId is null || attachments.Count == 0) + { + return; + } + + foreach (AzureDevOpsTestResultAttachment attachment in attachments) + { + try + { + await _client.UploadTestResultAttachmentAsync(_publishConfiguration, CurrentRunId.Value, testCaseResultId, attachment, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingResultAttachmentFailed} {ex.Message}"); + } + } } private async Task BackgroundFlushLoopAsync(CancellationToken cancellationToken) @@ -330,13 +565,13 @@ private async Task FlushPendingResultsAsync(bool force, CancellationToken cancel return; } - List batch = []; + List batch = []; while (batch.Count < _options.BatchSize && _retryResults.Count > 0) { batch.Add(_retryResults.Pop()); } - while (batch.Count < _options.BatchSize && _pendingResults.TryDequeue(out AzureDevOpsTestCaseResult? result)) + while (batch.Count < _options.BatchSize && _pendingResults.TryDequeue(out AzureDevOpsTestCaseResultWithAttachments? result)) { batch.Add(result); } @@ -346,6 +581,7 @@ private async Task FlushPendingResultsAsync(bool force, CancellationToken cancel return; } + IReadOnlyList? resultIds; try { if (_coordinatedRun is not null && _runIdCoordinator is not null) @@ -353,12 +589,19 @@ private async Task FlushPendingResultsAsync(bool force, CancellationToken cancel await _runIdCoordinator.RenewLeaseAsync(_coordinatedRun, cancellationToken).ConfigureAwait(false); } - await _client.PublishTestResultsAsync(_publishConfiguration, CurrentRunId.Value, batch, cancellationToken).ConfigureAwait(false); + var resultsOnly = new AzureDevOpsTestCaseResult[batch.Count]; + for (int i = 0; i < batch.Count; i++) + { + resultsOnly[i] = batch[i].Result; + } + + resultIds = await _client.PublishTestResultsAsync(_publishConfiguration, CurrentRunId.Value, resultsOnly, cancellationToken).ConfigureAwait(false); _lastFlushTime = _clock.UtcNow; } catch (Exception ex) when (ex is not OperationCanceledException) { - // Push results in reverse so Pop retries them in the original batch order. + // Transport/HTTP failure — AzDO may not have accepted the batch, so it's safe to + // requeue and retry. Push results in reverse so Pop retries them in batch order. for (int i = batch.Count - 1; i >= 0; i--) { _retryResults.Push(batch[i]); @@ -369,6 +612,45 @@ private async Task FlushPendingResultsAsync(bool force, CancellationToken cancel _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingPublishResultsFailed} {ex.Message}"); return; } + + // POST succeeded. If we couldn't parse the response we cannot upload result-level + // attachments for this batch, but we MUST NOT republish (that would create duplicate + // result rows in AzDO). Continue with the next batch. + if (resultIds is null) + { + if (BatchHasAttachments(batch)) + { + _logger.LogWarning(AzureDevOpsResources.AzureDevOpsLivePublishingResultIdParseFailedWarning); + } + + continue; + } + + for (int i = 0; i < batch.Count; i++) + { + if (batch[i].Attachments.Count == 0) + { + continue; + } + + try + { + if (_coordinatedRun is not null && _runIdCoordinator is not null) + { + await _runIdCoordinator.RenewLeaseAsync(_coordinatedRun, cancellationToken).ConfigureAwait(false); + } + + await UploadResultAttachmentsAsync(resultIds[i], batch[i].Attachments, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingResultAttachmentFailed} {ex.Message}"); + } + } } } finally @@ -377,6 +659,19 @@ private async Task FlushPendingResultsAsync(bool force, CancellationToken cancel } } + private static bool BatchHasAttachments(IReadOnlyList batch) + { + for (int i = 0; i < batch.Count; i++) + { + if (batch[i].Attachments.Count > 0) + { + return true; + } + } + + return false; + } + private bool TryCreatePublishConfiguration(out AzureDevOpsPublishConfiguration? publishConfiguration, out string? warning) { publishConfiguration = null; diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/IAzureDevOpsTestResultsClient.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/IAzureDevOpsTestResultsClient.cs index e15edd0077..92798884b4 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/IAzureDevOpsTestResultsClient.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/IAzureDevOpsTestResultsClient.cs @@ -7,7 +7,24 @@ internal interface IAzureDevOpsTestResultsClient { Task CreateTestRunAsync(AzureDevOpsPublishConfiguration configuration, CancellationToken cancellationToken); - Task PublishTestResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken); + /// + /// Publishes a batch of test case results to Azure DevOps and returns the IDs assigned to each result + /// in the same order as the input. Returns if the request succeeded (HTTP 2xx) + /// but the response could not be parsed or did not match the submitted batch; in that case the caller + /// MUST NOT retry the publish (the results were already accepted) but cannot upload result-level + /// attachments. Throws on transport/HTTP failures, which the caller may retry. + /// + Task?> PublishTestResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken); + + /// + /// Uploads an attachment to a specific test case result within a test run. + /// + Task UploadTestResultAttachmentAsync(AzureDevOpsPublishConfiguration configuration, int runId, int testCaseResultId, AzureDevOpsTestResultAttachment attachment, CancellationToken cancellationToken); + + /// + /// Uploads an attachment to the test run itself (e.g. code coverage files). + /// + Task UploadTestRunAttachmentAsync(AzureDevOpsPublishConfiguration configuration, int runId, AzureDevOpsTestResultAttachment attachment, CancellationToken cancellationToken); Task UpdateTestRunStateAsync(AzureDevOpsPublishConfiguration configuration, int runId, string state, CancellationToken cancellationToken); } diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/PACKAGE.md b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/PACKAGE.md index a7f24e3859..200151d501 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/PACKAGE.md +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/PACKAGE.md @@ -17,6 +17,8 @@ This package extends Microsoft.Testing.Platform with: - **Azure DevOps reporting**: emits individual CI errors/warnings for each test failure via the Azure DevOps logging commands - **Configurable severity**: supports `--report-azdo-severity` (`error` or `warning`) - **CI auto-detection**: detects Azure DevOps environments through the `TF_BUILD` variable +- **Live publishing**: streams test results to the Azure DevOps Tests tab while the run is still in progress (`--publish-azdo-test-results`) +- **Automatic attachments**: for failed tests, attaches stdout/stderr and any `FileArtifactProperty` data (dumps, screenshots) to the matching result in the Tests tab; uploads `*.coverage`, `*.cobertura.xml`, and `*.opencover.xml` artifacts as run-level attachments Enable Azure DevOps reporting with the `--report-azdo` command line option. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx index 4591cdc65c..2e0b42ef7c 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx @@ -166,6 +166,15 @@ Azure DevOps REST API request failed after all retries. + + Azure DevOps live publishing failed to upload a test result attachment. + + + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + + + Azure DevOps live publishing failed to upload a test run attachment. + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. {Locked="azdo-runid.<buildId>.json"} diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.cs.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.cs.xlf index 8b9a0f3564..a07f190f01 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.cs.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.cs.xlf @@ -77,6 +77,21 @@ Žádost na rozhraní REST API Azure DevOps se po všech opakovaných pokusech nezdařila. + + Azure DevOps live publishing failed to upload a test result attachment. + Azure DevOps live publishing failed to upload a test result attachment. + + + + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + + + + Azure DevOps live publishing failed to upload a test run attachment. + Azure DevOps live publishing failed to upload a test run attachment. + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. Živé publikování Azure DevOps nalezlo soubor azdo-runid.<buildId>.json pro jiný projekt Azure DevOps. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.de.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.de.xlf index 7ae7ba2a12..5efc5cd22e 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.de.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.de.xlf @@ -77,6 +77,21 @@ Die Azure DevOps REST API-Anforderung ist nach allen Wiederholungen fehlgeschlagen. + + Azure DevOps live publishing failed to upload a test result attachment. + Azure DevOps live publishing failed to upload a test result attachment. + + + + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + + + + Azure DevOps live publishing failed to upload a test run attachment. + Azure DevOps live publishing failed to upload a test run attachment. + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. Die Azure DevOps Liveveröffentlichung hat eine azdo-runid.<buildId>.json-Datei für ein anderes Azure DevOps-Projekt gefunden. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.es.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.es.xlf index cdac8a93fc..c1309e3335 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.es.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.es.xlf @@ -77,6 +77,21 @@ La solicitud a la API de REST de Azure DevOps falló después de todos los reintentos. + + Azure DevOps live publishing failed to upload a test result attachment. + Azure DevOps live publishing failed to upload a test result attachment. + + + + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + + + + Azure DevOps live publishing failed to upload a test run attachment. + Azure DevOps live publishing failed to upload a test run attachment. + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. La publicación en directo de Azure DevOps encontró un archivo azdo-runid.<buildId>.json para otro proyecto de Azure DevOps. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.fr.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.fr.xlf index bedded65fa..aea15e7d7b 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.fr.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.fr.xlf @@ -77,6 +77,21 @@ Nous n’avons pas pu effectué la requête d’API REST Azure DevOps après toutes les nouvelles tentatives. + + Azure DevOps live publishing failed to upload a test result attachment. + Azure DevOps live publishing failed to upload a test result attachment. + + + + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + + + + Azure DevOps live publishing failed to upload a test run attachment. + Azure DevOps live publishing failed to upload a test run attachment. + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. La publication en direct d’Azure DevOps a trouvé un fichier azdo-runid.<buildId>.json pour un autre projet Azure DevOps. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.it.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.it.xlf index 580a3c3b57..5186f4c7f3 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.it.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.it.xlf @@ -77,6 +77,21 @@ La richiesta dell'API REST di Azure DevOps non è riuscita dopo tutti i tentativi. + + Azure DevOps live publishing failed to upload a test result attachment. + Azure DevOps live publishing failed to upload a test result attachment. + + + + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + + + + Azure DevOps live publishing failed to upload a test run attachment. + Azure DevOps live publishing failed to upload a test run attachment. + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. La pubblicazione live di Azure DevOps ha trovato un file azdo-runid.<buildId>.json per un progetto Azure DevOps diverso. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ja.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ja.xlf index a3e9f89478..bc917cf348 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ja.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ja.xlf @@ -77,6 +77,21 @@ Azure DevOps REST API 要求は、すべての再試行後に失敗しました。 + + Azure DevOps live publishing failed to upload a test result attachment. + Azure DevOps live publishing failed to upload a test result attachment. + + + + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + + + + Azure DevOps live publishing failed to upload a test run attachment. + Azure DevOps live publishing failed to upload a test run attachment. + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. Azure DevOps ライブ公開で、別の Azure DevOps プロジェクトの azdo-runid.<buildId>.json ファイルが見つかりました。 diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ko.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ko.xlf index 9c12544342..e4764e256f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ko.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ko.xlf @@ -77,6 +77,21 @@ 모든 재시도 후 Azure DevOps REST API 요청이 실패했습니다. + + Azure DevOps live publishing failed to upload a test result attachment. + Azure DevOps live publishing failed to upload a test result attachment. + + + + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + + + + Azure DevOps live publishing failed to upload a test run attachment. + Azure DevOps live publishing failed to upload a test run attachment. + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. Azure DevOps 라이브 게시에서 다른 Azure DevOps 프로젝트에 대한 azdo-runid.<buildId>.json 파일을 찾았습니다. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pl.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pl.xlf index c8767f4934..236ae9c8a2 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pl.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pl.xlf @@ -77,6 +77,21 @@ Żądanie interfejsu API REST usługi Azure DevOps nie powiodło się po wszystkich ponownych próbach. + + Azure DevOps live publishing failed to upload a test result attachment. + Azure DevOps live publishing failed to upload a test result attachment. + + + + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + + + + Azure DevOps live publishing failed to upload a test run attachment. + Azure DevOps live publishing failed to upload a test run attachment. + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. Publikowanie na żywo w usłudze Azure DevOps znalazło plik azdo-runid.<buildId>.json dla innego projektu usługi Azure DevOps. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pt-BR.xlf index e2369ceae3..22e7e521cc 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pt-BR.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pt-BR.xlf @@ -77,6 +77,21 @@ A solicitação da API REST do Azure DevOps falhou após todas as tentativas. + + Azure DevOps live publishing failed to upload a test result attachment. + Azure DevOps live publishing failed to upload a test result attachment. + + + + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + + + + Azure DevOps live publishing failed to upload a test run attachment. + Azure DevOps live publishing failed to upload a test run attachment. + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. A publicação ao vivo do Azure DevOps encontrou um arquivo azdo-runid.<buildId>.json para um projeto do Azure DevOps diferente. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ru.xlf index 72ffd7618a..c8d9abc8a2 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ru.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ru.xlf @@ -77,6 +77,21 @@ Azure DevOps: не удалось выполнить запрос REST API после повторных попыток. + + Azure DevOps live publishing failed to upload a test result attachment. + Azure DevOps live publishing failed to upload a test result attachment. + + + + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + + + + Azure DevOps live publishing failed to upload a test run attachment. + Azure DevOps live publishing failed to upload a test run attachment. + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. Azure DevOps: при публикации в реальном времени обнаружен файл azdo-runid.<buildId>.json для другого проекта Azure DevOps. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.tr.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.tr.xlf index 737ffdb75e..3103836c28 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.tr.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.tr.xlf @@ -77,6 +77,21 @@ Azure DevOps REST API isteği tüm yeniden denemelerden sonra başarısız oldu. + + Azure DevOps live publishing failed to upload a test result attachment. + Azure DevOps live publishing failed to upload a test result attachment. + + + + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + + + + Azure DevOps live publishing failed to upload a test run attachment. + Azure DevOps live publishing failed to upload a test run attachment. + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. Azure DevOps canlı yayımlama, farklı bir Azure DevOps projesi için azdo-runid.<buildId>.json dosyası buldu. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hans.xlf index 9dbab55293..177d4d865d 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hans.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hans.xlf @@ -77,6 +77,21 @@ Azure DevOps REST API 请求在所有重试后均失败。 + + Azure DevOps live publishing failed to upload a test result attachment. + Azure DevOps live publishing failed to upload a test result attachment. + + + + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + + + + Azure DevOps live publishing failed to upload a test run attachment. + Azure DevOps live publishing failed to upload a test run attachment. + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. Azure DevOps 实时发布发现了属于其他 Azure DevOps 项目的 azdo-runid.<buildId>.json 文件。 diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hant.xlf index 3a996fa122..e9b0fe8ccd 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hant.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hant.xlf @@ -77,6 +77,21 @@ 在所有重試後,Azure DevOps REST API 要求失敗。 + + Azure DevOps live publishing failed to upload a test result attachment. + Azure DevOps live publishing failed to upload a test result attachment. + + + + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + Azure DevOps live publishing accepted a batch of test results but could not parse the assigned result IDs; result-level attachments for this batch will be skipped. + + + + Azure DevOps live publishing failed to upload a test run attachment. + Azure DevOps live publishing failed to upload a test run attachment. + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. Azure DevOps 即時發佈找到不同 Azure DevOps 專案的 azdo-runid.<buildId>.json 檔案。 diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs index c6fbe3620f..b5b67d3060 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Net; using System.Net.Http; +using System.Text; using System.Text.Json; using Microsoft.Testing.Extensions.AzureDevOpsReport; @@ -25,6 +26,8 @@ namespace Microsoft.Testing.Extensions.UnitTests; [TestClass] public sealed class AzureDevOpsLivePublishingTests { + private const string TruncationMarker = "\n...[truncated]"; + private readonly List _directoriesToDelete = []; [TestCleanup] @@ -73,7 +76,13 @@ public async Task ConsumeAsync_FlushesWhenBatchSizeIsReached() client.PublishTestResultsAsyncFunc = (_, _, results, _) => { publishedBatches.Add(results.ToArray()); - return Task.CompletedTask; + int[] ids = new int[results.Count]; + for (int i = 0; i < results.Count; i++) + { + ids[i] = i + 1; + } + + return Task.FromResult?>(ids); }; await StartPublisherAsync(publisher); @@ -95,7 +104,13 @@ public async Task ConsumeAsync_FlushesWhenFlushIntervalElapsed() client.PublishTestResultsAsyncFunc = (_, _, results, _) => { publishedBatches.Add(results.ToArray()); - return Task.CompletedTask; + int[] ids = new int[results.Count]; + for (int i = 0; i < results.Count; i++) + { + ids[i] = i + 1; + } + + return Task.FromResult?>(ids); }; await StartPublisherAsync(publisher); @@ -119,7 +134,13 @@ public async Task OnTestSessionFinishingAsync_FlushesRemainingResults() client.PublishTestResultsAsyncFunc = (_, _, results, _) => { publishedBatches.Add(results.ToArray()); - return Task.CompletedTask; + int[] ids = new int[results.Count]; + for (int i = 0; i < results.Count; i++) + { + ids[i] = i + 1; + } + + return Task.FromResult?>(ids); }; await StartPublisherAsync(publisher); @@ -202,12 +223,12 @@ public void CreateTestCaseResult_MapsMtpStatesToAzdoResults() { DateTimeOffset startTime = new(2025, 1, 1, 12, 0, 0, TimeSpan.Zero); TimingProperty timing = new(new TimingInfo(startTime, startTime.AddSeconds(2), TimeSpan.FromSeconds(2))); - AzureDevOpsTestCaseResult? passed = AzureDevOpsTestResultsPublisher.CreateTestCaseResult(CreateNode("passed", new PassedTestNodeStateProperty(), startTime, timing), "tests.dll"); - AzureDevOpsTestCaseResult? failed = AzureDevOpsTestResultsPublisher.CreateTestCaseResult(CreateNode("failed", new FailedTestNodeStateProperty(new InvalidOperationException("boom")), startTime, timing), "tests.dll"); - AzureDevOpsTestCaseResult? skipped = AzureDevOpsTestResultsPublisher.CreateTestCaseResult(CreateNode("skipped", new SkippedTestNodeStateProperty("skip"), startTime, timing), "tests.dll"); - AzureDevOpsTestCaseResult? timeout = AzureDevOpsTestResultsPublisher.CreateTestCaseResult(CreateNode("timeout", new TimeoutTestNodeStateProperty("too slow"), startTime, timing), "tests.dll"); + AzureDevOpsTestCaseResult? passed = AzureDevOpsTestResultsPublisher.CreateTestCaseResult(CreateNode("passed", new PassedTestNodeStateProperty(), startTime, timing), "tests.dll")?.Result; + AzureDevOpsTestCaseResult? failed = AzureDevOpsTestResultsPublisher.CreateTestCaseResult(CreateNode("failed", new FailedTestNodeStateProperty(new InvalidOperationException("boom")), startTime, timing), "tests.dll")?.Result; + AzureDevOpsTestCaseResult? skipped = AzureDevOpsTestResultsPublisher.CreateTestCaseResult(CreateNode("skipped", new SkippedTestNodeStateProperty("skip"), startTime, timing), "tests.dll")?.Result; + AzureDevOpsTestCaseResult? timeout = AzureDevOpsTestResultsPublisher.CreateTestCaseResult(CreateNode("timeout", new TimeoutTestNodeStateProperty("too slow"), startTime, timing), "tests.dll")?.Result; #pragma warning disable CS0618, MTP0001 // Type or member is obsolete - AzureDevOpsTestCaseResult? cancelled = AzureDevOpsTestResultsPublisher.CreateTestCaseResult(CreateNode("cancelled", new CancelledTestNodeStateProperty("stopped"), startTime, timing), "tests.dll"); + AzureDevOpsTestCaseResult? cancelled = AzureDevOpsTestResultsPublisher.CreateTestCaseResult(CreateNode("cancelled", new CancelledTestNodeStateProperty("stopped"), startTime, timing), "tests.dll")?.Result; #pragma warning restore CS0618, MTP0001 // Type or member is obsolete Assert.AreEqual(AzureDevOpsLivePublishingConstants.PassedTestOutcome, passed?.Outcome); @@ -316,7 +337,7 @@ public async Task ConsumeAsync_PublishFailureLogsWarningAndDoesNotThrow() using TestDirectory directory = CreateTestDirectory(); AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(1, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out FakeClock clock, out CollectingLogger logger); client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(103); - client.PublishTestResultsAsyncFunc = (_, _, _, _) => Task.FromException(new JsonException("publish failed")); + client.PublishTestResultsAsyncFunc = (_, _, _, _) => Task.FromException?>(new JsonException("publish failed")); await StartPublisherAsync(publisher); TestNodeUpdateMessage message = CreateMessage(CreateNode("test-1", new PassedTestNodeStateProperty(), clock.UtcNow)); @@ -339,9 +360,18 @@ public async Task ConsumeAsync_PublishFailureRetriesBatchOnFinalFlush() { publishAttempts++; publishedBatches.Add(results.ToArray()); - return publishAttempts == 1 - ? Task.FromException(new JsonException("publish failed")) - : Task.CompletedTask; + if (publishAttempts == 1) + { + return Task.FromException?>(new JsonException("publish failed")); + } + + int[] ids = new int[results.Count]; + for (int i = 0; i < results.Count; i++) + { + ids[i] = i + 1; + } + + return Task.FromResult?>(ids); }; await StartPublisherAsync(publisher); @@ -355,6 +385,278 @@ public async Task ConsumeAsync_PublishFailureRetriesBatchOnFinalFlush() Assert.Contains(AzureDevOpsResources.AzureDevOpsLivePublishingPublishResultsFailed, string.Join(Environment.NewLine, logger.Logs)); } + [TestMethod] + public async Task ConsumeAsync_UploadsAttachmentsForFailedTests() + { + using TestDirectory directory = CreateTestDirectory(); + string dumpPath = Path.Combine(directory.Path, "dump.txt"); + File.WriteAllText(dumpPath, "dump content"); + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(1, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out FakeClock clock, out _); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(200); + client.PublishTestResultsAsyncFunc = (_, _, results, _) => + { + int[] ids = new int[results.Count]; + for (int i = 0; i < results.Count; i++) + { + ids[i] = 1_000 + i; + } + + return Task.FromResult?>(ids); + }; + + TestNode node = CreateNode("failed-test", new FailedTestNodeStateProperty(new InvalidOperationException("boom")), clock.UtcNow); + node.Properties.Add(new FileArtifactProperty(new FileInfo(dumpPath), "dump", "crash dump")); + node.Properties.Add(new StandardOutputProperty("stdout content")); + node.Properties.Add(new StandardErrorProperty("stderr content")); + + await StartPublisherAsync(publisher); + await publisher.ConsumeAsync(Mock.Of(), CreateMessage(node), CancellationToken.None); + + Assert.HasCount(3, client.UploadTestResultAttachmentCalls); + Assert.AreEqual(1_000, client.UploadTestResultAttachmentCalls[0].TestCaseResultId); + Assert.AreEqual(1_000, client.UploadTestResultAttachmentCalls[1].TestCaseResultId); + Assert.AreEqual(1_000, client.UploadTestResultAttachmentCalls[2].TestCaseResultId); + Assert.AreEqual("dump.txt", client.UploadTestResultAttachmentCalls[0].Attachment.FileName); + Assert.AreEqual(AzureDevOpsAttachmentTypes.GeneralAttachment, client.UploadTestResultAttachmentCalls[0].Attachment.AttachmentType); + Assert.AreEqual("stdout.log", client.UploadTestResultAttachmentCalls[1].Attachment.FileName); + Assert.AreEqual(AzureDevOpsAttachmentTypes.ConsoleLog, client.UploadTestResultAttachmentCalls[1].Attachment.AttachmentType); + Assert.AreEqual("stderr.log", client.UploadTestResultAttachmentCalls[2].Attachment.FileName); + Assert.AreEqual(AzureDevOpsAttachmentTypes.GeneralAttachment, client.UploadTestResultAttachmentCalls[2].Attachment.AttachmentType); + } + + [TestMethod] + public async Task ConsumeAsync_DoesNotUploadAttachmentsForPassedTests() + { + using TestDirectory directory = CreateTestDirectory(); + string dumpPath = Path.Combine(directory.Path, "passing-dump.txt"); + File.WriteAllText(dumpPath, "should not be uploaded"); + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(1, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out FakeClock clock, out _); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(201); + + TestNode node = CreateNode("passing-test", new PassedTestNodeStateProperty(), clock.UtcNow); + node.Properties.Add(new FileArtifactProperty(new FileInfo(dumpPath), "dump")); + node.Properties.Add(new StandardOutputProperty("stdout content")); + + await StartPublisherAsync(publisher); + await publisher.ConsumeAsync(Mock.Of(), CreateMessage(node), CancellationToken.None); + + Assert.HasCount(0, client.UploadTestResultAttachmentCalls); + } + + [TestMethod] + public async Task ConsumeAsync_AttachmentUploadFailureLogsWarningAndDoesNotRetryPublish() + { + using TestDirectory directory = CreateTestDirectory(); + string dumpPath = Path.Combine(directory.Path, "dump.txt"); + File.WriteAllText(dumpPath, "dump content"); + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(1, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out FakeClock clock, out CollectingLogger logger); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(202); + + int publishCalls = 0; + client.PublishTestResultsAsyncFunc = (_, _, results, _) => + { + publishCalls++; + return Task.FromResult?>(Enumerable.Range(1, results.Count).ToArray()); + }; + client.UploadTestResultAttachmentAsyncFunc = (_, _, _, _, _) => throw new HttpRequestException("simulated upload failure"); + + TestNode node = CreateNode("failed-test", new FailedTestNodeStateProperty(new InvalidOperationException("boom")), clock.UtcNow); + node.Properties.Add(new FileArtifactProperty(new FileInfo(dumpPath), "dump")); + + await StartPublisherAsync(publisher); + await publisher.ConsumeAsync(Mock.Of(), CreateMessage(node), CancellationToken.None); + await publisher.OnTestSessionFinishingAsync(new Microsoft.Testing.Platform.Services.TestSessionContext(CancellationToken.None)); + + Assert.AreEqual(1, publishCalls); + Assert.HasCount(1, client.UploadTestResultAttachmentCalls); + Assert.Contains(AzureDevOpsResources.AzureDevOpsLivePublishingResultAttachmentFailed, string.Join(Environment.NewLine, logger.Logs)); + } + + [TestMethod] + public async Task ConsumeAsync_PublishReturnsNullSkipsAttachmentsAndDoesNotRetry() + { + using TestDirectory directory = CreateTestDirectory(); + string dumpPath = Path.Combine(directory.Path, "dump.txt"); + File.WriteAllText(dumpPath, "dump content"); + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(1, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out FakeClock clock, out CollectingLogger logger); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(203); + + int publishCalls = 0; + client.PublishTestResultsAsyncFunc = (_, _, _, _) => + { + publishCalls++; + return Task.FromResult?>(null); + }; + + TestNode node = CreateNode("failed-test", new FailedTestNodeStateProperty(new InvalidOperationException("boom")), clock.UtcNow); + node.Properties.Add(new FileArtifactProperty(new FileInfo(dumpPath), "dump")); + + await StartPublisherAsync(publisher); + await publisher.ConsumeAsync(Mock.Of(), CreateMessage(node), CancellationToken.None); + await publisher.OnTestSessionFinishingAsync(new Microsoft.Testing.Platform.Services.TestSessionContext(CancellationToken.None)); + + Assert.AreEqual(1, publishCalls); + Assert.HasCount(0, client.UploadTestResultAttachmentCalls); + Assert.Contains(AzureDevOpsResources.AzureDevOpsLivePublishingResultIdParseFailedWarning, string.Join(Environment.NewLine, logger.Logs)); + } + + [TestMethod] + public async Task ConsumeAsync_SkipsOversizedFileAttachment() + { + using TestDirectory directory = CreateTestDirectory(); + string smallPath = Path.Combine(directory.Path, "small.txt"); + File.WriteAllText(smallPath, "small content"); + string bigPath = Path.Combine(directory.Path, "big.bin"); + using (FileStream fs = File.OpenWrite(bigPath)) + { + fs.SetLength(AzureDevOpsLivePublishingConstants.MaxAttachmentSizeBytes + 1); + } + + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(1, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out FakeClock clock, out _); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(204); + // The publisher still queues the oversized attachment; the client side TryBuildAttachmentRequest + // drops it. In this fake we just record the call regardless — the contract is exercised end-to-end + // when running against the real client. For the unit test we only assert what the publisher sends. + client.UploadTestResultAttachmentAsyncFunc = (_, _, _, attachment, _) => Task.CompletedTask; + + TestNode node = CreateNode("failed-test", new FailedTestNodeStateProperty(new InvalidOperationException("boom")), clock.UtcNow); + node.Properties.Add(new FileArtifactProperty(new FileInfo(smallPath), "small")); + node.Properties.Add(new FileArtifactProperty(new FileInfo(bigPath), "big")); + + await StartPublisherAsync(publisher); + await publisher.ConsumeAsync(Mock.Of(), CreateMessage(node), CancellationToken.None); + + // The publisher passes both attachments through; the client's TryBuildAttachmentRequest is what + // skips oversized ones. We sanity-check that the publisher does forward both names so the client + // gets a chance to filter. + Assert.HasCount(2, client.UploadTestResultAttachmentCalls); + Assert.Contains(c => c.Attachment.FileName == "small.txt", client.UploadTestResultAttachmentCalls); + Assert.Contains(c => c.Attachment.FileName == "big.bin", client.UploadTestResultAttachmentCalls); + } + + [TestMethod] + public async Task ConsumeAsync_DoesNotTruncateStdoutAtInlineByteLimit() + { + string stdout = new('x', AzureDevOpsLivePublishingConstants.MaxInlineAttachmentBytes); + + AzureDevOpsTestResultAttachment uploaded = await UploadStdoutAttachmentAsync(stdout); + + Assert.AreEqual(stdout, uploaded.InlineContent); + Assert.AreEqual(AzureDevOpsLivePublishingConstants.MaxInlineAttachmentBytes, Encoding.UTF8.GetByteCount(uploaded.InlineContent!)); + } + + [TestMethod] + public async Task ConsumeAsync_TruncatesLargeStdoutInline() + { + string oversizedStdout = new('x', AzureDevOpsLivePublishingConstants.MaxInlineAttachmentBytes + 10_000); + + AzureDevOpsTestResultAttachment uploaded = await UploadStdoutAttachmentAsync(oversizedStdout); + + Assert.IsNotNull(uploaded.InlineContent); + Assert.IsLessThanOrEqualTo(AzureDevOpsLivePublishingConstants.MaxInlineAttachmentBytes, Encoding.UTF8.GetByteCount(uploaded.InlineContent!)); + Assert.EndsWith(TruncationMarker, uploaded.InlineContent!, StringComparison.Ordinal); + } + + [DataRow("€")] + [DataRow("😀")] + [TestMethod] + public async Task ConsumeAsync_TruncatesUnicodeStdoutWithinInlineByteLimit(string textElement) + { + int repeatCount = (AzureDevOpsLivePublishingConstants.MaxInlineAttachmentBytes / Encoding.UTF8.GetByteCount(textElement)) + 10_000; + string oversizedStdout = string.Concat(Enumerable.Repeat(textElement, repeatCount)); + + AzureDevOpsTestResultAttachment uploaded = await UploadStdoutAttachmentAsync(oversizedStdout); + + Assert.IsNotNull(uploaded.InlineContent); + Assert.IsLessThanOrEqualTo(AzureDevOpsLivePublishingConstants.MaxInlineAttachmentBytes, Encoding.UTF8.GetByteCount(uploaded.InlineContent!)); + Assert.EndsWith(TruncationMarker, uploaded.InlineContent!, StringComparison.Ordinal); + + string retainedContent = uploaded.InlineContent![..^TruncationMarker.Length]; + Assert.IsFalse(retainedContent.Length > 0 && char.IsHighSurrogate(retainedContent[^1])); + } + + [TestMethod] + public async Task ConsumeAsync_TruncatesStdoutWithoutSplittingSurrogatePairAtBoundary() + { + string emoji = "😀"; + int markerBytes = Encoding.UTF8.GetByteCount(TruncationMarker); + int budget = AzureDevOpsLivePublishingConstants.MaxInlineAttachmentBytes - markerBytes; + string prefix = new('x', budget - Encoding.UTF8.GetByteCount(emoji)); + string oversizedStdout = prefix + emoji + new string('x', markerBytes + 1); + + AzureDevOpsTestResultAttachment uploaded = await UploadStdoutAttachmentAsync(oversizedStdout); + + Assert.IsNotNull(uploaded.InlineContent); + Assert.IsLessThanOrEqualTo(AzureDevOpsLivePublishingConstants.MaxInlineAttachmentBytes, Encoding.UTF8.GetByteCount(uploaded.InlineContent!)); + Assert.AreEqual(prefix + emoji + TruncationMarker, uploaded.InlineContent); + } + + [TestMethod] + public async Task OnTestSessionFinishingAsync_UploadsCoverageSessionFileArtifacts() + { + using TestDirectory directory = CreateTestDirectory(); + string coveragePath = Path.Combine(directory.Path, "results.cobertura.xml"); + File.WriteAllText(coveragePath, ""); + string opencoverPath = Path.Combine(directory.Path, "results.opencover.xml"); + File.WriteAllText(opencoverPath, ""); + string binaryCoveragePath = Path.Combine(directory.Path, "results.coverage"); + File.WriteAllBytes(binaryCoveragePath, [0, 1, 2, 3]); + + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(10, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out _, out _); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(206); + + await StartPublisherAsync(publisher); + SessionUid sessionUid = new(Guid.NewGuid().ToString()); + await publisher.ConsumeAsync(Mock.Of(), new SessionFileArtifact(sessionUid, new FileInfo(coveragePath), "cobertura", "coverage report"), CancellationToken.None); + await publisher.ConsumeAsync(Mock.Of(), new SessionFileArtifact(sessionUid, new FileInfo(opencoverPath), "opencover"), CancellationToken.None); + await publisher.ConsumeAsync(Mock.Of(), new SessionFileArtifact(sessionUid, new FileInfo(binaryCoveragePath), "vs-coverage"), CancellationToken.None); + await publisher.OnTestSessionFinishingAsync(new Microsoft.Testing.Platform.Services.TestSessionContext(CancellationToken.None)); + + Assert.HasCount(3, client.UploadTestRunAttachmentCalls); + Assert.IsTrue(client.UploadTestRunAttachmentCalls.All(c => c.Attachment.AttachmentType == AzureDevOpsAttachmentTypes.CodeCoverage)); + Assert.Contains(c => c.Attachment.FileName == "results.cobertura.xml", client.UploadTestRunAttachmentCalls); + Assert.Contains(c => c.Attachment.FileName == "results.opencover.xml", client.UploadTestRunAttachmentCalls); + Assert.Contains(c => c.Attachment.FileName == "results.coverage", client.UploadTestRunAttachmentCalls); + } + + [TestMethod] + public async Task OnTestSessionFinishingAsync_SkipsNonCoverageSessionFileArtifacts() + { + using TestDirectory directory = CreateTestDirectory(); + string logPath = Path.Combine(directory.Path, "run.log"); + File.WriteAllText(logPath, "some log"); + + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(10, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out _, out _); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(207); + + await StartPublisherAsync(publisher); + SessionUid sessionUid = new(Guid.NewGuid().ToString()); + await publisher.ConsumeAsync(Mock.Of(), new SessionFileArtifact(sessionUid, new FileInfo(logPath), "log"), CancellationToken.None); + await publisher.OnTestSessionFinishingAsync(new Microsoft.Testing.Platform.Services.TestSessionContext(CancellationToken.None)); + + Assert.HasCount(0, client.UploadTestRunAttachmentCalls); + } + + [TestMethod] + public async Task OnTestSessionFinishingAsync_CoverageUploadFailureLogsWarning() + { + using TestDirectory directory = CreateTestDirectory(); + string coveragePath = Path.Combine(directory.Path, "results.cobertura.xml"); + File.WriteAllText(coveragePath, ""); + + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(10, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out _, out CollectingLogger logger); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(208); + client.UploadTestRunAttachmentAsyncFunc = (_, _, _, _) => throw new HttpRequestException("simulated upload failure"); + + await StartPublisherAsync(publisher); + SessionUid sessionUid = new(Guid.NewGuid().ToString()); + await publisher.ConsumeAsync(Mock.Of(), new SessionFileArtifact(sessionUid, new FileInfo(coveragePath), "cobertura"), CancellationToken.None); + await publisher.OnTestSessionFinishingAsync(new Microsoft.Testing.Platform.Services.TestSessionContext(CancellationToken.None)); + + Assert.HasCount(1, client.UploadTestRunAttachmentCalls); + Assert.Contains(AzureDevOpsResources.AzureDevOpsLivePublishingRunAttachmentFailed, string.Join(Environment.NewLine, logger.Logs)); + } + [TestMethod] public async Task RunIdCoordinator_CreateAndReadFlowSharesRunIdAcrossProcesses() { @@ -555,6 +857,22 @@ public async Task RunIdCoordinator_AcquireRunAsync_JoinerGivesUpWhenOwnerLeaseEx Assert.IsTrue(coordinatedRun.IsOwner); } + private async Task UploadStdoutAttachmentAsync(string stdout) + { + using TestDirectory directory = CreateTestDirectory(); + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(1, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out FakeClock clock, out _); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(205); + + TestNode node = CreateNode("failed-test", new FailedTestNodeStateProperty(new InvalidOperationException("boom")), clock.UtcNow); + node.Properties.Add(new StandardOutputProperty(stdout)); + + await StartPublisherAsync(publisher); + await publisher.ConsumeAsync(Mock.Of(), CreateMessage(node), CancellationToken.None); + + Assert.HasCount(1, client.UploadTestResultAttachmentCalls); + return client.UploadTestResultAttachmentCalls[0].Attachment; + } + private static async Task StartPublisherAsync(AzureDevOpsTestResultsPublisher publisher) { Assert.IsTrue(await publisher.IsEnabledAsync()); @@ -676,14 +994,32 @@ private sealed class FakeAzureDevOpsTestResultsClient : IAzureDevOpsTestResultsC { public Func> CreateTestRunAsyncFunc { get; set; } = (_, _) => Task.FromResult(0); - public Func, CancellationToken, Task> PublishTestResultsAsyncFunc { get; set; } = (_, _, _, _) => Task.CompletedTask; + public Func, CancellationToken, Task?>> PublishTestResultsAsyncFunc { get; set; } = + (_, _, results, _) => + { + int[] ids = new int[results.Count]; + for (int i = 0; i < results.Count; i++) + { + ids[i] = i + 1; + } + + return Task.FromResult?>(ids); + }; + + public Func UploadTestResultAttachmentAsyncFunc { get; set; } = (_, _, _, _, _) => Task.CompletedTask; + + public Func UploadTestRunAttachmentAsyncFunc { get; set; } = (_, _, _, _) => Task.CompletedTask; public List<(AzureDevOpsPublishConfiguration Configuration, int RunId, string State)> UpdateTestRunStateCalls { get; } = []; + public List<(int RunId, int TestCaseResultId, AzureDevOpsTestResultAttachment Attachment)> UploadTestResultAttachmentCalls { get; } = []; + + public List<(int RunId, AzureDevOpsTestResultAttachment Attachment)> UploadTestRunAttachmentCalls { get; } = []; + public Task CreateTestRunAsync(AzureDevOpsPublishConfiguration configuration, CancellationToken cancellationToken) => CreateTestRunAsyncFunc(configuration, cancellationToken); - public Task PublishTestResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken) + public Task?> PublishTestResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken) => PublishTestResultsAsyncFunc(configuration, runId, results, cancellationToken); public Task UpdateTestRunStateAsync(AzureDevOpsPublishConfiguration configuration, int runId, string state, CancellationToken cancellationToken) @@ -691,6 +1027,18 @@ public Task UpdateTestRunStateAsync(AzureDevOpsPublishConfiguration configuratio UpdateTestRunStateCalls.Add((configuration, runId, state)); return Task.CompletedTask; } + + public Task UploadTestResultAttachmentAsync(AzureDevOpsPublishConfiguration configuration, int runId, int testCaseResultId, AzureDevOpsTestResultAttachment attachment, CancellationToken cancellationToken) + { + UploadTestResultAttachmentCalls.Add((runId, testCaseResultId, attachment)); + return UploadTestResultAttachmentAsyncFunc(configuration, runId, testCaseResultId, attachment, cancellationToken); + } + + public Task UploadTestRunAttachmentAsync(AzureDevOpsPublishConfiguration configuration, int runId, AzureDevOpsTestResultAttachment attachment, CancellationToken cancellationToken) + { + UploadTestRunAttachmentCalls.Add((runId, attachment)); + return UploadTestRunAttachmentAsyncFunc(configuration, runId, attachment, cancellationToken); + } } private sealed class FakeTask(Action? delayCallback = null) : ITask