Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1d8948e
Add live Azure DevOps test result publishing
Copilot May 16, 2026
70d72f2
Address PR review comments: coordinator cleanup, timeout wrapping, ru…
Copilot May 17, 2026
14cdb92
Merge branch 'main' into dev/amauryleve/azdo-live-publishing
Evangelink May 17, 2026
a850bfa
Merge branch 'main' into dev/amauryleve/azdo-live-publishing
Evangelink May 17, 2026
95e4e06
Address 3rd-round review comments: fix acceptance test strings, catch…
Copilot May 17, 2026
f71e657
Polish: document Dispose lifecycle, log background task failure, rest…
Copilot May 17, 2026
e9472c8
Merge branch 'main' into dev/amauryleve/azdo-live-publishing
Evangelink May 18, 2026
363fc60
Fix AreAllDistinct comparer rendering to match test expectations
Copilot May 18, 2026
29bc24d
Merge branch 'main' into dev/amauryleve/azdo-live-publishing
Evangelink May 18, 2026
e7f511a
Address PR review comments: run-state semantics, lease race, joiner wait
Copilot May 18, 2026
fab4046
Merge branch 'main' into dev/amauryleve/azdo-live-publishing
Evangelink May 18, 2026
dc4cbe2
Merge remote-tracking branch 'origin/main' into dev/amauryleve/azdo-l…
Copilot May 18, 2026
857da70
Fix AzDO help acceptance coverage
Copilot May 19, 2026
214aafc
Merge remote-tracking branch 'origin/main' into dev/amauryleve/azdo-l…
Copilot May 20, 2026
c138475
Fix merge: dedupe System.Text.Json reference and regenerate AzDO xlf
Copilot May 20, 2026
c7e7221
Fix NRE in SystemFileSystem.ReadAllTextAsync on .NET Framework
Evangelink May 20, 2026
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 @@ -18,4 +18,6 @@ internal static class AzureDevOpsCommandLineOptions
public const string AzureDevOpsUploadArtifactsModeFiles = "files";
public const string AzureDevOpsUploadArtifactsModeOff = "off";
public const string AzureDevOpsUploadArtifactsModeTagsOnly = "tags-only";
public const string PublishAzureDevOpsRunNameOptionName = "publish-azdo-run-name";
public const string PublishAzureDevOpsTestResultsOptionName = "publish-azdo-test-results";
}
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 Microsoft.Extensions.FileSystemGlobbing;
Expand Down Expand Up @@ -50,6 +50,8 @@ public IReadOnlyCollection<CommandLineOption> GetCommandLineOptions()
new CommandLineOption(AzureDevOpsCommandLineOptions.AzureDevOpsUploadArtifactInclude, AzureDevOpsResources.UploadArtifactIncludeOptionDescription, ArgumentArity.ZeroOrMore, false),
new CommandLineOption(AzureDevOpsCommandLineOptions.AzureDevOpsUploadArtifactName, AzureDevOpsResources.UploadArtifactNameOptionDescription, ArgumentArity.ExactlyOne, false),
new CommandLineOption(AzureDevOpsCommandLineOptions.AzureDevOpsUploadArtifacts, AzureDevOpsResources.UploadArtifactsOptionDescription, ArgumentArity.ExactlyOne, false),
new CommandLineOption(AzureDevOpsCommandLineOptions.PublishAzureDevOpsRunNameOptionName, AzureDevOpsResources.PublishAzdoRunNameOptionDescription, ArgumentArity.ExactlyOne, false),
new CommandLineOption(AzureDevOpsCommandLineOptions.PublishAzureDevOpsTestResultsOptionName, AzureDevOpsResources.PublishAzdoTestResultsOptionDescription, ArgumentArity.Zero, false),
];

public Task<ValidationResult> ValidateOptionArgumentsAsync(CommandLineOption commandOption, string[] arguments)
Expand Down Expand Up @@ -98,6 +100,13 @@ public Task<ValidationResult> ValidateCommandLineOptionsAsync(ICommandLineOption
errorMessage = AzureDevOpsResources.ArtifactUploadOptionsRequireUploadArtifacts;
}

if (errorMessage is null
&& commandLineOptions.IsOptionSet(AzureDevOpsCommandLineOptions.PublishAzureDevOpsRunNameOptionName)
&& !commandLineOptions.IsOptionSet(AzureDevOpsCommandLineOptions.PublishAzureDevOpsTestResultsOptionName))
{
errorMessage = AzureDevOpsResources.PublishAzdoRunNameRequiresPublishAzdoTestResults;
}

return errorMessage is null
? ValidationResult.ValidTask
: ValidationResult.InvalidTask(errorMessage);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace Microsoft.Testing.Extensions;
public static class AzureDevOpsExtensions
{
/// <summary>
/// Adds support to the test application builder.
/// Adds support to the test application builder.
/// </summary>
/// <param name="builder">The test application builder.</param>
public static void AddAzureDevOpsProvider(this ITestApplicationBuilder builder)
Expand All @@ -33,6 +33,20 @@ public static void AddAzureDevOpsProvider(this ITestApplicationBuilder builder)
serviceProvider.GetTestApplicationModuleInfo(),
serviceProvider.GetLoggerFactory()));

