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)