diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLivePublishingModels.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLivePublishingModels.cs index 281cc4b28b..4a176fc1a0 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLivePublishingModels.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLivePublishingModels.cs @@ -99,8 +99,10 @@ internal sealed record AzureDevOpsTestCaseResult( /// /// /// Declared as properties rather than positional parameters so that adding them does not change the - /// record's constructor and deconstructor signatures. All three are absent from a freshly created - /// result and are only populated when a retry attempt turns an existing result into a rerun. + /// record's constructor and deconstructor signatures. is populated only when updating + /// an existing result. and are also populated on + /// a freshly created, attachment-bearing result when retry tracking is enabled, so its attachments can + /// target the first attempt. /// [JsonPropertyName("id")] public int? Id { get; init; } @@ -141,6 +143,22 @@ internal sealed record AzureDevOpsTestCaseResultWithAttachments( AzureDevOpsTestCaseResult Result, IReadOnlyList Attachments); +internal sealed class AzureDevOpsPublishedTestResult +{ + public AzureDevOpsPublishedTestResult(int id, IReadOnlyDictionary subResultIdsBySequenceId) + { + Id = id; + SubResultIdsBySequenceId = subResultIdsBySequenceId; + } + + public int Id { get; } + + public IReadOnlyDictionary SubResultIdsBySequenceId { get; } + + public bool TryGetSubResultId(int sequenceId, out int subResultId) + => SubResultIdsBySequenceId.TryGetValue(sequenceId, out subResultId); +} + /// /// 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 (). @@ -172,18 +190,6 @@ public static AzureDevOpsTestResultAttachment FromFile(string filePath, string a public static AzureDevOpsTestResultAttachment FromString(string content, string fileName, string attachmentType, string? comment = null) => new(fileName, attachmentType, comment, filePath: null, inlineContent: content); - - /// - /// Returns a copy of this attachment published under a different file name. - /// - /// - /// Every attempt of a rerun uploads its attachments against the same parent result, where Azure DevOps - /// accumulates rather than replaces them. Two attempts would therefore both contribute a - /// stdout.log, leaving no way to tell which attempt produced which. Renaming per attempt keeps - /// them distinguishable without needing a sub-result id we cannot reliably obtain. - /// - public AzureDevOpsTestResultAttachment WithFileName(string fileName) - => new(fileName, AttachmentType, Comment, FilePath, InlineContent); } internal sealed record AzureDevOpsTestResultsPublisherOptions( diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsResultIdStore.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsResultIdStore.cs index c1485883be..cc68068c52 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsResultIdStore.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsResultIdStore.cs @@ -116,7 +116,7 @@ public void RecordCreated(AzureDevOpsTestCaseResult result, int resultId) result.AutomatedTestName, result.TestCaseTitle, resultId, - [ToSubResult(result, sequenceId: 1)]) + [CreateFirstAttempt(result)]) { TotalDurationInMs = result.DurationInMs, StartedDate = result.StartedDate, @@ -125,6 +125,9 @@ public void RecordCreated(AzureDevOpsTestCaseResult result, int resultId) _hasUnsavedChanges = true; } + public static AzureDevOpsTestSubResult CreateFirstAttempt(AzureDevOpsTestCaseResult result) + => ToSubResult(result, sequenceId: 1); + /// /// Builds what the attempt history would become if were published, without /// recording it. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsClient.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsClient.cs index 0bec8d6cc8..b4085aee03 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsClient.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsClient.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// 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.Net; @@ -94,6 +94,15 @@ public async Task CreateTestRunAsync(AzureDevOpsPublishConfiguration config [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) + { + IReadOnlyList? publishedResults = + await PublishTestResultsWithSubResultsAsync(configuration, runId, results, cancellationToken).ConfigureAwait(false); + return publishedResults?.Select(static result => result.Id).ToArray(); + } + + [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?> PublishTestResultsWithSubResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken) { using HttpRequestMessage request = CreateRequest( HttpMethod.Post, @@ -105,33 +114,19 @@ public async Task CreateTestRunAsync(AzureDevOpsPublishConfiguration config 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; + string payload = await ReadAsStringAsync(response.Content, requestTimeoutSource.Token).ConfigureAwait(false); + return ParsePublishedResults(payload, results, validateAutomatedTestName: true); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return null; } - catch (JsonException) + catch (Exception ex) when (ex is JsonException or HttpRequestException or IOException or InvalidOperationException or ArgumentException) { return null; } @@ -141,12 +136,12 @@ public async Task CreateTestRunAsync(AzureDevOpsPublishConfiguration config /// Updates results that were already published to the run, folding a further attempt of the same test /// into the result that represents it. /// - /// - /// Deliberately does not surface the response body. Azure DevOps returns the updated results, but the - /// caller already knows the ids it sent (that is how it addressed them), and the sub-result ids in the - /// response are not needed: attachments for every attempt are uploaded against the parent result. - /// public async Task UpdateTestResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken) + => _ = await UpdateTestResultsWithSubResultsAsync(configuration, runId, results, cancellationToken).ConfigureAwait(false); + + [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?> UpdateTestResultsWithSubResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken) { using HttpRequestMessage request = CreateRequest( PatchMethod, @@ -155,10 +150,26 @@ public async Task UpdateTestResultsAsync(AzureDevOpsPublishConfiguration configu results, UpdateJsonSerializerOptions); - await SendAsync(request, cancellationToken).ConfigureAwait(false); + using var requestTimeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + requestTimeoutSource.CancelAfter(RequestTimeout); + using HttpResponseMessage response = await SendCoreAsync(request, requestTimeoutSource.Token, cancellationToken, AttemptTimeout).ConfigureAwait(false); + + try + { + string payload = await ReadAsStringAsync(response.Content, requestTimeoutSource.Token).ConfigureAwait(false); + return ParsePublishedResults(payload, results, validateAutomatedTestName: false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return null; + } + catch (Exception ex) when (ex is JsonException or HttpRequestException or IOException or InvalidOperationException or ArgumentException) + { + return null; + } } - public async Task UploadTestResultAttachmentAsync(AzureDevOpsPublishConfiguration configuration, int runId, int testCaseResultId, AzureDevOpsTestResultAttachment attachment, CancellationToken cancellationToken) + public async Task UploadTestResultAttachmentAsync(AzureDevOpsPublishConfiguration configuration, int runId, int testCaseResultId, int? testSubResultId, AzureDevOpsTestResultAttachment attachment, CancellationToken cancellationToken) { AttachmentRequest? payload = TryBuildAttachmentRequest(attachment); if (payload is null) @@ -168,7 +179,7 @@ public async Task UploadTestResultAttachmentAsync(AzureDevOpsPublishConfiguratio using HttpRequestMessage request = CreateRequest( HttpMethod.Post, - BuildResultAttachmentsUri(configuration.CollectionUri, configuration.Project, runId, testCaseResultId), + BuildResultAttachmentsUri(configuration.CollectionUri, configuration.Project, runId, testCaseResultId, testSubResultId), configuration.AccessToken, payload); @@ -250,12 +261,80 @@ 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 BuildResultAttachmentsUri(string collectionUri, string project, int runId, int testCaseResultId, int? testSubResultId) + { + string query = testSubResultId is null + ? $"api-version={ApiVersion}" + : $"testSubResultId={testSubResultId.Value.ToString(CultureInfo.InvariantCulture)}&api-version={ApiVersion}"; + return new(new Uri(collectionUri, UriKind.Absolute), $"{Uri.EscapeDataString(project)}/_apis/test/runs/{runId}/results/{testCaseResultId}/attachments?{query}"); + } 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}"); + [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.")] + private static IReadOnlyList? ParsePublishedResults( + string payload, + IReadOnlyList submittedResults, + bool validateAutomatedTestName) + { + PublishTestResultsResponse? parsed = JsonSerializer.Deserialize(payload, JsonSerializerOptions); + if (parsed?.Value is null || parsed.Value.Length != submittedResults.Count) + { + return null; + } + + var publishedResults = new AzureDevOpsPublishedTestResult[submittedResults.Count]; + for (int i = 0; i < submittedResults.Count; i++) + { + PublishedTestResult published = parsed.Value[i]; + AzureDevOpsTestCaseResult submitted = submittedResults[i]; + if (published.Id <= 0 + || (validateAutomatedTestName + ? !string.Equals(published.AutomatedTestName, submitted.AutomatedTestName, StringComparison.Ordinal) + : submitted.Id != published.Id)) + { + return null; + } + + IReadOnlyList? submittedSubResults = submitted.SubResults; + Dictionary subResultIdsBySequenceId = []; + if (submittedSubResults is { Count: > 0 } + && published.SubResults is { } publishedSubResults + && publishedSubResults.Length == submittedSubResults.Count) + { + var submittedSequenceIds = new HashSet(); + for (int j = 0; j < submittedSubResults.Count; j++) + { + if (!submittedSequenceIds.Add(submittedSubResults[j].SequenceId)) + { + break; + } + } + + if (submittedSequenceIds.Count == submittedSubResults.Count) + { + for (int j = 0; j < publishedSubResults.Length; j++) + { + PublishedTestSubResult publishedSubResult = publishedSubResults[j]; + if (publishedSubResult.Id <= 0 || !submittedSequenceIds.Remove(publishedSubResult.SequenceId)) + { + subResultIdsBySequenceId.Clear(); + break; + } + + subResultIdsBySequenceId.Add(publishedSubResult.SequenceId, publishedSubResult.Id); + } + } + } + + publishedResults[i] = new AzureDevOpsPublishedTestResult(published.Id, subResultIdsBySequenceId); + } + + return publishedResults; + } + private static AttachmentRequest? TryBuildAttachmentRequest(AzureDevOpsTestResultAttachment attachment) { byte[]? bytes; @@ -376,21 +455,28 @@ private async Task SendCoreAsync(HttpRequestMessage request try { - HttpResponseMessage response = await _httpClient.SendAsync(currentRequest, attemptTimeoutSource.Token).ConfigureAwait(false); + HttpResponseMessage response = await _httpClient.SendAsync(currentRequest, HttpCompletionOption.ResponseHeadersRead, attemptTimeoutSource.Token).ConfigureAwait(false); if (response.IsSuccessStatusCode) { return response; } - if (!ShouldRetry(response.StatusCode, attempt)) + TimeSpan delay; + try + { + if (!ShouldRetry(response.StatusCode, attempt)) + { + string responseBody = await ReadAsStringAsync(response.Content, requestCancellationToken).ConfigureAwait(false); + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, AzureDevOpsResources.AzureDevOpsLivePublishingHttpError, (int)response.StatusCode, responseBody)); + } + + delay = GetDelay(response, attempt); + } + finally { - string responseBody = await ReadAsStringAsync(response.Content, requestCancellationToken).ConfigureAwait(false); response.Dispose(); - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, AzureDevOpsResources.AzureDevOpsLivePublishingHttpError, (int)response.StatusCode, responseBody)); } - TimeSpan delay = GetDelay(response, attempt); - response.Dispose(); await _task.Delay(delay, requestCancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ShouldRetry(ex, userCancellationToken, requestCancellationToken, attempt)) @@ -488,12 +574,29 @@ private static Task ReadAsByteArrayAsync(HttpContent content, Cancellati => content.ReadAsByteArrayAsync(); #endif - private static Task ReadAsStringAsync(HttpContent content, CancellationToken cancellationToken) + private static async Task ReadAsStringAsync(HttpContent content, CancellationToken cancellationToken) + { #if NET - => content.ReadAsStringAsync(cancellationToken); + return await content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); #else - => content.ReadAsStringAsync(); + using Stream contentStream = await content.ReadAsStreamAsync().ConfigureAwait(false); + using MemoryStream bufferedContent = new(); + byte[] buffer = new byte[81920]; + int bytesRead; + while ((bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) > 0) + { + await bufferedContent.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false); + } + + bufferedContent.Position = 0; + string? charset = content.Headers.ContentType?.CharSet; + Encoding encoding = charset is { Length: > 0 } + ? Encoding.GetEncoding(charset.Trim('"')) + : Encoding.UTF8; + using StreamReader reader = new(bufferedContent, encoding, detectEncodingFromByteOrderMarks: true); + return await reader.ReadToEndAsync().ConfigureAwait(false); #endif + } private sealed record CreateTestRunRequest( [property: JsonPropertyName("name")] string Name, @@ -533,7 +636,12 @@ private sealed record PublishTestResultsResponse( private sealed record PublishedTestResult( [property: JsonPropertyName("id")] int Id, - [property: JsonPropertyName("automatedTestName")] string? AutomatedTestName); + [property: JsonPropertyName("automatedTestName")] string? AutomatedTestName, + [property: JsonPropertyName("subResults")] PublishedTestSubResult[]? SubResults); + + private sealed record PublishedTestSubResult( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("sequenceId")] int SequenceId); private sealed record AttachmentRequest( [property: JsonPropertyName("stream")] string Stream, diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.Flush.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.Flush.cs index ff006dfb28..3dc1a2a684 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.Flush.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.Flush.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Microsoft.Testing.Extensions.AzureDevOpsReport.Resources; @@ -34,7 +34,7 @@ private async Task UploadPendingRunAttachmentsAsync(CancellationToken cancellati } } - private async Task UploadResultAttachmentsAsync(int testCaseResultId, IReadOnlyList attachments, CancellationToken cancellationToken) + private async Task UploadResultAttachmentsAsync(int testCaseResultId, int? testSubResultId, IReadOnlyList attachments, CancellationToken cancellationToken) { if (_publishConfiguration is null || CurrentRunId is null || attachments.Count == 0) { @@ -45,7 +45,7 @@ private async Task UploadResultAttachmentsAsync(int testCaseResultId, IReadOnlyL { try { - await _client.UploadTestResultAttachmentAsync(_publishConfiguration, CurrentRunId.Value, testCaseResultId, attachment, cancellationToken).ConfigureAwait(false); + await _client.UploadTestResultAttachmentAsync(_publishConfiguration, CurrentRunId.Value, testCaseResultId, testSubResultId, attachment, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -127,7 +127,7 @@ private async Task FlushPendingResultsAsync(bool force, CancellationToken cancel // orchestrated run has a store, so an ordinary run takes the create path for everything. List creations = []; List<(AzureDevOpsPublishedResult Published, AzureDevOpsTestCaseResultWithAttachments Attempt)> updateCandidates = []; - List<(int ResultId, IReadOnlyList Attachments)> deferredAttachments = []; + List<(int ResultId, int? TestSubResultId, IReadOnlyList Attachments)> deferredAttachments = []; foreach (AzureDevOpsTestCaseResultWithAttachments item in batch) { if (_resultIdStore?.TryGet(item.Result) is { } published) @@ -231,10 +231,10 @@ private async Task FlushPendingResultsAsync(bool force, CancellationToken cancel /// private async Task TryCreateResultsAsync( List batch, - List<(int ResultId, IReadOnlyList Attachments)> deferredAttachments, + List<(int ResultId, int? TestSubResultId, IReadOnlyList Attachments)> deferredAttachments, CancellationToken cancellationToken) { - IReadOnlyList? resultIds; + IReadOnlyList? publishedResults; try { if (_coordinatedRun is not null && _runIdCoordinator is not null) @@ -245,10 +245,16 @@ private async Task TryCreateResultsAsync( var resultsOnly = new AzureDevOpsTestCaseResult[batch.Count]; for (int i = 0; i < batch.Count; i++) { - resultsOnly[i] = batch[i].Result; + resultsOnly[i] = _resultIdStore is not null && batch[i].Attachments.Count > 0 + ? batch[i].Result with + { + ResultGroupType = AzureDevOpsLivePublishingConstants.RerunResultGroupType, + SubResults = [AzureDevOpsResultIdStore.CreateFirstAttempt(batch[i].Result)], + } + : batch[i].Result; } - resultIds = await _client.PublishTestResultsAsync(_publishConfiguration!, CurrentRunId!.Value, resultsOnly, cancellationToken).ConfigureAwait(false); + publishedResults = await _client.PublishTestResultsWithSubResultsAsync(_publishConfiguration!, CurrentRunId!.Value, resultsOnly, cancellationToken).ConfigureAwait(false); _lastFlushTime = _clock.UtcNow; } catch (Exception ex) when (ex is not OperationCanceledException) @@ -263,7 +269,7 @@ private async Task TryCreateResultsAsync( // POST succeeded. If we couldn't parse the response we cannot upload result-level attachments for // this batch, nor remember the ids for a later attempt, but we MUST NOT republish (that would // create duplicate result rows in AzDO). - if (resultIds is null) + if (publishedResults is null) { if (BatchHasAttachments(batch)) { @@ -277,24 +283,44 @@ private async Task TryCreateResultsAsync( // Record the whole accepted batch before any cancellable attachment upload. Azure DevOps accepted // every result in one operation, so the map must describe all of them even if cancellation // interrupts the best-effort attachment phase. + bool failedToResolveSubResultId = false; for (int i = 0; i < batch.Count; i++) { // Folded data-driven rows share one uid. A failure in any row retries the whole uid, including // rows that passed or were skipped, so every row must retain its own result id and history. if (_resultIdStore is not null) { - _resultIdStore.RecordCreated(batch[i].Result, resultIds[i]); - _claimedResultIds.Add(resultIds[i]); + _resultIdStore.RecordCreated(batch[i].Result, publishedResults[i].Id); + _claimedResultIds.Add(publishedResults[i].Id); } if (batch[i].Attachments.Count > 0) { + int? testSubResultId = null; + if (_resultIdStore is not null) + { + if (!publishedResults[i].TryGetSubResultId(sequenceId: 1, out int resolvedSubResultId)) + { + Interlocked.Add(ref _failedAttachmentCount, batch[i].Attachments.Count); + failedToResolveSubResultId = true; + continue; + } + + testSubResultId = resolvedSubResultId; + } + deferredAttachments.Add(( - resultIds[i], - _resultIdStore is null ? batch[i].Attachments : RenameForAttempt(batch[i].Attachments, attemptNumber: 1))); + publishedResults[i].Id, + testSubResultId, + batch[i].Attachments)); } } + if (failedToResolveSubResultId) + { + TryLogWarning(AzureDevOpsResources.AzureDevOpsLivePublishingResultIdParseFailedWarning); + } + return true; } @@ -311,7 +337,7 @@ private async Task TryCreateResultsAsync( /// private async Task TryUpdateResultsAsync( List<(AzureDevOpsPublishedResult Published, AzureDevOpsTestCaseResultWithAttachments Attempt)> updates, - List<(int ResultId, IReadOnlyList Attachments)> deferredAttachments, + List<(int ResultId, int? TestSubResultId, IReadOnlyList Attachments)> deferredAttachments, CancellationToken cancellationToken) { var parents = new AzureDevOpsTestCaseResult[updates.Count]; @@ -338,6 +364,7 @@ private async Task TryUpdateResultsAsync( }; } + IReadOnlyList? publishedResults; try { if (_coordinatedRun is not null && _runIdCoordinator is not null) @@ -345,7 +372,7 @@ private async Task TryUpdateResultsAsync( await _runIdCoordinator.RenewLeaseAsync(_coordinatedRun, cancellationToken).ConfigureAwait(false); } - await _client.UpdateTestResultsAsync(_publishConfiguration!, CurrentRunId!.Value, parents, cancellationToken).ConfigureAwait(false); + publishedResults = await _client.UpdateTestResultsWithSubResultsAsync(_publishConfiguration!, CurrentRunId!.Value, parents, cancellationToken).ConfigureAwait(false); _lastFlushTime = _clock.UtcNow; } catch (Exception ex) @@ -377,54 +404,43 @@ private async Task TryUpdateResultsAsync( completedDates[i]); } + bool failedToResolveSubResultId = false; for (int i = 0; i < updates.Count; i++) { if (updates[i].Attempt.Attachments.Count > 0) { + int sequenceId = attemptHistories[i][^1].SequenceId; + if (publishedResults is null + || !publishedResults[i].TryGetSubResultId(sequenceId, out int testSubResultId)) + { + Interlocked.Add(ref _failedAttachmentCount, updates[i].Attempt.Attachments.Count); + failedToResolveSubResultId = true; + continue; + } + deferredAttachments.Add(( updates[i].Published.Id, - RenameForAttempt(updates[i].Attempt.Attachments, attemptHistories[i][^1].SequenceId))); + testSubResultId, + updates[i].Attempt.Attachments)); } } + if (failedToResolveSubResultId) + { + TryLogWarning(AzureDevOpsResources.AzureDevOpsLivePublishingResultIdParseFailedWarning); + } + return true; } private async Task UploadDeferredAttachmentsAsync( - List<(int ResultId, IReadOnlyList Attachments)> deferredAttachments, + List<(int ResultId, int? TestSubResultId, IReadOnlyList Attachments)> deferredAttachments, CancellationToken cancellationToken) { - foreach ((int resultId, IReadOnlyList attachments) in deferredAttachments) - { - await UploadAttachmentsForResultAsync(resultId, attachments, cancellationToken).ConfigureAwait(false); - } - } - - /// - /// Qualifies attachment names with the attempt that produced them. - /// - /// - /// Every attempt uploads against the same parent result, where Azure DevOps accumulates attachments - /// rather than replacing them, so two attempts would otherwise both contribute a stdout.log with - /// no way to tell them apart. - /// - private static IReadOnlyList RenameForAttempt(IReadOnlyList attachments, int attemptNumber) - { - if (attachments.Count == 0) + foreach ((int resultId, int? testSubResultId, IReadOnlyList attachments) in deferredAttachments) { - return attachments; + await UploadAttachmentsForResultAsync(resultId, testSubResultId, attachments, cancellationToken).ConfigureAwait(false); } - - var renamed = new AzureDevOpsTestResultAttachment[attachments.Count]; - for (int i = 0; i < attachments.Count; i++) - { - string fileName = attachments[i].FileName; - string extension = Path.GetExtension(fileName); - renamed[i] = attachments[i].WithFileName( - $"{Path.GetFileNameWithoutExtension(fileName)}.attempt-{attemptNumber.ToString(CultureInfo.InvariantCulture)}{extension}"); - } - - return renamed; } private static DateTimeOffset? Min(DateTimeOffset? left, DateTimeOffset? right) @@ -433,7 +449,7 @@ private static IReadOnlyList RenameForAttempt(I private static DateTimeOffset? Max(DateTimeOffset? left, DateTimeOffset? right) => left is null ? right : right is null || left >= right ? left : right; - private async Task UploadAttachmentsForResultAsync(int testCaseResultId, IReadOnlyList attachments, CancellationToken cancellationToken) + private async Task UploadAttachmentsForResultAsync(int testCaseResultId, int? testSubResultId, IReadOnlyList attachments, CancellationToken cancellationToken) { if (attachments.Count == 0) { @@ -447,7 +463,7 @@ private async Task UploadAttachmentsForResultAsync(int testCaseResultId, IReadOn await _runIdCoordinator.RenewLeaseAsync(_coordinatedRun, cancellationToken).ConfigureAwait(false); } - await UploadResultAttachmentsAsync(testCaseResultId, attachments, cancellationToken).ConfigureAwait(false); + await UploadResultAttachmentsAsync(testCaseResultId, testSubResultId, attachments, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/IAzureDevOpsTestResultsClient.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/IAzureDevOpsTestResultsClient.cs index ad7accc117..7ce871c88f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/IAzureDevOpsTestResultsClient.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/IAzureDevOpsTestResultsClient.cs @@ -16,6 +16,11 @@ internal interface IAzureDevOpsTestResultsClient /// Task?> PublishTestResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken); + /// + /// Publishes a batch and returns the parent and server-assigned sub-result IDs. + /// + Task?> PublishTestResultsWithSubResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken); + /// /// Updates results that were already published to the run, identified by /// . Used to turn a previously published result into a rerun @@ -26,9 +31,14 @@ internal interface IAzureDevOpsTestResultsClient Task UpdateTestResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken); /// - /// Uploads an attachment to a specific test case result within a test run. + /// Updates a batch and returns the parent and server-assigned sub-result IDs. + /// + Task?> UpdateTestResultsWithSubResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken); + + /// + /// Uploads an attachment to a specific test case result or sub-result within a test run. /// - Task UploadTestResultAttachmentAsync(AzureDevOpsPublishConfiguration configuration, int runId, int testCaseResultId, AzureDevOpsTestResultAttachment attachment, CancellationToken cancellationToken); + Task UploadTestResultAttachmentAsync(AzureDevOpsPublishConfiguration configuration, int runId, int testCaseResultId, int? testSubResultId, AzureDevOpsTestResultAttachment attachment, CancellationToken cancellationToken); /// /// Uploads an attachment to the test run itself (e.g. code coverage files). diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/InternalAPI/InternalAPI.Unshipped.txt index 2617cfe6b8..622f69364d 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/InternalAPI/InternalAPI.Unshipped.txt @@ -88,7 +88,13 @@ Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishedResult.Title. Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishedResult.Title.init -> void Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishedResult.TotalDurationInMs.get -> long? Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishedResult.TotalDurationInMs.init -> void +Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishedTestResult +Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishedTestResult.AzureDevOpsPublishedTestResult(int id, System.Collections.Generic.IReadOnlyDictionary! subResultIdsBySequenceId) -> void +Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishedTestResult.Id.get -> int +Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishedTestResult.SubResultIdsBySequenceId.get -> System.Collections.Generic.IReadOnlyDictionary! +Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishedTestResult.TryGetSubResultId(int sequenceId, out int subResultId) -> bool Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsResultIdStore +static Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsResultIdStore.CreateFirstAttempt(Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestCaseResult! result) -> Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestSubResult! Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsResultIdStore.RecordCreated(Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestCaseResult! result, int resultId) -> void Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsResultIdStore.Forget(Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishedResult! published) -> void Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsResultIdStore.SaveAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! @@ -131,8 +137,11 @@ Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestCaseResult.ResultG Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestCaseResult.ResultGroupType.init -> void Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestCaseResult.SubResults.get -> System.Collections.Generic.IReadOnlyList? Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestCaseResult.SubResults.init -> void -Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestResultAttachment.WithFileName(string! fileName) -> Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestResultAttachment! +Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestResultsClient.PublishTestResultsWithSubResultsAsync(Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishConfiguration! configuration, int runId, System.Collections.Generic.IReadOnlyList! results, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task?>! Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestResultsClient.UpdateTestResultsAsync(Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishConfiguration! configuration, int runId, System.Collections.Generic.IReadOnlyList! results, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestResultsClient.UpdateTestResultsWithSubResultsAsync(Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishConfiguration! configuration, int runId, System.Collections.Generic.IReadOnlyList! results, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task?>! +Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestResultsClient.UploadTestResultAttachmentAsync(Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishConfiguration! configuration, int runId, int testCaseResultId, int? testSubResultId, Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestResultAttachment! attachment, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestResultsClient.UploadTestResultAttachmentAsync(Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishConfiguration! configuration, int runId, int testCaseResultId, Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestResultAttachment! attachment, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestSubResult Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestSubResult.$() -> Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestSubResult! Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestSubResult.AzureDevOpsTestSubResult(int SequenceId, string! DisplayName, string! Outcome, long? DurationInMs, string? ErrorMessage, string? StackTrace, System.DateTimeOffset? StartedDate, System.DateTimeOffset? CompletedDate) -> void @@ -154,7 +163,11 @@ Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestSubResult.StackTra Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestSubResult.StackTrace.init -> void Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestSubResult.StartedDate.get -> System.DateTimeOffset? Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestSubResult.StartedDate.init -> void +Microsoft.Testing.Extensions.AzureDevOpsReport.IAzureDevOpsTestResultsClient.PublishTestResultsWithSubResultsAsync(Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishConfiguration! configuration, int runId, System.Collections.Generic.IReadOnlyList! results, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task?>! Microsoft.Testing.Extensions.AzureDevOpsReport.IAzureDevOpsTestResultsClient.UpdateTestResultsAsync(Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishConfiguration! configuration, int runId, System.Collections.Generic.IReadOnlyList! results, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +Microsoft.Testing.Extensions.AzureDevOpsReport.IAzureDevOpsTestResultsClient.UpdateTestResultsWithSubResultsAsync(Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishConfiguration! configuration, int runId, System.Collections.Generic.IReadOnlyList! results, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task?>! +Microsoft.Testing.Extensions.AzureDevOpsReport.IAzureDevOpsTestResultsClient.UploadTestResultAttachmentAsync(Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishConfiguration! configuration, int runId, int testCaseResultId, int? testSubResultId, Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestResultAttachment! attachment, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +*REMOVED*Microsoft.Testing.Extensions.AzureDevOpsReport.IAzureDevOpsTestResultsClient.UploadTestResultAttachmentAsync(Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishConfiguration! configuration, int runId, int testCaseResultId, Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsTestResultAttachment! attachment, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! override Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishedResult.Equals(object? obj) -> bool override Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishedResult.GetHashCode() -> int override Microsoft.Testing.Extensions.AzureDevOpsReport.AzureDevOpsPublishedResult.ToString() -> string! diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs index 15ddf71725..52390eec0c 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs @@ -736,6 +736,9 @@ public async Task ConsumeAsync_UploadsAttachmentsForFailedTests() 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.IsNull(client.UploadTestResultAttachmentCalls[0].TestSubResultId); + Assert.IsNull(client.UploadTestResultAttachmentCalls[1].TestSubResultId); + Assert.IsNull(client.UploadTestResultAttachmentCalls[2].TestSubResultId); 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); @@ -779,7 +782,7 @@ public async Task ConsumeAsync_AttachmentUploadFailureLogsWarningAndDoesNotRetry publishCalls++; return Task.FromResult?>(Enumerable.Range(1, results.Count).ToArray()); }; - client.UploadTestResultAttachmentAsyncFunc = (_, _, _, _, _) => throw new HttpRequestException("simulated upload failure"); + 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")); @@ -843,7 +846,7 @@ public async Task ConsumeAsync_SkipsOversizedFileAttachment() // 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; + 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")); @@ -2010,7 +2013,7 @@ await secondAttempt.ConsumeAsync( } [TestMethod] - public async Task RetryAttempt_AttachmentNameIncludesAttemptAndPreservesExtension() + public async Task RetryAttempt_AttachmentTargetsItsSubResultAndKeepsOriginalName() { using TestDirectory directory = CreateTestDirectory(); Mock environment = CreateEnvironmentMockWithSettableRunId(); @@ -2031,6 +2034,17 @@ await PublishSingleResultAsync( out _, out _, environment); + client.UpdateTestResultsWithSubResultsAsyncFunc = (_, _, results, _) => + { + IReadOnlyDictionary subResultIds = new Dictionary + { + [1] = 201, + [2] = 202, + }; + return Task.FromResult?>([ + new AzureDevOpsPublishedTestResult(results.Single().Id!.Value, subResultIds), + ]); + }; TestNode node = CreateNode( "MyTest", new FailedTestNodeStateProperty(new InvalidOperationException("second")), @@ -2043,13 +2057,14 @@ await PublishSingleResultAsync( Assert.HasCount(1, client.UploadTestResultAttachmentCalls); Assert.AreEqual(25, client.UploadTestResultAttachmentCalls[0].TestCaseResultId); - Assert.AreEqual("stdout.attempt-2.log", client.UploadTestResultAttachmentCalls[0].Attachment.FileName); + Assert.AreEqual(202, client.UploadTestResultAttachmentCalls[0].TestSubResultId); + Assert.AreEqual("stdout.log", client.UploadTestResultAttachmentCalls[0].Attachment.FileName); Assert.HasCount(1, client.UpdateTestResultsCalls); Assert.AreEqual(2, client.UpdateTestResultsCalls[0].Results.Single().SubResults![1].SequenceId); } [TestMethod] - public async Task FirstAttempt_AttachmentNameIncludesAttemptOne() + public async Task FirstAttempt_AttachmentTargetsFirstSubResultAndKeepsOriginalName() { using TestDirectory directory = CreateTestDirectory(); Mock environment = CreateEnvironmentMockWithSettableRunId(); @@ -2063,6 +2078,13 @@ public async Task FirstAttempt_AttachmentNameIncludesAttemptOne() out _, out _, environment); + AzureDevOpsTestCaseResult? publishedResult = null; + client.PublishTestResultsWithSubResultsAsyncFunc = (_, _, results, _) => + { + publishedResult = results.Single(); + IReadOnlyDictionary subResultIds = new Dictionary { [1] = 101 }; + return Task.FromResult?>([new AzureDevOpsPublishedTestResult(1, subResultIds)]); + }; TestNode node = CreateNode( "MyTest", new FailedTestNodeStateProperty(new InvalidOperationException("first")), @@ -2074,7 +2096,13 @@ public async Task FirstAttempt_AttachmentNameIncludesAttemptOne() await publisher.OnTestSessionFinishingAsync(new Microsoft.Testing.Platform.Services.TestSessionContext(CancellationToken.None)); Assert.HasCount(1, client.UploadTestResultAttachmentCalls); - Assert.AreEqual("stdout.attempt-1.log", client.UploadTestResultAttachmentCalls[0].Attachment.FileName); + Assert.IsNotNull(publishedResult); + Assert.AreEqual(AzureDevOpsLivePublishingConstants.RerunResultGroupType, publishedResult.ResultGroupType); + Assert.IsNotNull(publishedResult.SubResults); + Assert.ContainsSingle(publishedResult.SubResults); + Assert.AreEqual(1, publishedResult.SubResults[0].SequenceId); + Assert.AreEqual(101, client.UploadTestResultAttachmentCalls[0].TestSubResultId); + Assert.AreEqual("stdout.log", client.UploadTestResultAttachmentCalls[0].Attachment.FileName); } [TestMethod] @@ -2878,7 +2906,7 @@ public async Task AttachmentCancellationAfterCreate_StillRecordsTheWholeAccepted AzureDevOpsTestResultsPublisherOptions options = new(2, TimeSpan.FromMinutes(1), 40, TimeSpan.FromMilliseconds(250)); using AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options, out FakeAzureDevOpsTestResultsClient client, out _, out _, environment); client.PublishTestResultsAsyncFunc = (_, _, _, _) => Task.FromResult?>([101, 102]); - client.UploadTestResultAttachmentAsyncFunc = (_, _, _, _, _) => Task.FromException(new OperationCanceledException()); + client.UploadTestResultAttachmentAsyncFunc = (_, _, _, _, _, _) => Task.FromException(new OperationCanceledException()); await StartPublisherAsync(publisher); TestNode first = CreateNode("FirstTest", new FailedTestNodeStateProperty(new InvalidOperationException("first")), RetryTestStartTime); @@ -2921,7 +2949,7 @@ await PublishSingleResultAsync( created.AddRange(results); return Task.FromResult?>([202]); }; - client.UploadTestResultAttachmentAsyncFunc = (_, _, _, _, _) => Task.FromException(new OperationCanceledException()); + client.UploadTestResultAttachmentAsyncFunc = (_, _, _, _, _, _) => Task.FromException(new OperationCanceledException()); await StartPublisherAsync(publisher); TestNode newTest = CreateNode("NewTest", new FailedTestNodeStateProperty(new InvalidOperationException("new")), RetryTestStartTime); @@ -3157,23 +3185,24 @@ await publisher.ConsumeAsync( // bytes actually sent: the verb, the results URI, and the camelCase resultGroupType the service // expects (it rejects the PascalCase spelling used by the client SDK's enum). [TestMethod] - public async Task AzureDevOpsTestResultsClient_UpdateTestResults_PatchesTheResultsUriWithARerunPayload() + public async Task AzureDevOpsTestResultsClient_UpdateTestResults_PatchesRerunAndMapsReorderedSubResults() { FakeTask task = new(); FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; string? capturedBody = null; HttpMethod? capturedMethod = null; Uri? capturedUri = null; + using HttpResponseMessage response = new(HttpStatusCode.OK) + { + Content = new StringContent("{\"count\":1,\"value\":[{\"id\":777,\"subResults\":[{\"id\":1002,\"sequenceId\":2},{\"id\":1001,\"sequenceId\":1}]}]}"), + }; QueueHttpMessageHandler handler = new( async (request, cancellationToken) => { capturedMethod = request.Method; capturedUri = request.RequestUri; capturedBody = await ReadRequestBodyAsync(request, cancellationToken); - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{\"count\":1,\"value\":[]}"), - }; + return response; }); using HttpClient httpClient = new(handler) { @@ -3193,7 +3222,8 @@ public async Task AzureDevOpsTestResultsClient_UpdateTestResults_PatchesTheResul ], }; - await client.UpdateTestResultsAsync(configuration, runId: 42, [parent], CancellationToken.None); + IReadOnlyList? publishedResults = + await client.UpdateTestResultsWithSubResultsAsync(configuration, runId: 42, [parent], CancellationToken.None); Assert.AreEqual("PATCH", capturedMethod!.Method); Assert.AreEqual("https://dev.azure.com/org/project/_apis/test/runs/42/results?api-version=7.1", capturedUri!.ToString()); @@ -3213,6 +3243,308 @@ public async Task AzureDevOpsTestResultsClient_UpdateTestResults_PatchesTheResul Assert.AreEqual(AzureDevOpsLivePublishingConstants.FailedTestOutcome, subResults[0].GetProperty("outcome").GetString()); Assert.AreEqual("boom", subResults[0].GetProperty("errorMessage").GetString()); Assert.AreEqual(2, subResults[1].GetProperty("sequenceId").GetInt32()); + Assert.IsNotNull(publishedResults); + Assert.IsTrue(publishedResults[0].TryGetSubResultId(sequenceId: 1, out int firstSubResultId)); + Assert.IsTrue(publishedResults[0].TryGetSubResultId(sequenceId: 2, out int secondSubResultId)); + Assert.AreEqual(1001, firstSubResultId); + Assert.AreEqual(1002, secondSubResultId); + } + + [TestMethod] + public async Task AzureDevOpsTestResultsClient_UploadTestResultAttachment_TargetsTheSubResult() + { + FakeTask task = new(); + FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + Uri? capturedUri = null; + using HttpResponseMessage response = new(HttpStatusCode.OK) + { + Content = new StringContent("{}"), + }; + QueueHttpMessageHandler handler = new( + (request, _) => + { + capturedUri = request.RequestUri; + return Task.FromResult(response); + }); + using HttpClient httpClient = new(handler) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + AzureDevOpsTestResultsClient client = new(httpClient, task, clock); + AzureDevOpsPublishConfiguration configuration = new("https://dev.azure.com/org/", "project", "token", 123, "run", "tests.dll", "results"); + var attachment = AzureDevOpsTestResultAttachment.FromString("output", "stdout.log", AzureDevOpsAttachmentTypes.ConsoleLog); + + await client.UploadTestResultAttachmentAsync(configuration, runId: 42, testCaseResultId: 777, testSubResultId: 2, attachment, CancellationToken.None); + + Assert.AreEqual( + "https://dev.azure.com/org/project/_apis/test/runs/42/results/777/attachments?testSubResultId=2&api-version=7.1", + capturedUri!.ToString()); + } + + [TestMethod] + public async Task AzureDevOpsTestResultsClient_PublishTestResults_MissingSubResultsPreservesParentId() + { + FakeTask task = new(); + FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + using HttpResponseMessage response = new(HttpStatusCode.OK) + { + Content = new StringContent("{\"count\":1,\"value\":[{\"id\":777,\"automatedTestName\":\"MyTest\"}]}"), + }; + QueueHttpMessageHandler handler = new( + (_, _) => Task.FromResult(response)); + using HttpClient httpClient = new(handler) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + AzureDevOpsTestResultsClient client = new(httpClient, task, clock); + AzureDevOpsPublishConfiguration configuration = new("https://dev.azure.com/org/", "project", "token", 123, "run", "tests.dll", "results"); + AzureDevOpsTestCaseResult result = new("MyTest", "tests", "MyTest", AzureDevOpsLivePublishingConstants.FailedTestOutcome, 5, "boom", null, null, null) + { + ResultGroupType = AzureDevOpsLivePublishingConstants.RerunResultGroupType, + SubResults = + [ + new AzureDevOpsTestSubResult(1, "Attempt# 0 - MyTest", AzureDevOpsLivePublishingConstants.FailedTestOutcome, 5, "boom", null, null, null), + ], + }; + + IReadOnlyList? publishedResults = + await client.PublishTestResultsWithSubResultsAsync(configuration, runId: 42, [result], CancellationToken.None); + + Assert.IsNotNull(publishedResults); + Assert.AreEqual(777, publishedResults[0].Id); + Assert.IsEmpty(publishedResults[0].SubResultIdsBySequenceId); + Assert.IsFalse(publishedResults[0].TryGetSubResultId(sequenceId: 1, out _)); + } + + [TestMethod] + public async Task AzureDevOpsTestResultsClient_UpdateTestResults_ResponseReadFailureDoesNotReplayAcceptedPatch() + { + FakeTask task = new(); + FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + using HttpResponseMessage response = new(HttpStatusCode.OK) + { + Content = new ThrowingHttpContent(new IOException("response stream failed")), + }; + QueueHttpMessageHandler handler = new( + (_, _) => Task.FromResult(response)); + using HttpClient httpClient = new(handler) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + AzureDevOpsTestResultsClient client = new(httpClient, task, clock); + AzureDevOpsPublishConfiguration configuration = new("https://dev.azure.com/org/", "project", "token", 123, "run", "tests.dll", "results"); + AzureDevOpsTestCaseResult result = new("MyTest", "tests", "MyTest", AzureDevOpsLivePublishingConstants.PassedTestOutcome, 5, null, null, null, null) + { + Id = 777, + }; + + IReadOnlyList? publishedResults = + await client.UpdateTestResultsWithSubResultsAsync(configuration, runId: 42, [result], CancellationToken.None); + + Assert.IsNull(publishedResults); + } + + [TestMethod] + public async Task AzureDevOpsTestResultsClient_UpdateTestResults_ResponseBodyReadHonorsCancellation() + { + FakeTask task = new(); + FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + TaskCompletionSource responseBodyReadStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + using HttpResponseMessage response = new(HttpStatusCode.OK) + { + Content = new BlockingHttpContent(responseBodyReadStarted), + }; + QueueHttpMessageHandler handler = new( + (_, _) => Task.FromResult(response)); + using HttpClient httpClient = new(handler) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + AzureDevOpsTestResultsClient client = new(httpClient, task, clock); + AzureDevOpsPublishConfiguration configuration = new("https://dev.azure.com/org/", "project", "token", 123, "run", "tests.dll", "results"); + AzureDevOpsTestCaseResult result = new("MyTest", "tests", "MyTest", AzureDevOpsLivePublishingConstants.PassedTestOutcome, 5, null, null, null, null) + { + Id = 777, + }; + using CancellationTokenSource cancellationTokenSource = new(); + Task?> updateTask = + client.UpdateTestResultsWithSubResultsAsync(configuration, runId: 42, [result], cancellationTokenSource.Token); + + await responseBodyReadStarted.Task; +#if NET + await cancellationTokenSource.CancelAsync(); +#else +#pragma warning disable VSTHRD103 // CancelAsync is only available on .NET 8+; this project also targets .NET Framework. + cancellationTokenSource.Cancel(); +#pragma warning restore VSTHRD103 +#endif + + await Assert.ThrowsAsync( + () => updateTask); + } + + [TestMethod] + public async Task AzureDevOpsTestResultsClient_PublishTestResults_InvalidCharsetReturnsNullWithoutReplay() + { + FakeTask task = new(); + FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + using StringContent responseContent = new("{\"count\":1,\"value\":[{\"id\":777,\"automatedTestName\":\"MyTest\"}]}"); + responseContent.Headers.ContentType!.CharSet = "unsupported-charset"; + using HttpResponseMessage response = new(HttpStatusCode.OK) + { + Content = responseContent, + }; + int sendCount = 0; + QueueHttpMessageHandler handler = new( + (_, _) => + { + sendCount++; + return Task.FromResult(response); + }); + using HttpClient httpClient = new(handler) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + AzureDevOpsTestResultsClient client = new(httpClient, task, clock); + AzureDevOpsPublishConfiguration configuration = new("https://dev.azure.com/org/", "project", "token", 123, "run", "tests.dll", "results"); + AzureDevOpsTestCaseResult result = new("MyTest", "tests", "MyTest", AzureDevOpsLivePublishingConstants.PassedTestOutcome, 5, null, null, null, null); + + IReadOnlyList? publishedResults = + await client.PublishTestResultsWithSubResultsAsync(configuration, runId: 42, [result], CancellationToken.None); + + Assert.IsNull(publishedResults); + Assert.AreEqual(1, sendCount); + } + + [TestMethod] + public async Task AzureDevOpsTestResultsClient_UpdateTestResults_InvalidCharsetReturnsNullWithoutReplay() + { + FakeTask task = new(); + FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + using StringContent responseContent = new("{\"count\":1,\"value\":[{\"id\":777}]}"); + responseContent.Headers.ContentType!.CharSet = "unsupported-charset"; + using HttpResponseMessage response = new(HttpStatusCode.OK) + { + Content = responseContent, + }; + int sendCount = 0; + QueueHttpMessageHandler handler = new( + (_, _) => + { + sendCount++; + return Task.FromResult(response); + }); + using HttpClient httpClient = new(handler) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + AzureDevOpsTestResultsClient client = new(httpClient, task, clock); + AzureDevOpsPublishConfiguration configuration = new("https://dev.azure.com/org/", "project", "token", 123, "run", "tests.dll", "results"); + AzureDevOpsTestCaseResult result = new("MyTest", "tests", "MyTest", AzureDevOpsLivePublishingConstants.PassedTestOutcome, 5, null, null, null, null) + { + Id = 777, + }; + + IReadOnlyList? publishedResults = + await client.UpdateTestResultsWithSubResultsAsync(configuration, runId: 42, [result], CancellationToken.None); + + Assert.IsNull(publishedResults); + Assert.AreEqual(1, sendCount); + } + + [TestMethod] + public async Task AzureDevOpsTestResultsClient_NonSuccessResponse_BodyReadThrows_DisposesResponse() + { + FakeTask task = new(); + FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + using ThrowingHttpContent content1 = new(new IOException("response stream failed 1")); + using HttpResponseMessage response1 = new(HttpStatusCode.BadRequest) + { + Content = content1, + }; + using ThrowingHttpContent content2 = new(new IOException("response stream failed 2")); + using HttpResponseMessage response2 = new(HttpStatusCode.BadRequest) + { + Content = content2, + }; + using ThrowingHttpContent content3 = new(new IOException("response stream failed 3")); + using HttpResponseMessage response3 = new(HttpStatusCode.BadRequest) + { + Content = content3, + }; + QueueHttpMessageHandler handler = new( + (_, _) => Task.FromResult(response1), + (_, _) => Task.FromResult(response2), + (_, _) => Task.FromResult(response3)); + using HttpClient httpClient = new(handler) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + AzureDevOpsTestResultsClient client = new(httpClient, task, clock); + AzureDevOpsPublishConfiguration configuration = new("https://dev.azure.com/org/", "project", "token", 123, "run", "tests.dll", "results"); + + HttpRequestException exception = await Assert.ThrowsExactlyAsync( + () => client.CreateTestRunAsync(configuration, CancellationToken.None)); + Assert.IsInstanceOfType(exception.InnerException); + Assert.IsTrue(content1.IsDisposed); + Assert.IsTrue(content2.IsDisposed); + Assert.IsTrue(content3.IsDisposed); + } + + [TestMethod] + public async Task AzureDevOpsTestResultsClient_RetryableResponse_DelayThrows_DisposesResponse() + { + FakeTask task = new(delayCallback: _ => throw new IOException("delay failed")); + FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + using ThrowingHttpContent content = new(new InvalidOperationException("content should not be read")); + using HttpResponseMessage response = new(HttpStatusCode.ServiceUnavailable) + { + Content = content, + }; + QueueHttpMessageHandler handler = new( + (_, _) => Task.FromResult(response)); + using HttpClient httpClient = new(handler) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + AzureDevOpsTestResultsClient client = new(httpClient, task, clock); + AzureDevOpsPublishConfiguration configuration = new("https://dev.azure.com/org/", "project", "token", 123, "run", "tests.dll", "results"); + + await Assert.ThrowsExactlyAsync( + () => client.CreateTestRunAsync(configuration, CancellationToken.None)); + + Assert.IsTrue(content.IsDisposed); + } + + [TestMethod] + public async Task AzureDevOpsTestResultsClient_UploadTestResultAttachment_TargetsTheParentWhenSubResultIsNotSpecified() + { + FakeTask task = new(); + FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + Uri? capturedUri = null; + using HttpResponseMessage response = new(HttpStatusCode.OK) + { + Content = new StringContent("{}"), + }; + QueueHttpMessageHandler handler = new( + (request, _) => + { + capturedUri = request.RequestUri; + return Task.FromResult(response); + }); + using HttpClient httpClient = new(handler) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + AzureDevOpsTestResultsClient client = new(httpClient, task, clock); + AzureDevOpsPublishConfiguration configuration = new("https://dev.azure.com/org/", "project", "token", 123, "run", "tests.dll", "results"); + var attachment = AzureDevOpsTestResultAttachment.FromString("output", "stdout.log", AzureDevOpsAttachmentTypes.ConsoleLog); + + await client.UploadTestResultAttachmentAsync(configuration, runId: 42, testCaseResultId: 777, testSubResultId: null, attachment, CancellationToken.None); + + Assert.AreEqual( + "https://dev.azure.com/org/project/_apis/test/runs/42/results/777/attachments?api-version=7.1", + capturedUri!.ToString()); } // A result being created must not carry any of the rerun fields: sending an explicit null id would @@ -3620,11 +3952,15 @@ private sealed class FakeAzureDevOpsTestResultsClient : IAzureDevOpsTestResultsC return Task.FromResult?>(ids); }; + public Func, CancellationToken, Task?>>? PublishTestResultsWithSubResultsAsyncFunc { get; set; } + public Func, CancellationToken, Task> UpdateTestResultsAsyncFunc { get; set; } = (_, _, _, _) => Task.CompletedTask; + public Func, CancellationToken, Task?>>? UpdateTestResultsWithSubResultsAsyncFunc { get; set; } + public List<(int RunId, IReadOnlyList Results)> UpdateTestResultsCalls { get; } = []; - public Func UploadTestResultAttachmentAsyncFunc { get; set; } = (_, _, _, _, _) => Task.CompletedTask; + public Func UploadTestResultAttachmentAsyncFunc { get; set; } = (_, _, _, _, _, _) => Task.CompletedTask; public Func UploadTestRunAttachmentAsyncFunc { get; set; } = (_, _, _, _) => Task.CompletedTask; @@ -3632,7 +3968,7 @@ private sealed class FakeAzureDevOpsTestResultsClient : IAzureDevOpsTestResultsC public List<(AzureDevOpsPublishConfiguration Configuration, int RunId, string State)> UpdateTestRunStateCalls { get; } = []; - public List<(int RunId, int TestCaseResultId, AzureDevOpsTestResultAttachment Attachment)> UploadTestResultAttachmentCalls { get; } = []; + public List<(int RunId, int TestCaseResultId, int? TestSubResultId, AzureDevOpsTestResultAttachment Attachment)> UploadTestResultAttachmentCalls { get; } = []; public List<(int RunId, AzureDevOpsTestResultAttachment Attachment)> UploadTestRunAttachmentCalls { get; } = []; @@ -3647,10 +3983,27 @@ public Task CreateTestRunAsync(AzureDevOpsPublishConfiguration configuratio public Task?> PublishTestResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken) => PublishTestResultsAsyncFunc(configuration, runId, results, cancellationToken); - public Task UpdateTestResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken) + public async Task?> PublishTestResultsWithSubResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken) + { + if (PublishTestResultsWithSubResultsAsyncFunc is not null) + { + return await PublishTestResultsWithSubResultsAsyncFunc(configuration, runId, results, cancellationToken); + } + + IReadOnlyList? ids = await PublishTestResultsAsyncFunc(configuration, runId, results, cancellationToken); + return ids is null ? null : CreatePublishedResults(ids, results); + } + + public async Task UpdateTestResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken) + => _ = await UpdateTestResultsWithSubResultsAsync(configuration, runId, results, cancellationToken); + + public async Task?> UpdateTestResultsWithSubResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken) { UpdateTestResultsCalls.Add((runId, results)); - return UpdateTestResultsAsyncFunc(configuration, runId, results, cancellationToken); + await UpdateTestResultsAsyncFunc(configuration, runId, results, cancellationToken); + return UpdateTestResultsWithSubResultsAsyncFunc is null + ? CreatePublishedResults(results.Select(static result => result.Id!.Value).ToArray(), results) + : await UpdateTestResultsWithSubResultsAsyncFunc(configuration, runId, results, cancellationToken); } public Task UpdateTestRunStateAsync(AzureDevOpsPublishConfiguration configuration, int runId, string state, CancellationToken cancellationToken) @@ -3659,10 +4012,10 @@ public Task UpdateTestRunStateAsync(AzureDevOpsPublishConfiguration configuratio return UpdateTestRunStateAsyncFunc(configuration, runId, state, cancellationToken); } - public Task UploadTestResultAttachmentAsync(AzureDevOpsPublishConfiguration configuration, int runId, int testCaseResultId, AzureDevOpsTestResultAttachment attachment, CancellationToken cancellationToken) + public Task UploadTestResultAttachmentAsync(AzureDevOpsPublishConfiguration configuration, int runId, int testCaseResultId, int? testSubResultId, AzureDevOpsTestResultAttachment attachment, CancellationToken cancellationToken) { - UploadTestResultAttachmentCalls.Add((runId, testCaseResultId, attachment)); - return UploadTestResultAttachmentAsyncFunc(configuration, runId, testCaseResultId, attachment, cancellationToken); + UploadTestResultAttachmentCalls.Add((runId, testCaseResultId, testSubResultId, attachment)); + return UploadTestResultAttachmentAsyncFunc(configuration, runId, testCaseResultId, testSubResultId, attachment, cancellationToken); } public Task UploadTestRunAttachmentAsync(AzureDevOpsPublishConfiguration configuration, int runId, AzureDevOpsTestResultAttachment attachment, CancellationToken cancellationToken) @@ -3670,6 +4023,28 @@ public Task UploadTestRunAttachmentAsync(AzureDevOpsPublishConfiguration configu UploadTestRunAttachmentCalls.Add((runId, attachment)); return UploadTestRunAttachmentAsyncFunc(configuration, runId, attachment, cancellationToken); } + + private static IReadOnlyList CreatePublishedResults( + IReadOnlyList ids, + IReadOnlyList results) + { + var publishedResults = new AzureDevOpsPublishedTestResult[results.Count]; + for (int i = 0; i < results.Count; i++) + { + Dictionary subResultIds = []; + if (results[i].SubResults is { } subResults) + { + foreach (AzureDevOpsTestSubResult subResult in subResults) + { + subResultIds[subResult.SequenceId] = subResult.SequenceId; + } + } + + publishedResults[i] = new AzureDevOpsPublishedTestResult(ids[i], subResultIds); + } + + return publishedResults; + } } private sealed class FakeTask(Action? delayCallback = null) : ITask @@ -3747,6 +4122,103 @@ protected override Task SendAsync(HttpRequestMessage reques => _responses.Dequeue().Invoke(request, cancellationToken); } + private sealed class ThrowingHttpContent(Exception exception) : HttpContent + { + public bool IsDisposed { get; private set; } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + => Task.FromException(exception); + + protected override void Dispose(bool disposing) + { + IsDisposed = true; + base.Dispose(disposing); + } + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + } + + private sealed class BlockingHttpContent : HttpContent + { + private readonly TaskCompletionSource _responseBodyReadStarted; + + public BlockingHttpContent(TaskCompletionSource responseBodyReadStarted) + => _responseBodyReadStarted = responseBodyReadStarted; + +#if NET + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) + { + _responseBodyReadStarted.TrySetResult(true); + return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } +#endif + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + => Task.CompletedTask; + + protected override Task CreateContentReadStreamAsync() + => Task.FromResult(new BlockingReadStream(_responseBodyReadStarted)); + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + } + + private sealed class BlockingReadStream : Stream + { + private readonly TaskCompletionSource _responseBodyReadStarted; + + public BlockingReadStream(TaskCompletionSource responseBodyReadStarted) + => _responseBodyReadStarted = responseBodyReadStarted; + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set + { + _ = value; + throw new NotSupportedException(); + } + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + _responseBodyReadStarted.TrySetResult(true); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + } + private sealed class TestDirectory : IDisposable { public TestDirectory(ICollection trackedDirectories)