var compositeTestResultsPublisher =
new CompositeExtensionFactory<AzureDevOpsTestResultsPublisher>(serviceProvider =>
new AzureDevOpsTestResultsPublisher(
serviceProvider.GetCommandLineOptions(),
serviceProvider.GetConfiguration(),
serviceProvider.GetEnvironment(),
serviceProvider.GetFileSystem(),
serviceProvider.GetTestApplicationModuleInfo(),
serviceProvider.GetTestApplicationProcessExitCode(),
new AzureDevOpsTestResultsClient(serviceProvider.GetTask(), serviceProvider.GetClock()),
serviceProvider.GetTask(),
serviceProvider.GetClock(),
serviceProvider.GetLoggerFactory()));

builder.TestHost.AddDataConsumer(serviceProvider =>
{
historyService ??= CreateHistoryService(serviceProvider);
Expand All @@ -46,9 +60,11 @@ public static void AddAzureDevOpsProvider(this ITestApplicationBuilder builder)
historyService);
});
builder.TestHost.AddDataConsumer(compositeArtifactUploader);
builder.TestHost.AddDataConsumer(compositeTestResultsPublisher);
builder.TestHost.AddTestSessionLifetimeHandler(serviceProvider =>
historyService ??= CreateHistoryService(serviceProvider));
builder.TestHost.AddTestSessionLifetimeHandler(compositeArtifactUploader);
builder.TestHost.AddTestSessionLifetimeHandler(compositeTestResultsPublisher);
builder.CommandLine.AddProvider(() => new AzureDevOpsCommandLineProvider());
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// 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.Text.Json.Serialization;

namespace Microsoft.Testing.Extensions.AzureDevOpsReport;

internal static class AzureDevOpsLivePublishingConstants
{
public const string AbortedTestOutcome = "Aborted";
public const string AbortedTestRunState = "Aborted";
public const string CompletedTestRunState = "Completed";
public const string FailedTestOutcome = "Failed";
public const string InProgressTestRunState = "InProgress";
public const int MaxRunNameLength = 256;
public const string NotExecutedTestOutcome = "NotExecuted";
public const string PassedTestOutcome = "Passed";
}

internal sealed record AzureDevOpsPublishConfiguration(
string CollectionUri,
string Project,
string AccessToken,
int BuildId,
string RunName,
string AutomatedTestStorage,
string ResultsDirectory);

internal sealed record AzureDevOpsTestCaseResult(
[property: JsonPropertyName("automatedTestName")] string AutomatedTestName,
[property: JsonPropertyName("automatedTestStorage")] string AutomatedTestStorage,
[property: JsonPropertyName("testCaseTitle")] string TestCaseTitle,
[property: JsonPropertyName("outcome")] string Outcome,
[property: JsonPropertyName("durationInMs")] long? DurationInMs,
[property: JsonPropertyName("errorMessage")] string? ErrorMessage,
[property: JsonPropertyName("stackTrace")] string? StackTrace,
[property: JsonPropertyName("startedDate")] DateTimeOffset? StartedDate,
[property: JsonPropertyName("completedDate")] DateTimeOffset? CompletedDate);

internal sealed record AzureDevOpsTestResultsPublisherOptions(
int BatchSize,
TimeSpan FlushInterval,
int CoordinationReadRetryCount,
TimeSpan CoordinationReadRetryDelay,
TimeSpan CoordinationFinalizeTimeout,
TimeSpan CoordinationFileExpiration,
TimeSpan CoordinationJoinerMaxWaitTime)
{
public AzureDevOpsTestResultsPublisherOptions(int batchSize, TimeSpan flushInterval, int coordinationReadRetryCount, TimeSpan coordinationReadRetryDelay)
: this(batchSize, flushInterval, coordinationReadRetryCount, coordinationReadRetryDelay, TimeSpan.FromSeconds(30), TimeSpan.FromHours(4), TimeSpan.FromMinutes(2))
{
}

public AzureDevOpsTestResultsPublisherOptions(int batchSize, TimeSpan flushInterval, int coordinationReadRetryCount, TimeSpan coordinationReadRetryDelay, TimeSpan coordinationFinalizeTimeout, TimeSpan coordinationFileExpiration)
: this(batchSize, flushInterval, coordinationReadRetryCount, coordinationReadRetryDelay, coordinationFinalizeTimeout, coordinationFileExpiration, TimeSpan.FromMinutes(2))
{
}

public static AzureDevOpsTestResultsPublisherOptions Default { get; } = new(100, TimeSpan.FromSeconds(5), 40, TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(30), TimeSpan.FromHours(4), TimeSpan.FromMinutes(2));
}

internal enum LeaseFileStatus
{
/// <summary>The lease file is not present on disk.</summary>
NotFound,

/// <summary>The lease file was parsed and the lease is still valid.</summary>
Active,

/// <summary>The lease file was parsed and the lease has expired.</summary>
Expired,

/// <summary>The lease file is present but could not be read or parsed; it may be mid-write by another process.</summary>
TransientReadError,
}

internal readonly record struct LeaseReadResult(LeaseFileStatus Status, AzureDevOpsLeaseFile? Lease);
Loading
Loading