Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,10 @@ internal sealed record AzureDevOpsTestCaseResult(
/// </summary>
/// <remarks>
/// 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. <see cref="Id"/> is populated only when updating
/// an existing result. <see cref="ResultGroupType"/> and <see cref="SubResults"/> are also populated on
/// a freshly created, attachment-bearing result when retry tracking is enabled, so its attachments can
/// target the first attempt.
/// </remarks>
[JsonPropertyName("id")]
public int? Id { get; init; }
Expand Down Expand Up @@ -141,6 +143,22 @@ internal sealed record AzureDevOpsTestCaseResultWithAttachments(
AzureDevOpsTestCaseResult Result,
IReadOnlyList<AzureDevOpsTestResultAttachment> Attachments);

internal sealed class AzureDevOpsPublishedTestResult
{
public AzureDevOpsPublishedTestResult(int id, IReadOnlyDictionary<int, int> subResultIdsBySequenceId)
{
Id = id;
SubResultIdsBySequenceId = subResultIdsBySequenceId;
}

public int Id { get; }

public IReadOnlyDictionary<int, int> SubResultIdsBySequenceId { get; }

public bool TryGetSubResultId(int sequenceId, out int subResultId)
=> SubResultIdsBySequenceId.TryGetValue(sequenceId, out subResultId);
}

/// <summary>
/// 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 (<see cref="FilePath"/>) or from inline string content (<see cref="InlineContent"/>).
Expand Down Expand Up @@ -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);

/// <summary>
/// Returns a copy of this attachment published under a different file name.
/// </summary>
/// <remarks>
/// 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
/// <c>stdout.log</c>, 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.
/// </remarks>
public AzureDevOpsTestResultAttachment WithFileName(string fileName)
=> new(fileName, AttachmentType, Comment, FilePath, InlineContent);
}

internal sealed record AzureDevOpsTestResultsPublisherOptions(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -125,6 +125,9 @@ public void RecordCreated(AzureDevOpsTestCaseResult result, int resultId)
_hasUnsavedChanges = true;
}

public static AzureDevOpsTestSubResult CreateFirstAttempt(AzureDevOpsTestCaseResult result)
=> ToSubResult(result, sequenceId: 1);

/// <summary>
/// Builds what the attempt history would become if <paramref name="result"/> were published, without
/// recording it.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -94,6 +94,15 @@ public async Task<int> 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<IReadOnlyList<int>?> PublishTestResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList<AzureDevOpsTestCaseResult> results, CancellationToken cancellationToken)
{
IReadOnlyList<AzureDevOpsPublishedTestResult>? 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<IReadOnlyList<AzureDevOpsPublishedTestResult>?> PublishTestResultsWithSubResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList<AzureDevOpsTestCaseResult> results, CancellationToken cancellationToken)
{
using HttpRequestMessage request = CreateRequest(
HttpMethod.Post,
Expand All @@ -105,33 +114,19 @@ public async Task<int> 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<PublishTestResultsResponse>(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)
Comment thread
Evangelink marked this conversation as resolved.
Outdated
{
return null;
}
Expand All @@ -141,12 +136,12 @@ public async Task<int> 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public async Task UpdateTestResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList<AzureDevOpsTestCaseResult> 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<IReadOnlyList<AzureDevOpsPublishedTestResult>?> UpdateTestResultsWithSubResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList<AzureDevOpsTestCaseResult> results, CancellationToken cancellationToken)
{
using HttpRequestMessage request = CreateRequest(
PatchMethod,
Expand All @@ -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)
{
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)
Expand All @@ -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);

Expand Down Expand Up @@ -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<AzureDevOpsPublishedTestResult>? ParsePublishedResults(
string payload,
IReadOnlyList<AzureDevOpsTestCaseResult> submittedResults,
bool validateAutomatedTestName)
{
PublishTestResultsResponse? parsed = JsonSerializer.Deserialize<PublishTestResultsResponse>(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<AzureDevOpsTestSubResult>? submittedSubResults = submitted.SubResults;
Dictionary<int, int> subResultIdsBySequenceId = [];
if (submittedSubResults is { Count: > 0 }
&& published.SubResults is { } publishedSubResults
&& publishedSubResults.Length == submittedSubResults.Count)
{
var submittedSequenceIds = new HashSet<int>();
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;
Expand Down Expand Up @@ -376,7 +455,7 @@ private async Task<HttpResponseMessage> 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);
Comment thread
Evangelink marked this conversation as resolved.
Comment thread
Evangelink marked this conversation as resolved.
if (response.IsSuccessStatusCode)
{
return response;
Expand Down Expand Up @@ -488,12 +567,29 @@ private static Task<byte[]> ReadAsByteArrayAsync(HttpContent content, Cancellati
=> content.ReadAsByteArrayAsync();
#endif

private static Task<string> ReadAsStringAsync(HttpContent content, CancellationToken cancellationToken)
private static async Task<string> 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,
Expand Down Expand Up @@ -533,7 +629,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,
Expand Down
Loading
Loading